L’interface est localisée ; les présentations et documents des Skills sont traduits automatiquement par IA dans votre langue. Le code, les commandes, chemins et termes techniques restent en version originale, et le document de l’auteur fait foi.
Explorer les Skills
Données et recherche · SKILL.md

seo-audit

seo-audit

Runs a technical SEO audit of a live website and its codebase from the terminal and reports findings with evidence, ranked by how much each one keeps pages out of search results. Use when a user asks for an "SEO audit", "technical SEO check", "why did our traffic drop after the migration", "why isn't Google indexing these pages", "check robots.txt, sitemap …

Independent GitHub snapshot · Automated classification · Original documentation governs

Tterminalskills / skillsApache-2.0

Documentation de la Skill

GitHub

name: seo-audit description: >- Runs a technical SEO audit of a live website and its codebase from the terminal and reports findings with evidence, ranked by how much each one keeps pages out of search results. Use when a user asks for an "SEO audit", "technical SEO check", "why did our traffic drop after the migration", "why isn't Google indexing these pages", "check robots.txt, sitemap and canonicals", or "check our Core Web Vitals". Covers crawlability, indexing signals, canonical URLs, sitemaps, JavaScript rendering, structured data presence and Core Web Vitals, with the command for each check and how to read its result. license: Apache-2.0 compatibility: "bash with curl and jq, Python 3.8+ (standard library only). Optional: Chrome or Chromium for rendering checks, Node.js 22.19+ for Lighthouse 13, a CrUX API key for field data." metadata: author: terminal-skills version: "2.0.0" category: development tags: ["seo", "technical-seo", "crawling", "indexing", "core-web-vitals"]

SEO Audit

Overview

A page reaches search results only if a crawler can fetch it, it answers with HTTP 200, it carries content in a form the crawler reads, and no signal tells the engine to leave it out or prefer another URL. Most traffic losses come from one of those four breaking, usually after a redesign, a framework change or a configuration slip, and each one can be tested from a terminal.

This skill audits in that order: site-wide access, then a sample of pages, then rendering, speed and the source code that produced the fault. The output is a findings table in which every row has the command that showed the problem, so the user can rerun it after the fix. Rules quoted here follow Google Search Central as of October 2026; Bing and other engines read the same signals.

Instructions

1. Scope

Find out or ask: the canonical host (www or bare domain), what prompted the audit (a drop with a date, pages missing from the index, a pre-launch check), which page types earn the traffic, whether the repository is available, and whether the user can export data from Search Console. Do not sign in to anything: ask for the "Page indexing" export and the Performance report for the affected dates.

Work in a scratch directory and keep every output file; they are the evidence.

2. Site-wide access

SITE=https://larkfieldcycles.com
for u in http://larkfieldcycles.com/ https://larkfieldcycles.com/ http://www.larkfieldcycles.com/ https://www.larkfieldcycles.com/; do
  curl -sS -o /dev/null -L --max-time 20 -w '%{http_code} hops=%{num_redirects} %{url_effective}\n' "$u"
done
curl -sS -o robots.txt -w 'robots %{http_code} %{content_type} %{size_download} bytes\n' "$SITE/robots.txt"
curl -sS --compressed -o sitemap.xml -w 'sitemap %{http_code} %{content_type}\n' "$SITE/sitemap.xml"
grep -o '<loc>[^<]*' sitemap.xml | sed 's/<loc>//' > urls.txt && wc -l < urls.txt
grep -o '<lastmod>[^<]*' sitemap.xml | cut -c10-19 | sort | uniq -c | sort -rn | head -3

How to read it:

ResultConclusion
The four host variants do not all end on one URL with 200, or one fails TLSDuplicate or unreachable host. One hop to the canonical host is the target
robots.txt returns 5xxGoogle stops crawling the site for up to 12 hours, then falls back to the last copy it fetched. Treat as an outage
robots.txt returns 404 or another 4xx (except 429)Read as "no restrictions". Fine unless something was meant to be blocked
robots.txt is HTML or larger than 500 KiBRules are ignored or cut off. A catch-all route is probably serving the app shell
Disallow covers a path that should rankBlocked from crawling. The longest matching rule wins and Allow wins a tie; paths are case-sensitive
A blocked path also carries noindexThe noindex is never seen, because the page is not fetched. Pick one: unblock and noindex, or block
Sitemap is missing from robots.txt, or the file is a sitemap indexRead the Sitemap: lines; for an index, fetch each child and concatenate the loc values
More than 50,000 URLs or 50 MB uncompressed in one fileOver the limit; split it
Every lastmod is the same dateGoogle only trusts lastmod when it matches real changes; priority and changefreq are ignored either way

