chore: reorganize repo structure and update docs

- move temp scripts to scripts/, cv files to cv/, jobs.csv to results/
- update index.ts and recheck.ts CSV path to results/jobs.csv
- fix imports in scripts/ to use ../config.ts and ../fetch.ts
- update CLAUDE.md and README.md to reflect new structure
- add plans/ with LinkedIn post drafts
- update .gitignore: ignore results/, jobs/, cv binaries, .DS_Store
This commit is contained in:
2026-06-03 16:01:32 +02:00
parent bcb6ae5ea3
commit fb67c38edd
40 changed files with 1472 additions and 806 deletions
+10 -2
View File
@@ -1,4 +1,12 @@
node_modules/ node_modules/
jobs/
jobs.csv
.env .env
.DS_Store
# job data and personal notes
results/
jobs/
# CV files (large binaries + personal)
cv/*.pdf
cv/*.odt
cv/*.html
+105 -45
View File
@@ -1,92 +1,152 @@
# linkme — Claude skill # CLAUDE.md
This project scrapes LinkedIn jobs and filters/ranks them for the user's job search. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
The user's CV is at `polo.md`. Always use it when ranking opportunities.
---
## Commands ## Commands
| command | what it does | | 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 | | `bun recheck` | fetch in raw mode, list filtered-out jobs + word-frequency keyword suggestions |
--- Temp scripts: `bun run scripts/<name>-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. ```
config.ts # search keywords and areas
```ts fetch.ts # scraper, HTML parser, all filters
import { KEYWORDS } from "./config.ts"; fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch)
import { fetchJobs } from "./fetch.ts"; index.ts # orchestrator: runs searches, writes CSV
import type { SearchConfig } from "./config.ts"; recheck.ts # raw-mode fetch, lists filtered-out jobs
scripts/ # one-off search scripts; import from ../config.ts and ../fetch.ts
const searches: SearchConfig[] = [ cv/polo.md # Rafael's profile — use for ranking jobs
{ label: "City Name", keywords: KEYWORDS, location: "City, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" }, cv/ # CV files (html, pdf, odt)
]; results/jobs.csv # output — one row per job
// collect, deduplicate, print 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: Known Swiss geoIds:
- Basel city: `103383283` - Basel city: `103383283`
- Basel Metropolitan Area: `90009885` - Basel Metropolitan Area: `90009885`
- Basel-Country: `107117530` - 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 ```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; // City search (onsite + hybrid)
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; 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 ## 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 - **Seniority**: Senior / Staff / Principal / Lead / Architect — weight titles accordingly
- **Core stack**: Python · TypeScript · Ruby on Rails · React · Node.js - **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) - **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) - **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 - **Location**: based in Basel, open to Basel / Bern / Zürich; EU citizen, no permit needed
---
## Output format ## 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: Links use just the job ID as anchor text: `[4192954199](https://www.linkedin.com/jobs/view/4192954199/)`
```
[4392954199](https://www.linkedin.com/jobs/view/4392954199/) ## 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/<slug>" \
-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. **Tip:** Profile data is embedded as escaped JSON in the HTML. Use `html.unescape()` + regex for `"headline"`, `"locationName"`, `defaultLocalizedName` fields.
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.
## Workflow for "check if new keywords should be added" ## Workflow: "check if new keywords should be added"
1. Run `bun recheck`. 1. Run `bun recheck`.
2. Review the filtered-out list manually — identify false negatives (real software roles that got dropped). 2. Identify false negatives — real software roles that got dropped.
3. Check the word-frequency suggestions but ignore generic terms (`engineer`, `development`, `senior`). 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`. 4. Propose specific additions to `SOFTWARE_TITLE_RE` in `fetch.ts` or words to `NON_ENGLISH_RE`.
+45 -60
View File
@@ -5,75 +5,73 @@ Scrapes LinkedIn job listings into a local CSV. Run it daily, delete rows you've
## Usage ## Usage
```bash ```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 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 ## Output
`jobs.csv` — one row per job: `results/jobs.csv` — one row per job:
| column | description | | column | description |
|---|---| |---|---|
| `date_found` | date you ran the fetch | | `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 | | `search` | which search config matched it |
| `title` | job title | | `title` | job title |
| `company` | hiring company | | `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. Open in any spreadsheet app. Delete rows as you apply or dismiss.
## How it works ## 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 - **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, preventing content from adjacent cards bleeding into company or date values - **Card-boundary-safe parsing** — each job card is isolated before extracting fields
- **Language filter** — drops German (`m/w/d`, umlauts, gender-marked vocabulary) and French (`H/F`, accented characters) job titles automatically - **Language filter** — drops German (`m/w/d`, umlauts, gender markers) and French (`H/F`, accents) titles
- **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 - **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, jr., intern, trainee, apprentice, werkstudent, and entry-level titles - **Seniority filter** — drops junior, intern, trainee, apprentice, werkstudent, entry-level
- **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 - **Global deduplication** — IDs indexed from existing CSV on startup; already-seen jobs are never re-added
- **30-day recency** — only fetches jobs posted in the last month (`f_TPR=r2592000`) - **30-day recency** — only jobs posted in the last month
## recheck ## 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 ## Configure searches
Edit `config.ts`: Edit `config.ts`:
```ts ```ts
export const KEYWORDS = [ export const KEYWORDS = "Software Engineer OR Data Engineer OR ...";
"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 searches: SearchConfig[] = [ export const searches: SearchConfig[] = [
{ {
@@ -84,30 +82,17 @@ export const searches: SearchConfig[] = [
workType: ["onsite", "hybrid"], workType: ["onsite", "hybrid"],
timePosted: "month", 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) - `103383283` — Basel (city)
- `90009885` — Basel Metropolitan Area - `90009885` — Basel Metropolitan Area
- `107117530` — Basel-Country - `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. 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).
+31
View File
@@ -72,4 +72,35 @@ export const searches: SearchConfig[] = [
workType: ["onsite", "hybrid"], workType: ["onsite", "hybrid"],
timePosted: "month", 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",
},
]; ];
View File
+143
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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<Job[]> {
const allJobs: Job[] = [];
const seen = new Set<string>();
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(/<article[^>]*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<Job[]> {
const allJobs: Job[] = [];
const seen = new Set<string>();
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;
}
+13 -3
View File
@@ -8,6 +8,7 @@ export interface Job {
postedAt: string; postedAt: string;
link: string; link: string;
search: string; search: string;
source: string;
} }
const WORK_TYPE_MAP: Record<WorkType, string> = { const WORK_TYPE_MAP: Record<WorkType, string> = {
@@ -54,7 +55,7 @@ const SENIORITY_MAP: Record<Seniority, string> = {
// - gender markers: (H/F), (F/H), (h/f) // - gender markers: (H/F), (F/H), (h/f)
// - French-only accented chars: é è ê ë à â ç î ï ô ù û ÿ œ æ // - French-only accented chars: é è ê ë à â ç î ï ô ù û ÿ œ æ
// - French-only vocabulary // - 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) // German gender markers (w = weiblich; must not match m/f/d)
String.raw`\(m\/w[^)]*\)`, 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", "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; 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(/<h4 class="base-search-card__subtitle">[\s\S]{0,300}?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/); 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 timeM = card.match(/<time[^>]+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 title = clean(titleM[1]);
const company = companyM ? clean(companyM[1]) : ""; const company = companyM ? clean(companyM[1]) : "";
const postedAt = timeM ? timeM[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; return jobs;
@@ -196,6 +205,7 @@ export async function fetchJobs(config: SearchConfig, { raw = false } = {}): Pro
const jobs = parseJobs(html, config.label) const jobs = parseJobs(html, config.label)
.filter((j) => !NON_ENGLISH_RE.test(j.title)) .filter((j) => !NON_ENGLISH_RE.test(j.title))
.filter((j) => !JUNIOR_TITLE_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)); .filter((j) => raw || SOFTWARE_TITLE_RE.test(j.title));
let newOnPage = 0; let newOnPage = 0;
+66 -11
View File
@@ -1,20 +1,38 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { searches } from "./config.ts"; import { searches } from "./config.ts";
import { fetchJobs, isEnglishContent } from "./fetch.ts"; import { fetchJobs, isEnglishContent } from "./fetch.ts";
import { fetchDataCareer, fetchStellenBasel } from "./fetch-other.ts";
const CSV = "./jobs.csv"; const CSV = "./results/jobs.csv";
const HEADER = "date_found,posted_at,search,title,company,link"; 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 // Migrate CSV: add source column if the old header is present
const existingIds = new Set<string>(); 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<string>();
try { try {
const text = await Bun.file(CSV).text(); const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) { for (const line of text.split("\n").slice(1)) {
const id = line.match(/\/jobs\/view\/(\d+)\//)?.[1]; const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (id) existingIds.add(id); if (url) existingLinks.add(url);
} }
} catch { } catch {
// first run — file doesn't exist yet // first run
} }
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
@@ -24,6 +42,7 @@ function csvCell(val: string): string {
return `"${val.replace(/"/g, '""')}"`; return `"${val.replace(/"/g, '""')}"`;
} }
// LinkedIn searches
for (const config of searches) { for (const config of searches) {
console.log(`\n[${config.label}]`); console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config); const jobs = await fetchJobs(config);
@@ -36,7 +55,7 @@ for (const config of searches) {
let added = 0; let added = 0;
let langDropped = 0; let langDropped = 0;
for (const job of jobs) { for (const job of jobs) {
if (!existingIds.has(job.id)) { if (!existingLinks.has(job.link)) {
process.stdout.write(` checking content: ${job.title} … `); process.stdout.write(` checking content: ${job.title} … `);
const english = await isEnglishContent(job.id); const english = await isEnglishContent(job.id);
if (!english) { if (!english) {
@@ -45,9 +64,9 @@ for (const config of searches) {
continue; continue;
} }
console.log("ok"); console.log("ok");
existingIds.add(job.id); existingLinks.add(job.link);
newRows.push( 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) .map(csvCell)
.join(",") .join(",")
); );
@@ -57,12 +76,48 @@ for (const config of searches) {
console.log(` ${jobs.length} found, ${added} new, ${langDropped} non-English dropped`); 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<ReturnType<typeof fetchDataCareer>> }> = [
{ 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) { if (newRows.length === 0) {
console.log("\nNo new jobs."); console.log("\nNo new jobs.");
process.exit(0); process.exit(0);
} }
// Append to CSV (write header if first run) // Append to CSV
const existing = await Bun.file(CSV).exists(); const existing = await Bun.file(CSV).exists();
const append = existing const append = existing
? await Bun.file(CSV).text() ? await Bun.file(CSV).text()
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"name": "linkme", "name": "linkme",
"version": "1.0.0", "version": "1.0.0",
"type": "module",
"scripts": { "scripts": {
"fetch": "bun run index.ts", "fetch": "bun run index.ts",
"recheck": "bun run recheck.ts" "recheck": "bun run recheck.ts"
} },
"type": "module"
} }
+21
View File
@@ -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
+17
View File
@@ -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
+18
View File
@@ -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
+20
View File
@@ -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
+17
View File
@@ -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
+17
View File
@@ -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.
-234
View File
@@ -1,234 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Rafael Polo - CV</title>
<style>
@page {
size: 8.2681in 11.6929in;
margin: 0.5in;
}
body {
font-family: Calibri, Liberation Sans, sans-serif;
font-size: 11pt;
line-height: 1.2;
margin: 0;
padding: 0;
}
.container {
display: table;
width: 100%;
}
.left-col {
display: table-cell;
width: 100%;
}
.title {
font-size: 24pt;
font-weight: bold;
margin-bottom: 0.05in;
}
.subtitle {
font-size: 11pt;
font-style: italic;
margin-bottom: 0.15in;
color: #444;
}
table.personal {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.2in;
}
table.personal td {
padding: 0.02in 0.05in;
vertical-align: top;
}
table.personal .label {
font-weight: bold;
color: #333;
width: 1in;
}
table.personal .header-row td {
font-weight: bold;
font-size: 11pt;
text-transform: uppercase;
border-bottom: 1px solid #999;
padding-bottom: 0.05in;
}
h3 {
font-size: 11pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.15in 0 0.08in 0;
border-bottom: 1px solid #999;
padding-bottom: 0.03in;
}
ul {
margin: 0.05in 0;
padding-left: 0.15in;
}
li {
margin-bottom: 0.04in;
font-size: 10pt;
}
.job-item {
margin-bottom: 0.08in;
}
.job-title {
font-weight: bold;
font-size: 10pt;
}
.job-meta {
font-style: italic;
font-size: 9pt;
color: #555;
}
.job-desc {
font-style: italic;
font-size: 10pt;
color: #444;
margin-left: 0.1in;
}
.edu-item {
margin-bottom: 0.05in;
}
.edu-degree {
font-weight: bold;
font-size: 10pt;
}
.edu-school {
font-style: italic;
font-size: 10pt;
}
.skill-item {
font-size: 10pt;
margin-bottom: 0.02in;
}
.lang-item {
font-size: 10pt;
margin-bottom: 0.02in;
}
.footer {
font-size: 9pt;
color: #666;
margin-top: 0.2in;
padding-top: 0.1in;
border-top: 1px solid #ccc;
}
</style>
</head>
<body>
<div class="title">Rafael Polo</div>
<div class="subtitle">Senior Software Engineer — Platform, Data & AI</div>
<table class="personal">
<tr>
<td colspan="2" class="header-row">Personal</td>
</tr>
<tr>
<td class="label">First name:</td>
<td>Rafael</td>
</tr>
<tr>
<td class="label">Last name:</td>
<td>Polo</td>
</tr>
<tr>
<td class="label">Citizenship:</td>
<td>Portuguese (EU) · Brazilian — no work permit required</td>
</tr>
<tr>
<td class="label">Address:</td>
<td>Basel, 4055, Switzerland</td>
</tr>
<tr>
<td class="label">Email:</td>
<td>rafael@extrapolo.com</td>
</tr>
<tr>
<td class="label">Web:</td>
<td>linkedin.com/in/rafaelpolo<br>extrapolo.com</td>
</tr>
</table>
<h3>Formal Education</h3>
<ul>
<li class="edu-item">
<span class="edu-degree"><em><strong>Postgraduate in Data Science for Finance</strong></em> – 2021</span><br>
<span class="edu-school">NOVA IMS Information Management School, Lisbon</span>
</li>
<li class="edu-item">
<span class="edu-degree"><em><strong>Master in Information Systems</strong></em> – 2010–2012</span><br>
<span class="edu-school">PPGI/UNIRIO — Knowledge Representation & Reasoning</span>
</li>
<li class="edu-item">
<span class="edu-degree"><em><strong>Bachelor in Computer Science</strong></em> – 2004–2009</span><br>
<span class="edu-school">Metodista Bennett, Rio de Janeiro</span>
</li>
</ul>
<h3>Relevant Professional Experience</h3>
<ul>
<li class="job-item">
<span class="job-title">Swisscom</span> – <span class="job-meta">2024–2025 – Zürich, Switzerland</span><br>
<span class="job-desc"><strong>DevOps Engineer</strong> developing platforms for Telecommunication Operations.</span>
</li>
<li class="job-item">
<span class="job-title">EDGE Empower®</span> – <span class="job-meta">2024–2025 – Baar, Switzerland</span><br>
<span class="job-desc"><strong>Senior Technical Lead</strong> managing projects and external UX & Dev Teams for a SaaS Product.</span>
</li>
<li class="job-item">
<span class="job-title">ExtraPolo — Independent Consulting</span> – <span class="job-meta">2010–2022 – Rio de Janeiro · Berlin · Athens · Lisbon</span><br>
<span class="job-desc">40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients.</span>
</li>
<li class="job-item">
<span class="job-title">Moedax Digital Services</span> – <span class="job-meta">2018–2021 – Lisbon · Rio de Janeiro</span><br>
<span class="job-desc"><strong>Founder & CTO</strong> – Fintech startup connecting CBDC digital currency with KYC compliance automation.</span>
</li>
<li class="job-item">
<span class="job-title">GFOSS.eu / Code + Create</span> – <span class="job-meta">2017–2018 – Athens, Greece</span><br>
<span class="job-desc"><strong>Web Programming Teacher</strong> – taught HTML, CSS, jQuery to refugee and local youth.</span>
</li>
<li class="job-item">
<span class="job-title">Tactical Tech Collective</span> – <span class="job-meta">2014–2016 – Berlin, Germany</span><br>
<span class="job-desc"><strong>Full-Stack Web Architect</strong> – investigative data tools under Exposing the Invisible team.</span>
</li>
<li class="job-item">
<span class="job-title">Trommsdorf + Drüner</span> – <span class="job-meta">2013–2014 – Berlin, Germany</span><br>
<span class="job-desc"><strong>Full-Stack Engineer</strong> – Innovation Team developing Business Intelligence dashboards.</span>
</li>
<li class="job-item">
<span class="job-title">Cortex Intelligence</span> – <span class="job-meta">2007–2008 – Rio de Janeiro, Brazil</span><br>
<span class="job-desc"><strong>Developer</strong> – Competitive Intelligence and Data Mining R&D team.</span>
</li>
</ul>
<h3>Certifications</h3>
<ul>
<li class="skill-item">Swiss Federal Act on Data Protection (nFADP)</li>
<li class="skill-item">Gen AI Foundational Models — NLP & LLMs</li>
<li class="skill-item">AI Agents using RAG and LangChain</li>
</ul>
<h3>Relevant Tech Skills</h3>
<ul>
<li class="skill-item"><strong>Languages & Frameworks:</strong> Python · Ruby on Rails · TypeScript · React · Java</li>
<li class="skill-item"><strong>DevOps & Infra:</strong> Docker · Kubernetes · CI/CD · AWS · Linux · Git</li>
<li class="skill-item"><strong>Data & AI:</strong> SQL · ETL · Data Engineering · RAG · LangChain · LLM integrations</li>
<li class="skill-item"><strong>Architecture:</strong> REST APIs · MVC · OOP · UML · System Design · Web Crawling</li>
</ul>
<h3>Natural Languages</h3>
<ul>
<li class="lang-item">Native Brazilian Portuguese</li>
<li class="lang-item">Full professional English</li>
<li class="lang-item">Full professional Spanish</li>
<li class="lang-item">German B1 – TELC certified · 3 yrs Berlin + 3 yrs Basel</li>
</ul>
<div class="footer">
References available upon request · CV updated May 2026
</div>
</body>
</html>
-196
View File
@@ -1,196 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Rafael Polo - CV</title>
<style>
@page {
size: 8.2681in 11.6929in;
margin: 0.5in;
}
body {
font-family: Liberation Sans, Carlito, Georgia, serif;
font-size: 11pt;
line-height: 1.4;
margin: 0;
padding: 0;
}
.title {
font-size: 36pt;
font-weight: bold;
margin-bottom: 0.1in;
}
.subtitle {
font-size: 14pt;
margin-bottom: 0.3in;
color: #333;
}
h1 {
font-size: 24pt;
font-weight: bold;
margin: 0.2in 0 0.1in 0;
border-bottom: 1px solid #333;
padding-bottom: 0.05in;
}
h2 {
font-size: 18pt;
font-weight: bold;
margin: 0.15in 0 0.08in 0;
}
h3 {
font-size: 14pt;
font-weight: bold;
margin: 0.1in 0 0.05in 0;
}
.section {
margin-bottom: 0.15in;
}
.personalinline {
font-size: 10pt;
margin-bottom: 0.2in;
}
.personalinline span {
margin-right: 0.3in;
}
.key-value {
display: flex;
margin: 0.03in 0;
}
.key {
font-weight: bold;
width: 1.5in;
}
.value {
flex: 1;
}
ul {
margin: 0.05in 0;
padding-left: 0.2in;
}
li {
margin-bottom: 0.03in;
}
.small {
font-size: 10pt;
}
.experience-item {
margin-bottom: 0.1in;
}
.exp-header {
font-weight: bold;
}
.exp-meta {
font-style: italic;
font-size: 10pt;
}
.exp-desc {
margin-left: 0.2in;
}
</style>
</head>
<body>
<div class="title">Rafael Polo</div>
<div class="subtitle">Senior Software Engineer — Platform, Data & AI</div>
<div class="personalinline">
<span>Basel, 4055, Switzerland</span>
<span>rafael@extrapolo.com</span>
<span>linkedin.com/in/rafaelpolo</span>
<span>extrapolo.com</span>
</div>
<div class="personalinline">
<span>Portuguese (EU) · Brazilian — no work permit required</span>
<span>20+ years building production systems across Switzerland, Germany, Greece and Brazil</span>
<span>Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU citizen, immediately available</span>
</div>
<div class="section">
<h1>P R O F E S S I O N A L E X P E R I E N C E</h1>
<div class="experience-item">
<div class="exp-header">Swisscom</div>
<div class="exp-meta">DevOps Engineer · Zürich, Switzerland</div>
<div class="exp-desc">Modernization of mission-critical internal platforms & CI/CD pipelines for high-availability Telecom infrastructure.</div>
</div>
<div class="experience-item">
<div class="exp-header">EDGE Empower®</div>
<div class="exp-meta">Senior Technical Lead · Baar, Switzerland · 2024 – 2025</div>
<div class="exp-desc">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.</div>
</div>
<div class="experience-item">
<div class="exp-header">ExtraPolo — Independent Consulting</div>
<div class="exp-meta">Software Engineer & Consultant · Rio de Janeiro · Berlin · Athens · Lisbon · 2010 – 2022</div>
<div class="exp-desc">40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients.</div>
</div>
<div class="experience-item">
<div class="exp-header">Moedax Digital Services</div>
<div class="exp-meta">Founder & CTO · Lisbon · Rio de Janeiro · 2018 – 2021</div>
<div class="exp-desc">Fintech startup connecting CBDC digital currency with KYC compliance automation; built data pipelines and banking integrations in Python.</div>
</div>
<div class="experience-item">
<div class="exp-header">GFOSS.eu / Code + Create</div>
<div class="exp-meta">Web Programming Teacher · Athens, Greece · 2017 – 2018</div>
<div class="exp-desc">Taught web programming (HTML, CSS, jQuery) to refugee and local youth through the EU-funded Code + Create initiative.</div>
</div>
<div class="experience-item">
<div class="exp-header">Tactical Tech Collective</div>
<div class="exp-meta">Full-Stack Web Architect · Berlin, Germany · Dec 2014 – Jun 2016</div>
<div class="exp-desc">Built investigative data tools and platforms under the Exposing the Invisible team (Ruby on Rails, data visualisation).</div>
</div>
<div class="experience-item">
<div class="exp-header">Trommsdorff + Drüner</div>
<div class="exp-meta">Full-Stack Engineer · Berlin, Germany</div>
<div class="exp-desc">Innovation team — Business Intelligence dashboards for marketing consultancy clients.</div>
</div>
<div class="experience-item">
<div class="exp-header">Cortex Intelligence</div>
<div class="exp-meta">Developer · Rio de Janeiro, Brazil</div>
<div class="exp-desc">Competitive Intelligence and Data Mining R&D team.</div>
</div>
</div>
<div class="section">
<h1>E D U C A T I O N</h1>
<div class="key-value"><span class="key">Postgraduate in Data Science for Finance</span><span class="value">NOVA IMS Information Management School, Lisbon — 2021</span></div>
<div class="key-value"><span class="key">Master in Information Systems</span><span class="value">PPGI/UNIRIO — Knowledge Representation & Reasoning — 2010–2012</span></div>
<div class="key-value"><span class="key">Bachelor in Computer Science</span><span class="value">Metodista Bennett, Rio de Janeiro — 2004–2009</span></div>
</div>
<div class="section">
<h1>L A N G U A G E S</h1>
<div class="key-value"><span class="key">Portuguese</span><span class="value">native</span></div>
<div class="key-value"><span class="key">English</span><span class="value">full professional</span></div>
<div class="key-value"><span class="key">Spanish</span><span class="value">full professional</span></div>
<div class="key-value"><span class="key">German</span><span class="value">B1 — TELC certified · 3 yrs Berlin + 3 yrs Basel</span></div>
</div>
<div class="section">
<h1>C E R T I F I C A T I O N S</h1>
<ul>
<li>Swiss Federal Act on Data Protection (nFADP)</li>
<li>Gen AI Foundational Models — NLP & LLMs</li>
<li>AI Agents using RAG and LangChain</li>
</ul>
</div>
<div class="section">
<h1>T E C H S K I L L S</h1>
<div class="key-value"><span class="key">Languages & Frameworks</span><span class="value">Python · Ruby on Rails · TypeScript · React · Java</span></div>
<div class="key-value"><span class="key">DevOps & Infra</span><span class="value">Docker · Kubernetes · CI/CD · AWS · Linux · Git</span></div>
<div class="key-value"><span class="key">Data & AI</span><span class="value">SQL · ETL · Data Engineering · RAG · LangChain · LLM integrations · Data Science</span></div>
<div class="key-value"><span class="key">Architecture</span><span class="value">REST APIs · MVC · OOP · UML · System Design · Web Crawling & Scraping</span></div>
</div>
<div class="small">
References available upon request · CV updated May 2026
</div>
</body>
</html>
-243
View File
@@ -1,243 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Rafael Polo - CV</title>
<style>
@page {
size: 8.2681in 11.6929in;
margin: 0.5in;
}
body {
font-family: Liberation Sans, Carlito, Calibri, Georgia, sans-serif;
font-size: 11pt;
line-height: 1.3;
margin: 0;
padding: 0;
}
.container {
display: table;
width: 100%;
}
.left-col {
display: table-cell;
width: 65%;
padding-right: 0.2in;
vertical-align: top;
}
.right-col {
display: table-cell;
width: 35%;
vertical-align: top;
text-align: right;
}
.photo {
width: 2in;
height: auto;
border: 1px solid #ccc;
}
.title {
font-size: 24pt;
font-weight: bold;
margin-bottom: 0.05in;
}
.subtitle {
font-size: 12pt;
font-style: italic;
margin-bottom: 0.2in;
color: #444;
}
h3 {
font-size: 12pt;
font-weight: bold;
margin: 0.15in 0 0.08in 0;
text-transform: uppercase;
letter-spacing: 0.05in;
border-bottom: 1px solid #999;
padding-bottom: 0.03in;
}
.personal-table {
width: 100%;
border-collapse: collapse;
}
.personal-table td {
padding: 0.02in 0;
vertical-align: top;
}
.personal-table .label {
font-weight: bold;
width: 1.2in;
color: #333;
}
.personal-table .header {
font-weight: bold;
font-size: 12pt;
text-transform: uppercase;
letter-spacing: 0.05in;
padding-bottom: 0.05in;
}
.section-header {
font-weight: bold;
font-size: 11pt;
text-transform: uppercase;
letter-spacing: 0.03in;
margin-top: 0.15in;
color: #333;
}
ul {
margin: 0.05in 0;
padding-left: 0.15in;
}
li {
margin-bottom: 0.04in;
font-size: 10pt;
}
.job-title {
font-weight: bold;
}
.job-meta {
font-style: italic;
font-size: 10pt;
}
.job-company {
font-weight: bold;
}
.job-desc {
font-style: italic;
font-size: 10pt;
margin-left: 0.1in;
}
.edu-item {
font-size: 10pt;
margin-bottom: 0.03in;
}
.edu-school {
font-style: italic;
}
.skill-category {
font-weight: bold;
font-size: 10pt;
margin-top: 0.05in;
}
.skill-list {
font-size: 10pt;
margin-left: 0.1in;
}
.footer {
font-size: 9pt;
color: #666;
margin-top: 0.3in;
border-top: 1px solid #ccc;
padding-top: 0.1in;
}
</style>
</head>
<body>
<div class="container">
<div class="left-col">
<p class="title">Rafael Polo</p>
<p class="subtitle">Senior Software Engineer — Platform, Data & AI</p>
<table class="personal-table">
<tr>
<td class="header" colspan="2">Personal</td>
</tr>
<tr>
<td class="label">Address:</td>
<td>Basel, 4055, Switzerland</td>
</tr>
<tr>
<td class="label">Email:</td>
<td>rafael@extrapolo.com</td>
</tr>
<tr>
<td class="label">LinkedIn:</td>
<td>linkedin.com/in/rafaelpolo</td>
</tr>
<tr>
<td class="label">Web:</td>
<td>extrapolo.com</td>
</tr>
<tr>
<td class="label">Citizenship:</td>
<td>Portuguese (EU) · Brazilian — no work permit required</td>
</tr>
<tr>
<td class="label">Languages:</td>
<td>Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU citizen, immediately available</td>
</tr>
</table>
<h3>Formal Education</h3>
<ul>
<li><em><strong>Postgraduate in Data Science for Finance</strong></em> – 2021<br>
<span class="edu-school">NOVA IMS Information Management School, Lisbon</span></li>
<li><em><strong>Master in Information Systems</strong></em> – 2010–2012<br>
<span class="edu-school">Research Area: Knowledge Representation & Reasoning, PPGI/UNIRIO</span></li>
<li><em><strong>Bachelor in Computer Science</strong></em> – 2004–2009<br>
<span class="edu-school">Metodista Bennett, Rio de Janeiro</span></li>
<li>Thesis: Agile Systems Development for Web 2.0 with Ruby on Rails</li>
</ul>
<h3>Relevant Professional Experience</h3>
<ul>
<li><strong>Swisscom</strong> – <em>2024–2025</em> – <em>Zürich, Switzerland</em>
<p class="job-desc"><strong>DevOps Engineer</strong> developing platforms for Telecommunication Operations.</p></li>
<li><strong>EDGE Empower®</strong> – <em>2024–2025</em> – <em>Baar, Switzerland</em>
<p class="job-desc"><strong>Senior Technical Lead</strong> managing projects and external UX & Dev Teams for a SaaS Company.</p></li>
<li><strong>ExtraPolo — Independent Consulting</strong> – <em>2010–2022</em> – <em>Rio de Janeiro · Berlin · Athens · Lisbon</em>
<p class="job-desc">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.</p></li>
<li><strong>Moedax Digital Services</strong> – <em>2018–2021</em> – <em>Lisbon and Rio de Janeiro</em>
<p class="job-desc"><strong>Founder & CTO</strong> – connected Brazilian CBDC with KYC processes and automation</p></li>
<li><strong>GFOSS.eu / Code + Create</strong> – <em>2017–2018</em> – <em>Athens, Greece</em>
<p class="job-desc"><strong>Web Programming Teacher</strong> – taught web programming to refugee and local youth</p></li>
<li><strong>Tactical Tech Collective</strong> – <em>2014–2016</em> – <em>Berlin, Germany</em>
<p class="job-desc"><strong>Full-Stack Web Architect</strong> on an NGO under the Exposing the Invisible Team.</p></li>
<li><strong>Trommsdorf + Drüner</strong> – <em>2013–2014</em> – <em>Berlin, Germany</em>
<p class="job-desc"><strong>Full-stack Engineer</strong> – Innovation Team developing Business Intelligence dashboards.</p></li>
<li><strong>Cortex Intelligence</strong> – <em>2007–2008</em> – <em>Rio de Janeiro, Brazil</em>
<p class="job-desc"><strong>Developer</strong> in the Competitive Intelligence and Data Mining Team.</p></li>
</ul>
<h3>Certifications</h3>
<ul>
<li>Swiss Federal Act on Data Protection (nFADP)</li>
<li>Gen AI Foundational Models — NLP & LLMs</li>
<li>AI Agents using RAG and LangChain</li>
</ul>
<h3>Relevant Tech Skills</h3>
<ul>
<li>System Analysis & Modeling with UML</li>
<li>Quick Proof-of-Concepts development, deploy and management.</li>
<li>Large Language Models, Gen AI, LangChain integration and Processes Automation</li>
<li>MVC System Architecture, Design Patterns & Object-Oriented Programming</li>
<li>Data Engineering, Data Science, Relational Databases & SQL</li>
<li>Experience with Blockchain, Smart Contracts and P2P systems</li>
<li>Distributing and Scaling Processes with Message Queues</li>
<li>Developing Web Crawlers & Bots for Data Mining & ETL</li>
<li>Linux Systems, VPS, DevOps, SSH & Deploy with Continuous Integration processes</li>
<li>Projects mostly developed in Python, Ruby on Rails, Django, Java</li>
<li>Full Stack Developer & Front-end Web Design with HTML, CSS and JavaScript frameworks</li>
<li>CI/CD, Docker, Kubernetes and GitHub workflows</li>
</ul>
<h3>Natural Languages</h3>
<ul>
<li>Native Brazilian Portuguese</li>
<li>Advanced Spoken Spanish</li>
<li>A2 level Greek – <em>3 years in Athens</em></li>
<li>B1 level German / TELC proof – <em>3 years in Berlin</em> and 3 in Switzerland</li>
<li>Fluent English – <em>13 years in between international teams</em></li>
</ul>
<div class="footer">
References available upon request · CV updated May 2026
</div>
</div>
<div class="right-col">
<!-- Photo placeholder - remove if not needed -->
</div>
</div>
</body>
</html>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@
import { searches } from "./config.ts"; import { searches } from "./config.ts";
import { fetchJobs, SOFTWARE_TITLE_RE } from "./fetch.ts"; import { fetchJobs, SOFTWARE_TITLE_RE } from "./fetch.ts";
const CSV = "./jobs.csv"; const CSV = "./results/jobs.csv";
const existingIds = new Set<string>(); const existingIds = new Set<string>();
try { try {
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const searches: SearchConfig[] = [
{ label: "Basel city (onsite+hybrid)", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metropolitan (onsite+hybrid)", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel-Country (onsite+hybrid)", keywords: KEYWORDS, geoId: "107117530", location: "Basel-Country", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel city (remote)", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" },
{ label: "Basel Metropolitan (remote)", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["remote"], timePosted: "month" },
{ label: "Switzerland (remote)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no jobs found"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL: ${results.length} jobs ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id} | ${j.link}`);
}
+3 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { KEYWORDS } from "./config.ts"; import { KEYWORDS } from "../config.ts";
import { fetchJobs, SOFTWARE_TITLE_RE } from "./fetch.ts"; import { fetchJobs, SOFTWARE_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "./config.ts"; import type { SearchConfig } from "../config.ts";
const JUNIOR_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i; const JUNIOR_RE = /\b(junior|jr\.?|entry.?level|graduate|intern(?:ship)?|trainee|apprentice|werkstudent)\b/i;
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const searches: SearchConfig[] = [
{ label: "Bern onsite/hybrid", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Bern mid-senior", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid"], seniority: ["mid-senior", "director"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no results"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (lang)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}
+18 -6
View File
@@ -1,10 +1,22 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { KEYWORDS } from "./config.ts"; import { fetchJobs, isEnglishContent } from "../fetch.ts";
import { fetchJobs, isEnglishContent } from "./fetch.ts"; import type { SearchConfig } from "../config.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[] = [ 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<string>(); const seen = new Set<string>();
@@ -12,7 +24,7 @@ const results: { id: string; title: string; company: string; search: string }[]
for (const config of searches) { for (const config of searches) {
console.log(`\n[${config.label}]`); console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config); const jobs = await fetchJobs(config, { raw: true });
for (const job of jobs) { for (const job of jobs) {
if (!seen.has(job.id)) { if (!seen.has(job.id)) {
seen.add(job.id); seen.add(job.id);
@@ -31,7 +43,7 @@ for (const job of results) {
if (ok) english.push(job); 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()) { for (const [i, j] of english.entries()) {
console.log(` ${i + 1}. [${j.search}] ${j.title} — ${j.company}`); console.log(` ${i + 1}. [${j.search}] ${j.title} — ${j.company}`);
console.log(` https://www.linkedin.com/jobs/view/${j.id}/`); console.log(` https://www.linkedin.com/jobs/view/${j.id}/`);
+105
View File
@@ -0,0 +1,105 @@
import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
};
function clean(raw: string): string {
return raw.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
}
// parseJobs without Switzerland location guard
function parseJobsGlobal(html: string): { id: string; title: string; company: string; location: string }[] {
const jobs = [];
const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g;
const positions: { id: string; start: number }[] = [];
let m;
while ((m = idRe.exec(html)) !== null) positions.push({ id: m[1], start: m.index });
for (let i = 0; i < positions.length; i++) {
const { id, start } = positions[i];
const card = html.slice(start, Math.min(positions[i + 1]?.start ?? Infinity, start + 3000));
const titleM = card.match(/<h3 class="base-search-card__title">\s*([\s\S]*?)\s*<\/h3>/);
if (!titleM) continue;
const companyM = card.match(/<h4 class="base-search-card__subtitle">[\s\S]{0,300}?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
jobs.push({
id,
title: clean(titleM[1]),
company: companyM ? clean(companyM[1]) : "",
location: locM ? locM[1].trim() : "",
});
}
return jobs;
}
async function fetchGlobal(config: SearchConfig): Promise<{ id: string; title: string; company: string; location: string }[]> {
const baseUrl = buildUrl(config);
const all = [];
const seen = new Set<string>();
for (let page = 0; page < 6; page++) {
if (page > 0) await Bun.sleep(400);
const url = `${baseUrl}&start=${page * 25}`;
process.stdout.write(` page ${page + 1} … `);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.error(`failed (${res.status})`); break; }
const html = await res.text();
const jobs = parseJobsGlobal(html)
.filter(j => !NON_ENGLISH_RE.test(j.title))
.filter(j => !JUNIOR_TITLE_RE.test(j.title))
.filter(j => !JAVA_TITLE_RE.test(j.title));
let newCount = 0;
for (const j of jobs) {
if (!seen.has(j.id)) { seen.add(j.id); all.push(j); newCount++; }
}
console.log(`${jobs.length} jobs, ${newCount} new`);
if (newCount === 0) break;
}
return all;
}
const TECH_LEAD_RE = /\b(tech\s*lead|technical\s*lead|lead\s*(engineer|developer|backend|full.?stack)|staff\s*(engineer|developer)|principal\s*(engineer|developer)|engineering\s*lead|head\s*of\s*eng)\b/i;
const searches: SearchConfig[] = [
{
label: "Global Remote — Rails Tech Lead",
keywords: "Ruby on Rails Tech Lead OR Rails Lead Engineer OR Rails Staff Engineer OR Rails Principal Engineer OR Ruby Lead Developer",
workType: ["remote"],
timePosted: "month",
},
{
label: "Global Remote — Ruby Lead (broader)",
keywords: "Ruby on Rails Lead OR Ruby on Rails Staff OR Ruby on Rails Principal OR Ruby Tech Lead",
workType: ["remote"],
timePosted: "month",
},
];
type Result = { title: string; company: string; location: string; id: string; link: string };
const results: Result[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchGlobal(config);
for (const job of jobs) {
if (seen.has(job.id)) continue;
if (!TECH_LEAD_RE.test(job.title)) continue;
seen.add(job.id);
process.stdout.write(` checking: ${job.title} @ ${job.company} (${job.location}) … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (non-English)"); continue; }
console.log("ok");
results.push({ title: job.title, company: job.company, location: job.location, id: job.id, link: `https://www.linkedin.com/jobs/view/${job.id}/` });
}
}
console.log(`\n${results.length} global remote Rails lead jobs.\n`);
for (const r of results) console.log(`${r.title} | ${r.company} | ${r.location} | ${r.link}`);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const searches: SearchConfig[] = [
// Basel onsite/hybrid
{ label: "Basel city", keywords: KEYWORDS, geoId: "103383283", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metro", keywords: KEYWORDS, geoId: "90009885", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel-Country", keywords: KEYWORDS, geoId: "107117530", workType: ["onsite", "hybrid"], timePosted: "month" },
// Swiss remote
{ label: "CH remote", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "CH remote mid-senior", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no results"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (lang)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bun
import { searches, KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
// Load existing links for deduplication
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const remoteSearches: SearchConfig[] = [
{
label: "Basel (remote)",
keywords: KEYWORDS,
geoId: "103383283",
location: "Basel",
workType: ["remote"],
timePosted: "month",
},
{
label: "Basel Metropolitan Area (remote)",
keywords: KEYWORDS,
geoId: "90009885",
location: "Basel Metropolitan Area",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland (remote)",
keywords: KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
},
];
type Job = { id: string; title: string; company: string; link: string; postedAt: string };
const newJobs: Array<Job & { search: string }> = [];
const seenIds = new Set<string>();
for (const config of [...searches, ...remoteSearches]) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" No jobs found."); continue; }
let added = 0;
for (const job of jobs) {
if (existingLinks.has(job.link) || seenIds.has(job.id)) continue;
seenIds.add(job.id);
process.stdout.write(` checking: ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (non-English)"); continue; }
console.log("ok");
newJobs.push({ ...job, search: config.label });
added++;
}
console.log(` ${jobs.length} found, ${added} new`);
}
console.log(`\n=== ${newJobs.length} new jobs total ===\n`);
for (const j of newJobs) {
console.log(`${j.title} | ${j.company} | ${j.search} | ${j.link}`);
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bun
import { isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const RUBY_KEYWORDS = [
"Ruby on Rails", "Rails Engineer", "Ruby Engineer",
"Rails Developer", "Ruby Developer",
].join(" OR ");
const searches = [
{ label: "United States Remote (Ruby)", location: "United States", workType: "2" },
];
const RUBY_RE = /\bruby\b|\brails\b/i;
interface Job { id: string; title: string; company: string; location: string; link: string; postedAt: string }
function unescape(s: string) {
return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, " ").trim();
}
async function fetchPage(location: string, workType: string, start: number): Promise<Job[]> {
const params = new URLSearchParams({
keywords: RUBY_KEYWORDS,
location,
f_TPR: "r2592000",
f_WT: workType,
start: String(start),
});
const url = `https://www.linkedin.com/jobs/search/?${params}`;
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" }
});
const html = await res.text();
const jobs: Job[] = [];
const cardRe = /<div[^>]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn="urn:li:jobPosting:(\d+)"[^>]*>([\s\S]*?)(?=<div[^>]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn|<\/ul>)/g;
let m;
while ((m = cardRe.exec(html)) !== null) {
const id = m[1];
const chunk = m[2];
const titleM = chunk.match(/class="[^"]*base-search-card__title[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/h3>/);
const companyM = chunk.match(/class="[^"]*base-search-card__subtitle[^"]*"[\s\S]*?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const locationM = chunk.match(/class="[^"]*job-search-card__location[^"]*"[^>]*>\s*([\s\S]*?)\s*<\//);
const postedM = chunk.match(/datetime="([^"]+)"/);
const title = titleM ? unescape(titleM[1].replace(/<[^>]+>/g, "")) : "";
const company = companyM ? unescape(companyM[1].replace(/<[^>]+>/g, "")) : "";
const location = locationM ? unescape(locationM[1]) : "";
const postedAt = postedM ? postedM[1] : "";
if (!title || !id) continue;
jobs.push({ id, title, company, location, link: `https://www.linkedin.com/jobs/view/${id}/`, postedAt });
}
return jobs;
}
const newJobs: Array<Job & { search: string }> = [];
const seenIds = new Set<string>();
for (const s of searches) {
console.log(`\n[${s.label}]`);
let totalFound = 0, added = 0;
for (let page = 0; page < 4; page++) {
const jobs = await fetchPage(s.location, s.workType, page * 25);
if (jobs.length === 0) { console.log(` page ${page + 1} empty, stopping`); break; }
console.log(` page ${page + 1} … ${jobs.length} cards`);
totalFound += jobs.length;
for (const job of jobs) {
if (existingLinks.has(job.link) || seenIds.has(job.id)) continue;
if (NON_ENGLISH_RE.test(job.title) || JUNIOR_TITLE_RE.test(job.title)) continue;
if (!RUBY_RE.test(job.title)) continue;
seenIds.add(job.id);
process.stdout.write(` checking: ${job.title} @ ${job.company} (${job.location}) … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (non-English)"); continue; }
console.log("ok");
newJobs.push({ ...job, search: s.label });
added++;
}
}
console.log(` ${totalFound} total, ${added} new`);
}
console.log(`\n=== ${newJobs.length} Ruby remote jobs ===\n`);
for (const j of newJobs) {
console.log(`${j.title} | ${j.company} | ${j.location} | ${j.link}`);
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bun
import { KEYWORDS, fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
// Re-export KEYWORDS isn't in fetch.ts, import from config
import { KEYWORDS as CFG_KEYWORDS } from "../config.ts";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
};
const SAAS_RE = /\bsaas\b/i;
async function hasSaaS(id: string): Promise<boolean> {
try {
await Bun.sleep(350);
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
const html = await res.text();
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
const text = m ? m[1].replace(/<[^>]+>/g, " ") : html.replace(/<[^>]+>/g, " ").slice(0, 5000);
return SAAS_RE.test(text);
} catch { return false; }
}
const RUBY_KEYWORDS = [
"Ruby on Rails", "Rails Engineer", "Rails Developer",
"Ruby Engineer", "Ruby Developer", "Ruby",
].join(" OR ");
// --- Part 1: Ruby/Rails in Switzerland ---
const rubySearches: SearchConfig[] = [
{ label: "Basel Ruby (onsite+hybrid)", keywords: RUBY_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Ruby (remote)", keywords: RUBY_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" },
{ label: "Zürich Ruby (onsite+hybrid)",keywords: RUBY_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Ruby (remote)", keywords: RUBY_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Switzerland Ruby (remote)", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Bern Ruby", keywords: RUBY_KEYWORDS, location: "Bern, Switzerland", workType: ["onsite", "hybrid", "remote"], timePosted: "month" },
];
// --- Part 2: SaaS-experience jobs in Switzerland ---
const saasSearches: SearchConfig[] = [
{ label: "Basel SaaS", keywords: CFG_KEYWORDS, geoId: "103383283", location: "Basel", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Basel Metro SaaS", keywords: CFG_KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich SaaS", keywords: CFG_KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Switzerland SaaS remote", keywords: CFG_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
async function runSearches(searches: SearchConfig[], label: string, filterFn?: (id: string) => Promise<boolean>) {
const results: Job[] = [];
const seen = new Set<string>();
console.log(`\n${"=".repeat(50)}\n${label}\n${"=".repeat(50)}`);
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no jobs found"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id)) continue;
seen.add(job.id);
if (filterFn) {
process.stdout.write(` ${job.title} @ ${job.company} … `);
const pass = await filterFn(job.id);
if (!pass) { console.log("no SaaS"); continue; }
console.log("SaaS ✓");
}
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} added`);
}
return results;
}
const rubyJobs = await runSearches(rubySearches, "RUBY / RAILS IN SWITZERLAND");
const saasJobs = await runSearches(saasSearches, "SaaS-EXPERIENCE JOBS IN SWITZERLAND", hasSaaS);
console.log(`\n\n${"=".repeat(50)}\nRUBY RESULTS: ${rubyJobs.length}\n${"=".repeat(50)}`);
for (const j of rubyJobs) console.log(`${j.title} | ${j.company} | ${j.id} | ${j.link}`);
console.log(`\n${"=".repeat(50)}\nSaaS RESULTS: ${saasJobs.length}\n${"=".repeat(50)}`);
for (const j of saasJobs) console.log(`${j.title} | ${j.company} | ${j.id} | ${j.link}`);
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bun
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const RUBY_KEYWORDS = "Ruby on Rails OR Ruby Developer OR Rails Developer OR Backend Ruby OR Ruby Engineer";
const searches: SearchConfig[] = [
{ label: "Basel Ruby", keywords: RUBY_KEYWORDS, geoId: "90009885", timePosted: "month" },
{ label: "Zürich Ruby", keywords: RUBY_KEYWORDS, geoId: "107814425", timePosted: "month" },
{ label: "Bern Ruby", keywords: RUBY_KEYWORDS, location: "Bern, Switzerland", timePosted: "month" },
{ label: "CH remote Ruby", keywords: RUBY_KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
];
const RUBY_RE = /\bruby\b|\brails\b/i;
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no results"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (lang)"); continue; }
// fetch description to confirm Ruby/Rails mention
const html = await fetch(`https://www.linkedin.com/jobs/view/${job.id}/`).then(r => r.text()).catch(() => "");
if (!RUBY_RE.test(html)) { console.log("dropped (no ruby/rails in desc)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bun
import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
};
function clean(raw: string): string {
return raw.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
}
// Same as fetch.ts parseJobs but WITHOUT the Switzerland location guard
function parseJobs(html: string, label: string) {
const jobs: { id: string; title: string; company: string; link: string; location: string }[] = [];
const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g;
const positions: { id: string; start: number }[] = [];
let m;
while ((m = idRe.exec(html)) !== null) positions.push({ id: m[1], start: m.index });
for (let i = 0; i < positions.length; i++) {
const { id, start } = positions[i];
const card = html.slice(start, Math.min(positions[i + 1]?.start ?? Infinity, start + 3000));
const titleM = card.match(/<h3 class="base-search-card__title">\s*([\s\S]*?)\s*<\/h3>/);
if (!titleM) continue;
const companyM = card.match(/<h4 class="base-search-card__subtitle">[\s\S]{0,300}?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
const jobLocation = locM ? locM[1].trim() : "";
jobs.push({ id, title: clean(titleM[1]), company: companyM ? clean(companyM[1]) : "", location: jobLocation, link: `https://www.linkedin.com/jobs/view/${id}/` });
}
return jobs;
}
async function fetchJobsNoGeo(config: SearchConfig) {
const baseUrl = buildUrl(config);
const all: { id: string; title: string; company: string; location: string; link: string }[] = [];
const seen = new Set<string>();
for (let page = 0; page < 6; page++) {
if (page > 0) await Bun.sleep(400);
const url = `${baseUrl}&start=${page * 25}`;
process.stdout.write(` page ${page + 1} … `);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) { console.error(`failed (${res.status})`); break; }
const html = await res.text();
const jobs = parseJobs(html, config.label)
.filter(j => !NON_ENGLISH_RE.test(j.title))
.filter(j => !JUNIOR_TITLE_RE.test(j.title))
.filter(j => !JAVA_TITLE_RE.test(j.title))
.filter(j => SOFTWARE_TITLE_RE.test(j.title));
let newCount = 0;
for (const job of jobs) {
if (!seen.has(job.id)) { seen.add(job.id); all.push(job); newCount++; }
}
console.log(`${jobs.length} jobs, ${newCount} new`);
if (newCount === 0) break;
}
return all;
}
const RUBY_KEYWORDS = [
"Ruby on Rails",
"Rails Engineer",
"Rails Developer",
"Ruby Engineer",
"Ruby Developer",
"Tech Lead Ruby",
"Lead Engineer Ruby",
"Staff Ruby",
"Principal Ruby",
].join(" OR ");
const searches: SearchConfig[] = [
{ label: "USA Remote Ruby/Rails", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], timePosted: "month" },
{ label: "USA Remote Ruby/Rails mid-senior+", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; location: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobsNoGeo(config);
if (jobs.length === 0) { console.log(" no jobs found"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id)) continue;
seen.add(job.id);
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL: ${results.length} jobs ===\n`);
for (const j of results) {
console.log(`${j.title} | ${j.company} | ${j.location} | ${j.id} | ${j.link}`);
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const searches: SearchConfig[] = [
{ label: "Switzerland remote (by name)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Basel remote", keywords: KEYWORDS, geoId: "103383283", location: "Basel", workType: ["remote"], timePosted: "month" },
{ label: "Basel Metro remote", keywords: KEYWORDS, geoId: "90009885", location: "Basel Metropolitan Area", workType: ["remote"], timePosted: "month" },
{ label: "Zürich remote", keywords: KEYWORDS, geoId: "102436504", location: "Zürich, Switzerland", workType: ["remote"], timePosted: "month" },
{ label: "Zürich Metro remote", keywords: KEYWORDS, geoId: "107814425", location: "Zürich Metropolitan Area", workType: ["remote"], timePosted: "month" },
{ label: "Zürich Area remote", keywords: KEYWORDS, geoId: "90009888", location: "Zürich Area", workType: ["remote"], timePosted: "month" },
{ label: "Bern remote", keywords: KEYWORDS, location: "Bern, Switzerland", workType: ["remote"], timePosted: "month" },
// seniority-filtered pass for mid-senior+
{ label: "Switzerland remote (mid-senior)", keywords: KEYWORDS, location: "Switzerland", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no jobs found"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL: ${results.length} remote jobs ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id} | ${j.link}`);
}
+79
View File
@@ -0,0 +1,79 @@
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const RAILS_KEYWORDS = [
"Ruby on Rails",
"Ruby Developer",
"Rails Developer",
"Ruby Engineer",
"Rails Engineer",
"Full Stack Ruby",
"Backend Ruby",
"RoR Developer",
"RoR Engineer",
].join(" OR ");
const searches: SearchConfig[] = [
{
label: "Switzerland Remote Rails (Zürich)",
keywords: RAILS_KEYWORDS,
geoId: "102436504",
location: "Zürich, Switzerland",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote Rails (by name)",
keywords: RAILS_KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote Rails (Basel)",
keywords: RAILS_KEYWORDS,
geoId: "103383283",
location: "Basel",
workType: ["remote"],
timePosted: "month",
},
];
type Result = { title: string; company: string; id: string; link: string };
const results: Result[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config, { raw: true }); // raw: bypass SOFTWARE_TITLE_RE, we want all Rails hits
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` checking content: ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) {
console.log("dropped (non-English)");
continue;
}
console.log("ok");
results.push({ title: job.title, company: job.company, id: job.id, link: job.link });
}
}
console.log(`\n${results.length} new Rails remote jobs found.\n`);
for (const r of results) {
console.log(`${r.title} | ${r.company} | ${r.link}`);
}
+71
View File
@@ -0,0 +1,71 @@
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
// Load existing links for deduplication
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {
// first run
}
const searches: SearchConfig[] = [
{
label: "Switzerland Remote (Basel geoId)",
keywords: KEYWORDS,
geoId: "103383283",
location: "Basel",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote (Zürich geoId)",
keywords: KEYWORDS,
geoId: "102436504",
location: "Zürich, Switzerland",
workType: ["remote"],
timePosted: "month",
},
{
label: "Switzerland Remote (by name)",
keywords: KEYWORDS,
location: "Switzerland",
workType: ["remote"],
timePosted: "month",
},
];
type Result = { title: string; company: string; id: string; link: string };
const results: Result[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` checking content: ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) {
console.log("dropped (non-English)");
continue;
}
console.log("ok");
results.push({ title: job.title, company: job.company, id: job.id, link: job.link });
}
}
console.log(`\n${results.length} new remote jobs found.\n`);
for (const r of results) {
console.log(`${r.title} | ${r.company} | ${r.link}`);
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bun
import { KEYWORDS } from "../config.ts";
import { fetchJobs, isEnglishContent } from "../fetch.ts";
import type { SearchConfig } from "../config.ts";
const CSV = "./jobs.csv";
const existingLinks = new Set<string>();
try {
const text = await Bun.file(CSV).text();
for (const line of text.split("\n").slice(1)) {
const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1];
if (url) existingLinks.add(url);
}
} catch {}
const searches: SearchConfig[] = [
{ label: "Zürich city", keywords: KEYWORDS, geoId: "102436504", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Metro", keywords: KEYWORDS, geoId: "107814425", workType: ["onsite", "hybrid"], timePosted: "month" },
{ label: "Zürich Area", keywords: KEYWORDS, geoId: "90009888", workType: ["onsite", "hybrid"], timePosted: "month" },
];
type Job = { id: string; title: string; company: string; link: string; search: string };
const results: Job[] = [];
const seen = new Set<string>();
for (const config of searches) {
console.log(`\n[${config.label}]`);
const jobs = await fetchJobs(config);
if (jobs.length === 0) { console.log(" no results"); continue; }
let added = 0;
for (const job of jobs) {
if (seen.has(job.id) || existingLinks.has(job.link)) continue;
seen.add(job.id);
process.stdout.write(` ${job.title} @ ${job.company} … `);
const english = await isEnglishContent(job.id);
if (!english) { console.log("dropped (lang)"); continue; }
console.log("ok");
results.push({ ...job, search: config.label });
added++;
}
console.log(` => ${added} new`);
}
console.log(`\n=== TOTAL NEW: ${results.length} ===\n`);
for (const j of results) {
console.log(`[${j.search}] ${j.title} | ${j.company} | ${j.id}`);
}