n-seo 0.2.0 → 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 (36) 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 +93 -15
  9. package/bin/n-seo.mjs +163 -6
  10. package/docs/ADDING-A-SITE.md +4 -0
  11. package/docs/DEPLOY.md +4 -0
  12. package/docs/FAQ.md +32 -3
  13. package/docs/INSTANCE.md +54 -0
  14. package/docs/PRD.md +23 -5
  15. package/docs/SCHEDULING.md +4 -0
  16. package/docs/SETUP-GOOGLE.md +7 -0
  17. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  18. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  19. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  20. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  21. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  22. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  23. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  24. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  25. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  26. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  27. package/ops/demo_data.py +6 -0
  28. package/ops/doctor.py +9 -0
  29. package/ops/export_static.py +5 -0
  30. package/package.json +10 -2
  31. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  32. package/public/fonts/OFL.txt +105 -0
  33. package/public/fonts/montserrat-latin-var.woff2 +0 -0
  34. package/public/styles.css +18 -1
  35. package/src/server.tsx +22 -1
  36. package/src/views.tsx +70 -6
package/docs/PRD.md CHANGED
@@ -102,6 +102,10 @@ a fresh checkout runs.
102
102
  - Acceptance: date × page series for 180 days from both sources.
103
103
  - Acceptance: URL Inspection verdict for every sitemap URL (sitemap indexes
104
104
  followed one level, ≤400 URLs/host), with sitemap submission state.
105
+ - Acceptance: each coverage state renders with what it means, what to do
106
+ about it, and whether Request Indexing helps — true only where Google has
107
+ formed no judgement on the content (unknown, discovered-never-crawled),
108
+ false where it fetched and declined (soft 404, crawled-not-indexed).
105
109
  - Acceptance: metadata audit flags title/query mismatch, CTR below position
106
110
  expectation, missing/short/duplicate descriptions, long titles.
107
111
  - Acceptance: trend file with branded/generic split, rising/falling queries
@@ -148,7 +152,9 @@ the config with the current values as defaults.
148
152
  be marked watching or retired; rule-derived items expose no write buttons.
149
153
  - Acceptance: the server binds 127.0.0.1 by default and rejects cross-origin
150
154
  POSTs.
151
- - Acceptance: light and dark themes; no external fonts.
155
+ - Acceptance: light and dark themes; no external fonts — the one webfont is
156
+ served from the app's own origin, so the dashboard renders identically on a
157
+ host with no outbound internet and leaks no request to a font CDN.
152
158
 
153
159
  ## Feature: Settings page [shipped]
154
160
 
@@ -206,14 +212,22 @@ instead of a CLI, with the key read from `.env`.
206
212
  mode is unchanged when it is unset.
207
213
  - Acceptance: `n-seo init|start|dev|daily|doctor|demo|mcp|check|export|
208
214
  upgrade|version` behave as documented in `docs/INSTANCE.md`.
215
+ - Acceptance: an instance is a standalone project. `n-seo init` exits 2
216
+ without writing anything when the target holds an application marker, or
217
+ when the directory it would create falls inside a git repository that is
218
+ not an instance; `--force` overrides, re-running on an existing instance
219
+ succeeds, and in-place mode in the engine checkout is unaffected.
220
+ - Acceptance: `init` installs the seven instance-facing skills and a
221
+ `CLAUDE.md` with the operating rules, substituting the real engine and
222
+ instance paths, and leaves the contributor (`ndx-*`) skills behind.
209
223
  - Acceptance: `n-seo upgrade` refuses to leave the engine on a commit that
210
224
  fails `npm run check` without printing the rollback command.
211
225
 
212
- ## Feature: Publish the engine to npm [planned]
226
+ ## Feature: Publish the engine to npm [shipped]
213
227
 
214
228
  - Acceptance: `npm i n-seo` installs a working engine; `npx n-seo init`
215
229
  scaffolds an instance; the `files` list excludes tests and the marketing
216
- site.
230
+ site, and includes the instance-facing skills.
217
231
 
218
232
  ## Feature: Scheduled upgrade with gate [planned]
219
233
 
@@ -246,6 +260,10 @@ runs doctor.
246
260
  promises (local data, evidence first, briefs-not-acts) and the operating
247
261
  rules.
248
262
 
249
- ## Feature: Custom domain [planned]
263
+ ## Feature: Custom domain [shipped]
250
264
 
