n-seo 0.1.1 → 0.3.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 (61) hide show
  1. package/.claude/skills/n-seo-add-site/SKILL.md +125 -0
  2. package/.claude/skills/n-seo-deploy/SKILL.md +171 -0
  3. package/.claude/skills/n-seo-review/SKILL.md +106 -0
  4. package/.claude/skills/n-seo-setup/SKILL.md +139 -0
  5. package/.claude/skills/n-seo-ship/SKILL.md +122 -0
  6. package/.claude/skills/n-seo-triage/SKILL.md +90 -0
  7. package/.claude/skills/orient/SKILL.md +54 -0
  8. package/README.md +190 -49
  9. package/bin/n-seo.mjs +177 -10
  10. package/docs/ADDING-A-SITE.md +4 -0
  11. package/docs/DEPLOY.md +4 -0
  12. package/docs/FAQ.md +46 -6
  13. package/docs/INSTANCE.md +54 -0
  14. package/docs/MCP.md +25 -9
  15. package/docs/PRD.md +29 -9
  16. package/docs/SCHEDULING.md +46 -5
  17. package/docs/SETUP-GOOGLE.md +8 -1
  18. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  19. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  20. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  21. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  22. package/ingest/analyze_ga4.py +2 -2
  23. package/ingest/analyze_gsc.py +2 -2
  24. package/ingest/analyze_metadata.py +2 -2
  25. package/ingest/analyze_trends.py +2 -2
  26. package/ingest/google_auth.py +53 -20
  27. package/ingest/pull_ga4.py +1 -1
  28. package/ingest/pull_gsc.py +1 -1
  29. package/ingest/pull_index_status.py +1 -1
  30. package/ingest/pull_timeseries.py +3 -3
  31. package/ingest/seo_config.py +19 -3
  32. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  33. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  34. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  35. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  36. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  37. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  38. package/ops/daily.py +2 -2
  39. package/ops/daily_diff.py +9 -8
  40. package/ops/demo_data.py +23 -17
  41. package/ops/doctor.py +30 -7
  42. package/ops/export_static.py +10 -5
  43. package/ops/hn_digest.py +1 -1
  44. package/ops/indexnow.py +3 -3
  45. package/ops/llm.py +23 -1
  46. package/ops/opportunity_scan.py +4 -4
  47. package/ops/py.mjs +40 -0
  48. package/ops/reddit_digest.py +1 -1
  49. package/ops/templates/n-seo-daily-task.xml +65 -0
  50. package/package.json +24 -9
  51. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  52. package/probes/site_probe.py +1 -1
  53. package/public/fonts/OFL.txt +105 -0
  54. package/public/fonts/montserrat-latin-var.woff2 +0 -0
  55. package/public/styles.css +18 -1
  56. package/src/actions.ts +1 -1
  57. package/src/config.ts +1 -1
  58. package/src/mcp.ts +2 -2
  59. package/src/server.tsx +22 -1
  60. package/src/settings.tsx +3 -3
  61. package/src/views.tsx +79 -15
package/ops/doctor.py CHANGED
@@ -64,10 +64,19 @@ def check_config():
64
64
 
65
65
  def check_tools():
66
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}")
67
+ report("OK" if shutil.which("curl") else "FAIL", "curl",
68
+ "" if shutil.which("curl") else "install it — all HTTP goes through curl "
69
+ "(Windows 10+ ships it in System32)")
70
+ # node signs the service-account JWT, so it is required even for someone
71
+ # who never opens the dashboard.
72
+ import google_auth
73
+ node = google_auth.node_bin()
74
+ report("OK" if shutil.which(node) else "FAIL", f"node ({node})",
75
+ "" if shutil.which(node) else "install Node 20+ — it runs the dashboard "
76
+ "and signs the service-account JWT; $NODE overrides the path")
69
77
  v = sys.version_info
70
- report("OK" if v >= (3, 10) else "FAIL", f"python {v.major}.{v.minor}",
78
+ report("OK" if v >= (3, 10) else "FAIL",
79
+ f"python {v.major}.{v.minor} ({Path(sys.executable).name})",
71
80
  "" if v >= (3, 10) else "3.10+ required")
72
81
  nm = seo_config.ROOT / "node_modules"
73
82
  report("OK" if nm.exists() else "WARN", "node_modules", "" if nm.exists() else "run: npm install")
@@ -86,7 +95,7 @@ def check_auth(cfg):
86
95
  f"{kp or '(unset)'} — set google.serviceAccountKey or $GOOGLE_APPLICATION_CREDENTIALS; see docs/SETUP-GOOGLE.md")
