From 3f5360f44c6cc086a1d585d1fea640eb117389a0 Mon Sep 17 00:00:00 2001 From: rafapolo Date: Thu, 27 Aug 2026 09:03:00 +0200 Subject: [PATCH] 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. --- daily.ts | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +-- profile.ts | 69 +++++++++++++++++++++++++++++++++++ telegram.ts | 81 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 daily.ts create mode 100644 profile.ts create mode 100644 telegram.ts diff --git a/daily.ts b/daily.ts new file mode 100644 index 0000000..402e018 --- /dev/null +++ b/daily.ts @@ -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(`⚠️ 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 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(); +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}`); + } +} + +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`); +} diff --git a/package.json b/package.json index a7a1e63..db45cc9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "scripts": { "fetch": "bun run index.ts", - "recheck": "bun run recheck.ts" + "recheck": "bun run recheck.ts", + "daily": "bun run daily.ts" }, "type": "module" -} +} \ No newline at end of file diff --git a/profile.ts b/profile.ts new file mode 100644 index 0000000..0a7e3ea --- /dev/null +++ b/profile.ts @@ -0,0 +1,69 @@ +// Does a job match Rafael's profile? Title/company-level scoring, used to keep +// the Telegram digest to real leads instead of everything the scraper finds. +// +// Hard rejects encode standing decisions: +// - Microsoft stack (.NET/C#/Azure-first/Windows/SharePoint/Power Platform) +// - Jobgether (aggregator, not an employer) +// - ALLPS (postings require German despite passing the English filter) +// - life-science domains (also filtered at fetch time by BIO_DOMAIN_RE) +// - non-software functions (sales, HR, finance, marketing, support) + +export const MS_STACK_RE = + /\.net\b|\bc#|\basp\.net\b|sharepoint|dynamics\s*365|power\s*(?:platform|bi|apps|automate)|\bsccm\b|\bmecm\b|intune|autopilot|windows[\s\/]|\bm365\b|microsoft\s*365|\bazure\b/i; + +export const WRONG_STACK_RE = + /\bjava\b(?!script)|\bspring\s*boot\b|\bc\+\+\b|\bembedded\b|\bcobol\b|\babap\b|\bsap\b|\bdelphi\b/i; + +export const BLOCKED_COMPANY_RE = /\bjobgether\b|\ballps\b/i; + +export const BIO_RE = + /\bbio(?:informatic|statistic|logic)\w*|\bpatholog\w*|\bgenomic\w*|\bproteomic\w*|\bGMP\b|\bMSAT\b|\bdrug\s+(?:discovery|design)\b|\bchemist(?:ry)?\b|\bclinical\s+(?:trial|research)\b|\bbiomarker\b|\bassay\b/i; + +export const NON_SOFTWARE_RE = + /\b(?:sales\s+(?:director|manager|representative)|account\s+(?:executive|manager)|business\s+development|recruit(?:er|ment)|talent\s+acquisition|marketing|copywriter|\bHR\b|human\s+resources|accountant|controller|auditor|receptionist|customer\s+(?:support|service)\s+agent|help\s?desk|service\s+desk|1st\s+level)\b/i; + +/** Core stack and domains from cv/polo.md — each hit adds to the score. */ +const SIGNALS: [RegExp, number, string][] = [ + [/\bpython\b/i, 3, "Python"], + [/\btypescript\b|\bnode\.?js\b|\bjavascript\b/i, 3, "TS/Node"], + [/\bruby\b|\brails\b/i, 4, "Ruby/Rails"], + [/\breact\b/i, 2, "React"], + [/\bplatform\s+engineer|\binfrastructure\s+engineer|\bsre\b|site\s+reliability/i, 4, "platform/SRE"], + [/\bdevops\b|\bdevsecops\b|kubernetes|\bk8s\b|terraform|\bci\/cd\b/i, 4, "DevOps"], + [/\bdata\s+(?:engineer|platform|pipeline)/i, 4, "data eng"], + [/\bai\b|\bml\b|machine\s+learning|\bllm\b|\bgenai\b|\brag\b|mlops/i, 3, "AI/ML"], + [/\bcloud\b|\baws\b|solutions?\s+architect/i, 3, "cloud/AWS"], + [/\bsecurity\b/i, 2, "security"], + [/\bbackend\b|\bfull.?stack\b|\bapi\b|\bintegration\b/i, 2, "backend/API"], + [/forward\s+deployed|\bfield\s+engineer|solutions?\s+engineer|customer\s+engineer/i, 4, "forward-deployed"], + [/\bsoftware\s+engineer\b|\bdeveloper\b/i, 2, "software"], + // seniority — he's Staff-level; junior titles are already dropped upstream + [/\b(?:senior|staff|principal|lead|head\s+of|architect)\b/i, 2, "senior"], +]; + +export interface Match { + ok: boolean; + score: number; + reasons: string[]; + rejectedBy?: string; +} + +export function matchesProfile(title: string, company = "", minScore = 4): Match { + const t = `${title}`; + if (BLOCKED_COMPANY_RE.test(company) || BLOCKED_COMPANY_RE.test(t)) + return { ok: false, score: 0, reasons: [], rejectedBy: "blocked company" }; + if (MS_STACK_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "Microsoft stack" }; + if (WRONG_STACK_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "wrong stack (Java/C++/SAP)" }; + if (BIO_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "life-science domain" }; + if (NON_SOFTWARE_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "non-software role" }; + + let score = 0; + const reasons: string[] = []; + for (const [re, pts, label] of SIGNALS) { + if (re.test(t)) { + score += pts; + reasons.push(label); + } + } + return { ok: score >= minScore, score, reasons, rejectedBy: score >= minScore ? undefined : "weak match" }; +} diff --git a/telegram.ts b/telegram.ts new file mode 100644 index 0000000..b68fcf4 --- /dev/null +++ b/telegram.ts @@ -0,0 +1,81 @@ +// Telegram delivery for the daily digest. +// +// Setup: +// 1. Message @BotFather on Telegram → /newbot → copy the token +// 2. Put TELEGRAM_BOT= in .env (TELEGRAM_TOKEN also accepted) +// 3. Send your bot any message ("hi"), then run: bun run telegram.ts --setup +// That prints your chat ID; put it in .env as TELEGRAM_CHAT_ID= +const env = Object.fromEntries( + (await Bun.file(".env").text()) + .split("\n") + .filter((l) => l.includes("=") && !l.trimStart().startsWith("#")) + .map((l) => { + const i = l.indexOf("="); + return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; + }) +); + +export const TOKEN = env.TELEGRAM_BOT ?? env.TELEGRAM_TOKEN ?? ""; +export const CHAT_ID = env.TELEGRAM_CHAT_ID ?? env.TELEGRAM_CHAT ?? ""; + +const API = (m: string) => `https://api.telegram.org/bot${TOKEN}/${m}`; + +/** Telegram hard-caps a message at 4096 chars; split on line boundaries. */ +function chunk(text: string, limit = 3900): string[] { + const out: string[] = []; + let buf = ""; + for (const line of text.split("\n")) { + if (buf.length + line.length + 1 > limit) { + out.push(buf); + buf = ""; + } + buf += (buf ? "\n" : "") + line; + } + if (buf) out.push(buf); + return out; +} + +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +export async function send(text: string): Promise { + if (!TOKEN || !CHAT_ID) { + throw new Error("TELEGRAM_BOT / TELEGRAM_CHAT_ID missing from .env — see telegram.ts header"); + } + for (const part of chunk(text)) { + const res = await fetch(API("sendMessage"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: CHAT_ID, + text: part, + parse_mode: "HTML", + link_preview_options: { is_disabled: true }, + }), + }); + if (!res.ok) throw new Error(`Telegram ${res.status}: ${await res.text()}`); + await Bun.sleep(400); // stay under the ~1 msg/sec limit + } +} + +// --setup: discover the chat ID from recent messages to the bot +if (import.meta.main && process.argv.includes("--setup")) { + if (!TOKEN) { + console.error("Add TELEGRAM_BOT= to .env first (get one from @BotFather)."); + process.exit(1); + } + const res = await fetch(API("getUpdates")); + const data = (await res.json()) as any; + const chats = new Map(); + for (const u of data.result ?? []) { + const c = u.message?.chat ?? u.channel_post?.chat; + if (c) chats.set(String(c.id), c.username ?? c.title ?? c.first_name ?? ""); + } + if (!chats.size) { + console.log("No messages found. Send your bot a message in Telegram, then re-run this."); + process.exit(1); + } + console.log("Add this to .env:\n"); + for (const [id, name] of chats) console.log(`TELEGRAM_CHAT_ID=${id} # ${name}`); +}