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
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())
|
package/ops/hn_digest.py
ADDED
|
@@ -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())
|
package/ops/indexnow.py
ADDED
|
@@ -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())
|