osint-dashboard/news/scraper/run_news_scraper.py
Sirius DevOps 91f436390b
Some checks failed
build-and-deploy / build (push) Failing after 4s
Add news pipeline: hourly scraper + Gemini summarizer in compose
Vendor the newsPipeline scraper + summarizer into the repo and wire them into
docker-compose against the EXISTING osint-db (no second Postgres), replacing
the upstream k8s CronJobs with in-compose wall-clock loops (:00 scrape, :05
summarize).

- news/scraper: vendored Scrapy project (257 RSS feeds) + hourly loop
  scheduler (run_news_scraper.py)
- news/summerizer: vendored Gemini map-reduce summarizer, cleaned:
  * fix broken google-genai response handling (_extract_text, defensive)
  * fix malformed INSERT/GRANT query in save_summary_to_db
  * OSINT-neutral default MAP_PROMPT; futures/markets language gated behind
    INCLUDE_FUTURES=0 (yfinance lazy-imported)
  * env-configurable model, batch size, lookback window
  + hourly loop scheduler (run_news_summarizer.py, :05)
- alembic 003_news: idempotent articles + article_summaries tables
- API: GET /api/news and GET /api/news/summaries (+ models, schemas)
- tests/test_api_news.py: 5 DB-backed contract tests (all pass vs real PG)
- docs/news.md + .env.example updates

Both services run under the `ingest` compose profile (matching the
ingester/camera-scraper pattern) and build arm64 on the Pi via the existing
Forgejo CI workflow. telebot left out of scope (reserved env only).
2026-08-24 17:28:46 -04:00

67 lines
2.2 KiB
Python

#!/usr/bin/env python3
"""Scheduler loop for the news scraper — hourly scrape at minute :00.
Replaces the k8s CronJob (`0 * * * *`) with an in-compose loop so the whole
news pipeline lives inside docker-compose. Each iteration:
1. (optionally, on first boot) runs the Scrapy crawl once to seed data fast
2. sleeps until the next :NEWS_SCRAPE_MINUTE wall-clock boundary
Because the loop is serial, a crawl that overruns its hour simply delays the
next run to the following boundary — two crawls never overlap.
Env (all optional, 12-factor):
NEWS_SCRAPE_MINUTE minute of the hour to fire (default 0)
NEWS_SCRAPE_RUN_ON_START "1" to crawl once immediately on boot (default 1)
"""
from __future__ import annotations
import datetime
import logging
import os
import subprocess
import sys
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("news.scraper")
MINUTE = int(os.getenv("NEWS_SCRAPE_MINUTE", "0"))
RUN_ON_START = os.getenv("NEWS_SCRAPE_RUN_ON_START", "1").lower() in ("1", "true", "yes")
CRAWL_CMD = ["scrapy", "crawl", "articles"]
def seconds_until_next(minute: int) -> float:
"""Seconds until the next occurrence of ``minute`` past the hour (local time)."""
now = datetime.datetime.now()
nxt = now.replace(minute=minute, second=0, microsecond=0) + datetime.timedelta(hours=1)
return (nxt - now).total_seconds()
def run_crawl() -> None:
logger.info("scrape starting at %s", datetime.datetime.now().isoformat(timespec="seconds"))
try:
proc = subprocess.run(CRAWL_CMD, cwd="/app")
logger.info("scrape finished rc=%s", proc.returncode)
except Exception: # noqa: BLE001 — keep the loop alive across failures
logger.exception("scrape failed")
def main() -> None:
logger.info(
"news scraper loop starting (minute=%s, run_on_start=%s)",
MINUTE, RUN_ON_START,
)
if RUN_ON_START:
run_crawl()
while True:
delay = seconds_until_next(MINUTE)
logger.info("next scrape at :%02d (in %.0fs)", MINUTE, delay)
time.sleep(delay)
run_crawl()
if __name__ == "__main__":
sys.exit(main())