No description
  • Python 98.2%
  • Just 1.4%
  • Shell 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Gurbakhshish Singh 4b15fb4e47
All checks were successful
CI / Tests (push) Successful in 11s
CI / SAST (Bandit) (push) Successful in 3s
CI / SCA (Trivy) (push) Successful in 9s
CI / Publish to PyPI (push) Has been skipped
Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test
2026-10-14 13:19:32 -04:00
.agents/skills/jobsearch-profile Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test 2026-10-14 13:19:32 -04:00
.forgejo Phase 8: actionable block errors, prune, live drift tests + weekly CI, httpx2 test dep 2026-10-14 11:22:06 -04:00
src/jobsearch Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test 2026-10-14 13:19:32 -04:00
tests Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test 2026-10-14 13:19:32 -04:00
.gitignore Add Forgejo CI: tests, Bandit SAST, Trivy SCA (adapted from suredata_client) 2026-08-27 13:09:21 -04:00
.python-version getting started 2026-08-27 11:08:04 -04:00
AGENTS.md Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test 2026-10-14 13:19:32 -04:00
justfile Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test 2026-10-14 13:19:32 -04:00
LICENSE Phase 5: rename to jobsearch, MIT license, stealth extra, config.toml, mcp/api/config commands, tag-gated PyPI publish 2026-10-14 11:06:04 -04:00
PLAN.md Config: switch config.toml to config.yaml (same keys), drop tomllib; validate board sections 2026-10-14 12:47:52 -04:00
pyproject.toml Phase 11: YAML profiles, explainable recommendations, sync with LinkedIn page budget + description enrichment, profile watches; CLI/REST/MCP 2026-10-14 12:39:47 -04:00
README.md Add jobsearch-profile agent skill: adaptive interview (LinkedIn/resume prefill, consented single URL fetch) that saves via MCP or CLI; install-skill recipe; schema drift test 2026-10-14 13:19:32 -04:00
SPECS.md Phase 5: rename to jobsearch, MIT license, stealth extra, config.toml, mcp/api/config commands, tag-gated PyPI publish 2026-10-14 11:06:04 -04:00
uv.lock Phase 11: YAML profiles, explainable recommendations, sync with LinkedIn page budget + description enrichment, profile watches; CLI/REST/MCP 2026-10-14 12:39:47 -04:00

jobsearch

Your job search, in one local tool: search LinkedIn and company job boards (Greenhouse, Lever, Ashby) together, get jobs ranked against your own profile (titles, skills, preferences) with the reasons, track where you've applied, and see only the new postings from your saved searches. Works from the terminal, over a REST API, or through your AI assistant (MCP). Everything is stored locally in SQLite. No accounts, no tracking.

jobsearch search --keywords "data engineer" --remote --posted-within past_week --save-as de
jobsearch track linkedin 3952273769 applied --note "referral from Sam"
jobsearch watch run            # tomorrow: only jobs you haven't seen yet
jobsearch recommend            # ranked for your profile, with matched/missing skills
jobsearch list --status applied
  • One search, many sources: LinkedIn (no login) plus any company on Greenhouse, Lever, or Ashby
  • Never invents data: salary, workplace, and dates only when the posting states them; currency is detected, never assumed
  • Application tracking: saved → applied → interviewing → offer / rejected, with notes. The full posting is saved, so it survives being taken down.
  • Saved searches ("watches"): report only jobs first seen since the last run. Cron-friendly, with Markdown/CSV/JSON output.
  • Recommendations from your profile: a YAML file with target titles, places, preferences, and must/nice/avoid skills; every job gets an explainable 0–100 score
  • Fine-grained queries: title:"data engineer" (python OR scala) -senior salary>=150k posted<14d, answered instantly from the local store
  • Fetches less, finds more: company boards are fetched whole once a day and LinkedIn by coarse searches; every refinement runs locally
  • Filters that matter: remote/hybrid/onsite, posted-within, seniority, minimum salary, excluded companies and title words
  • Offline full-text search over every job you've ever fetched
  • Polite and resilient: caching, throttling, and backoff. If a source fails, you get cached results plus an error status, never a crash.

