On this page

Chapter 6 of 8. Prerequisite: Chapter 5 — you should have a telemetry/auth.py returning a Credentials object and telemetry/gsc.py with subcommand registrations declared. This chapter fills in the bodies of the GSC subcommands.

Three endpoints, one library

The Search Console API surfaces enough functionality to support both an indexation dashboard and a cannibalisation detector — both of which I have built and shipped against this exact surface. The endpoints you need, all documented under developers.google.com/webmaster-tools:

  • Sites — list the properties your authenticated account has access to. Read-only confirmation step; rarely the headline call but useful for sanity checks.
  • URL Inspection — per-URL state. Returns the bucket Google has assigned the URL, the date of the last crawl, the canonical Google has chosen, and so on. This is the endpoint behind the Index Coverage report.
  • Search Analytics — query-and-page-level performance data. Impressions, clicks, CTR, position, broken down by query, page, country, device, search appearance.

All three are exposed via the searchconsole service built by the google-api-python-client:

from googleapiclient.discovery import build
service = build("searchconsole", "v1", credentials=creds)

Sites — the sanity check

def cmd_sites(args: argparse.Namespace, creds: Credentials) -> int:
    """List GSC properties the authenticated account has access to."""
    service = build("searchconsole", "v1", credentials=creds)
    response = service.sites().list().execute()
    for entry in response.get("siteEntry", []):
        print(f"{entry['permissionLevel']:>12}  {entry['siteUrl']}")
    return 0

This is the call to make when you first wire the CLI up. If it returns an empty list, the OAuth client is authenticated but the account does not have any GSC properties. If it returns a property you expected but with permissionLevel: 'siteUnverifiedUser', the verification has lapsed and other endpoints will return 403s.

URL Inspection — what state is this URL in?

The URL Inspection endpoint takes one URL at a time and returns the indexation state. There is no batch variant; if you want to inspect twenty URLs you make twenty calls.

def cmd_indexation(args: argparse.Namespace, creds: Credentials) -> int:
    """Pull index-coverage state for every URL in a sitemap.

    Walks a sitemap URL, inspects each entry, groups by verdict, prints
    a one-line summary per bucket.  Detail in the inline comments."""
    service = build("searchconsole", "v1", credentials=creds)
    urls = _fetch_sitemap(args.sitemap)

    buckets: dict[str, list[str]] = {}
    for url in urls:
        body = {
            "inspectionUrl": url,
            "siteUrl": args.property,
        }
        response = service.urlInspection().index().inspect(body=body).execute()
        # The verdict field maps directly to the Index Coverage UI buckets
        # listed in the GSC help article.  See the bucket taxonomy callout below.
        verdict = response["inspectionResult"]["indexStatusResult"]["verdict"]
        buckets.setdefault(verdict, []).append(url)

    for verdict, items in sorted(buckets.items()):
        print(f"{verdict:>20}  {len(items):>4}")
    return 0

The _fetch_sitemap helper does an HTTP GET on the sitemap URL and parses out the <loc> entries. I am keeping that out-of-band of this chapter because it is incidental; the GSC-specific work is the inspect call.

Search Analytics — what queries are landing where?

The Search Analytics endpoint is where the cannibalisation detector lives. The shape is one HTTP call with a query body specifying the date range, dimensions, and row limit.

def cmd_queries(args: argparse.Namespace, creds: Credentials) -> int:
    """Pull top queries for the property over a date window."""
    service = build("searchconsole", "v1", credentials=creds)
    end = date.today()
    start = end - timedelta(days=args.days)

    body = {
        "startDate": start.isoformat(),
        "endDate": end.isoformat(),
        "dimensions": ["query"],
        "rowLimit": 1000,
    }
    response = service.searchanalytics().query(siteUrl=args.property, body=body).execute()

    for row in response.get("rows", []):
        query = row["keys"][0]
        clicks = row["clicks"]
        impressions = row["impressions"]
        ctr = row["ctr"]
        position = row["position"]
        print(f"{clicks:>5}  {impressions:>7}  {ctr:>6.2%}  {position:>5.1f}  {query}")
    return 0

