- Python 98.2%
- Just 1.4%
- Shell 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .agents/skills/jobsearch-profile | ||
| .forgejo | ||
| src/jobsearch | ||
| tests | ||
| .gitignore | ||
| .python-version | ||
| AGENTS.md | ||
| justfile | ||
| LICENSE | ||
| PLAN.md | ||
| pyproject.toml | ||
| README.md | ||
| SPECS.md | ||
| uv.lock | ||
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
-
(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]} -
Search. Combine filters freely; results are cached for 24 h:
jobsearch search --keywords "backend engineer" --remote --exclude-keyword senior -
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 -
Track it. Statuses are
saved,applied,interviewing,offer,rejected:jobsearch track linkedin 3952273769 applied --note "referral from Sam" jobsearch list --status applied # your pipeline -
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 -
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), runjobsearch watch runeach morning,track ... savedanything promising, and checkjobsearch list --status appliedbefore follow-ups. - Cut the noise:
--exclude-companyfor staffing agencies or companies you've ruled out;--exclude-keyword senior --exclude-keyword leadfor 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>, orjobs.ashbyhq.com/<slug>give it away. - Go easy on LinkedIn: repeat searches come from the cache for free; use
--refreshonly when you need fresh results, and keep--limitmodest (every 10 LinkedIn results is one request). - Spreadsheets and notes:
-f csv > jobs.csvopens in any spreadsheet;-f mdpastes 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, andrecommend_jobsdoes 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: nullstops 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_atcomes from search cards; detail pages only show relative dates, so they never overwrite it. workplaceis set only from an explicit marker or field ((Remote), Lever/AshbyworkplaceType, or a LinkedIn search filtered to exactly one workplace type);seniorityonly 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.pyneeds updating; the fixture tests fail loudly on drift.
Adding a new source
Sources plug in behind the SourceAdapter protocol without touching core or
interfaces:
- Create
src/jobsearch/sources/<name>.py(or a package for larger sources) with an adapter implementingname,scopes(CanonicalQuery) -> list[Scope](the coarse fetches a query needs; setmatches_queryif the source itself applies the query's keywords/location,snapshotif a scope lists everything under a job-id prefix),fetch(Scope) -> list[Job], andget_job(job_id) -> Job | None. All filtering, ranking, and paging happen locally. Job-board APIs can subclassats.BoardAdapterand implement onlyboard_jobs(board)(seelever.py). register(YourAdapter())at module import.- Add the module path to
_DEFAULT_SOURCESinsources/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
--refreshsearches in scripts. .forgejo/workflows/drift.ymlruns 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 avX.Y.Ztag. CI publishes to PyPI (repo secretPYPI_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.