3. Page signals on a sample

Save this as seo_page_check.py. It fetches the HTML the server sends, without running scripts.

#!/usr/bin/env python3
"""Read URLs on stdin, print one JSON line of indexability signals per URL."""
import json, re, sys, urllib.error, urllib.request
from html.parser import HTMLParser

class Signals(HTMLParser):
    def __init__(self):
        super().__init__()
        self.out, self.grab, self.buf = {"h1": 0, "hreflang": 0, "jsonld": []}, None, ""
    def handle_starttag(self, tag, attrs):
        a = {k: v or "" for k, v in attrs}
        name, rel = a.get("name", "").lower(), a.get("rel", "").lower()
        if tag == "title" and "title" not in self.out: self.grab, self.buf = "title", ""
        elif tag == "script" and a.get("type") == "application/ld+json": self.grab, self.buf = "jsonld", ""
        elif tag == "h1": self.out["h1"] += 1
        elif tag == "meta" and name in ("robots", "googlebot", "description"): self.out[name] = a.get("content", "")
        elif tag == "link" and rel == "canonical": self.out["canonical"] = a.get("href", "")
        elif tag == "link" and a.get("hreflang"): self.out["hreflang"] += 1
    def handle_data(self, data):
        if self.grab: self.buf += data
    def handle_endtag(self, tag):
        if self.grab == "title" and tag == "title": self.out["title"] = " ".join(self.buf.split())
        elif self.grab == "jsonld" and tag == "script":
            try:
                doc = json.loads(self.buf)
                nodes = doc.get("@graph", [doc]) if isinstance(doc, dict) else doc
                self.out["jsonld"] += [str(n.get("@type")) for n in nodes if isinstance(n, dict)]
            except ValueError:
                self.out["jsonld"].append("UNPARSABLE")
        else: return
        self.grab = None