For cannibalisation detection, the change is one line:

body["dimensions"] = ["query", "page"]

Now each row carries both the query and the URL it matched. Group by keys[0] (the query) in your client code and count distinct keys[1] (the page). Any query landing on two or more pages is a cannibalisation candidate. The cannibalisation article walks through what you do with that list once you have it.

// DECISION

Why read-only by design?

The call

The tool authorises webmasters.readonly and nothing more. It observes indexation; it never mutates anything.

Rejected
The full webmasters scopeprivilege a telemetry tool does not need. Least privilege is cheap on the day you set it up and expensive to retrofit after a leak.
The Indexing APIrestricted by its terms to JobPosting and BroadcastEvent content; using it to nudge ordinary pages is a policy violation, whatever the tutorials claim
What it costs

Rare write actions stay manual. When this site needed a sitemap resubmitted, the read-only token returned 403 and the owner clicked the button in Search Console. Fifteen seconds, once a quarter.

Revisit when

Never, for the Indexing API. If a genuine write need appears, it gets its own separately-scoped credential rather than broadening this one.

Quota — what counts and what does not

The GSC API has a per-project per-day quota cap. The exact number changes; check the current value in the API reference. The practical number to remember is that you can comfortably support:

  • A daily indexation pull across ≤200 URLs per property.
  • A daily Search Analytics pull with rowLimit: 1000 across ≤10 properties.
  • A few ad-hoc CLI invocations on top.

Where you run out of quota first is the URL Inspection endpoint, because every URL is a separate call. The mitigation is to inspect only URLs that changed — diff the sitemap day-over-day, inspect only the new entries, and rely on yesterday's bucket assignment for everything else. That cuts the daily inspect count from len(sitemap) to len(new_urls).

What this gives you

With sites, indexation, and queries wired, you have most of what the Index Coverage report and the Performance report surface in the UI — but as data, in your CLI, run from a cron. Chapter 7 does the same for GA4. Chapter 8 wraps both into a daily digest the Telegram bot DMs you.

The two articles linked above are the production case studies — both used exactly this CLI shape and exactly these three endpoints to surface signal the UI was burying under clicks.

// EXERCISE

Run a cannibalisation sweep on your own property

Fill in the GSC subcommand bodies against a property you own, then extend the queries pull into a cannibalisation detector: add page as a second dimension, group rows by query in client code, and flag every query that Google is matching to more than one of your URLs.

Expected behaviour
  • A sites subcommand listing your properties with permission levels, used as the wiring sanity check
  • An indexation subcommand that inspects each sitemap URL individually and groups results by verdict
  • A queries pull with dimensions query and page, grouped client-side to count distinct pages per query
  • A printed shortlist of queries landing on two or more URLs
  • An inspect-count strategy that only inspects URLs new to the sitemap since the previous run

PROVE IT Run the sweep over 28 days of data and walk through one flagged query, showing the competing URLs and how the impressions split between them.

// CHECKPOINT — GSC QUERIES
multiple choice · auto-checked

Your daily cron is burning through GSC quota. Which endpoint is the likely culprit?

exact answer · auto-checked

What service name do you pass to build() to reach all three GSC endpoints?

open · self-checked

You inspect a URL, deploy a fix, and inspect it again five minutes later. The verdict has not moved. What is going on?

Show answer

URL Inspection caches aggressively on Google's side, so inspecting the same URL twice in five minutes returns the cached response with no change to the verdict, even if the URL has been re-crawled in between. The chapter says to give it an hour for a fresh inspect, or trigger a re-crawl from the GSC UI first.

↺ re-read: “Quota — what counts and what does not

Lived experience

Sources

  • Google Search Console API reference
    Google
    The three endpoints this chapter calls
    developers.google.com
  • How Search Works — crawling, indexing, serving
    Google (Search Central docs)
    What the index-coverage buckets mean
    developers.google.com
  • Index Coverage report (in Search Console)
    Google (Search Console help)
    The bucket taxonomy mapped to UI labels
    support.google.com
  • google-api-python-client documentation
    Google (google-api-python-client docs)
    Service builder + request execution
    googleapis.github.io
Back to guide overview