n-seo 0.1.0 → 0.2.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 (71) hide show
  1. package/README.md +132 -46
  2. package/bin/n-seo.mjs +14 -4
  3. package/docs/FAQ.md +14 -3
  4. package/docs/MCP.md +25 -9
  5. package/docs/PRD.md +6 -4
  6. package/docs/RELEASING.md +21 -0
  7. package/docs/SCHEDULING.md +42 -5
  8. package/docs/SETUP-GOOGLE.md +1 -1
  9. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  10. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  11. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  12. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  13. package/ingest/analyze_ga4.py +2 -2
  14. package/ingest/analyze_gsc.py +2 -2
  15. package/ingest/analyze_metadata.py +2 -2
  16. package/ingest/analyze_trends.py +2 -2
  17. package/ingest/google_auth.py +53 -20
  18. package/ingest/pull_ga4.py +1 -1
  19. package/ingest/pull_gsc.py +1 -1
  20. package/ingest/pull_index_status.py +1 -1
  21. package/ingest/pull_timeseries.py +3 -3
  22. package/ingest/seo_config.py +19 -3
  23. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  24. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  25. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  26. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  27. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  28. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  29. package/ops/daily.py +2 -2
  30. package/ops/daily_diff.py +9 -8
  31. package/ops/demo_data.py +17 -17
  32. package/ops/doctor.py +21 -7
  33. package/ops/export_static.py +4 -4
  34. package/ops/hn_digest.py +1 -1
  35. package/ops/indexnow.py +3 -3
  36. package/ops/llm.py +23 -1
  37. package/ops/opportunity_scan.py +4 -4
  38. package/ops/py.mjs +40 -0
  39. package/ops/reddit_digest.py +1 -1
  40. package/ops/templates/n-seo-daily-task.xml +65 -0
  41. package/package.json +16 -9
  42. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  43. package/probes/site_probe.py +1 -1
  44. package/src/actions.ts +1 -1
  45. package/src/config.ts +1 -1
  46. package/src/mcp.ts +2 -2
  47. package/src/settings.tsx +3 -3
  48. package/src/views.tsx +9 -9
  49. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  50. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  51. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  52. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  53. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  54. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  55. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  56. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  57. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  58. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  59. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  60. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  61. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  62. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  63. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  64. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  65. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  66. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  67. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  68. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  69. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  70. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  71. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
@@ -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,10 +1,10 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.1.0",
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.2.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
- "homepage": "https://en-dash-consulting.github.io/n-seo/",
7
+ "homepage": "https://n-seo.dev/",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/en-dash-consulting/n-seo.git"
@@ -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"
@@ -53,13 +60,13 @@
53
60
  "typecheck": "tsc --noEmit",
54
61
  "mcp": "tsx src/mcp-stdio.ts",
55
62
  "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",
63
+ "doctor": "node ops/py.mjs ops/doctor.py",
64
+ "demo": "node ops/py.mjs ops/demo_data.py",
65
+ "daily": "node ops/py.mjs ops/daily.py",
59
66
  "test": "tsx --test tests/ts/*.test.ts",
60
- "test:py": "python3 -m unittest discover -s tests/py -v",
67
+ "test:py": "node ops/py.mjs -m unittest discover -s tests/py -v",
61
68
  "check": "npm run typecheck && npm test && npm run test:py",
62
- "export": "python3 ops/export_static.py",
69
+ "export": "node ops/py.mjs ops/export_static.py",
63
70
  "docker:build": "docker build -t n-seo:latest .",
64
71
  "docker:up": "docker compose -f docker/compose.yml up -d"
65
72
  },
@@ -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"]
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/settings.tsx CHANGED
@@ -105,7 +105,7 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
105
105
  <div class="s-title">Mode</div>
106
106
  <div class="mono">{cfg.google.auth}</div>
107
107
  <p class="sub">{cfg.google.auth === "service-account-key"
108
- ? "A service-account JSON key, signed locally with openssl. No gcloud needed."
108
+ ? "A service-account JSON key. The JWT is signed locally with node's crypto module — no gcloud, no openssl, nothing to install."
109
109
  : cfg.google.auth === "metadata"
110
110
  ? "The runtime service account from the GCE / Cloud Run / GKE metadata server — no key file. It mints its own scoped tokens, which needs Token Creator on itself."
111
111
  : cfg.google.auth === "gcloud-impersonate"
@@ -122,7 +122,7 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
122
122
  <div class="s-card">
123
123
  <div class="s-title">Grant access to</div>
124
124
  <div class="mono">{sa.email ?? cfg.google.impersonate ?? (cfg.google.auth === "metadata" ? "the runtime service account" : "—")}</div>
125
- <p class="sub">Add this email as a <b>Full</b> user on each Search Console property and a <b>Viewer</b> on each GA4 property. Then <code>python3 ops/doctor.py</code> confirms it can see them.</p>
125
+ <p class="sub">Add this email as a <b>Full</b> user on each Search Console property and a <b>Viewer</b> on each GA4 property. Then <code>n-seo doctor</code> confirms it can see them.</p>
126
126
  </div>
127
127
  </div>
128
128
 
@@ -254,7 +254,7 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
254
254
  <div class="mono">{lr.ts}</div>
255
255
  <p class="sub">{lr.failures.trim() ? <span class="chip bad">failed: {lr.failures}</span> : <span class="chip good">all steps OK</span>}</p>
256
256
  </>
257
- ) : <p class="sub">never — run <code>python3 ops/daily.py</code></p>}
257
+ ) : <p class="sub">never — run <code>n-seo daily</code></p>}
258
258
  </div>
259
259
  <div class="s-card">
260
260
  <div class="s-title">Data freshness</div>