87
96
  return None
88
97
  try:
89
- key = json.loads(Path(kp).read_text())
98
+ key = json.loads(Path(kp).read_text(encoding="utf-8"))
90
99
  except (OSError, json.JSONDecodeError) as exc:
91
100
  report("FAIL", "key file unreadable", str(exc))
92
101
  return None
@@ -197,7 +206,6 @@ def check_modules(cfg):
197
206
  report("OK", "enabled: " + (", ".join(on) or "(none beyond defaults)"))
198
207
  llm = cfg["modules"].get("llm", {})
199
208
  if llm.get("enabled"):
200
- import shlex
201
209
  sys.path.insert(0, str(Path(__file__).resolve().parent))
202
210
  import llm as llm_mod
203
211
  http = llm_mod.http_config()
@@ -211,11 +219,17 @@ def check_modules(cfg):
211
219
  f"check provider/model and that {llm['http'].get('apiKeyEnv') or 'apiKeyEnv'} "
212
220
  "is set in the environment or .env")
213
221
  for key in ("command", "fastCommand"):
214
- cmd = shlex.split(str(llm.get(key) or ""))
222
+ raw = str(llm.get(key) or "").strip()
223
+ cmd = llm_mod.split_command(raw)
215
224
  if not cmd:
225
+ if raw:
226
+ # Configured but unparseable — almost always an unbalanced
227
+ # quote. Say so, or the next line reads as "not configured".
228
+ report("FAIL", f"llm.{key} could not be parsed: {raw[:60]}",
229
+ "check for an unbalanced quote")
216
230
  # Only complain about a missing command when there is no http
217
231
  # block at all; a broken one already reported itself.
218
- if key == "command" and not http and not llm.get("http"):
232
+ elif key == "command" and not http and not llm.get("http"):
219
233
  report("FAIL", "llm has neither http nor command configured")
220
234
  continue
221
235
  report("OK" if shutil.which(cmd[0]) else "FAIL", f"llm.{key}: {cmd[0]}",
@@ -259,6 +273,15 @@ def main():
259
273
  check_dashboard()
260
274
  check_modules(cfg)
261
275
  print(f"\n{'all good' if not FAILS else f'{FAILS} problem(s) to fix'}")
276
+ # Most people should not work through the setup docs by hand. If the
277
+ # skills are installed, say so exactly when it is useful: after a report
278
+ # that lists things to fix.
279
+ if FAILS and (seo_config.INSTANCE / ".claude" / "skills" / "n-seo-setup" / "SKILL.md").exists():
280
+ print(
281
+ "\nnot sure what to do with these? this instance ships the fixes as skills.\n"
282
+ f" cd {seo_config.INSTANCE} && claude\n"
283
+ ' "set this up for my sites" → /n-seo-setup works through every item above'
284
+ )
262
285
  return 1 if FAILS else 0
263
286
 
264
287
 
@@ -103,11 +103,16 @@ def main():
103
103
  html = html.replace("</body>", staleness + "</body>", 1)
104
104
  dest = out / "index.html" if route == "/" else out / route.lstrip("/") / "index.html"
105
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")
106
+ dest.write_text(html, encoding="utf-8")
107
+
108
+ (out / "styles.css").write_text(fetch("/styles.css"), encoding="utf-8")
109
+ # styles.css declares @font-face against /fonts/, so the file has to
110
+ # travel with it or every heading in the mirror falls back silently.
111
+ fonts = ROOT / "public" / "fonts"
112
+ if fonts.is_dir():
113
+ shutil.copytree(fonts, out / "fonts")
114
+ (out / "favicon.svg").write_text(fetch("/favicon.svg"), encoding="utf-8")
115
+ (out / "robots.txt").write_text("User-agent: *\nDisallow: /\n", encoding="utf-8")
111
116
  except BaseException:
112
117
  # Never leave a half-built staging directory behind; the next
113
118
  # run would otherwise start from someone else's leftovers.
package/ops/hn_digest.py CHANGED
@@ -157,7 +157,7 @@ def main():
157
157
  seo_config.DATA.mkdir(parents=True, exist_ok=True)
158
158
  (seo_config.DATA / "hn-digest.json").write_text(json.dumps(
159
159
  {"generated": datetime.now(timezone.utc).isoformat(timespec="minutes"),
160
- "stats": stats, "picks": picks}, indent=1))
160
+ "stats": stats, "picks": picks}, indent=1), encoding="utf-8")
161
161
  for p in picks:
