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:
+139
@@ -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__`
|
||||
|
||||
Reference in New Issue
Block a user