Back to all posts
Automating Sitemap Discovery and Validation with Rotating Proxies

Automating Sitemap Discovery and Validation with Rotating Proxies

October 21, 2026

Why Rotating Proxies for Sitemap Work?

Search engines and large sites often serve different sitemap content based on the requester's IP location. A static IP can hit geo‑blocks, rate limits, or serve a stale cached version. Rotating residential proxies give you:

  • Geo‑targeted coverage – fetch the same sitemap from multiple countries to verify hreflang and regional URLs.
  • IP reputation safety – spread requests across thousands of clean IPs, avoiding bans.
  • Session control – sticky sessions keep cookies and TLS session tickets alive for multi‑step sitemap index traversal.

High‑Level Architecture

  1. Proxy pool manager – pulls a fresh residential IP per domain (or per batch) and optionally sticks it for the whole crawl.
  2. Sitemap discoverer – reads robots.txt, follows <link rel="sitemap"> tags, and parses sitemap indexes recursively.
  3. Validator – checks XML well‑formedness, schema compliance (sitemap 0.9), URL count limits, and optional lastmod freshness.
  4. Result sink – writes a JSON Lines report for downstream SEO tooling.
+----------------+      +-------------------+      +-----------------+
| Proxy Pool     | ---> | Sitemap Discoverer| ---> | Validator       |
| (rotating IP)  |      | (requests + lxml) |      | (xmlschema)     |
+----------------+      +-------------------+      +-----------------+
        |                       |                       |
        v                       v                       v
   Rotate IP            Parse XML              Emit JSONL

Step‑by‑Step Implementation (Python 3.11+)

1. Install Dependencies

pip install requests requests-toolbelt lxml xmlschema tenacity

requests-toolbelt adds HTTPProxyAuth helpers, tenacity gives retry logic, xmlschema validates against the official XSD.

2. Proxy Pool Wrapper

# proxy_pool.py
import random
from typing import Iterator

class RotatingProxyPool:
    """Simple round‑robin pool with optional sticky session per domain."""
    def __init__(self, proxies: list[str], sticky: bool = True):
        self._proxies = proxies
        self._sticky = sticky
        self._domain_map: dict[str, str] = {}
        self._idx = 0

    def get(self, domain: str) -> dict:
        if self._sticky and domain in self._domain_map:
            proxy = self._domain_map[domain]
        else:
            proxy = self._proxies[self._idx % len(self._proxies)]
            self._idx += 1
            if self._sticky:
                self._domain_map[domain] = proxy
        return {"http": proxy, "https": proxy}

    def __iter__(self) -> Iterator[dict]:
        while True:
            yield self.get("default")

3. Robots.txt & Sitemap Discovery

# discover.py
import re
from urllib.parse import urljoin, urlparse
import requests
from lxml import etree
from tenacity import retry, stop_after_attempt, wait_exponential_jitter

SITEMAP_XSD = "https://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"

class SitemapDiscoverer:
    def __init__(self, proxy_pool, timeout: int = 15):
        self.proxy_pool = proxy_pool
        self.timeout = timeout
        self.session = requests.Session()
        self._schema = None  # lazy load

    @retry(wait=wait_exponential_jitter(initial=1, max=8), stop=stop_after_attempt(3))
    def _fetch(self, url: str, domain: str) -> requests.Response:
        proxies = self.proxy_pool.get(domain)
        resp = self.session.get(url, proxies=proxies, timeout=self.timeout)
        resp.raise_for_status()
        return resp

    def _parse_robots(self, base_url: str) -> list[str]:
        domain = urlparse(base_url).netloc
        robots_url = urljoin(base_url, "/robots.txt")
        try:
            resp = self._fetch(robots_url, domain)
        except requests.HTTPError as e:
            if e.response.status_code == 404:
                return []
            raise
        sitemaps = []
        for line in resp.text.splitlines():
            if line.lower().startswith("sitemap:"):
                sitemaps.append(line.split(":", 1)[1].strip())
        return sitemaps

    def _parse_sitemap_index(self, xml_bytes: bytes) -> list[str]:
        root = etree.fromstring(xml_bytes)
        ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
        return [loc.text for loc in root.xpath("//sm:sitemap/sm:loc", namespaces=ns)]

    def _parse_urlset(self, xml_bytes: bytes) -> list[dict]:
        root = etree.fromstring(xml_bytes)
        ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
        urls = []
        for url_elem in root.xpath("//sm:url", namespaces=ns):
            loc = url_elem.findtext("sm:loc", namespaces=ns)
            lastmod = url_elem.findtext("sm:lastmod", namespaces=ns)
            changefreq = url_elem.findtext("sm:changefreq", namespaces=ns)
            priority = url_elem.findtext("sm:priority", namespaces=ns)
            urls.append({
                "loc": loc,
                "lastmod": lastmod,
                "changefreq": changefreq,
                "priority": priority,
            })
        return urls

    def discover(self, start_url: str) -> Iterator[dict]:
        """Yield each URL entry with metadata."""
        domain = urlparse(start_url).netloc
        # 1. robots.txt
        sitemap_urls = self._parse_robots(start_url)
        # 2. common locations
        for guess in ["/sitemap.xml", "/sitemap_index.xml", "/sitemap/sitemap.xml"]:
            sitemap_urls.append(urljoin(start_url, guess))
        # deduplicate
        seen = set()
        queue = list(dict.fromkeys(sitemap_urls))
        while queue:
            sm_url = queue.pop(0)
            if sm_url in seen:
                continue
            seen.add(sm_url)
            resp = self._fetch(sm_url, domain)
            ctype = resp.headers.get("Content-Type", "")
            if "xml" not in ctype:
                continue
            # index or urlset?
            if b"sitemapindex" in resp.content[:200].lower():
                queue.extend(self._parse_sitemap_index(resp.content))
            else:
                for entry in self._parse_urlset(resp.content):
                    entry["source_sitemap"] = sm_url
                    yield entry

