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:
+368
@@ -27,6 +27,19 @@ function applyFilters(title: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const MS_STACK_RE = /\.net\b|\bc#|sharepoint|dynamics 365|power platform|\bsccm\b|intune|successfactors|\bsap\b/i;
|
||||
|
||||
// DE/FR indicator words, counted against a description we already have in hand.
|
||||
// Mirrors the ≥ 6 threshold isEnglishContent uses on fetched pages.
|
||||
const DE_FR_WORDS_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;
|
||||
|
||||
function isEnglishDescription(description?: string): boolean {
|
||||
if (!description) return true; // nothing to judge — let the URL check in index.ts decide
|
||||
const text = description.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ");
|
||||
return (text.match(DE_FR_WORDS_RE) ?? []).length < 6;
|
||||
}
|
||||
|
||||
// datacareer.ch — HTML listing at /jobs/?city=Basel
|
||||
export async function fetchDataCareer(): Promise<Job[]> {
|
||||
const allJobs: Job[] = [];
|
||||
@@ -88,6 +101,361 @@ export async function fetchDataCareer(): Promise<Job[]> {
|
||||
return allJobs;
|
||||
}
|
||||
|
||||
// itjobs.ch — HTML scrape, jobs embedded as window.jobsList in page.
|
||||
// Swept Switzerland-wide (no location filter): the board is small, ~210 jobs
|
||||
// across 7 pages, and locking it to Basel returned only ~9.
|
||||
// Each item carries its full description, so the DE/FR content check runs here
|
||||
// instead of costing an extra page fetch per job in index.ts.
|
||||
export async function fetchItJobsCh(): Promise<Job[]> {
|
||||
const allJobs: Job[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let page = 1; page <= 25; page++) {
|
||||
if (page > 1) await Bun.sleep(400);
|
||||
process.stdout.write(` page ${page} … `);
|
||||
|
||||
const res = await fetch(
|
||||
`https://www.itjobs.ch/jobs?page=${page}`,
|
||||
{ headers: HEADERS }
|
||||
);
|
||||
// The board 404s one page past the end rather than serving an empty list.
|
||||
if (!res.ok) { console.log(`end (${res.status})`); break; }
|
||||
|
||||
const html = await res.text();
|
||||
const blocks = [...html.matchAll(/window\.jobsList\s*=\s*window\.jobsList\.concat\((\[[\s\S]*?\])\);/g)];
|
||||
if (!blocks.length) { console.log("0 items"); break; }
|
||||
|
||||
const items: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
posted_at: string;
|
||||
job_details_path: string;
|
||||
employer: { name: string };
|
||||
location?: string;
|
||||
job_location?: { name?: string };
|
||||
}> = [];
|
||||
for (const [, json] of blocks) {
|
||||
try { items.push(...JSON.parse(json)); } catch { /* skip malformed block */ }
|
||||
}
|
||||
|
||||
if (!items.length) { console.log("0 items"); break; }
|
||||
|
||||
let newOnPage = 0;
|
||||
let langDropped = 0;
|
||||
for (const item of items) {
|
||||
const title = decodeHtml(item.title);
|
||||
if (!applyFilters(title)) continue;
|
||||
if (MS_STACK_RE.test(title)) continue;
|
||||
|
||||
const id = `ij-${item.id}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
if (!isEnglishDescription(item.description)) { langDropped++; continue; }
|
||||
|
||||
allJobs.push({
|
||||
id,
|
||||
title,
|
||||
company: item.employer?.name ?? "",
|
||||
// job_location.name is the English form ("Aarau, Aargau, Switzerland");
|
||||
// the bare location field is German ("Aarau, Kanton Aargau, Schweiz").
|
||||
location: item.job_location?.name ?? item.location ?? "Switzerland",
|
||||
postedAt: item.posted_at?.slice(0, 10) ?? "",
|
||||
link: `https://www.itjobs.ch${item.job_details_path}`,
|
||||
search: "itjobs.ch",
|
||||
source: "itjobs.ch",
|
||||
contentChecked: true, // isEnglishDescription already ran on the real description
|
||||
});
|
||||
newOnPage++;
|
||||
}
|
||||
|
||||
console.log(`${items.length} items, ${newOnPage} new, ${langDropped} non-English`);
|
||||
if (items.length < 30) break;
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
}
|
||||
|
||||
// job-room.ch — the federal employment service board (RAV / Arbeitsvermittlung).
|
||||
// Roles under the Stellenmeldepflicht must be posted here before anywhere else,
|
||||
// so it often carries a job days ahead of LinkedIn.
|
||||
//
|
||||
// Quirks worth knowing:
|
||||
// - Each hit is wrapped: [{ jobAdvertisement: {...} }, …]
|
||||
// - Titles come back with <em> highlighting around the matched keyword.
|
||||
// - `languageIsoCode` on a description is unreliable — plenty of records tagged
|
||||
// "de" carry English text (Roche, Novartis, Bachem all do). The DE/FR word
|
||||
// count is what actually decides, so we ignore the tag.
|
||||
// - An unfiltered canton sweep returns thousands of ads across every industry,
|
||||
// so we narrow by keyword instead and union the results.
|
||||
// - Descriptions arrive inline, so the language check runs here rather than
|
||||
// costing a page fetch per job in index.ts.
|
||||
const JOBROOM_API = "https://www.job-room.ch/jobadservice/api/jobAdvertisements/_search";
|
||||
const JOBROOM_CANTONS = ["BS", "BL", "BE", "ZH"];
|
||||
const JOBROOM_KEYWORDS = [
|
||||
"software", "engineer", "developer", "devops", "data", "python",
|
||||
"cloud", "platform", "typescript", "backend", "full stack", "ai",
|
||||
];
|
||||
|
||||
// job-room carries every industry; pharma/lab titles slip past SOFTWARE_TITLE_RE
|
||||
// via words like "data", "director" and "technical".
|
||||
const PHARMA_ROLE_RE =
|
||||
/\b(clinical|pharmaceutical|drug\s+(substance|product)|ha?ematology|oncology|formulation|laborator|lab\s+technician|chemist|chemistry|biolog|preclinical|regulatory\s+affairs|medical\s+affairs|commercial\s+launch|veterinary|pathologist)\b/i;
|
||||
|
||||
interface JobRoomAd {
|
||||
id: string;
|
||||
createdTime?: string;
|
||||
jobContent?: {
|
||||
externalUrl?: string | null;
|
||||
jobDescriptions?: Array<{ title: string; description?: string }>;
|
||||
company?: { name?: string; city?: string };
|
||||
location?: { city?: string; cantonCode?: string };
|
||||
};
|
||||
}
|
||||
|
||||
// jobs.ch links arrive with a tracking query string that would break dedup
|
||||
// across runs if it ever changes; the path alone identifies the posting.
|
||||
function stripTracking(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
for (const k of [...u.searchParams.keys()]) {
|
||||
if (k.startsWith("utm_")) u.searchParams.delete(k);
|
||||
}
|
||||
return u.toString().replace(/\?$/, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJobRoom(): Promise<Job[]> {
|
||||
const ads = new Map<string, JobRoomAd>();
|
||||
|
||||
for (const keyword of JOBROOM_KEYWORDS) {
|
||||
process.stdout.write(` ${keyword} … `);
|
||||
let total = 0;
|
||||
for (let page = 0; page < 8; page++) {
|
||||
if (page > 0) await Bun.sleep(250);
|
||||
const res = await fetch(`${JOBROOM_API}?page=${page}&size=100`, {
|
||||
method: "POST",
|
||||
headers: { ...HEADERS, "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
onlineSince: 30,
|
||||
displayRestricted: false,
|
||||
keywords: [keyword],
|
||||
cantonCodes: JOBROOM_CANTONS,
|
||||
workloadPercentageMin: 0,
|
||||
workloadPercentageMax: 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) { console.log(`failed (${res.status})`); break; }
|
||||
|
||||
const hits = (await res.json()) as Array<{ jobAdvertisement?: JobRoomAd }>;
|
||||
for (const h of hits) {
|
||||
if (h.jobAdvertisement?.id) ads.set(h.jobAdvertisement.id, h.jobAdvertisement);
|
||||
}
|
||||
total += hits.length;
|
||||
if (hits.length < 100) break;
|
||||
}
|
||||
console.log(`${total} ads (${ads.size} unique)`);
|
||||
await Bun.sleep(250);
|
||||
}
|
||||
|
||||
const jobs: Job[] = [];
|
||||
let langDropped = 0;
|
||||
for (const ad of ads.values()) {
|
||||
const content = ad.jobContent;
|
||||
const desc = content?.jobDescriptions?.[0];
|
||||
if (!desc) continue;
|
||||
|
||||
// Strip the <em> highlighting the search API wraps around matched terms.
|
||||
const title = decodeHtml(desc.title.replace(/<[^>]+>/g, ""));
|
||||
if (!applyFilters(title)) continue;
|
||||
if (MS_STACK_RE.test(title)) continue;
|
||||
if (PHARMA_ROLE_RE.test(title)) continue;
|
||||
|
||||
// A handful of ads ship without a description. isEnglishDescription passes
|
||||
// those through by design, so leave contentChecked unset and let index.ts
|
||||
// fall back to fetching the page.
|
||||
const body = desc.description ?? "";
|
||||
const hasBody = body.trim().length > 40;
|
||||
if (hasBody && !isEnglishDescription(body)) { langDropped++; continue; }
|
||||
|
||||
const city = content?.location?.city ?? content?.company?.city ?? "";
|
||||
const canton = content?.location?.cantonCode ?? "";
|
||||
|
||||
jobs.push({
|
||||
id: `jr-${ad.id}`,
|
||||
title,
|
||||
company: content?.company?.name ?? "",
|
||||
location: [city, canton].filter(Boolean).join(", ") || "Switzerland",
|
||||
postedAt: ad.createdTime?.slice(0, 10) ?? "",
|
||||
link: content?.externalUrl
|
||||
? stripTracking(content.externalUrl)
|
||||
: `https://www.job-room.ch/job-search/${ad.id}`,
|
||||
search: "job-room.ch",
|
||||
source: "job-room.ch",
|
||||
contentChecked: hasBody, // only skip the re-check when we really judged a description
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ${ads.size} ads scanned, ${jobs.length} kept, ${langDropped} non-English`);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// International organisations in the Basel region (BIS, Swiss TPH, Basel
|
||||
// Institute on Governance). Each board carries only a handful of postings, so
|
||||
// they're swept together under one label rather than one entry apiece.
|
||||
//
|
||||
// These are mission-driven employers whose tech roles use UN-flavoured titles
|
||||
// ("Information Management Officer", "ICT Specialist") that SOFTWARE_TITLE_RE
|
||||
// alone would drop, so INTL_TECH_RE widens the net for these boards only.
|
||||
// Boards this small can afford the extra noise; missing a rare opening can't.
|
||||
const INTL_TECH_RE =
|
||||
/\b(information\s*(management|systems?|technology)|ict\b|digital|geospatial|\bmis\b|data\s*(management|steward))\b/i;
|
||||
|
||||
function applyIntlFilters(title: string): boolean {
|
||||
if (NON_ENGLISH_RE.test(title)) return false;
|
||||
if (JUNIOR_TITLE_RE.test(title)) return false;
|
||||
if (MS_STACK_RE.test(title)) return false;
|
||||
return SOFTWARE_TITLE_RE.test(title) || INTL_TECH_RE.test(title);
|
||||
}
|
||||
|
||||
// BIS runs a JS-rendered career site with no usable API, so we read the UNjobs
|
||||
// mirror of its Basel office instead. Titles arrive as "Role, City, Country".
|
||||
async function fetchBis(): Promise<Job[]> {
|
||||
const jobs: Job[] = [];
|
||||
const res = await fetch(
|
||||
"https://unjobs.org/offices/bis_182320855689338880",
|
||||
{ headers: HEADERS }
|
||||
);
|
||||
if (!res.ok) { console.log(` BIS … failed (${res.status})`); return jobs; }
|
||||
|
||||
const html = await res.text();
|
||||
const cards = [...html.matchAll(
|
||||
/<a[^>]+href="(https:\/\/unjobs\.org\/vacancies\/(\d+))"[\s\S]*?<span\s+class="jtitle">([^<]+)<\/span>([\s\S]*?)<\/a>/g
|
||||
)];
|
||||
|
||||
let kept = 0;
|
||||
for (const [, link, id, rawTitle, rest] of cards) {
|
||||
// "Senior Platform Engineer, Basel, Switzerland" → title + location
|
||||
const split = decodeHtml(rawTitle).match(/^(.*?),\s*([^,]+,\s*[^,]+)$/);
|
||||
const title = split ? split[1] : decodeHtml(rawTitle);
|
||||
const location = split ? split[2] : "Basel, Switzerland";
|
||||
if (!applyIntlFilters(title)) continue;
|
||||
|
||||
jobs.push({
|
||||
id: `bis-${id}`,
|
||||
title,
|
||||
company: "Bank for International Settlements",
|
||||
location,
|
||||
postedAt: rest.match(/datetime="(\d{4}-\d{2}-\d{2})/)?.[1] ?? "",
|
||||
link,
|
||||
search: "bis.org",
|
||||
source: "bis.org",
|
||||
contentChecked: true, // BIS publishes in English only
|
||||
});
|
||||
kept++;
|
||||
}
|
||||
|
||||
console.log(` BIS … ${cards.length} ads, ${kept} kept`);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// Swiss TPH runs Umantis. Site chrome is German even on English postings, so
|
||||
// isEnglishUrl would reject the lot — the title filters decide instead.
|
||||
async function fetchSwissTph(): Promise<Job[]> {
|
||||
const jobs: Job[] = [];
|
||||
const res = await fetch("https://jobs.swisstph.ch/Jobs/All", { headers: HEADERS });
|
||||
if (!res.ok) { console.log(` Swiss TPH … failed (${res.status})`); return jobs; }
|
||||
|
||||
const html = await res.text();
|
||||
// One block per posting, opened by the subtitle link that carries the title.
|
||||
const blocks = html.split(/(?=<a\s[^>]*class="HSTableLinkSubTitle")/).slice(1);
|
||||
|
||||
let kept = 0;
|
||||
for (const block of blocks) {
|
||||
const hrefM = block.match(/href="(\/Vacancies\/(\d+)\/Description\/\d+)"/);
|
||||
const titleM = block.match(/class="HSTableLinkSubTitle"[^>]*>([^<]+)</);
|
||||
if (!hrefM || !titleM) continue;
|
||||
|
||||
const title = decodeHtml(titleM[1]);
|
||||
if (!applyIntlFilters(title)) continue;
|
||||
|
||||
// "Online seit" label and its value sit in separate spans sharing an id suffix.
|
||||
const dateM = block.match(/Online seit<\/span>[\s\S]{0,300}?>\s*(\d{2})\.(\d{2})\.(\d{4})/);
|
||||
const locM = block.match(/>\s*Schweiz\s*-\s*([^<]+)</);
|
||||
|
||||
jobs.push({
|
||||
id: `tph-${hrefM[2]}`,
|
||||
title,
|
||||
company: "Swiss TPH",
|
||||
location: locM ? `${locM[1].trim()}, Switzerland` : "Allschwil, Switzerland",
|
||||
postedAt: dateM ? `${dateM[3]}-${dateM[2]}-${dateM[1]}` : "",
|
||||
link: `https://jobs.swisstph.ch${hrefM[1]}`,
|
||||
search: "swisstph.ch",
|
||||
source: "swisstph.ch",
|
||||
contentChecked: true, // German site chrome would fail the page-level check
|
||||
});
|
||||
kept++;
|
||||
}
|
||||
|
||||
console.log(` Swiss TPH … ${blocks.length} ads, ${kept} kept`);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// Basel Institute on Governance publishes each vacancy as a linked PDF, so
|
||||
// there is no HTML description to language-check downstream.
|
||||
async function fetchBaselGovernance(): Promise<Job[]> {
|
||||
const jobs: Job[] = [];
|
||||
const res = await fetch("https://baselgovernance.org/about/careers", { headers: HEADERS });
|
||||
if (!res.ok) { console.log(` Basel Governance … failed (${res.status})`); return jobs; }
|
||||
|
||||
const html = await res.text();
|
||||
// The `rounded-3xl` card class is what separates vacancy tiles from other
|
||||
// asset links on the page (the annual report PDF sits in a plain anchor).
|
||||
const cards = [...html.matchAll(
|
||||
/<a\s+href="(https:\/\/baselgovernance\.org\/cms\/api\/assets\/([^\/]+)\/[^"]+)"[^>]*class="[^"]*rounded-3xl[^"]*"[\s\S]{0,400}?<h2[^>]*>([^<]+)<\/h2>([\s\S]{0,900})/g
|
||||
)];
|
||||
|
||||
let kept = 0;
|
||||
for (const [, link, assetId, rawTitle, rest] of cards) {
|
||||
const title = decodeHtml(rawTitle);
|
||||
if (!applyIntlFilters(title)) continue;
|
||||
|
||||
const locM = rest.match(/map-pin[\s\S]{0,200}?<\/span>\s*([^<]+)</);
|
||||
|
||||
jobs.push({
|
||||
id: `big-${assetId}`,
|
||||
title,
|
||||
company: "Basel Institute on Governance",
|
||||
location: locM ? decodeHtml(locM[1]) : "Basel, Switzerland",
|
||||
postedAt: "", // board shows the closing date only
|
||||
link,
|
||||
search: "baselgovernance.org",
|
||||
source: "baselgovernance.org",
|
||||
contentChecked: true, // postings are PDFs, and the institute publishes in English
|
||||
});
|
||||
kept++;
|
||||
}
|
||||
|
||||
console.log(` Basel Governance … ${cards.length} ads, ${kept} kept`);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export async function fetchBaselIntlOrgs(): Promise<Job[]> {
|
||||
const jobs: Job[] = [];
|
||||
for (const fetchOrg of [fetchBis, fetchSwissTph, fetchBaselGovernance]) {
|
||||
try {
|
||||
jobs.push(...(await fetchOrg()));
|
||||
} catch (e) {
|
||||
console.log(` error: ${e}`);
|
||||
}
|
||||
await Bun.sleep(400);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// stellen-basel.ch — WordPress REST API
|
||||
export async function fetchStellenBasel(): Promise<Job[]> {
|
||||
const allJobs: Job[] = [];
|
||||
|
||||
@@ -16,6 +16,6 @@ Over the next weeks I'll be sharing side projects I've been building and just sh
|
||||
|
||||
---
|
||||
|
||||
#1 swissviz — an interactive map of every company registered in Switzerland. Every dot is a real commercial register entity. Zoom into any canton, from Basel, Zürich, or the Valais valleys and the economic density becomes visible like city lights at night. No database or special server, just your browser.
|
||||
#1 SwissViz — an interactive map of every company registered in Switzerland. Every dot is a real commercial register entity. Zoom into any canton, from Basel, Zürich, or the Valais valleys and the distinct economic density becomes visible like city lights at night. No database or special server, all happens in your browser.
|
||||
|
||||
🔗 github.com/rafapolo/swissviz
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
|
||||
---
|
||||
|
||||
I scanned 2.5 million Swiss .ch domains.
|
||||
Yesterday Antropic released Claude Fable as a way to increase the cyberguards around the so-called "cybersecurity that could be misused to cause serious damage" that their unreleased model could provoke.
|
||||
|
||||
Not randomly — every registered domain in the country, systematically. The goal was to understand the real shape of Switzerland's web infrastructure: what's exposed, what's misconfigured, and where the risk concentrates.
|
||||
Three months ago, when Claude Mythos was not even known, I have used a mix of Claude Sonnet and the chinese MiniMax.io to develop a project to submit for the **Swiss Innovation Challenge**, this became Helvetiscan - A complete map of Switzerland's digital surface: unpatched software, expired certificates, exposed databases, foreign dependencies, fakeable emails, weak or no encryption, and other previously uncharted vulnerabilities.
|
||||
|
||||
The results were more interesting than expected. A significant share of domains have outdated TLS configurations, exposed admin panels, or missing security headers. Not dramatic zero-days — just the quiet rot that accumulates when no one is looking.
|
||||
In my first post I mapped every company registered in Switzerland. Now, I went a layer further and mapped all existing ~2.5 million swiss .ch domains using a ultra fast programming language I've personally never used before but harnessing LLMs allowed to act more as a Product Designer than a Programmer. The main part of the Mapping took less than 4h.
|
||||
|
||||
**helvetiscan** is the tool I built to do it. Open source, written in Python, designed to be run incrementally so the dataset stays current.
|
||||
The results were more interesting than expected. It was aproved for participation but soon became clear a solo developer without a solid business model would have real chances to get a fund to move it futher. Fair enough, but if a solo developer enjoying his patternity leave could do in all in few days when his baby goes to sleep, now its clear what "malucionous actors" are doing right now.
|
||||
|
||||
If you work in Swiss infosec, infrastructure, or just find this kind of data interesting — the repo is open.
|
||||
Some AI models didn't denied me to write exploits and actively try to break into those millions of found vulnerabilities, but I wont take a step further and am releasing publiclyt the Scanner and the Key Findings at https://github.com/rafapolo/helvetiscan
|
||||
|
||||
As a Software Engineer, mostly of my carrer had to be focused on the 'How' the requirements shold be build, it is stil needed as we keep guiding and validating the Agents outputs, but as Agentic Engineering gets betters each month there is a huge shift into the 'What' and 'Why'.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
If you work in infosec, infrastructure, or just find this kind of data interesting — the repo is open.
|
||||
|
||||
🔗 github.com/rafapolo/helvetiscan
|
||||
|
||||
---
|
||||
|
||||
**GitHub repo checklist before posting:**
|
||||
- [ ] Add social preview image (1280×640px) in repo Settings → Social Preview — a map visualization stops the scroll on LinkedIn
|
||||
- [ ] Add 1-2 screenshots to the README for people who click through
|
||||
|
||||
@@ -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