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.
- package/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/n-seo.mjs +310 -0
- package/docs/ADDING-A-SITE.md +82 -0
- package/docs/ARCHITECTURE.md +213 -0
- package/docs/DEPLOY.md +300 -0
- package/docs/FAQ.md +93 -0
- package/docs/INSTANCE.md +365 -0
- package/docs/MCP.md +104 -0
- package/docs/OPERATING-RULES.md +106 -0
- package/docs/PLAYBOOK.md +122 -0
- package/docs/PRD.md +249 -0
- package/docs/RELEASING.md +189 -0
- package/docs/SCHEDULING.md +104 -0
- package/docs/SETUP-GOOGLE.md +215 -0
- package/docs/examples/campaign.json +59 -0
- package/docs/examples/draft.md +43 -0
- package/docs/screenshots/overview.png +0 -0
- package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
- package/ingest/analyze_ga4.py +79 -0
- package/ingest/analyze_gsc.py +136 -0
- package/ingest/analyze_metadata.py +158 -0
- package/ingest/analyze_trends.py +145 -0
- package/ingest/google_auth.py +238 -0
- package/ingest/http_util.py +87 -0
- package/ingest/pull_ga4.py +107 -0
- package/ingest/pull_gsc.py +111 -0
- package/ingest/pull_index_status.py +179 -0
- package/ingest/pull_timeseries.py +130 -0
- package/ingest/seo_config.py +213 -0
- package/n-seo.config.example.json +110 -0
- package/ops/__pycache__/daily.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
- package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
- package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
- package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
- package/ops/__pycache__/llm.cpython-313.pyc +0 -0
- package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
- package/ops/__pycache__/publish.cpython-313.pyc +0 -0
- package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
- package/ops/daily.py +250 -0
- package/ops/daily_diff.py +151 -0
- package/ops/demo_data.py +529 -0
- package/ops/doctor.py +266 -0
- package/ops/export_static.py +125 -0
- package/ops/hn_digest.py +169 -0
- package/ops/indexnow.py +107 -0
- package/ops/install-launchd.sh +76 -0
- package/ops/llm.py +139 -0
- package/ops/mcp-smoke-stdio.mjs +61 -0
- package/ops/opportunity_scan.py +185 -0
- package/ops/publish.py +158 -0
- package/ops/reddit_digest.py +168 -0
- package/ops/templates/n-seo-daily.service +11 -0
- package/ops/templates/n-seo-daily.timer +11 -0
- package/ops/templates/n-seo-dashboard.service +15 -0
- package/ops/templates/n-seo.cron +3 -0
- package/ops/templates/n-seo.daily.plist +29 -0
- package/ops/templates/n-seo.dashboard.plist +22 -0
- package/package.json +77 -0
- package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
- package/probes/site_probe.py +201 -0
- package/public/favicon.svg +6 -0
- package/public/styles.css +632 -0
- package/src/actions.ts +255 -0
- package/src/backlog.ts +197 -0
- package/src/config.ts +220 -0
- package/src/data.ts +895 -0
- package/src/insights.ts +22 -0
- package/src/mcp-stdio.ts +21 -0
- package/src/mcp.ts +490 -0
- package/src/server.tsx +260 -0
- package/src/settings.tsx +329 -0
- package/src/views.tsx +1487 -0
- 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())
|