Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 24 min read

Find Orphan Pages with Python (Standard Library Only): Compare Sitemap URLs with Internal Links, CSV Report

An orphan page is a page your sitemap lists but no other page on your site links to. It usually gets there quietly: a landing page built for one campaign, a docs page added after the docs index was last edited, a nav ite

An orphan page is a page your sitemap lists but no other page on your site links to. It usually gets there quietly: a landing page built for one campaign, a docs page added after the docs index was last edited, a nav item someone removed during a redesign, or a "link" that is really a <div> with a click handler. The page loads fine and sits in the sitemap. A visitor could only find it if they already had the URL.

The fix is cheap once you know which pages they are. Finding them takes two lists: every URL your sitemap claims, and every URL your pages actually link to. This post builds a small auditor in Python using only the standard library (urllib, urllib.robotparser, html.parser, xml.etree, csv, concurrent.futures). It reads a sitemap or sitemap index, crawls your site from the home page by following <a href> links, then compares the two sets. Output is a CSV with inbound link counts and click depth for every sitemap URL, a console summary, and an exit code that works in CI.

Google Search Central's Link best practices for Google and Learn about sitemaps (both showing "Last updated 2025-12-10 UTC") and Build and submit a sitemap (showing "Last updated 2026-07-08 UTC") were opened on 13 Nov 2026, and every Google statement below comes from those three pages. Appie free-tool descriptions are as of 13 Nov 2026. The script was tested on Python 3.13.5 against a local fixture site with planted orphan pages; the output below is real, from a re-run on 13 Nov 2026.

Disclosure: Appie.run publishes this article. Two free tools mentioned near the end are ours. The script does not depend on them.

Where this fits

This is another script in a small standard-library series. An earlier post covered finding broken links and redirect chains across a sitemap, which checks whether the URLs you list respond properly. Another compared two sitemaps before and after a site migration. This one asks a different question: of the URLs in your sitemap, which ones can a person (or a crawler) actually reach by clicking through your site?

What Google actually says (and what it does not)

The clearest line is on the link best practices page, under internal links: "Every page you care about should have a link from at least one other page on your site." That is the definition of an orphan check in one sentence. The same page explains why: "Google uses links as a signal when determining the relevancy of pages and to find new pages to crawl."

It is also specific about what counts as a link: "Generally, Google can only crawl your link if it's an <a> HTML element (also known as anchor element) with an href attribute." It lists <a routerLink="products/category">, <span href="https://example.com"> and <a onclick="goto('https://example.com')"> as "Not recommended (but Google may still attempt to parse this)". It also says links inserted with JavaScript are crawlable "as long as it uses the HTML markup shown above." So this script only counts <a href> links, and separately counts <a> tags that have an onclick or routerLink and no href, because those are the ones most likely to hide an orphan.

The sitemap overview page explains the relationship between the two lists. "If your site's pages are properly linked, Google can usually discover most of your site. Proper linking means that all pages that you deem important can be reached through some form of navigation, be that your site's menu or links that you placed on pages." And on what a sitemap does not do: "A sitemap helps search engines discover URLs on your site, but it doesn't guarantee that all the items in your sitemap will be crawled and indexed." In other words, listing an orphan in your sitemap is not the same as linking to it.

The same page describes a "comprehensively linked" site as one where "Googlebot can find all the important pages on your site by following links starting from the home page." That is exactly how the crawl below works: start at the home page, follow links, and see what you never reach.

The build page adds the rule for the other direction (pages you link to but do not list): "Include the URLs in your sitemap that you want to see in Google's search results," and "If you have the same content accessible under different URLs, choose the URL you prefer and include that in the sitemap instead of all URLs that lead to the same content." That matters when the script reports pages missing from the sitemap: some belong there, and some are duplicates that should not be.

What these pages do not give you: a maximum click depth, a minimum number of internal links, or a statement that orphan pages are penalized. The link page says only "There's no magical ideal number of links a given page should contain." The script reports depth and inbound link counts as columns so you can sort them, and does not flag any number as too deep or too few.

