chore: reorganize repo structure and update docs

- move temp scripts to scripts/, cv files to cv/, jobs.csv to results/
- update index.ts and recheck.ts CSV path to results/jobs.csv
- fix imports in scripts/ to use ../config.ts and ../fetch.ts
- update CLAUDE.md and README.md to reflect new structure
- add plans/ with LinkedIn post drafts
- update .gitignore: ignore results/, jobs/, cv binaries, .DS_Store
This commit is contained in:
2026-06-03 16:01:32 +02:00
parent bcb6ae5ea3
commit fb67c38edd
40 changed files with 1472 additions and 806 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/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}`);
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bun
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|Quereinsteiger|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;
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,*/*;q=0.8",
};
const jobs = [
{ id: "4412445330", title: "Senior Software and System Architect", company: "NVIDIA AI" },
{ id: "4406964785", title: "Digital Platform Engineer – EasyGov.swiss", company: "Swiss Federal Administration" },
{ id: "4405193749", title: "Technical Lead Web Portals", company: "Smart Energy Link AG" },
{ id: "4411445039", title: "Senior System Engineer (Cloud Native, Remote-First)", company: "Adfinis" },
{ id: "4411290320", title: "Automation Data Engineer", company: "Takeda Pharmaceuticals" },
{ id: "4411348405", title: "Solutions Architect", company: "RJC Group" },
{ id: "4406980033", title: "Enterprise Architect", company: "Swiss Federal Administration" },
{ id: "4367874399", title: "Software Engineer", company: "Cartier" },
{ id: "4399418573", title: "Data Science Specialist", company: "Nestlé" },
{ id: "4411369976", title: "Data Scientist 80–100%", company: "Fincons Group" },
{ id: "4412464434", title: "Solution Engineer Management Systems", company: "Siemens" },
{ id: "4401638413", title: "Fullstack Developer (Java/React)", company: "soobr" },
{ id: "4399356316", title: "Senior Java Software Engineer", company: "Smart Energy Link AG" },
{ id: "4407778229", title: "Software Engineer", company: "Softcom" },
{ id: "4402476156", title: "Software Developer", company: "SwissPeak Partners" },
// remaining from the 20
{ id: "4404649130", title: "Senior System Engineer (100%)", company: "nanoTRONIC" },
{ id: "4408770074", title: "System Engineer", company: "Infopro AG" },
{ id: "4408864023", title: "System Engineer – Workplace Engineering", company: "Bedag Informatique SA" },
{ id: "4405214433", title: "Cloud Platform Engineer (Kubernetes/Azure/DevOps)", company: "Gramian Consulting" },
];
function detectLanguage(text: string): "de" | "fr" | "en" {
const deMatches = (text.match(DE_CONTENT_RE) ?? []).length;
const frMatches = (text.match(FR_CONTENT_RE) ?? []).length;
if (deMatches >= 6) return "de";
if (frMatches >= 6) return "fr";
return "en";
}
function extractDescription(html: string): string {
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
if (m) return m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
// fallback: grab large text blocks
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").slice(0, 5000);
}
const passed: typeof jobs = [];
const dropped: { id: string; title: string; company: string; lang: string }[] = [];
for (const job of jobs) {
await Bun.sleep(350);
process.stdout.write(` checking ${job.company} — ${job.title} … `);
try {
const res = await fetch(`https://www.linkedin.com/jobs/view/${job.id}/`, { headers: HEADERS });
const html = await res.text();
const desc = extractDescription(html);
const lang = detectLanguage(desc);
console.log(lang);
if (lang === "en") passed.push(job);
else dropped.push({ ...job, lang });
} catch (e) {
console.log("fetch error, keeping");
passed.push(job);
}
}
console.log(`\n── ${dropped.length} dropped (non-English) ──`);
for (const j of dropped) console.log(` [${j.lang}] ${j.title} @ ${j.company}`);
console.log(`\n── ${passed.length} English jobs ──`);
for (const [i, j] of passed.entries()) {
console.log(` ${i + 1}. ${j.title} — ${j.company} https://www.linkedin.com/jobs/view/${j.id}/`);
}
+30
View File
@@ -0,0 +1,30 @@
#!/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}`);
}
+47
View File
@@ -0,0 +1,47 @@
#!/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: "Bern onsite/hybrid", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Bern mid-senior", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], seniority: ["mid-senior", "director"], 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 results"); 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 (lang)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bun
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const EM_KEYWORDS = [
"Engineering Manager",
"Director of Engineering",
"Head of Engineering",
"VP Engineering",
"Technical Lead Manager",
"Tech Lead Manager",
"Head of Software",
"Software Engineering Manager",
].join(" OR ");
const searches: SearchConfig[] = [
{ label: "Basel", keywords: EM_KEYWORDS, location: "Basel, Switzerland", timePosted: "month" },
{ label: "Bern", keywords: EM_KEYWORDS, location: "Bern, Switzerland", timePosted: "month" },
{ label: "Zürich", keywords: EM_KEYWORDS, location: "Zurich, Switzerland", timePosted: "month" },
];
const seen = new Set<string>();
const results: { id: string; title: string; company: string; search: string }[] = [];
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config, { raw: true });
for (const job of jobs) {
if (!seen.has(job.id)) {
seen.add(job.id);
results.push({ id: job.id, title: job.title, company: job.company, search: job.search });
}
}
}
console.log(`\n── ${results.length} jobs after title filter — checking content language ──\n`);
const english: typeof results = [];
for (const job of results) {
process.stdout.write(` ${job.company} — ${job.title} … `);
const ok = await isEnglishContent(job.id);
console.log(ok ? "en" : "dropped");
if (ok) english.push(job);
}
console.log(`\n── ${english.length} English EM jobs (Basel / Bern / Zürich) ──\n`);
for (const [i, j] of english.entries()) {
console.log(` ${i + 1}. [${j.search}] ${j.title} — ${j.company}`);
console.log(` https://www.linkedin.com/jobs/view/${j.id}/`);
}
+105
View File
@@ -0,0 +1,105 @@
import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.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();
}
// parseJobs without Switzerland location guard
function parseJobsGlobal(html: string): { id: string; title: string; company: string; location: string }[] {
const jobs = [];
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*([^<]+)/);
jobs.push({
id,
title: clean(titleM[1]),
company: companyM ? clean(companyM[1]) : "",
location: locM ? locM[1].trim() : "",
});
}
return jobs;
}
async function fetchGlobal(config: SearchConfig): Promise<{ id: string; title: string; company: string; location: string }[]> {
const baseUrl = buildUrl(config);
const all = [];
const seen = new Set<string>();
for (let page = 0; page < 6; page++) {
if (page > 0) await Bun.sleep(400);
const url = `${baseUrl}&start=${page * 25}`;
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 = parseJobsGlobal(html)
.filter(j => !NON_ENGLISH_RE.test(j.title))
.filter(j => !JUNIOR_TITLE_RE.test(j.title))
.filter(j => !JAVA_TITLE_RE.test(j.title));
let newCount = 0;
for (const j of jobs) {
if (!seen.has(j.id)) { seen.add(j.id); all.push(j); newCount++; }
}
console.log(`${jobs.length} jobs, ${newCount} new`);
if (newCount === 0) break;
}
return all;
}
const TECH_LEAD_RE = /\b(tech\s*lead|technical\s*lead|lead\s*(engineer|developer|backend|full.?stack)|staff\s*(engineer|developer)|principal\s*(engineer|developer)|engineering\s*lead|head\s*of\s*eng)\b/i;
const searches: SearchConfig[] = [
{
label: "Global Remote — Rails Tech Lead",
keywords: "Ruby on Rails Tech Lead OR Rails Lead Engineer OR Rails Staff Engineer OR Rails Principal Engineer OR Ruby Lead Developer",
workType: ["remote"],
timePosted: "month",
},
{
label: "Global Remote — Ruby Lead (broader)",
keywords: "Ruby on Rails Lead OR Ruby on Rails Staff OR Ruby on Rails Principal OR Ruby Tech Lead",
workType: ["remote"],
timePosted: "month",
},
];
type Result = { title: string; company: string; location: 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 fetchGlobal(config);
for (const job of jobs) {
if (seen.has(job.id)) continue;
if (!TECH_LEAD_RE.test(job.title)) continue;
seen.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");
results.push({ title: job.title, company: job.company, location: job.location, id: job.id, link: `https://www.linkedin.com/jobs/view/${job.id}/` });
}
}
console.log(`\n${results.length} global remote Rails lead jobs.\n`);
for (const r of results) console.log(`${r.title} | ${r.company} | ${r.location} | ${r.link}`);
+52
View File
@@ -0,0 +1,52 @@
#!/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[] = [
// Basel onsite/hybrid
{ label: "Basel city", keywords: KEYWORDS, geoId: "103383283", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metro", keywords: KEYWORDS, geoId: "90009885", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel-Country", keywords: KEYWORDS, geoId: "107117530", workType: ["onsite", "hybrid"], timePosted: "month" },
// Swiss remote
{ label: "CH remote", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "CH remote mid-senior", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], seniority: ["mid-senior", "director"], 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 results"); 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 (lang)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}
+70
View File
@@ -0,0 +1,70 @@
#!/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}`);
}
+101
View File
@@ -0,0 +1,101 @@
#!/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 {}
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;
interface Job { id: string; title: string; company: string; location: string; link: string; postedAt: 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[]> {
const params = new URLSearchParams({
keywords: RUBY_KEYWORDS,
location,
f_TPR: "r2592000",
f_WT: workType,
start: String(start),
});
const url = `https://www.linkedin.com/jobs/search/?${params}`;
const res = await fetch(url, {
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;
while ((m = cardRe.exec(html)) !== null) {
const id = m[1];
const chunk = m[2];
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 });
}
return jobs;
}
const newJobs: Array<Job & { search: string }> = [];
const seenIds = new Set<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++;
}
}
console.log(` ${totalFound} total, ${added} new`);
}
console.log(`\n=== ${newJobs.length} Ruby remote jobs ===\n`);
for (const j of newJobs) {
console.log(`${j.title} | ${j.company} | ${j.location} | ${j.link}`);
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bun
import { KEYWORDS, fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
// Re-export KEYWORDS isn't in fetch.ts, import from config
import { KEYWORDS as CFG_KEYWORDS } from "../config.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",
};
const SAAS_RE = /\bsaas\b/i;
async function hasSaaS(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);
return SAAS_RE.test(text);
} catch { return false; }
}
const RUBY_KEYWORDS = [
"Ruby on Rails", "Rails Engineer", "Rails Developer",
"Ruby Engineer", "Ruby Developer", "Ruby",
].join(" OR ");
// --- Part 1: Ruby/Rails in Switzerland ---
const rubySearches: SearchConfig[] = [
{ label: "Basel Ruby (onsite+hybrid)", keywords: RUBY_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Ruby (remote)", keywords: RUBY_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" },
{ label: "Zürich Ruby (onsite+hybrid)",keywords: RUBY_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Ruby (remote)", keywords: RUBY_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Switzerland Ruby (remote)", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Bern Ruby", keywords: RUBY_KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid", "remote"], timePosted: "month" },
];
// --- Part 2: SaaS-experience jobs in Switzerland ---
const saasSearches: SearchConfig[] = [
{ label: "Basel SaaS", keywords: CFG_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metro SaaS", keywords: CFG_KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich SaaS", keywords: CFG_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Switzerland SaaS remote", keywords: CFG_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
async function runSearches(searches: SearchConfig[], label: string, filterFn?: (id: string) => Promise<boolean>) {
const results: Job[] = [];
const seen = new Set<string>();
console.log(`\n${"=".repeat(50)}\n${label}\n${"=".repeat(50)}`);
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)) continue;
seen.add(job.id);
if (filterFn) {
process.stdout.write(` ${job.title} @ ${job.company} … `);
const pass = await filterFn(job.id);
if (!pass) { console.log("no SaaS"); continue; }
console.log("SaaS ✓");
}
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} added`);
}
return results;
}
const rubyJobs = await runSearches(rubySearches, "RUBY / RAILS IN SWITZERLAND");
const saasJobs = await runSearches(saasSearches, "SaaS-EXPERIENCE JOBS IN SWITZERLAND", hasSaaS);
console.log(`\n\n${"=".repeat(50)}\nRUBY RESULTS: ${rubyJobs.length}\n${"=".repeat(50)}`);
for (const j of rubyJobs) console.log(`${j.title} | ${j.company} | ${j.id} | ${j.link}`);
console.log(`\n${"=".repeat(50)}\nSaaS RESULTS: ${saasJobs.length}\n${"=".repeat(50)}`);
for (const j of saasJobs) console.log(`${j.title} | ${j.company} | ${j.id} | ${j.link}`);
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bun
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 RUBY_KEYWORDS = "Ruby on Rails OR Ruby Developer OR Rails Developer OR Backend Ruby OR Ruby Engineer";
const searches: SearchConfig[] = [
{ label: "Basel Ruby", keywords: RUBY_KEYWORDS, geoId: "90009885", timePosted: "month" },
{ label: "Zürich Ruby", keywords: RUBY_KEYWORDS, geoId: "107814425", timePosted: "month" },
{ label: "Bern Ruby", keywords: RUBY_KEYWORDS, location: "Bern, Switzerland", timePosted: "month" },
{ label: "CH remote Ruby", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
const RUBY_RE = /\bruby\b|\brails\b/i;
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 results"); 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 (lang)"); continue; }
// fetch description to confirm Ruby/Rails mention
const html = await fetch(`https://www.linkedin.com/jobs/view/${job.id}/`).then(r => r.text()).catch(() => "");
if (!RUBY_RE.test(html)) { console.log("dropped (no ruby/rails in desc)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bun
import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.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();
}
// Same as fetch.ts parseJobs but WITHOUT the Switzerland location guard
function parseJobs(html: string, label: string) {
const jobs: { id: string; title: string; company: string; link: string; location: string }[] = [];
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() : "";
jobs.push({ id, title: clean(titleM[1]), company: companyM ? clean(companyM[1]) : "", location: jobLocation, link: `https://www.linkedin.com/jobs/view/${id}/` });
}
return jobs;
}
async function fetchJobsNoGeo(config: SearchConfig) {
const baseUrl = buildUrl(config);
const all: { id: string; title: string; company: string; location: string; link: string }[] = [];
const seen = new Set<string>();
for (let page = 0; page < 6; page++) {
if (page > 0) await Bun.sleep(400);
const url = `${baseUrl}&start=${page * 25}`;
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, 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 => SOFTWARE_TITLE_RE.test(j.title));
let newCount = 0;
for (const job of jobs) {
if (!seen.has(job.id)) { seen.add(job.id); all.push(job); newCount++; }
}
console.log(`${jobs.length} jobs, ${newCount} new`);
if (newCount === 0) break;
}
return all;
}
const RUBY_KEYWORDS = [
"Ruby on Rails",
"Rails Engineer",
"Rails Developer",
"Ruby Engineer",
"Ruby Developer",
"Tech Lead Ruby",
"Lead Engineer Ruby",
"Staff Ruby",
"Principal Ruby",
].join(" OR ");
const searches: SearchConfig[] = [
{ label: "USA Remote Ruby/Rails", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], timePosted: "month" },
{ label: "USA Remote Ruby/Rails mid-senior+", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; location: 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 fetchJobsNoGeo(config);
if (jobs.length === 0) { console.log(" no jobs found"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id)) continue;
seen.add(job.id);
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.title} | ${j.company} | ${j.location} | ${j.id} | ${j.link}`);
}
+55
View File
@@ -0,0 +1,55 @@
#!/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: "Switzerland remote (by name)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Basel remote", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" },
{ label: "Basel Metro remote", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["remote"], timePosted: "month" },
{ label: "Zürich remote", keywords: KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Zürich Metro remote", keywords: KEYWORDS, geoId: "107814425", location: "Zürich Metropolitan Area", workType: ["remote"], timePosted: "month" },
{ label: "Zürich Area remote", keywords: KEYWORDS, geoId: "90009888", location: "Zürich Area", workType: ["remote"], timePosted: "month" },
{ label: "Bern remote", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["remote"], timePosted: "month" },
// seniority-filtered pass for mid-senior+
{ label: "Switzerland remote (mid-senior)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], seniority: ["mid-senior", "director"], 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} remote jobs ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id} | ${j.link}`);
}
+79
View File
@@ -0,0 +1,79 @@
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 RAILS_KEYWORDS = [
"Ruby on Rails",
"Ruby Developer",
"Rails Developer",
"Ruby Engineer",
"Rails Engineer",
"Full Stack Ruby",
"Backend Ruby",
"RoR Developer",
"RoR Engineer",
].join(" OR ");
const searches: SearchConfig[] = [
{
label: "Switzerland Remote Rails (Zürich)",
keywords: RAILS_KEYWORDS,
geoId: "102436504",
location: "Zürich, Switzerland",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote Rails (by name)",
keywords: RAILS_KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote Rails (Basel)",
keywords: RAILS_KEYWORDS,
geoId: "103383283",
location: "Basel",
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, { raw: true }); // raw: bypass SOFTWARE_TITLE_RE, we want all Rails hits
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 Rails remote jobs found.\n`);
for (const r of results) {
console.log(`${r.title} | ${r.company} | ${r.link}`);
}
+71
View File
@@ -0,0 +1,71 @@
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}`);
}
+48
View File
@@ -0,0 +1,48 @@
#!/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: "Zürich city", keywords: KEYWORDS, geoId: "102436504", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Metro", keywords: KEYWORDS, geoId: "107814425", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Area", keywords: KEYWORDS, geoId: "90009888", workType: ["onsite", "hybrid"], 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 results"); 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 (lang)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}