Automatically scrapes job boards, uses Claude AI to check relevance against your profile, sends matching jobs to Telegram, and tracks your applications.
Multi-tenant ready. The same codebase runs as a single-user tool and as a multi-client SaaS — the difference is configuration only, never a fork. Private use is simply one default client ("Privat"); an operator can create many clients (Klienten), each with its own CV, sources, filters, prompts and Telegram chat id, sharing one Anthropic key and one Telegram bot. See DEPLOY.md for the SaaS deployment (Docker + NGINX/Authelia).
The web GUI (npm run gui) — browse matches, track applications, edit sources/profile/prompts, and trigger runs from the browser. (Screenshots use throwaway sample data, not a real profile.)
| Jobs — scored matches with live filters | Statistics — activity heatmap & charts |
|---|---|
![]() |
![]() |
| Run — live pipeline log & run history | Clients — multi-tenant management (CLIENTS_ENABLED) |
|---|---|
![]() |
![]() |
| Profile — CV, preferences & platform accounts | Sources — career pages and job-board searches |
|---|---|
![]() |
![]() |
| Prompts — editable AI instructions per client | Settings — every option from a form |
|---|---|
![]() |
![]() |
index.js ← --once [--client <id>] or hourly scheduler
└── src/scheduler.js ← runAll(): loops enabled clients, runs the pipeline per client
├── src/scraper.js ← Playwright scraper (DOM + API interception, 3 pagination strategies)
│ └── src/scrapers/ ← Job-board scrapers (linkedin, stepstone, indeed) + shared browser setup
├── src/host-limiter.js ← Per-host request spacing shared by all scraping stages
├── src/database.js ← SQLite — clients table + per-client jobs (client_id), dedup, tracking
├── src/client-config.js ← resolves a client's profile/sources/filters/prompts (DB, legacy fallback)
├── src/analyzer.js ← Claude AI relevance scoring
├── src/prompts.js ← Built-in prompt defaults + the editable prompt fields
├── src/notifier.js ← Telegram notifications (one bot token, per-client chat id)
├── src/run-control.js ← Cooperative abort flag behind "Lauf stoppen" / SIGTERM
└── src/exporter.js ← Excel export (data/relevant_jobs[_<client>].xlsx)
└── src/csv-export.js ← CSV export of the Jobs tab (GUI download)
src/apply-worker.js ← Auto-apply queue: prepare → approve → submit (scheduler process only)
└── src/appliers/ ← Per-platform apply flows (linkedin, stepstone, indeed) + shared helpers
src/platform-login.js ← Platform logins & persisted browser sessions for auto-apply
src/credentials.js ← AES-256-GCM encryption of stored platform logins (CREDENTIALS_KEY)
src/questions.js ← Answer library for the apply forms' screening questions
src/telegram-bot.js ← Interactive Telegram bot (status reactions, apply approval, answers)
src/apply.js ← CLI for tracking applications (npm run apply)
src/cover-letter.js ← CLI for generating a cover letter via Claude (npm run cover-letter)
src/server.js ← Web GUI server (npm run gui) — built-in http, operator login + client CRUD
src/setup.js ← Guided setup wizard (steps, validation, debug sandbox)
src/backup.js ← Database backups (daily/manual/upload snapshots, retention)
public/ ← Dashboard frontend (index.html, app.js, i18n.js, setup.js, style.css)
scripts/migrate-to-multitenant.js ← One-time migration to the clients model (npm run migrate)
scripts/hash-password.js ← Generate OPERATOR_PASSWORD_HASH for SaaS login (npm run hash-password)
scripts/generate-credentials-key.js ← Generate CREDENTIALS_KEY (npm run generate-credentials-key)
scripts/operator-sandbox.js ← Throwaway operator GUI on :3001 (npm run gui:operator)
scripts/refetch-descriptions.js ← Re-fetch & re-analyze jobs saved with empty descriptions
config/*.json ← Default-client fallback (profile/jobs/filters/prompts); per-client config lives in the DB
logs/ ← Timestamped log file from each run (gitignored)
data/jobs.db ← SQLite database — clients, jobs, runs (gitignored)
data/backups/ ← Database backup snapshots (gitignored)
data/sessions/ ← Persisted platform browser sessions for auto-apply (gitignored)
data/apply-artifacts/ ← Dry-run screenshots of filled apply forms (gitignored)
data/relevant_jobs.xlsx ← Latest export (gitignored)
The easy way — guided setup wizard. After installing dependencies (step 1), just start the GUI with
npm run guiand open http://localhost:3000. On a fresh install a setup wizard pops up automatically and walks you through every step in small, non-technical sub-pages: API key → Telegram (with a “send test message” button) → your profile → the career pages to watch. Nothing to edit by hand. The manual steps below are the equivalent if you'd rather configure the files yourself.See Guided setup wizard for details (including a risk-free debug mode for testing the wizard without touching your real data).
npm install
npx playwright install --with-deps chromiumIf you're using the wizard, you can stop here and run
npm run gui— the wizard covers steps 2–6. Read on only for the manual route.
- Open Telegram and search for @BotFather
- Send
/newbotand follow the prompts - Copy the API token — this is your
TELEGRAM_BOT_TOKEN
- Start a conversation with your bot (send it any message)
- Open in a browser (replace
<TOKEN>):https://api.telegram.org/bot<TOKEN>/getUpdates - Find
"chat":{"id":...}— that number is yourTELEGRAM_CHAT_ID
cp .env.example .envEdit .env. The Anthropic API key is the only hard requirement; Telegram is optional:
ANTHROPIC_API_KEY=sk-ant-...
# Optional — only needed if you want Telegram push notifications.
# Leave blank (or set TELEGRAM_NOTIFICATIONS=off) to rely solely on the web GUI.
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
TELEGRAM_CHAT_ID=987654321Don't want Telegram? Skip steps 2–3, leave the two Telegram values blank (or set
TELEGRAM_NOTIFICATIONS=off), and browse matches in the web GUI instead. The setup wizard has a one-click toggle for exactly this.
All other variables are optional — see Configuration below.
Copy the template and edit it with your CV and preferences:
cp config/profile.example.json config/profile.jsonconfig/profile.json is gitignored (it's personal). The AI reads it to score how well each job matches you:
- cv: Name, current role, years of experience, skills, education, languages, summary
- preferences.desiredRoles: Job titles you're targeting
- preferences.locations: Acceptable locations (include "Remote" / "Hybrid" if desired)
- preferences.salaryMin: Minimum annual salary
- preferences.dealbreakers: Terms that automatically disqualify a job
{
"sources": [
{
"name": "Company Name",
"url": "https://company.com/careers",
"type": "careers-page"
}
]
}Optional source fields:
| Field | Description |
|---|---|
allowExternalLinks |
Follow links to external ATS platforms (e.g. Personio) |
extraWait |
Extra ms to wait after page load (for slow SPAs) |
jobUrlPattern |
Regex for sites with non-standard job URL patterns |
apiOnly |
Skip DOM extraction, trust only API-intercepted jobs (e.g. Bosch) |
apiUrl |
JSON endpoint to fetch directly, for listings that render only their first page and expose no pagination control. Requested from inside the page, so it inherits origin and cookies. Useful when the site's own XHR asks for a small slice but the endpoint accepts a larger limit. |
paginationParam |
Query param name for URL-based pagination (e.g. "pageNumber") |
paginationMode |
"index" = param counts pages (1, 2, 3…); default = row offset |
paginationStep |
Items per page (used with paginationParam) |
Behavior is controlled by environment variables (all optional — defaults shown) plus one filter file. Set variables in .env.
| Variable | Default | What it does |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required. Anthropic API key |
TELEGRAM_BOT_TOKEN |
— | Telegram bot token from @BotFather (optional — see below) |
TELEGRAM_CHAT_ID |
— | Your Telegram chat ID (optional) |
TELEGRAM_NOTIFICATIONS |
on |
Set to off to disable Telegram push notifications entirely |
EXPIRY_NOTIFICATIONS |
on |
Set to off to silence Telegram alerts for expired jobs (new-job alerts still fire) |
ANALYZER_MODEL |
claude-haiku-4-5 |
Model that scores job relevance |
COVER_LETTER_MODEL |
claude-opus-5 |
Model that writes cover letters |
MIN_RELEVANCE_SCORE |
4 |
Min AI score (1–10) for a job to count as relevant |
CRON_SCHEDULE |
0 * * * * |
Scheduler cadence (node-cron syntax) |
EXPIRY_THRESHOLD_HOURS |
72 |
Hours unseen before a notified job is marked expired |
ANALYSIS_CONCURRENCY |
2 |
Parallel Claude analysis requests |
SCRAPE_CONCURRENCY |
4 |
Parallel browser workers when scraping sources (one browser each) |
DESC_CONCURRENCY |
8 |
Parallel tabs when fetching descriptions. Requests are spread across hosts, so no site sees more than one at a time no matter how high this goes |
DESC_HOST_GAP_MS |
1500 |
Minimum pause between two requests to the same host. Raise this — not the concurrency — if a site starts blocking |
SOURCE_HOST_GAP_MS |
2000 |
Same, between two source scrapes that share a host |
PLATFORM_DESC_HOST_GAP_MS |
2500 |
Same, for the job boards (LinkedIn/StepStone/Indeed) |
GUI_PORT |
3000 |
Port for the web GUI |
RUN_STOP_GRACE_MS |
45000 |
How long Lauf stoppen waits for the run to wind down before force-killing it |
JOBS_DB_PATH |
data/jobs.db |
SQLite DB path (set to a mounted volume in Docker) |
CLIENTS_ENABLED |
false |
Show the multi-client UI (Klienten tab + client selector). Off = private single-user view |
AUTH_ENABLED |
false |
SaaS: true requires operator login for the GUI. Private/localhost: false |
OPERATOR_USER |
admin |
Login username when AUTH_ENABLED=true |
OPERATOR_PASSWORD_HASH |
— | scrypt hash of the operator password — generate with npm run hash-password -- "<pw>" |
SESSION_SECRET |
random | Signs login sessions; set a stable value or logins drop on every restart |
SESSION_COOKIE_SECURE |
auto | Force the Secure flag on the login cookie (true/false). Auto: on over HTTPS / a real hostname, off for localhost |
BACKUP_ENABLED |
true |
Set to false to disable the automatic daily database backup |
BACKUP_RETENTION_DAYS |
30 |
How many daily backups to keep before the oldest are pruned |
AUTO_APPLY_ENABLED |
false |
Global switch for auto-apply — off means nothing is ever prepared or submitted |
CREDENTIALS_KEY |
— | 64-hex-char key encrypting platform logins at rest (npm run generate-credentials-key) |
APPLY_DRY_RUN |
true |
Fill apply forms but never click the final submit (screenshot instead) |
APPLY_DAILY_CAP |
10 |
Max submitted applications per client+platform+day (LinkedIn/Indeed hard-capped at 5) |
APPLY_COOLDOWN_MINUTES |
15 |
Minimum minutes between two submissions on the same platform |
APPLY_HEADFUL |
false |
Show the apply browser window (for first logins with 2FA) |
Per-client vs global.
ANTHROPIC_API_KEY,TELEGRAM_BOT_TOKEN, models and the auth/server vars above are global (operator-wide). A client's CV, sources, filters, prompts, Telegram chat id, min-score and on/off toggles are stored per client in the database and edited in the GUI — not in.env.TELEGRAM_CHAT_IDin.envis only used bynpm run test-notifyand the initial migration of the default client.
Cheap title-based filters applied before any Claude call:
titleBlocklist— jobs whose title contains any of these substrings (case-insensitive) are dropped as irrelevant.priorityKeywords— jobs whose title contains any of these get their score boosted to ≥ 7.
Edit this file to tune what gets filtered. If the file is missing, built-in defaults are used.
The prompts sent to Claude (relevance-scoring system prompt + scoring guidance, and the
cover-letter system prompt + task) are editable in the Prompts tab of the GUI. Overrides
are stored in config/prompts.json (gitignored); anything not overridden falls back to the
built-in defaults in src/prompts.js. The analyzer's strict JSON output format is fixed and
not editable, so customizing prompts can't break parsing.
This project is tuned for the German job market out of the box:
config/filters.jsonand the exampleconfig/jobs.jsonuse German job titles and German company career pages.- Cover letters are generated in German (DIN 5008 style) — see the prompt in
src/cover-letter.js. - The relevance prompt in
src/analyzer.jsreferences German hiring terms (e.g. Initiativbewerbung, Doktorand). - Console/log output and Telegram labels are partly German.
To adapt it to another country/language, edit config/filters.json, the prompts in those two files, and supply your own config/jobs.json sources.
Besides company career pages, three job boards can be scraped. They are configured as
sources with a search object instead of a url (Sources tab or sources_json):
{ "name": "LinkedIn Suche", "type": "linkedin",
"search": { "keywords": "Entwicklungsingenieur", "location": "Stuttgart",
"radiusKm": 40, "postedWithinDays": 7, "maxResults": 100 } }
{ "name": "StepStone Suche", "type": "stepstone",
"search": { "keywords": "Entwicklungsingenieur", "location": "Stuttgart", "radiusKm": 30, "maxPages": 5 } }
{ "name": "Indeed Suche", "type": "indeed",
"search": { "keywords": "Entwicklungsingenieur", "location": "Stuttgart", "radiusKm": 25, "maxPages": 5 } }Scraping is read-only and needs no login (LinkedIn uses the public guest search). StepStone/Indeed results carry an ⚡ Einfach bewerben flag; LinkedIn's is verified later.
For platform jobs with one-click application the bot can prepare the application: cover letter (Claude), the apply form's screening questions (dry-run, never submitted), answers pre-filled from an answer library of your previous answers. The prepared application must then be explicitly approved — either in the job popup in the GUI (Bewerben button) or via Telegram (review summary with ✅ Bewerben / ✏️ Ändern / ❌ Verwerfen buttons). Unknown required questions are asked via Telegram one by one. After submission the job status flips to Beworben automatically.
Setup:
npm run generate-credentials-key→ put the key into.envasCREDENTIALS_KEY(or GUI → Einstellungen → Auto-Bewerbung).- GUI → Profil → Plattform-Zugänge: enter the platform e-mail/password per client (stored AES-256-GCM-encrypted), optionally upload a CV (PDF), and enable Auto-Bewerbung for the client.
- Set
AUTO_APPLY_ENABLED=true. KeepAPPLY_DRY_RUN=truefor the first runs — forms are filled and screenshotted (data/apply-artifacts/) but never submitted. - First login per platform may require 2FA — run once with
APPLY_HEADFUL=trueand complete it in the window; the session (data/sessions/) is then reused for weeks.
Telegram commands while an application is being prepared:
| Command | Effect |
|---|---|
/skip |
Closes the open question prompts. The question stays unanswered and can be filled in in the GUI; your next message is no longer read as an answer |
/abbrechen |
Discards the application the open prompts belong to |
Status per Telegram: react to a job notification with 👍 (beworben), 👎 (Absage), 🤝 (Interview) or 🎉 (Angebot), or reply to it with one of those words — the status is updated like in the GUI.
⚠️ Honest warning: automated applying violates the terms of service of LinkedIn/Indeed (and likely StepStone). Accounts can be restricted or suspended. The bot mitigates (rate caps, cooldowns, human-paced input, persisted sessions, mandatory human approval, no CAPTCHA solving) but cannot eliminate that risk. Cloudflare challenges on Indeed and LinkedIn login checkpoints are detected and reported — never bypassed.
# One-time: migrate an existing single-user DB to the multi-tenant clients model
# (imports your current profile/sources/filters/prompts + Telegram chat id into the
# default "Privat" client). Safe and idempotent; fresh installs don't need it.
npm run migrate
# Run the full pipeline once and exit (all enabled clients; --client <id> for one)
npm run run-once
# Start with hourly cron (runs immediately, then every hour at :00)
npm start
# Test just the scraper (prints found jobs as JSON, no DB writes)
npm run test-scraper
# Test Telegram notifications (sends a sample message)
npm run test-notify
# Re-fetch descriptions and re-analyze all DB entries saved with empty descriptions
# (useful after fixing the networkidle bug, or when a source was returning 0 chars)
npm run refetch-descriptions
# Run the test suite (node:test, offline — no network, no real DB, no API calls)
npm testCtrl+C, docker stop and a systemd restart all send a termination signal, and
what it does depends on whether a run is in flight:
- Idle (scheduler waiting for the next cron tick) — the process quits at once, exactly as it always did.
- Mid-run — the run is aborted, not killed: it winds down at its next
source/page/job boundary, the jobs already scraped and scored stay in the DB,
browsers close and the WAL is checkpointed. Only then does the process exit.
Give it a few seconds; a second signal stops waiting and exits immediately.
A worker sitting out a per-host pause (
DESC_HOST_GAP_MSand friends) does not wait it out first — the stop is noticed while it waits, so the gaps can be raised for politeness without making Lauf stoppen feel sluggish.
The same mechanism sits behind the GUI's Lauf stoppen button, which sends the
signal to the --once child it spawned.
For a single user nothing changes — leave AUTH_ENABLED unset and use the app as before
(you are the one default client). To offer it to an operator who manages several clients:
- Set
CLIENTS_ENABLED=trueso the Klienten tab and the client selector appear at all — they are hidden by default (see Web GUI). - Set
AUTH_ENABLED=trueand create login credentials withnpm run hash-password -- "<pw>"(→OPERATOR_PASSWORD_HASH), plus a stableSESSION_SECRET. - Run with Docker:
docker compose up -d --build(a gui + a scheduler container sharing the./dataSQLite volume), and put NGINX + Authelia in front of the GUI for SSO. - In the GUI's Klienten tab, create clients and set each one's Telegram chat id; switch the active client (top-right) to edit its profile/sources/filters/prompts.
Try the operator experience first.
npm run gui:operatorstarts a second, fully isolated GUI on port 3001 with login enabled and a throwaway copy of your database, so you can rehearse multi-client management without touching your real setup. See the header ofscripts/operator-sandbox.jsfor options (--fresh,--reset).
Full step-by-step guide: DEPLOY.md.
A modern, minimal dashboard for browsing matches, tracking applications, editing sources, and triggering runs — all in the browser. Built on Node's built-in http module, so it needs no extra dependencies and no build step.
npm run gui # → http://localhost:3000 (set GUI_PORT to change)| Tab | What it does |
|---|---|
| Jobs | All relevant jobs as cards with score badge, summary, and live filters (search, source, status, min score). Sort by relevance or by date found (oldest / newest first). Set application status or hide a job with one click. The ✎ button opens the cover-letter window (see Generating a Cover Letter). |
| Quellen | Edit the active client's job sources visually — add, edit, or remove career sites, then Save. Extra per-source fields (paginationParam, extraWait, …) are preserved. |
| Profil | Edit the active client's CV & preferences in a structured form — this is what the AI matches jobs against and uses for cover letters. |
| Prompts | Edit the prompts sent to Claude for the active client (relevance scoring + cover letters). Per-field “↺ Standard” restores the default. Changes take effect on the next run. |
| Lauf | Start node index.js --once and watch color-coded logs stream live (Server-Sent Events). Jobs auto-refresh when the run finishes. Lauf stoppen aborts a running pipeline: the run winds down at the next source/page boundary, keeps every job found so far, and skips the remaining stages (notifications, expiry check, statistics, export) so a partial pass can't distort them. If it hasn't exited after RUN_STOP_GRACE_MS (default 45 s) it is force-killed. Nothing already paid for is thrown away: each job is stored as soon as its description has been fetched, and every AI score written the moment it is scored — a stopped run leaves them in the DB, and the next run picks up where it left off instead of re-scraping. Jobs whose detail page was still loading are not stored (an empty description would be scored as a blank posting), so they are simply fetched again next time. |
| Klienten | Hidden unless CLIENTS_ENABLED=true. Create/edit/delete clients (tenants): name, Telegram chat id, active toggle, Telegram & expiry toggles, optional min-score. A Telegram-Test button verifies the chat id. Clients receive Telegram alerts only — they have no GUI access. |
| Statistik | Application heatmap, top sources/companies, run history, and a per-run overview table. |
| Einstellungen | Edit every .env variable from a form (incl. the SaaS auth vars); switch theme and language (see below); manage database backups (create / upload / download / restore — see Backups); pull the latest version from GitHub (update button); restart the GUI; and re-open or test the setup wizard. |
All tabs operate on the active client. Out of the box the multi-client UI is off
(CLIENTS_ENABLED=false): there is no Klienten tab and no client selector, and everything
silently operates on the single default client — which is all a private install needs. Switch
Einstellungen → Klienten (Mehrbenutzer) → Klienten-Verwaltung anzeigen on (or set
CLIENTS_ENABLED=true) to get the tab plus the client selector in the top-right of the header.
The GUI reuses the same SQLite database as the CLI — changes are reflected everywhere. When
AUTH_ENABLED=true, the GUI shows a login screen first (operator credentials).
Under Einstellungen → Darstellung you can choose:
- Color scheme — Light, Dark, or Automatic (follows your OS setting).
- Color theme — the default green, or a soft pink palette where "good score" accents turn pink.
- Language — Deutsch or English. The whole dashboard, the settings labels, and the setup wizard switch instantly.
All three choices are saved in your browser (localStorage) and applied before first paint, so there's no flash of the wrong appearance and no reload.
The first time you open the GUI with required configuration missing, a step-by-step setup wizard opens automatically. Each step is its own small sub-page, so even a non-technical user can get going without editing any files. The welcome screen lets you pick the interface language (Deutsch / English) first; everything that follows — and the rest of the GUI — switches accordingly.
- Anthropic API key
- Telegram (optional) — bot token + chat ID, with a “send test message” button to verify it works, or a toggle to skip notifications and use only the GUI
- Your profile — name, summary, desired roles, locations, … (what the AI matches against)
- Career pages — add the company sites to watch
- (optional) Title filters and fine-tuning (score threshold, schedule)
Only the steps you still need are shown. That means after an update that introduces a new required setting, only that one new step appears — you're never asked to redo everything. You can re-open the wizard any time from Einstellungen → Assistent öffnen.
Behind the scenes the wizard writes the same files you'd edit by hand
(.env, config/profile.json, config/jobs.json, config/filters.json) and records
which steps are done in config/setup-state.json (gitignored).
Einstellungen → 🧪 Im Debug-Modus testen runs the entire wizard against a throwaway
sandbox under data/setup-debug/. Forms are pre-filled from your real config so it feels
realistic, but every save goes to the sandbox — your real .env and config/ files are
never modified or deleted, and the full flow is always shown so you can rehearse it end to
end. Delete the data/setup-debug/ folder to discard the sandbox.
If you installed via git clone, Einstellungen → Updates → Nach Updates suchen pulls the
latest version (git pull --ff-only) and shows the git output. If new code was pulled, a
Restart & apply button appears to load it. When dependencies changed (package.json), it
asks you to run npm install in the terminal first instead of restarting. The pull is
fast-forward-only, so local commits that diverge from GitHub are refused rather than merged.
Keep the process running in the background with a process manager like pm2:
npm install -g pm2
pm2 start index.js --name job-alert
pm2 save # persist across rebootsThe GUI keeps full, self-contained snapshots of the SQLite database (all clients, jobs, and
run history) under data/backups/ (gitignored). Manage them in Einstellungen →
Datensicherung:
- Automatic daily — one snapshot per day, created on the first run / GUI start of the day.
Controlled by
BACKUP_ENABLED(default on) and pruned to the newestBACKUP_RETENTION_DAYS(default 30) dailies. - Pre-import safety — before every restore or database import, the current state is snapshotted first, so the operation is always reversible.
- Manual — create a snapshot on demand, download any snapshot to keep it off-site, or
upload a
.dbfile to bring one in. - Restore — replace the live database with a chosen snapshot. It's validated as an intact SQLite DB first, and the current state is auto-backed-up beforehand.
Snapshots are stored read-only and named by type — jobs-daily-…, jobs-manual-…,
jobs-preimport-…, jobs-upload-… — and retention only ever prunes old daily snapshots.
Each Telegram notification includes a job ID at the bottom. Use it to track where you are in the application process:
# Mark a job as applied
npm run apply -- abc123def456 applied
# Update when you get an interview
npm run apply -- abc123def456 interview
# Further stages
npm run apply -- abc123def456 offer
npm run apply -- abc123def456 rejected
# Mark a job as not relevant (removes it from Excel, no more notifications)
npm run apply -- ignore abc123def456
# See all tracked applications
npm run apply -- list
# Show help
npm run apply -- helpApplied jobs appear at the top of the Excel file highlighted in blue, with the application date and current status. The status is stored in the database, so it survives every Excel regeneration. Ignored jobs are removed from the Excel immediately on the next run.
Claude reads the full job description and your profile from config/profile.json and writes a complete, formal German Anschreiben (DIN 5008 style).
Click the ✎ button on any job card to open the cover-letter window. Before generating you can type extra notes into the optional text box — job-specific bonus info that should make it into the letter (e.g. a particular motivation, a relevant project, or your salary expectation). Then hit Anschreiben erstellen. Tweak the notes and ↻ Neu generieren as often as you like, then 📋 Kopieren the result.
Because writing a cover letter usually means you're applying, the window has a ✓ Als beworben markieren button in the top-right that sets the job's status to applied without leaving the modal (it turns green once done).
Each Telegram notification includes a job ID. Use it to generate a letter for that position:
npm run cover-letter -- abc123def456The letter is written to the console. To save it directly to a file:
npm run cover-letter -- abc123def456 > anschreiben.txt- Scraper visits each URL in
config/jobs.jsonwith a headless Chromium browser. It handles three pagination strategies automatically: href-based page links, "Load more" buttons, and next-button SPAs. For React/SPA sites (Bosch, Zeiss, SAP SuccessFactors, etc.) it intercepts XHR/fetch responses and extracts jobs directly from the API. - Deduplication — jobs are hashed by URL and skipped if already in the database. A title blocklist (internship, Ausbildung, sales, etc.) filters irrelevant postings before they reach Claude.
- Analyzer sends each new job to Claude with your profile and receives a relevance score (1–10), reasons, concerns, and a one-sentence summary.
- Notifier sends a formatted Telegram message for every relevant job, including the job ID for application tracking.
- Expiry detection — jobs that were notified but haven't appeared in any scrape for 72 hours trigger an expiry notification.
- Exporter regenerates
data/relevant_jobs.xlsxafter each run with all relevant jobs, application status, and score-based color coding. - Logging — each run writes a timestamped log file to
logs/and prints the elapsed time.
The spreadsheet (data/relevant_jobs.xlsx) is regenerated after every run:
| Column | Content |
|---|---|
| Firma | Company name |
| Ort | Location |
| Jobbezeichnung | Job title |
| Score | Claude relevance score (1–10) |
| Zusammenfassung | One-sentence Claude summary |
| Quelle | Source name from config |
| Gefunden am | Date first scraped |
| URL | Clickable hyperlink |
| ID | Job ID for npm run apply |
| Beworben am | Date you marked as applied |
| Status | Beworben / Interview / Angebot / Abgelehnt |
Row colors: 🟦 blue = applied, 🟩 green = score ≥ 8, 🟨 yellow = score ≥ 6, white = score < 6.
The Jobs tab has a ⬇ CSV button in the toolbar. It downloads exactly the jobs currently listed — the search box, source/status/score filters and the sort order are all applied to the file, so what you see is what you get. The download covers the active client only.
Unlike the Excel export (a fixed snapshot regenerated after each run), the CSV carries every field stored for a job, including the full job description:
| Column | Content |
|---|---|
| Jobbezeichnung | Job title |
| Firma | Company name |
| Ort | Location |
| Score | Claude relevance score (1–10) |
| Status | Beworben / Interview / Angebot / Abgelehnt |
| Beworben | Ja / Nein |
| Beworben am | Date + time you marked as applied |
| Quelle | Source name from config |
| Plattform | linkedin / stepstone / indeed (auto-apply platforms) |
| Einfach bewerben | Easy-apply available (Ja / Nein) |
| Auto-Bewerbung | State of the auto-application queue |
| Gefunden am | When first scraped |
| Analysiert am | When Claude scored it |
| Zuletzt gesehen | Last scrape that still listed the posting |
| Abgelaufen | Posting no longer online (Ja / Nein) |
| Zusammenfassung | One-sentence Claude summary |
| URL | Link to the posting |
| ID | Job ID for npm run apply |
| Beschreibung | Full job description text |
Format notes: UTF-8 with BOM and semicolon-separated, so a double-click opens it in a
German/European Excel with umlauts intact. Fields containing separators, quotes or line breaks
are quoted per RFC 4180; values starting with =, +, - or @ get a leading tab so Excel
treats them as text rather than a formula. Headers and labels follow the GUI language (de/en).
The endpoint behind the button is GET /api/jobs/export.csv, which accepts the same filters as
the toolbar: q, source, status, minScore, sort, plus clientId and lang.
Jobs are stored in data/jobs.db (SQLite). The clients table holds one row per tenant
(profile/sources/filters/prompts as JSON + Telegram chat id); jobs and runs carry a
client_id, and jobs is keyed by the composite (client_id, id) so the same posting can be
tracked independently per client. Inspect with any SQLite client:
sqlite3 data/jobs.db "SELECT title, company, score, status, scraped_at FROM jobs WHERE client_id='default' AND relevant=1 ORDER BY score DESC LIMIT 20;"Key jobs columns: client_id, id, title, company, url, location, source, relevant, score, summary, notified, expired, applied, applied_at, status, scraped_at, last_seen_at.
The schema migrates automatically on first open of the new code (adds
client_id, rebuilds thejobsprimary key, creates the default client).npm run migrateadditionally imports your existingconfig/*.jsoninto that default client.
Licensed under the GNU General Public License v3.0 or later. You may use, modify, and redistribute it, provided derivative works remain under the GPL.







