春江暮客

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

用 Python 检查 Hugo 站内链接和图片:发布前发现缺失文件

2026-10-12 技术
用 Python 检查 Hugo 站内链接和图片:发布前发现缺失文件

Hugo 构建成功了,文章里的旧地址或图片路径却可能仍然写错。上传后才发现 404,需要再改稿、重建和同步。本文用一个 Python 标准库脚本,先检查构建目录里是否存在链接对应的文件,再决定是否发布。

这适合已有 Hugo 项目的初级到中级读者。可以把检查加入 Hugo 自动部署流程,发布后再按 curl 网站检查教程 验证真实 HTTP 响应。

1. 方法:检查构建产物

脚本提取 HTML 中的 a[href] 和 img[src],把站内 URL 映射到输出目录:/guide/ 对应 guide/index.html,/post.html 对应 post.html。同一主机名的绝对 URL 也会检查,查询参数和片段不参与文件查找。

检查范围是部署在域名根路径的静态站点,主机名由 --host 指定;同一主机不同端口也按本地文件处理。外部域名、mailto:、data: 和纯 #fragment 链接跳过。它不验证锚点、外链可用性、图片解码、CSS 中的 URL、srcset 或 JavaScript 懒加载属性,也不模拟服务器重写规则。遇到 <base href> 会报错,避免相对路径被错误解释;子路径部署需要另行调整映射。

2. 准备环境

需要 Python 3.10 或更新版本,不需要 pip 包。下面的命令适用于 Linux 或 macOS 的 bash/zsh;本文脚本在 Linux、Python 3.11.4 下验证,构建使用 Hugo 0.164.0。

将后面的完整代码保存为项目根目录的 check_local_links.py,也可以下载 check_local_links.py。先检查工具和参数:

python3 --version
hugo version
python3 check_local_links.py --help

3. 实现:保存检查脚本

"""Check a[href] and img[src] in a root-hosted static site. Python 3.10+."""
import argparse
from html.parser import HTMLParser
from pathlib import Path
import sys
from urllib.parse import quote, unquote, urljoin, urlsplit


class References(HTMLParser):
    def __init__(self):
        super().__init__()
        self.items = []
        self.has_base = False

    def handle_starttag(self, tag, attrs):
        attrs = dict(attrs)
        if tag == "base" and "href" in attrs:
            self.has_base = True
        key = {"a": "href", "img": "src"}.get(tag)
        if key and attrs.get(key):
            self.items.append((self.getpos()[0], attrs[key].strip()))


