back to projects

jobhunter

1

claude, find me a job, make no mistakes

Python

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.

  1. Create the Telegram bot. Message @BotFather, send /newbot, follow the prompts, and save the bot token it gives you.

  2. Run the installer. (Linux or macOS.)

    ./scripts/install.sh
    

    This creates .env from .env.example if 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.

  3. Fill .env. Put the token from step 1 into TELEGRAM_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) before whoami can see it. After that:

    python3 scripts/notify.py whoami
    

    Copy the printed chat id into TELEGRAM_CHAT_ID. (The token is used only to send notifications — there is no bot daemon listening for messages.)

    KeyDefaultMeaning
    TELEGRAM_BOT_TOKENFrom @BotFather
    TELEGRAM_CHAT_IDFrom notify.py whoami
    MIN_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_PATHautodetectedChromium/Chrome executable the browser drives (install.sh fills it)
    BROWSER_PROFILE_DIR~/.jobhunter-browserPersistent browser profile holding the LinkedIn session

    .env is gitignored. The token lives nowhere else.

  4. Drop in your resume and onboard. Put your resume in profile/ as resume.md (preferred, plain markdown — scoring reads it) and/or resume.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.

  5. Log in to LinkedIn once.

    ./scripts/login.sh
    

    This 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, prints LOGIN OK, and closes the browser itself. All later headless runs reuse that session.

  6. Test a scan.

    ./scripts/scan.sh
    

    Watch 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.

  7. Enable scheduled scans.

    Linux (systemd):

    systemctl --user enable --now jobhunter-scan.timer
    

    This 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.sh checks this and warns if lingering is off.

    macOS (launchd):

    launchctl load -w ~/Library/LaunchAgents/com.jobhunter.scan.plist
    

    launchd 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)

ActionCommand
Scan now./scripts/scan.sh
Job counts per statuspython3 scripts/jobstore.py stats
Pause all runstouch data/paused
Resumerm data/paused

Files that matter

PathWhat it isTouch it?
profile/resume.mdSource of truth for scoring; you drop it inYes, keep current
profile/resume.pdfOptional PDF resume; an input to /job-setup if no resume.mdOptional
profile/preferences.yamlSearch queries, locations, filters, scoring notesGenerated by /job-setup; edit freely
.envToken, chat id, caps, browser paths. GitignoredYes
data/jobs.jsonThe job store and state machineOnly via scripts/jobstore.py
data/pausedFlag file; if present, runs exit immediatelytouch/rm it
data/browser.locklock target serializing browser accessNo
data/logs/scan-*.logRead when debugging
$BROWSER_PROFILE_DIR (default ~/.jobhunter-browser)Persistent Chromium profile holding the LinkedIn sessionNo

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.