An automated SEO report should keep GSC clicks, visible query rows and GA4 organic sessions as separate metrics. They come from different collection systems and need not agree. Build the page-level report first, then attach visible queries without pretending that their sum covers every click.
Keep the reporting contract explicit
| Source | Record with each export |
|---|---|
| GSC | Property, search type, dimensions, date range, data state and last available date |
| GA4 | Property timezone, channel/source filters, date range and reported metrics |
| Landing page | Canonical mapping, hostname, preserved query policy and redirect history |
| Change log | Published modification time, payload ID and verification status |
Request final GSC data for stable analysis. Keep preliminary hourly data separate and use it to flag a review, not to authorize consolidation. Query exports can omit anonymized or lower-volume rows; the difference below remains unassigned rather than being attributed to a guessed keyword. Consult the Search Analytics query documentation for limits.
Generate a row without equating clicks and sessions
from urllib.parse import urlsplit, urlunsplit
def page_key(url):
p = urlsplit(url)
if p.scheme not in ("http", "https") or not p.hostname:
raise ValueError("Expected absolute URL")
# Preserve query and slash: they may be different canonical resources.
return urlunsplit((p.scheme.lower(), p.netloc.lower(), p.path or "/", p.query, ""))
def reporting_row(page_url, gsc_clicks, visible_query_clicks, ga4_sessions, windows):
if min(gsc_clicks, visible_query_clicks, ga4_sessions) < 0 or visible_query_clicks > gsc_clicks:
raise ValueError("Invalid comparable query/page inputs")
return {"page":page_key(page_url), "gsc_clicks":gsc_clicks,
"visible_query_clicks":visible_query_clicks,
"unassigned_query_clicks":gsc_clicks-visible_query_clicks,
"ga4_organic_sessions":ga4_sessions, "windows":windows}
if __name__ == "__main__":
print(reporting_row("https://example.com/guide#faq", 8, 5, 6,
{"gsc":"2026-10-02/08 America/Los_Angeles", "ga4":"2026-10-02/08 Asia/Shanghai"}))
The synthetic inputs produce eight GSC clicks, five assigned to visible queries and three unassigned. Six GA4 sessions remain a separate field. The two date windows in the fixture use different timezones: matching calendar labels do not establish identical hours. Use hourly timestamps where available for a common interval, or label calendar-day comparisons as such. Do not silently shift aggregated daily data.
Add alerts that have an interpretable cause
Flag a cutoff that has not advanced, a changed landing canonical, a newly retired page still receiving sessions, or a query moving between two same-intent pages. For ranking movement, compare the same query, country and device with its sample size. Page-wide average position can change because the query mix changed. Record that uncertainty rather than treating every mean-position change as a gain.
Keep hostname and source/medium exports available when registrations change. A total organic-session metric can include a different engine mix. Use the same named event and attribution filter before comparing conversions. The source-checked brief builder handles content preparation; this page handles measured reporting after implementation.