diff --git a/.gitignore b/.gitignore index d3c5f24..2bb3c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ node_modules/ -jobs/ -jobs.csv .env +.DS_Store + +# job data and personal notes +results/ +jobs/ + +# CV files (large binaries + personal) +cv/*.pdf +cv/*.odt +cv/*.html diff --git a/CLAUDE.md b/CLAUDE.md index 3701267..e0ce77f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,92 +1,152 @@ -# linkme — Claude skill +# CLAUDE.md -This project scrapes LinkedIn jobs and filters/ranks them for the user's job search. -The user's CV is at `polo.md`. Always use it when ranking opportunities. - ---- +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands | command | what it does | |---|---| -| `bun fetch` | scrape Basel area (3 searches), append new jobs to `jobs.csv` | +| `bun fetch` | run all searches in `config.ts`, append new jobs to `results/jobs.csv` | | `bun recheck` | fetch in raw mode, list filtered-out jobs + word-frequency keyword suggestions | ---- +Temp scripts: `bun run scripts/-temp.ts` — never edit `config.ts` for one-off searches. -## Searching any city temporarily +## Structure -Write a throwaway script (e.g. `bern-temp.ts`) — never touch `config.ts` unless the user explicitly asks to save the change. - -```ts -import { KEYWORDS } from "./config.ts"; -import { fetchJobs } from "./fetch.ts"; -import type { SearchConfig } from "./config.ts"; - -const searches: SearchConfig[] = [ - { label: "City Name", keywords: KEYWORDS, location: "City, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" }, -]; -// collect, deduplicate, print +``` +config.ts # search keywords and areas +fetch.ts # scraper, HTML parser, all filters +fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch) +index.ts # orchestrator: runs searches, writes CSV +recheck.ts # raw-mode fetch, lists filtered-out jobs +scripts/ # one-off search scripts; import from ../config.ts and ../fetch.ts +cv/polo.md # Rafael's profile — use for ranking jobs +cv/ # CV files (html, pdf, odt) +results/jobs.csv # output — one row per job +jobs/ # notes on specific job leads +plans/ # LinkedIn post drafts ``` -Use `location: "City, Switzerland"` (no geoId) when the geoId is unknown — LinkedIn resolves by name. +## Architecture + +- **`config.ts`** — `KEYWORDS` string (joined with ` OR `) and `searches: SearchConfig[]`. Edit here to add/remove search areas or keywords. +- **`fetch.ts`** — HTTP scraping, HTML parsing, and all filters. Exports `fetchJobs(config, { raw? })` and `isEnglishContent(id)`. +- **`fetch-other.ts`** — scrapes extra sources (datacareer.ch, stellen-basel.ch). Imported by `index.ts`. +- **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV. +- **`recheck.ts`** — calls `fetchJobs(..., { raw: true })` to bypass `SOFTWARE_TITLE_RE`, then lists every new job that *would* be filtered out and prints word-frequency suggestions. + +### Filter pipeline (applied in order) + +1. **Switzerland location guard** (card HTML) — extracts `job-search-card__location` from each card; drops jobs where location doesn't contain "Switzerland". Prevents LinkedIn from returning global results when geoId is ignored on unauthenticated requests. +2. **`NON_ENGLISH_RE`** (title) — drops German gender markers, umlauts, `/in` suffix, French accents/markers. Runs inside `fetchJobs`. +3. **`JUNIOR_TITLE_RE`** (title) — drops junior/intern/trainee/werkstudent. Runs inside `fetchJobs`. +4. **`JAVA_TITLE_RE`** (title) — drops Java-titled roles. Runs inside `fetchJobs`. +5. **`SOFTWARE_TITLE_RE`** (title) — requires a relevant tech/role term. Skipped when `raw: true`. Runs inside `fetchJobs`. +6. **`isEnglishContent`** (description page) — fetches the job page, counts DE/FR indicator words, drops if ≥ 6 matches. Runs in `index.ts` only (not `recheck.ts`). Adds 350 ms delay per job. + +Deduplication: job IDs are extracted from `results/jobs.csv` on startup; any ID already seen is skipped before content-checking. + +### SearchConfig + +`url` overrides all filter params. Otherwise build from: `keywords`, `geoId`/`location`, `workType`, `jobType`, `timePosted`, `seniority`. Known Swiss geoIds: - Basel city: `103383283` - Basel Metropolitan Area: `90009885` - Basel-Country: `107117530` +- Zürich city: `102436504` +- Zürich Metropolitan Area: `107814425` +- Zürich Area: `90009888` ---- +Use `location: "City, Switzerland"` (no `geoId`) when the geoId is unknown — LinkedIn resolves by name. -## Filtering job descriptions by language +**Auth warning:** Do NOT send the `li_at` cookie in job search requests (`/jobs/search/`). With the cookie, LinkedIn serves the React SPA instead of server-rendered HTML, breaking the card parser entirely. The cookie is only useful for individual profile pages. -Use the `bern-langcheck.ts` pattern: fetch each job page, extract the description, count German/French indicator words, drop anything with ≥ 6 matches. +## Searching any city / mode temporarily + +Write a throwaway script in `scripts/` (e.g. `scripts/bern-temp.ts`) — **never touch `config.ts`** unless the user explicitly asks to save the change. Delete temp scripts after the session unless the user asks to keep them. + +Common patterns: ```ts -const DE_CONTENT_RE = /\b(Sie|Ihre?|Ihnen|Wir|wir|und\s|für|mit\s|von\s|bei\s|das\s|ist\s|sind\s|haben\s|werden\s|können|suchen|bieten|Kenntnisse|Erfahrung|Anforderungen|Aufgaben|Stellenbeschreibung|Stellenangebot|Bewerbung|Ausbildung|Berufserfahrung|Teamarbeit|Quereinsteiger|Vollzeit|Teilzeit|unbefristet|Abschluss|gesucht|Deutsch|Englisch|Schweizer)\b/g; -const FR_CONTENT_RE = /\b(vous|nous|votre|notre|pour\s|avec\s|profil|poste|emploi|entreprise|compétences|expérience|formation|équipe|rejoindre|recherchons|offrons|missions|candidature|CDI|CDD|temps\s+plein|temps\s+partiel)\b/g; +// City search (onsite + hybrid) +import { KEYWORDS } from "../config.ts"; +import { fetchJobs } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const config: SearchConfig = { + label: "Bern", keywords: KEYWORDS, location: "Bern, Switzerland", + workType: ["onsite", "hybrid"], timePosted: "month", +}; +const jobs = await fetchJobs(config); +for (const j of jobs) console.log(j.title, j.company, j.link); ``` -Extract description from `class="show-more-less-html__markup"`, fall back to stripping all HTML. Add 350 ms delay between requests. +```ts +// Remote-only across all Switzerland +const config: SearchConfig = { + label: "Switzerland Remote", keywords: KEYWORDS, location: "Switzerland", + workType: ["remote"], timePosted: "month", +}; +``` -Title-only filtering runs during scraping via `NON_ENGLISH_RE` in `fetch.ts`. Content-level filtering via `isEnglishContent()` is now always applied in `index.ts` before writing to CSV — every new job has its description page fetched and checked. +```ts +// Description-level keyword check (e.g. Ruby/Rails) +const RUBY_RE = /\bruby\b|\brails\b/i; +// fetch each job page, test RUBY_RE against the description text +``` ---- +Extra filters for temp scripts (import from `../fetch.ts`): +- `JAVA_TITLE_RE` — drop Java roles +- `JUNIOR_TITLE_RE` — drop junior/intern +- `isEnglishContent(id)` — drop DE/FR description content +- Add your own inline RE for EMEA, .NET, etc. ## Ranking jobs against the CV -Read `polo.md` for the full profile. Key signals to match against: +Read `cv/polo.md` for the full profile. Key signals: - **Seniority**: Senior / Staff / Principal / Lead / Architect — weight titles accordingly - **Core stack**: Python · TypeScript · Ruby on Rails · React · Node.js - **Domain strengths**: platform engineering · data pipelines · AI/LLM integrations · DevOps (Docker, Kubernetes, CI/CD, AWS) - **Background edges**: fintech/CBDC (Moedax), civic tech (Tactical Tech / GFOSS), independent consulting (ExtraPolo, 40+ projects) -- **Languages**: English (full) · German B1 TELC certified · Spanish (full) — flag roles that require C1+ German +- **Languages**: English (full) · German B1 TELC certified · Spanish (full) — flag roles requiring C1+ German - **Location**: based in Basel, open to Basel / Bern / Zürich; EU citizen, no permit needed ---- - ## Output format -Always present jobs as a markdown table with columns: `#`, `Title`, `Company`, `Why it fits`, `Link`. +Present jobs as a markdown table: `#`, `Title`, `Company`, `Why it fits`, `Link`. -Links use just the job ID as the anchor text: -``` -[4392954199](https://www.linkedin.com/jobs/view/4392954199/) +Links use just the job ID as anchor text: `[4192954199](https://www.linkedin.com/jobs/view/4192954199/)` + +## Workflow: "find jobs in X city / remote / custom search" + +1. Write a temp script in `scripts/` with the right `SearchConfig` (city, workType, custom keywords). +2. Apply extra inline filters as needed: EMEA, .NET, description-level keyword check. +3. Run `isEnglishContent` per job to drop DE/FR content. +4. Rank survivors against `cv/polo.md`, present as a table. +5. Delete temp scripts after the session unless asked to keep them. + +## LinkedIn session token + +`.env` contains `LI_AT` — the LinkedIn session cookie (`li_at` value). Use it to fetch authenticated LinkedIn pages via curl: + +```bash +curl -s "https://www.linkedin.com/in/" \ + -H "Cookie: li_at=$(grep LI_AT .env | cut -d= -f2-)" \ + -H "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" \ + --compressed -o /tmp/profile.html ``` ---- +**What works:** Full HTML pages for profiles that don't 999-block (varies by profile). Extract data with Python regex on the HTML. -## Workflow for "find jobs in X city" +**What doesn't:** LinkedIn Voyager API returns 401/403 (CSRF issues). `ch.linkedin.com` slugs sometimes work when `www.linkedin.com` returns HTTP 999. Public profiles without login redirect to a 1530-byte JS stub — no useful data. -1. Write a temp script for that city, run it, collect all passing jobs. -2. If the user says "English only" or "filter language": run the content-language check script against the results. -3. Rank survivors against `polo.md`, present top N as a table. -4. Delete the temp scripts after the session unless the user asks to keep them. +**Tip:** Profile data is embedded as escaped JSON in the HTML. Use `html.unescape()` + regex for `"headline"`, `"locationName"`, `defaultLocalizedName` fields. -## Workflow for "check if new keywords should be added" +## Workflow: "check if new keywords should be added" 1. Run `bun recheck`. -2. Review the filtered-out list manually — identify false negatives (real software roles that got dropped). -3. Check the word-frequency suggestions but ignore generic terms (`engineer`, `development`, `senior`). +2. Identify false negatives — real software roles that got dropped. +3. Ignore generic frequency suggestions (`engineer`, `development`, `senior`). 4. Propose specific additions to `SOFTWARE_TITLE_RE` in `fetch.ts` or words to `NON_ENGLISH_RE`. diff --git a/README.md b/README.md index 47bf1e6..b3e3400 100644 --- a/README.md +++ b/README.md @@ -5,75 +5,73 @@ Scrapes LinkedIn job listings into a local CSV. Run it daily, delete rows you've ## Usage ```bash -bun fetch # scrape and append new jobs to jobs.csv +bun fetch # scrape and append new jobs to results/jobs.csv bun recheck # show jobs currently filtered out + keyword suggestions ``` -No setup needed. Results are appended to `jobs.csv` — reruns are safe, already-seen jobs are skipped by ID. +No setup needed. Results are appended to `results/jobs.csv` — reruns are safe, already-seen jobs are skipped by ID. + +## Structure + +``` +config.ts # search keywords and areas +fetch.ts # scraper, HTML parser, filters +fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch) +index.ts # orchestrator: runs searches, writes CSV +recheck.ts # raw-mode fetch, lists filtered-out jobs +scripts/ # one-off search scripts (temp, not in config.ts) +cv/ # polo.md (profile) + CV files +results/ # jobs.csv output +jobs/ # notes on specific job leads +plans/ # LinkedIn post drafts +``` ## Output -`jobs.csv` — one row per job: +`results/jobs.csv` — one row per job: | column | description | |---|---| | `date_found` | date you ran the fetch | -| `posted_at` | date the job was posted on LinkedIn | +| `posted_at` | date the job was posted | | `search` | which search config matched it | | `title` | job title | | `company` | hiring company | -| `link` | canonical LinkedIn job URL | +| `link` | canonical job URL | +| `source` | site the job came from | Open in any spreadsheet app. Delete rows as you apply or dismiss. ## How it works -- **Paginated scraping** — fetches up to 6 pages per search (150 jobs) with a 400 ms delay between pages to avoid rate limiting -- **Card-boundary-safe parsing** — each job card is isolated before extracting fields, preventing content from adjacent cards bleeding into company or date values -- **Language filter** — drops German (`m/w/d`, umlauts, gender-marked vocabulary) and French (`H/F`, accented characters) job titles automatically -- **Software-role filter** — requires the title to contain a relevant term (software, developer, data, AI, backend, frontend, devops, platform, architect, tech lead, typescript, python, rails, node.js, ML, cloud, security, SRE, solutions, consultant, applications/system engineer, director, product/IT/engineering manager, staff/principal engineer, CTO, VP engineering, etc.) — eliminates aviation, mechanical, and pharma-process roles that match "Engineer" broadly -- **Seniority filter** — drops junior, jr., intern, trainee, apprentice, werkstudent, and entry-level titles -- **Global deduplication** — job IDs are indexed from the existing CSV on startup; jobs you've already seen are never re-added, even if you re-run -- **30-day recency** — only fetches jobs posted in the last month (`f_TPR=r2592000`) +- **Paginated scraping** — fetches up to 6 pages per search (150 jobs) with a 400 ms delay between pages +- **Card-boundary-safe parsing** — each job card is isolated before extracting fields +- **Language filter** — drops German (`m/w/d`, umlauts, gender markers) and French (`H/F`, accents) titles +- **Software-role filter** — requires a relevant term in the title (software, developer, data, AI, backend, frontend, devops, platform, architect, tech lead, typescript, python, rails, node.js, ML, cloud, security, SRE, etc.) +- **Seniority filter** — drops junior, intern, trainee, apprentice, werkstudent, entry-level +- **Global deduplication** — IDs indexed from existing CSV on startup; already-seen jobs are never re-added +- **30-day recency** — only jobs posted in the last month ## recheck -`bun recheck` fetches all three search areas in raw mode (bypassing the software-role filter) and lists every job that would be filtered out — useful for spotting false negatives. It also prints a word-frequency table of the most common terms in filtered titles so you can decide what to add to `SOFTWARE_TITLE_RE`. +`bun recheck` fetches all searches in raw mode (bypassing the software-role filter) and lists every job that would be filtered out — useful for spotting false negatives. Also prints a word-frequency table of filtered titles to help decide what to add to `SOFTWARE_TITLE_RE`. + +## Temporary searches + +Write a throwaway script in `scripts/` instead of editing `config.ts`: + +```bash +bun run scripts/bern-temp.ts +``` + +Scripts in `scripts/` import from `../config.ts` and `../fetch.ts`. ## Configure searches Edit `config.ts`: ```ts -export const KEYWORDS = [ - "Software Engineer", - "Solutions Engineer", - "Full Stack Engineer", - "Backend Engineer", - "Data Engineer", - "AI Engineer", - "Applications Engineer", - "Platform Engineer", - "DevOps Engineer", - "Staff Engineer", - "Principal Engineer", - "Tech Lead", - "Developer", - "Ruby on Rails", - "Python", - "Node.js", - "TypeScript", - "Artificial Intelligence", - "Machine Learning", - "Data Science", - "AI Consultant", - "ML Engineer", - "Director of Engineering", - "Engineering Manager", - "Lead Developer", - "System Engineer", - "Solution Engineer", -].join(" OR "); +export const KEYWORDS = "Software Engineer OR Data Engineer OR ..."; export const searches: SearchConfig[] = [ { @@ -84,30 +82,17 @@ export const searches: SearchConfig[] = [ workType: ["onsite", "hybrid"], timePosted: "month", }, - { - label: "Basel Metropolitan Area", - keywords: KEYWORDS, - geoId: "90009885", - location: "Basel Metropolitan Area", - workType: ["onsite", "hybrid"], - timePosted: "month", - }, - { - label: "Basel-Country", - keywords: KEYWORDS, - geoId: "107117530", - location: "Basel-Country", - workType: ["onsite", "hybrid"], - timePosted: "month", - }, ]; ``` -`geoId` values for the Basel area: +Known Swiss geoIds: - `103383283` — Basel (city) - `90009885` — Basel Metropolitan Area - `107117530` — Basel-Country +- `102436504` — Zürich (city) +- `107814425` — Zürich Metropolitan Area +- `90009888` — Zürich Area You can also paste any LinkedIn search URL directly as `url:` instead of building from filters. -`SearchConfig` also supports `jobType` (full-time, part-time, contract, temporary, internship) and `seniority` (internship, entry, associate, mid-senior, director, executive) to pass LinkedIn's native API filters. +`SearchConfig` also supports `jobType` (full-time, part-time, contract, temporary, internship) and `seniority` (internship, entry, associate, mid-senior, director, executive). diff --git a/config.ts b/config.ts index 0b4dabb..4e0f61a 100644 --- a/config.ts +++ b/config.ts @@ -72,4 +72,35 @@ export const searches: SearchConfig[] = [ workType: ["onsite", "hybrid"], timePosted: "month", }, + { + label: "Bern", + keywords: KEYWORDS, + location: "Bern, Switzerland", + workType: ["onsite", "hybrid"], + timePosted: "month", + }, + { + label: "Zürich", + 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", + }, ]; diff --git a/polo.md b/cv/polo.md similarity index 100% rename from polo.md rename to cv/polo.md diff --git a/fetch-other.ts b/fetch-other.ts new file mode 100644 index 0000000..131804e --- /dev/null +++ b/fetch-other.ts @@ -0,0 +1,143 @@ +import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, SOFTWARE_TITLE_RE } from "./fetch.ts"; +import type { Job } 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,*/*;q=0.8", +}; + +function decodeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/\s+/g, " ") + .trim(); +} + +function applyFilters(title: string): boolean { + if (NON_ENGLISH_RE.test(title)) return false; + if (JUNIOR_TITLE_RE.test(title)) return false; + if (!SOFTWARE_TITLE_RE.test(title)) return false; + return true; +} + +// datacareer.ch — HTML listing at /jobs/?city=Basel +export async function fetchDataCareer(): Promise { + const allJobs: Job[] = []; + const seen = new Set(); + + for (let page = 1; page <= 10; page++) { + if (page > 1) await Bun.sleep(400); + process.stdout.write(` page ${page} … `); + + const res = await fetch( + `https://www.datacareer.ch/jobs/?city=Basel&page=${page}`, + { headers: HEADERS } + ); + if (!res.ok) { console.error(`failed (${res.status})`); break; } + + const html = await res.text(); + const articles = [...html.matchAll(/]*listing-item[^>]*>(.*?)<\/article>/gs)]; + if (articles.length === 0) { console.log("0 items"); break; } + + let newOnPage = 0; + for (const [, card] of articles) { + const titleM = card.match(/class="link"[^>]*>([^<]+)/); + const hrefM = card.match(/href="(https:\/\/www\.datacareer\.ch\/job\/(\d+)\/[^"]*)"\s+class="link"/); + if (!titleM || !hrefM) continue; + + const title = decodeHtml(titleM[1]); + if (!applyFilters(title)) continue; + + const id = `dc-${hrefM[2]}`; + if (seen.has(id)) continue; + seen.add(id); + + const companyM = card.match(/listing-item__info--item-company[^>]*>\s*([^<\s][^<]*)/); + const locM = card.match(/listing-item__info--item-location[^>]*>\s*([^<\s][^<]*)/); + const dateM = card.match(/listing-item__date[^>]*>\s*([\d\/]+)/); + + // Date comes as DD/MM/YYYY → reformat to YYYY-MM-DD + let postedAt = ""; + const dm = dateM?.[1].match(/(\d{2})\/(\d{2})\/(\d{4})/); + if (dm) postedAt = `${dm[3]}-${dm[2]}-${dm[1]}`; + + allJobs.push({ + id, + title, + company: companyM ? decodeHtml(companyM[1].trim()) : "", + location: locM ? locM[1].trim() : "Basel", + postedAt, + link: hrefM[1], + search: "datacareer.ch", + source: "datacareer.ch", + }); + newOnPage++; + } + + console.log(`${articles.length} items, ${newOnPage} new`); + if (articles.length < 8) break; + } + + return allJobs; +} + +// stellen-basel.ch — WordPress REST API +export async function fetchStellenBasel(): Promise { + const allJobs: Job[] = []; + const seen = new Set(); + + for (let page = 1; page <= 30; page++) { + if (page > 1) await Bun.sleep(400); + process.stdout.write(` page ${page} … `); + + const res = await fetch( + `https://www.stellen-basel.ch/wp-json/wp/v2/job-listings?per_page=100&page=${page}&_fields=id,title,date,link,meta`, + { headers: { ...HEADERS, Accept: "application/json" } } + ); + if (!res.ok) { console.log("done"); break; } + + const data = (await res.json()) as Array<{ + id: number; + title: { rendered: string }; + date: string; + link: string; + meta: { _company_name?: string; _job_location?: string }; + }>; + + if (!data.length) { console.log("0 items"); break; } + + let newOnPage = 0; + for (const item of data) { + const title = decodeHtml(item.title.rendered); + if (!applyFilters(title)) continue; + + const id = `sb-${item.id}`; + if (seen.has(id)) continue; + seen.add(id); + + allJobs.push({ + id, + title, + company: item.meta._company_name ?? "", + location: item.meta._job_location ?? "Basel", + postedAt: item.date.slice(0, 10), + link: item.link, + search: "stellen-basel.ch", + source: "stellen-basel.ch", + }); + newOnPage++; + } + + console.log(`${data.length} items, ${newOnPage} new`); + if (data.length < 100) break; + } + + return allJobs; +} diff --git a/fetch.ts b/fetch.ts index 06a8ec4..101985e 100644 --- a/fetch.ts +++ b/fetch.ts @@ -8,6 +8,7 @@ export interface Job { postedAt: string; link: string; search: string; + source: string; } const WORK_TYPE_MAP: Record = { @@ -54,7 +55,7 @@ const SENIORITY_MAP: Record = { // - gender markers: (H/F), (F/H), (h/f) // - French-only accented chars: é è ê ë à â ç î ï ô ù û ÿ œ æ // - French-only vocabulary -const NON_ENGLISH_RE = new RegExp( +export const NON_ENGLISH_RE = new RegExp( [ // German gender markers (w = weiblich; must not match m/f/d) String.raw`\(m\/w[^)]*\)`, @@ -87,7 +88,9 @@ const HEADERS = { "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", }; -const JUNIOR_TITLE_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i; +export const JUNIOR_TITLE_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i; + +export const JAVA_TITLE_RE = /\bjava\b/i; export const SOFTWARE_TITLE_RE = /\b(software|developer|architect|devops|platform|data|backend|frontend|fullstack|full.stack|tech\s*lead|technical|typescript|python|rails|node\.?js|ai\b|machine\s*learning|cloud|security|analytics|sre|reliability|solutions?|consultant|applications?\s*engineer|system[\s-]*engineer|informatics|director|product\s*manager|engineering\s*manager|it\s*manager|it\s*project|staff\s*engineer|principal\s*engineer|cto|vp\s*eng)\b/i; @@ -166,11 +169,17 @@ function parseJobs(html: string, label: string): Job[] { const companyM = card.match(/