162
162
  print(f"- {p['title']} ({p['comments']}c/{p['points']}p) {p['url']} [{p['why']}]")
163
163
  if not picks:
package/ops/indexnow.py CHANGED
@@ -33,12 +33,12 @@ def key_file() -> Path:
33
33
  def cmd_init() -> int:
34
34
  kf = key_file()
35
35
  if kf.exists():
36
- key = kf.read_text().strip()
36
+ key = kf.read_text(encoding="utf-8").strip()
37
37
  print(f"key file already exists: {kf}")
38
38
  else:
39
39
  key = secrets.token_hex(16)
40
40
  kf.parent.mkdir(parents=True, exist_ok=True)
41
- kf.write_text(key + "\n")
41
+ kf.write_text(key + "\n", encoding="utf-8")
42
42
  print(f"wrote {kf}")
43
43
  print(f"\nkey: {key}\n\nServe this key as plain text at, for every site:")
44
44
  for s in seo_config.sites():
@@ -52,7 +52,7 @@ def cmd_ping(urls: list[str]) -> int:
52
52
  if not kf.exists():
53
53
  print(f"no key file at {kf} — run: python3 ops/indexnow.py init")
54
54
  return 1
55
- key = kf.read_text().strip()
55
+ key = kf.read_text(encoding="utf-8").strip()
56
56
  by_host: dict[str, list[str]] = {}
57
57
  for u in urls:
58
58
  host = urlsplit(u).netloc
package/ops/llm.py CHANGED
@@ -28,6 +28,7 @@ path runs. Nothing that comes back is applied automatically: callers turn
28
28
  replies into briefings or proposals a human accepts or ignores.
