Every Monday we want the same answer for each client site: did search traffic go up or down last week, and which pages moved? Search Console can show it, but clicking through 15 properties takes an hour. This script does it in seconds.

It uses only Python's standard library for the report itself. One small package is needed once, to log in.

Along the way we hit a trap in the Search Console API that would have made every report wrong. It's at the end, and it matters even if you never use this script.

What you need

  • Python 3.9 or newer
  • Owner or full access to the site in Search Console
  • A free Google Cloud project (no billing needed)

Step 1: Turn on the API and create a login

  1. In the Google Cloud console, create a project (or pick one).
  2. Open APIs & Services, then Library, search for Google Search Console API, and click Enable.
  3. Open Credentials, choose Create credentials, then OAuth client ID, and pick Desktop app. If asked to set up the consent screen first, choose External and add your own Google account as a test user.
  4. Download the JSON file and save it next to your scripts as client_secret.json.

Step 2: Save a refresh token (once)

This uses Google's login helper to open a browser, ask for read-only Search Console access, and save a long-lived refresh token.

pip install google-auth-oauthlib
get_token.py
import json
 
from google_auth_oauthlib.flow import InstalledAppFlow
 
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
 
flow = InstalledAppFlow.from_client_secrets_file("client_secret.json", SCOPES)
creds = flow.run_local_server(port=0)
 
with open("gsc-token.json", "w") as f:
    json.dump({
        "client_id": creds.client_id,
        "client_secret": creds.client_secret,
        "refresh_token": creds.refresh_token,
    }, f)
 
print("Saved gsc-token.json")

Run python get_token.py, sign in with the Google account that has access to the site, and approve. Treat gsc-token.json like a password: anyone with it can read your Search Console data.

Step 3: The report

gsc_weekly.py
"""Weekly Search Console report: last 7 days vs the 7 before, by page.
Standard library only. Needs gsc-token.json from get_token.py."""
import csv
import datetime as dt
import json
import sys
import urllib.parse
import urllib.request
 
TOKEN_FILE = "gsc-token.json"
SITE = sys.argv[1] if len(sys.argv) > 1 else "https://www.example.com/"
COUNTRY = "usa"  # three-letter code, or None for every country
API = "https://www.googleapis.com/webmasters/v3/sites/"
 
 
def access_token():
    """Trade the saved refresh token for a one-hour access token."""
    saved = json.load(open(TOKEN_FILE))
    body = urllib.parse.urlencode({
        "client_id": saved["client_id"],
        "client_secret": saved["client_secret"],
        "refresh_token": saved["refresh_token"],
        "grant_type": "refresh_token",
    }).encode()
    with urllib.request.urlopen("https://oauth2.googleapis.com/token", body) as res:
        return json.load(res)["access_token"]
 
 
def report(token, start, end, dimension, country=None):
    """Clicks and impressions per `dimension` value between two dates (inclusive)."""
    payload = {
        "startDate": str(start),
        "endDate": str(end),
        "dimensions": [dimension],
        "rowLimit": 25000,
        "dataState": "final",
    }
    if country:
        payload["dimensionFilterGroups"] = [
            {"filters": [{"dimension": "country", "operator": "equals", "expression": country}]}
        ]
    url = API + urllib.parse.quote(SITE, safe="") + "/searchAnalytics/query"
    req = urllib.request.Request(url, json.dumps(payload).encode(), {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    })
    with urllib.request.urlopen(req) as res:
        rows = json.load(res).get("rows", [])
    return {r["keys"][0]: (r["clicks"], r["impressions"]) for r in rows}
 
 
