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}`);
+}