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