What the script checks

  1. Sitemap URLs. It reads a urlset or a sitemap index (recursively, gzip supported) and keeps every <loc>.
  2. The link crawl. Starting from the site root (or any --start URLs), it fetches pages breadth first, follows same-host <a href> links, skips links marked rel="nofollow" and pages whose meta robots says nofollow, follows redirects, and respects robots.txt. Click depth is the number of hops from the start page. Links to files like PDFs and .txt count as reached but are not fetched.
  3. The leftovers. Sitemap URLs the crawl never fetched are fetched once at the end, so the report has their status code and so their own outgoing links count too. That is how it tells "nothing links here" apart from "only another orphan links here."
  4. Matching. URLs are compared after removing fragments, lowercasing scheme and host, and dropping default ports. /about and /about/ count as the same page unless you pass --strict-slash, and --drop-query ignores query strings. A link that redirects is credited to the page it redirects to.

What the script flags

  • orphan: Meaning: In the sitemap, and no fetched page links to it (self-links do not count); What to check: Should it be linked from a hub, nav or related page, or removed from the sitemap?
  • not_reached_from_start: Meaning: Something links to it, but only pages the crawl from the home page never reached (usually other orphans); What to check: Fix the orphan above it in the chain
  • nofollow_links_only: Meaning: Every internal link to it is rel="nofollow" or sits on a nofollow page; What to check: Was that intended for an internal page?
  • linked_only_via_redirect: Meaning: Every link to it points at an old URL that redirects here; What to check: Update the links to the final URL
  • not_in_sitemap: Meaning: A crawled, indexable HTML page (no noindex, canonical points to itself) that the sitemap does not list; What to check: Add it, or if it duplicates another URL, canonicalise it
  • http_error / fetch_failed: Meaning: The sitemap URL did not return 200; What to check: Fix it or remove it from the sitemap
  • redirected: Meaning: The sitemap URL itself redirects; What to check: List the final URL instead
  • blocked_by_robots: Meaning: robots.txt disallows it for this crawler, so it was not fetched; What to check: Expected for admin areas; odd for a sitemap URL
  • noindex: Meaning: The page has a robots or googlebot meta noindex (informational); What to check: Why is it in the sitemap?

Exit codes: 0 when nothing needs fixing, 1 when there is at least one orphan, not_reached_from_start, nofollow_links_only, http_error or fetch_failed, and 2 when the sitemap cannot be read.

The script

Save as orphan_pages.py. No pip install needed.

#!/usr/bin/env python3
"""Find orphan pages: URLs in your sitemap that no crawled page links to,
plus pages you can only reach from other orphans and linked pages that are
missing from the sitemap. CSV report.

Standard library only. Usage:
    python3 orphan_pages.py https://example.com/sitemap.xml --out orphans.csv
"""
import argparse
import csv
import gzip
import sys
import time
import urllib.error
import urllib.request
import urllib.robotparser
import xml.etree.ElementTree as ET
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from html.parser import HTMLParser
from urllib.parse import urldefrag, urljoin, urlsplit, urlunsplit

UA = "orphan-pages-audit/1.0 (+stdlib python)"
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
SKIP_EXT = (".pdf", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".zip",
            ".mp4", ".mp3", ".css", ".js", ".xml", ".txt", ".ico", ".woff2")

def fetch(url, timeout=20, max_bytes=3_000_000):
    """GET a URL. Returns (status, final_url, content_type, body_bytes)."""
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return (resp.status, resp.geturl(),
                    resp.headers.get("Content-Type", ""), resp.read(max_bytes))
    except urllib.error.HTTPError as e:
        return e.code, url, e.headers.get("Content-Type", "") if e.headers else "", b""
    except Exception as e:  # DNS, timeout, TLS, connection reset
        return None, url, "", str(e).encode()

