Find Missing, Empty, Multiple and Duplicate H1 Tags Across Your Sitemap with Python (Standard Library Only)
A template change can quietly strip the <h1> from every product page, wrap the logo in a second <h1>, or give fifty blog posts the same heading ("Blog"). None of that breaks a sitemap or returns an error. You find it whe
A template change can quietly strip the <h1> from every product page, wrap the logo in a second <h1>, or give fifty blog posts the same heading ("Blog"). None of that breaks a sitemap or returns an error. You find it when someone opens a page and the main heading is missing, or when Google picks an odd title link for a result.
This post builds a small auditor in Python using only the standard library (urllib, html.parser, xml.etree, csv, concurrent.futures). It reads a sitemap or sitemap index, fetches each URL, collects the text of every <h1>, and flags pages with no H1, an empty H1, more than one H1, or a first H1 that is duplicated across URLs. Output is a CSV plus a short console summary, and the exit code works in CI.
Google Search Central's SEO Starter Guide and title link documentation were opened on 8 Nov 2026 (both showed "Last updated 2025-12-10 UTC"). Appie free-tool descriptions are as of 8 Nov 2026. The script was tested on Python 3.13.5 against a local fixture site; the output below is real.
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
Earlier posts in this series covered finding broken links and redirect chains in a sitemap and extracting Open Graph and Twitter Card tags. Same pattern here: parse what the server actually returns, report what is there, and leave the judgment to a person.
What Google actually says about H1s
A lot of SEO checklists say "every page must have exactly one H1." Google does not say that, so this script does not treat it as a rule.
In the SEO Starter Guide, under things Google says not to worry about, the section on number and order of headings says semantic order is good for screen readers, but from Google Search's perspective it does not matter if headings are out of order, and there is no ideal number of headings a page should have. The same guide recommends breaking long content into sections with headings so it is easy to read.
The title link docs are where headings matter for search results. Google lists the main visual title, heading elements and other large, prominent text among the sources it uses to build a title link. It recommends making the main title distinctive and the most prominent text on the page, for example by putting the title text in the first visible <h1>. It also notes that when a page has several large, prominent headings, Google may use the first one.
So the practical reasons to audit H1s are:
- Clarity for readers. A page with no main heading, or with three competing ones, is harder to scan.
-
Accessibility. Screen reader users jump between headings. An empty
<h1>or a logo-only<h1>is a poor first stop. - Title link signals. If the first prominent heading is "Blog" on fifty pages, that is weak text for Google to fall back on.
None of the flags below is an automatic ranking failure. They are a list of pages for a human to open.
What the script flags
| Flag | Meaning | What to check |
|---|---|---|
missing_h1 |
No <h1> in the HTML response |
Is the main title styled as a div or h2? Is it rendered only by JavaScript? |
empty_h1 |
An <h1> exists but has no text (and no image alt text) |
Often a template bug or an icon-only heading |
multiple_h1 |
More than one <h1>
|
Often the logo wrapped in an <h1> in the header, plus the article title. Fine sometimes; check which one is most prominent |
duplicate_h1_xN |
The first H1 text (case-insensitive) is shared by N URLs | Category pages, paginated archives, or a template that hard-codes the heading |
http_error / fetch_failed
|
URL did not return a page | Remove from the sitemap or fix the page |
redirected |
URL in the sitemap redirects | Update the sitemap to the final URL |
Text inside <script>, <style> and <template> is ignored, nested markup like <em> is flattened, and an <img alt> inside an H1 counts as its text, so a logo heading with alt text is not reported as empty.
The script
Save as h1_audit.py. No pip install needed.
#!/usr/bin/env python3
"""Find missing, empty, multiple, and duplicate H1 tags across a sitemap.
Standard library only. Usage:
python3 h1_audit.py https://example.com/sitemap.xml --out h1_report.csv
"""
import argparse
import csv
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from html.parser import HTMLParser
UA = "h1-audit/1.0 (+stdlib python)"
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
def fetch(url, timeout=15):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.status, resp.geturl(), resp.read().decode(charset, "replace")
def sitemap_urls(sitemap_url, seen=None):
"""Return page URLs from a urlset, following sitemap indexes."""
seen = seen if seen is not None else set()
if sitemap_url in seen:
return []
seen.add(sitemap_url)
_, _, body = fetch(sitemap_url)
root = ET.fromstring(body.encode("utf-8"))
locs = [el.text.strip() for el in root.iter(SM_NS + "loc") if el.text]
if root.tag == SM_NS + "sitemapindex":
urls = []
for child in locs:
urls.extend(sitemap_urls(child, seen))
return urls
return locs
class H1Parser(HTMLParser):
"""Collect the visible text of every <h1> element."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.h1s = []
self._depth = 0
self._buf = []
self._skip = 0 # inside <script>/<style>/<template>
def handle_starttag(self, tag, attrs):
if tag in ("script", "style", "template"):
self._skip += 1
elif tag == "h1":
self._depth += 1
if self._depth == 1:
self._buf = []
elif tag == "img" and self._depth:
alt = dict(attrs).get("alt")
if alt:
self._buf.append(alt)
def handle_endtag(self, tag):
if tag in ("script", "style", "template") and self._skip:
self._skip -= 1
elif tag == "h1" and self._depth:
self._depth -= 1
if self._depth == 0:
self.h1s.append(" ".join("".join(self._buf).split()))
def handle_data(self, data):
if self._depth and not self._skip:
self._buf.append(data)
def close(self):
super().close()
if self._depth: # unclosed <h1>
self.h1s.append(" ".join("".join(self._buf).split()))
self._depth = 0
def audit(url):
row = {"url": url, "status": "", "h1_count": 0, "h1_text": "", "issues": []}
try:
status, final_url, body = fetch(url)
row["status"] = status
if final_url != url:
row["issues"].append("redirected")
except urllib.error.HTTPError as e:
row["status"] = e.code
row["issues"].append("http_error")
return row
except (urllib.error.URLError, TimeoutError) as e:
row["status"] = "error"
row["issues"].append(f"fetch_failed:{e}")
return row
p = H1Parser()
p.feed(body)
p.close()
row["h1_count"] = len(p.h1s)
row["h1_text"] = " | ".join(p.h1s)
if not p.h1s:
row["issues"].append("missing_h1")
elif any(t == "" for t in p.h1s):
row["issues"].append("empty_h1")
if len(p.h1s) > 1:
row["issues"].append("multiple_h1")
row["_first"] = p.h1s[0].casefold() if p.h1s and p.h1s[0] else ""
return row
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("sitemap")
ap.add_argument("--out", default="h1_report.csv")
ap.add_argument("--workers", type=int, default=8)
args = ap.parse_args()
try:
urls = sitemap_urls(args.sitemap)
except (urllib.error.URLError, ET.ParseError) as e:
print(f"Could not read sitemap: {e}", file=sys.stderr)
return 2
print(f"Checking {len(urls)} URLs from {args.sitemap}")
with ThreadPoolExecutor(max_workers=args.workers) as pool:
rows = list(pool.map(audit, urls))
by_text = defaultdict(list)
for r in rows:
if r.get("_first"):
by_text[r["_first"]].append(r)
for group in by_text.values():
if len(group) > 1:
for r in group:
r["issues"].append(f"duplicate_h1_x{len(group)}")
with open(args.out, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["url", "status", "h1_count", "h1_text", "issues"])
for r in rows:
w.writerow([r["url"], r["status"], r["h1_count"], r["h1_text"], ";".join(r["issues"])])
flagged = [r for r in rows if r["issues"]]
for r in flagged:
print(f" {r['url']} h1={r['h1_count']} {';'.join(r['issues'])}")
print(f"{len(flagged)} of {len(rows)} URLs need a look. Report: {args.out}")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main())
A few design choices worth knowing:
-
Sitemap indexes are followed recursively, with a
seenset so a sitemap that lists itself does not loop. - Duplicates use the first H1 only, compared case-insensitively, because that is the heading most likely to be read as the page's main title.
- The HTML is the first server response. If your H1 is injected by client-side JavaScript, this script will report it as missing. That is useful information: check whether your framework renders headings on the server.
-
Exit codes:
0when no URL is flagged,1when at least one is,2when the sitemap itself cannot be read.
Testing it against a local fixture
To check every flag, I built a tiny site with a sitemap index pointing at two child sitemaps (nine URLs total): a normal page, a page whose "title" is a styled div, an empty H1, a 404, a blog post with the logo wrapped in an <h1>, two posts whose H1 is "Blog" and "blog", a page with an <h1> inside a <script> string plus a real heading containing <em>, and a page with an & entity.
Serve it locally:
cd site
python3 -m http.server 8765 --bind 127.0.0.1
Then run the audit from another terminal:
python3 h1_audit.py http://127.0.0.1:8765/sitemap.xml --out h1_report.csv
echo "exit code: $?"
Real output on Python 3.13.5:
Checking 9 URLs from http://127.0.0.1:8765/sitemap.xml
http://127.0.0.1:8765/pricing.html h1=0 missing_h1
http://127.0.0.1:8765/about.html h1=1 empty_h1
http://127.0.0.1:8765/old-page.html h1=0 http_error
http://127.0.0.1:8765/blog/post-1.html h1=2 multiple_h1
http://127.0.0.1:8765/blog/post-2.html h1=1 duplicate_h1_x2
http://127.0.0.1:8765/blog/post-3.html h1=1 duplicate_h1_x2
6 of 9 URLs need a look. Report: h1_report.csv
exit code: 1
And the CSV:
url,status,h1_count,h1_text,issues
http://127.0.0.1:8765/index.html,200,1,Acme helps teams ship faster,
http://127.0.0.1:8765/pricing.html,200,0,,missing_h1
http://127.0.0.1:8765/about.html,200,1,,empty_h1
http://127.0.0.1:8765/old-page.html,404,0,,http_error
http://127.0.0.1:8765/blog/post-1.html,200,2,Acme | How we cut onboarding time,multiple_h1
http://127.0.0.1:8765/blog/post-2.html,200,1,Blog,duplicate_h1_x2
http://127.0.0.1:8765/blog/post-3.html,200,1,blog,duplicate_h1_x2
http://127.0.0.1:8765/docs/setup.html,200,1,Install the Acme CLI,
http://127.0.0.1:8765/docs/api.html,200,1,API reference & limits,
Notes on that result:
-
docs/setup.htmlhas<h1>fake</h1>inside adocument.writestring in a<script>tag. It was correctly ignored, and the real headingInstall the <em>Acme</em> CLIcame through as plain text. -
blog/post-1.htmlshows the classic logo pattern:Acme | How we cut onboarding time. Whether that needs fixing is a judgment call; the title docs suggest making the article title the most prominent heading. -
Blogandblogwere grouped as duplicates because comparison is case-insensitive.
When the sitemap URL is wrong, the script exits early with code 2:
Could not read sitemap: HTTP Error 404: File not found
exit code: 2
Running it on a real site
python3 h1_audit.py https://example.com/sitemap.xml --out h1_report.csv --workers 4
Be polite: keep --workers low on sites you do not own, and run it against staging before production if you can. Open the CSV in a spreadsheet, sort by issues, and work through the list with the page open next to it.
If you do not know where the sitemap lives, the free Sitemap Finder and Checker reads robots.txt Sitemap lines and the usual sitemap paths, then reports which files load and how many URLs they list. If you just want the URL list in a browser first, the Sitemap URL Extractor takes a sitemap URL or pasted XML and returns one URL per line; for an index, it lists the child sitemaps so you can run each one. Both ask for a captcha before they run.
Extending it
-
Compare H1 with
<title>. Add a<title>collector and flag pages where the two share no words. That often catches templates that put the site name in the H1. - Flag very long H1s. Add a length column and sort; there is no Google limit, but a 200-character heading is usually a paragraph in the wrong tag.
-
Check heading order. Record
h2toh6and flag jumps (anh4directly after theh1). Google says order does not matter for Search, but it does matter for screen readers. -
Diff runs. Save the CSV per deploy and diff it, so a template regression shows up as a spike in
missing_h1.
FAQ
Does Google require exactly one H1 per page?
No. Google's SEO Starter Guide says there is no ideal number of headings and that heading order does not matter for Google Search. Its title link docs recommend making the main title distinctive and prominent, for example in the first visible <h1>.
Is a page with two H1s an SEO problem?
Not automatically. It is worth a look when the first H1 is something generic like a logo or "Menu," because Google may use the first prominent heading when building a title link.
Why flag duplicate H1s across URLs?
Identical main headings make pages hard to tell apart for readers and give weak text to any system that uses headings to describe the page. Paginated archives and category templates are the usual cause.
Will this see headings rendered by React or Vue?
Only if they are in the HTML the server returns. Client-only headings show up as missing_h1, which is a prompt to check server rendering.
Can I run it in CI?
Yes. It exits 1 when any URL is flagged and 2 when the sitemap cannot be read. If you only want to fail the build on missing_h1 and empty_h1, filter the CSV before deciding.
Can I use Beautiful Soup instead?
Yes, if it is already a dependency. This series sticks to the standard library so the script runs anywhere Python runs.
Wrap-up
H1 problems rarely show up in sitemap validators. They show up as pages without a clear main heading, logo-first headings and fifty pages called "Blog." Run this auditor after template changes and migrations, review the flagged pages by hand, and fix the ones where a reader or a screen reader would be confused.
RG, Founder, Appie.run
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.