春江暮客

春江暮客的个人学习分享网站

用 Python 检查 Hugo sitemap:XML 命名空间、重复 URL 与必需页面

2026-10-27 技术
用 Python 检查 Hugo sitemap:XML 命名空间、重复 URL 与必需页面

Hugo 构建成功,新文章却没有出现在 sitemap 中;或者多个源文件用了同一个永久链接,生成了重复 URL。只检查 XML 文件存在,发现不了这些问题。

本文面向已有 Hugo 项目的初级到中级读者,用 Python 标准库检查本地 sitemap 的命名空间、重复地址和必需页面。先用两条 URL 的样例验证,再接入临时构建目录;若还要检查文章里的链接和图片,可以配合站内链接检查教程。

1. 方法:分清页面清单与索引

Hugo 官方 sitemap 文档说明:单语言站点生成页面清单,多语言站点还会在输出根目录生成 sitemap 索引。<urlset> 直接列页面,<sitemapindex> 列的是子清单地址;本文脚本只接受前者,索引里的子文件要显式传入。

Sitemaps 协议要求使用对应命名空间,并在每个 <url> 中提供 <loc>,清单中的页面来自同一主机。这里再约定三条发布检查规则:

  1. 每个输入清单非空,每条记录恰好有一个纯文本 <loc>。
  2. 所有 URL 使用 --origin 指定的协议和域名(含显式端口),禁止空白、反斜杠与片段;查询参数可以保留。
  3. 在所有输入文件中按完整字符串检查重复,并要求每个 --require 地址至少出现一次。

重复比较不合并百分号编码、查询参数顺序或尾斜杠等不同写法。脚本不递归读取索引,不请求网络,不验证完整 XML Schema、lastmod、hreflang 对应关系或搜索引擎收录情况,也不核对本地 HTML 文件是否存在。它一次解析一个 XML 文件,并保存已见 URL 集合,适合自己构建的中小型站点;不要用于处理不可信上传文件。

2. 准备:创建可重复的样例

使用已有的 Python 3.9 或更新版本,无第三方依赖。命令适用于 Linux 的 bash/zsh;本文用 Python 3.11.4、Hugo 0.164.0 验证。

  1. 新建目录:
python3 --version
mkdir sitemap-check-demo
cd sitemap-check-demo
  1. 将下面内容保存为 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>
  1. 将下一节完整代码保存为同目录的 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. 使用与验证

  1. 查看帮助,并检查两个必需地址:
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}
  1. 依次验证重复输入、漏页和域名错误。每次都应输出错误到 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。

  1. 将下面保存为 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,并先验证失败退出码。让检查在独立预览目录通过后,再进行正式发布。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它