Geonode logo
Geonode Team

Geonode Team

Updated: October 16, 2026

Published: October 16, 2026

Incremental Web Crawling: Update Changed Pages Without Recrawling Everything

Incremental web crawling is the practice of refetching only the pages that changed, not the whole site. By the end of this guide you will have a Python module that discovers URLs from a sitemap or the Geonode `/v1/map` endpoint, normalises them, keeps per-URL state in SQLite, uses ETag and content hashes to decide what changed, adapts revisit intervals, detects deletions, and pulls the changed set through `/v1/extract` or `/v1/batch`. Geonode fetches the pages; your script owns the state and the comparison.

Incremental web crawling is the practice of refetching only the pages that changed, not the whole site. By the end of this guide you will have a Python module that discovers URLs from a sitemap or the Geonode /v1/map endpoint, normalises them, keeps per-URL state in SQLite, uses ETag and content hashes to decide what changed, adapts revisit intervals, detects deletions, and pulls the changed set through /v1/extract or /v1/batch. Geonode fetches the pages; your script owns the state and the comparison.

Why full recrawls waste budget

A site with 50,000 pages where 2% change per week needs about 1,000 fetches a week to stay current. A weekly full recrawl spends 50,000. That is a 50x difference in API requests, bandwidth, and the time your pipeline blocks on I/O.

The waste compounds on JavaScript-rendered pages, where each fetch is slower and more expensive, and on sites that rate-limit aggressively. Incremental web crawling turns the problem around: you keep a small amount of state per URL and let that state tell you where to spend the next request.

The module below is about 120 lines. Each section adds one piece. Install the dependency with pip install requests (httpx works identically if you prefer it).

Discovering URLs

Two sources, in order of preference. A sitemap gives you URLs plus an optional lastmod, which is the cheapest change signal there is. When a site has no usable sitemap, POST /v1/map discovers URLs by combining sitemap parsing with HTML link extraction and returns each URL with its source.

import os
import xml.etree.ElementTree as ET
import requests

API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}
SM_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}

def urls_from_sitemap(sitemap_url: str, session: requests.Session) -> list[tuple[str, str | None]]:
    """Return (loc, lastmod) pairs. Follows sitemap index files one level."""
    root = ET.fromstring(session.get(sitemap_url, timeout=30).content)
    out: list[tuple[str, str | None]] = []
    for sm in root.findall("sm:sitemap", SM_NS):          # sitemap index
        out.extend(urls_from_sitemap(sm.findtext("sm:loc", namespaces=SM_NS), session))
    for u in root.findall("sm:url", SM_NS):
        out.append((u.findtext("sm:loc", namespaces=SM_NS),
                    u.findtext("sm:lastmod", namespaces=SM_NS)))
    return out

def urls_from_map(site_url: str, session: requests.Session) -> list[tuple[str, None]]:
    body = {"url": site_url, "include_subdomains": False, "ignore_query_parameters": True}
    r = session.post(f"{API}/v1/map", json=body, headers=HEADERS, timeout=120)
    r.raise_for_status()
    return [(link["url"], None) for link in r.json()["links"]]

lastmod from a sitemap is a hint, not a guarantee. Some CMSs stamp every URL with the deploy time; others never update it. Treat it as a reason to check sooner, never as a reason to skip a check entirely.

URL normalisation

Without normalisation, https://Example.com/docs/?utm_source=x#top and https://example.com/docs become two rows and two fetches. Normalise before anything touches the state store.

from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
            "gclid", "fbclid", "ref", "mc_cid", "mc_eid"}

def normalize(url: str) -> str:
    parts = urlsplit(url.strip())
    scheme = parts.scheme.lower()
    host = parts.netloc.lower()
    if (scheme, host.rsplit(":", 1)[-1]) in {("http", "80"), ("https", "443")}:
        host = host.rsplit(":", 1)[0]
    query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
             if k.lower() not in TRACKING]
    query.sort()
    path = parts.path or "/"
    if len(path) > 1 and path.endswith("/"):
        path = path[:-1]                       # pick one convention and stick to it
    return urlunsplit((scheme, host, path, urlencode(query), ""))  # fragment dropped

The trailing-slash rule is a policy choice. Some frameworks serve different content at /docs and /docs/. If the target site is one of them, remove that line and let the two URLs live as separate rows.

