n-seo 0.6.1 → 0.7.1

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,60 @@ uses [Semantic Versioning](https://semver.org/).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.7.1] - 2026-09-28
12
+
13
+ ### Fixed
14
+ - **One HTTP/2 hiccup no longer fails the whole index-status step.** The
15
+ retry helper only retried the curl exits it knew about, and treated every
16
+ other one as a bad request. A dropped HTTP/2 connection (curl exit 16)
17
+ mid-run failed the first URL Inspection call it hit, which took the step
18
+ down, and the daily run's one step-level retry hit the same thing. Exits
19
+ 16 and 92 (HTTP/2 connection and stream errors) and 55 (send failure) now
20
+ get the same per-request backoff as timeouts and resets.
21
+ - **A curl failure now says why.** Requests ran with `-s`, which silences
22
+ curl's own error line along with the progress meter, so the failure read
23
+ `curl exit 16: ` with nothing after it. They run with `-sS` now.
24
+ - **A slow first answer no longer reads as a regression.** The site probe
25
+ fetched each file once with a 15-second limit. A cold origin behind a CDN
26
+ can miss that on its first request of the morning, and the run then
27
+ alerted `robots.txt REGRESSED` for a site serving it fine. It happened
28
+ every few days to one site. The probe now tries once more, five seconds
29
+ later, when there is no answer or a 5xx. A site that is really down fails
30
+ both tries and is still reported.
31
+
32
+ ## [0.7.0] - 2026-09-18
33
+
34
+ ### Added
35
+ - **A site's indexing problems, on its own page.** `/site/<host>` said nothing
36
+ about indexing, so anyone looking at one site had to leave for `/indexing`
37
+ and find their host in a list — while the data was already keyed by host and
38
+ already rendered per host one page over. The site page now carries that
39
+ host's coverage: indexed against checked, problems grouped by coverage
40
+ state, never-crawled. The explanations and the Request Indexing verdict come
41
+ with it, because both pages now render from one component and cannot drift
42
+ into disagreeing about what Google said. No new pull and no new data file.
43
+ - A host that is all clear, or that has no sitemap Search Console can fetch,
44
+ says which rather than rendering an empty panel. An empty panel is
45
+ indistinguishable from a broken one.
46
+ - **Update checking is documented where people schedule things.**
47
+ `update-check` has been a step in the daily run since 0.4.1 and
48
+ `modules.updateCheck` is the only module enabled by default, but nothing
49
+ said so in `docs/SCHEDULING.md` — the one place someone would otherwise
50
+ conclude they need a second scheduled job for it. They do not: a weekly
51
+ `n-seo upgrade --check` would ask the registry the same question the daily
52
+ run already asks and write the answer to a log nobody opens. The Upgrades
53
+ section of `docs/DEPLOY.md` says the same for a deployed instance, where
54
+ nobody is watching a terminal and the notice waits on the dashboard.
55
+
56
+ ### Fixed
57
+ - **The package no longer ships Python bytecode.** `files` lists `ops/`,
58
+ `ingest/` and `probes/` wholesale, and whatever ran the tests before
59
+ packing left `__pycache__` beside the sources — so a release built on CI
60
+ carried the runner's `.pyc` files. 0.6.1 shipped thirteen of them, a little
61
+ over a tenth of the package, compiled for a Python version no user is
62
+ guaranteed to have. `.gitignore` covers `__pycache__`, which is why this was
63
+ invisible in a checkout and only appeared in the tarball.
64
+
11
65
  ## [0.6.1] - 2026-09-15
12
66
 
13
67
  ### Fixed
@@ -504,7 +558,9 @@ First public release.
504
558
  `n-seo upgrade` gate — without changing the reported test counts, and an
505
559
  in-place install ran those assertions against the owner's live data.
506
560
 
507
- [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.1...HEAD
561
+ [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.7.1...HEAD
562
+ [0.7.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.7.0...v0.7.1
563
+ [0.7.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.1...v0.7.0
508
564
  [0.6.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.0...v0.6.1
509
565
  [0.6.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...v0.6.0
510
566
  [0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
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 |
@@ -14,9 +14,10 @@ import subprocess
14
14
  import sys
15
15
  import time
16
16
 
17
- # curl exits worth another try: 6/7 resolve+connect, 28 timeout,
18
- # 35 TLS handshake, 52 empty reply, 56 recv error.
19
- RETRYABLE_EXITS = {6, 7, 28, 35, 52, 56}
17
+ # curl exits worth another try: 6/7 resolve+connect, 16 HTTP/2 framing,
18
+ # 28 timeout, 35 TLS handshake, 52 empty reply, 55 send error,
19
+ # 56 recv error, 92 HTTP/2 stream reset.
20
+ RETRYABLE_EXITS = {6, 7, 16, 28, 35, 52, 55, 56, 92}
20
21
  TIMEOUT = 180
21
22
  ATTEMPTS = 4
22
23
 
@@ -29,7 +30,8 @@ def curl_json(args, *, timeout=TIMEOUT, attempts=ATTEMPTS, label=""):
29
30
  """
30
31
  last = None
31
32
  for attempt in range(1, attempts + 1):
32
- p = subprocess.run(["curl", "-s", "--max-time", str(timeout), *args],
33
+ # -sS: no progress meter, but curl still says why it failed.
34
+ p = subprocess.run(["curl", "-sS", "--max-time", str(timeout), *args],
33
35
  capture_output=True, text=True)
34
36
  if p.returncode == 0:
35
37
  try:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
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"
@@ -13,6 +13,7 @@ Stdlib only — no pip installs needed.
13
13
  import json
14
14
  import re
15
15
  import sys
16
+ import time
16
17
  from datetime import datetime, timezone
17
18
  from html.parser import HTMLParser
18
19
  from pathlib import Path
@@ -33,7 +34,15 @@ UA = "Mozilla/5.0 (compatible; n-seo-probe/0.1)"
33
34
 
34
35
 
35
36
  def fetch(url, timeout=15):
36
- return fetch_text(url, timeout=timeout, ua=UA)
37
+ """One retry when the site doesn't answer or answers 5xx. A cold origin
38
+ behind a CDN can take longer than the timeout on its first request of the
39
+ morning, and without the retry that read as robots.txt or sitemap.xml
40
+ REGRESSED. A site that is really down fails both tries."""
41
+ status, body = fetch_text(url, timeout=timeout, ua=UA)
42
+ if status is None or status >= 500:
43
+ time.sleep(5)
44
+ status, body = fetch_text(url, timeout=timeout, ua=UA)
45
+ return status, body
37
46
 
38
47
 
39
48
  class MetaParser(HTMLParser):
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