// Daily watch for the "Delivery Lead – Agentic Solutions" opening (Biomedical // Research Digital & Data, likely Novartis) that MRP-Global posted on LinkedIn // as job 4473851998 on 2026-10-03. Looks for the same role resurfacing in // another form — another agency, a direct Novartis posting, a retitled // repost — and reports when the original stops accepting applications. // // Runs from launchd (com.rafapolo.linkme-watch-delivery-lead, daily 10:00). // Pushes to Telegram only when something new turns up; everything else goes to // results/watch-delivery-lead.log. State lives in results/.watch-delivery-lead.json, // so a hit is reported once. `--dry-run` prints instead of sending. import { fetchDescription } from "../fetch.ts"; import { send, escapeHtml } from "../telegram.ts"; const ORIGINAL_ID = "4473851998"; const STATE_FILE = "results/.watch-delivery-lead.json"; const DRY_RUN = process.argv.includes("--dry-run"); const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; // Searches cast wide; SIGNATURE decides. A repost of this opening reuses the // client's wording, so a candidate needs one client-specific phrase (STRONG) // plus one role phrase. "agentic" + "delivery lead" alone matched Eraneos and // AWS on the first dry run (2026-10-05) — generic consultancy wording. const LINKEDIN_QUERIES = [ "delivery lead agentic", "delivery lead AI data pharma", "delivery lead biomedical", "agentic solutions delivery", "biomedical research digital data", "delivery manager agentic", ]; const NOVARTIS_QUERIES = ["delivery lead", "delivery manager", "agentic", "biomedical research digital"]; const SIGNATURE: [string, RegExp][] = [ ["agentic", /\bagentic\b/i], ["Biomedical Research", /biomedical\s+research/i], ["Digital & Data", /digital\s*(?:&|and)\s*data/i], ["delivery lead", /delivery\s+lead/i], ["Palantir", /\bpalantir\b/i], ["Databricks", /\bdatabricks\b/i], ["RWD", /\bRWD\b|real[-\s]world\s+data|clinical\s+trials?/i], ]; const STRONG = new Set(["Biomedical Research", "Digital & Data", "Palantir", "RWD"]); const ROLE = new Set(["agentic", "delivery lead"]); const isMatch = (m: string[]) => m.some((x) => STRONG.has(x)) && m.some((x) => ROLE.has(x)); const TITLE_RE = /deliver|agentic|programme?\s+lead|project\s+lead/i; interface Hit { key: string; title: string; company: string; link: string; matched: string[] } interface State { seen: string[]; reported: string[]; originalClosed?: boolean } async function loadState(): Promise { const f = Bun.file(STATE_FILE); return (await f.exists()) ? await f.json() : { seen: [], reported: [] }; } function signature(text: string): string[] { return SIGNATURE.filter(([, re]) => re.test(text)).map(([name]) => name); } const strip = (s: string) => s.replace(/<[^>]+>/g, " ").replace(/&/g, "&").replace(/'|'/g, "'").replace(/\s+/g, " ").trim(); async function get(url: string): Promise { const res = await fetch(url, { headers: { "User-Agent": UA } }); if (!res.ok) throw new Error(`${res.status} ${url}`); return res.text(); } async function searchLinkedIn(state: State): Promise { const cards = new Map(); for (const q of LINKEDIN_QUERIES) { // Past month, Switzerland-wide. One page is enough: these queries are narrow. const url = `https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=${encodeURIComponent(q)}&location=Switzerland&f_TPR=r2592000&start=0`; try { const html = await get(url); for (const c of html.split("
  • ").slice(1)) { const id = c.match(/jobs\/view\/[^"]*?(\d{8,})/)?.[1]; const title = strip(c.match(/base-search-card__title[^>]*>([\s\S]*?)<\/h3>/)?.[1] ?? ""); const company = strip(c.match(/base-search-card__subtitle[^>]*>([\s\S]*?)<\/h4>/)?.[1] ?? ""); if (id && id !== ORIGINAL_ID && TITLE_RE.test(title)) cards.set(id, { title, company }); } } catch (e) { console.log(` linkedin "${q}": ${e}`); } await Bun.sleep(1500); } const hits: Hit[] = []; for (const [id, { title, company }] of cards) { const key = `li-${id}`; if (state.seen.includes(key)) continue; const desc = await fetchDescription(id); if (desc === null) continue; // unreadable — retry tomorrow rather than mark seen state.seen.push(key); const matched = signature(`${title} ${desc}`); console.log(` li ${id} ${title} @ ${company}: [${matched.join(", ")}]`); if (isMatch(matched)) { hits.push({ key, title, company, link: `https://www.linkedin.com/jobs/view/${id}/`, matched }); } } return hits; } async function searchNovartis(state: State): Promise { const found = new Map(); for (const q of NOVARTIS_QUERIES) { const url = `https://www.novartis.com/careers/career-search?search_api_fulltext=${encodeURIComponent(q)}&country%5B%5D=LOC_CH`; try { const html = await get(url); for (const m of html.matchAll(/href="(\/careers\/career-search\/job\/details\/(req-\d+)[^"]*)"[^>]*>([\s\S]*?)<\/a>/g)) { const title = strip(m[3]); if (TITLE_RE.test(title)) found.set(m[2], { title, path: m[1] }); } } catch (e) { console.log(` novartis "${q}": ${e}`); } } const hits: Hit[] = []; for (const [req, { title, path }] of found) { const key = `nv-${req}`; if (state.seen.includes(key)) continue; const link = `https://www.novartis.com${path}`; let desc: string; try { desc = strip(await get(link)); } catch { continue; } state.seen.push(key); const matched = signature(`${title} ${desc}`); console.log(` nv ${req} ${title}: [${matched.join(", ")}]`); if (isMatch(matched)) hits.push({ key, title, company: "Novartis", link, matched }); } return hits; } async function originalClosed(): Promise { try { const html = await get(`https://www.linkedin.com/jobs/view/${ORIGINAL_ID}/`); return /no longer accepting applications/i.test(html); } catch { return null; } } const state = await loadState(); console.log(`[${new Date().toISOString()}] watch-delivery-lead`); const hits = [...(await searchLinkedIn(state)), ...(await searchNovartis(state))].filter( (h) => !state.reported.includes(h.key), ); const closed = await originalClosed(); const lines: string[] = []; for (const h of hits) { lines.push( `• ${escapeHtml(h.title)} — ${escapeHtml(h.company)}\n ${h.link}\n matched: ${escapeHtml(h.matched.join(", "))}`, ); } if (closed === true && !state.originalClosed) { lines.push(`• The original MRP-Global posting (${ORIGINAL_ID}) is no longer accepting applications.`); } if (lines.length) { const msg = `🔎 Delivery Lead – Agentic Solutions watch\n\n${lines.join("\n\n")}`; if (DRY_RUN) console.log(msg); else await send(msg); } // Only mark as reported after a successful send, same rule as daily.ts. state.reported.push(...hits.map((h) => h.key)); if (closed === true) state.originalClosed = true; if (!DRY_RUN) await Bun.write(STATE_FILE, JSON.stringify(state, null, 2)); console.log(` ${hits.length} new hit(s), original closed: ${closed}`);