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,158 @@
1
+ #!/usr/bin/env python3
2
+ """Page-level metadata audit: does each ranking page's title/description earn
3
+ its clicks?
4
+
5
+ For every significant page (by impressions, host-filtered), fetches the LIVE
6
+ title + meta description and joins them with the page's top queries from the
7
+ 90-day Search Console pull. Flags: query language missing from the title,
8
+ missing/short/title-duplicating descriptions, bad title lengths, and CTR
9
+ below position expectations (positions 1-15).
10
+
11
+ Writes data/metadata-audit.json — consumed by the dashboard's action queue.
12
+ Stdlib + curl. Runs in the daily batch when modules.metadataAudit is on.
13
+ """
14
+
15
+ import html
16
+ import json
17
+ import re
18
+ import sys
19
+ from datetime import date
20
+
21
+ import seo_config
22
+ from http_util import fetch_text
23
+
24
+ EXPECTED_CTR = {1: .28, 2: .15, 3: .10, 4: .07, 5: .05, 6: .04,
25
+ 7: .035, 8: .03, 9: .026, 10: .022,
26
+ 11: .018, 12: .016, 13: .014, 14: .012, 15: .011}
27
+
28
+ # A ranged request comes back 206 from a server that honours the Range header
29
+ # and 200 from one that ignores it. Both mean the page serves; treating only
30
+ # 200 as healthy would report every page on such a site as dead.
31
+ SERVING = (200, 206)
32
+
33
+ MIN_PAGE_IMPS = 15 # 90d window
34
+ TOP_PAGES_PER_SITE = 15
35
+ STOP = set("a an the and or of to in on for with vs what is how why your our "
36
+ "you we i it its this that de la".split())
37
+
38
+
39
+ def fetch_head(url):
40
+ """(status, title, description) for a live page.
41
+
42
+ The status matters: a page that still ranks but no longer serves must not
43
+ be audited against whatever its 404 page happens to contain. The quote in
44
+ the description pattern is back-referenced so an apostrophe inside the
45
+ text cannot end the match early, and entities are decoded rather than
46
+ blanked, because both mistakes invent findings that are not there.
47
+ """
48
+ status, h = fetch_text(url, byte_range="0-40000")
49
+ title = re.search(r"<title[^>]*>(.*?)</title>", h, re.S | re.I)
50
+ desc = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=(["\'])(.*?)\1', h, re.S | re.I) \
51
+ or re.search(r'<meta[^>]+content=(["\'])(.*?)\1[^>]+name=["\']description["\']', h, re.S | re.I)
52
+ return (status,
53
+ html.unescape(title.group(1)).strip() if title else "",
54
+ html.unescape(desc.group(2)).strip() if desc else "")
55
+
56
+
57
+ def tokens(text):
58
+ return {w for w in re.findall(r"[a-z0-9']+", text.lower()) if w not in STOP and len(w) > 1}
59
+
60
+
61
+ def main():
62
+ qp_cache = {}
63
+ audit = {"generated": date.today().isoformat(), "window": "90d", "sites": {}}
64
+
65
+ for site in seo_config.sites():
66
+ gsc_dir = seo_config.gsc_dir_for(site)
67
+ if not gsc_dir:
68
+ continue
69
+ recent = gsc_dir / "query_page_90d.json"
70
+ gsc_file = recent if recent.exists() else gsc_dir / "query_page.json"
71
+ if not gsc_file.exists():
72
+ continue
73
+ host = site["gscHost"]
74
+ cache_key = str(gsc_file)
75
+ if cache_key not in qp_cache:
76
+ qp_cache[cache_key] = json.loads(gsc_file.read_text())["rows"]
77
+ rows = [r for r in qp_cache[cache_key]
78
+ if r["keys"][1].split("/")[2] == host]
79
+
80
+ by_page = {}
81
+ for r in rows:
82
+ p = by_page.setdefault(r["keys"][1], {"imps": 0, "clicks": 0, "queries": []})
83
+ p["imps"] += r["impressions"]
84
+ p["clicks"] += r["clicks"]
85
+ p["queries"].append({"q": r["keys"][0], "imps": r["impressions"],
86
+ "clicks": r["clicks"], "pos": round(r["position"], 1),
87
+ "ctr": r["ctr"]})
88
+ pages = sorted(((u, d) for u, d in by_page.items() if d["imps"] >= MIN_PAGE_IMPS),
89
+ key=lambda t: -t[1]["imps"])[:TOP_PAGES_PER_SITE]
90
+
91
+ findings = []
92
+ for url, d in pages:
93
+ status, title, desc = fetch_head(url)
94
+ d["queries"].sort(key=lambda q: -q["imps"])
95
+ top_q = d["queries"][:5]
96
+
97
+ if status not in SERVING:
98
+ # It still earns impressions, so it is worth reporting — but a
99
+ # title rewrite is the wrong move and would burn one of the
100
+ # ~8 metadata changes a week on a page that does not serve.
101
+ findings.append({
102
+ "page": url, "title": "", "description": "",
103
+ "imps": round(d["imps"]), "clicks": round(d["clicks"]),
104
+ "issues": [f"page does not serve (HTTP {status or 'no response'}) "
105
+ f"but still ranks — fix, redirect or retire it"],
106
+ "top_queries": top_q, "missed_clicks_window": 0,
107
+ })
108
+ continue
109
+
110
+ t_tokens = tokens(title)
111
+ issues, missed = [], 0.0
112
+
113
+ uncovered = []
114
+ for q in top_q:
115
+ q_tokens = tokens(q["q"])
116
+ if q_tokens and len(q_tokens & t_tokens) / len(q_tokens) < 0.5:
117
+ uncovered.append(q["q"])
118
+ if uncovered and sum(q["imps"] for q in top_q) >= MIN_PAGE_IMPS:
119
+ issues.append(f"title misses ranking-query language: {', '.join(uncovered[:3])}")
120
+
121
+ for q in d["queries"]:
122
+ exp = EXPECTED_CTR.get(round(q["pos"]))
123
+ if exp and q["imps"] >= 20 and q["ctr"] < exp * 0.5:
124
+ missed += q["imps"] * (exp - q["ctr"])
125
+ if missed >= 5:
126
+ issues.append(f"CTR below position expectation (~{missed:.0f} clicks missed in 90d)")
127
+
128
+ if not desc:
129
+ issues.append("meta description missing")
130
+ elif len(desc) < 60:
131
+ issues.append(f"meta description too short ({len(desc)} chars)")
132
+ elif title and desc.lower().startswith(title.lower()[:40]):
133
+ issues.append("meta description duplicates the title")
134
+ if title and len(title) > 65:
135
+ issues.append(f"title long ({len(title)} chars — SERP truncates ~60)")
136
+ if not title:
137
+ issues.append("no <title> found")
138
+
139
+ if issues:
140
+ findings.append({
141
+ "page": url, "title": title[:120], "description": desc[:180],
142
+ "imps": round(d["imps"]), "clicks": round(d["clicks"]),
143
+ "issues": issues, "top_queries": top_q,
144
+ "missed_clicks_window": round(missed),
145
+ })
146
+ findings.sort(key=lambda f: -(f["missed_clicks_window"] + f["imps"] / 50))
147
+ # keyed by the site's canonical host, which is how the dashboard looks it up
148
+ audit["sites"][site["host"]] = findings
149
+ print(f"{site['host']:28s} {len(pages)} pages audited, {len(findings)} with findings")
150
+
151
+ seo_config.DATA.mkdir(parents=True, exist_ok=True)
152
+ (seo_config.DATA / "metadata-audit.json").write_text(json.dumps(audit, indent=1))
153
+ print("saved data/metadata-audit.json")
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """Trend analysis for marketer-grade recommendations.
3
+
4
+ Pulls supplemental windowed data (GSC recent-vs-prior query windows, GA4
5
+ monthly AI-referral series) and computes:
6
+ - branded vs non-branded share (clicks + impressions), using each site's
7
+ `brand` regex from the config (no regex -> everything counts as generic)
8
+ - rising / falling queries (trailing 84d vs prior 84d)
9
+ - monthly clicks/impressions trajectory (from the existing dates.json)
10
+ - AI-referral sessions by month
11
+ Writes data/trends-<date>.json and prints a summary.
12
+ """
13
+
14
+ import json
15
+ import re
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
+ AI_RE = re.compile(r"chatgpt|chat\.openai|openai\.com|perplexity|claude\.ai|copilot|gemini\.google"
25
+ r"|edgeservices|you\.com|poe\.com|phind|kagi|mistral|deepseek", re.I)
26
+
27
+
28
+ PAGE = 25000
29
+
30
+
31
+ def gsc_queries(tok, site, start, end):
32
+ """Every query in the window, paged — a single request caps out and the
33
+ truncation would quietly skew the branded/generic split."""
34
+ url = f"https://searchconsole.googleapis.com/webmasters/v3/sites/{quote(site, safe='')}/searchAnalytics/query"
35
+ rows, start_row = {}, 0
36
+ while True:
37
+ r = post_json(url, {"startDate": start, "endDate": end, "dimensions": ["query"],
38
+ "rowLimit": PAGE, "startRow": start_row, "dataState": "final"},
39
+ tok, label=f"trends {site}")
40
+ batch = r.get("rows", [])
41
+ rows.update({row["keys"][0]: row for row in batch})
42
+ if len(batch) < PAGE:
43
+ return rows
44
+ start_row += PAGE
45
+
46
+
47
+ def main():
48
+ out = {"generated": date.today().isoformat(), "sites": {}}
49
+ gsc_props = seo_config.gsc_properties(include_extra=False)
50
+ brands = {p: re.compile(rx, re.I) for p, rx in seo_config.brand_patterns().items()}
51
+ ga4_props = seo_config.ga4_properties()
52
+
53
+ end = date.today() - timedelta(days=3)
54
+ mid = end - timedelta(days=84)
55
+ start = mid - timedelta(days=84)
56
+ # The windows must not share their boundary day, or it is counted on both
57
+ # sides of every rising/falling comparison.
58
+ prior_end = mid - timedelta(days=1)
59
+
60
+ if gsc_props:
61
+ gsc_tok = google_auth.access_token(google_auth.WEBMASTERS_RO)
62
+ for site, slug in gsc_props.items():
63
+ recent = gsc_queries(gsc_tok, site, mid.isoformat(), end.isoformat())
64
+ prior = gsc_queries(gsc_tok, site, start.isoformat(), prior_end.isoformat())
65
+ brand = brands.get(site)
66
+ is_brand = (lambda q: bool(brand.search(q))) if brand else (lambda q: False)
67
+
68
+ def split(rows):
69
+ return {
70
+ "branded_clicks": sum(r["clicks"] for q, r in rows.items() if is_brand(q)),
71
+ "generic_clicks": sum(r["clicks"] for q, r in rows.items() if not is_brand(q)),
72
+ "branded_imps": sum(r["impressions"] for q, r in rows.items() if is_brand(q)),
73
+ "generic_imps": sum(r["impressions"] for q, r in rows.items() if not is_brand(q)),
74
+ }
75
+
76
+ movers = []
77
+ for q in set(recent) | set(prior):
78
+ ri = recent.get(q, {}).get("impressions", 0)
79
+ pi = prior.get(q, {}).get("impressions", 0)
80
+ if max(ri, pi) >= 30:
81
+ movers.append({"query": q, "recent_imps": ri, "prior_imps": pi,
82
+ "delta": ri - pi,
83
+ "recent_pos": round(recent.get(q, {}).get("position", 0), 1),
84
+ "recent_clicks": recent.get(q, {}).get("clicks", 0)})
85
+ movers.sort(key=lambda m: -abs(m["delta"]))
86
+
87
+ entry = {
88
+ "recent_split": split(recent), "prior_split": split(prior),
89
+ "rising": [m for m in movers if m["delta"] > 0][:20],
90
+ "falling": [m for m in movers if m["delta"] < 0][:15],
91
+ }
92
+ # monthly trajectory from the existing 16-month dates.json
93
+ p = seo_config.DATA / "gsc" / slug / "dates.json"
94
+ if p.exists():
95
+ monthly = {}
96
+ for r in json.loads(p.read_text())["rows"]:
97
+ m = r["keys"][0][:7]
98
+ cur = monthly.setdefault(m, {"clicks": 0, "imps": 0})
99
+ cur["clicks"] += r["clicks"]
100
+ cur["imps"] += r["impressions"]
101
+ entry["monthly"] = monthly
102
+ out["sites"][site] = entry
103
+
104
+ # GA4 AI referrals by month
105
+ if ga4_props:
106
+ ga_tok = google_auth.access_token(google_auth.ANALYTICS_RO)
107
+ for host, prop in ga4_props.items():
108
+ r = post_json(f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport", {
109
+ "dateRanges": [{"startDate": "365daysAgo", "endDate": "yesterday"}],
110
+ "dimensions": [{"name": "yearMonth"}, {"name": "sessionSource"}],
111
+ "metrics": [{"name": "sessions"}], "limit": PAGE}, ga_tok, label=f"ga4 monthly {host}")
112
+ ai_by_month, total_by_month = {}, {}
113
+ for row in r.get("rows", []):
114
+ ym, src = row["dimensionValues"][0]["value"], row["dimensionValues"][1]["value"]
115
+ n = float(row["metricValues"][0]["value"])
116
+ total_by_month[ym] = total_by_month.get(ym, 0) + n
117
+ if AI_RE.search(src):
118
+ ai_by_month[ym] = ai_by_month.get(ym, 0) + n
119
+ out.setdefault("ai_referrals", {})[host] = {
120
+ "ai": dict(sorted(ai_by_month.items())),
121
+ "total": dict(sorted(total_by_month.items()))}
122
+
123
+ seo_config.DATA.mkdir(parents=True, exist_ok=True)
124
+ dest = seo_config.DATA / f"trends-{date.today().isoformat()}.json"
125
+ dest.write_text(json.dumps(out, indent=1))
126
+ print(f"saved {dest}\n")
127
+
128
+ for site, d in out["sites"].items():
129
+ rs, ps = d["recent_split"], d["prior_split"]
130
+ print(f"== {site}")
131
+ print(f" clicks 84d: branded {rs['branded_clicks']:.0f} vs generic {rs['generic_clicks']:.0f}"
132
+ f" (prior: {ps['branded_clicks']:.0f}/{ps['generic_clicks']:.0f})")
133
+ print(f" imps 84d: branded {rs['branded_imps']:.0f} vs generic {rs['generic_imps']:.0f}")
134
+ print(" RISING:", "; ".join(f"{m['query']} ({m['prior_imps']}->{m['recent_imps']}, pos {m['recent_pos']})" for m in d["rising"][:8]))
135
+ print(" FALLING:", "; ".join(f"{m['query']} ({m['prior_imps']}->{m['recent_imps']})" for m in d["falling"][:5]))
136
+ if out.get("ai_referrals"):
137
+ print("\n== AI referrals by month (sessions)")
138
+ for site, d in out["ai_referrals"].items():
139
+ series = ", ".join(f"{k[-2:]}:{v:.0f}" for k, v in list(d["ai"].items())[-6:])
140
+ print(f" {site:28s} {series}")
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ sys.exit(main())
@@ -0,0 +1,238 @@
1
+ """Access tokens for the Google APIs (Search Console, GA4), four ways.
2
+
3
+ service-account-key (default, recommended)
4
+ A service-account JSON key on disk (config google.serviceAccountKey,
5
+ or $GOOGLE_APPLICATION_CREDENTIALS). We build the OAuth JWT ourselves
6
+ and sign it with the `openssl` CLI, so there is no gcloud and no pip
7
+ dependency. Add the service account's email to your Search Console
8
+ property (Full user) and your GA4 property (Viewer) — that is all the
9
+ access it gets.
10
+
11
+ gcloud-impersonate
12
+ `gcloud auth print-access-token --impersonate-service-account=<sa>`.
13
+ For people who already run gcloud and would rather grant themselves
14
+ Token Creator on the SA than keep a key file.
15
+
16
+ gcloud-user
17
+ `gcloud auth print-access-token` with a user login. Only works if that
18
+ login already carries the webmasters/analytics scopes, which Google's
19
+ default gcloud client does not — kept for completeness, not
20
+ recommended.
21
+
22
+ metadata (for GCE / Cloud Run / GKE — no key file at all)
23
+ The runtime service account, taken from the metadata server. Its token
24
+ is `cloud-platform` scoped and Search Console rejects that, so the
25
+ account mints a correctly scoped token for itself through IAM
26
+ Credentials. That self-impersonation needs
27
+ `roles/iam.serviceAccountTokenCreator` on itself; the error says so if
28
+ it is missing. Prefer this over shipping a key file into a container.
29
+
30
+ Scopes are requested per call, minimal: webmasters.readonly for pulls and
31
+ inspection, analytics.readonly for GA4.
32
+ """
33
+ import base64
34
+ import json
35
+ import os
36
+ import subprocess
37
+ import tempfile
38
+ import time
39
+
40
+ import seo_config
41
+ from http_util import curl_json
42
+
43
+ WEBMASTERS_RO = "https://www.googleapis.com/auth/webmasters.readonly"
44
+ WEBMASTERS = "https://www.googleapis.com/auth/webmasters"
45
+ ANALYTICS_RO = "https://www.googleapis.com/auth/analytics.readonly"
46
+
47
+ METADATA_ROOT = "http://metadata.google.internal/computeMetadata/v1"
48
+ IAM_CREDENTIALS = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts"
49
+ # Off GCP the hostname does not resolve, so the probe fails on curl's DNS
50
+ # error rather than waiting; the short timeouts bound the case where some
51
+ # network resolves it to something that then hangs.
52
+ _MD_ARGS = ["--connect-timeout", "1", "-H", "Metadata-Flavor: Google"]
53
+
54
+ _cache: dict[str, tuple[str, float]] = {}
55
+
56
+
57
+ def _b64url(b: bytes) -> str:
58
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
59
+
60
+
61
+ def key_path() -> str | None:
62
+ """The service-account key to sign with.
63
+
64
+ The configured path wins, but the example config ships a default one, so
65
+ a key that is not there must not shadow a working
66
+ GOOGLE_APPLICATION_CREDENTIALS — otherwise the documented env var can
67
+ never take effect on a fresh install.
68
+ """
69
+ g = seo_config.load()["google"]
70
+ configured = g.get("serviceAccountKey")
71
+ env = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
72
+ if configured:
73
+ p = str(seo_config.expand(configured))
74
+ if os.path.exists(p) or not env:
75
+ return p
76
+ return str(seo_config.expand(env)) if env else None
77
+
78
+
79
+ def _sa_key_token(scope: str) -> str:
80
+ kp = key_path()
81
+ if not kp or not os.path.exists(kp):
82
+ raise RuntimeError(
83
+ "google.auth is service-account-key but no key file was found at "
84
+ f"{kp or '(unset)'} — see docs/SETUP-GOOGLE.md")
85
+ key = json.loads(open(kp).read())
86
+ # Backdate slightly: Google rejects a JWT issued in its future, so a
87
+ # machine whose clock runs a few seconds fast otherwise fails to
88
+ # authenticate at all, with an error that names nothing useful.
89
+ iat = int(time.time()) - 60
90
+ header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
91
+ claims = _b64url(json.dumps({
92
+ "iss": key["client_email"], "scope": scope, "aud": key["token_uri"],
93
+ "iat": iat, "exp": iat + 3600,
94
+ }).encode())
95
+ signing_input = f"{header}.{claims}".encode()
96
+ # openssl needs the private key in a file; keep it 0600 and short-lived.
97
+ fd, tmp = tempfile.mkstemp(prefix="n-seo-", suffix=".pem")
98
+ try:
99
+ os.fchmod(fd, 0o600)
100
+ with os.fdopen(fd, "w") as f:
101
+ f.write(key["private_key"])
102
+ sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", tmp],
103
+ input=signing_input, capture_output=True, check=True).stdout
104
+ finally:
105
+ try:
106
+ os.unlink(tmp)
107
+ except OSError:
108
+ pass
109
+ assertion = signing_input.decode() + "." + _b64url(sig)
110
+ resp = curl_json([
111
+ "-X", "POST", "-d", f"grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={assertion}",
112
+ key["token_uri"]], label="token")
113
+ if "access_token" not in resp:
114
+ hint = ""
115
+ if resp.get("error") == "invalid_grant":
116
+ hint = (" — invalid_grant usually means this machine's clock is off, "
117
+ "or the key has been disabled or deleted")
118
+ raise RuntimeError(f"token exchange failed{hint}: {json.dumps(resp)[:300]}")
119
+ return resp["access_token"]
120
+
121
+
122
+ def _metadata_json(path: str, *, timeout: int = 2, attempts: int = 1):
123
+ return curl_json([*_MD_ARGS, f"{METADATA_ROOT}{path}"],
124
+ timeout=timeout, attempts=attempts, label=f"metadata {path}")
125
+
126
+
127
+ def metadata_service_account() -> str | None:
128
+ """The runtime service account's email, or None when not on GCP."""
129
+ try:
130
+ sa = _metadata_json("/instance/service-accounts/default/?recursive=true")
131
+ except (RuntimeError, OSError):
132
+ return None
133
+ return sa.get("email") if isinstance(sa, dict) else None
134
+
135
+
136
+ def metadata_available() -> bool:
137
+ """True on GCE, Cloud Run and GKE. Safe (and quick) to call anywhere."""
138
+ return metadata_service_account() is not None
139
+
140
+
141
+ def _metadata_token(scope: str) -> str:
142
+ """Runtime service account -> a token carrying `scope`.
143
+
144
+ Two steps, because the metadata server only issues `cloud-platform`
145
+ tokens and the Search Console API checks for its own scope: take the
146
+ metadata token, then ask IAM Credentials for a scoped one for the same
147
+ account.
148
+ """
149
+ g = seo_config.load()["google"]
150
+ email = g.get("impersonate") or metadata_service_account()
151
+ if not email:
152
+ raise RuntimeError(
153
+ "google.auth is metadata, but the metadata server did not answer. "
154
+ "That mode only works on GCE, Cloud Run or GKE. Off GCP, use "
155
+ "service-account-key; see docs/SETUP-GOOGLE.md")
156
+ md = _metadata_json("/instance/service-accounts/default/token", timeout=5, attempts=2)
157
+ base = md.get("access_token") if isinstance(md, dict) else None
158
+ if not base:
159
+ raise RuntimeError(f"metadata server returned no access_token: {json.dumps(md)[:200]}")
160
+ resp = curl_json([
161
+ "-X", "POST",
162
+ "-H", f"Authorization: Bearer {base}",
163
+ "-H", "Content-Type: application/json",
164
+ "-d", json.dumps({"scope": [scope], "lifetime": "3600s"}),
165
+ f"{IAM_CREDENTIALS}/{email}:generateAccessToken",
166
+ ], label="generateAccessToken")
167
+ if isinstance(resp, dict) and resp.get("accessToken"):
168
+ return resp["accessToken"]
169
+ err = resp.get("error") if isinstance(resp, dict) else None
170
+ code = int(err.get("code", 0)) if isinstance(err, dict) else 0
171
+ if code in (401, 403):
172
+ raise RuntimeError(
173
+ f"{email} is not allowed to mint tokens for itself. Grant it Token "
174
+ f"Creator on itself:\n"
175
+ f" gcloud iam service-accounts add-iam-policy-binding {email} \\\n"
176
+ f" --member=serviceAccount:{email} \\\n"
177
+ f" --role=roles/iam.serviceAccountTokenCreator\n"
178
+ f"({json.dumps(resp)[:200]})")
179
+ raise RuntimeError(f"generateAccessToken failed: {json.dumps(resp)[:300]}")
180
+
181
+
182
+ def _gcloud_token(scope: str, impersonate: str | None) -> str:
183
+ cmd = ["gcloud", "auth", "print-access-token"]
184
+ if impersonate:
185
+ cmd += [f"--impersonate-service-account={impersonate}", f"--scopes={scope}"]
186
+ p = subprocess.run(cmd, capture_output=True, text=True)
187
+ if p.returncode != 0:
188
+ raise RuntimeError(f"gcloud token failed: {p.stderr.strip()[:200]}")
189
+ return p.stdout.strip()
190
+
191
+
192
+ def access_token(scope: str = WEBMASTERS_RO) -> str:
193
+ """A bearer token for `scope`, cached in-process for ~50 minutes."""
194
+ hit = _cache.get(scope)
195
+ if hit and hit[1] > time.time():
196
+ return hit[0]
197
+ g = seo_config.load()["google"]
198
+ mode = g.get("auth", "service-account-key")
199
+ if mode == "service-account-key":
200
+ tok = _sa_key_token(scope)
201
+ elif mode == "gcloud-impersonate":
202
+ if not g.get("impersonate"):
203
+ raise RuntimeError("google.auth is gcloud-impersonate but google.impersonate is empty")
204
+ tok = _gcloud_token(scope, g["impersonate"])
205
+ elif mode == "gcloud-user":
206
+ tok = _gcloud_token(scope, None)
207
+ elif mode == "metadata":
208
+ tok = _metadata_token(scope)
209
+ else:
210
+ raise RuntimeError(f"unknown google.auth mode {mode!r}")
211
+ _cache[scope] = (tok, time.time() + 50 * 60)
212
+ return tok
213
+
214
+
215
+ def service_account_email() -> str | None:
216
+ """Who to add in the Search Console / GA4 consoles."""
217
+ g = seo_config.load()["google"]
218
+ if g.get("auth") == "gcloud-impersonate":
219
+ return g.get("impersonate") or None
220
+ if g.get("auth") == "metadata":
221
+ return g.get("impersonate") or metadata_service_account()
222
+ kp = key_path()
223
+ if kp and os.path.exists(kp):
224
+ try:
225
+ return json.loads(open(kp).read()).get("client_email")
226
+ except (OSError, json.JSONDecodeError):
227
+ return None
228
+ return None
229
+
230
+
231
+ if __name__ == "__main__":
232
+ # `python3 ingest/google_auth.py` — mint one token and list GSC properties.
233
+ tok = access_token()
234
+ props = curl_json(["-H", f"Authorization: Bearer {tok}",
235
+ "https://searchconsole.googleapis.com/webmasters/v3/sites"], label="sites")
236
+ print("token OK;", service_account_email() or "(user login)")
237
+ for e in props.get("siteEntry", []):
238
+ print(f" {e.get('siteUrl'):45s} {e.get('permissionLevel')}")
@@ -0,0 +1,87 @@
1
+ """HTTP via curl, with retry.
2
+
3
+ Why curl and not urllib: macOS's system Python ships without a CA bundle, so
4
+ urllib fails TLS verification out of the box, and the whole point of this
5
+ pipeline is "clone it and run it" with no pip installs. curl is present on
6
+ every macOS and nearly every Linux box.
7
+
8
+ Why retry: the largest pulls (16 months of query x page) occasionally time
9
+ out or come back 5xx. One blip used to fail the whole step, and the daily run
10
+ would then analyze yesterday's files and write a log entry that looked fine.
11
+ """
12
+ import json
13
+ import subprocess
14
+ import sys
15
+ import time
16
+
17
+ # curl exits worth another try: 6/7 resolve+connect, 28 timeout,
18
+ # 35 TLS handshake, 52 empty reply, 56 recv error.
19
+ RETRYABLE_EXITS = {6, 7, 28, 35, 52, 56}
20
+ TIMEOUT = 180
21
+ ATTEMPTS = 4
22
+
23
+
24
+ def curl_json(args, *, timeout=TIMEOUT, attempts=ATTEMPTS, label=""):
25
+ """Run curl with `args`, parse JSON, retry transient failures.
26
+
27
+ Raises RuntimeError once the attempts are spent, so the calling step
28
+ fails loudly rather than returning partial data.
29
+ """
30
+ last = None
31
+ for attempt in range(1, attempts + 1):
32
+ p = subprocess.run(["curl", "-s", "--max-time", str(timeout), *args],
33
+ capture_output=True, text=True)
34
+ if p.returncode == 0:
35
+ try:
36
+ data = json.loads(p.stdout)
37
+ except json.JSONDecodeError as exc:
38
+ last = f"unparseable response ({exc})"
39
+ else:
40
+ err = data.get("error") if isinstance(data, dict) else None
41
+ # 5xx and 429 are the server's problem and worth retrying;
42
+ # 4xx means the request is wrong and retrying won't fix it.
43
+ code = int(err.get("code", 0)) if isinstance(err, dict) else 0
44
+ if code >= 500 or code == 429:
45
+ last = f"HTTP {code} {str(err.get('message', ''))[:80]}"
46
+ else:
47
+ return data
48
+ elif p.returncode in RETRYABLE_EXITS:
49
+ last = f"curl exit {p.returncode}"
50
+ else:
51
+ raise RuntimeError(f"curl exit {p.returncode}: {p.stderr.strip()[:200]}")
52
+ if attempt < attempts:
53
+ delay = 2 ** attempt # 2s, 4s, 8s
54
+ print(f" retry {attempt}/{attempts - 1} {label}: {last}; sleeping {delay}s",
55
+ file=sys.stderr)
56
+ time.sleep(delay)
57
+ raise RuntimeError(f"{label or 'request'} failed after {attempts} attempts: {last}")
58
+
59
+
60
+ def get_json(url, token=None, *, label=""):
61
+ args = []
62
+ if token:
63
+ args += ["-H", f"Authorization: Bearer {token}"]
64
+ return curl_json([*args, url], label=label or url[-60:])
65
+
66
+
67
+ def post_json(url, body, token=None, *, label=""):
68
+ args = ["-X", "POST", "-H", "Content-Type: application/json"]
69
+ if token:
70
+ args += ["-H", f"Authorization: Bearer {token}"]
71
+ return curl_json([*args, "-d", json.dumps(body), url], label=label or url[-60:])
72
+
73
+
74
+ def fetch_text(url, *, timeout=15, ua="Mozilla/5.0 (compatible; n-seo/0.1)", follow=True,
75
+ byte_range=None):
76
+ """Plain page fetch → (status, body). status is None when curl itself failed."""
77
+ cmd = ["curl", "-s", "-A", ua, "--max-time", str(timeout), "-w", "\n%{http_code}"]
78
+ if follow:
79
+ cmd.append("-L")
80
+ if byte_range:
81
+ cmd += ["-r", byte_range]
82
+ try:
83
+ p = subprocess.run([*cmd, url], capture_output=True, text=True, timeout=timeout + 5)
84
+ except subprocess.TimeoutExpired:
85
+ return None, ""
86
+ body, _, code = p.stdout.rpartition("\n")
87
+ return (int(code) if code.isdigit() else None), body