Site Architecture Audit Claude Skill
This skill audits a website's architecture and crawlability and produces an actionable SEO report. It crawls the site and inspects robots.txt and XML sitemaps to find hostname and protocol duplication, URL structure problems, excessive crawl depth, weak directory taxonomy, blocked or conflicting crawl and index directives, and sitemap-to-crawl parity gaps, then recommends concrete fixes.
What The Skill Checks
- High-severity structural problems: multiple indexable hostname and protocol variants (http and https, www and non-www) with no canonical host enforcement, important pages blocked by robots.txt, and conflicting crawl and index directives such as a noindex page that is also disallowed or canonicalized elsewhere.
- XML sitemap health, checked in both directions: sitemap URLs that are non-200, redirected, noindexed, or non-canonical, plus indexable pages missing from the sitemap entirely, along with robots.txt sitemap declaration and size-limit handling.
- URL and depth structure: uppercase, underscore, encoded, over-long, over-deep, or ID-only URLs, inconsistent trailing-slash, protocol, and www forms across internal links, crawlable tracking and session parameter spaces, click-depth distribution, and thin or inconsistent directory taxonomy.
How The Skill Is Packaged
The skill follows the standard Claude Agent Skill structure: a SKILL.md file with YAML frontmatter and workflow instructions, a references/ folder with the full audit check definitions and report template, and a scripts/ folder with Python scripts that crawl the site and gather architecture signals (host variants, robots.txt, and XML sitemaps) and audit the resulting inventory. The architecture skill runs against a live site, since robots.txt, sitemaps, redirects, and host-variant behavior only exist on a live server. Every report can be exported to a shareable Word .docx or a flat .csv of findings via the bundled md_to_docx.py and findings_to_csv.py scripts. Install it with npx claude-seo-skills install site-architecture-audit, or copy the skill folder into your Claude skills directory; Claude invokes it automatically when a request matches its description.
Skill Files
Every file in the skill is shown below, copied from the Claude-SEO-Skills repository, so you can review exactly what the skill instructs Claude to do before installing it.
SKILL.md
The skill definition: frontmatter, inputs to collect, the five-step audit workflow, and the resources the skill relies on.
Site Architecture Audit
Audit the structural layer of a website (URL design, hostname and protocol canonicalization, robots.txt, XML sitemaps, crawl and index directives, depth distribution, and directory taxonomy) and produce an actionable SEO report.
When to use this skill
Use this skill when the user asks to:
- Audit or analyze a site's architecture, structure, or how it is organized
- Review URL structure quality (depth, readability, parameters, trailing slashes, case)
- Check crawlability and indexability: robots.txt, meta robots, canonical signals, conflicts between them
- Validate XML sitemaps and how well they match what is actually crawlable
- Confirm hostname and protocol canonicalization (http vs https, www vs non-www)
- Understand click-depth distribution and directory grouping across the site
This skill covers the structural layer. For the link-by-link view of broken links, orphan pages, anchor text, and redirect chains, use internal-link-audit. For outbound links use external-link-audit. The two are complementary: architecture explains the shape of the site, internal-link explains the flow of equity within it.
Inputs to collect
Before starting, confirm with the user:
- Site URL : a live site root URL (e.g.,
https://example.com). A live crawl is required because robots.txt, sitemaps, and redirect behavior cannot be assessed from local files alone. - Scope : full site or a specific section (e.g.,
/blog/). - Crawl limits : max pages (default 500) and max depth (default 10) for the crawl.
- Business context : site type (e-commerce, blog, local business, SaaS) and the few page templates that matter most, so depth and taxonomy expectations can be set.
Workflow
Step 1: Crawl the site and gather architecture signals
Use scripts/crawl_architecture.py to crawl from the homepage (same registrable host only), probe the four hostname/protocol variants, fetch and parse robots.txt, and collect every XML sitemap URL (following sitemap index files):
python3 scripts/crawl_architecture.py https://example.com --max-pages 500 --max-depth 10 --output architecture_inventory.json
For every crawled page the inventory records: requested URL, final URL, HTTP status, redirect chain, click depth, scheme, host, path, query, path-segment count, canonical, meta robots, title, and the raw resolved internal link targets (used to detect URL-form inconsistency). It also records the host-variant probe, the parsed robots.txt rules and Sitemap: directives, and the full sitemap URL set.
Step 2: Run the audit checks
Use scripts/audit_architecture.py to evaluate every check against the inventory:
python3 scripts/audit_architecture.py architecture_inventory.json --output audit_report.json
Useful flags: --max-url-depth 4 (path-segment threshold), --max-click-depth 3 (click-depth threshold), and --tracking-params utm_source,utm_medium,gclid,fbclid to extend the tracking-parameter list.
Step 3: Evaluate the audit checks
Evaluate each check defined in references/audit-checks.md. The core checks are:
| Check | Severity |
|---|---|
| Multiple indexable hostname/protocol variants (no canonical host enforcement) | High |
| Important pages blocked by robots.txt | High |
| Conflicting crawl/index directives (disallow + noindex, noindex + canonical elsewhere, canonical to non-200/redirect/noindex) | High |
| Sitemap lists non-indexable URLs (non-200, redirected, noindexed, or non-canonical) | High |
| Indexable pages missing from the XML sitemap (sitemap/crawl parity gap) | Medium |
| Excessive click depth or top-heavy depth distribution | Medium |
| URL structure problems (uppercase, underscores, spaces/encoding, over-long, over-deep, ID-only slugs) | Medium |
| Inconsistent URL forms in internal links (mixed trailing slash, mixed http/https, mixed www) | Medium |
| Crawlable parameter URLs (tracking, session, sort/filter) creating duplicate spaces | Medium |
| Missing or non-200 robots.txt | Medium |
| No XML sitemap found | Medium |
| Pages missing a self-referencing canonical | Low |
| Sitemap not referenced in robots.txt | Low |
| Thin or inconsistent directory taxonomy | Low |
| Sitemap over size limits (50,000 URLs / 50 MB) without a sitemap index | Low |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary : pages crawled, canonical host, sitemap/robots status, issue counts by severity
- Canonicalization and crawl controls : host-variant table, robots.txt findings, index-signal conflicts
- URL and depth structure : depth histogram, URL-quality issues, directory map
- Sitemap health : parity gaps and non-indexable entries
- Prioritized action list : ordered by severity and estimated impact
Step 5: Recommend fixes
For each issue, give a concrete, copy-pasteable recommendation, e.g.:
- Redirect
http://example.comandhttps://www.example.comtohttps://example.comwith a single 301 so only one host variant resolves - Remove
Disallow: /blogfrom robots.txt; it is blocking 42 linked, indexable articles - Drop
noindexfrom/services/seoor remove it from the sitemap; the two signals contradict each other - Lowercase and de-underscore
/Services/SEO_Auditto/services/seo-auditand 301 the old URL - Add the 60 indexable pages listed below to
sitemap.xmland reference the sitemap from robots.txt
Optional: export the report
As a Word document (.docx)
If the user wants the report as a .docx (for example, to share with stakeholders or attach to a ticket), save the Markdown report to a file and convert it:
python3 scripts/md_to_docx.py report.md --output report.docx
scripts/md_to_docx.py uses only the Python standard library (no pip install) and renders headings, tables, lists, links, bold/italic, and code blocks. Offer this whenever a user asks for a Word doc, a .docx, or a shareable/downloadable report.
As a CSV of findings (.csv)
If the user wants the raw findings as a spreadsheet (for filtering, sorting, or triage in Sheets or Excel), convert the audit's audit_report.json directly:
python3 scripts/findings_to_csv.py audit_report.json --output findings.csv
scripts/findings_to_csv.py is also standard-library only. It writes one row per finding, with check and severity columns prepended and list fields (e.g. the pages sharing a duplicate value) joined with ; . Unlike the .docx, which reformats the written report, the CSV is a direct dump of the structured findings — offer it whenever a user wants the data itself, a spreadsheet, or to slice findings by check or severity.
Resources
scripts/md_to_docx.py— convert the Markdown report into a Word (.docx) document (standard library only)scripts/findings_to_csv.py— flatten the audit findings JSON into a CSV, one row per finding (standard library only)references/audit-checks.md: full definitions, thresholds, and rationale for every audit checkreferences/report-template.md: report output structurescripts/crawl_architecture.py: crawl the site, probe host variants, parse robots.txt and sitemaps, build the inventoryscripts/audit_architecture.py: run audit checks against an architecture inventory JSON file
Audit Checks Reference (references/audit-checks.md)
Full definitions, thresholds, and rationale for all fifteen audit checks the skill evaluates, from hostname canonicalization and directive conflicts to sitemap parity and URL structure.
Site Architecture Audit Checks
Full definitions, thresholds, and rationale for each audit check.
High severity
1. Multiple indexable hostname/protocol variants
- Definition: The site answers with HTTP 200 on more than one of the four host/protocol variants (
http://,https://, with and withoutwww) instead of redirecting every variant to one canonical version with a 301. - Why it matters: Each resolvable variant is a separate, fully duplicated copy of the site. It splits link equity, wastes crawl budget, and forces search engines to guess the canonical host.
- Fix: Pick one canonical host (almost always
https://and a singlewww/non-wwwchoice) and 301-redirect the other three variants to it. Confirm the canonical tag and internal links use the chosen host.
2. Important pages blocked by robots.txt
- Definition: A
Disallowrule in robots.txt matches URLs that are linked from the site and otherwise indexable (200, not noindex). - Why it matters: Disallowed URLs are not crawled, so their content and links are never seen. If they are also linked elsewhere they can still appear in results as bare URLs with no snippet.
- Fix: Narrow or remove the
Disallow. Reserve robots.txt for genuinely crawl-wasteful spaces (internal search, faceted parameter URLs); usenoindex(notDisallow) for pages that should be crawlable but kept out of the index.
3. Conflicting crawl/index directives
- Definition: Two signals on the same URL contradict each other. Common cases:
- A URL is
Disallowed in robots.txt and carries anoindexmeta tag. The crawler cannot fetch the page, so it never sees thenoindex, and the URL can linger in the index. - A page is
noindexand has a canonical pointing to a different URL. Canonical asks to consolidate; noindex asks to drop. Search engines may ignore both. - A page's canonical points to a URL that is non-200, redirects, or is itself
noindex.
- A URL is
- Why it matters: Contradictory directives produce unpredictable indexing and waste the signals entirely.
- Fix: Choose one outcome per URL. To deindex but keep crawlable, use
noindexand allow crawling. To consolidate duplicates, use a self-consistent canonical that points to a live, indexable, self-canonical target.
4. Sitemap lists non-indexable URLs
- Definition: The XML sitemap contains URLs that are non-200, redirect (3xx), carry
noindex, or canonicalize to a different URL. - Why it matters: XML sitemaps are a statement of the canonical, indexable URL set. Polluting them with redirects, errors, or non-canonical URLs erodes trust in the sitemap and wastes crawl budget on dead ends.
- Fix: Regenerate the sitemap to include only final, 200, self-canonical, indexable URLs. Automate generation from the canonical URL set rather than maintaining it by hand.
Medium severity
5. Indexable pages missing from the XML sitemap
- Definition: Pages that are 200, not
noindex, and self-canonical, but absent from every sitemap. - Why it matters: Sitemaps speed discovery and give search engines a complete picture of the canonical URL set. Gaps slow indexing of new or deep content.
- Fix: Add the missing indexable URLs to the sitemap. If the gap is large and systematic (a whole section missing), fix the sitemap generator rather than patching URLs individually.
6. Excessive click depth or top-heavy depth distribution
- Definition: Pages requiring more than 3 clicks from the homepage to reach, or a distribution where a large share of pages sit beyond depth 3.
- Threshold: Flag click depth greater than
--max-click-depth(default 3); depth greater than 5 is critical. Also flag when more than ~40% of crawled pages sit beyond the threshold. - Why it matters: Depth is a proxy for importance and for crawl reachability. Deep pages are crawled less often and accrue less internal equity.
- Fix: Flatten the architecture with hub/category pages, contextual links from shallow high-authority pages, and a navigation that exposes key sections within two or three clicks.
7. URL structure problems
- Definition: URLs that are hard to read, crawl, or maintain. Detected sub-cases:
- Uppercase letters in the path (case-sensitivity duplicate risk)
- Underscores as word separators (hyphens are the convention)
- Spaces or percent-encoded characters in the path
- Over-long URLs (path + query beyond ~115 characters)
- Over-deep paths (more than
--max-url-depthpath segments, default 4) - ID-only or opaque slugs (final segment is all digits or a long random token, carrying no keyword signal)
- Why it matters: Clean, lowercase, hyphenated, shallow, descriptive URLs are easier to crawl, share, and understand, and they reduce duplicate-content risk from case or encoding variants.
- Fix: Adopt a lowercase, hyphenated, descriptive URL convention; 301 the old URLs to the new ones. Apply at the template level so new URLs are correct by default.
8. Inconsistent URL forms in internal links
- Definition: The same site is linked using inconsistent URL forms. Detected sub-cases across the internal links found:
- Mixed trailing slash (both
/pathand/path/linked) - Mixed protocol (some internal links use
http://on anhttps://site) - Mixed host (some links use
www, others non-www)
- Mixed trailing slash (both
- Why it matters: Each inconsistent form is a redirect hop at best and a duplicate URL at worst. It dilutes equity and muddies canonicalization.
- Fix: Standardize internal links on one form (canonical host, one trailing-slash convention) and enforce it in templates. Make sure server redirects normalize the other forms.
9. Crawlable parameter URLs
- Definition: Internal links or crawled URLs carrying query strings that create duplicate or near-duplicate crawl spaces. Categorized as:
- Tracking parameters in internal links (
utm_*,gclid,fbclid) - Session identifiers (
sid,sessionid,phpsessid) - Sort/filter/pagination parameters that multiply URLs
- Tracking parameters in internal links (
- Why it matters: Parameterized duplicates waste crawl budget and split signals. Tracking parameters never belong on internal links.
- Fix: Link to clean canonical URLs internally (strip tracking params). For functional parameters, set self-referencing canonicals to the clean URL, and where appropriate disallow the parameter space in robots.txt.
10. Missing or non-200 robots.txt
- Definition:
/robots.txtis absent or returns a non-200 status. - Why it matters: A missing robots.txt is not fatal (everything is allowed by default), but a 5xx robots.txt can cause search engines to pause crawling entirely, and the absence means no place to declare sitemaps or manage crawl-wasteful spaces.
- Fix: Serve a valid
200robots.txt, even a permissive one, and reference the sitemap from it.
11. No XML sitemap found
- Definition: No sitemap at
/sitemap.xmland none declared in robots.txt. - Why it matters: Sitemaps are the most direct discovery channel, especially for large sites, new content, and deep pages.
- Fix: Generate an XML sitemap of canonical indexable URLs and declare it in robots.txt and Search Console.
Low severity
12. Pages missing a self-referencing canonical
- Definition: Indexable 200 pages with no
rel="canonical"at all. - Why it matters: A self-referencing canonical is a low-cost defense against parameter and variant duplication. Its absence is not an error but leaves the page exposed.
- Fix: Add a self-referencing canonical to every indexable page at the template level.
13. Sitemap not referenced in robots.txt
- Definition: A sitemap exists but is not declared with a
Sitemap:line in robots.txt. - Why it matters: The robots.txt
Sitemap:directive is a standard discovery path for every crawler. - Fix: Add
Sitemap: https://example.com/sitemap.xmlto robots.txt.
14. Thin or inconsistent directory taxonomy
- Definition: First-level directories (sections) that contain only a single page, or a taxonomy where naming conventions are inconsistent across sections.
- Why it matters: Single-page sections suggest a hierarchy that does not reflect real content groupings; inconsistent naming makes the architecture harder to reason about and scale.
- Fix: Consolidate one-off sections into a relevant parent, or expand them into genuine hubs. Standardize directory naming.
15. Sitemap over size limits without a sitemap index
- Definition: A single sitemap file with more than 50,000 URLs or larger than 50 MB uncompressed, with no sitemap index splitting it.
- Why it matters: Over-limit sitemaps may be rejected or partially processed.
- Fix: Split into multiple sitemaps under the limits and reference them from a sitemap index file.
Additional signals to note (informational)
- Subdomain sprawl : content split across many subdomains (
blog.,shop.,help.). Confirm it is intentional; subfolders usually consolidate authority better than subdomains. - hreflang presence : for international sites, note whether
hreflangannotations exist and appear reciprocal. Full hreflang validation is out of scope here. - Breadcrumb wayfinding : deep pages without breadcrumb navigation are harder to place in the hierarchy (overlaps
schema-markup-auditforBreadcrumbList). - Crawl vs sitemap count delta : a large gap between crawlable pages and sitemap URL count, in either direction, is worth explaining.
- Mixed content :
https://pages linkinghttp://internal resources.
Report Template (references/report-template.md)
The output structure the skill follows when writing the final audit report, including the host-variant table and the click-depth distribution.
Site Architecture Audit Report Template
Use this structure for the final report delivered to the user.
# Site Architecture Audit : {site}
**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **Canonical host:** {host} | **robots.txt:** {found/missing} | **Sitemap URLs:** {n}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2-3 sentence overview of the site's architectural health and the biggest wins.}
## Canonicalization and crawl controls
### Hostname / protocol variants
| Variant | Status | Resolves to | Canonical? |
|---|---|---|---|
| http://example.com | 301 | https://example.com/ | redirects (good) |
| https://www.example.com | 200 | https://www.example.com/ | duplicate (fix) |
| ... | ... | ... | ... |
### robots.txt
{Status, the Disallow rules that block indexable content, and whether a sitemap is declared.}
### Index-signal conflicts ({n})
| Page | Conflict | Suggested fix |
|---|---|---|
| ... | noindex + canonical to other URL | ... |
## URL and depth structure
### Click-depth distribution
| Depth | Pages |
|---|---|
| 0 | 1 |
| 1 | {n} |
| 2 | {n} |
| 3 | {n} |
| 4+ | {n} |
### URL-quality issues ({n})
| Page | Problem | Suggested URL |
|---|---|---|
| /Services/SEO_Audit | uppercase, underscore | /services/seo-audit |
### Directory map
| Section (first path segment) | Pages | Note |
|---|---|---|
| /blog | {n} | |
| /services | {n} | |
| /x | 1 | single-page section, consider consolidating |
## Sitemap health
### Non-indexable URLs in sitemap ({n})
| Sitemap URL | Problem | Suggested fix |
|---|---|---|
| ... | 301 redirect | list the final URL instead |
### Indexable pages missing from sitemap ({n})
| Page | Note |
|---|---|
| ... | add to sitemap |
## Prioritized action list
1. {Highest-impact fix, with exact hosts/URLs/rules}
2. ...
Guidance
- Every issue row must name the exact URL or rule and a concrete fix, never generic advice.
- Lead the action list with canonicalization and robots.txt issues: a single host-redirect or one over-broad
Disallowline usually outweighs dozens of cosmetic URL fixes. - Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
- Group templated problems (every product URL using underscores, a whole section missing from the sitemap) as one template-level fix with a few example URLs rather than listing hundreds of rows.
- When recommending URL changes, always pair them with a 301 from the old URL so existing equity and links are preserved.
- State thresholds you used (click-depth and URL-depth limits) so the user can recalibrate for their site type.
Architecture Crawler Script (scripts/crawl_architecture.py)
Crawls a live site within the configured page and depth limits, probes the four hostname and protocol variants, parses robots.txt, and collects every XML sitemap URL (following sitemap index files) into a JSON inventory.
#!/usr/bin/env python3
"""Crawl a website and gather architecture signals into a JSON inventory.
Usage:
python3 crawl_architecture.py https://example.com [--max-pages 500] [--max-depth 10] [--output architecture_inventory.json]
Gathers:
- Host/protocol variant probe (http/https x www/non-www): status + redirect target of each
- robots.txt: status, parsed user-agent groups (Disallow/Allow), Sitemap: directives, raw text
- XML sitemaps: every <loc>, following sitemap index files; total URL count
- Per crawled page: requested URL, final URL, status, redirect chain, click depth,
scheme/host/path/query, path-segment count, canonical, meta robots, title,
and the raw resolved internal link targets (for URL-form consistency checks)
Output JSON schema:
{
"site": "https://example.com",
"canonical_host": "https://example.com",
"host_variants": {"<variant>": {"status": 301, "final_url": "..."}},
"robots_txt": {"found": true, "status": 200, "sitemaps": [...],
"rules": [{"agent": "*", "disallow": [...], "allow": [...]}], "raw": "..."},
"sitemap": {"found": true, "sources": [...], "url_count": 120, "urls": [...], "errors": [...]},
"pages_crawled": 100,
"pages": {
"<requested url>": {
"status": 200, "final_url": "...", "redirected": false, "redirect_chain": [],
"depth": 0, "scheme": "https", "host": "example.com", "path": "/x", "query": "",
"segments": 1, "canonical": "...|null", "meta_robots": "...|null", "title": "...|null",
"links": ["<raw resolved internal target>", ...]
}
}
}
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import deque
from html.parser import HTMLParser
USER_AGENT = "SiteArchitectureAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
MAX_SITEMAPS = 50
class PageParser(HTMLParser):
"""Extract title, canonical, meta robots, and same-document link hrefs."""
def __init__(self, base_url):
super().__init__(convert_charrefs=True)
self.base_url = base_url
self.title = None
self.canonical = None
self.meta_robots = None
self.links = []
self._in_title = False
self._title_parts = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "title":
self._in_title = True
self._title_parts = []
elif tag == "link" and (a.get("rel") or "").lower() == "canonical" and a.get("href"):
self.canonical = urllib.parse.urljoin(self.base_url, a["href"].strip())
elif tag == "meta" and (a.get("name") or "").lower() == "robots":
self.meta_robots = a.get("content")
elif tag == "a" and a.get("href"):
href = a["href"].strip()
if href.startswith(("mailto:", "tel:", "javascript:", "#")):
return
target = urllib.parse.urljoin(self.base_url, href)
self.links.append(urllib.parse.urldefrag(target)[0])
def handle_endtag(self, tag):
if tag == "title" and self._in_title:
self._in_title = False
self.title = " ".join("".join(self._title_parts).split()) or None
def handle_data(self, data):
if self._in_title:
self._title_parts.append(data)
def norm_host(host):
host = host.lower()
return host[4:] if host.startswith("www.") else host
def fetch(url, follow=True):
"""Return (status, final_url, html_or_none, redirect_chain).
redirect_chain is a list of {"from": url, "status": code, "to": location}.
"""
chain = []
class ChainRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
chain.append({"from": req.full_url, "status": code, "to": newurl})
if not follow:
return None
return super().redirect_request(req, fp, code, msg, headers, newurl)
opener = urllib.request.build_opener(ChainRedirect)
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with opener.open(req, timeout=TIMEOUT) as resp:
ctype = resp.headers.get("Content-Type", "")
body = None
if "html" in ctype or "xml" in ctype:
body = resp.read(2_000_000).decode("utf-8", errors="replace")
return resp.status, resp.geturl(), body, chain
except urllib.error.HTTPError as e:
if not follow and e.code in range(300, 400):
loc = e.headers.get("Location")
final = urllib.parse.urljoin(url, loc) if loc else url
return e.code, final, None, chain
return e.code, url, None, chain
except Exception:
return 0, url, None, chain # 0 = network/connection error
def probe_host_variants(start):
"""Probe http/https x www/non-www; return dict and the chosen canonical host."""
parsed = urllib.parse.urlparse(start)
bare = norm_host(parsed.netloc)
variants = {}
for scheme in ("https", "http"):
for host in (bare, "www." + bare):
root = f"{scheme}://{host}/"
status, final_url, _body, _chain = fetch(root, follow=False)
variants[f"{scheme}://{host}"] = {"status": status, "final_url": final_url}
# Canonical host = the 200 variant that the others redirect toward, preferring https.
live_200 = [v for v, info in variants.items()
if info["status"] == 200]
canonical_host = None
for pref in (f"https://{bare}", f"https://www.{bare}",
f"http://{bare}", f"http://www.{bare}"):
if pref in variants and variants[pref]["status"] == 200:
canonical_host = pref
break
if canonical_host is None and live_200:
canonical_host = live_200[0]
if canonical_host is None:
canonical_host = f"{parsed.scheme}://{parsed.netloc}"
return variants, canonical_host
def parse_robots(text):
"""Minimal robots.txt parse: groups of agents with allow/disallow, plus sitemaps.
A run of consecutive User-agent lines shares one group. The first allow/disallow
after a User-agent line closes the run, so the next User-agent starts a new group.
"""
groups = []
sitemaps = []
current = None
last_was_directive = False
for raw_line in text.splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line or ":" not in line:
continue
field, _, value = line.partition(":")
field = field.strip().lower()
value = value.strip()
if field == "sitemap":
if value:
sitemaps.append(value)
continue
if field == "user-agent":
if current is None or last_was_directive:
current = {"agents": [], "disallow": [], "allow": []}
groups.append(current)
current["agents"].append(value)
last_was_directive = False
elif field in ("disallow", "allow") and current is not None:
current[field].append(value)
last_was_directive = True
flat = []
for g in groups:
for agent in g["agents"]:
flat.append({"agent": agent, "disallow": list(g["disallow"]),
"allow": list(g["allow"])})
return flat, sitemaps
def fetch_robots(canonical_host):
url = canonical_host.rstrip("/") + "/robots.txt"
status, _final, body, _chain = fetch(url, follow=True)
if status != 200 or body is None:
# Try a plain fetch in case content-type was not html/xml
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
status = resp.status
body = resp.read(1_000_000).decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return {"found": False, "status": e.code, "sitemaps": [], "rules": [], "raw": ""}
except Exception:
return {"found": False, "status": status, "sitemaps": [], "rules": [], "raw": ""}
rules, sitemaps = parse_robots(body or "")
return {"found": status == 200, "status": status, "sitemaps": sitemaps,
"rules": rules, "raw": (body or "")[:20000]}
def collect_sitemap_urls(seed_sitemaps, canonical_host):
"""Follow sitemap index files and gather all page <loc> URLs."""
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
seeds = list(seed_sitemaps)
if not seeds:
seeds = [canonical_host.rstrip("/") + "/sitemap.xml"]
seen = set()
queue = deque(seeds)
page_urls = []
sources = []
errors = []
found_any = False
while queue and len(seen) < MAX_SITEMAPS:
sm_url = queue.popleft()
if sm_url in seen:
continue
seen.add(sm_url)
status, _final, body, _chain = fetch(sm_url, follow=True)
if status != 200 or not body:
# retry plain
try:
req = urllib.request.Request(sm_url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
status = resp.status
body = resp.read(5_000_000).decode("utf-8", errors="replace")
except Exception:
errors.append({"sitemap": sm_url, "error": f"status {status}"})
continue
try:
root = ET.fromstring(body)
except ET.ParseError as e:
errors.append({"sitemap": sm_url, "error": f"parse error: {e}"})
continue
found_any = True
sources.append(sm_url)
tag = root.tag.lower()
if tag.endswith("sitemapindex"):
for loc in root.iter(SM_NS + "loc"):
if loc.text:
queue.append(loc.text.strip())
else: # urlset
for loc in root.iter(SM_NS + "loc"):
if loc.text:
page_urls.append(loc.text.strip())
# Deduplicate while preserving order
seen_u = set()
deduped = []
for u in page_urls:
if u not in seen_u:
seen_u.add(u)
deduped.append(u)
return {"found": found_any, "sources": sources, "url_count": len(deduped),
"urls": deduped, "errors": errors}
def url_parts(url):
p = urllib.parse.urlparse(url)
path = p.path or "/"
segments = [s for s in path.split("/") if s]
return {"scheme": p.scheme, "host": p.netloc.lower(), "path": path,
"query": p.query, "segments": len(segments)}
def main():
ap = argparse.ArgumentParser(description="Crawl a site and gather architecture signals.")
ap.add_argument("start_url")
ap.add_argument("--max-pages", type=int, default=500)
ap.add_argument("--max-depth", type=int, default=10)
ap.add_argument("--output", default="architecture_inventory.json")
args = ap.parse_args()
start = args.start_url.rstrip("/")
host = norm_host(urllib.parse.urlparse(start).netloc)
print("Probing host/protocol variants...", file=sys.stderr)
host_variants, canonical_host = probe_host_variants(start)
print(f"Canonical host: {canonical_host}", file=sys.stderr)
print("Fetching robots.txt...", file=sys.stderr)
robots = fetch_robots(canonical_host)
print("Collecting sitemap URLs...", file=sys.stderr)
sitemap = collect_sitemap_urls(robots.get("sitemaps", []), canonical_host)
print(f"Sitemap URLs found: {sitemap['url_count']}", file=sys.stderr)
print("Crawling pages...", file=sys.stderr)
pages = {}
queue = deque([(start, 0)])
seen = {start}
while queue and len(pages) < args.max_pages:
url, depth = queue.popleft()
status, final_url, html, chain = fetch(url, follow=True)
parts = url_parts(final_url if status < 400 else url)
page = {
"status": status,
"final_url": final_url,
"redirected": bool(chain),
"redirect_chain": chain,
"depth": depth,
"scheme": parts["scheme"],
"host": parts["host"],
"path": parts["path"],
"query": parts["query"],
"segments": parts["segments"],
"canonical": None,
"meta_robots": None,
"title": None,
"links": [],
}
print(f"[{status}] depth={depth} {url}", file=sys.stderr)
if html:
parser = PageParser(final_url)
try:
parser.feed(html)
except Exception:
pass
page["canonical"] = parser.canonical
page["meta_robots"] = parser.meta_robots
page["title"] = parser.title
internal = []
for target in parser.links:
if not target.startswith(("http://", "https://")):
continue
if norm_host(urllib.parse.urlparse(target).netloc) == host:
internal.append(target)
if target not in seen and depth + 1 <= args.max_depth:
seen.add(target)
queue.append((target, depth + 1))
page["links"] = internal
pages[url] = page
inventory = {
"site": start,
"canonical_host": canonical_host,
"host_variants": host_variants,
"robots_txt": robots,
"sitemap": sitemap,
"pages_crawled": len(pages),
"pages": pages,
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(inventory, f, indent=2, ensure_ascii=False)
print(f"Wrote {len(pages)} pages to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()
Architecture Auditor Script (scripts/audit_architecture.py)
Runs the audit checks against the architecture inventory JSON, with options for click-depth, URL-depth, and tracking-parameter thresholds, and outputs the structured audit report data.
#!/usr/bin/env python3
"""Analyze an architecture inventory JSON (from crawl_architecture.py) and emit audit findings.
Usage:
python3 audit_architecture.py architecture_inventory.json [--output audit_report.json]
[--max-url-depth 4] [--max-click-depth 3] [--tracking-params utm_source,gclid,fbclid]
Checks implemented (see references/audit-checks.md):
- multiple_host_variants: more than one 200 host/protocol variant (high)
- robots_blocks_content: Disallow rules matching linked/indexable pages (high)
- directive_conflicts: disallow+noindex, noindex+canonical-elsewhere, canonical to bad target (high)
- sitemap_non_indexable: sitemap URLs that are non-200/redirect/noindex/non-canonical (high)
- sitemap_missing_pages: indexable pages absent from the sitemap (medium)
- deep_pages / depth_distribution: click depth issues (medium)
- url_quality: uppercase/underscore/space/over-long/over-deep/id-only URLs (medium)
- inconsistent_url_forms: mixed trailing slash, http/https, www in internal links (medium)
- parameter_urls: tracking/session/sort params in internal links (medium)
- missing_robots / missing_sitemap (medium)
- missing_self_canonical (low)
- sitemap_not_in_robots (low)
- thin_directories (low)
- sitemap_over_limit (low)
Uses only the Python standard library.
"""
import argparse
import json
import re
import sys
import urllib.parse
from collections import Counter, defaultdict
SITEMAP_URL_LIMIT = 50000
TRACKING_DEFAULT = ["utm_source", "utm_medium", "utm_campaign", "utm_term",
"utm_content", "gclid", "fbclid", "mc_cid", "mc_eid"]
SESSION_PARAMS = ["sid", "sessionid", "phpsessid", "jsessionid", "aspsessionid"]
ID_SLUG = re.compile(r"^(\d+|[0-9a-f]{12,}|[0-9a-f-]{20,})$", re.I)
def norm_host(host):
host = host.lower()
return host[4:] if host.startswith("www.") else host
def normalize_url(url):
"""Canonical comparison key: lowercase host, strip default ports and fragment, keep path/query."""
p = urllib.parse.urlparse(url)
netloc = p.netloc.lower()
netloc = re.sub(r":(80|443)$", "", netloc)
path = p.path or "/"
return urllib.parse.urlunparse((p.scheme, netloc, path, "", p.query, ""))
def strip_slash(url):
p = urllib.parse.urlparse(url)
path = p.path.rstrip("/") or "/"
return urllib.parse.urlunparse((p.scheme, p.netloc.lower(), path, "", p.query, ""))
def robots_match(path, rules, agent="*"):
"""Return True if path is Disallowed for the given agent (longest-match, Allow wins on ties)."""
applicable = [r for r in rules if r["agent"] == agent]
if not applicable:
applicable = [r for r in rules if r["agent"] == "*"]
if not applicable:
return False
disallow, allow = [], []
for r in applicable:
disallow.extend(r["disallow"])
allow.extend(r["allow"])
def pattern_len(pattern):
if pattern == "":
return None # empty Disallow means allow everything
regex = re.escape(pattern).replace(r"\*", ".*")
if regex.endswith(r"\$"):
regex = regex[:-2] + "$"
m = re.match(regex, path)
return len(pattern) if m else -1
best_dis = max([pattern_len(p) for p in disallow if pattern_len(p) is not None] or [-1])
best_allow = max([pattern_len(p) for p in allow if pattern_len(p) is not None] or [-1])
if best_dis < 0:
return False
return best_dis > best_allow
def is_noindex(meta_robots):
return "noindex" in (meta_robots or "").lower()
def main():
ap = argparse.ArgumentParser(description="Run architecture audit checks on an inventory.")
ap.add_argument("inventory")
ap.add_argument("--output", default="audit_report.json")
ap.add_argument("--max-url-depth", type=int, default=4)
ap.add_argument("--max-click-depth", type=int, default=3)
ap.add_argument("--tracking-params", default="")
args = ap.parse_args()
with open(args.inventory, encoding="utf-8") as f:
inv = json.load(f)
pages = inv["pages"]
host_variants = inv.get("host_variants", {})
robots = inv.get("robots_txt", {})
sitemap = inv.get("sitemap", {})
rules = robots.get("rules", [])
tracking = set(TRACKING_DEFAULT + [t.strip() for t in args.tracking_params.split(",") if t.strip()])
findings = {
"multiple_host_variants": [], "robots_blocks_content": [], "directive_conflicts": [],
"sitemap_non_indexable": [], "sitemap_missing_pages": [],
"deep_pages": [], "url_quality": [], "inconsistent_url_forms": [],
"parameter_urls": [], "missing_robots": [], "missing_sitemap": [],
"missing_self_canonical": [], "sitemap_not_in_robots": [],
"thin_directories": [], "sitemap_over_limit": [],
}
# --- 1. Host/protocol variants -------------------------------------------------
live_200 = [v for v, info in host_variants.items() if info.get("status") == 200]
if len(live_200) > 1:
findings["multiple_host_variants"].append({
"variants_200": live_200,
"canonical_host": inv.get("canonical_host"),
"detail": "More than one host/protocol variant returns 200; redirect all but the canonical host.",
})
# --- robots.txt / sitemap presence --------------------------------------------
if not robots.get("found"):
findings["missing_robots"].append(
{"status": robots.get("status"),
"detail": "robots.txt missing or non-200; serve a valid robots.txt and declare the sitemap."})
if not sitemap.get("found"):
findings["missing_sitemap"].append(
{"detail": "No XML sitemap found at /sitemap.xml or via robots.txt."})
elif not robots.get("sitemaps"):
findings["sitemap_not_in_robots"].append(
{"detail": "A sitemap exists but is not referenced with a Sitemap: line in robots.txt."})
if sitemap.get("url_count", 0) > SITEMAP_URL_LIMIT and len(sitemap.get("sources", [])) <= 1:
findings["sitemap_over_limit"].append(
{"url_count": sitemap["url_count"],
"detail": f"Single sitemap has {sitemap['url_count']} URLs (limit {SITEMAP_URL_LIMIT}); split with a sitemap index."})
# --- Build lookups for indexability -------------------------------------------
# A crawled page is "indexable" if 200, not noindex, and self-canonical (or no canonical).
indexable_keys = {} # normalized self-URL -> requested url
page_by_norm = {} # normalized requested url -> page
canonical_target = {} # normalized requested url -> normalized canonical
status_by_norm = {}
noindex_by_norm = {}
redirected_by_norm = {}
for url, page in pages.items():
nkey = normalize_url(url)
page_by_norm[nkey] = page
status_by_norm[nkey] = page["status"]
noindex_by_norm[nkey] = is_noindex(page.get("meta_robots"))
redirected_by_norm[nkey] = page.get("redirected", False)
canon = page.get("canonical")
if canon:
canonical_target[nkey] = normalize_url(canon)
self_canon = (not canon) or strip_slash(canon) == strip_slash(url)
if page["status"] == 200 and not noindex_by_norm[nkey] and self_canon:
indexable_keys[nkey] = url
# --- 2. robots.txt blocks indexable/linked content ----------------------------
for nkey, url in indexable_keys.items():
path = urllib.parse.urlparse(url).path or "/"
if robots_match(path, rules):
findings["robots_blocks_content"].append(
{"page": url, "detail": "Disallowed in robots.txt but linked and otherwise indexable."})
# --- 3. Directive conflicts ----------------------------------------------------
for url, page in pages.items():
nkey = normalize_url(url)
path = urllib.parse.urlparse(url).path or "/"
noidx = noindex_by_norm[nkey]
canon = page.get("canonical")
if noidx and robots_match(path, rules):
findings["directive_conflicts"].append(
{"page": url, "conflict": "disallow + noindex",
"detail": "Page is Disallowed in robots.txt, so the noindex can never be seen."})
if noidx and canon and strip_slash(canon) != strip_slash(url):
findings["directive_conflicts"].append(
{"page": url, "conflict": "noindex + canonical elsewhere",
"detail": f"noindex contradicts canonical -> {canon}."})
if canon:
ckey = normalize_url(canon)
if ckey in status_by_norm:
if status_by_norm[ckey] >= 400 or status_by_norm[ckey] == 0:
findings["directive_conflicts"].append(
{"page": url, "conflict": "canonical to broken URL",
"detail": f"canonical -> {canon} returns {status_by_norm[ckey]}."})
elif redirected_by_norm.get(ckey):
findings["directive_conflicts"].append(
{"page": url, "conflict": "canonical to redirect",
"detail": f"canonical -> {canon} is itself a redirect."})
elif noindex_by_norm.get(ckey) and ckey != nkey:
findings["directive_conflicts"].append(
{"page": url, "conflict": "canonical to noindex",
"detail": f"canonical -> {canon} is noindex."})
# --- 4 & 5. Sitemap parity -----------------------------------------------------
sitemap_urls = sitemap.get("urls", [])
sitemap_norm = {normalize_url(u): u for u in sitemap_urls}
for nkey, original in sitemap_norm.items():
page = page_by_norm.get(nkey)
if page is None:
continue # not crawled; cannot assess
problem = None
if page["status"] >= 400 or page["status"] == 0:
problem = f"returns {page['status']}"
elif page.get("redirected"):
problem = f"redirects to {page.get('final_url')}"
elif is_noindex(page.get("meta_robots")):
problem = "is noindex"
else:
canon = page.get("canonical")
if canon and strip_slash(canon) != strip_slash(original):
problem = f"canonical points to {canon}"
if problem:
findings["sitemap_non_indexable"].append({"sitemap_url": original, "problem": problem})
sitemap_slash_set = {strip_slash(u) for u in sitemap_urls}
if sitemap.get("found"):
for nkey, url in indexable_keys.items():
if strip_slash(url) not in sitemap_slash_set:
findings["sitemap_missing_pages"].append({"page": url})
# --- 6. Click depth ------------------------------------------------------------
depth_hist = Counter()
beyond = 0
total = 0
for url, page in pages.items():
if page["status"] != 200:
continue
total += 1
d = page["depth"]
depth_hist[d] += 1
if d > args.max_click_depth:
beyond += 1
findings["deep_pages"].append({"page": url, "depth": d})
depth_distribution = {
"histogram": dict(sorted(depth_hist.items())),
"beyond_threshold": beyond,
"share_beyond_threshold": round(beyond / total, 3) if total else 0,
"threshold": args.max_click_depth,
}
# --- 7. URL quality ------------------------------------------------------------
for url, page in pages.items():
if page["status"] >= 400 or page["status"] == 0:
continue
p = urllib.parse.urlparse(url)
path = p.path or "/"
problems = []
if re.search(r"[A-Z]", path):
problems.append("uppercase")
if "_" in path:
problems.append("underscore")
if "%20" in url or " " in path or re.search(r"%[0-9A-Fa-f]{2}", path):
problems.append("space/encoded char")
if len(url) > 115:
problems.append(f"long ({len(url)} chars)")
if page["segments"] > args.max_url_depth:
problems.append(f"deep path ({page['segments']} segments)")
last_seg = [s for s in path.split("/") if s]
if last_seg and ID_SLUG.match(last_seg[-1]):
problems.append("ID-only slug")
if problems:
findings["url_quality"].append({"page": url, "problems": problems})
# --- 8. Inconsistent URL forms (across internal links) ------------------------
link_forms = []
for page in pages.values():
link_forms.extend(page.get("links", []))
slash_paths = defaultdict(set) # path without slash -> set of "slash"/"noslash"
schemes = set()
www_forms = set()
for target in link_forms:
tp = urllib.parse.urlparse(target)
schemes.add(tp.scheme)
www_forms.add(tp.netloc.lower().startswith("www."))
base = tp.path.rstrip("/")
if tp.path.endswith("/") and tp.path != "/":
slash_paths[base].add("slash")
elif tp.path and tp.path != "/":
slash_paths[base].add("noslash")
mixed_slash = [b for b, forms in slash_paths.items() if len(forms) > 1]
if mixed_slash:
findings["inconsistent_url_forms"].append(
{"type": "mixed trailing slash", "examples": mixed_slash[:10],
"count": len(mixed_slash)})
if "http" in schemes and "https" in schemes:
findings["inconsistent_url_forms"].append(
{"type": "mixed http/https in internal links"})
if len(www_forms) > 1:
findings["inconsistent_url_forms"].append(
{"type": "mixed www / non-www in internal links"})
# --- 9. Parameter URLs ---------------------------------------------------------
seen_param = set()
for target in link_forms:
tp = urllib.parse.urlparse(target)
if not tp.query:
continue
keys = {k.lower() for k, _ in urllib.parse.parse_qsl(tp.query)}
cat = None
if keys & tracking:
cat = "tracking parameter in internal link"
elif keys & set(SESSION_PARAMS):
cat = "session id in URL"
else:
cat = "functional parameter (sort/filter/pagination)"
key = (cat, urllib.parse.urlunparse(tp._replace(query="")), tuple(sorted(keys)))
if key in seen_param:
continue
seen_param.add(key)
findings["parameter_urls"].append(
{"url": target, "category": cat, "params": sorted(keys)})
# --- 12. Missing self-canonical ------------------------------------------------
for url, page in pages.items():
if page["status"] == 200 and not is_noindex(page.get("meta_robots")) and not page.get("canonical"):
findings["missing_self_canonical"].append({"page": url})
# --- 14. Directory taxonomy ----------------------------------------------------
section_counts = Counter()
for url, page in pages.items():
if page["status"] != 200:
continue
segs = [s for s in urllib.parse.urlparse(url).path.split("/") if s]
section = "/" + segs[0] if segs else "/"
section_counts[section] += 1
thin = [{"section": s, "pages": c} for s, c in section_counts.items()
if c == 1 and s != "/"]
if thin:
findings["thin_directories"] = thin
severity = {
"multiple_host_variants": "high", "robots_blocks_content": "high",
"directive_conflicts": "high", "sitemap_non_indexable": "high",
"sitemap_missing_pages": "medium", "deep_pages": "medium",
"url_quality": "medium", "inconsistent_url_forms": "medium",
"parameter_urls": "medium", "missing_robots": "medium", "missing_sitemap": "medium",
"missing_self_canonical": "low", "sitemap_not_in_robots": "low",
"thin_directories": "low", "sitemap_over_limit": "low",
}
summary = {
"pages_crawled": inv.get("pages_crawled"),
"canonical_host": inv.get("canonical_host"),
"robots_found": robots.get("found"),
"sitemap_found": sitemap.get("found"),
"sitemap_url_count": sitemap.get("url_count"),
"depth_distribution": depth_distribution,
"section_counts": dict(section_counts.most_common()),
"issues_by_check": {k: len(v) for k, v in findings.items()},
"issues_by_severity": {
level: sum(len(findings[k]) for k, s in severity.items() if s == level)
for level in ("high", "medium", "low")
},
}
report = {"summary": summary, "severity": severity, "findings": findings,
"host_variants": host_variants}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(json.dumps(summary, indent=2))
print(f"Full report written to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()