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,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
|