Compare commits

...
3 Commits
Author SHA1 Message Date
nikola e81b67e229 Add star 2026-10-25 22:32:41 +01:00
nikolaandClaude Opus 5.5 b50f269c41 Don't mark a job as seen when opening it
Jobs now stay in New until 'Mark seen' is clicked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:02:10 +02:00
nikolaandClaude Opus 5.5 784c3c5220 Add --host flag, listen on 127.0.0.1 by default
The server previously bound to all interfaces with no auth. Default to
loopback so it sits behind a reverse proxy; --host (or JOB_BOARD_HOST)
overrides it. Plain HOST is ignored since some environments set it to
the machine hostname.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:43:34 +02:00
4 changed files with 81 additions and 30 deletions
+10 -5
View File
@@ -18,14 +18,14 @@ No runtime dependencies. It uses Bun's built-in SQLite, HTTP server and HTMLRewr
```sh
bun install
bun start # http://localhost:3000
bun start # http://127.0.0.1:3000
bun start --port 8080 # or -p 8080
```
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; `--port` overrides it), `SCRAPE_INTERVAL_HOURS` (24), `DB_PATH` (`data/jobs.sqlite`).
Env vars: `PORT` (3000; `--port` overrides it), `JOB_BOARD_HOST` (127.0.0.1; `--host` overrides it), `SCRAPE_INTERVAL_HOURS` (24), `DB_PATH` (`data/jobs.sqlite`).
## Build a binary for a server
@@ -41,11 +41,15 @@ bun run build:linux-arm64 # bin/job-board-linux-arm64 (e.g. Raspberry Pi, ARM
On the server:
```sh
./job-board # web server on :3000, scrapes every 24h
./job-board # http://127.0.0.1:3000, scrapes every 24h
./job-board --port 8080
./job-board --host 0.0.0.0 --port 8080 # listen on all interfaces
./job-board scrape # scrape once and exit (exit code 1 if a scraper failed)
```
It listens on loopback (`127.0.0.1`) only by default, so it's meant to sit behind a reverse proxy
(nginx, Caddy). There is no login: only use `--host 0.0.0.0` on a trusted network.
The database goes to `data/jobs.sqlite` next to the binary (override with `DB_PATH`).
The binaries target glibc Linux; for Alpine use `--target=bun-linux-x64-musl`, and for old CPUs
without AVX2 use `--target=bun-linux-x64-baseline`.
@@ -58,7 +62,7 @@ Description=Job board
After=network-online.target
[Service]
ExecStart=/opt/job-board/job-board --port 3000
ExecStart=/opt/job-board/job-board --host 127.0.0.1 --port 3000
Restart=on-failure
User=jobboard
@@ -68,7 +72,8 @@ WantedBy=multi-user.target
## UI
- **New / Seen / All** tabs. Opening a job, or clicking *Mark seen*, moves it to Seen.
- **New / Seen / All** tabs. Click *Mark seen* to move a job to Seen (opening a job does not mark it).
- Click ☆ to star a job; starred jobs are listed under **Starred** (starring is independent of seen/new).
- **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.
+15 -4
View File
@@ -25,9 +25,13 @@ db.run(`
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)
starred_at TEXT, -- when you starred it (NULL = not starred)
UNIQUE (source, external_id)
)
`);
// No migration system: add columns that older databases are missing.
const jobColumns = (db.query("PRAGMA table_info(jobs)").all() as { name: string }[]).map((c) => c.name);
if (!jobColumns.includes("starred_at")) db.run("ALTER TABLE jobs ADD COLUMN starred_at TEXT");
db.run(`
CREATE TABLE IF NOT EXISTS scrape_runs (
id INTEGER PRIMARY KEY,
@@ -53,6 +57,7 @@ export interface JobRow {
first_seen_at: string;
last_seen_at: string;
seen_at: string | null;
starred_at: string | null;
/** 0 if the job was missing from the source's latest successful scrape. */
still_listed: number;
}
@@ -113,13 +118,14 @@ export function finishRun(id: number, result: { found?: number; newCount?: numbe
);
}
export type Filter = "new" | "seen" | "all";
export type Filter = "new" | "seen" | "starred" | "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.filter === "starred") where.push("j.starred_at IS NOT NULL");
if (opts.source) {
where.push("j.source = $source");
params.source = opts.source;
@@ -142,18 +148,23 @@ export function listJobs(opts: { filter: Filter; source?: string; q?: string }):
.all(params) as JobRow[];
}
export function counts(): { new: number; seen: number; all: number } {
export function counts(): { new: number; seen: number; starred: 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`,
`SELECT SUM(seen_at IS NULL) AS new, SUM(seen_at IS NOT NULL) AS seen, SUM(starred_at IS NOT NULL) AS starred,
COUNT(*) AS "all" FROM jobs`,
)
.get() as { new: number; seen: number; all: number };
.get() as { new: number; seen: number; starred: 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 setStarred(id: number, starred: boolean) {
db.query("UPDATE jobs SET starred_at = ? WHERE id = ?").run(starred ? 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();
+6 -2
View File
@@ -1,10 +1,10 @@
// Entry point (also used by the compiled binary):
// job-board [serve] [--port <n>] start the web server (scrapes every 24h)
// job-board [serve] [--host <addr>] [--port <n>] start the web server (scrapes every 24h)
// job-board scrape run all scrapers once and exit (exit code 1 if any failed)
export {};
const USAGE = "Usage: job-board [serve] [--port <n>] | job-board scrape";
const USAGE = "Usage: job-board [serve] [--host <addr>] [--port <n>] | job-board scrape\n --host address to listen on (default 127.0.0.1; 0.0.0.0 = all interfaces)\n --port port to listen on (default 3000)";
function fail(message: string): never {
console.error(`${message}\n${USAGE}`);
@@ -20,6 +20,10 @@ for (let i = 0; i < args.length; i++) {
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) fail(`Invalid port "${value ?? ""}".`);
process.env.PORT = String(port); // read by server.ts; overrides the PORT env var
} else if (arg === "--host" || arg.startsWith("--host=")) {
const value = arg.startsWith("--host=") ? arg.slice("--host=".length) : args[++i];
if (!value || value.startsWith("-")) fail(`Invalid host "${value ?? ""}".`);
process.env.JOB_BOARD_HOST = value; // read by server.ts; overrides the env var
} else if (arg === "--help" || arg === "-h") {
console.log(USAGE);
process.exit(0);
+49 -18
View File
@@ -1,8 +1,22 @@
import { counts, lastRunAt, listJobs, markAllSeen, recentRuns, setSeen, sources, type Filter, type JobRow } from "./db";
import {
counts,
lastRunAt,
listJobs,
markAllSeen,
recentRuns,
setSeen,
setStarred,
sources,
type Filter,
type JobRow,
} from "./db";
import { isScraping, runAllScrapers } from "./scrape";
import { scrapers } from "./scrapers";
const PORT = Number(process.env.PORT ?? 3000);
// Loopback only by default; use --host 0.0.0.0 (or JOB_BOARD_HOST) to expose it on the network.
// Not plain HOST: some shells/environments set that to the machine hostname.
const HOST = process.env.JOB_BOARD_HOST || "127.0.0.1";
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
@@ -29,7 +43,7 @@ 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}">
<li class="job ${j.seen_at ? "seen" : "new"} ${j.starred_at ? "starred" : ""} ${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>`}
@@ -45,12 +59,13 @@ function jobCard(j: JobRow) {
</div>
${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""}
</div>
<button class="star" type="button" title="${j.starred_at ? "Unstar" : "Star"}" aria-pressed="${!!j.starred_at}">${j.starred_at ? "★" : "☆"}</button>
<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") ?? "")
const filter = (["new", "seen", "starred", "all"].includes(url.searchParams.get("filter") ?? "")
? url.searchParams.get("filter")
: "new") as Filter;
const source = url.searchParams.get("source") ?? "";
@@ -71,9 +86,9 @@ function page(url: URL) {
<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; }
:root { --bg:#f6f7f9; --card:#fff; --fg:#1c1f24; --muted:#6b7280; --border:#e3e6ea; --accent:#2563eb; --new:#16a34a; --gone:#b45309; --tag:#eef1f5; --star:#ca8a04; }
@media (prefers-color-scheme: dark) {
:root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; }
:root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; --star:#facc15; }
}
* { 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; }
@@ -108,6 +123,8 @@ function page(url: URL) {
.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; }
.star { flex-shrink:0; font-size:18px; line-height:1; padding:4px 8px; color:var(--muted); }
.job.starred .star { color:var(--star); border-color:var(--star); }
.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; }
@@ -130,6 +147,7 @@ function page(url: URL) {
<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("starred")}" class="${filter === "starred" ? "active" : ""}">★ Starred (<span id="c-starred">${c.starred ?? 0}</span>)</a>
<a href="${link("all")}" class="${filter === "all" ? "active" : ""}">All (${c.all ?? 0})</a>
</nav>
<form method="get">
@@ -143,7 +161,7 @@ function page(url: URL) {
${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>`}
${jobs.length ? `<ul class="jobs">${jobs.map(jobCard).join("")}</ul>` : `<p class="empty">${filter === "new" ? "No new jobs. 🎉" : filter === "starred" ? "No starred jobs." : "No jobs."}</p>`}
<details>
<summary>Recent scrape runs</summary>
@@ -170,19 +188,23 @@ function page(url: URL) {
li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen";
if ((filter === "new" && seen) || (filter === "seen" && !seen)) li.remove();
}
async function setStarred(li, starred) {
const res = await fetch("/api/jobs/" + li.dataset.id + "/star", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ starred }), keepalive: true,
});
const c = await res.json();
document.getElementById("c-starred").textContent = c.starred ?? 0;
li.classList.toggle("starred", starred);
const btn = li.querySelector(".star");
btn.textContent = starred ? "★" : "☆";
btn.title = starred ? "Unstar" : "Star";
btn.setAttribute("aria-pressed", String(starred));
if (filter === "starred" && !starred) 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";
});
li.querySelector(".star").addEventListener("click", () => setStarred(li, !li.classList.contains("starred")));
});
document.getElementById("all-seen")?.addEventListener("click", async () => {
const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id));
@@ -205,6 +227,7 @@ function page(url: URL) {
const html = (body: string) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
Bun.serve({
hostname: HOST,
port: PORT,
routes: {
"/": (req) => html(page(new URL(req.url))),
@@ -216,6 +239,13 @@ Bun.serve({
return Response.json(counts());
},
},
"/api/jobs/:id/star": {
POST: async (req) => {
const { starred } = (await req.json()) as { starred: boolean };
setStarred(Number(req.params.id), !!starred);
return Response.json(counts());
},
},
"/api/jobs/:id/seen": {
POST: async (req) => {
const { seen } = (await req.json()) as { seen: boolean };
@@ -227,4 +257,5 @@ Bun.serve({
fetch: () => new Response("Not found", { status: 404 }),
});
console.log(`Job board running at http://localhost:${PORT} (scraping every ${SCRAPE_INTERVAL_HOURS}h)`);
const shownHost = HOST.includes(":") ? `[${HOST}]` : HOST;
console.log(`Job board running at http://${shownHost}:${PORT} (scraping every ${SCRAPE_INTERVAL_HOURS}h)`);