RSSAmplifier

BurgeonLab: Full-text · Dec 12, 2025

How to Find Orphan Pages on Static Sites: Python Checker

0
Sign in to vote or save

Naty S · BurgeonLab

This blog goes through the process of creating a Python script to find orphan pages (pages without internal links) on a website, Hugo static site files in this case. The script extracts all links from the site and compares it with the full list of pages to get pages which are orphaned.

Initial Experience with SEO

I learnt about orphan pages when starting my blogging journey in 2022 as a novice using WordPress. I focused too much on learning search engine optimization (SEO) back then, killing my motivation to create content. Now, I write freely without being hindered by SEO plugins or keyword research. Leaving WordPress for Hugo has removed the pressure to produce “good SEO content” (whatever that implies); and it feels great! This is especially true with the support I’ve had from the small web and IndieWeb communities. No more nagging WordPress SEO popups, reminders to do “keyword research”, or having a list of SEO tasks to be completed that doesn’t involve actually writing!

What I Actually Care About

But while I don’t actively pursue keyword research for my personal sites (as I’m not a commercial entity), I do focus and put thought into making the user experience and backend on my sites (especially BurgeonLab) as optimized and inclusive as possible by:

  • Ensuring good navigation and web accessibility
  • Using concise headings
  • Internally linking relevant content and finding orphan pages
  • Optimizing images for page performance
  • Utilizing correct meta tags in the baseof.html and single.html Hugo templates
  • Finding and replacing broken links
  • Using a single canonical URL per piece of content with 301 redirects if URLs have changed
  • Implementing good security practices

What Are Orphan Pages

Orphan pages occur when there are pages that have no links pointing to them originating from your own site, i.e., no internal links. In WordPress (and I assume other blogging platforms), SEO plugins will usually have an indicator for “orphaned” content/pages/posts. But for static site generators like Hugo, I couldn’t find any free tool that scans for orphan pages. I believe there are paid SEO tools like Screaming Frog that scans for orphan pages even on the free tier, but I’m not really interested in using proprietary tools.1 Orphan pages does affect SEO somewhat, and I didn’t want to have any on my site—which is why I created the following Python Orphan Page Checker Script.

Warning

I’ve only tested the script on my own Hugo site. Treat this post as a personal experiment log of learning to use Python with Hugo. The code snippets are for learning purposes only and may contain mistakes. (Corrections are welcome!)

Static Site Orphan Page Checker

While researching for free tools, I realize I could use Python to do the following:

  1. Get a list of all the pages from my site from the public/ directory.
  2. Extract all internal <a href> links from these pages.
  3. Compare the two, to find pages that are not internally linked.

So that’s what I did and it works! It requires Python3, beautifulsoup4, and a directory with your static site HTML files. Although I have not tested this with dynamic websites, theoretically, it should work the same way by scanning the public/ folder where all the HTML pages are stored.

Disclaimer:

My python skills are not good enough to write this script unaided—I used qwen2.5-coder:3b via Ollama to help me get the script working as intended.

Overview

  1. Create Python script to find all orphan pages, e.g. yoursite/tools/orphan-checker.py

  2. Scan the public/ directory for HTML pages and extract all internal links.

    • For my specific use case, I wanted to exclude links from outside the main content div, i.e. sections of the site that may link to other posts like recent-posts-section, related-posts-section, or on archive list pages.
    • To exclude sections, I made links are only extracted from div class="content e-content" which contains only {{ .Content }} in the single post template.
    • And because I’m only extracting links from class="content e-content", my archive list pages are automatically excluded, as they use class="h-feed".
  3. Normalize internal links.

  4. Output three files (delete after processing the check results).

    • py_all_pages.txt
    • py_linked_from_content.txt
    • py_orphan_pages.txt
  5. Using the information gathered in py_orphan_pages.txt, process the orphan pages:

    • Add internal links if content is current
    • Delete if it has outdated content or redirect (301) if content can be merged with newer content
    • Update page and then proceed to add internal links

Tip

As I’m in the process of writing documentation for a side project I have (Hugo Calendar Heatmap); I found the Google Developer Documentation Style Guide to be useful in trying to write professional and easy-to-understand docs. It has a bit about writing effective link text.

Commands / Instructions

First Time Setup

1cd yoursite
2hugo --gc # builds site in /public
3python3 -m venv .venv # creates a venv (virtual environment)
4pip install --upgrade pip
5pip install beautifulsoup4
6chmod +x 'path/to/script/orphan-checker.py' # give run permission 

Script Usage

1source .venv/bin/activate # activate venv every time before running script
2python3 'path/to/script/orphan-checker.py' # run script 
3deactivate # close venv when complete, or close CLI window