29
29
  """
30
30
  import json
31
+ import os
31
32
  import shlex
32
33
  import shutil
33
34
  import subprocess
@@ -42,6 +43,27 @@ ANTHROPIC_VERSION = "2023-06-01"
42
43
  MAX_TOKENS = 4096
43
44
 
44
45
 
46
+ def split_command(cmd: str) -> list[str]:
47
+ """Split a configured command string into argv, per platform.
48
+
49
+ POSIX rules treat a backslash as an escape, so on Windows they quietly
50
+ turn `C:\\tools\\claude.exe` into `C:toolsclaude.exe`. There, split with
51
+ the non-POSIX rules cmd.exe uses and strip the quotes shlex leaves on.
52
+
53
+ Returns [] for anything unparseable. This string comes from the Settings
54
+ form, so a stray quote is a typo a user can make, and it must not take
55
+ the daily run down with a ValueError from inside shlex — callers already
56
+ treat [] as "no command configured". doctor tells them which it was.
57
+ """
58
+ try:
59
+ if os.name != "nt":
60
+ return shlex.split(cmd)
61
+ parts = shlex.split(cmd, posix=False)
62
+ except ValueError:
63
+ return []
64
+ return [p[1:-1] if len(p) > 1 and p[0] == p[-1] == '"' else p for p in parts]
65
+
66
+
45
67
  def _enabled() -> dict | None:
46
68
  m = seo_config.module("llm")
47
69
  return m if m.get("enabled") else None
@@ -71,7 +93,7 @@ def command(fast: bool = False) -> list[str] | None:
71
93
  if not m:
72
94
  return None
73
95
  cmd = (m.get("fastCommand") if fast else None) or m.get("command") or ""
74
- parts = shlex.split(str(cmd))
96
+ parts = split_command(str(cmd))
75
97
  if not parts or not shutil.which(parts[0]):
76
98
  return None
77
99
  return parts
@@ -36,7 +36,7 @@ RISE_MIN_RATIO = 2.5 # and have grown at least this much vs prior 84d
36
36
 
37
37
  def latest_trends():
38
38
  files = sorted(DATA.glob("trends-*.json"))
39
- return json.loads(files[-1].read_text()) if files else None
39
+ return json.loads(files[-1].read_text(encoding="utf-8")) if files else None
40
40
 
41
41
 
42
42
  def queue():
@@ -51,7 +51,7 @@ def queue():
51
51
  except json.JSONDecodeError:
52
52
  pass
53
53
  try:
54
- return json.loads((seo_config.INSTANCE / "config" / "backlog.json").read_text()).get("actions", [])
54
+ return json.loads((seo_config.INSTANCE / "config" / "backlog.json").read_text(encoding="utf-8")).get("actions", [])
55
55
  except (OSError, json.JSONDecodeError):
56
56
  return []
57
57
 
@@ -84,7 +84,7 @@ def _host_resolver(prop):
84
84
  best = {}
85
85
  qp = seo_config.DATA / "gsc" / seo_config.gsc_data_slug(prop) / "query_page_90d.json"
86
86
  try:
87
- for r in json.loads(qp.read_text()).get("rows", []):
87
+ for r in json.loads(qp.read_text(encoding="utf-8")).get("rows", []):
88
88
  q, page = r["keys"][0], r["keys"][1]
89
89
  h = page.split("/")[2] if page.count("/") >= 2 else ""
90
90
  if h in by_gsc_host and r["impressions"] > best.get(q, (0, ""))[0]:
@@ -175,7 +175,7 @@ def main():
175
175
  print("llm module off — candidates recorded without proposals")
176
176
 
177
177
  DATA.mkdir(parents=True, exist_ok=True)
178
- (DATA / "opportunity-proposals.json").write_text(json.dumps(out, indent=1))
178
+ (DATA / "opportunity-proposals.json").write_text(json.dumps(out, indent=1), encoding="utf-8")
179
179
  print(f"candidates: {len(candidates)} | proposals: {len(out['proposals'])} | "
180
180
  f"verdicts: {len(out['verdicts'])} | inference: {out['inference_ran']}")
181
181
  return 0
package/ops/py.mjs ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /* Run a Python script with whichever interpreter this machine actually has.
3
+ *
4
+ * The npm scripts used to hardcode `python3`, which does not exist on
5
+ * Windows: Python installs there as `python`, or only behind the `py`
6
+ * launcher. Worse, Windows ships an App Execution Alias at `python3.exe`
7
+ * that opens the Microsoft Store instead of running anything, so a hardcoded
8
+ * `python3` fails in a way that looks like Python is missing when it is not.
9
+ * Probing for one that answers `--version` is the only reliable answer.
10
+ *
11
+ * node ops/py.mjs ops/doctor.py --offline
12
+ *
13
+ * $PYTHON overrides, which is also how the n-seo CLI behaves.
14
+ */
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { pathToFileURL } from "node:url";
17
+
18
+ const CANDIDATES = process.platform === "win32"
19
+ ? ["python", "python3", "py"]
20
+ : ["python3", "python"];
21
+
22
+ export function pythonBin() {
23
+ if (process.env.PYTHON) return process.env.PYTHON;
24
+ for (const c of CANDIDATES) {
25
+ const probe = spawnSync(c, ["--version"], { stdio: "ignore" });
26
+ if (!probe.error && probe.status === 0) return c;
27
+ }
28
+ return CANDIDATES[0]; // let the real command fail with a useful message
29
+ }
30
+
31
+ // Only act as a launcher when invoked directly, not when imported.
32
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
33
+ const child = spawn(pythonBin(), process.argv.slice(2), { stdio: "inherit" });
34
+ child.on("error", (err) => {
35
+ console.error(`could not start ${pythonBin()}: ${err.message}`);
36
+ console.error("install Python 3.10+, or set $PYTHON to its path");
37
+ process.exit(127);
38
+ });
39
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 1));
40
+ }
@@ -151,7 +151,7 @@ def main():
151
151
  seo_config.DATA.mkdir(parents=True, exist_ok=True)
152
152
  (seo_config.DATA / "reddit-digest.json").write_text(json.dumps(
153
153
  {"generated": datetime.now(timezone.utc).isoformat(timespec="minutes"),
154
- "user": user or None, "auth": bool(oauth_token()), "picks": picks}, indent=1))
154
+ "user": user or None, "auth": bool(oauth_token()), "picks": picks}, indent=1), encoding="utf-8")
155
155
  for p in picks:
156
156
  print(f"- r/{p['sub']}: {p['title']} ({p['comments']}c/{p['score']}pts, {p['age_days']}d) {p['url']}")
157
157
  if not picks:
@@ -0,0 +1,65 @@
1
+ <?xml version="1.0" encoding="UTF-16"?>
2
+ <!--
3
+ n-seo daily run at 07:00 local, for Windows Task Scheduler.
4
+
5
+ Register it (from an elevated or normal prompt, your choice — this is a
6
+ per-user task):
7
+
8
+ schtasks /create /tn "n-seo daily" /xml ops\templates\n-seo-daily-task.xml
9
+
10
+ Before you do, replace the two placeholders below:
11
+ __NSEO__ the n-seo command, e.g. C:\Users\you\AppData\Roaming\npm\n-seo.cmd
12
+ (find it with: where n-seo)
13
+ __INSTANCE__ your instance directory, e.g. C:\Users\you\my-sites
14
+
15
+ StartWhenAvailable is the Windows equivalent of launchd's catch-up
16
+ behaviour: a machine that was asleep at 07:00 runs the job when it wakes,
17
+ rather than skipping the day the way cron does.
18
+ -->
19
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
20
+ <RegistrationInfo>
21
+ <Description>n-seo daily run: pulls Search Console and GA4, probes the sites, refreshes the queue.</Description>
22
+ <URI>\n-seo daily</URI>
23
+ </RegistrationInfo>
24
+ <Triggers>
25
+ <CalendarTrigger>
26
+ <StartBoundary>2026-01-01T07:00:00</StartBoundary>
27
+ <Enabled>true</Enabled>
28
+ <ScheduleByDay>
29
+ <DaysInterval>1</DaysInterval>
30
+ </ScheduleByDay>
31
+ </CalendarTrigger>
32
+ </Triggers>
33
+ <Principals>
34
+ <Principal id="Author">
35
+ <LogonType>InteractiveToken</LogonType>
36
+ <RunLevel>LeastPrivilege</RunLevel>
37
+ </Principal>
38
+ </Principals>
39
+ <Settings>
40
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
41
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
42
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
43
+ <AllowHardTerminate>true</AllowHardTerminate>
44
+ <StartWhenAvailable>true</StartWhenAvailable>
45
+ <RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
46
+ <IdleSettings>
47
+ <StopOnIdleEnd>false</StopOnIdleEnd>
48
+ <RestartOnIdle>false</RestartOnIdle>
49
+ </IdleSettings>
50
+ <AllowStartOnDemand>true</AllowStartOnDemand>
51
+ <Enabled>true</Enabled>
52
+ <Hidden>false</Hidden>
53
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
54
+ <WakeToRun>false</WakeToRun>
55
+ <ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
56
+ <Priority>7</Priority>
57
+ </Settings>
58
+ <Actions Context="Author">
59
+ <Exec>
60
+ <Command>__NSEO__</Command>
61
+ <Arguments>daily --instance "__INSTANCE__"</Arguments>
62
+ <WorkingDirectory>__INSTANCE__</WorkingDirectory>
63
+ </Exec>
64
+ </Actions>
65
+ </Task>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.1.1",
4
- "description": "Local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and turns the data into a ranked action queue you can act on without an agency.",
3
+ "version": "0.3.0",
4
+ "description": "Agentic, local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and ranks the next moves. Your LLM writes the proposals; your coding agent works the queue over MCP.",
5
5
  "license": "MIT",
6
6
  "author": "En Dash Consulting (https://endash.us)",
7
7
  "homepage": "https://n-seo.dev/",
@@ -16,12 +16,19 @@
16
16
  "seo",
17
17
  "aeo",
18
18
  "geo",
19
+ "agent",
20
+ "ai-agent",
21
+ "agentic",
22
+ "llm",
23
+ "claude",
24
+ "claude-code",
25
+ "mcp",
26
+ "mcp-server",
19
27
  "search-console",
20
28
  "google-analytics",
21
29
  "ga4",
22
30
  "llms-txt",
23
31
  "ai-crawlers",
24
- "mcp",
25
32
  "dashboard",
26
33
  "local-first",
27
34
  "cli"
@@ -40,6 +47,14 @@
40
47
  "public/",
41
48
  "n-seo.config.example.json",
42
49
  ".env.example",
50
+ ".claude/skills/orient/",
51
+ ".claude/skills/n-seo-setup/",
52
+ ".claude/skills/n-seo-add-site/",
53
+ ".claude/skills/n-seo-triage/",
54
+ ".claude/skills/n-seo-ship/",
55
+ ".claude/skills/n-seo-review/",
56
+ ".claude/skills/n-seo-deploy/",
57
+ "!.claude/skills/README.md",
43
58
  "docs/",
44
59
  "README.md",
45
60
  "LICENSE"
@@ -53,18 +68,18 @@
53
68
  "typecheck": "tsc --noEmit",
54
69
  "mcp": "tsx src/mcp-stdio.ts",
55
70
  "mcp:smoke": "node ops/mcp-smoke-stdio.mjs",
56
- "doctor": "python3 ops/doctor.py",
57
- "demo": "python3 ops/demo_data.py",
58
- "daily": "python3 ops/daily.py",
71
+ "doctor": "node ops/py.mjs ops/doctor.py",
72
+ "demo": "node ops/py.mjs ops/demo_data.py",
73
+ "daily": "node ops/py.mjs ops/daily.py",
59
74
  "test": "tsx --test tests/ts/*.test.ts",
60
- "test:py": "python3 -m unittest discover -s tests/py -v",
75
+ "test:py": "node ops/py.mjs -m unittest discover -s tests/py -v",
61
76
  "check": "npm run typecheck && npm test && npm run test:py",
62
- "export": "python3 ops/export_static.py",
77
+ "export": "node ops/py.mjs ops/export_static.py",
63
78
  "docker:build": "docker build -t n-seo:latest .",
64
79
  "docker:up": "docker compose -f docker/compose.yml up -d"
65
80
  },
66
81
  "dependencies": {
67
- "@hono/node-server": "^1.13.7",
82
+ "@hono/node-server": "^2.1.1",
68
83
  "@modelcontextprotocol/sdk": "^1.30.0",
69
84
  "hono": "^4.6.14",
70
85
  "tsx": "^4.19.2",
@@ -173,7 +173,7 @@ def main():
173
173
  out_dir = seo_config.DATA / "probes"
174
174
  out_dir.mkdir(parents=True, exist_ok=True)
175
175
  out = out_dir / f"probe-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}.json"
176
- out.write_text(json.dumps(snapshot, indent=2))
176
+ out.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
177
177
 
178
178
  for s in snapshot["sites"]:
179
179
  h = s["homepage"]
@@ -0,0 +1,105 @@
1
+ The two fonts in this directory are used under the SIL Open Font License 1.1.
2
+ The full license text follows. Both are redistributed unmodified, as the
3
+ latin-subset variable woff2 files Google Fonts serves.
4
+
5
+ montserrat-latin-var.woff2
6
+ Copyright 2024 The Montserrat Project Authors
7
+ https://github.com/JulietaUla/Montserrat
8
+
9
+ merriweather-latin-var.woff2
10
+ Copyright 2020 The Merriweather Project Authors, with Reserved Font Name
11
+ "Merriweather". https://github.com/EbenSorkin/Merriweather4
12
+
13
+ --------------------------------------------------------------------------
14
+
15
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
16
+ This license is copied below, and is also available with a FAQ at:
17
+ https://openfontlicense.org
18
+
19
+
20
+ -----------------------------------------------------------
21
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
22
+ -----------------------------------------------------------
23
+
24
+ PREAMBLE
25
+ The goals of the Open Font License (OFL) are to stimulate worldwide
26
+ development of collaborative font projects, to support the font creation
27
+ efforts of academic and linguistic communities, and to provide a free and
28
+ open framework in which fonts may be shared and improved in partnership
29
+ with others.
30
+
31
+ The OFL allows the licensed fonts to be used, studied, modified and
32
+ redistributed freely as long as they are not sold by themselves. The
33
+ fonts, including any derivative works, can be bundled, embedded,
34
+ redistributed and/or sold with any software provided that any reserved
35
+ names are not used by derivative works. The fonts and derivatives,
36
+ however, cannot be released under any other type of license. The
37
+ requirement for fonts to remain under this license does not apply
38
+ to any document created using the fonts or their derivatives.
39
+
40
+ DEFINITIONS
41
+ "Font Software" refers to the set of files released by the Copyright
42
+ Holder(s) under this license and clearly marked as such. This may
43
+ include source files, build scripts and documentation.
44
+
45
+ "Reserved Font Name" refers to any names specified as such after the
46
+ copyright statement(s).
47
+
48
+ "Original Version" refers to the collection of Font Software components as
49
+ distributed by the Copyright Holder(s).
50
+
51
+ "Modified Version" refers to any derivative made by adding to, deleting,
52
+ or substituting -- in part or in whole -- any of the components of the
53
+ Original Version, by changing formats or by porting the Font Software to a
54
+ new environment.
55
+
56
+ "Author" refers to any designer, engineer, programmer, technical
57
+ writer or other person who contributed to the Font Software.
58
+
59
+ PERMISSION & CONDITIONS
60
+ Permission is hereby granted, free of charge, to any person obtaining
61
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
62
+ redistribute, and sell modified and unmodified copies of the Font
63
+ Software, subject to the following conditions:
64
+
65
+ 1) Neither the Font Software nor any of its individual components,
66
+ in Original or Modified Versions, may be sold by itself.
67
+
68
+ 2) Original or Modified Versions of the Font Software may be bundled,
69
+ redistributed and/or sold with any software, provided that each copy
70
+ contains the above copyright notice and this license. These can be
71
+ included either as stand-alone text files, human-readable headers or
72
+ in the appropriate machine-readable metadata fields within text or
73
+ binary files as long as those fields can be easily viewed by the user.
74
+
75
+ 3) No Modified Version of the Font Software may use the Reserved Font
76
+ Name(s) unless explicit written permission is granted by the corresponding
77
+ Copyright Holder. This restriction only applies to the primary font name as
78
+ presented to the users.
79
+
80
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
81
+ Software shall not be used to promote, endorse or advertise any
82
+ Modified Version, except to acknowledge the contribution(s) of the
83
+ Copyright Holder(s) and the Author(s) or with their explicit written
84
+ permission.
85
+
86
+ 5) The Font Software, modified or unmodified, in part or in whole,
87
+ must be distributed entirely under this license, and must not be
88
+ distributed under any other license. The requirement for fonts to
89
+ remain under this license does not apply to any document created
90
+ using the Font Software.
91
+
92
+ TERMINATION
93
+ This license becomes null and void if any of the above conditions are
94
+ not met.
95
+
96
+ DISCLAIMER
97
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
98
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
99
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
100
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
101
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
102
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
103
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
104
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
105
+ OTHER DEALINGS IN THE FONT SOFTWARE.
package/public/styles.css CHANGED
@@ -62,6 +62,19 @@
62
62
  }
63
63
  }
64
64
 
65
+ /* Montserrat, self-hosted: one variable file, latin subset, from this origin.
66
+ Headings and the brand mark only — the tables, chips and numbers stay on the
67
+ system UI font, which is what a dense data view is designed around. The
68
+ fallback stack is what renders if the file is missing, so nothing breaks. */
69
+ @font-face {
70
+ font-family: "Montserrat";
71
+ src: url("/fonts/montserrat-latin-var.woff2") format("woff2-variations"),
72
+ url("/fonts/montserrat-latin-var.woff2") format("woff2");
73
+ font-weight: 100 900;
74
+ font-style: normal;
75
+ font-display: swap;
76
+ }
77
+
65
78
  * { box-sizing: border-box; }
66
79
  body {
67
80
  margin: 0;
@@ -72,7 +85,7 @@ body {
72
85
  line-height: 1.55;
73
86
  }
74
87
  main { max-width: 1480px; margin: 0 auto; padding: 28px 24px 80px; }
75
- h1, h2, h3 { line-height: 1.2; letter-spacing: -0.01em; }
88
+ h1, h2, h3 { font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.2; letter-spacing: -0.01em; }
76
89
  h1 { font-size: 1.8rem; font-weight: 800; margin: 0.5em 0; }
77
90
  h2 { font-size: 1.05rem; font-weight: 700; margin: 2em 0 0.6em; text-transform: uppercase; letter-spacing: 0.06em; }
78
91
  h2 small { font-weight: 500; color: var(--muted); font-size: 0.75rem; text-transform: none; letter-spacing: 0; }
@@ -544,6 +557,10 @@ footer { max-width: 1480px; margin: 0 auto; padding: 16px 24px 40px; color: var(
544
557
  .idx-help { color: var(--muted); font-size: 0.82rem; margin-left: 8px; }
545
558
  .idx-stale { color: var(--muted); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
546
559
  .idx-sitemap { margin: -4px 0 8px; font-size: 0.78rem; color: var(--muted); }
560
+ .idx-fix { margin: 6px 0 2px 8px; font-size: 0.8rem; color: var(--muted); line-height: 1.55; max-width: 96ch; }
561
+ .idx-ri { display: inline-block; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; border-radius: 4px; padding: 1px 6px; margin-right: 4px; white-space: nowrap; }
562
+ .idx-ri.yes { background: var(--good-soft); color: var(--good); }
563
+ .idx-ri.no { background: var(--bad-soft); color: var(--bad); }
547
564
 
548
565
  /* ---- settings ---- */
549
566
  .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
package/src/actions.ts CHANGED
@@ -118,7 +118,7 @@ function probeActions(site: SiteCfg): Action[] {
118
118
  const mk = (key: string, title: string, how: string): Action => ({
119
119
  id: `gen-hygiene-${slug(site.host)}-${key}`,
120
120
  host: site.host, title, kind: "Config change", why: "Live probe failing on this check", how,
121
- spec: [how, `Verify with: python3 probes/site_probe.py after deploy.`],
121
+ spec: [how, `Verify with: n-seo daily --only probe after deploy.`],
122
122
  impact: 5, effort: "S", tag: "hygiene", source: "rule",
123
123
  });
124
124
  if (!p.robots.exists) out.push(mk("robots", "Add robots.txt", "Serve robots.txt with a Sitemap: pointer and explicit AI-crawler allowances."));
package/src/config.ts CHANGED
@@ -80,7 +80,7 @@ export interface Hooks {
80
80
  beforeRun: string[];
81
81
  /** … after the last step (and after last-run.json is written) */
82
82
  afterRun: string[];
83
- /** … after a named step, e.g. { "daily-diff": ["python3 my_sync.py"] } */
83
+ /** … after a named step, e.g. { "daily-diff": ["node my_sync.mjs"] } */
84
84
  afterStep: Record<string, string[]>;
85
85
  }
86
86
 
package/src/mcp.ts CHANGED
@@ -273,7 +273,7 @@ export function createMcpServer(): McpServer {
273
273
  },
274
274
  async ({ host }) => {
275
275
  const audit = data.metadataAudit();
276
- if (!audit) return fail("No metadata audit on disk yet — run python3 ingest/analyze_metadata.py.");
276
+ if (!audit) return fail("No metadata audit on disk yet — run `n-seo daily --only metadata-audit`.");
277
277
  if (!host) return json(audit);
278
278
  const findings = audit.sites[host];
279
279
  if (!findings) return fail(`No audit entry for "${host}". Known: ${Object.keys(audit.sites).join(", ")}`);
@@ -379,7 +379,7 @@ export function createMcpServer(): McpServer {
379
379
  },
380
380
  async () => {
381
381
  const scan = data.opportunityScan();
382
- if (!scan) return fail("No opportunity scan on disk yet — run python3 ops/opportunity_scan.py.");
382
+ if (!scan) return fail("No opportunity scan on disk yet — run `n-seo daily --only opportunity-scan`.");
383
383
  return json(scan);
384
384
  }
385
385
  );
package/src/server.tsx CHANGED
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
- import { ROOT, PORT, siteByHost, dotEnv } from "./config.js";
8
+ import { ROOT, INSTANCE, PORT, siteByHost, dotEnv } from "./config.js";
9
9
  import { draftBySlug, campaignBySlug } from "./data.js";
10
10
  import { allActions } from "./actions.js";
11
11
  import { acceptProposal, setWatching, retire } from "./backlog.js";
@@ -94,6 +94,20 @@ app.get("/styles.css", (c) => {
94
94
  return c.text(css, 200, { "Content-Type": "text/css; charset=utf-8" });
95
95
  });
96
96
 
97
+ // The one webfont the dashboard uses, served from this origin. Self-hosting
98
+ // keeps the dashboard free of third-party requests — it renders on machines
99
+ // with no outbound internet, and it never tells Google when someone opens it.
100
+ app.get("/fonts/:file", (c) => {
101
+ const file = c.req.param("file");
102
+ if (!/^[\w.-]+\.woff2$/.test(file)) return c.notFound();
103
+ const abs = path.join(ROOT, "public", "fonts", file);
104
+ if (!fs.existsSync(abs)) return c.notFound();
105
+ return c.body(fs.readFileSync(abs), 200, {
106
+ "Content-Type": "font/woff2",
107
+ "Cache-Control": "public, max-age=31536000, immutable",
108
+ });
109
+ });
110
+
97
111
  app.get("/favicon.svg", (c) => {
98
112
  const svg = fs.readFileSync(path.join(ROOT, "public", "favicon.svg"), "utf8");
99
113
  return c.text(svg, 200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
@@ -257,4 +271,11 @@ app.post("/api/backlog/:id/retire", (c) => {
257
271
  const HOST = process.env.SEO_HOST ?? "127.0.0.1";
258
272
  serve({ fetch: app.fetch, port: PORT, hostname: HOST }, (info) => {
259
273
  console.log(`n-seo → http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${info.port}`);
274
+ // Printed once per start, and only where it is actionable: an instance that
275
+ // has the skills installed. A running dashboard says nothing about how to
276
+ // get the next thing done, and most people should be asking rather than
277
+ // reading.
278
+ if (fs.existsSync(path.join(INSTANCE, ".claude", "skills", "n-seo-triage", "SKILL.md"))) {
279
+ console.log(` ask it: cd ${INSTANCE} && claude → "what should I work on today?"`);
280
+ }
260
281
  });