[\s\S]{0,300}?]*>\s*([\s\S]*?)\s*<\/a>/); const timeM = card.match(/]+datetime="([^"]+)"/); + const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/); + const jobLocation = locM ? locM[1].trim() : ""; + + // Drop jobs not physically in Switzerland — LinkedIn ignores geoId without auth + if (jobLocation && !jobLocation.includes("Switzerland")) continue; + const title = clean(titleM[1]); const company = companyM ? clean(companyM[1]) : ""; const postedAt = timeM ? timeM[1] : ""; - jobs.push({ id, title, company, location: label, postedAt, link: `https://www.linkedin.com/jobs/view/${id}/`, search: label }); + jobs.push({ id, title, company, location: label, postedAt, link: `https://www.linkedin.com/jobs/view/${id}/`, search: label, source: "linkedin.com" }); } return jobs; @@ -196,6 +205,7 @@ export async function fetchJobs(config: SearchConfig, { raw = false } = {}): Pro const jobs = parseJobs(html, config.label) .filter((j) => !NON_ENGLISH_RE.test(j.title)) .filter((j) => !JUNIOR_TITLE_RE.test(j.title)) + .filter((j) => !JAVA_TITLE_RE.test(j.title)) .filter((j) => raw || SOFTWARE_TITLE_RE.test(j.title)); let newOnPage = 0; diff --git a/index.ts b/index.ts index f0e412d..cd5af47 100644 --- a/index.ts +++ b/index.ts @@ -1,20 +1,38 @@ #!/usr/bin/env bun import { searches } from "./config.ts"; import { fetchJobs, isEnglishContent } from "./fetch.ts"; +import { fetchDataCareer, fetchStellenBasel } from "./fetch-other.ts"; -const CSV = "./jobs.csv"; -const HEADER = "date_found,posted_at,search,title,company,link"; +const CSV = "./results/jobs.csv"; +const OLD_HEADER = "date_found,posted_at,search,title,company,link"; +const HEADER = "date_found,posted_at,search,title,company,link,source"; -// Load existing IDs from CSV to deduplicate across runs -const existingIds = new Set(); +// Migrate CSV: add source column if the old header is present +const csvExists = await Bun.file(CSV).exists(); +if (csvExists) { + const raw = await Bun.file(CSV).text(); + if (raw.startsWith(OLD_HEADER + "\n") || raw === OLD_HEADER) { + const lines = raw.split("\n"); + const migrated = [ + HEADER, + ...lines.slice(1).map((l) => (l.trim() ? l + ',"linkedin.com"' : "")), + ] + .join("\n") + .replace(/\n+$/, "") + "\n"; + await Bun.write(CSV, migrated); + } +} + +// Load existing links for deduplication +const existingLinks = new Set(); 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); + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); } } catch { - // first run — file doesn't exist yet + // first run } const date = new Date().toISOString().slice(0, 10); @@ -24,6 +42,7 @@ function csvCell(val: string): string { return `"${val.replace(/"/g, '""')}"`; } +// LinkedIn searches for (const config of searches) { console.log(`\n[${config.label}]`); const jobs = await fetchJobs(config); @@ -36,7 +55,7 @@ for (const config of searches) { let added = 0; let langDropped = 0; for (const job of jobs) { - if (!existingIds.has(job.id)) { + if (!existingLinks.has(job.link)) { process.stdout.write(` checking content: ${job.title} … `); const english = await isEnglishContent(job.id); if (!english) { @@ -45,9 +64,9 @@ for (const config of searches) { continue; } console.log("ok"); - existingIds.add(job.id); + existingLinks.add(job.link); newRows.push( - [date, job.postedAt, config.label, job.title, job.company, job.link] + [date, job.postedAt, config.label, job.title, job.company, job.link, "linkedin.com"] .map(csvCell) .join(",") ); @@ -57,12 +76,48 @@ for (const config of searches) { console.log(` ${jobs.length} found, ${added} new, ${langDropped} non-English dropped`); } +// Extra sources (no isEnglishContent — title filters already applied) +const extraSources: Array<{ label: string; fetch: () => Promise> }> = [ + { label: "datacareer.ch", fetch: fetchDataCareer }, + { label: "stellen-basel.ch", fetch: fetchStellenBasel }, +]; + +for (const { label, fetch: fetchFn } of extraSources) { + console.log(`\n[${label}]`); + let jobs; + try { + jobs = await fetchFn(); + } catch (e) { + console.error(` error: ${e}`); + continue; + } + + if (jobs.length === 0) { + console.log(" No jobs found."); + continue; + } + + let added = 0; + for (const job of jobs) { + if (!existingLinks.has(job.link)) { + existingLinks.add(job.link); + newRows.push( + [date, job.postedAt, job.search, job.title, job.company, job.link, job.source] + .map(csvCell) + .join(",") + ); + added++; + } + } + console.log(` ${jobs.length} found, ${added} new`); +} + if (newRows.length === 0) { console.log("\nNo new jobs."); process.exit(0); } -// Append to CSV (write header if first run) +// Append to CSV const existing = await Bun.file(CSV).exists(); const append = existing ? await Bun.file(CSV).text() diff --git a/package.json b/package.json index 7a5c0fa..a7a1e63 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "linkme", "version": "1.0.0", - "type": "module", "scripts": { "fetch": "bun run index.ts", "recheck": "bun run recheck.ts" - } + }, + "type": "module" } diff --git a/plans/post-1-swissviz.md b/plans/post-1-swissviz.md new file mode 100644 index 0000000..80c920e --- /dev/null +++ b/plans/post-1-swissviz.md @@ -0,0 +1,21 @@ +# Post 1 — Jun 3 · swissviz + +**Angle:** Swiss data, first post intro, personal + +--- + +This is my very first post on LinkedIn after years coming and going. + +For the past months I decided to step back from work and give full attention to my family. It's been 13 years since I left Brazil, and I just had my newborn son here in Switzerland. Good timing to breathe together. + +Meanwhile, I completed my B1 German and IBM AI certifications, and have been immersed in how AI agents are completely changing the way we build software. + +I was already playing with programming years before having internet access, and it's been a fascinating adventure to be a technologist for the last 25 years. + +Over the next weeks I'll be sharing side projects I've been building and just sharing among friends til now. + +--- + +#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. + +🔗 github.com/rafapolo/swissviz diff --git a/plans/post-2-helvetiscan.md b/plans/post-2-helvetiscan.md new file mode 100644 index 0000000..35e84ee --- /dev/null +++ b/plans/post-2-helvetiscan.md @@ -0,0 +1,17 @@ +# Post 2 — Jun 10 · helvetiscan + +**Angle:** Swiss infrastructure, technical depth, bold claim + +--- + +I scanned 2.5 million Swiss .ch domains. + +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. + +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. + +**helvetiscan** is the tool I built to do it. Open source, written in Python, designed to be run incrementally so the dataset stays current. + +If you work in Swiss infosec, infrastructure, or just find this kind of data interesting — the repo is open. + +🔗 github.com/rafapolo/helvetiscan diff --git a/plans/post-3-rodado-datative.md b/plans/post-3-rodado-datative.md new file mode 100644 index 0000000..d6f2da0 --- /dev/null +++ b/plans/post-3-rodado-datative.md @@ -0,0 +1,18 @@ +# Post 3 — Jun 17 · Rodado + datative + +**Angle:** Data engineering at scale, pipeline story + +--- + +675 GB. 533 datasets. One question: what do you actually do with Brazilian open data? + +The government publishes it. The files exist. But they're scattered across formats, encodings, and schemas that change without notice. Most of it is effectively inaccessible to anyone who isn't willing to spend weeks just cleaning. + +**Rodado** is my answer to that: a pipeline that organizes, normalizes, and indexes the full archive of Brazilian public datasets — census data, company registries, electoral records, public spending — into something you can actually query. + +On top of that I built **datative**, an investigation platform that turns any of those datasets into interactive force-graphs. It was designed for journalists and researchers who need to follow connections, not just rows. + +Both open source. The pipeline patterns transfer directly to any domain — scientific data, financial records, public health. + +🔗 Rodado: git.extrapolo.com/polo/baseldosdados +🔗 datative: github.com/rafapolo/datative diff --git a/plans/post-4-tocador.md b/plans/post-4-tocador.md new file mode 100644 index 0000000..0affebb --- /dev/null +++ b/plans/post-4-tocador.md @@ -0,0 +1,20 @@ +# Post 4 — Jun 24 · Tocador + +**Angle:** Product + design + cultural identity + +--- + +I moved from Brazil to Berlin to Basel. The music came with me — but Spotify's algorithm never quite figured out what I was actually listening to. + +So I built my own player. + +**Tocador** is a Spotify-alternative with a catalogue of 7,000+ Brazilian artists, curated and indexed by hand. No recommendation algorithm, no ads, no "because you listened to X." Just artists, genres, and a search that works. + +It's live at **tocador.cc** — and it still runs, four years later. + +I built it in a weekend because I was frustrated. It ended up teaching me more about product design, music metadata, and what "simple" actually requires than most client projects I've taken on. + +Sometimes the side projects that start from irritation are the ones that last. + +🔗 tocador.cc +🔗 github.com/rafapolo/tocador diff --git a/plans/post-5-viso.md b/plans/post-5-viso.md new file mode 100644 index 0000000..5b0fe5f --- /dev/null +++ b/plans/post-5-viso.md @@ -0,0 +1,17 @@ +# Post 5 — Jul 1 · VISO + +**Angle:** Civic accountability, live SQL, dataviz with substance + +--- + +Parliamentary expenses are public. They're also 400,000 rows of CSV that nobody reads. + +**VISO** is a dashboard I built to change that — not just charts, but a live SQL interface that lets anyone query the raw data directly from the browser. No download, no Excel, no waiting for a journalist to summarize it for you. + +The design question was: what does transparency actually look like when you respect the user's intelligence? The answer I landed on was: give them the data and the tool to interrogate it, not just the conclusions. + +Built with Python on the backend, React on the front, and a query engine that runs directly against the normalized dataset. Open source. + +Civic tech is some of the most technically interesting work I've done — the constraint that the data is messy and the user is real makes everything harder and more honest. + +🔗 github.com/rafapolo/viso diff --git a/plans/posts.md b/plans/posts.md new file mode 100644 index 0000000..059fac6 --- /dev/null +++ b/plans/posts.md @@ -0,0 +1,17 @@ +# LinkedIn Posts — June/July 2026 + +5 posts, every Wednesday. First post ever on LinkedIn. + +| # | File | Date | Project | +|---|------|------|---------| +| 1 | [post-1-swissviz.md](post-1-swissviz.md) | Jun 3 | swissviz | +| 2 | [post-2-helvetiscan.md](post-2-helvetiscan.md) | Jun 10 | helvetiscan | +| 3 | [post-3-rodado-datative.md](post-3-rodado-datative.md) | Jun 17 | Rodado + datative | +| 4 | [post-4-tocador.md](post-4-tocador.md) | Jun 24 | Tocador | +| 5 | [post-5-viso.md](post-5-viso.md) | Jul 1 | VISO | + +## Backlog (next candidates) + +- **sumaria** — YouTube transcript summarizer with LLM. AI tooling angle. +- **VERAΞFIKO** — Awarded Web3 SmartContracts bridging fees into carbon credits. Swiss sustainability resonance. +- **Pegadas Tigrinho** — Corporate network of Brazilian gaming scam. Investigative dataviz storytelling. diff --git a/polo-cv-accurate.html b/polo-cv-accurate.html deleted file mode 100644 index af1b388..0000000 --- a/polo-cv-accurate.html +++ /dev/null @@ -1,234 +0,0 @@ - - - - - Rafael Polo - CV - - - -
Rafael Polo
-
Senior Software Engineer — Platform, Data & AI
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Personal
First name:Rafael
Last name:Polo
Citizenship:Portuguese (EU) · Brazilian — no work permit required
Address:Basel, 4055, Switzerland
Email:rafael@extrapolo.com
Web:linkedin.com/in/rafaelpolo
extrapolo.com
- -

Formal Education

-
    -
  • - Postgraduate in Data Science for Finance – 2021
    - NOVA IMS Information Management School, Lisbon -
  • -
  • - Master in Information Systems – 2010–2012
    - PPGI/UNIRIO — Knowledge Representation & Reasoning -
  • -
  • - Bachelor in Computer Science – 2004–2009
    - Metodista Bennett, Rio de Janeiro -
  • -
- -

Relevant Professional Experience

-
    -
  • - Swisscom – 2024–2025 – Zürich, Switzerland
    - DevOps Engineer developing platforms for Telecommunication Operations. -
  • -
  • - EDGE Empower® – 2024–2025 – Baar, Switzerland
    - Senior Technical Lead managing projects and external UX & Dev Teams for a SaaS Product. -
  • -
  • - ExtraPolo — Independent Consulting – 2010–2022 – Rio de Janeiro · Berlin · Athens · Lisbon
    - 40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients. -
  • -
  • - Moedax Digital Services – 2018–2021 – Lisbon · Rio de Janeiro
    - Founder & CTO – Fintech startup connecting CBDC digital currency with KYC compliance automation. -
  • -
  • - GFOSS.eu / Code + Create – 2017–2018 – Athens, Greece
    - Web Programming Teacher – taught HTML, CSS, jQuery to refugee and local youth. -
  • -
  • - Tactical Tech Collective – 2014–2016 – Berlin, Germany
    - Full-Stack Web Architect – investigative data tools under Exposing the Invisible team. -
  • -
  • - Trommsdorf + Drüner – 2013–2014 – Berlin, Germany
    - Full-Stack Engineer – Innovation Team developing Business Intelligence dashboards. -
  • -
  • - Cortex Intelligence – 2007–2008 – Rio de Janeiro, Brazil
    - Developer – Competitive Intelligence and Data Mining R&D team. -
  • -
- -

Certifications

-
    -
  • Swiss Federal Act on Data Protection (nFADP)
  • -
  • Gen AI Foundational Models — NLP & LLMs
  • -
  • AI Agents using RAG and LangChain
  • -
- -

Relevant Tech Skills

-
    -
  • Languages & Frameworks: Python · Ruby on Rails · TypeScript · React · Java
  • -
  • DevOps & Infra: Docker · Kubernetes · CI/CD · AWS · Linux · Git
  • -
  • Data & AI: SQL · ETL · Data Engineering · RAG · LangChain · LLM integrations
  • -
  • Architecture: REST APIs · MVC · OOP · UML · System Design · Web Crawling
  • -
- -

Natural Languages

-
    -
  • Native Brazilian Portuguese
  • -
  • Full professional English
  • -
  • Full professional Spanish
  • -
  • German B1 – TELC certified · 3 yrs Berlin + 3 yrs Basel
  • -
- - - - \ No newline at end of file diff --git a/polo-cv.html b/polo-cv.html deleted file mode 100644 index ec614a8..0000000 --- a/polo-cv.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - - Rafael Polo - CV - - - -
Rafael Polo
-
Senior Software Engineer — Platform, Data & AI
- -
- Basel, 4055, Switzerland - rafael@extrapolo.com - linkedin.com/in/rafaelpolo - extrapolo.com -
- -
- Portuguese (EU) · Brazilian — no work permit required - 20+ years building production systems across Switzerland, Germany, Greece and Brazil - Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU citizen, immediately available -
- -
-

P R O F E S S I O N A L E X P E R I E N C E

- -
-
Swisscom
-
DevOps Engineer · Zürich, Switzerland
-
Modernization of mission-critical internal platforms & CI/CD pipelines for high-availability Telecom infrastructure.
-
- -
-
EDGE Empower®
-
Senior Technical Lead · Baar, Switzerland · 2024 – 2025
-
Built a PayTool cutting processing time by 98%; reduced CI/CD pipeline from 14 → 4 minutes; managed 3 developers and a UX designer for a SaaS product.
-
- -
-
ExtraPolo — Independent Consulting
-
Software Engineer & Consultant · Rio de Janeiro · Berlin · Athens · Lisbon · 2010 – 2022
-
40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients.
-
- -
-
Moedax Digital Services
-
Founder & CTO · Lisbon · Rio de Janeiro · 2018 – 2021
-
Fintech startup connecting CBDC digital currency with KYC compliance automation; built data pipelines and banking integrations in Python.
-
- -
-
GFOSS.eu / Code + Create
-
Web Programming Teacher · Athens, Greece · 2017 – 2018
-
Taught web programming (HTML, CSS, jQuery) to refugee and local youth through the EU-funded Code + Create initiative.
-
- -
-
Tactical Tech Collective
-
Full-Stack Web Architect · Berlin, Germany · Dec 2014 – Jun 2016
-
Built investigative data tools and platforms under the Exposing the Invisible team (Ruby on Rails, data visualisation).
-
- -
-
Trommsdorff + Drüner
-
Full-Stack Engineer · Berlin, Germany
-
Innovation team — Business Intelligence dashboards for marketing consultancy clients.
-
- -
-
Cortex Intelligence
-
Developer · Rio de Janeiro, Brazil
-
Competitive Intelligence and Data Mining R&D team.
-
-
- -
-

E D U C A T I O N

-
Postgraduate in Data Science for FinanceNOVA IMS Information Management School, Lisbon — 2021
-
Master in Information SystemsPPGI/UNIRIO — Knowledge Representation & Reasoning — 2010–2012
-
Bachelor in Computer ScienceMetodista Bennett, Rio de Janeiro — 2004–2009
-
- -
-

L A N G U A G E S

-
Portuguesenative
-
Englishfull professional
-
Spanishfull professional
-
GermanB1 — TELC certified · 3 yrs Berlin + 3 yrs Basel
-
- -
-

C E R T I F I C A T I O N S

-
    -
  • Swiss Federal Act on Data Protection (nFADP)
  • -
  • Gen AI Foundational Models — NLP & LLMs
  • -
  • AI Agents using RAG and LangChain
  • -
-
- -
-

T E C H S K I L L S

-
Languages & FrameworksPython · Ruby on Rails · TypeScript · React · Java
-
DevOps & InfraDocker · Kubernetes · CI/CD · AWS · Linux · Git
-
Data & AISQL · ETL · Data Engineering · RAG · LangChain · LLM integrations · Data Science
-
ArchitectureREST APIs · MVC · OOP · UML · System Design · Web Crawling & Scraping
-
- -
- References available upon request · CV updated May 2026 -
- - \ No newline at end of file diff --git a/polo-new-cv.html b/polo-new-cv.html deleted file mode 100644 index c1f6460..0000000 --- a/polo-new-cv.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - Rafael Polo - CV - - - -
-
-

Rafael Polo

-

Senior Software Engineer — Platform, Data & AI

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Personal
Address:Basel, 4055, Switzerland
Email:rafael@extrapolo.com
LinkedIn:linkedin.com/in/rafaelpolo
Web:extrapolo.com
Citizenship:Portuguese (EU) · Brazilian — no work permit required
Languages:Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU citizen, immediately available
- -

Formal Education

-
    -
  • Postgraduate in Data Science for Finance – 2021
    - NOVA IMS Information Management School, Lisbon
  • -
  • Master in Information Systems – 2010–2012
    - Research Area: Knowledge Representation & Reasoning, PPGI/UNIRIO
  • -
  • Bachelor in Computer Science – 2004–2009
    - Metodista Bennett, Rio de Janeiro
  • -
  • Thesis: Agile Systems Development for Web 2.0 with Ruby on Rails
  • -
- -

Relevant Professional Experience

-
    -
  • Swisscom – 2024–2025 – Zürich, Switzerland -

    DevOps Engineer developing platforms for Telecommunication Operations.

  • -
  • EDGE Empower® – 2024–2025 – Baar, Switzerland -

    Senior Technical Lead managing projects and external UX & Dev Teams for a SaaS Company.

  • -
  • ExtraPolo — Independent Consulting – 2010–2022 – Rio de Janeiro · Berlin · Athens · Lisbon -

    For more than 10 years, worked as an independent Software Developer and IT Consultant, developing dozens of Projects for public and private contractors in Brazil, Germany, Greece and Switzerland.

  • -
  • Moedax Digital Services – 2018–2021 – Lisbon and Rio de Janeiro -

    Founder & CTO – connected Brazilian CBDC with KYC processes and automation

  • -
  • GFOSS.eu / Code + Create – 2017–2018 – Athens, Greece -

    Web Programming Teacher – taught web programming to refugee and local youth

  • -
  • Tactical Tech Collective – 2014–2016 – Berlin, Germany -

    Full-Stack Web Architect on an NGO under the Exposing the Invisible Team.

  • -
  • Trommsdorf + Drüner – 2013–2014 – Berlin, Germany -

    Full-stack Engineer – Innovation Team developing Business Intelligence dashboards.

  • -
  • Cortex Intelligence – 2007–2008 – Rio de Janeiro, Brazil -

    Developer in the Competitive Intelligence and Data Mining Team.

  • -
- -

Certifications

-
    -
  • Swiss Federal Act on Data Protection (nFADP)
  • -
  • Gen AI Foundational Models — NLP & LLMs
  • -
  • AI Agents using RAG and LangChain
  • -
- -

Relevant Tech Skills

-
    -
  • System Analysis & Modeling with UML
  • -
  • Quick Proof-of-Concepts development, deploy and management.
  • -
  • Large Language Models, Gen AI, LangChain integration and Processes Automation
  • -
  • MVC System Architecture, Design Patterns & Object-Oriented Programming
  • -
  • Data Engineering, Data Science, Relational Databases & SQL
  • -
  • Experience with Blockchain, Smart Contracts and P2P systems
  • -
  • Distributing and Scaling Processes with Message Queues
  • -
  • Developing Web Crawlers & Bots for Data Mining & ETL
  • -
  • Linux Systems, VPS, DevOps, SSH & Deploy with Continuous Integration processes
  • -
  • Projects mostly developed in Python, Ruby on Rails, Django, Java
  • -
  • Full Stack Developer & Front-end Web Design with HTML, CSS and JavaScript frameworks
  • -
  • CI/CD, Docker, Kubernetes and GitHub workflows
  • -
- -

Natural Languages

-
    -
  • Native Brazilian Portuguese
  • -
  • Advanced Spoken Spanish
  • -
  • A2 level Greek – 3 years in Athens
  • -
  • B1 level German / TELC proof – 3 years in Berlin and 3 in Switzerland
  • -
  • Fluent English – 13 years in between international teams
  • -
- - -
-
- -
-
- - \ No newline at end of file diff --git a/polo-new-cv.odt b/polo-new-cv.odt deleted file mode 100644 index e25f172..0000000 Binary files a/polo-new-cv.odt and /dev/null differ diff --git a/polo-new-cv.pdf b/polo-new-cv.pdf deleted file mode 100644 index 4c6bdb2..0000000 Binary files a/polo-new-cv.pdf and /dev/null differ diff --git a/rafael-polo-cv-2026-senior.odt b/rafael-polo-cv-2026-senior.odt deleted file mode 100644 index 02656e4..0000000 Binary files a/rafael-polo-cv-2026-senior.odt and /dev/null differ diff --git a/rafael-polo-cv-2026-senior.pdf b/rafael-polo-cv-2026-senior.pdf deleted file mode 100644 index fac8f41..0000000 Binary files a/rafael-polo-cv-2026-senior.pdf and /dev/null differ diff --git a/rafael-polo-cv.pdf b/rafael-polo-cv.pdf deleted file mode 100644 index 35c207c..0000000 Binary files a/rafael-polo-cv.pdf and /dev/null differ diff --git a/recheck.ts b/recheck.ts index 564b7f6..d58fae7 100644 --- a/recheck.ts +++ b/recheck.ts @@ -2,7 +2,7 @@ import { searches } from "./config.ts"; import { fetchJobs, SOFTWARE_TITLE_RE } from "./fetch.ts"; -const CSV = "./jobs.csv"; +const CSV = "./results/jobs.csv"; const existingIds = new Set(); try { diff --git a/scripts/basel-all-temp.ts b/scripts/basel-all-temp.ts new file mode 100644 index 0000000..ad311cf --- /dev/null +++ b/scripts/basel-all-temp.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env bun +import { KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; + +const existingLinks = new Set(); +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(); + +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}`); +} diff --git a/bern-langcheck.ts b/scripts/bern-langcheck.ts similarity index 100% rename from bern-langcheck.ts rename to scripts/bern-langcheck.ts diff --git a/bern-temp.ts b/scripts/bern-temp.ts similarity index 86% rename from bern-temp.ts rename to scripts/bern-temp.ts index 2857686..25022c4 100644 --- a/bern-temp.ts +++ b/scripts/bern-temp.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun -import { KEYWORDS } from "./config.ts"; -import { fetchJobs, SOFTWARE_TITLE_RE } from "./fetch.ts"; -import type { SearchConfig } from "./config.ts"; +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; diff --git a/scripts/bern2-temp.ts b/scripts/bern2-temp.ts new file mode 100644 index 0000000..2196e57 --- /dev/null +++ b/scripts/bern2-temp.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env bun +import { KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const searches: SearchConfig[] = [ + { label: "Bern onsite/hybrid", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Bern mid-senior", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], seniority: ["mid-senior", "director"], timePosted: "month" }, +]; + +type Job = { id: string; title: string; company: string; link: string; search: string }; +const results: Job[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config); + if (jobs.length === 0) { console.log(" no results"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id) || existingLinks.has(job.link)) continue; + seen.add(job.id); + process.stdout.write(` ${job.title} @ ${job.company} … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped (lang)"); continue; } + console.log("ok"); + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} new`); +} + +console.log(`\n=== TOTAL NEW: ${results.length} ===\n`); +for (const j of results) { + console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`); +} diff --git a/zurich-temp.ts b/scripts/em-search-temp.ts similarity index 55% rename from zurich-temp.ts rename to scripts/em-search-temp.ts index 42c960a..0344771 100644 --- a/zurich-temp.ts +++ b/scripts/em-search-temp.ts @@ -1,10 +1,22 @@ #!/usr/bin/env bun -import { KEYWORDS } from "./config.ts"; -import { fetchJobs, isEnglishContent } from "./fetch.ts"; -import type { SearchConfig } from "./config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const EM_KEYWORDS = [ + "Engineering Manager", + "Director of Engineering", + "Head of Engineering", + "VP Engineering", + "Technical Lead Manager", + "Tech Lead Manager", + "Head of Software", + "Software Engineering Manager", +].join(" OR "); const searches: SearchConfig[] = [ - { label: "Zürich", keywords: KEYWORDS, location: "Zurich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Basel", keywords: EM_KEYWORDS, location: "Basel, Switzerland", timePosted: "month" }, + { label: "Bern", keywords: EM_KEYWORDS, location: "Bern, Switzerland", timePosted: "month" }, + { label: "Zürich", keywords: EM_KEYWORDS, location: "Zurich, Switzerland", timePosted: "month" }, ]; const seen = new Set(); @@ -12,7 +24,7 @@ const results: { id: string; title: string; company: string; search: string }[] for (const config of searches) { console.log(`\n[${config.label}]`); - const jobs = await fetchJobs(config); + const jobs = await fetchJobs(config, { raw: true }); for (const job of jobs) { if (!seen.has(job.id)) { seen.add(job.id); @@ -31,7 +43,7 @@ for (const job of results) { if (ok) english.push(job); } -console.log(`\n── ${english.length} English jobs in Zürich ──\n`); +console.log(`\n── ${english.length} English EM jobs (Basel / Bern / Zürich) ──\n`); for (const [i, j] of english.entries()) { console.log(` ${i + 1}. [${j.search}] ${j.title} — ${j.company}`); console.log(` https://www.linkedin.com/jobs/view/${j.id}/`); diff --git a/scripts/global-remote-rails-lead-temp.ts b/scripts/global-remote-rails-lead-temp.ts new file mode 100644 index 0000000..a55f83c --- /dev/null +++ b/scripts/global-remote-rails-lead-temp.ts @@ -0,0 +1,105 @@ +import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", +}; + +function clean(raw: string): string { + return raw.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim(); +} + +// parseJobs without Switzerland location guard +function parseJobsGlobal(html: string): { id: string; title: string; company: string; location: string }[] { + const jobs = []; + const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g; + const positions: { id: string; start: number }[] = []; + let m; + while ((m = idRe.exec(html)) !== null) positions.push({ id: m[1], start: m.index }); + + for (let i = 0; i < positions.length; i++) { + const { id, start } = positions[i]; + const card = html.slice(start, Math.min(positions[i + 1]?.start ?? Infinity, start + 3000)); + const titleM = card.match(/

