job-scraper/dashboard.py

386 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Generate a simple text-based HTML dashboard of all tracked jobs.
"""
from datetime import datetime
from pathlib import Path
from db import Database
def generate_dashboard(output_path: str = "data/dashboard.html"):
"""Generate a static HTML dashboard."""
db = Database()
jobs = db.get_all_active_jobs()
# Group by company
companies = {}
for company_name, job in jobs:
if company_name not in companies:
companies[company_name] = []
companies[company_name].append(job)
# Sort companies by name
sorted_companies = sorted(companies.items())
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Job Board</title>
<style>
:root {{
--bg: #1a1a1a;
--fg: #e0e0e0;
--accent: #4a9eff;
--muted: #888;
--border: #333;
--highlight: #2a2a2a;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
font-size: 14px;
line-height: 1.6;
background: var(--bg);
color: var(--fg);
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}}
header {{
border-bottom: 1px solid var(--border);
padding-bottom: 15px;
margin-bottom: 20px;
}}
h1 {{
font-size: 18px;
font-weight: normal;
color: var(--accent);
}}
.meta {{
color: var(--muted);
font-size: 12px;
margin-top: 5px;
}}
.filters {{
margin: 15px 0;
padding: 10px;
background: var(--highlight);
border-radius: 4px;
}}
.filters input {{
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
padding: 8px 12px;
width: 100%;
max-width: 400px;
font-family: inherit;
font-size: 14px;
border-radius: 4px;
}}
.filters input:focus {{
outline: none;
border-color: var(--accent);
}}
.stats {{
display: flex;
gap: 20px;
margin: 10px 0;
font-size: 12px;
color: var(--muted);
}}
.company {{
margin-bottom: 25px;
}}
.company-header {{
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}}
.company-header:hover {{
color: var(--accent);
}}
.company-name {{
font-weight: bold;
color: var(--accent);
}}
.company-count {{
color: var(--muted);
font-size: 12px;
}}
.jobs {{
margin-left: 20px;
}}
.job {{
padding: 6px 0;
border-bottom: 1px solid var(--border);
display: grid;
grid-template-columns: 1fr 180px;
gap: 10px;
align-items: baseline;
}}
.job:last-child {{
border-bottom: none;
}}
.job:hover {{
background: var(--highlight);
}}
.job-title {{
overflow: hidden;
text-overflow: ellipsis;
}}
.job-title a {{
color: var(--fg);
text-decoration: none;
}}
.job-title a:hover {{
color: var(--accent);
text-decoration: underline;
}}
.job-location {{
color: var(--muted);
font-size: 12px;
text-align: right;
}}
.tag {{
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
margin-left: 5px;
}}
.tag-remote {{
background: #1a4a1a;
color: #4ade80;
}}
.tag-canada {{
background: #4a1a1a;
color: #f87171;
}}
.tag-berlin {{
background: #4a4a1a;
color: #facc15;
}}
.hidden {{
display: none;
}}
.toc {{
margin: 20px 0;
padding: 15px;
background: var(--highlight);
border-radius: 4px;
}}
.toc-title {{
font-size: 12px;
color: var(--muted);
margin-bottom: 10px;
}}
.toc-links {{
display: flex;
flex-wrap: wrap;
gap: 10px;
}}
.toc-links a {{
color: var(--accent);
text-decoration: none;
font-size: 13px;
}}
.toc-links a:hover {{
text-decoration: underline;
}}
.filter-buttons {{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}}
.filter-btn {{
background: var(--bg);
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 12px;
font-family: inherit;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}}
.filter-btn:hover {{
border-color: var(--accent);
color: var(--fg);
}}
.filter-btn.active {{
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
}}
</style>
</head>
<body>
<header>
<h1>$ job-board</h1>
<div class="meta">
Last updated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} |
{len(jobs)} jobs across {len(companies)} companies
</div>
</header>
<div class="filters">
<input type="text" id="search" placeholder="Filter jobs... (e.g. 'senior engineer', 'remote', 'canada')" autofocus>
<div class="filter-buttons">
<button class="filter-btn" data-filter="">All</button>
<button class="filter-btn" data-filter="engineer">Engineering</button>
<button class="filter-btn" data-filter="senior engineer">Senior Eng</button>
<button class="filter-btn" data-filter="staff principal">Staff+</button>
<button class="filter-btn" data-filter="manager director">Management</button>
<button class="filter-btn" data-filter="product">Product</button>
<button class="filter-btn" data-filter="design">Design</button>
<button class="filter-btn" data-filter="security">Security</button>
<button class="filter-btn" data-filter="remote">Remote</button>
<button class="filter-btn" data-filter="canada toronto vancouver">Canada</button>
<button class="filter-btn" data-filter="berlin germany">Berlin</button>
</div>
<div class="stats">
<span id="visible-count">{len(jobs)} jobs shown</span>
</div>
</div>
<div class="toc">
<div class="toc-title">Jump to company:</div>
<div class="toc-links">
"""
# Table of contents
for company_name, company_jobs in sorted_companies:
anchor = company_name.lower().replace(" ", "-")
html += f' <a href="#{anchor}">{company_name} ({len(company_jobs)})</a>\n'
html += """ </div>
</div>
<main id="job-list">
"""
# Job listings
for company_name, company_jobs in sorted_companies:
anchor = company_name.lower().replace(" ", "-")
html += f"""
<div class="company" id="{anchor}">
<div class="company-header">
<span class="company-name">{company_name}</span>
<span class="company-count">{len(company_jobs)} positions</span>
</div>
<div class="jobs">
"""
for job in sorted(company_jobs, key=lambda j: j.title):
location = job.location or ""
location_lower = location.lower()
# Tags
tags = ""
if job.remote_type == "remote" or "remote" in location_lower:
tags += '<span class="tag tag-remote">remote</span>'
if "canada" in location_lower or "toronto" in location_lower or "vancouver" in location_lower:
tags += '<span class="tag tag-canada">canada</span>'
if "berlin" in location_lower or "germany" in location_lower:
tags += '<span class="tag tag-berlin">berlin</span>'
html += f""" <div class="job" data-search="{job.title.lower()} {location_lower} {(job.department or '').lower()}">
<span class="job-title"><a href="{job.url}" target="_blank">{job.title}</a>{tags}</span>
<span class="job-location">{location}</span>
</div>
"""
html += """ </div>
</div>
"""
html += """ </main>
<script>
const search = document.getElementById('search');
const jobs = document.querySelectorAll('.job');
const companies = document.querySelectorAll('.company');
const visibleCount = document.getElementById('visible-count');
const filterBtns = document.querySelectorAll('.filter-btn');
function filterJobs(query) {
let visible = 0;
const terms = query.toLowerCase().trim().split(/\\s+/).filter(t => t);
companies.forEach(company => {
const companyJobs = company.querySelectorAll('.job');
let companyVisible = 0;
companyJobs.forEach(job => {
const searchText = job.dataset.search;
// Match if ANY term matches (OR logic for filter buttons)
const matches = terms.length === 0 || terms.some(term => searchText.includes(term));
job.classList.toggle('hidden', !matches);
if (matches) {
companyVisible++;
visible++;
}
});
company.classList.toggle('hidden', companyVisible === 0);
});
visibleCount.textContent = `${visible} jobs shown`;
}
search.addEventListener('input', (e) => {
// Clear active button when typing
filterBtns.forEach(btn => btn.classList.remove('active'));
filterJobs(e.target.value);
});
// Filter buttons
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.dataset.filter;
search.value = filter;
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterJobs(filter);
});
});
// Keyboard shortcut: / to focus search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== search) {
e.preventDefault();
search.focus();
}
if (e.key === 'Escape') {
search.value = '';
filterBtns.forEach(b => b.classList.remove('active'));
filterJobs('');
search.blur();
}
});
// Set "All" as active by default
filterBtns[0].classList.add('active');
</script>
</body>
</html>
"""
# Write the file
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(html)
print(f"Dashboard generated: {output_path}")
return output_path
if __name__ == "__main__":
generate_dashboard()