chore: update search scripts, extra sources and post drafts

Adds the one-off scripts accumulated across recent sessions, removes
temp scripts whose searches now live in config.ts as role-family
sweeps, and updates fetch-other.ts plus the LinkedIn post drafts.
This commit is contained in:
2026-08-27 09:03:05 +02:00
parent 3f5360f44c
commit b4957025b1
31 changed files with 2008 additions and 283 deletions
+46
View File
@@ -0,0 +1,46 @@
import { KEYWORDS } from "../config.ts";
import type { SearchConfig } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import { readFileSync } from "fs";
const csv = readFileSync("./results/jobs.csv", "utf8");
const existingIds = new Set(
[...csv.matchAll(/jobs\/view\/(\d+)\//g)].map((m) => m[1])
);
const searches: SearchConfig[] = [
{ label: "Switzerland (all)", keywords: KEYWORDS, location: "Switzerland", workType: ["onsite", "hybrid", "remote"], timePosted: "month" },
{ label: "Zürich Area", keywords: KEYWORDS, geoId: "90009888", location: "Zürich Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metro", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Bern", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Geneva", keywords: KEYWORDS, location: "Geneva, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Lausanne", keywords: KEYWORDS, location: "Lausanne, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zug", keywords: KEYWORDS, location: "Zug, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Lucerne", keywords: KEYWORDS, location: "Lucerne, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "St. Gallen", keywords: KEYWORDS, location: "St. Gallen, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Lugano", keywords: KEYWORDS, location: "Lugano, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
];
const seen = new Map<string, any>();
for (const s of searches) {
const jobs = await fetchJobs(s);
for (const j of jobs) if (!seen.has(j.id) && !existingIds.has(j.id)) seen.set(j.id, j);
console.log(`${s.label}: ${jobs.length} raw, ${seen.size} unique new so far`);
}
const MS_RE = /\b(\.net|c#|azure|sharepoint|dynamics|power\s?(bi|platform|apps)|sccm|intune|windows)\b/i;
const candidates = [...seen.values()].filter((j) => !MS_RE.test(j.title));
console.log(`\n${candidates.length} candidates. Checking English content…\n`);
const results = [];
for (const j of candidates) {
const ok = await isEnglishContent(j.id);
if (ok) results.push(j);
else console.log(` dropped (non-English): ${j.title}`);
}
console.log(`\n=== ${results.length} NEW SWISS JOBS ===\n`);
for (const j of results) {
console.log(`${j.title} | ${j.company} | ${j.location} | ${j.postedAt}`);
console.log(` ${j.link}`);
}
+44
View File
@@ -0,0 +1,44 @@
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import { readFileSync } from "fs";
const csv = readFileSync("./results/jobs.csv", "utf8");
const existingIds = new Set(
[...csv.matchAll(/jobs\/view\/(\d+)\//g)].map((m) => m[1])
);
const MICROSOFT_RE = /\.net\b|\bc#|sharepoint|dynamics 365|power platform|windows system|\bazure\b|\bintune\b|\bsccm\b/i;
const jobs = await fetchJobs({
label: "Allschwil",
keywords: KEYWORDS,
location: "Allschwil, Switzerland",
workType: ["onsite", "hybrid"],
timePosted: "month",
});
const seen = new Set<string>();
const newJobs = jobs.filter((j) => {
if (seen.has(j.id) || existingIds.has(j.id)) return false;
seen.add(j.id);
if (MICROSOFT_RE.test(j.title)) {
console.log(` [ms-stack] dropped: ${j.title} — ${j.company}`);
return false;
}
return true;
});
console.log(`\n${newJobs.length} new Allschwil jobs. Checking English content…\n`);
const results = [];
for (const j of newJobs) {
process.stdout.write(` ${j.title} — ${j.company} … `);
const ok = await isEnglishContent(j.id);
console.log(ok ? "ok" : "dropped (non-English)");
if (ok) results.push(j);
}
console.log(`\n=== ${results.length} new English Allschwil jobs ===\n`);
for (const j of results) {
console.log(`${j.title} | ${j.company} | ${j.link}`);
}
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
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 {}
const searches: SearchConfig[] = [
{ label: "Basel city (onsite+hybrid)", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metropolitan (onsite+hybrid)", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel-Country (onsite+hybrid)", keywords: KEYWORDS, geoId: "107117530", location: "Basel-Country", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel city (remote)", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" },
{ label: "Basel Metropolitan (remote)", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["remote"], timePosted: "month" },
{ label: "Switzerland (remote)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
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;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL: ${results.length} jobs ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id} | ${j.link}`);
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bun
import type { SearchConfig } from "../config.ts";
import { fetchJobs, isEnglishContent, JUNIOR_TITLE_RE, JAVA_TITLE_RE } from "../fetch.ts";
const CSV = "./results/jobs.csv";
// Titles the engineer-centric KEYWORDS list never searches for.
const BROAD_KEYWORDS = [
"Implementation Manager",
"Implementation Consultant",
"Implementation Specialist",
"Solutions Consultant",
"Solution Architect",
"Technical Consultant",
"Technical Account Manager",
"Technical Program Manager",
"Technical Project Manager",
"Delivery Manager",
"Integration Engineer",
"Integration Specialist",
"Customer Success Engineer",
"Sales Engineer",
"Presales Engineer",
"Digital Transformation",
"Product Owner",
"Scientific Software",
"Research Software",
"Automation Engineer",
"Systems Integration",
].join(" OR ");
// Microsoft stack = hard no (see cv/polo.md).
const MICROSOFT_RE = /\b(\.net|dotnet|c#|azure|sharepoint|dynamics|power\s*(platform|bi|apps|automate)|sccm|intune|windows\s*server|m365|office\s*365|uipath)\b/i;
const SAP_RE = /\bsap\b|\bs\/4hana\b|\bsalesforce\b|\bservicenow\b/i;
const AREAS: SearchConfig[] = [
{ label: "Basel (city)", keywords: BROAD_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "week" },
{ label: "Basel Metropolitan Area", keywords: BROAD_KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "week" },
{ label: "Basel-Country", keywords: BROAD_KEYWORDS, geoId: "107117530", location: "Basel-Country", workType: ["onsite", "hybrid"], timePosted: "week" },
];
const existingIds = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const id = line.match(/\/jobs\/view\/(\d+)\//)?.[1];
if (id) existingIds.add(id);
}
} catch { /* first run */ }
type Row = { id: string; title: string; company: string; location: string; link: string; posted: string; isNew: boolean };
const seen = new Set<string>();
const rows: Row[] = [];
for (const config of AREAS) {
// raw: true — bypass SOFTWARE_TITLE_RE so non-engineer titles survive
const jobs = await fetchJobs(config, { raw: true });
console.error(`[${config.label}] ${jobs.length} jobs`);
for (const job of jobs) {
if (seen.has(job.id)) continue;
seen.add(job.id);
const t = job.title;
if (JUNIOR_TITLE_RE.test(t)) { console.error(` junior: ${t}`); continue; }
if (JAVA_TITLE_RE.test(t)) { console.error(` java: ${t}`); continue; }
if (MICROSOFT_RE.test(t)) { console.error(` MS stack: ${t}`); continue; }
if (SAP_RE.test(t)) { console.error(` SAP/SF/SNOW: ${t}`); continue; }
rows.push({
id: job.id, title: t, company: job.company,
location: (job as any).location ?? "", link: job.link,
posted: (job as any).postedAt ?? "", isNew: !existingIds.has(job.id),
});
}
}
const survivors: Row[] = [];
for (const r of rows) {
if (await isEnglishContent(r.id)) survivors.push(r);
else console.error(` DE/FR content dropped: ${r.title} — ${r.company}`);
}
console.error(`\n${survivors.length} survivors (${survivors.filter((r) => r.isNew).length} not in CSV)`);
console.log(JSON.stringify(survivors, null, 2));
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bun
import { searches } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
const CSV = "./results/jobs.csv";
const BASEL_LABELS = new Set(["Basel (city)", "Basel Metropolitan Area", "Basel-Country"]);
const existingIds = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const id = line.match(/\/jobs\/view\/(\d+)\//)?.[1];
if (id) existingIds.add(id);
}
} catch { /* first run */ }
type Row = { id: string; title: string; company: string; location: string; link: string; posted: string; isNew: boolean };
const seen = new Set<string>();
const rows: Row[] = [];
for (const config of searches.filter((s) => BASEL_LABELS.has(s.label))) {
const jobs = await fetchJobs(config);
console.error(`[${config.label}] ${jobs.length} jobs`);
for (const job of jobs) {
if (seen.has(job.id)) continue;
seen.add(job.id);
rows.push({
id: job.id,
title: job.title,
company: job.company,
location: (job as any).location ?? "",
link: job.link,
posted: (job as any).postedAt ?? "",
isNew: !existingIds.has(job.id),
});
}
}
const survivors: Row[] = [];
for (const r of rows) {
if (await isEnglishContent(r.id)) survivors.push(r);
else console.error(` DE/FR content dropped: ${r.title} — ${r.company}`);
}
console.error(`\n${survivors.length} survivors (${survivors.filter((r) => r.isNew).length} new)`);
console.log(JSON.stringify(survivors, null, 2));
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, SOFTWARE_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const JUNIOR_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i;
const searches: SearchConfig[] = [
{ label: "Bern (city)", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
];
const seen = new Set<string>();
const results: { title: string; company: string; link: string; search: string }[] = [];
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config, { raw: false });
for (const job of jobs) {
if (!seen.has(job.id)) {
seen.add(job.id);
results.push({ title: job.title, company: job.company, link: job.link, search: job.search });
}
}
}
console.log(`\n── ${results.length} jobs found in Bern ──\n`);
for (const j of results) {
console.log(` [${j.search}] ${j.title}`);
console.log(` ${j.company} ${j.link}`);
}
+106
View File
@@ -0,0 +1,106 @@
import { KEYWORDS } from "../config.ts";
import { JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
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",
};
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(/\s+/g, " ").trim();
}
interface Job { id: string; title: string; company: string; location: string; link: string; }
function parseJobs(html: string): Job[] {
const jobs: Job[] = [];
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];
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 locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
const jobLocation = locM ? locM[1].trim() : "";
const loc = jobLocation.toLowerCase();
const isBrazil = loc.includes("brazil") || loc.includes("brasil") || loc === "" || loc.includes("remote");
const isOther = /switzerland|germany|united states|france|uk\b|netherlands/i.test(jobLocation);
if (!isBrazil || isOther) continue;
jobs.push({ id, title: clean(titleM[1]), company: companyM ? clean(companyM[1]) : "", location: jobLocation || "Remote", link: `https://www.linkedin.com/jobs/view/${id}/` });
}
return jobs;
}
// German language signal in description
const DE_LANG_RE = /\b(german|alemão|deutsch|deutschkenntnisse|german\s+language|língua\s+alem[aã])\b/i;
// Multilingual signal (3+ languages)
const MULTI_RE = /\b(trilingual|multilingual|3\s+languages|três\s+idiomas|três\s+línguas)\b/i;
async function checkLanguages(id: string): Promise<{ german: boolean; multi: boolean }> {
await Bun.sleep(400);
try {
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] : html).replace(/<[^>]+>/g, " ");
return { german: DE_LANG_RE.test(text), multi: MULTI_RE.test(text) };
} catch {
return { german: false, multi: false };
}
}
const PAGE_SIZE = 25;
const MAX_PAGES = 4;
const baseUrl = `https://www.linkedin.com/jobs/search/?keywords=${encodeURIComponent(KEYWORDS)}&location=Brazil&f_WT=2&f_TPR=r2592000`;
const allJobs: Job[] = [];
const seen = new Set<string>();
for (let page = 0; page < MAX_PAGES; page++) {
if (page > 0) await Bun.sleep(500);
const url = `${baseUrl}&start=${page * PAGE_SIZE}`;
process.stdout.write(` page ${page + 1} … `);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.error(`failed (${res.status})`); break; }
const html = await res.text();
const jobs = parseJobs(html)
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
.filter((j) => !JAVA_TITLE_RE.test(j.title))
.filter((j) => 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} found, ${newOnPage} new`);
if (newOnPage === 0) break;
}
console.log(`\n${allJobs.length} vagas. Checando por alemão / multilíngue…\n`);
const german: Job[] = [];
const multi: Job[] = [];
for (const job of allJobs) {
process.stdout.write(` ${job.id}… `);
const { german: hasDE, multi: hasMulti } = await checkLanguages(job.id);
const flags = [hasDE && "DE", hasMulti && "MULTI"].filter(Boolean).join("+");
console.log(flags || "—");
if (hasDE) german.push(job);
if (hasMulti) multi.push(job);
}
console.log(`\n🇩🇪 ${german.length} vagas com alemão:`);
for (const j of german) console.log(` ${j.title} — ${j.company} (${j.location})\n ${j.link}`);
console.log(`\n🌐 ${multi.length} vagas multilíngue:`);
for (const j of multi) console.log(` ${j.title} — ${j.company} (${j.location})\n ${j.link}`);
+104
View File
@@ -0,0 +1,104 @@
import { KEYWORDS } from "../config.ts";
import { JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
// Supplement for Portuguese job titles not caught by SOFTWARE_TITLE_RE
const PT_SOFT_RE = /\b(desenvolvedor|engenheiro\s+de\s+software|arquiteto|analista\s+de\s+(sistemas|dados)|tech\s+lead|dados|plataforma|ia\b|inteligência\s+artificial)\b/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",
};
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();
}
interface Job {
id: string;
title: string;
company: string;
location: string;
postedAt: string;
link: string;
}
function parseJobs(html: string): Job[] {
const jobs: Job[] = [];
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];
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() : "";
// Accept Brazil locations, Remote, or empty (unknown)
// Reject jobs clearly anchored to other countries
const loc = jobLocation.toLowerCase();
const isBrazil = loc.includes("brazil") || loc.includes("brasil") || loc === "" || loc.includes("remote");
const isOtherCountry = /switzerland|germany|deutschland|united states|france|uk\b|united kingdom|netherlands|spain|italy/i.test(jobLocation);
if (!isBrazil || isOtherCountry) continue;
jobs.push({
id,
title: clean(titleM[1]),
company: companyM ? clean(companyM[1]) : "",
location: jobLocation || "Remote",
postedAt: timeM ? timeM[1] : "",
link: `https://www.linkedin.com/jobs/view/${id}/`,
});
}
return jobs;
}
const PAGE_SIZE = 25;
const MAX_PAGES = 6;
const baseUrl = `https://www.linkedin.com/jobs/search/?keywords=${encodeURIComponent(KEYWORDS)}&location=Brazil&f_WT=2&f_TPR=r2592000`;
const allJobs: Job[] = [];
const seen = new Set<string>();
for (let page = 0; page < MAX_PAGES; page++) {
if (page > 0) await Bun.sleep(500);
const url = `${baseUrl}&start=${page * PAGE_SIZE}`;
process.stdout.write(` page ${page + 1} … `);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.error(`failed (${res.status})`); break; }
const html = await res.text();
const jobs = parseJobs(html)
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
.filter((j) => !JAVA_TITLE_RE.test(j.title))
.filter((j) => SOFTWARE_TITLE_RE.test(j.title) || PT_SOFT_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} found, ${newOnPage} new`);
if (newOnPage === 0) break;
}
console.log(`\nTotal: ${allJobs.length} vagas remotas no Brasil\n`);
for (const j of allJobs) {
console.log(`${j.title} — ${j.company} (${j.location})`);
console.log(` ${j.link}`);
}
+128
View File
@@ -0,0 +1,128 @@
import { JUNIOR_TITLE_RE, JAVA_TITLE_RE } from "../fetch.ts";
const TECH_LEAD_KEYWORDS = [
"Tech Lead", "Technical Lead", "Lead Engineer", "Lead Software Engineer",
"Lead Developer", "Staff Engineer", "Principal Engineer", "Engineering Manager",
"Lead Fullstack", "Lead Backend", "Lead Platform",
].join(" OR ");
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",
};
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();
}
interface Job {
id: string;
title: string;
company: string;
location: string;
postedAt: string;
link: string;
}
function parseJobs(html: string): Job[] {
const jobs: Job[] = [];
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];
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() : "";
const loc = jobLocation.toLowerCase();
const isBrazil = loc.includes("brazil") || loc.includes("brasil") || loc === "" || loc.includes("remote");
const isOtherCountry = /switzerland|germany|united states|france|uk\b|united kingdom|netherlands|spain|italy/i.test(jobLocation);
if (!isBrazil || isOtherCountry) continue;
jobs.push({
id,
title: clean(titleM[1]),
company: companyM ? clean(companyM[1]) : "",
location: jobLocation || "Remote",
postedAt: timeM ? timeM[1] : "",
link: `https://www.linkedin.com/jobs/view/${id}/`,
});
}
return jobs;
}
const EN_RE = /\b(english|inglês|inglés)\b/i;
const ES_RE = /\b(spanish|espanhol|español)\b/i;
async function requiresEnglishAndSpanish(id: string): Promise<boolean> {
await Bun.sleep(400);
try {
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, " ");
return EN_RE.test(text) && ES_RE.test(text);
} catch {
return false;
}
}
const PAGE_SIZE = 25;
const MAX_PAGES = 6;
const baseUrl = `https://www.linkedin.com/jobs/search/?keywords=${encodeURIComponent(TECH_LEAD_KEYWORDS)}&location=Brazil&f_WT=2&f_TPR=r2592000`;
const allJobs: Job[] = [];
const seen = new Set<string>();
for (let page = 0; page < MAX_PAGES; page++) {
if (page > 0) await Bun.sleep(500);
const url = `${baseUrl}&start=${page * PAGE_SIZE}`;
process.stdout.write(` page ${page + 1} … `);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.error(`failed (${res.status})`); break; }
const html = await res.text();
const jobs = parseJobs(html)
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
.filter((j) => !JAVA_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} found, ${newOnPage} new`);
if (newOnPage === 0) break;
}
console.log(`\n${allJobs.length} vagas Tech Lead/Lead. Checando descrições por EN+ES…\n`);
const matches: Job[] = [];
for (const job of allJobs) {
process.stdout.write(` checking ${job.id}… `);
const ok = await requiresEnglishAndSpanish(job.id);
console.log(ok ? "✓ EN+ES" : "—");
if (ok) matches.push(job);
}
console.log(`\n✓ ${matches.length} vagas com inglês + espanhol\n`);
for (const j of matches) {
console.log(`${j.title} — ${j.company} (${j.location})`);
console.log(` ${j.link}`);
}
+37
View File
@@ -0,0 +1,37 @@
import { fetchJobs } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const EM_KEYWORDS = [
"Engineering Manager",
"Engineering Lead",
"Head of Engineering",
"VP Engineering",
"Director of Engineering",
"CTO",
"Tech Lead Manager",
].join(" OR ");
const configs: SearchConfig[] = [
{ label: "Basel city", keywords: EM_KEYWORDS, geoId: "103383283", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metro", keywords: EM_KEYWORDS, geoId: "90009885", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich city", keywords: EM_KEYWORDS, geoId: "102436504", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Metro", keywords: EM_KEYWORDS, geoId: "107814425", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Bern", keywords: EM_KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "CH Remote", keywords: EM_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
const all = new Map<string, Awaited<ReturnType<typeof fetchJobs>>[number]>();
for (const config of configs) {
console.log(`\n--- ${config.label} ---`);
const jobs = await fetchJobs(config);
for (const j of jobs) {
if (!all.has(j.id)) all.set(j.id, j);
}
}
console.log(`\nTotal: ${all.size} vagas de Engineering Manager na Suíça\n`);
for (const j of all.values()) {
console.log(`${j.title} — ${j.company} (${j.location})`);
console.log(` ${j.link}`);
}
+34
View File
@@ -0,0 +1,34 @@
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import { readFileSync } from "fs";
// Load existing IDs from CSV
const csv = readFileSync("./results/jobs.csv", "utf8");
const existingIds = new Set(
[...csv.matchAll(/jobs\/view\/(\d+)\//g)].map((m) => m[1])
);
const jobs = await fetchJobs({
label: "CH Remote",
keywords: KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
});
const newJobs = jobs.filter((j) => !existingIds.has(j.id));
console.log(`\n${newJobs.length} new remote CH jobs. Checking English content…\n`);
const results = [];
for (const j of newJobs) {
process.stdout.write(` ${j.title} … `);
const ok = await isEnglishContent(j.id);
console.log(ok ? "ok" : "dropped (non-English)");
if (ok) results.push(j);
}
console.log(`\n${results.length} vagas remotas novas na Suíça\n`);
for (const j of results) {
console.log(`${j.title} — ${j.company}`);
console.log(` ${j.link}`);
}
+83
View File
@@ -0,0 +1,83 @@
// jobs.ch — public search API sweep, Switzerland-wide.
// Endpoint: /api/v1/public/search?query=…&rows=…&page=…
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
import { readFileSync } from "fs";
const csv = readFileSync("./results/jobs.csv", "utf8");
const existing = new Set([...csv.matchAll(/"(https?:\/\/[^"]+)"/g)].map((m) => m[1]));
const QUERIES = [
"software engineer", "full stack engineer", "backend engineer", "platform engineer",
"devops engineer", "data engineer", "AI engineer", "machine learning engineer",
"python developer", "typescript developer", "ruby on rails", "node.js developer",
"tech lead", "engineering manager", "staff engineer", "principal engineer",
"solution architect", "data scientist",
];
const MS_RE = /\b(\.net|c#|azure|sharepoint|dynamics|power\s?(bi|platform|apps)|sccm|intune|windows|sap|abap)\b/i;
// Entry-level roles whose *title* looks neutral — caught only in the description.
const JUNIOR_TEXT_RE = /\b(0\s*[-–to]+\s*[1-5]\s*years|entry.level|junior|graduate program|no experience required)\b/i;
const DE_FR_WORDS_RE =
/\b(und|oder|wir|Sie|Ihre|Ihrer|Kenntnisse|Erfahrung|Aufgaben|Anforderungen|Stelle|Unternehmen|Mitarbeiter|Bereich|für|mit|nous|vous|votre|pour|avec|des|les|une|dans)\b/g;
interface Doc {
job_id: string;
title: string;
preview?: string;
company_name?: string;
place?: string;
publication_date?: string;
language_skills?: Array<{ language: string; level: number }>;
_links?: { detail_en?: { href: string } };
}
const seen = new Map<string, Doc>();
for (const q of QUERIES) {
// rows is capped at 20 by the API (422 above that); sort=date puts fresh postings first.
for (let page = 1; page <= 6; page++) {
const url = `https://www.jobs.ch/api/v1/public/search?query=${encodeURIComponent(q)}&rows=20&page=${page}&sort=date`;
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0", Accept: "application/json" },
});
if (!res.ok) break;
const data = (await res.json()) as { documents: Doc[]; num_pages: number };
for (const d of data.documents ?? []) if (!seen.has(d.job_id)) seen.set(d.job_id, d);
if (page >= (data.num_pages ?? 1)) break;
await Bun.sleep(250);
}
console.log(`${q}: ${seen.size} unique so far`);
}
console.log(`\n${seen.size} raw postings. Filtering…\n`);
const out: Array<Doc & { link: string }> = [];
let dropped = { title: 0, ms: 0, lang: 0, junior: 0, dup: 0 };
for (const d of seen.values()) {
const t = d.title;
if (NON_ENGLISH_RE.test(t) || JUNIOR_TITLE_RE.test(t) || JAVA_TITLE_RE.test(t) || !SOFTWARE_TITLE_RE.test(t)) { dropped.title++; continue; }
if (MS_RE.test(t)) { dropped.ms++; continue; }
// German/French required at a working level → drop
const needsDeFr = (d.language_skills ?? []).some(
(l) => (l.language === "de" || l.language === "fr") && l.level >= 3
);
const preview = (d.preview ?? "").replace(/<[^>]+>/g, " ");
const deWords = (preview.match(DE_FR_WORDS_RE) ?? []).length;
if (needsDeFr || deWords >= 4) { dropped.lang++; continue; }
if (JUNIOR_TEXT_RE.test(preview)) { dropped.junior++; continue; }
const link = d._links?.detail_en?.href ?? `https://www.jobs.ch/en/vacancies/detail/${d.job_id}/`;
if (existing.has(link)) { dropped.dup++; continue; }
out.push({ ...d, link });
}
out.sort((a, b) => (b.publication_date ?? "").localeCompare(a.publication_date ?? ""));
console.log(`dropped: ${dropped.title} title, ${dropped.ms} MS-stack, ${dropped.lang} DE/FR, ${dropped.junior} entry-level, ${dropped.dup} already in CSV`);
console.log(`\n=== ${out.length} jobs.ch CANDIDATES ===\n`);
for (const j of out) {
console.log(`${j.title} | ${j.company_name} | ${j.place} | ${(j.publication_date ?? "").slice(0, 10)}`);
console.log(` ${j.link}`);
}
+101
View File
@@ -0,0 +1,101 @@
// Luzern sweep: LinkedIn + job-room.ch (canton LU)
import { KEYWORDS } from "../config.ts";
import type { SearchConfig } from "../config.ts";
import { fetchJobs, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
const MS_STACK_RE = /\.net\b|\bc#|sharepoint|dynamics 365|power platform|\bsccm\b|intune|successfactors|\bsap\b|\bm365\b/i;
const PHARMA_RE = /\b(clinical|pharmaceutical|drug\s+(substance|product)|ha?ematology|oncology|formulation|laborator|chemist|chemistry|biolog|preclinical|regulatory\s+affairs|medical\s+affairs|veterinary|pathologist)\b/i;
const DE_FR_RE = /\b(und|oder|wir|Sie|Ihre|Ihrer|Kenntnisse|Erfahrung|Aufgaben|Anforderungen|Stelle|Unternehmen|Mitarbeiter|Bereich|nous|vous|votre|pour|avec|des|les)\b/g;
const strip = (s: string) => s.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
// ---------- LinkedIn ----------
console.log("=== LinkedIn: Lucerne ===");
const config: SearchConfig = {
label: "Luzern",
keywords: KEYWORDS,
location: "Lucerne, Switzerland",
workType: ["onsite", "hybrid"],
timePosted: "month",
};
const liJobs = await fetchJobs(config);
console.log(`${liJobs.length} after title filters\n`);
const liKept: any[] = [];
for (const j of liJobs) {
if (MS_STACK_RE.test(j.title)) continue;
process.stdout.write(` ${j.title} … `);
const ok = await isEnglishContent(j.id);
console.log(ok ? "ok" : "dropped (DE/FR)");
if (ok) liKept.push(j);
}
// ---------- job-room.ch, canton LU ----------
console.log("\n=== job-room.ch: canton LU ===");
const API = "https://www.job-room.ch/jobadservice/api/jobAdvertisements/_search";
const UA = "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";
const KWS = ["software", "engineer", "developer", "devops", "data", "python", "cloud", "platform", "backend", "full stack", "ai"];
const ads = new Map<string, any>();
for (const kw of KWS) {
for (let page = 0; page < 5; page++) {
const res = await fetch(`${API}?page=${page}&size=100`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": UA },
body: JSON.stringify({
onlineSince: 30, displayRestricted: false, keywords: [kw],
cantonCodes: ["LU"], workloadPercentageMin: 0, workloadPercentageMax: 100,
}),
});
if (!res.ok) break;
const hits = (await res.json()) as any[];
for (const h of hits) if (h.jobAdvertisement?.id) ads.set(h.jobAdvertisement.id, h.jobAdvertisement);
if (hits.length < 100) break;
await Bun.sleep(200);
}
await Bun.sleep(200);
}
console.log(`${ads.size} unique ads in LU`);
const jrKept: any[] = [];
for (const ad of ads.values()) {
const c = ad.jobContent;
const d = c?.jobDescriptions?.[0];
if (!d) continue;
const title = strip(d.title);
if (NON_ENGLISH_RE.test(title) || JUNIOR_TITLE_RE.test(title) || JAVA_TITLE_RE.test(title)) continue;
if (!SOFTWARE_TITLE_RE.test(title)) continue;
if (MS_STACK_RE.test(title) || PHARMA_RE.test(title)) continue;
const body = strip(d.description ?? "");
if ((body.match(DE_FR_RE) ?? []).length >= 6) continue;
if (MS_STACK_RE.test(body)) continue;
const langs = (c.languageSkills ?? [])
.map((l: any) => `${l.languageIsoCode}:${l.spokenLevel ?? "?"}/${l.writtenLevel ?? "?"}`).join(" ");
jrKept.push({
title,
company: c.company?.name ?? "",
city: c.location?.city ?? "",
workload: `${c.employment?.workloadPercentageMin ?? "?"}-${c.employment?.workloadPercentageMax ?? "?"}%`,
posted: ad.createdTime?.slice(0, 10) ?? "",
langs,
url: c.externalUrl ?? `https://www.job-room.ch/job-search/${ad.id}`,
snippet: body.slice(0, 200),
});
}
// ---------- output ----------
console.log(`\n\n######## LinkedIn survivors: ${liKept.length} ########\n`);
for (const j of liKept) console.log(`### ${j.title} [${j.company}]\n ${j.location} · ${j.postedAt}\n ${j.link}\n`);
console.log(`\n######## job-room survivors: ${jrKept.length} ########\n`);
jrKept.sort((a, b) => b.posted.localeCompare(a.posted));
for (const j of jrKept) {
console.log(`### ${j.title} [${j.company}]`);
console.log(` ${j.city} · ${j.workload} · ${j.posted}`);
if (j.langs) console.log(` langs: ${j.langs}`);
console.log(` ${j.url}`);
console.log(` ${j.snippet}\n`);
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bun
import { searches } from "../config.ts";
import { fetchJobs, SOFTWARE_TITLE_RE } from "../fetch.ts";
const CSV = "./results/jobs.csv";
const BASEL_LABELS = new Set(["Basel (city)", "Basel Metropolitan Area", "Basel-Country"]);
const existingIds = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const id = line.match(/\/jobs\/view\/(\d+)\//)?.[1];
if (id) existingIds.add(id);
}
} catch { /* first run */ }
const missed: { title: string; company: string; link: string }[] = [];
const seenIds = new Set<string>();
for (const config of searches.filter((s) => BASEL_LABELS.has(s.label))) {
console.log(`\n[${config.label}]`);
const all = await fetchJobs(config, { raw: true });
for (const job of all) {
if (!seenIds.has(job.id) && !existingIds.has(job.id) && !SOFTWARE_TITLE_RE.test(job.title)) {
seenIds.add(job.id);
missed.push({ title: job.title, company: job.company, link: job.link });
}
}
}
if (missed.length === 0) {
console.log("\nNo missed jobs — all new titles pass the filter.");
process.exit(0);
}
console.log(`\n── ${missed.length} new Basel-area jobs currently filtered out ──\n`);
for (const j of missed) {
console.log(` ${j.title}`);
console.log(` ${j.company} ${j.link}`);
}
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env bun
import { searches, KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
// 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 {}
const remoteSearches: SearchConfig[] = [
{
label: "Basel (remote)",
keywords: KEYWORDS,
geoId: "103383283",
location: "Basel",
workType: ["remote"],
timePosted: "month",
},
{
label: "Basel Metropolitan Area (remote)",
keywords: KEYWORDS,
geoId: "90009885",
location: "Basel Metropolitan Area",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland (remote)",
keywords: KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
},
];
type Job = { id: string; title: string; company: string; link: string; postedAt: string };
const newJobs: Array<Job & { search: string }> = [];
const seenIds = new Set<string>();
for (const config of [...searches, ...remoteSearches]) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" No jobs found."); continue; }
let added = 0;
for (const job of jobs) {
if (existingLinks.has(job.link) || seenIds.has(job.id)) continue;
seenIds.add(job.id);
process.stdout.write(` checking: ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (non-English)"); continue; }
console.log("ok");
newJobs.push({ ...job, search: config.label });
added++;
}
console.log(` ${jobs.length} found, ${added} new`);
}
console.log(`\n=== ${newJobs.length} new jobs total ===\n`);
for (const j of newJobs) {
console.log(`${j.title} | ${j.company} | ${j.search} | ${j.link}`);
}
+34
View File
@@ -0,0 +1,34 @@
// Re-runs the one search that returned HTTP 500 during the full fetch and
// appends its survivors to results/jobs.csv in the same format as index.ts.
import { RESEARCH_KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./results/jobs.csv";
const config: SearchConfig = {
label: "Research — Zürich Metropolitan Area",
keywords: RESEARCH_KEYWORDS,
geoId: "107814425",
location: "Zürich Metropolitan Area",
workType: ["onsite", "hybrid"],
timePosted: "month",
};
function csvCell(val: string): string {
return `"${(val ?? "").replace(/"/g, '""')}"`;
}
const text = await Bun.file(CSV).text();
const seen = new Set(
text.split("\n").map((l) => l.match(/jobs\/view\/(\d+)/)?.[1]).filter(Boolean) as string[]
);
const date = new Date().toISOString().slice(0, 10);
const jobs = (await fetchJobs(config)).filter((j) => !seen.has(j.id));
const rows: string[] = [];
for (const j of jobs) {
if (!(await isEnglishContent(j.id))) continue;
rows.push([date, j.postedAt, config.label, j.title, j.company, j.link, "linkedin.com"].map(csvCell).join(","));
}
await Bun.write(CSV, text.trimEnd() + "\n" + rows.join("\n") + "\n");
console.log(`${rows.length} appended → ${CSV}`);
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bun
// Ruby/Rails — GLOBAL remote sweep.
// Most Rails roles are titled "Backend Engineer", so this runs both a
// Ruby-title search and a description-level Ruby check over generic titles,
// across every remote market that is realistic for an EU-citizen in Basel.
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts";
const RUBY_KEYWORDS = [
"Ruby on Rails", "Rails Engineer", "Ruby Engineer",
"Rails Developer", "Ruby Developer", "Ruby", "Rails",
].join(" OR ");
const RUBY_RE = /\bruby\b|\brails\b|\bsidekiq\b|\bhotwire\b|\bactiverecord\b|\bactive record\b/i;
const MS_RE = /\b(\.net|c#|sharepoint|dynamics|sccm|intune)\b/i;
const AGENCY_RE = /\bjobgether\b|\ballps\b/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",
};
// geoId 92000000 = "Worldwide" on LinkedIn
const MARKETS: { label: string; geoId?: string; location?: string }[] = [
{ label: "Worldwide", geoId: "92000000" },
{ label: "European Union", location: "European Union" },
{ label: "Portugal", location: "Portugal" },
{ label: "Germany", location: "Germany" },
{ label: "United Kingdom", location: "United Kingdom" },
{ label: "Spain", location: "Spain" },
{ label: "Netherlands", location: "Netherlands" },
{ label: "Switzerland", location: "Switzerland" },
{ label: "Brazil", location: "Brazil" },
{ label: "United States", location: "United States" },
];
interface Job { id: string; title: string; company: string; location: string; link: string }
function unescape(s: string) {
return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, " ").trim();
}
function parseCards(html: string): Job[] {
const jobs: Job[] = [];
const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g;
const pos: { id: string; start: number }[] = [];
let m;
while ((m = idRe.exec(html)) !== null) pos.push({ id: m[1], start: m.index });
for (let i = 0; i < pos.length; i++) {
const { id, start } = pos[i];
const card = html.slice(start, Math.min(pos[i + 1]?.start ?? Infinity, start + 3000));
const titleM = card.match(/base-search-card__title[^>]*>\s*([\s\S]*?)\s*<\/h3>/);
if (!titleM) continue;
const companyM = card.match(/base-search-card__subtitle[\s\S]{0,300}?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
jobs.push({
id,
title: unescape(titleM[1].replace(/<[^>]+>/g, "")),
company: companyM ? unescape(companyM[1].replace(/<[^>]+>/g, "")) : "",
location: locM ? unescape(locM[1]) : "",
link: `https://www.linkedin.com/jobs/view/${id}/`,
});
}
return jobs;
}
const bodyCache = new Map<string, string>();
async function fetchBody(id: string): Promise<string> {
if (bodyCache.has(id)) return bodyCache.get(id)!;
let body = "";
try {
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
const html = await res.text();
body = (html.match(/show-more-less-html__markup[^"]*"[^>]*>([\s\S]*?)<\/div>/)?.[1] ?? "")
.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
} catch { /* leave empty */ }
bodyCache.set(id, body);
await Bun.sleep(350);
return body;
}
type Hit = Job & { market: string; via: string; snippet: string };
const hits: Hit[] = [];
const seen = new Set<string>();
const checked = new Set<string>();
for (const market of MARKETS) {
console.log(`\n[${market.label}]`);
for (let page = 0; page < 3; page++) {
const params = new URLSearchParams({
keywords: RUBY_KEYWORDS,
f_TPR: "r2592000",
f_WT: "2", // remote
start: String(page * 25),
});
if (market.geoId) params.set("geoId", market.geoId);
if (market.location) params.set("location", market.location);
let cards: Job[] = [];
try {
const res = await fetch(`https://www.linkedin.com/jobs/search/?${params}`, { headers: HEADERS });
if (!res.ok) { console.log(` page ${page + 1}: HTTP ${res.status}`); break; }
cards = parseCards(await res.text());
} catch (e) { console.log(` page ${page + 1}: ${e}`); break; }
if (!cards.length) break;
let fresh = 0;
for (const job of cards) {
if (seen.has(job.id) || checked.has(job.id)) continue;
if (NON_ENGLISH_RE.test(job.title) || JUNIOR_TITLE_RE.test(job.title)) continue;
if (MS_RE.test(job.title) || AGENCY_RE.test(job.company)) continue;
checked.add(job.id);
fresh++;
const titleRuby = RUBY_RE.test(job.title);
const body = await fetchBody(job.id);
const bodyRuby = RUBY_RE.test(body);
if (!titleRuby && !bodyRuby) continue;
if (MS_RE.test(body)) continue;
const idx = body.search(RUBY_RE);
seen.add(job.id);
hits.push({
...job,
market: market.label,
via: titleRuby ? (bodyRuby ? "title+desc" : "title") : "desc",
snippet: idx >= 0 ? body.slice(Math.max(0, idx - 90), idx + 130).trim() : "",
});
console.log(` HIT: ${job.title} | ${job.company} | ${job.location}`);
}
console.log(` page ${page + 1}: ${cards.length} cards, ${fresh} new`);
if (fresh === 0) break;
await Bun.sleep(400);
}
}
console.log(`\n=== ${hits.length} RUBY/RAILS REMOTE HITS (global) ===\n`);
for (const h of hits) {
console.log(`${h.title} | ${h.company} | ${h.location} | ${h.market} | via ${h.via}`);
console.log(` ${h.link}`);
if (h.snippet) console.log(` …${h.snippet}…`);
console.log();
}
+37 -53
View File
@@ -1,40 +1,28 @@
#!/usr/bin/env bun
import { isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts";
const CSV = "./jobs.csv";
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 {}
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const RUBY_KEYWORDS = [
"Ruby on Rails", "Rails Engineer", "Ruby Engineer",
"Rails Developer", "Ruby Developer",
].join(" OR ");
const searches = [
{ label: "United States Remote (Ruby)", location: "United States", workType: "2" },
];
const RUBY_RE = /\bruby\b|\brails\b/i;
const DE_FR_RE = /\b(wir|sind|für|mit|und|oder|eine|einen|notre|nous|vous|votre|pour|avec|dans|des)\b/gi;
interface Job { id: string; title: string; company: string; location: string; link: string; postedAt: string }
interface Job { id: string; title: string; company: string; location: string; link: string }
function unescape(s: string) {
return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, " ").trim();
}
async function fetchPage(location: string, workType: string, start: number): Promise<Job[]> {
async function fetchPage(start: number): Promise<Job[]> {
const params = new URLSearchParams({
keywords: RUBY_KEYWORDS,
location,
location: "Switzerland",
f_TPR: "r2592000",
f_WT: workType,
f_WT: "2",
start: String(start),
});
const url = `https://www.linkedin.com/jobs/search/?${params}`;
@@ -42,7 +30,6 @@ async function fetchPage(location: string, workType: string, start: number): Pro
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" }
});
const html = await res.text();
const jobs: Job[] = [];
const cardRe = /<div[^>]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn="urn:li:jobPosting:(\d+)"[^>]*>([\s\S]*?)(?=<div[^>]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn|<\/ul>)/g;
let m;
@@ -52,50 +39,47 @@ async function fetchPage(location: string, workType: string, start: number): Pro
const titleM = chunk.match(/class="[^"]*base-search-card__title[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/h3>/);
const companyM = chunk.match(/class="[^"]*base-search-card__subtitle[^"]*"[\s\S]*?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const locationM = chunk.match(/class="[^"]*job-search-card__location[^"]*"[^>]*>\s*([\s\S]*?)\s*<\//);
const postedM = chunk.match(/datetime="([^"]+)"/);
const title = titleM ? unescape(titleM[1].replace(/<[^>]+>/g, "")) : "";
const company = companyM ? unescape(companyM[1].replace(/<[^>]+>/g, "")) : "";
const location = locationM ? unescape(locationM[1]) : "";
const postedAt = postedM ? postedM[1] : "";
if (!title || !id) continue;
jobs.push({ id, title, company, location, link: `https://www.linkedin.com/jobs/view/${id}/`, postedAt });
jobs.push({ id, title, company, location, link: `https://www.linkedin.com/jobs/view/${id}/` });
}
return jobs;
}
const newJobs: Array<Job & { search: string }> = [];
async function checkDescription(id: string): Promise<{ hasRuby: boolean; isEnglish: boolean }> {
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, {
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" }
});
const html = await res.text();
const deMatches = (html.match(DE_FR_RE) || []).length;
return { hasRuby: RUBY_RE.test(html), isEnglish: deMatches < 6 };
}
const seenIds = new Set<string>();
const results: (Job & { note: string })[] = [];
for (const s of searches) {
console.log(`\n[${s.label}]`);
let totalFound = 0, added = 0;
for (let page = 0; page < 4; page++) {
const jobs = await fetchPage(s.location, s.workType, page * 25);
if (jobs.length === 0) { console.log(` page ${page + 1} empty, stopping`); break; }
console.log(` page ${page + 1} … ${jobs.length} cards`);
totalFound += jobs.length;
for (const job of jobs) {
if (existingLinks.has(job.link) || seenIds.has(job.id)) continue;
if (NON_ENGLISH_RE.test(job.title) || JUNIOR_TITLE_RE.test(job.title)) continue;
if (!RUBY_RE.test(job.title)) continue;
seenIds.add(job.id);
process.stdout.write(` checking: ${job.title} @ ${job.company} (${job.location}) … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (non-English)"); continue; }
console.log("ok");
newJobs.push({ ...job, search: s.label });
added++;
}
for (let page = 0; page < 4; page++) {
const jobs = await fetchPage(page * 25);
if (!jobs.length) break;
for (const job of jobs) {
if (seenIds.has(job.id)) continue;
if (NON_ENGLISH_RE.test(job.title) || JUNIOR_TITLE_RE.test(job.title)) continue;
seenIds.add(job.id);
process.stdout.write(` checking: ${job.title} @ ${job.company} … `);
const { hasRuby, isEnglish } = await checkDescription(job.id);
if (!isEnglish) { console.log("non-English"); continue; }
if (!hasRuby) { console.log("no Ruby"); continue; }
console.log("RUBY!");
results.push({ ...job, note: RUBY_RE.test(job.title) ? "title+desc" : "desc only" });
await Bun.sleep(200);
}
console.log(` ${totalFound} total, ${added} new`);
await Bun.sleep(300);
}
console.log(`\n=== ${newJobs.length} Ruby remote jobs ===\n`);
for (const j of newJobs) {
console.log(`${j.title} | ${j.company} | ${j.location} | ${j.link}`);
console.log(`\n=== ${results.length} Ruby remote jobs (Switzerland) ===\n`);
for (const j of results) {
console.log(`[${j.note}] ${j.title} | ${j.company} | ${j.location}`);
console.log(` ${j.link}`);
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bun
// Ruby/Rails sweep: Switzerland-wide (onsite+hybrid) + Switzerland remote + EU remote.
// Title-keyword search is too narrow for Ruby — most Rails roles are titled
// "Backend Engineer" — so this also runs a description-level Ruby check over
// generic backend/fullstack titles.
import { fetchJobs, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const RUBY_KEYWORDS = [
"Ruby on Rails", "Rails Engineer", "Ruby Engineer", "Rails Developer",
"Ruby Developer", "Ruby", "Rails",
].join(" OR ");
const BACKEND_KEYWORDS = [
"Backend Engineer", "Full Stack Engineer", "Fullstack Developer",
"Senior Software Engineer", "Tech Lead", "Staff Engineer",
].join(" OR ");
const RUBY_RE = /\bruby\b|\brails\b|\bsidekiq\b|\bhotwire\b|\bactiverecord\b|\bactive record\b/i;
const MS_RE = /\b(\.net|c#|sharepoint|dynamics|sccm|intune)\b/i;
const csv = await Bun.file("./results/jobs.csv").text();
const existing = new Set([...csv.matchAll(/"(https?:\/\/[^"]+)"/g)].map((m) => m[1]));
const searches: Array<SearchConfig & { descCheck?: boolean }> = [
{ label: "CH — Ruby titles", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "CH remote — Ruby titles", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Basel — Ruby titles", keywords: RUBY_KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich — Ruby titles", keywords: RUBY_KEYWORDS, geoId: "90009888", location: "Zürich Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "CH — backend (desc check)", keywords: BACKEND_KEYWORDS, location: "Switzerland", workType: ["onsite", "hybrid", "remote"], timePosted: "month", descCheck: true },
];
type Hit = { title: string; company: string; location: string; link: string; label: string; via: string };
const hits: Hit[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
let jobs;
try {
jobs = await fetchJobs(config, { raw: true });
} catch (e) {
console.log(` error: ${e}`);
continue;
}
console.log(` ${jobs.length} raw cards`);
for (const j of jobs) {
if (seen.has(j.link)) continue;
if (NON_ENGLISH_RE.test(j.title) || JUNIOR_TITLE_RE.test(j.title)) continue;
if (MS_RE.test(j.title)) continue;
const titleRuby = RUBY_RE.test(j.title);
if (!titleRuby && !config.descCheck) continue;
// Confirm Ruby in the description (and that the posting is in English).
let bodyRuby = false;
try {
const res = await fetch(`https://www.linkedin.com/jobs/view/${j.id}/`, {
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" },
});
const html = await res.text();
const body = (html.match(/<div class="show-more-less-html__markup[^"]*"[^>]*>([\s\S]*?)<\/div>/)?.[1] ?? "")
.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
bodyRuby = RUBY_RE.test(body);
await Bun.sleep(350);
} catch { /* fall back to the title signal */ }
if (!titleRuby && !bodyRuby) continue;
if (!(await isEnglishContent(j.id))) {
console.log(` dropped (non-English): ${j.title}`);
continue;
}
seen.add(j.link);
hits.push({
title: j.title, company: j.company, location: (j as any).location ?? "?",
link: j.link, label: config.label,
via: titleRuby ? (bodyRuby ? "title+desc" : "title") : "desc",
});
console.log(` HIT ${existing.has(j.link) ? "(already in CSV)" : "(NEW)"}: ${j.title} | ${j.company}`);
}
}
console.log(`\n=== ${hits.length} RUBY/RAILS HITS ===\n`);
for (const h of hits) {
console.log(`${existing.has(h.link) ? "[seen] " : "[NEW] "}${h.title} | ${h.company} | ${h.location} | via ${h.via} | ${h.label}`);
console.log(` ${h.link}`);
}
+1 -1
View File
@@ -2,7 +2,7 @@
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const CSV = "./results/jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
+112
View File
@@ -0,0 +1,112 @@
#!/bin/bash
# Daily watch on sciCORE's open-positions page.
# Sends a Telegram message ONLY when a new line mentioning "software" appears.
# Other changes are logged silently so the snapshot stays current.
# Credentials come from .env: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
set -uo pipefail
REPO="/Users/polux/Projetos/linkme"
URL="${SCICORE_URL:-https://scicore.unibas.ch/about-scicore/open-positions/}" # override for testing
WANTED="software" # only changes mentioning this word are sent
STATE="$REPO/results/.scicore-watch"
SNAP="$STATE/snapshot.txt"
FAILS="$STATE/consecutive-failures"
LOG="$STATE/watch.log"
UA="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"
mkdir -p "$STATE"
# shellcheck disable=SC1091
# accepts either TELEGRAM_BOT_TOKEN= or the shorter TELEGRAM_BOT=
TELEGRAM_BOT_TOKEN=$(grep -E '^TELEGRAM_BOT(_TOKEN)?=' "$REPO/.env" 2>/dev/null | head -1 | cut -d= -f2-)
TELEGRAM_CHAT_ID=$(grep -E '^TELEGRAM_CHAT_ID=' "$REPO/.env" 2>/dev/null | cut -d= -f2-)
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"; }
notify() {
local text="$1"
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
log "NOTIFY SKIPPED (no telegram creds in .env): $text"
return 1
fi
local code
code=$(curl -s -o "$STATE/last-telegram-response.json" -w '%{http_code}' \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${text}" \
--data-urlencode "disable_web_page_preview=true")
log "telegram http $code"
[ "$code" = "200" ]
}
HTML=$(curl -s --max-time 30 -A "$UA" "$URL")
if [ -z "$HTML" ]; then
n=$(( $(cat "$FAILS" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$FAILS"
log "FETCH FAILED (streak $n)"
[ "$n" -eq 3 ] && notify "⚠️ sciCORE watcher: 3 days in a row the page failed to load. Check the script.
$URL"
exit 0
fi
echo 0 > "$FAILS"
CURRENT=$(printf '%s' "$HTML" | python3 -c '
import sys, re, html
h = sys.stdin.read()
m = re.search(r"<h1>Join the team!</h1>(.*?)<footer", h, re.S | re.I)
body = m.group(1) if m else h
body = re.sub(r"<(script|style).*?</\1>", "", body, flags=re.S | re.I)
body = re.sub(r"<li[^>]*>", "\n- ", body, flags=re.I)
body = re.sub(r"</(p|div|h[1-6]|ul|li|tr)>", "\n", body, flags=re.I)
body = re.sub(r"<[^>]+>", " ", body)
body = html.unescape(body)
lines = [re.sub(r"\s+", " ", l).strip() for l in body.split("\n")]
print("\n".join(l for l in lines if l and l not in {"-", " "}))
')
if [ -z "$CURRENT" ]; then
log "PARSE FAILED (empty extraction)"
exit 0
fi
if [ ! -f "$SNAP" ]; then
printf '%s\n' "$CURRENT" > "$SNAP"
log "BASELINE saved"
if printf '%s' "$CURRENT" | grep -qi "$WANTED"; then
notify "🚨 sciCORE listing mentions Software:
$CURRENT
$URL"
fi
exit 0
fi
if diff -q <(printf '%s\n' "$CURRENT") "$SNAP" >/dev/null; then
log "no change"
exit 0
fi
DIFF=$(diff "$SNAP" <(printf '%s\n' "$CURRENT") | sed -n 's/^> /+ /p;s/^< /- /p')
printf '%s\n' "$CURRENT" > "$SNAP"
log "CHANGED:
$DIFF"
# Only notify when a newly ADDED line contains the wanted word.
# Everything else (sysadmin roles, wording tweaks, removals) is tracked silently.
ADDED=$(printf '%s\n' "$DIFF" | grep '^+ ' || true)
if ! printf '%s' "$ADDED" | grep -qi "$WANTED"; then
log "change ignored (no \"$WANTED\" in added lines)"
exit 0
fi
notify "🚨 sciCORE posted a Software role
What changed:
$DIFF
Full listing now:
$CURRENT
$URL"
+80
View File
@@ -0,0 +1,80 @@
import { KEYWORDS } from "../config.ts";
import { JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
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",
};
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(/\s+/g, " ").trim();
}
interface Job { id: string; title: string; company: string; location: string; link: string; }
function parseJobs(html: string): Job[] {
const jobs: Job[] = [];
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];
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 locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
const company = companyM ? clean(companyM[1]) : "";
// Only Stone (exact or known variants)
if (!/^stone(\s+co\.?|\s+pagamentos)?$/i.test(company.trim())) continue;
jobs.push({
id, title: clean(titleM[1]), company,
location: locM ? locM[1].trim() : "Brazil",
link: `https://www.linkedin.com/jobs/view/${id}/`,
});
}
return jobs;
}
const PAGE_SIZE = 25;
const MAX_PAGES = 6;
// No work type filter — include onsite/hybrid too since it's a target company
const baseUrl = `https://www.linkedin.com/jobs/search/?keywords=${encodeURIComponent(KEYWORDS)}&location=Brazil&f_TPR=r2592000`;
const allJobs: Job[] = [];
const seen = new Set<string>();
for (let page = 0; page < MAX_PAGES; page++) {
if (page > 0) await Bun.sleep(500);
const url = `${baseUrl}&start=${page * PAGE_SIZE}`;
process.stdout.write(` page ${page + 1} … `);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.error(`failed (${res.status})`); break; }
const html = await res.text();
const jobs = parseJobs(html)
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
.filter((j) => !JAVA_TITLE_RE.test(j.title))
.filter((j) => 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} Stone jobs, ${newOnPage} new`);
if (newOnPage === 0) break;
}
console.log(`\nTotal: ${allJobs.length} vagas na Stone\n`);
for (const j of allJobs) {
console.log(`${j.title} — ${j.company} (${j.location})`);
console.log(` ${j.link}`);
}
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
// Load already-seen job IDs from the CSV to show only NEW jobs.
const csv = readFileSync("results/jobs.csv", "utf8");
const seen = new Set<string>();
for (const m of csv.matchAll(/jobs\/view\/(\d+)/g)) seen.add(m[1]);
const config: SearchConfig = {
label: "Switzerland Remote", keywords: KEYWORDS, location: "Switzerland",
workType: ["remote"], timePosted: "month",
};
const fresh = new Map<string, { title: string; company: string; link: string }>();
const jobs = await fetchJobs(config);
for (const j of jobs) {
const id = j.link.match(/jobs\/view\/(\d+)/)?.[1];
if (!id || seen.has(id) || fresh.has(id)) continue;
fresh.set(id, j);
}
console.log(`\n${fresh.size} candidate new jobs, checking English content...\n`);
for (const [id, j] of fresh) {
const ok = await isEnglishContent(id);
if (!ok) continue;
console.log(`${id}\t${j.title}\t${j.company}`);
}
-71
View File
@@ -1,71 +0,0 @@
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
// 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 searches: SearchConfig[] = [
{
label: "Switzerland Remote (Basel geoId)",
keywords: KEYWORDS,
geoId: "103383283",
location: "Basel",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote (Zürich geoId)",
keywords: KEYWORDS,
geoId: "102436504",
location: "Zürich, Switzerland",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote (by name)",
keywords: KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
},
];
type Result = { title: string; company: string; id: string; link: string };
const results: Result[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` checking content: ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) {
console.log("dropped (non-English)");
continue;
}
console.log("ok");
results.push({ title: job.title, company: job.company, id: job.id, link: job.link });
}
}
console.log(`\n${results.length} new remote jobs found.\n`);
for (const r of results) {
console.log(`${r.title} | ${r.company} | ${r.link}`);
}
+58
View File
@@ -0,0 +1,58 @@
// swissdevjobs.ch — RSS feed sweep (Switzerland-wide, tech only, salary disclosed).
// The /api/jobs endpoint is deprecated; the RSS feed carries the full description.
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
import { readFileSync } from "fs";
const csv = readFileSync("./results/jobs.csv", "utf8");
const existing = new Set([...csv.matchAll(/"(https?:\/\/[^"]+)"/g)].map((m) => m[1]));
const res = await fetch("https://swissdevjobs.ch/rss", { headers: { "User-Agent": "Mozilla/5.0" } });
const xml = await res.text();
const MS_RE = /\b(\.net|c#|azure|sharepoint|dynamics|power\s?(bi|platform|apps)|sccm|intune|windows|sap|abap)\b/i;
const DE_FR_WORDS_RE =
/\b(und|oder|wir|Sie|Ihre|Ihrer|Kenntnisse|Erfahrung|Aufgaben|Anforderungen|Stelle|Unternehmen|Mitarbeiter|Bereich|für|mit|nous|vous|votre|pour|avec|des|les|une|dans)\b/g;
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => m[1]);
console.log(`${items.length} feed items`);
const out: Array<{ title: string; company: string; salary: string; link: string; date: string; text: string }> = [];
for (const item of items) {
const rawTitle = item.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/)?.[1] ?? "";
const link = (item.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? "").split("?")[0];
const date = item.match(/<pubDate>([\s\S]*?)<\/pubDate>/)?.[1] ?? "";
const desc = item.match(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/)?.[1] ?? "";
// "Title @ Company [CHF x - y]"
const m = rawTitle.match(/^(.*?)\s+@\s+(.*?)(?:\s+\[(.*?)\])?$/);
const title = (m?.[1] ?? rawTitle).trim();
const company = (m?.[2] ?? "").trim();
const salary = (m?.[3] ?? "").trim();
if (NON_ENGLISH_RE.test(title) || JUNIOR_TITLE_RE.test(title) || JAVA_TITLE_RE.test(title) || !SOFTWARE_TITLE_RE.test(title)) continue;
if (MS_RE.test(title)) continue;
if (existing.has(link)) continue;
const text = desc.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
if ((text.match(DE_FR_WORDS_RE) ?? []).length >= 6) continue;
out.push({ title, company, salary, link, date, text });
}
console.log(`\n${out.length} candidates — fetching locations…\n`);
for (const j of out) {
let loc = "";
try {
const page = await fetch(j.link, { headers: { "User-Agent": "Mozilla/5.0" } });
const html = await page.text();
// The SPA shell carries no JSON-LD, but the meta description reads
// "Become <title> at <company> in <City>. …"
loc =
html.match(/<meta name="description" content="[^"]*?\sin\s([^".]+)\./)?.[1]?.trim() ?? "";
await Bun.sleep(300);
} catch { /* leave blank */ }
console.log(`${j.title} | ${j.company} | ${loc || "?"} | ${j.salary} | ${j.date.slice(5, 16)}`);
console.log(` ${j.link}`);
}
+29
View File
@@ -0,0 +1,29 @@
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const searches: SearchConfig[] = [
{ label: "Web Developer Switzerland (onsite/hybrid)", keywords: "Web Developer OR Full Stack Developer OR Frontend Developer OR Backend Developer", location: "Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Web Developer Switzerland (remote)", keywords: "Web Developer OR Full Stack Developer OR Frontend Developer OR Backend Developer", location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
const seen = new Set<string>();
const results: { title: string; company: string; location: string; link: string; search: string }[] = [];
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
for (const job of jobs) {
if (seen.has(job.id)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const ok = await isEnglishContent(job.id);
if (!ok) { console.log("dropped"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
}
}
console.log(`\n=== ${results.length} web developer jobs ===\n`);
for (const j of results) {
console.log(`${j.title} | ${j.company} | ${j.location} | ${j.link}`);
}
+90
View File
@@ -0,0 +1,90 @@
// Zürich sweep via job-room.ch (canton ZH), scored against cv/polo.md
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
const MS_STACK_RE = /\.net\b|\bc#|sharepoint|dynamics 365|power platform|\bsccm\b|intune|successfactors|\bsap\b|\bm365\b|servicenow|salesforce|avaloq|abap/i;
const PHARMA_RE = /\b(clinical|pharmaceutical|drug\s+(substance|product)|ha?ematology|oncology|formulation|laborator|chemist|chemistry|biolog|preclinical|regulatory\s+affairs|medical\s+affairs|veterinary|pathologist)\b/i;
const DE_FR_RE = /\b(und|oder|wir|Sie|Ihre|Ihrer|Kenntnisse|Erfahrung|Aufgaben|Anforderungen|Stelle|Unternehmen|Mitarbeiter|Bereich|nous|vous|votre|pour|avec|des|les)\b/g;
const EMBEDDED_RE = /\bembedded\b|\bfirmware\b|\bplc\b|\bsps\b|\bqt\b|\bc\+\+\b|automation\s?engineer/i;
const SUPPORT_RE = /service\s?desk|1st|2nd\s?level|helpdesk|support\s?engineer|technician|operator/i;
const SENIOR_RE = /\b(senior|staff|principal|lead|head|director|architect|vp)\b/i;
const STACK_RE = /\b(python|typescript|javascript|ruby|rails|react|node\.?js|golang|\bgo\b|postgres|kafka)\b/i;
const DOMAIN_RE = /\b(platform|kubernetes|docker|terraform|ci\/cd|devops|aws|data\s+(pipeline|platform|engineer)|llm|genai|microservice|sre)\b/i;
const strip = (s: string) => s.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
const API = "https://www.job-room.ch/jobadservice/api/jobAdvertisements/_search";
const UA = "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";
const KWS = ["software", "engineer", "developer", "devops", "data", "python", "cloud",
"platform", "typescript", "backend", "full stack", "ai", "kubernetes", "react"];
const ads = new Map<string, any>();
for (const kw of KWS) {
process.stdout.write(`${kw} `);
for (let page = 0; page < 8; page++) {
const res = await fetch(`${API}?page=${page}&size=100`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": UA },
body: JSON.stringify({
onlineSince: 30, displayRestricted: false, keywords: [kw],
cantonCodes: ["ZH"], workloadPercentageMin: 0, workloadPercentageMax: 100,
}),
});
if (!res.ok) break;
const hits = (await res.json()) as any[];
for (const h of hits) if (h.jobAdvertisement?.id) ads.set(h.jobAdvertisement.id, h.jobAdvertisement);
if (hits.length < 100) break;
await Bun.sleep(200);
}
await Bun.sleep(200);
}
console.log(`\n${ads.size} unique ads in ZH\n`);
const kept: any[] = [];
for (const ad of ads.values()) {
const c = ad.jobContent;
const d = c?.jobDescriptions?.[0];
if (!d) continue;
const title = strip(d.title);
if (NON_ENGLISH_RE.test(title) || JUNIOR_TITLE_RE.test(title) || JAVA_TITLE_RE.test(title)) continue;
if (!SOFTWARE_TITLE_RE.test(title)) continue;
if (MS_STACK_RE.test(title) || PHARMA_RE.test(title)) continue;
if (EMBEDDED_RE.test(title) || SUPPORT_RE.test(title)) continue;
const body = strip(d.description ?? "");
if ((body.match(DE_FR_RE) ?? []).length >= 6) continue;
if (MS_STACK_RE.test(body)) continue;
const hay = `${title} ${body}`;
let score = 0;
if (SENIOR_RE.test(title)) score += 3;
score += (hay.match(STACK_RE) ?? []).length;
score += (hay.match(DOMAIN_RE) ?? []).length;
const langs = (c.languageSkills ?? [])
.map((l: any) => `${l.languageIsoCode}:${l.spokenLevel ?? "?"}`).join(" ");
// A declared German requirement above B1 is disqualifying
const hardGerman = /de(-ch)?:(PROFICIENT|ADVANCED)/.test(langs);
kept.push({
score, title,
company: c.company?.name ?? "",
city: c.location?.city ?? "",
workload: `${c.employment?.workloadPercentageMin ?? "?"}-${c.employment?.workloadPercentageMax ?? "?"}%`,
posted: ad.createdTime?.slice(0, 10) ?? "",
langs, hardGerman,
url: c.externalUrl ?? `https://www.job-room.ch/job-search/${ad.id}`,
snippet: body.slice(0, 230),
});
}
kept.sort((a, b) => b.score - a.score || b.posted.localeCompare(a.posted));
const good = kept.filter((k) => !k.hardGerman);
console.log(`${kept.length} survivors (${kept.length - good.length} dropped for German requirement)\n`);
for (const j of good.slice(0, 30)) {
console.log(`### [${j.score}] ${j.title} — ${j.company}`);
console.log(` ${j.city} · ${j.workload} · ${j.posted}${j.langs ? ` · ${j.langs}` : ""}`);
console.log(` ${j.url}`);
console.log(` ${j.snippet}\n`);
}
+62
View File
@@ -0,0 +1,62 @@
import { KEYWORDS } from "../config.ts";
import type { SearchConfig } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import { readFileSync } from "fs";
const csv = readFileSync("./results/jobs.csv", "utf8");
const existingIds = new Set(
[...csv.matchAll(/jobs\/view\/(\d+)\//g)].map((m) => m[1])
);
const searches: SearchConfig[] = [
{
label: "Zürich (city)",
keywords: KEYWORDS,
geoId: "102436504",
location: "Zürich, Switzerland",
workType: ["onsite", "hybrid"],
timePosted: "month",
},
{
label: "Zürich Metropolitan Area",
keywords: KEYWORDS,
geoId: "107814425",
location: "Zürich Metropolitan Area",
workType: ["onsite", "hybrid"],
timePosted: "month",
},
{
label: "Zürich Area",
keywords: KEYWORDS,
geoId: "90009888",
location: "Zürich Area",
workType: ["onsite", "hybrid"],
timePosted: "month",
},
];
const seen = new Set<string>();
const allJobs = [];
for (const cfg of searches) {
const jobs = await fetchJobs(cfg);
for (const j of jobs) {
if (seen.has(j.id) || existingIds.has(j.id)) continue;
seen.add(j.id);
allJobs.push(j);
}
}
console.log(`\n${allJobs.length} new Zürich jobs. Checking English content…\n`);
const results = [];
for (const j of allJobs) {
process.stdout.write(` ${j.title} — ${j.company} … `);
const ok = await isEnglishContent(j.id);
console.log(ok ? "ok" : "dropped (non-English)");
if (ok) results.push(j);
}
console.log(`\n=== ${results.length} new English Zürich jobs ===\n`);
for (const j of results) {
console.log(`${j.title} | ${j.company} | ${j.link}`);
}