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
package/ops/doctor.py ADDED
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env python3
2
+ """Setup checker: is this install ready to run?
3
+
4
+ python3 ops/doctor.py # everything, including live Google calls
5
+ python3 ops/doctor.py --offline # local checks only
6
+
7
+ Each check prints OK / WARN / FAIL with a fix hint. Exit code 1 on any FAIL.
8
+ """
9
+
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ import sys
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
+ OFFLINE = "--offline" in sys.argv
20
+ FAILS = 0
21
+
22
+
23
+ def report(level, name, detail=""):
24
+ global FAILS
25
+ if level == "FAIL":
26
+ FAILS += 1
27
+ print(f" {level:4s} {name}" + (f" — {detail}" if detail else ""))
28
+
29
+
30
+ def check_engine():
31
+ e = seo_config.engine_info()
32
+ print("engine")
33
+ report("OK", f"n-seo {e['version']}" + (f" · {e['commit']}" if e["commit"] else ""))
34
+ report("OK", f"mode: {e['mode']}", "" if e["mode"] == "instance"
35
+ else "config, queue and data live inside the engine checkout (see docs/INSTANCE.md to split them)")
36
+ report("OK", f"engine: {e['root']}")
37
+ report("OK", f"instance: {e['instance']}")
38
+
39
+
40
+ def check_config():
41
+ print("config")
42
+ if seo_config.using_example():
43
+ report("WARN", "n-seo.config.json missing",
44
+ "running on the example — copy n-seo.config.example.json to "
45
+ "n-seo.config.json or open /settings in the dashboard")
46
+ else:
47
+ report("OK", f"config at {seo_config.CONFIG_PATH}")
48
+ try:
49
+ cfg = seo_config.load(force=True)
50
+ except json.JSONDecodeError as exc:
51
+ report("FAIL", "config is not valid JSON", str(exc))
52
+ return None
53
+ sites = cfg["sites"]
54
+ if not sites:
55
+ report("FAIL", "no sites configured", "add at least one entry to sites[]")
56
+ else:
57
+ report("OK", f"{len(sites)} site(s): {', '.join(s['host'] for s in sites)}")
58
+ for s in sites:
59
+ if not s.get("gscProperty") and not s.get("ga4Property"):
60
+ report("WARN", f"{s['host']} has neither gscProperty nor ga4Property",
61
+ "it will be probed but have no search or traffic data")
62
+ return cfg
63
+
64
+
65
+ def check_tools():
66
+ print("tools")
67
+ for tool, why in (("curl", "all HTTP goes through curl"), ("openssl", "signs the service-account JWT")):
68
+ report("OK" if shutil.which(tool) else "FAIL", tool, "" if shutil.which(tool) else f"install it — {why}")
69
+ v = sys.version_info
70
+ report("OK" if v >= (3, 10) else "FAIL", f"python {v.major}.{v.minor}",
71
+ "" if v >= (3, 10) else "3.10+ required")
72
+ nm = seo_config.ROOT / "node_modules"
73
+ report("OK" if nm.exists() else "WARN", "node_modules", "" if nm.exists() else "run: npm install")
74
+
75
+
76
+ def check_auth(cfg):
77
+ print("google auth")
78
+ g = cfg["google"]
79
+ mode = g.get("auth", "service-account-key")
80
+ report("OK", f"mode: {mode}")
81
+ import google_auth
82
+ if mode == "service-account-key":
83
+ kp = google_auth.key_path()
84
+ if not kp or not Path(kp).exists():
85
+ report("FAIL", "service-account key file not found",
86
+ f"{kp or '(unset)'} — set google.serviceAccountKey or $GOOGLE_APPLICATION_CREDENTIALS; see docs/SETUP-GOOGLE.md")
87
+ return None
88
+ try:
89
+ key = json.loads(Path(kp).read_text())
90
+ except (OSError, json.JSONDecodeError) as exc:
91
+ report("FAIL", "key file unreadable", str(exc))
92
+ return None
93
+ if not key.get("client_email") or not key.get("private_key"):
94
+ report("FAIL", "key file lacks client_email/private_key", "download a fresh JSON key for the service account")
95
+ return None
96
+ report("OK", f"key for {key['client_email']}")
97
+ elif mode == "metadata":
98
+ # Only meaningful on GCE / Cloud Run / GKE; say so plainly elsewhere
99
+ # rather than leaving a confusing token failure as the only clue.
100
+ email = g.get("impersonate") or google_auth.metadata_service_account()
101
+ if not email:
102
+ report("FAIL", "no metadata server on this machine",
103
+ "google.auth is metadata, which needs GCE, Cloud Run or GKE — "
104
+ "use service-account-key elsewhere; see docs/SETUP-GOOGLE.md")
105
+ return None
106
+ report("OK", f"runtime service account {email}")
107
+ else:
108
+ if not shutil.which("gcloud"):
109
+ report("FAIL", "gcloud not on PATH", "install the Google Cloud SDK or switch to service-account-key")
110
+ return None
111
+ if mode == "gcloud-impersonate" and not g.get("impersonate"):
112
+ report("FAIL", "google.impersonate is empty", "set the service-account email to impersonate")
113
+ return None
114
+ report("OK", "gcloud present")
115
+ if OFFLINE:
116
+ return None
117
+ try:
118
+ tok = google_auth.access_token(google_auth.WEBMASTERS_RO)
119
+ report("OK", "minted a Search Console token")
120
+ return tok
121
+ except Exception as exc: # noqa: BLE001
122
+ report("FAIL", "could not mint a token", str(exc)[:200])
123
+ return None
124
+
125
+
126
+ def check_gsc(cfg, tok):
127
+ print("search console")
128
+ props = seo_config.gsc_properties()
129
+ if not props:
130
+ report("WARN", "no gscProperty configured")
131
+ return
132
+ if OFFLINE or not tok:
133
+ report("WARN", "skipped (offline or no token)")
134
+ return
135
+ from http_util import get_json
136
+ import google_auth
137
+ try:
138
+ resp = get_json("https://searchconsole.googleapis.com/webmasters/v3/sites", tok, label="sites")
139
+ except RuntimeError as exc:
140
+ report("FAIL", "sites list failed", str(exc)[:200])
141
+ return
142
+ have = {e["siteUrl"]: e.get("permissionLevel") for e in resp.get("siteEntry", []) if "siteUrl" in e}
143
+ who = google_auth.service_account_email() or "your account"
144
+ for p in props:
145
+ if p in have:
146
+ report("OK", f"{p} ({have[p]})")
147
+ else:
148
+ report("FAIL", f"{p} not accessible",
149
+ f"in Search Console add {who} as a Full user on that property (or verify the property first)")
150
+
151
+
152
+ def check_ga4(cfg, tok_unused):
153
+ print("ga4")
154
+ props = seo_config.ga4_properties()
155
+ if not props:
156
+ report("WARN", "no ga4Property configured")
157
+ return
158
+ if OFFLINE:
159
+ report("WARN", "skipped (offline)")
160
+ return
161
+ import google_auth
162
+ from http_util import post_json
163
+ try:
164
+ tok = google_auth.access_token(google_auth.ANALYTICS_RO)
165
+ except Exception as exc: # noqa: BLE001
166
+ report("FAIL", "could not mint an Analytics token", str(exc)[:200])
167
+ return
168
+ who = google_auth.service_account_email() or "your account"
169
+ for host, prop in props.items():
170
+ try:
171
+ r = post_json(f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
172
+ {"dateRanges": [{"startDate": "yesterday", "endDate": "yesterday"}],
173
+ "metrics": [{"name": "sessions"}]}, tok, label=f"ga4 {prop}")
174
+ except RuntimeError as exc:
175
+ report("FAIL", f"{host} (properties/{prop})", str(exc)[:200])
176
+ continue
177
+ if r.get("error"):
178
+ report("FAIL", f"{host} (properties/{prop})",
179
+ f"{r['error'].get('message', '')[:120]} — add {who} as Viewer on the GA4 property")
180
+ else:
181
+ report("OK", f"{host} (properties/{prop})")
182
+
183
+
184
+ def check_dashboard():
185
+ print("dashboard")
186
+ if OFFLINE:
187
+ report("WARN", "skipped (offline)")
188
+ return
189
+ base = seo_config.dashboard_base()
190
+ p = subprocess.run(["curl", "-sf", "--max-time", "5", base + "/api/actions"], capture_output=True)
191
+ report("OK" if p.returncode == 0 else "WARN", base, "" if p.returncode == 0 else "not running — npm start")
192
+
193
+
194
+ def check_modules(cfg):
195
+ print("modules")
196
+ on = [k for k, v in cfg["modules"].items() if v.get("enabled")]
197
+ report("OK", "enabled: " + (", ".join(on) or "(none beyond defaults)"))
198
+ llm = cfg["modules"].get("llm", {})
199
+ if llm.get("enabled"):
200
+ import shlex
201
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
202
+ import llm as llm_mod
203
+ http = llm_mod.http_config()
204
+ if http:
205
+ report("OK", f"llm over http: {http['provider']} {http['model']}")
206
+ fast = llm_mod.http_config(fast=True)
207
+ if fast and fast["model"] != http["model"]:
208
+ report("OK", f"llm fast model: {fast['model']}")
209
+ elif isinstance(llm.get("http"), dict) and llm["http"]:
210
+ report("FAIL", "llm.http is configured but unusable",
211
+ f"check provider/model and that {llm['http'].get('apiKeyEnv') or 'apiKeyEnv'} "
212
+ "is set in the environment or .env")
213
+ for key in ("command", "fastCommand"):
214
+ cmd = shlex.split(str(llm.get(key) or ""))
215
+ if not cmd:
216
+ # Only complain about a missing command when there is no http
217
+ # block at all; a broken one already reported itself.
218
+ if key == "command" and not http and not llm.get("http"):
219
+ report("FAIL", "llm has neither http nor command configured")
220
+ continue
221
+ report("OK" if shutil.which(cmd[0]) else "FAIL", f"llm.{key}: {cmd[0]}",
222
+ "" if shutil.which(cmd[0]) else "not on PATH")
223
+ hn = cfg["modules"].get("hackerNews", {})
224
+ if hn.get("enabled"):
225
+ user = (hn.get("user") or "").strip()
226
+ if not user:
227
+ report("WARN", "hackerNews.user empty", "threads you already joined won't be marked")
228
+ elif not OFFLINE:
229
+ from http_util import get_json
230
+ try:
231
+ prof = get_json(f"https://hacker-news.firebaseio.com/v0/user/{user}.json", label="hn user")
232
+ report("OK" if prof else "FAIL", f"HN user {user}", "" if prof else "profile not found")
233
+ except RuntimeError as exc:
234
+ report("WARN", f"HN user {user}", str(exc)[:120])
235
+ if not hn.get("topics"):
236
+ report("WARN", "hackerNews.topics empty", "add [query, why] pairs")
237
+ if not (cfg["participation"] or {}).get("expertise", "").strip():
238
+ report("WARN", "participation.expertise empty", "briefings will be skipped")
239
+ rd = cfg["modules"].get("reddit", {})
240
+ if rd.get("enabled"):
241
+ ok = bool(seo_config.env("REDDIT_CLIENT_ID") and seo_config.env("REDDIT_CLIENT_SECRET"))
242
+ report("OK" if ok else "FAIL", "reddit credentials",
243
+ "" if ok else "REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET missing from .env")
244
+ if cfg["modules"].get("indexNow", {}).get("enabled"):
245
+ kf = cfg["modules"]["indexNow"].get("keyFile") or "indexnow.key"
246
+ p = Path(kf) if Path(kf).is_absolute() else seo_config.INSTANCE / kf
247
+ report("OK" if p.exists() else "WARN", f"indexNow key {kf}", "" if p.exists() else "run: python3 ops/indexnow.py init")
248
+
249
+
250
+ def main():
251
+ check_engine()
252
+ cfg = check_config()
253
+ check_tools()
254
+ if cfg is None:
255
+ return 1
256
+ tok = check_auth(cfg)
257
+ check_gsc(cfg, tok)
258
+ check_ga4(cfg, tok)
259
+ check_dashboard()
260
+ check_modules(cfg)
261
+ print(f"\n{'all good' if not FAILS else f'{FAILS} problem(s) to fix'}")
262
+ return 1 if FAILS else 0
263
+
264
+
265
+ if __name__ == "__main__":
266
+ sys.exit(main())
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Snapshot the running dashboard into site/ as static HTML (modules.staticExport).
3
+
4
+ Host site/ anywhere you like — behind your own login, on an internal box, in
5
+ a private bucket. All data collection stays on the machine that runs the
6
+ daily job; the export is a read-only mirror of what the dashboard showed.
7
+
8
+ Every exported page gets a noindex meta and the export writes a deny-all
9
+ robots.txt: this is an internal ops view, not public content. Pages also
10
+ carry a small script that shows a banner when the mirror is more than 36
11
+ hours old, so a stalled daily job is visible from the mirror itself.
12
+ """
13
+
14
+ import html as html_mod
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
23
+ import seo_config # noqa: E402
24
+
25
+ ROOT = seo_config.ROOT
26
+ INSTANCE = seo_config.INSTANCE
27
+ SITE = INSTANCE / "site"
28
+ BASE = seo_config.dashboard_base()
29
+
30
+
31
+ def routes():
32
+ hosts = seo_config.hosts()
33
+ drafts_dir = INSTANCE / "content" / "drafts"
34
+ camps_dir = INSTANCE / "content" / "campaigns"
35
+ # Same filter as the dashboard's drafts()/campaigns(): README and _-prefixed files are not content.
36
+ skip = lambda p: p.name.lower() == "readme.md" or p.name.startswith("_")
37
+ drafts = sorted(p.stem for p in drafts_dir.glob("*.md") if not skip(p)) if drafts_dir.exists() else []
38
+ camps = sorted(p.stem for p in camps_dir.glob("*.json") if not skip(p)) if camps_dir.exists() else []
39
+ return (["/", "/actions", "/insights", "/trends", "/trends/30", "/trends/60", "/trends/90",
40
+ # /settings is deliberately absent: it shows local paths and the
41
+ # service-account email, and its form cannot work on a static mirror.
42
+ "/trends/120", "/trends/180", "/content", "/indexing", "/probes", "/logs"]
43
+ + [f"/site/{h}" for h in hosts]
44
+ + [f"/drafts/{s}" for s in drafts]
45
+ + [f"/campaigns/{s}" for s in camps])
46
+
47
+
48
+ # hono/jsx renders the slot without whitespace, but match loosely so a
49
+ # future formatting change cannot silently drop the sign-out link.
50
+ EXPORT_SLOT = re.compile(r'<span id="export-slot"\s*>\s*</span>')
51
+
52
+
53
+ def sign_out_link() -> str:
54
+ """The <a> that replaces the export slot, or "" when unconfigured.
55
+
56
+ Any auth proxy will do — IAP, Cloudflare Access, oauth2-proxy — so this
57
+ is just a URL the operator supplies.
58
+ """
59
+ m = seo_config.module("staticExport")
60
+ url = (m.get("signOutUrl") or "").strip()
61
+ if not url:
62
+ return ""
63
+ label = (m.get("signOutLabel") or "Sign out").strip() or "Sign out"
64
+ return (f'<a class="signout" href="{html_mod.escape(url, quote=True)}">'
65
+ f'{html_mod.escape(label)}</a>')
66
+
67
+
68
+ def fetch(path):
69
+ p = subprocess.run(["curl", "-sf", "--max-time", "30", BASE + path],
70
+ capture_output=True, text=True)
71
+ if p.returncode != 0:
72
+ raise RuntimeError(f"fetch failed for {path} — is the dashboard running at {BASE}? (npm start)")
73
+ return p.stdout
74
+
75
+
76
+ def main():
77
+ # Build into a staging directory and swap only once every page has been
78
+ # fetched. Emptying site/ first meant that a dashboard which happened to
79
+ # be down at 07:00 left an empty directory for the afterRun rsync hook to
80
+ # publish over the live mirror.
81
+ out = SITE.with_name(SITE.name + ".new")
82
+ if out.exists():
83
+ shutil.rmtree(out)
84
+ out.mkdir(parents=True)
85
+ try:
86
+
87
+ stamp = datetime.now(timezone.utc).isoformat(timespec="minutes")
88
+ staleness = (
89
+ '<script>(function(){var g=new Date("' + stamp + '");'
90
+ 'var h=(Date.now()-g.getTime())/36e5;'
91
+ 'if(h>36){var b=document.createElement("div");b.className="stale-banner";'
92
+ 'b.textContent="⚠ This mirror is "+Math.round(h)+"h old — the daily run has not published since '
93
+ + stamp + ' UTC. Check the machine that runs it.";'
94
+ 'document.body.prepend(b);}})();</script>'
95
+ )
96
+ signout = sign_out_link()
97
+ rs = routes()
98
+ for route in rs:
99
+ html = fetch(route)
100
+ html = html.replace("<head>", '<head><meta name="robots" content="noindex, nofollow">', 1)
101
+ if signout:
102
+ html = EXPORT_SLOT.sub(signout, html, count=1)
103
+ html = html.replace("</body>", staleness + "</body>", 1)
104
+ dest = out / "index.html" if route == "/" else out / route.lstrip("/") / "index.html"
105
+ dest.parent.mkdir(parents=True, exist_ok=True)
106
+ dest.write_text(html)
107
+
108
+ (out / "styles.css").write_text(fetch("/styles.css"))
109
+ (out / "favicon.svg").write_text(fetch("/favicon.svg"))
110
+ (out / "robots.txt").write_text("User-agent: *\nDisallow: /\n")
111
+ except BaseException:
112
+ # Never leave a half-built staging directory behind; the next
113
+ # run would otherwise start from someone else's leftovers.
114
+ shutil.rmtree(out, ignore_errors=True)
115
+ raise
116
+
117
+ if SITE.exists():
118
+ shutil.rmtree(SITE)
119
+ out.rename(SITE)
120
+ print(f"exported {len(rs)} pages to {SITE}")
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ sys.exit(main())
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ """Daily Hacker News comment-opportunity digest (modules.hackerNews).
3
+
4
+ Finds active HN threads in your expertise areas via the public Algolia API
5
+ and writes the best candidates, each with a short briefing, to
6
+ data/hn-digest.json for the dashboard. The point is to make GENUINE
7
+ participation fast — the comments themselves are yours. This script never
8
+ generates comment text, and the briefing prompt forbids it.
9
+
10
+ Config: modules.hackerNews.topics = [[query, why-you], ...],
11
+ modules.hackerNews.user = your HN username (marks threads you already joined),
12
+ participation.expertise = the persona the briefing is written for.
13
+ Briefings need modules.llm; without it, picks are listed unbriefed.
14
+ """
15
+
16
+ import json
17
+ import re
18
+ import sys
19
+ from datetime import datetime, timedelta, timezone
20
+ from pathlib import Path
21
+ from urllib.parse import quote
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
24
+ import seo_config # noqa: E402
25
+ import llm # noqa: E402
26
+ from http_util import fetch_text, get_json # noqa: E402
27
+
28
+ CUTOFF_HOURS = 48
29
+ MIN_COMMENTS = 0 # early threads are prime commenting real estate
30
+ MAX_COMMENTS = 120 # not already saturated
31
+ MAX_PICKS = 5
32
+
33
+
34
+ def search(query):
35
+ since = int((datetime.now(timezone.utc) - timedelta(hours=CUTOFF_HOURS)).timestamp())
36
+ filters = quote(f"created_at_i>{since},num_comments>={MIN_COMMENTS},num_comments<{MAX_COMMENTS}")
37
+ url = ("https://hn.algolia.com/api/v1/search_by_date?tags=story"
38
+ f"&numericFilters={filters}&hitsPerPage=5&query={quote(query)}")
39
+ try:
40
+ return get_json(url, label="hn search").get("hits", [])
41
+ except RuntimeError:
42
+ return []
43
+
44
+
45
+ def article_text(url, cap=4000):
46
+ if not url:
47
+ return ""
48
+ _, body = fetch_text(url)
49
+ t = re.sub(r"<(script|style|noscript)[^>]*>.*?</\1>", " ", body, flags=re.S | re.I)
50
+ t = re.sub(r"<[^>]+>", " ", t)
51
+ return re.sub(r"\s+", " ", t).strip()[:cap]
52
+
53
+
54
+ def top_comments(object_id, cap=5):
55
+ try:
56
+ kids = get_json(f"https://hn.algolia.com/api/v1/items/{object_id}", label="hn item").get("children", [])[:cap]
57
+ except RuntimeError:
58
+ return ""
59
+ out = []
60
+ for k in kids:
61
+ txt = re.sub(r"<[^>]+>", " ", k.get("text") or "")
62
+ if txt.strip():
63
+ out.append(re.sub(r"\s+", " ", txt).strip()[:400])
64
+ return "\n".join(out)
65
+
66
+
67
+ def briefing(pick, expertise):
68
+ prompt = (
69
+ f"About the reader (first-hand expertise): {expertise}\n\n"
70
+ f"HN story: {pick['title']}\n"
71
+ f"Article excerpt:\n{article_text(pick.get('story_url')) or '(no article text — likely a Show HN app or paywalled)'}\n\n"
72
+ f"Top comments so far:\n{top_comments(pick.get('id')) or '(none yet)'}\n\n"
73
+ "Write a briefing for the reader in EXACTLY this format, one line each:\n"
74
+ "GIST: <one sentence — what the article/story actually says>\n"
75
+ "THREAD: <one sentence — what commenters are focusing on or debating>\n"
76
+ "ANGLE: <1-2 sentences — where the reader's genuine first-hand experience connects, and any of THEIR OWN facts worth citing. If their expertise does not genuinely connect, say 'weak fit — skip unless personally interested.'>\n"
77
+ "Do NOT write any comment text or suggested wording — briefing only."
78
+ )
79
+ out = llm.infer(prompt, fast=True) or ""
80
+ return out if "GIST:" in out else ""
81
+
82
+
83
+ def _fb(path):
84
+ try:
85
+ return get_json(f"https://hacker-news.firebaseio.com/v0/{path}.json", label="hn firebase") or {}
86
+ except RuntimeError:
87
+ return {}
88
+
89
+
90
+ def my_activity(user, recent=30):
91
+ """Story ids the user has commented on + profile stats. Uses the Firebase
92
+ API rather than Algolia's author search, which can lag days behind for
93
+ young accounts. Comments carry only a parent id, so walk up to the story."""
94
+ if not user:
95
+ return set(), None
96
+ profile = _fb(f"user/{user}")
97
+ submitted = profile.get("submitted", []) or []
98
+ story_ids, comment_count = set(), 0
99
+ for item_id in submitted[:recent]:
100
+ it = _fb(f"item/{item_id}")
101
+ if it.get("type") != "comment" or it.get("dead"):
102
+ continue
103
+ comment_count += 1
104
+ cur = it
105
+ for _ in range(8):
106
+ parent = cur.get("parent")
107
+ if parent is None:
108
+ break
109
+ cur = _fb(f"item/{parent}")
110
+ if cur.get("type") == "story":
111
+ story_ids.add(str(parent))
112
+ break
113
+ return story_ids, {"user": user, "karma": profile.get("karma"),
114
+ "created": profile.get("created"), "comments": comment_count}
115
+
116
+
117
+ def main():
118
+ m = seo_config.module("hackerNews")
119
+ if not m.get("enabled"):
120
+ print("hackerNews module disabled (modules.hackerNews.enabled) — skipping")
121
+ return 0
122
+ topics = [t for t in (m.get("topics") or []) if isinstance(t, list) and t and t[0]]
123
+ if not topics:
124
+ print("hackerNews: no topics configured — add [query, why] pairs in Settings")
125
+ return 0
126
+ expertise = (seo_config.load()["participation"] or {}).get("expertise", "").strip()
127
+
128
+ picks, seen = [], set()
129
+ for query, *rest in topics:
130
+ why = rest[0] if rest else ""
131
+ for h in search(query):
132
+ oid = h.get("objectID")
133
+ if oid in seen:
134
+ continue
135
+ seen.add(oid)
136
+ picks.append({
137
+ "id": oid,
138
+ "title": h.get("title", ""),
139
+ "url": f"https://news.ycombinator.com/item?id={oid}",
140
+ "story_url": h.get("url") or "",
141
+ "comments": h.get("num_comments", 0),
142
+ "points": h.get("points", 0),
143
+ "why": why,
144
+ })
145
+ picks.sort(key=lambda p: -((p["points"] or 0) + 2 * (p["comments"] or 0)))
146
+ picks = picks[:MAX_PICKS]
147
+
148
+ commented_ids, stats = my_activity((m.get("user") or "").strip())
149
+ can_brief = llm.available(fast=True) and bool(expertise)
150
+ if picks and not can_brief:
151
+ print("briefings skipped: " + ("participation.expertise is empty" if not expertise
152
+ else "llm module off"))
153
+ for pick in picks:
154
+ pick["commented"] = str(pick.get("id")) in commented_ids
155
+ pick["briefing"] = briefing(pick, expertise) if (can_brief and not pick["commented"]) else ""
156
+
157
+ seo_config.DATA.mkdir(parents=True, exist_ok=True)
158
+ (seo_config.DATA / "hn-digest.json").write_text(json.dumps(
159
+ {"generated": datetime.now(timezone.utc).isoformat(timespec="minutes"),
160
+ "stats": stats, "picks": picks}, indent=1))
161
+ for p in picks:
162
+ print(f"- {p['title']} ({p['comments']}c/{p['points']}p) {p['url']} [{p['why']}]")
163
+ if not picks:
164
+ print("no matching active threads today")
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ sys.exit(main())
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """IndexNow: tell Bing, Copilot, Yandex and friends about changed URLs (modules.indexNow).
3
+
4
+ python3 ops/indexnow.py init # create the key file, print where each site must serve it
5
+ python3 ops/indexnow.py ping URL [URL ...] # submit changed URLs (grouped by host)
6
+
7
+ The key is public by design: every site serves it at https://<host>/<key>.txt
8
+ and that is how IndexNow verifies you own the host. Free, instant, and it
9
+ does nothing for Google (Google's fast lane is Request Indexing in the
10
+ Search Console UI).
11
+ """
12
+
13
+ import json
14
+ import secrets
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from urllib.parse import urlsplit
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
21
+ import seo_config # noqa: E402
22
+
23
+ ENDPOINT = "https://api.indexnow.org/indexnow"
24
+
25
+
26
+ def key_file() -> Path:
27
+ m = seo_config.module("indexNow")
28
+ p = m.get("keyFile") or "indexnow.key"
29
+ path = Path(p)
30
+ return path if path.is_absolute() else seo_config.INSTANCE / path
31
+
32
+
33
+ def cmd_init() -> int:
34
+ kf = key_file()
35
+ if kf.exists():
36
+ key = kf.read_text().strip()
37
+ print(f"key file already exists: {kf}")
38
+ else:
39
+ key = secrets.token_hex(16)
40
+ kf.parent.mkdir(parents=True, exist_ok=True)
41
+ kf.write_text(key + "\n")
42
+ print(f"wrote {kf}")
43
+ print(f"\nkey: {key}\n\nServe this key as plain text at, for every site:")
44
+ for s in seo_config.sites():
45
+ print(f" https://{s['gscHost']}/{key}.txt (content: {key})")
46
+ print("\nThen: python3 ops/indexnow.py ping https://example.com/changed-page")
47
+ return 0
48
+
49
+
50
+ def cmd_ping(urls: list[str]) -> int:
51
+ kf = key_file()
52
+ if not kf.exists():
53
+ print(f"no key file at {kf} — run: python3 ops/indexnow.py init")
54
+ return 1
55
+ key = kf.read_text().strip()
56
+ by_host: dict[str, list[str]] = {}
57
+ for u in urls:
58
+ host = urlsplit(u).netloc
59
+ if not host:
60
+ print(f"skipping non-URL argument: {u}")
61
+ continue
62
+ by_host.setdefault(host, []).append(u)
63
+ if not by_host:
64
+ print("nothing to ping")
65
+ return 1
66
+ rc = 0
67
+ for host, group in by_host.items():
68
+ body = json.dumps({
69
+ "host": host, "key": key,
70
+ "keyLocation": f"https://{host}/{key}.txt",
71
+ "urlList": group,
72
+ })
73
+ # IndexNow answers 200/202 with an EMPTY body, so the status code is
74
+ # the whole answer — plain curl rather than the JSON helper.
75
+ p = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
76
+ "--max-time", "30", "-X", "POST",
77
+ "-H", "Content-Type: application/json; charset=utf-8",
78
+ "-d", body, ENDPOINT], capture_output=True, text=True)
79
+ code = p.stdout.strip()
80
+ if code in ("200", "202"):
81
+ print(f"{host}: {code} — submitted {len(group)} URL(s)")
82
+ else:
83
+ rc = 1
84
+ hint = {"400": "bad request", "403": "key not found at keyLocation — is it served?",
85
+ "422": "URLs don't belong to this host", "429": "too many requests"}.get(code, "")
86
+ print(f"{host}: HTTP {code or 'no response'} {hint}")
87
+ return rc
88
+
89
+
90
+ def main() -> int:
91
+ if not seo_config.enabled("indexNow"):
92
+ print("indexNow module disabled — enable modules.indexNow in Settings or the config file")
93
+ return 1
94
+ args = sys.argv[1:]
95
+ if not args or args[0] not in ("init", "ping"):
96
+ print(__doc__)
97
+ return 2
98
+ if args[0] == "init":
99
+ return cmd_init()
100
+ if len(args) < 2:
101
+ print("ping needs at least one URL")
102
+ return 2
103
+ return cmd_ping(args[1:])
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())