跳到正文
DEV Community· Vishal Chauhan·· 4 小時前AI 評分66

排名追蹤器遇上受污染的 Google 搜尋結果,如何用 Search Console API 核查跌幅

Rank Trackers Are Getting Poisoned Google SERPs. Here's How I Check Drops With the Search Console API

AI 導讀

作者分享 Python 腳本,透過 Search Console API 對照排名追蹤器的關鍵字跌幅,判斷哪些情況可能是追蹤數據異常,哪些需要進一步調查。Nozzle 的研究指出,9 月 13 日後可判定的 17.8 million 個 SERP 中有 8.0% 受污染,基準期(8 月 20 日至 9 月 9 日)為 0.17%;Google 尚未確認相關情況。

正文

If your rank tracker showed a page-one keyword falling off a cliff in late September and then coming back a few days later, it may not have been your site at all. According to a study Nozzle published on October 10, Google has been serving some rank trackers search result pages that look completely normal but have the results swapped out.

I spent the last couple of days reading the data and changing how I check rankings. Here's what's going on, and the small Python script I now run before I believe any drop.

What a "poisoned" SERP is

Nozzle is an enterprise rank tracker. In its Poisoned Google SERPs study (data through October 9, 2026), it describes pages with no error, no captcha, and the same template as a real results page. Most of the keyword's usual sites are simply gone.

The numbers that matter to me:

  • 8.0% of the 17.8 million SERPs it could judge since September 13 came back poisoned, against 0.17% in the August 20 to September 9 baseline.
  • It peaked on September 27 to 29, at roughly a quarter of all SERPs (27.0% on September 29), and has run at about 5 to 11% a day since October 1.
  • A poisoned page keeps 20.7% of the previous page's organic sites. A normal one keeps 88.5%.
  • For keywords where a project's own site is one of the usual results, that site was missing from the top 10 on 58.6% of poisoned SERPs, against 2.7% in the baseline.
  • 77.9% of poisoned SERPs had their usual sites back within three days. 80% of spells lasted a single fetch.

Nozzle's read is that this is Google pushing back on bulk scraping for AI answers, and that rank trackers get caught because they collect SERPs the same way. Google hasn't confirmed any of it. Search Engine Journal covered it on October 14, and it's been all over SEO X this week.

Two more details make this messier:

  1. The September 2026 spam update started on September 24 and ran for 13 days and 16 hours. So a drop on September 29 could be the update, bad data, or both.
  2. It isn't over. TrueRanker's write-up, updated October 15, says most of the obviously irrelevant results are fixed, but Google is also serving pages whose results fit the query and still aren't the real ones. Those are much harder to spot.

Why developers should care

This isn't only an SEO-agency problem. If you've built any of these, you're consuming the same data:

  • an internal dashboard that pulls positions from a SERP API
  • a scraper that checks where your docs or landing pages rank
  • an "AI visibility" tracker that records which sites Google's AI Overview cites

That last one is hit too. On poisoned pages, the AI Overview showed up 34.9% of the time instead of 54.2%, and its sources kept only 42.3% of the previous page's sites, against 68.8% in the baseline. That timing is awkward: on October 15 Google announced more inline links in AI Mode and AI Overviews, plus a test of a section at the top of the response that highlights one or two official pages. Citation tracking is getting more interesting just as scraped data gets less reliable.

Use Search Console as the source of truth

For your own site, there's a source that scraping can't poison: Search Console. Its clicks and impressions come from real searches, not from a page served to a bot. Nozzle's own checklist ends with the same advice: if a site "disappears" in a tracker while Search Console still shows impressions and clicks, nothing happened to the site.

The catch is that checking this by hand for every alert gets old quickly, so I automated it with the Search Console API.

One-time setup

  1. In Google Cloud, create a project, enable the Search Console API, and create a service account with a JSON key.
  2. In Search Console, open the property, go to Settings > Users and permissions > Add user, and paste the service account's email. The API only needs read permission on the property, so I give it the lowest role that can read Performance data instead of making it an owner. If you skip this step, every call fails with a 403 even though the key is valid.
  3. Install the client:
pip install --upgrade google-api-python-client google-auth

Use the property string exactly as Search Console shows it: sc-domain:example.com for a Domain property, or https://www.example.com/ for a URL-prefix property.

The drop checker

My tracker exports a CSV with date,query,position (position left empty when the site wasn't found). The script finds every day a keyword left the top 10, pulls the same days from Search Console, and prints a verdict.

import csv
import re
import sys
from datetime import date, timedelta

from google.oauth2 import service_account
from googleapiclient.discovery import build

SITE = "sc-domain:example.com"  # or "https://www.example.com/" for a URL-prefix property
KEY_FILE = "gsc-reader.json"
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]


def gsc_service():
    creds = service_account.Credentials.from_service_account_file(KEY_FILE, scopes=SCOPES)
    return build("searchconsole", "v1", credentials=creds)


