Files
linkme/index.ts
T
rodado 2de9809ea9 fix: group telegram digest by posting's real location, not search label
fetch.ts already parses each job card's own location, but index.ts
discarded it and stored only the search-area label in the CSV, so
daily.ts's telegram grouping used the query that found a job instead
of where it actually is (e.g. a Bern search surfacing a Lausanne job).
Now the location is written to a new CSV column and used for grouping,
with a one-time migration for existing rows.
2026-08-28 19:56:40 +02:00

161 lines
4.8 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_NO_LOCATION = "date_found,posted_at,search,title,company,link,source";
const HEADER = "date_found,posted_at,search,title,company,link,source,location";
// Migrate CSV: add source column if the old header is present, then location
// (the posting's real location, as parsed off its own card — distinct from
// `search`, which is just the query that found it. See CLAUDE.md.)
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_NO_LOCATION,
...lines.slice(1).map((l) => (l.trim() ? l + ',"linkedin.com"' : "")),
]
.join("\n")
.replace(/\n+$/, "") + "\n";
await Bun.write(CSV, migrated);
}
}
{
const raw = await Bun.file(CSV).text().catch(() => "");
if (raw.startsWith(HEADER_NO_LOCATION + "\n") || raw === HEADER_NO_LOCATION) {
const lines = raw.split("\n");
const migrated = [
HEADER,
// Unknown for pre-existing rows — daily.ts falls back to `search` when empty.
...lines.slice(1).map((l) => (l.trim() ? l + ',""' : "")),
]
.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", job.location]
.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, job.location]
.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}`);