Skip to content

Repository files navigation

jearch

This project is fully generated by claude code Opus 5 high effort mode, may use higher effort in future

To use claude code as API use this https://github.com/aqeelshamz/claude-code-api and configure endpoint in config.yaml

A job scraper and application assistant. It pulls postings from company ATS boards, scores them against your actual resume with a local LLM, rewrites your resume to fit the ones worth applying to, and assembles a ready-to-submit application packet for each.

Built around two models you already have running:

ModelUsed forWhy
qwen2.5:14b via Ollamascreening every posting, drafting short answershundreds of calls per run, so it needs to be free
Claude via the local proxy on :8001resume tailoring, cover letters, fact-checkinga handful of calls where quality decides the outcome

If the Claude proxy is down, Ollama covers both and you get a warning. Nothing in the pipeline hard-fails on a missing service.

Quick start

- copy resume.tex in the root folder
python3 -m venv .venv &&source .venv/bin/activate
pip install -r requirements.txt
brew install tectonic # LaTeX engine; no admin needed
playwright install chromium # only if you want browser submission
cp config.example.yaml config.yaml # edit: resume path, salary bar, answers
python -m jearch run # scrape, screen, prepare packets

Point profile.resume_tex at your LaTeX resume. jearch reads it as the master and renders every tailored copy back through your template — same preamble, same macros, same header — so the output looks like the resume you wrote.

Without a .tex, set profile.resume_path to a PDF or DOCX and run python -m jearch init once. That parses it into data/master_resume.json; open the file and fix anything the parser got wrong, because every tailored resume is generated from it.

Nothing is submitted anywhere until you pass --submit. See Actually submitting.

Commands

python -m jearch init parse your resume into structured JSON
python -m jearch scrape fetch new postings from every enabled board
python -m jearch screen score unscored postings (local model)
python -m jearch shortlist show the best unapplied matches
python -m jearch apply build application packets for the top matches
python -m jearch referrals find who to ask for a referral, and draft the message
python -m jearch run one full cycle: scrape + screen + apply
python -m jearch watch keep cycling every ~90 min until you stop it
python -m jearch status counts and recent applications

Every command takes -v for detail and -c other-config.yaml to use a different config; both work on either side of the subcommand.

python scripts/check_boards.py tells you which board slugs in your config are still live across Greenhouse, Lever, Ashby and SmartRecruiters, and python scripts/check_boards.py acme tests a new one.

A normal session

python -m jearch scrape # pull new postings (a few minutes)
python -m jearch screen --limit 40 # score them (~20s each)
python -m jearch shortlist --min-score 70 # see what is worth your time
python -m jearch apply --limit 5 # build packets for the top 5

Each packet lands in applications/<company>--<role>--<id>/:

filewhat it is
APPLY.mdthe checklist — link, match reasoning, answers to paste
resume.pdftailored, one page, built from your LaTeX template
resume.texthe generated source, if you want to hand-edit and recompile
cover_letter.txt~180 words, drawn only from your resume
answers.jsonthe standard questions, pre-answered

Open APPLY.md, read the ⚠ Check before sending section if there is one, attach resume.pdf, submit. About a minute per application.

python -m jearch watch runs that cycle every ~90 minutes until you stop it.

Asking for a referral

A cold application sits in a queue. The same application with a referral gets read. Once packets exist, this works out who to ask at each company and drafts what to say:

python -m jearch referrals # every application you've made
python -m jearch referrals --limit 5 # just the five most recent

Three files come out:

filewhat it is
applications/CONNECT-LIST.mdthe working queue — who to connect with, and the note for each
applications/REFERRALS.mdthe index, best leads first
applications/<job>/referrals.mdeverything for one application

Each application gets four kinds of contact, in the order worth writing to them:

  • Warm paths first. Anyone at that company from IIIT Jabalpur, Zeta, or anywhere else on your resume. You are not a stranger to them, which is most of why people reply.
  • The recruiter. Owns the requisition, is paid to answer candidates, and can move your application today.
  • The hiring manager, narrowed by what the team actually does — an "Engineering Manager" search at a 2,000-person company is useless without it.
  • An engineer on the team. Their referral carries the most weight internally, because it costs them something to give.