def normalize(url, keep_query=True):
    """Exact form used for fetching: no fragment, lowercase scheme and host,
    no default port, '/' for an empty path."""
    url, _ = urldefrag(url.strip())
    p = urlsplit(url)
    host = (p.hostname or "").lower()
    if p.port and not ((p.scheme == "http" and p.port == 80) or
                       (p.scheme == "https" and p.port == 443)):
        host = f"{host}:{p.port}"
    return urlunsplit((p.scheme.lower(), host, p.path or "/",
                       p.query if keep_query else "", ""))

def match_key(url, strict_slash=False):
    """Looser form used only for matching sitemap URLs with links:
    '/about' and '/about/' count as the same page unless --strict-slash."""
    if strict_slash:
        return url
    p = urlsplit(url)
    path = p.path.rstrip("/") or "/"
    return urlunsplit((p.scheme, p.netloc, path, p.query, ""))

def load_sitemap(url, seen=None):
    """Return page URLs from a urlset or sitemap index (recursive, .gz ok)."""
    seen = seen if seen is not None else set()
    if url in seen:
        return []
    seen.add(url)
    status, _, _, body = fetch(url)
    if status != 200:
        raise RuntimeError(f"{url} returned {status or body.decode(errors='replace')}")
    if body[:2] == b"\x1f\x8b":
        body = gzip.decompress(body)
    root = ET.fromstring(body)
    locs = [el.text.strip() for el in root.iter(f"{SM_NS}loc") if el.text]
    if root.tag == f"{SM_NS}sitemapindex":
        urls = []
        for child in locs:
            urls.extend(load_sitemap(child, seen))
        return urls
    return locs

