feat: add sciCORE and Swisscom as job sources

sciCORE now runs inside bun fetch/daily via fetchSciCore instead of its
own standalone launchd watcher. Swisscom's Workday career site is added
as a new source (fetchSwisscom), pulled via its public CXS JSON API.

Also fixes a false positive in requiresGerman(): Swisscom's stock line
"proficiency in German is considered an advantage" was being flagged as
requiring German because the noun "proficiency" sat closer to the
mention than the real optional marker "advantage". A local-predicate
override now lets the words immediately following "German is/are ..."
decide the verdict instead of losing to raw character distance.
This commit is contained in:
2026-09-18 11:49:49 +02:00
parent 9cbc525cf5
commit 288b8bdcd1
4 changed files with 177 additions and 15 deletions
+139
View File
@@ -219,6 +219,101 @@ export async function fetchItJobsCh(): Promise<Job[]> {
return allJobs;
}
// swisscom.com — Swisscom's own Workday career site. Small board (~47 openings,
// Switzerland-wide), so no keyword sweep — pull the full listing and filter
// locally, like itjobs.ch. The listing page itself is a bare JS shell (no jobs
// in the raw HTML); postings live behind the standard Workday CXS JSON API:
// POST /wday/cxs/swisscom/SwisscomExternalCareers/jobs {appliedFacets, limit, offset, searchText}
// `total` on the first page (offset 0) is authoritative — on later pages it
// comes back as 0 (a quirk of this tenant), and an offset past the true total
// silently wraps around and re-serves page 1 instead of an empty list. So the
// loop's stopping condition is the first page's total, never a later one's.
// Listing rows carry no description, so each title-filtered candidate costs
// one extra fetch against the job detail endpoint (`/job{externalPath}`),
// which returns the full HTML description — the DE/FR + requiresGerman check
// runs on that here, same as job-room.ch/jobbasel.ch.
const SWISSCOM_API_BASE = "https://swisscom.wd103.myworkdayjobs.com/wday/cxs/swisscom/SwisscomExternalCareers";
interface SwisscomListing {
title: string;
externalPath: string;
postedOn: string;
bulletFields: string[];
}
function parseSwisscomPostedOn(text: string): string {
const iso = (d: Date) => d.toISOString().slice(0, 10);
const now = new Date();
if (/posted today/i.test(text)) return iso(now);
if (/posted yesterday/i.test(text)) {
const d = new Date(now);
d.setDate(d.getDate() - 1);
return iso(d);
}
const m = text.match(/posted (\d+) days? ago/i);
if (m) {
const d = new Date(now);
d.setDate(d.getDate() - Number(m[1]));
return iso(d);
}
return ""; // "Posted 30+ Days Ago" — no exact date available
}
export async function fetchSwisscom(): Promise<Job[]> {
const listings: SwisscomListing[] = [];
let total = Infinity;
for (let offset = 0; offset < total; offset += 20) {
if (offset > 0) await Bun.sleep(400);
const res = await fetch(`${SWISSCOM_API_BASE}/jobs`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ appliedFacets: {}, limit: 20, offset, searchText: "" }),
});
if (!res.ok) { console.log(` Swisscom … failed (${res.status})`); break; }
const data = (await res.json()) as { total: number; jobPostings: SwisscomListing[] };
if (offset === 0) total = data.total;
if (!data.jobPostings.length) break;
listings.push(...data.jobPostings);
}
const jobs: Job[] = [];
let kept = 0;
let langDropped = 0;
for (const item of listings) {
const title = decodeHtml(item.title);
if (!applyFilters(title)) continue;
if (MS_STACK_RE.test(title)) continue;
await Bun.sleep(300);
const detailRes = await fetch(`${SWISSCOM_API_BASE}${item.externalPath}`, { headers: HEADERS });
if (!detailRes.ok) continue;
const detail = (await detailRes.json()) as {
jobPostingInfo?: { location?: string; jobDescription?: string; externalUrl?: string };
};
const info = detail.jobPostingInfo;
if (!isViableDescription(info?.jobDescription).ok) { langDropped++; continue; }
jobs.push({
id: `sc-${item.bulletFields?.[0] ?? item.externalPath}`,
title,
company: "Swisscom",
location: info?.location ?? "Switzerland",
postedAt: parseSwisscomPostedOn(item.postedOn),
link: info?.externalUrl ?? `https://swisscom.wd103.myworkdayjobs.com/SwisscomExternalCareers${item.externalPath}`,
search: "swisscom.com",
source: "swisscom.com",
contentChecked: true, // isViableDescription already ran on the real description
});
kept++;
}
console.log(` Swisscom … ${listings.length} items, ${kept} kept, ${langDropped} non-English`);
return jobs;
}
// job-room.ch — the federal employment service board (RAV / Arbeitsvermittlung).
// Roles under the Stellenmeldepflicht must be posted here before anywhere else,
// so it often carries a job days ahead of LinkedIn.
@@ -538,6 +633,50 @@ export async function fetchBaselIntlOrgs(): Promise<Job[]> {
return jobs;
}
// sciCORE's open-positions page (formerly polled separately by
// scripts/scicore-watch.sh) is a plain-text announcement list, not a job
// board: each <li> is either an active posting linked to careers.unibas.ch
// or a closed/in-review notice with no link at all. Skip the latter — "the
// interview process is still ongoing, but the submission deadline has
// passed" is the site's own wording for "not accepting applications" — since
// they're not something Rafael could still apply to.
export async function fetchSciCore(): Promise<Job[]> {
const jobs: Job[] = [];
const url = "https://scicore.unibas.ch/about-scicore/open-positions/";
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.log(` sciCORE … failed (${res.status})`); return jobs; }
const html = await res.text();
const sectionM = html.match(/<h1>Join the team!<\/h1>([\s\S]*?)<footer/i);
const items = sectionM ? [...sectionM[1].matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)] : [];
let kept = 0;
for (const [, raw] of items) {
if (/deadline has passed|interview process is (?:still\s+)?ongoing/i.test(raw)) continue;
const linkM = raw.match(/<a\s[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
const title = decodeHtml((linkM ? linkM[2] : raw).replace(/<[^>]+>/g, " "));
if (!title || !applyFilters(title)) continue;
const link = linkM ? linkM[1] : url;
jobs.push({
id: `scicore-${link.replace(/\W+/g, "").slice(-24)}`,
title,
company: "sciCORE",
location: "Basel, Switzerland",
postedAt: "",
link,
search: "scicore.unibas.ch",
source: "scicore.unibas.ch",
contentChecked: true, // listing is site-authored English; no separate description to fetch
});
kept++;
}
console.log(` sciCORE … ${items.length} items, ${kept} kept`);
return jobs;
}
// jobbasel.ch — the page shell is a React/Vite SPA (no jobs in the raw HTML
// on the homepage), but the search results route is server-rendered: fetching
// `/job/suche-{slug}` returns full HTML with a `window.__PRELOADED_STATE__`