Google Search Console skill for Codex
Explain the drop, then find the next clicks. Codex reads Search Console exports, API pulls or the BigQuery bulk export with the data's quirks in mind: anonymized queries, impression-weighted position, AI features counted under Web. A bundled script produces branded splits, striking-distance lists, CTR gaps against the site's own position curve, period movers and split-page queries, and Codex maps each indexing status to a concrete action.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- google-search-console Installs to ~/.agents/skills/google-search-console/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install google-search-console The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project google-search-console Installs to .agents/skills/google-search-console/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/google-search-console.zipunzip -o google-search-console.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) google-search-console PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $google-search-console, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Analyzes Google Search Console data — Performance report exports, Search Analytics API and BigQuery bulk export, Page indexing and Crawl stats reports, URL Inspection — to explain traffic drops, find striking-distance queries and CTR gaps, spot cannibalization, and triage indexing statuses. Use when someone shares a GSC export or asks why clicks fell, which queries to target next, what "Crawled - currently not indexed" means for their site, or how to pull GSC data with the API.
Use it when
- Organic clicks or impressions changed and you need the cause
- Prioritizing pages to improve from real query data
- The Page indexing report shows growing exclusions
- Automating Search Console pulls via the API or BigQuery
Use something else when
- You need on-site behaviour and conversions rather than search data: use ga4-analytics
- The issue needs a full crawl of the site: use technical-seo-audit
How to check the result
Every finding cites a comparison of equal periods, the share of the change it explains, and a URL Inspection or report row; recheck the same windows after fixes.
The mistake it prevents
Reading a falling average position as lost rankings when the site simply started appearing for more low-position queries.
A good analysis answers a specific question (why did clicks drop, where is the next traffic coming from, why is this not indexed) with numbers compared like for like, and separates what Search Console can prove from what it can only suggest.
Know the data before reading it
- Clicks, impressions, CTR, position are Google Search only. AI Overviews and AI Mode appearances are counted inside the “Web” search type with no separate filter.
- Average position is impression-weighted and moves when the mix of queries changes. A drop in average position with rising impressions often means you started ranking for more (lower) queries, not that you lost ground.
- Anonymized queries are omitted from query-level rows, so query rows never sum to the chart total; page-level totals are more complete.
- Property type matters: a Domain property covers all protocols and subdomains; a URL-prefix property only that prefix.
- Data lags about 2–3 days; the UI keeps 16 months. The UI export caps at 1,000 rows;
use the API (up to 25,000 rows per request, paged with
startRow) or the BigQuery bulk export for more. - Search Console may offer a branded-query filter in Performance; when it does not, split brand terms with a regex.
Workflow
- [ ] 1. Pin the question, property, date ranges and search type
- [ ] 2. Get the data (export, API or BigQuery)
- [ ] 3. Run gsc_analyze.py for the standard cuts
- [ ] 4. Answer the specific question with drill-downs
- [ ] 5. Cross-check indexing and technical causes
- [ ] 6. Report findings with confidence levels and actions
1. Frame the comparison
- Compare equal-length periods with the same weekdays (28 days vs previous 28 days, or year over year for seasonal businesses).
- Annotate known events: deploys, migrations, Google core or spam updates (from the Google Search Status Dashboard), seasonality, tracking or property changes.
2. Get the data
- UI: Performance → set dates/compare → Export (CSV/Sheets). The zip contains
Queries.csv,Pages.csv,Countries.csv,Devices.csv,Dates.csvand more. - API (needs network and OAuth or a service account added as a user on the property):
# pip install google-api-python-client google-auth (needs network; may need approval)
from google.oauth2 import service_account
from googleapiclient.discovery import build
creds = service_account.Credentials.from_service_account_file(
"sa.json", scopes=["https://www.googleapis.com/auth/webmasters.readonly"])
gsc = build("searchconsole", "v1", credentials=creds)
def rows(site, start, end, dims, start_row=0):
body = {"startDate": start, "endDate": end, "dimensions": dims,
"type": "web", "rowLimit": 25000, "startRow": start_row}
return gsc.searchanalytics().query(siteUrl=site, body=body).execute().get("rows", [])
data = rows("sc-domain:example.com", "2026-09-01", "2026-09-28", ["query", "page"])
Use sc-domain:example.com for Domain properties and the full URL with trailing slash
for URL-prefix properties. Never commit the credentials file.
- BigQuery bulk export: tables
searchdata_site_impressionandsearchdata_url_impression; position is stored as zero-basedsum_position(average position =sum_position / impressions + 1).
3. Standard cuts with the script
python3 scripts/gsc_analyze.py Queries.csv --brand 'acme|acmeapp' --min-impr 100
python3 scripts/gsc_analyze.py Queries.csv --compare Queries_prev.csv --brand 'acme'
python3 scripts/gsc_analyze.py Pages.csv --compare Pages_prev.csv
python3 scripts/gsc_analyze.py query_page.csv # API export with query+page columns
Output (Markdown) covers: branded share, striking-distance rows (position 8–20 by impressions), rows with CTR under half of the site’s own median CTR at that position (ranked by estimated missed clicks), biggest losers and gainers between periods, and queries whose impressions split across two or more pages. The CTR baseline comes from the site’s own data, not a generic industry curve. Non-English UI exports need headers renamed to English first.
4. Answer the question
Traffic drop. Decompose before theorizing:
| Pattern | Likely cause | Next check |
|---|---|---|
| Impressions flat, CTR down, position flat | SERP feature change (AI Overview, ads, new rich results), title rewrite | Inspect the SERP; compare page title vs shown title |
| Impressions down, position down, broad | Core update or sitewide quality/technical issue | Update dates; Page indexing trend; crawl |
| Losses concentrated in one directory or template | Template regression: noindex, canonical, rendering, internal links | URL Inspection on samples; deploy history |
| Losses on a few head queries | Competitor or intent shift | Compare SERPs before/after |
| Drop on one date across everything, including branded | Tracking/property issue or outage | Server logs, uptime, property settings |
| Seasonal | Demand change | Year-over-year comparison, Google Trends |
Next opportunities. Striking-distance queries mapped to their ranking page; improve
that page (see on-page-seo) before creating new ones. CTR outliers: check whether
the shown title was rewritten or the SERP is feature-heavy before rewriting.
Cannibalization. Two URLs alternating for one query with split impressions; decide which should own it and consolidate or differentiate.
5. Indexing and crawling
Page indexing report statuses and what to do:
| Status | Meaning | Action |
|---|---|---|
| Crawled - currently not indexed | Google fetched it and chose not to index | Improve uniqueness/quality and internal links; often expected for thin or duplicate URLs |
| Discovered - currently not indexed | Known but not crawled yet | Internal links, crawl budget, server speed; normal briefly for new pages |
| Duplicate, Google chose different canonical than user | Your canonical is overridden | Align redirects, links, sitemaps, hreflang with the intended canonical |
| Alternate page with proper canonical tag | Working as intended | None |
| Excluded by ‘noindex’ tag | Directive seen | Confirm it is intended |
| Blocked by robots.txt / Indexed, though blocked | Crawl blocked | Decide index intent; see sitemaps-robots |
| Soft 404 | Page looks empty or “not found” with 200 | Return 404/410 or add real content |
| Page with redirect / Not found (404) | Usually fine | Fix only if listed in sitemaps or internally linked |
- URL Inspection → Test live URL shows the rendered HTML, the detected canonical and indexability; use it on 3–5 samples per affected template.
- Settings → Crawl stats shows requests per day, response codes, host status and file types; spikes in 5xx or response time precede crawl slowdowns.
- After fixing, use Validate fix in the report; validation takes days to weeks.
Deep technical diagnosis belongs to technical-seo-audit.
Gotchas
- Do not add up average positions across rows or compare position for a page whose query mix changed; compare per query.
- Filters on query apply to the rows, but chart totals with a query filter exclude anonymized queries; numbers shift when you add a filter.
- Country and device splits: a drop on mobile only often points at mobile rendering or interstitials.
- “Impressions” counts a result as shown even if it was below the fold or in a collapsed feature; big impression jumps from new SERP features can crash CTR without any real loss of clicks.
- Indexing report numbers are sampled by status and update slowly; do not panic over daily wobbles.
Output template
# Search Console review: example.com, 2026-09-01..28 vs 2026-08-04..31
**Question:** why did non-branded clicks fall 23%?
**Finding (high confidence):** 71% of the loss is /docs/* after the 2026-09-05 deploy;
positions fell from ~4 to ~11 on 140 docs queries; URL Inspection shows the canonical on
docs pages points to /docs/ (layout-level canonical).
**Finding (medium):** CTR on "pgbouncer setup" fell from 9% to 4% at the same position;
an AI Overview now appears.
**Opportunities:** 38 striking-distance queries (table), top 10 mapped to pages.
**Actions:** 1) fix docs canonical (owner, ETA) 2) Validate fix 3) refresh 5 pages.
**Recheck:** 2026-10-12 with the same comparison windows. What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Google Search Console"
short_description: "Explain the drop, then find the next clicks"
default_prompt: "$google-search-console Here are this month's and last month's Queries.csv, why did clicks drop?" scripts/gsc_analyze.py
#!/usr/bin/env python3
"""Analyze Google Search Console performance exports. Standard library only.
Accepts the UI export CSVs (Queries.csv / Pages.csv from the Performance report zip) and
API/BigQuery-style CSVs with columns such as query, page, clicks, impressions, ctr, position.
python3 scripts/gsc_analyze.py Queries.csv [--brand 'acme|acme ?app'] [--min-impr 100]
python3 scripts/gsc_analyze.py Queries.csv --compare Queries_prev.csv
python3 scripts/gsc_analyze.py query_page.csv # query+page rows: cannibalization
Sections: totals (branded vs non-branded), striking-distance rows (position 8-20),
CTR outliers against the site's own CTR-by-position curve, period-over-period movers,
and queries split across several pages.
Exit codes: 0 ok, 2 unreadable input or missing columns.
"""
from __future__ import annotations
import argparse
import csv
import re
import statistics
import sys
from collections import defaultdict
ALIASES = {
"query": ["top queries", "query", "queries", "search query"],
"page": ["top pages", "page", "pages", "url", "landing page"],
"clicks": ["clicks", "url_clicks"],
"impressions": ["impressions"],
"ctr": ["ctr"],
"position": ["position", "avg. position", "average position", "sum_position"],
}
def num(value: str) -> float:
v = (value or "").strip().replace(",", "")
if v.endswith("%"):
return float(v[:-1]) / 100
return float(v) if v else 0.0
def load(path: str) -> tuple[list[dict], str]:
with open(path, encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
if not reader.fieldnames:
raise ValueError("empty file")
mapping = {}
lower = {f.lower().strip(): f for f in reader.fieldnames}
for key, names in ALIASES.items():
for n in names:
if n in lower:
mapping[key] = lower[n]
break
missing = [k for k in ("clicks", "impressions") if k not in mapping]
if missing or not ({"query", "page"} & mapping.keys()):
raise ValueError(f"cannot find columns {missing or ['query/page']} in {reader.fieldnames}; "
"rename headers to English (query/page, clicks, impressions, ctr, position)")
rows = []
for r in reader:
row = {k: r[v] for k, v in mapping.items()}
row["clicks"] = num(row["clicks"])
row["impressions"] = num(row["impressions"])
row["position"] = num(row.get("position", "0"))
if mapping.get("position") and "sum_position" in mapping["position"].lower() and row["impressions"]:
row["position"] = row["position"] / row["impressions"] + 1 # BigQuery sum_position is zero-based
row["ctr"] = row["clicks"] / row["impressions"] if row["impressions"] else 0.0
rows.append(row)
key = "query" if "query" in mapping else "page"
return rows, key
def fmt_row(r: dict, key: str) -> str:
return (f"| {r[key][:70]} | {int(r['clicks'])} | {int(r['impressions'])} | "
f"{r['ctr']:.1%} | {r['position']:.1f} |")
def table(rows: list[dict], key: str, limit: int) -> None:
print(f"| {key} | clicks | impressions | ctr | position |\n|---|---|---|---|---|")
for r in rows[:limit]:
print(fmt_row(r, key))
if not rows:
print("| (none) | | | | |")
print()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv")
ap.add_argument("--compare", help="same export for the previous period")
ap.add_argument("--brand", help="regex for branded queries, case-insensitive")
ap.add_argument("--min-impr", type=int, default=100, help="minimum impressions for opportunity lists")
ap.add_argument("--limit", type=int, default=25)
args = ap.parse_args()
try:
rows, key = load(args.csv)
except (OSError, ValueError) as exc:
print(f"error: {args.csv}: {exc}", file=sys.stderr)
return 2
brand = re.compile(args.brand, re.I) if args.brand else None
print(f"# Search Console analysis: {args.csv}\n")
tot_c = sum(r["clicks"] for r in rows)
tot_i = sum(r["impressions"] for r in rows)
print(f"Rows: {len(rows)} ({key} level) · clicks {int(tot_c):,} · impressions {int(tot_i):,} "
f"· CTR {tot_c / tot_i if tot_i else 0:.2%}")
print("Note: exported rows omit anonymized queries, so row totals are below the report total.\n")
if brand and key == "query":
b = [r for r in rows if brand.search(r["query"])]
bc, bi = sum(r["clicks"] for r in b), sum(r["impressions"] for r in b)
print(f"Branded: {len(b)} queries, {int(bc):,} clicks ({bc / tot_c if tot_c else 0:.0%} of clicks), "
f"{int(bi):,} impressions. Non-branded analysis below excludes them.\n")
rows = [r for r in rows if not brand.search(r["query"])]
has_pos = any(r["position"] for r in rows)
if has_pos:
print("## Striking distance (position 8-20, by impressions)\n")
sd = sorted((r for r in rows if 8 <= r["position"] <= 20 and r["impressions"] >= args.min_impr),
key=lambda r: -r["impressions"])
table(sd, key, args.limit)
print("## CTR below this site's norm for the position\n")
buckets = defaultdict(list)
for r in rows:
if r["impressions"] >= args.min_impr and r["position"] >= 1:
buckets[min(int(round(r["position"])), 20)].append(r["ctr"])
norms = {b: statistics.median(v) for b, v in buckets.items() if len(v) >= 5}
print("Median CTR by rounded position (rows with enough impressions): " +
", ".join(f"{b}: {norms[b]:.1%}" for b in sorted(norms)) + "\n")
low = []
for r in rows:
b = min(int(round(r["position"])), 20)
if r["impressions"] >= args.min_impr and b in norms and r["position"] <= 10 and r["ctr"] < 0.5 * norms[b]:
r["missed"] = (norms[b] - r["ctr"]) * r["impressions"]
low.append(r)
low.sort(key=lambda r: -r["missed"])
print("Sorted by estimated missed clicks. Check SERP features and intent before rewriting titles.\n")
table(low, key, args.limit)
if args.compare:
try:
prev, pkey = load(args.compare)
except (OSError, ValueError) as exc:
print(f"error: {args.compare}: {exc}", file=sys.stderr)
return 2
if brand and pkey == "query":
prev = [r for r in prev if not brand.search(r["query"])]
before = {r[pkey]: r for r in prev}
now = {r[key]: r for r in rows}
deltas = []
for k in set(before) | set(now):
c1, c0 = now.get(k, {}).get("clicks", 0), before.get(k, {}).get("clicks", 0)
deltas.append((c1 - c0, k, before.get(k), now.get(k)))
print("## Biggest click losses vs previous period\n")
print("| key | clicks before | clicks now | Δ | position before → now |\n|---|---|---|---|---|")
for d, k, b, n in sorted(deltas)[: args.limit]:
if d >= 0:
break
pb = f"{b['position']:.1f}" if b else "-"
pn = f"{n['position']:.1f}" if n else "gone"
print(f"| {k[:70]} | {int(b['clicks']) if b else 0} | {int(n['clicks']) if n else 0} | {int(d)} | {pb} → {pn} |")
print("\n## Biggest click gains\n")
print("| key | clicks before | clicks now | Δ |\n|---|---|---|---|")
for d, k, b, n in sorted(deltas, reverse=True)[: args.limit]:
if d <= 0:
break
print(f"| {k[:70]} | {int(b['clicks']) if b else 0} | {int(n['clicks']) if n else 0} | +{int(d)} |")
print()
if rows and "query" in rows[0] and "page" in rows[0]:
print("## Queries split across several pages (possible cannibalization)\n")
by_q = defaultdict(list)
for r in rows:
by_q[r["query"]].append(r)
found = []
for q, rs in by_q.items():
total = sum(r["impressions"] for r in rs)
strong = [r for r in rs if total and r["impressions"] / total >= 0.2]
if len(strong) >= 2 and total >= args.min_impr:
found.append((total, q, strong))
found.sort(reverse=True)
for total, q, strong in found[: args.limit]:
pages = "; ".join(f"{r['page']} (pos {r['position']:.1f}, {r['impressions'] / total:.0%})" for r in strong)
print(f"- **{q}** ({int(total)} impr): {pages}")
if not found:
print("- none above thresholds")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.