Before any of that, it reads the posting for people it names outright — "you will report to…", a named hiring manager, a non-generic email address. A name in the posting beats any search.

Each section gives you a LinkedIn search link, a Google fallback, a connection request note trimmed to LinkedIn's 200-character limit, and a longer message to send once they accept. Warm messages open with the specific thing you share, so the note to an ex-Zeta colleague never claims you went to the same college.

It does not send anything, and it does not read LinkedIn. It builds the searches; you click them. Drafts are cached, so re-running costs nothing — --refresh redraws them.

--browser, and why it is off

python -m jearch referrals --browser opens the browser profile you logged in with (python -m jearch linkedin-login) and reads the search results, which gets you real names and the 1st/2nd/3rd degree badge — the one thing that decides whether you send a message or a connection request. It never sends either.

It is still automated access to LinkedIn, which their User Agreement prohibits and which does get accounts restricted. Everything except the degree badge works without it. Your account, your call.

Sending is manual by design, and not only for etiquette: LinkedIn caps invitations at roughly 100 a week, so spend them from the top of CONNECT-LIST.md.

Where the jobs come from

SourceWhat it coversAuth
greenhouse34 company boards — Razorpay, PhonePe, Postman, Stripe, Databricksnone
lever10 boards — Paytm, Meesho, CRED, Epifi, Porternone
ashby11 boards — Tekion, Sarvam, Navi, Atlan, Snowflakenone
smartrecruitersSwiggy, Freshworks, Canva, Grab, Wisenone
softwarejobs4meAggregated India feed: JPMorgan, Google, Amazon, Visa, eBay, Salesforce, SAP, Nutanixnone
remoteokRemote roles paying in USDnone
manualAnything you paste into data/manual_jobs.yamlnone
linkedinOff by default — see belowyour own browser session

Two of these have no descriptions in their list endpoints and cap page size, so a naive crawl would be one request per posting. softwarejobs4me and smartrecruiters instead run the free title and location filters against the list rows and fetch full descriptions only for survivors — on a real run that was 19 detail fetches out of 120 postings scanned.

softwarejobs4me is an aggregator, so its job_link field is used as the apply URL: you land on the employer's own Workday or Oracle page rather than on the aggregator.

How a job becomes an application

sources/ ─▶ store ─▶ matching ─▶ brief ─▶ resume ─▶ latex ─▶ apply/
board APIs sqlite prefilter compress tailor + compile packet, or
+ screen locally check to PDF browser submit
  1. Scrape. Each board's public API, normalised into one Job shape. Deduped by a hash of company + title + URL, so re-running is cheap.
  2. Prefilter (free, pure Python). Title keywords, seniority, years-of- experience demands, stated salary, and locations you'd need a visa for. On a real run this cut 6,067 postings to 33 without a single LLM call.
  3. Screen (one LLM call each). Score 0-100, a verdict, reasons, concerns, and a compensation estimate when the posting doesn't state one.
  4. Compress. The local model reduces the posting to a brief — 59-86% smaller on real postings, with no loss of tech-stack detail — and caches it. The brief is what Claude sees, twice, instead of the full posting twice.
  5. Tailor. For anything above score_threshold, the model re-angles your resume at that posting. It does not regenerate the document — it returns only a summary, a regrouped skills list, and rewritten bullets keyed to the roles they belong to. Python merges those into a copy of your master resume.
  6. Fact-check. A second pass audits the reworded text against the master and flags anything unsupported, into APPLY.md under ⚠ Check before sending. Bullets carried across verbatim are skipped — asking a model whether an identical sentence is supported produces confident false alarms.
  7. Fit and compile. The tailored resume renders through your LaTeX template and compiles. If it runs long, content is trimmed — least valuable first, never a whole job — and re-measured until it fits the page budget.
  8. Apply. Writes applications/<company>--<role>--<id>/ and, with --submit, drives the employer's own form.

Compensation filtering

You want >25 LPA, and most Indian postings state no salary at all. So:

  • If the posting states a number, it's parsed (LPA, ₹, and USD are handled) and used as a hard filter.
  • If it doesn't, the model estimates a band from role, company tier and location. That's recorded as ctc_source: "estimate" and only lowers the score — it never silently drops a job on a guess.
  • allow_unknown_ctc: false turns that leniency off if you'd rather not see postings with no signal at all.

