feat: send matching jobs to telegram twice daily
telegram.ts
- Bot API wrapper: HTML escaping, 4096-char chunking, rate-limit
spacing, and --setup to discover the chat ID from the bot's inbox.
Reads TELEGRAM_BOT (or TELEGRAM_TOKEN) and TELEGRAM_CHAT_ID.
profile.ts
- matchesProfile() scores a title against cv/polo.md. Hard rejects for
Microsoft stack, Java/C++/SAP, aggregators (Jobgether, ALLPS),
non-software functions and life-science domains; then weighted
signals for the core stack, platform/DevOps, data, AI/ML, cloud and
seniority. Threshold is 4 so short bullseye titles ("Data Engineer",
"DevOps Engineer") pass, and forward-deployed is weighted to clear it
on its own.
daily.ts
- Runs the fetch, diffs jobs.csv, keeps only profile matches, groups by
area and pushes to Telegram. results/sent.txt records every link
actually sent, written only after a successful send, so a failure
retries instead of losing jobs and an ad is never sent twice.
- --dry-run previews, --resend --date= re-sends a past day.
- A failed fetch reports itself rather than going quiet.
Scheduled at 08:00 and 18:00 by a launchd agent
(com.rafapolo.linkme-daily). launchd runs missed jobs on wake, and the
digest is diff-based, so downtime delays delivery without losing jobs.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// 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";
|
||||
|
||||
const CSV = "./results/jobs.csv";
|
||||
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 };
|
||||
|
||||
function parse(text: string): Row[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.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] };
|
||||
});
|
||||
}
|
||||
|
||||
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 fresh = resend
|
||||
? all.filter((r) => r.fetched === (dateArg ?? new Date().toISOString().slice(0, 10)))
|
||||
: all.filter((r) => !before.has(r.link));
|
||||
|
||||
// Only leads that actually match the CV (profile.ts) and haven't been sent before.
|
||||
const matched = fresh.filter((r) => !sent.has(r.link) && matchesProfile(r.title, r.company).ok);
|
||||
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.
|
||||
const area = (r: Row) => {
|
||||
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";
|
||||
return "⚪️ Other";
|
||||
};
|
||||
const order = ["🟢 Basel", "🌍 Remote", "🔵 Zürich", "🟣 Bern", "⚪️ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
Reference in New Issue
Block a user