Sitemap 为爬虫提供一份明确的候选网址清单,适合帮助发现新内容或普通导航中较深的文档。这份列表不会绕过登录、授予抓取权限、保证搜索收录,也不能承诺某天一定重新访问。应把它视为计划公开文档的发布清单,并让清单与规范网址及实际发布状态保持一致。文件成功上传,只能说明某一步交付发生了,不能替代后续抓取和收录证据。
从发布清单生成
使用与公开页面相同的权威内容筛选规则,排除草稿、私有文档和多余的跟踪参数变体。源码目录里存在一个文件,不代表它已经对应公开 URL;反过来,分页、翻译文档或生成路由,也未必与源文件逐一对应。因此需要查看生成器实际输出,而不是仅凭目录列表猜测发布范围。内容筛选与 sitemap 生成相互独立维护,容易出现遗漏或提前暴露未发布入口。
XML sitemap 使用声明的命名空间,以及包含网址条目的 urlset,每个 loc 提供完整网址。XML 转义与 URL 编码解决不同问题:分隔查询参数的与号,需要在 XML 文本里转义,而网址本身的字符也必须符合相应 URI 表示方式。生成后应重新解析文件,确认经过两层处理,仍然得到原本希望发布的网址,不能因为 XML 看起来能打开就省略这一检查。
还要遵守格式的范围与大小条件。通常应列出相应主机及允许位置范围内的网址,受支持的跨站提交安排有额外规则。每份 sitemap 最多五万个网址,未压缩内容不得超过 52,428,800 字节。gzip 可以减少传输量,却不能取消解压后的上限。更大的清单应拆分并使用适当索引,不能为了让文件通过检查而悄悄截断后面的页面。
这份清单本身也是公开数据。私有文档没有出现在导航里,并不意味着它的网址可以放心写入公开 sitemap。发布前应检查生成 XML 是否暴露了不该公开的入口,同时在资源处独立执行访问控制。爬虫发现偏好与权限控制是不同机制,清单筛选不能替代真正的授权检查。
坏掉的样子
XML 合法的文件,仍可能列出跳转、缺失页面或不应进入索引的内容。应同时验证目标与语法,并让目标上的 canonical 声明符合预期清单。某个地址返回二百,却只有登录界面或通用错误页,不能证明列出的文档真正可用。Sitemap 自身成功交付,与其描述的页面是否有用,需要分别检查,不能在一个状态码上完成所有判断。
修改时间也可能失真。提供 lastmod 时,应依据页面有意义的变化,而不是每次构建都刷新所有条目的时间。Google 明确说明,它会使用持续准确的 lastmod,并忽略 priority 和 changefreq。这些字段不是调度接口,把所有页面都标成最高优先级,也不能命令爬虫增加访问。没有可信历史依据时,省略可选字段比编造精确时间更诚实。
Sitemap 不能让私有页面自动公开,也不会覆盖 robots 限制。提交之后,搜索引擎未必选择相同的规范网址,未必读取每一项,更不保证全部进入结果。普通导航仍然应该可用,后续结果则需要依据实际抓取或索引报告判断。不能把上传成功写成发现和收录已经完成,否则发布记录与外部系统的真实状态就会混在一起。
快速验证
保存为 sitemap.py,执行 python3 sitemap.py。标准库实验提供生成的 XML sitemap,以及一个很小的本地发布清单。三个条目公开,其中一个包含两个查询参数;草稿被排除。生成的网址只指向临时回环源站,验证器在请求目标之前先检查范围,因此不会顺着输入访问无关外部主机。这里的清单内容全部是演示数据。
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urlsplit
import json
import xml.etree.ElementTree as ET
namespace = 'http://www.sitemaps.org/schemas/sitemap/0.9'
ET.register_namespace('', namespace)
pages = {'/article': True, '/zh/article': True, '/report?year=2026®ion=west': True, '/draft': False}
state = {'bad_entry': False}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/sitemap.xml':
root = ET.Element(f'{{{namespace}}}urlset')
paths = [path for path, public in pages.items() if public]
if state['bad_entry']:
paths.append('/missing')
for path in paths:
item = ET.SubElement(root, f'{{{namespace}}}url')
ET.SubElement(item, f'{{{namespace}}}loc').text = f'http://127.0.0.1:{self.server.server_port}{path}'
status, data, kind = 200, ET.tostring(root, encoding='utf-8', xml_declaration=True), 'application/xml'
else:
status = 200 if pages.get(self.path) else 404
data, kind = (b'published page' if status == 200 else b'missing'), 'text/plain'
self.send_response(status)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
def fetch(path):
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
connection.request('GET', path)
response = connection.getresponse()
return response.status, response.read()
finally:
connection.close()
def validate(data):
root = ET.fromstring(data)
assert root.tag == f'{{{namespace}}}urlset'
locations = [item.text for item in root.findall(f'{{{namespace}}}url/{{{namespace}}}loc')]
assert len(locations) == len(set(locations))
for location in locations:
parsed = urlsplit(location)
assert (parsed.scheme, parsed.hostname, parsed.port) == ('http', '127.0.0.1', server.server_port)
path = parsed.path + ('?' + parsed.query if parsed.query else '')
status, _ = fetch(path)
if status != 200:
raise ValueError(f'listed URL returned {status}: {path}')
return locations
status, data = fetch('/sitemap.xml')
assert status == 200 and b'&' in data
locations = validate(data)
assert len(locations) == 3 and all('/draft' not in value for value in locations)
assert any('?year=2026®ion=west' in value for value in locations)
state['bad_entry'] = True
_, invalid = fetch('/sitemap.xml')
try:
validate(invalid)
except ValueError as error:
rejection = str(error)
else:
raise AssertionError('missing destination accepted')
print(json.dumps({'public_urls': len(locations), 'query_round_trip': True,
'draft_excluded': True, 'invalid_destination_rejected': rejection,
'indexing_tested': False}))
finally:
server.shutdown()
thread.join()
server.server_close()
第一份 sitemap 在正确命名空间中包含三个不重复 loc。原始 XML 将查询分隔符写成 &,解析后则恢复成网址中预期的与号。三个目标均返回二百,草稿没有进入清单。示例没有填入可选修改日期,因为它不存在能够支持这些日期的发布历史来源。验证输出保留各项结论,而不是仅记录解析器没有报错。
随后实验加入一个缺失目标,但 sitemap 仍是合法 XML,获取它的 HTTP 状态也仍为二百。第二次验证会在列出的地址返回四零四时失败,说明语法检查不能证明清单有效。验证器报告问题,而不是在检查过程中静默删掉坏条目,避免把错误隐藏成已经通过的发布结果。清单修正应当回到生成输入完成。
实验没有联系搜索引擎,小验证器也没有实现全部协议限制和索引规则。它验证的是所列案例的生成、转义、命名空间、唯一性、本地范围及目标可达性。真正发布之前,还应把 sitemap 与完整的计划公开清单比较,并在对外声明的网址读取已发布文件。这样建立的是实际交付证据,不是对爬虫后续选择的控制承诺。
来源
- Sitemaps.org protocol,XML 结构、网址编码、范围及大小限制,已核验 2026-09-13。
- Google: build and submit a sitemap,规范网址、有意义的 lastmod 及不采用的提示字段,已核验 2026-09-13。