ETL pipeline that pulls articles from NewsAPI, validates and normalizes them, then loads clean records into PostgreSQL. Two execution modes: a worker queue that processes search requests for web users (using each user's own encrypted NewsAPI key or the app owner's key in trial mode), and a debug script that dumps raw/clean payloads to disk and writes to a separate inspection table.
- extract (
src/extract.py) — fetches paginated articles from NewsAPI with timeout, retries, exponential backoff, and429/5xxhandling. - transform (
src/transform.py) — validates each article, normalizes text and timestamps, classifies rejection reasons and accumulates per-page statistics. - load (
src/load.py) — upserts articles by URL intoarticles, links them to asearch_requestviauser_news, and persists per-request stats inrequest_stats. - AI summary (
src/ai/) — after each web/worker run, a Mistral call summarizes the loaded pocket and stores the result inrequest_ai_report(one row persearch_request_id). - worker (
src/worker.py) — single loop that handles two job types in priority order:- Key validation (
one_key_validation) — atomically claims apending_validationrow fromusers_keys, decrypts and pings NewsAPI, then marks the key asvalid,invalid, orexhausted. - Search requests (
one_request) — atomically claims aqueuedrow fromsearch_requests(FOR UPDATE SKIP LOCKED), runs the pipeline, and marks the request assuccessorfailed.
- Key validation (
- per-user keys (
src/user_news_api_key.py) — AES-GCM encryption/decryption of NewsAPI keys stored inusers_keys, plus live key validation against the NewsAPI/top-headlinesendpoint.
project/
├── config/
│ └── config.py # Settings dataclass loaded from .env
├── notebooks/
│ └── 01_eda.ipynb
├── src/
│ ├── __init__.py
│ ├── db.py # Connection, schema creation, queue claim
│ ├── extract.py # NewsAPI fetch (web + debug variants)
│ ├── transform.py # Validation, normalization, rejection stats
│ ├── load.py # Upserts and request_stats persistence
│ ├── pipeline.py # run_debug_pipeline / run_pipeline_for_web_user
│ ├── worker.py # Queue worker loop (key validation + search requests)
│ ├── user_news_api_key.py # AES-GCM encryption helpers + key validation
│ └── ai/ # Mistral-based pocket summary
│ ├── __init__.py
│ ├── client.py
│ ├── prompts.py
│ ├── report.py
│ └── schemas.py
├── tests/
│ ├── test_pipeline.py
│ ├── test_transform.py
│ └── test_ai_report.py
├── .env.example
├── DockerFile
├── docker-compose.yml
├── main.py # CLI entry point
├── pyproject.toml # Ruff + pytest config
├── requirements.txt
└── requirements-dev.txt
data/raw/anddata/clean/are created on demand by debug runs and are gitignored.
Copy .env.example to .env and fill in your values:
cp .env.example .envDatabase
DB_HOST,DB_PORT,DB_USER,DB_PASSWORDDB_ADMIN_DB(defaultpostgres) — admin DB used to createNEWS_DBNEWS_DB(defaultnews_db)
NewsAPI
NEWSAPI_KEY— app owner's key; used as the fallback in CLI/debug mode and for all trial requests in worker modeNEWSAPI_URL(defaulthttps://newsapi.org/v2/everything)NEWSAPI_DEFAULT_LANGUAGE(defaultru)NEWSAPI_SORT_BY(defaultpublishedAt; one ofrelevancy,popularity,publishedAt)NEWS_API_KEY_ENCRYPTION_SECRET— secret used to derive the AES-GCM key for encrypting user-supplied NewsAPI keysTRIAL_REQUEST_LIMIT— max number of trial searches allowed per user (enforced at the web layer)
AI summary (Mistral)
MISTRAL_API_KEY— required whenAI_SUMMARY_ENABLED=trueMISTRAL_API_URL(defaulthttps://api.mistral.ai/v1/chat/completions)MISTRAL_MODEL(defaultmistral-large-latest)AI_SUMMARY_ENABLED(defaulttrue) — set tofalseto skip the AI step entirelyAI_PROMPT_VERSION(defaultv1) — stored alongside each report for prompt-version trackingAI_PROVIDER(defaultmistral) — provider label written torequest_ai_report.model_providerAI_REQUEST_TIMEOUT_SECONDS(default60) — HTTP timeout for the AI provider callAI_REPORT_MAX_ARTICLES(default50) — hard cap on articles passed to the model per report
Reliability and limits
REQUEST_TIMEOUT_SECONDS,REQUEST_MAX_RETRIES,REQUEST_BACKOFF_FACTOR,REQUEST_MAX_BACKOFF_SECONDSREQUEST_PAGE_SIZE_MAX(default100),MAX_PAGES_PER_REQUEST(default50),NEWSAPI_MAX_TOTAL_RESULTS(default1000)DB_CONNECT_TIMEOUT_SECONDS,DB_STATEMENT_TIMEOUT_MS
python -m venv .venv
source .venv/bin/activate # macOS/Linux
# .venv\Scripts\activate # Windows
pip install -r requirements.txtpython main.py --init-onlyCreates the news_db database and all production tables. Add --debug to also create the debug table bad_news_bears. Alternatively, append --bootstrap to any run command to initialize on startup.
python main.py --keyword python --limit 20 --page_size 50 --language en --user_id 1 --search_request_id 1Uses the global NEWSAPI_KEY from .env. Articles go into articles and are linked to the user via user_news; per-request stats land in request_stats.
python main.py --worker --poll_interval 3The worker processes two job types in a tight loop:
- Key validation — picks a
pending_validationkey, pings NewsAPI, writesvalid/invalid/exhausted. - Search requests — picks a
queuedrequest, runs the full ETL pipeline, writessuccess/failed.
Multiple workers can run concurrently — FOR UPDATE SKIP LOCKED prevents duplicate processing.
See the Debug mode section at the bottom of this README.
The project expects an external Docker network named news-stack-net (shared with the web app / Postgres stack). Create it once if it doesn't exist:
docker network create news-stack-netThen build and start the worker:
docker compose up --buildThe app container runs python main.py --worker --bootstrap --poll_interval 3. Postgres must be reachable on the news-stack-net network as news-postgres (configure DB_HOST=news-postgres in .env).
pip install -r requirements-dev.txt
python -m compileall .
ruff check .
pytest
bandit -r src config main.py
pip-audit| Table | Purpose |
|---|---|
app_users |
Web users (Google OAuth). Includes trial_uses counter tracking how many free trial searches a user has consumed. |
users_keys |
Per-user encrypted NewsAPI keys (AES-GCM). Each key goes through a validation lifecycle: pending_validation → validating → valid / invalid / exhausted. |
search_requests |
Queue of search jobs with status (queued/running/success/failed) and is_trial flag. |
articles |
Deduplicated articles, unique by url. |
user_news |
Many-to-many link between app_users, search_requests, and articles. |
request_stats |
Per-request totals: income_articles, accepted_articles, rejected_articles, reasons_counts (JSONB), prime_reasons (JSONB). |
request_ai_report |
One Mistral-generated summary per search_request_id (counts, conclusions, sentiment, topics, highlight, warnings, token usage). |
bad_news_bears |
Debug only — populated by --debug runs. Not touched by web/worker mode. |
When search_requests.is_trial = true, the worker uses the app owner's NEWSAPI_KEY from config instead of looking up the user's personal key from users_keys. The trial_uses counter on app_users is managed by the web layer; the ETL worker only checks the flag.
When a user registers a NewsAPI key via the web app, a row is inserted into users_keys with status = 'pending_validation'. On each worker loop tick, one_key_validation() picks one such row and:
- Decrypts the key from AES-GCM ciphertext.
- Sends a minimal request to
https://newsapi.org/v2/top-headlines. - Sets the status:
valid— HTTP 200invalid— HTTP 401 with any code other thanapiKeyExhaustedexhausted— HTTP 401 withcode = "apiKeyExhausted"
- On network/crypto error the status is reset to
pending_validationfor retry.
Only keys with status = 'valid' are used by search requests.
After run_pipeline_for_web_user finishes loading and persists request_stats, it calls _generate_and_store_ai_summary, which:
- Reads the loaded pocket from DB:
articles JOIN user_news WHERE search_request_id = ?. - Truncates the article list to
AI_REPORT_MAX_ARTICLES(default 50) before calling the model. - Calls Mistral (
response_format: json_object) with the system prompt insrc/ai/prompts.py. - Validates and normalizes the JSON response (sentiment percentages re-normalized to sum to 100;
sentiment_labelforced to match the dominant key;highlight.urlreplaced with a real article URL if the model invented one). - Upserts a row in
request_ai_reportkeyed bysearch_request_idwithstatus='success'.
The AI step is best-effort: any Mistral or DB error is logged but does not fail the search request. On failure, a request_ai_report row with status='failed' is written so the frontend can distinguish "AI failed" from "not yet generated." To skip AI entirely, set AI_SUMMARY_ENABLED=false.
Stored summary fields:
status(success|failed) anderror_textnews_count— articles linked to this request (capped atAI_REPORT_MAX_ARTICLES)summary— 1-2 sentence neutral overviewmain_conclusions(JSONB) — exactly 3 short conclusionssentiment_label(positive|negative|neutral) andsentiment_scoresentiment_distribution(JSONB) —{positive, negative, neutral}summing to 100main_topics(JSONB) — 1 to 3 topics in order of importancehighlight(JSONB) —{url, title, author?, description?, reason}data_quality_warnings(JSONB)model_provider,model_name,promt_version(DB column name),input_tokens,output_tokens,total_tokens
- HTTP retries with backoff and
Retry-Afterhandling (429,5xx). - Strict input validation in transform layer; rejected records counted by reason in
request_stats. - Idempotent article upsert on
url; idempotentuser_newsinsert on(user_id, article_id, search_request_id). - Atomic worker dequeue (
FOR UPDATE SKIP LOCKED), safe for multiple worker processes. - DB connect and statement timeouts are configurable.
- NewsAPI keys are stored encrypted (AES-GCM) and only decrypted in-memory for the duration of a single operation.
.env,.venv,__pycache__, and generated files underdata/rawanddata/cleanare gitignored.- Never commit real keys — only
.env.exampleshould be in git.
Database 'news_db' does not exist— runpython main.py --init-onlyonce, or add--bootstrapto your run command.password authentication failed— verifyDB_HOST,DB_PORT,DB_USER,DB_PASSWORD.User has no NEWSAPI key yet(worker mode, non-trial) — the user has no row inusers_keyswithservice = 'news_api'. Use the web flow to upload their key.User has no NEWSAPI key yet(worker mode, trial) —NEWSAPI_KEYis empty in.env.NEWSAPI_KEY contains a placeholder value— replaceYOUR_REAL_NEWSAPI_KEYin.env(needed for CLI/debug mode and trial requests).- Key stuck in
pending_validation— check thatNEWS_API_KEY_ENCRYPTION_SECRETmatches the value used when the key was encrypted; a mismatch causes a crypto error and the status is reset topending_validation.
The --debug flag turns main.py into a pipeline-inspection script. Use it to examine exactly what NewsAPI returned, what the transform layer kept or rejected, and why — without touching any production tables.
python main.py --debug --keyword python --limit 20 --page_size 50 --language enWhat it does, page by page:
- Extract — calls NewsAPI with
NEWSAPI_KEYfrom.envand writes the raw payload to:data/raw/<UTC-timestamp>_<sanitized-keyword>_page_<N>.json - Transform — runs the same validation/normalization as production and writes:
data/clean/cleaned_<keyword>_page_<N>_<timestamp>.json # accepted articles data/clean/stats/stats_<keyword>_page_<N>_<timestamp>.json - Load — inserts cleaned articles into
bad_news_bearswithON CONFLICT (url) DO NOTHING.
Pagination follows the same --limit / --page_size / MAX_PAGES_PER_REQUEST rules as the production pipeline.
Use it to:
- Verify NewsAPI responses for a new keyword/language combination.
- Reproduce a transform-layer rejection without re-hitting the API (re-run transform against saved
data/raw/*.json). - Compare
stats_*.jsonacross runs when tuning validation rules. - Sanity-check the SQL load path on a throwaway table before touching
articles.