251
- `www/CNAME` and URL updates once a domain is chosen.
265
+ `www/CNAME` carries n-seo.dev; analytics are guarded on the hostname so a
266
+ fork's Pages deploy never reports into the project's property.
267
+
268
+ - Acceptance: typography is self-hosted (latin-subset variable woff2 from the
269
+ page's own origin), so the site makes no third-party request.
@@ -4,6 +4,10 @@ Two things should always be running: the **dashboard** (a long-lived process
4
4
  serving the configured port) and the **daily run** (`ops/daily.py`, once a
5
5
  morning). Both are plain processes; pick the scheduler your OS already has.
6
6
 
7
+ > Or say *"schedule the morning run"* in the instance directory — the
8
+ > `n-seo-deploy` skill covers scheduling on each platform, and `n-seo-setup`
9
+ > ends here.
10
+
7
11
  ## macOS (launchd)
8
12
 
9
13
  The install script fills in the templates under `ops/templates/`, copies them
@@ -7,6 +7,13 @@ account with a JSON key** — a machine identity you add as a read-only user in
7
7
  each console. No gcloud install, no browser login, no token that expires when
8
8
  your session does.
9
9
 
10
+ > **You can have this done for you.** `n-seo init` installed the
11
+ > `n-seo-setup` skill in your instance. Open the directory in Claude Code and
12
+ > say *"set this up for my sites"*: it works through everything below, runs
13
+ > `n-seo doctor` between steps, and stops at each decision that is yours (the
14
+ > Cloud project, the property ids, whether to grant access). This page is the
15
+ > reference behind it, and what to read when something does not fit.
16
+
10
17
  ## 1. Pick a Google Cloud project
11
18
 
12
19
  Any project works; a dedicated one keeps it tidy. In the
Binary file
package/ops/demo_data.py CHANGED
@@ -522,6 +522,12 @@ def main():
522
522
  else:
523
523
  print("\nnow run: n-seo start (then open the dashboard)")
524
524
  print("remove with: n-seo demo --clean")
525
+ # The demo answers "what is this"; the next question is always "how do I
526
+ # point it at my own sites", and the answer is a sentence, not a doc.
527
+ if (seo_config.INSTANCE / ".claude" / "skills" / "n-seo-setup" / "SKILL.md").exists():
528
+ print("\nwhen you want it on your own sites, you can just ask:")
529
+ print(f" cd {seo_config.INSTANCE} && claude")
530
+ print(' "set this up for my sites" → /n-seo-setup')
525
531
  return 0
526
532
 
527
533
 
package/ops/doctor.py CHANGED
@@ -273,6 +273,15 @@ def main():
273
273
  check_dashboard()
274
274
  check_modules(cfg)
275
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
+ )
276
285
  return 1 if FAILS else 0
277
286
 
278
287
 
@@ -106,6 +106,11 @@ def main():
106
106
  dest.write_text(html, encoding="utf-8")
107
107
 
108
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")
109
114
  (out / "favicon.svg").write_text(fetch("/favicon.svg"), encoding="utf-8")
110
115
  (out / "robots.txt").write_text("User-agent: *\nDisallow: /\n", encoding="utf-8")
111
116
  except BaseException:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.2.0",
3
+ "version": "0.3.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)",
@@ -47,6 +47,14 @@
47
47
  "public/",
48
48
  "n-seo.config.example.json",
49
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",
50
58
  "docs/",
51
59
  "README.md",
52
60
  "LICENSE"
@@ -71,7 +79,7 @@
71
79
  "docker:up": "docker compose -f docker/compose.yml up -d"
72
80
  },
73
81
  "dependencies": {
74
- "@hono/node-server": "^1.13.7",
82
+ "@hono/node-server": "^2.1.1",
75
83
  "@modelcontextprotocol/sdk": "^1.30.0",
76
84
  "hono": "^4.6.14",
77
85
  "tsx": "^4.19.2",
@@ -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/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
  });
package/src/views.tsx CHANGED
@@ -652,13 +652,60 @@ const COVERAGE_ORDER = [
652
652
  "Crawled - currently not indexed",
653
653
  ];
654
654
 