def main():
    # Search Console data settles after about 2 days, so end 3 days back.
    end = dt.date.today() - dt.timedelta(days=3)
    this_week = (end - dt.timedelta(days=6), end)
    last_week = (end - dt.timedelta(days=13), end - dt.timedelta(days=7))
 
    token = access_token()
    # Site totals: by date, where a country filter is accurate.
    days_now = report(token, *this_week, "date", COUNTRY)
    days_before = report(token, *last_week, "date", COUNTRY)
    # Pages: no country filter. Grouping by page AND filtering by country
    # drops a large share of clicks (37% to 87% on the sites we tested).
    now, before = report(token, *this_week, "page"), report(token, *last_week, "page")
 
    rows = []
    for page in set(now) | set(before):
        c1, i1 = now.get(page, (0, 0))
        c0, i0 = before.get(page, (0, 0))
        rows.append({"page": page, "clicks": c1, "clicks_change": c1 - c0,
                     "impressions": i1, "impressions_change": i1 - i0})
    rows.sort(key=lambda r: r["clicks_change"])
 
    with open(f"gsc-weekly-{end}.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)
 
    total = lambda d, i: int(sum(v[i] for v in d.values()))
    where = COUNTRY.upper() if COUNTRY else "all countries"
    print(f"{SITE}  {this_week[0]} to {this_week[1]}  vs  the 7 days before")
    print(f"Clicks, {where}:      {total(days_before, 0):>6,} -> {total(days_now, 0):,}")
    print(f"Impressions, {where}: {total(days_before, 1):>6,} -> {total(days_now, 1):,}")
    print("\nBy page (all countries). Biggest click losses:")
    for r in rows[:5]:
        print(f"  {r['clicks_change']:+5.0f}  {r['page']}")
    print("Biggest click gains:")
    for r in rows[::-1][:5]:
        print(f"  {r['clicks_change']:+5.0f}  {r['page']}")
 
 
if __name__ == "__main__":
    main()

Run it with your property exactly as Search Console lists it, including the trailing slash. Domain properties start with sc-domain:.

python gsc_weekly.py https://www.example.com/
python gsc_weekly.py sc-domain:example.com

Here is a real run on one of our client sites, a property management company (page names changed):

https://www.example.com/  2026-10-31 to 2026-11-06  vs  the 7 days before
Clicks, USA:         289 -> 426
Impressions, USA:  6,514 -> 8,496
 
By page (all countries). Biggest click losses:
     -3  https://www.example.com/building-a/
     -3  https://www.example.com/building-b/
     -3  https://www.example.com/building-c/
     -2  https://www.example.com/tenant-screening/
     -2  https://www.example.com/about-us/
Biggest click gains:
    +34  https://www.example.com/resident-portal/
    +33  https://www.example.com/
     +8  https://www.example.com/building-d/
     +6  https://www.example.com/building-e/
     +5  https://www.example.com/seattle-north/

The full page list, with impressions, lands in a CSV next to the script.

The trap: page reports plus a country filter

Our first version filtered everything to the US, pages included. The page totals came out far below the site totals. On one roofing site, the same week showed 344 US clicks by date, and 46 by page.

So we tested four sites, same week (Oct 31 to Nov 6), US only:

SiteUS clicks, grouped by dateUS clicks, grouped by pageMissing
Roofing contractor3444687%
Apartment building672464%
Property manager, Seattle42620552%
Property manager, Washington42026537%

Asking for page and country as two dimensions gave the same low numbers, so this isn't a mistake in how the filter is written. Without the country filter, the page totals matched the site totals again (395 clicks against 394 on the roofing site).

We don't know the exact cause. Search Console counts data grouped by page differently from data grouped by site, and it withholds some rows for privacy; combining page and country seems to cut much deeper than either alone. Whatever the reason, the practical rule is simple:

  • Site totals for one country: group by date, with the country filter.
  • Page by page: no country filter.
  • Never add up page rows to get a site total when a country filter is on.

The script above already works this way.

Run it every Monday

On Mac or Linux, add a cron line (crontab -e) to run it at 8 a.m. every Monday:

0 8 * * 1 cd /path/to/scripts && python3 gsc_weekly.py https://www.example.com/ >> weekly.log

On Windows, use Task Scheduler: create a basic task, set it to weekly on Monday, and have it start python with the script and your site as arguments, starting in your scripts folder.

Keep the token out of git

If your scripts live in a repository, add these lines to .gitignore before your first commit:

gsc-token.json
client_secret.json
gsc-weekly-*.csv