From 5d9b3a6070d8885a803202385c5850aabc759dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 9 Sep 2026 11:40:35 +0200 Subject: [PATCH] changed method of fetching crontab schedule --- api/README.md | 18 ++++- .../scrape_schedule.cpython-313.pyc | Bin 0 -> 5559 bytes api/main.py | 12 ++- api/scrape_schedule.py | 69 ++++++++++++++++-- 4 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 api/__pycache__/scrape_schedule.cpython-313.pyc diff --git a/api/README.md b/api/README.md index 8e4da39..0bf8991 100644 --- a/api/README.md +++ b/api/README.md @@ -31,8 +31,17 @@ DATA_FILE=/path/to/events.json ARCHIVE_DATA_FILE=/path/to/archive.json uvicorn m ### Scrape schedule reporting `/health` reports when each scraper output file was last written (its mtime) -and, if you tell it the cron schedule, when it's next due. This doesn't read -your crontab — set the same expression(s) you put there as env vars: +and, if it can determine the cron schedule, when it's next due. The schedule +is sourced in order: + +1. `SCRAPE_CRON` / `ARCHIVE_SCRAPE_CRON` env vars, if set — always wins, and + the only option that works when the API doesn't run as the same user/host + as the cron job. +2. Otherwise, the API's own OS user's crontab (`crontab -l`), looked up for a + line invoking `scraper.py` (with vs. without `--archive` picks archive vs. + plain). Only works when the API process runs as the same user whose + personal crontab holds the scrape job — not a system crontab/cron.d entry, + not a job scheduled under a different user or host. ```bash SCRAPE_CRON="0 * * * *" ARCHIVE_SCRAPE_CRON="0 4 * * *" uvicorn main:app @@ -40,8 +49,9 @@ SCRAPE_CRON="0 * * * *" ARCHIVE_SCRAPE_CRON="0 4 * * *" uvicorn main:app `SCRAPE_CRON` covers the plain scrape (`fundi-scraped-output.json`); `ARCHIVE_SCRAPE_CRON` covers `--archive` (`fundi-archive-output.json`) and -falls back to `SCRAPE_CRON` if unset — set it separately only if the archive -scrape runs on its own cron line. Leave both unset to just get the +falls back to `SCRAPE_CRON` (env or crontab-discovered) if unset — set it +separately only if the archive scrape runs on its own cron line. If neither +an env var nor a matching crontab line is found, you just get the last-scrape info with `next_scrape_at` / `seconds_until_next_scrape` as `null`. diff --git a/api/__pycache__/scrape_schedule.cpython-313.pyc b/api/__pycache__/scrape_schedule.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fac45a5624cd4c17c4970cafe6178dfbce04c780 GIT binary patch literal 5559 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 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()