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:
@@ -1,92 +1,152 @@
|
||||
# linkme — Claude skill
|
||||
# CLAUDE.md
|
||||
|
||||
This project scrapes LinkedIn jobs and filters/ranks them for the user's job search.
|
||||
The user's CV is at `polo.md`. Always use it when ranking opportunities.
|
||||
|
||||
---
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `bun fetch` | scrape Basel area (3 searches), append new jobs to `jobs.csv` |
|
||||
| `bun fetch` | run all searches in `config.ts`, append new jobs to `results/jobs.csv` |
|
||||
| `bun recheck` | fetch in raw mode, list filtered-out jobs + word-frequency keyword suggestions |
|
||||
|
||||
---
|
||||
Temp scripts: `bun run scripts/<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.
|
||||
|
||||
```ts
|
||||
import { KEYWORDS } from "./config.ts";
|
||||
import { fetchJobs } from "./fetch.ts";
|
||||
import type { SearchConfig } from "./config.ts";
|
||||
|
||||
const searches: SearchConfig[] = [
|
||||
{ label: "City Name", keywords: KEYWORDS, location: "City, Switzerland", workType: ["onsite", "hybrid"], timePosted: "month" },
|
||||
];
|
||||
// collect, deduplicate, print
|
||||
```
|
||||
config.ts # search keywords and areas
|
||||
fetch.ts # scraper, HTML parser, all filters
|
||||
fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch)
|
||||
index.ts # orchestrator: runs searches, writes CSV
|
||||
recheck.ts # raw-mode fetch, lists filtered-out jobs
|
||||
scripts/ # one-off search scripts; import from ../config.ts and ../fetch.ts
|
||||
cv/polo.md # Rafael's profile — use for ranking jobs
|
||||
cv/ # CV files (html, pdf, odt)
|
||||
results/jobs.csv # output — one row per job
|
||||
jobs/ # notes on specific job leads
|
||||
plans/ # LinkedIn post drafts
|
||||
```
|
||||
|
||||
Use `location: "City, Switzerland"` (no geoId) when the geoId is unknown — LinkedIn resolves by name.
|
||||
## Architecture
|
||||
|
||||
- **`config.ts`** — `KEYWORDS` string (joined with ` OR `) and `searches: SearchConfig[]`. Edit here to add/remove search areas or keywords.
|
||||
- **`fetch.ts`** — HTTP scraping, HTML parsing, and all filters. Exports `fetchJobs(config, { raw? })` and `isEnglishContent(id)`.
|
||||
- **`fetch-other.ts`** — scrapes extra sources (datacareer.ch, stellen-basel.ch). Imported by `index.ts`.
|
||||
- **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV.
|
||||
- **`recheck.ts`** — calls `fetchJobs(..., { raw: true })` to bypass `SOFTWARE_TITLE_RE`, then lists every new job that *would* be filtered out and prints word-frequency suggestions.
|
||||
|
||||
### Filter pipeline (applied in order)
|
||||
|
||||
1. **Switzerland location guard** (card HTML) — extracts `job-search-card__location` from each card; drops jobs where location doesn't contain "Switzerland". Prevents LinkedIn from returning global results when geoId is ignored on unauthenticated requests.
|
||||
2. **`NON_ENGLISH_RE`** (title) — drops German gender markers, umlauts, `/in` suffix, French accents/markers. Runs inside `fetchJobs`.
|
||||
3. **`JUNIOR_TITLE_RE`** (title) — drops junior/intern/trainee/werkstudent. Runs inside `fetchJobs`.
|
||||
4. **`JAVA_TITLE_RE`** (title) — drops Java-titled roles. Runs inside `fetchJobs`.
|
||||
5. **`SOFTWARE_TITLE_RE`** (title) — requires a relevant tech/role term. Skipped when `raw: true`. Runs inside `fetchJobs`.
|
||||
6. **`isEnglishContent`** (description page) — fetches the job page, counts DE/FR indicator words, drops if ≥ 6 matches. Runs in `index.ts` only (not `recheck.ts`). Adds 350 ms delay per job.
|
||||
|
||||
Deduplication: job IDs are extracted from `results/jobs.csv` on startup; any ID already seen is skipped before content-checking.
|
||||
|
||||
### SearchConfig
|
||||
|
||||
`url` overrides all filter params. Otherwise build from: `keywords`, `geoId`/`location`, `workType`, `jobType`, `timePosted`, `seniority`.
|
||||
|
||||
Known Swiss geoIds:
|
||||
- Basel city: `103383283`
|
||||
- Basel Metropolitan Area: `90009885`
|
||||
- Basel-Country: `107117530`
|
||||
- Zürich city: `102436504`
|
||||
- Zürich Metropolitan Area: `107814425`
|
||||
- Zürich Area: `90009888`
|
||||
|
||||
---
|
||||
Use `location: "City, Switzerland"` (no `geoId`) when the geoId is unknown — LinkedIn resolves by name.
|
||||
|
||||
## Filtering job descriptions by language
|
||||
**Auth warning:** Do NOT send the `li_at` cookie in job search requests (`/jobs/search/`). With the cookie, LinkedIn serves the React SPA instead of server-rendered HTML, breaking the card parser entirely. The cookie is only useful for individual profile pages.
|
||||
|
||||
Use the `bern-langcheck.ts` pattern: fetch each job page, extract the description, count German/French indicator words, drop anything with ≥ 6 matches.
|
||||
## Searching any city / mode temporarily
|
||||
|
||||
Write a throwaway script in `scripts/` (e.g. `scripts/bern-temp.ts`) — **never touch `config.ts`** unless the user explicitly asks to save the change. Delete temp scripts after the session unless the user asks to keep them.
|
||||
|
||||
Common patterns:
|
||||
|
||||
```ts
|
||||
const DE_CONTENT_RE = /\b(Sie|Ihre?|Ihnen|Wir|wir|und\s|für|mit\s|von\s|bei\s|das\s|ist\s|sind\s|haben\s|werden\s|können|suchen|bieten|Kenntnisse|Erfahrung|Anforderungen|Aufgaben|Stellenbeschreibung|Stellenangebot|Bewerbung|Ausbildung|Berufserfahrung|Teamarbeit|Quereinsteiger|Vollzeit|Teilzeit|unbefristet|Abschluss|gesucht|Deutsch|Englisch|Schweizer)\b/g;
|
||||
const FR_CONTENT_RE = /\b(vous|nous|votre|notre|pour\s|avec\s|profil|poste|emploi|entreprise|compétences|expérience|formation|équipe|rejoindre|recherchons|offrons|missions|candidature|CDI|CDD|temps\s+plein|temps\s+partiel)\b/g;
|
||||
// City search (onsite + hybrid)
|
||||
import { KEYWORDS } from "../config.ts";
|
||||
import { fetchJobs } from "../fetch.ts";
|
||||
import type { SearchConfig } from "../config.ts";
|
||||
|
||||
const config: SearchConfig = {
|
||||
label: "Bern", keywords: KEYWORDS, location: "Bern, Switzerland",
|
||||
workType: ["onsite", "hybrid"], timePosted: "month",
|
||||
};
|
||||
const jobs = await fetchJobs(config);
|
||||
for (const j of jobs) console.log(j.title, j.company, j.link);
|
||||
```
|
||||
|
||||
Extract description from `class="show-more-less-html__markup"`, fall back to stripping all HTML. Add 350 ms delay between requests.
|
||||
```ts
|
||||
// Remote-only across all Switzerland
|
||||
const config: SearchConfig = {
|
||||
label: "Switzerland Remote", keywords: KEYWORDS, location: "Switzerland",
|
||||
workType: ["remote"], timePosted: "month",
|
||||
};
|
||||
```
|
||||
|
||||
Title-only filtering runs during scraping via `NON_ENGLISH_RE` in `fetch.ts`. Content-level filtering via `isEnglishContent()` is now always applied in `index.ts` before writing to CSV — every new job has its description page fetched and checked.
|
||||
```ts
|
||||
// Description-level keyword check (e.g. Ruby/Rails)
|
||||
const RUBY_RE = /\bruby\b|\brails\b/i;
|
||||
// fetch each job page, test RUBY_RE against the description text
|
||||
```
|
||||
|
||||
---
|
||||
Extra filters for temp scripts (import from `../fetch.ts`):
|
||||
- `JAVA_TITLE_RE` — drop Java roles
|
||||
- `JUNIOR_TITLE_RE` — drop junior/intern
|
||||
- `isEnglishContent(id)` — drop DE/FR description content
|
||||
- Add your own inline RE for EMEA, .NET, etc.
|
||||
|
||||
## Ranking jobs against the CV
|
||||
|
||||
Read `polo.md` for the full profile. Key signals to match against:
|
||||
Read `cv/polo.md` for the full profile. Key signals:
|
||||
|
||||
- **Seniority**: Senior / Staff / Principal / Lead / Architect — weight titles accordingly
|
||||
- **Core stack**: Python · TypeScript · Ruby on Rails · React · Node.js
|
||||
- **Domain strengths**: platform engineering · data pipelines · AI/LLM integrations · DevOps (Docker, Kubernetes, CI/CD, AWS)
|
||||
- **Background edges**: fintech/CBDC (Moedax), civic tech (Tactical Tech / GFOSS), independent consulting (ExtraPolo, 40+ projects)
|
||||
- **Languages**: English (full) · German B1 TELC certified · Spanish (full) — flag roles that require C1+ German
|
||||
- **Languages**: English (full) · German B1 TELC certified · Spanish (full) — flag roles requiring C1+ German
|
||||
- **Location**: based in Basel, open to Basel / Bern / Zürich; EU citizen, no permit needed
|
||||
|
||||
---
|
||||
|
||||
## Output format
|
||||
|
||||
Always present jobs as a markdown table with columns: `#`, `Title`, `Company`, `Why it fits`, `Link`.
|
||||
Present jobs as a markdown table: `#`, `Title`, `Company`, `Why it fits`, `Link`.
|
||||
|
||||
Links use just the job ID as the anchor text:
|
||||
```
|
||||
[4392954199](https://www.linkedin.com/jobs/view/4392954199/)
|
||||
Links use just the job ID as anchor text: `[4192954199](https://www.linkedin.com/jobs/view/4192954199/)`
|
||||
|
||||
## Workflow: "find jobs in X city / remote / custom search"
|
||||
|
||||
1. Write a temp script in `scripts/` with the right `SearchConfig` (city, workType, custom keywords).
|
||||
2. Apply extra inline filters as needed: EMEA, .NET, description-level keyword check.
|
||||
3. Run `isEnglishContent` per job to drop DE/FR content.
|
||||
4. Rank survivors against `cv/polo.md`, present as a table.
|
||||
5. Delete temp scripts after the session unless asked to keep them.
|
||||
|
||||
## LinkedIn session token
|
||||
|
||||
`.env` contains `LI_AT` — the LinkedIn session cookie (`li_at` value). Use it to fetch authenticated LinkedIn pages via curl:
|
||||
|
||||
```bash
|
||||
curl -s "https://www.linkedin.com/in/<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.
|
||||
2. If the user says "English only" or "filter language": run the content-language check script against the results.
|
||||
3. Rank survivors against `polo.md`, present top N as a table.
|
||||
4. Delete the temp scripts after the session unless the user asks to keep them.
|
||||
**Tip:** Profile data is embedded as escaped JSON in the HTML. Use `html.unescape()` + regex for `"headline"`, `"locationName"`, `defaultLocalizedName` fields.
|
||||
|
||||
## Workflow for "check if new keywords should be added"
|
||||
## Workflow: "check if new keywords should be added"
|
||||
|
||||
1. Run `bun recheck`.
|
||||
2. Review the filtered-out list manually — identify false negatives (real software roles that got dropped).
|
||||
3. Check the word-frequency suggestions but ignore generic terms (`engineer`, `development`, `senior`).
|
||||
2. Identify false negatives — real software roles that got dropped.
|
||||
3. Ignore generic frequency suggestions (`engineer`, `development`, `senior`).
|
||||
4. Propose specific additions to `SOFTWARE_TITLE_RE` in `fetch.ts` or words to `NON_ENGLISH_RE`.
|
||||
|
||||
Reference in New Issue
Block a user