diff --git a/CLAUDE.md b/CLAUDE.md index 6344ab9..4a04885 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,9 +50,10 @@ plans/ # LinkedIn post drafts - **Basel Institute on Governance** — `baselgovernance.org/about/careers`. Each vacancy is a linked PDF, so there's no HTML description to language-check. The `rounded-3xl` card class is what separates vacancy tiles from the annual-report PDF link. - These boards use UN-flavoured titles ("Information Management Officer", "ICT Specialist") that `SOFTWARE_TITLE_RE` alone drops, so `INTL_TECH_RE` widens the net **for these sources only**. Boards this small can afford the noise; missing a rare opening can't. - **itjobs.ch** is swept Switzerland-wide (no `location` param) — the board is small, ~210 jobs over 7 pages, and locking it to Basel returned only ~9. It 404s one page past the end instead of serving an empty list. Each item embeds its full `description`, so the DE/FR check runs in-process via `isEnglishDescription` rather than costing a page fetch per job. Use `job_location.name` for location — it's the English form; the bare `location` field is German. -- **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV. +- **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV. Writes both `search` (the query/source that found the job) and `location` (the posting's own location, as parsed off its card by `fetch.ts`/`fetch-other.ts` — no extra page fetch) to the CSV. Migrates old CSVs on startup: adds `source` if missing, then `location` (backfilled `""` for rows written before the column existed). - **`recheck.ts`** — calls `fetchJobs(..., { raw: true })` to bypass `SOFTWARE_TITLE_RE`, then lists every new job that *would* be filtered out and prints word-frequency suggestions. - **`daily.ts`** — the notification layer on top of `index.ts`. Snapshots the CSV's links, shells out to `bun fetch`, then diffs to find what's new. Survivors are filtered through `matchesProfile` and grouped by area (Basel / Remote / Zürich / Bern) so the message scans on a phone. `results/sent.txt` is written **only after a successful send**, so a Telegram failure retries next run instead of silently losing jobs. A non-zero exit from `bun fetch` sends a failure notice rather than failing quietly. + - The area grouping matches against the CSV's `location` column (the posting's real location) first, falling back to `search` (the query/source label) only when `location` is empty — i.e. for rows written before the `location` column existed. This fixed two real mislabels: a LinkedIn "Bern" sweep can surface a job actually based in Lausanne (search label ≠ real location), and `fetch-other.ts` sources (job-room.ch, itjobs.ch, stellen-basel.ch, datacareer.ch) write the source name into `search` instead of a city, which used to dump their jobs into "⚪️ Other" regardless of where they actually were. - **`profile.ts`** — `matchesProfile(title, company, minScore = 4)`. Hard rejects run first (Microsoft stack, Java/C++/SAP, life-science, non-software functions, blocked companies Jobgether and ALLPS), then `SIGNALS` scores stack and seniority terms from `cv/polo.md`. This is a **second, stricter gate than the fetch filters**: `fetch.ts` decides what lands in the CSV, `profile.ts` decides what's worth a phone buzz. Widen `SOFTWARE_TITLE_RE` to catch more in the CSV; tune `SIGNALS`/`minScore` to change what gets sent. - **`telegram.ts`** — reads `.env` directly (no dotenv dep). Accepts `TELEGRAM_BOT` or `TELEGRAM_TOKEN` for the token. Splits at 3900 chars on line boundaries (Telegram hard-caps at 4096) and sleeps 400 ms between parts to stay under the rate limit. Messages are HTML — run titles/companies through `escapeHtml`. @@ -150,6 +151,8 @@ Read `cv/polo.md` for the full profile. Key signals: Links use just the job ID as anchor text: `[4192954199](https://www.linkedin.com/jobs/view/4192954199/)` +Some employers (Optiml, LocalBini seen repeatedly) repost the same role under a new job ID every few weeks rather than keeping one listing open. When several rows share the same title + company, show only the most recent job ID and drop the older duplicates from the table — they're the same opening, not new leads. + ## Workflow: "find jobs in X city / remote / custom search" 1. Write a temp script in `scripts/` with the right `SearchConfig` (city, workType, custom keywords). diff --git a/daily.ts b/daily.ts index 402e018..545dc64 100644 --- a/daily.ts +++ b/daily.ts @@ -11,7 +11,7 @@ const SENT = "./results/sent.txt"; // links already pushed — never resend const dry = process.argv.includes("--dry-run"); const resend = process.argv.includes("--resend"); -type Row = { fetched: string; posted: string; search: string; title: string; company: string; link: string; source: string }; +type Row = { fetched: string; posted: string; search: string; title: string; company: string; link: string; source: string; location: string }; function parse(text: string): Row[] { return text @@ -20,7 +20,7 @@ function parse(text: string): Row[] { .filter(Boolean) .map((l) => { const c = l.replace(/^"|"$/g, "").split('","'); - return { fetched: c[0], posted: c[1], search: c[2], title: c[3], company: c[4], link: c[5], source: c[6] }; + return { fetched: c[0], posted: c[1], search: c[2], title: c[3], company: c[4], link: c[5], source: c[6], location: c[7] ?? "" }; }); } @@ -60,13 +60,20 @@ if (!matched.length) { process.exit(0); } -// Group by area so the message scans quickly on a phone. +// Group by area so the message scans quickly on a phone. Match against the +// posting's own location first — `search` is just the query that found it, +// and recruiters/multi-site companies often post under a different area than +// the job actually sits in (e.g. a "Bern" LinkedIn sweep surfacing a Lausanne +// posting). Fall back to `search` only when no location was captured (rows +// written before the location column existed). const area = (r: Row) => { + const loc = r.location.toLowerCase(); const s = r.search.toLowerCase(); - if (s.includes("remote")) return "🌍 Remote"; - if (s.includes("basel")) return "🟢 Basel"; - if (s.includes("zürich") || s.includes("zurich")) return "🔵 Zürich"; - if (s.includes("bern")) return "🟣 Bern"; + const text = loc || s; + if (s.includes("remote") || loc.includes("remote")) return "🌍 Remote"; + if (text.includes("basel")) return "🟢 Basel"; + if (text.includes("zürich") || text.includes("zurich")) return "🔵 Zürich"; + if (text.includes("bern")) return "🟣 Bern"; return "⚪️ Other"; }; const order = ["🟢 Basel", "🌍 Remote", "🔵 Zürich", "🟣 Bern", "⚪️ Other"]; diff --git a/index.ts b/index.ts index ccfed76..0357580 100644 --- a/index.ts +++ b/index.ts @@ -5,16 +5,19 @@ import { fetchDataCareer, fetchStellenBasel, fetchItJobsCh, fetchJobRoom, fetchB const CSV = "./results/jobs.csv"; const OLD_HEADER = "date_found,posted_at,search,title,company,link"; -const HEADER = "date_found,posted_at,search,title,company,link,source"; +const HEADER_NO_LOCATION = "date_found,posted_at,search,title,company,link,source"; +const HEADER = "date_found,posted_at,search,title,company,link,source,location"; -// Migrate CSV: add source column if the old header is present +// Migrate CSV: add source column if the old header is present, then location +// (the posting's real location, as parsed off its own card — distinct from +// `search`, which is just the query that found it. See CLAUDE.md.) const csvExists = await Bun.file(CSV).exists(); if (csvExists) { const raw = await Bun.file(CSV).text(); if (raw.startsWith(OLD_HEADER + "\n") || raw === OLD_HEADER) { const lines = raw.split("\n"); const migrated = [ - HEADER, + HEADER_NO_LOCATION, ...lines.slice(1).map((l) => (l.trim() ? l + ',"linkedin.com"' : "")), ] .join("\n") @@ -22,6 +25,20 @@ if (csvExists) { await Bun.write(CSV, migrated); } } +{ + const raw = await Bun.file(CSV).text().catch(() => ""); + if (raw.startsWith(HEADER_NO_LOCATION + "\n") || raw === HEADER_NO_LOCATION) { + const lines = raw.split("\n"); + const migrated = [ + HEADER, + // Unknown for pre-existing rows — daily.ts falls back to `search` when empty. + ...lines.slice(1).map((l) => (l.trim() ? l + ',""' : "")), + ] + .join("\n") + .replace(/\n+$/, "") + "\n"; + await Bun.write(CSV, migrated); + } +} // Load existing links for deduplication const existingLinks = new Set(); @@ -68,7 +85,7 @@ for (const config of searches) { console.log("ok"); existingLinks.add(job.link); newRows.push( - [date, job.postedAt, config.label, job.title, job.company, job.link, "linkedin.com"] + [date, job.postedAt, config.label, job.title, job.company, job.link, "linkedin.com", job.location] .map(csvCell) .join(",") ); @@ -117,7 +134,7 @@ for (const { label, fetch: fetchFn } of extraSources) { } existingLinks.add(job.link); newRows.push( - [date, job.postedAt, job.search, job.title, job.company, job.link, job.source] + [date, job.postedAt, job.search, job.title, job.company, job.link, job.source, job.location] .map(csvCell) .join(",") );