The state store

One SQLite table carries everything the crawler needs to decide. The columns map to the signals discussed in the rest of this guide.

import sqlite3
import time

SCHEMA = """
CREATE TABLE IF NOT EXISTS pages (
  normalized_url TEXT PRIMARY KEY,
  url            TEXT NOT NULL,
  last_seen      REAL,
  last_fetched   REAL,
  next_check     REAL DEFAULT 0,
  interval_s     INTEGER DEFAULT 86400,
  content_hash   TEXT,
  etag           TEXT,
  last_modified  TEXT,
  sitemap_lastmod TEXT,
  missing_runs   INTEGER DEFAULT 0,
  gone           INTEGER DEFAULT 0
);
"""

def open_db(path: str = "crawl_state.sqlite") -> sqlite3.Connection:
    db = sqlite3.connect(path)
    db.executescript(SCHEMA)
    return db

def upsert_discovered(db: sqlite3.Connection, found: list[tuple[str, str | None]]) -> None:
    now = time.time()
    for raw, lastmod in found:
        n = normalize(raw)
        row = db.execute("SELECT sitemap_lastmod FROM pages WHERE normalized_url=?", (n,)).fetchone()
        if row is None:
            db.execute("INSERT INTO pages (normalized_url,url,last_seen,sitemap_lastmod) VALUES (?,?,?,?)",
                       (n, raw, now, lastmod))
        else:
            bump = lastmod is not None and lastmod != row[0]
            db.execute("UPDATE pages SET last_seen=?, missing_runs=0, sitemap_lastmod=?, "
                       "next_check=CASE WHEN ? THEN 0 ELSE next_check END WHERE normalized_url=?",
                       (now, lastmod, bump, n))
    db.commit()

last_seen records the last discovery run that listed the URL, which is separate from last_fetched. A changed sitemap_lastmod resets next_check to zero so the page is picked up in the next change queue.

Conditional requests: ETag and If-Modified-Since

Where a site sends ETag or Last-Modified, a conditional request lets the origin answer 304 with no body. You send those headers through the headers field of /v1/extract and read the origin's status from metadata.http_status. The request still counts against your plan, but it is faster than a full render.

Not every site honours validators, and CDNs sometimes strip them. The code below sends them when present and falls through to hashing when the answer is 200.

Content hashing after stripping volatile blocks

A raw hash of the Markdown changes every run on pages that print the current time, a CSRF token or a "related articles" block. Strip those before hashing. Keep the stripping rules in one place so you can tune them per site.

import hashlib
import re

VOLATILE = [
    re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?\S*"),   # ISO timestamps
    re.compile(r"(?i)(csrf|nonce|session)[_-]?token[^\s]*"),
    re.compile(r"(?i)^#+\s*related (posts|articles).*?(?=^#|\Z)", re.M | re.S),
    re.compile(r"[ \t]+"),
]

def content_fingerprint(markdown: str) -> str:
    text = markdown
    for rx in VOLATILE:
        text = rx.sub(" ", text)
    text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def fetch_one(session: requests.Session, row: sqlite3.Row | tuple) -> dict:
    """Sync extract with conditional headers. Returns a dict the caller can store."""
    normalized_url, url, etag, last_modified = row
    cond = {}
    if etag:
        cond["If-None-Match"] = etag
    if last_modified:
        cond["If-Modified-Since"] = last_modified
    body = {"url": url, "formats": ["markdown"], "render_js": False,
            "processing_mode": "sync", "headers": cond or None, "timeout": 60000}
    r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
    if r.status_code == 429:
        raise RuntimeError(f"rate limited: {r.json().get('error', r.json())}")
    r.raise_for_status()
    payload = r.json()
    status = payload["metadata"].get("http_status")
    md = (payload.get("data") or {}).get("markdown") or ""
    return {"http_status": status, "markdown": md,
            "hash": content_fingerprint(md) if status == 200 else None}

Whether the origin's ETag and Last-Modified response headers are exposed in metadata depends on the API version you are on; check the response for your account before relying on them. If they are not exposed, the content hash still gives you a reliable changed/unchanged answer, you just lose the 304 shortcut.

The change queue and adaptive revisit

