Plugin · 18 files
search-visibility
Two Claude Code skills to analyze Search Console data and audit pages for AI search visibility.
Search Console gives you thousands of rows and no priority. You can see that clicks are down, that some pages are not indexed, that a query sits at position 11, and still have no idea which of those is worth a Tuesday afternoon.
This bundle is two skills for the two ways people find things now: a search engine, and an AI assistant answering on their behalf.
What you get
/search-visibility:a-seo-gsc: give it your property name (the website as registered in Search Console) and get a prioritized action plan. It pulls your Search Console data over the API, then finds the pages earning impressions but no clicks, the queries sitting just off page one where a title rewrite is worth more than a new post, the places your own pages compete with each other, and the indexing failures that quietly keep content out of results. The plan lists changes ranked by expected impact and effort, rather than by the most alarming number./search-visibility:a-geo-optimizer: the AI-search half. It checks whether assistants such as ChatGPT, Claude, Perplexity, or Gemini can fetch a page, understand it, and find a usable answer. It then recommends the crawler, structured-data, and content changes worth making. It improves eligibility for citation, but no tool can promise a citation. Use it when auditing a live site and when writing a page, as the second is much cheaper than the first.
The numbers in your plan are computed by a script, not read off a spreadsheet by a model. A bundled parser pre-processes everything before anything reasons about it. The methodology then adapts to what you actually run, because a 300-page blog, a large store, a docs site, and a local business fail in different ways and the generic advice fits none of them.
Fetching over the API buys more than saved clicks. Search Console's own export caps its query and page tables near a thousand rows; the API returns up to 25,000 top rows per request, fetches a matching earlier window so trend analysis always runs, and returns page and query together. That last one answers, across the whole site at once, a question the standard export can only answer one filtered page at a time: which of your own pages compete for the same term, and whether the one Google shows most often is the one that actually ranks best. Two reports still need downloading by hand, coverage and links, because Google publishes no API for either, and the guide says so plainly instead of letting you discover it.
Need the one-time setup? The full Search Console API walkthrough explains the Cloud project, service account, key placement, and page-and-query request. The installed skill still guides the setup one step at a time.
The bundled API fetcher needs Python 3 and openssl on macOS, Linux, or WSL. On native Windows, the skill offers WSL or the manual-export route before asking you to create a key.
If you would rather keep downloading CSVs, nothing changes. The fetcher writes Google's own export shapes, so the parser cannot tell the two apart.
The fetcher saves the raw exports locally and sends requests only to Google's OAuth and Search Console endpoints. The bundled parser computes the findings on your machine, then Claude reads those findings in the session under your Claude Code plan's data controls. The plugin adds no telemetry, and the service-account key never leaves your machine.
What's in the bundle
.claude-plugin/plugin.json1.1 KB
{
"name": "search-visibility",
"displayName": "Search Visibility: SEO and AI Search",
"description": "For website owners and marketers: turn Google Search Console data into a ranked list of SEO fixes, and check whether AI assistants can find, read and cite your site.",
"version": "1.4.0",
"author": {
"name": "Ivan Misic",
"url": "https://ivanmisic.net"
},
"homepage": "https://ivanmisic.net/toolshed/plugins/search-visibility",
"repository": "https://github.com/imisic/claude-marketplace",
"license": "MIT",
"keywords": [
"seo",
"search-console",
"ai-search",
"seo-audit",
"indexing",
"structured-data",
"organic-traffic",
"content-strategy",
"analytics"
],
"documentationUrl": "https://github.com/imisic/claude-marketplace/tree/main/plugins/search-visibility#readme",
"privacyPolicyUrl": "https://github.com/imisic/claude-marketplace/tree/main/plugins/search-visibility#privacy",
"supportUrl": "https://github.com/imisic/claude-marketplace/issues",
"termsOfServiceUrl": "https://github.com/imisic/claude-marketplace/blob/main/LICENSE"
}
CHANGELOG.md5.7 KB
# search-visibility changelog
## 1.4.0
`a-geo-optimizer` gains `scripts/geo_probe.py`, a deterministic first pass over robots.txt (per AI crawler), `llms.txt`, JSON-LD blocks and head tags. It reports evidence with a severity and exits non-zero when anything at or above `--severity` is found, so the audit starts from a candidate set instead of hand-fetching, and the model spends its reading on what a script cannot judge. Stdlib only, no network in its tests.
`a-geo-optimizer` no longer states citation percentages, multipliers or word-count quotas as rules; the figures had no named source. Its llms.txt guidance now matches what Lighthouse actually checks (an H1, at least one markdown link, at least 50 characters), and `geo_probe.py` reports those as `llms-format` and its own spec heuristic separately as `llms-spec`. `a-seo-gsc` describes page+query pairs as site-wide shared-query candidates to investigate, not proof of cannibalization, and asks for a same-intent check before recommending a merge.
## 1.3.2
Clearer name, description and keywords for the plugin directory. The README now opens by saying plainly what the plugin does and for whom, and its privacy section separates what the plugin does from how Claude itself handles your conversation. Skill descriptions reworded for accuracy; no behaviour changes.
## 1.3.1
Fixes that landed after 1.3.0 was first published went out under the same version number, so an install from that first day never received them: the guided API setup, the native Windows check, and calling the bundled scripts through `${CLAUDE_PLUGIN_ROOT}` so they work from your own project directory. This release carries no new behaviour; the new number is what makes those installs update.
## 1.3.0
`a-seo-gsc` can now pull Search Console data itself instead of asking you to download three zips. `scripts/fetch_gsc.py` authenticates with a Google service account and writes the same CSV shapes the manual export produces, so the parser reads either one and the export route still works unchanged. Setup is in `references/export-guide.md`.
The API is not just less clicking. The UI export caps its query and page tables near a thousand rows where the API returns up to 25,000 top rows per request, it writes a matching previous window so trend analysis runs without you remembering to tick Compare, and it returns page+query pairs. Google still does not guarantee every row through the API.
That last one adds an analysis the skill could not do before. No manual export tells you which of your pages a given query hit, so "which of my own pages compete for this term" was unanswerable. The parser now detects it, and flags the case worth acting on first: where the page Google shows most often is not the one that ranks best.
Coverage and Links still need a manual export. Neither has an API, and the guide now says so rather than leaving you to find out. The URL Inspection API is not a substitute for the coverage report, because it only answers for URLs you hand it and so can never surface index bloat from URLs you never submitted.
The script is standard library only and signs its request with openssl, so there is nothing to pip install. Your key is read from a path you choose and is never written to disk or sent anywhere but Google. Raw exports and arithmetic stay local; Claude receives the parser's findings in the session, and the plugin adds no telemetry.
If you want the API but have not set it up, the skill now walks you through it one step at a time and verifies at the end, rather than handing you a link. It also recognises the two 403s that point somewhere other than where they read: the one that means the API is switched off, and the one that means the service account is not on the property yet.
Installed copies now invoke the bundled fetcher and parser through `${CLAUDE_PLUGIN_ROOT}`, so they work from the customer's project directory instead of depending on the current working directory.
Setup now checks the environment before key creation. The bundled API fetcher needs Python 3 and `openssl` on macOS, Linux, or WSL; native Windows users get the WSL or manual-export choice before setup starts.
Downloading the reports by hand stays a first-class route, not a fallback. The skill opens by asking which one you are on and gives the full export list inline either way. The only thing the download route gives up is the page and query pairing, so that one analysis is skipped rather than reported as clean, and for a small site the API is often not worth setting up to get it.
## 1.2.2
The marketplace and storefront copy now describe AI-search work as an eligibility audit and fix plan, not a promise of citations.
## 1.2.1
The README now says which commands you get after installing, and the GEO acronym is spelled out where it first appears. Four hard-banned words swapped for plain ones in `a-geo-optimizer`'s reference docs.
## 1.2.0
The skills in this plugin now carry an `agents/openai.yaml` sidecar, so they show up in Codex's skill picker with a proper name and one-line description instead of a raw folder name. `a-seo-gsc` is explicit-only in Codex, `a-geo-optimizer` is not, matching how each behaves in Claude Code.
## 1.1.0
The two skills now differ in who can start them, because they are used at opposite moments.
`a-seo-gsc` is yours to invoke only. It is a session you sit down to run once a Search Console export is in hand, and nothing about a conversation tells Claude that has happened.
`a-geo-optimizer` still fires on its own. Its whole value is arriving while a page is being built, before anyone thinks to ask whether an assistant will be able to quote it.
The README table now names the mode per skill.
## Earlier
1.0.0 predates this file. See the git history at https://github.com/imisic/claude-marketplace.
README.md6.9 KB
# search-visibility search-visibility helps website owners and marketers decide what to fix in Google search and get their site ready for AI assistants. `a-seo-gsc` turns your Google Search Console (GSC) data into a ranked action plan: which pages lost traffic, which searches you nearly rank for, and what is blocking indexing. `a-geo-optimizer` checks whether AI assistants such as ChatGPT, Claude and Perplexity can reach, read and cite your pages, and lists the fixes. Neither can guarantee rankings or citations. Once it is listed, you can also add it from Anthropic's plugin directory: **Customize > Plugins** in claude.ai, or `/plugin` in Claude Code. ## Install ``` /plugin marketplace add imisic/claude-marketplace /plugin install search-visibility@imisic ``` Then the commands are `/search-visibility:a-seo-gsc` and `/search-visibility:a-geo-optimizer`. The longer write-up lives on the storefront: [ivanmisic.net/toolshed/plugins/search-visibility](https://ivanmisic.net/toolshed/plugins/search-visibility). ## The two skills | Skill | Starts from | Answers | Invoked by | |---|---|---|---| | `a-seo-gsc` | Your Google Search Console data, over the API or exported | What is actually costing me traffic, and in what order do I fix it | you only | | `a-geo-optimizer` | The site or page itself | Can an AI assistant reach this, parse it, and quote it | you or Claude | `a-seo-gsc` is a session you sit down to run once your data is in hand, so Claude will not start it for you. `a-geo-optimizer` is the opposite: Claude can start it while you are building a page, which is when its fixes are cheapest. ### a-seo-gsc Give it a property name and it produces a prioritized action plan instead of a wall of rows. It covers the diagnoses that matter and that people usually miss: pages with impressions but no clicks, striking-distance queries (searches where you rank just off page one, positions 11 to 20), keyword cannibalization where your own pages compete, indexing failures (`crawled - currently not indexed`, `discovered - currently not indexed`), and index bloat. `scripts/fetch_gsc.py` pulls the Performance data over the Search Console API, so most of the downloading is gone. It is also more than the export gives you: the UI caps its query and page tables near a thousand rows where the API returns up to 25,000 top rows per request, it fetches a matching previous window so trend analysis always runs, and it returns **page+query pairs**. Google does not guarantee every row through the API, even with pagination. The standard export lists queries and pages separately, so "which of my own pages compete for this query" means one page-filtered export per page; the API answers it for the whole site at once. The parser now answers it, and flags the case worth fixing first, where the page Google shows most often is not the one that ranks best. Coverage and Links still need a manual export, because neither has an API. The guide says which two reports to grab and why the URL Inspection API is not a substitute for the coverage one. Prefer to keep downloading CSVs? That still works exactly as before. The fetcher writes Google's own export shapes, so the parser cannot tell the two apart. It adapts to the site type, because a 300-page blog, a 50k-SKU store, a docs site, and a local business fail in different ways. `scripts/parse_gsc.py` pre-processes the data deterministically, so the numbers come from arithmetic rather than from a model's reading of a CSV. `references/export-guide.md` covers the one-time API setup, the exact click-paths for the reports you still export by hand, and which alternative sources work (Bing Webmaster Tools, an Ahrefs or Semrush CSV). For the same setup with an explanation of what each step does, read the [full Search Console API walkthrough](https://ivanmisic.net/blog/ai-tools/search-console-api-page-query-pairs). The installed guide remains complete and walks through the setup without depending on the article. The bundled API fetcher needs Python 3 and `openssl` on macOS, Linux, or WSL. On native Windows, the skill offers WSL or the manual-export route before asking you to create a key. The fetcher saves the raw exports locally and sends requests only to `oauth2.googleapis.com` and `www.googleapis.com`. The bundled parser computes the findings on your machine, then Claude reads those findings in the session under your Claude Code plan's data controls. The plugin adds no telemetry, and your service-account key never leaves your machine. ### a-geo-optimizer Generative engine optimization, the AI-search counterpart to SEO. Being useful is not enough if the assistant cannot fetch the page, cannot tell what it says, or cannot find a quotable claim in it. Covers crawler access, structured data, and content shaped so an answer can be lifted out of it. An audit starts with a bundled script, `geo_probe.py`, that checks robots.txt per AI crawler, `llms.txt`, JSON-LD and head tags, so the model spends its reading on what a script cannot judge. It recommends changes that improve the page's eligibility for citation, not a guaranteed result. Use it when building a page as well as when auditing one: the cheapest time to fix this is before publishing. ## Examples - `/search-visibility:a-seo-gsc example.com: which pages lost clicks over the last three months, and what should I fix first?` - `My Search Console exports are in ./gsc-exports. Build the prioritized action plan from them.` - `Use a-geo-optimizer to check whether AI assistants can fetch, parse and cite example.com, and list the fixes.` ## Privacy `a-seo-gsc` reads Search Console data for a property you own. On the API route, `fetch_gsc.py` signs a request with the service-account key file you provide and sends it only to Google (`oauth2.googleapis.com`, `www.googleapis.com`); the key is read from the path you give it and never written elsewhere. Exports are saved in your project and analysed on your machine. `a-geo-optimizer` fetches public pages of the site you ask it to check. The plugin adds no telemetry and sends nothing to its author. Your conversation with Claude is processed by Anthropic under its usual terms; that is separate from the plugin. Retention: the plugin's author never receives any of it, and the only thing kept is what the plugin writes in your own project, which you can delete at any time. ## Support Questions, bugs or a security concern: open an issue at https://github.com/imisic/claude-marketplace/issues, or email hello@ivanmisic.net. ## Using them together Run `a-seo-gsc` first when you have data, because it tells you where the loss actually is and stops you optimizing a page nobody was going to visit. Run `a-geo-optimizer` on a new site or page, where there is no data yet, and on anything you want quoted by an assistant. `a-seo-gsc` only produces findings and a plan. `a-geo-optimizer` can also make the fixes it recommends (crawler rules, structured data, page structure) when you ask it to. Either way, you decide what lands.
skills/a-geo-optimizer/SKILL.md9.7 KB
---
name: a-geo-optimizer
description: "Optimize websites for AI search visibility (GEO/AEO) so ChatGPT, Claude, Gemini, Perplexity and Copilot can find and cite their pages. Use for AI SEO, GEO, AEO, generative or answer engine optimization, llms.txt, schema markup for AI, robots.txt for AI bots, WebMCP, agentic browsers and structured data audits for AI visibility, \"make my site AI-friendly\", \"optimize for ChatGPT\" or \"get cited by AI\". Also use when building a new site or page, even without an explicit GEO request. Covers crawler access, content structure and AI referral monitoring, not general copy editing. For your site's Search Console traffic analysis, use a-seo-gsc."
---
# GEO Optimizer: AI Search Visibility Skill
Make any web project discoverable and citable by AI assistants (ChatGPT, Claude, Gemini, Perplexity, Copilot). This skill covers technical implementation, content structure, and ongoing monitoring.
## When to use this skill
- **New site/page creation**: Bake GEO in from the start
- **Existing site audit**: Diagnose and fix AI visibility gaps
- **Content writing/restructuring**: Format content for AI citation
- **Template development**: Build PHP/HTML templates with GEO built-in
- **robots.txt / schema / sitemap work**: Ensure AI crawler compatibility
- **Competitive analysis**: Compare AI citation performance
## How to use this skill
### Step 1: Determine the task type
Ask yourself which mode applies:
| Mode | When | What to do |
|------|------|------------|
| **Full audit** | User wants the full review | Run all checklists from `references/technical-checklist.md` |
| **New build** | Creating a site/page from scratch | Follow the build-time integration guide below |
| **Content optimization** | Writing or restructuring content | Follow `references/content-strategy.md` |
| **Quick fix** | Specific technical task (robots.txt, schema, etc.) | Jump to relevant section in `references/technical-checklist.md` |
### Step 2: For full audits, follow this sequence
1. **Technical foundation**: Start with `python3 "${CLAUDE_SKILL_DIR}/scripts/geo_probe.py" <url> [more page URLs] --json`. It fetches robots.txt (per-crawler allow/disallow/unmentioned, Sitemap lines), `/llms.txt` and `/llms-full.txt` (presence, size, first heading, format), and every page's JSON-LD blocks (`@type` values, parse errors, missing template fields) plus `<title>`, meta description, canonical, `<html lang>` and a no-JS body-text heuristic. Its findings are evidence with a severity, never recommendations: judge that candidate set, then read `references/technical-checklist.md` and verify what the probe cannot see:
- robots.txt allows AI crawlers (exact user-agents in `references/crawler-reference.md`)
- Server-side rendering (critical: most AI crawlers don't execute JS)
- Schema.org JSON-LD markup on key pages
- Page speed (FCP < 0.4s target)
- XML sitemap + RSS/Atom feed
- HTTPS, mobile-friendly
- llms.txt file: if present, it should pass Lighthouse's llms-txt audit, which checks three things: an H1 header, at least one markdown `[text](url)` link, and at least 50 characters (checklist section 5)
- Agentic Browsing readiness (checklist section 13): run PageSpeed Insights and read the Agentic Browsing category; agent-critical a11y (programmatic names, valid ARIA, touch targets) and CLS now double as agent-readiness signals. WebMCP only matters for interactive sites (search, cart, booking)
- If behind Cloudflare: fetch the LIVE robots.txt and check for injected Content-Signal directives (`ai-train=no` is the managed default; `ai-input=no` would hurt AI citation, see crawler-reference section 12)
2. **Content structure**: Read `references/content-strategy.md` and evaluate:
- Answer-capsule format (40-60 word direct answer after each heading)
- Front-loaded key content (the answer near the top, not after the background)
- Sections sized to the question each one answers
- Evidence where it helps the reader, with sources for any statistic
- Question-style headings matching conversational queries
- FAQ sections with real user questions
- Author bios with credentials
3. **Entity and authority signals**: Check:
- Organization schema with consistent NAP (Name, Address, Phone)
- Author schema with credentials and external profile links
- Third-party presence (review sites, directories, Reddit, YouTube)
- Internal linking structure (pillar-cluster architecture)
4. **Monitoring setup**: Recommend:
- GA4 AI traffic segment (regex for AI referral sources)
- Monthly prompt testing across AI platforms
- GEO monitoring tool appropriate to budget
### Step 3: For new builds, integrate GEO at template level
When building PHP/HTML templates, include these by default:
**In the `<head>` of every page:**
```html
<!-- JSON-LD schema block (populated per page type) -->
<script type="application/ld+json" id="schema-jsonld">
<!-- Populated server-side per template -->
</script>
<!-- Markdown alternate for long-form content (optional) -->
<link rel="alternate" type="text/markdown" href="<?= $markdown_url ?>" />
<!-- Standard meta for AI extraction -->
<meta name="description" content="<?= htmlspecialchars($meta_description) ?>" />
<meta name="author" content="<?= htmlspecialchars($author_name) ?>" />
<meta property="article:published_time" content="<?= $published_date ?>" />
<meta property="article:modified_time" content="<?= $modified_date ?>" />
```
**Content templates should enforce:**
- Single H1 per page
- Logical H2/H3 hierarchy
- First paragraph = direct answer to the page's core question
- Visible "Last Updated" date reflecting genuine content changes
- Author bio block with photo, credentials, external links
**Server-side requirements:**
- All content rendered server-side (SSR), not client-side JS
- JSON-LD generated server-side per page template
- Clean semantic HTML (proper heading hierarchy, lists, tables)
- Fast TTFB and FCP (< 0.4s target)
### Step 4: Output deliverables
Depending on the task, produce one or more of:
- **robots.txt**: Ready to deploy, with all AI crawlers allowed (see `references/crawler-reference.md` for exact user-agents)
- **llms.txt / llms-full.txt** (optional, low-medium priority): Markdown site overview, plus an optional full-content companion. As of 2026-07 adoption as a retrieval source is still uneven: AI dev tools (Claude Code, Cursor, Copilot, Windsurf, Cline, Aider) fetch both and some providers (Anthropic, Perplexity) confirm support, but Google doesn't use it for Search and no major provider has committed to it in production answer systems. What changed in 2026: Lighthouse/PageSpeed Insights now audits the file (Agentic Browsing category) for an H1, at least one markdown link and at least 50 characters, so if one exists it should pass those checks; write links as `- [name](url): note` bullets, never plain `Title: URL` lines. Generate it when trivial alongside other work, notably when the site already exposes per-page markdown, since a route can then concatenate it cheaply. Don't prioritize it over robots.txt, schema, sitemap, or content.
- **Schema JSON-LD blocks**: Per page type (Article, Product, FAQPage, Organization, etc.)
- **Content restructured**: In answer-capsule format with front-loaded answers
- **Audit report**: Findings and priority-ordered recommendations
- **PHP template code**: With GEO elements built into the template layer
- **Sitemap + RSS/Atom feed**: If missing or incomplete
- **GA4 configuration**: AI referral traffic tracking setup
## Key principles to always follow
1. **SSR is non-negotiable**: If content requires JavaScript to render, AI crawlers (except Googlebot) cannot see it. Always flag this.
2. **Front-load answers**: The most important content goes near the top of the page. AI systems tend to extract from the top.
3. **Be specific, not promotional**: AI systems filter out sales copy. Use definitive, factual language with specific numbers, dates, and names. Entity density matters.
4. **Earned media > owned content**: Assistants lean on third-party mentions, not just what a site says about itself. Recommend off-site authority building where the business can earn it.
5. **Freshness matters**: Recently updated pages are favoured, so recommend a refresh cycle for pages whose facts change, with real updates rather than date bumps.
6. **Cross-platform optimization**: Different assistants cite different sources. Optimize for all major AI systems, not just one.
7. **Don't block crawlers by accident**: Always check the LIVE robots.txt for inherited CMS/CDN blocks on AI user-agents, injected Content-Signal directives (Cloudflare's managed robots.txt defaults to `ai-train=no` on 3.8M+ domains), and CDN-level bot blocking that fires before robots.txt is read (test with `curl -A "GPTBot"`).
8. **Agent-readiness is the new layer**: Lighthouse's Agentic Browsing category (2026) grades llms.txt format, WebMCP tools, agent-critical accessibility, and CLS. Agentic browsers (OpenAI Atlas, Perplexity Comet, Gemini in Chrome) drive real sessions through the rendered page and a11y tree, so accessibility fixes now double as agent task-completion fixes.
## Reference files
- `references/technical-checklist.md`: Complete technical implementation checklist (robots.txt, schema, sitemap, llms.txt, page speed, rendering)
- `references/content-strategy.md`: Content formatting, answer-capsule structure, citation optimization, topic clustering
- `references/crawler-reference.md`: All AI crawler user-agents, which platforms they serve, robots.txt templates, and platform-specific submission guides
- `scripts/geo_probe.py`: deterministic first phase (robots.txt, llms.txt, JSON-LD, head tags), stdlib only; `--json` for phase two, exit 1 when any finding at or above `--severity` exists. The embedded crawler list mirrors `references/crawler-reference.md` sections 1 and 7, so update both together.
skills/a-geo-optimizer/agents/openai.yaml115 B
interface: display_name: "GEO Optimizer" short_description: "Make a page reachable and quotable by assistants"
skills/a-geo-optimizer/references/content-strategy.md14.5 KB
# Content Strategy for AI Citations
How to structure, write, and maintain content that AI systems extract, cite, and recommend.
**How to read this file.** It gives direction, not measured effects. Earlier versions quoted citation percentages and multipliers from industry analyses that named no source; they were removed because a figure with no source and no stated limits cannot justify a rule. If you quote a number to a client or on a page, quote one you have checked against a named primary source, with its date, the platforms it measured and its limits.
## Table of Contents
1. [The Answer-Capsule Format](#1-the-answer-capsule-format)
2. [Front-Loading Critical Content](#2-front-loading-critical-content)
3. [Section Structure and Length](#3-section-structure-and-length)
4. [Heading Strategy](#4-heading-strategy)
5. [Statistical Density and Source Attribution](#5-statistical-density-and-source-attribution)
6. [Entity Density](#6-entity-density)
7. [Language and Tone](#7-language-and-tone)
8. [FAQ Sections](#8-faq-sections)
9. [Comparison Tables](#9-comparison-tables)
10. [Content Formats That Get Cited](#10-content-formats-that-get-cited)
11. [Author Authority Signals](#11-author-authority-signals)
12. [Content Freshness](#12-content-freshness)
13. [Topic Cluster Architecture](#13-topic-cluster-architecture)
14. [Off-Site Authority Building](#14-off-site-authority-building)
15. [Intent Modifier Phrases](#15-intent-modifier-phrases)
16. [Brand Sentiment Auditing](#16-brand-sentiment-auditing)
17. [What NOT to Do](#17-what-not-to-do)
---
## 1. The Answer-Capsule Format
The core content pattern for AI citation. Every major section should follow this structure:
```
[Question-style H2 heading]
[40-60 word direct answer, the "capsule"]
[Supporting detail, evidence, examples, 80-120 additional words]
[Optional: data table, comparison, or specific example]
```
**Example (good):**
```markdown
## How long does it take to set up a Synology NAS?
A basic Synology NAS setup takes 15-30 minutes for hardware assembly
and initial DSM installation. Full configuration including RAID setup,
shared folders, user accounts, and package installation typically
requires 1-2 hours. Advanced setups with Docker containers, reverse
proxies, and automated backups can take a full day.
The process breaks down into three phases. First, physical assembly:
insert drives, connect cables, power on. Second, DSM installation
via find.synology.com or Synology Assistant. Third, configuration
of storage pools, shared folders, and services.
```
**Example (bad, buries the answer):**
```markdown
## NAS Setup Guide
Network Attached Storage devices have become increasingly popular
in recent years. There are many brands to choose from, each with
their own strengths. In this guide, we'll walk through everything
you need to know about setting up your device. But first, let's
understand what a NAS actually is...
[answer appears 500 words later]
```
---
## 2. Front-Loading Critical Content
**Put the answer near the start of the page.** Readers and AI systems both meet the opening first; background and method can follow.
Rules:
- The most important, quotable, citable content goes at the top
- First paragraph after H1 should answer the page's core question
- No lengthy introductions, no "in this article we will discuss..."
- Get to the point immediately, then expand
For product/service pages: Lead with what it is, who it's for, and what problem it solves. Pricing and key differentiators in the first 3 paragraphs.
For blog posts: Lead with the answer or key finding. Background and methodology come after.
---
## 3. Section Structure and Length
**Size each section to the question it answers.** Give a distinct question its own section, long enough to answer it completely and short enough that the answer is not buried.
Each section should be:
- Self-contained (extractable without needing context from other sections)
- Focused on one specific sub-question
- Bounded by a descriptive heading
- Complete enough to stand alone as an answer
Articles over 2,900 words average 5.1 citations vs 3.2 for content under 800 words. Longer is better, but only if each section adds genuine value.
---
## 4. Heading Strategy
Use question-style headings that mirror how people ask AI assistants.
**Good headings (match conversational queries):**
- "How does [X] compare to [Y]?"
- "What are the pricing options for [service]?"
- "How long does [process] take?"
- "What's the best [tool] for [use case]?"
- "Is [product] worth it in 2026?"
**Bad headings (traditional SEO style, hard for AI to map to queries):**
- "Features"
- "Overview"
- "Technical Specifications"
- "Our Approach"
- "More Information"
Also: ChatGPT internally appends intent modifiers like "detailed guide", "step-by-step tutorial", "in-depth comparison" to its search queries. Including these phrases naturally in your headings or opening paragraphs can improve retrieval.
---
## 5. Statistical Density and Source Attribution
**Include evidence where it helps the reader.** The original GEO research (the 2023 paper "GEO: Generative Engine Optimization") found that adding cited sources, quotations and statistics improved visibility on its own benchmark; treat that as direction, not a guarantee for any particular assistant.
Rules:
- Use a statistic when it is the clearest evidence for a claim, not to meet a quota
- Always attribute statistics to their source
- Use specific numbers, not vague claims ("3.2× faster" not "much faster")
- Include dates with stats to signal freshness
- Prefer original data you've collected over recycled third-party stats
**Good (hypothetical figures, use your own sourced data):** "According to our 2026 client survey, 73% of small businesses in Croatia report spending less than 2 hours per week on social media marketing."
**Bad:** "Many businesses find social media marketing time-consuming."
---
## 6. Entity Density
**Name things specifically.** Specific names, dates, places and products make a passage easier to understand and to quote than vague references.
Entities = proper nouns, specific names, dates, places, product names, company names, standards, technologies.
- Use full, specific names on first mention (not just pronouns)
- Include dates, version numbers, specific locations
- Reference specific standards, frameworks, or methodologies by name
- Mention specific tools, platforms, or competitors by name when relevant
**High entity density (hypothetical figures):** "Zagreb-based agencies using Figma and Webflow in 2026 report 40% faster delivery times compared to agencies still using WordPress page builders."
**Low entity density:** "Agencies using modern design tools report faster delivery times than those using older platforms."
---
## 7. Language and Tone
AI systems prefer **definitive, authoritative language** over hedging.
**Preferred:**
- "The best approach is..."
- "This costs €500-800 per month"
- "Setup takes 2-3 business days"
- "The main drawback is..."
**Avoided by AI systems:**
- "It depends on various factors..."
- "There are many considerations to keep in mind..."
- "Results may vary..."
- "Contact us to learn more" (dead-end for AI)
**Critical:** AI systems filter out overtly promotional or sales-heavy content. Write as a knowledgeable advisor, not a salesperson. Neutral, factual language with specific claims wins citations.
---
## 8. FAQ Sections
FAQ sections with question-style headings nearly double citation chances. AI systems extract Q&A pairs directly, especially when paired with FAQPage schema.
**Requirements:**
- Use actual questions your customers/readers ask
- Match the conversational phrasing people use with AI ("How do I..." not "Methodology for...")
- Each answer direct and complete, as long as the question needs
- Include the questions readers actually ask, not a set number
- Pair with FAQPage JSON-LD schema (see technical-checklist.md)
**Good FAQ structure:**
```markdown
## Frequently Asked Questions
### How much does a website redesign cost in Croatia?
A professional website redesign in Croatia typically costs €2,000-8,000
for small business sites, €8,000-25,000 for mid-size corporate sites,
and €25,000+ for complex e-commerce platforms. Pricing depends on page
count, custom functionality, and whether content migration is included.
### How long does a typical web project take?
Most small business websites take 4-8 weeks from kickoff to launch.
Mid-size projects run 8-16 weeks. Enterprise-level builds with custom
integrations can extend to 3-6 months.
```
---
## 9. Comparison Tables
**Use a table when readers compare options.** A table states the relationships between items more clearly than prose.
Use HTML tables or Markdown tables for:
- Product vs. competitor comparisons
- Feature comparisons across tiers/plans
- Pros vs. cons
- Technology comparisons
- Before/after scenarios
Keep tables clean and data-rich. AI systems extract structured relationships from tables better than from prose.
---
## 10. Content Formats That Get Cited
Formats that answer a clear question tend to be quoted:
| Format | Notes |
|--------|-------|
| "Best X" lists | Useful when the list is honest about criteria |
| How-to guides | Especially with step-by-step structure |
| Comparison articles | X vs Y format |
| Definitions and explainers | "What is X" format |
| Case studies | With specific metrics and outcomes |
Pick the format that fits what your readers are trying to decide, not the one assumed to be cited most.
---
## 11. Author Authority Signals
Cited pages average 4.1 expert quotations vs 2.4 on non-cited pages.
For every content page, include:
- Author name and photo
- Professional title/role
- Years of experience or credentials
- Links to LinkedIn, professional profiles, or personal site
- Brief description of relevant expertise
For the Organization/brand:
- Clear "About Us" page with founding date, team, and mission
- Google Business Profile (fully optimized)
- Consistent Name, Address, Phone (NAP) across all platforms
- Wikidata entry (for entity recognition by AI)
---
## 12. Content Freshness
**Keep pages current when their facts change.**
Rules:
- Review key pages on a schedule that matches how fast their facts change
- Update with substantive changes (not just timestamp bumps)
- A changed date with no changed content misleads readers and earns nothing
- Display a visible "Last Updated: [date]" on the page
- The `dateModified` in your Article schema must match the visible date
---
## 13. Topic Cluster Architecture
**Group related pages into clusters.** A hub page that links its detailed pages, and links back from them, makes the whole topic easier to follow and to crawl.
Structure:
```
Pillar Page: "Complete Guide to [Topic]" (2,000-4,000 words)
├── Cluster: "How to [subtopic A]" (1,200-2,000 words)
├── Cluster: "Best [tools] for [subtopic B]" (1,500-2,500 words)
├── Cluster: "[Topic] vs [Alternative]" (1,000-1,500 words)
├── Cluster: "[Topic] for [specific audience]" (1,200-2,000 words)
└── Cluster: "Common [topic] mistakes and how to fix them" (1,000-1,500 words)
```
Rules:
- Pillar links to all cluster pages
- Each cluster page links back to pillar
- Cluster pages cross-link to related clusters
- Use descriptive anchor text
---
## 14. Off-Site Authority Building
**Earn mentions elsewhere.** Assistants draw on what other sites say about a brand, not only on the brand's own pages.
Priority platforms for AI citations:
1. **YouTube**: demo videos, tutorials, case studies.
2. **Reddit**: build an authentic presence in relevant subreddits.
3. **Review platforms**: G2, Clutch, Capterra, Trustpilot.
4. **Industry comparison articles**: Get listed in "best [service] in [location/niche]" articles.
5. **Guest posts**: Contribute to industry publications with expert commentary.
6. **Wikipedia/Wikidata**: Create or update entity entries for your brand/key people.
7. **Google Business Profile**: Primary data source for local AI recommendations.
Active profiles on the platforms your audience actually uses matter more than a presence everywhere.
---
## 15. Intent Modifier Phrases
ChatGPT appends modifier phrases to internal search queries when users ask questions. Including these naturally in your content improves retrieval:
- "detailed guide"
- "step-by-step tutorial"
- "in-depth comparison"
- "comprehensive overview"
- "complete guide"
- "how to [specific task]"
- "best [thing] for [use case]"
- "[thing] vs [alternative]"
- "pricing", "cost", "how much"
- "pros and cons"
- "review" / "honest review"
Don't stuff these artificially. Use them in headings and opening paragraphs where they fit naturally.
---
## 16. Brand Sentiment Auditing
Regularly test what AI tools say about your brand:
1. Ask each major AI tool: "Give me pros and cons of [Your Brand]"
2. Ask: "What are the best [your service type] companies in [your location]?"
3. Ask: "Compare [Your Brand] vs [Competitor]"
4. Document what's accurate, what's wrong, what's missing
If AI is hallucinating facts about you:
- Update your About page with clear, declarative sentences
- Update llms.txt with correct information
- Add specific factual claims to schema markup
- Build corrective third-party content (reviews, profiles, mentions)
AI systems pick up corrections faster from multiple independent sources than from your own site alone.
---
## 17. What NOT to Do
**Content that gets ignored or filtered by AI:**
- **Sales copy and promotional language**: AI filters out overtly commercial content
- **Vague, hedging language**: "It depends" answers get skipped in favor of specific ones
- **Thin content under 500 words**: Rarely provides enough value for citation
- **AI-generated content at scale without editorial enhancement**: Produces patterns AI learns to skip
- **Keyword-stuffed content**: The original GEO research showed keyword stuffing produced zero gains
- **Content with no original information**: If AI can generate the same content itself, it won't cite you
- **Content behind login walls**: AI crawlers cannot access paywalled content
- **Information scattered across multiple thin pages**: Consolidate into one page that covers the whole topic
- **Timestamp-only refreshes**: Changing the date without substantive updates doesn't fool AI systems
- **Ignoring a platform**: different assistants cite different sources; optimize for all
**The Chegg lesson:** If your content is commodity information that AI can generate independently, you'll be displaced. Content must include what AI cannot replicate: original data, proprietary research, real case studies with specific metrics, expert quotes from named professionals, and first-person experience.
skills/a-geo-optimizer/references/crawler-reference.md12.5 KB
# AI Crawler Reference Complete reference of AI crawler user-agents, which platforms they serve, and how to configure access. ## Table of Contents 1. [Crawler Quick Reference Table](#1-crawler-quick-reference-table) 2. [OpenAI Crawlers](#2-openai-crawlers) 3. [Anthropic (Claude) Crawlers](#3-anthropic-claude-crawlers) 4. [Google AI Crawlers](#4-google-ai-crawlers) 5. [Perplexity Crawlers](#5-perplexity-crawlers) 6. [Microsoft/Bing Crawlers](#6-microsoftbing-crawlers) 7. [Other AI Crawlers](#7-other-ai-crawlers) 8. [Complete robots.txt Template](#8-complete-robotstxt-template) 9. [Platform Submission Guide](#9-platform-submission-guide) 10. [GA4 AI Traffic Tracking](#10-ga4-ai-traffic-tracking) 11. [Agentic Browsers](#11-agentic-browsers) 12. [Content Signals in robots.txt](#12-content-signals-in-robotstxt) --- ## 1. Crawler Quick Reference Table | User-Agent | Company | Purpose | Block = | |-----------|---------|---------|---------| | `GPTBot` | OpenAI | Training data collection | Less model familiarity | | `OAI-SearchBot` | OpenAI | Search index for ChatGPT Search | Invisible in ChatGPT search | | `ChatGPT-User` | OpenAI | Real-time user-triggered fetch | May not respect robots.txt | | `ClaudeBot` | Anthropic | Training data collection | Less model familiarity | | `Claude-SearchBot` | Anthropic | Search index for Claude web search | Invisible in Claude search | | `Claude-User` | Anthropic | Real-time user-triggered fetch | May not respect robots.txt | | `Googlebot` | Google | Search index (also feeds AI Overviews) | No Google visibility at all | | `Google-Extended` | Google | Gemini training data only | No Gemini training (AI Overviews unaffected) | | `PerplexityBot` | Perplexity | Search index + real-time crawl | Invisible in Perplexity | | `Bingbot` | Microsoft | Bing index (feeds ChatGPT + Copilot) | No Bing/ChatGPT/Copilot visibility | **Key insight:** For maximum AI search visibility, you must allow at minimum: OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Googlebot, and Bingbot. The training crawlers (GPTBot, ClaudeBot, Google-Extended) are optional but recommended. --- ## 2. OpenAI Crawlers **GPTBot** - Purpose: Collects web content for training OpenAI models - User-agent string: `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.0; +https://openai.com/gptbot)` - Respects: robots.txt - Impact of blocking: 73% fewer ChatGPT citations (correlation, not direct causation) **OAI-SearchBot** - Purpose: Indexes content for ChatGPT's web search feature - User-agent string: `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)` - Respects: robots.txt - Impact of blocking: Removed from ChatGPT search results **ChatGPT-User** - Purpose: Real-time page fetching when a user's conversation triggers web lookup - User-agent string: `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)` - Respects: robots.txt partially (operates on behalf of human user) - Impact of blocking: Reduced but not eliminated ChatGPT access --- ## 3. Anthropic (Claude) Crawlers **ClaudeBot** - Purpose: Collects web content for training Claude models - User-agent string: `claudebot` (case variations exist) - Respects: robots.txt (including non-standard Crawl-delay directive) - Impact of blocking: Less model familiarity with your content **Claude-SearchBot** - Purpose: Indexes content for Claude's web search (via Brave Search supplementation) - User-agent string: `Claude-SearchBot` - Respects: robots.txt - Impact of blocking: Reduced visibility in Claude web search **Claude-User** - Purpose: Real-time fetching during user conversations with web search enabled - User-agent string: `Claude-User` - Respects: robots.txt partially (operates on behalf of human user) - Impact of blocking: Reduced but not eliminated Claude access **Note:** Claude's search primarily uses Brave Search's independent index. Ensure your site is visible in Brave Search for maximum Claude visibility. --- ## 4. Google AI Crawlers **Googlebot** - Purpose: Google's main search crawler. Feeds both traditional search AND AI Overviews. - User-agent string: Various (Googlebot, Googlebot-Mobile, etc.) - Respects: robots.txt - Impact of blocking: Complete loss of Google search AND AI Overview visibility - **Critical:** You CANNOT block AI Overviews without losing all Google search visibility. There is no separate AI Overview crawler. **Google-Extended** - Purpose: Controls training data for Gemini and Vertex AI products - User-agent string: `Google-Extended` - Respects: robots.txt - Impact of blocking: Blocks Gemini training. Does NOT affect AI Overviews or Google Search. - **Note:** Blocking Google-Extended is a viable choice if you want search visibility but don't want to contribute to Gemini training. --- ## 5. Perplexity Crawlers **PerplexityBot** - Purpose: Indexes content for Perplexity's AI search engine - User-agent string: `PerplexityBot` - Respects: robots.txt - Impact of blocking: Invisible in Perplexity search results Perplexity maintains its own independent index. It's the most citation-transparent platform (numbered inline citations in every response). Reddit content dominates at 46.5% of Perplexity citations. **Perplexity Publishers' Program:** Partnership program with revenue sharing (80/20 split favoring publishers). Currently 30+ partners including TIME, Fortune, Der Spiegel. --- ## 6. Microsoft/Bing Crawlers **Bingbot** - Purpose: Bing's main search crawler. Feeds Bing Search, ChatGPT (via Bing), and Microsoft Copilot. - User-agent string: `Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)` - Respects: robots.txt - Impact of blocking: Complete loss of Bing, ChatGPT (Bing-backed), and Copilot visibility **Critical:** Bingbot is the single most impactful crawler for ChatGPT visibility. Industry analyses have reported ~87% overlap between ChatGPT citations and Bing's top results (figure undated, verify against a current source before quoting). If you do nothing else, make sure Bingbot can access your site and you've submitted to Bing Webmaster Tools. **Bing Content Submission API:** Enables pushing structured facts directly for Copilot sidebar answers. --- ## 7. Other AI Crawlers | User-Agent | Company | Purpose | |-----------|---------|---------| | `cohere-ai` | Cohere | Training/search | | `Bytespider` | ByteDance | Training for AI products | | `Applebot` | Apple | Apple Intelligence/Siri | | `Applebot-Extended` | Apple | Apple Intelligence training | | `FacebookBot` | Meta | Meta AI features | | `meta-externalagent` | Meta | Meta AI training | | `Amazonbot` | Amazon | Alexa/AI features | | `Timesbot` | Perplexity (for publisher partners) | Premium content | --- ## 8. Complete robots.txt Template ### Maximum AI Visibility (recommended) ``` # Standard search User-agent: Googlebot Allow: / User-agent: Bingbot Allow: / # AI Search (required for AI search visibility) User-agent: OAI-SearchBot Allow: / User-agent: ChatGPT-User Allow: / User-agent: Claude-SearchBot Allow: / User-agent: Claude-User Allow: / User-agent: PerplexityBot Allow: / # AI Training (recommended for maximum citation likelihood) User-agent: GPTBot Allow: / User-agent: ClaudeBot Allow: / Crawl-delay: 10 User-agent: Google-Extended Allow: / # Apple Intelligence User-agent: Applebot Allow: / User-agent: Applebot-Extended Allow: / # Blocked paths (customize per site) User-agent: * Disallow: /admin/ Disallow: /api/ Disallow: /tmp/ Disallow: /private/ Disallow: /wp-admin/ Disallow: /cart/ Disallow: /checkout/ Disallow: /my-account/ Sitemap: https://yoursite.com/sitemap.xml ``` ### Selective (search only, no training) ``` # Standard search User-agent: Googlebot Allow: / User-agent: Bingbot Allow: / # AI Search only User-agent: OAI-SearchBot Allow: / User-agent: ChatGPT-User Allow: / User-agent: Claude-SearchBot Allow: / User-agent: Claude-User Allow: / User-agent: PerplexityBot Allow: / # Block AI training User-agent: GPTBot Disallow: / User-agent: ClaudeBot Disallow: / User-agent: Google-Extended Disallow: / Sitemap: https://yoursite.com/sitemap.xml ``` --- ## 9. Platform Submission Guide ### Google Search Console - URL: https://search.google.com/search-console - Submit: XML sitemap - Feeds: Google Search + AI Overviews + Gemini - Monitor: Performance report (AI Overview data merged under "Web" search type) ### Bing Webmaster Tools - URL: https://www.bing.com/webmasters - Submit: XML sitemap + implement IndexNow - Feeds: Bing + ChatGPT + Microsoft Copilot - **This is the #1 priority submission for ChatGPT visibility** ### Brave Search - Brave uses its own independent index (Web Discovery Project) - No manual submission process - Focus on standard SEO + allowing Brave's crawler - Claude's search draws from Brave's index ### Perplexity - No manual submission - Allow PerplexityBot in robots.txt - Perplexity's own index discovers content independently - Publishers' Program for partnership: https://www.perplexity.ai/hub/partnerships ### Google Business Profile - URL: https://business.google.com - Critical for local/service businesses - Primary data source for AI local recommendations - Complete all fields: services, hours, photos, Q&A, posts --- ## 10. GA4 AI Traffic Tracking ### Create an AI referral traffic segment In GA4, go to Explore > create a custom segment with traffic source filter: **Regex for AI referral sources:** ``` perplexity\.ai|chatgpt\.com|chat\.openai\.com|claude\.ai|gemini\.google\.com|copilot\.microsoft\.com|you\.com|phind\.com ``` Apply this to: - Session source/medium (for full session data) - Landing page (to see which pages AI tools are sending traffic to) ### Track AI-influenced conversions Add "How did you find us?" to lead forms with options: - ChatGPT - Claude - Perplexity - Google AI Overview - Other AI tool This captures dark AI traffic (users who get a recommendation from AI but then go straight to your site). ### Isolate likely AI Overview impressions in Google Search Console No direct filter exists. Workarounds: - Filter by query length (10+ words) - Filter by conversational keywords: "compare", "top", "best", "vs", "how to", "what is" - These queries are more likely to trigger AI Overviews --- ## 11. Agentic Browsers A category that matters since late 2025: full browsers where an AI agent drives real user sessions. These are NOT crawlers and mostly cannot be managed via robots.txt. | Product | Company | Notes | |-|-|-| | Atlas | OpenAI | Launched October 2025. Agent Mode executes multi-step tasks autonomously | | Comet | Perplexity | Chromium-based; traffic looks like a normal Chrome session | | Gemini in Chrome | Google | The main consumer of WebMCP tools during the origin trial | **Implications for GEO:** - Their traffic is largely indistinguishable from human sessions in analytics; you cannot segment it with a user-agent regex. - They interact with your site through the rendered page and the accessibility tree, which is why Lighthouse's Agentic Browsing category audits a11y and CLS (see technical-checklist section 13). - Sites that work well for keyboard/screen-reader users work well for agentic browsers. Broken forms, unlabeled buttons, and layout shift break agent task completion (and the sale that came with it). --- ## 12. Content Signals in robots.txt Cloudflare's Content Signals Policy (September 2025) extends robots.txt with usage-category signals, expressed per user-agent group: ``` User-agent: * Content-Signal: search=yes, ai-input=yes, ai-train=no Allow: / ``` The three signals: `search` (build a search index), `ai-input` (feed content into AI answers at query time, i.e. RAG/grounding), `ai-train` (training or fine-tuning). The policy text frames restrictions as an express reservation of rights under EU DSM Directive 2019/790 Article 4. **Why a GEO skill cares:** - **Cloudflare's managed robots.txt injects `search=yes, ai-train=no` for 3.8M+ domains.** If the site sits behind Cloudflare, fetch the LIVE robots.txt and check whether a managed policy has been layered on top of (or replaces) the site's own file. A client maximizing AI visibility may be sending "don't train on me" without knowing it. - For maximum AI visibility, either omit content signals entirely or set them permissively. `ai-input=no` works against citation in AI answers. - Same audit lesson as user-agents: the robots.txt you deploy is not always the robots.txt the world sees. CDN-level bot management (Cloudflare Bot Fight Mode, WAF rules, security plugins) can also block AI crawlers before robots.txt is ever read; test with `curl -A "GPTBot" https://site.com/` and check for 403s.
skills/a-geo-optimizer/references/technical-checklist.md18.6 KB
# Technical Checklist for AI Search Visibility
Complete technical implementation guide. Work through sequentially for new builds, or use as an audit checklist for existing sites.
## Table of Contents
1. [Server-Side Rendering](#1-server-side-rendering)
2. [Robots.txt Configuration](#2-robotstxt-configuration)
3. [Schema.org Structured Data](#3-schemaorg-structured-data)
4. [XML Sitemap + RSS/Atom Feed](#4-xml-sitemap--rssatom-feed)
5. [llms.txt File](#5-llmstxt-file)
6. [Page Speed Optimization](#6-page-speed-optimization)
7. [Search Console Submissions](#7-search-console-submissions)
8. [IndexNow Implementation](#8-indexnow-implementation)
9. [Markdown Alternate Links](#9-markdown-alternate-links)
10. [Meta Tags for AI Extraction](#10-meta-tags-for-ai-extraction)
11. [HTTPS and Security](#11-https-and-security)
12. [Internal Linking Architecture](#12-internal-linking-architecture)
13. [Lighthouse Agentic Browsing Audit](#13-lighthouse-agentic-browsing-audit)
---
## 1. Server-Side Rendering
**Priority: CRITICAL. This is a binary gate**
Most AI crawlers (GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot) do NOT execute JavaScript. Analysis of 500M+ GPTBot fetches showed zero JS execution. Only Googlebot reliably renders JS.
**Audit check:**
```bash
# Test what a non-JS crawler sees
curl -s -A "GPTBot" https://yoursite.com/page | grep -c "your-expected-content"
# If 0 matches, your content is invisible to AI crawlers
```
**For PHP sites:** This is rarely an issue since PHP renders server-side. But check for:
- Content loaded via AJAX after page load
- React/Vue/Angular widgets embedded in PHP pages
- Lazy-loaded content that requires scroll events
- Content behind JavaScript tabs/accordions (use `<details>`/`<summary>` instead)
**For JS frameworks (React, Vue, Angular, Next.js, Nuxt):**
- Enable SSR or Static Site Generation (SSG)
- Use `getServerSideProps` (Next.js) or equivalent
- Pre-render critical pages at minimum
---
## 2. Robots.txt Configuration
**Priority: CRITICAL. Blocking crawlers = invisible to AI**
See `crawler-reference.md` for complete user-agent list. Here's the recommended robots.txt template:
```
# === Standard Search Crawlers ===
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
# === AI Search Crawlers (allow for search visibility) ===
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: Claude-SearchBot
Allow: /
User-agent: Claude-User
Allow: /
User-agent: PerplexityBot
Allow: /
# === AI Training Crawlers (optional, allow if you want to be in training data) ===
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Google-Extended
Allow: /
# === Block paths that shouldn't be crawled ===
User-agent: *
Disallow: /admin/
Disallow: /api/
Disallow: /tmp/
Disallow: /private/
Sitemap: https://yoursite.com/sitemap.xml
```
**Important distinctions:**
- **Search crawlers** (OAI-SearchBot, Claude-SearchBot, ChatGPT-User, Claude-User, PerplexityBot): These power real-time search features. Blocking them removes you from AI search results.
- **Training crawlers** (GPTBot, ClaudeBot, Google-Extended): These collect data for model training. Blocking them doesn't affect real-time search but reduces long-term model familiarity with your content. Sites blocking GPTBot were cited 73% less often.
- ChatGPT-User and Claude-User operate on behalf of human users and may not fully respect robots.txt blocks.
**Audit check:**
```bash
# Fetch and inspect current robots.txt
curl -s https://yoursite.com/robots.txt
# Look for blanket "Disallow: /" rules or blocks on AI user-agents
```
---
## 3. Schema.org Structured Data
**Priority: HIGH. GPT-4 accuracy jumps from 16% to 54% with structured data**
All schema must be:
- In JSON-LD format (Google-recommended, easiest for AI to parse)
- Rendered server-side in the HTML `<head>` (not injected via JS)
- Accurately matching visible page content (mismatches reduce trust)
### Schema templates by page type
**Organization (site-wide, on every page):**
```json
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Your Company Name",
"url": "https://yoursite.com",
"logo": "https://yoursite.com/logo.png",
"description": "One-sentence description of what you do",
"foundingDate": "2020",
"address": {
"@type": "PostalAddress",
"streetAddress": "123 Main St",
"addressLocality": "Zagreb",
"addressCountry": "HR"
},
"contactPoint": {
"@type": "ContactPoint",
"email": "info@yoursite.com",
"contactType": "customer service"
},
"sameAs": [
"https://www.linkedin.com/company/yourcompany",
"https://www.facebook.com/yourcompany",
"https://twitter.com/yourcompany"
]
}
```
**Article / BlogPosting:**
```json
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Article Title Here",
"description": "2-sentence summary of what this article covers",
"author": {
"@type": "Person",
"name": "Author Name",
"url": "https://yoursite.com/about/author-name",
"jobTitle": "Author's Role/Title",
"sameAs": ["https://linkedin.com/in/author"]
},
"publisher": {
"@type": "Organization",
"name": "Your Company Name",
"logo": { "@type": "ImageObject", "url": "https://yoursite.com/logo.png" }
},
"datePublished": "2026-03-01",
"dateModified": "2026-03-20",
"mainEntityOfPage": "https://yoursite.com/blog/article-slug",
"image": "https://yoursite.com/images/article-hero.jpg"
}
```
**FAQPage (powerful for AI extraction):**
```json
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is [topic]?",
"acceptedAnswer": {
"@type": "Answer",
"text": "A direct, complete answer to the question."
}
},
{
"@type": "Question",
"name": "How does [topic] compare to [alternative]?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Specific comparison with concrete details."
}
}
]
}
```
**Product (for e-commerce/service pages):**
```json
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Product Name",
"description": "Clear product description",
"image": "https://yoursite.com/images/product.jpg",
"brand": { "@type": "Brand", "name": "Brand Name" },
"sku": "SKU-12345",
"offers": {
"@type": "Offer",
"price": "99.00",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock",
"url": "https://yoursite.com/products/product-name"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"reviewCount": "42"
}
}
```
**HowTo (for tutorials/guides):**
```json
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "How to [do thing]",
"description": "Brief overview",
"step": [
{
"@type": "HowToStep",
"name": "Step 1 title",
"text": "Step 1 instructions"
}
]
}
```
**LocalBusiness (for agency/local service sites):**
```json
{
"@context": "https://schema.org",
"@type": "ProfessionalService",
"name": "Agency Name",
"description": "What you do in one sentence",
"url": "https://yoursite.com",
"address": { ... },
"geo": { "@type": "GeoCoordinates", "latitude": "45.815", "longitude": "15.982" },
"openingHours": "Mo-Fr 09:00-17:00",
"priceRange": "$$",
"areaServed": "Croatia"
}
```
### PHP implementation pattern
```php
// In your base template or header include
function render_schema_jsonld(array $schema_data): string {
return '<script type="application/ld+json">' .
json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) .
'</script>';
}
// Per page template, build the schema array and render in <head>
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $article->title,
'datePublished' => $article->published_at,
'dateModified' => $article->updated_at,
// ... etc
];
echo render_schema_jsonld($schema);
```
---
## 4. XML Sitemap + RSS/Atom Feed
**Priority: HIGH**
**XML Sitemap requirements:**
- Include all indexable pages
- Accurate `<lastmod>` timestamps (critical for AI crawlers detecting fresh content)
- Split by content type if site is large (blog-sitemap.xml, product-sitemap.xml)
- Submit to both Google Search Console AND Bing Webmaster Tools
- Reference in robots.txt: `Sitemap: https://yoursite.com/sitemap.xml`
**RSS/Atom feed:**
- Google recommends pairing sitemaps with RSS/Atom feeds for change detection
- Microsoft's NLWeb protocol uses RSS feeds as a primary data source
- Include at minimum: title, link, description, pubDate/updated, author
- Auto-generate from your CMS or build a `/feed.xml` endpoint
```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Site Name</title>
<link>https://yoursite.com</link>
<description>Site description</description>
<atom:link href="https://yoursite.com/feed.xml" rel="self" type="application/rss+xml"/>
<item>
<title>Article Title</title>
<link>https://yoursite.com/blog/slug</link>
<description>First 200 chars of article</description>
<pubDate>Wed, 20 Mar 2026 10:00:00 +0000</pubDate>
<guid>https://yoursite.com/blog/slug</guid>
</item>
</channel>
</rss>
```
---
## 5. llms.txt File
**Priority: LOW-MEDIUM (uneven adoption as a retrieval source, but since 2026 Lighthouse/PageSpeed Insights audits the file, so one that fails its checks shows up as a red audit to anyone who tests your site)**
Place at `https://yoursite.com/llms.txt`. Markdown-formatted overview of your site for AI consumption. Takes minutes to create, costs nothing.
**Format is now machine-checked.** Lighthouse's llms-txt audit (Agentic Browsing category, see section 13) runs three regular expressions over the raw file, per its source (`core/audits/agentic/llms-txt.js` in the GoogleChrome/lighthouse repo, checked 2026-10-11). It fails the file when any of these is missing:
- An H1 header anywhere in the file (`# Title`).
- At least one markdown link, `[text](url)`. Plain `Title: URL` lines do not count and fail with "File does not appear to contain any links".
- At least 50 characters.
A server error when fetching the file also fails the audit; a 404 is reported as not applicable, because the file is optional. Lighthouse checks nothing else: section order, blockquotes and H2 sections are the llmstxt.org format, worth following, but not part of the audit. `geo_probe.py` reports the three Lighthouse checks as `llms-format` and its own spec heuristic (the H1 should be the first line) separately as low-severity `llms-spec`.
```markdown
# Site Name
> One-sentence description of what this site is about.
## About
Brief 2-3 sentence description of the organization, what it does, who it serves.
## Key Pages
- [Homepage](https://yoursite.com/): Brief description
- [Services](https://yoursite.com/services/): What services are offered
- [Blog](https://yoursite.com/blog/): Topics covered
## Popular Content
- [Article Title](https://yoursite.com/blog/article): One-line summary
- [Guide Title](https://yoursite.com/guides/guide): One-line summary
## Contact
- Email: info@yoursite.com
- Location: Zagreb, Croatia
```
Optionally create `llms-full.txt`: the same overview followed by the full markdown of your key pages/articles, so a tool gets deep context in one fetch. This is cheap when the site already exposes per-page markdown (e.g. a `/article/markdown` endpoint or a content/export service): a single route can concatenate the `llms.txt` intro with each published page's markdown. Serve it `X-Robots-Tag: noindex, follow` (it duplicates page bodies, so avoid search-index duplicate content) and exclude any gated/premium content so you don't leak it. When you bound the dump (top-N, page cap), say what was dropped rather than silently truncating.
**Priority:** LOW / optional. Future-proofing, not a required output.
**Current status (as of 2026-07, per third-party trackers, not primary sources; verify before quoting):** Adoption is growing but uneven (roughly 10% of large domain samples). Some providers have confirmed support (Anthropic by late 2024, Perplexity by 2025), and by 2026 most major Western AI platforms offer partial support. The most concrete, verifiable adoption is in AI dev tools: Claude Code, Cursor, Windsurf, GitHub Copilot, Cline, Aider all fetch `/llms.txt` and `/llms-full.txt`, especially for documentation/reference sites. Google has said it does not use llms.txt for Search, and as of Q1 2026 no major AI provider (OpenAI, Google, Anthropic, Meta, Mistral) has publicly committed to reading it in production answer systems. What HAS changed: Google now validates the file in Lighthouse and PageSpeed Insights (Agentic Browsing category, section 13), which makes it a visible pass/fail checkbox for clients, tools, and audits. Bottom line: still don't prioritize it over robots.txt, schema, sitemap, or content structure, but if you ship one, make sure it passes the three Lighthouse checks above.
---
## 6. Page Speed Optimization
**Priority: HIGH. Pages with FCP < 0.4s get 3× more AI citations**
Target metrics:
- First Contentful Paint (FCP): < 0.4 seconds
- Time to First Byte (TTFB): < 200ms
- Largest Contentful Paint (LCP): < 2.5 seconds
AI crawlers abandon slow pages more aggressively than traditional search crawlers.
Quick wins for PHP sites:
- Enable OPcache
- Use a reverse proxy cache (Varnish, Nginx FastCGI cache)
- Optimize images (WebP, lazy loading for below-fold)
- Minimize CSS/JS blocking resources
- Use a CDN for static assets
- Database query optimization (avoid N+1 queries)
---
## 7. Search Console Submissions
**Priority: HIGH**
Submit your sitemap to both:
- **Google Search Console**: https://search.google.com/search-console
- **Bing Webmaster Tools**: https://www.bing.com/webmasters
Bing is critical because ChatGPT and Copilot both use Bing's index. 87% of ChatGPT citations match Bing's top results.
---
## 8. IndexNow Implementation
**Priority: MEDIUM-HIGH**
IndexNow instantly notifies Bing (and by extension ChatGPT/Copilot) when content is published or updated.
```php
// After publishing or updating content
function notify_indexnow(string $url, string $api_key): void {
$endpoint = "https://api.indexnow.org/indexnow";
$params = http_build_query([
'url' => $url,
'key' => $api_key,
'host' => parse_url($url, PHP_URL_HOST),
]);
$ch = curl_init("{$endpoint}?{$params}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
curl_close($ch);
}
```
Place your API key file at `https://yoursite.com/{api-key}.txt`.
---
## 9. Markdown Alternate Links
**Priority: LOW (experimental, but zero-cost if you have Markdown source)**
If you write content in Markdown (e.g., from Obsidian, a CMS, or a static site generator), serve the raw Markdown version alongside HTML and signal it:
```html
<link rel="alternate" type="text/markdown" href="/blog/post-slug.md" />
```
AI models process Markdown natively. This gives them a clean, uncluttered version of your content.
---
## 10. Meta Tags for AI Extraction
**Priority: MEDIUM**
Ensure every important page has:
```html
<meta name="description" content="Concise, factual 150-160 char description" />
<meta name="author" content="Author Name" />
<meta property="og:title" content="Page Title" />
<meta property="og:description" content="Same as meta description" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://yoursite.com/page" />
<meta property="og:image" content="https://yoursite.com/images/og-image.jpg" />
<meta property="article:published_time" content="2026-03-01T10:00:00Z" />
<meta property="article:modified_time" content="2026-03-20T10:00:00Z" />
```
---
## 11. HTTPS and Security
**Priority: CRITICAL (baseline requirement)**
All pages must be served over HTTPS. This is a ranking factor for Google/Bing and a trust signal for AI systems. No exceptions.
---
## 12. Internal Linking Architecture
**Priority: HIGH. Pillar-cluster architecture delivers 2.7× citation multiplier**
Structure sites as topic clusters:
- **Pillar page**: Covers a whole topic end to end (2000+ words)
- **Cluster pages**: Detailed sub-topic articles linking back to pillar
- **Bidirectional links**: Pillar links to clusters, clusters link to pillar
- Use descriptive anchor text (not "click here")
82.5% of ChatGPT citations link to pages within established topic hierarchies, not isolated articles.
---
## 13. Lighthouse Agentic Browsing Audit
**Priority: MEDIUM (new in 2026, the first mainstream tool that grades agent-readiness; category is explicitly "under development and subject to change")**
Chrome Lighthouse (Chrome 150+, also surfaced on pagespeed.web.dev) added an "Agentic Browsing" category that scores how well a site works for AI agents. Unlike other categories there is no 0-100 weighted score: each audit reports pass/fail (or a pass ratio) independently.
**What it checks:**
1. **llms.txt presence and format**: see section 5 for the three checks (an H1, at least one markdown `[text](url)` link, at least 50 characters).
2. **WebMCP integration**: WebMCP (Web Model Context Protocol) is a W3C proposal that lets a page expose callable tools to AI agents via `navigator.modelContext` instead of the agent guessing at the UI. Announced February 2026, in Chrome origin trial (Chrome 149-156); the main consuming agent today is Gemini in Chrome. Lighthouse verifies both declarative tools (defined in HTML) and imperative tools (registered from JS). Only relevant for interactive sites (search, cart, booking, filters); content/publishing sites can safely skip it. Reference: https://developer.chrome.com/docs/ai/webmcp
3. **Agent-centric accessibility subset**: agents drive pages through the accessibility tree, so Lighthouse filters the a11y audits that matter for machine interaction: every interactive element has a programmatic name, ARIA roles are valid and on compatible elements, identical links have the same purpose, touch targets have sufficient size/spacing. Accessibility fixes now double as agent-readiness fixes; pitch them that way.
4. **Layout stability (CLS)**: agents target elements by position, so layout shift breaks them the same way it annoys humans.
**Audit check:** run the site through https://pagespeed.web.dev/ and read the Agentic Browsing section at the bottom of the report, or use Lighthouse in Chrome DevTools (Chrome 150+).
**Watch for false positives:** entrance animations that fade content in (opacity 0 to 1 on scroll-reveal) can make the contrast and visibility audits fail elements whose static styles pass. Verify the computed static colors before "fixing" contrast findings caused by animation timing.
skills/a-geo-optimizer/scripts/geo_probe.py24.5 KB
#!/usr/bin/env python3
"""
geo_probe.py - deterministic first phase of a GEO (AI search visibility) audit.
Fetches what the skill's technical checklist tells the model to inspect by hand on every
run: robots.txt (per AI crawler: allowed, disallowed, unmentioned; Sitemap and
Content-Signal lines), /llms.txt and /llms-full.txt (presence, size, first heading,
format), and each page's JSON-LD blocks plus the head tags AI extraction depends on.
Plain Python, standard library only. It reports evidence, never recommendations: the
model judges the candidate set in phase two and reads the pages only for what a probe
cannot see (wording, citability of the content itself).
Usage:
python3 geo_probe.py <url-or-domain> [more page URLs...]
python3 geo_probe.py <url> --json # machine-readable, for phase two
python3 geo_probe.py <url> --severity high # report and gate on high only
python3 geo_probe.py <url> --timeout 5 # per-request timeout in seconds
Exit code is 1 when any finding at or above --severity exists (default: low, so any
finding), 0 when the reported set is empty, 2 on bad arguments.
"""
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
# Source of truth for this list: references/crawler-reference.md, section 1 (quick
# reference table) and section 7 (other AI crawlers). The reference may not ship next to
# this script, so the tokens are embedded; update both together. Tier decides severity
# when a crawler is disallowed: "search" powers live answers (blocking removes the site
# from AI search), "training" only reduces long-term model familiarity, "other" is
# smaller platforms.
CRAWLERS = {
"Googlebot": "search", "Bingbot": "search", "OAI-SearchBot": "search", "ChatGPT-User": "search",
"Claude-SearchBot": "search", "Claude-User": "search", "PerplexityBot": "search",
"GPTBot": "training", "ClaudeBot": "training", "Google-Extended": "training",
"cohere-ai": "other", "Bytespider": "other", "Applebot": "other", "Applebot-Extended": "other",
"FacebookBot": "other", "meta-externalagent": "other", "Amazonbot": "other", "Timesbot": "other",
}
TIER_SEVERITY = {"search": "high", "training": "medium", "other": "low"}
# Fields per @type, from references/technical-checklist.md section 3 templates and the
# entity checks in SKILL.md step 2. First tuple is required (missing = medium), second is
# recommended (missing = low). Aliases map subtypes onto the template they follow.
SCHEMA_FIELDS = {
"Organization": (("name", "url"), ("logo", "sameAs", "address")),
"WebSite": (("name", "url"), ()),
"Article": (("headline", "author", "datePublished"), ("dateModified", "publisher")),
"FAQPage": (("mainEntity",), ()),
"Product": (("name", "offers"), ("description", "brand")),
"HowTo": (("name", "step"), ()),
}
SCHEMA_ALIASES = {
"LocalBusiness": "Organization", "ProfessionalService": "Organization", "Corporation": "Organization",
"BlogPosting": "Article", "NewsArticle": "Article", "TechArticle": "Article",
}
SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
# Below this many characters of visible body text, a homepage is almost certainly a JS
# shell (`<div id="root"></div>` plus a bundle). Heuristic: a legitimately terse landing
# page trips it too, which is why the finding says so in its evidence.
MIN_BODY_TEXT = 400
# shortcut: single attempt per URL, no retry or backoff. A flaky host shows up as
# `unreachable` and the model re-runs; retries would hide intermittent blocking, which is
# itself a finding for AI crawlers.
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
MAX_BODY_BYTES = 5 * 1024 * 1024 # shortcut: cap reads; nothing we inspect lives past 5 MB
SKIP_TEXT_TAGS = {"script", "style", "noscript", "template", "svg", "head"}
VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
"track", "wbr"}
# ---------------------------------------------------------------- fetching
def fetch(url, timeout):
"""One GET. Returns a dict with http_status, text, content_type, final_url, error.
Network and HTTP errors land in the dict rather than raising, so every URL yields
a finding instead of a crash."""
out = {"url": url, "http_status": None, "text": "", "content_type": "", "final_url": url, "error": None}
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read(MAX_BODY_BYTES)
out["http_status"] = resp.status
out["final_url"] = resp.geturl()
out["content_type"] = resp.headers.get_content_type()
charset = resp.headers.get_content_charset() or "utf-8"
except urllib.error.HTTPError as e:
# A 404 is an answer, not a failure: robots.txt or llms.txt may simply not exist.
out["http_status"] = e.code
try:
raw = e.read(MAX_BODY_BYTES)
except Exception: # noqa: BLE001 - the status is what matters here
raw = b""
out["content_type"] = e.headers.get_content_type() if e.headers else ""
charset = (e.headers.get_content_charset() if e.headers else None) or "utf-8"
except Exception as e: # noqa: BLE001 - URLError, socket.timeout, ssl errors, bad host
out["error"] = f"{type(e).__name__}: {e}"
return out
try:
out["text"] = raw.decode(charset, errors="replace")
except LookupError: # unknown charset label in the header
out["text"] = raw.decode("utf-8", errors="replace")
return out
def normalize_target(arg):
"""Accept `example.com`, `example.com/path` or a full URL. Returns (origin, page_url)."""
if not re.match(r"^[a-z][a-z0-9+.-]*://", arg, re.I):
arg = "https://" + arg
parts = urllib.parse.urlsplit(arg)
if not parts.netloc:
raise ValueError(f"not a URL or domain: {arg}")
origin = f"{parts.scheme}://{parts.netloc}"
page = urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path or "/", parts.query, ""))
return origin, page
# ---------------------------------------------------------------- robots.txt
def parse_robots(text):
"""Groups of (agent tokens, rules) plus Sitemap and Content-Signal lines. Follows the
REP grouping: consecutive User-agent lines share one rule block; a rule line closes
the agent list. Comments and blank lines are ignored."""
groups, sitemaps, signals = [], [], []
agents, rules, collecting = [], [], False
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line or ":" not in line:
continue
key, _, val = line.partition(":")
key, val = key.strip().lower(), val.strip()
if key == "user-agent":
if not collecting:
if agents:
groups.append((agents, rules))
agents, rules = [], []
collecting = True
agents.append(val.lower())
continue
collecting = False
if key in ("allow", "disallow") and agents:
rules.append((key, val))
elif key == "sitemap":
sitemaps.append(val)
elif key == "content-signal":
signals.append({"agents": list(agents), "value": val, "line": line})
if agents:
groups.append((agents, rules))
return groups, sitemaps, signals
def _rule_matches_root(path):
# Empty Disallow means "nothing disallowed"; `*` and `$` are the only wildcards.
if not path:
return False
pattern = "^" + re.escape(path).replace(r"\*", ".*")
if pattern.endswith(r"\$"):
pattern = pattern[:-2] + "$"
return re.match(pattern, "/") is not None
def root_access(rules):
"""Longest-match evaluation of `/`; Allow wins ties, as Google and Bing do it."""
best = None
for kind, path in rules:
if not _rule_matches_root(path):
continue
key = (len(path), kind == "allow")
if best is None or key > best[0]:
best = (key, kind, path)
if best is None:
return "allowed", None
return ("allowed" if best[1] == "allow" else "disallowed"), f"{best[1].capitalize()}: {best[2]}"
def crawler_status(groups, token):
own = [r for agents, rules in groups if token.lower() in agents for r in rules]
if any(token.lower() in agents for agents, _ in groups):
status, rule = root_access(own)
return {"status": status, "via": token, "rule": rule}
wildcard = [r for agents, rules in groups if "*" in agents for r in rules]
if any("*" in agents for agents, _ in groups):
status, rule = root_access(wildcard)
return {"status": status, "via": "*", "rule": rule}
return {"status": "unmentioned", "via": None, "rule": None}
def check_robots(origin, timeout, findings):
url = origin + "/robots.txt"
got = fetch(url, timeout)
data = {"url": url, "http_status": got["http_status"], "status": "present", "sitemaps": [],
"content_signals": [], "crawlers": {}}
if got["error"]:
data["status"] = "unreachable"
findings.append(finding("robots-unreachable", "medium", url, got["error"]))
return data
if got["http_status"] == 404:
data["status"] = "missing"
findings.append(finding("robots-missing", "low", url, "HTTP 404: no robots.txt, every crawler is allowed by default"))
return data
if not 200 <= got["http_status"] < 300:
data["status"] = "unreachable"
findings.append(finding("robots-unreachable", "medium", url, f"HTTP {got['http_status']}"))
return data
if got["text"].lstrip()[:1] == "<":
# Soft 404: a server that answers unknown paths with the homepage.
data["status"] = "missing"
findings.append(finding("robots-missing", "low", url, f"HTTP {got['http_status']} but the body is HTML, not a robots.txt"))
return data
groups, sitemaps, signals = parse_robots(got["text"])
data["sitemaps"] = sitemaps
data["content_signals"] = signals
if not sitemaps:
findings.append(finding("robots-no-sitemap", "medium", url, "no Sitemap: line"))
for sig in signals:
for part in re.split(r"[,\s]+", sig["value"]):
name, _, val = part.partition("=")
if val.lower() != "no":
continue
sev = "high" if name in ("ai-input", "search") else "low"
findings.append(finding("robots-content-signal", sev, url,
f"{sig['line']} (User-agent: {', '.join(sig['agents']) or '?'})"))
for token, tier in CRAWLERS.items():
st = crawler_status(groups, token)
st["tier"] = tier
data["crawlers"][token] = st
if st["status"] == "disallowed":
findings.append(finding("robots-disallow", TIER_SEVERITY[tier], url,
f"{token} ({tier}) blocked by `{st['rule']}` under User-agent: {st['via']}"))
return data
# ---------------------------------------------------------------- llms.txt
LINK_RE = re.compile(r"\[[^\]]+\]\(https?://[^)\s]+\)")
def llms_format_problems(text):
"""Split llms.txt problems into what Lighthouse fails and what is only this plugin's reading of the spec.
The first list mirrors Lighthouse's llms-txt audit (core/audits/agentic/llms-txt.js): three regexes over
the raw text, an H1 anywhere, at least one [text](url) link, at least 50 characters. Nothing else fails
that audit. The second list is a heuristic from the llmstxt.org format, which puts the H1 first; Lighthouse
does not check it, so it reports at low severity and never as a Lighthouse failure.
"""
lighthouse = []
if not re.search(r"^\s*#\s+.+", text, re.M):
lighthouse.append("no H1 header (Lighthouse check)")
if not re.search(r"\[.+\]\(.+\)", text):
lighthouse.append("no markdown [text](url) links (Lighthouse check)")
if len(text) < 50:
lighthouse.append(f"only {len(text)} characters, under Lighthouse's 50 (Lighthouse check)")
lines = [ln for ln in text.splitlines() if ln.strip()]
spec = []
if lines and not lines[0].lstrip().startswith("# "):
spec.append("first non-blank line is not the H1 the llmstxt.org format puts first (plugin heuristic)")
return lighthouse, spec
def check_llms(origin, name, timeout, findings):
url = f"{origin}/{name}"
got = fetch(url, timeout)
data = {"url": url, "http_status": got["http_status"], "status": "absent", "size": 0,
"first_heading": None, "h1_first": False, "link_count": 0}
if got["error"]:
data["status"] = "unreachable"
findings.append(finding("llms-unreachable", "low", url, got["error"]))
return data
text = got["text"]
ok = got["http_status"] and 200 <= got["http_status"] < 300
is_html = text.lstrip()[:1] == "<" or "html" in got["content_type"]
if not ok or is_html:
# A 2xx with an HTML body is a server answering unknown paths with the homepage.
why = f"HTTP {got['http_status']}" + (" but the body is HTML (soft 404)" if ok and is_html else "")
findings.append(finding("llms-absent", "low", url, why))
return data
data["status"] = "present"
data["size"] = len(text.encode("utf-8"))
lines = [ln for ln in text.splitlines() if ln.strip()]
heading = next((ln.strip() for ln in lines if ln.lstrip().startswith("#")), None)
data["first_heading"] = heading
data["h1_first"] = bool(lines) and lines[0].lstrip().startswith("# ")
data["link_count"] = len(LINK_RE.findall(text))
lighthouse, spec = llms_format_problems(text)
if lighthouse:
findings.append(finding("llms-format", "medium", url, "; ".join(lighthouse)))
if spec:
findings.append(finding("llms-spec", "low", url, "; ".join(spec)))
return data
# ---------------------------------------------------------------- pages
class PageParser(HTMLParser):
"""Collects the head tags the checklist names, the JSON-LD blocks, and the visible
body text length. Tolerant of unclosed tags the way browsers are."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.title = None
self.meta_description = None
self.canonical = None
self.lang = None
self.jsonld = []
self.text_chars = 0
self._stack = []
self._title = None
self._jsonld = None
def handle_starttag(self, tag, attrs):
tag = tag.lower()
a = {k.lower(): (v or "") for k, v in attrs}
if tag == "html" and self.lang is None:
self.lang = a.get("lang") or ""
elif tag == "meta" and (a.get("name") or "").lower() == "description":
self.meta_description = a.get("content", "")
elif tag == "link" and "canonical" in (a.get("rel") or "").lower().split():
self.canonical = a.get("href", "")
if tag in VOID_TAGS:
return
self._stack.append(tag)
if tag == "script" and "ld+json" in a.get("type", "").lower():
self._jsonld = []
elif tag == "title" and self.title is None:
self._title = []
def handle_endtag(self, tag):
tag = tag.lower()
if tag in VOID_TAGS or tag not in self._stack:
return
if tag == "script" and self._jsonld is not None:
self.jsonld.append("".join(self._jsonld))
self._jsonld = None
if tag == "title" and self._title is not None:
self.title = re.sub(r"\s+", " ", "".join(self._title)).strip()
self._title = None
idx = len(self._stack) - 1 - self._stack[::-1].index(tag)
del self._stack[idx:]
def handle_data(self, data):
if self._jsonld is not None:
self._jsonld.append(data)
elif self._title is not None:
self._title.append(data)
elif not (set(self._stack) & SKIP_TEXT_TAGS):
self.text_chars += len(re.sub(r"\s+", " ", data).strip())
def flatten_jsonld(node):
"""Yield the page's own entities: top-level arrays and @graph members. Nested
author/publisher/offers objects are references and stay out, otherwise an Article's
publisher would be judged against the site-wide Organization template."""
if isinstance(node, list):
for item in node:
yield from flatten_jsonld(item)
elif isinstance(node, dict):
if "@type" in node:
yield node
if "@graph" in node:
yield from flatten_jsonld(node["@graph"])
def types_of(node):
t = node.get("@type")
return [t] if isinstance(t, str) else [x for x in (t or []) if isinstance(x, str)]
def check_schema_fields(node, url, findings):
for t in types_of(node):
canon = SCHEMA_ALIASES.get(t, t)
if canon not in SCHEMA_FIELDS:
continue
required, recommended = SCHEMA_FIELDS[canon]
for field in required:
if not node.get(field):
findings.append(finding("schema-field-missing", "medium", url, f"{t} without `{field}`"))
for field in recommended:
if not node.get(field):
findings.append(finding("schema-field-missing", "low", url, f"{t} without `{field}`"))
if canon == "FAQPage":
items = node.get("mainEntity") or []
items = items if isinstance(items, list) else [items]
for i, q in enumerate(items):
answer = q.get("acceptedAnswer") if isinstance(q, dict) else None
if not isinstance(q, dict) or not q.get("name") or not (isinstance(answer, dict) and answer.get("text")):
findings.append(finding("schema-faq-item-incomplete", "medium", url,
f"mainEntity[{i}] lacks name or acceptedAnswer.text"))
def check_page(url, timeout, findings):
got = fetch(url, timeout)
data = {"url": url, "final_url": got["final_url"], "http_status": got["http_status"], "title": None,
"meta_description": None, "canonical": None, "lang": None, "text_chars": 0, "jsonld": []}
if got["error"]:
findings.append(finding("unreachable", "high", url, got["error"]))
return data
if not 200 <= got["http_status"] < 300:
findings.append(finding("unreachable", "high", url, f"HTTP {got['http_status']}"))
return data
if not got["final_url"].startswith("https://"):
findings.append(finding("not-https", "high", url, f"served at {got['final_url']}"))
p = PageParser()
p.feed(got["text"])
p.close()
data.update(title=p.title, meta_description=p.meta_description, canonical=p.canonical, lang=p.lang,
text_chars=p.text_chars)
if not p.title:
findings.append(finding("title-missing", "medium", url, "no <title> text"))
if not p.meta_description:
findings.append(finding("meta-description-missing", "medium", url, "no <meta name=description>"))
if not p.canonical:
findings.append(finding("canonical-missing", "low", url, "no <link rel=canonical>"))
if not p.lang:
findings.append(finding("html-lang-missing", "low", url, "<html> has no lang attribute"))
if p.text_chars < MIN_BODY_TEXT:
findings.append(finding("thin-body-without-js", "high", url,
f"{p.text_chars} visible body chars without JS execution (heuristic, threshold {MIN_BODY_TEXT})"))
types_seen = []
for i, block in enumerate(p.jsonld):
entry = {"index": i, "ok": False, "types": [], "error": None}
try:
parsed = json.loads(block)
except json.JSONDecodeError as e:
entry["error"] = str(e)
snippet = re.sub(r"\s+", " ", block.strip())[:120] or "(empty block)"
findings.append(finding("schema-parse-error", "high", url, f"block {i}: {e}; starts `{snippet}`"))
data["jsonld"].append(entry)
continue
entry["ok"] = True
for node in flatten_jsonld(parsed):
entry["types"].extend(types_of(node))
check_schema_fields(node, url, findings)
types_seen.extend(entry["types"])
data["jsonld"].append(entry)
if not p.jsonld:
findings.append(finding("schema-missing", "high", url, "no <script type=application/ld+json> blocks"))
elif not any(SCHEMA_ALIASES.get(t, t) == "Organization" for t in types_seen):
findings.append(finding("schema-no-organization", "low", url,
f"JSON-LD types present: {', '.join(sorted(set(types_seen))) or 'none'}"))
return data
# ---------------------------------------------------------------- orchestration
def finding(check, severity, url, evidence):
return {"check": check, "severity": severity, "url": url, "evidence": evidence}
def probe(target, extra_urls=(), timeout=10):
origin, first_page = normalize_target(target)
findings = []
pages = [first_page] + [normalize_target(u)[1] for u in extra_urls]
result = {
"target": origin,
"timeout": timeout,
"robots": check_robots(origin, timeout, findings),
"llms": {name: check_llms(origin, name, timeout, findings) for name in ("llms.txt", "llms-full.txt")},
"pages": [check_page(u, timeout, findings) for u in pages],
}
findings.sort(key=lambda f: (SEVERITY_RANK[f["severity"]], f["url"], f["check"]))
result["findings"] = findings
result["counts"] = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITY_RANK}
return result
def filter_findings(findings, min_severity):
floor = SEVERITY_RANK[min_severity]
return [f for f in findings if SEVERITY_RANK[f["severity"]] <= floor]
def render_text(result, findings):
out = [f"\n geo probe: {result['target']}"]
r = result["robots"]
out.append(f" robots.txt: {r['status']} (HTTP {r['http_status']}) sitemaps: {len(r['sitemaps'])}"
f" content-signals: {len(r['content_signals'])}")
if r["crawlers"]:
for token, st in r["crawlers"].items():
via = "not mentioned" if st["status"] == "unmentioned" else f"via {st['via']}"
rule = f" {st['rule']}" if st["rule"] else ""
out.append(f" {token:<20} {st['status']:<12} {via}{rule}")
for name, d in result["llms"].items():
extra = f", {d['size']} B, heading: {d['first_heading'] or 'none'}, links: {d['link_count']}" if d["status"] == "present" else ""
out.append(f" /{name}: {d['status']} (HTTP {d['http_status']}){extra}")
for pg in result["pages"]:
types = sorted({t for b in pg["jsonld"] for t in b["types"]})
bad = sum(1 for b in pg["jsonld"] if not b["ok"])
out.append(f" page {pg['url']} (HTTP {pg['http_status']})")
out.append(f" title: {pg['title']!r} lang: {pg['lang']!r} canonical: {pg['canonical']!r}")
out.append(f" meta description: {pg['meta_description']!r}")
out.append(f" body text: {pg['text_chars']} chars JSON-LD blocks: {len(pg['jsonld'])}"
f" ({bad} unparseable) types: {', '.join(types) or 'none'}")
c = result["counts"]
out.append(f"\n findings: {len(findings)} reported high: {c['high']} medium: {c['medium']} low: {c['low']}\n")
for f in findings:
out.append(f" [{f['severity'].upper():<6}] {f['check']:<26} {f['url']}")
out.append(f" {f['evidence']}")
if findings:
out.append("")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser(description="Deterministic GEO probe: robots.txt, llms.txt, JSON-LD and head tags.")
ap.add_argument("target", help="domain or URL; its origin is used for robots.txt and llms.txt")
ap.add_argument("urls", nargs="*", help="additional page URLs to probe")
ap.add_argument("--json", action="store_true", help="machine-readable output for phase two")
ap.add_argument("--timeout", type=int, default=10, help="per-request timeout in seconds (default 10)")
ap.add_argument("--severity", choices=["high", "medium", "low"], default="low",
help="minimum severity to report and gate on (default: low = everything)")
args = ap.parse_args(argv)
try:
result = probe(args.target, args.urls, args.timeout)
except ValueError as e:
print(str(e), file=sys.stderr)
return 2
reported = filter_findings(result["findings"], args.severity)
result["findings"] = reported
result["counts"] = {s: sum(1 for f in reported if f["severity"] == s) for s in SEVERITY_RANK}
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(render_text(result, reported))
return 1 if reported else 0
if __name__ == "__main__":
sys.exit(main())
+8 more · Show all 18
skills/a-seo-gsc/SKILL.md14.1 KB
---
name: a-seo-gsc
description: Turn Search Console data, fetched over the API or exported by hand, into a ranked, evidence-backed action plan.
disable-model-invocation: true
---
# a-seo-gsc: Search Console → action plan
Google Search Console already holds most of the signals for what is wrong with a site's search performance. This skill fetches its Performance data over the API or ingests manual exports, runs a deterministic parser, applies a full analysis playbook (adapting to the site type), and writes a prioritized action plan you can actually execute. It works on any website, not one specific project.
The output is a doc: `docs/seo/YYYY-MM-DD-action-plan.md` in the target project (or wherever the user keeps analyses), plus a spoken summary of the headline finding and the top moves.
## First run: what to give me, and where to get it
**Open by asking which route they are on**, because both are fully supported and the answer changes only the first step:
> Do you have the Search Console API set up for this property, or would you rather download the reports by hand? Both work. The API saves clicking and returns one extra thing the download cannot, but hand-exported CSVs are a first-class input here, not a fallback.
### Route A: they have the API, or want it
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/a-seo-gsc/scripts/fetch_gsc.py" \
--property sc-domain:example.com --days 90
```
That writes `current/` and `previous/` directories of CSVs in Google's own export shape. If the property has no service-account key yet, do not hand them a link. Run Setup mode below.
Then ask for the two reports no API serves:
> Two exports I cannot fetch, both from **Indexing → Pages**:
> 1. Click **Export** on the summary. That gives the indexed vs not-indexed counts and the reason breakdown.
> 2. Click the biggest "not indexed" reason (usually "Crawled - currently not indexed"), then **Export** the URL table. Repeat for any other large reason. This is the one people skip and the most useful file in the set.
> Drop the zips in a folder next to the fetched data. Optional extras: the **Links** report (top linking sites, also no API), **Sitemaps**, and **Core Web Vitals**.
### Route B: no API, or they would rather click
Give them this in full. Do not redirect them to a reference file.
> Go to Google Search Console (search.google.com/search-console), pick the property, and export three things. Each "Export" button gives a zip of CSVs.
> 1. **Performance → Search results**, set the date range (**Last 3 months** at minimum; if you can, also use the **Compare** tab against the previous period so I can see trends). Click **Export**.
> 2. **Indexing → Pages**, click **Export** on the summary (the indexed vs not-indexed counts and the reason breakdown).
> 3. **Indexing → Pages → click the biggest "not indexed" reason** (usually "Crawled - currently not indexed") **→ Export** the URL table. Repeat for any other large reason. This is the one people skip, and it is the most useful file.
> Drop all the zips in a folder and point me at it. Optional extras: the **Links** report, **Sitemaps**, and **Core Web Vitals**.
Everything downstream is identical. The parser cannot tell a fetched directory from an exported zip, and every analysis runs the same way on either.
**Say plainly what route B costs**, once, without labouring it: page+query pairs across the whole property. The standard export lists queries and pages separately; a page-filtered export shows one page's queries at a time, which does not scale to a site-wide check. Without pairs, the shared-query section is omitted rather than reported as clean. Every other dimension is unaffected. That is the whole difference, and for a small site it is often not worth setting up an API to get.
Then ask for the site's **brand terms** (name, domain, product names) so branded vs non-branded traffic can be split, and what the site is **for** (blog, ecommerce, SaaS, docs, local business) so the playbook adapts.
## Modes
| Mode | Trigger | What happens |
|------|---------|--------------|
| **Setup** | Route A, no key yet | Walk them through the API setup one step at a time, then verify |
| **Fetch** | Route A, key present | Run `fetch_gsc.py`, then ask only for the two coverage exports |
| **Intake** | Route B, or no data yet | Give the full three-export list inline, collect brand terms + site type |
| **Analyze** | Exports in hand | Run the parser, then work the playbook, report findings |
| **Plan** | After analyze | Write the tiered action-plan doc to `docs/seo/` |
| **Competitors** | On request, or when content gaps dominate | Dependency-free competitor lens (see below) |
Analyze and Plan usually run together. Do not skip Plan: the written, prioritized doc is the deliverable.
## Setup mode
Ask for the property exactly as Search Console names it, then resolve the key the same way `fetch_gsc.py` does: an explicit `--key` path, `GSC_SERVICE_ACCOUNT_JSON`, or `~/.config/gsc/<property-slug>.json`. Only skip setup when one of those resolves for the requested property. Do not skip because another JSON file happens to exist in `~/.config/gsc/`.
Before creating a key, identify the environment and check for Python 3 and `openssl`. The bundled API fetcher needs a Unix-like environment because its secure key handoff uses `/dev/fd`. On native Windows, offer WSL or Route B before walking through setup. If they are setting up the API for their own Windows-capable script, continue and let that script define its credential location.
One service-account key can be reused for several properties, but its account must be added to each property. When reusing one key, pass its path with `--key` or set `GSC_SERVICE_ACCOUNT_JSON`; the property-derived default filename is for zero-argument lookup, not a second key requirement.
Read `references/export-guide.md` and walk them through its setup section **one step at a time, waiting after each**. Do not paste seven steps at once. This is a browser flow across two Google products and people get stuck at a single step, usually the one they think is a formality.
The procedure stays in the reference rather than here on purpose. It runs once per property and this file loads on every invocation.
Finish by verifying, which is the part a written guide cannot do:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/a-seo-gsc/scripts/fetch_gsc.py" \
--property <theirs> --days 7 --no-compare
```
Two failures are worth recognising on sight, because both point somewhere other than where they read:
- **`403 ... has not been used in project ... or it is disabled`.** The API is off. That is the Enable step, not a permissions problem, and the wording sends people back to Search Console to re-check the user they just added. Send them to the Cloud console instead and wait a minute for it to propagate.
- **`403` mentioning permission or the property.** The service account is not a user on the property yet, or the property string does not match. Check it is entered exactly as Search Console names it, including the `sc-domain:` prefix.
## The method
### 1. Parse (deterministic, cheap)
Run the parser over whatever the user gave you. It auto-detects Performance / Coverage / Drilldown files, normalizes the numbers, and prints findings.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/a-seo-gsc/scripts/parse_gsc.py" \
<zip-or-dir-or-csv> [more...] \
--brand "brand one,brandtwo,domain.com" --min-impressions 5
```
Add `--json` to get machine output, `--out FILE` to save. Read the printed report before reasoning further; it already computes baseline, concentration, striking-distance, CTR underperformers, zero-click queries, parameter-duplicate cannibalization, coverage buckets, the index-starvation-vs-bloat diagnosis, and a categorized not-indexed list.
**Page+query pairs.** A fetched directory carries `pairs.csv`: page and query together across the whole property, without assembling one page-filtered export per page. It flags shared-query candidates to investigate: which of your own pages appear for one query, and whether the page Google shows most often is the one that ranks best. Absent that file the section is omitted rather than reported as clean, because nothing was examined. Parse `current/` and `previous/` separately and diff them for the decay dimension.
**Search types.** A downloads folder often holds Performance exports for more than one search type (Web, Image, Video, News). They describe the same URLs from different indexes, so merging them double-counts every page and the duplicates surface as parameter cannibalization that is not real. The parser reads each export's `Filters.csv`, parses one search type, and prints on stderr what it ignored. It defaults to Web; pass `--search-type image` for another. Coverage and drilldown CSVs are never filtered, so they always come through. Read the stderr note: it tells you which dataset the numbers below it describe.
**Read the index before the data.** If the project keeps a `docs/seo/README.md` or equivalent, read it first. It should say which analysis is current, what each past one settled, and what checks are outstanding. Reconstructing that by opening six dated files is the expensive way to start a run.
### 2. Analyze (judgment on top of the numbers)
Open `references/analysis-playbook.md` and walk every dimension that the data supports. The playbook covers far more than any one site will show: striking distance, CTR-vs-position, cannibalization, content gaps, indexing pathologies, decay (needs two date ranges), device/geo mismatch, rich-result opportunities, authority/linking signals, and site-type-specific failure modes (blog vs ecommerce vs SaaS vs docs vs local vs directory). Only report what the data actually supports, and say what it cannot show.
The non-negotiable discipline (from hard-won review experience): **separate observation from hypothesis from action.** "Impressions dropped" is an observation. "Googlebot got empty HTML" is a hypothesis until verified. Do not let a plausible story become a confident diagnosis. When you cannot prove causation (and a single export almost never can), say so and propose the check that would confirm it.
### 3. Plan (the deliverable)
Use `references/action-plan.md` as the template. Write findings evidence-first, then a tiered action list ranked by impact-over-effort, then a tracking table (one row per action: page/query, baseline, change date, expected effect, 28-day result) so next month's run can tell whether anything worked. Save to `docs/seo/YYYY-MM-DD-action-plan.md`. Create `docs/seo/` if absent. If the project has no `docs/`, ask where to save or offer the repo root.
### 4. Competitors (optional, dependency-free)
See `references/competitor-analysis.md`. Identify who ranks for the site's striking-distance and target queries (via `WebSearch`), fetch their ranking pages (`WebFetch`), and compare depth, structure, schema, freshness, and angle coverage. Produces a content-gap matrix and prioritized competitive moves. No paid API required; if the user has an Ahrefs/Semrush export, it plugs in as an extra CSV for real volume and backlink numbers.
## Principles
1. **Impressions are not the same as the right audience.** A page can soak up impressions from a query cluster that has nothing to do with the site's purpose. Growing that is vanity. Always sanity-check whether the winning queries match who the site is for.
2. **Index-starvation vs index-bloat are opposite problems with opposite fixes.** Starvation (good pages not indexed): fix authority, internal linking, and request indexing; do NOT publish more. Bloat (thin/duplicate pages diluting crawl): consolidate, noindex, canonicalize. The parser flags which one the data shows. Never prescribe "write more content" for a starved site.
3. **Authority is usually the real gate on a small site.** If only backlinked pages get indexed and rank while internally-linked-only pages sit in "crawled, currently not indexed," the bottleneck is authority and link architecture, not content volume. One earned link often does more than ten new posts.
4. **CTR far below the position curve = a snippet or intent problem, not a ranking problem.** A page at position 9 with 0% CTR usually means the title/meta does not match the query intent, or a SERP feature is eating the click. Fix the snippet, not the ranking.
5. **No controls means self-attributed wins.** Without an untouched comparison set and a dated change log, you cannot prove a fix caused a change. Build the tracking table so the next run can.
6. **Adapt to the site type.** The failure modes of a 300-page blog, a 50k-SKU store, a docs site, and a local business are different. Read the site-type section of the playbook.
## Cross-skill boundaries
- **a-geo-optimizer**, AI-search / GEO visibility (ChatGPT, Perplexity, Gemini citations, llms.txt, answer-capsule content). Use it for the AI-discoverability angle; this skill does not duplicate it.
- **A project-specific technical-SEO skill**, where you have one, audits the *codebase* (meta tags, canonicals, structured data, CWV). This skill diagnoses from *data* and tells that one what to go fix. Run this to find the problem, the code-level skill to fix it.
`fetch_gsc.py` saves raw exports locally and sends requests only to `oauth2.googleapis.com` and `www.googleapis.com`. The bundled parser computes the findings on the user's machine, then Claude reads those findings in the session under the user's Claude Code plan data controls. The plugin adds no telemetry, and the service-account key never leaves the machine or touches disk beyond the file the user points it at.
## Reference files
- `references/export-guide.md`, exact GSC export click-paths for every report, how to get two date ranges for trend diffing, and alternative sources (Bing Webmaster Tools, Ahrefs/Semrush CSV).
- `references/analysis-playbook.md`, the full dimension-by-dimension methodology with thresholds, formulas, and per-site-type adaptations.
- `references/action-plan.md`, output doc template and the impact/effort prioritization framework.
- `references/competitor-analysis.md`, the dependency-free competitor lens.
- `scripts/fetch_gsc.py`, the Search Console API fetcher. Standard library only, signs RS256 via openssl.
- `scripts/parse_gsc.py`, the deterministic export parser. Reads fetched and hand-exported data identically.
skills/a-seo-gsc/agents/openai.yaml153 B
interface: display_name: "SEO from GSC" short_description: "Rank the fixes from your Search Console data" policy: allow_implicit_invocation: false
skills/a-seo-gsc/references/action-plan.md4.0 KB
# Action-plan template The deliverable. Write it to `docs/seo/YYYY-MM-DD-action-plan.md` in the target project. Evidence first, then ranked actions, then a tracking table so the next run can measure what worked. Keep it honest: every claim ties to a number from the export, every hypothesis is labeled as one, and anything the data cannot show is stated plainly. ## Structure ```markdown # SEO action plan, <site>, <YYYY-MM-DD> Source: GSC export, <date range>. Brand terms: <...>. Site type: <...>. ## Snapshot - Clicks / impressions / CTR / avg position (with the one-line "so what"). - Branded vs non-branded. - Concentration (top page's share of impressions). ## Headline finding The single most important thing, stated in one paragraph, with the numbers that prove it and the one hypothesis (labeled) for the cause. This is what the whole plan hinges on. ## Findings by dimension For each dimension the data supports (indexing, striking distance, CTR, cannibalization, content gaps, decay, device/geo, authority): - **Observation:** what the numbers say (cite them). - **Hypothesis:** the likely cause, labeled as a hypothesis. - **Check:** what would confirm it (so it does not stay a guess). ## Action list (ranked) Tiered by impact-over-effort. Each action: what, why (which finding it addresses), effort (S/M/L), expected effect, and who does it (code change vs GSC click vs content edit vs outreach). ### Tier 1. Highest impact ### Tier 2. Quick wins / hygiene ### Tier 3. Worth doing, secondary ## What this export cannot tell us Explicit limits: no volume/difficulty data, no conversion data, causation unproven, query×page pairing unavailable, single snapshot (no trend), etc. Plus what to export next time to close the gap. ## Tracking table | # | Action | Target (page/query) | Baseline (date) | Changed on | Expected effect | 28-day result | |---|--------|--------------------|-----------------|-----------|-----------------|---------------| Leave "Changed on" and "28-day result" blank; they get filled as work ships and at the next run. ``` ## Prioritization Rank by **impact ÷ effort**, not by dimension order. A rough ICE (Impact, Confidence, Ease) or a 2×2 (impact vs effort) is enough; do not over-formalize. Bias toward: 1. **Actions that unblock everything else.** On a starved site, getting content indexed (authority + linking) gates every other win, so it is Tier 1 even though it is slow. On a bloated site, the parameter/canonical cleanup is the unblock. 2. **Zero-click strong positions.** Rewriting a title for a page already at position 5-10 with near-zero CTR is the cheapest click recovery on the board. 3. **Striking distance.** Small pushes on page-2 queries that already have impressions. 4. **Hygiene that stops the bleed.** 404 fixes, param exclusions, junk-URL noindex. Low effort, prevents further dilution. De-prioritize: chasing off-audience query clusters (vanity impressions), building new content on a site that cannot get its existing content indexed, and anything whose expected effect you cannot articulate. ## Who executes what (label every action) - **Code change**, needs a dev edit (canonical logic, robots rules, schema, redirects). If the project has a technical-SEO or codebase-audit skill, point there. - **GSC action**, manual Search Console step (Request Indexing, submit sitemap, validate fix). Give the exact click-path and the prioritized URL list to paste. - **Content edit**, rewrite title/meta, add a section, consolidate posts, refresh. Route through the project's content workflow (never raw DB edits on content fields). - **Off-site**, earn a link, get a mention, cross-post. Usually the real lever for authority-gated sites; connect to whatever promotion workflow exists. ## Tone Write like a senior analyst briefing the owner, not a report generator. Lead with the decision, support with the number, name the uncertainty. No filler, no "it is worth noting," no em dashes. If the honest read is "your content is fine, the problem is nobody links to you," say exactly that.
skills/a-seo-gsc/references/analysis-playbook.md13.4 KB
# Analysis playbook
Work every dimension the data supports. Most sites show only a subset; report those, name what is missing, and never invent a finding the data does not carry. The parser (`scripts/parse_gsc.py`) computes the arithmetic for most dimensions below; you supply judgment and the ones it cannot.
Order below is roughly "most decisive first." On any given site, one or two dimensions dominate. Find the dominant one and lead with it.
---
## 0. Baseline and shape
Read the totals first (from the Dates/Chart file, which is authoritative; query/page files are a top-N sample).
- **Clicks, impressions, CTR, average position.** Position is impression-weighted, so a few high-impression page-2 queries drag it up.
- **Branded vs non-branded** (needs `--brand`). Branded clicks are people who already know the site. If branded is most of the traffic, the site is not winning new audiences. If the site cannot even rank #1 for its own brand, that is a distinct, fixable problem (entity signals, homepage authority).
- **Concentration.** What share of impressions sits on the single top page? High concentration (one page ≫ everything) means the site's search presence is one lucky page, not a system. Ask whether that page is even on-topic for the site.
Sanity flag: if impressions are large but clicks are near zero, the whole site is probably ranking on page 2, or the snippets are wrong, or the impressions are for queries the content does not actually answer.
## 1. Indexing / coverage pathology
Often the real story, and people never look. Diagnose which pattern:
- **Index-starvation:** more pages excluded than indexed, good content sitting in "Crawled - currently not indexed" or "Discovered - currently not indexed." Cause is almost always **authority + internal linking + crawl priority**, not content quality. Fixes: earn links, strengthen internal links from indexed high-authority pages, get hub/index pages indexed, request indexing for priority URLs, submit a clean sitemap. Do NOT tell a starved site to publish more; it just grows the parked pile.
- **Index-bloat:** thousands of thin/duplicate/parameter URLs indexed or clogging crawl. Fixes: canonicalize, noindex thin templates, `Disallow` parameter and faceted URLs, consolidate.
- **Canonical confusion:** large "Duplicate without user-selected canonical" or "Alternate page with proper canonical tag." Check that canonicals are self-consistent and point where you intend.
Coverage reason cheat-sheet (GSC label → what it means → move):
| Reason | Meaning | Move |
|--------|---------|------|
| Crawled - currently not indexed | Google fetched it, chose not to index | Quality/authority/linking. Improve links + request indexing. If genuinely thin, improve or noindex. |
| Discovered - currently not indexed | Known but not even crawled yet | Crawl budget / low priority. Internal links + sitemap + authority. |
| Duplicate without user-selected canonical | Google picked a different canonical | Set explicit canonical; consolidate duplicates. |
| Duplicate, Google chose different canonical than user | Your canonical was overridden | The "duplicate" is stronger; merge or differentiate. |
| Alternate page with proper canonical tag | Correctly canonicalized dupe | Usually fine. Expected for paginated/param/AMP. |
| Page with redirect | 301/302 | Fine if intended (restructure fallout). Audit for redirect chains. |
| Not found (404) | Broken | Fix internal links or 301 to the right page. |
| Soft 404 | Thin/empty page returning 200 | Add content or return real 404. |
| Blocked by robots.txt | Disallowed | Verify intentional; accidental blocks are common after CMS/CDN changes. |
| Blocked due to unauthorized request (401) | Auth-gated | Fine if intended. |
| Server error (5xx) | Broke during crawl | Reliability issue; investigate logs. |
Then **categorize the not-indexed URL list** (the parser buckets by generic path pattern: content, taxonomy-tag, taxonomy-category, product, pagination, parameter-url, feed, redirect-endpoint). Interpret per site: which buckets are junk that should never be index candidates (feeds, redirect endpoints, param/sort URLs, thin tag pages) vs which are real content being wrongly excluded. Junk → exclude from crawl. Real content excluded → the starvation fight above.
## 2. Striking distance
Queries at **positions 11-20** with real impressions are the highest-ROI lever on most sites: they already rank, just below the fold of page 1. Small on-page improvements (title/meta relevance, adding the exact sub-topic, one internal link, a bit more depth) can push them onto page 1 where clicks live. Sort by impressions; the top of that list is the week's to-do. Positions 5-10 with low CTR belong to dimension 3, not here.
## 3. CTR vs position
For queries in the top 10, compare actual CTR to the expected-for-position curve (parser flags actual below 40% of expected). Under-performers are a **snippet or intent problem**, not a ranking problem:
- Title/meta does not match the query intent (page ranks for "X system requirements" but the title says "X overview").
- A SERP feature (featured snippet owned by someone else, People-Also-Ask, a pack, an ad block) is eating the clicks.
- The URL/brand looks untrustworthy in the SERP.
Fixes: rewrite the title to lead with the searcher's outcome and include the query's head term; tighten the meta description; add the structured data that could win the feature (defer schema specifics to `a-geo-optimizer`, or to your project's code-level SEO skill). Zero-click at a strong position is the loudest, cheapest win on the board.
## 3b. How much of the traffic do query rows actually cover?
Before trusting anything computed from query rows, total them and compare against the page rows. Google withholds queries too few people ran, so the query table is a sample, and on a low-volume site it is a small one. Measured on one low-traffic blog over 28 days: page rows carried roughly 50 clicks and 12,000 impressions while query rows carried 4 and 1,900. Query rows saw 16% of impressions and 8% of clicks. Expect the gap to shrink as a site gets busier, and check it rather than assuming.
That does not invalidate striking-distance or CTR work, but it changes what those findings are: statements about the disclosed minority, not about the site. State the coverage ratio in the report. Never present a query-row total as the site's traffic, and never subtract query clicks from page clicks and call the remainder anything.
Related, and worth not chasing: `dates.csv` and `pages.csv` disagree slightly, around 1% on the window above. Both are Google's own aggregates over the same data. Use page rows for totals.
## 4. Cannibalization and duplicate URLs (partial)
- **Parameter/duplicate URLs** (parser detects): the same clean URL indexed under `?ref=`, `?sort=`, `?utm=`, session IDs, tracking tags, pagination. Splits signals and wastes crawl. Fix: self-referential-clean canonical, `Disallow` the params, or 301 the tracked variants.
- **Shared-query candidates** (several of your pages appearing for one query): the parser flags these whenever a `pairs.csv` is present, which `fetch_gsc.py` writes across the whole property. Google's standard export lists queries and pages in separate tables, and a page-filtered export covers one page at a time, so without pairs the section is omitted rather than reported as clean. A flagged query has two or more of your pages each holding at least the impression floor, with the runner-up on 20% or more of the query's impressions. Read the **position/impression mismatch** flag first: Google shows one page most often while a different one ranks better. A flag is a candidate, not a diagnosis: pages can share a query and serve different intents, and both may deserve to rank. Before merging, check that the pages answer the same intent for the same reader. Only then pick one canonical page, merge the others into it and 301 them. (Typical symptom when it is real: several near-identical titles, none ranking well, positions bouncing.)
- **Over-splitting a topic:** a cluster of thin posts on near-identical sub-topics that would rank better as one strong page. Consolidation beats proliferation on low-authority sites.
## 5. Content gaps (partial)
Queries the site gets impressions for but has no dedicated page answering. Signals: a broad page ranking at position 9-15 for many specific long-tail variants it does not directly address (e.g., one overview post catching dozens of "X requirements / X pricing / X vs Y" queries). Two responses:
1. If on-topic and worth it: build the dedicated page (or a strong section) that directly answers the cluster.
2. If off-topic for the site's purpose: ignore it. Chasing off-audience queries grows vanity impressions. Judge against who the site is for.
GSC only shows queries you already appear for. For demand you are missing entirely, you need a keyword tool or the competitor lens.
## 6. Decay and trend (needs two date ranges)
`fetch_gsc.py` writes `current/` and `previous/` windows of equal length by default, so this dimension runs without anyone having remembered to tick Compare in the UI. Parse each directory and diff. With hand exports it still depends on the Compare box, and if only one range is present, say the dimension was skipped rather than implying stability.
With a comparison export, diff period-over-period:
- **Declining queries/pages:** falling clicks or impressions, or rising position number. Often a competitor overtook, content went stale, or a redesign/restructure lost signals. Freshness updates and re-earning links help.
- **Position improved but CTR dropped:** you moved up but the SERP got more competitive (features, ads) or the snippet stopped matching. Snippet work.
- **Seasonality:** distinguish a real drop from an annual dip. Year-over-year comparison separates them.
- **Restructure fallout:** a category/URL migration typically shows a wave of new redirects, duplicate-canonical entries, and a not-indexed bump as Google re-evaluates. Time findings against known deploy dates before blaming quality.
Without two ranges, say so and recommend pulling the comparison export next time.
## 7. Device and geo
- **Device CTR gap:** if mobile CTR ≪ desktop at the same position (or vice versa), the mobile SERP layout or the page's mobile snippet/experience is the issue.
- **Geo/language mismatch:** impressions concentrated in countries the site does not serve or in a language it does not publish (common: a homelab/tech post pulling global traffic a personal/consulting site cannot use). Or the reverse: target market underrepresented, implying weak local relevance or hreflang gaps. Match the geo distribution against the site's actual market.
## 8. Rich results / search appearance
The Search-appearance breakdown (and Enhancements report) shows which rich-result types the site is eligible for and which are erroring. Missing eligibility for obvious types (Article, FAQ, Product, Breadcrumb, HowTo) is a structured-data opportunity. Hand the specifics to `a-geo-optimizer` (AI-citation schema) or to whatever skill owns code changes in the project; this skill just flags the gap from the data.
## 9. Authority and internal linking (needs Links export)
- **Referring domains:** few linking sites = low authority = the index-starvation gate. The clearest tell: only backlinked pages are indexed and ranking. One earned link often pulls in a whole cluster.
- **Internal-link distribution:** pages with zero internal links are orphans Google deprioritizes. Ensure every important page is linked from indexed, higher-authority pages (home, hubs, related content), not just from each other.
- **Anchor concentration:** if all internal anchors are generic ("read more"), you are wasting relevance signals.
---
## Site-type adaptations
The dominant failure mode shifts with what the site is.
- **Blog / content site:** cannibalization from overlapping posts, thin-tag index bloat, striking-distance long-tail, freshness decay. Growth = consolidate + strengthen the best, not endless new posts (on low authority).
- **Ecommerce:** faceted-navigation parameter explosion (the #1 cause of index bloat), thin product pages, category-vs-product cannibalization, out-of-stock/discontinued 404s, seasonality. Canonicals and parameter handling dominate.
- **SaaS / marketing site:** small page count, so every page matters; branded vs non-branded split is diagnostic; feature/comparison/alternative pages are the striking-distance goldmine; docs subdomain interplay.
- **Docs / knowledge base:** huge page count, internal-search-style queries, version duplication (v1/v2 pages competing), "answer" intent where featured snippets matter most.
- **Local business:** geo distribution is everything; Maps/local-pack impressions; branded + "near me" queries; NAP consistency and local schema.
- **Directory / aggregator / marketplace:** thin auto-generated pages Google routinely refuses to index (expect large "crawled not indexed"); the fight is proving unique value per page or accepting partial indexing and concentrating authority on the pages that matter.
## Thresholds used by the parser (tune per site)
- Striking distance: position 11-20, impressions ≥ `--min-impressions` (default 5).
- CTR underperformer: position ≤ 10 and actual CTR < 40% of the expected-for-position value.
- Zero-click: clicks == 0 and impressions ≥ max(min-impressions, 10).
- Expected CTR curve is a blended industry average (pos 1 ≈ 27%, pos 10 ≈ 2%, page 2 ≈ 1%). It is a reference for spotting outliers, not ground truth; real CTR varies wildly by query type and SERP layout.
skills/a-seo-gsc/references/competitor-analysis.md4.0 KB
# Competitor analysis (dependency-free) GSC shows what a site ranks for. It cannot show who is beating it, or what demand it is missing entirely. This lens fills that gap using only `WebSearch` and `WebFetch` (no paid API, no new dependency). If the user has an Ahrefs/Semrush/Moz export, fold it in for real volume and backlink numbers, but the lens works without one. Run it when: content gaps or striking-distance dominate the findings, the user asks about competitors, or a target query cluster is worth a serious push. ## Method ### 1. Pick the battleground queries From the analysis, take the striking-distance and target-content-gap queries that actually matter for the site's audience (not the vanity clusters). Cap it: 5-15 queries is enough to see the pattern. ### 2. See who ranks For each query, `WebSearch` it and record the top 5-10 organic results (skip ads and the site's own pages). Tally domains across queries. The domains that recur are the real competitors for this space, which is more accurate than whoever the user assumes. Note SERP features too: who owns the featured snippet, what People-Also-Ask questions appear (these are content-gap gold), whether a pack/carousel/forum result (Reddit, Stack Overflow) dominates. If forums own the SERP, the winning move may be a genuinely better resource or participating there, not another blog post. ### 3. Read the winners For the top 2-4 competitor pages per key query, `WebFetch` the page and compare against the site's competing (or missing) page on: - **Depth and coverage**, sub-topics they answer that the site does not. - **Structure**, headings, tables, direct answers up top, scannability. - **Freshness**, visible last-updated date; stale winners are beatable. - **Format match**, does the query want a listicle, a how-to, a definition, a comparison, a tool? Ranking pages reveal the intent. - **Schema / rich results**, what they render in the SERP (rating stars, FAQ, breadcrumbs). - **Authority proxy**, domain seniority, how referenced they are. Without a backlink tool this is a judgment call; say so. ### 4. Build the gap matrix A simple table: rows = sub-topics/angles, columns = the site + top competitors, cells = covered / thin / missing. The site's "missing" rows that competitors all cover are the content plan. The rows everyone is thin on are the differentiation opportunity. ### 5. Output competitive moves Fold into the action plan as its own tier: - **Beat on depth:** specific sub-topics to add to an existing striking-distance page to overtake a shallow winner. - **Beat on freshness:** pages where the winner is stale and a refresh + re-earn-links wins. - **Beat on format:** where the site's format mismatches intent (a prose post where the SERP wants a comparison table or tool). - **New pages:** only for gaps that are on-audience and winnable given the site's authority. Do not recommend targeting a query cluster owned by DR-90 sites on a new site; name that wall when it exists. ## Honest limits - `WebSearch` results are personalized/regional and a moment in time, not a ranking tool's aggregate. Treat them as a strong sample, not a rank tracker. - No backlink or traffic numbers without a paid export. State authority comparisons as estimates. - "Winnable" depends on the site's own authority (from the Links export and the indexing diagnosis). A gap you cannot rank for yet is not an opportunity, it is a someday. Sequence competitive content behind the authority work if the site is index-starved. ## Optional: paid-tool CSV inputs If the user provides them, these upgrade the lens from qualitative to quantitative: - **Keyword export** (volume, difficulty) → prioritize gaps by real demand, not just GSC impressions. - **Backlink export** (referring domains, competitor link profiles) → quantify the authority gap and surface link targets (who links to competitors but not the site). - **Site audit export** → competitor technical posture. Read them as extra CSVs alongside the GSC findings; the volume/difficulty columns slot straight into prioritization.
skills/a-seo-gsc/references/export-guide.md7.8 KB
# GSC export guide
Exact steps to pull the data this skill needs. Google Search Console UI wording drifts; match on intent if a label moved.
For the same setup with an explanation of what each step does, read the [full Search Console API walkthrough](https://ivanmisic.net/blog/ai-tools/search-console-api-page-query-pairs). This guide remains complete, so the skill never depends on the article being available.
## Prefer the API for Performance
`scripts/fetch_gsc.py` pulls the whole Performance half over the API, so export 1 below is only needed when the API is not set up for a property. The API also adds data: the UI export caps its query and page tables near 1000 rows while the API returns up to 25,000 top rows per request, it writes a matching previous window so trend analysis always runs, and it returns **page+query pairs** across the whole property, which a manual route can only assemble one page-filtered export at a time. Pairs flag pages that share a query; they are candidates to investigate, not proof that the pages compete. Google does not guarantee every row through the API, even with pagination, so treat it as a larger and more useful sample rather than a complete copy of every search.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/a-seo-gsc/scripts/fetch_gsc.py" \
--property sc-domain:example.com --days 90
```
Output is a directory of CSVs in Google's own export shape, so `parse_gsc.py` reads it exactly like a hand-exported zip and everything below stays a working fallback.
The bundled fetcher requires Python 3 and `openssl` on macOS, Linux, or WSL. On native Windows, use WSL for the API route or take the manual-export route below. The Google API itself is not limited to those systems; this is a constraint of the bundled dependency-free fetcher.
**One-time setup per Google account:**
1. `console.cloud.google.com` → pick or create a project.
2. APIs & Services → Library → **Google Search Console API** → Enable. Skipping this returns a 403 from the query endpoint whose wording sounds like a permissions problem rather than a disabled API.
3. APIs & Services → Credentials → Create credentials → **Service account**. No project IAM roles are needed; property access is granted in Search Console, not in IAM.
4. Open it → **Keys** → Add key → Create new key → **JSON**. Save it outside any webroot at `~/.config/gsc/<property-slug>.json`, mode 600. The slug is the property with `:`, `/` and `.` turned into `-`, so `sc-domain:example.com` becomes `example-com`.
**Per property:** Search Console → Settings → Users and permissions → Add user → the service account's `...iam.gserviceaccount.com` address → **Restricted**. The query endpoint needs read permission, so Full is unnecessary. One service account can be granted access to several properties; pass the same key with `--key` or `GSC_SERVICE_ACCOUNT_JSON` when its default filename belongs to another property.
**What the API cannot give you, ever:** the Index Coverage report (exports 2 and 3 below) and the Links report. There is no endpoint for either. The URL Inspection API is not a substitute for coverage: it only answers for URLs you hand it, so it can confirm a sitemap but can never reveal index bloat from URLs Google holds that you never submitted, which is half of what that report is for.
## The three core exports
Open [search.google.com/search-console](https://search.google.com/search-console) and select the property. With the API set up, only 2 and 3 still need doing by hand.
### 1. Performance (Search results)
1. Left nav → **Performance** → **Search results**.
2. Top of page: click the **Date** filter. Choose **Last 3 months** (or longer; 12 months is better if the site is older). To get trend data in one shot, use the **Compare** tab → "Compare last 3 months to previous period".
3. Make sure the four metric toggles are all on: **Total clicks, Total impressions, Average CTR, Average position**.
4. Click **Export** (top right) → **Download CSV** (a zip).
This zip contains: `Queries.csv`, `Pages.csv`, `Countries.csv`, `Devices.csv`, `Dates.csv` (or `Chart.csv`), `Filters.csv`, `Search appearance.csv`. Note: the Queries and Pages files are a top-N sample (~1000 rows), not every query, so query-level totals are a sample, not the site total. The Dates/Chart file carries the true totals.
### 2. Indexing summary (Pages)
1. Left nav → **Indexing** → **Pages**.
2. Click **Export** on the summary view.
Gives the indexed vs not-indexed counts over time (`Chart.csv`) and the reason breakdown (`Critical issues.csv` / `Non-critical issues.csv`, columns `Reason,Source,Validation,Pages`).
### 3. Indexing drilldown (the one people skip)
1. Still in **Indexing → Pages**, scroll to "Why pages aren't indexed".
2. Click the largest reason (usually **Crawled - currently not indexed**, or **Discovered - currently not indexed**, or **Duplicate without user-selected canonical**).
3. On the detail page, click **Export**. This gives `Table.csv` (`URL,Last crawled`), the actual list of affected URLs.
4. Repeat for any other reason with a meaningful count.
The drilldown is what turns "44 pages aren't indexed" into "these specific 44 URLs, and here's the pattern." Without it the analysis is guesswork.
## Getting trend data (two date ranges)
With `fetch_gsc.py` this is automatic: it writes `current/` and `previous/` windows of equal length unless you pass `--no-compare`. The rest of this section is the manual route.
A single export is a snapshot: it cannot show "position improved but CTR dropped" or "this query is decaying." To diff:
- **Easiest:** in Performance, use the **Compare** tab (previous period or year-over-year), then Export. The CSVs then carry both periods.
- **Manual:** export "Last 3 months" today, and separately export the prior 3 months (custom range). Hand both to the skill; it will diff them.
## Optional exports that sharpen the analysis
| Report | Where | Adds |
|--------|-------|------|
| **Links** | Left nav → Links → Export | Top linking sites, top linked pages, internal links. Reveals authority and orphan pages. |
| **Sitemaps** | Indexing → Sitemaps | Submitted vs discovered counts; sitemap errors. |
| **Core Web Vitals** | Experience → Core Web Vitals | Slow-URL groups (LCP/CLS/INP). CWV code fixes belong to a performance or technical-SEO skill, not here. |
| **Enhancements / Structured data** | Experience/Enhancements | Which rich-result types are eligible and erroring. |
| **Manual actions / Security** | Security & Manual Actions | Penalties or hacks. Always check; a manual action explains everything at once. |
## Alternative and supplementary data sources
- **Bing Webmaster Tools** exports in a similar shape; the parser reads generic Clicks/Impressions/CTR/Position CSVs, so Bing "Search Performance" exports work too.
- **Ahrefs / Semrush / Moz** CSV exports (keyword volume, difficulty, backlinks) are not GSC but plug into the competitor lens as extra inputs for real search-volume and referring-domain numbers. GSC has no volume or difficulty data, so if the user has these, ask for them.
- **GA4** organic-landing-page export pairs conversions with the GSC clicks, answering "traffic but no conversions." GSC alone cannot see conversions.
## What GSC data cannot tell you (state these up front)
- **Search volume or keyword difficulty**, GSC shows your impressions, not total market demand. Use a keyword tool for that.
- **Why visitors don't convert**, clicks in, but not what happened after. Needs analytics.
- **Query × page pairing at scale**, the standard export gives queries and pages separately, not which query drove which page (unless you export a page-filtered Performance view). True one-query-many-pages cannibalization needs that filtered export or the API.
- **Causation**, correlation only. A change and a metric move in the same week is a hypothesis, not proof.
skills/a-seo-gsc/scripts/fetch_gsc.py14.2 KB
#!/usr/bin/env python3
"""Pull Search Console Performance data straight from the API, as CSVs.
Replaces the click-through-and-download-three-zips intake for the Performance
half of a-seo-gsc. Writes the same CSV shapes Google's own export produces, so
parse_gsc.py reads a fetched directory and a hand-exported zip identically and
the manual route stays a working fallback.
What this CANNOT fetch, because no API serves it:
- the Index Coverage report (indexed vs not, reason breakdown, the
"Crawled - currently not indexed" URL table). The URL Inspection API only
answers for URLs you already hand it, so it can confirm a sitemap but never
reveal bloat from URLs Google holds that you never submitted. Still a
manual export.
- the Links report. No API at all.
What it fetches that the standard exports do not give you:
- page+query PAIRS across the whole property. Google's standard export
lists queries and pages in separate tables, and a page-filtered export
covers one page at a time, so a site-wide "which of my pages share this
query" check is impractical from exports. Shared queries are candidates
to investigate, not proof that the pages compete.
- a second, immediately preceding window of equal length, so decay analysis
works without anyone remembering to tick Compare in the UI.
Usage:
python3 fetch_gsc.py --property sc-domain:example.com
python3 fetch_gsc.py --property https://example.com/ --days 180 --out ./gsc
python3 fetch_gsc.py --property sc-domain:example.com --no-compare
Auth is a Google service account with the Search Console API enabled, added as
a user on the property. Key file resolution, in order: --key, then
$GSC_SERVICE_ACCOUNT_JSON, then ~/.config/gsc/<property-slug>.json.
Standard library only, like parse_gsc.py. On Unix-like systems, RS256 is signed
by shelling out to openssl rather than pulling in google-auth or cryptography.
The private key is passed to openssl through a pipe file descriptor and never
written to disk.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import io
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
API_BASE = "https://www.googleapis.com/webmasters/v3/sites/"
TOKEN_URI = "https://oauth2.googleapis.com/token"
SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
# Google's per-request cap. Paginated past with startRow.
ROW_LIMIT = 25000
# Runaway guard, not a real limit: 40 pages is a million rows.
MAX_PAGES = 40
# Search Console finishes processing a day about two days late, and a range
# ending today reads as a slump that is really just missing data.
LAG_DAYS = 3
# Each output CSV, as (filename, dimensions, first column header).
#
# Everything except dates.csv is aggregated over the whole range, one row per
# dimension value, because that is the shape Google's export has and the shape
# parse_gsc.py analyses. Asking for ['date', 'query'] instead would return one
# row per query PER DAY, and every query would then appear ninety times in the
# striking-distance table.
DATASETS = [
("dates.csv", ["date"], "Date"),
("queries.csv", ["query"], "Query"),
("pages.csv", ["page"], "Page"),
("countries.csv", ["country"], "Country"),
("devices.csv", ["device"], "Device"),
("pairs.csv", ["page", "query"], "Page"),
]
def eprint(*a: object) -> None:
print(*a, file=sys.stderr)
# ---------------------------------------------------------------- auth
def load_key(path: str) -> dict:
with open(path, encoding="utf-8") as f:
key = json.load(f)
for field in ("client_email", "private_key"):
if not key.get(field):
raise SystemExit(
f"{path} has no {field}. Point --key at the JSON Google issued, unedited."
)
return key
def b64url(raw: bytes) -> bytes:
import base64
return base64.urlsafe_b64encode(raw).rstrip(b"=")
def require_supported_environment() -> None:
if os.name == "nt":
raise SystemExit(
"The bundled API fetcher needs a Unix-like environment for secure "
"key handoff. Use it in WSL, or use the manual Search Console export route."
)
def sign_rs256(payload: bytes, private_key_pem: str) -> bytes:
"""Sign with openssl, handing it the key on a pipe rather than a temp file.
A service-account private key written to /tmp, even at mode 600, outlives
the process if anything crashes between write and unlink. The pipe dies
with the process. 64KB is the default pipe buffer and a PEM is under 2KB,
so writing the whole key before the child reads cannot deadlock.
"""
require_supported_environment()
read_fd, write_fd = os.pipe()
try:
os.write(write_fd, private_key_pem.encode())
finally:
os.close(write_fd)
try:
proc = subprocess.run(
["openssl", "dgst", "-sha256", "-sign", f"/dev/fd/{read_fd}"],
input=payload,
capture_output=True,
pass_fds=(read_fd,),
)
except FileNotFoundError:
raise SystemExit("openssl is not on PATH; it is needed to sign the assertion.")
finally:
os.close(read_fd)
if proc.returncode != 0:
raise SystemExit(
"openssl rejected the private key while signing: "
+ proc.stderr.decode("utf-8", "replace")[:300]
)
return proc.stdout
def access_token(key: dict, scope: str = SCOPE) -> str:
"""Mint a bearer token. `scope` is a parameter so sibling fetchers
(fetch_ga4.py) reuse this signing path instead of copying key handling."""
now = int(dt.datetime.now(dt.timezone.utc).timestamp())
header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
claims = b64url(
json.dumps(
{
"iss": key["client_email"],
"scope": scope,
"aud": key.get("token_uri", TOKEN_URI),
"iat": now,
"exp": now + 3600,
}
).encode()
)
signing_input = header + b"." + claims
assertion = signing_input + b"." + b64url(sign_rs256(signing_input, key["private_key"]))
body = urllib.parse.urlencode(
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion.decode(),
}
).encode()
req = urllib.request.Request(
key.get("token_uri", TOKEN_URI),
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
token = json.load(resp).get("access_token")
except urllib.error.HTTPError as e:
# The failure body can echo part of the assertion back, so truncate it.
raise SystemExit(
f"Token exchange failed (HTTP {e.code}): "
+ e.read().decode("utf-8", "replace")[:500]
)
if not token:
raise SystemExit("Google returned no access_token for the service account assertion.")
return token
# ---------------------------------------------------------------- fetch
def query_api(prop: str, token: str, dimensions: list[str], start: str, end: str,
search_type: str) -> list[dict]:
"""One dataset, paginated to exhaustion."""
url = API_BASE + urllib.parse.quote(prop, safe="") + "/searchAnalytics/query"
rows: list[dict] = []
for page in range(MAX_PAGES):
payload = {
"startDate": start,
"endDate": end,
"dimensions": dimensions,
"rowLimit": ROW_LIMIT,
"startRow": page * ROW_LIMIT,
"type": search_type,
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
batch = json.load(resp).get("rows", [])
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:600]
raise SystemExit(
f"Search Console returned HTTP {e.code} for {'+'.join(dimensions)} "
f"{start}..{end}:\n{detail}"
)
rows.extend(batch)
if len(batch) < ROW_LIMIT:
break
return rows
def write_csv(path: str, dimensions: list[str], first_header: str, rows: list[dict]) -> int:
"""Write one dataset in Google's own export shape.
CTR is written as a PERCENTAGE NUMBER, not the 0-1 fraction the API
returns, because a real export writes '1.92%' and parse_gsc.py divides the
CTR column by 100. Getting this wrong understates every CTR by 100x and the
CTR-underperformer analysis then flags the entire site.
"""
headers = [first_header]
if len(dimensions) > 1:
headers.append("Query" if dimensions[1] == "query" else dimensions[1].title())
headers += ["Clicks", "Impressions", "CTR", "Position"]
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f, lineterminator="\n")
w.writerow(headers)
for r in rows:
keys = r.get("keys", [])
w.writerow(
keys
+ [
int(r.get("clicks", 0)),
int(r.get("impressions", 0)),
f"{r.get('ctr', 0.0) * 100:.2f}%",
f"{r.get('position', 0.0):.2f}",
]
)
return len(rows)
def write_filters(path: str, search_type: str, start: str, end: str) -> None:
"""The sibling Filters.csv a real export carries.
parse_gsc.py reads it to tell a Web export from an Image one before merging
anything. Without it a fetched directory behaves differently from an
exported one the moment the two are analysed together.
"""
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f, lineterminator="\n")
w.writerow(["Filter", "Value"])
w.writerow(["Search type", search_type.title()])
w.writerow(["Date range", f"{start} to {end}"])
def fetch_window(prop: str, token: str, start: str, end: str, outdir: str,
search_type: str) -> dict[str, int]:
os.makedirs(outdir, exist_ok=True)
counts: dict[str, int] = {}
for filename, dimensions, first_header in DATASETS:
rows = query_api(prop, token, dimensions, start, end, search_type)
counts[filename] = write_csv(
os.path.join(outdir, filename), dimensions, first_header, rows
)
write_filters(os.path.join(outdir, "Filters.csv"), search_type, start, end)
return counts
# ---------------------------------------------------------------- cli
def property_slug(prop: str) -> str:
return (
prop.replace("sc-domain:", "")
.replace("https://", "")
.replace("http://", "")
.strip("/")
.replace("/", "-")
.replace(":", "-")
.replace(".", "-")
)
def resolve_key_path(explicit: str | None, prop: str) -> str:
for candidate in (
explicit,
os.environ.get("GSC_SERVICE_ACCOUNT_JSON"),
os.path.expanduser(f"~/.config/gsc/{property_slug(prop)}.json"),
):
if candidate and os.path.isfile(candidate):
return candidate
raise SystemExit(
"No service-account key found. Pass --key PATH, set "
"GSC_SERVICE_ACCOUNT_JSON, or put the JSON at "
f"~/.config/gsc/{property_slug(prop)}.json"
)
def main() -> int:
ap = argparse.ArgumentParser(description="Fetch Search Console Performance data as CSVs.")
ap.add_argument("--property", required=True,
help="Property exactly as Search Console names it, e.g. sc-domain:example.com")
ap.add_argument("--days", type=int, default=90, help="Window length in days (default 90)")
ap.add_argument("--end", default="",
help=f"Last day, YYYY-MM-DD (default: today minus {LAG_DAYS} days)")
ap.add_argument("--key", default=None, help="Service-account JSON path")
ap.add_argument("--out", default="", help="Output directory (default: ./gsc-<slug>-<end>)")
ap.add_argument("--search-type", default="web",
choices=["web", "image", "video", "news", "discover", "googleNews"])
ap.add_argument("--no-compare", action="store_true",
help="Skip the preceding window; decay analysis then cannot run")
args = ap.parse_args()
require_supported_environment()
if args.days < 1:
raise SystemExit("--days must be at least 1")
end = (
dt.date.fromisoformat(args.end)
if args.end
else dt.date.today() - dt.timedelta(days=LAG_DAYS)
)
start = end - dt.timedelta(days=args.days - 1)
prev_end = start - dt.timedelta(days=1)
prev_start = prev_end - dt.timedelta(days=args.days - 1)
outroot = args.out or f"./gsc-{property_slug(args.property)}-{end.isoformat()}"
key = load_key(resolve_key_path(args.key, args.property))
token = access_token(key)
eprint(f"Property: {args.property} ({key['client_email']})")
windows = [("current", start, end)]
if not args.no_compare:
windows.append(("previous", prev_start, prev_end))
for label, w_start, w_end in windows:
outdir = os.path.join(outroot, label)
counts = fetch_window(
args.property, token, w_start.isoformat(), w_end.isoformat(),
outdir, args.search_type,
)
summary = ", ".join(f"{n} {f.replace('.csv', '')}" for f, n in counts.items() if n)
eprint(f"{label:8} {w_start} .. {w_end} -> {outdir}")
eprint(f" {summary or 'no rows returned'}")
if all(
os.path.getsize(os.path.join(outroot, w[0], "dates.csv")) < 40 for w in windows
):
eprint("\nEvery dataset came back empty. Check the property name and that the "
"service account is a user on it.")
return 1
print(outroot)
eprint(f"\nNext: python3 parse_gsc.py {outroot}/current --brand \"...\"")
if not args.no_compare:
eprint(f" and {outroot}/previous for the decay diff.")
eprint("Coverage is not in here. Export Indexing -> Pages by hand; no API serves it.")
return 0
if __name__ == "__main__":
sys.exit(main())
skills/a-seo-gsc/scripts/parse_gsc.py27.2 KB
#!/usr/bin/env python3
"""
parse_gsc.py: normalize Google Search Console exports into a findings report.
Deterministic pre-processor for the a-seo-gsc skill. Point it at one or more
GSC export zips (or a directory / loose CSVs) and it auto-detects each report
type, normalizes the numbers, and emits a structured set of findings: striking-
distance queries, zero-click high-impression queries, CTR outliers vs an
expected curve, parameter-duplicate cannibalization, coverage buckets, and a
categorized list of not-indexed URLs.
The model reads this output and layers strategy on top. Keeping the arithmetic
here makes the analysis fast, cheap, and repeatable across sites.
Pure standard library. No third-party deps. Cross-platform.
Usage:
python3 parse_gsc.py <path...> [options]
<path> One or more GSC export .zip files, a directory
containing them (or extracted CSVs), or loose .csv files.
Options:
--brand "a,b,c" Comma-separated brand terms to split branded vs
non-branded queries (case-insensitive substring match).
--min-impressions N Minimum impressions for a query to surface in findings
(default: 5).
--json Emit JSON instead of the markdown report.
--out FILE Write output to FILE instead of stdout.
Exit codes: 0 on success, 2 on "no recognizable GSC data found".
"""
from __future__ import annotations
import argparse
import csv
import io
import json
import os
import sys
import tempfile
import zipfile
# Rough organic CTR-by-position reference (desktop+mobile blended, 2023-2024
# industry averages). Used only to flag snippet underperformance, not as truth.
EXPECTED_CTR = {
1: 0.275, 2: 0.150, 3: 0.100, 4: 0.070, 5: 0.055,
6: 0.043, 7: 0.034, 8: 0.028, 9: 0.024, 10: 0.021,
}
PAGE2_CTR = 0.010 # positions 11-20, generic
def _num(s: str) -> float:
"""Parse a GSC numeric cell: '1.92%', '11.85', '1,234', '40%', '' -> float."""
if s is None:
return 0.0
s = s.strip().replace("%", "")
if not s:
return 0.0
# Thousands separators vs decimal comma: GSC uses '.' decimals, but be lenient.
if s.count(",") == 1 and "." not in s:
s = s.replace(",", ".")
else:
s = s.replace(",", "")
try:
return float(s)
except ValueError:
return 0.0
def _read_csv(text: str) -> tuple[list[str], list[list[str]]]:
"""Return (header, rows). Handles quoted multi-line cells (GSC query cells)."""
reader = csv.reader(io.StringIO(text))
rows = [r for r in reader if r]
if not rows:
return [], []
return rows[0], rows[1:]
def _find_col(header: list[str], *names: str) -> int:
low = [h.strip().lower() for h in header]
for name in names:
n = name.lower()
for i, h in enumerate(low):
if h == n or h.startswith(n) or n in h:
return i
return -1
def _collect_csvs(paths: list[str], tmpdir: str) -> tuple[dict[str, str], dict[str, str]]:
"""Return ({logical_name: csv_text}, {logical_name: source_export}) from zips/dirs/loose csvs.
The second map records which export each CSV came from (the zip, or the
directory holding it). Performance exports carry their settings in a
sibling Filters.csv, so grouping by source is what lets us tell a Web
export from an Image one before anything gets merged.
"""
out: dict[str, str] = {}
src: dict[str, str] = {}
def add(name: str, text: str, source: str) -> None:
base = os.path.basename(name)
key = base
n = 1
while key in out: # keep duplicates from multiple exports distinct
n += 1
key = f"{base}#{n}"
out[key] = text
src[key] = source
def handle_file(path: str) -> None:
if not os.path.exists(path):
print(f"warning: path not found, skipping: {path}", file=sys.stderr)
return
low = path.lower()
if low.endswith(".zip"):
try:
with zipfile.ZipFile(path) as z:
for info in z.namelist():
if info.lower().endswith(".csv"):
with z.open(info) as f:
add(info, f.read().decode("utf-8-sig", "replace"), path)
except zipfile.BadZipFile:
print(f"warning: not a valid zip, skipping: {path}", file=sys.stderr)
elif low.endswith(".csv"):
with open(path, encoding="utf-8-sig", errors="replace") as f:
add(path, f.read(), os.path.dirname(os.path.abspath(path)))
for p in paths:
if os.path.isdir(p):
for root, _dirs, files in os.walk(p):
for fn in files:
handle_file(os.path.join(root, fn))
else:
handle_file(p)
return out, src
# Performance-report members. Coverage and drilldown CSVs are not in here, so
# they always survive the search-type filter below.
_PERF_FILES = {
"queries.csv", "pages.csv", "countries.csv", "devices.csv",
"dates.csv", "chart.csv", "filters.csv", "search appearance.csv",
"pairs.csv",
}
def _basename_of(key: str) -> str:
return key.split("#", 1)[0].lower()
def _search_types(csvs: dict[str, str], src: dict[str, str]) -> dict[str, str]:
"""Map source export -> declared search type, read from its Filters.csv."""
types: dict[str, str] = {}
for key, text in csvs.items():
if _basename_of(key) != "filters.csv":
continue
_header, rows = _read_csv(text)
for row in rows:
if len(row) >= 2 and row[0].strip().lower() == "search type":
types[src.get(key, "")] = row[1].strip().lower()
return types
def _split_by_search_type(
csvs: dict[str, str], src: dict[str, str], prefer: str
) -> tuple[dict[str, str], str, dict[str, int]]:
"""Keep one search type's performance data; drop the rest.
Web and Image exports describe the same URLs from different indexes.
Merging them double-counts every page and surfaces the duplicates as
parameter cannibalization, which is an artifact rather than a finding.
"""
types = _search_types(csvs, src)
present = {t for t in types.values() if t}
if len(present) <= 1:
return csvs, (next(iter(present)) if present else ""), {}
if prefer and prefer.lower() in present:
keep = prefer.lower()
elif "web" in present:
keep = "web"
else:
keep = sorted(present)[0]
kept: dict[str, str] = {}
dropped: dict[str, int] = {}
for key, text in csvs.items():
t = types.get(src.get(key, ""))
if t and t != keep and _basename_of(key) in _PERF_FILES:
dropped[t] = dropped.get(t, 0) + 1
continue
kept[key] = text
return kept, keep, dropped
def _classify_url(url: str) -> str:
"""Generic path-pattern bucket for a not-indexed URL (site-agnostic)."""
u = url.lower()
path = u.split("://", 1)[-1]
path = "/" + path.split("/", 1)[1] if "/" in path else "/"
if "?" in u:
return "parameter-url"
if any(seg in path for seg in ("/feed", "/rss", "/atom")) or path.endswith(".xml"):
return "feed"
if any(seg in path for seg in ("/visit", "/go/", "/out/", "/redirect", "/goto")):
return "redirect-endpoint"
if "/page/" in path or "/p/" in path.rstrip("/").rsplit("/", 1)[0] + "/":
return "pagination"
if any(seg in path for seg in ("/tag/", "/tags/", "/label/", "/topic/", "/topics/")):
return "taxonomy-tag"
if any(seg in path for seg in ("/category/", "/categories/", "/cat/", "/c/")):
return "taxonomy-category"
if any(seg in path for seg in ("/product/", "/products/", "/shop/", "/item/")):
return "product"
return "content-page"
class Report:
def __init__(self) -> None:
self.queries: list[dict] = []
self.pages: list[dict] = []
self.countries: list[dict] = []
self.devices: list[dict] = []
self.dates: list[dict] = [] # perf time series
self.coverage_issues: list[dict] = []
self.coverage_series: list[dict] = [] # indexed vs not-indexed over time
self.not_indexed: list[dict] = [] # drilldown table
self.pairs: list[dict] = [] # page+query rows (API only)
self.drill_issue: str = ""
def _ingest(csvs: dict[str, str]) -> Report:
r = Report()
for name, text in csvs.items():
header, rows = _read_csv(text)
if not header:
continue
h = [c.strip().lower() for c in header]
# --- Coverage drilldown table: URL,Last crawled ---
if "url" in h and any("crawl" in c for c in h):
ci = _find_col(header, "url")
cc = _find_col(header, "last crawled", "crawled")
for row in rows:
if len(row) > ci and row[ci].strip():
r.not_indexed.append({
"url": row[ci].strip(),
"last_crawled": row[cc].strip() if cc >= 0 and len(row) > cc else "",
})
continue
# --- Coverage issue tables: Reason,Source,Validation,Pages ---
if "reason" in h and "pages" in h:
cr = _find_col(header, "reason")
cp = _find_col(header, "pages")
for row in rows:
if len(row) > cr and row[cr].strip():
r.coverage_issues.append({
"reason": row[cr].strip(),
"pages": int(_num(row[cp])) if cp >= 0 and len(row) > cp else 0,
})
continue
# --- Coverage chart: Date,Not indexed,Indexed,Impressions ---
if "date" in h and any("not indexed" in c for c in h) and any(c == "indexed" or c.endswith("indexed") for c in h):
cn = _find_col(header, "not indexed")
cidx = -1
for i, c in enumerate(h):
if c == "indexed" or (c.endswith("indexed") and "not" not in c):
cidx = i
break
for row in rows:
if len(row) <= cn or row[cn].strip() == "":
continue
r.coverage_series.append({
"date": row[0].strip(),
"not_indexed": int(_num(row[cn])),
"indexed": int(_num(row[cidx])) if cidx >= 0 and len(row) > cidx else 0,
})
continue
# --- Page+query pairs (API only; no GSC export has this shape) ---
#
# Checked BEFORE the generic performance branch on purpose. That branch
# buckets on column 0, and a pairs file leads with Page, so it would be
# merged into r.pages and double every page's clicks and impressions.
if ("clicks" in h and "impressions" in h
and any("quer" in c for c in h)
and any(c.startswith("page") or c.startswith("url") for c in h)):
cq = _find_col(header, "query")
cu = _find_col(header, "page", "url")
cc = _find_col(header, "clicks")
cimp = _find_col(header, "impressions")
cpos = _find_col(header, "position")
for row in rows:
if len(row) <= max(cq, cu) or not row[cq].strip() or not row[cu].strip():
continue
r.pairs.append({
"query": row[cq].strip(),
"url": row[cu].strip(),
"clicks": int(_num(row[cc])) if len(row) > cc else 0,
"impressions": int(_num(row[cimp])) if len(row) > cimp else 0,
"position": _num(row[cpos]) if cpos >= 0 and len(row) > cpos else 0.0,
})
continue
# --- Performance breakdowns: have Clicks + Impressions + Position ---
if "clicks" in h and "impressions" in h and "position" in h:
cc = _find_col(header, "clicks")
cimp = _find_col(header, "impressions")
cctr = _find_col(header, "ctr")
cpos = _find_col(header, "position")
dim = header[0].strip().lower()
bucket = None
label = "name"
if "quer" in dim:
bucket, label = r.queries, "query"
elif "page" in dim or "url" in dim:
bucket, label = r.pages, "url"
elif "countr" in dim:
bucket, label = r.countries, "country"
elif "device" in dim:
bucket, label = r.devices, "device"
elif "date" in dim:
bucket, label = r.dates, "date"
else:
continue
for row in rows:
if not row or not row[0].strip():
continue
rec = {
label: row[0].strip(),
"clicks": int(_num(row[cc])) if len(row) > cc else 0,
"impressions": int(_num(row[cimp])) if len(row) > cimp else 0,
"ctr": _num(row[cctr]) / 100.0 if cctr >= 0 and len(row) > cctr else 0.0,
"position": _num(row[cpos]) if cpos >= 0 and len(row) > cpos else 0.0,
}
bucket.append(rec)
continue
return r
def _clean_url(url: str) -> str:
return url.split("?", 1)[0].split("#", 1)[0]
def analyze(r: Report, brand: list[str], min_imp: int) -> dict:
f: dict = {}
# Baseline totals: prefer the date series (authoritative), else devices.
if r.dates:
tot_clicks = sum(d["clicks"] for d in r.dates)
tot_imp = sum(d["impressions"] for d in r.dates)
elif r.devices:
tot_clicks = sum(d["clicks"] for d in r.devices)
tot_imp = sum(d["impressions"] for d in r.devices)
else:
tot_clicks = sum(q["clicks"] for q in r.queries)
tot_imp = sum(q["impressions"] for q in r.queries)
wpos = (sum(q["position"] * q["impressions"] for q in r.queries) /
sum(q["impressions"] for q in r.queries)) if r.queries else 0.0
f["baseline"] = {
"clicks": tot_clicks,
"impressions": tot_imp,
"ctr": (tot_clicks / tot_imp) if tot_imp else 0.0,
"impression_weighted_position": round(wpos, 2),
"queries_tracked": len(r.queries),
"pages_tracked": len(r.pages),
}
# Branded vs non-branded
if brand:
bl = [b.lower() for b in brand if b.strip()]
def is_brand(q: str) -> bool:
ql = q.lower()
return any(b in ql for b in bl)
bq = [q for q in r.queries if is_brand(q["query"])]
nbq = [q for q in r.queries if not is_brand(q["query"])]
f["branded_split"] = {
"branded": {"clicks": sum(q["clicks"] for q in bq), "impressions": sum(q["impressions"] for q in bq)},
"non_branded": {"clicks": sum(q["clicks"] for q in nbq), "impressions": sum(q["impressions"] for q in nbq)},
}
# Striking distance: page-2 queries (pos 11-20) with real impressions.
striking = sorted(
[q for q in r.queries if 10.5 < q["position"] <= 20.5 and q["impressions"] >= min_imp],
key=lambda q: -q["impressions"],
)
f["striking_distance"] = striking[:30]
# Page-1 low CTR: pos <=10, impressions >= min, actual CTR far below expected.
ctr_outliers = []
for q in r.queries:
if q["position"] <= 10.5 and q["impressions"] >= min_imp:
exp = EXPECTED_CTR.get(round(q["position"]), PAGE2_CTR)
if q["ctr"] < exp * 0.4: # under 40% of expected = snippet/intent problem
ctr_outliers.append({**q, "expected_ctr": round(exp, 3)})
f["ctr_underperformers"] = sorted(ctr_outliers, key=lambda q: -q["impressions"])[:30]
# Zero-click high-impression
f["zero_click"] = sorted(
[q for q in r.queries if q["clicks"] == 0 and q["impressions"] >= max(min_imp, 10)],
key=lambda q: -q["impressions"],
)[:30]
# Parameter-duplicate cannibalization (same clean URL indexed under variants)
groups: dict[str, list[dict]] = {}
for p in r.pages:
groups.setdefault(_clean_url(p["url"]), []).append(p)
dup = []
for clean, variants in groups.items():
if len(variants) > 1:
dup.append({
"clean_url": clean,
"variants": sorted(variants, key=lambda p: -p["impressions"]),
"total_impressions": sum(v["impressions"] for v in variants),
})
f["param_cannibalization"] = sorted(dup, key=lambda d: -d["total_impressions"])
# Shared-query candidates: one query, several of your pages appearing for it.
#
# Only possible from page+query pairs, which no GSC export contains. The
# UI's own export lists queries and pages in separate tables, so this
# question was unanswerable until the API path existed. Absent pairs, the
# key is left out entirely rather than reported as "none found", because
# nothing was looked at and saying otherwise would read as a clean bill.
if r.pairs:
by_query: dict[str, list[dict]] = {}
for row in r.pairs:
by_query.setdefault(row["query"], []).append(row)
cannibal = []
for query, hits in by_query.items():
total = sum(x["impressions"] for x in hits)
# Rank by impressions, but report position too: the actionable
# signal is a mismatch, where the page Google shows most often is
# not the page that ranks best for the query.
ranked = sorted(hits, key=lambda x: -x["impressions"])
rivals = [x for x in ranked if x["impressions"] >= min_imp]
if len(rivals) < 2 or total <= 0:
continue
# A runner-up holding a token share is noise, not competition.
if rivals[1]["impressions"] / total < 0.20:
continue
# Over rivals, NOT over hits. Picking the best position from every
# page for the query lets a page below the impression floor win,
# and the report then names a URL that does not appear in its own
# evidence list while the listed numbers contradict the claim.
best = min(rivals, key=lambda x: x["position"] if x["position"] else 999)
cannibal.append({
"query": query,
"total_impressions": total,
"rival_impressions": sum(x["impressions"] for x in rivals),
"pages_seen": len(hits),
"total_clicks": sum(x["clicks"] for x in hits),
"pages": rivals,
"most_shown": ranked[0]["url"],
"best_ranked": best["url"],
"split_mismatch": best["url"] != ranked[0]["url"],
})
f["query_cannibalization"] = sorted(
cannibal, key=lambda d: -d["total_impressions"]
)[:30]
f["pairs_seen"] = len(r.pairs)
# Concentration: what share of impressions does the top page hold?
if r.pages:
top = max(r.pages, key=lambda p: p["impressions"])
f["concentration"] = {
"top_page": top["url"],
"top_page_impressions": top["impressions"],
"share_of_total": round(top["impressions"] / tot_imp, 3) if tot_imp else 0.0,
}
# Coverage
if r.coverage_issues:
buckets = {}
for c in r.coverage_issues:
buckets[c["reason"]] = buckets.get(c["reason"], 0) + c["pages"]
f["coverage_issues"] = buckets
if r.coverage_series:
first = r.coverage_series[0]
last = r.coverage_series[-1]
f["coverage_trend"] = {
"first": first, "last": last,
"indexed_delta": last["indexed"] - first["indexed"],
"not_indexed_delta": last["not_indexed"] - first["not_indexed"],
"diagnosis": (
"index-starvation (more pages excluded than indexed)"
if last["not_indexed"] > last["indexed"]
else "index-bloat risk" if last["not_indexed"] > last["indexed"] * 0.5
else "healthy"
),
}
# Not-indexed URL categorization
if r.not_indexed:
cats: dict[str, list[str]] = {}
for u in r.not_indexed:
cats.setdefault(_classify_url(u["url"]), []).append(u["url"])
f["not_indexed"] = {
"issue": r.drill_issue or "not indexed",
"total": len(r.not_indexed),
"by_category": {k: {"count": len(v), "sample": v[:12]} for k, v in
sorted(cats.items(), key=lambda kv: -len(kv[1]))},
}
return f
def _pct(x: float) -> str:
return f"{x * 100:.2f}%"
def to_markdown(f: dict) -> str:
o: list[str] = ["# GSC parsed findings", ""]
b = f["baseline"]
o += [
"## Baseline",
f"- Clicks: **{b['clicks']}** | Impressions: **{b['impressions']}** | CTR: **{_pct(b['ctr'])}**",
f"- Impression-weighted avg position: **{b['impression_weighted_position']}**",
f"- Queries tracked: {b['queries_tracked']} | Pages tracked: {b['pages_tracked']}",
"",
]
if "branded_split" in f:
bs = f["branded_split"]
o += [
"## Branded vs non-branded",
f"- Branded: {bs['branded']['clicks']} clicks / {bs['branded']['impressions']} impr",
f"- Non-branded: {bs['non_branded']['clicks']} clicks / {bs['non_branded']['impressions']} impr",
"",
]
if f.get("concentration"):
c = f["concentration"]
o += ["## Concentration",
f"- Top page holds **{_pct(c['share_of_total'])}** of all impressions: `{c['top_page']}` ({c['top_page_impressions']} impr)",
""]
def qtable(title: str, rows: list[dict], extra: str = "") -> None:
nonlocal o
o.append(f"## {title}")
if not rows:
o.extend(["_none_", ""])
return
o.append(f"| query | clicks | impr | CTR | pos |{' expected CTR |' if extra=='exp' else ''}")
o.append(f"|---|--:|--:|--:|--:|{'--:|' if extra=='exp' else ''}")
for q in rows:
line = f"| {q['query']} | {q['clicks']} | {q['impressions']} | {_pct(q['ctr'])} | {q['position']:.1f} |"
if extra == "exp":
line += f" {_pct(q.get('expected_ctr', 0))} |"
o.append(line)
o.append("")
qtable("Striking distance (pos 11-20, quick wins)", f.get("striking_distance", []))
qtable("Page-1 CTR underperformers (snippet/intent problem)", f.get("ctr_underperformers", []), "exp")
qtable("Zero-click high-impression queries", f.get("zero_click", []))
o.append("## Parameter-duplicate cannibalization")
if f.get("param_cannibalization"):
for d in f["param_cannibalization"]:
o.append(f"- `{d['clean_url']}`: {len(d['variants'])} indexed variants, {d['total_impressions']} impr total")
for v in d["variants"]:
o.append(f" - {v['impressions']} impr, pos {v['position']:.1f}: `{v['url']}`")
else:
o.append("_none detected_")
o.append("")
if "query_cannibalization" in f:
o.append("## Shared-query candidates (from page+query pairs; investigate before merging)")
o.append(f"_{f.get('pairs_seen', 0)} page+query rows analysed._")
if f["query_cannibalization"]:
for d in f["query_cannibalization"]:
flag = " **position/impression mismatch**" if d["split_mismatch"] else ""
tail = ""
if d.get("pages_seen", 0) > len(d["pages"]):
tail = (f", {d['pages_seen'] - len(d['pages'])} more below the "
f"impression floor")
o.append(
f"- `{d['query']}`: {len(d['pages'])} pages competing on "
f"{d.get('rival_impressions', d['total_impressions'])} of "
f"{d['total_impressions']} impr, {d['total_clicks']} clicks{tail}{flag}"
)
for pg in d["pages"]:
o.append(
f" - {pg['impressions']} impr, {pg['clicks']} clicks, "
f"pos {pg['position']:.1f}: `{pg['url']}`"
)
if d["split_mismatch"]:
o.append(
f" - Google shows `{d['most_shown']}` most, but "
f"`{d['best_ranked']}` ranks better."
)
else:
o.append("_none detected_")
o.append("")
if "coverage_issues" in f:
o.append("## Coverage issues")
for reason, n in sorted(f["coverage_issues"].items(), key=lambda kv: -kv[1]):
o.append(f"- **{n}**: {reason}")
o.append("")
if "coverage_trend" in f:
t = f["coverage_trend"]
o += ["## Coverage trend",
f"- {t['first']['date']}: {t['first']['indexed']} indexed / {t['first']['not_indexed']} not indexed",
f"- {t['last']['date']}: {t['last']['indexed']} indexed / {t['last']['not_indexed']} not indexed",
f"- Diagnosis: **{t['diagnosis']}**", ""]
if "not_indexed" in f:
ni = f["not_indexed"]
o.append(f"## Not-indexed URLs ({ni['total']} total)")
for cat, d in ni["by_category"].items():
o.append(f"### {cat} ({d['count']})")
for u in d["sample"]:
o.append(f"- {u}")
if d["count"] > len(d["sample"]):
o.append(f"- … +{d['count'] - len(d['sample'])} more")
o.append("")
return "\n".join(o)
def main() -> int:
ap = argparse.ArgumentParser(description="Normalize GSC exports into findings.")
ap.add_argument("paths", nargs="+")
ap.add_argument("--brand", default="")
ap.add_argument("--min-impressions", type=int, default=5)
ap.add_argument("--json", action="store_true")
ap.add_argument("--out", default="")
ap.add_argument(
"--search-type",
default="web",
help="which search type to parse when the paths hold more than one "
"(web, image, video, news). Default web.",
)
args = ap.parse_args()
with tempfile.TemporaryDirectory() as tmp:
csvs, sources = _collect_csvs(args.paths, tmp)
if not csvs:
print("No CSV files found in the given paths.", file=sys.stderr)
return 2
csvs, kept_type, dropped = _split_by_search_type(csvs, sources, args.search_type)
if dropped:
others = ", ".join(f"{t} ({n} files)" for t, n in sorted(dropped.items()))
print(
f"note: the given paths hold more than one search type. Parsed '{kept_type}' "
f"and ignored {others}. Merging them would double-count every page. "
f"Re-run with --search-type to read another one.",
file=sys.stderr,
)
elif kept_type:
print(f"note: search type '{kept_type}'.", file=sys.stderr)
report = _ingest(csvs)
if not (report.queries or report.pages or report.coverage_issues or report.not_indexed):
print("No recognizable GSC performance or coverage data found.", file=sys.stderr)
return 2
brand = [b for b in args.brand.split(",") if b.strip()]
findings = analyze(report, brand, args.min_impressions)
out = json.dumps(findings, indent=2, ensure_ascii=False) if args.json else to_markdown(findings)
if args.out:
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(out + "\n")
print(f"Wrote {args.out}")
else:
print(out)
return 0
if __name__ == "__main__":
sys.exit(main())