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:
@@ -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}`);
|
||||
}
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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}`);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/'/g, "'").replace(/"/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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user