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.
This commit is contained in:
2026-10-05 19:22:07 +02:00
parent dfba00ae91
commit 74f8df0b15
12 changed files with 189 additions and 810 deletions
+177
View File
@@ -0,0 +1,177 @@
// 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}`);