655
- const COVERAGE_HELP: Record<string, string> = {
656
- "URL is unknown to Google": "Not discovered at all — check the sitemap and internal links.",
657
- "Discovered - currently not indexed": "Known but never fetched. Request Indexing (manual, in the Search Console UI) moves these.",
658
- "Soft 404": "Serves 200 but Google reads it as an error or empty page.",
659
- "Crawled - currently not indexed": "Google fetched it and declined — a content/value judgement.",
655
+ /** What each verdict means and — separately — what to actually do about it.
656
+ *
657
+ * These are not the same action, and treating them as one is the common
658
+ * mistake. "Request Indexing" only helps where Google has *not yet judged*
659
+ * the page: unknown or discovered-never-crawled. Once Google has fetched a
660
+ * URL and declined it (soft 404, crawled-not-indexed), another request
661
+ * re-runs the same judgement on the same content and spends a slot of a
662
+ * quota that is roughly ten URLs a day. Those need a page change first. */
663
+ const COVERAGE_HELP: Record<string, { means: string; fix: string }> = {
664
+ "URL is unknown to Google": {
665
+ means: "Not discovered at all — Google has never seen this URL.",
666
+ fix: "Discovery problem, not a content one: confirm it is in the sitemap Search Console actually read, and link to it from a page that is already indexed. Then request indexing.",
667
+ },
668
+ "Discovered - currently not indexed": {
669
+ means: "Known but never fetched — it is sitting in the crawl queue.",
670
+ fix: "Request Indexing (manual, in the Search Console UI) is the right move here and usually works within hours. Spend the daily quota on these first.",
671
+ },
672
+ "Soft 404": {
673
+ means: "Google fetched it, got a 200, and read the response as an error or an empty page.",
674
+ fix: "Do NOT just request indexing — Google already judged this content and would judge it the same way again. Fetch the URL and find the cause: content rendered only in JavaScript (the crawler sees an empty shell), a genuinely thin page, or an error page returning 200 that should return 404 or 410 and leave the sitemap. Fix it, then request indexing.",
675
+ },
676
+ "Crawled - currently not indexed": {
677
+ means: "Google fetched it and declined to index it — a judgement about value.",
678
+ fix: "Requesting indexing re-submits the same page for the same verdict. Either make it substantially better and more distinct from your other pages, or accept it as not worth indexing and drop it from the sitemap.",
679
+ },
680
+ "Page with redirect": {
681
+ means: "The URL redirects, so the destination is what gets indexed.",
682
+ fix: "Expected for an old URL. If it is in your sitemap, remove it — sitemaps should list final URLs only.",
683
+ },
684
+ "Alternate page with proper canonical tag": {
685
+ means: "Correctly pointing at a canonical elsewhere. Not a problem.",
686
+ fix: "No action. Remove it from the sitemap if you want the report clean.",
687
+ },
688
+ "Duplicate without user-selected canonical": {
689
+ means: "Google picked a different URL as canonical because it reads as a duplicate.",
690
+ fix: "Set an explicit canonical, or differentiate the content. Requesting indexing does not change a duplicate judgement.",
691
+ },
692
+ "Blocked by robots.txt": {
693
+ means: "Crawling is disallowed, so it cannot be indexed.",
694
+ fix: "Either remove the Disallow rule, or remove the URL from the sitemap — asking for a URL you block is a contradiction.",
695
+ },
696
+ "Excluded by 'noindex' tag": {
697
+ means: "The page asks not to be indexed and Google is obeying.",
698
+ fix: "If that is intentional, drop it from the sitemap. If not, remove the noindex tag, then request indexing.",
699
+ },
660
700
  };
661
701
 
702
+ /** True where re-submitting the URL is a reasonable use of the daily quota:
703
+ * Google has not yet formed a verdict on the content. */
704
+ const REQUEST_INDEXING_HELPS = new Set([
705
+ "URL is unknown to Google",
706
+ "Discovered - currently not indexed",
707
+ ]);
708
+
662
709
  const STALE_DAYS = 90;
663
710
 
664
711
  /** A verdict is only as current as the crawl behind it. A URL can read as
@@ -760,7 +807,24 @@ export const IndexingPage: FC = () => {
760
807
  <tr class="idx-group">
761
808
  <td colspan={2}>
762
809
  <strong>{header}</strong>{" "}
763
- <span class="idx-help">{COVERAGE_HELP[header] ?? p.detail ?? ""}</span>
810
+ <span class="idx-help">{COVERAGE_HELP[header]?.means ?? p.detail ?? ""}</span>
811
+ {COVERAGE_HELP[header] && (
812
+ <div class="idx-fix">
813
+ <span
814
+ class={`idx-ri ${REQUEST_INDEXING_HELPS.has(header) ? "yes" : "no"}`}
815
+ title={
816
+ REQUEST_INDEXING_HELPS.has(header)
817
+ ? "Google has not judged this content yet — a request moves it"
818
+ : "Google already fetched and judged this page; re-requesting repeats the same verdict and spends quota"
819
+ }
820
+ >
821
+ {REQUEST_INDEXING_HELPS.has(header)
822
+ ? "Request Indexing helps"
823
+ : "Request Indexing will not fix this"}
824
+ </span>{" "}
825
+ {COVERAGE_HELP[header].fix}
826
+ </div>
827
+ )}
764
828
  </td>
765
829
  </tr>
766
830
  )}