Files
linkme/scripts/watch-delivery-lead.ts
T
rodado 74f8df0b15 chore: move cv and job applications to private rafapolo/job, add delivery-lead watcher
cv/ and jobs/ become symlinks into ../rafapolo/job (gitignored there).
scripts/watch-delivery-lead.ts runs daily via launchd and alerts on Telegram
when the MRP-Global agentic Delivery Lead role resurfaces or closes.
2026-10-05 19:22:07 +02:00

178 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<State> {
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(/&amp;/g, "&").replace(/&#39;|&#x27;/g, "'").replace(/\s+/g, " ").trim();
async function get(url: string): Promise<string> {
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<Hit[]> {
const cards = new Map<string, { title: string; company: string }>();
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("<li>").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<Hit[]> {
const found = new Map<string, { title: string; path: string }>();
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<boolean | null> {
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(
`• <b>${escapeHtml(h.title)}</b> — ${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 = `🔎 <b>Delivery Lead – Agentic Solutions watch</b>\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}`);