The queue is the heart of incremental web crawling, and it is a query, not a data structure: every row whose next_check is in the past and which is not marked gone. After each fetch the interval moves. Change found: halve it, floor one hour. No change: multiply by 1.5, ceiling 30 days. Over a few weeks the schedule converges on each page's observed change frequency.

MIN_S, MAX_S = 3600, 30 * 86400

def change_queue(db: sqlite3.Connection, limit: int = 500) -> list[tuple]:
    return db.execute(
        "SELECT normalized_url, url, etag, last_modified FROM pages "
        "WHERE gone=0 AND next_check<=? ORDER BY next_check LIMIT ?",
        (time.time(), limit)).fetchall()

def record_result(db: sqlite3.Connection, normalized_url: str, res: dict) -> str:
    now = time.time()
    old_hash, interval = db.execute(
        "SELECT content_hash, interval_s FROM pages WHERE normalized_url=?",
        (normalized_url,)).fetchone()
    if res["http_status"] in (404, 410):
        db.execute("UPDATE pages SET gone=1, last_fetched=? WHERE normalized_url=?", (now, normalized_url))
        db.commit()
        return "gone"
    changed = res["http_status"] == 200 and res["hash"] != old_hash
    interval = max(MIN_S, interval // 2) if changed else min(MAX_S, int(interval * 1.5))
    db.execute("UPDATE pages SET last_fetched=?, next_check=?, interval_s=?, "
               "content_hash=COALESCE(?, content_hash) WHERE normalized_url=?",
               (now, now + interval, interval, res["hash"], normalized_url))
    db.commit()
    return "changed" if changed else "unchanged"

A 304 arrives with http_status == 304 and no hash, so it falls into the unchanged branch and the interval grows. Any 5xx or network error should leave the row untouched so it is retried on the next run; wrap fetch_one in a try/except in your runner for that.

Detecting deleted URLs

Two signals, used together. A direct 404 or 410 flips gone immediately in record_result. A URL that disappears from the sitemap is weaker evidence, because sitemaps lag and sometimes omit sections. Count consecutive discovery runs where it was absent and only force a confirming fetch after N of them.

def mark_missing(db: sqlite3.Connection, run_started: float, n_runs: int = 3) -> None:
    """Call after upsert_discovered. Rows not seen this run get missing_runs+1."""
    db.execute("UPDATE pages SET missing_runs=missing_runs+1 WHERE last_seen<? AND gone=0", (run_started,))
    db.execute("UPDATE pages SET next_check=0 WHERE missing_runs>=? AND gone=0", (n_runs,))
    db.commit()

Rows marked gone stay in the table. That keeps a stale inbound link from reintroducing the URL as new. If the page comes back, a later discovery run will list it again; reset gone to 0 in upsert_discovered if you want that behaviour.

Fetching the changed set in bulk

For a few hundred URLs the sync loop above is fine, and on a mature incremental web crawling setup the changed set is usually that small. For thousands, submit the queue as one /v1/batch job and poll it. The batch body accepts the same formats, render_js, proxy, headers and wait_config fields as a single extract, applied to every URL, so conditional headers cannot vary per URL in a batch. Use batch for the plain content-hash path and keep the sync path for URLs where you hold an ETag.

def batch_fetch(session: requests.Session, urls: list[str], poll_s: int = 10) -> dict[str, dict]:
    body = {"urls": urls, "formats": ["markdown"], "render_js": False, "ignore_invalid_urls": True}
    job = session.post(f"{API}/v1/batch", json=body, headers=HEADERS, timeout=60).json()
    while True:
        st = session.get(f"{API}/v1/batch/{job['job_id']}", headers=HEADERS, timeout=60).json()
        if st["status"] in ("completed", "failed", "cancelled"):
            break
        time.sleep(poll_s)
    out = {}
    for res in st.get("results", []):
        if res["status"] != "completed":
            continue
        md = (res.get("data") or {}).get("markdown") or ""
        out[res["url"]] = {"http_status": res["metadata"].get("http_status"),
                           "markdown": md, "hash": content_fingerprint(md)}
    return out

The alternative is processing_mode: "async" on individual /v1/extract calls, which returns 202 with a job_id you poll at GET /v1/extract/{job_id}. That keeps per-URL headers but costs one request per URL. Either way, feed the results through record_result exactly as the sync path does.

Putting the run together

A single run is: discover, upsert, mark missing, drain the queue, record. Run it from cron or a scheduler at whatever cadence your freshness target needs; the adaptive intervals keep the fetch count low regardless of how often the runner fires.

def run(sitemap_url: str | None, site_url: str) -> None:
    db = open_db()
    session = requests.Session()
    started = time.time()
    found = urls_from_sitemap(sitemap_url, session) if sitemap_url else urls_from_map(site_url, session)
    upsert_discovered(db, found)
    mark_missing(db, started)
    queue = change_queue(db)
    with_etag = [r for r in queue if r[2] or r[3]]
    plain = [r for r in queue if not (r[2] or r[3])]
    for row in with_etag:
        try:
            record_result(db, row[0], fetch_one(session, row))
        except Exception as exc:          # leave row for next run
            print("retry later:", row[1], exc)
    if plain:
        results = batch_fetch(session, [r[1] for r in plain])
        for row in plain:
            if row[1] in results:
                record_result(db, row[0], results[row[1]])

This is the entire incremental web crawling module. The table below summarises which signal does what, so you can drop pieces you do not need.

SignalCostReliabilityWhat it does in this module
Sitemap lastmodone sitemap fetchlow to mediumresets next_check to 0
ETag / Last-Modifiedone request, no renderhigh where supported304 means unchanged
Content hashone full fetchhighthe ground truth comparison
Adaptive intervalnonen/adecides when to re-check
404 / 410one requesthighmarks gone
Absent from sitemap N runsnonelowforces a confirming fetch

Common mistakes

Hashing before stripping volatile content

A page that prints "Generated at 14:02:11" changes every fetch, so your crawler halves its interval until it hits the floor and refetches hourly. Look at a diff of two consecutive fetches for any page that reports change on every run and add the pattern to VOLATILE.

Normalising after the first insert

If the first run stores raw URLs and normalisation is added later, every row is a duplicate waiting to happen. Normalise from day one and make normalized_url the primary key, as the schema does.

Trusting sitemap absence alone

Sitemaps lag deploys, split across index files, and sometimes drop whole sections after a CMS change. Marking a page gone because it vanished from one sitemap fetch will delete live content from your index. Require a confirming 404 or 410.

Running batch with per-URL conditional headers

/v1/batch applies one headers object to every URL in the job. Sending an If-None-Match meant for one page to a thousand others produces meaningless results. Route ETag-bearing rows through the sync path and the rest through batch, as run() does.

FAQ

What is incremental web crawling?

Incremental web crawling means revisiting only the pages that are likely to have changed since your last run, instead of refetching an entire site every time. The crawler keeps a state store with a fingerprint per URL, discovers candidates cheaply (sitemap lastmod, ETag, Last-Modified), and spends fetch budget on the subset that actually needs it.

How do I know which pages changed without downloading them?

You usually cannot know for certain without a request, but you can rank candidates. Sitemap lastmod is the cheapest signal. A conditional request with If-None-Match or If-Modified-Since returns 304 on sites that support validators. For everything else, fetch and compare a content hash computed after stripping volatile blocks like timestamps and session tokens.

Does the Geonode Scraper API handle state and change detection for me?

No. The Scraper API fetches pages and returns Markdown or HTML plus metadata such as http_status. Your script owns the state store, the URL normalisation, the hashing and the scheduling. That split is deliberate: the API stays stateless and cheap to call, while your incremental web crawling logic lives in code you control and can test.

How should I schedule revisits for incremental web crawling?

Start with a default interval, then adapt per URL. If a fetch finds a change, halve the interval down to a floor; if it finds no change, multiply it up to a ceiling. Pages that churn get checked often, static pages drift toward the ceiling. Store next_check in the state table and query for rows whose next_check has passed.

How do I detect pages that were deleted?

Use two signals together. A 404 or 410 on fetch is a strong signal. Absence from the sitemap on N consecutive runs is a weaker one, because sitemaps are often incomplete. Mark the row as gone only when a direct fetch confirms it, and keep the row so you do not rediscover and refetch the URL from stale links.

What are the limits and compliance rules I should respect?

Respect robots.txt and the site's terms of service, keep request rates modest, and never bypass authentication. On the Scraper API side, the free tier covers 1.5K requests per month and paid plans run from 2 to 100 threads, so size your changed set to your plan. Conditional requests still count as requests.