n-seo 0.1.1 → 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.
- package/.claude/skills/n-seo-add-site/SKILL.md +125 -0
- package/.claude/skills/n-seo-deploy/SKILL.md +171 -0
- package/.claude/skills/n-seo-review/SKILL.md +106 -0
- package/.claude/skills/n-seo-setup/SKILL.md +139 -0
- package/.claude/skills/n-seo-ship/SKILL.md +122 -0
- package/.claude/skills/n-seo-triage/SKILL.md +90 -0
- package/.claude/skills/orient/SKILL.md +54 -0
- package/README.md +190 -49
- package/bin/n-seo.mjs +177 -10
- package/docs/ADDING-A-SITE.md +4 -0
- package/docs/DEPLOY.md +4 -0
- package/docs/FAQ.md +46 -6
- package/docs/INSTANCE.md +54 -0
- package/docs/MCP.md +25 -9
- package/docs/PRD.md +29 -9
- package/docs/SCHEDULING.md +46 -5
- package/docs/SETUP-GOOGLE.md +8 -1
- package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
- package/ingest/analyze_ga4.py +2 -2
- package/ingest/analyze_gsc.py +2 -2
- package/ingest/analyze_metadata.py +2 -2
- package/ingest/analyze_trends.py +2 -2
- package/ingest/google_auth.py +53 -20
- package/ingest/pull_ga4.py +1 -1
- package/ingest/pull_gsc.py +1 -1
- package/ingest/pull_index_status.py +1 -1
- package/ingest/pull_timeseries.py +3 -3
- package/ingest/seo_config.py +19 -3
- package/ops/__pycache__/daily.cpython-312.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
- package/ops/__pycache__/llm.cpython-312.pyc +0 -0
- package/ops/__pycache__/publish.cpython-312.pyc +0 -0
- package/ops/daily.py +2 -2
- package/ops/daily_diff.py +9 -8
- package/ops/demo_data.py +23 -17
- package/ops/doctor.py +30 -7
- package/ops/export_static.py +10 -5
- package/ops/hn_digest.py +1 -1
- package/ops/indexnow.py +3 -3
- package/ops/llm.py +23 -1
- package/ops/opportunity_scan.py +4 -4
- package/ops/py.mjs +40 -0
- package/ops/reddit_digest.py +1 -1
- package/ops/templates/n-seo-daily-task.xml +65 -0
- package/package.json +24 -9
- package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
- package/probes/site_probe.py +1 -1
- package/public/fonts/OFL.txt +105 -0
- package/public/fonts/montserrat-latin-var.woff2 +0 -0
- package/public/styles.css +18 -1
- package/src/actions.ts +1 -1
- package/src/config.ts +1 -1
- package/src/mcp.ts +2 -2
- package/src/server.tsx +22 -1
- package/src/settings.tsx +3 -3
- package/src/views.tsx +79 -15
package/bin/n-seo.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* n-seo upgrade git pull + npm ci + npm run check, with a rollback hint
|
|
15
15
|
*/
|
|
16
16
|
import { spawnSync, execFileSync } from "node:child_process";
|
|
17
|
+
import { pythonBin } from "../ops/py.mjs";
|
|
17
18
|
import { createHash } from "node:crypto";
|
|
18
19
|
import fs from "node:fs";
|
|
19
20
|
import path from "node:path";
|
|
@@ -27,7 +28,9 @@ const USAGE = `n-seo ${PKG.version} — an SEO on your machine
|
|
|
27
28
|
|
|
28
29
|
usage: n-seo <command> [--instance <dir>] [args...]
|
|
29
30
|
|
|
30
|
-
init [dir] scaffold
|
|
31
|
+
init [dir] scaffold a standalone instance directory (default: cwd); never
|
|
32
|
+
overwrites, and refuses to scaffold inside a website's repo
|
|
33
|
+
(--force overrides)
|
|
31
34
|
start run the dashboard for the instance
|
|
32
35
|
dev dashboard with reload on engine code changes
|
|
33
36
|
daily the morning run (args pass through: --only, --skip, --list, …)
|
|
@@ -121,6 +124,15 @@ function tsxBin() {
|
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/* npm, npx and similar arrive as `.cmd` batch shims on Windows, which
|
|
128
|
+
* spawnSync cannot execute without a shell — a bare `npm` fails there with
|
|
129
|
+
* ENOENT. (The Python interpreter has the same problem; ops/py.mjs solves
|
|
130
|
+
* it, and this file imports pythonBin from there so the CLI and the npm
|
|
131
|
+
* scripts resolve the interpreter identically.) */
|
|
132
|
+
function npmBin(name = "npm") {
|
|
133
|
+
return process.platform === "win32" ? `${name}.cmd` : name;
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
function missingDeps(cmd, what) {
|
|
125
137
|
const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
|
|
126
138
|
console.error(`${cmd} needs ${what}.`);
|
|
@@ -132,8 +144,87 @@ function missingDeps(cmd, what) {
|
|
|
132
144
|
|
|
133
145
|
/* ---------- init ---------- */
|
|
134
146
|
|
|
135
|
-
|
|
147
|
+
/** Files that mean "this directory is an application", not a place to keep
|
|
148
|
+
* search-ops files. n-seo is a standalone project: it writes a config, a
|
|
149
|
+
* queue, drafts and a data/ tree that the daily run rewrites every morning.
|
|
150
|
+
* Dropped into a website's repo those files get committed, deployed, and
|
|
151
|
+
* eventually served — and `n-seo upgrade` starts fighting the site's own
|
|
152
|
+
* package.json. The instance sits beside the sites it watches, never inside
|
|
153
|
+
* one. */
|
|
154
|
+
const APP_MARKERS = [
|
|
155
|
+
"package.json", "index.html", "next.config.js", "next.config.mjs", "next.config.ts",
|
|
156
|
+
"vite.config.js", "vite.config.ts", "astro.config.mjs", "nuxt.config.ts",
|
|
157
|
+
"svelte.config.js", "gatsby-config.js", "remix.config.js", "angular.json",
|
|
158
|
+
"Gemfile", "go.mod", "Cargo.toml", "pyproject.toml", "composer.json",
|
|
159
|
+
"hugo.toml", "config.toml", "_config.yml", "wp-config.php", "Dockerfile",
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
/** The nearest ancestor holding a .git, or null. */
|
|
163
|
+
function gitRootOf(dir) {
|
|
164
|
+
let cur = path.resolve(dir);
|
|
165
|
+
for (;;) {
|
|
166
|
+
if (fs.existsSync(path.join(cur, ".git"))) return cur;
|
|
167
|
+
const up = path.dirname(cur);
|
|
168
|
+
if (up === cur) return null;
|
|
169
|
+
cur = up;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Why this directory is the wrong place for an instance, or null if it is
|
|
174
|
+
* fine.
|
|
175
|
+
*
|
|
176
|
+
* `forChild` means the instance will be a NEW directory created inside
|
|
177
|
+
* `dir`, which changes one answer: a repository root is a fine place to put
|
|
178
|
+
* an instance (it becomes its own repo) but a terrible parent for one (the
|
|
179
|
+
* new directory lands inside that repo's working tree).
|
|
180
|
+
*
|
|
181
|
+
* Re-running init on an existing instance is always allowed. */
|
|
182
|
+
function wrongPlaceFor(dir, forChild = false) {
|
|
183
|
+
if (fs.existsSync(path.join(dir, "n-seo.config.json"))) return null;
|
|
184
|
+
// In-place mode: the engine checkout is the instance. It has a package.json
|
|
185
|
+
// and a .git of its own, both of which would otherwise trip every check.
|
|
186
|
+
if (!forChild && path.resolve(dir) === ROOT) return null;
|
|
187
|
+
const marker = APP_MARKERS.find((f) => fs.existsSync(path.join(dir, f)));
|
|
188
|
+
if (marker) {
|
|
189
|
+
return forChild
|
|
190
|
+
? `${dir} contains ${marker} — an instance created here would sit inside an application`
|
|
191
|
+
: `${dir} contains ${marker} — that is an application, not an n-seo instance`;
|
|
192
|
+
}
|
|
193
|
+
const root = gitRootOf(dir);
|
|
194
|
+
const insideSomeoneElsesRepo = root && (forChild || root !== path.resolve(dir));
|
|
195
|
+
if (insideSomeoneElsesRepo && !fs.existsSync(path.join(root, "n-seo.config.json"))) {
|
|
196
|
+
return `${dir} is inside the git repository at ${root} — the instance would be committed to it`;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function init(dirArg, force = false) {
|
|
136
202
|
const dir = path.resolve(dirArg ?? process.cwd());
|
|
203
|
+
// A directory that does not exist yet inherits its parent's problem: a new
|
|
204
|
+
// folder created inside a site repo is still inside that site repo.
|
|
205
|
+
const wrong = fs.existsSync(dir) ? wrongPlaceFor(dir) : wrongPlaceFor(path.dirname(dir), true);
|
|
206
|
+
if (wrong && !force) {
|
|
207
|
+
console.error(`n-seo init: refusing to scaffold here.
|
|
208
|
+
|
|
209
|
+
${wrong}
|
|
210
|
+
|
|
211
|
+
n-seo is a standalone project, not something you add to a website. It keeps a
|
|
212
|
+
config, an action queue, content drafts and a data/ tree that the daily run
|
|
213
|
+
rewrites every morning; inside a site repo those get committed and deployed.
|
|
214
|
+
The instance watches your sites over the Search Console API — it never needs
|
|
215
|
+
to live in their code.
|
|
216
|
+
|
|
217
|
+
Do this instead:
|
|
218
|
+
|
|
219
|
+
cd ~ # anywhere outside your site repos
|
|
220
|
+
n-seo init my-sites
|
|
221
|
+
cd my-sites
|
|
222
|
+
|
|
223
|
+
Then list the sites you own in my-sites/n-seo.config.json.
|
|
224
|
+
|
|
225
|
+
If you really mean it here, pass --force.`);
|
|
226
|
+
return 2;
|
|
227
|
+
}
|
|
137
228
|
fs.mkdirSync(dir, { recursive: true });
|
|
138
229
|
const name = path.basename(dir);
|
|
139
230
|
const put = (rel, content) => {
|
|
@@ -160,6 +251,59 @@ function init(dirArg) {
|
|
|
160
251
|
const src = path.join(ROOT, rel);
|
|
161
252
|
put(rel, fs.existsSync(src) ? fs.readFileSync(src, "utf8") : `# ${rel}\n`);
|
|
162
253
|
}
|
|
254
|
+
// The skills are the product's front door: with them in place the instance
|
|
255
|
+
// is a directory an agent can be pointed at, and setup / triage / shipping
|
|
256
|
+
// become a conversation instead of a docs-reading exercise. The engine's
|
|
257
|
+
// own contributor skills (ndx-*) stay behind — they are for working on
|
|
258
|
+
// n-seo, not with it. Placeholders become this install's real paths so the
|
|
259
|
+
// commands in them are copy-pasteable.
|
|
260
|
+
const INSTANCE_SKILLS = ["orient", "n-seo-setup", "n-seo-add-site", "n-seo-triage", "n-seo-ship", "n-seo-review", "n-seo-deploy"];
|
|
261
|
+
let skillsCopied = 0;
|
|
262
|
+
for (const skill of INSTANCE_SKILLS) {
|
|
263
|
+
const src = path.join(ROOT, ".claude", "skills", skill, "SKILL.md");
|
|
264
|
+
if (!fs.existsSync(src)) continue;
|
|
265
|
+
const body = fs
|
|
266
|
+
.readFileSync(src, "utf8")
|
|
267
|
+
.replaceAll("<engine checkout>/bin/n-seo.mjs", path.join(ROOT, "bin", "n-seo.mjs"))
|
|
268
|
+
.replaceAll("<instance dir>", dir);
|
|
269
|
+
put(path.join(".claude", "skills", skill, "SKILL.md"), body);
|
|
270
|
+
skillsCopied++;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
put("CLAUDE.md", `# ${name} — an n-seo instance
|
|
274
|
+
|
|
275
|
+
This directory is an n-seo instance: config, the curated action queue, content
|
|
276
|
+
drafts, and the data the daily run writes. It is **not** a website. The sites
|
|
277
|
+
it watches live in their own repositories and are read over the Search Console
|
|
278
|
+
and GA4 APIs.
|
|
279
|
+
|
|
280
|
+
Engine (the code): ${ROOT}
|
|
281
|
+
Docs: ${path.join(ROOT, "docs")}
|
|
282
|
+
|
|
283
|
+
## Working here
|
|
284
|
+
|
|
285
|
+
Skills in \`.claude/skills/\` cover the routine work — start with
|
|
286
|
+
\`/orient\` in a fresh session, then \`/n-seo-triage\` for what to do today,
|
|
287
|
+
\`/n-seo-ship\` to implement one card, \`/n-seo-review\` for the weekly pass.
|
|
288
|
+
|
|
289
|
+
## Rules that are not negotiable
|
|
290
|
+
|
|
291
|
+
- **28-day metadata freeze.** After a page's title or description changes,
|
|
292
|
+
leave that page's metadata alone for 28 days. Title churn reads as
|
|
293
|
+
manipulation and resets Google's evaluation.
|
|
294
|
+
- **At most ~8 title/description changes a week** across all sites.
|
|
295
|
+
- **Impact numbers order the queue. They are not forecasts.** Never report
|
|
296
|
+
one as an expected result.
|
|
297
|
+
- **Decisions ride the trailing 90 days.** The 16-month data is for totals
|
|
298
|
+
and history only.
|
|
299
|
+
- **Shipped work becomes \`watching\`, never deleted** — that is how the loop
|
|
300
|
+
closes.
|
|
301
|
+
- **Machine proposals never self-promote.** They wait in the holding area on
|
|
302
|
+
/actions until a human accepts them.
|
|
303
|
+
- **Site changes ship as branches and pull requests** in the site's own repo,
|
|
304
|
+
never committed straight to its main branch.
|
|
305
|
+
`);
|
|
306
|
+
|
|
163
307
|
const envExample = path.join(ROOT, ".env.example");
|
|
164
308
|
put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
|
|
165
309
|
put(".gitignore", "data/\nsite/\n.env\n__pycache__/\nnode_modules/\n");
|
|
@@ -193,15 +337,38 @@ n-seo upgrade # update the engine; your files here are untouched
|
|
|
193
337
|
|
|
194
338
|
Edit \`n-seo.config.json\` for sites, Google access, modules and hooks.
|
|
195
339
|
Docs: ${path.join(ROOT, "docs")}
|
|
340
|
+
|
|
341
|
+
## Or just ask
|
|
342
|
+
|
|
343
|
+
n-seo is built to be driven by an AI agent. Open this directory in Claude Code
|
|
344
|
+
(or any agent that reads \`.claude/skills/\`) and talk to it:
|
|
345
|
+
|
|
346
|
+
"set this up for my sites" → /n-seo-setup
|
|
347
|
+
"what should I work on today?" → /n-seo-triage
|
|
348
|
+
"do the first one" → /n-seo-ship
|
|
349
|
+
"how did last month go?" → /n-seo-review
|
|
350
|
+
|
|
351
|
+
A read-only MCP server is wired up in \`.mcp.json\`, so the agent reads the
|
|
352
|
+
same queue and metrics the dashboard shows. It can propose and implement;
|
|
353
|
+
it cannot publish, post, or change your sites behind your back.
|
|
196
354
|
`);
|
|
197
355
|
console.log(`
|
|
198
356
|
instance ready at ${dir}
|
|
357
|
+
a standalone project — your websites stay in their own repos, untouched
|
|
199
358
|
|
|
200
359
|
next:
|
|
201
360
|
cd ${dir}
|
|
202
|
-
|
|
203
|
-
n-seo
|
|
204
|
-
n-seo
|
|
361
|
+
n-seo demo && n-seo start see the dashboard working on synthetic data
|
|
362
|
+
edit n-seo.config.json your sites + google.serviceAccountKey
|
|
363
|
+
n-seo doctor checks the setup and names what is missing
|
|
364
|
+
|
|
365
|
+
or hand it to an agent — ${skillsCopied} skills are installed in .claude/skills/:
|
|
366
|
+
cd ${dir} && claude
|
|
367
|
+
"set this up for my sites" → /n-seo-setup walks the whole thing, including
|
|
368
|
+
the Google service account and both grants
|
|
369
|
+
later: "what should I work on today?" → /n-seo-triage
|
|
370
|
+
|
|
371
|
+
setup guide: ${path.join(ROOT, "docs", "SETUP-GOOGLE.md")}
|
|
205
372
|
`);
|
|
206
373
|
return 0;
|
|
207
374
|
}
|
|
@@ -229,14 +396,14 @@ function upgrade(instance) {
|
|
|
229
396
|
}
|
|
230
397
|
if (sha256(path.join(ROOT, "package-lock.json")) !== lockBefore || !fs.existsSync(path.join(ROOT, "node_modules"))) {
|
|
231
398
|
console.log("lockfile changed — npm ci");
|
|
232
|
-
r = spawnSync(
|
|
399
|
+
r = spawnSync(npmBin(), ["ci"], { cwd: ROOT, stdio: "inherit" });
|
|
233
400
|
if (r.status !== 0) {
|
|
234
401
|
console.error(`npm ci failed. Roll back with:\n git -C ${ROOT} reset --hard ${before}`);
|
|
235
402
|
return r.status ?? 1;
|
|
236
403
|
}
|
|
237
404
|
}
|
|
238
405
|
console.log("running the engine's checks");
|
|
239
|
-
r = spawnSync(
|
|
406
|
+
r = spawnSync(npmBin(), ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
|
|
240
407
|
if (r.status !== 0) {
|
|
241
408
|
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
409
|
return 1;
|
|
@@ -249,12 +416,12 @@ function upgrade(instance) {
|
|
|
249
416
|
|
|
250
417
|
const { cmd, instance: flag, rest } = parseArgs(process.argv.slice(2));
|
|
251
418
|
const instance = resolveInstance(flag);
|
|
252
|
-
const py =
|
|
419
|
+
const py = pythonBin();
|
|
253
420
|
let code = 0;
|
|
254
421
|
|
|
255
422
|
switch (cmd) {
|
|
256
423
|
case "init":
|
|
257
|
-
code = init(rest
|
|
424
|
+
code = init(rest.find((a) => !a.startsWith("-")) ?? flag ?? process.cwd(), rest.includes("--force"));
|
|
258
425
|
break;
|
|
259
426
|
case "start":
|
|
260
427
|
case "dev":
|
|
@@ -274,7 +441,7 @@ switch (cmd) {
|
|
|
274
441
|
case "check":
|
|
275
442
|
// The self-test needs devDependencies, which an npm install omits.
|
|
276
443
|
code = resolvePkgDir("typescript")
|
|
277
|
-
? run(
|
|
444
|
+
? run(npmBin(), ["run", "check", "--silent", "--", ...rest], instance)
|
|
278
445
|
: missingDeps(cmd, "the engine's dev dependencies (it is the engine's own test suite)");
|
|
279
446
|
break;
|
|
280
447
|
case "daily":
|
package/docs/ADDING-A-SITE.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
One entry in `n-seo.config.json`. There are no per-script site lists:
|
|
4
4
|
every pull, probe, audit, page and export reads the same `sites` array.
|
|
5
5
|
|
|
6
|
+
> Or say *"add example.com"* in the instance directory: the `n-seo-add-site`
|
|
7
|
+
> skill picks the right property form, finds the numeric GA4 id, proposes a
|
|
8
|
+
> brand regex, and reminds you about both access grants.
|
|
9
|
+
|
|
6
10
|
```json
|
|
7
11
|
{
|
|
8
12
|
"host": "docs.example.com",
|
package/docs/DEPLOY.md
CHANGED
|
@@ -7,6 +7,10 @@ it sleeps, it travels, and the run silently skips a day.
|
|
|
7
7
|
|
|
8
8
|
This document is about moving it to a box that is always on.
|
|
9
9
|
|
|
10
|
+
> Or say *"get this off my laptop"* in the instance directory — the
|
|
11
|
+
> `n-seo-deploy` skill runs this procedure, and refuses to expose the
|
|
12
|
+
> dashboard port to the internet whatever you ask it for.
|
|
13
|
+
|
|
10
14
|
## The shape
|
|
11
15
|
|
|
12
16
|
One host runs two long-lived things — the dashboard and a scheduler that
|
package/docs/FAQ.md
CHANGED
|
@@ -5,6 +5,25 @@ Nothing. The Search Console and GA4 APIs are free within quotas that ordinary
|
|
|
5
5
|
use never approaches. The optional LLM module runs whatever local command you
|
|
6
6
|
give it; if that is a paid CLI, that is your cost, not the tool's.
|
|
7
7
|
|
|
8
|
+
**Can I install it inside my website's repository?**
|
|
9
|
+
No — and `n-seo init` refuses to, so you cannot do it by accident. n-seo is a
|
|
10
|
+
standalone project. It writes a config, an action queue, content drafts and a
|
|
11
|
+
`data/` tree that the daily run rewrites every morning; inside a site repo all
|
|
12
|
+
of that gets committed, and usually deployed. It reads your sites through the
|
|
13
|
+
Search Console and GA4 APIs, so it has no reason to sit in their code. Put the
|
|
14
|
+
instance anywhere else — `~/my-sites` is fine — and list the sites you own in
|
|
15
|
+
its config. One instance can watch as many sites as you like. `--force`
|
|
16
|
+
overrides the check if you have a reason.
|
|
17
|
+
|
|
18
|
+
**Do I have to read all these docs?**
|
|
19
|
+
No. `n-seo init` installs seven skills into your instance, so setup and daily
|
|
20
|
+
use are a conversation: open the directory in Claude Code (or any agent that
|
|
21
|
+
reads `.claude/skills/`) and say "set this up for my sites", "what should I
|
|
22
|
+
work on today?", "do the first one". The agent reads the queue over a
|
|
23
|
+
read-only MCP server, follows the operating rules in the generated
|
|
24
|
+
`CLAUDE.md`, and cannot publish or change a site on its own. The docs are the
|
|
25
|
+
reference behind the skills, not a prerequisite.
|
|
26
|
+
|
|
8
27
|
**Does it change my site?**
|
|
9
28
|
No. It reads your data, probes your pages, and produces a queue of actions
|
|
10
29
|
with evidence and specs. You make the change in your own repo and ship it.
|
|
@@ -28,8 +47,19 @@ proposals and verdicts to the opportunity scan and briefings to the digests.
|
|
|
28
47
|
Nothing it produces is applied automatically.
|
|
29
48
|
|
|
30
49
|
**Does it run on Windows?**
|
|
31
|
-
|
|
32
|
-
|
|
50
|
+
Yes, since 0.2.0, and CI runs the full test suite on Windows on every commit
|
|
51
|
+
rather than taking the claim on trust. You need Node 20+ and Python 3.10+;
|
|
52
|
+
`curl` ships with Windows 10 and later. There is no `openssl` requirement on
|
|
53
|
+
any platform any more — the service-account JWT is signed with node's crypto
|
|
54
|
+
module. Schedule the daily run with Task Scheduler, using the task definition
|
|
55
|
+
in `ops/templates/n-seo-daily-task.xml` (see [SCHEDULING.md](SCHEDULING.md)).
|
|
56
|
+
|
|
57
|
+
Two differences worth knowing. Commands in the `hooks` block go to `cmd.exe`
|
|
58
|
+
rather than `/bin/sh`, so write them in its syntax or point them at a script.
|
|
59
|
+
And use `n-seo daily` (or `npm run daily`) rather than `python3 ops/daily.py`:
|
|
60
|
+
Windows installs Python as `python`, and the `python3.exe` it ships is a stub
|
|
61
|
+
that opens the Microsoft Store instead of running anything. The CLI finds the
|
|
62
|
+
real interpreter for you, and `$PYTHON` overrides it.
|
|
33
63
|
|
|
34
64
|
**Where does my data go?**
|
|
35
65
|
Into `data/` on the machine that runs the pipeline, as JSON. The only network
|
|
@@ -78,12 +108,22 @@ shown flat rather than omitted so you can tell "connected and quiet" from
|
|
|
78
108
|
"missing from the pipeline."
|
|
79
109
|
|
|
80
110
|
**The indexing page lists a "Soft 404" for a page that is fine.**
|
|
81
|
-
Check the "last crawled" column. A verdict is only as current as the
|
|
82
|
-
behind it
|
|
83
|
-
|
|
111
|
+
Check the "last crawled" column first. A verdict is only as current as the
|
|
112
|
+
crawl behind it, and the page flags verdicts older than 90 days as stale — a
|
|
113
|
+
page rewritten since then is being judged on content Google has not seen. For
|
|
114
|
+
a stale verdict, request indexing once and let the recrawl settle it.
|
|
115
|
+
|
|
116
|
+
For a *fresh* soft 404, requesting indexing is the wrong move: Google already
|
|
117
|
+
fetched the page, got a 200, and decided the content was an error or empty.
|
|
118
|
+
Asking again re-runs the same judgement and spends the daily quota. Fetch the
|
|
119
|
+
URL yourself and pick the real cause — the page renders its content in
|
|
120
|
+
JavaScript and the crawler saw an empty shell, or it is genuinely thin, or it
|
|
121
|
+
is an error page returning 200 and should return 404 or 410 and leave the
|
|
122
|
+
sitemap. Fix that, then request indexing. n-seo's indexing page names the
|
|
123
|
+
check to run for each verdict.
|
|
84
124
|
|
|
85
125
|
**A daily step failed. What now?**
|
|
86
|
-
Open `/logs` (or `data/daily-ops.log`), then `
|
|
126
|
+
Open `/logs` (or `data/daily-ops.log`), then run `n-seo doctor`. A 401
|
|
87
127
|
or 403 from Google means the service account lost access or the key file
|
|
88
128
|
moved. A run that fails every network step at once was offline; the next run
|
|
89
129
|
will recover. Steps are independent — one failing does not stop the others.
|
package/docs/INSTANCE.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Running n-seo as an engine + instance
|
|
2
2
|
|
|
3
|
+
> **An instance is a standalone project.** It is never a subdirectory of a
|
|
4
|
+
> website you are optimising. n-seo reads your sites through the Search
|
|
5
|
+
> Console and GA4 APIs; it has no reason to live in their code, and living
|
|
6
|
+
> there means committing and deploying its config, its queue and a `data/`
|
|
7
|
+
> tree that is rewritten every morning.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
~/
|
|
11
|
+
├── code/
|
|
12
|
+
│ ├── my-website/ a site you own — untouched
|
|
13
|
+
│ └── docs-site/ another one — untouched
|
|
14
|
+
└── my-sites/ the instance
|
|
15
|
+
├── n-seo.config.json both sites listed here
|
|
16
|
+
├── config/backlog.json the curated queue
|
|
17
|
+
├── content/ drafts, campaigns
|
|
18
|
+
├── .claude/skills/ the operating skills
|
|
19
|
+
├── CLAUDE.md the operating rules
|
|
20
|
+
└── data/ the daily run's output, gitignored
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
One instance watches as many sites as you own, and `n-seo init` refuses to
|
|
24
|
+
scaffold into an application directory or a git repository it does not own
|
|
25
|
+
(`--force` overrides).
|
|
26
|
+
|
|
3
27
|
n-seo can run two ways. In the simplest, you clone the repo, put your config
|
|
4
28
|
in the checkout and run it there. In the second, the checkout is an **engine**
|
|
5
29
|
you never edit, and everything that is yours — config, queue, content, data,
|
|
@@ -93,6 +117,36 @@ and `content/campaigns/README.md`, `.env` (from `.env.example`), a
|
|
|
93
117
|
`.gitignore` that excludes `data/`, `site/` and `.env`, a `.mcp.json` that
|
|
94
118
|
spawns the engine's MCP server with `N_SEO_INSTANCE` set, and a short README.
|
|
95
119
|
|
|
120
|
+
It also makes the instance agent-ready, which is how most people should drive
|
|
121
|
+
it:
|
|
122
|
+
|
|
123
|
+
- `.claude/skills/` — the seven operating skills (`orient`, `n-seo-setup`,
|
|
124
|
+
`n-seo-add-site`, `n-seo-triage`, `n-seo-ship`, `n-seo-review`,
|
|
125
|
+
`n-seo-deploy`), copied from the engine with this install's real engine and
|
|
126
|
+
instance paths substituted in, so every command in them is copy-pasteable.
|
|
127
|
+
The engine's contributor skills (`ndx-*`) are not copied — they are for
|
|
128
|
+
developing n-seo, not operating it.
|
|
129
|
+
- `CLAUDE.md` — the operating rules an agent working in this directory has to
|
|
130
|
+
follow: the 28-day freeze, the weekly metadata budget, impact-is-not-a-
|
|
131
|
+
forecast, decisions on the 90-day window, shipped-becomes-watching,
|
|
132
|
+
proposals never self-promote, site changes ship as pull requests.
|
|
133
|
+
|
|
134
|
+
With those two files plus `.mcp.json`, opening the instance in Claude Code is
|
|
135
|
+
enough: it can read the queue and the metrics, and it already knows the rules.
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
cd ~/sites/search-ops && claude
|
|
139
|
+
"set this up for my sites" → /n-seo-setup
|
|
140
|
+
"what should I work on today?" → /n-seo-triage
|
|
141
|
+
"do the first one" → /n-seo-ship
|
|
142
|
+
"how did last month go?" → /n-seo-review
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Both files are yours once written. `n-seo init` never overwrites, so editing
|
|
146
|
+
`CLAUDE.md` to add your own rules survives every re-run and every engine
|
|
147
|
+
upgrade. To pick up improved skills from a newer engine, delete the ones you
|
|
148
|
+
have not customised and re-run `n-seo init`.
|
|
149
|
+
|
|
96
150
|
To make `n-seo` a command, link the engine once:
|
|
97
151
|
|
|
98
152
|
```sh
|
package/docs/MCP.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
# MCP server —
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
# MCP server — your agent, on the queue
|
|
2
|
+
|
|
3
|
+
n-seo has two agentic surfaces. Inside the daily run, the `llm` module hands
|
|
4
|
+
the findings to a model you configure and gets back proposals, verdicts and
|
|
5
|
+
briefings (see the README). This document is the other one: `src/mcp.ts`
|
|
6
|
+
exposes the control plane over the Model Context Protocol, so the coding agent
|
|
7
|
+
you already use — Claude Code, Claude Desktop, or any MCP client — reads the
|
|
8
|
+
real queue and the real metrics instead of scraping the dashboard, then goes
|
|
9
|
+
and implements the work in your site's repo.
|
|
10
|
+
|
|
11
|
+
The division of labour is the point. n-seo decides *what is worth doing*, from
|
|
12
|
+
data, with the evidence attached. Your agent does the doing, in the repo where
|
|
13
|
+
the change belongs, under the rules in `CLAUDE.md`.
|
|
14
|
+
|
|
15
|
+
It is **read-only by design**: the queue is edited through the dashboard's
|
|
16
|
+
accept/watch/retire flow, so nothing here writes anything. All 16 tools and 3
|
|
17
|
+
doc resources are annotated `readOnlyHint`. An agent can reason over your
|
|
18
|
+
search data all day and still cannot bypass the 28-day title freeze, the
|
|
19
|
+
weekly metadata budget, or you.
|
|
8
20
|
|
|
9
21
|
## Two transports
|
|
10
22
|
|
|
@@ -65,7 +77,7 @@ another device, tunnel to the dashboard port (Tailscale, `cloudflared`,
|
|
|
65
77
|
an SSH tunnel) rather than exposing it — the bearer token is the only thing
|
|
66
78
|
in front of it.
|
|
67
79
|
|
|
68
|
-
## Tools
|
|
80
|
+
## Tools (16)
|
|
69
81
|
|
|
70
82
|
| Tool | Returns |
|
|
71
83
|
|---|---|
|
|
@@ -84,8 +96,9 @@ in front of it.
|
|
|
84
96
|
| `conversions_status` | Whether conversion events are instrumented and their 28-day counts by source |
|
|
85
97
|
| `campaigns` | Outreach campaigns from `content/campaigns/` — targets, plan, template ids |
|
|
86
98
|
| `settings` | The effective config: sites, module switches, auth mode (never the key) |
|
|
99
|
+
| `engine_info` | Which engine version, commit and instance directory are running, and which modules are on — check before assuming a feature exists |
|
|
87
100
|
|
|
88
|
-
Resources: `seo://docs/playbook`, `seo://docs/daily-log`,
|
|
101
|
+
Resources (3): `seo://docs/playbook`, `seo://docs/daily-log`,
|
|
89
102
|
`seo://docs/operating-rules`.
|
|
90
103
|
|
|
91
104
|
## Example prompts
|
|
@@ -98,6 +111,9 @@ Resources: `seo://docs/playbook`, `seo://docs/daily-log`,
|
|
|
98
111
|
- "Summarize the last five daily-log entries — what regressed, what moved."
|
|
99
112
|
- "Read the opportunity proposals and tell me which ones are worth accepting
|
|
100
113
|
and why. Don't add anything to the queue."
|
|
114
|
+
- "Take the top striking-distance card for example.com, implement it on a
|
|
115
|
+
branch in the site's repo, and open the PR." — this is the `n-seo-ship`
|
|
116
|
+
skill's job; it stops rather than crossing the freeze or the weekly budget.
|
|
101
117
|
|
|
102
118
|
The rules in `CLAUDE.md` apply to an agent using these tools: it proposes,
|
|
103
119
|
you accept; it never drafts community comments; it reads outputs rather than
|
package/docs/PRD.md
CHANGED
|
@@ -52,7 +52,8 @@ with evidence attached, and measure yesterday's changes.
|
|
|
52
52
|
- Not an auto-publisher, link builder, or comment bot.
|
|
53
53
|
- Not a hosted product; there is no account and no server-side component
|
|
54
54
|
beyond an optional static mirror the owner hosts.
|
|
55
|
-
-
|
|
55
|
+
- Not a hosted control panel for other people's sites; one owner, one
|
|
56
|
+
instance, their own credentials.
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
|
@@ -72,9 +73,10 @@ a fresh checkout runs.
|
|
|
72
73
|
|
|
73
74
|
## Feature: Service-account auth without gcloud [shipped]
|
|
74
75
|
|
|
75
|
-
- Acceptance: with only a service-account JSON key
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
- Acceptance: with only a service-account JSON key, `n-seo doctor` mints a
|
|
77
|
+
token and lists accessible properties. No gcloud, no pip install and no
|
|
78
|
+
openssl binary: the JWT is signed with node's crypto module, which the
|
|
79
|
+
dashboard already requires.
|
|
78
80
|
- Acceptance: gcloud impersonation and gcloud user modes remain selectable.
|
|
79
81
|
- Acceptance: `ops/doctor.py` reports the exact email to add in Search
|
|
80
82
|
Console and GA4 and which configured properties are not yet accessible.
|
|
@@ -100,6 +102,10 @@ a fresh checkout runs.
|
|
|
100
102
|
- Acceptance: date × page series for 180 days from both sources.
|
|
101
103
|
- Acceptance: URL Inspection verdict for every sitemap URL (sitemap indexes
|
|
102
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).
|
|
103
109
|
- Acceptance: metadata audit flags title/query mismatch, CTR below position
|
|
104
110
|
expectation, missing/short/duplicate descriptions, long titles.
|
|
105
111
|
- Acceptance: trend file with branded/generic split, rising/falling queries
|
|
@@ -146,7 +152,9 @@ the config with the current values as defaults.
|
|
|
146
152
|
be marked watching or retired; rule-derived items expose no write buttons.
|
|
147
153
|
- Acceptance: the server binds 127.0.0.1 by default and rejects cross-origin
|
|
148
154
|
POSTs.
|
|
149
|
-
- 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.
|
|
150
158
|
|
|
151
159
|
## Feature: Settings page [shipped]
|
|
152
160
|
|
|
@@ -204,14 +212,22 @@ instead of a CLI, with the key read from `.env`.
|
|
|
204
212
|
mode is unchanged when it is unset.
|
|
205
213
|
- Acceptance: `n-seo init|start|dev|daily|doctor|demo|mcp|check|export|
|
|
206
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.
|
|
207
223
|
- Acceptance: `n-seo upgrade` refuses to leave the engine on a commit that
|
|
208
224
|
fails `npm run check` without printing the rollback command.
|
|
209
225
|
|
|
210
|
-
## Feature: Publish the engine to npm [
|
|
226
|
+
## Feature: Publish the engine to npm [shipped]
|
|
211
227
|
|
|
212
228
|
- Acceptance: `npm i n-seo` installs a working engine; `npx n-seo init`
|
|
213
229
|
scaffolds an instance; the `files` list excludes tests and the marketing
|
|
214
|
-
site.
|
|
230
|
+
site, and includes the instance-facing skills.
|
|
215
231
|
|
|
216
232
|
## Feature: Scheduled upgrade with gate [planned]
|
|
217
233
|
|
|
@@ -244,6 +260,10 @@ runs doctor.
|
|
|
244
260
|
promises (local data, evidence first, briefs-not-acts) and the operating
|
|
245
261
|
rules.
|
|
246
262
|
|
|
247
|
-
## Feature: Custom domain [
|
|
263
|
+
## Feature: Custom domain [shipped]
|
|
248
264
|
|
|
249
|
-
`www/CNAME`
|
|
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.
|
package/docs/SCHEDULING.md
CHANGED
|
@@ -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
|
|
@@ -79,16 +83,53 @@ loginctl enable-linger "$USER" # keep user units running when you are logged
|
|
|
79
83
|
Check on them with `systemctl --user status n-seo-dashboard` and
|
|
80
84
|
`journalctl --user -u n-seo-daily`.
|
|
81
85
|
|
|
86
|
+
## Windows (Task Scheduler)
|
|
87
|
+
|
|
88
|
+
`ops/templates/n-seo-daily-task.xml` is a ready task definition. Replace the
|
|
89
|
+
two placeholders in it, then register it:
|
|
90
|
+
|
|
91
|
+
```bat
|
|
92
|
+
where n-seo
|
|
93
|
+
:: put that path in __NSEO__, and your instance directory in __INSTANCE__
|
|
94
|
+
schtasks /create /tn "n-seo daily" /xml ops\templates\n-seo-daily-task.xml
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Check it with `schtasks /query /tn "n-seo daily" /v /fo list`, run it now with
|
|
98
|
+
`schtasks /run /tn "n-seo daily"`, remove it with
|
|
99
|
+
`schtasks /delete /tn "n-seo daily"`.
|
|
100
|
+
|
|
101
|
+
The task sets `StartWhenAvailable`, which is the Windows equivalent of the
|
|
102
|
+
launchd behaviour above: a machine that was asleep at 07:00 runs the job when
|
|
103
|
+
it wakes rather than skipping the day the way cron does. It also sets
|
|
104
|
+
`RunOnlyIfNetworkAvailable`, so it will not start into a dead connection.
|
|
105
|
+
|
|
106
|
+
For the dashboard, run `n-seo start` from a terminal, or register a second
|
|
107
|
+
task with the same XML, changing the arguments to `start` and the trigger to
|
|
108
|
+
"At log on".
|
|
109
|
+
|
|
110
|
+
**Hooks run through `cmd.exe` on Windows.** The `hooks` block in your config
|
|
111
|
+
is handed to the system shell, which is `cmd.exe` there and `/bin/sh`
|
|
112
|
+
elsewhere, so a hook written as `foo && bar` behaves but one relying on
|
|
113
|
+
POSIX quoting, `$VAR` or pipelines into Unix tools will not. Point the hook at
|
|
114
|
+
a `.cmd`/`.ps1` script if it needs to do anything shell-specific.
|
|
115
|
+
|
|
82
116
|
## Running by hand
|
|
83
117
|
|
|
84
118
|
```sh
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
119
|
+
n-seo daily # everything
|
|
120
|
+
n-seo daily --list # the step names
|
|
121
|
+
n-seo daily --only gsc,ga4 # a subset
|
|
122
|
+
n-seo daily --skip index-status
|
|
123
|
+
n-seo daily --no-network-wait
|
|
90
124
|
```
|
|
91
125
|
|
|
126
|
+
The CLI is the portable form: it finds the Python interpreter this machine
|
|
127
|
+
actually has. `python3 ops/daily.py` is equivalent on macOS and Linux, but
|
|
128
|
+
Windows has no `python3` — it installs Python as `python`, and the
|
|
129
|
+
`python3.exe` that Windows ships is an App Execution Alias that opens the
|
|
130
|
+
Microsoft Store instead of running anything. From a checkout, `npm run daily`
|
|
131
|
+
works everywhere for the same reason. `$PYTHON` overrides the choice.
|
|
132
|
+
|
|
92
133
|
Each run appends to `data/daily-ops.log`, writes `data/last-run.json` (which
|
|
93
134
|
the dashboard shows as the run status chip), and appends a dated entry to
|
|
94
135
|
`docs/daily-log.md`. Re-running on the same day replaces that day's entry
|