for url in filter(None, map(str.strip, sys.stdin)):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (compatible; site-audit)"})
    try:
        with urllib.request.urlopen(req, timeout=30) as res:
            status, final, x_robots, raw = res.status, res.url, res.headers.get("X-Robots-Tag"), res.read()
    except urllib.error.HTTPError as err:
        print(json.dumps({"url": url, "status": err.code})); continue
    except OSError as err:
        print(json.dumps({"url": url, "status": 0, "error": str(err)})); continue
    html = raw.decode("utf-8", "replace")
    page = Signals(); page.feed(html)
    words = len(re.sub(r"(?is)<(script|style)\b.*?</\1>|<[^>]+>", " ", html).split())
    print(json.dumps({"url": url, "status": status, "final": final, "x_robots": x_robots,
                      "kb": len(raw) // 1024, "words": words, **page.out}, ensure_ascii=False))

Sample every template rather than every URL: the home page, a few of each page type, and a random slice of the sitemap. Add one invented path to test the error page.

{ head -5 urls.txt; shuf -n 40 urls.txt; echo "$SITE/audit-missing-page-check"; } | python3 seo_page_check.py > pages.jsonl
# URLs whose status, redirect, robots rule or canonical needs a look
jq -r 'select(.status != 200 or .final != .url or ((.robots // "") + (.x_robots // "") | test("noindex"; "i")) or (.canonical // "") != .url)
  | [.status, .url, .final // "-", .robots // "-", .canonical // "-"] | @tsv' pages.jsonl
jq -r '.title // empty' pages.jsonl | sort | uniq -d          # titles shared by several pages
jq -r 'select(.status == 200) | [.url, .h1, .words, .kb, (.jsonld | join(","))] | @tsv' pages.jsonl
SignalConclusion
A sitemap URL is not 200, or final differs from urlThe sitemap advertises dead or redirecting URLs; list only final, canonical ones
The invented path returns 200Soft 404: every mistyped or deleted URL looks like a real page. Return a real 404 status
noindex in robots, googlebot or x_robotsThe page is excluded on purpose or by accident; the most restrictive rule found wins
canonical missingAcceptable, but add a self-referencing absolute canonical on indexable pages
canonical names another URLYou are asking for that other URL to be indexed instead. Check it is 200, indexable and truly the same content
canonical differs only by host, protocol or trailing slashConflicting signals; align links, redirects, sitemap and canonical on one form
words far below what the browser showsContent arrives through JavaScript; go to step 4
kb above 2000Googlebot reads the first 2 MB of an HTML file and of each script or stylesheet; content past that is not indexed
h1 is 0, title empty or duplicated, description missingSearch engines pick their own title and snippet. There is no character limit to enforce; aim for a distinct, descriptive title per page
hreflang above 0Each language version must list itself and every other version, with absolute URLs, and the listed pages must link back
jsonld empty or UNPARSABLEHand over to structured data work; it affects appearance, not indexing

For a migration, also take the old URLs with traffic (from the user's export or an old sitemap) and test each: curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' "$old". Every old URL should answer 301 or 308 straight to its closest equivalent. A 404, a 302, a chain of several hops, or a blanket redirect to the home page all lose the old page's standing.

4. Rendering

chrome=$(command -v google-chrome || command -v chromium || command -v chromium-browser)
"$chrome" --headless --user-data-dir="$PWD/chrome-profile" --virtual-time-budget=8000 --dump-dom "$SITE/pricing" > rendered.html
curl -sS "$SITE/pricing" > raw.html
for f in raw.html rendered.html; do echo "$f words=$(wc -w < "$f") links=$(grep -o '<a [^>]*href=' "$f" | wc -l)"; done

If text, links, the canonical or the robots meta exist only in rendered.html, indexing depends on Google's render queue: it works, but later and less reliably, and other crawlers that do not run scripts see an empty page. Recommend server rendering or prerendering for pages that must rank. Two rules are strict: a noindex in the raw HTML may stop the page being rendered at all, so removing it with script does not work; and a canonical changed by script conflicts with the one in the raw HTML. Navigation must be real a elements with href; click handlers on other elements are not followed.

5. Core Web Vitals

Good means, at the 75th percentile of real visits: LCP within 2.5 s, INP at or under 200 ms, CLS at or under 0.1. Field data decides; lab runs explain.

# Field data: 28 days of real Chrome users. Needs a free API key in CRUX_API_KEY.
curl -sS -X POST "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  -H 'Content-Type: application/json' -d "{\"origin\": \"$SITE\", \"formFactor\": \"PHONE\"}" \
  | jq '.record.metrics | {lcp: .largest_contentful_paint.percentiles.p75, inp: .interaction_to_next_paint.percentiles.p75, cls: .cumulative_layout_shift.percentiles.p75}'
# Lab data: one simulated mobile load, no key needed
npx --yes lighthouse@13 "$SITE/" --only-categories=performance,seo --output=json --output-path=lh.json --quiet --chrome-flags="--headless=new"
jq '{perf: .categories.performance.score, seo: .categories.seo.score, lcp_ms: .audits["largest-contentful-paint"].numericValue,
     cls: .audits["cumulative-layout-shift"].numericValue, tbt_ms: .audits["total-blocking-time"].numericValue}' lh.json

A 404 "data not found" from CrUX means too few visits for a record: say so and report lab numbers as indicative only. Lighthouse cannot measure INP; total blocking time is its nearest proxy. Speed is rarely the cause of a sudden drop; treat it as a tiebreaker.

6. Trace each fault to the code

grep -rnE 'noindex|X-Robots-Tag|canonical|Disallow' --exclude-dir={node_modules,.next,dist,build,.git} . | head -40
StackWhere the signals are produced
Next.js App Routerapp/robots.ts, app/sitemap.ts, metadata or generateMetadata (alternates.canonical, robots), redirects() and headers() in next.config, middleware.ts
Astro, Eleventy, Hugosite or base URL in the config, the sitemap integration, the head partial of the base layout, public/robots.txt
Hosting layervercel.json, netlify.toml, _redirects, _headers, nginx or Caddy config; staging rules and environment checks often leak into production here
WordPressSettings, Reading, "Discourage search engines"; then the SEO plugin's canonical and noindex settings

7. Report

Lead with a three-line summary, then one table: severity | finding | evidence (command and output) | affected URLs | fix (file and change). Severity: Blocker means pages cannot be crawled or indexed; Major means the wrong URL is indexed or signals are split; Minor affects only how a result looks; Info is context. Close with what could not be checked and what the user should confirm in Search Console (URL Inspection on two fixed URLs, then the Page indexing report after a few weeks).

Examples

Example 1: Traffic drop after a replatform

Request: "Organic clicks on larkfieldcycles.com fell about 40% since we moved from WooCommerce to a Next.js storefront on 8 September. Find out why." The user supplies the 200 top landing pages from before the move.

$ while read old; do curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' "$old"; done < old-top200.txt | cut -d' ' -f1 | sort | uniq -c
     17 200
     52 308
    131 404
$ jq -r 'select((.canonical // "") != .url) | .canonical' pages.jsonl | cut -d/ -f3 | sort | uniq -c
     41 www.larkfieldcycles.com
SeverityFindingEvidenceFix
Blocker131 of 200 former landing pages return 404; /product/... moved to /bikes/... with no mappingFirst command aboveAdd one-hop permanent redirects in next.config.js redirects() from each old path to its new equivalent; keep them at least a year
MajorAll 52 redirects point to the home pageredirect_url is / for eachRedirect to the matching product or category instead
MajorCanonicals name www. while the site resolves to the bare domainSecond command; metadataBase in app/layout.tsxSet metadataBase to the bare domain so canonical, sitemap and redirects agree
Minor38 product pages share one titleuniq -d on titlesBuild the title from the product name in generateMetadata

Example 2: Pages that never get indexed

Request: "Search Console says most of kestrelbooks.io/guides is 'Excluded by noindex tag', but there is no noindex anywhere in our HTML. What is wrong?" The site is a Vite single-page app on Vercel.

$ for f in raw.html rendered.html; do echo "$f words=$(wc -w < "$f") links=$(grep -o '<a [^>]*href=' "$f" | wc -l)"; done
raw.html words=61 links=0
rendered.html words=1340 links=47
$ printf '%s\n' https://kestrelbooks.io/guides/vat-returns https://kestrelbooks.io/audit-missing-page-check | python3 seo_page_check.py | jq -c '[.status, .x_robots, .words, .canonical]'
[200,"noindex",38,null]
[200,"noindex",38,null]
SeverityFindingEvidenceFix
BlockerEvery response carries X-Robots-Tag: noindexx_robots above; a headers rule in vercel.json written for preview deployments has no host conditionLimit the rule to preview hosts or delete it
BlockerGuides carry 38 words of text and no links before scripts runwords and the raw/rendered comparison abovePrerender /guides/* at build time so the article text is in the HTML
MajorUnknown paths answer 200 with the app shellSecond URL aboveServe a 404 status for unmatched routes
InfoNo canonical on any pagecanonical is nullAdd a self-referencing canonical once pages are prerendered

The report notes that the status label in Search Console will only change after Google recrawls, and asks the user to run URL Inspection on two guides after deploying.

Guidelines

  • Order matters: a page that is blocked, noindexed, erroring or empty before rendering gains nothing from better titles or faster loading. Report those first and say so.
  • Evidence or it is not a finding. Never report an issue you did not observe in a command output or in the user's export, and never estimate traffic impact as a number.
  • A sample proves a template is broken, not that every page is fine. State the sample size, and widen it when the sitemap mixes many page types.
  • Be polite to the server: one request at a time, a few hundred URLs at most, no login-protected or disallowed areas. A full-site crawl needs a dedicated crawler and the owner's consent.
  • The fetches here use a generic user agent. Sites that serve bots differently, or block them at a firewall, need the URL Inspection tool to see what Googlebot really received.
  • robots.txt controls crawling, not indexing: a disallowed URL can still be listed without a snippet if others link to it. Use noindex on a crawlable page to keep it out.
  • Do not report retired or mythical checks: there is no ranking penalty for title length, priority and changefreq do nothing, the site: operator is not a count of indexed pages, and appearing in AI Overviews needs no special file or markup beyond being indexed and eligible for a snippet.
  • Out of scope: keyword research, content strategy, backlink analysis and writing structured data. Say so and hand over rather than improvising them.
Skills enregistrées