package/src/views.tsx CHANGED
@@ -215,7 +215,7 @@ export const SplitQueue: FC<{ actions: Action[]; idPrefix: string }> = ({ action
215
215
  {active.length ? (
216
216
  <ActionQueue actions={active} showHost idPrefix={`${idPrefix}-a`} />
217
217
  ) : (
218
- <p class="empty">Nothing active. Either the data has no findings yet (run <code>python3 ops/daily.py</code>) or the queue is clear — add strategic items in <code>config/backlog.json</code>.</p>
218
+ <p class="empty">Nothing active. Either the data has no findings yet (run <code>n-seo daily</code>) or the queue is clear — add strategic items in <code>config/backlog.json</code>.</p>
219
219
  )}
220
220
  {watching.length > 0 && (
221
221
  <>
@@ -526,7 +526,7 @@ export const SiteDetail: FC<{ site: SiteCfg; days?: number }> = ({ site, days =
526
526
  ) : (
527
527
  <p class="empty">
528
528
  {site.ga4Property || site.gscProperty
529
- ? <>no time series yet — run <code>python3 ingest/pull_timeseries.py</code></>
529
+ ? <>no time series yet — run <code>n-seo daily --only timeseries</code></>
530
530
  : "no Search Console or GA4 property configured for this site"}
531
531
  </p>
532
532
  )}
@@ -614,11 +614,11 @@ export const SiteDetail: FC<{ site: SiteCfg; days?: number }> = ({ site, days =
614
614
 
615
615
  export const Probes: FC = () => {
616
616
  const probe = data.latestProbe();
617
- if (!probe) return <><h1>Live-site probe</h1><p class="empty">No probe snapshots yet — run <code>python3 probes/site_probe.py</code></p></>;
617
+ if (!probe) return <><h1>Live-site probe</h1><p class="empty">No probe snapshots yet — run <code>n-seo daily --only probe</code></p></>;
618
618
  return (
619
619
  <>
620
620
  <h1>Live-site probe</h1>
621
- <p class="sub">Snapshot {probe.probed_at} · no-auth checks of what crawlers actually see. Re-run any time: <code>python3 probes/site_probe.py</code></p>
621
+ <p class="sub">Snapshot {probe.probed_at} · no-auth checks of what crawlers actually see. Re-run any time: <code>n-seo daily --only probe</code></p>
622
622
  <div class="tbl-wrap"><table>
623
623
  <thead>
624
624
  <tr><th>Site</th><th>robots</th><th>AI crawlers</th><th>sitemap</th><th>llms.txt</th><th>404</th><th>JSON-LD</th><th>visible text</th><th>Title</th></tr>
@@ -704,7 +704,7 @@ export const IndexingPage: FC = () => {
704
704
  <>
705
705
  <h1>Indexing</h1>
706
706
  <p class="empty">
707
- No index snapshot yet — enable the <b>Index coverage sweep</b> module and run <code>python3 ingest/pull_index_status.py</code>
707
+ No index snapshot yet — enable the <b>Index coverage sweep</b> module and run <code>n-seo daily --only index-status</code>
708
708
  </p>
709
709
  </>
710
710
  );
@@ -890,7 +890,7 @@ export const InsightsPage: FC = () => {
890
890
  })()}
891
891
  </>
892
892
  ) : (
893
- <p class="empty">No trend analysis yet — run <code>python3 ingest/analyze_trends.py</code> (the opportunity-scan module does this daily).</p>
893
+ <p class="empty">No trend analysis yet — run <code>n-seo daily --only opportunity-scan</code>, which refreshes it (the daily run does this for you when the module is on).</p>
894
894
  )}
895
895
  </>
896
896
  );
@@ -976,7 +976,7 @@ export const ContentPage: FC = () => {
976
976
  ))}
977
977
  </div>
978
978
  ) : (
979
- <p class="empty">No fresh threads — the daily run refreshes this list (<code>python3 ops/hn_digest.py</code> any time).</p>
979
+ <p class="empty">No fresh threads — the daily run refreshes this list (<code>n-seo daily --only hn-digest</code> any time).</p>
980
980
  )}
981
981
  </>
982
982
  )}
@@ -1216,7 +1216,7 @@ export const LogsPage: FC = () => {
1216
1216
  <h2>{sec.heading}</h2>
1217
1217
  <ul class="log-lines">{sec.lines.map(logLine)}</ul>
1218
1218
  </div>
1219
- )) : <p class="empty">No daily-log entries yet — <code>python3 ops/daily.py</code> writes <code>docs/daily-log.md</code>.</p>}
1219
+ )) : <p class="empty">No daily-log entries yet — <code>n-seo daily</code> writes <code>docs/daily-log.md</code>.</p>}
1220
1220
 
1221
1221
  <h2>Raw run output <small>(data/daily-ops.log, last 150 lines — look here if a run failed)</small></h2>
1222
1222
  <div class="tbl-wrap"><pre class="ops-log">{data.opsLogTail()}</pre></div>
@@ -1403,7 +1403,7 @@ export const TrendsPage: FC<{ days: number }> = ({ days }) => {
1403
1403
  <span class="domain">{site.host}</span>
1404
1404
  <span class="chip">{site.gscProperty ? "Search Console connected" : "no Search Console property"}</span>
1405
1405
  <span class="chip">{site.ga4Property ? "GA4 connected" : "no GA4 property"}</span>
1406
- <span class="trend-empty-note">no time series yet — run <code>python3 ingest/pull_timeseries.py</code></span>
1406
+ <span class="trend-empty-note">no time series yet — run <code>n-seo daily --only timeseries</code></span>
1407
1407
  </div>
1408
1408
  );
1409
1409
  }
Binary file