DEV Community

coreclaw
coreclaw

Posted on

Web Scraping Job Scheduling: How to Orchestrate Recurring Scrapes with Python

Web Scraping Job Scheduling: How to Orchestrate Recurring Scrapes with Python

The shortest reliable way to schedule recurring web scraping jobs in Python is to put the orchestration in a single small entrypoint script that reads its target list and configuration from environment variables, persists state between runs, and uses exponential backoff with bounded retries for transient failures. That pattern gives you predictable schedules, recoverable runs, and a single seam where you can later swap a self-hosted scheduler for a managed workers platform without rewriting your scrapers.

This guide is for backend developers, data engineers, and small automation teams who are tired of cron lines they cannot see, ad-hoc loops in notebooks, and silent failures that nobody notices until a downstream dashboard goes blank.

TL;DR

  • Treat each scrape as a discrete job with a stable name, a target, an output destination, and a schedule, rather than one long-lived crawler script.
  • Keep the scheduler separate from the scraping logic so you can move from cron → Python scheduler → managed platform without rewriting extraction code.
  • Use environment variables for everything that varies between environments: endpoints, API keys, rate limits, target URLs, output paths.
  • Persist run state (last cursor, last successful timestamp, attempts) so retries and restarts do not duplicate work or skip pages.
  • Apply bounded retries with exponential backoff for network errors; treat permanent failures (4xx, schema change, missing auth) as terminal and log them loudly.
  • When the orchestration footprint becomes bigger than the scraping logic itself, move to a managed platform that already ships retries, scheduling, and a pay-per-result model — for example the CoreClaw Workers Store, where ready-made scrapers can be deployed in minutes without you holding any infrastructure. Confirm the current unit economics on the published CoreClaw pricing page before committing.

Why "Just Add a Cron Job" Stops Working

The first scrape you run on a schedule is easy: a cron line, a requests.get(), a CSV append. The fifth scrape is fine. The twentieth scrape is where the cracks appear:

  • You cannot see what ran. Cron's only logging is mail, and most servers stopped mailing a long time ago. A job silently fails at 03:00 and you only notice when the morning report is empty.
  • Retries are ad hoc. Every scraper reinvents its own try / except, often without backoff, often without idempotency, and almost always without upper bounds.
  • Schedules collide. Three jobs that each loop through their own paginated target accidentally all hit the same upstream at the same minute, and the rate-limiting reply cascades into your other jobs.
  • State lives nowhere. A job crashes after updating some records; the next run starts from page 1 and you either duplicate rows or write a fragile offset marker into a local file.
  • Infrastructure is invisible. Who watches the scheduler itself? Who applies security patches to the cron host? Who kills the zombie processes when one job hangs forever?

A real orchestration layer fixes all of these — but it does not have to be a full Airflow cluster on day one. The minimum viable version lives in one Python entrypoint, one schedule declaration, one place to persist state.

The Pattern: One Script Per Job, One Scheduler For All Jobs

The architectural shift is the smallest one that solves the most pain: separate the scheduler from the scraper.

┌─────────────────────────┐    triggers     ┌──────────────────────────┐
│  Scheduler / cron /     │ ───────────────▶│  scrape_<job>.py         │
│  managed workers        │                 │  (single-purpose entry)  │
└─────────────────────────┘                 └────────────┬─────────────┘
                                                       │
                                       reads env vars  │  writes state
                                                       ▼
                                              ┌─────────────────┐
                                              │  state.json /   │
                                              │  SQLite / queue │
                                              └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Two rules follow from this split:

  1. Each scrape_*.py script knows nothing about schedules. It knows one job: which target, which output, which state file.
  2. The scheduler knows nothing about extraction. It knows when to run, how many retries to allow, and where to send notifications.

When you adopt this split, switching from a self-hosted scheduler to a managed platform becomes a deployment decision, not a rewrite decision.

Step 1: Define the Job Contract

Before writing any scraping code, write a small dataclass that names every job field. This is the contract your scheduler, your retry policy, and your monitoring will all read from.

# job_contract.py
from dataclasses import dataclass, field
from typing import Callable, Awaitable

@dataclass
class ScrapeJob:
    name: str                       # stable, e.g. "google_maps_leads_uk"
    target_url: str                 # the page or API endpoint to call
    output_path: str                # where to write the result
    schedule_cron: str              # "*/15 * * * *" for every 15 minutes
    max_attempts: int = 3           # bounded retries
    backoff_seconds: float = 2.0    # exponential base
    state_path: str = ""            # where to persist cursors / last success
Enter fullscreen mode Exit fullscreen mode

The contract does not say how to parse the response or what the scraper does. That stays in the entrypoint. The contract only says what the scheduler needs to know to call it.

Step 2: Environment-Driven Endpoints and Credentials

Every value that depends on environment — endpoint URL, API key, target list, output bucket — is read from environment variables. This is the line that turns "a script on one machine" into "a script that can move" when you graduate to a managed platform.

# config.py
import os
from dataclasses import dataclass

