An automated job-search pipeline that scrapes listings from LinkedIn and Seek, scores them against a personal profile using LLM-based multi-stage classification, and sends Telegram alerts for high-relevance matches.
Manually checking job boards is time-consuming and easy to miss postings. This project automates the full cycle: periodic scraping → deduplication → AI-powered relevance scoring → push notification, letting you focus on applying rather than searching.
The system is composed of independent services that communicate through a shared SQLite database and internal HTTP APIs.
┌──────────────┐ ┌──────────────┐
│ jobspy_scraper│ │ seek_scraper │
│ (LinkedIn) │ │ (Seek.com.au)│
└──────┬───────┘ └──────┬───────┘
│ writes │ writes
▼ ▼
┌──────────────┐ ┌──────────────┐
│ jobs.db │ │ seek_jobs.db │
│ (SQLite) │ │ (SQLite/PG) │
└──────┬───────┘ └──────┬───────┘
│ reads │ reads
▼ ▼
┌──────────────┐ ┌──────────────┐
│ jobspy_api │ │ seek_api │
│ (FastAPI) │ │ (FastAPI) │
└──────┬───────┘ └──────┬───────┘
│ │
└───────┬───────────┘
▼
┌───────────────┐
│ aggregator │
│ (FastAPI) │ ← AI relevance scoring (Groq)
└───────┬───────┘
│
┌───────┴───────┐
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ flask_app │ │ matcher │
│ (dashboard)│ │(AI stages) │
└────────────┘ └─────┬──────┘
│
▼
┌────────────┐
│ Telegram │
│ alerts │
└────────────┘
| Service | Purpose | Port |
|---|---|---|
| jobspy_scraper | Scrapes LinkedIn via python-jobspy, stores raw JSON payloads into SQLite. Runs on a cron schedule (hourly) or as a one-shot. | — |
| seek_scraper | Scrapes Seek.com.au IT listings via Scrapy with configurable weekday/weekend schedules, optional AI post-processing and backfill. | — |
| matcher | Three-stage AI pipeline: language filter → IT-role classifier → profile-fit scorer. Sends Telegram alerts for high-scoring matches. | — |
| jobspy_api | Read-only FastAPI over the LinkedIn scraper's raw_jobs table. |
6060 |
| seek_api | Read-only FastAPI over Seek job listings. Supports both SQLite and PostgreSQL backends. | 6059 |
| aggregator_service | Pulls from both APIs, deduplicates by URL, applies AI relevance scoring, and exposes a unified job listing with profile management. | 6061 |
| flask_app | Web dashboard for reviewing scraped jobs, AI decisions, and pipeline status. | 5000 |
| Layer | Technologies |
|---|---|
| Language | Python 3.11 / 3.12 |
| Scraping | python-jobspy ≥1.1.82, Scrapy 2.8 |
| AI / LLM | Groq API (Llama 3.1 8B for Stage 1, Llama 3.3 70B for Stage 2) |
| APIs | FastAPI ≥0.109, Uvicorn |
| Dashboard | Flask, Jinja2 templates |
| Database | SQLite (WAL mode) as primary; PostgreSQL optional for Seek |
| Scheduling | APScheduler (cron and interval triggers) |
| Notifications | Telegram Bot API |
| Containerisation | Docker, Docker Compose |
| CI/CD | GitHub Actions (self-hosted runner), Dependabot |
| Language detection | fast-langdetect |
The matcher processes jobs through three sequential stages:
-
Stage 0 — Language filter: Uses
fast-langdetectto identify the description language. Jobs detected as German (≥90% confidence by default) are excluded. Configurable vialanguage_filterinconfig.yaml. -
Stage 1 — IT-role classification: Sends job titles in batched requests to a fast LLM (
llama-3.1-8b-instant). Each title is classified as IT/non-IT with a confidence score. Non-IT titles with high confidence are excluded immediately. -
Stage 2 — Profile scoring: Each remaining job is individually scored (0–100) against your profile summary by a larger LLM (
llama-3.3-70b-versatile). Returnsinclude(≥75),maybe(≥55), orexcludelabels with reasons, fit factors, and anti-fit factors.
Jobs scoring ≥80 trigger a Telegram notification with title, company, score, and a direct link.
Both matcher and aggregator implement client-side rate limiting that respects Groq's RPM/TPM quotas. Token usage is estimated before each request, reserved against the budget, and corrected after actual usage is reported. Exponential backoff with jitter handles 429 responses.
- Python 3.11+ (3.12 used in primary Dockerfile)
- Docker and Docker Compose (for containerised deployment)
- A Groq API key (required for matcher and aggregator AI features)
- A Telegram bot token and chat ID (required for notifications)
.
├── config.yaml # Central YAML config (scraper + matcher)
├── Dockerfile # Multi-service image (scraper, matcher, flask, jobspy_api)
├── docker-compose.flask.yml # Standalone Flask development compose
├── infra/
│ ├── docker-compose.yml # Production compose (all services)
│ ├── docker-compose.override.yml # Local development overrides
│ ├── migrate.py # SQL migration runner
│ └── migrations/ # Versioned SQL schema files (0001–0006)
├── jobspy_scraper/ # LinkedIn scraper service
├── matcher/ # AI classification pipeline
├── flask_app/ # Web dashboard
├── jobspy_api/ # Read-only API for LinkedIn jobs
├── seek_scraper/ # Seek.com.au scraper + scheduler
├── seek_api/ # Read-only API for Seek jobs
├── tests/ # Aggregator Service Tests
├── aggregator_service/ # Unified aggregator with AI scoring
├── data/ # Runtime SQLite databases (gitignored)
└── .github/
├── workflows/deploy.yml # CD: build + deploy on push to main
├── workflows/ci.yml # CI: Unit and Integration Tests
├── dependabot.yml # Automated dependency updates
└── copilot-instructions.md # Project guidelines for AI tools
git clone <repository-url>
cd JobSpyCopy and edit the configuration:
# Central config for scraper and matcher
cp config.yaml config.yaml # Edit search_terms, locations, thresholds, profile
# Seek scraper environment (if using Seek services)
cp seek_scraper/.env.example seek_scraper/.envEdit config.yaml to set your search terms, locations, and profile summary:
scraper:
search_terms: ["software engineer", "software developer"]
locations: ["Melbourne, Victoria, Australia"]
hours_old: 12
results_wanted: 200
matcher:
profile:
summary: >
Your skills and preferences here.
thresholds:
include: 75
maybe: 55
min_score_to_alert: 80Create infra/.env with the required secrets:
GROQ_API_KEY=your_groq_api_key
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_telegram_chat_id
# Optional
HTTP_PROXY=
HTTPS_PROXY=
# Required for Seek services
SEEK_DATABASE_ENGINE=sqlite
SEEK_DATABASE_TABLE=seek_jobsWarning: Never commit
.envfiles. They are listed in.gitignore.
cd infra
docker compose build
docker compose up -dThis starts all services: migration → scraper → matcher → APIs → aggregator → dashboard.
Warning:
docker compose down -vwill remove named volumes and permanently delete all scraped data. Usedocker compose down(without-v) to stop services while preserving data.
Verify:
# Dashboard
curl http://127.0.0.1:5000
# JobSpy API
curl http://127.0.0.1:6060/health
# Seek API
curl http://127.0.0.1:6059/health
# Aggregator API
curl http://127.0.0.1:6061/api/v1/healthRun migrations first to create the schema:
python infra/migrate.pyThis applies all SQL files in infra/migrations/ sequentially and tracks them in a schema_migrations table.
pip install -r jobspy_scraper/requirements.txt
# One-shot scrape
python -m jobspy_scraper.main run --config config.yaml
# Scheduled (hourly at :00)
python -m jobspy_scraper.main serve --config config.yamlpip install -r matcher/requirements.txt
# Requires GROQ_API_KEY in environment
export GROQ_API_KEY=your_key
export TELEGRAM_BOT_TOKEN=your_token
export TELEGRAM_CHAT_ID=your_chat_id
python matcher/main.pyThe matcher runs in an infinite loop, processing new jobs through all three stages, then sleeping for sleep_seconds_when_idle (default: 30s) when no work remains.
# JobSpy API
pip install -r jobspy_api/requirements.txt
uvicorn jobspy_api.main:app --reload --port 8000
# Seek API (requires DATABASE_ENGINE env var)
pip install -r seek_api/requirements.txt
export DATABASE_ENGINE=sqlite
export SQLITE_DB_PATH=./data/seek_jobs.sqlite3
export DATABASE_TABLE=seek_jobs
uvicorn seek_api.main:app --reload --port 8001
# Aggregator
pip install -r aggregator_service/requirements.txt
python -m aggregator_service.migrate
uvicorn aggregator_service.main:app --reload --port 8010pip install -r flask_app/requirements.txt
python flask_app/app.pyNote: The Flask app connects to
/app/data/jobs.dbby default (a Docker path). When running outside Docker, ensure the database exists atdata/jobs.dbrelative to the project root, or modify the path inflask_app/app.py.
| Key | Type | Default | Description |
|---|---|---|---|
sqlite_path |
string | ./data/jobs.db |
Path to the SQLite database |
search_terms |
list | ["Werkstudent", "working student"] |
Job search terms (env: SEARCH_TERMS) |
locations |
list | ["Germany"] |
Search locations (env: LOCATIONS) |
hours_old |
int | 24 |
Only fetch jobs posted within this window |
results_wanted |
int | 500 |
Max results per search-term/location pair |
fetch_description |
bool | true |
Whether to fetch full job descriptions |
delay_ms_min |
int | 500 |
Minimum delay between requests (ms) |
delay_ms_max |
int | 2000 |
Maximum delay between requests (ms) |
http_proxy |
string | — | Optional HTTP proxy |
linkedin.li_at |
string | — | Optional LinkedIn auth cookie |
linkedin.jsessionid |
string | — | Optional LinkedIn session cookie |
linkedin.bsc |
string | — | Optional LinkedIn BSC cookie |
| Key | Type | Default | Description |
|---|---|---|---|
ai.stage1_model |
string | llama-3.1-8b-instant |
Model for IT-role classification |
ai.stage2_model |
string | llama-3.3-70b-versatile |
Model for profile scoring |
ai.rpm |
int | 30 |
Requests per minute limit |
ai.tpm |
int | 80000 |
Tokens per minute limit |
ai.safety_factor |
float | 0.9 |
Fraction of TPM to actually use |
thresholds.include |
int | 75 |
Minimum score for "include" label |
thresholds.maybe |
int | 55 |
Minimum score for "maybe" label |
thresholds.min_score_to_alert |
int | 80 |
Minimum score to trigger Telegram alert |
profile.summary |
string | — | Your professional summary for AI matching |
language_filter.enabled |
bool | true |
Enable language-based filtering |
language_filter.german_exclude_threshold |
float | 0.9 |
Confidence threshold for German exclusion |
| Variable | Required By | Description |
|---|---|---|
GROQ_API_KEY |
matcher, aggregator | Groq API key for LLM inference |
TELEGRAM_BOT_TOKEN |
matcher | Telegram bot token for notifications |
TELEGRAM_CHAT_ID |
matcher | Telegram chat ID for notifications |
SQLITE_PATH |
jobspy_api | Path to LinkedIn jobs database |
DATABASE_ENGINE |
seek_api, seek_scraper | sqlite or postgres |
SQLITE_DB_PATH |
seek_api (sqlite mode) | Path to Seek SQLite database |
SEEK_DATABASE_TABLE |
seek_api, seek_scraper | Database table name |
AGG_DB_PATH |
aggregator | Path to aggregator SQLite database |
AGG_AI_API_KEY |
aggregator | AI API key (falls back to GROQ_API_KEY) |
CONFIG_PATH |
matcher | Override path to config.yaml |
LOG_LEVEL |
all | Logging level (default: INFO) |
The core pipeline uses SQLite in WAL mode with the following tables (managed by infra/migrations/):
raw_jobs— Stores JSON payloads from scrapers with generated virtual columns (title,company,location,description,job_url) extracted from JSON.job_decisions— Final classification result per job (label, score, reasons, notification status).job_language_analysis— Stage 0 results (detected language, confidence, exclusion flag).job_stage1_results— Stage 1 results (IT-role boolean, confidence, reason).job_stage2_results— Stage 2 state and results (status, attempts, payload, errors).scrape_runs/scrape_errors— Scraper telemetry and error logging.ai_audit— Full request/response audit trail for all LLM API calls.schema_migrations— Migration tracking.
Deployment is automated via GitHub Actions on push to main (.github/workflows/deploy.yml):
- Checks out the repository on a self-hosted runner.
- Writes secrets (
GROQ_API_KEY,TELEGRAM_BOT_TOKEN,TELEGRAM_CHAT_ID, proxy settings) toinfra/.env. - Builds the Docker image tagged with the commit SHA.
- Runs
docker compose up -dwith the production compose file. - Verifies database persistence at
/var/lib/jobs-app/data.
Dependabot is configured (.github/dependabot.yml) to check weekly for updates across:
- Python packages in
flask_app/,matcher/, andjobspy_scraper/ - Docker base images
- GitHub Actions versions
-
SQLite as primary datastore: Chosen for zero-ops simplicity. WAL mode enables concurrent reads during writes. The trade-off is limited write concurrency — only one writer should be active at a time. The Seek subsystem optionally supports PostgreSQL for heavier workloads.
-
JSON payloads with virtual columns:
raw_jobsstores the full scraper payload as JSON and uses SQLite generated columns to extract fields. This preserves the complete payload for debugging while enabling SQL queries on key fields. -
Two-model matcher strategy: Stage 1 uses a fast, small model for bulk title classification (cheap, fast). Stage 2 uses a larger model for detailed scoring of the remaining candidates. This keeps costs manageable.
-
Client-side rate limiting: Rather than relying solely on 429 responses, the matcher estimates token usage before each request and proactively throttles. This reduces wasted requests and avoids API bans.
-
Separate databases per source: The LinkedIn pipeline (
jobs.db) and Seek pipeline (seek_jobs.db/PostgreSQL) use independent databases. The aggregator service bridges them by pulling from both APIs and deduplicating.
- Minimal automated coverage only: The repository now has four focused aggregator tests, but the scraper, matcher loop, Flask dashboard, and API endpoints still need broader coverage.
- Hardcoded Flask DB path:
flask_app/app.pyconnects to/app/data/jobs.db(a Docker path). Running locally requires the database to exist at that path or a code change. - Stage 2 is currently disabled in the matcher loop: The
claim_stage2_batchcall is commented out inmatcher/main.py. Stage 2 processing does not execute in the current main loop. - Missing
import osinmatcher/ai_client.py: The module referencesos.getenvwithout importingosat the module level. - Port conflicts: Services bind to ports 5000, 6059, 6060, and 6061. Check for conflicts with local services.
Each subsystem has its own README with endpoint specifications, environment variable matrices, and usage examples:
jobspy_api/README.md— JobSpy API endpoints and query parametersseek_api/README.md— Seek API endpoints, filtering, and response formatseek_scraper/README.md— Seek spider setup, scheduling, regions, and troubleshootingaggregator_service/README.md— Aggregator endpoints, AI config, and environment variables
The deliberately small test suite exercises the aggregator's URL canonicalisation, AI-score validation, SQLite deduplication, and failed-sync checkpoint handling. It uses a temporary SQLite database and replaces source connectors with fakes; an autouse fixture fails the test immediately if code attempts a live HTTP request.
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --disable-pip-version-check -r requirements-test.txt
python -m pytest -qNo API keys, Telegram credentials, scraper access, production database, or Docker services are required.