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