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.
178 lines
7.2 KiB
TypeScript
178 lines
7.2 KiB
TypeScript
// 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(/&/g, "&").replace(/'|'/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}`);
|