Files
linkme/fetch.ts
T
rodado e3368ce161 fix: stop title filters from truncating a LinkedIn search early
fetchJobs ended pagination on `newOnPage === 0`, but newOnPage counts
jobs that survived the title filters. A page whose 25 cards all happened
to be German/Java/junior/non-software read as end-of-results and killed
the whole search, discarding every later page. The end condition is now
new raw data-entity-urn card ids, pre-filter, so what gets filtered can
no longer decide where the crawl stops.

Also add a name-only "Basel (broad)" search alongside the three geoId
ones. LinkedIn's guest search serves a different slice per query shape:
the Basel-city geoId query exhausts at 76 unique cards and never returns
some genuinely Basel-Stadt postings a name-resolved query does. PAS01's
"Full-Stack Software Engineer (60-80%)" in Basel (4461764782) was
invisible to all six Basel searches and reached the digest only because
the Bern query — also name-only — happened to return it. Union across
the Basel searches: 126 unique.
2026-09-05 18:04:36 +02:00

492 lines
23 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.
import type { SearchConfig, WorkType, JobType, TimePosted, Seniority } from "./config.ts";
export interface Job {
id: string;
title: string;
company: string;
location: string;
postedAt: string;
link: string;
search: string;
source: string;
/**
* Set when the source already language-checked the real job description.
* `isEnglishUrl` counts DE/FR words across the whole page, so boards with
* German site chrome (itjobs.ch, stellen-basel.ch) fail it even when the
* posting itself is English — skip the re-check for those.
*/
contentChecked?: boolean;
}
const WORK_TYPE_MAP: Record<WorkType, string> = {
onsite: "1",
remote: "2",
hybrid: "3",
};
const JOB_TYPE_MAP: Record<JobType, string> = {
"full-time": "F",
"part-time": "P",
contract: "C",
temporary: "T",
internship: "I",
};
const TIME_MAP: Record<TimePosted, string> = {
day: "r86400",
week: "r604800",
month: "r2592000",
};
const SENIORITY_MAP: Record<Seniority, string> = {
internship: "1",
entry: "2",
associate: "3",
"mid-senior": "4",
director: "5",
executive: "6",
};
// Detect non-English (German / French / Swiss-German) job titles.
//
// German signals:
// - gender markers: (m/w/d), (w/m/d), (d/w/m), (mwd), (M/W/D) etc.
// but NOT (m/f/d) / (f/m/d) which are used in English international posts
// - Swiss neutral suffix: standalone (a) before % or end of string
// - German umlauts / ß
// - /in word-ending (Pflegehelfer/in, Köchin/Koch)
// - German-only vocabulary
// - Swiss German: EFZ (apprenticeship cert), Schweiz
//
// French signals:
// - gender markers: (H/F), (F/H), (h/f)
// - French-only accented chars: é è ê ë à â ç î ï ô ù û ÿ œ æ
// - French-only vocabulary
export const NON_ENGLISH_RE = new RegExp(
[
// German gender markers (w = weiblich; must not match m/f/d)
String.raw`\(m\/w[^)]*\)`,
String.raw`\(w\/m[^)]*\)`,
String.raw`\(d\/w[^)]*\)`,
String.raw`\(mwd\)`,
// m/w without parens (e.g. "Developer m/w")
String.raw`\bm\/w\b`,
// Swiss neutral (a) suffix: " (a)" before %, digits, or end
String.raw`\s\(a\)(\s*[\d%]|\s*$)`,
// German/Swiss umlauts and ß
String.raw`[äöüÄÖÜß]`,
// Gender-neutral suffix, in all four notations Swiss postings use
// (Mitarbeiter/in, Entwickler:in, Engineer*in, Mitarbeiter_in) — excludes
// m/f, n/a, etc. Also covers the adjectival-noun form, which appends "r"
// instead of "in" (Verantwortliche:r, Angestellte:r, Studierende:r) since
// the base word already ends in "-e". ZHAW's "Senior Full Stack
// Entwickler:in 80-100 %" used ":in" and slipped past both this rule
// (slash-only, at the time) and the word list ("Entwickler" wasn't on it
// either); the audit below found the *in/_in/:r variants were equally
// unhandled (OT-System Engineer*in, Applikationsverantwortliche:r).
String.raw`[a-zäöü][\/:*_](in|r)\b`,
// German-only words (case-insensitive handled by flag)
String.raw`\b(Deine|Mitarbeiter|Pflegehelfer|Köchin|Koch EFZ|Kellner|Sozialpädagog|Lehrperson|Produktionsmitarbeiter|Produktionsplaner|Geschäftsführer|Spontanbewerbung|Arztsekretär|Physiotherapeut|Empfangsmitarbeiter|Automobilmechatroniker|Verkäufer|Projektleiter|Crewmitarbeiter|Crewmember|Systemgastronomie|Studienteilnehmende|Teilnehmende|Bauingenieur|Tiefbau|Hochbau|Elektroingenieur|Maschinenbau|gesucht|EFZ|Schweiz|Filiale|Ingenieur|Spezialist|Perspektive|Stellvertretung|Leitung|Beratung|Entwicklung|Betreuung|Verwaltung|unbefristet|befristet)\b`,
// German occupational suffixes — matched with NO leading \b, deliberately,
// because German compounds glue words together with no separator
// (Aussendienstmitarbeiter, Vertriebsingenieur, Applikationsverantwortliche)
// and a leading \b would only ever match the bare word. Found by auditing
// jobs.csv for titles containing these strings that the rest of the regex
// still let through: "Software Berater Fokus Lohn/HR in Bern",
// "Leiter Business Applikationen, CRM & Analytics", "Leiterin Trading
// Data, Integration & Reporting", "ICT System Engineer / Informatiker
// Systemtechnik". All are German-only strings with no English overlap.
String.raw`(mitarbeiter|ingenieur|berater|leiterin|leiter|informatiker|verantwortliche)\b`,
// German prepositions/articles/conjunctions in title context (must appear
// as whole words) — none of these exist as English words, so they're safe
// unambiguous markers. Added after "Software Engineer auf dem Weg zum
// Data / AI Engineer" (ti&m, via job-room.ch) sailed through: none of the
// German-word list above matched, and the job-room description was too
// short (~1 hit) to clear the separate ≥6 DE/FR word-count threshold
// either. "und"/"oder" added after the same audit found "System Engineer
// Microsoft und OnPrem Infrastruktur" slipping through — even the most
// basic German conjunctions weren't covered.
String.raw`\bmit\b|\bauf\b|\bzum\b|\bzur\b|\bvom\b|\bfür\b|\bbeim\b|\bund\b|\boder\b`,
// French gender markers
String.raw`\(H\/F\)|\(F\/H\)|\(h\/f\)|\(f\/h\)`,
// French-only accented characters
String.raw`[éèêëàâçîïôùûÿœæ]`,
// French-only words
String.raw`\b(Responsable|Directeur|Directrice|Adjoint|Chargé|Ingénieur|Technicien|Gestionnaire|Coordinateur|Analyste)\b`,
].join("|"),
"i"
);
const HEADERS = {
"User-Agent":
"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",
"Accept-Language": "en-US,en;q=0.9",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
};
export const JUNIOR_TITLE_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i;
export const JAVA_TITLE_RE = /\bjava\b/i;
// Roles whose *domain* is biology / chemistry / the lab bench. Rafael has no
// life-science background, so these need expertise he can't offer.
//
// This filters on the ROLE, never the employer: software, platform, data and
// infrastructure jobs at Roche, Novartis, Swiss TPH and sciCORE stay in scope —
// Basel pharma is a core target market. Only the science itself is excluded.
export const BIO_DOMAIN_RE = new RegExp(
[
// bench / domain science job families
String.raw`\b(bio)?statistician\b`,
String.raw`\bbioinformatic\w*`,
String.raw`\bcomputational\s+(?:biolog\w*|chemist\w*)`,
String.raw`\b(?:cheminformatic\w*|chemometric\w*)`,
String.raw`\b(?:micro)?biolog(?:y|ist)\b`,
String.raw`\b(?:bio)?chemist(?:ry|s)?\b`,
String.raw`\bpharmacolog\w*|\btoxicolog\w*|\bimmunolog\w*|\bvirolog\w*|\bepidemiolog\w*`,
String.raw`\bgenomic\w*|\bproteomic\w*|\bmetabolomic\w*|\bmolecular\s+(?:biolog\w*|patholog\w*)`,
// manufacturing / QC / regulated-lab functions
String.raw`\bGMP\b|\bGxP\b|\bMSAT\b|\bQC\s+analyst\b`,
String.raw`\b(?:bio)?process\s+(?:development|engineer)|\bupstream\b|\bdownstream\s+process`,
String.raw`\bassay\b|\bbioassay\b|\bwet.?lab\b|\blaboratory\s+(?:technician|assistant|manager)`,
// clinical / trial work
String.raw`\bclinical\s+(?:trial|research|data\s+manager|operations)`,
String.raw`\bbiomarker\b|\bpharmacovigilance\b|\bregulatory\s+affairs\b`,
// life-science domains that make any title (Scientist, Engineer, Analyst) a domain role
String.raw`\bbiologic\w*|\bpatholog\w*|\bhistolog\w*`,
String.raw`\bdrug\s+(?:discovery|design|development)\b`,
String.raw`\breal.world\s+(?:data|evidence)\b`,
].join("|"),
"i"
);
export const SOFTWARE_TITLE_RE = /\b(software|developer|architect|devops|devsecops|platform|data|backend|frontend|fullstack|full.stack|tech\s*lead|technical|typescript|python|rails|node\.?js|ai\b|machine\s*learning|cloud|security|analytics|sre|reliability|infrastructure|mlops|solutions?|consultant|applications?\s*engineer|system[\s-]*engineer|informatics|scientific\s*(?:software|comput\w*|programmer)|research\s*(?:software|engineer)|hpc|forward\s*deployed|field\s*engineer|customer\s*engineer|sales\s*engineer|pre.?sales|integration\s*engineer|api\s*engineer|founding\s*engineer|product\s*engineer|automation\s*engineer|director|product\s*manager|engineering\s*manager|it\s*manager|it\s*project|staff\s*engineer|principal\s*engineer|cto|vp\s*eng)\b/i;
const DE_CONTENT_RE = /\b(Sie|Ihre?|Ihnen|Wir|wir|und\s|für|mit\s|von\s|bei\s|das\s|ist\s|sind\s|haben\s|werden\s|können|suchen|bieten|Kenntnisse|Erfahrung|Anforderungen|Aufgaben|Stellenbeschreibung|Stellenangebot|Bewerbung|Ausbildung|Berufserfahrung|Teamarbeit|Vollzeit|Teilzeit|unbefristet|Abschluss|gesucht|Deutsch|Englisch|Schweizer)\b/g;
const FR_CONTENT_RE = /\b(vous|nous|votre|notre|pour\s|avec\s|profil|poste|emploi|entreprise|compétences|expérience|formation|équipe|rejoindre|recherchons|offrons|missions|candidature|CDI|CDD|temps\s+plein|temps\s+partiel)\b/g;
// A bare umlaut/ß is a near-certain German signal, and unlike the ≥6 word-count
// threshold above it, it can catch even a short or truncated snippet — one
// German sentence almost always has one ("für", "können", "möchten"...). Added
// 2026-09-05 after an Expleo (Bern) posting slipped through: a concurrent fetch
// under load returned a version of the description with too few DE_CONTENT_RE
// word matches to trip ≥6, even though the full text (72 matches) was entirely
// German. "Zürich"/"Zürcher" is excluded — the one umlaut word that legitimately
// shows up constantly in genuinely English-language Swiss postings, just as a
// place name.
const UMLAUT_RE = /[äöüÄÖÜß]/;
export function hasNonEnglishUmlaut(text: string): boolean {
return UMLAUT_RE.test(text.replace(/zürich|zürcher/gi, ""));
}
// --- German-requirement detection -----------------------------------------
//
// Distinct from isEnglishContent: that drops postings *written* in German.
// This drops postings written in English that *require* German above B1 —
// e.g. Helvetia's "Good knowledge of German and English", which reads as a
// clean English posting and sails through every other filter.
//
// Rafael is German B1 (TELC). B1 or "basic"/"a plus" is fine; B2+ is not.
/** Any mention of the language, in either language. */
const GERMAN_MENTION_RE = /\b(german|deutsch\w*|allemand)\b/gi;
/** Within a mention's window: signals the requirement is optional. */
const GERMAN_OPTIONAL_RE =
/\b(?:a\s+plus|an?\s+advantage|advantageous|beneficial|nice[\s-]to[\s-]have|desirable|preferred|welcome|bonus|not\s+required|no\s+german|optional|willing(?:ness)?\s+to\s+learn|basic|beginner|elementary|grundkenntnisse|von\s+vorteil|wünschenswert|A1|A2|B1)\b/i;
/** Within a mention's window: signals it is genuinely required at B2+. */
const GERMAN_REQUIRED_RE =
/\b(?:required|require[sd]?|must|mandatory|essential|necessary|fluen\w*|proficien\w*|excellent|strong|very\s+good|good\s+(?:knowledge|command|level)|native|mother\s*tongue|business\s+level|negotiat\w*|B2|C1|C2|verhandlungssicher\w*|fliessend\w*|fließend\w*|muttersprach\w*|sehr\s+gut\w*|gut\w*\s+Deutschkenntnisse|Deutschkenntnisse|voraussetzung|zwingend|erforderlich)\b/i;
/**
* Spec-sheet style language line ("Languages: German & English"), common on
* contractor postings. There is no adjective for the nearest-marker logic to
* bind to, so the label itself counts as a requirement — but it goes through
* the same nearest-marker comparison, so "Language: English and German (bonus)"
* still loses to the "bonus" sitting closer to the mention.
*/
const GERMAN_SPEC_LABEL_RE = /\b(?:languages?|sprachen|sprache)\s*[::]/i;
/** Title-level marker — no description fetch needed. */
export const GERMAN_TITLE_RE =
/\bgerman[\s-]speaking\b|\bdeutschsprachig\w*|\(\s*de\s*\)\s*speaking|\bgerman\s+(?:required|mandatory)\b/i;
/**
* True when the text demands German above B1.
*
* Presence of a marker in the window isn't enough — an unrelated "a plus" from
* a neighbouring bullet would cancel a real requirement (Helvetia's posting
* puts "Infrastructure skills are a plus" ~95 chars before "Good knowledge of
* German"). So for each mention we take whichever marker sits *closest*.
*/
export function requiresGerman(text: string): boolean {
const flat = text.replace(/\s+/g, " ");
const optional = new RegExp(GERMAN_OPTIONAL_RE.source, "gi");
const required = new RegExp(
`${GERMAN_REQUIRED_RE.source}|${GERMAN_SPEC_LABEL_RE.source}`,
"gi"
);
// A marker binds to the German mention only if no OTHER language name sits
// between them: in "Fluent in English; German ... advantageous", "Fluent"
// qualifies English, not German.
const OTHER_LANG = /\b(english|french|italian|spanish|portuguese|anglais|français|englisch|franz|italienisch)\b/i;
// …except when that other language is *coordinated* with German, where the
// marker governs both: "Excellent communication skills in both English and
// German" (Karakun) and "Fluency in English and German" (J. Safra Sarasin)
// are hard requirements that the bare rule above waved through — the entire
// reason 24 basel-jobs.ch postings mentioning German were caught 0 times.
// Deliberately excludes "and/or" and a bare "or": "Very good English and/or
// German" (BBF) is satisfied by English alone, so it must stay optional.
const CONJOINED = /\b(?:and|und|sowie|as\s+well\s+as|&)\s*$/i;
const ANDOR = /\b(?:and\s*\/\s*or|or)\s*$/i;
const nearest = (re: RegExp, window: string, at: number, bind: boolean): number => {
let best = Infinity;
for (const m of window.matchAll(re)) {
const i = m.index ?? 0;
const between = i < at ? window.slice(i + m[0].length, at) : window.slice(at, i);
// "…English and German" keeps the marker; "…English; German" or
// "…English and/or German" hands it to the other language.
const conjoined = CONJOINED.test(between) && !ANDOR.test(between);
if (bind && OTHER_LANG.test(between) && !conjoined) continue;
const d = Math.abs(i - at);
if (d < best) best = d;
}
return best;
};
for (const m of flat.matchAll(GERMAN_MENTION_RE)) {
const i = m.index ?? 0;
const from = Math.max(0, i - 110);
const window = flat.slice(from, i + 110);
const at = i - from; // mention's offset inside the window
const req = nearest(required, window, at, true);
const opt = nearest(optional, window, at, false);
if (req === Infinity) continue; // nothing demanding it
if (req < opt) return true; // the closer marker wins
}
return false;
}
export async function fetchDescription(id: string): Promise<string | null> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
await Bun.sleep(350 * (attempt + 1));
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
const html = await res.text();
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
if (m) return m[1].replace(/<[^>]+>/g, " ");
if (!res.ok || attempt < 2) continue;
return html.replace(/<[^>]+>/g, " ");
} catch {
// transient — retry
}
}
return null;
}
/** English-language AND not demanding German above B1. */
export async function isViableContent(id: string): Promise<{ ok: boolean; reason?: string }> {
const text = await fetchDescription(id);
if (text === null) return { ok: true }; // keep when we genuinely can't read it
if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" };
const de = (text.match(DE_CONTENT_RE) ?? []).length;
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
if (de >= 6 || fr >= 6) return { ok: false, reason: "non-English" };
if (requiresGerman(text)) return { ok: false, reason: "requires German > B1" };
return { ok: true };
}
export async function isEnglishContent(id: string): Promise<boolean> {
// Retry when the description block is missing — LinkedIn serves a stub page
// under rate limiting, and the English UI chrome would otherwise pass as a
// false "English" result. Back off and try again before falling back.
for (let attempt = 0; attempt < 3; attempt++) {
try {
await Bun.sleep(350 * (attempt + 1));
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
const html = await res.text();
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
if (!m) {
if (!res.ok || attempt < 2) continue; // rate-limited / stub — retry
// Last resort: scan the whole stripped page (still catches DE/FR body text)
const text = html.replace(/<[^>]+>/g, " ");
if (hasNonEnglishUmlaut(text)) return false;
const de = (text.match(DE_CONTENT_RE) ?? []).length;
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
return de < 6 && fr < 6;
}
const text = m[1].replace(/<[^>]+>/g, " ");
if (hasNonEnglishUmlaut(text)) return false;
const de = (text.match(DE_CONTENT_RE) ?? []).length;
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
return de < 6 && fr < 6;
} catch {
// transient fetch error — retry
}
}
return true; // keep when we genuinely can't fetch the description
}
export async function isEnglishUrl(url: string): Promise<boolean> {
return (await isViableUrl(url)).ok;
}
/** As isViableContent, but for an arbitrary job URL (non-LinkedIn sources). */
export async function isViableUrl(url: string): Promise<{ ok: boolean; reason?: string }> {
try {
await Bun.sleep(350);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) return { ok: true };
const text = (await res.text()).replace(/<[^>]+>/g, " ");
if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" };
const de = (text.match(DE_CONTENT_RE) ?? []).length;
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
if (de >= 6 || fr >= 6) return { ok: false, reason: "non-English" };
if (requiresGerman(text)) return { ok: false, reason: "requires German > B1" };
return { ok: true };
} catch {
return { ok: true };
}
}
const PAGE_SIZE = 25;
const MAX_PAGES = 6;
export function buildUrl(config: SearchConfig): string {
if (config.url) return config.url;
const params = new URLSearchParams();
params.set("keywords", config.keywords ?? "");
if (config.location) params.set("location", config.location);
if (config.geoId) params.set("geoId", config.geoId);
if (config.timePosted) params.set("f_TPR", TIME_MAP[config.timePosted]);
if (config.workType?.length)
params.set("f_WT", config.workType.map((w) => WORK_TYPE_MAP[w]).join(","));
if (config.jobType?.length)
params.set("f_JT", config.jobType.map((j) => JOB_TYPE_MAP[j]).join(","));
if (config.seniority?.length)
params.set("f_E", config.seniority.map((s) => SENIORITY_MAP[s]).join(","));
return `https://www.linkedin.com/jobs/search/?${params.toString()}`;
}
function clean(raw: string): string {
return raw
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&ndash;/g, "–")
.replace(/&mdash;/g, "—")
.replace(/\s+/g, " ")
.trim();
}
function parseJobs(html: string, label: string): Job[] {
const jobs: Job[] = [];
// Locate every card boundary by job ID position
const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g;
const positions: { id: string; start: number }[] = [];
let m;
while ((m = idRe.exec(html)) !== null) {
positions.push({ id: m[1], start: m.index });
}
for (let i = 0; i < positions.length; i++) {
const { id, start } = positions[i];
// Slice only up to the next card (or 3 000 chars max) so field regexes can't bleed across cards
const card = html.slice(start, Math.min(positions[i + 1]?.start ?? Infinity, start + 3000));
const titleM = card.match(/<h3 class="base-search-card__title">\s*([\s\S]*?)\s*<\/h3>/);
if (!titleM) continue;
const companyM = card.match(/<h4 class="base-search-card__subtitle">[\s\S]{0,300}?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const timeM = card.match(/<time[^>]+datetime="([^"]+)"/);
const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
const jobLocation = locM ? locM[1].trim() : "";
// Drop jobs not physically in Switzerland — LinkedIn ignores geoId without auth
if (jobLocation && !jobLocation.includes("Switzerland")) continue;
const title = clean(titleM[1]);
const company = companyM ? clean(companyM[1]) : "";
const postedAt = timeM ? timeM[1] : "";
// Prefer the location printed on the card over the search label — recruiters
// and multi-site companies often post under a different area than the job sits in.
jobs.push({ id, title, company, location: jobLocation || label, postedAt, link: `https://www.linkedin.com/jobs/view/${id}/`, search: label, source: "linkedin.com" });
}
return jobs;
}
export async function fetchJobs(config: SearchConfig, { raw = false } = {}): Promise<Job[]> {
const baseUrl = buildUrl(config);
const allJobs: Job[] = [];
const seen = new Set<string>();
const seenCards = new Set<string>(); // every card id served, pre-filter — drives the end condition
for (let page = 0; page < MAX_PAGES; page++) {
if (page > 0) await Bun.sleep(400);
const url = `${baseUrl}&start=${page * PAGE_SIZE}`;
process.stdout.write(` page ${page + 1} … `);
// 429s became common once index.ts started running several searches
// concurrently — a transient rate-limit, not a real end-of-results
// signal, so retry with backoff instead of silently truncating the
// search (losing every page after this one).
let res = await fetch(url, { headers: HEADERS });
for (let attempt = 0; res.status === 429 && attempt < 3; attempt++) {
await Bun.sleep(1000 * 2 ** attempt); // 1s, 2s, 4s
res = await fetch(url, { headers: HEADERS });
}
if (!res.ok) {
console.error(`failed (${res.status})`);
break;
}
const html = await res.text();
// End-of-results is decided on the raw card ids LinkedIn served, never on
// how many survived the title filters. A page whose cards all happen to be
// German/Java/junior/non-software read as "no new jobs" and truncated the
// whole search, discarding every later page — pages that do carry keepers.
let newCards = 0;
for (const m of html.matchAll(/data-entity-urn="urn:li:jobPosting:(\d+)"/g)) {
if (!seenCards.has(m[1])) {
seenCards.add(m[1]);
newCards++;
}
}
const jobs = parseJobs(html, config.label)
.filter((j) => !NON_ENGLISH_RE.test(j.title))
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
.filter((j) => !JAVA_TITLE_RE.test(j.title))
.filter((j) => !BIO_DOMAIN_RE.test(j.title))
.filter((j) => !GERMAN_TITLE_RE.test(j.title))
.filter((j) => raw || SOFTWARE_TITLE_RE.test(j.title));
let newOnPage = 0;
for (const job of jobs) {
if (!seen.has(job.id)) {
seen.add(job.id);
allJobs.push(job);
newOnPage++;
}
}
console.log(`${jobs.length} jobs, ${newOnPage} new (${newCards} new cards)`);
if (newCards === 0) break;
}
return allJobs;
}