Install

Needs Python 3.12+. With uv (or pipx install jobsearch):

uv tool install jobsearch      # puts `jobsearch` on your PATH
uvx jobsearch --help           # or run without installing

From source:

git clone https://git.gsingh.io/gurbakhshish/jobsearch && cd jobsearch
uv sync && uv run jobsearch --help

LinkedIn needs a residential IP. Guest traffic is rate-limited to roughly 10 pages per IP before HTTP 429, and datacenter/VPN IPs get HTTP 999 outright. jobsearch throttles (2–3 s between requests), retries with backoff, and serves cached results when blocked. If you keep getting blocked, enable the stealth-browser fallback:

uv tool install 'jobsearch[stealth]'   # adds Camoufox + Playwright (large)
camoufox fetch                         # download the browser once
export JOBSEARCH_STEALTH=1             # or `stealth: true` in config.yaml

Quick start

  1. (Optional) Add companies you care about. LinkedIn works out of the box; company job boards need their slugs (see Sources):

    jobsearch config --init                  # writes ~/.config/jobsearch/config.yaml
    $EDITOR ~/.config/jobsearch/config.yaml  # e.g. lever: {boards: [spotify]}
    
  2. Search. Combine filters freely; results are cached for 24 h:

    jobsearch search --keywords "backend engineer" --remote --exclude-keyword senior
    
  3. Read a posting. Take the Job ID column (source:id) and split it at the first : (see Job IDs):

    jobsearch get linkedin 3952273769        # Job ID linkedin:3952273769
    jobsearch open linkedin 3952273769       # or open it in your browser
    
  4. Track it. Statuses are saved, applied, interviewing, offer, rejected:

    jobsearch track linkedin 3952273769 applied --note "referral from Sam"
    jobsearch list --status applied          # your pipeline
    
  5. Save the search, then check back for new jobs only:

    jobsearch search --keywords "backend engineer" --remote --save-as backend
    jobsearch watch run                      # tomorrow: only jobs you haven't seen
    
  6. Let it rank jobs for you. Describe what you want once (titles, places, skills), then get explainable recommendations (see Profiles):

    jobsearch profile init && jobsearch profile edit
    jobsearch recommend
    

Stuck? Every command has examples in --help (e.g. jobsearch search --help), and the Troubleshooting table covers common errors.

Sources

Source How Setup
linkedin public guest endpoints (HTML) none; needs a residential IP
greenhouse public board API (JSON) list company slugs in config
lever public postings API (JSON) list company slugs in config
ashby public job-board API (JSON) list company slugs in config

Company boards are reliable (no scraping, no blocking) and state pay more often (Lever/Ashby). Find a company's slug in its careers URL, e.g. jobs.lever.co/spotify → spotify, jobs.ashbyhq.com/linear → linear, job-boards.greenhouse.io/discord → discord:

# ~/.config/jobsearch/config.yaml
greenhouse:
  boards: [discord, airbnb]
lever:
  boards: [spotify]
ashby:
  boards: [linear, ramp]