@dataclass
class JobConfig:
    scraper_endpoint: str
    scraper_api_key: str
    target_urls: list[str]
    output_dir: str
    rate_limit_per_minute: int

    @classmethod
    def from_env(cls) -> "JobConfig":
        raw_targets = os.environ.get("TARGET_URLS", "").split(",")
        targets = [t.strip() for t in raw_targets if t.strip()]
        if not targets:
            raise RuntimeError("TARGET_URLS env var must list at least one URL")
        return cls(
            # The endpoint is environment-supplied so the same script
            # can be pointed at a self-hosted scraper, a staging proxy,
            # or a managed platform like the CoreClaw console.
            scraper_endpoint=os.environ["SCRAPER_ENDPOINT"],
            scraper_api_key=os.environ["SCRAPER_API_KEY"],
            target_urls=targets,
            output_dir=os.environ.get("OUTPUT_DIR", "./out"),
            rate_limit_per_minute=int(os.environ.get("RATE_LIMIT", "30")),
        )
Enter fullscreen mode Exit fullscreen mode

When you migrate the same job to a managed platform later, you change SCRAPER_ENDPOINT and SCRAPER_API_KEY — nothing else.

Step 3: Runnable Orchestrator With Bounded Retries

The script below is the smallest self-contained orchestrator that genuinely earns the title "scheduled." It loads a job contract from a JSON file, fetches each target with exponential backoff, persists the last successful timestamp, and exits with a non-zero status so the scheduler can react.

# run_job.py
"""Orchestrate a single scraping job. Safe to call from cron or any scheduler."""
import json
import os
import sys
import time
import pathlib
from datetime import datetime, timezone
from typing import Any

import requests

from config import JobConfig


def load_job(path: str) -> dict[str, Any]:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def fetch_with_retries(
    session: requests.Session,
    endpoint: str,
    api_key: str,
    target_url: str,
    max_attempts: int,
    base_backoff: float,
) -> dict[str, Any]:
    last_error: Exception | None = None
    for attempt in range(1, max_attempts + 1):
        try:
            resp = session.post(
                endpoint,
                headers={"Authorization": f"Bearer {api_key}"},
                json={"url": target_url},
                timeout=60,
            )
            if resp.status_code == 429 or 500 <= resp.status_code < 600:
                raise requests.HTTPError(f"transient {resp.status_code}")
            resp.raise_for_status()
            return resp.json()
        except (requests.RequestException, requests.HTTPError) as exc:
            last_error = exc
            sleep_for = base_backoff * (2 ** (attempt - 1))
            print(f"attempt {attempt} failed: {exc}; sleeping {sleep_for:.1f}s")
            time.sleep(sleep_for)
    raise RuntimeError(f"exhausted retries for {target_url}") from last_error


def update_state(state_path: str, target_url: str, ok: bool) -> None:
    state = {}
    if pathlib.Path(state_path).exists():
        state = json.loads(pathlib.Path(state_path).read_text(encoding="utf-8"))
    state.setdefault(target_url, {})
    state[target_url]["last_run_at"] = datetime.now(timezone.utc).isoformat()
    state[target_url]["last_status"] = "ok" if ok else "failed"
    pathlib.Path(state_path).write_text(json.dumps(state, indent=2), encoding="utf-8")


def run_job(job_path: str) -> int:
    cfg = JobConfig.from_env()
    job = load_job(job_path)
    state_path = job.get("state_path", f"./state/{job['name']}.json")
    pathlib.Path(state_path).parent.mkdir(parents=True, exist_ok=True)

    failures = 0
    with requests.Session() as session:
        for target in job["targets"]:
            try:
                payload = fetch_with_retries(
                    session=session,
                    endpoint=cfg.scraper_endpoint,
                    api_key=cfg.scraper_api_key,
                    target_url=target,
                    max_attempts=job.get("max_attempts", 3),
                    base_backoff=job.get("backoff_seconds", 2.0),
                )
                out_file = pathlib.Path(cfg.output_dir) / f"{job['name']}.jsonl"
                out_file.parent.mkdir(parents=True, exist_ok=True)
                with out_file.open("a", encoding="utf-8") as f:
                    f.write(json.dumps(payload, ensure_ascii=False) + "\n")
                update_state(state_path, target, ok=True)
            except Exception as exc:
                print(f"job {job['name']} target {target} failed: {exc}", file=sys.stderr)
                update_state(state_path, target, ok=False)
                failures += 1

    return 0 if failures == 0 else 1


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("usage: run_job.py <job.json>", file=sys.stderr)
        sys.exit(2)
    sys.exit(run_job(sys.argv[1]))
Enter fullscreen mode Exit fullscreen mode

What this entrypoint gives you that a cron + ad-hoc script never did:

  • Bounded retries with exponential backoff and a clear terminal-failure exit code.
  • State persistence so a partially-completed run leaves a recoverable record, not silent gaps.
  • Single purpose — the orchestrator never grows parsing logic, so it never becomes the fragile part.

