Broaden the search beyond engineer/developer titles and stop three
classes of bad result from ever reaching the CSV.
config.ts
- PLATFORM_KEYWORDS (architect, SRE, infra, security, MLOps),
RESEARCH_KEYWORDS (research/scientific software, HPC, data platform)
and FIELD_KEYWORDS (forward-deployed, customer/sales, founding).
Separate OR-strings, not folded into KEYWORDS: LinkedIn ranks a
focused query far better than one 50-term disjunction.
- sweep() generates one search per area per family. 7 searches -> 17.
fetch.ts
- BIO_DOMAIN_RE drops life-science roles (bioinformatics, biostats,
GMP/MSAT, genomics, pathology, drug discovery). Filters on the role,
never the employer: software jobs at Roche/Novartis/sciCORE stay.
- requiresGerman() drops English postings demanding German above B1.
Two rules, both from real false results: the nearest marker wins
("Infrastructure skills are a plus" sat 95 chars before "Good
knowledge of German"), and an intervening language name breaks the
binding ("Fluent in English; German and/or French advantageous").
- GERMAN_TITLE_RE catches "(German-speaking)" without a page fetch.
- isViableContent/isViableUrl run the DE/FR and German-requirement
checks on a single fetch; isEnglishContent/isEnglishUrl kept for
recheck.ts and temp scripts.
- SOFTWARE_TITLE_RE widened so the new families survive it.
index.ts
- both content-check paths use the combined check and report which
filter dropped a job.
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { searches } from "./config.ts";
|
|
import { fetchJobs, isViableContent, isViableUrl } from "./fetch.ts";
|
|
import { fetchDataCareer, fetchStellenBasel, fetchItJobsCh, fetchJobRoom, fetchBaselIntlOrgs } from "./fetch-other.ts";
|
|
|
|
const CSV = "./results/jobs.csv";
|
|
const OLD_HEADER = "date_found,posted_at,search,title,company,link";
|
|
const HEADER = "date_found,posted_at,search,title,company,link,source";
|
|
|
|
// Migrate CSV: add source column if the old header is present
|
|
const csvExists = await Bun.file(CSV).exists();
|
|
if (csvExists) {
|
|
const raw = await Bun.file(CSV).text();
|
|
if (raw.startsWith(OLD_HEADER + "\n") || raw === OLD_HEADER) {
|
|
const lines = raw.split("\n");
|
|
const migrated = [
|
|
HEADER,
|
|
...lines.slice(1).map((l) => (l.trim() ? l + ',"linkedin.com"' : "")),
|
|
]
|
|
.join("\n")
|
|
.replace(/\n+$/, "") + "\n";
|
|
await Bun.write(CSV, migrated);
|
|
}
|
|
}
|
|
|
|
// Load existing links for deduplication
|
|
const existingLinks = new Set<string>();
|
|
try {
|
|
const text = await Bun.file(CSV).text();
|
|
for (const line of text.split("\n").slice(1)) {
|
|
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
|
|
if (url) existingLinks.add(url);
|
|
}
|
|
} catch {
|
|
// first run
|
|
}
|
|
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
const newRows: string[] = [];
|
|
|
|
function csvCell(val: string): string {
|
|
return `"${val.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
// LinkedIn searches
|
|
for (const config of searches) {
|
|
console.log(`\n[${config.label}]`);
|
|
const jobs = await fetchJobs(config);
|
|
|
|
if (jobs.length === 0) {
|
|
console.log(" No jobs found.");
|
|
continue;
|
|
}
|
|
|
|
let added = 0;
|
|
let langDropped = 0;
|
|
for (const job of jobs) {
|
|
if (!existingLinks.has(job.link)) {
|
|
process.stdout.write(` checking content: ${job.title} … `);
|
|
// One fetch, two checks: written in German/French, or written in English
|
|
// but demanding German above B1.
|
|
const viable = await isViableContent(job.id);
|
|
if (!viable.ok) {
|
|
console.log(`dropped (${viable.reason})`);
|
|
langDropped++;
|
|
continue;
|
|
}
|
|
console.log("ok");
|
|
existingLinks.add(job.link);
|
|
newRows.push(
|
|
[date, job.postedAt, config.label, job.title, job.company, job.link, "linkedin.com"]
|
|
.map(csvCell)
|
|
.join(",")
|
|
);
|
|
added++;
|
|
}
|
|
}
|
|
console.log(` ${jobs.length} found, ${added} new, ${langDropped} non-English dropped`);
|
|
}
|
|
|
|
const extraSources: Array<{ label: string; fetch: () => Promise<ReturnType<typeof fetchDataCareer>> }> = [
|
|
{ label: "datacareer.ch", fetch: fetchDataCareer },
|
|
{ label: "stellen-basel.ch", fetch: fetchStellenBasel },
|
|
{ label: "itjobs.ch", fetch: fetchItJobsCh },
|
|
{ label: "job-room.ch", fetch: fetchJobRoom },
|
|
{ label: "basel intl orgs", fetch: fetchBaselIntlOrgs },
|
|
];
|
|
|
|
for (const { label, fetch: fetchFn } of extraSources) {
|
|
console.log(`\n[${label}]`);
|
|
let jobs;
|
|
try {
|
|
jobs = await fetchFn();
|
|
} catch (e) {
|
|
console.error(` error: ${e}`);
|
|
continue;
|
|
}
|
|
|
|
if (jobs.length === 0) {
|
|
console.log(" No jobs found.");
|
|
continue;
|
|
}
|
|
|
|
let added = 0;
|
|
let langDropped = 0;
|
|
for (const job of jobs) {
|
|
if (!existingLinks.has(job.link)) {
|
|
if (!job.contentChecked) {
|
|
process.stdout.write(` checking content: ${job.title} … `);
|
|
const viable = await isViableUrl(job.link);
|
|
if (!viable.ok) {
|
|
console.log(`dropped (${viable.reason})`);
|
|
langDropped++;
|
|
continue;
|
|
}
|
|
console.log("ok");
|
|
}
|
|
existingLinks.add(job.link);
|
|
newRows.push(
|
|
[date, job.postedAt, job.search, job.title, job.company, job.link, job.source]
|
|
.map(csvCell)
|
|
.join(",")
|
|
);
|
|
added++;
|
|
}
|
|
}
|
|
console.log(` ${jobs.length} found, ${added} new, ${langDropped} non-English dropped`);
|
|
}
|
|
|
|
if (newRows.length === 0) {
|
|
console.log("\nNo new jobs.");
|
|
process.exit(0);
|
|
}
|
|
|
|
// Append to CSV
|
|
const existing = await Bun.file(CSV).exists();
|
|
const append = existing
|
|
? await Bun.file(CSV).text()
|
|
: HEADER + "\n";
|
|
|
|
await Bun.write(CSV, append + newRows.join("\n") + "\n");
|
|
|
|
console.log(`\n${newRows.length} new jobs → ${CSV}`);
|