Add job board scraper: modular scrapers, SQLite storage, web UI

Scrapes slo-tech.com (Ljubljana) every 24h, stores jobs in SQLite and
tracks which are new vs. seen. New sites can be added as modules in
src/scrapers/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:45:40 +02:00
co-authored by Claude Opus 5.5
parent 42f97ea073
commit 0324ff7f2a
12 changed files with 697 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
data/
+41
View File
@@ -0,0 +1,41 @@
# Job Board
Scrapes job sites once a day, stores results in SQLite (`data/jobs.sqlite`), and shows them in a web UI where you can mark jobs as seen.
No runtime dependencies. It uses Bun's built-in SQLite, HTTP server and HTMLRewriter.
## Run
```sh
bun install
bun start # http://localhost:3000
```
The server scrapes every 24h. On startup it waits out the rest of the 24h since the last scrape (or scrapes right away if none happened in the last 24h).
You can also click **Scrape now** in the UI, or run `bun run scrape` once (e.g. from cron).
Env vars: `PORT` (3000), `SCRAPE_INTERVAL_HOURS` (24), `DB_PATH` (`data/jobs.sqlite`).
## UI
- **New / Seen / All** tabs. Opening a job, or clicking *Mark seen*, moves it to Seen.
- **no longer listed** means the job was missing from that source's latest successful scrape.
- Search filters on title, company and tags. Recent scrape runs (and errors) are listed at the bottom.
## Adding a site
1. Create `src/scrapers/<site>.ts` exporting an object that implements `Scraper` (`src/scrapers/types.ts`):
```ts
export const mySite: Scraper = {
id: "my-site", // stable, stored in DB
name: "My Site",
async scrape() {
const html = await fetchHtml("https://..."); // helper in ./http.ts
return [/* { externalId, title, url, company?, location?, tags?, postedAt? } */];
},
};
```
2. Add it to the list in `src/scrapers/index.ts`.
`externalId` must stay stable for a job, because it is what decides whether a job is new.
For another slo-tech city, just add `sloTech("Maribor")` to the list.
+23
View File
@@ -0,0 +1,23 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "job-board",
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"undici-types": ["undici-types@8.11.2", "", {}, "sha512-iMVNmWZ0leK/goS6eXMizSzmm9CDWtyphwbaCms3DNLqRxDL+mMoNVcZMTyyVgXP0N+Z8neAMzDoUOUJL8veKg=="],
"@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "job-board",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/server.ts",
"dev": "bun --watch run src/server.ts",
"scrape": "bun run src/scrape.ts"
},
"devDependencies": {
"@types/bun": "latest"
}
}
+176
View File
@@ -0,0 +1,176 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ScrapedJob } from "./scrapers/types";
const DB_PATH = process.env.DB_PATH ?? `${import.meta.dir}/../data/jobs.sqlite`;
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db = new Database(DB_PATH, { create: true, strict: true });
db.run("PRAGMA journal_mode = WAL");
db.run(`
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
external_id TEXT NOT NULL,
title TEXT NOT NULL,
company TEXT,
url TEXT NOT NULL,
location TEXT,
tags TEXT NOT NULL DEFAULT '[]',
posted_at TEXT,
first_seen_at TEXT NOT NULL, -- when the scraper first found it
last_seen_at TEXT NOT NULL, -- last scrape that still listed it
seen_at TEXT, -- when you marked it as seen (NULL = new)
UNIQUE (source, external_id)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS scrape_runs (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
found INTEGER,
new_count INTEGER,
error TEXT
)
`);
export interface JobRow {
id: number;
source: string;
external_id: string;
title: string;
company: string | null;
url: string;
location: string | null;
tags: string;
posted_at: string | null;
first_seen_at: string;
last_seen_at: string;
seen_at: string | null;
/** 0 if the job was missing from the source's latest successful scrape. */
still_listed: number;
}
export interface RunRow {
id: number;
source: string;
started_at: string;
finished_at: string | null;
found: number | null;
new_count: number | null;
error: string | null;
}
const upsertJob = db.prepare(`
INSERT INTO jobs (source, external_id, title, company, url, location, tags, posted_at, first_seen_at, last_seen_at)
VALUES ($source, $externalId, $title, $company, $url, $location, $tags, $postedAt, $now, $now)
ON CONFLICT (source, external_id) DO UPDATE SET
title = excluded.title, company = excluded.company, url = excluded.url, location = excluded.location,
tags = excluded.tags, posted_at = excluded.posted_at, last_seen_at = excluded.last_seen_at
RETURNING first_seen_at = $now AS is_new
`);
/** Insert or refresh scraped jobs; returns how many were not in the DB before. */
export const saveJobs = db.transaction((source: string, jobs: ScrapedJob[], now: string): number => {
let newCount = 0;
for (const j of jobs) {
const row = upsertJob.get({
source,
externalId: j.externalId,
title: j.title,
company: j.company ?? null,
url: j.url,
location: j.location ?? null,
tags: JSON.stringify(j.tags ?? []),
postedAt: j.postedAt ?? null,
now,
}) as { is_new: number };
newCount += row.is_new;
}
return newCount;
});
export function startRun(source: string, now: string): number {
const row = db
.query("INSERT INTO scrape_runs (source, started_at) VALUES (?, ?) RETURNING id")
.get(source, now) as { id: number };
return row.id;
}
export function finishRun(id: number, result: { found?: number; newCount?: number; error?: string }) {
db.query("UPDATE scrape_runs SET finished_at = ?, found = ?, new_count = ?, error = ? WHERE id = ?").run(
new Date().toISOString(),
result.found ?? null,
result.newCount ?? null,
result.error ?? null,
id,
);
}
export type Filter = "new" | "seen" | "all";
export function listJobs(opts: { filter: Filter; source?: string; q?: string }): JobRow[] {
const where: string[] = [];
const params: Record<string, string> = {};
if (opts.filter === "new") where.push("j.seen_at IS NULL");
if (opts.filter === "seen") where.push("j.seen_at IS NOT NULL");
if (opts.source) {
where.push("j.source = $source");
params.source = opts.source;
}
if (opts.q) {
where.push("(j.title LIKE $q OR j.company LIKE $q OR j.tags LIKE $q)");
params.q = `%${opts.q}%`;
}
return db
.query(
`SELECT j.*,
j.last_seen_at >= COALESCE(
(SELECT MAX(r.started_at) FROM scrape_runs r WHERE r.source = j.source AND r.error IS NULL AND r.finished_at IS NOT NULL),
j.last_seen_at
) AS still_listed
FROM jobs j
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY still_listed DESC, COALESCE(j.posted_at, j.first_seen_at) DESC`,
)
.all(params) as JobRow[];
}
export function counts(): { new: number; seen: number; all: number } {
return db
.query(
`SELECT SUM(seen_at IS NULL) AS new, SUM(seen_at IS NOT NULL) AS seen, COUNT(*) AS "all" FROM jobs`,
)
.get() as { new: number; seen: number; all: number };
}
export function setSeen(id: number, seen: boolean) {
db.query("UPDATE jobs SET seen_at = ? WHERE id = ?").run(seen ? new Date().toISOString() : null, id);
}
export function markAllSeen(ids: number[]) {
const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL");
const now = new Date().toISOString();
db.transaction(() => ids.forEach((id) => stmt.run(now, id)))();
}
export function sources(): string[] {
return (db.query("SELECT DISTINCT source FROM jobs ORDER BY source").all() as { source: string }[]).map(
(r) => r.source,
);
}
export function recentRuns(limit = 10): RunRow[] {
return db.query("SELECT * FROM scrape_runs ORDER BY id DESC LIMIT ?").all(limit) as RunRow[];
}
/** Start time of the most recent run that finished (successfully or not), across all sources. */
export function lastRunAt(): string | null {
const row = db.query("SELECT MAX(started_at) AS t FROM scrape_runs WHERE finished_at IS NOT NULL").get() as {
t: string | null;
};
return row.t;
}
+44
View File
@@ -0,0 +1,44 @@
import { finishRun, saveJobs, startRun } from "./db";
import { scrapers } from "./scrapers";
export interface RunSummary {
source: string;
found?: number;
newCount?: number;
error?: string;
}
let running: Promise<RunSummary[]> | null = null;
/** Run every registered scraper. A failing scraper is logged and doesn't stop the others. */
export function runAllScrapers(): Promise<RunSummary[]> {
running ??= (async () => {
const results: RunSummary[] = [];
for (const s of scrapers) {
const now = new Date().toISOString();
const runId = startRun(s.id, now);
try {
const jobs = await s.scrape();
if (jobs.length === 0) throw new Error("scraper returned 0 jobs (site layout changed?)");
const newCount = saveJobs(s.id, jobs, now);
finishRun(runId, { found: jobs.length, newCount });
results.push({ source: s.id, found: jobs.length, newCount });
console.log(`[scrape] ${s.id}: ${jobs.length} found, ${newCount} new`);
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
finishRun(runId, { error });
results.push({ source: s.id, error });
console.error(`[scrape] ${s.id} failed: ${error}`);
}
}
return results;
})().finally(() => (running = null));
return running;
}
export const isScraping = () => running !== null;
if (import.meta.main) {
const results = await runAllScrapers();
process.exit(results.some((r) => r.error) ? 1 : 0);
}
+45
View File
@@ -0,0 +1,45 @@
// Shared helpers for scrapers.
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
// Upper half (0xA0-0xFF) of ISO-8859-2, which Bun's TextDecoder does not support.
const LATIN2_HIGH =
" Ą˘Ł¤ĽŚ§¨ŠŞŤŹ­ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙";
function decodeLatin2(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) out += b < 0xa0 ? String.fromCharCode(b) : LATIN2_HIGH[b - 0xa0];
return out;
}
/** Fetch a page and decode it according to its declared charset. */
export async function fetchHtml(url: string): Promise<string> {
const res = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
const charset = /charset=([\w-]+)/i.exec(res.headers.get("content-type") ?? "")?.[1]?.toLowerCase() ?? "utf-8";
if (charset === "iso-8859-2" || charset === "latin2") return decodeLatin2(bytes);
try {
return new TextDecoder(charset).decode(bytes);
} catch {
return new TextDecoder("utf-8").decode(bytes);
}
}
const NAMED_ENTITIES: Record<string, string> = {
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", raquo: "»", laquo: "«",
};
/** Decode HTML entities (HTMLRewriter text chunks are not decoded). */
export function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e: string) => {
if (e[0] === "#") {
const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
}
return NAMED_ENTITIES[e.toLowerCase()] ?? m;
});
}
export const clean = (s: string) => decodeEntities(s).replace(/\s+/g, " ").trim();
+13
View File
@@ -0,0 +1,13 @@
import { sloTech } from "./slotech";
import type { Scraper } from "./types";
/**
* All active scrapers. To add a site:
* 1. create src/scrapers/<site>.ts exporting an object implementing `Scraper`
* 2. add it to this list
*/
export const scrapers: Scraper[] = [
sloTech("Ljubljana"),
];
export type { Scraper, ScrapedJob } from "./types";
+74
View File
@@ -0,0 +1,74 @@
import { clean, fetchHtml } from "./http";
import type { ScrapedJob, Scraper } from "./types";
const BASE = "https://slo-tech.com";
/** Scraper for slo-tech.com job listings, filtered by city (e.g. "Ljubljana", "Maribor"). */
export function sloTech(city: string): Scraper {
return {
id: `slo-tech-${city.toLowerCase()}`,
name: `Slo-Tech (${city})`,
async scrape() {
const html = await fetchHtml(`${BASE}/delo/mesto/${encodeURIComponent(city)}`);
return parseSloTech(html, city);
},
};
}
export async function parseSloTech(html: string, city: string): Promise<ScrapedJob[]> {
type Draft = { href: string; title: string; company: string; tags: string[]; postedAt?: string };
const drafts: Draft[] = [];
let cur: Draft | undefined;
await new HTMLRewriter()
.on("table.forums tbody tr", {
element() {
cur = { href: "", title: "", company: "", tags: [] };
drafts.push(cur);
},
})
.on("table.forums tbody td.name h3 a", {
element(el) {
if (cur) cur.href = el.getAttribute("href") ?? "";
},
text(t) {
if (cur) cur.title += t.text;
},
})
.on("table.forums tbody td.name span.oddelek a", {
element() {
cur?.tags.push("");
},
text(t) {
if (cur?.tags.length) cur.tags[cur.tags.length - 1] += t.text;
},
})
.on("table.forums tbody td.company", {
text(t) {
if (cur) cur.company += t.text;
},
})
.on("table.forums tbody td.last_msg time", {
element(el) {
if (cur) cur.postedAt = el.getAttribute("datetime") ?? undefined;
},
})
.transform(new Response(html))
.text();
return drafts
.map((d): ScrapedJob | null => {
const id = /\/delo\/(\d+)/.exec(d.href)?.[1];
if (!id) return null;
return {
externalId: id,
title: clean(d.title),
url: new URL(d.href, BASE).href,
company: clean(d.company) || undefined,
location: city,
tags: d.tags.map(clean).filter(Boolean),
postedAt: d.postedAt ? new Date(d.postedAt).toISOString() : undefined,
};
})
.filter((j): j is ScrapedJob => j !== null);
}
+24
View File
@@ -0,0 +1,24 @@
/** A single job posting as returned by a scraper. */
export interface ScrapedJob {
/** Stable ID unique within the source (used to detect new vs. already-known jobs). */
externalId: string;
title: string;
url: string;
company?: string;
location?: string;
tags?: string[];
/** ISO 8601 date the job was posted, if the site exposes it. */
postedAt?: string;
}
/**
* Implement this interface to add a new site.
* Then register the instance in `src/scrapers/index.ts`.
*/
export interface Scraper {
/** Unique, stable identifier stored in the DB (e.g. "slo-tech-ljubljana"). Don't change it later. */
id: string;
/** Human-readable name shown in the UI. */
name: string;
scrape(): Promise<ScrapedJob[]>;
}
+230
View File
@@ -0,0 +1,230 @@
import { counts, lastRunAt, listJobs, markAllSeen, recentRuns, setSeen, sources, type Filter, type JobRow } from "./db";
import { isScraping, runAllScrapers } from "./scrape";
import { scrapers } from "./scrapers";
const PORT = Number(process.env.PORT ?? 3000);
const SCRAPE_INTERVAL_HOURS = Number(process.env.SCRAPE_INTERVAL_HOURS ?? 24);
// --- Scheduler: scrape every SCRAPE_INTERVAL_HOURS. On startup, wait out whatever is left of the
// interval since the last run (or scrape right away if it's overdue). ---
const INTERVAL_MS = SCRAPE_INTERVAL_HOURS * 3600_000;
async function scheduledScrape() {
await runAllScrapers();
setTimeout(scheduledScrape, INTERVAL_MS);
}
const last = lastRunAt();
setTimeout(scheduledScrape, last ? Math.max(0, new Date(last).getTime() + INTERVAL_MS - Date.now()) : 0);
// --- HTML rendering ---
const esc = (s: unknown) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString("sl-SI", { day: "numeric", month: "short", year: "numeric" }) : "";
const fmtDateTime = (iso: string | null) =>
iso ? new Date(iso).toLocaleString("sl-SI", { dateStyle: "medium", timeStyle: "short" }) : "never";
const sourceName = (id: string) => scrapers.find((s) => s.id === id)?.name ?? id;
function jobCard(j: JobRow) {
const tags = JSON.parse(j.tags) as string[];
return `
<li class="job ${j.seen_at ? "seen" : "new"} ${j.still_listed ? "" : "gone"}" data-id="${j.id}">
<div class="main">
<div class="title-row">
${j.seen_at ? "" : `<span class="badge new-badge">NEW</span>`}
${j.still_listed ? "" : `<span class="badge gone-badge" title="Missing from the latest scrape">no longer listed</span>`}
<a class="title" href="${esc(j.url)}" target="_blank" rel="noopener">${esc(j.title)}</a>
</div>
<div class="meta">
${j.company ? `<span class="company">${esc(j.company)}</span>` : ""}
${j.location ? `<span>${esc(j.location)}</span>` : ""}
<span>${esc(sourceName(j.source))}</span>
${j.posted_at ? `<span title="Posted">posted ${fmtDate(j.posted_at)}</span>` : ""}
<span title="First found by scraper">found ${fmtDate(j.first_seen_at)}</span>
</div>
${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""}
</div>
<button class="toggle" type="button">${j.seen_at ? "Mark new" : "Mark seen"}</button>
</li>`;
}
function page(url: URL) {
const filter = (["new", "seen", "all"].includes(url.searchParams.get("filter") ?? "")
? url.searchParams.get("filter")
: "new") as Filter;
const source = url.searchParams.get("source") ?? "";
const q = url.searchParams.get("q") ?? "";
const jobs = listJobs({ filter, source: source || undefined, q: q || undefined });
const c = counts();
const runs = recentRuns(5);
const link = (f: Filter) => {
const p = new URLSearchParams(url.searchParams);
p.set("filter", f);
return `?${p}`;
};
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Job Board</title>
<style>
:root { --bg:#f6f7f9; --card:#fff; --fg:#1c1f24; --muted:#6b7280; --border:#e3e6ea; --accent:#2563eb; --new:#16a34a; --gone:#b45309; --tag:#eef1f5; }
@media (prefers-color-scheme: dark) {
:root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
.wrap { max-width: 920px; margin: 0 auto; padding: 24px 16px 64px; }
header { display:flex; flex-wrap:wrap; align-items:center; gap:12px; justify-content:space-between; margin-bottom:16px; }
h1 { font-size:22px; margin:0; }
.status { color:var(--muted); font-size:13px; }
button, .btn { font:inherit; font-size:13px; padding:6px 12px; border-radius:8px; border:1px solid var(--border); background:var(--card); color:var(--fg); cursor:pointer; }
button:hover { border-color:var(--accent); }
button:disabled { opacity:.6; cursor:wait; }
.toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:16px; }
.tabs { display:flex; gap:4px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:3px; }
.tabs a { padding:5px 12px; border-radius:7px; color:var(--fg); text-decoration:none; font-size:14px; }
.tabs a.active { background:var(--accent); color:#fff; }
.toolbar form { display:flex; gap:8px; flex:1; min-width:240px; }
input, select { font:inherit; font-size:14px; padding:6px 10px; border-radius:8px; border:1px solid var(--border); background:var(--card); color:var(--fg); }
input[type=search] { flex:1; min-width:0; }
ul.jobs { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px; }
.job { display:flex; gap:12px; align-items:flex-start; background:var(--card); border:1px solid var(--border); border-left:4px solid var(--new); border-radius:10px; padding:12px 14px; }
.job.seen { border-left-color:var(--border); }
.job.seen .title { color:var(--muted); }
.job.gone { opacity:.65; }
.main { flex:1; min-width:0; }
.title-row { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.title { font-weight:600; font-size:16px; color:var(--fg); text-decoration:none; overflow-wrap:anywhere; }
.title:hover { color:var(--accent); text-decoration:underline; }
.badge { font-size:11px; font-weight:700; padding:1px 6px; border-radius:5px; }
.new-badge { color:var(--new); border:1px solid var(--new); }
.gone-badge { color:var(--gone); border:1px solid var(--gone); font-weight:500; }
.meta { display:flex; flex-wrap:wrap; gap:4px 12px; color:var(--muted); font-size:13px; margin-top:2px; }
.company { color:var(--fg); font-weight:500; }
.tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
.tag { background:var(--tag); font-size:12px; padding:1px 8px; border-radius:999px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.toggle { flex-shrink:0; }
.empty { text-align:center; color:var(--muted); padding:48px 0; }
details { margin-top:32px; color:var(--muted); font-size:13px; }
table { border-collapse:collapse; width:100%; margin-top:8px; }
td, th { text-align:left; padding:4px 8px; border-bottom:1px solid var(--border); }
.err { color:#dc2626; }
@media (max-width: 560px) { .job { flex-direction:column; } }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Job Board</h1>
<div class="status">Last scrape: ${fmtDateTime(lastRunAt())}${isScraping() ? " · scraping now…" : ""}</div>
</div>
<button id="scrape" type="button" ${isScraping() ? "disabled" : ""}>Scrape now</button>
</header>
<div class="toolbar">
<nav class="tabs">
<a href="${link("new")}" class="${filter === "new" ? "active" : ""}">New (<span id="c-new">${c.new ?? 0}</span>)</a>
<a href="${link("seen")}" class="${filter === "seen" ? "active" : ""}">Seen (<span id="c-seen">${c.seen ?? 0}</span>)</a>
<a href="${link("all")}" class="${filter === "all" ? "active" : ""}">All (${c.all ?? 0})</a>
</nav>
<form method="get">
<input type="hidden" name="filter" value="${filter}">
<input type="search" name="q" value="${esc(q)}" placeholder="Search title, company, tags…">
<select name="source" onchange="this.form.submit()">
<option value="">All sources</option>
${sources().map((s) => `<option value="${esc(s)}" ${s === source ? "selected" : ""}>${esc(sourceName(s))}</option>`).join("")}
</select>
</form>
${filter !== "seen" && jobs.some((j) => !j.seen_at) ? `<button id="all-seen" type="button">Mark all shown as seen</button>` : ""}
</div>
${jobs.length ? `<ul class="jobs">${jobs.map(jobCard).join("")}</ul>` : `<p class="empty">${filter === "new" ? "No new jobs. 🎉" : "No jobs."}</p>`}
<details>
<summary>Recent scrape runs</summary>
<table>
<tr><th>Source</th><th>Started</th><th>Found</th><th>New</th><th>Error</th></tr>
${runs.map((r) => `<tr><td>${esc(sourceName(r.source))}</td><td>${fmtDateTime(r.started_at)}</td><td>${r.found ?? ""}</td><td>${r.new_count ?? ""}</td><td class="err">${esc(r.error)}</td></tr>`).join("")}
</table>
</details>
</div>
<script>
const filter = ${JSON.stringify(filter)};
async function setSeen(li, seen) {
const res = await fetch("/api/jobs/" + li.dataset.id + "/seen", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ seen }), keepalive: true,
});
const c = await res.json();
document.getElementById("c-new").textContent = c.new ?? 0;
document.getElementById("c-seen").textContent = c.seen ?? 0;
li.classList.toggle("seen", seen);
li.classList.toggle("new", !seen);
li.querySelector(".new-badge")?.remove();
if (!seen) li.querySelector(".title-row").insertAdjacentHTML("afterbegin", '<span class="badge new-badge">NEW</span>');
li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen";
if ((filter === "new" && seen) || (filter === "seen" && !seen)) li.remove();
}
document.querySelectorAll(".job").forEach((li) => {
li.querySelector(".toggle").addEventListener("click", () => setSeen(li, !li.classList.contains("seen")));
// Opening a job counts as seeing it (the card stays until you reload).
li.querySelector(".title").addEventListener("click", () => {
if (li.classList.contains("seen")) return;
fetch("/api/jobs/" + li.dataset.id + "/seen", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ seen: true }), keepalive: true,
});
li.classList.add("seen"); li.classList.remove("new");
li.querySelector(".new-badge")?.remove();
li.querySelector(".toggle").textContent = "Mark new";
});
});
document.getElementById("all-seen")?.addEventListener("click", async () => {
const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id));
await fetch("/api/jobs/seen", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) });
location.reload();
});
document.getElementById("scrape").addEventListener("click", async (e) => {
e.target.disabled = true; e.target.textContent = "Scraping…";
const res = await fetch("/api/scrape", { method: "POST" });
const results = await res.json();
const errors = results.filter((r) => r.error);
if (errors.length) alert("Some scrapers failed:\\n" + errors.map((r) => r.source + ": " + r.error).join("\\n"));
location.reload();
});
</script>
</body>
</html>`;
}
const html = (body: string) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
Bun.serve({
port: PORT,
routes: {
"/": (req) => html(page(new URL(req.url))),
"/api/scrape": { POST: async () => Response.json(await runAllScrapers()) },
"/api/jobs/seen": {
POST: async (req) => {
const { ids } = (await req.json()) as { ids: number[] };
markAllSeen(ids.filter(Number.isInteger));
return Response.json(counts());
},
},
"/api/jobs/:id/seen": {
POST: async (req) => {
const { seen } = (await req.json()) as { seen: boolean };
setSeen(Number(req.params.id), !!seen);
return Response.json(counts());
},
},
},
fetch: () => new Response("Not found", { status: 404 }),
});
console.log(`Job board running at http://localhost:${PORT} (scraping every ${SCRAPE_INTERVAL_HOURS}h)`);
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
}
}