n-seo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/bin/n-seo.mjs +310 -0
  5. package/docs/ADDING-A-SITE.md +82 -0
  6. package/docs/ARCHITECTURE.md +213 -0
  7. package/docs/DEPLOY.md +300 -0
  8. package/docs/FAQ.md +93 -0
  9. package/docs/INSTANCE.md +365 -0
  10. package/docs/MCP.md +104 -0
  11. package/docs/OPERATING-RULES.md +106 -0
  12. package/docs/PLAYBOOK.md +122 -0
  13. package/docs/PRD.md +249 -0
  14. package/docs/RELEASING.md +189 -0
  15. package/docs/SCHEDULING.md +104 -0
  16. package/docs/SETUP-GOOGLE.md +215 -0
  17. package/docs/examples/campaign.json +59 -0
  18. package/docs/examples/draft.md +43 -0
  19. package/docs/screenshots/overview.png +0 -0
  20. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  21. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  22. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  23. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  24. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  25. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  26. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  27. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  28. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  29. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  30. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  31. package/ingest/analyze_ga4.py +79 -0
  32. package/ingest/analyze_gsc.py +136 -0
  33. package/ingest/analyze_metadata.py +158 -0
  34. package/ingest/analyze_trends.py +145 -0
  35. package/ingest/google_auth.py +238 -0
  36. package/ingest/http_util.py +87 -0
  37. package/ingest/pull_ga4.py +107 -0
  38. package/ingest/pull_gsc.py +111 -0
  39. package/ingest/pull_index_status.py +179 -0
  40. package/ingest/pull_timeseries.py +130 -0
  41. package/ingest/seo_config.py +213 -0
  42. package/n-seo.config.example.json +110 -0
  43. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  44. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  45. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  46. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  48. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  49. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  50. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  51. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  52. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  53. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  54. package/ops/daily.py +250 -0
  55. package/ops/daily_diff.py +151 -0
  56. package/ops/demo_data.py +529 -0
  57. package/ops/doctor.py +266 -0
  58. package/ops/export_static.py +125 -0
  59. package/ops/hn_digest.py +169 -0
  60. package/ops/indexnow.py +107 -0
  61. package/ops/install-launchd.sh +76 -0
  62. package/ops/llm.py +139 -0
  63. package/ops/mcp-smoke-stdio.mjs +61 -0
  64. package/ops/opportunity_scan.py +185 -0
  65. package/ops/publish.py +158 -0
  66. package/ops/reddit_digest.py +168 -0
  67. package/ops/templates/n-seo-daily.service +11 -0
  68. package/ops/templates/n-seo-daily.timer +11 -0
  69. package/ops/templates/n-seo-dashboard.service +15 -0
  70. package/ops/templates/n-seo.cron +3 -0
  71. package/ops/templates/n-seo.daily.plist +29 -0
  72. package/ops/templates/n-seo.dashboard.plist +22 -0
  73. package/package.json +77 -0
  74. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
  75. package/probes/site_probe.py +201 -0
  76. package/public/favicon.svg +6 -0
  77. package/public/styles.css +632 -0
  78. package/src/actions.ts +255 -0
  79. package/src/backlog.ts +197 -0
  80. package/src/config.ts +220 -0
  81. package/src/data.ts +895 -0
  82. package/src/insights.ts +22 -0
  83. package/src/mcp-stdio.ts +21 -0
  84. package/src/mcp.ts +490 -0
  85. package/src/server.tsx +260 -0
  86. package/src/settings.tsx +329 -0
  87. package/src/views.tsx +1487 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Pull GA4 data for every configured property into data/ga4/<host>/.