Board sources only appear once configured. Each board is fetched whole (one request, at most once per cache_ttl_hours) and every search is answered locally from that snapshot, so changing keywords or filters never refetches it. A posting that disappears from a newer snapshot is marked closed and hidden (--include-closed shows it). For board jobs, keywords must all appear in the title, company, location, or description (word variants count: engineering finds engineer); --location is a plain text match ("Remote", "New York", "Germany"); --seniority keeps them (boards don't state it). A newly added board is fetched on the next search.

CLI

Command What it does
search [QUERY] [OPTIONS] search all enabled sources (query syntax; fetches cached 24 h, filters run locally)
get SOURCE JOB_ID one posting with full description
open SOURCE JOB_ID open the posting in your browser
track SOURCE JOB_ID STATUS [--note] set saved / applied / interviewing / offer / rejected
untrack SOURCE JOB_ID stop tracking (posting stays stored)
list [QUERY] [--status S] offline search over stored jobs, same query syntax; --status = your pipeline
watch list / watch run [NAME] / watch rm NAME saved searches; run shows only new jobs
recommend [-p NAME] jobs ranked for a profile, 0–100 with matched/missing skills
profile list / init / edit / show / path manage YAML profiles
sync [-p NAME] refresh what profiles and watches need (cron-friendly)
prune [--days 90] forget old untracked jobs and cached fetches (tracked jobs are kept)
config [--init] show/write settings
mcp / api run the MCP server / REST API

Job IDs

Tables show a Job ID like linkedin:3952273769 or greenhouse:discord:8806482002. Commands that take SOURCE JOB_ID want it split at the first colon:

Job ID column Command arguments
linkedin:3952273769 linkedin 3952273769
greenhouse:discord:8806482002 greenhouse discord:8806482002
lever:spotify:223f9722-... lever spotify:223f9722-...

With -f csv or -f json, source and job_id are separate fields.

Search options

All options are optional:

Option Notes
--keywords free text, e.g. "python developer"
--location e.g. "United States", "Berlin", "Remote"
--posted-within past_day past_week past_month
-w/--workplace remote hybrid onsite, repeatable (--remote is a shortcut)
--seniority internship entry associate mid_senior director executive (LinkedIn only)
--salary-min minimum annual pay, in the posting's own currency; postings without stated pay are kept
--exclude-company / --exclude-keyword drop companies / titles containing the text; repeatable
--new-since only jobs first seen after a date/time, e.g. 2026-10-01
--limit / --offset paging over all sources together
--sort relevance (default: title matches first), newest, salary
--include-closed also show postings a board no longer lists, or no source has listed for stale_after_days
--refresh refetch from sources instead of using cached fetches (go easy on LinkedIn)
--source one source only, e.g. lever
--save-as NAME also save the search as a watch
-f/--format table (default), json, csv, md

Query syntax

search and list take an optional query for fine-grained searching. It runs locally over everything fetched so far, combines with the options (AND), and works the same in the REST API (?q=) and MCP (query).

jobsearch search 'title:"data engineer" (python OR scala) -senior salary>=150k posted<14d'
jobsearch search 'kubernetes terraform workplace:remote -company:staffing' --sort newest
jobsearch list 'status:applied,interviewing seen<30d'
Syntax Meaning
python spark both words, anywhere (title, company, location, description); word variants match (engineering finds engineer)
"machine learning" exact phrase; also quote terms with symbols: "c++", "node.js"
engin* prefix
python OR scala, (a OR b) c, a NOT b boolean logic; operators must be UPPERCASE
NEAR(kafka streaming, 5) words within 5 words of each other
title:engineer, title:"data engineer", desc:dbt only in the title / description
-senior, -"team lead", -title:intern exclude
company:stripe, location:berlin substring of company / location; quote values with spaces or commas
source:lever, status:applied,interviewing (or none), workplace:remote,hybrid, seniority:entry any of the comma-separated values; prefix - to negate
has:salary (also description, posted, workplace, seniority) the posting states it
salary>=150k (>, <, <=) annualized pay in the posting's currency; postings without pay are kept unless you add has:salary
posted<7d, posted>30d, posted>=2026-10-01 posting age or date (d/w)
seen<2d, seen>=2026-10-01 when jobsearch first stored it (h/d/w)
is:closed, is:any closed/unlisted postings (default is:open)

Without --keywords, LinkedIn is searched with the query's plain words and title: values (operators and exclusions aren't sent). Because LinkedIn search results carry no description until you open them, plain words LinkedIn searched for count as matched for its results; title: filters are always checked locally.

Every list command takes -f/--format. Status lines go to stderr, so stdout can be piped:

jobsearch search --keywords "data engineer" -f csv > jobs.csv          # open in a spreadsheet
jobsearch search --keywords "data engineer" -f json | jq '.jobs[].title'
jobsearch list --status applied -f md                                   # your pipeline as Markdown

Tracking your applications