An estimate is shown in APPLY.md marked as an estimate. Don't treat it as an offer band.

Actually submitting

Three things have to line up before an application leaves your machine, which is deliberate — a submitted application cannot be recalled.

  1. apply.mode: auto, or the --auto flag
  2. apply.browser.enabled: true in config
  3. --submit on the command line

Miss any one and jearch fills the form, screenshots it, and stops.

# watch one go out, in a visible browser window
python -m jearch apply --auto --submit --watch-browser --limit 1
# check what happened before doing more
cat applications/*/form-report.json
open applications/*/form-after-submit.png
python -m jearch status
# then the rest, headless
python -m jearch apply --auto --submit --limit 5
flageffect
--submitreally send
--dry-runfill and screenshot, never submit — overrides --submit
--watch-browservisible window instead of headless

It says which mode it is in before touching anything:

browser: DRY RUN — forms are filled and screenshotted, nothing is submitted
browser: LIVE — applications will be submitted under your name and cannot be recalled

Every attempt writes form-report.json (every field and its value) and a screenshot into the packet folder. Success is only recorded when the page comes back with a confirmation; a submit that produces no confirmation is reported as a draft with a warning, not as a success.

What the browser can and cannot drive

ATSstatus
Greenhousefills completely on the boards tested — including dropdowns, work history and EEO sections
Ashbyfills; field labels vary by board, so check the first one
Leverfiller written, not yet exercised against a live form
Workday, Oracle, amazon.jobsno filler — these become manual packets

That last row matters: several high-scoring roles (JPMorgan, Amazon) live on those systems, so --submit will skip them and leave you a packet.

A form jearch does not fully understand is abandoned, never guessed at. If a required field has no honest answer, it blocks and tells you which field.

Rate limits

Scoped to what actually leaves the machine. max_per_company applies always — it stops one company getting three applications for the same role. max_per_day, min_seconds_between and quiet_hours only gate submissions, so watch keeps drafting packets overnight and they are waiting in the morning.

Answering the questions forms ask

config.yaml carries the recurring answers: profile (experience, notice period, CTC, locations), answers (links, salary expectation) and form_answers (country, application source, non-compete, demographics).

Skill questions are answered from your resume — including topics it demonstrates without naming, so a DDoS-resilience study counts as security experience. For graded scales ("which best describes your experience with…") the model picks from the options the form offers, using lines from your resume as evidence, and is instructed to take the more modest option when the evidence sits between two.

If you have skills your resume leaves out, declare them:

profile:
also_experienced_with:
- "Built an internal auth service with OAuth2 and JWT rotation"

Those count as evidence too. Only list what you can discuss in an interview.

Truthfulness

A model asked to make a resume "fit better" will quietly start inventing, and this is not hypothetical. The first version of this tool asked the model to regenerate the whole resume for a posting titled "SDE III". It promoted the candidate from Software Development Engineer II to III to match. The fact-check caught it, but relying on a checker to catch a thing you allowed by design is the weaker arrangement.

So there are two layers now:

Structural. The model never emits a resume. It returns a summary, a skills grouping, and bullets keyed to role indices. _reorder() copies each employer, title, date and location from your master resume, and _filter_skills() drops any skill your master doesn't claim. A model that decides you know Rust because the posting asks for it simply loses that word. Employers and dates cannot be edited because nothing downstream reads the model's version of them.

The summary is the one part still written freehand, so _drop_false_year_claims() deletes any sentence overstating your experience. This also fired on the first real run — the model opened with "Seasoned backend engineer with over 7 years of experience" for a 2.5-year candidate. Understating is left alone; only overstatement is removed.

Verification.fact_check() still audits summary and bullets — the parts the model does write — against the master, and anything unsupported lands in APPLY.md under ⚠ Check before sending.

fact_check() only audits text that is actually new. Bullets carried across verbatim are filtered out first by _novel_claims() — asking a 14B model whether an identical sentence is "supported" reliably produces confident false positives. On a real packet this cut the audit from every bullet down to two.

