118 lines
3.9 KiB
Python
118 lines
3.9 KiB
Python
"""Reports when a scrape last ran and when cron will next run it.
|
|
|
|
"Last ran" is read from the mtime of the scraper's output file - true
|
|
regardless of how/when the scrape was actually invoked. "Next run" needs the
|
|
cron schedule, sourced in order of preference:
|
|
|
|
1. An explicit env var (SCRAPE_CRON / ARCHIVE_SCRAPE_CRON) - always wins,
|
|
works anywhere.
|
|
2. The current OS user's crontab (``discover_cron_expr``) - only works when
|
|
this process runs as the same user whose personal crontab (``crontab -e``)
|
|
contains the scrape job, and the ``crontab`` binary is available. Not
|
|
portable beyond that (different user, no cron, system crontab/cron.d
|
|
instead of a personal one, containers split across hosts, ...).
|
|
"""
|
|
|
|
import subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
|
|
from croniter import croniter
|
|
|
|
|
|
def _now_local() -> datetime:
|
|
return datetime.now().astimezone()
|
|
|
|
|
|
def _read_crontab_lines() -> List[str]:
|
|
"""Return the invoking OS user's crontab lines, or [] if unavailable."""
|
|
try:
|
|
result = subprocess.run(
|
|
["crontab", "-l"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return []
|
|
if result.returncode != 0:
|
|
return []
|
|
return result.stdout.splitlines()
|
|
|
|
|
|
def _parse_crontab_line(line: str) -> Optional[Tuple[str, str]]:
|
|
"""Split one crontab line into (schedule, command); None if not a job."""
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
return None
|
|
if line.startswith("@"):
|
|
parts = line.split(maxsplit=1)
|
|
if len(parts) != 2:
|
|
return None
|
|
return parts[0], parts[1]
|
|
parts = line.split(maxsplit=5)
|
|
if len(parts) != 6:
|
|
return None
|
|
return " ".join(parts[:5]), parts[5]
|
|
|
|
|
|
def discover_cron_expr(command_hint: str, *, archive: bool) -> Optional[str]:
|
|
"""Find a matching scrape job in the current user's crontab.
|
|
|
|
``command_hint`` is a substring identifying the scrape command (e.g.
|
|
"scraper.py"); ``archive`` picks the "--archive" line vs. the plain one.
|
|
Returns None if the crontab can't be read or no line matches.
|
|
"""
|
|
for line in _read_crontab_lines():
|
|
parsed = _parse_crontab_line(line)
|
|
if parsed is None:
|
|
continue
|
|
schedule, command = parsed
|
|
if command_hint not in command:
|
|
continue
|
|
if ("--archive" in command) != archive:
|
|
continue
|
|
return schedule
|
|
return None
|
|
|
|
|
|
def _file_mtime_local(path: Path) -> Optional[datetime]:
|
|
try:
|
|
return datetime.fromtimestamp(path.stat().st_mtime).astimezone()
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
|
|
def _next_cron_run(cron_expr: str, after: datetime) -> Optional[datetime]:
|
|
try:
|
|
naive_after = after.replace(tzinfo=None)
|
|
return croniter(cron_expr, naive_after).get_next(datetime).astimezone()
|
|
except (ValueError, KeyError):
|
|
return None
|
|
|
|
|
|
def get_schedule_info(path: Path, cron_expr: Optional[str]) -> dict:
|
|
"""Build the last/next-scrape block for one scraper output file.
|
|
|
|
``cron_expr`` is that scrape's cron expression (e.g. "0 * * * *");
|
|
None/invalid -> next-run fields are null.
|
|
"""
|
|
now = _now_local()
|
|
cron_expr = (cron_expr or "").strip() or None
|
|
|
|
last_scrape_at = _file_mtime_local(path)
|
|
next_scrape_at = _next_cron_run(cron_expr, now) if cron_expr else None
|
|
|
|
return {
|
|
"cron_schedule": cron_expr,
|
|
"last_scrape_at": last_scrape_at.isoformat() if last_scrape_at else None,
|
|
"seconds_since_last_scrape": (
|
|
round((now - last_scrape_at).total_seconds()) if last_scrape_at else None
|
|
),
|
|
"next_scrape_at": next_scrape_at.isoformat() if next_scrape_at else None,
|
|
"seconds_until_next_scrape": (
|
|
round((next_scrape_at - now).total_seconds()) if next_scrape_at else None
|
|
),
|
|
}
|