Compare commits
3
Commits
beac32469c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81b67e229 | ||
|
|
b50f269c41 | ||
|
|
784c3c5220 |
+10
-5
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 scrape run all scrapers once and exit (exit code 1 if any failed)
|
||||
// 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
@@ -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)`);
|
||||
|
||||
Reference in New Issue
Block a user