It is a dynamic web scraper which gets the posted jobs which are applicable from within the site and don't redirect to another site. It then applies to them automatically with your stored (Latest) Resume.
Note: Filter operation hasn't been implemented yet for jobs
The project is managed with uv:
uv sync
uv run playwright install chromium
requirements.txt is kept in sync for anyone who would rather use pip install -r requirements.txt.
uv sync # includes the dev group: ruff, pyright, pytest
uv run pytest tests # or: uv run python -m unittest discover -s tests -t .
uv run ruff check . && uv run ruff format .
uv run pyright
Tests are unittest.TestCase subclasses run under pytest, so both runners work and
neither is required. Nothing in the suite touches the network: the boards take an
injected httpx client, the parsers run against fixtures, and currency tests use
JobFilter(rates=Rates(offline=True)) so no ECB or World Bank call is ever made.
Pass rates= yourself to keep a real search off the network too.
CI mirrors this in two workflows. format is the only one that writes - it runs
ruff format and pushes the result back to the branch. ci runs ruff, pyright and
the tests across Python 3.10-3.13, reports everything to the run summary, and stores
a status.json artifact; none of it gates a merge.
No chromedriver step any more - everything runs on Playwright, which manages its own
browser. The --driver flag is still accepted but ignored.
If you have Chrome or Edge installed, keep it. Naukri and Google both reject Playwright's bundled Chromium outright ("Access Denied"), so the scrapers try an installed Chrome, then Edge, then the bundled build.
applicant search pulls listings from LinkedIn, Indeed, Naukri and Google Jobs into
job_listing.json. No account needed - none of these use your login.
uv run applicant search "python developer" -l India -n 25
uv run applicant search "data engineer" -s linkedin indeed -n 50
-s/--sourcepicks any oflinkedin,indeed,naukri,googlejobs, orall(default). A board that gets blocked is reported and skipped rather than losing the others' results.--showruns the browser visibly so you can clear a bot check yourself.- Results are merged into the output file across runs rather than overwritten.
uv run applicant search "developer" -l India --title "senior python" \
--company infosys --min-salary 1200000 --currency INR --posted-within 7
| Flag | Filters on |
|---|---|
-l/--location |
where the job is |
-t/--title |
words in the job title, any order |
-c/--company |
the hiring company |
--min-salary + --currency |
annual pay floor |
--salary-basis |
how to compare other currencies: ppp, market or strict |
-e/--experience YEARS |
years you have; keeps jobs asking for that much |
--posted-within DAYS |
how recently it was posted |
Location and date are handed to the boards themselves where they support it
(LinkedIn and Indeed both filter by date server side), and applied locally otherwise.
-n/--limit is how many to pull from each board before filtering, so a tight
filter returns fewer than you asked for - raise it if you want more survivors.
Unverifiable jobs are kept and flagged, not dropped. Most postings state no
salary, and only Naukri publishes required experience, so those come back marked
salary-unknown / experience-unknown in the flags field. Pass --strict to drop
anything that could not actually be checked.
--experience 5 keeps jobs whose stated range contains 5 years, so it excludes
roles wanting 0-2 years as well as ones wanting 8+. 5+ years is treated as having
no upper bound, not as exactly five.
Pay is normalised to an annual figure first, so 2-2.5 Lacs PA, ₹25K–₹40K a month
and $30 an hour all compare properly. Pay in another currency is then converted,
and the basis matters a great deal:
--salary-basis |
₹20,00,000 compared against a USD floor |
|---|---|
ppp (default) |
$99,559 - what it is worth where it is earned |
market |
$20,978 - today's exchange rate |
strict |
not compared at all, flagged salary-currency-mismatch |
Purchasing power parity is the default because a market conversion makes every
Indian salary look small next to an American one, which is not a useful way to
choose a job. Exchange rates come from the ECB via frankfurter.dev; PPP conversion
factors from the World Bank indicator PA.NUS.PPP. Both are cached in
.money_cache.json.
Nothing is ever guessed. The World Bank API throttles hard, so factors are
fetched one country at a time and only when needed. When one cannot be had, the
comparison falls back to a market rate and says so with a ppp-unavailable flag
rather than inventing a number. Factors for India, Japan and the US ship built in;
the rest are fetched on first use.
uv run applicant apply --min-salary 1200000 --currency INR --dry-run
uv run applicant apply --title "python"
Reads job_listing.json, keeps what matches the same filters as above, and appends
every one to applied_jobs.csv. --dry-run records what would happen without
submitting anything.
Only LinkedIn Easy Apply is actually automated. Indeed, Naukri and Google Jobs
hand off to each employer's own form, which differs every time, so those are logged
as needs_manual_apply with their url - the CSV doubles as your worklist. Multi-step
LinkedIn forms are left open rather than answered with guesses.
applied_jobs.csv appends across runs and never records the same posting twice. It
is written UTF-8 with a BOM and a stable column order, so Google Sheets imports it
cleanly via File > Import > Upload (currency symbols survive). Columns:
applied_at, status, source, id, title, company, location, salary,
salary_annual_low, salary_annual_high, currency, experience_min,
experience_max, posted, url, flags, note
status is one of applied, needs_manual_apply, would_apply (dry run) or failed.
--min-salary needs a --currency, and most postings are priced in another one.
--salary-basis decides how they are compared:
| basis | ₹20,00,000 against a USD floor | when to use it |
|---|---|---|
ppp (default) |
~$99,600 | "which of these is the better job" |
market |
~$21,000 | "what is this worth in my currency" |
strict |
not compared, flagged | when you would rather judge it yourself |
PPP factors come from the World Bank indicator PA.NUS.PPP and are cached in
src/applicant/ppp_factors.json, which is checked in so a fresh clone starts
with whatever is already known.
uv run applicant rates # what is cached right now
uv run applicant rates --refresh # fetch everything missing
uv run applicant rates --refresh -c GBP SEK NZD # just these
uv run applicant rates --refresh --force # re-fetch what is already there
The file ships with only USD, INR and JPY. The World Bank API throttles
hard - roughly one country per attempt - so the rest are fetched on demand and
cached as you use them, or all at once with --refresh. Run it once and commit
the result so nobody else has to.
Nothing is ever written from memory. A factor that cannot be retrieved is
reported and left out, and a comparison needing it falls back to a market rate
flagged ppp-unavailable rather than being handed an invented number.
Two things worth knowing before reading a converted figure: EUR maps to the
euro area aggregate, which is coarser than a single country, and TWD has no
factor at all because Taiwan is not a World Bank member.
uv run applicant status
uv run applicant status --json status.json
Reads both files and reports how many jobs are stored per board and how many
applications sit in each status. --json writes the same summary as a machine
readable file.
How each board is reached, since they differ a lot:
| Board | Needs a browser | How the data is read |
|---|---|---|
| no | its guest endpoint serves job cards to logged out clients | |
| Indeed | no (browser as fallback) | the mosaic-provider-jobcards JSON blob |
| Naukri | yes | its own search API call is intercepted; falls back to the rendered tuples |
| Google Jobs | yes | the udm=8 Jobs tab, read structurally |
Google Jobs is the most fragile of the four: it is Google Search, its CSS classes are
obfuscated and rotate, and it gives no posting URL - applications route back to the
originating board, which is reported as via.
uv run applicant -h lists everything. python -m applicant works identically, and is
what to use without uv.
uv run applicant --helpfor the full list of argumentsuv run applicantwithout arguments creates two files in the current directory:cookies.jsonfor the session andjob_listing.jsonfor the scraped jobs
Job scraping also has its own subcommand, applicant jobs, which takes the same flags.
Running applicant with bare flags still means the job run, so invocations documented
before subcommands existed keep working.
src/applicant/
cli.py argument parsing and the subcommand handlers
__main__.py python -m applicant
models.py the Job dataclass and the shared error types
dates.py relative and epoch posting dates -> ISO
salary.py reading pay off a posting, normalised to an annual figure
browser.py launching Playwright in a way the boards accept
filters.py JobFilter, and the flags saying what could not be checked
storage.py job_listing.json and applied_jobs.csv
search.py the facade over boards, filters and storage
boards/ one module per job board, all returning Job
money.py FX and PPP factors, for comparing pay across currencies
ppp_factors.json the checked in PPP table, filled by `applicant rates --refresh`
reviews/ company ratings from AmbitionBox and Glassdoor
tests/ unittest.TestCase suites, run under pytest
applicant reviews pulls a company's overall rating, its rating count and the pros/cons of
individual reviews from AmbitionBox and Glassdoor, writing them to company_reviews.json.
uv run applicant reviews tcs -n 40
uv run applicant reviews "https://www.glassdoor.com/Reviews/Google-Reviews-E9079.htm" -s glassdoor --login
-s/--sourcepicksambitionbox(default),glassdoororboth. Withboth, a source that fails is reported and skipped rather than losing the other one's results.- AmbitionBox takes a company slug (
tcs,tata-consultancy) and needs no browser. - Glassdoor takes a reviews url or a
Google-E9079style slug with employer id. Its company search sits behind the same bot check, so plain names cannot be resolved. - Glassdoor is behind Cloudflare, so the first run needs
--login: a visible browser opens, you clear the check and sign in once, and the profile in.gd_profile/is reused headlessly afterwards. Without a warmed profile the run reports a bot check and stops instead of returning empty results.
The whole thing is importable, with Jobs as the front door:
from applicant.search import Jobs
from applicant.filters import JobFilter
board = Jobs()
hits = board.search(
'python developer',
JobFilter(
location='India',
min_salary=1_200_000,
currency='INR',
salary_basis='ppp',
experience=5,
posted_within_days=7,
),
)
board.apply(hits, log='applied_jobs.csv', dry_run=True)Job is a Pydantic model, so postings are validated as they are built - blank
strings become None, whitespace is stripped, and a year range is derived from
whatever the board said (experience_text) into experience_min / experience_max.
Individual boards work standalone too, and all four return the same Job objects:
from applicant.boards.indeed import Indeed
for job in Indeed().search('python developer', 'remote', limit=10):
print(job.title, job.company, job.location, job.salary)LinkedIn additionally keeps the signed-in flows - login(), restore_session(),
scrape_jobs() for recommended jobs and easy_apply(). Sessions are stored as Playwright
storage state, and a cookies.json written by the older Selenium version is converted
automatically on read.
Company ratings are also importable:
from applicant.reviews import AmbitionBoxClient
rating = AmbitionBoxClient().fetch('tcs', max_reviews=40)
print(rating.overall_rating, rating.review_count)
print(rating.reviews[0].pros, rating.reviews[0].cons)Both clients share the same fetch(company, max_reviews) signature and return a
CompanyRating, so callers never branch on the source.