Check Hugo Internal Links and Images with Python Before Publishing
Hugo finishes building, yet an old article URL or image path can still be wrong. Finding the 404 after uploading means another edit, build, and sync. This tutorial uses a Python standard-library script to check whether linked files exist in the build directory before publishing.
It is aimed at beginner-to-intermediate readers with an existing Hugo project. Add the check to your Hugo deployment workflow, then verify real HTTP responses with the curl website checking tutorial after publishing.
1. Method: check the build output
The script extracts a[href] and img[src] from HTML and maps internal URLs to output files: /guide/ maps to guide/index.html, and /post.html maps to post.html. Absolute URLs with the configured hostname are checked too. Queries and fragments do not affect file lookup.
The scope is a static site hosted at the domain root, with its hostname supplied through --host; other ports on that hostname also map to local files. External hosts, mailto:, data:, and pure #fragment references are skipped. It does not validate anchors, external availability, image decoding, CSS URLs, srcset, or JavaScript lazy-loading attributes, and it does not emulate server rewrites. A <base href> causes an error to avoid misinterpreting relative paths. Subdirectory hosting needs a different mapping.
2. Set up the environment
Use Python 3.10 or newer; no pip packages are needed. The shell commands assume bash/zsh on Linux or macOS. The script was tested on Linux with Python 3.11.4, and the build used Hugo 0.164.0.
Save the full code below as check_local_links.py in your project root, or download check_local_links.py. Check the tools and CLI options first:
python3 --version
hugo version
python3 check_local_links.py --help
3. Implementation: save the checker
"""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())
The code uses HTMLParser to collect attributes and line numbers for troubleshooting. urljoin, urlsplit, and unquote handle relative addresses, URL components, and percent encoding. The resolved filesystem path must remain inside the output directory.
Exit codes: 0 means no problems were found within the selected scope; 1 means a missing file or invalid path; 2 means an argument, reading, or unsupported page-structure error. The checked counter counts internal references, rather than unique files.
4. Validation: deliberately omit an image
- Run this isolated experiment in one terminal. The temporary directory has a homepage and a Guide page, but deliberately lacks the image:
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' "$?"
Expected output follows. Exit status 1 is intentional; do not place this experiment in a set -e script:
MISSING index.html:4 '/logo.svg' -> logo.svg
pages=2 checked=3 problems=1
exit=1
- Add the image and run the check again:
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' "$?"
This time, expect:
pages=2 checked=3 problems=0
exit=0
Both /guide/ and the absolute URL with a query and fragment resolve to the same local file. The external Python documentation link produces no network request.
5. Add the check before Hugo publishing
- From your Hugo project root, create a temporary output directory, build, and check. The example uses this site’s hostname; replace it with your own hostname for your project:
preview_dir=$(mktemp -d)
hugo --destination "$preview_dir" &&
python3 check_local_links.py "$preview_dir" --host www.bobobk.com
hugo --destination chooses the output directory. A fresh directory prevents stale output from hiding deleted pages. If your project uses a separate configuration file, add its actual --config argument to the Hugo command. Omit --buildDrafts for a normal publishing check so draft output cannot make a link appear available.
- An older site may have many historical issues. You can start by selecting only the changed pages. These are this tutorial’s Chinese and English output files; paths are relative to the build directory:
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 only limits which HTML files are read. Targets are still looked up throughout the build directory, and navigation, related articles, and featured images on those pages are checked too. Passing a selected-page check does not imply that the entire site passes.
- Continue with your existing sync or publishing command only after the checker returns 0. Use
set -eor&&in automation to preserve failure status. For calling tools from Python, see subprocess timeouts and error handling. After publishing, request the public URLs and inspect status codes and content; local existence cannot prove that the server has updated.
6. Troubleshooting: fix the reported source
| Symptom | Action |
|---|---|
MISSING ... '/post.html' |
Check the article’s front matter url, and whether draft: true or a future date prevented generation. |
| Missing image | Put the file for /images/logo.svg at static/images/logo.svg, then rebuild into a fresh directory. |
| Missing Chinese or space-containing path | Compare the decoded filename, its case, and the actual output; Linux filesystems commonly distinguish case. |
no HTML pages found |
Check that the build succeeded and that the supplied directory is this build’s output. |
--page error |
Supply an existing .html file inside the build directory; use guide/index.html for guide/. |
<base href> or server-side dynamic routes |
This mapping is unsupported; use a checker that understands your deployment rules and verify real HTTP responses. |
After fixing Markdown, front matter, or a static/ source file, create a fresh preview directory and repeat the build and check. Editing only a temporary output file leaves the original problem for the next build.
7. Summary
A fresh Hugo output directory and a Python check can reveal missing internal pages and images before publishing. Use the deliberately missing image to confirm that failures produce a nonzero status, then put the check before deployment. Follow publishing with HTTP status and content checks on the public pages to complete the verification.
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/en/hugo-local-link-check-python.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
相关文章
- Troubleshoot Linux Scheduled Jobs: Python Paths, Working Directories, and Environment
- Beginner Guide: Auto-build and Deploy a Hugo Site with GitHub Actions
- Rotate Python Logs by Size with RotatingFileHandler
- Run Commands from Python: Timeouts, Errors, and Log Files
- Save JSON Files Safely with Atomic Writes in Python