用 Python 检查 Hugo sitemap:XML 命名空间、重复 URL 与必需页面
Hugo 构建成功,新文章却没有出现在 sitemap 中;或者多个源文件用了同一个永久链接,生成了重复 URL。只检查 XML 文件存在,发现不了这些问题。
本文面向已有 Hugo 项目的初级到中级读者,用 Python 标准库检查本地 sitemap 的命名空间、重复地址和必需页面。先用两条 URL 的样例验证,再接入临时构建目录;若还要检查文章里的链接和图片,可以配合站内链接检查教程。
1. 方法:分清页面清单与索引
Hugo 官方 sitemap 文档说明:单语言站点生成页面清单,多语言站点还会在输出根目录生成 sitemap 索引。<urlset> 直接列页面,<sitemapindex> 列的是子清单地址;本文脚本只接受前者,索引里的子文件要显式传入。
Sitemaps 协议要求使用对应命名空间,并在每个 <url> 中提供 <loc>,清单中的页面来自同一主机。这里再约定三条发布检查规则:
- 每个输入清单非空,每条记录恰好有一个纯文本
<loc>。 - 所有 URL 使用
--origin指定的协议和域名(含显式端口),禁止空白、反斜杠与片段;查询参数可以保留。 - 在所有输入文件中按完整字符串检查重复,并要求每个
--require地址至少出现一次。
重复比较不合并百分号编码、查询参数顺序或尾斜杠等不同写法。脚本不递归读取索引,不请求网络,不验证完整 XML Schema、lastmod、hreflang 对应关系或搜索引擎收录情况,也不核对本地 HTML 文件是否存在。它一次解析一个 XML 文件,并保存已见 URL 集合,适合自己构建的中小型站点;不要用于处理不可信上传文件。
2. 准备:创建可重复的样例
使用已有的 Python 3.9 或更新版本,无第三方依赖。命令适用于 Linux 的 bash/zsh;本文用 Python 3.11.4、Hugo 0.164.0 验证。
- 新建目录:
python3 --version
mkdir sitemap-check-demo
cd sitemap-check-demo
- 将下面内容保存为
demo.xml。xhtml:link是翻译链接,脚本只统计<url>的直接<loc>,不会把翻译链接多算一次。
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.org/guide.html</loc>
<xhtml:link rel="alternate" hreflang="en"
href="https://example.org/en/guide.html" />
</url>
<url>
<loc>https://example.org/en/guide.html</loc>
</url>
</urlset>
- 将下一节完整代码保存为同目录的
check_sitemap.py。
3. 实现:带命名空间读取 loc
"""Check local urlset files, exact duplicate URLs, and required pages. Python 3.9+."""
import argparse
import json
from pathlib import Path
import sys
from urllib.parse import urlsplit
import xml.etree.ElementTree as ET
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
def origin_arg(value):
try:
parts = urlsplit(value)
parts.port # Validate a supplied port.
if (parts.scheme not in ("http", "https") or not parts.hostname
or parts.username is not None or parts.password is not None
or parts.path not in ("", "/") or parts.query or parts.fragment
or any(ord(c) < 33 or c.isspace() for c in value)
or "\\" in value or "?" in value or "#" in value):
raise ValueError("expected an HTTP(S) origin with no path or credentials")
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
return parts.scheme, parts.netloc
def check_url(value, origin):
if (not value or any(ord(c) < 33 or c.isspace() for c in value)
or "\\" in value or "#" in value):
raise ValueError(f"empty URL, whitespace, backslash, or fragment: {value!r}")
parts = urlsplit(value)
if (parts.scheme, parts.netloc) != origin or not parts.path.startswith("/"):
raise ValueError(f"URL must use the configured origin and an absolute path: {value!r}")
return value
def collect(paths, origin):
seen = set()
for path in paths:
root = ET.parse(path).getroot()
if root.tag == NS + "sitemapindex":
raise ValueError(f"{path}: sitemap index; pass its child urlset files")
if root.tag != NS + "urlset":
raise ValueError(f"{path}: expected urlset with the sitemap namespace")
if len(root) == 0:
raise ValueError(f"{path}: empty urlset")
for number, entry in enumerate(root, 1):
locations = entry.findall(NS + "loc")
if (entry.tag != NS + "url" or len(locations) != 1
or len(locations[0]) != 0):
raise ValueError(f"{path}: entry {number}: expected url with one text loc")
value = check_url(locations[0].text or "", origin)
if value in seen:
raise ValueError(f"{path}: duplicate URL: {value}")
seen.add(value)
return seen
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("sitemaps", nargs="+", type=Path)
parser.add_argument("--origin", required=True, type=origin_arg)
parser.add_argument("--require", action="append", default=[], metavar="URL")
args = parser.parse_args()
try:
required = {check_url(value, args.origin) for value in args.require}
urls = collect(args.sitemaps, args.origin)
missing = required - urls
if missing:
raise ValueError("missing required URLs: " + ", ".join(sorted(missing)))
except (OSError, ET.ParseError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps({"files": len(args.sitemaps), "required": len(required),
"urls": len(urls)}, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
ElementTree 的命名空间说明指出,解析后标签使用 {命名空间}标签名 的形式。因此直接 findall("url") 可能一个节点也找不到;这里用 NS + "url"、NS + "loc" 精确匹配,缺失命名空间则明确失败。
seen 跨输入文件保留,能发现同一语言清单内的重复,也能发现不同清单之间重复列出的地址。--require 则是你明确列出的上线要求:脚本无法凭 sitemap 本身推断所有应发布的文章。
4. 使用与验证
- 查看帮助,并检查两个必需地址:
python3 check_sitemap.py --help
python3 check_sitemap.py demo.xml --origin https://example.org \
--require https://example.org/guide.html \
--require https://example.org/en/guide.html
预期输出与退出码分别是下面的 JSON 和 0:
{"files": 1, "required": 2, "urls": 2}
- 依次验证重复输入、漏页和域名错误。每次都应输出错误到 stderr、退出
1,stdout 不输出成功 JSON:
python3 check_sitemap.py demo.xml demo.xml --origin https://example.org
printf 'exit=%s\n' "$?"
python3 check_sitemap.py demo.xml --origin https://example.org \
--require https://example.org/missing.html
printf 'exit=%s\n' "$?"
python3 check_sitemap.py demo.xml --origin https://wrong.example.org
printf 'exit=%s\n' "$?"
错误分别包含 duplicate URL、missing required URLs 和 configured origin。非法命令行参数,例如缺少 --origin,由 argparse 返回 2。
- 将下面保存为
check_cases.py,执行python3 check_cases.py。它还检查缺失命名空间、缺失 loc、索引误用和截断 XML,并确认输入未被修改:
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path("check_sitemap.py").resolve()
original = Path("demo.xml").read_text(encoding="utf-8")
ns = ' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'
cases = [
(original, 0, '"urls": 2'),
(original.replace(ns, ""), 1, "sitemap namespace"),
(original.replace("<loc>", "<missing>").replace("</loc>", "</missing>"),
1, "one text loc"),
(original.replace("/en/guide.html</loc>", "/guide.html</loc>"),
1, "duplicate URL"),
('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"/>',
1, "child urlset files"),
('<urlset', 1, "ERROR:"),
]
with tempfile.TemporaryDirectory(prefix="sitemap-tests-") as directory:
path = Path(directory) / "case.xml"
for text, expected, marker in cases:
path.write_text(text, encoding="utf-8")
result = subprocess.run(
[sys.executable, str(script), str(path),
"--origin", "https://example.org"],
capture_output=True, text=True, timeout=5,
)
assert result.returncode == expected, result.stderr
assert marker in (result.stdout if expected == 0 else result.stderr)
if expected:
assert result.stdout == ""
assert path.read_text(encoding="utf-8") == text
print("OK: namespace, loc, duplicates, index, and malformed XML")
预期输出:
OK: namespace, loc, duplicates, index, and malformed XML
5. 接入 Hugo 临时预览
在自己的 Hugo 项目根目录保存检查脚本。以下单语言示例假设 baseURL 为 https://example.org/,并且项目应发布 /guide.html;先把域名和必需页面改为项目的真实值。
preview_root="$(mktemp -d)"
(
trap 'rm -rf -- "$preview_root"' EXIT
hugo --destination "$preview_root/site" &&
python3 check_sitemap.py "$preview_root/site/sitemap.xml" \
--origin https://example.org \
--require https://example.org/guide.html
)
--destination 是 Hugo 的构建输出参数。这里先创建独立目录,构建成功才检查;子 shell 退出时只删除刚创建的预览目录。需要指定配置文件时,在 hugo 后加入项目实际使用的 --config 参数。这个例子仅检查,不执行部署。
多语言项目先打开预览根目录的 sitemap.xml,查看索引中每个 <sitemap><loc> 指向的子文件。在上面子 shell 内,用下面命令替换单语言检查命令;路径只是一个中英站点的示例,应按实际索引调整,且这些语言须共用同一 origin:
python3 check_sitemap.py \
"$preview_root/site/zh-cn/sitemap.xml" \
"$preview_root/site/en/sitemap.xml" \
--origin https://example.org \
--require https://example.org/guide.html \
--require https://example.org/en/guide.html
不要对索引使用 .//loc 后把结果当作文章列表。某语言的 sitemap 位于 /zh-cn/sitemap.xml,也不代表它列出的文章 URL 必须有 /zh-cn/ 前缀。
6. 常见错误与修复
| 错误 | 具体处理 |
|---|---|
sitemap index; pass its child urlset files |
打开索引,把它指向的本地子清单路径传给脚本。脚本不会下载索引里的 URL。 |
expected urlset with the sitemap namespace |
确认传入的是 XML 页面清单;自定义模板须保留协议命名空间。不要删掉检查来掩盖空结果。 |
expected url with one text loc |
检查自定义模板,确保每条记录恰好包含一个直接、纯文本的 loc。 |
duplicate URL |
按报错 URL 搜索源文章的 url 和 permalink 规则,修正冲突后在新目录重新构建;只删输出 XML 不能修复源配置。 |
missing required URLs |
确认必需地址与实际永久链接一致,再按Hugo 文章不显示排查检查草稿和发布时间。 |
configured origin |
检查 baseURL 与 --origin 的协议、主机名、显式端口是否一致,包括 www;不要通过放宽主机检查掩盖错误地址。 |
预览通过后,再走项目已有发布流程,并用curl 部署检查确认真实页面内容。清单里有 URL 不等于线上能访问,也不保证搜索引擎会收录。
7. 总结
用带命名空间的 XML 查询读取页面清单,再跨文件检查重复 URL,可以把模板和永久链接问题提前暴露。把本次必须上线的地址列为 --require,并先验证失败退出码。让检查在独立预览目录通过后,再进行正式发布。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/hugo-sitemap-check-python.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。