\s*([\s\S]*?)\s*<\/h3>/); + if (!titleM) continue; + const companyM = card.match(/

[\s\S]{0,300}?]*>\s*([\s\S]*?)\s*<\/a>/); + const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/); + jobs.push({ + id, + title: clean(titleM[1]), + company: companyM ? clean(companyM[1]) : "", + location: locM ? locM[1].trim() : "", + }); + } + return jobs; +} + +async function fetchGlobal(config: SearchConfig): Promise<{ id: string; title: string; company: string; location: string }[]> { + const baseUrl = buildUrl(config); + const all = []; + const seen = new Set(); + + for (let page = 0; page < 6; page++) { + if (page > 0) await Bun.sleep(400); + const url = `${baseUrl}&start=${page * 25}`; + process.stdout.write(` page ${page + 1} … `); + const res = await fetch(url, { headers: HEADERS }); + if (!res.ok) { console.error(`failed (${res.status})`); break; } + const html = await res.text(); + const jobs = parseJobsGlobal(html) + .filter(j => !NON_ENGLISH_RE.test(j.title)) + .filter(j => !JUNIOR_TITLE_RE.test(j.title)) + .filter(j => !JAVA_TITLE_RE.test(j.title)); + + let newCount = 0; + for (const j of jobs) { + if (!seen.has(j.id)) { seen.add(j.id); all.push(j); newCount++; } + } + console.log(`${jobs.length} jobs, ${newCount} new`); + if (newCount === 0) break; + } + return all; +} + +const TECH_LEAD_RE = /\b(tech\s*lead|technical\s*lead|lead\s*(engineer|developer|backend|full.?stack)|staff\s*(engineer|developer)|principal\s*(engineer|developer)|engineering\s*lead|head\s*of\s*eng)\b/i; + +const searches: SearchConfig[] = [ + { + label: "Global Remote — Rails Tech Lead", + keywords: "Ruby on Rails Tech Lead OR Rails Lead Engineer OR Rails Staff Engineer OR Rails Principal Engineer OR Ruby Lead Developer", + workType: ["remote"], + timePosted: "month", + }, + { + label: "Global Remote — Ruby Lead (broader)", + keywords: "Ruby on Rails Lead OR Ruby on Rails Staff OR Ruby on Rails Principal OR Ruby Tech Lead", + workType: ["remote"], + timePosted: "month", + }, +]; + +type Result = { title: string; company: string; location: string; id: string; link: string }; +const results: Result[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchGlobal(config); + + for (const job of jobs) { + if (seen.has(job.id)) continue; + if (!TECH_LEAD_RE.test(job.title)) continue; + seen.add(job.id); + + process.stdout.write(` checking: ${job.title} @ ${job.company} (${job.location}) … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped (non-English)"); continue; } + console.log("ok"); + results.push({ title: job.title, company: job.company, location: job.location, id: job.id, link: `https://www.linkedin.com/jobs/view/${job.id}/` }); + } +} + +console.log(`\n${results.length} global remote Rails lead jobs.\n`); +for (const r of results) console.log(`${r.title} | ${r.company} | ${r.location} | ${r.link}`); diff --git a/scripts/new-jobs-temp.ts b/scripts/new-jobs-temp.ts new file mode 100644 index 0000000..c1a2ff2 --- /dev/null +++ b/scripts/new-jobs-temp.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env bun +import { KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const searches: SearchConfig[] = [ + // Basel onsite/hybrid + { label: "Basel city", keywords: KEYWORDS, geoId: "103383283", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Basel Metro", keywords: KEYWORDS, geoId: "90009885", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Basel-Country", keywords: KEYWORDS, geoId: "107117530", workType: ["onsite", "hybrid"], timePosted: "month" }, + // Swiss remote + { label: "CH remote", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" }, + { label: "CH remote mid-senior", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" }, +]; + +type Job = { id: string; title: string; company: string; link: string; search: string }; +const results: Job[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config); + if (jobs.length === 0) { console.log(" no results"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id) || existingLinks.has(job.link)) continue; + seen.add(job.id); + process.stdout.write(` ${job.title} @ ${job.company} … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped (lang)"); continue; } + console.log("ok"); + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} new`); +} + +console.log(`\n=== TOTAL NEW: ${results.length} ===\n`); +for (const j of results) { + console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`); +} diff --git a/scripts/remote-temp.ts b/scripts/remote-temp.ts new file mode 100644 index 0000000..1732902 --- /dev/null +++ b/scripts/remote-temp.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env bun +import { searches, KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; + +// Load existing links for deduplication +const existingLinks = new Set(); +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 = []; +const seenIds = new Set(); + +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}`); +} diff --git a/scripts/ruby-remote-temp.ts b/scripts/ruby-remote-temp.ts new file mode 100644 index 0000000..389f9cc --- /dev/null +++ b/scripts/ruby-remote-temp.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env bun +import { isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts"; + +const CSV = "./jobs.csv"; +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const RUBY_KEYWORDS = [ + "Ruby on Rails", "Rails Engineer", "Ruby Engineer", + "Rails Developer", "Ruby Developer", +].join(" OR "); + +const searches = [ + { label: "United States Remote (Ruby)", location: "United States", workType: "2" }, +]; + +const RUBY_RE = /\bruby\b|\brails\b/i; + +interface Job { id: string; title: string; company: string; location: string; link: string; postedAt: string } + +function unescape(s: string) { + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + .replace(/'/g, "'").replace(/"/g, '"').replace(/\s+/g, " ").trim(); +} + +async function fetchPage(location: string, workType: string, start: number): Promise { + const params = new URLSearchParams({ + keywords: RUBY_KEYWORDS, + location, + f_TPR: "r2592000", + f_WT: workType, + start: String(start), + }); + const url = `https://www.linkedin.com/jobs/search/?${params}`; + const res = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } + }); + const html = await res.text(); + + const jobs: Job[] = []; + const cardRe = /]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn="urn:li:jobPosting:(\d+)"[^>]*>([\s\S]*?)(?=]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn|<\/ul>)/g; + let m; + while ((m = cardRe.exec(html)) !== null) { + const id = m[1]; + const chunk = m[2]; + const titleM = chunk.match(/class="[^"]*base-search-card__title[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/h3>/); + const companyM = chunk.match(/class="[^"]*base-search-card__subtitle[^"]*"[\s\S]*?]*>\s*([\s\S]*?)\s*<\/a>/); + const locationM = chunk.match(/class="[^"]*job-search-card__location[^"]*"[^>]*>\s*([\s\S]*?)\s*<\//); + const postedM = chunk.match(/datetime="([^"]+)"/); + + const title = titleM ? unescape(titleM[1].replace(/<[^>]+>/g, "")) : ""; + const company = companyM ? unescape(companyM[1].replace(/<[^>]+>/g, "")) : ""; + const location = locationM ? unescape(locationM[1]) : ""; + const postedAt = postedM ? postedM[1] : ""; + + if (!title || !id) continue; + jobs.push({ id, title, company, location, link: `https://www.linkedin.com/jobs/view/${id}/`, postedAt }); + } + return jobs; +} + +const newJobs: Array = []; +const seenIds = new Set(); + +for (const s of searches) { + console.log(`\n[${s.label}]`); + let totalFound = 0, added = 0; + + for (let page = 0; page < 4; page++) { + const jobs = await fetchPage(s.location, s.workType, page * 25); + if (jobs.length === 0) { console.log(` page ${page + 1} empty, stopping`); break; } + console.log(` page ${page + 1} … ${jobs.length} cards`); + totalFound += jobs.length; + + for (const job of jobs) { + if (existingLinks.has(job.link) || seenIds.has(job.id)) continue; + if (NON_ENGLISH_RE.test(job.title) || JUNIOR_TITLE_RE.test(job.title)) continue; + if (!RUBY_RE.test(job.title)) continue; + + seenIds.add(job.id); + process.stdout.write(` checking: ${job.title} @ ${job.company} (${job.location}) … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped (non-English)"); continue; } + console.log("ok"); + newJobs.push({ ...job, search: s.label }); + added++; + } + } + console.log(` ${totalFound} total, ${added} new`); +} + +console.log(`\n=== ${newJobs.length} Ruby remote jobs ===\n`); +for (const j of newJobs) { + console.log(`${j.title} | ${j.company} | ${j.location} | ${j.link}`); +} diff --git a/scripts/ruby-saas-swiss-temp.ts b/scripts/ruby-saas-swiss-temp.ts new file mode 100644 index 0000000..b0b2907 --- /dev/null +++ b/scripts/ruby-saas-swiss-temp.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env bun +import { KEYWORDS, fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +// Re-export KEYWORDS isn't in fetch.ts, import from config +import { KEYWORDS as CFG_KEYWORDS } from "../config.ts"; + +const HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", +}; + +const SAAS_RE = /\bsaas\b/i; + +async function hasSaaS(id: string): Promise { + try { + await Bun.sleep(350); + const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS }); + const html = await res.text(); + const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/); + const text = m ? m[1].replace(/<[^>]+>/g, " ") : html.replace(/<[^>]+>/g, " ").slice(0, 5000); + return SAAS_RE.test(text); + } catch { return false; } +} + +const RUBY_KEYWORDS = [ + "Ruby on Rails", "Rails Engineer", "Rails Developer", + "Ruby Engineer", "Ruby Developer", "Ruby", +].join(" OR "); + +// --- Part 1: Ruby/Rails in Switzerland --- +const rubySearches: SearchConfig[] = [ + { label: "Basel Ruby (onsite+hybrid)", keywords: RUBY_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Basel Ruby (remote)", keywords: RUBY_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" }, + { label: "Zürich Ruby (onsite+hybrid)",keywords: RUBY_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Zürich Ruby (remote)", keywords: RUBY_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["remote"], timePosted: "month" }, + { label: "Switzerland Ruby (remote)", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" }, + { label: "Bern Ruby", keywords: RUBY_KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid", "remote"], timePosted: "month" }, +]; + +// --- Part 2: SaaS-experience jobs in Switzerland --- +const saasSearches: SearchConfig[] = [ + { label: "Basel SaaS", keywords: CFG_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Basel Metro SaaS", keywords: CFG_KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Zürich SaaS", keywords: CFG_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Switzerland SaaS remote", keywords: CFG_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" }, +]; + +type Job = { id: string; title: string; company: string; link: string; search: string }; + +async function runSearches(searches: SearchConfig[], label: string, filterFn?: (id: string) => Promise) { + const results: Job[] = []; + const seen = new Set(); + console.log(`\n${"=".repeat(50)}\n${label}\n${"=".repeat(50)}`); + + for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config); + if (jobs.length === 0) { console.log(" no jobs found"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id)) continue; + seen.add(job.id); + if (filterFn) { + process.stdout.write(` ${job.title} @ ${job.company} … `); + const pass = await filterFn(job.id); + if (!pass) { console.log("no SaaS"); continue; } + console.log("SaaS ✓"); + } + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} added`); + } + return results; +} + +const rubyJobs = await runSearches(rubySearches, "RUBY / RAILS IN SWITZERLAND"); +const saasJobs = await runSearches(saasSearches, "SaaS-EXPERIENCE JOBS IN SWITZERLAND", hasSaaS); + +console.log(`\n\n${"=".repeat(50)}\nRUBY RESULTS: ${rubyJobs.length}\n${"=".repeat(50)}`); +for (const j of rubyJobs) console.log(`${j.title} | ${j.company} | ${j.id} | ${j.link}`); + +console.log(`\n${"=".repeat(50)}\nSaaS RESULTS: ${saasJobs.length}\n${"=".repeat(50)}`); +for (const j of saasJobs) console.log(`${j.title} | ${j.company} | ${j.id} | ${j.link}`); diff --git a/scripts/ruby-swiss-temp.ts b/scripts/ruby-swiss-temp.ts new file mode 100644 index 0000000..7b1f424 --- /dev/null +++ b/scripts/ruby-swiss-temp.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env bun +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const RUBY_KEYWORDS = "Ruby on Rails OR Ruby Developer OR Rails Developer OR Backend Ruby OR Ruby Engineer"; + +const searches: SearchConfig[] = [ + { label: "Basel Ruby", keywords: RUBY_KEYWORDS, geoId: "90009885", timePosted: "month" }, + { label: "Zürich Ruby", keywords: RUBY_KEYWORDS, geoId: "107814425", timePosted: "month" }, + { label: "Bern Ruby", keywords: RUBY_KEYWORDS, location: "Bern, Switzerland", timePosted: "month" }, + { label: "CH remote Ruby", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" }, +]; + +const RUBY_RE = /\bruby\b|\brails\b/i; + +type Job = { id: string; title: string; company: string; link: string; search: string }; +const results: Job[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config); + if (jobs.length === 0) { console.log(" no results"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id) || existingLinks.has(job.link)) continue; + seen.add(job.id); + process.stdout.write(` ${job.title} @ ${job.company} … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped (lang)"); continue; } + // fetch description to confirm Ruby/Rails mention + const html = await fetch(`https://www.linkedin.com/jobs/view/${job.id}/`).then(r => r.text()).catch(() => ""); + if (!RUBY_RE.test(html)) { console.log("dropped (no ruby/rails in desc)"); continue; } + console.log("ok"); + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} new`); +} + +console.log(`\n=== TOTAL NEW: ${results.length} ===\n`); +for (const j of results) { + console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`); +} diff --git a/scripts/ruby-tl-usa-temp.ts b/scripts/ruby-tl-usa-temp.ts new file mode 100644 index 0000000..e529994 --- /dev/null +++ b/scripts/ruby-tl-usa-temp.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", +}; + +function clean(raw: string): string { + return raw.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim(); +} + +// Same as fetch.ts parseJobs but WITHOUT the Switzerland location guard +function parseJobs(html: string, label: string) { + const jobs: { id: string; title: string; company: string; link: string; location: string }[] = []; + const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g; + const positions: { id: string; start: number }[] = []; + let m; + while ((m = idRe.exec(html)) !== null) positions.push({ id: m[1], start: m.index }); + + for (let i = 0; i < positions.length; i++) { + const { id, start } = positions[i]; + const card = html.slice(start, Math.min(positions[i + 1]?.start ?? Infinity, start + 3000)); + const titleM = card.match(/

\s*([\s\S]*?)\s*<\/h3>/); + if (!titleM) continue; + const companyM = card.match(/

[\s\S]{0,300}?]*>\s*([\s\S]*?)\s*<\/a>/); + const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/); + const jobLocation = locM ? locM[1].trim() : ""; + jobs.push({ id, title: clean(titleM[1]), company: companyM ? clean(companyM[1]) : "", location: jobLocation, link: `https://www.linkedin.com/jobs/view/${id}/` }); + } + return jobs; +} + +async function fetchJobsNoGeo(config: SearchConfig) { + const baseUrl = buildUrl(config); + const all: { id: string; title: string; company: string; location: string; link: string }[] = []; + const seen = new Set(); + + for (let page = 0; page < 6; page++) { + if (page > 0) await Bun.sleep(400); + const url = `${baseUrl}&start=${page * 25}`; + process.stdout.write(` page ${page + 1} … `); + const res = await fetch(url, { headers: HEADERS }); + if (!res.ok) { console.error(`failed (${res.status})`); break; } + const html = await res.text(); + const jobs = parseJobs(html, config.label) + .filter(j => !NON_ENGLISH_RE.test(j.title)) + .filter(j => !JUNIOR_TITLE_RE.test(j.title)) + .filter(j => !JAVA_TITLE_RE.test(j.title)) + .filter(j => SOFTWARE_TITLE_RE.test(j.title)); + + let newCount = 0; + for (const job of jobs) { + if (!seen.has(job.id)) { seen.add(job.id); all.push(job); newCount++; } + } + console.log(`${jobs.length} jobs, ${newCount} new`); + if (newCount === 0) break; + } + return all; +} + +const RUBY_KEYWORDS = [ + "Ruby on Rails", + "Rails Engineer", + "Rails Developer", + "Ruby Engineer", + "Ruby Developer", + "Tech Lead Ruby", + "Lead Engineer Ruby", + "Staff Ruby", + "Principal Ruby", +].join(" OR "); + +const searches: SearchConfig[] = [ + { label: "USA Remote Ruby/Rails", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], timePosted: "month" }, + { label: "USA Remote Ruby/Rails mid-senior+", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" }, +]; + +type Job = { id: string; title: string; company: string; location: string; link: string; search: string }; +const results: Job[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobsNoGeo(config); + if (jobs.length === 0) { console.log(" no jobs found"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id)) continue; + seen.add(job.id); + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} new`); +} + +console.log(`\n=== TOTAL: ${results.length} jobs ===\n`); +for (const j of results) { + console.log(`${j.title} | ${j.company} | ${j.location} | ${j.id} | ${j.link}`); +} diff --git a/scripts/swiss-remote-all-temp.ts b/scripts/swiss-remote-all-temp.ts new file mode 100644 index 0000000..d36077f --- /dev/null +++ b/scripts/swiss-remote-all-temp.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env bun +import { KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; + +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const searches: SearchConfig[] = [ + { label: "Switzerland remote (by name)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" }, + { label: "Basel remote", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" }, + { label: "Basel Metro remote", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["remote"], timePosted: "month" }, + { label: "Zürich remote", keywords: KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["remote"], timePosted: "month" }, + { label: "Zürich Metro remote", keywords: KEYWORDS, geoId: "107814425", location: "Zürich Metropolitan Area", workType: ["remote"], timePosted: "month" }, + { label: "Zürich Area remote", keywords: KEYWORDS, geoId: "90009888", location: "Zürich Area", workType: ["remote"], timePosted: "month" }, + { label: "Bern remote", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["remote"], timePosted: "month" }, + // seniority-filtered pass for mid-senior+ + { label: "Switzerland remote (mid-senior)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" }, +]; + +type Job = { id: string; title: string; company: string; link: string; search: string }; +const results: Job[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config); + if (jobs.length === 0) { console.log(" no jobs found"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id) || existingLinks.has(job.link)) continue; + seen.add(job.id); + process.stdout.write(` ${job.title} @ ${job.company} … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped"); continue; } + console.log("ok"); + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} new`); +} + +console.log(`\n=== TOTAL: ${results.length} remote jobs ===\n`); +for (const j of results) { + console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id} | ${j.link}`); +} diff --git a/scripts/swiss-remote-rails-temp.ts b/scripts/swiss-remote-rails-temp.ts new file mode 100644 index 0000000..547e3e2 --- /dev/null +++ b/scripts/swiss-remote-rails-temp.ts @@ -0,0 +1,79 @@ +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; + +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const RAILS_KEYWORDS = [ + "Ruby on Rails", + "Ruby Developer", + "Rails Developer", + "Ruby Engineer", + "Rails Engineer", + "Full Stack Ruby", + "Backend Ruby", + "RoR Developer", + "RoR Engineer", +].join(" OR "); + +const searches: SearchConfig[] = [ + { + label: "Switzerland Remote Rails (Zürich)", + keywords: RAILS_KEYWORDS, + geoId: "102436504", + location: "Zürich, Switzerland", + workType: ["remote"], + timePosted: "month", + }, + { + label: "Switzerland Remote Rails (by name)", + keywords: RAILS_KEYWORDS, + location: "Switzerland", + workType: ["remote"], + timePosted: "month", + }, + { + label: "Switzerland Remote Rails (Basel)", + keywords: RAILS_KEYWORDS, + geoId: "103383283", + location: "Basel", + workType: ["remote"], + timePosted: "month", + }, +]; + +type Result = { title: string; company: string; id: string; link: string }; +const results: Result[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config, { raw: true }); // raw: bypass SOFTWARE_TITLE_RE, we want all Rails hits + + for (const job of jobs) { + if (seen.has(job.id) || existingLinks.has(job.link)) continue; + seen.add(job.id); + + process.stdout.write(` checking content: ${job.title} @ ${job.company} … `); + const english = await isEnglishContent(job.id); + if (!english) { + console.log("dropped (non-English)"); + continue; + } + console.log("ok"); + results.push({ title: job.title, company: job.company, id: job.id, link: job.link }); + } +} + +console.log(`\n${results.length} new Rails remote jobs found.\n`); +for (const r of results) { + console.log(`${r.title} | ${r.company} | ${r.link}`); +} diff --git a/scripts/swiss-remote-temp.ts b/scripts/swiss-remote-temp.ts new file mode 100644 index 0000000..5a10cc5 --- /dev/null +++ b/scripts/swiss-remote-temp.ts @@ -0,0 +1,71 @@ +import { KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; + +// Load existing links for deduplication +const existingLinks = new Set(); +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(); + +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}`); +} diff --git a/scripts/zurich2-temp.ts b/scripts/zurich2-temp.ts new file mode 100644 index 0000000..2eac0b2 --- /dev/null +++ b/scripts/zurich2-temp.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env bun +import { KEYWORDS } from "../config.ts"; +import { fetchJobs, isEnglishContent } from "../fetch.ts"; +import type { SearchConfig } from "../config.ts"; + +const CSV = "./jobs.csv"; +const existingLinks = new Set(); +try { + const text = await Bun.file(CSV).text(); + for (const line of text.split("\n").slice(1)) { + const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; + if (url) existingLinks.add(url); + } +} catch {} + +const searches: SearchConfig[] = [ + { label: "Zürich city", keywords: KEYWORDS, geoId: "102436504", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Zürich Metro", keywords: KEYWORDS, geoId: "107814425", workType: ["onsite", "hybrid"], timePosted: "month" }, + { label: "Zürich Area", keywords: KEYWORDS, geoId: "90009888", workType: ["onsite", "hybrid"], timePosted: "month" }, +]; + +type Job = { id: string; title: string; company: string; link: string; search: string }; +const results: Job[] = []; +const seen = new Set(); + +for (const config of searches) { + console.log(`\n[${config.label}]`); + const jobs = await fetchJobs(config); + if (jobs.length === 0) { console.log(" no results"); continue; } + + let added = 0; + for (const job of jobs) { + if (seen.has(job.id) || existingLinks.has(job.link)) continue; + seen.add(job.id); + process.stdout.write(` ${job.title} @ ${job.company} … `); + const english = await isEnglishContent(job.id); + if (!english) { console.log("dropped (lang)"); continue; } + console.log("ok"); + results.push({ ...job, search: config.label }); + added++; + } + console.log(` => ${added} new`); +} + +console.log(`\n=== TOTAL NEW: ${results.length} ===\n`); +for (const j of results) { + console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`); +}