
Seo Audit
- 84 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
SEO Audit is a Claude skill that runs an 85-point technical SEO audit across crawlability, indexation, Core Web Vitals, on-page and content, with severity-weighted scoring and a prioritized remediation plan.
About
SEO Audit is a technical SEO auditing framework built around an 85-point checklist across 8 dimensions with severity-weighted scoring. It covers crawlability, indexation, Core Web Vitals, on-page optimization, content quality, competitive gap analysis and migration readiness, and produces prioritized remediation plans. It runs in four modes: full site audit, focused single-dimension audit, pre-migration audit, and traffic-drop diagnosis. Marketers and developers use it to find and prioritize the SEO issues holding a site back.
- Runs an 85-point audit across 8 dimensions with severity-weighted scoring
- Covers crawlability, indexation, Core Web Vitals, on-page and competitive gap analysis
- Has dedicated modes for full audit, focused audit, pre-migration and traffic-drop diagnosis
Seo Audit by the numbers
- 84 all-time installs (skills.sh)
- Ranked #1,170 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
seo-audit capabilities & compatibility
Free; uses Google Search Console data if available, no paid keys required.
- Capabilities
- site architecture
- Use cases
- seo · web search
- Pricing
- Free
What seo-audit says it does
Comprehensive technical SEO auditing covering crawlability, indexation, Core Web Vitals, on-page optimization, content quality, and competitive gap analysis.
Production-grade SEO audit framework with an 85-point checklist across 8 dimensions, severity-weighted scoring, automated diagnostic workflows, and prioritized remediation plans.
Emergency diagnostic when organic traffic drops. Follows the traffic drop decision tree to isolate the cause.
npx skills add https://github.com/borghei/claude-skills --skill seo-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 84 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Run an 85-point technical SEO audit with severity scoring and a prioritized remediation plan.
Who is it for?
Auditing a site's technical SEO, diagnosing a traffic drop, or checking migration readiness.
When should I use this skill?
You need a technical SEO audit, are diagnosing an organic traffic drop, or are planning a URL/platform migration.
What you get
A severity-weighted audit score and a prioritized remediation plan across 8 SEO dimensions.
- Severity-weighted audit score
- Prioritized remediation plan
- Core Web Vitals and indexation findings
By the numbers
- 85-point audit checklist
- 8 audit dimensions
- 4 operating modes
Files
SEO Audit
Production-grade SEO audit framework with an 85-point checklist across 8 dimensions, severity-weighted scoring, automated diagnostic workflows, and prioritized remediation plans. Covers technical SEO, on-page optimization, content quality, competitive positioning, and migration readiness.
---
Table of Contents
- Operating Modes
- Initial Assessment
- The 85-Point Audit Checklist
- Severity-Weighted Scoring
- Core Web Vitals Deep Dive
- Crawl and Indexation Analysis
- On-Page SEO Analysis
- Content Quality Assessment
- Competitive Gap Analysis
- Migration Audit Checklist
- Prioritized Remediation Plan
- Output Artifacts
- Related Skills
---
Operating Modes
Mode 1: Full Site Audit
Comprehensive audit across all 8 dimensions. Use for initial assessment or annual reviews.
Mode 2: Focused Audit
Single-dimension deep dive. Use when the problem area is already identified (e.g., "our Core Web Vitals are failing" or "we have indexation issues").
Mode 3: Pre-Migration Audit
Checklist for planned URL changes, platform switches, or redesigns. Establishes baseline and creates the redirect mapping framework.
Mode 4: Traffic Drop Diagnosis
Emergency diagnostic when organic traffic drops. Follows the traffic drop decision tree to isolate the cause.
---
Initial Assessment
Required Context
| Question | Why It Matters |
|---|---|
| What type of site? (SaaS, e-commerce, blog, local business) | Determines which audit dimensions to weight |
| What is the primary SEO goal? | Focuses the audit on business-relevant outcomes |
| What is the current organic traffic baseline? | Establishes the benchmark for measuring improvement |
| Any recent changes? (migration, redesign, content update, algorithm update) | Identifies potential cause of current issues |
| Do you have Google Search Console access? | Essential for indexation and performance data |
| Who are 3 organic competitors? | For competitive gap analysis |
Scope Definition
| Scope | Pages to Audit | Recommended When |
|---|---|---|
| Full site | All indexed pages + sample of non-indexed | Annual audit, new client onboarding |
| Section audit | One directory or content type | Known problem area |
| Top pages | Top 50 by traffic + top 50 by impressions | Quick wins identification |
| New pages | Pages published in last 90 days | Content quality check |
---
The 85-Point Audit Checklist
Dimension 1: Crawlability (12 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 1.1 | robots.txt accessible and valid | Critical | 200 response, no syntax errors |
| 1.2 | Important pages not blocked by robots.txt | Critical | No disallow rules for key pages |
| 1.3 | XML sitemap exists and is valid | High | Submitted in GSC, < 50K URLs per file |
| 1.4 | Sitemap reflects actual site structure | High | No 404s, no redirects, no noindex pages in sitemap |
| 1.5 | Crawl depth < 4 clicks from homepage | Medium | 95%+ of pages within 3 clicks |
| 1.6 | No infinite crawl traps | Critical | No parameter-based infinite loops |
| 1.7 | Internal links use crawlable HTML | High | No JavaScript-only navigation |
| 1.8 | Pagination uses rel=next/prev or load-more | Medium | Paginated content is crawlable |
| 1.9 | No orphan pages (indexed but no internal links) | High | 0 orphan pages in indexed set |
| 1.10 | Crawl budget not wasted on low-value pages | Medium | < 10% crawl budget on utility pages |
| 1.11 | Server response time < 500ms | High | TTFB < 500ms for 95% of pages |
| 1.12 | No 5xx errors in crawl | Critical | 0% server errors |
Dimension 2: Indexation (10 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 2.1 | Target pages are indexed | Critical | > 95% of target pages in Google index |
| 2.2 | No duplicate content issues | High | No duplicate titles, no near-duplicate content |
| 2.3 | Canonical tags implemented correctly | High | Self-referencing on unique pages, cross-domain where needed |
| 2.4 | No conflicting signals (canonical vs noindex vs sitemap) | Critical | No page that is both noindex and in sitemap |
| 2.5 | Hreflang tags correct (if multilingual) | High | Valid hreflang with return tags |
| 2.6 | No index bloat (unnecessary pages indexed) | Medium | Utility pages not indexed |
| 2.7 | Thin content pages identified | High | No pages with < 200 words of unique content |
| 2.8 | Parameter handling configured | Medium | URL parameters handled in GSC or via canonical |
| 2.9 | JavaScript-rendered content indexable | High | Key content visible in cached/rendered version |
| 2.10 | New pages getting indexed within 7 days | Medium | Using IndexNow or manual submission |
Dimension 3: Core Web Vitals (10 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 3.1 | LCP < 2.5s | High | 75th percentile of page loads |
| 3.2 | INP < 200ms | High | 75th percentile of interactions |
| 3.3 | CLS < 0.1 | High | 75th percentile of page loads |
| 3.4 | Mobile CWV passing | Critical | Mobile scores meeting thresholds |
| 3.5 | Desktop CWV passing | Medium | Desktop scores meeting thresholds |
| 3.6 | No render-blocking resources | Medium | CSS/JS delivery optimized |
| 3.7 | Images optimized (WebP/AVIF, lazy loading) | Medium | All above-fold images preloaded |
| 3.8 | Font loading optimized | Low | No FOUT/FOIT, font-display: swap |
| 3.9 | Third-party script impact measured | Medium | No third-party scripts adding > 500ms |
| 3.10 | HTTPS with no mixed content | Critical | All resources served over HTTPS |
Dimension 4: On-Page SEO (15 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 4.1 | Unique title tags on every page | Critical | No duplicates, < 60 chars |
| 4.2 | Title includes primary keyword | High | Keyword in first 60 chars |
| 4.3 | Unique meta descriptions | High | No duplicates, < 155 chars, includes keyword |
| 4.4 | H1 tag present and unique per page | High | One H1 per page with primary keyword |
| 4.5 | Heading hierarchy logical (H1 > H2 > H3) | Medium | No skipped levels |
| 4.6 | Internal links with descriptive anchor text | High | No "click here" or naked URLs |
| 4.7 | Images have alt text | Medium | Descriptive alt text on all meaningful images |
| 4.8 | URL structure is clean and descriptive | Medium | No IDs, parameters, or excessive depth |
| 4.9 | Primary keyword in first 100 words | Medium | Natural inclusion in opening |
| 4.10 | Content matches search intent | Critical | Page type matches what Google ranks for query |
| 4.11 | No keyword cannibalization | High | No two pages targeting the same primary keyword |
| 4.12 | Open Graph and Twitter Card tags | Low | Social sharing metadata present |
| 4.13 | Structured data implemented | Medium | Relevant schema types present |
| 4.14 | Internal links to relevant pages | High | 3-5 contextual internal links per content page |
| 4.15 | Breadcrumbs present | Medium | Functional breadcrumbs with schema |
Dimension 5: Content Quality (12 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 5.1 | Content provides unique value | Critical | Not available elsewhere in same form |
| 5.2 | Content depth matches intent | High | Comprehensive coverage of the topic |
| 5.3 | Content freshness appropriate | Medium | Updated within last 12 months for evergreen |
| 5.4 | No AI-generated content markers | High | No generic filler, overused phrases, or em-dash patterns |
| 5.5 | E-E-A-T signals present | High | Author attribution, credentials, experience evidence |
| 5.6 | Content readability appropriate | Medium | Matches target audience reading level |
| 5.7 | No thin content pages | High | All pages > 300 words of unique content |
| 5.8 | No content duplication across pages | High | Jaccard similarity < 50% between any two pages |
| 5.9 | Content supports conversion goal | Medium | Clear CTA aligned with page intent |
| 5.10 | Visual content present | Medium | Images, charts, or videos enhance understanding |
| 5.11 | Content organized with subheadings | Medium | Scannable structure with clear sections |
| 5.12 | External references and citations | Low | Links to authoritative sources where relevant |
Dimension 6: Technical Infrastructure (10 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 6.1 | HTTPS properly configured | Critical | Valid certificate, no mixed content |
| 6.2 | Proper redirects (301 not 302) | High | Permanent redirects for permanent moves |
| 6.3 | No redirect chains (> 2 hops) | Medium | Direct redirect from origin to destination |
| 6.4 | No broken internal links (404s) | High | 0 broken internal links |
| 6.5 | No broken external links | Low | < 5% broken outbound links |
| 6.6 | Mobile-responsive design | Critical | Passes Google's mobile-friendly test |
| 6.7 | Proper 404 page | Low | Custom 404 with navigation and search |
| 6.8 | Server uptime > 99.9% | Critical | Monitoring in place |
| 6.9 | CDN configured for global audience | Medium | If international traffic > 20% |
| 6.10 | Security headers present | Low | HSTS, CSP, X-Frame-Options |
Dimension 7: Off-Page Signals (8 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 7.1 | Backlink profile health | High | No toxic link patterns |
| 7.2 | Referring domain diversity | Medium | > 50 unique referring domains |
| 7.3 | Anchor text distribution natural | Medium | < 30% exact-match anchors |
| 7.4 | No manual actions in GSC | Critical | Clean manual actions report |
| 7.5 | Google Business Profile (if local) | High | Claimed, verified, complete |
| 7.6 | Social profiles linked | Low | Active profiles on major platforms |
| 7.7 | Brand mentions without links | Low | Unlinked mentions as link opportunities |
| 7.8 | Competitor link gap identified | Medium | Top competitors' link sources mapped |
Dimension 8: Analytics and Tracking (8 points)
| # | Check | Severity | Pass Criteria |
|---|---|---|---|
| 8.1 | Google Search Console verified | Critical | All properties verified |
| 8.2 | GA4 properly configured | High | Events tracking, no duplicate tags |
| 8.3 | Conversion tracking in place | High | Key conversions tracked |
| 8.4 | GSC and GA4 linked | Medium | Data flowing between tools |
| 8.5 | Bing Webmaster Tools configured | Low | Verified and sitemap submitted |
| 8.6 | Search Console coverage report clean | High | No unexpected errors |
| 8.7 | Core Web Vitals field data available | Medium | Enough traffic for CrUX data |
| 8.8 | UTM parameter strategy | Low | Consistent campaign tracking |
---
Severity-Weighted Scoring
Scoring Formula
Total Score = Sum of (Dimension Weight x Dimension Pass Rate)
Dimension Pass Rate = (Passed Checks x Severity Multiplier) / (Total Checks x Severity Multiplier)
Severity Multipliers:
Critical = 4x
High = 3x
Medium = 2x
Low = 1xOverall Health Grades
| Score | Grade | Assessment |
|---|---|---|
| 90-100 | A | Excellent -- focus on competitive edge |
| 80-89 | B | Good -- fix remaining high-priority items |
| 70-79 | C | Fair -- significant improvements available |
| 60-69 | D | Poor -- critical issues blocking performance |
| < 60 | F | Failing -- foundational problems require immediate attention |
---
Core Web Vitals Deep Dive
LCP Optimization Priority Stack
| Cause | Detection | Fix | Impact |
|---|---|---|---|
| Slow server response | TTFB > 600ms | CDN, server upgrade, caching | High |
| Render-blocking CSS/JS | PageSpeed Insights flags | Inline critical CSS, defer JS | High |
| Large hero image | LCP element is an image > 500KB | WebP/AVIF, responsive sizes, preload | High |
| Client-side rendering | LCP requires JS execution | SSR or prerendering | Medium |
| Web font blocking | FOUT delays LCP text | font-display: swap, preload fonts | Medium |
INP Optimization Priority Stack
| Cause | Detection | Fix | Impact |
|---|---|---|---|
| Long JavaScript tasks | Chrome DevTools Performance tab | Break into smaller tasks, use requestIdleCallback | High |
| Heavy event handlers | Slow click/scroll responses | Debounce, optimize handler code | High |
| Main thread blocking | Third-party scripts | Defer or lazy-load third-party JS | Medium |
| Layout thrashing | Forced reflows on interaction | Batch DOM reads/writes | Medium |
CLS Optimization Priority Stack
| Cause | Detection | Fix | Impact |
|---|---|---|---|
| Images without dimensions | CLS in PageSpeed Insights | Add width/height attributes | High |
| Dynamic content injection | Ads, embeds loading late | Reserve space with CSS aspect-ratio | High |
| Web fonts causing reflow | Text shifts when font loads | font-display: optional or swap | Medium |
| Late-loading CSS | Styles applied after render | Inline critical CSS | Medium |
---
Crawl and Indexation Analysis
Indexation Gap Analysis
Compare these three data sets to find gaps:
Set A: Pages in XML sitemap (your intended index)
Set B: Pages indexed in Google (site: query + GSC Coverage)
Set C: Pages receiving organic traffic (GSC Performance)
A - B = Submitted but not indexed (quality or crawl issue)
B - A = Indexed but not in sitemap (orphan or forgotten pages)
B - C = Indexed but receiving zero traffic (rank 100+ or deindexed from results)Traffic Drop Decision Tree
Traffic dropped →
├── Sitewide drop?
│ ├── Yes → Check for manual action in GSC
│ │ Check for algorithm update (timeline match?)
│ │ Check for robots.txt or noindex changes
│ │ Check server uptime/5xx errors
│ └── No (specific pages/sections) →
│ ├── Check for cannibalization (new page stealing from old)
│ ├── Check for content freshness (competitor updated, you didn't)
│ ├── Check for lost backlinks to those pages
│ └── Check for SERP feature changes (new featured snippet, AI overview)
└── Gradual decline vs sudden drop?
├── Sudden → Technical issue or algorithm update
└── Gradual → Content decay, competitive pressure, or authority erosion---
On-Page SEO Analysis
Keyword Cannibalization Detection
Two or more pages targeting the same keyword compete with each other. Detection method:
1. Export all pages + their primary keyword from GSC (queries with most impressions per page) 2. Group by keyword -- any keyword assigned to 2+ pages is cannibalized 3. Check which page ranks higher for each cannibalized keyword 4. Action: Consolidate (merge into one page), differentiate (change one page's target), or canonical (point one to the other)
Intent Match Scoring
For each target keyword, verify that your page type matches what Google actually ranks:
| Google Ranks | Your Page Type | Match? | Action |
|---|---|---|---|
| Listicles | Single product page | No | Create a listicle targeting this keyword |
| How-to guides | Blog opinion piece | No | Restructure as how-to |
| Comparison tables | Feature page | Partial | Add comparison elements |
| Videos | Text-only page | No | Add video or target a different keyword |
---
Content Quality Assessment
AI Content Detection Signals
Watch for these patterns that signal low-quality AI-generated content:
| Signal | Example | Fix |
|---|---|---|
| Excessive em dashes | "The tool -- which is powerful -- delivers results" | Use commas or periods |
| Filler hedging | "It's important to note that..." | Delete and get to the point |
| Generic superlatives | "This groundbreaking, cutting-edge solution" | Use specific, evidence-based language |
| Perfect paragraph symmetry | Every section has exactly 3 paragraphs of 4 sentences | Vary structure naturally |
| No first-person experience | Zero personal anecdotes or experience markers | Add genuine experience signals |
| Overused transitions | "Furthermore", "Moreover", "Additionally" at every paragraph | Vary connectors or remove them |
E-E-A-T Scoring
| Signal | Weight | Check |
|---|---|---|
| Author byline with credentials | High | Named author with bio and expertise |
| Author page with sameAs links | High | Links to LinkedIn, publications |
| Original research or data | High | First-party data, surveys, experiments |
| Experience evidence | High | Screenshots, photos, personal narrative |
| Citations to authoritative sources | Medium | Links to primary sources |
| Publication date and update date | Medium | Visible and accurate |
| About page with team credentials | Medium | Company expertise established |
| External reviews and mentions | Low | Third-party validation |
---
Competitive Gap Analysis
Framework
For each of your top 3 competitors, compare:
| Dimension | Your Site | Competitor 1 | Competitor 2 | Competitor 3 |
|---|---|---|---|---|
| Domain Rating | ||||
| Indexed pages | ||||
| Organic keywords (top 100) | ||||
| Estimated organic traffic | ||||
| Content publishing frequency | ||||
| Avg content depth (word count) | ||||
| Referring domains | ||||
| Top-performing content types |
Keyword Gap Priority Matrix
| Gap Type | Description | Priority |
|---|---|---|
| Uncontested | Keyword with volume, no competitor ranks | Highest |
| Weak competition | You could realistically rank page 1 | High |
| Content gap | Competitor has content, you don't | Medium |
| Quality gap | Both have content, theirs ranks better | Medium |
| Authority gap | Competitor's DR advantage is the barrier | Lower (long-term) |
---
Migration Audit Checklist
Pre-Migration (2-4 weeks before)
- [ ] Crawl entire current site and export all URLs
- [ ] Map every old URL to its new URL (1:1 redirect mapping)
- [ ] Export current rankings and organic traffic baseline
- [ ] Document all existing 301 redirects (chain them to new destinations)
- [ ] Verify robots.txt will be correct on new site
- [ ] Verify XML sitemap will be updated
- [ ] Test new site in staging environment
- [ ] Verify all schema markup transfers to new templates
During Migration
- [ ] Implement all 301 redirects
- [ ] Submit updated XML sitemap to GSC
- [ ] Verify robots.txt is not blocking critical pages
- [ ] Test 50 sample redirects for correctness
- [ ] Monitor server errors in real-time
Post-Migration (2-8 weeks after)
- [ ] Monitor GSC Coverage report daily for 2 weeks
- [ ] Track organic traffic vs. baseline daily
- [ ] Check for crawl errors in GSC
- [ ] Verify old URLs redirect correctly (batch test)
- [ ] Monitor rankings for top 50 keywords
- [ ] Check for broken internal links on new site
- [ ] Verify analytics tracking is firing correctly
---
Prioritized Remediation Plan
Priority Framework
Every finding gets classified:
| Priority | Criteria | Timeline |
|---|---|---|
| P0 Emergency | Blocking indexation or causing active ranking loss | Fix today |
| P1 Critical | Major negative impact on rankings or traffic | Fix this week |
| P2 High | Significant improvement potential | Fix this month |
| P3 Medium | Moderate improvement, standard best practice | Fix this quarter |
| P4 Low | Minor improvement or nice-to-have | Backlog |
Remediation Plan Template
| # | Finding | Dimension | Severity | Current State | Recommended Fix | Expected Impact | Effort |
|---|---|---|---|---|---|---|---|
| 1 | |||||||
| 2 |
---
Output Artifacts
| Artifact | Format | Description |
|---|---|---|
| Executive Summary | 3-5 bullet points | Overall grade, top issues, quick wins |
| Full Audit Report | 85-point checklist | Pass/fail per check with evidence |
| Severity Scorecard | Weighted score table | Per-dimension scores and overall grade |
| Prioritized Fix List | Ranked table | Every finding with severity, fix, effort, impact |
| Keyword Cannibalization Map | Table | Pages competing for same keywords with resolution action |
| Competitive Gap Report | Comparison matrix | Your site vs. 3 competitors across 8 dimensions |
| Migration Checklist | Checkbox list | Pre/during/post migration tasks |
---
Related Skills
- programmatic-seo -- Use when audit reveals keyword gap clusters that could be addressed with template-based page generation at scale.
- schema-markup -- Use when audit reveals missing or broken structured data opportunities.
- site-architecture -- Use when audit uncovers structural issues (orphan pages, deep nesting, poor internal linking) requiring architectural redesign.
- content-creator -- Use when audit reveals content quality issues requiring editorial improvement.
- competitor-alternatives -- Use when competitive gap analysis reveals positioning opportunities for comparison content.
---
Troubleshooting
| Problem | Likely Cause | Fix |
|---|---|---|
| Sitewide traffic drop after algorithm update | Core update changed quality thresholds or E-E-A-T weight | Run full E-E-A-T audit, compare content against newly ranking competitors, add experience signals |
| Pages indexed but receiving zero impressions | Keyword cannibalization or content quality below ranking threshold | Run cannibalization detection, consolidate or differentiate competing pages |
| Core Web Vitals failing on mobile only | Third-party scripts or unoptimized images loading on mobile viewport | Defer non-critical JS, serve responsive images, audit third-party script impact |
| Crawl budget exhausted on low-value pages | Parameter URLs, tag archives, or pagination consuming crawl resources | Noindex thin pages, consolidate parameters, block faceted navigation in robots.txt |
| Manual action in Search Console | Unnatural links, thin content, or structured data misuse detected | Follow Google's reconsideration process — fix all flagged issues, document changes, submit review |
| Rankings dropped for specific pages only | Competitor published stronger content or lost key backlinks | Check backlink profile changes, compare content depth against new page 1 results |
| INP failing despite fast server response | Long JavaScript tasks blocking main thread on user interaction | Break JS into smaller tasks, defer non-critical scripts, audit event handler performance |
---
Success Criteria
- Overall audit score: Achieve grade B (80+) or higher on the severity-weighted scoring system
- Zero critical failures: All Critical-severity checks passing across all 8 dimensions
- Core Web Vitals pass rate: All three metrics (LCP < 2.5s, INP < 200ms, CLS < 0.1) passing at 75th percentile — benchmark: only 47-55% of sites pass as of 2026
- Indexation coverage: 95%+ of target pages indexed with zero conflicting signals (canonical vs noindex vs sitemap)
- Crawl error rate: Zero 5xx errors and fewer than 0.5% 4xx errors in the indexed page set
- Organic CTR: Position 1 pages achieving 25%+ CTR, position 2-3 achieving 10%+ (2026 benchmarks: pos 1 avg 27.6%, pos 2 avg 15.8%)
- Remediation velocity: All P0 emergency findings fixed within 24 hours, P1 critical within 7 days
---
Scope & Limitations
In scope:
- Technical SEO across all 8 audit dimensions (crawlability, indexation, CWV, on-page, content quality, infrastructure, off-page, analytics)
- Severity-weighted scoring with prioritized remediation plans
- Traffic drop diagnosis and root cause analysis
- Competitive gap analysis across technical and content dimensions
- Pre-migration and post-migration audit checklists
- AI content quality detection signals
Out of scope:
- Content creation or rewriting (use Content Creator)
- Structured data implementation (use Schema Markup)
- Site architecture redesign (use Site Architecture)
- Link building execution (audit identifies gaps, not outreach)
- Paid search or advertising campaign audits
- Server infrastructure provisioning or CDN setup
Known limitations:
- Field CWV data requires sufficient traffic volume for CrUX reporting — low-traffic sites must rely on lab data
- Off-page signals (backlinks, brand mentions) require third-party tools (Ahrefs, SEMrush) for comprehensive data
- AI Overview impact on CTR varies by query type — no universal benchmark exists
- Google's algorithm changes 500-600 times per year; audit findings reflect a point-in-time snapshot
---
Integration Points
- AI SEO -- Run after audit to optimize for AI search citation alongside traditional SEO findings.
- Schema Markup -- Use when audit reveals missing or broken structured data opportunities.
- Site Architecture -- Use when audit uncovers structural issues requiring architectural redesign.
- Content Humanizer -- Use when audit flags AI content detection signals on key pages.
- Content Strategy -- Use when audit reveals content gaps or pillar coverage weaknesses.
---
Scripts
# Check redirect chains and status codes
python scripts/redirect_checker.py --url https://example.com/old-page --json
# Analyze XML sitemap for errors
python scripts/sitemap_analyzer.py --sitemap https://example.com/sitemap.xml
# Score content quality for SEO
python scripts/content_scorer.py article.md --json#!/usr/bin/env python3
"""
SEO Content Quality Scorer
Scores content across SEO dimensions including keyword usage, readability,
structure, E-E-A-T signals, AI content detection markers, and on-page
optimization. Produces a weighted score 0-100 with actionable recommendations.
Usage:
python content_scorer.py article.md
python content_scorer.py article.md --keyword "cloud cost optimization" --json
python content_scorer.py article.md --keyword "SEO audit" --verbose
"""
import argparse
import json
import math
import re
import sys
from pathlib import Path
def count_words(text):
"""Count words in text."""
return len(text.split())
def count_sentences(text):
"""Count sentences in text."""
sentences = re.split(r'[.!?]+', text)
return len([s for s in sentences if s.strip()])
def count_syllables(word):
"""Estimate syllable count for a word."""
word = word.lower().strip()
if len(word) <= 3:
return 1
count = 0
vowels = 'aeiouy'
prev_vowel = False
for char in word:
is_vowel = char in vowels
if is_vowel and not prev_vowel:
count += 1
prev_vowel = is_vowel
if word.endswith('e'):
count -= 1
if word.endswith('le') and len(word) > 2 and word[-3] not in vowels:
count += 1
return max(count, 1)
def flesch_reading_ease(text):
"""Calculate Flesch Reading Ease score."""
words = text.split()
word_count = len(words)
sentence_count = count_sentences(text)
if word_count == 0 or sentence_count == 0:
return 0
syllable_count = sum(count_syllables(w) for w in words)
score = 206.835 - 1.015 * (word_count / sentence_count) - 84.6 * (syllable_count / word_count)
return round(max(0, min(100, score)), 1)
def analyze_keyword_usage(text, keyword):
"""Analyze keyword placement and density."""
if not keyword:
return {"score": 0, "checks": {}, "detail": "No keyword provided"}
text_lower = text.lower()
keyword_lower = keyword.lower()
words = text_lower.split()
word_count = len(words)
checks = {}
# Keyword density
keyword_count = text_lower.count(keyword_lower)
density = (keyword_count * len(keyword_lower.split()) / max(word_count, 1)) * 100
checks["density"] = {
"value": round(density, 2),
"pass": 0.5 <= density <= 2.5,
"detail": f"Keyword density: {round(density, 2)}% (target: 0.5-2.5%)",
}
# Keyword in first 100 words
first_100 = " ".join(words[:100])
in_first_100 = keyword_lower in first_100
checks["in_first_100_words"] = {
"pass": in_first_100,
"detail": "Keyword found in first 100 words" if in_first_100
else "Keyword NOT in first 100 words",
}
# Keyword in headings
headings = re.findall(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
headings_with_kw = sum(1 for h in headings if keyword_lower in h.lower())
checks["in_headings"] = {
"count": headings_with_kw,
"total_headings": len(headings),
"pass": headings_with_kw >= 1,
"detail": f"Keyword in {headings_with_kw}/{len(headings)} headings",
}
# Keyword in H1
h1s = re.findall(r'^#\s+(.+)', text, re.MULTILINE)
in_h1 = any(keyword_lower in h.lower() for h in h1s)
checks["in_h1"] = {
"pass": in_h1,
"detail": "Keyword found in H1" if in_h1 else "Keyword NOT in H1",
}
passed = sum(1 for c in checks.values() if c["pass"])
score = (passed / len(checks)) * 100
return {"score": round(score, 1), "checks": checks}
def analyze_structure(text):
"""Analyze content structure for SEO."""
checks = {}
lines = text.splitlines()
word_count = count_words(text)
# H1 presence
h1s = re.findall(r'^#\s+', text, re.MULTILINE)
checks["h1_present"] = {
"pass": len(h1s) == 1,
"count": len(h1s),
"detail": f"{len(h1s)} H1 tag(s) — should be exactly 1",
}
# H2 sections
h2s = re.findall(r'^##\s+', text, re.MULTILINE)
checks["h2_sections"] = {
"pass": len(h2s) >= 3,
"count": len(h2s),
"detail": f"{len(h2s)} H2 sections found",
}
# Heading hierarchy (no skipped levels)
heading_levels = [len(m.group(1)) for m in re.finditer(r'^(#{1,6})\s+', text, re.MULTILINE)]
skipped = False
for i in range(1, len(heading_levels)):
if heading_levels[i] > heading_levels[i-1] + 1:
skipped = True
break
checks["heading_hierarchy"] = {
"pass": not skipped,
"detail": "Heading hierarchy is clean" if not skipped
else "Heading levels are skipped (e.g., H2 to H4)",
}
# Word count
checks["word_count"] = {
"value": word_count,
"pass": word_count >= 800,
"detail": f"{word_count} words (minimum 800 for comprehensive content)",
}
# Paragraph length
paragraphs = [p for p in re.split(r'\n\s*\n', text) if p.strip() and not p.strip().startswith('#')]
long_paragraphs = sum(1 for p in paragraphs if count_words(p) > 150)
checks["paragraph_length"] = {
"pass": long_paragraphs == 0,
"long_count": long_paragraphs,
"detail": f"{long_paragraphs} paragraphs exceed 150 words",
}
# Lists present
list_items = len(re.findall(r'^\s*[\-\*\d+\.]\s+', text, re.MULTILINE))
checks["lists_present"] = {
"pass": list_items >= 3,
"count": list_items,
"detail": f"{list_items} list items found",
}
# Tables present
table_rows = len(re.findall(r'^\s*\|.*\|', text, re.MULTILINE))
checks["tables_present"] = {
"pass": table_rows >= 3,
"count": table_rows,
"detail": f"{table_rows} table rows found",
}
passed = sum(1 for c in checks.values() if c["pass"])
score = (passed / len(checks)) * 100
return {"score": round(score, 1), "checks": checks}
def analyze_eeat(text):
"""Analyze E-E-A-T signals in content."""
checks = {}
# Author attribution
has_author = bool(re.search(
r'(author|written by|by\s+[A-Z][a-z]+\s+[A-Z])',
text, re.IGNORECASE
))
checks["author_attribution"] = {
"pass": has_author,
"detail": "Author attribution found" if has_author else "No author attribution",
}
# Date signals
has_dates = bool(re.search(r'(published|updated|modified|date).*\d{4}', text, re.IGNORECASE))
checks["date_signals"] = {
"pass": has_dates,
"detail": "Publication date signals found" if has_dates else "No date signals",
}
# Source citations
citations = len(re.findall(
r'(according to|source:|cited from|published in|\(\d{4}\)|reported by)',
text, re.IGNORECASE
))
checks["source_citations"] = {
"pass": citations >= 2,
"count": citations,
"detail": f"{citations} source citations found",
}
# External links
ext_links = len(re.findall(r'\[.*?\]\(https?://[^)]+\)', text))
checks["external_references"] = {
"pass": ext_links >= 1,
"count": ext_links,
"detail": f"{ext_links} external links found",
}
# Experience markers
experience_markers = len(re.findall(
r'\b(we found|we tested|we noticed|in our experience|I\'ve seen|'
r'we observed|our data shows|in practice|real-world)\b',
text, re.IGNORECASE
))
checks["experience_signals"] = {
"pass": experience_markers >= 1,
"count": experience_markers,
"detail": f"{experience_markers} first-hand experience markers found",
}
passed = sum(1 for c in checks.values() if c["pass"])
score = (passed / len(checks)) * 100
return {"score": round(score, 1), "checks": checks}
def analyze_ai_patterns(text):
"""Detect AI content patterns that may reduce SEO performance."""
checks = {}
word_count = count_words(text)
# AI filler words
filler_words = [
'delve', 'landscape', 'crucial', 'vital', 'pivotal', 'leverage',
'robust', 'comprehensive', 'holistic', 'foster', 'facilitate',
'utilize', 'furthermore', 'moreover', 'streamline', 'cutting-edge',
'game-changer', 'paradigm', 'synergy', 'ecosystem', 'transformative',
'seamless', 'navigate', 'embark',
]
filler_count = sum(
len(re.findall(r'\b' + w + r'\b', text, re.IGNORECASE))
for w in filler_words
)
per_1000 = (filler_count / max(word_count, 1)) * 1000
checks["ai_filler_density"] = {
"pass": per_1000 < 3,
"count": filler_count,
"per_1000": round(per_1000, 1),
"detail": f"{filler_count} AI filler words ({round(per_1000, 1)} per 1000 words)",
}
# Hedging phrases
hedging = len(re.findall(
r"(it'?s important to note|it'?s worth mentioning|one might argue|"
r"it goes without saying|needless to say)",
text, re.IGNORECASE
))
checks["hedging_phrases"] = {
"pass": hedging == 0,
"count": hedging,
"detail": f"{hedging} hedging phrases found",
}
# Sentence length uniformity
sentences = [s.strip() for s in re.split(r'[.!?]+', text) if s.strip()]
if len(sentences) >= 5:
lengths = [len(s.split()) for s in sentences]
avg = sum(lengths) / len(lengths)
variance = sum((l - avg) ** 2 for l in lengths) / len(lengths)
std_dev = math.sqrt(variance)
checks["sentence_variety"] = {
"pass": std_dev > 4,
"std_dev": round(std_dev, 1),
"detail": f"Sentence length std dev: {round(std_dev, 1)} (>4 is natural variety)",
}
else:
checks["sentence_variety"] = {
"pass": True,
"detail": "Not enough sentences to analyze variety",
}
passed = sum(1 for c in checks.values() if c["pass"])
score = (passed / len(checks)) * 100
return {"score": round(score, 1), "checks": checks}
def calculate_overall(keyword_score, structure_score, eeat_score, ai_score, readability):
"""Calculate weighted overall SEO content score."""
# Readability factor (target 60-70)
readability_score = 100 if 50 <= readability <= 80 else max(0, 100 - abs(readability - 65) * 2)
weighted = (
keyword_score * 0.25 +
structure_score * 0.25 +
eeat_score * 0.20 +
ai_score * 0.15 +
readability_score * 0.15
)
return round(weighted, 1)
def main():
parser = argparse.ArgumentParser(
description="Score content quality for SEO"
)
parser.add_argument("file", help="Path to content file (Markdown or HTML)")
parser.add_argument("--keyword", help="Target keyword to analyze placement")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--verbose", action="store_true", help="Show all check details")
args = parser.parse_args()
filepath = Path(args.file)
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
text = filepath.read_text(encoding="utf-8", errors="replace")
word_count = count_words(text)
readability = flesch_reading_ease(text)
keyword_analysis = analyze_keyword_usage(text, args.keyword)
structure_analysis = analyze_structure(text)
eeat_analysis = analyze_eeat(text)
ai_analysis = analyze_ai_patterns(text)
overall = calculate_overall(
keyword_analysis["score"],
structure_analysis["score"],
eeat_analysis["score"],
ai_analysis["score"],
readability,
)
grade = "A" if overall >= 85 else "B" if overall >= 70 else "C" if overall >= 55 else "D" if overall >= 40 else "F"
result = {
"file": str(filepath),
"word_count": word_count,
"readability_score": readability,
"overall_score": overall,
"grade": grade,
"keyword_analysis": keyword_analysis,
"structure_analysis": structure_analysis,
"eeat_analysis": eeat_analysis,
"ai_pattern_analysis": ai_analysis,
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"\n{'='*60}")
print(f" SEO CONTENT SCORE: {overall}/100 (Grade: {grade})")
print(f"{'='*60}")
print(f" File: {filepath}")
print(f" Words: {word_count}")
print(f" Readability: {readability} (Flesch Reading Ease)")
if args.keyword:
print(f" Keyword: {args.keyword}")
print()
print(f" Keyword Usage: {keyword_analysis['score']}/100")
print(f" Structure: {structure_analysis['score']}/100")
print(f" E-E-A-T: {eeat_analysis['score']}/100")
print(f" AI Patterns: {ai_analysis['score']}/100")
if args.verbose:
for label, analysis in [
("Keyword", keyword_analysis),
("Structure", structure_analysis),
("E-E-A-T", eeat_analysis),
("AI Patterns", ai_analysis),
]:
print(f"\n --- {label} Details ---")
for key, check in analysis.get("checks", {}).items():
status = "PASS" if check.get("pass") else "FAIL"
print(f" [{status}] {key}: {check['detail']}")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Redirect Chain Checker
Checks URLs for redirect chains, status codes, and common redirect issues.
Identifies chains longer than 2 hops, mixed HTTP/HTTPS redirects, redirect
loops, and temporary (302) redirects that should be permanent (301).
Usage:
python redirect_checker.py --url https://example.com/old-page
python redirect_checker.py --file urls.txt --json
python redirect_checker.py --url https://example.com/page --max-hops 10
"""
import argparse
import json
import re
import sys
from pathlib import Path
from urllib.parse import urlparse, urljoin
try:
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
HAS_URLLIB = True
except ImportError:
HAS_URLLIB = False
def normalize_url(url):
"""Ensure URL has a scheme."""
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
return url.rstrip('/')
def simulate_redirect_check(url, max_hops=10):
"""
Check a URL for redirect chains.
Uses urllib to follow redirects manually, recording each hop.
Falls back to simulation if network is unavailable.
"""
url = normalize_url(url)
chain = []
visited = set()
current_url = url
issues = []
if HAS_URLLIB:
for hop in range(max_hops + 1):
if current_url in visited:
issues.append({
"type": "redirect_loop",
"severity": "Critical",
"detail": f"Redirect loop detected at {current_url}",
})
break
visited.add(current_url)
try:
req = Request(current_url, method='HEAD')
req.add_header('User-Agent', 'SEO-Audit-Bot/1.0')
import http.client
# Use lower-level to avoid auto-redirect
parsed = urlparse(current_url)
if parsed.scheme == 'https':
import ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(parsed.netloc, timeout=10, context=ctx)
else:
conn = http.client.HTTPConnection(parsed.netloc, timeout=10)
path = parsed.path or '/'
if parsed.query:
path += '?' + parsed.query
conn.request('HEAD', path, headers={'User-Agent': 'SEO-Audit-Bot/1.0'})
resp = conn.getresponse()
status = resp.status
chain.append({
"hop": hop + 1,
"url": current_url,
"status": status,
})
if status in (301, 302, 303, 307, 308):
location = resp.getheader('Location', '')
if location:
# Handle relative redirects
next_url = urljoin(current_url, location)
# Check for HTTP to HTTPS or vice versa
if urlparse(current_url).scheme != urlparse(next_url).scheme:
issues.append({
"type": "scheme_change",
"severity": "Medium",
"detail": f"Scheme change: {urlparse(current_url).scheme} -> {urlparse(next_url).scheme} at hop {hop + 1}",
})
# Flag 302 that should be 301
if status == 302:
issues.append({
"type": "temporary_redirect",
"severity": "High",
"detail": f"302 temporary redirect at hop {hop + 1} — should this be 301 permanent?",
})
current_url = next_url
else:
issues.append({
"type": "missing_location",
"severity": "Critical",
"detail": f"Redirect status {status} but no Location header at hop {hop + 1}",
})
break
else:
# Final destination reached
break
conn.close()
except Exception as e:
chain.append({
"hop": hop + 1,
"url": current_url,
"status": "error",
"error": str(e),
})
issues.append({
"type": "connection_error",
"severity": "Critical",
"detail": f"Connection error at hop {hop + 1}: {str(e)}",
})
break
else:
# Simulation mode when no network available
chain.append({
"hop": 1,
"url": current_url,
"status": "simulated",
"note": "Network check unavailable — install urllib for live checks",
})
# Analyze chain
hop_count = len(chain)
if hop_count > 3:
issues.append({
"type": "long_chain",
"severity": "High",
"detail": f"Redirect chain has {hop_count} hops — should be 2 or fewer",
})
final_url = chain[-1]["url"] if chain else url
final_status = chain[-1].get("status", "unknown") if chain else "unknown"
return {
"original_url": url,
"final_url": final_url,
"final_status": final_status,
"total_hops": max(hop_count - 1, 0),
"chain": chain,
"issues": issues,
"passed": len(issues) == 0,
}
def check_url_patterns(url):
"""Check URL for common SEO issues without making requests."""
issues = []
parsed = urlparse(url)
# Check for HTTP (should be HTTPS)
if parsed.scheme == 'http':
issues.append({
"type": "no_https",
"severity": "High",
"detail": "URL uses HTTP instead of HTTPS",
})
# Check for trailing slash inconsistency
if parsed.path and parsed.path != '/' and not parsed.path.endswith('/'):
issues.append({
"type": "trailing_slash",
"severity": "Low",
"detail": "No trailing slash — verify consistency with canonical URL",
})
# Check for URL parameters
if parsed.query:
issues.append({
"type": "url_parameters",
"severity": "Medium",
"detail": f"URL contains parameters: {parsed.query} — verify canonical handling",
})
# Check for uppercase in path
if parsed.path != parsed.path.lower():
issues.append({
"type": "uppercase_url",
"severity": "Medium",
"detail": "URL path contains uppercase characters — may cause duplicate content",
})
# Check path depth
depth = len([s for s in parsed.path.split('/') if s])
if depth > 4:
issues.append({
"type": "deep_url",
"severity": "Medium",
"detail": f"URL depth is {depth} levels — consider flattening for crawl efficiency",
})
return issues
def main():
parser = argparse.ArgumentParser(
description="Check URLs for redirect chains and SEO issues"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--url", help="Single URL to check")
group.add_argument("--file", help="File with one URL per line")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--max-hops", type=int, default=10,
help="Maximum redirect hops to follow (default: 10)"
)
parser.add_argument(
"--patterns-only", action="store_true",
help="Only check URL patterns without making network requests"
)
args = parser.parse_args()
urls = []
if args.url:
urls = [args.url]
else:
filepath = Path(args.file)
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
urls = [line.strip() for line in filepath.read_text().splitlines() if line.strip()]
results = []
for url in urls:
if args.patterns_only:
pattern_issues = check_url_patterns(normalize_url(url))
results.append({
"url": url,
"pattern_issues": pattern_issues,
"passed": len(pattern_issues) == 0,
})
else:
result = simulate_redirect_check(url, args.max_hops)
result["pattern_issues"] = check_url_patterns(normalize_url(url))
results.append(result)
if args.json:
print(json.dumps({"urls_checked": len(results), "results": results}, indent=2))
else:
print(f"\n{'='*60}")
print(f" REDIRECT CHECK — {len(results)} URL(s)")
print(f"{'='*60}")
for r in results:
url = r.get("original_url", r.get("url", ""))
print(f"\n URL: {url}")
if "chain" in r:
print(f" Final URL: {r['final_url']}")
print(f" Total hops: {r['total_hops']}")
print(f" Final status: {r['final_status']}")
if r["chain"]:
print(f" Chain:")
for hop in r["chain"]:
print(f" Hop {hop['hop']}: [{hop['status']}] {hop['url']}")
all_issues = r.get("issues", []) + r.get("pattern_issues", [])
if all_issues:
print(f" Issues ({len(all_issues)}):")
for issue in all_issues:
print(f" [{issue['severity']}] {issue['type']}: {issue['detail']}")
else:
print(f" Status: PASSED — no issues found")
passed = sum(1 for r in results if r.get("passed", False))
print(f"\n Summary: {passed}/{len(results)} URLs passed")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
XML Sitemap Analyzer
Analyzes XML sitemaps for SEO issues including missing URLs, invalid entries,
stale lastmod dates, priority misconfigurations, and size violations.
Supports both sitemap index files and individual sitemaps.
Usage:
python sitemap_analyzer.py --sitemap https://example.com/sitemap.xml
python sitemap_analyzer.py --file sitemap.xml --json
python sitemap_analyzer.py --file sitemap.xml --check-urls
"""
import argparse
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urlparse
try:
import xml.etree.ElementTree as ET
HAS_XML = True
except ImportError:
HAS_XML = False
SITEMAP_NS = {
'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9',
'xhtml': 'http://www.w3.org/1999/xhtml',
'image': 'http://www.google.com/schemas/sitemap-image/1.1',
}
MAX_URLS_PER_SITEMAP = 50000
MAX_SIZE_BYTES = 50 * 1024 * 1024 # 50MB uncompressed
def parse_sitemap_xml(content):
"""Parse sitemap XML content and extract URLs."""
if not HAS_XML:
return [], [], "XML parser not available"
try:
root = ET.fromstring(content)
except ET.ParseError as e:
return [], [], f"XML parse error: {str(e)}"
tag = root.tag.split('}')[-1] if '}' in root.tag else root.tag
urls = []
sub_sitemaps = []
if tag == 'sitemapindex':
# Sitemap index file
for sitemap in root.findall('.//sm:sitemap', SITEMAP_NS):
loc = sitemap.find('sm:loc', SITEMAP_NS)
lastmod = sitemap.find('sm:lastmod', SITEMAP_NS)
if loc is not None and loc.text:
sub_sitemaps.append({
"url": loc.text.strip(),
"lastmod": lastmod.text.strip() if lastmod is not None and lastmod.text else None,
})
# Also try without namespace
if not sub_sitemaps:
for sitemap in root.findall('.//sitemap'):
loc = sitemap.find('loc')
lastmod = sitemap.find('lastmod')
if loc is not None and loc.text:
sub_sitemaps.append({
"url": loc.text.strip(),
"lastmod": lastmod.text.strip() if lastmod is not None and lastmod.text else None,
})
elif tag == 'urlset':
# Regular sitemap
for url_elem in root.findall('.//sm:url', SITEMAP_NS):
entry = {}
loc = url_elem.find('sm:loc', SITEMAP_NS)
lastmod = url_elem.find('sm:lastmod', SITEMAP_NS)
changefreq = url_elem.find('sm:changefreq', SITEMAP_NS)
priority = url_elem.find('sm:priority', SITEMAP_NS)
if loc is not None and loc.text:
entry["url"] = loc.text.strip()
entry["lastmod"] = lastmod.text.strip() if lastmod is not None and lastmod.text else None
entry["changefreq"] = changefreq.text.strip() if changefreq is not None and changefreq.text else None
entry["priority"] = priority.text.strip() if priority is not None and priority.text else None
urls.append(entry)
# Also try without namespace
if not urls:
for url_elem in root.findall('.//url'):
entry = {}
loc = url_elem.find('loc')
lastmod = url_elem.find('lastmod')
changefreq = url_elem.find('changefreq')
priority = url_elem.find('priority')
if loc is not None and loc.text:
entry["url"] = loc.text.strip()
entry["lastmod"] = lastmod.text.strip() if lastmod is not None and lastmod.text else None
entry["changefreq"] = changefreq.text.strip() if changefreq is not None and changefreq.text else None
entry["priority"] = priority.text.strip() if priority is not None and priority.text else None
urls.append(entry)
return urls, sub_sitemaps, None
def parse_date(date_str):
"""Parse various date formats."""
if not date_str:
return None
formats = [
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d",
"%Y-%m-%dT%H:%M:%SZ",
]
# Handle timezone offset without colon
date_str = re.sub(r'(\+\d{2}):(\d{2})$', r'\1\2', date_str)
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
return None
def analyze_urls(urls, content_size=None):
"""Analyze sitemap URLs for issues."""
issues = []
stats = {
"total_urls": len(urls),
"with_lastmod": 0,
"with_changefreq": 0,
"with_priority": 0,
"stale_urls": 0,
"duplicate_urls": 0,
"domains": set(),
}
seen_urls = set()
now = datetime.now()
stale_threshold = now - timedelta(days=365)
valid_changefreqs = {'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'}
stale_urls = []
for entry in urls:
url = entry.get("url", "")
parsed = urlparse(url)
stats["domains"].add(parsed.netloc)
# Check for duplicates
normalized = url.rstrip('/')
if normalized in seen_urls:
stats["duplicate_urls"] += 1
issues.append({
"type": "duplicate_url",
"severity": "Medium",
"url": url,
"detail": "Duplicate URL in sitemap",
})
seen_urls.add(normalized)
# Check lastmod
if entry.get("lastmod"):
stats["with_lastmod"] += 1
parsed_date = parse_date(entry["lastmod"])
if parsed_date:
if parsed_date.replace(tzinfo=None) < stale_threshold:
stats["stale_urls"] += 1
stale_urls.append(url)
else:
issues.append({
"type": "invalid_lastmod",
"severity": "Low",
"url": url,
"detail": f"Invalid date format: {entry['lastmod']}",
})
# Check changefreq
if entry.get("changefreq"):
stats["with_changefreq"] += 1
if entry["changefreq"].lower() not in valid_changefreqs:
issues.append({
"type": "invalid_changefreq",
"severity": "Low",
"url": url,
"detail": f"Invalid changefreq: {entry['changefreq']}",
})
# Check priority
if entry.get("priority"):
stats["with_priority"] += 1
try:
pval = float(entry["priority"])
if pval < 0 or pval > 1:
issues.append({
"type": "invalid_priority",
"severity": "Low",
"url": url,
"detail": f"Priority out of range: {entry['priority']}",
})
except ValueError:
issues.append({
"type": "invalid_priority",
"severity": "Low",
"url": url,
"detail": f"Non-numeric priority: {entry['priority']}",
})
# Check URL format
if not url.startswith(('http://', 'https://')):
issues.append({
"type": "invalid_url",
"severity": "High",
"url": url,
"detail": "URL missing scheme (http/https)",
})
# Check for URL parameters (potential duplicates)
if parsed.query:
issues.append({
"type": "parameterized_url",
"severity": "Medium",
"url": url,
"detail": "URL contains query parameters — verify canonical handling",
})
# Size checks
if len(urls) > MAX_URLS_PER_SITEMAP:
issues.append({
"type": "too_many_urls",
"severity": "Critical",
"detail": f"Sitemap contains {len(urls)} URLs — max is {MAX_URLS_PER_SITEMAP}",
})
if content_size and content_size > MAX_SIZE_BYTES:
issues.append({
"type": "file_too_large",
"severity": "Critical",
"detail": f"Sitemap is {content_size / 1024 / 1024:.1f}MB — max is 50MB",
})
# Coverage checks
if stats["with_lastmod"] == 0 and len(urls) > 0:
issues.append({
"type": "no_lastmod",
"severity": "Medium",
"detail": "No URLs have lastmod dates — Google uses this for crawl prioritization",
})
elif stats["with_lastmod"] < len(urls):
issues.append({
"type": "partial_lastmod",
"severity": "Low",
"detail": f"Only {stats['with_lastmod']}/{len(urls)} URLs have lastmod dates",
})
if stats["stale_urls"] > 0:
issues.append({
"type": "stale_content",
"severity": "Medium",
"detail": f"{stats['stale_urls']} URLs have lastmod dates older than 1 year",
})
# Multiple domains check
if len(stats["domains"]) > 1:
issues.append({
"type": "multiple_domains",
"severity": "High",
"detail": f"Sitemap contains URLs from {len(stats['domains'])} different domains",
})
stats["domains"] = list(stats["domains"])
return stats, issues, stale_urls
def main():
parser = argparse.ArgumentParser(
description="Analyze XML sitemap for SEO issues"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--sitemap", help="URL of sitemap to fetch and analyze")
group.add_argument("--file", help="Local XML sitemap file to analyze")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--check-urls", action="store_true",
help="List all URLs found in the sitemap"
)
args = parser.parse_args()
content = None
content_size = None
if args.file:
filepath = Path(args.file)
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
content = filepath.read_text(encoding="utf-8", errors="replace")
content_size = filepath.stat().st_size
elif args.sitemap:
try:
from urllib.request import urlopen, Request
req = Request(args.sitemap, headers={'User-Agent': 'SEO-Audit-Bot/1.0'})
resp = urlopen(req, timeout=30)
content = resp.read().decode("utf-8", errors="replace")
content_size = len(content.encode("utf-8"))
except Exception as e:
print(f"Error fetching sitemap: {e}", file=sys.stderr)
sys.exit(1)
urls, sub_sitemaps, parse_error = parse_sitemap_xml(content)
if parse_error:
print(f"Error: {parse_error}", file=sys.stderr)
sys.exit(1)
stats, issues, stale_urls = analyze_urls(urls, content_size)
# Determine sitemap type
sitemap_type = "sitemap_index" if sub_sitemaps else "urlset"
result = {
"source": args.file or args.sitemap,
"type": sitemap_type,
"file_size_bytes": content_size,
"stats": stats,
"sub_sitemaps": sub_sitemaps if sub_sitemaps else None,
"issues": issues,
"issue_count": len(issues),
"passed": all(i["severity"] not in ("Critical", "High") for i in issues),
}
if args.check_urls:
result["all_urls"] = [u["url"] for u in urls]
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"\n{'='*60}")
print(f" SITEMAP ANALYSIS")
print(f"{'='*60}")
print(f" Source: {args.file or args.sitemap}")
print(f" Type: {sitemap_type}")
if content_size:
print(f" File size: {content_size / 1024:.1f} KB")
if sub_sitemaps:
print(f"\n Sitemap Index: {len(sub_sitemaps)} sub-sitemaps")
for sm in sub_sitemaps[:10]:
mod = f" (lastmod: {sm['lastmod']})" if sm['lastmod'] else ""
print(f" - {sm['url']}{mod}")
if len(sub_sitemaps) > 10:
print(f" ... and {len(sub_sitemaps) - 10} more")
if urls:
print(f"\n URL Statistics:")
print(f" Total URLs: {stats['total_urls']}")
print(f" With lastmod: {stats['with_lastmod']}")
print(f" With changefreq: {stats['with_changefreq']}")
print(f" With priority: {stats['with_priority']}")
print(f" Duplicates: {stats['duplicate_urls']}")
print(f" Stale (>1 year): {stats['stale_urls']}")
print(f" Domains: {', '.join(stats['domains'])}")
if issues:
print(f"\n Issues ({len(issues)}):")
for issue in issues:
url_info = f" — {issue['url']}" if 'url' in issue else ""
print(f" [{issue['severity']}] {issue['type']}: {issue['detail']}{url_info}")
else:
print(f"\n No issues found.")
status = "PASSED" if result["passed"] else "NEEDS ATTENTION"
print(f"\n Overall: {status}")
if args.check_urls and urls:
print(f"\n All URLs:")
for u in urls:
mod = f" [{u['lastmod']}]" if u.get('lastmod') else ""
print(f" {u['url']}{mod}")
print()
if __name__ == "__main__":
main()
Related skills
FAQ
How big is the checklist?
An 85-point checklist across 8 dimensions, with severity-weighted scoring.
What operating modes does it have?
Full site audit, focused single-dimension audit, pre-migration audit, and traffic-drop diagnosis.