Sitemaps and Feeds
On this page
The Structured Doors Into a Site#
Before writing a single selector, check whether the site publishes a machine-readable map of itself. Most content sites do, because search engines use these files. Two formats dominate: XML sitemaps, which list URLs with optional metadata, and RSS/Atom feeds, which list the newest content. Both turn "crawl the whole site to discover pages" into "read the index", which is faster for you and far lighter for the server. Use them first and crawl second.
| Source | Answers | Typical size | Best for |
|---|---|---|---|
| Sitemap | What pages exist, and when did they change? | Up to 50,000 URLs per file | Full inventories, incremental re-crawls |
| Sitemap index | Which sitemaps exist? | Up to 50,000 sitemaps | Large sites split by section or date |
| RSS / Atom feed | What is new? | Usually the latest 10 to 100 items | Detecting new content quickly |
| Homepage crawl | Whatever is linked | Unbounded | Last resort when neither exists |
Finding Them#
Check in this order:
Sitemap:lines in robots.txt. There can be several, and they may point to other hosts (for example a CDN). Python's standard library parses them:urllib.robotparser.RobotFileParserhas asite_maps()method.- Conventional paths:
/sitemap.xml,/sitemap_index.xml,/sitemap.xml.gz. Content management systems have their own conventions, so a 404 on one does not mean the others are missing. - Feed links in the HTML head:
<link rel="alternate" type="application/rss+xml" href="...">ortype="application/atom+xml". Section and category pages often advertise their own narrower feeds. - Conventional feed paths:
/feed,/rss,/atom.xml,/index.xml.
import httpx
from lxml import html as lh
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser
client = httpx.Client(timeout=20.0, follow_redirects=True,
headers={"User-Agent": "example-research-bot/1.0 (contact@example.org)"})
def discover(base):
rp = RobotFileParser()
rp.parse(client.get(urljoin(base, "/robots.txt")).text.splitlines())
sitemaps = rp.site_maps() or [urljoin(base, "/sitemap.xml")]
head = lh.fromstring(client.get(base).content)
feeds = [urljoin(base, h) for h in head.xpath(
'//link[@rel="alternate"][contains(@type,"rss") or contains(@type,"atom")]/@href')]
return rp, sitemaps, feeds
Keep the RobotFileParser object. A sitemap lists URLs the site wants search engines to find, but robots.txt rules still apply to you, and occasionally a sitemap contains URLs that robots.txt disallows. Check rp.can_fetch(user_agent, url) before fetching each one (see robots and ethics).
Parsing Sitemaps Correctly#
A sitemap is either a <urlset> of <url> entries or a <sitemapindex> of <sitemap> entries pointing to more sitemaps. Both use the namespace http://www.sitemaps.org/schemas/sitemap/0.9, and forgetting the namespace is the most common reason a sitemap parser returns nothing.
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemaps/products-1.xml.gz</loc>
<lastmod>2026-09-28T04:10:00+00:00</lastmod>
</sitemap>
</sitemapindex>
A parser that handles both types, gzip compression, and nesting:
import gzip
from lxml import etree
NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
SAFE_PARSER = etree.XMLParser(resolve_entities=False, no_network=True, huge_tree=False)
def fetch_xml(url):
body = client.get(url).content
if body[:2] == b"\x1f\x8b": # gzip magic bytes
body = gzip.decompress(body)
return etree.fromstring(body, parser=SAFE_PARSER)
def walk_sitemap(url, seen=None, depth=0):
seen = seen if seen is not None else set()
if url in seen or depth > 5:
return
seen.add(url)
root = fetch_xml(url)
tag = etree.QName(root).localname
if tag == "sitemapindex":
for loc in root.xpath("sm:sitemap/sm:loc/text()", namespaces=NS):
yield from walk_sitemap(loc.strip(), seen, depth + 1)
elif tag == "urlset":
for u in root.xpath("sm:url", namespaces=NS):
yield {
"loc": u.findtext("sm:loc", namespaces=NS).strip(),
"lastmod": u.findtext("sm:lastmod", namespaces=NS),
}
for entry in walk_sitemap("https://example.com/sitemap_index.xml"):
print(entry)
Why the non-obvious lines are there:
- Checking gzip magic bytes instead of the file extension. Some servers send
.xml.gzfiles withContent-Encoding: gzip, in which casehttpxhas already decompressed them; others send raw gzip bytes. Checking the first two bytes handles both. seenanddepth. Misconfigured sites have indexes that reference themselves. Without the guard, the walker recurses forever.QName(root).localnamestrips the namespace, so the type check works regardless of prefix.- The safe parser. Sitemaps are untrusted XML from the internet. Disabling entity resolution and network access protects against XML entity attacks such as "billion laughs" and external entity reads. The
defusedxmlpackage offers the same protection for the standard library'sElementTree.
The protocol limits each sitemap file to 50,000 URLs and 50 MB uncompressed, so large sites always use indexes. For very large files, etree.iterparse streams entries instead of loading the whole tree, which keeps memory flat.
Treat lastmod as a Hint#
lastmod is the site telling you when a URL last changed, and it powers incremental scraping: fetch only URLs whose lastmod is newer than your last run.
from datetime import datetime, timezone
def parse_lastmod(value):
if not value:
return None
value = value.strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(value) # handles 2026-09-28 and full timestamps
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
last_run = datetime(2026, 9, 20, tzinfo=timezone.utc)
changed = [e for e in walk_sitemap(index_url)
if (lm := parse_lastmod(e["lastmod"])) is None or lm > last_run]
Entries without lastmod are included, because unknown means "might have changed". Be sceptical of the values themselves: some sites set every lastmod to the time the sitemap was generated, which makes every URL look new on every run. If more than, say, 90 percent of entries changed since yesterday, the field is probably meaningless for that site. Fall back to comparing content hashes, or to conditional requests with ETag and If-Modified-Since. The <changefreq> and <priority> fields are optional hints that search engines themselves largely ignore; do not schedule around them.
Sitemap extensions add useful data for free. Image, video, and news sitemaps use additional namespaces (for example <image:loc> or <news:publication_date>), giving you media URLs and publication dates without fetching the page.
Feeds: The Change Stream#
RSS and Atom exist so readers can follow a site without re-scanning it. The feedparser library handles both formats, broken XML, and the many date formats in the wild:
import feedparser
resp = client.get("https://example.com/feed.xml")
feed = feedparser.parse(resp.content)
if feed.bozo:
print("feed is malformed:", feed.bozo_exception) # often still usable
for entry in feed.entries:
print(entry.get("id") or entry.link, entry.title, entry.get("published", ""))
Notes from practice:
- Fetch with your own client, then parse the bytes.
feedparser.parse(url)can fetch by itself, but using your HTTP client keeps timeouts, headers, proxies, and rate limits consistent with the rest of the scraper. bozois set for feeds with any XML error. feedparser still returns what it could parse, so log it rather than discarding the feed.- Deduplicate on the entry
id(Atom) orguid(RSS) where present, not on the title, which gets edited after publication. feedparser exposes both asentry.id. - Poll with conditional requests. Send
If-None-Matchwith the feed's lastETag. A304means nothing is new and costs the server almost nothing, which makes frequent polling acceptable.
A feed only shows the latest N items. If you poll less often than the site publishes N items, you will miss entries. Measure the publishing rate from the timestamps in two consecutive fetches and set the polling interval well below the time it takes to fill the feed.
Combining Sources#
A robust design uses each source for what it does best: the feed for fast discovery of new items, the sitemap for periodic completeness checks and change detection, and a link crawl only for sections neither covers. Reconcile the three: URLs that appear in your crawl but not in the sitemap are worth logging, as they often reveal sections the site forgot to include. A site without a sitemap or feed is not a red flag by itself, but it does mean discovery falls back to the crawl frontier, which costs both you and the server more.
Practice#
For a site you are allowed to scrape, list every sitemap reachable from its robots.txt, count URLs per child sitemap, and compute what percentage have a lastmod within the last 24 hours. Then fetch its main feed twice, an hour apart, with a conditional request the second time, and record whether you got a 304.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.