Add content-level language filter, recheck command, expanded keywords

- isEnglishContent() fetches each job page and drops descriptions with ≥6 German or French indicator words; called for every new job in index.ts before writing to CSV
- New `bun recheck` command (recheck.ts) runs searches in raw mode, lists filtered-out jobs, and prints word-frequency suggestions for SOFTWARE_TITLE_RE
- Expanded KEYWORDS and SOFTWARE_TITLE_RE to cover ML/AI, system/applications engineer, director, informatics, and more title variants
- Added `raw` flag to fetchJobs() to bypass the software-role filter
- Add CLAUDE.md with project workflow docs; add CV files and city temp scripts
This commit is contained in:
2026-05-12 13:42:27 +02:00
parent ffe2b4467a
commit bcb6ae5ea3
19 changed files with 1201 additions and 14 deletions
+24 -4
View File
@@ -68,7 +68,7 @@ const NON_ENGLISH_RE = new RegExp(
// /in word-ending (e.g. Mitarbeiter/in) — excludes m/f, n/a, etc.
String.raw`[a-zäöü]\/in\b`,
// German-only words (case-insensitive handled by flag)
String.raw`\b(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)\b`,
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)\b`,
// French gender markers
String.raw`\(H\/F\)|\(F\/H\)|\(h\/f\)|\(f\/h\)`,
// French-only accented characters
@@ -89,7 +89,27 @@ const HEADERS = {
const JUNIOR_TITLE_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i;
const SOFTWARE_TITLE_RE = /\b(software|developer|architect|devops|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|solutions|consultant|product\s*manager|engineering\s*manager|it\s*manager|it\s*project|staff\s*engineer|principal\s*engineer|cto|vp\s*eng)\b/i;
export const SOFTWARE_TITLE_RE = /\b(software|developer|architect|devops|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|solutions?|consultant|applications?\s*engineer|system[\s-]*engineer|informatics|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;
export async function isEnglishContent(id: string): Promise<boolean> {
try {
await Bun.sleep(350);
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>/);
const text = m
? m[1].replace(/<[^>]+>/g, " ")
: html.replace(/<[^>]+>/g, " ").slice(0, 5000);
const de = (text.match(DE_CONTENT_RE) ?? []).length;
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
return de < 6 && fr < 6;
} catch {
return true; // keep on fetch error
}
}
const PAGE_SIZE = 25;
const MAX_PAGES = 6;
@@ -156,7 +176,7 @@ function parseJobs(html: string, label: string): Job[] {
return jobs;
}
export async function fetchJobs(config: SearchConfig): Promise<Job[]> {
export async function fetchJobs(config: SearchConfig, { raw = false } = {}): Promise<Job[]> {
const baseUrl = buildUrl(config);
const allJobs: Job[] = [];
const seen = new Set<string>();
@@ -176,7 +196,7 @@ export async function fetchJobs(config: SearchConfig): Promise<Job[]> {
const jobs = parseJobs(html, config.label)
.filter((j) => !NON_ENGLISH_RE.test(j.title))
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
.filter((j) => SOFTWARE_TITLE_RE.test(j.title));
.filter((j) => raw || SOFTWARE_TITLE_RE.test(j.title));
let newOnPage = 0;
for (const job of jobs) {