def local_target(root, page, raw, host):
    if not raw or raw.startswith("#"):
        return None
    base = "https://" + host + "/" + quote(page.relative_to(root).as_posix())
    url = urlsplit(urljoin(base, raw))
    if url.scheme not in {"http", "https"} or url.hostname != host:
        return None
    path = unquote(url.path, errors="strict")
    if "\x00" in path or "\\" in path:
        raise ValueError("unsupported path character")
    target = (root / path.lstrip("/")).resolve()
    if not target.is_relative_to(root):
        raise ValueError("target leaves output directory")
    if target.is_dir() or path.endswith("/"):
        target = (target / "index.html").resolve()
    if not target.is_relative_to(root):
        raise ValueError("target leaves output directory")
    return target


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("root", type=Path, help="Hugo output directory")
    parser.add_argument("--host", required=True, help="site hostname, without scheme or port")
    parser.add_argument("--page", action="append", help="HTML file relative to root; repeatable")
    args = parser.parse_args()
    root = args.root.resolve()
    host = args.host.lower()
    if not root.is_dir() or any(c in host for c in "/:@") or not host:
        parser.error("provide an existing root and a hostname without scheme or port")
    try:
        pages = sorted(set((root / p).resolve() for p in args.page)) if args.page else sorted(set(p.resolve() for p in root.rglob("*.html")))
        if not pages:
            raise ValueError("no HTML pages found")
        if any(not p.is_relative_to(root) or p.suffix != ".html" or not p.is_file() for p in pages):
            raise ValueError("each --page must be an existing HTML file inside root")
        checked = problems = 0
        for page in pages:
            refs = References()
            refs.feed(page.read_text(encoding="utf-8"))
            refs.close()
            if refs.has_base:
                raise ValueError(f"{page.relative_to(root)}: <base href> is unsupported")
            for line, raw in refs.items:
                try:
                    target = local_target(root, page, raw, host)
                except (ValueError, OSError) as exc:
                    problems += 1
                    print(f"INVALID {page.relative_to(root)}:{line} {raw!r}: {exc}")
                    continue
                if target is None:
                    continue
                checked += 1
                if not target.is_file():
                    problems += 1
                    print(f"MISSING {page.relative_to(root)}:{line} {raw!r} -> {target.relative_to(root)}")
        print(f"pages={len(pages)} checked={checked} problems={problems}")
        return 1 if problems else 0
    except (OSError, UnicodeError, ValueError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

代码使用 HTMLParser 读取标签属性和行号,便于定位问题。用 urljoin、urlsplit 和 unquote 处理相对地址、URL 组成部分和百分号编码。解析后再检查文件路径是否仍在输出目录内。

退出码约定: 0 表示检查范围内未发现问题;1 表示有缺失文件或无效路径;2 表示参数、读取或不支持的页面结构有问题。输出中的 checked 是站内引用次数,不是去重后的文件数量。

4. 验证:先制造一张缺失图片

  1. 在同一个终端运行下面的独立实验。临时目录里只有首页和 Guide 页面,故意不创建图片:
demo_dir=$(mktemp -d)
mkdir -p "$demo_dir/guide"
printf '<h1>Guide</h1>\n' > "$demo_dir/guide/index.html"
cat > "$demo_dir/index.html" <<'HTML'
<a href="/guide/">Guide</a>
<a href="https://example.test/guide/?from=home#intro">Same host</a>
<a href="https://docs.python.org/3/">External</a>
<img src="/logo.svg" alt="Demo logo">
HTML
python3 check_local_links.py "$demo_dir" --host example.test
printf 'exit=%s\n' "$?"

预期输出如下。脚本应返回 1;这是故意制造的失败,不要把本组实验放进 set -e 脚本:

MISSING index.html:4 '/logo.svg' -> logo.svg
pages=2 checked=3 problems=1
exit=1
  1. 补上图片,再运行检查:
cat > "$demo_dir/logo.svg" <<'SVG'
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
  <circle cx="16" cy="16" r="12" fill="teal"/>
</svg>
SVG
python3 check_local_links.py "$demo_dir" --host example.test
printf 'exit=%s\n' "$?"

这次应得到:

pages=2 checked=3 problems=0
exit=0

/guide/ 和带查询参数、片段的绝对 URL 都指向同一个本地文件,外部 Python 文档地址没有产生网络请求。

5. 接入 Hugo 发布前检查

  1. 在 Hugo 项目根目录新建临时输出目录,再构建和检查。下面以本站主机名为例,使用自己的站点时改成自己的主机名:
preview_dir=$(mktemp -d)
hugo --destination "$preview_dir" &&
python3 check_local_links.py "$preview_dir" --host www.bobobk.com

hugo --destination 指定输出目录;全新的目录避免旧产物掩盖已经删除的页面。项目使用单独配置文件时,在 Hugo 命令中加上实际的 --config 参数。正常发布检查不加 --buildDrafts,以免草稿文件让链接误判为可用。

  1. 老站点已有大量历史问题时,可先指定本次修改的页面。下面的文件路径是本文的中英文输出文件,参数相对于构建目录:
python3 check_local_links.py "$preview_dir" --host www.bobobk.com \
  --page hugo-local-link-check-python.html \
  --page en/hugo-local-link-check-python.html

--page 只限制读取哪些 HTML 文件,链接目标仍从整个构建目录查找,也会检查这些页面的导航、相关文章和缩略图。不要把局部检查通过当成全站检查通过。

  1. 只有检查命令返回 0,才继续原有同步或发布步骤。在自动化脚本中用 set -e 或 && 保留失败状态;需要 Python 调用外部工具时,参考 subprocess 超时与错误处理。上线后仍需请求公开 URL,检查状态码和正文;本地文件存在不能证明服务器已经更新。

6. 排障:按报告修复

现象 操作
MISSING ... '/post.html' 核对文章 front matter 的 url,以及是否因 draft: true 或未来日期未生成。
图片缺失 把 /images/logo.svg 对应的文件放到 static/images/logo.svg,然后重新构建到新目录。
中文路径或带空格的路径报缺失 对照解码后的文件名,检查大小写和实际输出;Linux 通常区分大小写。
no HTML pages found 检查构建是否成功,以及传入的是否是本次 Hugo 输出目录。
--page 报错 使用构建目录内的实际 .html 文件路径;guide/ 应写为 guide/index.html。
<base href> 或服务器动态路由 本脚本不支持这种映射;改用了解部署规则的检查器,并验证实际 HTTP 响应。

修复 Markdown、front matter 或 static/ 源文件后,重新创建预览目录并运行构建和检查。只修改临时输出文件,下一次构建还会重现同样的问题。

7. 总结

用全新的 Hugo 输出目录配合 Python 检查,能在发布前发现站内文章和图片的缺失文件。先用故意缺图的实验确认失败状态会传出来,再把检查放在发布步骤之前。发布后继续检查公开页面的 HTTP 状态和实际内容,形成完整的验证流程。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor automation batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena copy cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization deployment desktop-app devtools disk-space disown docker dovecot download electron esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp ffmpeg file-io flashppi flask folium frontend game generator git github-actions google google-research grep harness hls html http hugo indexnow javascript jev json just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp mirror mmcif model-evaluation mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pdb pep-723 phaser pillow pip postfix preprocessing print 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 sklearn solana somaticsignatures spl sqlite ssh standardize static-site subprocess sysadmin system-one tensorflow tkinter tron tronpy troubleshooting turtle typesafe-ai ubuntu usdt uv vhhbert vite webp wordcloud wordpress workflow yaml 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它