jobhunter
claude, find me a job, make no mistakes
JobHunter
Self-hosted, single-user LinkedIn job-search automation: scan, score, and get notified on
Telegram. Runs on Linux (systemd) or macOS (launchd). You run your own instance; there is no
shared service. Drop in your resume, run /job-setup, and the pipeline is ready.
A scheduled timer scans LinkedIn every 4 hours. Claude scores each new posting 0-100 against
your resume and preferences. Matches above the threshold arrive as informational Telegram cards
with the posting link — you open the link and apply on the company's site yourself. JobHunter is
discovery-only: it never applies for you, never uses LinkedIn Easy Apply, and never uploads
your resume anywhere. Python 3.14 stdlib only, bash, the claude CLI, and (for login) the
Playwright MCP server. Zero pip dependencies.
docs/SPEC.md is the binding contract for every schema, CLI, and status in this repo.
Architecture
scan timer (systemd --user / launchd, every 4h, plus a random 0-20 min delay)
|
v
scripts/scan.sh
|
v
scripts/harvest.mjs ------> Chromium (headless), persistent profile,
| exclusive lock on data/browser.lock
v
data/harvest.json
|
v
claude -p "/job-scan" ----> scores each posting 0-100 (no browser)
|
v
data/jobs.json <---------- ALL reads/writes via scripts/jobstore.py
| score >= MIN_SCORE
v
scripts/notify.py --------> Telegram card with the 🔗 posting link
|
v
you open the link and apply on the company site yourself
JobHunter is discovery-only. It scans LinkedIn, scores postings against your resume and
preferences, and notifies you. It never applies for you, never uses LinkedIn Easy Apply, and
never uploads your resume — LinkedIn is for discovery only. Applying is yours: open the card's
link and apply on the company's site. You can optionally track those applications in the
AppTracker webapp (see apptracker/). The full state machine is in docs/SPEC.md.
Setup
Follow this order. Steps depend on the previous ones.
-
Create the Telegram bot. Message @BotFather, send
/newbot, follow the prompts, and save the bot token it gives you. -
Run the installer. (Linux or macOS.)
./scripts/install.shThis creates
.envfrom.env.exampleif missing, autodetects your Chromium/Chrome binary, installs the service units (systemd user units on Linux, launchd agents on macOS), and verifies the Telegram setup once credentials are filled in. Safe to re-run anytime. -
Fill
.env. Put the token from step 1 intoTELEGRAM_BOT_TOKEN. Then send your new bot any message on Telegram. Bots cannot message you until you message them first. BotFather's privacy mode setting does not matter here — it only affects group chats; for the direct-message chat this project uses, the only requirement is that you open the chat and press Start (send any message) beforewhoamican see it. After that:python3 scripts/notify.py whoamiCopy the printed chat id into
TELEGRAM_CHAT_ID. (The token is used only to send notifications — there is no bot daemon listening for messages.)Key Default Meaning TELEGRAM_BOT_TOKENFrom @BotFather TELEGRAM_CHAT_IDFrom notify.py whoamiMIN_SCORE70Minimum score, 0-100, for a job to be sent to Telegram MAX_NOTIFY_PER_SCAN10Card cap per scan run CLAUDE_BINclaudeClaude CLI binary CHROMIUM_PATHautodetected Chromium/Chrome executable the browser drives (install.sh fills it) BROWSER_PROFILE_DIR~/.jobhunter-browserPersistent browser profile holding the LinkedIn session .envis gitignored. The token lives nowhere else. -
Drop in your resume and onboard. Put your resume in
profile/asresume.md(preferred, plain markdown — scoring reads it) and/orresume.pdf. Then run an interactive Claude session and invoke the onboarding skill:claude "/job-setup"It reads your resume, asks a few questions (target roles, locations, seniority, salary floor, …), and generates
profile/preferences.yaml(search queries, locations, filters, scoring notes). Review and edit it afterward. -
Log in to LinkedIn once.
./scripts/login.shThis opens a visible browser using the persistent profile at
~/.jobhunter-browser. Log in manually and complete any 2FA. Do not close the window — the script detects your feed within ~5 minutes, printsLOGIN OK, and closes the browser itself. All later headless runs reuse that session. -
Test a scan.
./scripts/scan.shWatch Telegram. You should get job cards for anything scoring at or above
MIN_SCORE, then a one-line summary. The first run can take a while. -
Enable scheduled scans.
Linux (systemd):
systemctl --user enable --now jobhunter-scan.timerThis is a systemd user timer: without lingering it stops the moment you log out (scans only catch up at your next login). Enable lingering once:
loginctl enable-linger "$USER"scripts/install.shchecks this and warns if lingering is off.macOS (launchd):
launchctl load -w ~/Library/LaunchAgents/com.jobhunter.scan.plistlaunchd agents run while you are logged in; for 24/7 operation keep the Mac logged in (or convert the agents to LaunchDaemons).
Daily use
Matches arrive as Telegram cards; you apply yourself via the link. Everything else is driven from the command line — Telegram is outbound-only (there is no bot listening for commands).
The job card
Each match arrives as an informational card (no buttons):
🎯 87/100 — Security Analyst
🏢 Acme Corp — Omaha, NE (hybrid)
💰 $70k–$85k
📝 Strong SOC/SIEM and detection engineering overlap; entry-level friendly.
🔗 https://www.linkedin.com/jobs/view/4012345678/
Open the 🔗 link and apply on the company's site yourself. Optionally record the application in
the AppTracker webapp (apptracker/) to track its lifecycle.
Control (from a terminal)
| Action | Command |
|---|---|
| Scan now | ./scripts/scan.sh |
| Job counts per status | python3 scripts/jobstore.py stats |
| Pause all runs | touch data/paused |
| Resume | rm data/paused |
Files that matter
| Path | What it is | Touch it? |
|---|---|---|
profile/resume.md | Source of truth for scoring; you drop it in | Yes, keep current |
profile/resume.pdf | Optional PDF resume; an input to /job-setup if no resume.md | Optional |
profile/preferences.yaml | Search queries, locations, filters, scoring notes | Generated by /job-setup; edit freely |
.env | Token, chat id, caps, browser paths. Gitignored | Yes |
data/jobs.json | The job store and state machine | Only via scripts/jobstore.py |
data/paused | Flag file; if present, runs exit immediately | touch/rm it |
data/browser.lock | lock target serializing browser access | No |
data/logs/ | scan-*.log | Read when debugging |
$BROWSER_PROFILE_DIR (default ~/.jobhunter-browser) | Persistent Chromium profile holding the LinkedIn session | No |
All durable state is in data/ plus the browser profile. Back those up and you can rebuild the
rest from the repo.
Troubleshooting
Telegram says the session expired or was challenged. The skills detect login walls, checkpoints, and authwall redirects, alert you, and stop the run. Fix:
./scripts/login.sh
Headless runs keep getting challenged. LinkedIn challenges headless browsers more often.
Run ./scripts/scan.sh --watch to harvest with a visible browser. Caveat: a headful browser
launched by a systemd user unit needs your session's display environment imported first:
systemctl --user import-environment WAYLAND_DISPLAY DISPLAY
or simpler, run scans from a terminal while you stay headful.
A run appears stuck, or you started two at once. The harvester takes an exclusive lock on
data/browser.lock for the whole run, since there is only one browser profile. A second run
waits up to 15 minutes for the lock, then gives up. This is by design; just let it finish.
Scans stop running after you log out. The systemd user timer only runs while you have a
session unless lingering is enabled. Check with loginctl show-user "$USER" --property=Linger
and fix with loginctl enable-linger "$USER".
Where to look. Run logs are in data/logs/ as scan-*.log, newest timestamp last. For the
scheduled runs, the systemd journal also has them:
journalctl --user -u jobhunter-scan -f
Use responsibly
Automated activity on LinkedIn violates their User Agreement. Accounts doing it can be
restricted or banned, and detection is plausible regardless of how careful the tooling is. This
project mitigates, not eliminates: it only reads LinkedIn (discovery — never Easy Apply, never an
account, never a resume upload), the harvest is capped and paced 2-4 seconds between page loads,
and it drives your own authenticated session at human speed. The risk remains yours. Keep
MAX_NOTIFY_PER_SCAN conservative, keep the scan window modest, and do not turn discovery into a
scraper.
Docs last updated 2026-06-23. Interfaces are versioned by docs/SPEC.md.