4. Schema Validation

# validate.py
import xmlschema
from lxml import etree

class SitemapValidator:
    def __init__(self):
        # download once, cache locally for speed
        self.schema = xmlschema.XMLSchema(SITEMAP_XSD)

    def validate_bytes(self, xml_bytes: bytes) -> tuple[bool, list[str]]:
        try:
            self.schema.validate(xml_bytes)
            return True, []
        except xmlschema.XMLSchemaValidationError as e:
            return False, [str(e)]
        except etree.XMLSyntaxError as e:
            return False, [f"XML syntax error: {e}"]

5. Putting It Together – CLI Script

# main.py
import argparse
import json
import sys
from proxy_pool import RotatingProxyPool
from discover import SitemapDiscoverer
from validate import SitemapValidator


def load_proxies(path: str) -> list[str]:
    with open(path) as f:
        return [line.strip() for line in f if line.strip() and not line.startswith("#")]


def main():
    parser = argparse.ArgumentParser(description="Fetch & validate sitemaps via rotating proxies")
    parser.add_argument("start_url", help="Root URL of the site (e.g. https://example.com)")
    parser.add_argument("--proxy-file", required=True, help="File with one proxy URL per line")
    parser.add_argument("--output", default="sitemap_report.jsonl", help="JSONL output file")
    parser.add_argument("--sticky", action="store_true", help="Keep same proxy per domain")
    args = parser.parse_args()

    proxies = load_proxies(args.proxy_file)
    if not proxies:
        sys.exit("No proxies loaded")

    pool = RotatingProxyPool(proxies, sticky=args.sticky)
    discoverer = SitemapDiscoverer(pool)
    validator = SitemapValidator()

    with open(args.output, "w", encoding="utf-8") as out:
        for entry in discoverer.discover(args.start_url):
            # fetch the individual sitemap file again for validation
            domain = entry["source_sitemap"].split("/")[2]
            resp = discoverer._fetch(entry["source_sitemap"], domain)
            ok, errors = validator.validate_bytes(resp.content)
            record = {
                **entry,
                "valid": ok,
                "validation_errors": errors,
            }
            out.write(json.dumps(record, ensure_ascii=False) + "\n")
    print(f"Report written to {args.output}")

if __name__ == "__main__":
    main()

Run it:

python main.py https://example.com --proxy-file proxies.txt --sticky --output report.jsonl

Handling Edge Cases

Situation Mitigation
Sitemap > 50 k URLs Split processing: the discoverer already follows sitemap indexes; validate each chunk separately.
Compressed responses (gzip, brotli) requests auto‑decompresses; ensure Accept-Encoding header is present (default).
Redirect loops requests follows up to 30 redirects; set allow_redirects=False and handle manually if you need to audit redirect chains.
CAPTCHA on robots.txt Rotate proxy immediately; the tenacity retry with exponential back‑off plus a fresh IP usually clears it.
Mixed HTTP/HTTPS Proxy pool returns both schemes; requests picks the correct one.

Monitoring & Observability

Add a thin wrapper around _fetch to emit Prometheus metrics:

from prometheus_client import Counter, Histogram
FETCH_TOTAL = Counter('sitemap_fetch_total', 'Total fetches', ['result'])
FETCH_LATENCY = Histogram('sitemap_fetch_seconds', 'Fetch latency')

@FETCH_LATENCY.time()
def _fetch(...):
    try:
        resp = ...
        FETCH_TOTAL.labels(result='success').inc()
        return resp
    except Exception as e:
        FETCH_TOTAL.labels(result='error').inc()
        raise

Grafana dashboards can then show success‑rate per proxy geo, latency percentiles, and validation error trends.

Scaling Tips

  1. Batch domains – run multiple main.py processes, each with a shard of the target list.
  2. Proxy health checks – periodically GET https://httpbin.org/ip through each proxy; retire those with >5 % failure.
  3. Cache validated sitemaps – store the XML + validation result in Redis keyed by URL hash; TTL 24 h.
  4. Respect Crawl-delay – parse robots.txt for Crawl-delay: and sleep accordingly per domain.

Common Pitakes & Quick Fixes

  • "Proxy authentication required" (407) – ensure proxy URL includes user:pass@ or use HTTPProxyAuth in requests.
  • "SSL certificate verify failed" – residential proxies often present self‑signed certs; set verify=False only for testing, otherwise add the provider’s CA bundle.
  • "Too many open files" – increase ulimit -n or use a connection pool (requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100)).
  • "Memory spike on huge sitemaps" – stream parse with lxml.etree.iterparse instead of loading whole tree.

Next Steps

  • Extend the validator to check hreflang consistency across regional sitemaps.
  • Integrate with Google Search Console API to push validated URLs for faster indexing.
  • Add change detection – diff successive runs to surface new/removed URLs automatically.

By combining a rotating residential proxy pool with a disciplined discovery‑validation pipeline you gain reliable, geo‑aware sitemap intelligence without triggering anti‑bot defenses. The code above is production‑ready; adapt the proxy source, concurrency model, and reporting format to fit your SEO workflow}{