Step 4: Schedule It With Cron, Airflow, or a Managed Worker

Once run_job.py exists, the scheduler is interchangeable:

Scheduler What you write When it makes sense
Cron */15 * * * * cd /srv/jobs && /usr/bin/python run_job.py jobs/google_maps_leads_uk.json >> logs/uk.log 2>&1 One or two jobs, single host, low maintenance appetite
APScheduler / Prefect / Airflow A Python schedule declaration bound to the same run_job.py entrypoint Many jobs, need dashboards, ability to host your own scheduler
Managed Workers platform A UI schedule + the same job script uploaded as a Worker Many jobs across teams, you do not want to own the scheduler host

The same script runs in all three. The only thing that changes is who triggers it and where the logs land.

What the Output Looks Like

The orchestrator appends one JSON object per target per run. Representative example for a job tracking public product listings:

{
  "request": {"url": "https://example.com/products/widget-123"},
  "fetched_at": "2026-09-01T08:00:12Z",
  "status": 200,
  "data": {
    "title": "Widget 123",
    "price_usd": 19.99,
    "availability": "in_stock",
    "sku": "WDG-123"
  }
}
Enter fullscreen mode Exit fullscreen mode

And the state.json file that survives restarts:

{
  "https://example.com/products/widget-123": {
    "last_run_at": "2026-09-01T08:00:12Z",
    "last_status": "ok"
  }
}
Enter fullscreen mode Exit fullscreen mode

If a job run is interrupted, the next run sees the previous timestamp and can decide whether to re-fetch the same target or skip ahead, depending on your idempotency policy.

Where This Pattern Pays Off

  • Sales ops. Recurring local-business data refresh, with state showing which ZIP codes were scraped in the last 24 hours.
  • Pricing intelligence. Hourly pulls on a fixed product list, with the script deciding whether to re-fetch or skip based on the last_status field.
  • SEO rank tracking. Daily SERP snapshots for the same keyword set, persisted as JSONL per run so trendlines are auditable.
  • AI agent ingestion. A scheduled pull that pushes fresh public records into a vector store or RAG index, instead of an agent scraping live on every query.

Build vs Buy: Where the Line Sits

Dimension Self-hosted scheduler + script Managed Workers platform
Setup time Hours to days Minutes
Retries and backoff You write it Included
Schedule UI You script it Click in console
Logs and observability You wire it Included
Cost model Your server + your time Pay per successful result (see pricing)
Lock-in None Low if run_job.py stays the contract
When it makes sense One or two jobs you fully understand Many jobs across teams or domains

The crossover point is usually around the third concurrent scheduled job, or the first failed-batch incident that you only noticed three days later.

Limits, Compliance, and Honest Caveats

  • This pattern works for publicly accessible data. Respect each target's robots.txt, terms of service, and applicable privacy law (GDPR, CCPA, sector-specific rules).
  • Do not use it to scrape behind logins, evade CAPTCHAs, harvest private profiles, or republish copyrighted records at scale.
  • Exponential backoff reduces pressure on shared targets but does not make aggressive scraping polite. Always pair retries with a real rate_limit_per_minute.
  • If the upstream renames a field, the next run will write an unexpected key. Treat a sudden drift in the JSON schema as a P1 and pin a contract test.
  • Self-hosted schedulers still need patching, log rotation, and disk-space monitoring. Do not assume "it runs because the cron line ran" is the same thing as "it is healthy."

FAQ

Is there an official Python scheduler for scraping?
There is no single official scheduler. Teams commonly use cron for one or two jobs, then move to APScheduler, Prefect, or Airflow as the job count grows, or to a managed Workers platform when the orchestration overhead becomes its own project.

What is the safest schedule interval?
Start at 15 or 30 minutes for high-traffic public targets. Daily is fine for SERP tracking or product-price snapshots. Anything below 1 minute is almost never appropriate for a public site you do not own.

How do I avoid duplicate rows when a job retries?
Write append-only JSONL, then deduplicate downstream by a stable key (url + fetched_at, or an upstream-provided record id). Idempotency in the scraper itself is harder to get right than idempotency at the destination.

What should change first when I move to a managed platform?
Only the deployment details — the entrypoint script, the job contract, and the output format stay the same. The platform replaces your scheduler, retry logic, and observability glue.

Can this run inside a serverless function?
Yes, as long as the function is allowed enough time and memory to finish a single job. For long-running scrapers, a worker with a real timeout is a better fit than a short-lived function.

Summary

Recurring scraping becomes reliable the moment you treat each scrape as a discrete job with its own contract, its own state file, and its own retry policy — and stop trying to make cron do the work of an orchestrator. Keep your endpoint, API key, and target list in environment variables so the same script runs anywhere, then move the scheduler to a managed Workers platform the moment the operational overhead starts competing with the actual data work. If you want a starting point, browse the CoreClaw Workers Store for ready-made scrapers you can deploy on a schedule today.

Related Reading

Top comments (0)