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
- Proxy pool manager – pulls a fresh residential IP per domain (or per batch) and optionally sticks it for the whole crawl.
- Sitemap discoverer – reads
robots.txt, follows<link rel="sitemap">tags, and parses sitemap indexes recursively. - Validator – checks XML well‑formedness, schema compliance (sitemap 0.9), URL count limits, and optional
lastmodfreshness. - 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
- Batch domains – run multiple
main.pyprocesses, each with a shard of the target list. - Proxy health checks – periodically
GET https://httpbin.org/ipthrough each proxy; retire those with >5 % failure. - Cache validated sitemaps – store the XML + validation result in Redis keyed by URL hash; TTL 24 h.
- Respect
Crawl-delay– parserobots.txtforCrawl-delay:and sleep accordingly per domain.
Common Pitakes & Quick Fixes
- "Proxy authentication required" (407) – ensure proxy URL includes
user:pass@or useHTTPProxyAuthinrequests. - "SSL certificate verify failed" – residential proxies often present self‑signed certs; set
verify=Falseonly for testing, otherwise add the provider’s CA bundle. - "Too many open files" – increase
ulimit -nor use a connection pool (requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100)). - "Memory spike on huge sitemaps" – stream parse with
lxml.etree.iterparseinstead 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}{