OpenViSTAI · Agents · Blockchain · Crypto
Menu
OPENVIST RECIPES

Compare two saved sitemap snapshots

Python

Find added, removed and duplicate URLs in two sitemap urlsets without crawling the website.

Python code excerpt: Compare two saved sitemap snapshots. Version 1.0.0. Read the full recipe for code, sources and recorded test scope.

Read two UTF-8 sitemap files already saved on your machine. The script requires the sitemap namespace, checks absolute HTTP(S) URLs and one protocol and host per file, rejects DTD and entity declarations, and compares complete URL strings. Query parameters remain part of the identity.

Before you start

  • Python 3.10 or later; standard library only.
  • Two saved UTF-8 XML sitemap urlsets with the standard sitemap namespace.
  • Files up to 8 MiB and 50,000 URL entries each.

Build and check

  1. Save the earlier and current urlset files in your working folder.
  2. Run: python sitemap_diff.py before.xml after.xml > sitemap-diff.json
  3. Inspect added and removed URL lists and the count of unchanged URLs.
  4. Check duplicate URL lists and investigate unexpected removals.

Limits to understand

  • Sitemap indexes are not expanded. Download the individual urlsets separately.
  • Only URL membership is compared; lastmod, priority and changefreq do not affect the result.
  • Sitemap presence indicates discovery information, not actual search-engine indexing, traffic or ranking.
INSPECT THE SCRIPT

Inspect, copy and run the documented checks.

This browser preview builds code locally. It does not contact an API or run the code.

Default example ready.

Python example
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 OpenViST contributors
"""Compare URLs in two saved sitemap urlsets without crawling the site."""
import argparse
from collections import Counter
import json
from pathlib import Path
import re
import sys
from urllib.parse import urlsplit
import xml.etree.ElementTree as ET

MAX_BYTES = 8 * 1024 * 1024
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"


def read_urls(path):
    with Path(path).open("rb") as stream:
        raw = stream.read(MAX_BYTES + 1)
    if len(raw) > MAX_BYTES:
        raise ValueError("sitemap exceeds 8 MiB")
    text = raw.decode("utf-8-sig")
    if "\x00" in text or re.search(r"<!\s*(?:DOCTYPE|ENTITY)\b", text, re.I):
        raise ValueError("use UTF-8 XML without DTD or entity declarations")
    try:
        root = ET.fromstring(text)
    except ET.ParseError as error:
        raise ValueError("invalid sitemap XML") from error
    if root.tag != NS + "urlset":
        raise ValueError("use a namespaced urlset, not a sitemap index")
    urls, host = Counter(), None
    if len(root) > 50000:
        raise ValueError("sitemap exceeds 50,000 entries")
    for entry in root:
        locations = entry.findall(NS + "loc")
        if entry.tag != NS + "url" or len(locations) != 1:
            raise ValueError("each url must contain exactly one loc")
        url = (locations[0].text or "").strip()
        parsed = urlsplit(url)
        if len(url) > 2048 or re.search(r"\s", url) or parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment:
            raise ValueError("loc must be an absolute HTTP(S) URL without credentials or fragments")
        identity = (parsed.scheme, parsed.hostname, parsed.port)
        if host is None:
            host = identity
        elif identity != host:
            raise ValueError("each sitemap must use one protocol and host")
        urls[url] += 1
    return urls


def compare(before, after):
    old, new = read_urls(before), read_urls(after)
    return {"before_unique": len(old), "after_unique": len(new),
            "added": sorted(new.keys() - old.keys()), "removed": sorted(old.keys() - new.keys()),
            "unchanged": len(old.keys() & new.keys()),
            "duplicates_before": sorted(url for url, count in old.items() if count > 1),
            "duplicates_after": sorted(url for url, count in new.items() if count > 1)}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("before")
    parser.add_argument("after")
    args = parser.parse_args()
    try:
        print(json.dumps(compare(args.before, args.after), ensure_ascii=False, indent=2))
    except (OSError, ValueError, UnicodeError) as error:
        print(f"error: {error}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())
What to verify after running
  • Use fixtures containing one added URL, one removed URL and one unchanged URL.
  • Confirm query strings and duplicate URLs are preserved in comparison.
  • Invalid XML, DTDs, wrong namespaces, credentials, fragments, mixed hosts and large files must fail with a nonzero exit status.

Request or format preview

Illustrative preview. No provider response, price or balance is fetched.

Change a setting to inspect the preview.

Official sources and related profiles

    Code license: MIT. Keep the license notice with your copy.

    Example version 1.0.0 · Documentation reviewed 2026-10-06.

    Executed locally with deterministic fixtures and CLI checks on Python 3.12.14. The three scripts pass 22 tests covering valid output, invalid input and size limits. No network or provider call was made.

    Related browser tools

    Inspect related data locally in your browser. These tools have their own input rules and limits; they do not execute this recipe.