class LinkParser(HTMLParser):
    """Collect <a href> links the way Google describes crawlable links,
    plus <base href>, meta robots and rel=canonical."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.links = []          # (href, rel_nofollow)
        self.base = None
        self.robots = ""
        self.canonical = None
        self.script_only = 0     # <a> without href but with onclick/routerlink

    def handle_starttag(self, tag, attrs):
        a = {k.lower(): (v or "") for k, v in attrs}
        if tag == "a":
            if "href" in a:
                rel = a.get("rel", "").lower().split()
                self.links.append((a["href"], "nofollow" in rel))
            elif "onclick" in a or "routerlink" in a:
                self.script_only += 1
        elif tag == "base" and a.get("href") and self.base is None:
            self.base = a["href"]
        elif tag == "meta" and a.get("name", "").lower() in ("robots", "googlebot"):
            self.robots += " " + a.get("content", "").lower()
        elif tag == "link" and "canonical" in a.get("rel", "").lower().split():
            self.canonical = a.get("href")

class Page:
    def __init__(self, url):
        self.url = url
        self.status = None
        self.final = url
        self.html = False
        self.noindex = False
        self.canonical = ""
        self.depth = None        # click depth from start; None = not reached
        self.script_links = 0
        self.outlinks = []       # (target_url, nofollow)

def same_site(url, hosts):
    p = urlsplit(url)
    return p.scheme in ("http", "https") and p.netloc in hosts

def get_page(url, keep_query, delay):
    if delay:
        time.sleep(delay)
    page = Page(url)
    status, final, ctype, body = fetch(url)
    page.status = status
    page.final = normalize(final, keep_query)
    if status != 200 or "html" not in ctype.lower():
        return page
    page.html = True
    parser = LinkParser()
    charset = "utf-8"
    if "charset=" in ctype:
        charset = ctype.split("charset=")[-1].split(";")[0].strip() or "utf-8"
    try:
        parser.feed(body.decode(charset, errors="replace"))
    except LookupError:
        parser.feed(body.decode("utf-8", errors="replace"))
    base = urljoin(final, parser.base) if parser.base else final
    page.noindex = "noindex" in parser.robots or "none" in parser.robots.split()
    page_nofollow = "nofollow" in parser.robots or "none" in parser.robots.split()
    if parser.canonical:
        page.canonical = normalize(urljoin(base, parser.canonical), keep_query)
    page.script_links = parser.script_only
    for href, nofollow in parser.links:
        href = href.strip()
        if not href or href.lower().startswith(("javascript:", "mailto:", "tel:", "data:", "#")):
            continue
        page.outlinks.append((normalize(urljoin(base, href), keep_query),
                              nofollow or page_nofollow))
    return page

def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("sitemap", help="sitemap.xml or sitemap index URL")
    ap.add_argument("--start", action="append",
                    help="crawl start URL (repeatable); default: site root")
    ap.add_argument("--out", default="orphan_report.csv")
    ap.add_argument("--max-pages", type=int, default=500,
                    help="stop the link crawl after this many fetches")
    ap.add_argument("--workers", type=int, default=4)
    ap.add_argument("--delay", type=float, default=0.0,
                    help="seconds to wait before each request (per worker)")
    ap.add_argument("--host", action="append", default=[],
                    help="extra host to treat as internal, e.g. www.example.com")
    ap.add_argument("--drop-query", action="store_true",
                    help="ignore ?query strings when comparing URLs")
    ap.add_argument("--strict-slash", action="store_true",
                    help="treat /page and /page/ as different URLs")
    ap.add_argument("--ignore-robots", action="store_true",
                    help="crawl paths robots.txt disallows (your own site only)")
    args = ap.parse_args()
    keep_q = not args.drop_query
    key = lambda u: match_key(u, args.strict_slash)

    # 1. Sitemap URLs
    try:
        raw = load_sitemap(args.sitemap)
    except Exception as e:
        print(f"Could not read sitemap: {e}")
        return 2
    sitemap = {}
    for u in raw:
        sitemap.setdefault(key(normalize(u, keep_q)), normalize(u, keep_q))
    root = urlunsplit(urlsplit(args.sitemap)._replace(path="/", query="", fragment=""))
    hosts = {urlsplit(root).netloc} | {h.lower() for h in args.host}
    starts = [normalize(s, keep_q) for s in (args.start or [root])]
    print(f"Sitemap: {len(sitemap)} URLs from {args.sitemap}")

    robots = urllib.robotparser.RobotFileParser(urljoin(root, "/robots.txt"))
    try:
        robots.read()
    except Exception:
        robots = None
    allowed = lambda u: args.ignore_robots or robots is None or robots.can_fetch(UA, u)

    pages = {}           # requested url -> Page
    blocked = set()

    def run(urls):
        with ThreadPoolExecutor(max_workers=args.workers) as pool:
            return list(pool.map(lambda u: get_page(u, keep_q, args.delay), urls))

    # 2. Breadth-first crawl from the start URLs, following <a href> links
    frontier, depth, hit_limit = [u for u in starts], 0, False
    queued = set(frontier)
    while frontier:
        batch = []
        for u in frontier:
            if not allowed(u):
                blocked.add(u)
            elif len(pages) + len(batch) < args.max_pages:
                batch.append(u)
            else:
                hit_limit = True
        nxt = []
        for page in run(batch):
            page.depth = depth
            pages[page.url] = page
            if page.final != page.url and page.final not in queued:
                queued.add(page.final)          # follow the redirect target too
                nxt.append(page.final)
            for target, nofollow in page.outlinks:
                if same_site(target, hosts) and not nofollow and target not in queued \
                        and not target.lower().endswith(SKIP_EXT):
                    queued.add(target)
                    nxt.append(target)
        frontier, depth = nxt, depth + 1
    reached = len(pages)

    # 3. Fetch sitemap URLs the crawl never reached, to read their status and links
    crawled = {key(p.url) for p in pages.values()}
    missing = [u for k, u in sitemap.items() if k not in crawled and allowed(u)]
    for page in run(missing):
        pages[page.url] = page          # depth stays None: not reachable from start

    # 4. Build the inbound link graph (keyed loosely, redirects resolved)
    redirect = {key(p.url): key(p.final) for p in pages.values() if p.final != p.url}
    resolve = lambda k: redirect.get(k, k)
    inlinks = defaultdict(set)          # target key -> source urls (followed)
    nofollow_in = defaultdict(set)
    via_redirect = defaultdict(set)
    depth_of = {}
    for p in pages.values():
        if p.depth is not None:
            for k in (key(p.url), key(p.final)):
                depth_of[k] = min(depth_of.get(k, p.depth), p.depth)
    for p in pages.values():
        for target, nofollow in p.outlinks:
            if not same_site(target, hosts):
                continue
            t = resolve(key(target))
            if t in (key(p.url), key(p.final)):
                continue                # a page linking to itself does not count
            (nofollow_in if nofollow else inlinks)[t].add(p.final)
            if t != key(target):
                via_redirect[t].add(p.final)
            if p.depth is not None and not nofollow:
                # files we skipped (PDF, .txt) still count as reached via this link
                depth_of[t] = min(depth_of.get(t, p.depth + 1), p.depth + 1)

    # 5. Classify
    by_key = {}
    for p in pages.values():
        by_key.setdefault(key(p.url), p)
    rows, counts = [], defaultdict(int)
    for k, url in sitemap.items():
        p = by_key.get(k)
        issues = []
        if p is None:                   # only skipped URLs were never fetched
            issues.append("blocked_by_robots")
        elif p is not None and p.status != 200:
            issues.append("http_error" if p.status else "fetch_failed")
        if p is not None and p.final != p.url:
            issues.append("redirected")
        fk = resolve(k)
        followed = inlinks.get(fk, set())
        nf = nofollow_in.get(fk, set())
        if not followed and not nf:
            issues.append("orphan")
        elif not followed:
            issues.append("nofollow_links_only")
        elif fk not in depth_of:
            issues.append("not_reached_from_start")
        if followed and via_redirect.get(fk) and via_redirect[fk] >= followed:
            issues.append("linked_only_via_redirect")
        if p is not None and p.noindex:
            issues.append("noindex")
        for i in issues:
            counts[i] += 1
        rows.append({
            "url": url, "in_sitemap": "yes",
            "status": p.status if p else "",
            "depth": depth_of.get(fk, ""),
            "inlinks": len(followed), "nofollow_inlinks": len(nf),
            "example_source": sorted(followed or nf)[0] if (followed or nf) else "",
            "issues": ";".join(issues),
        })

    # 6. Linked, indexable HTML pages that the sitemap does not list
    finals = {}
    for p in pages.values():
        if p.depth is not None and p.html and p.status == 200:
            finals.setdefault(key(p.final), p)
    for k, p in sorted(finals.items()):
        if k in sitemap or p.noindex:
            continue
        if p.canonical and key(p.canonical) != k:
            continue                    # canonicalised elsewhere: fine to leave out
        counts["not_in_sitemap"] += 1
        src = inlinks.get(k, set())
        rows.append({"url": p.final, "in_sitemap": "no", "status": p.status,
                     "depth": depth_of.get(k, ""), "inlinks": len(src),
                     "nofollow_inlinks": len(nofollow_in.get(k, set())),
                     "example_source": sorted(src)[0] if src else "",
                     "issues": "not_in_sitemap"})

    order = {"orphan": 0, "not_reached_from_start": 1, "nofollow_links_only": 2,
             "linked_only_via_redirect": 3, "not_in_sitemap": 4}
    rows.sort(key=lambda r: (min([order.get(i, 9) for i in r["issues"].split(";")] or [9]),
                             r["url"]))
    with open(args.out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["url"])
        w.writeheader()
        w.writerows(rows)

    script_pages = [p for p in pages.values() if p.script_links]
    print(f"Crawled {reached} pages from {', '.join(starts)}; "
          f"fetched {len(pages) - reached} sitemap URLs the crawl had not reached")
    if hit_limit:
        print(f"WARNING: stopped at --max-pages {args.max_pages}; "
              "orphan results are incomplete")
    if blocked:
        print(f"Skipped {len(blocked)} URLs disallowed by robots.txt")
    for name in ("orphan", "not_reached_from_start", "nofollow_links_only",
                 "linked_only_via_redirect", "not_in_sitemap", "redirected",
                 "http_error", "fetch_failed", "blocked_by_robots", "noindex"):
        if counts[name]:
            print(f"  {name:<26} {counts[name]}")
    if script_pages:
        print(f"  {len(script_pages)} pages have <a> tags with onclick/routerLink "
              "and no href (not counted as links)")
    for r in rows:
        if set(r["issues"].split(";")) & set(order):
            print(f"  {r['issues']:<40} {r['url']}")
    print(f"Report: {args.out}")
    problems = {"orphan", "not_reached_from_start", "nofollow_links_only", "http_error",
                "fetch_failed"}
    return 1 if any(counts[p] for p in problems) else 0

if __name__ == "__main__":
    sys.exit(main())

A few design choices worth knowing before you run it:

  • Only <a href> counts as a link. That follows Google's link page. Buttons, onclick handlers and routerLink attributes are counted separately and reported as a single line in the summary, because the script cannot know where they go.
  • The crawl stops at --max-pages (default 500). If it hits the limit it prints a warning, because any page past the limit would look unreached. Raise the limit for bigger sites rather than trusting a truncated run.
  • Pages you link to but that are noindex, or that point their canonical elsewhere, are not reported as missing from the sitemap. Those are usually login screens, thank-you pages and printable versions, and leaving them out is correct.
  • It respects robots.txt by default. --ignore-robots exists for auditing your own staging site; do not use it on sites you do not run.

Testing it against a local fixture

I built a small site for a made-up notes app, "Northwind Notes," with a sitemap index pointing at two child sitemaps (14 URLs). Every page shares a header nav (Features, Pricing, Blog, About) and a footer with a Privacy link and a Careers link marked rel="nofollow". The problem cases:

  • /integrations/slack is in the sitemap, and the only "link" to it is <a onclick="go('/integrations/slack')"> on the features page, with no href.
  • /landing/spring-promo is in the sitemap and nothing links to it. It links to /docs/setup, which nothing else links to.
  • /webinar-2025 is in the sitemap and returns 404.
  • /careers is only linked from the nofollow footer link.
  • /customers is only linked through /case-studies, an old URL that returns a 301 to /customers.
  • /blog/post-3 is linked from the home page but missing from the sitemap.
  • /thank-you (meta noindex) and /print/pricing (canonical pointing at /pricing) are linked but not in the sitemap, and should not be reported.
  • The sitemap lists /about while the nav links to /about/.
  • robots.txt disallows /admin/, which the home page links to.

Python's http.server cannot serve extensionless URLs or redirects on its own, so the fixture uses a short handler:

"""Serve ./site with clean URLs (/pricing -> pricing.html) and one redirect."""
import functools
import http.server
import os

REDIRECTS = {"/case-studies": "/customers"}

class Handler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        path = self.path.split("?")[0]
        if path in REDIRECTS:
            self.send_response(301)
            self.send_header("Location", REDIRECTS[path])
            self.end_headers()
            return
        if path.endswith("/"):           # /blog/ -> blog/index.html, /about/ -> about.html
            path = path + "index" if os.path.isdir("site" + path) else path.rstrip("/")
        if not os.path.splitext(path)[1]:
            path += ".html"
        self.path = path
        super().do_GET()

    def log_message(self, *args):
        pass

http.server.ThreadingHTTPServer(
    ("127.0.0.1", 8771),
    functools.partial(Handler, directory="site")).serve_forever()

Run the server, then from another terminal:

python3 orphan_pages.py http://127.0.0.1:8771/sitemap.xml --out orphan_report.csv
echo "exit code: $?"

Real output on Python 3.13.5:

Sitemap: 14 URLs from http://127.0.0.1:8771/sitemap.xml
Crawled 13 pages from http://127.0.0.1:8771/; fetched 5 sitemap URLs the crawl had not reached
Skipped 1 URLs disallowed by robots.txt
  orphan                     3
  not_reached_from_start     1
  nofollow_links_only        1
  linked_only_via_redirect   1
  not_in_sitemap             1
  http_error                 1
  1 pages have <a> tags with onclick/routerLink and no href (not counted as links)
  orphan                                   http://127.0.0.1:8771/integrations/slack
  orphan                                   http://127.0.0.1:8771/landing/spring-promo
  http_error;orphan                        http://127.0.0.1:8771/webinar-2025
  not_reached_from_start                   http://127.0.0.1:8771/docs/setup
  nofollow_links_only                      http://127.0.0.1:8771/careers
  linked_only_via_redirect                 http://127.0.0.1:8771/customers
  not_in_sitemap                           http://127.0.0.1:8771/blog/post-3
Report: orphan_report.csv
exit code: 1

And the CSV:

url,in_sitemap,status,depth,inlinks,nofollow_inlinks,example_source,issues
http://127.0.0.1:8771/integrations/slack,yes,200,,0,0,,orphan
http://127.0.0.1:8771/landing/spring-promo,yes,200,,0,0,,orphan
http://127.0.0.1:8771/webinar-2025,yes,404,,0,0,,http_error;orphan
http://127.0.0.1:8771/docs/setup,yes,200,,1,0,http://127.0.0.1:8771/landing/spring-promo,not_reached_from_start
http://127.0.0.1:8771/careers,yes,200,,0,15,http://127.0.0.1:8771/,nofollow_links_only
http://127.0.0.1:8771/customers,yes,200,1,1,0,http://127.0.0.1:8771/,linked_only_via_redirect
http://127.0.0.1:8771/blog/post-3,no,200,1,1,0,http://127.0.0.1:8771/,not_in_sitemap
http://127.0.0.1:8771/,yes,200,0,15,0,http://127.0.0.1:8771/about/,
http://127.0.0.1:8771/about,yes,200,1,15,0,http://127.0.0.1:8771/,
http://127.0.0.1:8771/blog/,yes,200,1,15,0,http://127.0.0.1:8771/,
http://127.0.0.1:8771/blog/post-1,yes,200,1,2,0,http://127.0.0.1:8771/,
http://127.0.0.1:8771/blog/post-2,yes,200,2,1,0,http://127.0.0.1:8771/blog/,
http://127.0.0.1:8771/features,yes,200,1,15,0,http://127.0.0.1:8771/,
http://127.0.0.1:8771/pricing,yes,200,1,15,0,http://127.0.0.1:8771/,
http://127.0.0.1:8771/privacy,yes,200,1,15,0,http://127.0.0.1:8771/,

Notes on that result:

  • All three kinds of orphan were caught. The Slack page has an <a> tag pointing at it in spirit, but with no href it is not a link, which matches how Google describes crawlable links. The summary line about one page with onclick/routerLink anchors is the hint to go and look at the features page.
  • The promo chain is split correctly. /landing/spring-promo is the orphan; /docs/setup has one inbound link, but it comes from the orphan, so it gets not_reached_from_start instead. Linking the promo page from somewhere fixes both. A tool that only counts inbound links would have marked /docs/setup as fine.
  • The 404 is both an error and an orphan. That is usually a page that was removed from the site but not from the sitemap.
  • /careers has 15 inbound links, all nofollow, so it was never crawled from the home page. The footer is on every page, which is why the count is high.
  • /customers was reached (depth 1) because the crawl followed the redirect, but every link points at the old URL.
  • /blog/post-3 is the only not_in_sitemap row. The noindex thank-you page and the canonicalised print page were correctly left out, and the /about versus /about/ difference did not create a false orphan.
  • /admin/ was skipped because robots.txt disallows it.

When the sitemap URL is wrong, the script exits early:

Could not read sitemap: http://127.0.0.1:8771/missing.xml returned 404
exit code: 2

Running it on your own sitemap

Point the script at your sitemap, start with a low worker count, and use --limit for a first pass on a large site:

python3 orphan_pages.py https://example.com/sitemap.xml --out orphan_report.csv --workers 2 --limit 50

If you want to eyeball the two lists in a browser first on a small site, the free Sitemap URL Extractor takes a sitemap URL or pasted XML and returns one URL per line (for a sitemap index it lists the child sitemaps, so run each one), and the Website URL Extractor collects same-host links from up to 80 HTML pages or until 12 seconds pass, and skips a host whose robots.txt disallows all crawling. Paste both lists into a spreadsheet and anything only in the first column is an orphan candidate. Both ask for a captcha before they run, and the 80-page cap means they are for small sites; the script has no such limit and also gives you depth, nofollow and redirect detail.

Fixing what it finds

  1. Link it, if the page matters. Add a link from the most relevant hub: the docs index for a docs page, a features or solutions page for a product page, a category page for a post. Google's page asks you to think about "what other resources on your site could help your readers understand a given page on your site, and link to those pages in context," so a contextual link beats a footer link.
  2. Remove it from the sitemap, if it is not meant to be found. Expired campaign pages, one-off landing pages for paid traffic and pages shared by direct link do not need to be in the sitemap. Decide whether they should exist at all.
  3. Turn script-only links into real links. If the summary reports onclick or routerLink anchors, render them as <a href="/real/url"> with the handler on top. Google's page shows <a href="/products/category/shoes" onclick="javascript:goTo('shoes')"> as a pattern it can parse.
  4. Update links that go through redirects. Point them straight at the final URL.
  5. For not_in_sitemap, decide which URL is canonical. Add real pages to the sitemap; canonicalise or remove duplicates.
  6. Rerun after the fix. The exit code makes this easy to keep in CI so a redesign that drops a nav item fails the build.

Extending it

  • Several start points. Pass --start more than once if parts of your site are meant to be entered from somewhere other than the home page, such as a docs subdomain or a help center.
  • Rendered pages. If your navigation is built entirely in JavaScript, this script will report most pages as orphans. Check one in Search Console's URL Inspection to see what Google rendered, and if needed swap fetch for HTML saved from a headless browser.
  • Anchor text. LinkParser already sees every <a> tag; capturing the text inside lets you list the anchor text each page receives, which Google's link page covers in detail.
  • Diff runs. Save the CSV per deploy and compare the inlinks column. A page that drops from 77 links to 1 after a template change is worth knowing about before it becomes an orphan.

FAQ

What is an orphan page?
A page with no internal links pointing to it. In this script it means a URL in your sitemap that none of the fetched pages links to with an <a href>. Google's link page says "Every page you care about should have a link from at least one other page on your site."

If a page is in my sitemap, does it matter that nothing links to it?
Google's sitemap page says a sitemap "helps search engines discover URLs on your site, but it doesn't guarantee that all the items in your sitemap will be crawled and indexed," and that properly linked pages are ones that "can be reached through some form of navigation." Visitors also cannot reach a page that nothing links to. Google does not say orphans are penalized; the script finds them so you can decide.

Why does my JavaScript menu make everything look orphaned?
The script reads the HTML your server sends and counts <a href> elements. If links are only created in the browser, or are buttons with click handlers, it cannot see them. Google says links added with JavaScript are crawlable if they use the <a href> markup, so check the rendered HTML in URL Inspection.

How deep is too deep?
Google's pages do not give a number. The CSV has a depth column so you can sort by it and judge each section yourself.

Can I run it in CI?
Yes. It exits 1 for orphans, unreached pages, nofollow-only pages and broken sitemap URLs, 0 when clean and 2 when the sitemap cannot be read. not_in_sitemap and redirect findings are reported but do not fail the build.

Wrap-up

Orphan pages are rarely one mistake. They are a campaign page nobody linked, a docs page added after the index, a nav item removed in a redesign, or a click handler that looks like a link. Comparing the sitemap with the links your pages actually contain finds all of them in one run, and the crawl from the home page shows which ones a visitor could never reach. Run it after a redesign or a docs reorganisation, start with the orphan rows, and for each one choose between linking it and dropping it from the sitemap.

RG, Founder, Appie.run

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.