用 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. 验证:先制造一张缺失图片
- 在同一个终端运行下面的独立实验。临时目录里只有首页和 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
- 补上图片,再运行检查:
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 发布前检查
- 在 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,以免草稿文件让链接误判为可用。
- 老站点已有大量历史问题时,可先指定本次修改的页面。下面的文件路径是本文的中英文输出文件,参数相对于构建目录:
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 文件,链接目标仍从整个构建目录查找,也会检查这些页面的导航、相关文章和缩略图。不要把局部检查通过当成全站检查通过。
- 只有检查命令返回 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 状态和实际内容,形成完整的验证流程。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/hugo-local-link-check-python.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。