#!/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())