track saves the full posting (fetching the description if needed) and records your status and a note. Refreshed searches never overwrite your status, notes, or known fields, and search results show the status column.

jobsearch track linkedin 3952273769 applied --note "applied via site, cv v3"
jobsearch track linkedin 3952273769 interviewing          # keeps the note
jobsearch list --status interviewing
jobsearch list "kubernetes remote"                        # search everything you've seen

Saved searches (watches)

A watch remembers a search. watch run reports only jobs this tool has never stored before (since that watch's previous run, or since it was saved). Run it from cron for a daily digest:

jobsearch search --keywords "staff engineer" -w remote --exclude-keyword manager --save-as staff
jobsearch watch run                       # all watches
# crontab: weekdays 08:00, push a Markdown digest to your phone via ntfy.sh
0 8 * * 1-5  jobsearch watch run -f md | curl -s -d @- ntfy.sh/your-private-topic

Profiles and recommendations

A profile says what you're looking for. It's a YAML file at ~/.config/jobsearch/profiles/<name>.yaml; default is used unless you pass -p NAME (or set default_profile). jobsearch profile init writes a commented template:

titles: [data engineer, analytics engineer]   # LinkedIn searches + title scoring
locations: [United States]                    # each title x location is one LinkedIn search
workplace: [remote, hybrid]                   # keeps jobs stating one of these
seniority: mid_senior                         # optional
salary_min: 140000                            # yearly, posting currency; unknown pay is kept
skills:
  must: [python, sql]                         # each missing one costs a lot
  nice: [airflow, dbt, kafka, aws]            # each present one adds a little
  avoid: [php]                                # present -> penalty
exclude_companies: [staffing]
exclude_keywords: [intern, clearance]         # title words
aliases: {bigquery: [bq]}                     # extra spellings (k8s, js, postgres, aws, gcp... are built in)
jobsearch recommend                       # fetches the profile's stale searches, then ranks
jobsearch recommend --offline -f md       # rank only what's stored
jobsearch recommend --save-as picks       # a watch: `watch run picks` shows new matches only

Each match gets a 0–100 score from title fit (30), must-have skills (35), nice-to-have skills (15), location (10), recency (5), workplace (5), and pay (5). Parts your profile leaves empty don't count, and avoid-skills subtract. Skills match as whole words, including their aliases. Every result lists the skills it matched and the must-haves it's missing.

LinkedIn search results have no description until opened, so their skills are judged from the title alone until sync fetches a few descriptions. sync refreshes everything your profiles and watches need, stalest first, within linkedin_page_budget pages per run, so it's safe in cron:

0 */6 * * *  jobsearch sync >/dev/null
0 8 * * *    jobsearch watch run -f md | curl -s -d @- ntfy.sh/your-private-topic

Build your profile with an AI assistant

The repo ships an Agent Skill at .agents/skills/jobsearch-profile/ that interviews you and writes the profile. It can prefill from your resume or LinkedIn profile (pasted text, a "Save to PDF" export, or, if you agree, a single fetch of your public profile URL), asks only about the gaps, shows the YAML, and saves it after you approve. It uses the jobsearch MCP tools when connected, otherwise the jobsearch CLI.

  • Agents that read .agents/skills/ (such as pi) find it while you work in this repo.
  • For every project: just install-skill (symlinks it into ~/.agents/skills/).

Then ask your agent to "build my jobsearch profile" (in pi: /skill:jobsearch-profile).

Tips

  • A daily routine: keep 2–4 focused watches (--save-as), run jobsearch watch run each morning, track ... saved anything promising, and check jobsearch list --status applied before follow-ups.
  • Cut the noise: --exclude-company for staffing agencies or companies you've ruled out; --exclude-keyword senior --exclude-keyword lead for level mismatches. Both are case-insensitive substring matches and repeatable.
  • Prefer company boards for target companies: they're never blocked, list every open role, and often state pay. Find the slug by clicking Apply on a company's careers page: links to job-boards.greenhouse.io/<slug>, jobs.lever.co/<slug>, or jobs.ashbyhq.com/<slug> give it away.
  • Go easy on LinkedIn: repeat searches come from the cache for free; use --refresh only when you need fresh results, and keep --limit modest (every 10 LinkedIn results is one request).
  • Spreadsheets and notes: -f csv > jobs.csv opens in any spreadsheet; -f md pastes into Notion/Obsidian/GitHub.
  • Search what you've already seen: jobsearch list "terraform remote" searches every stored posting offline, including descriptions of jobs you opened.
  • Let an assistant build your profile: over MCP, paste your resume and ask it to "create my jobsearch profile". It calls update_profile, and recommend_jobs does the rest.
  • Tab completion: jobsearch --install-completion (then restart your shell).

Troubleshooting

You see What to do
error: LinkedIn blocks this IP (HTTP 999...) You're on a datacenter/VPN/cloud IP. Use a home connection, or enable the stealth fallback. Cached results are still shown.
error: LinkedIn rate limit (HTTP 429)... Too many requests from your IP. Wait a while before searching again and avoid --refresh loops.
unknown source 'lever' (enabled: linkedin) That board source has no companies configured. Add lever: with boards: [slug] to config.yaml; jobsearch config shows what's loaded.
board not found: ... (check the board name...) The slug is wrong. Copy it from the company's careers URL.
A posting you saw is gone Its board stopped listing it (closed) or no source has listed it for stale_after_days. Add --include-closed; tracked jobs always show.
Same results all day Source fetches are reused for cache_ttl_hours (24). Use --refresh, or lower it.
list shows nothing list only searches jobs already fetched. Run jobsearch search first.
invalid query: ... The message says what to fix. Common causes: lowercase or/and (use OR/AND), unquoted symbols ("c++"), an unknown field: (quote it to search it as text).
Results mention the word only in passing Word variants match in descriptions too (designer ~ design). Use title:designer, or keep the default --sort relevance (title matches first).
watch run shows 0 new Working as intended: only jobs never seen before are shown, and results present when you saved the watch count as seen.
not found: SOURCE:ID Check the Job ID split. LinkedIn may also have removed the posting.
no profile 'default' Create one: jobsearch profile init, then jobsearch profile edit.
A profile error names a key The file is checked strictly so typos don't silently do nothing; fix the key it names (e.g. tittles).
LinkedIn matches say "skills judged from the title only" Their descriptions haven't been fetched yet. jobsearch sync fetches a few per run (enrich_per_sync), or open one with jobsearch get.
searches deferred: page budget sync/recommend stopped at linkedin_page_budget to avoid a LinkedIn block; the next run picks up the rest first.
Where is my data? jobsearch config prints the DB path. Copy that file to back up jobs, tracking, and watches.

Use it from an AI assistant (MCP)

jobsearch mcp runs an MCP server on stdio. Add it to your client's config, e.g. Claude Desktop (claude_desktop_config.json), Cursor (.cursor/mcp.json), or any MCP client that takes command/args:

{
  "mcpServers": {
    "jobsearch": { "command": "uvx", "args": ["jobsearch", "mcp"] }
  }
}

From a source checkout, use "command": "uv", "args": ["run", "--directory", "/path/to/jobsearch", "jobsearch", "mcp"]. Then ask things like "here's my resume, build my job-search profile", "what should I apply to today?", "find remote data engineer jobs posted this week, skip staffing agencies", "mark the Stripe one as applied", or "anything new in my saved searches?"

Tools (list results omit descriptions to save context; get_job_detail has them):

  • search_jobs(query, keywords, location, posted_within, workplace, seniority, salary_min, exclude_companies, exclude_keywords, new_since, limit, offset, sort, include_closed, refresh, source, save_as)
  • get_job_detail(source, job_id)
  • track_job(source, job_id, status, note) (status: null stops tracking)
  • list_stored_jobs(query, status, limit)
  • list_watches(), run_watches(name), delete_watch(name)
  • recommend_jobs(profile, limit, min_score, refresh, save_as) (matches with score, matched/missing skills, notes)
  • list_profiles(), get_profile(name), update_profile(profile, name) (replaces the whole profile), sync_sources(profile_names, enrich)

REST API

jobsearch api                   # http://127.0.0.1:8000, OpenAPI docs at /docs
Endpoint Notes
GET /jobs q = query syntax; other params mirror search (workplace, exclude_company, exclude_keyword repeatable) → {jobs, status}; bad q → 422 with a readable detail
GET /jobs/{source}/{job_id} one job with description; 404 if unknown
PUT /jobs/{source}/{job_id}/tracking body {"status": "applied", "note": "..."}
DELETE /jobs/{source}/{job_id}/tracking stop tracking
GET /stored?q=&status=&limit= offline search over stored jobs (q = query syntax)
GET /watches, PUT /watches/{name}, DELETE /watches/{name} body {"query": {...CanonicalQuery}, "source": null} or {"profile": "default"}
POST /watches/run?name= new jobs (or new matches) per watch
GET /profiles, GET/PUT /profiles/{name} list, read, create/replace a profile (404 unknown, 422 invalid)
GET /recommend?profile=&limit=&min_score=&refresh=&offline= ranked matches with reasons
POST /sync?profile=&enrich= refresh profiles + watches within the page budget
curl -s "http://127.0.0.1:8000/jobs?keywords=python&workplace=remote&limit=5" | jq '.status'

status per source is ok (fetched), cached, skipped: ... (nothing coarse to ask it), or error: ... (stored results are still served).

Configuration

Settings live in ~/.config/jobsearch/config.yaml (override the path with JOBSEARCH_CONFIG). Every key is optional:

jobsearch config --init     # write a commented template (never overwrites)
jobsearch config            # show config/DB paths and effective values
Key Default Notes
db_path ~/.local/state/jobsearch/jobs.sqlite3 env JOBSEARCH_DB wins
cache_ttl_hours 24 how long a source fetch (a board, a LinkedIn keyword+location scope) is reused
stale_after_days 30 hide untracked postings no source has listed for this long
request_delay_seconds [2.0, 3.0] random pause between LinkedIn requests
max_retries 3 on HTTP 429/999, backoff 2 s → 4 s → 8 s
stealth false Camoufox fallback when blocked; env JOBSEARCH_STEALTH=1
greenhouse: / lever: / ashby: → boards [] company slugs to search (see Sources)
default_profile "default" profile used without -p
profiles_dir profiles/ next to config.yaml where <name>.yaml profiles live
linkedin_page_budget 8 max LinkedIn result pages per recommend/sync run
linkedin_pages_per_title 2 pages per profile title × location search
enrich_per_sync 4 LinkedIn descriptions sync fetches so skills can be scored

Your data (jobs, tracking, watches) is the single SQLite file at db_path; profiles are YAML files you can keep in git. Back it up by copying it.

How it works

CLI / REST / MCP            (thin interfaces, no business logic)
        │
        ▼
core.service.SearchService  orchestration
        │  1. each source maps the query to coarse scopes: a whole board, or one
        │     LinkedIn keywords+location(+filters) search; local-only filters never
        │     reach a source
        │  2. stale/missing scopes are fetched (TTL per scope); results are merged into
        │     stored jobs (empty fields never erase known ones); board snapshots close
        │     postings they stop listing
        │  3. the query is answered by ONE local SQL query over all stored jobs:
        │     FTS5 (stemmed, title-weighted bm25) + field filters + sort + paging;
        │     LinkedIn's own matches count via scope membership ("evidence")
        │  4. source error → stored results with an error status, never raise
        │  + tracking, watches, offline search over stored jobs
        │  + recommend: profile → candidate queries (title × location, skills) →
        │    core/matching.py explainable score; sync: profile/watch scopes under a
        │    LinkedIn page budget + description enrichment
        ▼
sources/                    pluggable adapters: scopes(query) + fetch(scope) + get_job
  ats.py + greenhouse.py,   public board JSON; one snapshot scope per board
  lever.py, ashby.py
  linkedin/adapter.py       one scope per normalized guest-API params (keywords,
                            location, f_E, f_TPR, f_WT); depth = pages needed
  linkedin/transport.py     curl_cffi with Chrome TLS impersonation + throttle +
                            backoff (primary); Camoufox stealth browser (fallback)
  linkedin/parser.py        HTML → model dicts; ALL selectors are module constants
        ▼
storage/sqlite.py           jobs (+ first_seen, closed_at), scopes + scope_jobs
                            (fetch cache + membership), tracking, watches,
                            jobs_fts (FTS5 porter, maintained by triggers)

Canonical Job fields: title, company.name, location.raw (+ country when stated), workplace (onsite|remote|hybrid), description, salary (min, max, currency, period, raw: only when the employer stated pay), posted_at, seniority, source.{source, job_id, url}, fetched_at; local-only first_seen, closed_at, and tracking.{status, note, updated_at}.

Notes on data fidelity:

  • Salary appears only when the source states pay; currency comes from the symbol ($, CA$, €, £, ...) or the API, and unknown symbols mean no salary.
  • LinkedIn's exact posted_at comes from search cards; detail pages only show relative dates, so they never overwrite it.
  • workplace is set only from an explicit marker or field ((Remote), Lever/Ashby workplaceType, or a LinkedIn search filtered to exactly one workplace type); seniority only from a LinkedIn search filtered to exactly one level. Otherwise null.
  • LinkedIn changes guest HTML every few weeks. When parsing breaks, only sources/linkedin/parser.py needs updating; the fixture tests fail loudly on drift.

Adding a new source

Sources plug in behind the SourceAdapter protocol without touching core or interfaces:

  1. Create src/jobsearch/sources/<name>.py (or a package for larger sources) with an adapter implementing name, scopes(CanonicalQuery) -> list[Scope] (the coarse fetches a query needs; set matches_query if the source itself applies the query's keywords/location, snapshot if a scope lists everything under a job-id prefix), fetch(Scope) -> list[Job], and get_job(job_id) -> Job | None. All filtering, ranking, and paging happen locally. Job-board APIs can subclass ats.BoardAdapter and implement only board_jobs(board) (see lever.py).
  2. register(YourAdapter()) at module import.
  3. Add the module path to _DEFAULT_SOURCES in sources/registry.py.

Cache, dedup, TTL, error handling, tracking, watches, and all three interfaces work with the new source automatically (--source <name> / ?source=<name>).

Development

uv sync
uv run pytest                  # full suite, offline (fixtures in tests/fixtures/)
just drift                     # live check that every source still parses (opt-in `live` marker)
just                           # list task recipes
  • tests/fixtures/ holds real responses: LinkedIn guest HTML and trimmed Greenhouse/Lever/Ashby JSON. To refresh LinkedIn: curl -s "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=...&start=0", then update the fixture and any affected assertions.
  • Be polite when testing against live LinkedIn: the transport throttles itself, don't loop --refresh searches in scripts.
  • .forgejo/workflows/drift.yml runs the live checks weekly. LinkedIn skips rather than fails when the runner's IP is blocked.
  • Releases: bump with uv version --bump patch, commit, push a vX.Y.Z tag. CI publishes to PyPI (repo secret PYPI_TOKEN).

Limitations and responsible use

  • LinkedIn data comes from unofficial guest pages: personal use only, subject to LinkedIn's terms, and it may break when LinkedIn changes its HTML. Board APIs (Greenhouse/Lever/Ashby) are public and meant for this.
  • Guest LinkedIn shows only what anonymous search shows: no easy-apply, employment-type, or sort filters; salary only when the employer states it.
  • Board sources: Greenhouse pay isn't fetched (needs one request per job); Lever/Ashby show the board slug as the company name.
  • Blocking is IP-based; heavy LinkedIn use gets throttled (backoff + stale cache absorb it).
  • Single-user SQLite; fine at personal scale (tens of thousands of postings).

License

MIT, see LICENSE.