3
+
4
+ Per site (numeric `ga4Property` in the config), trailing 90 days:
5
+ daily.json — sessions/users by date
6
+ sources.json — sessions by sessionSource/sessionMedium (AI-referral detection
7
+ happens downstream from this file)
8
+ landing.json — sessions/engagement by landing page
9
+ funnel.json — only for the `conversions.site`: your key events by date,
10
+ optionally split by a custom dimension (e.g. which app or
11
+ page the signup came from)
12
+
13
+ Auth: analytics.readonly scope via ingest/google_auth.py. Stdlib + curl.
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from datetime import date
19
+
20
+ import google_auth
21
+ import seo_config
22
+ from http_util import post_json
23
+
24
+ API = "https://analyticsdata.googleapis.com/v1beta"
25
+ RANGE = [{"startDate": "90daysAgo", "endDate": "yesterday"}]
26
+
27
+
28
+ def reports(conversions, host):
29
+ base = {
30
+ "daily": {
31
+ "dateRanges": RANGE,
32
+ "dimensions": [{"name": "date"}],
33
+ "metrics": [{"name": "sessions"}, {"name": "totalUsers"}],
34
+ "limit": 100,
35
+ },
36
+ "sources": {
37
+ "dateRanges": RANGE,
38
+ "dimensions": [{"name": "sessionSource"}, {"name": "sessionMedium"}],
39
+ "metrics": [{"name": "sessions"}, {"name": "totalUsers"}],
40
+ "orderBys": [{"metric": {"metricName": "sessions"}, "desc": True}],
41
+ "limit": 200,
42
+ },
43
+ "landing": {
44
+ "dateRanges": RANGE,
45
+ "dimensions": [{"name": "landingPage"}],
46
+ "metrics": [{"name": "sessions"}, {"name": "engagementRate"}],
47
+ "orderBys": [{"metric": {"metricName": "sessions"}, "desc": True}],
48
+ "limit": 100,
49
+ },
50
+ }
51
+ if conversions and conversions.get("site") == host and conversions.get("events"):
52
+ dims = [{"name": "date"}, {"name": "eventName"}]
53
+ if conversions.get("sourceDimension"):
54
+ dims.append({"name": conversions["sourceDimension"]})
55
+ base["funnel"] = {
56
+ "dateRanges": RANGE,
57
+ "dimensions": dims,
58
+ "metrics": [{"name": "eventCount"}],
59
+ "dimensionFilter": {"filter": {"fieldName": "eventName", "inListFilter": {
60
+ "values": list(conversions["events"])}}},
61
+ "limit": 5000,
62
+ }
63
+ return base
64
+
65
+
66
+ def run_report(token, prop, body):
67
+ return post_json(f"{API}/properties/{prop}:runReport", body, token, label=f"ga4 {prop}")
68
+
69
+
70
+ def main():
71
+ props = seo_config.ga4_properties()
72
+ if not props:
73
+ print("no site has a ga4Property configured — nothing to pull")
74
+ return 0
75
+ conversions = seo_config.load()["conversions"]
76
+ token = google_auth.access_token(google_auth.ANALYTICS_RO)
77
+ out_root = seo_config.DATA / "ga4"
78
+
79
+ had_error = False
80
+ for host, prop in props.items():
81
+ site_dir = out_root / host
82
+ site_dir.mkdir(parents=True, exist_ok=True)
83
+ for name, body in reports(conversions, host).items():
84
+ resp = run_report(token, prop, body)
85
+ # A custom dimension only works once registered in GA4 Admin
86
+ # (Custom definitions). Fall back to date+event until then.
87
+ if name == "funnel" and resp.get("error") and len(body["dimensions"]) == 3:
88
+ fallback = dict(body, dimensions=body["dimensions"][:2])
89
+ resp = run_report(token, prop, fallback)
90
+ if resp.get("error"):
91
+ # Keep the previous report rather than replacing it with an
92
+ # empty one; the step fails and the run reports it.
93
+ print(f"{host:28s} {name:8s} ERROR (kept previous): "
94
+ f"{resp['error'].get('message', '')[:60]}")
95
+ had_error = True
96
+ continue
97
+ (site_dir / f"{name}.json").write_text(json.dumps(
98
+ {"site": host, "property": f"properties/{prop}",
99
+ "pulled": date.today().isoformat(), **resp}))
100
+ print(f"{host:28s} {name:8s} {resp.get('rowCount', 0)} rows")
101
+
102
+ print(f"\nSaved under {out_root}")
103
+ return 1 if had_error else 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Pull Search Console data for every configured property into data/gsc/.
3
+
4
+ Datasets per property, two windows each:
5
+ queries.json — by query (what people search)
6
+ pages.json — by page (what lands)
7
+ query_page.json — by query+page (striking-distance analysis)
8
+ dates.json — by date (trend; full window only)
9
+ and *_90d.json twins for the trailing 90 days — the decision window. The
10
+ 16-month window (Search Console's maximum) is for totals and history.
11
+
12
+ Properties come from n-seo.config.json (ingest/seo_config.py); a domain
13
+ property covers its subdomains, so several sites can share one pull.
14
+ Stdlib only; HTTP via curl (see http_util.py).
15
+ """
16
+
17
+ import json
18
+ import sys
19
+ from datetime import date, timedelta
20
+ from urllib.parse import quote
21
+
22
+ import google_auth
23
+ import seo_config
24
+ from http_util import get_json, post_json
25
+
26
+ API = "https://searchconsole.googleapis.com/webmasters/v3/sites"
27
+
28
+ DATASETS = {
29
+ "queries": ["query"],
30
+ "pages": ["page"],
31
+ "query_page": ["query", "page"],
32
+ "dates": ["date"],
33
+ }
34
+
35
+
36
+ def pull(token, site_url, dimensions, start, end):
37
+ rows, start_row = [], 0
38
+ url = f"{API}/{quote(site_url, safe='')}/searchAnalytics/query"
39
+ while True:
40
+ body = {
41
+ "startDate": start, "endDate": end,
42
+ "dimensions": dimensions,
43
+ "rowLimit": 25000, "startRow": start_row,
44
+ "dataState": "final",
45
+ }
46
+ resp = post_json(url, body, token, label=site_url[:60])
47
+ if "error" in resp:
48
+ return {"error": resp["error"], "rows": rows}
49
+ batch = resp.get("rows", [])
50
+ rows.extend(batch)
51
+ if len(batch) < 25000:
52
+ break
53
+ start_row += 25000
54
+ return {"rows": rows}
55
+
56
+
57
+ def main():
58
+ props = seo_config.gsc_properties()
59
+ if not props:
60
+ print("no site has a gscProperty configured — nothing to pull")
61
+ return 0
62
+ end = (date.today() - timedelta(days=3)).isoformat() # GSC finalizes ~3 days behind
63
+ start_full = (date.today() - timedelta(days=488)).isoformat() # ~16 months (GSC max)
64
+ start_recent = (date.today() - timedelta(days=93)).isoformat() # trailing 90d
65
+ token = google_auth.access_token(google_auth.WEBMASTERS_RO)
66
+
67
+ available = {
68
+ e["siteUrl"]
69
+ for e in get_json(API, token, label="sites list").get("siteEntry", [])
70
+ if "siteUrl" in e
71
+ }
72
+ who = google_auth.service_account_email() or "your Google account"
73
+
74
+ out_root = seo_config.DATA / "gsc"
75
+ windows = [("", start_full), ("_90d", start_recent)]
76
+ had_error = False
77
+ for site_url, slug in props.items():
78
+ if site_url not in available:
79
+ print(f"{slug}: {site_url} is not accessible — verify the property in "
80
+ f"Search Console and add {who} as a user; skipping")
81
+ continue
82
+ site_dir = out_root / slug
83
+ site_dir.mkdir(parents=True, exist_ok=True)
84
+ for suffix, start in windows:
85
+ for name, dims in DATASETS.items():
86
+ if suffix and name == "dates":
87
+ continue # the daily series is windowable from the full pull
88
+ result = pull(token, site_url, dims, start, end)
89
+ if "error" in result:
90
+ # Writing zero rows here would read as a traffic collapse
91
+ # on the dashboard tomorrow. Keep yesterday's snapshot and
92
+ # let the step fail so daily.py records and notifies it.
93
+ print(f"{slug:28s} {name}{suffix:5s} ERROR (kept previous): "
94
+ f"{result['error'].get('message', '?')[:70]}")
95
+ had_error = True
96
+ continue
97
+ payload = {
98
+ "site": site_url, "dimensions": dims,
99
+ "startDate": start, "endDate": end,
100
+ "rowCount": len(result["rows"]),
101
+ "rows": result["rows"],
102
+ }
103
+ (site_dir / f"{name}{suffix}.json").write_text(json.dumps(payload))
104
+ print(f"{slug:28s} {name}{suffix:5s} {len(result['rows'])} rows")
105
+
106
+ print(f"\nWindows: {start_full} and {start_recent} -> {end}\nSaved under {out_root}")
107
+ return 1 if had_error else 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Ask Search Console whether each sitemap URL is actually in Google's index.
3
+
4
+ The rest of the pipeline measures pages that already rank. Nothing measures
5
+ the step before that: a page can sit in the sitemap for months in
6
+ "Discovered - currently not indexed" — known to Google, never fetched — and
7
+ look identical to a page nobody searches for, because both show zero
8
+ impressions. The URL Inspection API is the only place that distinction is
9
+ visible, and it decides where the manual "Request Indexing" quota (the
10
+ Search Console UI, roughly 10/day) is worth spending.
11
+
12
+ Quota: 2,000 inspections/day and 600/minute per property, so a full sweep of
13
+ a modest sitemap is affordable daily. Read-only scope. Stdlib + curl.
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from datetime import datetime, timezone
20
+ from urllib.parse import quote
21
+
22
+ import google_auth
23
+ import seo_config
24
+ from http_util import fetch_text, get_json, post_json
25
+
26
+ OUT = seo_config.DATA / "index-status.json"
27
+ INSPECT = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"
28
+ SITES_API = "https://searchconsole.googleapis.com/webmasters/v3/sites"
29
+
30
+ # The URL Inspection quota is per property (2,000/day), and several hosts can
31
+ # share one domain property, so the budget has to be per property too.
32
+ MAX_PER_PROPERTY = 1500
33
+ WORKERS = 4
34
+
35
+ # Anything other than this needs a human to look at it.
36
+ INDEXED = "Submitted and indexed"
37
+
38
+
39
+ def sitemap_urls(host: str) -> list[str]:
40
+ """The site's own sitemap is the list of pages it claims should rank.
41
+ Sitemap indexes are followed one level."""
42
+ status, raw = fetch_text(f"https://{host}/sitemap.xml", timeout=60)
43
+ if status != 200:
44
+ return []
45
+
46
+ def locs(xml):
47
+ out, rest = [], xml
48
+ while "<loc>" in rest:
49
+ _, _, rest = rest.partition("<loc>")
50
+ loc, _, rest = rest.partition("</loc>")
51
+ loc = loc.strip()
52
+ if loc.startswith("http"):
53
+ out.append(loc)
54
+ return out
55
+
56
+ urls = locs(raw)
57
+ if "<sitemapindex" in raw:
58
+ pages = []
59
+ for child in urls[:20]:
60
+ st, body = fetch_text(child, timeout=60)
61
+ if st == 200:
62
+ pages.extend(locs(body))
63
+ urls = pages
64
+ return urls
65
+
66
+
67
+ def inspect(token: str, site_url: str, page_url: str) -> dict:
68
+ resp = post_json(INSPECT, {"inspectionUrl": page_url, "siteUrl": site_url},
69
+ token, label=page_url[-60:])
70
+ if "error" in resp:
71
+ return {"url": page_url, "coverage": "API error",
72
+ "detail": str(resp["error"].get("message", ""))[:120]}
73
+ r = resp.get("inspectionResult", {}).get("indexStatusResult", {})
74
+ canonical = r.get("googleCanonical")
75
+ return {
76
+ "url": page_url,
77
+ "coverage": r.get("coverageState", "unknown"),
78
+ "lastCrawl": r.get("lastCrawlTime"),
79
+ "verdict": r.get("verdict"),
80
+ "robots": r.get("robotsTxtState"),
81
+ # A Google canonical pointing elsewhere is why a page can be crawled
82
+ # and still never appear — worth surfacing, not just the coverage line.
83
+ "canonicalMismatch": bool(canonical and canonical != page_url),
84
+ "googleCanonical": canonical,
85
+ }
86
+
87
+
88
+ def accessible_properties(token: str) -> set[str]:
89
+ """Properties this account can actually inspect. An unverified or
90
+ unshared property answers every inspection with a 403, which would fill
91
+ the report with identical error rows and bury the real findings."""
92
+ resp = get_json(SITES_API, token, label="sites list")
93
+ return {e["siteUrl"] for e in resp.get("siteEntry", []) if "siteUrl" in e}
94
+
95
+
96
+ def sitemap_state(token: str, site_url: str) -> dict:
97
+ """What Search Console has done with the submitted sitemap.
98
+
99
+ The UI shows a bare "Couldn't fetch" for a sitemap Google has simply not
100
+ read yet, which reads as a failure and is not one — the API distinguishes
101
+ them: isPending with errors 0 means queued, a real problem shows up as a
102
+ non-zero error count. Recording both keeps the difference visible."""
103
+ resp = get_json(f"{SITES_API}/{quote(site_url, safe='')}/sitemaps", token,
104
+ label=f"sitemaps {site_url}")
105
+ maps = resp.get("sitemap", []) if isinstance(resp, dict) else []
106
+ return {
107
+ "submitted": len(maps),
108
+ "entries": [
109
+ {
110
+ "path": m.get("path"),
111
+ "lastSubmitted": m.get("lastSubmitted"),
112
+ "lastDownloaded": m.get("lastDownloaded"),
113
+ "pending": bool(m.get("isPending")),
114
+ "errors": int(m.get("errors", 0) or 0),
115
+ "warnings": int(m.get("warnings", 0) or 0),
116
+ }
117
+ for m in maps
118
+ ],
119
+ }
120
+
121
+
122
+ def main() -> int:
123
+ hosts = seo_config.index_hosts()
124
+ if not hosts:
125
+ print("no site has a gscProperty configured — nothing to inspect")
126
+ return 0
127
+ token = google_auth.access_token(google_auth.WEBMASTERS_RO)
128
+ available = accessible_properties(token)
129
+ who = google_auth.service_account_email() or "your Google account"
130
+ out = {"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
131
+ "sites": {}}
132
+ total_problems = 0
133
+ budget: dict[str, int] = {}
134
+
135
+ for host, site_url in hosts.items():
136
+ if site_url not in available:
137
+ print(f"{host}: {site_url} not accessible — verify it in Search Console "
138
+ f"and add {who}; skipping")
139
+ continue
140
+ left = budget.get(site_url, MAX_PER_PROPERTY)
141
+ if left <= 0:
142
+ print(f"{host}: today's inspection budget for {site_url} is spent; skipping")
143
+ continue
144
+ urls = sitemap_urls(host)
145
+ if not urls:
146
+ print(f"{host}: no sitemap URLs, skipping")
147
+ continue
148
+ if len(urls) > left:
149
+ print(f"{host}: {len(urls)} sitemap URLs, inspecting {left} "
150
+ f"(shared quota for {site_url})")
151
+ urls = urls[:left]
152
+ budget[site_url] = left - len(urls)
153
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
154
+ rows = list(pool.map(lambda u: inspect(token, site_url, u), urls))
155
+
156
+ problems = [r for r in rows if r["coverage"] != INDEXED]
157
+ never_crawled = [r for r in problems if not r.get("lastCrawl")]
158
+ out["sites"][host] = {
159
+ "property": site_url,
160
+ "sitemap": sitemap_state(token, site_url),
161
+ "checked": len(rows),
162
+ "indexed": len(rows) - len(problems),
163
+ "neverCrawled": len(never_crawled),
164
+ # Only the problems are stored: the point is the short list a
165
+ # human acts on; hundreds of healthy rows would bury it.
166
+ "problems": sorted(problems, key=lambda r: (bool(r.get("lastCrawl")), r["url"])),
167
+ }
168
+ total_problems += len(problems)
169
+ print(f"{host}: {len(rows)} checked, {len(problems)} not indexed "
170
+ f"({len(never_crawled)} never crawled)")
171
+
172
+ OUT.parent.mkdir(parents=True, exist_ok=True)
173
+ OUT.write_text(json.dumps(out, indent=2))
174
+ print(f"saved {OUT} — {total_problems} URLs needing attention")
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Daily time series for the Trends view.
3
+
4
+ GSC: date x page, trailing 180 days, per property -> data/timeseries/gsc-<slug>.json
5
+ GA4: date x pagePath, 180 days, per site -> data/timeseries/ga4-<host>.json
6
+ GA4: date x source/medium, 180 days, per site -> data/timeseries/ga4-sources-<host>.json
7
+
8
+ The source series is what makes "where is traffic coming from, over time"
9
+ answerable. data/ga4/<host>/sources.json is one 90-day aggregate, so it can
10
+ say AI assistants sent 216 sessions but not whether that is growing.
11
+
12
+ Runs in the daily batch. Stdlib + curl.
13
+ """
14
+
15
+ import json
16
+ import sys
17
+ from datetime import date, timedelta
18
+ from urllib.parse import quote
19
+
20
+ import google_auth
21
+ import seo_config
22
+ from http_util import post_json
23
+
24
+ OUT = seo_config.DATA / "timeseries"
25
+
26
+ # Both APIs cap a single response; page until a batch comes back short.
27
+ PAGE = 25000
28
+
29
+
30
+ def ga4_series(tok, prop, dims, label):
31
+ """Every row of a GA4 date-series report, paged.
32
+
33
+ Returns (rows, ok). On an API error the caller keeps the previous file
34
+ rather than replacing a full series with a partial or empty one.
35
+ """
36
+ rows, offset = [], 0
37
+ while True:
38
+ r = post_json(
39
+ f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
40
+ {"dateRanges": [{"startDate": "180daysAgo", "endDate": "yesterday"}],
41
+ "dimensions": [{"name": d} for d in dims],
42
+ "metrics": [{"name": "sessions"}],
43
+ "limit": PAGE, "offset": offset},
44
+ tok, label=label)
45
+ if r.get("error"):
46
+ print(f"ga4 {label:28s} ERROR (kept previous): "
47
+ f"{r['error'].get('message', '')[:60]}")
48
+ return [], False
49
+ batch = r.get("rows", [])
50
+ rows.extend(batch)
51
+ if len(batch) < PAGE:
52
+ return rows, True
53
+ offset += PAGE
54
+
55
+
56
+ def main():
57
+ OUT.mkdir(parents=True, exist_ok=True)
58
+ had_error = False
59
+ end = (date.today() - timedelta(days=3)).isoformat()
60
+ start = (date.today() - timedelta(days=183)).isoformat()
61
+
62
+ gsc_props = seo_config.gsc_properties()
63
+ if gsc_props:
64
+ tok = google_auth.access_token(google_auth.WEBMASTERS_RO)
65
+ for site, slug in gsc_props.items():
66
+ url = (f"https://searchconsole.googleapis.com/webmasters/v3/sites/"
67
+ f"{quote(site, safe='')}/searchAnalytics/query")
68
+ rows, start_row, failed = [], 0, False
69
+ while True:
70
+ r = post_json(url, {
71
+ "startDate": start, "endDate": end,
72
+ "dimensions": ["date", "page"],
73
+ "rowLimit": PAGE, "startRow": start_row, "dataState": "final"},
74
+ tok, label=f"timeseries {slug}")
75
+ if "error" in r:
76
+ # Keep the previous series rather than replacing a full
77
+ # one with a partial or empty write.
78
+ print(f"gsc {slug:28s} ERROR (kept previous): "
79
+ f"{r['error'].get('message', '')[:70]}")
80
+ had_error = failed = True
81
+ break
82
+ batch = r.get("rows", [])
83
+ rows.extend(batch)
84
+ if len(batch) < PAGE:
85
+ break
86
+ start_row += PAGE
87
+ if failed:
88
+ continue
89
+ (OUT / f"gsc-{slug}.json").write_text(json.dumps(
90
+ {"site": site, "startDate": start, "endDate": end, "rows": rows}))
91
+ print(f"gsc {slug:28s} {len(rows)} date x page rows")
92
+
93
+ ga4_props = seo_config.ga4_properties()
94
+ if ga4_props:
95
+ tok = google_auth.access_token(google_auth.ANALYTICS_RO)
96
+ for host, prop in ga4_props.items():
97
+ # 180 days x a few hundred pages passes 25,000 rows on a modest
98
+ # site, and GA4 truncates silently — ga4_series pages for us.
99
+ batch, ok = ga4_series(tok, prop, ["date", "pagePath"], f"pages {host}")
100
+ if not ok:
101
+ had_error = True
102
+ else:
103
+ rows = [{"date": row["dimensionValues"][0]["value"],
104
+ "page": row["dimensionValues"][1]["value"],
105
+ "sessions": float(row["metricValues"][0]["value"])}
106
+ for row in batch]
107
+ (OUT / f"ga4-{host}.json").write_text(json.dumps({"site": host, "rows": rows}))
108
+ print(f"ga4 {host:28s} {len(rows)} date x page rows")
109
+
110
+ batch, ok = ga4_series(tok, prop, ["date", "sessionSource", "sessionMedium"],
111
+ f"sources {host}")
112
+ if not ok:
113
+ had_error = True
114
+ else:
115
+ rows = [{"date": row["dimensionValues"][0]["value"],
116
+ "source": row["dimensionValues"][1]["value"],
117
+ "medium": row["dimensionValues"][2]["value"],
118
+ "sessions": float(row["metricValues"][0]["value"])}
119
+ for row in batch]
120
+ (OUT / f"ga4-sources-{host}.json").write_text(
121
+ json.dumps({"site": host, "rows": rows}))
122
+ print(f"ga4 {host:28s} {len(rows)} date x source rows")
123
+
124
+ if not gsc_props and not ga4_props:
125
+ print("no GSC or GA4 properties configured — nothing to pull")
126
+ return 1 if had_error else 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())