// Daily digest: run the full fetch, then push whatever is new to Telegram. // // bun daily fetch, then send the new jobs // bun daily --dry-run fetch, print the message instead of sending // bun daily --resend skip fetching, re-send today's jobs from the CSV import { send, escapeHtml } from "./telegram.ts"; import { matchesProfile } from "./profile.ts"; import { parseCsvLine } from "./csv.ts"; const CSV = "./results/jobs.csv"; const SENT = "./results/sent.txt"; // links already pushed — never resend const LANG_DROPPED = "./results/.lang-dropped.json"; // written by index.ts — jobs dropped for German/French content never reach the CSV 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; location: string }; function parse(text: string): Row[] { return text .split("\n") .slice(1) .filter(Boolean) .map((l) => { const c = parseCsvLine(l); 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] ?? "" }; }); } const before = new Set( (await Bun.file(CSV).exists()) ? parse(await Bun.file(CSV).text()).map((r) => r.link) : [] ); if (!resend) { const proc = Bun.spawn(["bun", "fetch"], { stdout: "inherit", stderr: "inherit" }); const code = await proc.exited; if (code !== 0) { // Still worth telling him the run broke rather than failing silently. if (!dry) await send(`⚠️ linkme: bun fetch exited ${code} — no digest today.`); process.exit(code); } } const all = parse(await Bun.file(CSV).text()); // Never send the same ad twice — survives re-runs, --resend and re-fetches. const sent = new Set( (await Bun.file(SENT).exists()) ? (await Bun.file(SENT).text()).split("\n").map((l) => l.trim()).filter(Boolean) : [] ); const dateArg = process.argv.find((a) => a.startsWith("--date="))?.slice(7); const digestDate = dateArg ?? new Date().toISOString().slice(0, 10); const fresh = resend ? all.filter((r) => r.fetched === digestDate) : all.filter((r) => !before.has(r.link)); // Jobs dropped for German/French content never make it into the CSV, so this // is the only place their count survives — shown as a count-only category, // never listed, since there's no Row to show for them. const langDropped = await Bun.file(LANG_DROPPED) .json() .then((j) => (j.date === digestDate ? j.count : 0)) .catch(() => 0); // Only leads that actually match the CV (profile.ts) and haven't been sent before. const preDedupe = fresh.filter((r) => !sent.has(r.link) && matchesProfile(r.title, r.company).ok); // Sources can surface the same underlying opening twice under different // links/providers — e.g. englishjobsearch.ch listing the same posting on two // canton pages with the company rendered as "Swiss AviationSoftware" on one // and "Swiss-AS" on the other. Exact title+company catches the common case // (same employer, identical spelling); the title-only pass catches spelling // drift too, but only for titles specific enough (5+ words) that a // coincidental match between two genuinely different postings is implausible. const normTitle = (title: string) => title .replace(/[([][^)\]]*[)\]]/g, "") // drop "(all genders)", "(m/w/d)", "(a)", "80-100%" noise .replace(/[^a-z0-9 ]/gi, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); const normCompany = (company: string) => company .replace(/\b(ag|gmbh|ltd|llc|inc|sa|se|plc|co)\b\.?/gi, "") .replace(/[^a-z0-9]/gi, "") .toLowerCase(); const seenExact = new Set(); const seenTitleOnly = new Set(); // Seed both sets from everything already sent (any past day), keyed the same // way — not just today's batch. Without this, a job re-dedupes only against // jobs sent in the *same run*: LinkedIn reposts the same opening under a new // job ID every few weeks (Optiml, LocalBini, hexagonab, nicollcurtin, etc. — // confirmed repeats in sent.txt across different days), each repost lands // under a fresh link that passes the `sent.has(link)` check untouched, and it // got renotified as if new. `all` (the full CSV) still has every past row, so // each previously-sent link resolves back to a title+company to seed with. for (const r of all) { if (!sent.has(r.link)) continue; const t = normTitle(r.title); seenExact.add(`${t}|${normCompany(r.company)}`); if (t.split(" ").filter(Boolean).length >= 5) seenTitleOnly.add(t); } const matched = preDedupe.filter((r) => { const t = normTitle(r.title); const exactKey = `${t}|${normCompany(r.company)}`; if (seenExact.has(exactKey)) return false; const specific = t.split(" ").filter(Boolean).length >= 5; if (specific && seenTitleOnly.has(t)) return false; seenExact.add(exactKey); if (specific) seenTitleOnly.add(t); return true; }); console.log(`${fresh.length} new · ${matched.length} match profile · ${fresh.length - matched.length} filtered or already sent`); if (!matched.length) { console.log("nothing to send"); process.exit(0); } // 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(); 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"]; const groups = new Map(); for (const r of matched) { const k = area(r); (groups.get(k) ?? groups.set(k, []).get(k)!).push(r); } const date = new Date().toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }); const lines = [`${matched.length} new job${matched.length === 1 ? "" : "s"} · ${date}`]; for (const k of order) { const rows = groups.get(k); if (!rows?.length) continue; lines.push(`\n${k} (${rows.length})`); for (const r of rows) { const id = r.link.match(/(\d{6,})/)?.[1]; const label = id ? `${id}` : `link`; lines.push(`• ${escapeHtml(r.title)} — ${escapeHtml(r.company)} ${label}`); } } if (langDropped > 0) { lines.push(`\n🔴 Ignored (German/French) (${langDropped})`); } const msg = lines.join("\n"); if (dry) { console.log(msg); } else { await send(msg); // Recorded only after a successful send, so a failure retries instead of losing jobs. await Bun.write(SENT, [...sent, ...matched.map((r) => r.link)].join("\n") + "\n"); console.log(`sent ${matched.length} jobs to Telegram`); }