Python Script

  1#!/usr/bin/env python3
  2
  3import os
  4import sys
  5import posixpath
  6from urllib.parse import urlparse
  7from bs4 import BeautifulSoup
  8
  9# CONFIGURATION
 10PUBLIC_DIR = "public" # HTML files
 11SITE_BASE = ""  # set if HTML links are absolute or same-origin only
 12CONTENT_SELECTORS = [".content.e-content"] # my content wrapper class, yours may be different or remove to extract all links from all pages
 13ANCHOR_SELECTOR = "a[href]"
 14IGNORE_PATH_PREFIXES = ["/images/", "/css/", "/js/", "/fonts/", "/assets/"]
 15IGNORE_PATTERNS = ["/tags/", "/series/", "/pages/", "/404"]  # exclude these from orphan results, these may be different for you
 16
 17def is_asset_path(p):
 18    for pre in IGNORE_PATH_PREFIXES:
 19        if p.startswith(pre):
 20            return True
 21    return False
 22
 23def is_ignored_pattern(p):
 24    for pat in IGNORE_PATTERNS:
 25        if p.startswith(pat):
 26            return True
 27    return False
 28
 29def normalize_href(href, page_dir):
 30    if not href:
 31        return None
 32    href = href.split("#")[0].strip()
 33    if href == "" or href == "#":
 34        return None
 35    if href.startswith(("mailto:", "tel:", "javascript:")):
 36        return None
 37    parsed = urlparse(href)
 38    if parsed.scheme in ("http", "https"):
 39        if SITE_BASE:
 40            base_netloc = urlparse(SITE_BASE).netloc
 41            if parsed.netloc != base_netloc:
 42                return None
 43        path = parsed.path or "/"
 44        return posixpath.normpath(path)
 45    if href.startswith("/"):
 46        return posixpath.normpath(href)
 47    # relative path, check against page_dir
 48    base = page_dir if page_dir.endswith("/") else page_dir + "/"
 49    joined = posixpath.normpath(posixpath.join(base, href))
 50    if not joined.startswith("/"):
 51        joined = "/" + joined
 52    return joined
 53
 54def collect_py_all_pages(public_dir):
 55    pages = set()
 56    for root, dirs, files in os.walk(public_dir):
 57        for fn in files:
 58            if not fn.lower().endswith(".html"):
 59                continue
 60            full = os.path.join(root, fn)
 61            rel = os.path.relpath(full, public_dir).replace(os.path.sep, "/")
 62            if fn == "index.html":
 63                dirpath = posixpath.dirname(rel)
 64                if dirpath in ("", "."):
 65                    path = "/"
 66                else:
 67                    path = "/" + dirpath
 68            else:
 69                path = "/" + rel
 70            pages.add(posixpath.normpath(path))
 71    return pages
 72
 73def collect_links_from_content(public_dir):
 74    linked = set()
 75    for root, dirs, files in os.walk(public_dir):
 76        for fn in files:
 77            if not fn.lower().endswith(".html"):
 78                continue
 79            full = os.path.join(root, fn)
 80            with open(full, "rb") as f:
 81                raw = f.read()
 82            try:
 83                soup = BeautifulSoup(raw, "html.parser")
 84            except Exception:
 85                continue
 86            rel = os.path.relpath(full, public_dir).replace(os.path.sep, "/")
 87            if fn == "index.html":
 88                page_dir = "/" + posixpath.dirname(rel)
 89                if page_dir == "/.":
 90                    page_dir = "/"
 91            else:
 92                page_dir = "/" + rel
 93            page_dir = posixpath.normpath(page_dir)
 94
 95            # Find specific content container
 96            nodes = []
 97            for sel in CONTENT_SELECTORS:
 98                nodes.extend(soup.select(sel))
 99            if not nodes:
100                continue
101
102            for node in nodes:
103                for a in node.select(ANCHOR_SELECTOR):
104                    href = a.get("href")
105                    norm = normalize_href(href, page_dir)
106                    if not norm:
107                        continue
108                    if is_asset_path(norm):
109                        continue
110                    linked.add(posixpath.normpath(norm))
111    return linked
112
113def main():
114    public = PUBLIC_DIR
115    if not os.path.isdir(public):
116        print(f"Error: public directory '{public}' not found.", file=sys.stderr)
117        sys.exit(2)
118
119    pages = collect_py_all_pages(public)
120    print(f"Collected {len(pages)} pages from {public}")
121
122    linked = collect_links_from_content(public)
123    print(f"Collected {len(linked)} links from content selectors {CONTENT_SELECTORS}")
124
125    candidate_pages = {p for p in pages if not is_ignored_pattern(p)}
126    candidate_pages.discard("/")  # exclude site root from orphan list
127
128    orphans = sorted(p for p in candidate_pages if p not in linked)
129
130    print("\nOrphan pages (not linked from .content.e-content):")
131    for o in orphans:
132        print(o)
133
134    with open("py_all_pages.txt", "w", encoding="utf-8") as f:
135        for p in sorted(pages):
136            f.write(p + "\n")
137    with open("py_linked_from_content.txt", "w", encoding="utf-8") as f:
138        for p in sorted(linked):
139            f.write(p + "\n")
140    with open("py_orphan_pages.txt", "w", encoding="utf-8") as f:
141        for p in orphans:
142            f.write(p + "\n")
143
144    print("\nWrote py_all_pages.txt, py_linked_from_content.txt, py_orphan_pages.txt")
145
146if __name__ == "__main__":
147    main()

Results—Finding Orphan Pages in Hugo

Screenshot of a list of pages on BurgeonLab that do not have internal links to it (orphan pages).

Mostly my early posts from 2023, two pages, and my latest weeknotes are not internally linked

Conclusion

I’m so happy with how this turned out—I will update the old content and link to them in related posts. During the process, I actually discovered a neat tool that checks for broken links too (post coming soon).

If you do try it, let me know how it goes! Running a Python script to find orphan pages on my Hugo site—offline and free, with no online tools needed, is a real win in my books. 🥳


  1. They do look professional though I have to say—if I was a proper freelance writer or content creator, I think I’ll give it a shot. ↩︎

Read the original on burgeonlab.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.