TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Detect AI Query Fan-Out Impressions in Google Search Console With Python
Learning Hub

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.

August 28, 2026 18 Min Read
59

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 to pip 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:

  1. generate_sample_data.py: writes 24 rows; control page overall CTR near 7.3%, suspect page overall CTR near 0.08%.
  2. 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 no AssertionError.
  3. query_heuristics.py: 9 of 24 rows flagged, all nine on the suspect page, zero false positives on the control page.
  4. 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:

  1. In Search Console, open your property and go to the Performance report.
  2. 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.
  3. 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)
  4. 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.
  5. Point band_and_compare.py, query_heuristics.py, and naive_opportunity_trap.py at your new CSV file instead of search_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.

Tags:

AIGoogle Search ConsolePythonQuery Fan-OutSEO

Share

Railway tracks ending at red buffer stops beneath a covered station platform, illustrating infrastructure that reaches its limit
Previous Post

CNCF’s Platform Readiness Blueprint Turns Kubernetes-for-AI Adoption Into a Maturity Gap

The staggered, offset glass facade of 56 Leonard Street in Manhattan, nicknamed the Jenga Building, used here as a visual metaphor for stacked financial leverage
Next Post

Lambda Borrows Another $1 Billion for Nvidia Chips as Central Banks Warn About AI’s Debt Load

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

Blue-lit server racks in a modern data center, illustrating the compute infrastructure behind the AI boom.
Articles

The AI Boom Is Spending Real Money Before Proving Real Returns

June 7, 2026
Technician working with a laptop beside server racks, representing enterprise AI retrieval infrastructure
Articles

Google’s Agentic RAG Push Makes Enterprise AI Less of a One-Shot Guess

June 7, 2026
A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026