171 lines
7.2 KiB
TypeScript
171 lines
7.2 KiB
TypeScript
// 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(`⚠️ <b>linkme</b>: <code>bun fetch</code> 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<string>();
|
|
const seenTitleOnly = new Set<string>();
|
|
|
|
// 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";
|
|
if (text.includes("luzern") || text.includes("lucerne")) return "🟠 Luzern";
|
|
return "⚪️ Other";
|
|
};
|
|
const order = ["🟢 Basel", "🌍 Remote", "🔵 Zürich", "🟣 Bern", "🟠 Luzern", "⚪️ Other"];
|
|
const groups = new Map<string, Row[]>();
|
|
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 = [`<b>${matched.length} new job${matched.length === 1 ? "" : "s"}</b> · ${date}`];
|
|
for (const k of order) {
|
|
const rows = groups.get(k);
|
|
if (!rows?.length) continue;
|
|
lines.push(`\n<b>${k}</b> (${rows.length})`);
|
|
for (const r of rows) {
|
|
const id = r.link.match(/(\d{6,})/)?.[1];
|
|
const label = id ? `<a href="${r.link}">${id}</a>` : `<a href="${r.link}">link</a>`;
|
|
lines.push(`• ${escapeHtml(r.title)} — ${escapeHtml(r.company)} ${label}`);
|
|
}
|
|
}
|
|
if (langDropped > 0) {
|
|
lines.push(`\n<b>🔴 Ignored (German/French)</b> (${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`);
|
|
}
|