春江暮客

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

Check Hugo Internal Links and Images with Python Before Publishing

2026-10-12 Technology
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

  1. 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
  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

  1. 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.

  1. 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.

  1. Continue with your existing sync or publishing command only after the checker returns 0. Use set -e or && 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.

标签

1024 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation background backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn checkpoint cli cloudflare codex cofoldarena copy cpu monitoring 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 economics electron encoding esm esm2 esm3 esmc esmfold2 fable faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo imports indexnow 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 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 phaser pillow pip postfix preprocessing print probability 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 seo sequence-identity sha256 shell signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tensorflow tkinter tls toml trade tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode usdt utf-8 uv vhhbert vite wallet webp wordcloud wordpress workflow yaml zipfile zoneinfo 迅雷解析

友情链接

其它