n-seo 0.1.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 (88) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/bin/n-seo.mjs +310 -0
  5. package/docs/ADDING-A-SITE.md +82 -0
  6. package/docs/ARCHITECTURE.md +213 -0
  7. package/docs/DEPLOY.md +300 -0
  8. package/docs/FAQ.md +93 -0
  9. package/docs/INSTANCE.md +365 -0
  10. package/docs/MCP.md +104 -0
  11. package/docs/OPERATING-RULES.md +106 -0
  12. package/docs/PLAYBOOK.md +122 -0
  13. package/docs/PRD.md +249 -0
  14. package/docs/RELEASING.md +189 -0
  15. package/docs/SCHEDULING.md +104 -0
  16. package/docs/SETUP-GOOGLE.md +215 -0
  17. package/docs/examples/campaign.json +59 -0
  18. package/docs/examples/draft.md +43 -0
  19. package/docs/screenshots/overview.png +0 -0
  20. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  21. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  22. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  23. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  24. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  25. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  26. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  27. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  28. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  29. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  30. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  31. package/ingest/analyze_ga4.py +79 -0
  32. package/ingest/analyze_gsc.py +136 -0
  33. package/ingest/analyze_metadata.py +158 -0
  34. package/ingest/analyze_trends.py +145 -0
  35. package/ingest/google_auth.py +238 -0
  36. package/ingest/http_util.py +87 -0
  37. package/ingest/pull_ga4.py +107 -0
  38. package/ingest/pull_gsc.py +111 -0
  39. package/ingest/pull_index_status.py +179 -0
  40. package/ingest/pull_timeseries.py +130 -0
  41. package/ingest/seo_config.py +213 -0
  42. package/n-seo.config.example.json +110 -0
  43. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  44. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  45. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  46. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  48. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  49. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  50. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  51. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  52. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  53. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  54. package/ops/daily.py +250 -0
  55. package/ops/daily_diff.py +151 -0
  56. package/ops/demo_data.py +529 -0
  57. package/ops/doctor.py +266 -0
  58. package/ops/export_static.py +125 -0
  59. package/ops/hn_digest.py +169 -0
  60. package/ops/indexnow.py +107 -0
  61. package/ops/install-launchd.sh +76 -0
  62. package/ops/llm.py +139 -0
  63. package/ops/mcp-smoke-stdio.mjs +61 -0
  64. package/ops/opportunity_scan.py +185 -0
  65. package/ops/publish.py +158 -0
  66. package/ops/reddit_digest.py +168 -0
  67. package/ops/templates/n-seo-daily.service +11 -0
  68. package/ops/templates/n-seo-daily.timer +11 -0
  69. package/ops/templates/n-seo-dashboard.service +15 -0
  70. package/ops/templates/n-seo.cron +3 -0
  71. package/ops/templates/n-seo.daily.plist +29 -0
  72. package/ops/templates/n-seo.dashboard.plist +22 -0
  73. package/package.json +77 -0
  74. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
  75. package/probes/site_probe.py +201 -0
  76. package/public/favicon.svg +6 -0
  77. package/public/styles.css +632 -0
  78. package/src/actions.ts +255 -0
  79. package/src/backlog.ts +197 -0
  80. package/src/config.ts +220 -0
  81. package/src/data.ts +895 -0
  82. package/src/insights.ts +22 -0
  83. package/src/mcp-stdio.ts +21 -0
  84. package/src/mcp.ts +490 -0
  85. package/src/server.tsx +260 -0
  86. package/src/settings.tsx +329 -0
  87. package/src/views.tsx +1487 -0
  88. package/tsconfig.json +15 -0