Tests in tests/test_filters.py lock both layers in, including a case that feeds _reorder() a deliberately malicious payload trying to rewrite the employer to "Google" and the title to "Senior Engineer".

Adding a job board

Sources are plugins. Add a file in jearch/sources/:

from ..modelsimportJobfrom .baseimportSource, register, strip_html@register("wellfound")classWellfoundSource(Source):
deffetch(self):
forpostinself.get_json("https://.../jobs"):
yieldJob(source="wellfound", company=post["org"],
title=post["title"], url=post["url"],
description=strip_html(post["body"]))

Import it in sources/base.py:build_sources, add it to config.yaml under sources: with enabled: true, and it joins the pipeline. Appliers work the same way — subclass Applier, implement can_handle and submit, and add it to the tuple in apply/__init__.py.

LinkedIn, Naukri, my.greenhouse.io

Two options, and the boring one is better.

Recommended — the manual source. Search on LinkedIn or wherever you like, paste the URLs into data/manual_jobs.yaml, and jearch fetches, scores and tailors for them exactly like a scraped job:

- url: https://boards.greenhouse.io/acme/jobs/123
- url: https://www.linkedin.com/jobs/view/456company: Acmetitle: Backend Engineerdescription: | # paste the JD if the page needs a login to read ...

Not recommended — sources.linkedin. LinkedIn's User Agreement prohibits automated scraping and automated interaction. Accounts running bots do get restricted, and losing your LinkedIn mid-search is a genuinely bad outcome. The module exists, is off by default, drives a Chrome profile you log into yourself (it never takes your password), and never auto-submits. Your account, your call — but it isn't free.

Note that my.greenhouse.io is a candidate-side tracker with no public API; its underlying postings live on boards.greenhouse.io/<company>, which the greenhouse source already reads. Add the company slugs to config.yaml rather than trying to automate the dashboard.

Layout

jearch/
models.py Job, Resume, Match, Application — plain dataclasses
config.py YAML + defaults
store.py sqlite: seen / scored / applied
llm.py Ollama + Claude-proxy behind one interface, JSON helpers
matching.py prefilter (free) then LLM screen; salary parsing
resume.py parse, tailor, fact-check, page-fit, render
latex.py read the user's resume.tex, render tailored copies, compile
brief.py compress a posting locally before it costs Claude tokens
pipeline.py scrape -> screen -> apply
cli.py argparse commands
sources/ greenhouse, lever, ashby, smartrecruiters,
softwarejobs4me, remoteok, manual, linkedin
apply/ packet builder (default), browser submit, greenhouse API
(dormant — that endpoint needs an employer's key)
apply/forms/ per-ATS fillers plus the shared machinery:
greenhouse, lever, ashby, combobox, fields, reason
geo.py what country a posting is in, and whether it needs a visa
outreach/ referral contacts and the messages to send them:
targets (who and how to find them), messages (drafting
and the length/truth guards), lookup (optional LinkedIn
read), report (the markdown), run (the orchestration)

State lives in data/jearch.sqlite3. Delete it to start over; the scraper will happily re-fetch everything.

Performance notes

Screening is one LLM call per posting that clears the free filters. It only ever runs on postings it hasn't seen before, so cycles after the first are short.

llm.screen_with picks the backend. Measured on this machine, same posting:

BackendTimeScore on JPMorgan SDE IICost
fast — local qwen2.5:14b~22s72free
smart — Claude via the proxy~18s82per call

Claude is both quicker and better calibrated here, but screening is the high-volume path — hundreds of calls per full run — so the default stays on the local model. Flip screen_with: smart in config.yaml if you'd rather spend the money for sharper scores; tailoring and cover letters always use smart.

If the local model is too slow, qwen2.5:7b screens in roughly a third of the time and the task (score a posting, name the reasons) is within its range.

llm.compress_jd (on by default) has the local model reduce each posting to a brief before Claude sees it, cached per job. That cut input tokens ~31% per packet on real postings; turn it off if you would rather Claude read the full posting text.

Tests

python tests/test_filters.py # or: python -m pytest tests/ -q

Covers the pure logic where a silent bug is expensive: salary parsing (including the "INR 150 lakh crore is a transaction volume, not a salary" case), seniority and specialisation filters, remote-region handling, and PDF link extraction.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages