n-seo 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -8,6 +8,57 @@ uses [Semantic Versioning](https://semver.org/).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.7.0] - 2026-09-18
12
+
13
+ ### Added
14
+ - **A site's indexing problems, on its own page.** `/site/<host>` said nothing
15
+ about indexing, so anyone looking at one site had to leave for `/indexing`
16
+ and find their host in a list — while the data was already keyed by host and
17
+ already rendered per host one page over. The site page now carries that
18
+ host's coverage: indexed against checked, problems grouped by coverage
19
+ state, never-crawled. The explanations and the Request Indexing verdict come
20
+ with it, because both pages now render from one component and cannot drift
21
+ into disagreeing about what Google said. No new pull and no new data file.
22
+ - A host that is all clear, or that has no sitemap Search Console can fetch,
23
+ says which rather than rendering an empty panel. An empty panel is
24
+ indistinguishable from a broken one.
25
+ - **Update checking is documented where people schedule things.**
26
+ `update-check` has been a step in the daily run since 0.4.1 and
27
+ `modules.updateCheck` is the only module enabled by default, but nothing
28
+ said so in `docs/SCHEDULING.md` — the one place someone would otherwise
29
+ conclude they need a second scheduled job for it. They do not: a weekly
30
+ `n-seo upgrade --check` would ask the registry the same question the daily
31
+ run already asks and write the answer to a log nobody opens. The Upgrades
32
+ section of `docs/DEPLOY.md` says the same for a deployed instance, where
33
+ nobody is watching a terminal and the notice waits on the dashboard.
34
+
35
+ ### Fixed
36
+ - **The package no longer ships Python bytecode.** `files` lists `ops/`,
37
+ `ingest/` and `probes/` wholesale, and whatever ran the tests before
38
+ packing left `__pycache__` beside the sources — so a release built on CI
39
+ carried the runner's `.pyc` files. 0.6.1 shipped thirteen of them, a little
40
+ over a tenth of the package, compiled for a Python version no user is
41
+ guaranteed to have. `.gitignore` covers `__pycache__`, which is why this was
42
+ invisible in a checkout and only appeared in the tarball.
43
+
44
+ ## [0.6.1] - 2026-09-15
45
+
46
+ ### Fixed
47
+ - **The daily log threw away the reason a model call failed.** The llm module
48
+ reported `stderr` only, but a CLI may put its failure on stdout — `claude`
49
+ exits 1 and writes "Failed to authenticate" there — so the log read
50
+ `llm: exit 1:` with nothing after the colon. Every call, cause discarded.
51
+ It now falls back to stdout. This is the bug that matters here: it turned a
52
+ one-line diagnosis into a long one, and produced a confident wrong answer
53
+ along the way.
54
+ - **`doctor` reported a model command as healthy when it could not run.** The
55
+ check was `shutil.which`: the file exists, therefore fine. A revoked key, a
56
+ lapsed subscription or an unreadable credential store all leave the binary
57
+ exactly where it was. It now sends a trivial prompt and reports what came
58
+ back. Note the limitation — it reports what happens *in the environment you
59
+ run it in*. A stripped environment that cannot reach the system credential
60
+ store will fail this check while the scheduled job, which can, is fine.
61
+
11
62
  ## [0.6.0] - 2026-09-15
12
63
 
13
64
  ### Added
@@ -486,7 +537,8 @@ First public release.
486
537
  `n-seo upgrade` gate — without changing the reported test counts, and an
487
538
  in-place install ran those assertions against the owner's live data.
488
539
 
489
- [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.0...HEAD
540
+ [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.1...HEAD
541
+ [0.6.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.0...v0.6.1
490
542
  [0.6.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...v0.6.0
491
543
  [0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
492
544
  [0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  **An SEO, self-hosted.** Website: https://n-seo.dev/ · From the makers of [n-dx](https://n-dx.dev).
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/n-seo)](https://www.npmjs.com/package/n-seo)
6
+ [![downloads](https://img.shields.io/npm/dw/n-seo)](https://www.npmjs.com/package/n-seo)
6
7
  [![CI](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml/badge.svg)](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml)
7
8
  [![license](https://img.shields.io/npm/l/n-seo)](LICENSE)
8
9
 
package/docs/DEPLOY.md CHANGED
@@ -273,6 +273,15 @@ checks. The instance directory is never touched by an upgrade — that
273
273
  separation is the point of `docs/INSTANCE.md`. Roll back by checking out the
274
274
  previous engine commit and running `manage up`.
275
275
 
276
+ **You will hear about a release without going to look.** The daily run's
277
+ `update-check` step asks the registry once a day and writes
278
+ `data/update-check.json`; the Settings page and `doctor` read it. That matters
279
+ more on a deployed instance than on a laptop, because nobody is watching a
280
+ terminal here — the notice is waiting on the dashboard the next time you open
281
+ it. Do not add a separate scheduled `n-seo upgrade --check`; it would duplicate
282
+ the step the daily run already performs. `modules.updateCheck.enabled: false`
283
+ turns it off.
284
+
276
285
  ## Watching it
277
286
 
278
287
  | Where | What |
package/docs/PRD.md CHANGED
@@ -6,6 +6,13 @@ deliberately does not do, and the capabilities that exist or are planned.
6
6
  Each feature lists acceptance criteria in the form a test or a reviewer can
7
7
  check. Status markers: **[shipped]**, **[planned]**, **[idea]**.
8
8
 
9
+ Some capabilities are stated as epic-level acceptance criteria rather than as
10
+ their own `## Feature:` section — the epic *is* the capability, and splitting
11
+ it would add a heading without adding a claim. Those still appear as features
12
+ in the `.rex/` tree, which needs a node to schedule work against. The two
13
+ files agree on what exists and on its status; the tree is simply finer-grained
14
+ in places.
15
+
9
16
  ## Purpose
10
17
 
11
18
  n-seo is a local-first control plane for organic growth across one or more
@@ -89,7 +96,8 @@ a fresh checkout runs.
89
96
  - Acceptance: `ops/doctor.py` reports the exact email to add in Search
90
97
  Console and GA4 and which configured properties are not yet accessible.
91
98
 
92
- # Epic: Ingest [shipped]
99
+ # Epic: Ingest and site health [shipped]
100
+
93
101
 
94
102
  ## Feature: Search Console pulls [shipped]
95
103
 
@@ -105,7 +113,7 @@ a fresh checkout runs.
105
113
  `funnel` report only for `conversions.site`, falling back when the custom
106
114
  dimension is unregistered.
107
115
 
108
- ## Feature: Time series, index coverage, metadata audit, trend analysis [shipped]
116
+ ## Feature: Time series, index coverage, metadata audit and trend analysis [shipped]
109
117
 
110
118
  - Acceptance: date × page series for 180 days from both sources.
111
119
  - Acceptance: URL Inspection verdict for every sitemap URL (sitemap indexes
@@ -119,7 +127,8 @@ a fresh checkout runs.
119
127
  - Acceptance: trend file with branded/generic split, rising/falling queries
120
128
  (84d vs prior 84d), monthly trajectory and AI-referral sessions by month.
121
129
 
122
- # Epic: Live-site probe [shipped]
130
+ ## Feature: No-auth health probe with regression alerts [shipped]
131
+
123
132
 
124
133
  - Acceptance: robots.txt (incl. AI-crawler disallows), sitemap.xml, llms.txt
125
134
  and llms-full.txt, homepage metadata/JSON-LD/visible-text bytes, and a real
@@ -127,7 +136,8 @@ a fresh checkout runs.
127
136
  - Acceptance: the daily diff raises an ALERT line on any regression versus
128
137
  the previous snapshot.
129
138
 
130
- # Epic: Action engine [shipped]
139
+ # Epic: Action engine and profiles [shipped]
140
+
131
141
 
132
142
  - Acceptance: six rules over the 90-day window (metadata findings, CTR
133
143
  gaps, striking distance, probe hygiene, engagement mismatch, traffic drop)
@@ -164,6 +174,11 @@ published once and installed many times. Full reference: `docs/PROFILES.md`.
164
174
  corrupting the sort.
165
175
  - Acceptance: a priority can never create a card. Everything in the queue
166
176
  still came from data.
177
+ - Acceptance: `principles` carries the part of a method that is not a number —
178
+ a title, a body, and a kind. `hard` is a constraint an agent must not cross;
179
+ `guide` is judgement it should apply. They render at the top of the action
180
+ queue and on Settings, and `n-seo init` writes them into the instance's
181
+ `CLAUDE.md`, so the owner and their agent read the same rules.
167
182
  - Decided: a profile is data only and may not ship executable code. Installing
168
183
  a method should not mean running its author's code on the machine holding
169
184
  your Search Console credentials. `priorities` covers reordering and hiding
@@ -177,7 +192,8 @@ published once and installed many times. Full reference: `docs/PROFILES.md`.
177
192
  with the engine defaults documented in `src/config.ts` and mirrored in
178
193
  `ingest/seo_config.py`; tests cover an override changing a rule's output.
179
194
 
180
- # Epic: Dashboard [shipped]
195
+ # Epic: Dashboard and agent access [shipped]
196
+
181
197
 
182
198
  - Acceptance: routes `/`, `/actions`, `/insights`, `/trends[/N]`,
183
199
  `/content`, `/drafts/:slug`, `/campaigns/:slug`, `/site/:host`,
@@ -199,12 +215,41 @@ published once and installed many times. Full reference: `docs/PROFILES.md`.
199
215
  pages and conversions, and writes the config file (creating it from the
200
216
  example on first save); invalid topic lines are rejected with a message.
201
217
 
218
+ ## Feature: Read-only MCP over stdio and authenticated HTTP [shipped]
219
+
220
+
221
+ - Acceptance: stdio transport with no secret; HTTP transport that returns
222
+ 503 with no token configured and compares tokens in constant time.
223
+ - Acceptance: every tool is annotated read-only; tools cover the queue, a
224
+ single action, sites, per-site report, top queries, striking distance, CTR
225
+ gaps, metadata audit, time series, ops status, daily log, proposals,
226
+ conversions, campaigns, settings and engine info; docs exposed as
227
+ resources.
228
+
229
+ ## Feature: Indexing problems on the individual site page [shipped]
230
+
231
+ `/site/:host` says nothing about indexing today, so someone looking at one
232
+ site has to leave for `/indexing` and find their host in a list. The data is
233
+ already keyed by host — `indexStatus()` carries problems, never-crawled,
234
+ indexed and checked per site — so this is surfacing, not a new pull.
235
+
236
+ - Acceptance: the site page shows that host's indexed/checked counts, its
237
+ problems grouped by coverage state, and its never-crawled count.
238
+ - Acceptance: each problem keeps its coverage-state explanation and whether
239
+ Request Indexing helps — true only where Google has formed no judgement,
240
+ false where it fetched and declined.
241
+ - Acceptance: a host that is all clear, or has no sitemap coverage, says so
242
+ explicitly rather than rendering an empty panel; it links through to
243
+ `/indexing` for the cross-site view.
244
+ - Acceptance: renders from demo data with no server error, in both themes.
245
+
202
246
  ## Feature: Custom pages and rules in instance mode [idea]
203
247
 
204
248
  Allow an instance to register extra routes and rules without forking the
205
249
  engine (e.g. `instance/src/extensions.ts`).
206
250
 
207
- # Epic: Modules (opt-in) [shipped]
251
+ # Epic: Optional modules [shipped]
252
+
208
253
 
209
254
  - Acceptance: each of indexStatus, metadataAudit, opportunityScan, llm,
210
255
  hackerNews, reddit, indexNow, staticExport, gitAutoCommit, notifications,
@@ -218,37 +263,23 @@ engine (e.g. `instance/src/extensions.ts`).
218
263
  briefings.
219
264
  - Acceptance: briefing prompts forbid generating comment text.
220
265
 
221
- ## Feature: HTTP LLM adapter [planned]
266
+ ## Feature: HTTP LLM adapter [shipped]
222
267
 
223
268
  Optional adapter that calls an OpenAI-compatible or Anthropic HTTP endpoint
224
269
  instead of a CLI, with the key read from `.env`.
225
270
 
271
+ - Acceptance: `modules.llm.http` takes `provider` (`anthropic` or `openai`),
272
+ `model`, an optional `baseUrl` and a key resolved from the environment;
273
+ `http` wins when configured and its key resolves, otherwise the CLI command
274
+ runs, and with neither the scan records candidates only.
275
+
226
276
  ## Feature: Bing Webmaster ingest [idea]
227
277
 
228
278
  ## Feature: Core Web Vitals via CrUX [idea]
229
279
 
230
- # Epic: Daily run and scheduling [shipped]
231
-
232
- - Acceptance: `ops/daily.py` waits for the network, runs the enabled steps
233
- in order, retries a failed step once, tees output to `data/daily-ops.log`,
234
- writes `data/last-run.json` with per-step timing, honours `--only`,
235
- `--skip`, `--list`, `--no-network-wait`, and exits 1 on any failure.
236
- - Acceptance: hooks (`beforeRun`, `afterRun`, `afterStep`) run in the
237
- instance directory and are logged like steps without aborting the run.
238
- - Acceptance: launchd, cron and systemd templates plus an installer script.
239
-
240
- # Epic: MCP server [shipped]
241
-
242
- - Acceptance: stdio transport with no secret; HTTP transport that returns
243
- 503 with no token configured and compares tokens in constant time.
244
- - Acceptance: every tool is annotated read-only; tools cover the queue, a
245
- single action, sites, per-site report, top queries, striking distance, CTR
246
- gaps, metadata audit, time series, ops status, daily log, proposals,
247
- conversions, campaigns, settings and engine info; docs exposed as
248
- resources.
249
-
250
280
  # Epic: Engine and instance [shipped]
251
281
 
282
+
252
283
  - Acceptance: `N_SEO_INSTANCE` relocates every instance-owned path; in-place
253
284
  mode is unchanged when it is unset.
254
285
  - Acceptance: `n-seo init|start|dev|daily|doctor|demo|mcp|check|export|
@@ -264,6 +295,17 @@ instead of a CLI, with the key read from `.env`.
264
295
  - Acceptance: `n-seo upgrade` refuses to leave the engine on a commit that
265
296
  fails `npm run check` without printing the rollback command.
266
297
 
298
+ ## Feature: Portable orchestrator with hooks and scheduler templates [shipped]
299
+
300
+
301
+ - Acceptance: `ops/daily.py` waits for the network, runs the enabled steps
302
+ in order, retries a failed step once, tees output to `data/daily-ops.log`,
303
+ writes `data/last-run.json` with per-step timing, honours `--only`,
304
+ `--skip`, `--list`, `--no-network-wait`, and exits 1 on any failure.
305
+ - Acceptance: hooks (`beforeRun`, `afterRun`, `afterStep`) run in the
306
+ instance directory and are logged like steps without aborting the run.
307
+ - Acceptance: launchd, cron and systemd templates plus an installer script.
308
+
267
309
  ## Feature: Publish the engine to npm [shipped]
268
310
 
269
311
  - Acceptance: `npm i n-seo` installs a working engine; `npx n-seo init`
@@ -284,13 +326,30 @@ instead of a CLI, with the key read from `.env`.
284
326
  and both compare against the engine running now — so an upgrade stops the
285
327
  notice immediately rather than at the next daily run.
286
328
 
287
- # Epic: Quality [shipped]
329
+ ## Feature: Update checking is visible where people schedule things [shipped]
288
330
 
289
- - Acceptance: `npm run check` runs typecheck, TypeScript tests (sandboxed
290
- copy) and Python tests; CI runs both suites, a demo-data dashboard smoke,
291
- `doctor --offline`, and a grep that fails on any private name.
331
+ The capability shipped in 0.4.1 and nobody needs a second scheduled job for
332
+ it: `update-check` is a step in the daily run, `modules.updateCheck` is the
333
+ only module enabled by default, and the Settings page and `doctor` read the
334
+ file it writes. What was missing was saying so where someone goes to set
335
+ scheduling up, which is exactly where they would otherwise invent a redundant
336
+ weekly job.
337
+
338
+ - Acceptance: `docs/SCHEDULING.md` names the `update-check` step, that
339
+ `modules.updateCheck` is on by default, and that the result appears on
340
+ Settings and in `doctor` — so a reader setting up scheduling does not add a
341
+ second job for it.
342
+ - Acceptance: the Upgrades section of `docs/DEPLOY.md` says the same for a
343
+ deployed instance, where nobody is watching a terminal.
344
+ - Acceptance: neither document suggests scheduling `n-seo upgrade --check`
345
+ separately; both say why that would duplicate the daily step.
346
+ - Decided: no weekly scheduler template. A second job would make a duplicate
347
+ registry request and write the answer to a log nobody opens. The narrow case
348
+ it would serve — an install that never schedules the daily run — is a
349
+ contradiction of how n-seo is meant to work.
350
+
351
+ # Epic: Onboarding, docs and quality [shipped]
292
352
 
293
- # Epic: Onboarding and docs [shipped]
294
353
 
295
354
  - Acceptance: `npm run demo` populates every page with synthetic data before
296
355
  any Google setup.
@@ -298,6 +357,13 @@ instead of a CLI, with the key read from `.env`.
298
357
  INSTANCE, MCP, OPERATING-RULES, PLAYBOOK, FAQ, CONTRIBUTING, SECURITY,
299
358
  CHANGELOG exist and match the CLI contracts.
300
359
 
360
+ ## Feature: Tests and CI [shipped]
361
+
362
+
363
+ - Acceptance: `npm run check` runs typecheck, TypeScript tests (sandboxed
364
+ copy) and Python tests; CI runs both suites, a demo-data dashboard smoke,
365
+ `doctor --offline`, and a grep that fails on any private name.
366
+
301
367
  ## Feature: Interactive setup wizard [idea]
302
368
 
303
369
  `n-seo init --guided`: asks for hosts, property ids and the key path, then
@@ -135,6 +135,28 @@ the dashboard shows as the run status chip), and appends a dated entry to
135
135
  `docs/daily-log.md`. Re-running on the same day replaces that day's entry
136
136
  rather than stacking a second one.
137
137
 
138
+ ## Checking for a newer n-seo
139
+
140
+ You do not need a second scheduled job for this. `update-check` is a step in
141
+ the daily run, and `modules.updateCheck` is the only module enabled by
142
+ default: once a day it asks the registry whether a newer engine has been
143
+ published and writes `data/update-check.json`. The Settings page and
144
+ `n-seo doctor` both read that file, so the notice reaches you without either
145
+ of them making a network request.
146
+
147
+ Scheduling `n-seo upgrade --check` weekly on top of that would ask the
148
+ registry the same question a second time and write the answer to a log nobody
149
+ opens. Run it by hand when you want to look:
150
+
151
+ ```sh
152
+ n-seo upgrade --check # reports what is available, changes nothing
153
+ n-seo upgrade # performs it, prints the changelog and the rollback
154
+ ```
155
+
156
+ Switch the daily check off with `modules.updateCheck.enabled: false`; nothing
157
+ about your instance is sent either way — it is the same public metadata
158
+ request `npm view n-seo version` makes from any machine.
159
+
138
160
  ## Logs, in one place
139
161
 
140
162
  | File | What |
package/ops/doctor.py CHANGED
@@ -79,6 +79,36 @@ def check_profile(cfg):
79
79
  report("OK", f"from {d}")
80
80
 
81
81
 
82
+ def probe_llm(cmd):
83
+ """Actually call the model and see whether it answers.
84
+
85
+ `shutil.which` only proves a file exists. An expired OAuth session, a
86
+ revoked key or a missing subscription all leave the binary exactly where
87
+ it was, so the old check reported OK while every call in the daily run
88
+ failed — eleven of them, silently, because the llm module degrades rather
89
+ than failing the run. A green tick for something that cannot work is
90
+ worse than no check at all.
91
+
92
+ Returns (ok, detail). Never raises.
93
+ """
94
+ try:
95
+ p = subprocess.run(cmd, input="Reply with the single word: ok",
96
+ capture_output=True, text=True, timeout=90, encoding="utf-8")
97
+ except subprocess.TimeoutExpired:
98
+ return False, "timed out after 90s"
99
+ except OSError as exc:
100
+ return False, str(exc)[:120]
101
+ if p.returncode != 0:
102
+ detail = (p.stderr or p.stdout or "").strip().splitlines()
103
+ return False, (detail[0][:160] if detail else f"exit {p.returncode}, no output")
104
+ out = (p.stdout or "").strip()
105
+ if not out:
106
+ # Some CLIs report an auth failure on stdout and still exit 0.
107
+ err = (p.stderr or "").strip().splitlines()
108
+ return False, (err[0][:160] if err else "exited 0 but said nothing")
109
+ return True, out.splitlines()[0][:60]
110
+
111
+
82
112
  def check_config():
83
113
  print("config")
84
114
  if seo_config.using_example():
@@ -301,8 +331,15 @@ def check_modules(cfg):
301
331
  elif key == "command" and not http and not llm.get("http"):
302
332
  report("FAIL", "llm has neither http nor command configured")
303
333
  continue
304
- report("OK" if shutil.which(cmd[0]) else "FAIL", f"llm.{key}: {cmd[0]}",
305
- "" if shutil.which(cmd[0]) else "not on PATH")
334
+ if not shutil.which(cmd[0]):
335
+ report("FAIL", f"llm.{key}: {cmd[0]}", "not on PATH")
336
+ continue
337
+ if OFFLINE:
338
+ report("OK", f"llm.{key}: {cmd[0]}", "on PATH — not called (offline)")
339
+ continue
340
+ ok, detail = probe_llm(cmd)
341
+ report("OK" if ok else "FAIL", f"llm.{key}: {raw[:48]}",
342
+ detail if not ok else f"answered: {detail}")
306
343
  hn = cfg["modules"].get("hackerNews", {})
307
344
  if hn.get("enabled"):
308
345
  user = (hn.get("user") or "").strip()
package/ops/llm.py CHANGED
@@ -155,7 +155,12 @@ def infer(prompt: str, fast: bool = False) -> str | None:
155
155
  print(f" llm: {exc}", file=sys.stderr)
156
156
  return None
157
157
  if p.returncode != 0:
158
- print(f" llm: exit {p.returncode}: {p.stderr.strip()[:200]}", file=sys.stderr)
158
+ # Not every CLI reports its failure on stderr. `claude` exits 1 and
159
+ # writes "Failed to authenticate: OAuth session expired" to stdout,
160
+ # so logging stderr alone produced "llm: exit 1:" with nothing after
161
+ # it — eleven times in one run, with the reason thrown away each time.
162
+ detail = (p.stderr.strip() or p.stdout.strip() or "no output")
163
+ print(f" llm: exit {p.returncode}: {detail[:200]}", file=sys.stderr)
159
164
  return None
160
165
  out = p.stdout.strip()
161
166
  return out or None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
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)",
@@ -56,10 +56,12 @@
56
56
  ".claude/skills/n-seo-review/",
57
57
  ".claude/skills/n-seo-deploy/",
58
58
  "!.claude/skills/README.md",
59
+ "!ops/install-git-filters.sh",
59
60
  "docs/",
60
61
  "README.md",
61
62
  "CHANGELOG.md",
62
- "LICENSE"
63
+ "LICENSE",
64
+ "!**/__pycache__"
63
65
  ],
64
66
  "engines": {
65
67
  "node": ">=20"
package/src/views.tsx CHANGED
@@ -658,6 +658,42 @@ export const SiteDetail: FC<{ site: SiteCfg; days?: number }> = ({ site, days =
658
658
  </table></div>
659
659
  </>
660
660
  )}
661
+ <SiteIndexing site={site} />
662
+ </>
663
+ );
664
+ };
665
+
666
+ /** This site's index coverage, on the page someone is already looking at.
667
+ * /indexing is the cross-site view; needing to leave a site page and find
668
+ * your host in a list is the thing this removes. Same snapshot, same
669
+ * component, no second pull. */
670
+ const SiteIndexing: FC<{ site: SiteCfg }> = ({ site }) => {
671
+ const status = data.indexStatus();
672
+ const s = status?.sites[site.host];
673
+ return (
674
+ <>
675
+ {!status ? (
676
+ <>
677
+ <h2>Indexing</h2>
678
+ <p class="empty">
679
+ No index snapshot yet — enable the <b>Index coverage sweep</b> module and run{" "}
680
+ <code>n-seo daily --only index-status</code>
681
+ </p>
682
+ </>
683
+ ) : !s ? (
684
+ <>
685
+ <h2>Indexing</h2>
686
+ <p class="empty">
687
+ Nothing for {site.host} in the latest snapshot — the sweep reads sitemap URLs, so a
688
+ site with no sitemap Search Console can fetch will not appear here.
689
+ </p>
690
+ </>
691
+ ) : (
692
+ <HostIndexing host={site.host} s={s} title="Indexing" />
693
+ )}
694
+ <p class="sub">
695
+ <a href="/indexing">Every site's coverage →</a>
696
+ </p>
661
697
  </>
662
698
  );
663
699
  };
@@ -794,6 +830,104 @@ const SitemapLine: FC<{ sitemap?: data.IndexStatus["sites"][string]["sitemap"] }
794
830
  );
795
831
  };
796
832
 
833
+ /** One host's index coverage. Rendered per host on /indexing, and for a
834
+ * single host on its own site page — same verdicts, same explanations, so
835
+ * the two pages cannot drift into disagreeing about what Google said. */
836
+ export const HostIndexing: FC<{
837
+ host: string;
838
+ s: data.IndexStatus["sites"][string];
839
+ /** Heading text when the host name would be redundant (a site page). */
840
+ title?: string;
841
+ }> = ({ host, s, title }) => {
842
+ if (s.problems.length === 0) {
843
+ return (
844
+ <section class="idx-host">
845
+ <h2>
846
+ {title ?? host} <small>{s.indexed}/{s.checked} indexed — all clear</small>
847
+ </h2>
848
+ </section>
849
+ );
850
+ }
851
+ const groups = [...s.problems].sort(
852
+ (a, b) =>
853
+ (COVERAGE_ORDER.indexOf(a.coverage) + 1 || 99) -
854
+ (COVERAGE_ORDER.indexOf(b.coverage) + 1 || 99) ||
855
+ a.url.localeCompare(b.url),
856
+ );
857
+ let lastCoverage = "";
858
+ return (
859
+ <section class="idx-host">
860
+ <h2>
861
+ {title ?? host}{" "}
862
+ <small>
863
+ {s.indexed}/{s.checked} indexed · {s.neverCrawled} never crawled
864
+ </small>
865
+ </h2>
866
+ <SitemapLine sitemap={s.sitemap} />
867
+ <div class="tbl-wrap">
868
+ <table>
869
+ <thead>
870
+ <tr><th>URL</th><th>Last crawled</th></tr>
871
+ </thead>
872
+ <tbody>
873
+ {groups.map((p) => {
874
+ const header = p.coverage !== lastCoverage ? p.coverage : null;
875
+ lastCoverage = p.coverage;
876
+ return (
877
+ <>
878
+ {header && (
879
+ <tr class="idx-group">
880
+ <td colspan={2}>
881
+ <strong>{header}</strong>{" "}
882
+ <span class="idx-help">{COVERAGE_HELP[header]?.means ?? p.detail ?? ""}</span>
883
+ {COVERAGE_HELP[header] && (
884
+ <div class="idx-fix">
885
+ <span
886
+ class={`idx-ri ${REQUEST_INDEXING_HELPS.has(header) ? "yes" : "no"}`}
887
+ title={
888
+ REQUEST_INDEXING_HELPS.has(header)
889
+ ? "Google has not judged this content yet — a request moves it"
890
+ : "Google already fetched and judged this page; re-requesting repeats the same verdict and spends quota"
891
+ }
892
+ >
893
+ {REQUEST_INDEXING_HELPS.has(header)
894
+ ? "Request Indexing helps"
895
+ : "Request Indexing will not fix this"}
896
+ </span>{" "}
897
+ {COVERAGE_HELP[header].fix}
898
+ </div>
899
+ )}
900
+ </td>
901
+ </tr>
902
+ )}
903
+ <tr>
904
+ <td class="mono trunc">
905
+ <a href={p.url} target="_blank" rel="noopener noreferrer">
906
+ {p.url.replace(/^https?:\/\/[^/]+/, "") || "/"}
907
+ </a>
908
+ {p.canonicalMismatch && (
909
+ <span class="chip"> canonical → {p.googleCanonical}</span>
910
+ )}
911
+ </td>
912
+ <td class="mono">
913
+ {p.lastCrawl ? p.lastCrawl.slice(0, 10) : "never"}
914
+ {staleVerdict(p.lastCrawl) && (
915
+ <span class="idx-stale" title="Google has not looked since this verdict — recheck before treating it as a live bug">
916
+ {" "}stale
917
+ </span>
918
+ )}
919
+ </td>
920
+ </tr>
921
+ </>
922
+ );
923
+ })}
924
+ </tbody>
925
+ </table>
926
+ </div>
927
+ </section>
928
+ );
929
+ };
930
+
797
931
  export const IndexingPage: FC = () => {
798
932
  const status = data.indexStatus();
799
933
  if (!status) {
@@ -816,95 +950,9 @@ export const IndexingPage: FC = () => {
816
950
  Search Console's verdict on every sitemap URL · {totalProblems} not indexed,{" "}
817
951
  {totalNever} never crawled · snapshot {status.generated.slice(0, 16).replace("T", " ")}
818
952
  </p>
819
- {hosts.map(([host, s]) => {
820
- if (s.problems.length === 0) {
821
- return (
822
- <section class="idx-host">
823
- <h2>
824
- {host} <small>{s.indexed}/{s.checked} indexed — all clear</small>
825
- </h2>
826
- </section>
827
- );
828
- }
829
- const groups = [...s.problems].sort(
830
- (a, b) =>
831
- (COVERAGE_ORDER.indexOf(a.coverage) + 1 || 99) -
832
- (COVERAGE_ORDER.indexOf(b.coverage) + 1 || 99) ||
833
- a.url.localeCompare(b.url),
834
- );
835
- let lastCoverage = "";
836
- return (
837
- <section class="idx-host">
838
- <h2>
839
- {host}{" "}
840
- <small>
841
- {s.indexed}/{s.checked} indexed · {s.neverCrawled} never crawled
842
- </small>
843
- </h2>
844
- <SitemapLine sitemap={s.sitemap} />
845
- <div class="tbl-wrap">
846
- <table>
847
- <thead>
848
- <tr><th>URL</th><th>Last crawled</th></tr>
849
- </thead>
850
- <tbody>
851
- {groups.map((p) => {
852
- const header = p.coverage !== lastCoverage ? p.coverage : null;
853
- lastCoverage = p.coverage;
854
- return (
855
- <>
856
- {header && (
857
- <tr class="idx-group">
858
- <td colspan={2}>
859
- <strong>{header}</strong>{" "}
860
- <span class="idx-help">{COVERAGE_HELP[header]?.means ?? p.detail ?? ""}</span>
861
- {COVERAGE_HELP[header] && (
862
- <div class="idx-fix">
863
- <span
864
- class={`idx-ri ${REQUEST_INDEXING_HELPS.has(header) ? "yes" : "no"}`}
865
- title={
866
- REQUEST_INDEXING_HELPS.has(header)
867
- ? "Google has not judged this content yet — a request moves it"
868
- : "Google already fetched and judged this page; re-requesting repeats the same verdict and spends quota"
869
- }
870
- >
871
- {REQUEST_INDEXING_HELPS.has(header)
872
- ? "Request Indexing helps"
873
- : "Request Indexing will not fix this"}
874
- </span>{" "}
875
- {COVERAGE_HELP[header].fix}
876
- </div>
877
- )}
878
- </td>
879
- </tr>
880
- )}
881
- <tr>
882
- <td class="mono trunc">
883
- <a href={p.url} target="_blank" rel="noopener noreferrer">
884
- {p.url.replace(/^https?:\/\/[^/]+/, "") || "/"}
885
- </a>
886
- {p.canonicalMismatch && (
887
- <span class="chip"> canonical → {p.googleCanonical}</span>
888
- )}
889
- </td>
890
- <td class="mono">
891
- {p.lastCrawl ? p.lastCrawl.slice(0, 10) : "never"}
892
- {staleVerdict(p.lastCrawl) && (
893
- <span class="idx-stale" title="Google has not looked since this verdict — recheck before treating it as a live bug">
894
- {" "}stale
895
- </span>
896
- )}
897
- </td>
898
- </tr>
899
- </>
900
- );
901
- })}
902
- </tbody>
903
- </table>
904
- </div>
905
- </section>
906
- );
907
- })}
953
+ {hosts.map(([host, s]) => (
954
+ <HostIndexing key={host} host={host} s={s} />
955
+ ))}
908
956
  </>
909
957
  );
910
958
  };
Binary file