Compare commits
9
Commits
42f97ea073
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81b67e229 | ||
|
|
b50f269c41 | ||
|
|
784c3c5220 | ||
|
|
beac32469c | ||
|
|
ad2fa2b775 | ||
|
|
5d30bcf85a | ||
|
|
88420b54e7 | ||
|
|
f6e5605108 | ||
|
|
0324ff7f2a |
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
data/
|
||||||
|
bin/
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Source | How | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| slo-tech.com (Ljubljana) | HTML scrape | ~20 newest jobs |
|
||||||
|
| startup.si | HTML scrape (no public API or feed) | Site lists every posting since 2019 on one page, so only the last 90 days are kept (`startupSi(days)` in `src/scrapers/index.ts`) |
|
||||||
|
| adecco.com (Slovenia, IT) | Internal JSON API the site's job search uses (no public API) | Filtered by category; add more with `adecco("<CategoryFilter value>", "<label>")` |
|
||||||
|
| mojedelo.com (IT, Osrednjeslovenska) | Internal JSON API the site's search uses (no public API) | Takes a search URL copied from the site; add more with `mojeDelo("<label>", "<search URL>")` (don't rename the label later, it's part of the source ID) |
|
||||||
|
| optius.com (IT, Osrednjeslovenska) | Official RSS feed | Build a feed URL at https://www.optius.com/rss/ (its IDs differ from the search page's); add more with `optius("<label>", "<feed URL>")` (same label rule as MojeDelo) |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
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), `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
|
||||||
|
|
||||||
|
`bun build --compile` bundles the app and the Bun runtime into one executable (~90 MB), so the
|
||||||
|
server doesn't need Bun or `node_modules`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run build # bin/job-board for this machine
|
||||||
|
bun run build:linux-x64 # bin/job-board-linux-x64
|
||||||
|
bun run build:linux-arm64 # bin/job-board-linux-arm64 (e.g. Raspberry Pi, ARM VPS)
|
||||||
|
```
|
||||||
|
|
||||||
|
On the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./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`.
|
||||||
|
|
||||||
|
Example systemd unit (`/etc/systemd/system/job-board.service`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Job board
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/job-board/job-board --host 127.0.0.1 --port 3000
|
||||||
|
Restart=on-failure
|
||||||
|
User=jobboard
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "job-board",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/main.ts",
|
||||||
|
"dev": "bun --watch run src/main.ts",
|
||||||
|
"scrape": "bun run src/scrape.ts",
|
||||||
|
"build": "bun build src/main.ts --compile --minify --sourcemap --outfile bin/job-board",
|
||||||
|
"build:linux-x64": "bun build src/main.ts --compile --minify --sourcemap --target=bun-linux-x64 --outfile bin/job-board-linux-x64",
|
||||||
|
"build:linux-arm64": "bun build src/main.ts --compile --minify --sourcemap --target=bun-linux-arm64 --outfile bin/job-board-linux-arm64"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import type { ScrapedJob } from "./scrapers/types";
|
||||||
|
|
||||||
|
// In a compiled binary (`bun run build`) import.meta.dir is a virtual path, so keep data next to the executable.
|
||||||
|
const isCompiled = import.meta.dir.startsWith("/$bunfs");
|
||||||
|
const baseDir = isCompiled ? dirname(process.execPath) : `${import.meta.dir}/..`;
|
||||||
|
const DB_PATH = process.env.DB_PATH ?? `${baseDir}/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)
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
starred_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" | "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;
|
||||||
|
}
|
||||||
|
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; starred: number; all: number } {
|
||||||
|
return db
|
||||||
|
.query(
|
||||||
|
`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; 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();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Entry point (also used by the compiled binary):
|
||||||
|
// 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] [--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}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let command: string | undefined;
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!;
|
||||||
|
if (arg === "--port" || arg === "-p" || arg.startsWith("--port=")) {
|
||||||
|
const value = arg.startsWith("--port=") ? arg.slice("--port=".length) : args[++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);
|
||||||
|
} else if (!command && !arg.startsWith("-")) {
|
||||||
|
command = arg;
|
||||||
|
} else {
|
||||||
|
fail(`Unknown argument "${arg}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "scrape") {
|
||||||
|
const { runAllScrapers } = await import("./scrape");
|
||||||
|
const results = await runAllScrapers();
|
||||||
|
process.exit(results.some((r) => r.error) ? 1 : 0);
|
||||||
|
} else if (command === undefined || command === "serve") {
|
||||||
|
await import("./server");
|
||||||
|
} else {
|
||||||
|
fail(`Unknown command "${command}".`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { slugify, USER_AGENT } from "./http";
|
||||||
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
|
// Adecco has no public API, but its job search page loads results from this internal JSON endpoint
|
||||||
|
// (a Solr-style query passed as `queryString`). Unknown filter fields are silently ignored by the
|
||||||
|
// API, so results are checked against the requested category.
|
||||||
|
const API_URL = "https://www.adecco.com/api/data/jobs/summarized";
|
||||||
|
const JOB_PAGE = "https://www.adecco.com/sl-si/prosta-delovna-mesta";
|
||||||
|
const MAX_PAGES = 50; // 10 jobs per page
|
||||||
|
|
||||||
|
interface AdeccoJob {
|
||||||
|
jobId: string;
|
||||||
|
jobTitle: string;
|
||||||
|
jobLocation: string | null;
|
||||||
|
cityName: string | null;
|
||||||
|
postedDate: string | null;
|
||||||
|
jobCategoryId: string | null;
|
||||||
|
jobSubCategoryId: string | null;
|
||||||
|
contractTypeTitle: string | null;
|
||||||
|
employmentTypeTitle: string | null;
|
||||||
|
industryTypeTitle: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdeccoResponse {
|
||||||
|
jobs?: AdeccoJob[];
|
||||||
|
pagination?: { total: number; nextRange: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The site builds "<title>-<location>/<jobid>" URLs; only the job ID matters, the slug is cosmetic. */
|
||||||
|
function jobUrl(j: AdeccoJob): string {
|
||||||
|
const slug = slugify(`${j.jobTitle}-${j.jobLocation ?? ""}`);
|
||||||
|
return `${JOB_PAGE}/${slug}/${j.jobId.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scraper for adecco.com (Slovenia), filtered by job category.
|
||||||
|
* `category` is the CategoryFilter value from the site's URL, e.g. "ADCSICAT014" (IT).
|
||||||
|
*/
|
||||||
|
export function adecco(category: string, label: string): Scraper {
|
||||||
|
return {
|
||||||
|
id: `adecco-si-${category.toLowerCase()}`,
|
||||||
|
name: `Adecco (${label})`,
|
||||||
|
async scrape() {
|
||||||
|
const raw: AdeccoJob[] = [];
|
||||||
|
for (let page = 0, range = 0; page < MAX_PAGES; page++) {
|
||||||
|
const res = await fetch(API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "User-Agent": USER_AGENT, "content-type": "text/plain;charset=UTF-8" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
queryString: `&fq=JobCategoryId:${category}`,
|
||||||
|
range,
|
||||||
|
siteName: "adecco",
|
||||||
|
brand: "adecco",
|
||||||
|
countryCode: "SI",
|
||||||
|
languageCode: "sl-SI",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST ${API_URL} -> HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as AdeccoResponse;
|
||||||
|
if (!Array.isArray(data.jobs)) throw new Error("unexpected API response (no `jobs` array)");
|
||||||
|
raw.push(...data.jobs);
|
||||||
|
const total = data.pagination?.total ?? 0;
|
||||||
|
if (data.jobs.length === 0 || raw.length >= total || !data.pagination?.nextRange) break;
|
||||||
|
range = data.pagination.nextRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = raw.filter((j) => j.jobCategoryId === category || j.jobSubCategoryId === category);
|
||||||
|
if (raw.length > 0 && jobs.length === 0) {
|
||||||
|
throw new Error(`category filter ${category} was ignored by the API (query format changed?)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.map((j) => ({
|
||||||
|
externalId: j.jobId,
|
||||||
|
title: j.jobTitle.trim(),
|
||||||
|
url: jobUrl(j),
|
||||||
|
location: j.cityName || j.jobLocation || undefined,
|
||||||
|
tags: [j.contractTypeTitle, j.employmentTypeTitle, j.industryTypeTitle].filter(
|
||||||
|
(t): t is string => !!t?.trim(),
|
||||||
|
),
|
||||||
|
postedAt: j.postedDate ?? undefined,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Shared helpers for scrapers.
|
||||||
|
|
||||||
|
export 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: "«",
|
||||||
|
scaron: "š", Scaron: "Š", zcaron: "ž", Zcaron: "Ž", ccaron: "č", Ccaron: "Č",
|
||||||
|
ndash: "–", mdash: "—", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bdquo: "„", hellip: "…",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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] ?? NAMED_ENTITIES[e.toLowerCase()] ?? m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clean = (s: string) => decodeEntities(s).replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
/** ASCII URL/ID slug: "Inženir (m/ž)" -> "inzenir-m-z". */
|
||||||
|
export const slugify = (s: string) =>
|
||||||
|
s
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^A-Za-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { adecco } from "./adecco";
|
||||||
|
import { mojeDelo } from "./mojedelo";
|
||||||
|
import { optius } from "./optius";
|
||||||
|
import { sloTech } from "./slotech";
|
||||||
|
import { startupSi } from "./startupsi";
|
||||||
|
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"),
|
||||||
|
startupSi(),
|
||||||
|
adecco("ADCSICAT014", "IT"),
|
||||||
|
mojeDelo(
|
||||||
|
"IT, Osrednjeslovenska",
|
||||||
|
"https://www.mojedelo.com/iskanje-zaposlitve?jobCategoryIds=64f003ff-6d8b-4be0-b58c-4580e4eeeb8a®ionIds=d1dce9b1-9fa4-438b-b582-10d371d442e6",
|
||||||
|
),
|
||||||
|
optius("IT, Osrednjeslovenska", "https://www.optius.com/rss/?regija=7&podrocje=7"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export type { Scraper, ScrapedJob } from "./types";
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { slugify, USER_AGENT } from "./http";
|
||||||
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
|
// MojeDelo is a single-page app with no public API; its search page calls this internal JSON API.
|
||||||
|
// The API requires tenant headers, which the site itself reads from a public config file.
|
||||||
|
const API = "https://api.mojedelo.com";
|
||||||
|
const CONFIG_URL = `${API}/uploaded-files/config/www.mojedelo.com/jb.globals.js`;
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const MAX_PAGES = 25;
|
||||||
|
|
||||||
|
interface Named {
|
||||||
|
id: string;
|
||||||
|
translation?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MojeDeloJob {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string | null;
|
||||||
|
repostingDate: string | null;
|
||||||
|
company: { name?: string } | null;
|
||||||
|
anonymizedCompany: { name?: string } | null;
|
||||||
|
town: Named | null;
|
||||||
|
regions: Named[] | null;
|
||||||
|
jobCategories: Named[] | null;
|
||||||
|
workModes: Named[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
data?: { items?: MojeDeloJob[]; total?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tenantHeaders(): Promise<Record<string, string>> {
|
||||||
|
const res = await fetch(CONFIG_URL, { headers: { "User-Agent": USER_AGENT } });
|
||||||
|
if (!res.ok) throw new Error(`GET ${CONFIG_URL} -> HTTP ${res.status}`);
|
||||||
|
const config = await res.text();
|
||||||
|
const get = (key: string) => {
|
||||||
|
const value = new RegExp(`["']?${key}["']?\\s*:\\s*["']([^"']+)["']`).exec(config)?.[1];
|
||||||
|
if (!value) throw new Error(`${key} not found in site config (site changed?)`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return { tenantId: get("tenantId"), channelId: get("jbChannelId"), languageId: get("defaultLanguageId") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scraper for mojedelo.com. Pass a search URL copied from the site (filters in the query string,
|
||||||
|
* e.g. jobCategoryIds / regionIds). `label` is shown in the UI and also forms the stable source ID,
|
||||||
|
* so don't change it later.
|
||||||
|
*/
|
||||||
|
export function mojeDelo(label: string, searchUrl: string): Scraper {
|
||||||
|
const filters = new URL(searchUrl).searchParams;
|
||||||
|
filters.delete("page");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `mojedelo-${slugify(label)}`,
|
||||||
|
name: `MojeDelo (${label})`,
|
||||||
|
async scrape() {
|
||||||
|
const headers = { "User-Agent": USER_AGENT, ...(await tenantHeaders()) };
|
||||||
|
const raw: MojeDeloJob[] = [];
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const params = new URLSearchParams(filters);
|
||||||
|
params.set("pageSize", String(PAGE_SIZE));
|
||||||
|
params.set("startFrom", String(page * PAGE_SIZE));
|
||||||
|
const url = `${API}/job-ads-search?${params}`;
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`);
|
||||||
|
const body = (await res.json()) as SearchResponse;
|
||||||
|
const items = body.data?.items;
|
||||||
|
if (!Array.isArray(items)) throw new Error("unexpected API response (no `data.items` array)");
|
||||||
|
raw.push(...items);
|
||||||
|
if (items.length < PAGE_SIZE || raw.length >= (body.data?.total ?? 0)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API may silently ignore filters it doesn't recognise, so check results actually match.
|
||||||
|
const wanted = (param: string) => new Set(filters.getAll(param).flatMap((v) => v.split(",")));
|
||||||
|
const categories = wanted("jobCategoryIds");
|
||||||
|
const regions = wanted("regionIds");
|
||||||
|
const matches = (list: Named[] | null, ids: Set<string>) => ids.size === 0 || !!list?.some((x) => ids.has(x.id));
|
||||||
|
const jobs = raw.filter((j) => matches(j.jobCategories, categories) && matches(j.regions, regions));
|
||||||
|
if (raw.length > 0 && jobs.length === 0) {
|
||||||
|
throw new Error("search filters were ignored by the API (query format changed?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.map(
|
||||||
|
(j): ScrapedJob => ({
|
||||||
|
externalId: j.id,
|
||||||
|
title: j.title.trim(),
|
||||||
|
url: `https://www.mojedelo.com/oglas/${slugify(j.title)}/${j.id}`,
|
||||||
|
company: j.anonymizedCompany?.name ?? j.company?.name ?? undefined,
|
||||||
|
location: j.town?.translation ?? j.town?.name ?? undefined,
|
||||||
|
tags: [...(j.workModes ?? []), ...(j.jobCategories ?? [])]
|
||||||
|
.map((x) => x.translation?.trim())
|
||||||
|
.filter((t): t is string => !!t),
|
||||||
|
postedAt: j.repostingDate ?? j.startDate ?? undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { clean, fetchHtml, slugify } from "./http";
|
||||||
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
|
// Optius publishes an official RSS feed with region/field filters, so no HTML scraping is needed.
|
||||||
|
// Build a feed URL at https://www.optius.com/rss/ (its IDs differ from the search page's IDs).
|
||||||
|
|
||||||
|
const tag = (xml: string, name: string) => new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(xml)?.[1];
|
||||||
|
const text = (s: string | undefined) => clean((s ?? "").replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, "$1"));
|
||||||
|
|
||||||
|
export function parseOptiusFeed(xml: string): ScrapedJob[] {
|
||||||
|
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)]
|
||||||
|
.map((m): ScrapedJob | null => {
|
||||||
|
const item = m[1]!;
|
||||||
|
const url = text(tag(item, "link") ?? tag(item, "guid"));
|
||||||
|
const id = /-(\d+)-\1\/?$/.exec(url)?.[1] ?? /-(\d+)\/?$/.exec(url)?.[1];
|
||||||
|
const title = text(tag(item, "title"));
|
||||||
|
if (!id || !title) return null;
|
||||||
|
const pubDate = tag(item, "pubDate");
|
||||||
|
return {
|
||||||
|
externalId: id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
company: text(tag(item, "employer")) || undefined,
|
||||||
|
tags: [...item.matchAll(/<field(?:\s[^>]*)?>([\s\S]*?)<\/field>/g)].map((f) => text(f[1])).filter(Boolean),
|
||||||
|
postedAt: pubDate ? new Date(pubDate).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((j): j is ScrapedJob => j !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scraper for an optius.com RSS feed, e.g. "https://www.optius.com/rss/?regija=7&podrocje=7". */
|
||||||
|
export function optius(label: string, feedUrl: string): Scraper {
|
||||||
|
return {
|
||||||
|
id: `optius-${label.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()}`,
|
||||||
|
name: `Optius (${label})`,
|
||||||
|
async scrape() {
|
||||||
|
const xml = await fetchHtml(feedUrl);
|
||||||
|
if (!xml.includes("<rss")) throw new Error("response is not an RSS feed (site changed?)");
|
||||||
|
return parseOptiusFeed(xml);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { clean, fetchHtml } from "./http";
|
||||||
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
|
// startup.si has no public jobs API or feed, so we parse the listing page. It holds every posting
|
||||||
|
// on a single page (no pagination), going back years, so old postings are filtered out by age.
|
||||||
|
const LIST_URL = "https://www.startup.si/sl-si/za-startupe/oglasi/zaposlitve";
|
||||||
|
|
||||||
|
const MONTHS = ["januar", "februar", "marec", "april", "maj", "junij", "julij", "avgust", "september", "oktober", "november", "december"];
|
||||||
|
|
||||||
|
/** "21. september 2026" -> ISO date */
|
||||||
|
function parseSlDate(s: string): string | undefined {
|
||||||
|
const m = /(\d{1,2})\.\s*([a-zčšž]+)\s+(\d{4})/i.exec(s);
|
||||||
|
const month = m ? MONTHS.indexOf(m[2]!.toLowerCase()) : -1;
|
||||||
|
if (!m || month < 0) return undefined;
|
||||||
|
return new Date(Date.UTC(Number(m[3]), month, Number(m[1]))).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Links on this site are hand-entered: some are empty, relative, or missing the scheme. */
|
||||||
|
function normalizeUrl(href: string): string {
|
||||||
|
const h = clean(href);
|
||||||
|
if (!h) return LIST_URL;
|
||||||
|
if (/^www\./i.test(h)) return `https://${h}`;
|
||||||
|
try {
|
||||||
|
return new URL(h, LIST_URL + "/").href;
|
||||||
|
} catch {
|
||||||
|
return LIST_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scraper for startup.si job ads. Only postings from the last `maxAgeDays` days are returned. */
|
||||||
|
export function startupSi(maxAgeDays = 90): Scraper {
|
||||||
|
return {
|
||||||
|
id: "startup-si",
|
||||||
|
name: "Startup.si",
|
||||||
|
async scrape() {
|
||||||
|
const jobs = await parseStartupSi(await fetchHtml(LIST_URL));
|
||||||
|
if (jobs.length === 0) throw new Error("no job cards found (site layout changed?)");
|
||||||
|
const cutoff = Date.now() - maxAgeDays * 86400_000;
|
||||||
|
return jobs.filter((j) => !j.postedAt || new Date(j.postedAt).getTime() >= cutoff);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseStartupSi(html: string): Promise<ScrapedJob[]> {
|
||||||
|
type Draft = { id: string; title: string; date: string; href?: string };
|
||||||
|
const drafts: Draft[] = [];
|
||||||
|
let cur: Draft | undefined;
|
||||||
|
let lastHref = "";
|
||||||
|
|
||||||
|
await new HTMLRewriter()
|
||||||
|
.on(".advertisment-card", {
|
||||||
|
element() {
|
||||||
|
cur = { id: "", title: "", date: "" };
|
||||||
|
drafts.push(cur);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on(".advertisment-card .right > .title", {
|
||||||
|
text(t) {
|
||||||
|
if (cur) cur.title += t.text;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on(".advertisment-card .title button", {
|
||||||
|
element(el) {
|
||||||
|
if (cur) cur.id = (el.getAttribute("data-target") ?? "").replace(/^#description/, "");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on(".advertisment-card .published-info b", {
|
||||||
|
text(t) {
|
||||||
|
if (cur) cur.date += t.text;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The "preberi več" (read more) link is an <a> wrapping span.read-more; descriptions can contain other links.
|
||||||
|
.on(".advertisment-card a", {
|
||||||
|
element(el) {
|
||||||
|
lastHref = el.getAttribute("href") ?? "";
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on(".advertisment-card span.read-more", {
|
||||||
|
element() {
|
||||||
|
if (cur) cur.href = lastHref;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.transform(new Response(html))
|
||||||
|
.text();
|
||||||
|
|
||||||
|
return drafts
|
||||||
|
.filter((d) => d.id && clean(d.title))
|
||||||
|
.map((d) => ({
|
||||||
|
externalId: d.id,
|
||||||
|
title: clean(d.title),
|
||||||
|
url: normalizeUrl(d.href ?? ""),
|
||||||
|
postedAt: parseSlDate(clean(d.date)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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
|
||||||
|
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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.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>`}
|
||||||
|
${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="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", "starred", "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; --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; --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; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
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("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">
|
||||||
|
<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. 🎉" : filter === "starred" ? "No starred 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();
|
||||||
|
}
|
||||||
|
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")));
|
||||||
|
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));
|
||||||
|
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({
|
||||||
|
hostname: HOST,
|
||||||
|
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/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 };
|
||||||
|
setSeen(Number(req.params.id), !!seen);
|
||||||
|
return Response.json(counts());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch: () => new Response("Not found", { status: 404 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const shownHost = HOST.includes(":") ? `[${HOST}]` : HOST;
|
||||||
|
console.log(`Job board running at http://${shownHost}:${PORT} (scraping every ${SCRAPE_INTERVAL_HOURS}h)`);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user