def gsc_daily(service, queries, days=28):
    """Return {(date, query): row} for the tracked queries."""
    end = date.today()
    start = end - timedelta(days=days)
    # Escape RE2 metacharacters only (Python's re.escape also escapes spaces).
    safe = [re.sub(r"([.^$|?*+()\[\]{}\\])", r"\\\1", q) for q in queries]
    pattern = "(?i)^(" + "|".join(safe) + ")$"
    body = {
        "startDate": start.isoformat(),
        "endDate": end.isoformat(),
        "dimensions": ["date", "query"],
        "dimensionFilterGroups": [{
            "filters": [{
                "dimension": "query",
                "operator": "includingRegex",
                "expression": pattern,
            }]
        }],
        "dataState": "all",  # include fresh, still-changing days
        "rowLimit": 25000,
    }
    resp = service.searchanalytics().query(siteUrl=SITE, body=body).execute()
    rows = {(r["keys"][0], r["keys"][1].lower()): r for r in resp.get("rows", [])}
    return rows, resp.get("metadata", {})


def load_tracker(path):
    """CSV with columns: date,query,position (empty position = not found)."""
    data = {}
    with open(path, newline="") as f:
        for row in csv.DictReader(f):
            pos = float(row["position"]) if row["position"] else None
            data.setdefault(row["query"].lower(), {})[row["date"]] = pos
    return data


def main(tracker_csv):
    tracker = load_tracker(tracker_csv)
    gsc, meta = gsc_daily(gsc_service(), list(tracker))
    incomplete = meta.get("firstIncompleteDate") or meta.get("first_incomplete_date")

    for query, days in tracker.items():
        dates = sorted(days)
        for prev, day in zip(dates, dates[1:]):
            was_top10 = days[prev] is not None and days[prev] <= 10
            now_out = days[day] is None or days[day] > 10
            if not (was_top10 and now_out):
                continue

            later = [d for d in dates if d > day][:3]
            bounced = any(days[d] is not None and days[d] <= 10 for d in later)
            row = gsc.get((day, query))

            if incomplete and day >= incomplete:
                verdict = "GSC still processing this day, check again later"
            elif row and row["impressions"] > 0 and row["position"] <= 10:
                verdict = "GSC disagrees: likely bad tracker data"
            elif row is None:
                verdict = "no GSC impressions that day: low volume or a real drop"
            else:
                verdict = "GSC agrees: investigate"

            gsc_txt = (f"impr={row['impressions']:.0f} pos={row['position']:.1f}"
                       if row else "no row")
            print(f"{day}  {query!r}: tracker {days[prev]} -> {days[day]}, "
                  f"{gsc_txt}, bounced back={bounced}\n    {verdict}")


if __name__ == "__main__":
    main(sys.argv[1])

Run it with python check_drops.py tracker.csv. Here's the output from a dry run with a test CSV and stubbed API responses:

2026-10-06  'website audit tool': tracker 6.0 -> None, impr=120 pos=6.4, bounced back=True
    GSC disagrees: likely bad tracker data
2026-10-06  'free seo checker': tracker 8.0 -> 18.0, impr=40 pos=17.2, bounced back=False
    GSC agrees: investigate
2026-10-06  'ai search readiness': tracker 4.0 -> 30.0, no row, bounced back=False
    no GSC impressions that day: low volume or a real drop

Things the API docs make you learn the hard way

  • Dates are Pacific Time. startDate and endDate are read in PT, so line up your tracker's dates before you compare day by day.
  • dataState: "all" includes fresh data that can still change. When you group by date, the response can include a metadata object with the first incomplete date (firstIncompleteDate in the client library). Anything from that day onward may still move, which is why the script won't give a verdict for those days.
  • Missing rows aren't zeros. Days without data are left out of the result, and the API returns top rows rather than guaranteeing every row. A low-volume query with no row on a given day proves nothing either way.
  • Average position isn't a tracker position. Search Console averages your topmost result across every search, location and device. A tracker checks one location and one device. I treat "Search Console still shows impressions in the top 10" as the signal, not an exact match.
  • Grouping by query is the expensive kind of request. The usage limits page says queries grouped or filtered by page or query cost the most load, and longer date ranges cost more. That's why I filter to tracked keywords and keep the window at 28 days.

When the drop is real

If a drop lasts more than three days and Search Console agrees, I stop blaming the data and look at the page. The URL Inspection API is the quick first check, with the same service object:

result = service.urlInspection().index().inspect(body={
    "inspectionUrl": "https://example.com/pricing",
    "siteUrl": SITE,
}).execute()["inspectionResult"]["indexStatusResult"]

print(result["verdict"], result.get("coverageState"), result.get("googleCanonical"))

If the page is no longer indexed, or Google picked a different canonical than the one you declared, that's usually your answer. Inspection is capped at 2,000 calls per day per site, so I run it only on URLs that failed the drop check.

After that I audit the page itself: a deploy that added noindex, a canonical pointing at a redirect, content that moved behind client-side rendering. I build Siteory, a free audit for SEO, AI-search readiness and security that gives you a prioritized list of fixes, so that's what I reach for. Lighthouse and Search Console's own reports cover plenty of the same ground if you'd rather use those.

My new rules for rank data

  • A one-day drop that comes back within three days is noise until proven otherwise.
  • Compare a keyword against its usual top-10 sites over the past few weeks, not against yesterday. Yesterday might have been poisoned too.
  • Search Console decides. If it still shows impressions and clicks, the site didn't disappear.
  • Keep the tracker for competitor views and trends, because Search Console only covers your own property. Just stop treating its daily numbers as exact.

Rank trackers were always an estimate. This month it became obvious how rough that estimate can get.

來源:DEV Community · dev.to