How to Detect AI Query Fan-Out Impressions in Google Search Console With Python
Learn to separate real human search impressions from AI query fan-out noise in Google Search Console using a Python script you can run and verify against your own data.
If you check Google Search Console and see a page with thousands of impressions and almost no clicks, the reflex is to blame the title or meta description and rewrite them. Sometimes that is the right call. But increasingly, the real explanation is that no human ever saw a results page for those impressions in the first place: Google’s AI Overviews and AI Mode features generate their own hidden sub-queries to build an answer, and if your page gets retrieved for one of those, Search Console logs a genuine impression even though there was nothing on screen for a person to click.
Table Of Content
- What “Impressions” Actually Means in Search Console
- What Query Fan-Out Is, and Why It Inflates Impressions
- Prerequisites
- Step 1: Get Your Data Into a Consistent Shape
- Step 2: Group Impressions Into Position Bands
- The bug this author actually hit
- Step 3: Compare the Suspect Page Against a Control Page
- Step 4: Scan Query Text for Fan-Out Shaped Signatures
- Common Mistakes and Gotchas
- The naive “biggest opportunity” trap
- A flag is a hint, not a verdict
- Verify It Worked End to End
- Adapting This to Your Own Real Data
- Next Steps
This tutorial teaches you how to tell the two situations apart using nothing but Python’s standard library. You will build a small toolkit that groups impressions by position, compares a suspect page against a control page you trust, scans query text for the shape of an AI-generated sub-query, and, along the way, walks through a real bug this author hit while building it: a silent off-by-one gap in a bucketing function that quietly misfiled data into the wrong group. By the end you will have working code you can point at your own Search Console export, plus a clear sense of what this technique can and cannot prove.
What “Impressions” Actually Means in Search Console
Before writing any code, it helps to be precise about what each number in Search Console’s Performance report actually measures. Google’s own documentation defines the four core metrics like this:
- Clicks: “The number of times a user clicked your site from Google Search results.”
- Impressions: “How many times your site appeared in Search results.”
- CTR (click-through rate): “The click count divided by the impression count.”
- Position: in a table broken down by query or page, “The average position in Search results for the specific URL or grouping dimension shown in the table row.”
(Source: Search Console Help, Performance report)
Notice that “appeared in Search results” does not say “was seen by a human on a rendered results page.” That distinction matters more than it used to, because of how Google’s newer AI features actually work.
What Query Fan-Out Is, and Why It Inflates Impressions
Google’s own developer documentation explains the mechanism plainly:
“Both AI Overviews and AI Mode may use a ‘query fan-out’ technique, issuing multiple related searches across subtopics and data sources, to develop a response.”
(Source: Google Search Central, AI features and your website)
In plain terms: when someone asks an AI feature a single question, Google’s models silently expand that one prompt into a set of narrower sub-queries behind the scenes, run each one against the index, and stitch the results into an answer. If your page is retrieved for one of those sub-queries, Search Console logs a real, honest impression at whatever position your page held for that specific sub-query. The impression is not fake. The position is not fake. But the sub-query was never typed by a person, and no results page was ever rendered for anyone to look at, so a click was never possible.
Here is the part that makes this genuinely hard to diagnose: Google’s documentation also confirms that Search Console does not break this out separately: “sites appearing in AI features (such as AI Overviews and AI Mode) are included in the overall search traffic in Search Console,” reported under the ordinary “Web” search type, with no separate impression or click metric of their own. There is no checkbox in Search Console that says “show me only the AI-sourced rows.” Everything you will build in this tutorial is an inference from patterns in the data you already have, not a lookup against a field Google provides.
Prerequisites
- Python 3.9 or newer. Everything here uses only the standard library (
csv,collections), so there is nothing topip install. - A text editor and a terminal.
- Optional, for using your own real numbers later: a Google Search Console property you already have verified access to, with at least a few weeks of data.
- No prior data analysis experience assumed. If you have never written a Python script that reads a CSV file, this tutorial explains each step as it goes.
Step 1: Get Your Data Into a Consistent Shape
Every tool in this tutorial expects one row per query, with six fields: query, page, clicks, impressions, ctr, and position. That is not an arbitrary choice: it mirrors the actual fields Google’s Search Analytics API returns for each row of a query, where every row carries clicks (a click count), impressions (an impression count), ctr (a value from 0 to 1.0), and position (“Average position in search results”). (Source: Search Analytics API reference)
This tutorial’s sandbox has no live Search Console property to query, so instead of asking you to trust untestable code, every script below runs against a small, clearly synthetic sample dataset built to be internally consistent (clicks never exceed impressions, CTR is always clicks divided by impressions) so you can run every step yourself right now, before you touch your own site. The last section of this tutorial shows you exactly how to swap in your own real export once you have it.
Save this as generate_sample_data.py:
#!/usr/bin/env python3
"""
Generates a representative sample Search Console dataset for two pages:
one with a normal, human-driven CTR curve (the control page) and one
showing the lopsided pattern this tutorial teaches you to recognize
(the suspect page). This is synthetic data built to be internally
consistent with how Search Console actually reports (impressions,
clicks, ctr, position per query row); it stands in for the export
you would pull from your own property.
Every row still obeys real constraints: clicks <= impressions,
ctr == clicks / impressions, and position is a plausible average
position for that clicks/impressions/query combination.
"""
import csv
import random
random.seed(20260828)
CONTROL_PAGE = "/blog/python-dict-comprehension-guide"
SUSPECT_PAGE = "/blog/kubernetes-resourcequota-explained"
# Real, short, head-term style queries a human actually types.
CONTROL_QUERIES = [
"python dict comprehension",
"dict comprehension python",
"python dictionary comprehension example",
"how to use dict comprehension in python",
"python comprehension syntax",
"dict comprehension if else python",
"python dict comprehension with condition",
"nested dict comprehension python",
"python list vs dict comprehension",
"dict comprehension python w3schools",
"python create dict from list",
"python dict comprehension performance",
]
# Long, full-sentence, question-shaped queries typical of an AI
# system's fan-out sub-queries rather than something a person types
# into a search box.
SUSPECT_QUERIES = [
"what happens when a kubernetes namespace exceeds its resource quota during a rolling deployment",
"does resourcequota apply to init containers in kubernetes",
"how does kubernetes enforce resource quotas across multiple namespaces at once",
"can a resourcequota object limit the number of persistentvolumeclaims in a namespace",
"what is the difference between resourcequota and limitrange in kubernetes",
"why would a pod be rejected due to resourcequota even with available cluster capacity",
"how do you calculate the total cpu request needed to satisfy a resourcequota",
"is resourcequota enforced at admission time or at scheduling time in kubernetes",
"what error message appears when a deployment exceeds its resourcequota",
"kubernetes resourcequota",
"k8s resourcequota example",
"resourcequota yaml",
]
def band_bias(position):
"""Return a rough baseline click probability for a given average
position, used only to keep the synthetic control page internally
consistent (better position, more plausible clicks). This is a
made-up shape for this dataset, not a cited industry benchmark."""
if position <= 3:
return 0.22
if position <= 10:
return 0.07
if position <= 20:
return 0.015
return 0.004
def make_row(query, page, position, impressions, human_like):
bias = band_bias(position)
if human_like:
# Real human queries: clicks roughly track the position-based
# baseline, with noise, because a person who sees a relevant
# result sometimes clicks it.
p_click = max(0.0, min(1.0, bias + random.uniform(-0.03, 0.05)))
clicks = sum(1 for _ in range(impressions) if random.random() < p_click)
else:
# Fan-out-shaped queries: impressions accumulate from repeated
# AI sub-query retrieval, but there is structurally no results
# page for a human to click, so clicks stay at or near zero
# regardless of how good the position is.
clicks = 0 if random.random() > 0.08 else 1
ctr = round(clicks / impressions, 4) if impressions else 0.0
return {
"query": query,
"page": page,
"clicks": clicks,
"impressions": impressions,
"ctr": ctr,
"position": position,
}
rows = []
for q in CONTROL_QUERIES:
position = round(random.uniform(2.0, 9.5), 1)
impressions = random.randint(40, 900)
rows.append(make_row(q, CONTROL_PAGE, position, impressions, human_like=True))
for q in SUSPECT_QUERIES:
position = round(random.uniform(2.5, 7.5), 1)
impressions = random.randint(60, 500)
rows.append(make_row(q, SUSPECT_PAGE, position, impressions, human_like=False))
with open("search_console_sample.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["query", "page", "clicks", "impressions", "ctr", "position"])
writer.writeheader()
writer.writerows(rows)
print(f"Wrote {len(rows)} rows to search_console_sample.csv")
totals = {}
for r in rows:
t = totals.setdefault(r["page"], {"impressions": 0, "clicks": 0})
t["impressions"] += r["impressions"]
t["clicks"] += r["clicks"]
for page, t in totals.items():
overall_ctr = t["clicks"] / t["impressions"] if t["impressions"] else 0
print(f"{page}: {t['impressions']} impressions, {t['clicks']} clicks, {overall_ctr:.4%} overall CTR")
Run it:
python generate_sample_data.py
You should see:
Wrote 24 rows to search_console_sample.csv
/blog/python-dict-comprehension-guide: 6331 impressions, 465 clicks, 7.3448% overall CTR
/blog/kubernetes-resourcequota-explained: 2436 impressions, 2 clicks, 0.0821% overall CTR
Notice the shape already: the control page’s overall CTR (7.34%) is what you would expect from a page with normal human search behavior. The suspect page’s overall CTR (0.08%) is not just lower, it is nearly zero despite thousands of impressions. That gap alone is a clue, but a single overall number can hide as much as it reveals, which is exactly why the next step exists.
Step 2: Group Impressions Into Position Bands
A page’s average position is a single number computed across every query and every day in your date range. That single number can be quietly misleading. A query that sits at position 2 for half its impressions and position 40 for the other half will show an average position of 21, which reads like “middle of page 3,” even though what actually happened was “doing great sometimes, invisible other times.” Averages hide bimodal behavior, so before comparing anything, you want to bucket rows into ranges (bands) that roughly track how a real results page is laid out: the top 3 results, the rest of page 1, page 2, and everything past that.
Here is a first attempt at that bucketing function. Save it as band_and_compare.py:
#!/usr/bin/env python3
import csv
from collections import defaultdict
BANDS = [
("top 3", 1, 3),
("rest of page 1", 4, 10),
("page 2", 11, 20),
("page 3+", 21, 1000),
]
def band_for(position):
for name, lo, hi in BANDS:
if lo <= position <= hi:
return name
return BANDS[-1][0]
def load_rows(path):
with open(path, newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def summarize_by_band(rows):
buckets = defaultdict(lambda: {"impressions": 0, "clicks": 0, "queries": 0})
for r in rows:
band = band_for(float(r["position"]))
b = buckets[band]
b["impressions"] += int(r["impressions"])
b["clicks"] += int(r["clicks"])
b["queries"] += 1
return buckets
def print_summary(page, buckets):
print(f"\n{page}")
print(f"{'band':<16}{'queries':>8}{'impressions':>13}{'clicks':>8}{'ctr':>9}")
for name, _, _ in BANDS:
b = buckets.get(name)
if not b or b["impressions"] == 0:
continue
ctr = b["clicks"] / b["impressions"]
print(f"{name:<16}{b['queries']:>8}{b['impressions']:>13}{b['clicks']:>8}{ctr:>9.4%}")
rows = load_rows("search_console_sample.csv")
control_rows = [r for r in rows if r["page"] == "/blog/python-dict-comprehension-guide"]
suspect_rows = [r for r in rows if r["page"] == "/blog/kubernetes-resourcequota-explained"]
control_buckets = summarize_by_band(control_rows)
suspect_buckets = summarize_by_band(suspect_rows)
print_summary("CONTROL: /blog/python-dict-comprehension-guide", control_buckets)
print_summary("SUSPECT: /blog/kubernetes-resourcequota-explained", suspect_buckets)
Run it, and look closely at the control page’s rows:
CONTROL: /blog/python-dict-comprehension-guide
band queries impressions clicks ctr
top 3 1 120 27 22.5000%
rest of page 1 10 5656 415 7.3373%
page 3+ 1 555 23 4.1441%
SUSPECT: /blog/kubernetes-resourcequota-explained
band queries impressions clicks ctr
rest of page 1 7 1295 1 0.0772%
page 3+ 5 1141 1 0.0876%
The bug this author actually hit
Something is wrong here. The control page has 12 queries total, and they should split across only two bands (“top 3” and “rest of page 1”), since every control query was generated with a position between 2.0 and 9.5. But the summary shows a “page 3+” row with 1 query and 555 impressions. Checking the raw CSV, the culprit is the row for “python comprehension syntax,” which has a position of 3.4.
Look at the BANDS list again: ("top 3", 1, 3) covers positions 1 through 3, and ("rest of page 1", 4, 10) covers positions 4 through 10. A position of 3.4 falls in neither range, because it is a real number and the boundaries were written as if positions were whole numbers. When band_for() loops through every band and finds no match, it falls through to return BANDS[-1][0], silently returning “page 3+” for a query that is actually sitting near the top of page 1. The function never raises an error. It just quietly mislabels the data, and you would only notice by manually checking totals, the way this tutorial just did.
The fix is to stop thinking in terms of separate (low, high) pairs and instead use a single ascending list of upper thresholds, so every possible position matches exactly one band with no gap between them:
BAND_THRESHOLDS = [
("top 3", 3),
("rest of page 1", 10),
("page 2", 20),
("page 3+", float("inf")),
]
def band_for(position):
for name, upper in BAND_THRESHOLDS:
if position <= upper:
return name
raise AssertionError("unreachable: last threshold is inf")
Because the last threshold is float("inf"), the loop is guaranteed to return before it ever falls through, so the raise on the last line can only fire if someone breaks the list itself, which is exactly the kind of bug you want to fail loudly instead of silently. Update the loops inside summarize_by_band and print_summary to iterate for name, _ in BAND_THRESHOLDS instead of unpacking three values, add one sanity check at the end that the bucketed impression totals match the raw CSV totals, and re-run:
CONTROL: /blog/python-dict-comprehension-guide
band queries impressions clicks ctr
top 3 1 120 27 22.5000%
rest of page 1 11 6211 438 7.0520%
SUSPECT: /blog/kubernetes-resourcequota-explained
band queries impressions clicks ctr
rest of page 1 12 2436 2 0.0821%
Sanity check passed: bucketed impressions match raw CSV totals for both pages.
Now the control page’s 12 queries split correctly between “top 3” and “rest of page 1,” with no phantom “page 3+” row, and the sanity check confirms every impression was counted exactly once. This is the version to keep using from here on. The lesson generalizes past this one script: any time you write boundary conditions by hand for continuous, real-valued data (positions, prices, timestamps, latencies), write them as a single ascending threshold list rather than a set of separate ranges, and add a fallback that raises instead of one that silently returns a default.
Step 3: Compare the Suspect Page Against a Control Page
A control page is simply a page on the same site that you already know earns real human clicks, used as a baseline. Comparing raw numbers is not useful on its own (of course a bigger page gets more clicks), but comparing CTR within the same position band strips out the position advantage and asks a narrower question: for two pages ranking similarly well, why is one converting impressions into clicks and the other is not?
Add this comparison to the end of the fixed script and re-run:
print("\nBand-by-band comparison (suspect CTR vs control CTR, same band):")
print(f"{'band':<16}{'control ctr':>13}{'suspect ctr':>13}{'gap':>10}")
for name, _ in BAND_THRESHOLDS:
c = control_buckets.get(name)
s = suspect_buckets.get(name)
if not c or not s or c["impressions"] == 0 or s["impressions"] == 0:
continue
c_ctr = c["clicks"] / c["impressions"]
s_ctr = s["clicks"] / s["impressions"]
gap = c_ctr - s_ctr
print(f"{name:<16}{c_ctr:>13.4%}{s_ctr:>13.4%}{gap:>10.4%}")
Band-by-band comparison (suspect CTR vs control CTR, same band):
band control ctr suspect ctr gap
rest of page 1 7.0520% 0.0821% 6.9699%
Both pages have queries in the “rest of page 1” band, so this is a fair, like-for-like comparison. The control page converts 7.05% of its impressions into clicks in that band. The suspect page converts 0.08%, a gap of nearly 7 percentage points on impressions that, by position alone, should behave similarly. That gap is the strongest single signal in this tutorial: it is not explained by ranking position, since both pages are ranking in the same band, so something else is suppressing clicks specifically on the suspect page.
Step 4: Scan Query Text for Fan-Out Shaped Signatures
The band comparison tells you something is wrong with a page. It does not tell you which individual queries are the problem. For that, look at the query text itself. Sub-queries generated by an AI system’s fan-out step tend to look like full natural-language questions (“what is the difference between resourcequota and limitrange in kubernetes”), while queries a person actually types tend to be short head terms (“kubernetes resourcequota”). Save this as query_heuristics.py:
#!/usr/bin/env python3
import csv
QUESTION_STARTERS = (
"what", "why", "how", "does", "do", "can", "is", "are", "when",
"where", "which", "should", "will", "who",
)
def looks_fan_out_shaped(query):
words = query.split()
starts_as_question = words[0].lower() in QUESTION_STARTERS if words else False
is_long = len(words) >= 8
return starts_as_question and is_long
with open("search_console_sample.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
print(f"{'flag':<6}{'words':>6}{'impr':>7}{'clicks':>7} query")
for r in rows:
flagged = looks_fan_out_shaped(r["query"])
marker = "FLAG" if flagged else ""
print(f"{marker:<6}{len(r['query'].split()):>6}{r['impressions']:>7}{r['clicks']:>7} {r['query']}")
flagged_rows = [r for r in rows if looks_fan_out_shaped(r["query"])]
print(f"\n{len(flagged_rows)} of {len(rows)} queries flagged as fan-out-shaped.")
Run it. The control page’s queries pass straight through with no flags:
flag words impr clicks query
3 377 24 python dict comprehension
3 470 38 dict comprehension python
4 766 71 python dictionary comprehension example
7 891 49 how to use dict comprehension in python
3 555 23 python comprehension syntax
5 265 19 dict comprehension if else python
5 120 27 python dict comprehension with condition
4 617 55 nested dict comprehension python
5 542 40 python list vs dict comprehension
4 629 46 dict comprehension python w3schools
5 602 28 python create dict from list
4 497 45 python dict comprehension performance
FLAG 14 179 0 what happens when a kubernetes namespace exceeds its resource quota during a rolling deployment
FLAG 8 368 0 does resourcequota apply to init containers in kubernetes
FLAG 11 305 1 how does kubernetes enforce resource quotas across multiple namespaces at once
FLAG 12 245 0 can a resourcequota object limit the number of persistentvolumeclaims in a namespace
FLAG 10 262 0 what is the difference between resourcequota and limitrange in kubernetes
FLAG 14 79 0 why would a pod be rejected due to resourcequota even with available cluster capacity
FLAG 13 302 0 how do you calculate the total cpu request needed to satisfy a resourcequota
FLAG 12 73 0 is resourcequota enforced at admission time or at scheduling time in kubernetes
FLAG 10 142 0 what error message appears when a deployment exceeds its resourcequota
2 253 0 kubernetes resourcequota
3 69 0 k8s resourcequota example
2 159 1 resourcequota yaml
9 of 24 queries flagged as fan-out-shaped.
Nine of the twelve suspect-page queries get flagged, and zero false positives fire on the control page. That is a genuinely useful signal. But look closely at the three suspect-page rows that did not get flagged: “kubernetes resourcequota” (253 impressions, 0 clicks), “k8s resourcequota example” (69 impressions, 0 clicks), and “resourcequota yaml” (159 impressions, 1 click). These are short, ordinary-looking head terms, exactly the shape a real engineer would type, yet they behave identically to the flagged rows: near-zero clicks despite solid impression counts. A query-shape heuristic cannot catch these, because a fan-out sub-query and a real short search can look identical in text. This is exactly why Step 3’s control-page comparison is the more load-bearing technique here: it flagged the whole page’s “rest of page 1” band regardless of individual query wording, while this heuristic only ever catches the subset of rows that happen to read like full questions.
Common Mistakes and Gotchas
The naive “biggest opportunity” trap
A common next move, once you have impressions and CTR side by side, is to write a script that ranks queries by how far their actual CTR falls short of an expected CTR for their position, sorted with the biggest shortfall first, and treat the top of that list as your best title-rewrite opportunities. Watch what happens when fan-out rows are mixed into that ranking. Save this as naive_opportunity_trap.py:
#!/usr/bin/env python3
import csv
# An expected-CTR-by-position curve. The first two thresholds (3 and
# 10) use the control page's own observed band CTRs from the fixed
# band_and_compare script (22.5% and roughly 7.05%). The control page
# had no queries ranking past position 10, so the last two values are
# reasonable extrapolations, not observed data, and are labeled that
# way here on purpose: using your own control page's numbers where
# you have them is more honest than an unverified industry-average
# curve, but it is still just one page's history, not a universal
# constant, and you should not pretend otherwise for the bands you
# have no real data for.
EXPECTED_CTR_BY_POSITION = [
(3, 0.225),
(10, 0.0705),
(20, 0.02), # extrapolated, not observed
(float("inf"), 0.01), # extrapolated, not observed
]
def expected_ctr(position):
for upper, ctr in EXPECTED_CTR_BY_POSITION:
if position <= upper:
return ctr
raise AssertionError("unreachable")
with open("search_console_sample.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
scored = []
for r in rows:
impressions = int(r["impressions"])
clicks = int(r["clicks"])
actual_ctr = clicks / impressions
position = float(r["position"])
exp_ctr = expected_ctr(position)
gap = max(0.0, exp_ctr - actual_ctr)
missed_clicks = gap * impressions
scored.append((missed_clicks, r["query"], r["page"], impressions, clicks, position))
scored.sort(key=lambda t: -t[0])
print("Naive 'biggest opportunity' ranking (estimated missed clicks, descending):")
print(f"{'missed':>8}{'impr':>7}{'clicks':>7}{'pos':>6} page query")
for missed, query, page, impr, clicks, pos in scored[:6]:
short_page = page.split("/")[-1]
print(f"{missed:>8.1f}{impr:>7}{clicks:>7}{pos:>6.1f} {short_page} {query}")
Naive 'biggest opportunity' ranking (estimated missed clicks, descending):
missed impr clicks pos page query
25.9 368 0 3.5 kubernetes-resourcequota-explained does resourcequota apply to init containers in kubernetes
21.3 302 0 5.7 kubernetes-resourcequota-explained how do you calculate the total cpu request needed to satisfy a resourcequota
20.5 305 1 3.8 kubernetes-resourcequota-explained how does kubernetes enforce resource quotas across multiple namespaces at once
18.5 262 0 6.4 kubernetes-resourcequota-explained what is the difference between resourcequota and limitrange in kubernetes
17.8 253 0 3.3 kubernetes-resourcequota-explained kubernetes resourcequota
17.3 245 0 5.9 kubernetes-resourcequota-explained can a resourcequota object limit the number of persistentvolumeclaims in a namespace
Every single row in the top six is on the suspect page, including “kubernetes resourcequota,” the short head-term row that Step 4’s query-shape heuristic could not flag. A naive ranking like this would point you straight at rewriting titles and meta descriptions for queries that, per the evidence gathered in Steps 3 and 4, were most likely never seen by a person at all. Zero clicks against any positive expected CTR produces the largest possible gap the formula can produce, no matter how many impressions the row has, so rows like this will always float to the top of a plain CTR-gap ranking once fan-out impressions are mixed in with real ones. The fix is not to abandon this kind of ranking, it is to never trust it in isolation: cross-reference its top candidates against a control-page band comparison (Step 3) and a query-shape check (Step 4) before you spend an afternoon rewriting a title that no human was ever going to read.
A flag is a hint, not a verdict
It is worth repeating the limitation from earlier in this tutorial, because it is easy to forget once you have working code producing confident-looking tables: Search Console does not expose a ground-truth “this impression came from AI query fan-out” field. Every technique in this tutorial, position banding, control-page comparison, and query-text shape, is inference from a converging pattern, not a lookup against something Google tells you directly. Treat a page that fails all three checks as “worth investigating further,” not as a confirmed diagnosis.
Verify It Worked End to End
Run the four scripts in order against search_console_sample.csv and confirm you see the same shape of results:
generate_sample_data.py: writes 24 rows; control page overall CTR near 7.3%, suspect page overall CTR near 0.08%.band_and_compare.py(fixed version): control and suspect both show a “rest of page 1” band; control CTR in that band is roughly 7%, suspect CTR in that band is roughly 0.08%; the sanity check prints “passed” with noAssertionError.query_heuristics.py: 9 of 24 rows flagged, all nine on the suspect page, zero false positives on the control page.naive_opportunity_trap.py: every row in the top six of the ranking belongs to the suspect page.
If your numbers land in the same shape (control page comfortably outperforming the suspect page’s CTR within a shared band, most but not all suspect queries flagged by the text heuristic), the toolkit is working correctly and you are ready to point it at real data.
Adapting This to Your Own Real Data
To run this against your own site instead of the sample dataset:
- In Search Console, open your property and go to the Performance report.
- Filter to your suspect page (the one with the high-impressions, near-zero-clicks pattern) using the Pages tab, then switch to the Queries tab to see its individual query rows.
- Do the same for a control page: one of your own pages that you already know gets real human clicks. Search Console’s reports include an export option for downloading both the chart and the table data, which is the simplest way to get every query row for a page in one pass. (Source: Search Console Help, Performance report)
- Reshape whatever you export into the six columns this tutorial’s scripts expect:
query,page,clicks,impressions,ctr,position, matching them to the Clicks / Impressions / CTR / Position columns Search Console shows you on screen. - Point
band_and_compare.py,query_heuristics.py, andnaive_opportunity_trap.pyat your new CSV file instead ofsearch_console_sample.csv.
If you want to automate this instead of exporting by hand, Google’s Search Analytics API supports the same query, clicks, impressions, ctr, and position fields used throughout this tutorial. Access is granted the same way Search Console grants any account access: from your property, open Settings, then Users and permissions, then Add user, and grant Full user access (Google’s documentation describes Full user as having “view rights to all data and can take some actions”) to the account making the API calls. (Source: Search Console Help, Users and permissions) Setting up the API client itself is a larger topic than this tutorial covers; Google’s own Search Analytics API reference is the right starting point once you are ready for it.
Next Steps
A page that fails the checks in this tutorial is not automatically a page to abandon or rewrite. Two things are worth keeping in mind before you act on what you find:
- Do not rewrite a title or meta description based on a single low-CTR row without first checking whether that row looks fan-out-shaped. You would be optimizing copy that, per the evidence, nobody was ever going to read.
- A page that is genuinely attracting a lot of fan-out impressions may still be doing useful work: if an AI feature keeps citing it as a source, that is a form of visibility and possible brand exposure even without a click. Treat it as a different kind of outcome to track, not as a failure to fix.
For more on how AI-era traffic is reshaping site analytics more broadly, see this site’s coverage of Cloudflare’s AEO dashboard for tracking AI recommendation visibility and Cloudflare’s bot preference sync for controlling which AI crawlers can use your content.








No Comment! Be the first one.