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,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Post-refresh daily diff: appends a dated entry to docs/daily-log.md.
|
|
3
|
+
|
|
4
|
+
Reports, per day: probe health (with ALERT lines for checks that regressed
|
|
5
|
+
since the previous snapshot), the Search Console numbers for every URL in
|
|
6
|
+
config `watchPages` (shipped work being measured), your conversion events
|
|
7
|
+
if `conversions` is configured, and cross-referrals between your own sites.
|
|
8
|
+
Pure stdlib; runs unattended from ops/daily.py.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import date
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
|
|
17
|
+
import seo_config # noqa: E402
|
|
18
|
+
|
|
19
|
+
ROOT = seo_config.ROOT
|
|
20
|
+
INSTANCE = seo_config.INSTANCE
|
|
21
|
+
DATA = seo_config.DATA
|
|
22
|
+
|
|
23
|
+
PROBE_KEYS = [
|
|
24
|
+
("robots.txt", lambda s: s["robots"].get("exists")),
|
|
25
|
+
("sitemap.xml", lambda s: s["sitemap"].get("exists")),
|
|
26
|
+
("llms.txt", lambda s: s["llms.txt"].get("exists")),
|
|
27
|
+
("real-404", lambda s: s["soft_404"].get("real_404")),
|
|
28
|
+
("homepage-200", lambda s: s["homepage"].get("status") == 200),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def probes():
|
|
33
|
+
files = sorted((DATA / "probes").glob("probe-*.json"))
|
|
34
|
+
return [json.loads(f.read_text()) for f in files[-2:]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def watch_groups():
|
|
38
|
+
"""{gsc slug -> [watch URLs]} — each watched URL is matched to the site
|
|
39
|
+
whose host (or gscHost) it belongs to, then to that site's property."""
|
|
40
|
+
by_host = {}
|
|
41
|
+
for s in seo_config.sites():
|
|
42
|
+
if s.get("gscProperty"):
|
|
43
|
+
slug = seo_config.gsc_data_slug(s["gscProperty"])
|
|
44
|
+
by_host[s["host"]] = slug
|
|
45
|
+
by_host[s["gscHost"]] = slug
|
|
46
|
+
groups = {}
|
|
47
|
+
for url in seo_config.load()["watchPages"]:
|
|
48
|
+
host = url.split("/")[2] if "//" in url else ""
|
|
49
|
+
slug = by_host.get(host)
|
|
50
|
+
if slug:
|
|
51
|
+
groups.setdefault(slug, []).append(url)
|
|
52
|
+
return groups
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
lines, alerts = [], []
|
|
57
|
+
|
|
58
|
+
snaps = probes()
|
|
59
|
+
if len(snaps) == 2:
|
|
60
|
+
prev = {s["site"]: s for s in snaps[0]["sites"]}
|
|
61
|
+
for cur in snaps[1]["sites"]:
|
|
62
|
+
old = prev.get(cur["site"])
|
|
63
|
+
if not old:
|
|
64
|
+
continue
|
|
65
|
+
for name, get in PROBE_KEYS:
|
|
66
|
+
if get(old) and not get(cur):
|
|
67
|
+
alerts.append(f"{cur['site']}: {name} REGRESSED (was OK, now failing)")
|
|
68
|
+
if snaps:
|
|
69
|
+
latest = snaps[-1]
|
|
70
|
+
ok = sum(1 for s in latest["sites"] if all(get(s) for _, get in PROBE_KEYS))
|
|
71
|
+
lines.append(f"probe: {ok}/{len(latest['sites'])} sites fully healthy")
|
|
72
|
+
|
|
73
|
+
for slug, urls in watch_groups().items():
|
|
74
|
+
f90 = DATA / "gsc" / slug / "pages_90d.json"
|
|
75
|
+
f = f90 if f90.exists() else DATA / "gsc" / slug / "pages.json"
|
|
76
|
+
if not f.exists():
|
|
77
|
+
continue
|
|
78
|
+
win = "90d" if f90.exists() else "16mo"
|
|
79
|
+
by_url = {r["keys"][0]: r for r in json.loads(f.read_text())["rows"]}
|
|
80
|
+
for url in urls:
|
|
81
|
+
r = by_url.get(url) or by_url.get(url.rstrip("/")) or by_url.get(url + "/")
|
|
82
|
+
label = url.split("//", 1)[-1]
|
|
83
|
+
if r:
|
|
84
|
+
lines.append(
|
|
85
|
+
f"{label}: {r['clicks']:.0f} clicks / {r['impressions']:.0f} imps"
|
|
86
|
+
f" / CTR {100 * r['ctr']:.2f}% / pos {r['position']:.1f} ({win})")
|
|
87
|
+
else:
|
|
88
|
+
lines.append(f"{label}: no impressions yet ({win})")
|
|
89
|
+
|
|
90
|
+
conv = seo_config.load()["conversions"]
|
|
91
|
+
if conv:
|
|
92
|
+
try:
|
|
93
|
+
fr = json.loads((DATA / "ga4" / conv["site"] / "funnel.json").read_text())
|
|
94
|
+
rows = fr.get("rows", [])
|
|
95
|
+
if rows:
|
|
96
|
+
counts = {}
|
|
97
|
+
for r in rows:
|
|
98
|
+
ev = r["dimensionValues"][1]["value"]
|
|
99
|
+
counts[ev] = counts.get(ev, 0) + float(r["metricValues"][0]["value"])
|
|
100
|
+
lines.append("CONVERSIONS: " + ", ".join(f"{k}={v:.0f}" for k, v in counts.items()) + " (90d)")
|
|
101
|
+
else:
|
|
102
|
+
lines.append("conversions: no events yet (instrumentation pending or zero conversions)")
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# cross-referrals: sessions one of your sites sends to another
|
|
107
|
+
hosts = seo_config.hosts()
|
|
108
|
+
for host in hosts:
|
|
109
|
+
p = DATA / "ga4" / host / "sources.json"
|
|
110
|
+
if not p.exists():
|
|
111
|
+
continue
|
|
112
|
+
# exact host match (www stripped) — a substring test would count a
|
|
113
|
+
# subdomain's own traffic as a referral from the apex
|
|
114
|
+
others = {h.removeprefix("www.") for h in hosts if h != host}
|
|
115
|
+
xref = []
|
|
116
|
+
for r in json.loads(p.read_text()).get("rows", []):
|
|
117
|
+
name = r["dimensionValues"][0]["value"]
|
|
118
|
+
if name.lower().removeprefix("www.") in others:
|
|
119
|
+
xref.append(f"{name}={float(r['metricValues'][0]['value']):.0f}")
|
|
120
|
+
if xref:
|
|
121
|
+
lines.append(f"cross-referrals into {host} (90d sessions): " + ", ".join(xref))
|
|
122
|
+
|
|
123
|
+
log = INSTANCE / "docs" / "daily-log.md"
|
|
124
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if not log.exists():
|
|
126
|
+
log.write_text("# Daily ops log\n\nAppended by ops/daily.py — newest entries last.\n")
|
|
127
|
+
today = date.today().isoformat()
|
|
128
|
+
entry = [f"\n## {today}\n"]
|
|
129
|
+
entry += [f"- **ALERT:** {a}" for a in alerts]
|
|
130
|
+
entry += [f"- {l}" for l in lines]
|
|
131
|
+
body = "\n".join(entry) + "\n"
|
|
132
|
+
|
|
133
|
+
# Re-running on the same day must replace today's entry, not stack a
|
|
134
|
+
# second one under the same heading.
|
|
135
|
+
text = log.read_text()
|
|
136
|
+
head = f"\n## {today}\n"
|
|
137
|
+
start = text.find(head)
|
|
138
|
+
if start == -1:
|
|
139
|
+
log.write_text(text.rstrip("\n") + "\n" + body)
|
|
140
|
+
else:
|
|
141
|
+
nxt = text.find("\n## ", start + len(head))
|
|
142
|
+
tail = text[nxt:] if nxt != -1 else ""
|
|
143
|
+
log.write_text(text[:start].rstrip("\n") + "\n" + body + tail.lstrip("\n"))
|
|
144
|
+
for a in alerts:
|
|
145
|
+
print(f"ALERT: {a}")
|
|
146
|
+
print(f"daily-log updated ({len(alerts)} alerts, {len(lines)} lines)")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
sys.exit(main())
|