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.htmlandsingle.htmlHugo 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:
- Get a list of all the pages from my site from the
public/directory. - Extract all internal
<a href>links from these pages. - 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
Create Python script to find all orphan pages, e.g.
yoursite/tools/orphan-checker.pyScan 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 likerecent-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 useclass="h-feed".
- For my specific use case, I wanted to exclude links from outside the main content
Normalize internal links.
Output three files (delete after processing the check results).
- py_all_pages.txt
- py_linked_from_content.txt
- py_orphan_pages.txt
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 windowPython 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
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. 🥳
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. ↩︎

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.