package/.env.example ADDED
@@ -0,0 +1,13 @@
1
+ # Copy to .env — everything here is optional.
2
+
3
+ # Reddit "script" app credentials (https://www.reddit.com/prefs/apps) — only
4
+ # needed when modules.reddit.enabled is true; Reddit blocks anonymous JSON.
5
+ REDDIT_CLIENT_ID=
6
+ REDDIT_CLIENT_SECRET=
7
+
8
+ # Bearer token for the HTTP MCP endpoint (POST /mcp). Leave empty to keep the
9
+ # endpoint disabled (it returns 503). stdio MCP needs no token.
10
+ SEO_MCP_TOKEN=
11
+
12
+ # Override the config file location (default: ./n-seo.config.json)
13
+ # N_SEO_CONFIG=
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nick Daniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # n-seo
2
+
3
+ **An SEO, self-hosted.** Website: https://en-dash-consulting.github.io/n-seo/ · From the makers of [n-dx](https://n-dx.dev).
4
+
5
+ A local-first control plane for growing organic traffic to your own sites —
6
+ classic search (SEO), answer engines (AEO) and AI assistants that cite sources
7
+ (GEO) — without paying an agency to read Search Console for you.
8
+
9
+ It pulls Search Console and GA4 into local JSON, probes your live sites for the
10
+ things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
11
+ JS-only shells), and turns all of it into a **ranked queue of concrete actions**:
12
+ which page to retitle, which query to answer, which page never got crawled.
13
+ It runs on hardware you control, on a schedule, and shows you the result on a dashboard.
14
+
15
+ - **Your data stays on your own hardware.** Nothing is sent anywhere except the
16
+ Google APIs you authorize and, optionally, a local LLM command you choose.
17
+ - **It briefs you; it never posts for you.** Community modules find threads
18
+ and write a briefing. The words are always yours.
19
+ - **It proposes; you ship.** No module edits your site. The queue tells you
20
+ what to change and why, with the numbers; you make the change in your own
21
+ repo and the next day's data tells you whether it worked.
22
+
23
+ ![Overview](docs/screenshots/overview.png)
24
+
25
+ ## Quickstart (5 minutes, no Google setup)
26
+
27
+ ```sh
28
+ git clone https://github.com/en-dash-consulting/n-seo.git
29
+ cd n-seo
30
+ npm install
31
+ npm run demo # writes a synthetic dataset for example.com under data/
32
+ npm start # dashboard → http://localhost:4600
33
+ ```
34
+
35
+ Open http://localhost:4600. Every page is populated from the demo data, so you
36
+ can see what the tool does before you connect anything.
37
+
38
+ ## Connect your real sites
39
+
40
+ 1. Copy the config and edit the `sites` list:
41
+ ```sh
42
+ cp n-seo.config.example.json n-seo.config.json
43
+ ```
44
+ 2. Give the tool read access to Search Console and GA4 — a service account
45
+ with a JSON key, added as a user in both consoles. Follow
46
+ [docs/SETUP-GOOGLE.md](docs/SETUP-GOOGLE.md).
47
+ 3. Check the setup:
48
+ ```sh
49
+ python3 ops/doctor.py
50
+ ```
51
+ 4. Run the pipeline once, then look at the dashboard:
52
+ ```sh
53
+ python3 ops/daily.py
54
+ npm start
55
+ ```
56
+ 5. Schedule it to run every morning: [docs/SCHEDULING.md](docs/SCHEDULING.md).
57
+
58
+ Adding another site later is one entry in the config —
59
+ [docs/ADDING-A-SITE.md](docs/ADDING-A-SITE.md).
60
+
61
+ Running several people's sites, or want upgrades to be a `git pull`? Keep your
62
+ config in its own directory — see [docs/INSTANCE.md](docs/INSTANCE.md).
63
+
64
+ ## What you get
65
+
66
+ | Page | What it shows |
67
+ |---|---|
68
+ | `/` Overview | A **Today** board (fix / approve / publish / comment / ship) and a one-row-per-site strip: sessions, 28-day trend, Google clicks, AI-referral share, probe health |
69
+ | `/actions` | The full ranked queue: machine proposals awaiting your accept, active items, and shipped items being watched. Searchable; every card opens a spec |
70
+ | `/insights` | Rising and falling queries (84 days vs the prior 84), branded vs generic split, AI-referral sessions by month, plus your own written briefing if you keep one |
71
+ | `/trends` | Daily clicks and sessions per site with a per-page heatmap; one time window drives every row |
72
+ | `/content` | Participation briefings (HN, Reddit — opt-in), publish-ready drafts, and outreach campaigns with targets and templates |
73
+ | `/site/:host` | Per-site detail: striking-distance queries, CTR gaps, top queries and pages, landing-page engagement, AI referral sources |
74
+ | `/indexing` | Search Console's verdict on every sitemap URL — indexed, discovered-never-crawled, soft 404 — with stale verdicts flagged |
75
+ | `/probes` | The latest live-site health snapshot for every site |
76
+ | `/logs` | The daily log (one dated entry per run, ALERT lines for regressions) and raw run output |
77
+ | `/settings` | Module switches and digest topics; writes `n-seo.config.json` |
78
+
79
+ ## The action engine
80
+
81
+ `src/actions.ts` runs a small set of rules over the trailing 90 days of data
82
+ and emits actions with the evidence, the concrete move, a spec, an impact
83
+ estimate (clicks per month, used **only to order the queue**) and an effort
84
+ size. The rules: pages whose title or description miss the queries they rank
85
+ for; queries ranking well but rarely clicked (CTR gaps); queries at position
86
+ 5–15 (striking distance); probe hygiene failures; landing pages with high
87
+ traffic and low engagement; 28-day traffic drops. These merge with your own
88
+ strategic backlog in `config/backlog.json`, ranked by impact per unit of
89
+ effort. When something ships you mark it *watching* — it stays in view until
90
+ the data says whether it worked.
91
+
92
+ ## Modules
93
+
94
+ Everything below is off by default except the three data steps. Toggle them
95
+ on the Settings page or in `n-seo.config.json`.
96
+
97
+ | Module | What it does | Needs | Default |
98
+ |---|---|---|---|
99
+ | `indexStatus` | Asks the URL Inspection API whether each sitemap URL is indexed | Search Console access | on |
100
+ | `metadataAudit` | Fetches each ranking page's live title/description and scores them against its queries | nothing extra | on |
101
+ | `opportunityScan` | Refreshes trend analysis; flags rising queries no queue item covers | nothing extra | on |
102
+ | `llm` | Runs a local command (default: the `claude` CLI) for scan proposals and digest briefings | any CLI that reads a prompt on stdin | off |
103
+ | `hackerNews` | Finds fresh HN threads in your expertise areas and briefs you | your HN username (optional) | off |
104
+ | `reddit` | The same for subreddits | a free Reddit "script" app's credentials in `.env` | off |
105
+ | `indexNow` | Generates a key and pings Bing/Copilot/Yandex with changed URLs | nothing (does nothing for Google) | off |
106
+ | `staticExport` | Snapshots the dashboard into `site/` as static HTML | nothing | off |
107
+ | `gitAutoCommit` | Commits (and pushes) the daily log and export after each run | a git remote, if you want the push | off |
108
+ | `notifications` | macOS notification when a daily step fails | macOS | off |
109
+
110
+ ## Working with an AI agent
111
+
112
+ The repo ships an MCP server over the same data the dashboard reads, so an
113
+ agent can answer "what should I do first this week?" from the actual queue
114
+ instead of scraping pages. `.mcp.json` registers it for Claude Code
115
+ automatically; Claude Desktop and HTTP clients are covered in
116
+ [docs/MCP.md](docs/MCP.md). It is read-only by design, and `CLAUDE.md` holds
117
+ the operating rules an agent working here has to follow.
118
+
119
+ It also ships skills for the work itself, so the rules are enforced rather
120
+ than merely documented:
121
+
122
+ | Skill | Use it when |
123
+ |---|---|
124
+ | `orient` | First thing in a fresh session — get current in a few reads |
125
+ | `n-seo-setup` | Fresh clone to first real daily run, including Google access |
126
+ | `n-seo-add-site` | Adding a site: property form, `gscHost`, GA4 id, brand regex, grants |
127
+ | `n-seo-triage` | "What should I work on today" from the queue and the last run |
128
+ | `n-seo-ship` | Implement one queue card in the site's repo, then record it as watching |
129
+ | `n-seo-review` | The weekly pass: judge watching items, retire what is done, refresh insights |
130
+
131
+ `.claude/skills/README.md` explains which skills operate an instance and which
132
+ are contributor tooling for developing the engine with [n-dx](https://n-dx.dev).
133
+
134
+ ## Working with n-dx
135
+
136
+ The repo is wired for [n-dx](https://n-dx.dev): `docs/PRD.md` is the product
137
+ definition, `.rex/` holds the PRD tree generated from it (`ndx status .` to
138
+ see it, `ndx next .` for the next actionable task), and `.rex/workflow.md`
139
+ carries the project's execution rules. Run `ndx init .` once after cloning to
140
+ create the local analysis caches (they are gitignored).
141
+
142
+ ## Repo layout
143
+
144
+ ```
145
+ n-seo.config.json your sites, auth, modules (copy from the .example)
146
+ config/ backlog.json (your strategic queue) · insights.json
147
+ content/ drafts/*.md · campaigns/*.json — shown on /content
148
+ bin/n-seo.mjs the CLI: init an instance, run it, upgrade the engine
149
+ src/ dashboard + MCP (Hono, hono/jsx SSR, tsx runtime, no bundler)
150
+ config.ts data.ts actions.ts backlog.ts views.tsx server.tsx mcp.ts
151
+ ingest/ pull_gsc.py · pull_ga4.py · pull_timeseries.py · pull_index_status.py
152
+ analyze_metadata.py · analyze_trends.py · analyze_gsc.py · analyze_ga4.py
153
+ google_auth.py · seo_config.py · http_util.py
154
+ probes/site_probe.py no-auth live health probe
155
+ ops/ daily.py · daily_diff.py · opportunity_scan.py · hn_digest.py · reddit_digest.py
156
+ export_static.py · indexnow.py · doctor.py · demo_data.py · install-launchd.sh · templates/
157
+ docs/ setup, scheduling, playbook, operating rules, PRD.md
158
+ daily-log.md and reports/ (instance-owned)
159
+ tests/ TypeScript (node --test via tsx) + Python (unittest)
160
+ .rex/ n-dx PRD tree, generated from docs/PRD.md
161
+ www/ the marketing site published to GitHub Pages
162
+ data/ machine-refreshed snapshots (gitignored, regenerable)
163
+ ```
164
+
165
+ ## Operating rules
166
+
167
+ The tool encodes a few rules that keep SEO work honest. The short version:
168
+ decisions ride the 90-day window; impact numbers order the queue and are not
169
+ forecasts; after changing a title, leave it alone for 28 days; no more than
170
+ about eight metadata changes a week across all sites; shipped work becomes
171
+ *watching*, never deleted; proposals never enter the queue without your
172
+ accept; community participation is human. The reasoning is in
173
+ [docs/OPERATING-RULES.md](docs/OPERATING-RULES.md); the strategy is in
174
+ [docs/PLAYBOOK.md](docs/PLAYBOOK.md).
175
+
176
+ ## Requirements
177
+
178
+ Node 20 or newer, Python 3.10 or newer, `curl`, `openssl`. macOS or Linux.
179
+ Python is stdlib-only (no pip); the TypeScript app runs under `tsx` with no
180
+ build step.
181
+
182
+ ## Contributing and license
183
+
184
+ See [CONTRIBUTING.md](CONTRIBUTING.md). MIT — see [LICENSE](LICENSE).
package/bin/n-seo.mjs ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * n-seo — run the engine against an instance directory.
4
+ *
5
+ * The engine is wherever this file lives (a git checkout or an npm install);
6
+ * the instance is `--instance <path>`, else $N_SEO_INSTANCE, else the current
7
+ * directory. Every command below just sets N_SEO_INSTANCE and runs the
8
+ * engine's own npm script or Python entry point with cwd = the engine, so the
9
+ * engine can be upgraded without touching a single instance file.
10
+ *
11
+ * n-seo init [dir] scaffold an instance directory
12
+ * n-seo start dashboard for the instance in cwd
13
+ * n-seo daily --only probe
14
+ * n-seo upgrade git pull + npm ci + npm run check, with a rollback hint
15
+ */
16
+ import { spawnSync, execFileSync } from "node:child_process";
17
+ import { createHash } from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { createRequire } from "node:module";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
25
+
26
+ const USAGE = `n-seo ${PKG.version} — an SEO on your machine
27
+
28
+ usage: n-seo <command> [--instance <dir>] [args...]
29
+
30
+ init [dir] scaffold an instance directory (default: cwd); never overwrites
31
+ start run the dashboard for the instance
32
+ dev dashboard with reload on engine code changes
33
+ daily the morning run (args pass through: --only, --skip, --list, …)
34
+ doctor setup checker (--offline for local checks only)
35
+ demo write a synthetic dataset for the instance's configured sites
36
+ mcp the read-only MCP server on stdio (for .mcp.json)
37
+ export static export of the dashboard into <instance>/site/
38
+ check engine self-test: typecheck + unit tests
39
+ upgrade update the engine (git pull / npm ci / check) with a rollback hint
40
+ version engine version, commit, paths, mode
41
+ help this text
42
+
43
+ instance = --instance <dir>, else $N_SEO_INSTANCE, else the current directory.
44
+ engine = ${ROOT}
45
+ `;
46
+
47
+ function parseArgs(argv) {
48
+ const out = { cmd: argv[0] ?? "help", instance: undefined, rest: [] };
49
+ for (let i = 1; i < argv.length; i++) {
50
+ if (argv[i] === "--instance") {
51
+ const value = argv[++i];
52
+ // Silently falling back to the cwd here would point `daily` — and its
53
+ // afterRun hooks — at the wrong directory.
54
+ if (!value || value.startsWith("-")) {
55
+ console.error("--instance needs a directory, e.g. --instance ~/my-sites");
56
+ process.exit(2);
57
+ }
58
+ out.instance = value;
59
+ continue;
60
+ }
61
+ if (argv[i].startsWith("--instance=")) { out.instance = argv[i].slice("--instance=".length); continue; }
62
+ out.rest.push(argv[i]);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function gitCommit() {
68
+ try {
69
+ return execFileSync("git", ["-C", ROOT, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ const isGitEngine = () => fs.existsSync(path.join(ROOT, ".git"));
76
+
77
+ function resolveInstance(flag) {
78
+ const p = flag ?? process.env.N_SEO_INSTANCE ?? process.cwd();
79
+ return path.resolve(p);
80
+ }
81
+
82
+ function run(cmd, args, instance, opts = {}) {
83
+ const r = spawnSync(cmd, args, {
84
+ cwd: ROOT,
85
+ stdio: "inherit",
86
+ env: { ...process.env, N_SEO_INSTANCE: instance, ...(opts.env ?? {}) },
87
+ });
88
+ if (r.error) {
89
+ console.error(`could not run ${cmd}: ${r.error.message}`);
90
+ return 1;
91
+ }
92
+ return r.status ?? 1;
93
+ }
94
+
95
+ /* Resolve the engine's own dependencies.
96
+ *
97
+ * A git checkout has them in <engine>/node_modules; an npm install has them
98
+ * hoisted into the CONSUMER's node_modules, and <engine>/node_modules does not
99
+ * exist at all. Testing for that directory therefore refused to start a
100
+ * perfectly good `npm i n-seo` install. Resolution works for both layouts. */
101
+ const engineRequire = createRequire(path.join(ROOT, "package.json"));
102
+
103
+ function resolvePkgDir(name) {
104
+ try {
105
+ return path.dirname(engineRequire.resolve(`${name}/package.json`));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /** The engine runs TypeScript through tsx, with no build step. */
112
+ function tsxBin() {
113
+ const dir = resolvePkgDir("tsx");
114
+ if (!dir) return null;
115
+ try {
116
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
117
+ const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.tsx;
118
+ return rel ? path.join(dir, rel) : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function missingDeps(cmd, what) {
125
+ const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
126
+ console.error(`${cmd} needs ${what}.`);
127
+ console.error(installed
128
+ ? ` the package looks incomplete — reinstall it: npm i n-seo`
129
+ : ` run: npm ci (in ${ROOT})`);
130
+ return 1;
131
+ }
132
+
133
+ /* ---------- init ---------- */
134
+
135
+ function init(dirArg) {
136
+ const dir = path.resolve(dirArg ?? process.cwd());
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ const name = path.basename(dir);
139
+ const put = (rel, content) => {
140
+ const p = path.join(dir, rel);
141
+ if (fs.existsSync(p)) { console.log(` exists, kept ${rel}`); return; }
142
+ fs.mkdirSync(path.dirname(p), { recursive: true });
143
+ fs.writeFileSync(p, content);
144
+ console.log(` created ${rel}`);
145
+ };
146
+ const example = JSON.parse(fs.readFileSync(path.join(ROOT, "n-seo.config.example.json"), "utf8"));
147
+ example.name = name;
148
+ put("n-seo.config.json", JSON.stringify(example, null, 2) + "\n");
149
+ put("config/backlog.json", JSON.stringify({
150
+ _comment: "Your curated strategic queue. The dashboard merges these with data-derived actions. Add items with evidence (why) and a spec; when something ships, set `watching` to a dated note instead of deleting it. `shippedWatch` maps page URLs to notes so data-derived cards on those pages show as watching too.",
151
+ actions: [],
152
+ shippedWatch: {},
153
+ }, null, 2) + "\n");
154
+ put("config/insights.json", JSON.stringify({
155
+ _comment: "Optional hand-written marketer briefing shown on /insights above the live trend tables. verdict: opportunity | warning | momentum | deprioritize. Leave the list empty to show only the live tables.",
156
+ date: "",
157
+ insights: [],
158
+ }, null, 2) + "\n");
159
+ for (const rel of ["content/drafts/README.md", "content/campaigns/README.md"]) {
160
+ const src = path.join(ROOT, rel);
161
+ put(rel, fs.existsSync(src) ? fs.readFileSync(src, "utf8") : `# ${rel}\n`);
162
+ }
163
+ const envExample = path.join(ROOT, ".env.example");
164
+ put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
165
+ put(".gitignore", "data/\nsite/\n.env\n__pycache__/\nnode_modules/\n");
166
+ put(".mcp.json", JSON.stringify({
167
+ mcpServers: {
168
+ "n-seo": {
169
+ command: "node",
170
+ args: [path.join(ROOT, "bin", "n-seo.mjs"), "mcp"],
171
+ env: { N_SEO_INSTANCE: dir },
172
+ },
173
+ },
174
+ }, null, 2) + "\n");
175
+ put("README.md", `# ${name} — an n-seo instance
176
+
177
+ This directory holds one n-seo instance: the config, the curated queue
178
+ (\`config/backlog.json\`), content (\`content/\`), and the data the daily run
179
+ writes (\`data/\`, gitignored). The engine — the code — lives at:
180
+
181
+ ${ROOT}
182
+
183
+ Run everything from here with the \`n-seo\` CLI (it sets \`N_SEO_INSTANCE\`
184
+ to this directory):
185
+
186
+ \`\`\`sh
187
+ n-seo doctor # is the setup complete?
188
+ n-seo demo # synthetic data to see the dashboard working
189
+ n-seo start # dashboard on the configured port
190
+ n-seo daily # the morning run
191
+ n-seo upgrade # update the engine; your files here are untouched
192
+ \`\`\`
193
+
194
+ Edit \`n-seo.config.json\` for sites, Google access, modules and hooks.
195
+ Docs: ${path.join(ROOT, "docs")}
196
+ `);
197
+ console.log(`
198
+ instance ready at ${dir}
199
+
200
+ next:
201
+ cd ${dir}
202
+ edit n-seo.config.json (sites, google.serviceAccountKey — see ${path.join(ROOT, "docs", "SETUP-GOOGLE.md")})
203
+ n-seo doctor
204
+ n-seo demo && n-seo start (or n-seo daily once the key is in place)
205
+ `);
206
+ return 0;
207
+ }
208
+
209
+ /* ---------- upgrade ---------- */
210
+
211
+ const sha256 = (p) => (fs.existsSync(p) ? createHash("sha256").update(fs.readFileSync(p)).digest("hex") : "");
212
+
213
+ function upgrade(instance) {
214
+ if (!isGitEngine()) {
215
+ console.log("engine installed from npm — run: npm update n-seo");
216
+ return 0;
217
+ }
218
+ const before = gitCommit();
219
+ const lockBefore = sha256(path.join(ROOT, "package-lock.json"));
220
+ console.log(`engine ${ROOT} at ${before ?? "?"} — pulling`);
221
+ let r = spawnSync("git", ["-C", ROOT, "pull", "--ff-only"], { stdio: "inherit" });
222
+ if (r.status !== 0) {
223
+ console.error("git pull --ff-only failed — the engine has local changes or diverged; resolve in the engine checkout and retry");
224
+ return r.status ?? 1;
225
+ }
226
+ const after = gitCommit();
227
+ if (after === before) {
228
+ console.log("already up to date");
229
+ }
230
+ if (sha256(path.join(ROOT, "package-lock.json")) !== lockBefore || !fs.existsSync(path.join(ROOT, "node_modules"))) {
231
+ console.log("lockfile changed — npm ci");
232
+ r = spawnSync("npm", ["ci"], { cwd: ROOT, stdio: "inherit" });
233
+ if (r.status !== 0) {
234
+ console.error(`npm ci failed. Roll back with:\n git -C ${ROOT} reset --hard ${before}`);
235
+ return r.status ?? 1;
236
+ }
237
+ }
238
+ console.log("running the engine's checks");
239
+ r = spawnSync("npm", ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
240
+ if (r.status !== 0) {
241
+ console.error(`\nengine checks FAILED at ${after ?? "?"}. Previous commit: ${before ?? "?"}. Roll back with:\n git -C ${ROOT} reset --hard ${before}\n (then npm ci in ${ROOT} if the lockfile moved)`);
242
+ return 1;
243
+ }
244
+ console.log(`\nengine ${before ?? "?"} → ${after ?? "?"}: checks pass. Restart the dashboard to pick it up.`);
245
+ return 0;
246
+ }
247
+
248
+ /* ---------- main ---------- */
249
+
250
+ const { cmd, instance: flag, rest } = parseArgs(process.argv.slice(2));
251
+ const instance = resolveInstance(flag);
252
+ const py = process.env.PYTHON ?? "python3";
253
+ let code = 0;
254
+
255
+ switch (cmd) {
256
+ case "init":
257
+ code = init(rest[0] ?? flag ?? process.cwd());
258
+ break;
259
+ case "start":
260
+ case "dev":
261
+ case "mcp": {
262
+ // Spawn tsx directly rather than through `npm run`, so the command does
263
+ // not depend on npm resolving a bin from inside node_modules/n-seo.
264
+ const bin = tsxBin();
265
+ if (!bin) {
266
+ code = missingDeps(cmd, "the engine's dependencies");
267
+ break;
268
+ }
269
+ const entry = cmd === "mcp" ? "src/mcp-stdio.ts" : "src/server.tsx";
270
+ const args = cmd === "dev" ? ["watch", entry] : [entry];
271
+ code = run(process.execPath, [bin, ...args, ...rest], instance);
272
+ break;
273
+ }
274
+ case "check":
275
+ // The self-test needs devDependencies, which an npm install omits.
276
+ code = resolvePkgDir("typescript")
277
+ ? run("npm", ["run", "check", "--silent", "--", ...rest], instance)
278
+ : missingDeps(cmd, "the engine's dev dependencies (it is the engine's own test suite)");
279
+ break;
280
+ case "daily":
281
+ code = run(py, [path.join(ROOT, "ops", "daily.py"), ...rest], instance);
282
+ break;
283
+ case "doctor":
284
+ code = run(py, [path.join(ROOT, "ops", "doctor.py"), ...rest], instance);
285
+ break;
286
+ case "demo":
287
+ code = run(py, [path.join(ROOT, "ops", "demo_data.py"), ...rest], instance);
288
+ break;
289
+ case "export":
290
+ code = run(py, [path.join(ROOT, "ops", "export_static.py"), ...rest], instance);
291
+ break;
292
+ case "upgrade":
293
+ code = upgrade(instance);
294
+ break;
295
+ case "version": {
296
+ const mode = instance === ROOT ? "in-place" : "instance";
297
+ console.log(`n-seo ${PKG.version}${gitCommit() ? ` (${gitCommit()})` : ""}\nengine: ${ROOT}\ninstance: ${instance}\nmode: ${mode}`);
298
+ break;
299
+ }
300
+ case "help":
301
+ case "--help":
302
+ case "-h":
303
+ process.stdout.write(USAGE);
304
+ break;
305
+ default:
306
+ console.error(`unknown command: ${cmd}\n`);
307
+ process.stdout.write(USAGE);
308
+ code = 2;
309
+ }
310
+ process.exit(code);
@@ -0,0 +1,82 @@
1
+ # Adding a site
2
+
3
+ One entry in `n-seo.config.json`. There are no per-script site lists:
4
+ every pull, probe, audit, page and export reads the same `sites` array.
5
+
6
+ ```json
7
+ {
8
+ "host": "docs.example.com",
9
+ "label": "docs",
10
+ "gscProperty": "sc-domain:example.com",
11
+ "gscHost": "docs.example.com",
12
+ "ga4Property": "987654321",
13
+ "brand": "example|exmpl",
14
+ "repo": "../example-docs",
15
+ "hosting": "GitHub Pages"
16
+ }
17
+ ```
18
+
19
+ | Field | Required | Meaning |
20
+ |---|---|---|
21
+ | `host` | yes | Canonical hostname. The URL slug in the dashboard (`/site/docs.example.com`) and the directory name under `data/ga4/` |
22
+ | `label` | no | Short display name; defaults to `host` |
23
+ | `gscProperty` | for GSC data | The Search Console property exactly as the console shows it: `sc-domain:example.com` (domain property) or `https://www.example.com/` (URL-prefix property, trailing slash included). Leave empty for a site without Search Console; it will still be probed |
24
+ | `gscHost` | no | Hostname to filter page URLs by when the property covers more than this host (see below). Defaults to `host`. Use the hostname the site actually serves on — `www.example.com` if the apex redirects to www |
25
+ | `ga4Property` | for GA4 data | Numeric GA4 property id (Admin → Property details). Not the `G-…` measurement id. Leave empty for no GA4 |
26
+ | `brand` | no | Case-insensitive regex matching your branded queries, for the branded/generic split on `/insights`. Several hosts on one property are OR-ed together |
27
+ | `repo` | no | Where the site's code lives, shown on the site page so actions can name it |
28
+ | `hosting` | no | Free text, shown on the site page |
29
+
30
+ ## Domain properties and subdomains
31
+
32
+ A `sc-domain:` property covers every subdomain and protocol. If
33
+ `example.com` and `docs.example.com` are separate sites for you but one
34
+ property in Search Console, give both entries the same `gscProperty` and a
35
+ different `gscHost`. The pull happens once per property (into
36
+ `data/gsc/example.com/`) and each site's pages are filtered by `gscHost` when
37
+ read. URL-prefix properties are per host by nature; each gets its own
38
+ directory.
39
+
40
+ ## Access grants
41
+
42
+ The service account needs to be a user on the new property in Search Console
43
+ (Full) and on the GA4 property (Viewer). If it is already Viewer at the GA4
44
+ *account* level, new properties under that account inherit it. Details and
45
+ the DNS-verification alternative for stubborn domain properties are in
46
+ [SETUP-GOOGLE.md](SETUP-GOOGLE.md).
47
+
48
+ ## Check, then wait for the next run
49
+
50
+ ```sh
51
+ python3 ops/doctor.py
52
+ ```
53
+
54
+ The doctor confirms the service account can see the property and the GA4
55
+ id resolves. Then either wait for the next scheduled run or run it now:
56
+
57
+ ```sh
58
+ python3 ops/daily.py
59
+ ```
60
+
61
+ The site appears on the overview strip, gets its own `/site/<host>` page, is
62
+ probed, audited, swept for index coverage, and shows on `/trends` — with a
63
+ flat "no traffic recorded yet" row rather than silently missing if the data
64
+ is empty, so a brand-new site is visibly connected even before it ranks.
65
+
66
+ ## Removing a site
67
+
68
+ Delete its entry. Its data under `data/` stays until you remove it (or run
69
+ `npm run demo -- --clean`, which clears the whole `data/` tree). Backlog
70
+ items for that host in `config/backlog.json` remain and still render on
71
+ `/actions`; retire or delete them.
72
+
73
+ ## Adding a site to the watch list
74
+
75
+ `watchPages` is a flat list of URLs whose GSC numbers appear in every daily
76
+ log entry. Add the pages you have just changed there so the log tracks them:
77
+
78
+ ```json
79
+ "watchPages": [
80
+ "https://docs.example.com/getting-started"
81
+ ]
82
+ ```