n-seo 0.1.0 → 0.2.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/README.md +132 -46
- package/bin/n-seo.mjs +14 -4
- package/docs/FAQ.md +14 -3
- package/docs/MCP.md +25 -9
- package/docs/PRD.md +6 -4
- package/docs/RELEASING.md +21 -0
- package/docs/SCHEDULING.md +42 -5
- package/docs/SETUP-GOOGLE.md +1 -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 +17 -17
- package/ops/doctor.py +21 -7
- package/ops/export_static.py +4 -4
- 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 +16 -9
- package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
- package/probes/site_probe.py +1 -1
- package/src/actions.ts +1 -1
- package/src/config.ts +1 -1
- package/src/mcp.ts +2 -2
- package/src/settings.tsx +3 -3
- package/src/views.tsx +9 -9
- package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
- package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
- package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
- package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
- package/ops/__pycache__/llm.cpython-313.pyc +0 -0
- package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
- package/ops/__pycache__/publish.cpython-313.pyc +0 -0
- package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
- package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
package/README.md
CHANGED
|
@@ -1,39 +1,69 @@
|
|
|
1
|
-
# n-seo
|
|
1
|
+
# n-seo — En Dash SEO
|
|
2
2
|
|
|
3
|
-
**An SEO, self-hosted.** Website: https://
|
|
3
|
+
**An SEO, self-hosted.** Website: https://n-seo.dev/ · From the makers of [n-dx](https://n-dx.dev).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
(
|
|
5
|
+
[](https://www.npmjs.com/package/n-seo)
|
|
6
|
+
[](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
An agentic, local-first control plane for growing organic traffic to your own
|
|
10
|
+
sites — classic search (SEO), answer engines (AEO) and AI assistants that cite
|
|
11
|
+
sources (GEO) — without paying an agency to read Search Console for you.
|
|
8
12
|
|
|
9
13
|
It pulls Search Console and GA4 into local JSON, probes your live sites for the
|
|
10
14
|
things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
|
|
11
15
|
JS-only shells), and turns all of it into a **ranked queue of concrete actions**:
|
|
12
16
|
which page to retitle, which query to answer, which page never got crawled.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
That part is plain deterministic code. Everything that needs judgment is handed
|
|
18
|
+
to **a model you choose**, and everything that changes a site is handed to you.
|
|
19
|
+
|
|
20
|
+
- **Your model, whichever you pick.** Point the `llm` module at any CLI that
|
|
21
|
+
reads a prompt on stdin (`claude -p`, `ollama run`, a shell script) or at an
|
|
22
|
+
HTTP endpoint — Anthropic, or anything OpenAI-compatible. It writes the
|
|
23
|
+
proposals, the verdicts on shipped work, and the community briefings. No key
|
|
24
|
+
ships with n-seo. Leave it off and you still get the full data-derived queue.
|
|
25
|
+
- **Your coding agent, on the queue.** A read-only MCP server exposes the same
|
|
26
|
+
data the dashboard reads, so Claude Code — or any MCP client — can answer
|
|
27
|
+
"what should I do first this week?" and then go implement it. Six skills ship
|
|
28
|
+
with it, so the operating rules are enforced rather than merely documented.
|
|
29
|
+
- **It proposes; it never acts.** No module edits a site, sends an email or
|
|
30
|
+
posts a comment. Machine proposals wait in a holding area until you accept
|
|
31
|
+
them. Your data stays on your hardware: nothing leaves the host except the
|
|
32
|
+
Google APIs you authorize and the provider you configured yourself.
|
|
22
33
|
|
|
23
34
|

|
|
24
35
|
|
|
25
36
|
## Quickstart (5 minutes, no Google setup)
|
|
26
37
|
|
|
38
|
+
```sh
|
|
39
|
+
npm install -g n-seo
|
|
40
|
+
n-seo init my-sites && cd my-sites
|
|
41
|
+
n-seo demo # a synthetic dataset for example.com
|
|
42
|
+
n-seo start # dashboard → http://localhost:4600
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Open http://localhost:4600. Every page is populated from the demo data, so you
|
|
46
|
+
can see what the tool does before you connect anything. `n-seo demo --clean`
|
|
47
|
+
removes it.
|
|
48
|
+
|
|
49
|
+
Prefer not to install globally? `npx n-seo init my-sites` works the same way.
|
|
50
|
+
|
|
51
|
+
<details>
|
|
52
|
+
<summary>Or run it from a clone, if you want to change the engine itself</summary>
|
|
53
|
+
|
|
27
54
|
```sh
|
|
28
55
|
git clone https://github.com/en-dash-consulting/n-seo.git
|
|
29
56
|
cd n-seo
|
|
30
57
|
npm install
|
|
31
|
-
npm run demo
|
|
32
|
-
npm start
|
|
58
|
+
npm run demo
|
|
59
|
+
npm start
|
|
33
60
|
```
|
|
34
61
|
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
In this mode the config, queue and data live inside the checkout. That is the
|
|
63
|
+
right shape for hacking on n-seo; for running it, the instance layout above
|
|
64
|
+
keeps your files separate from the engine so upgrades are a reinstall rather
|
|
65
|
+
than a merge. See [docs/INSTANCE.md](docs/INSTANCE.md).
|
|
66
|
+
</details>
|
|
37
67
|
|
|
38
68
|
## Connect your real sites
|
|
39
69
|
|
|
@@ -61,6 +91,85 @@ Adding another site later is one entry in the config —
|
|
|
61
91
|
Running several people's sites, or want upgrades to be a `git pull`? Keep your
|
|
62
92
|
config in its own directory — see [docs/INSTANCE.md](docs/INSTANCE.md).
|
|
63
93
|
|
|
94
|
+
## Agentic by design
|
|
95
|
+
|
|
96
|
+
Two things are agentic here, and they are separate. Inside n-seo, the daily run
|
|
97
|
+
hands its findings to a model you configure. Alongside n-seo, your own coding
|
|
98
|
+
agent reads the queue over MCP and does the work.
|
|
99
|
+
|
|
100
|
+
### Your model, on your findings
|
|
101
|
+
|
|
102
|
+
The `llm` module is off by default. Turn it on and point it at a provider, and
|
|
103
|
+
the morning run stops being a report:
|
|
104
|
+
|
|
105
|
+
- **Proposals** — rising queries nothing in the queue covers become concrete
|
|
106
|
+
cards: the page to write, the section to add, with the numbers cited.
|
|
107
|
+
- **Verdicts** — shipped work is judged against its own success criterion:
|
|
108
|
+
succeeded, failed, or still cooking.
|
|
109
|
+
- **Briefings** — for each community thread worth joining, the gist, the
|
|
110
|
+
debate, and where your genuine experience connects.
|
|
111
|
+
|
|
112
|
+
Reach it two ways, in `n-seo.config.json`:
|
|
113
|
+
|
|
114
|
+
```jsonc
|
|
115
|
+
// a CLI — anything that reads a prompt on stdin and prints a reply
|
|
116
|
+
"llm": {
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"command": "claude -p --model claude-sonnet-5", // or `llm -m gpt-4o`, `ollama run llama3`, a script
|
|
119
|
+
"fastCommand": "claude -p --model haiku" // optional: cheaper, for the high-volume calls
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// or an HTTP endpoint — for containers and servers with no CLI signed in
|
|
123
|
+
"llm": {
|
|
124
|
+
"enabled": true,
|
|
125
|
+
"http": {
|
|
126
|
+
"provider": "anthropic", // or "openai" for anything OpenAI-compatible
|
|
127
|
+
"model": "claude-sonnet-5",
|
|
128
|
+
"fastModel": "claude-haiku-4-5-20251001",
|
|
129
|
+
"apiKeyEnv": "ANTHROPIC_API_KEY", // read from the env or .env; no key ships with n-seo
|
|
130
|
+
"baseUrl": "" // optional: a gateway or a local server
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`provider: "openai"` speaks the OpenAI chat-completions shape, so it also covers
|
|
136
|
+
the many gateways and local servers that emulate it. `http` wins when it is
|
|
137
|
+
configured and its key resolves; otherwise the CLI path runs. Proposals land in
|
|
138
|
+
a holding area on `/actions` marked PROPOSED; accepting one into the queue is a
|
|
139
|
+
click a human makes. Nothing that comes back is applied automatically.
|
|
140
|
+
|
|
141
|
+
### Your coding agent, on the queue
|
|
142
|
+
|
|
143
|
+
The repo ships an MCP server over the same data the dashboard reads, so an
|
|
144
|
+
agent can answer "what should I do first this week?" from the actual queue
|
|
145
|
+
instead of scraping pages: 16 tools and 3 doc resources, every one annotated
|
|
146
|
+
read-only. `.mcp.json` registers it for Claude Code automatically; Claude
|
|
147
|
+
Desktop, stdio and authenticated HTTP clients are covered in
|
|
148
|
+
[docs/MCP.md](docs/MCP.md). `CLAUDE.md` holds the operating rules an agent
|
|
149
|
+
working here has to follow.
|
|
150
|
+
|
|
151
|
+
Skills ship for the work itself, so the rules are enforced rather than
|
|
152
|
+
merely documented — six that operate an instance, plus `orient` to get
|
|
153
|
+
current in a fresh session:
|
|
154
|
+
|
|
155
|
+
| Skill | Use it when |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `n-seo-setup` | Fresh clone to first real daily run, including Google access |
|
|
158
|
+
| `n-seo-add-site` | Adding a site: property form, `gscHost`, GA4 id, brand regex, grants |
|
|
159
|
+
| `n-seo-triage` | "What should I work on today" from the queue and the last run |
|
|
160
|
+
| `n-seo-ship` | Implement one queue card in the site's repo, then record it as watching |
|
|
161
|
+
| `n-seo-review` | The weekly pass: judge watching items, retire what is done, refresh insights |
|
|
162
|
+
| `n-seo-deploy` | Moving n-seo off the laptop onto an always-on host, on a schedule |
|
|
163
|
+
| `orient` | First thing in a fresh session — get current in a few reads |
|
|
164
|
+
|
|
165
|
+
`n-seo-ship` stops rather than crossing the 28-day title freeze or the weekly
|
|
166
|
+
metadata budget. `.claude/skills/README.md` explains which skills operate an
|
|
167
|
+
instance and which are contributor tooling for developing the engine with
|
|
168
|
+
[n-dx](https://n-dx.dev).
|
|
169
|
+
|
|
170
|
+
Read-only is the point: an agent can reason over your search data all day and
|
|
171
|
+
still cannot bypass the freeze, the batching, or you.
|
|
172
|
+
|
|
64
173
|
## What you get
|
|
65
174
|
|
|
66
175
|
| Page | What it shows |
|
|
@@ -99,7 +208,7 @@ on the Settings page or in `n-seo.config.json`.
|
|
|
99
208
|
| `indexStatus` | Asks the URL Inspection API whether each sitemap URL is indexed | Search Console access | on |
|
|
100
209
|
| `metadataAudit` | Fetches each ranking page's live title/description and scores them against its queries | nothing extra | on |
|
|
101
210
|
| `opportunityScan` | Refreshes trend analysis; flags rising queries no queue item covers | nothing extra | on |
|
|
102
|
-
| `llm` |
|
|
211
|
+
| `llm` | Hands the run's findings to the model you choose: scan proposals, verdicts on shipped work, community briefings | a provider you configure: any stdin CLI, or an Anthropic / OpenAI-compatible endpoint | off |
|
|
103
212
|
| `hackerNews` | Finds fresh HN threads in your expertise areas and briefs you | your HN username (optional) | off |
|
|
104
213
|
| `reddit` | The same for subreddits | a free Reddit "script" app's credentials in `.env` | off |
|
|
105
214
|
| `indexNow` | Generates a key and pings Bing/Copilot/Yandex with changed URLs | nothing (does nothing for Google) | off |
|
|
@@ -107,30 +216,6 @@ on the Settings page or in `n-seo.config.json`.
|
|
|
107
216
|
| `gitAutoCommit` | Commits (and pushes) the daily log and export after each run | a git remote, if you want the push | off |
|
|
108
217
|
| `notifications` | macOS notification when a daily step fails | macOS | off |
|
|
109
218
|
|
|
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
219
|
## Working with n-dx
|
|
135
220
|
|
|
136
221
|
The repo is wired for [n-dx](https://n-dx.dev): `docs/PRD.md` is the product
|
|
@@ -175,9 +260,10 @@ accept; community participation is human. The reasoning is in
|
|
|
175
260
|
|
|
176
261
|
## Requirements
|
|
177
262
|
|
|
178
|
-
Node 20 or newer, Python 3.10 or newer, `curl
|
|
179
|
-
|
|
180
|
-
build step.
|
|
263
|
+
Node 20 or newer, Python 3.10 or newer, and `curl`. macOS, Linux or Windows
|
|
264
|
+
10+, all three exercised by CI on every commit. Python is stdlib-only (no
|
|
265
|
+
pip); the TypeScript app runs under `tsx` with no build step. Nothing needs
|
|
266
|
+
`openssl`, `gcloud` or a compiler.
|
|
181
267
|
|
|
182
268
|
## Contributing and license
|
|
183
269
|
|
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";
|
|
@@ -121,6 +122,15 @@ function tsxBin() {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/* npm, npx and similar arrive as `.cmd` batch shims on Windows, which
|
|
126
|
+
* spawnSync cannot execute without a shell — a bare `npm` fails there with
|
|
127
|
+
* ENOENT. (The Python interpreter has the same problem; ops/py.mjs solves
|
|
128
|
+
* it, and this file imports pythonBin from there so the CLI and the npm
|
|
129
|
+
* scripts resolve the interpreter identically.) */
|
|
130
|
+
function npmBin(name = "npm") {
|
|
131
|
+
return process.platform === "win32" ? `${name}.cmd` : name;
|
|
132
|
+
}
|
|
133
|
+
|
|
124
134
|
function missingDeps(cmd, what) {
|
|
125
135
|
const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
|
|
126
136
|
console.error(`${cmd} needs ${what}.`);
|
|
@@ -229,14 +239,14 @@ function upgrade(instance) {
|
|
|
229
239
|
}
|
|
230
240
|
if (sha256(path.join(ROOT, "package-lock.json")) !== lockBefore || !fs.existsSync(path.join(ROOT, "node_modules"))) {
|
|
231
241
|
console.log("lockfile changed — npm ci");
|
|
232
|
-
r = spawnSync(
|
|
242
|
+
r = spawnSync(npmBin(), ["ci"], { cwd: ROOT, stdio: "inherit" });
|
|
233
243
|
if (r.status !== 0) {
|
|
234
244
|
console.error(`npm ci failed. Roll back with:\n git -C ${ROOT} reset --hard ${before}`);
|
|
235
245
|
return r.status ?? 1;
|
|
236
246
|
}
|
|
237
247
|
}
|
|
238
248
|
console.log("running the engine's checks");
|
|
239
|
-
r = spawnSync(
|
|
249
|
+
r = spawnSync(npmBin(), ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
|
|
240
250
|
if (r.status !== 0) {
|
|
241
251
|
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
252
|
return 1;
|
|
@@ -249,7 +259,7 @@ function upgrade(instance) {
|
|
|
249
259
|
|
|
250
260
|
const { cmd, instance: flag, rest } = parseArgs(process.argv.slice(2));
|
|
251
261
|
const instance = resolveInstance(flag);
|
|
252
|
-
const py =
|
|
262
|
+
const py = pythonBin();
|
|
253
263
|
let code = 0;
|
|
254
264
|
|
|
255
265
|
switch (cmd) {
|
|
@@ -274,7 +284,7 @@ switch (cmd) {
|
|
|
274
284
|
case "check":
|
|
275
285
|
// The self-test needs devDependencies, which an npm install omits.
|
|
276
286
|
code = resolvePkgDir("typescript")
|
|
277
|
-
? run(
|
|
287
|
+
? run(npmBin(), ["run", "check", "--silent", "--", ...rest], instance)
|
|
278
288
|
: missingDeps(cmd, "the engine's dev dependencies (it is the engine's own test suite)");
|
|
279
289
|
break;
|
|
280
290
|
case "daily":
|
package/docs/FAQ.md
CHANGED
|
@@ -28,8 +28,19 @@ proposals and verdicts to the opportunity scan and briefings to the digests.
|
|
|
28
28
|
Nothing it produces is applied automatically.
|
|
29
29
|
|
|
30
30
|
**Does it run on Windows?**
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
Yes, since 0.2.0, and CI runs the full test suite on Windows on every commit
|
|
32
|
+
rather than taking the claim on trust. You need Node 20+ and Python 3.10+;
|
|
33
|
+
`curl` ships with Windows 10 and later. There is no `openssl` requirement on
|
|
34
|
+
any platform any more — the service-account JWT is signed with node's crypto
|
|
35
|
+
module. Schedule the daily run with Task Scheduler, using the task definition
|
|
36
|
+
in `ops/templates/n-seo-daily-task.xml` (see [SCHEDULING.md](SCHEDULING.md)).
|
|
37
|
+
|
|
38
|
+
Two differences worth knowing. Commands in the `hooks` block go to `cmd.exe`
|
|
39
|
+
rather than `/bin/sh`, so write them in its syntax or point them at a script.
|
|
40
|
+
And use `n-seo daily` (or `npm run daily`) rather than `python3 ops/daily.py`:
|
|
41
|
+
Windows installs Python as `python`, and the `python3.exe` it ships is a stub
|
|
42
|
+
that opens the Microsoft Store instead of running anything. The CLI finds the
|
|
43
|
+
real interpreter for you, and `$PYTHON` overrides it.
|
|
33
44
|
|
|
34
45
|
**Where does my data go?**
|
|
35
46
|
Into `data/` on the machine that runs the pipeline, as JSON. The only network
|
|
@@ -83,7 +94,7 @@ behind it; the page flags verdicts older than 90 days as stale. Request
|
|
|
83
94
|
indexing and re-check rather than chasing a template bug.
|
|
84
95
|
|
|
85
96
|
**A daily step failed. What now?**
|
|
86
|
-
Open `/logs` (or `data/daily-ops.log`), then `
|
|
97
|
+
Open `/logs` (or `data/daily-ops.log`), then run `n-seo doctor`. A 401
|
|
87
98
|
or 403 from Google means the service account lost access or the key file
|
|
88
99
|
moved. A run that fails every network step at once was offline; the next run
|
|
89
100
|
will recover. Steps are independent — one failing does not stop the others.
|
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.
|
package/docs/RELEASING.md
CHANGED
|
@@ -139,6 +139,27 @@ workflow also deliberately omits `registry-url` from `setup-node`, because it
|
|
|
139
139
|
writes an `.npmrc` auth line interpolating `NODE_AUTH_TOKEN`, and an empty
|
|
140
140
|
value there produces the same misleading 404.
|
|
141
141
|
|
|
142
|
+
## Approving a staged release
|
|
143
|
+
|
|
144
|
+
The trusted publisher is configured to allow `npm stage publish` only, so the
|
|
145
|
+
workflow submits a version and stops. It is not on the registry until a human
|
|
146
|
+
approves it with 2FA. That is deliberate: a compromised runner or a stray tag
|
|
147
|
+
cannot put code on npm that other people's machines will then execute.
|
|
148
|
+
|
|
149
|
+
After the release run goes green:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
npm stage list n-seo # the stage id
|
|
153
|
+
npm stage view <stage-id> # inspect exactly what CI built
|
|
154
|
+
npm stage approve <stage-id> # 2FA; now it is live
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`npm stage reject <stage-id>` throws it away. The package's Staged Packages
|
|
158
|
+
tab on npmjs.com does the same thing in a browser, which is easier if your
|
|
159
|
+
second factor is a passkey rather than an authenticator app.
|
|
160
|
+
|
|
161
|
+
The workflow's run summary prints these commands with the version filled in.
|
|
162
|
+
|
|
142
163
|
## Verify what was published
|
|
143
164
|
|
|
144
165
|
Do not trust the workflow's own output. Install from the registry, into an
|
package/docs/SCHEDULING.md
CHANGED
|
@@ -79,16 +79,53 @@ loginctl enable-linger "$USER" # keep user units running when you are logged
|
|
|
79
79
|
Check on them with `systemctl --user status n-seo-dashboard` and
|
|
80
80
|
`journalctl --user -u n-seo-daily`.
|
|
81
81
|
|
|
82
|
+
## Windows (Task Scheduler)
|
|
83
|
+
|
|
84
|
+
`ops/templates/n-seo-daily-task.xml` is a ready task definition. Replace the
|
|
85
|
+
two placeholders in it, then register it:
|
|
86
|
+
|
|
87
|
+
```bat
|
|
88
|
+
where n-seo
|
|
89
|
+
:: put that path in __NSEO__, and your instance directory in __INSTANCE__
|
|
90
|
+
schtasks /create /tn "n-seo daily" /xml ops\templates\n-seo-daily-task.xml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Check it with `schtasks /query /tn "n-seo daily" /v /fo list`, run it now with
|
|
94
|
+
`schtasks /run /tn "n-seo daily"`, remove it with
|
|
95
|
+
`schtasks /delete /tn "n-seo daily"`.
|
|
96
|
+
|
|
97
|
+
The task sets `StartWhenAvailable`, which is the Windows equivalent of the
|
|
98
|
+
launchd behaviour above: a machine that was asleep at 07:00 runs the job when
|
|
99
|
+
it wakes rather than skipping the day the way cron does. It also sets
|
|
100
|
+
`RunOnlyIfNetworkAvailable`, so it will not start into a dead connection.
|
|
101
|
+
|
|
102
|
+
For the dashboard, run `n-seo start` from a terminal, or register a second
|
|
103
|
+
task with the same XML, changing the arguments to `start` and the trigger to
|
|
104
|
+
"At log on".
|
|
105
|
+
|
|
106
|
+
**Hooks run through `cmd.exe` on Windows.** The `hooks` block in your config
|
|
107
|
+
is handed to the system shell, which is `cmd.exe` there and `/bin/sh`
|
|
108
|
+
elsewhere, so a hook written as `foo && bar` behaves but one relying on
|
|
109
|
+
POSIX quoting, `$VAR` or pipelines into Unix tools will not. Point the hook at
|
|
110
|
+
a `.cmd`/`.ps1` script if it needs to do anything shell-specific.
|
|
111
|
+
|
|
82
112
|
## Running by hand
|
|
83
113
|
|
|
84
114
|
```sh
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
n-seo daily # everything
|
|
116
|
+
n-seo daily --list # the step names
|
|
117
|
+
n-seo daily --only gsc,ga4 # a subset
|
|
118
|
+
n-seo daily --skip index-status
|
|
119
|
+
n-seo daily --no-network-wait
|
|
90
120
|
```
|
|
91
121
|
|
|
122
|
+
The CLI is the portable form: it finds the Python interpreter this machine
|
|
123
|
+
actually has. `python3 ops/daily.py` is equivalent on macOS and Linux, but
|
|
124
|
+
Windows has no `python3` — it installs Python as `python`, and the
|
|
125
|
+
`python3.exe` that Windows ships is an App Execution Alias that opens the
|
|
126
|
+
Microsoft Store instead of running anything. From a checkout, `npm run daily`
|
|
127
|
+
works everywhere for the same reason. `$PYTHON` overrides the choice.
|
|
128
|
+
|
|
92
129
|
Each run appends to `data/daily-ops.log`, writes `data/last-run.json` (which
|
|
93
130
|
the dashboard shows as the run status chip), and appends a dated entry to
|
|
94
131
|
`docs/daily-log.md`. Re-running on the same day replaces that day's entry
|
package/docs/SETUP-GOOGLE.md
CHANGED
|
@@ -120,7 +120,7 @@ Set `google.auth` to one of:
|
|
|
120
120
|
|
|
121
121
|
| Mode | How it gets a token | When to use |
|
|
122
122
|
|---|---|---|
|
|
123
|
-
| `service-account-key` | Signs an OAuth JWT with the key file using
|
|
123
|
+
| `service-account-key` | Signs an OAuth JWT with the key file, using node's crypto module | Default. No extra tools, on any platform |
|
|
124
124
|
| `gcloud-impersonate` | `gcloud auth print-access-token --impersonate-service-account=<google.impersonate>` | You already use gcloud and would rather grant your user *Service Account Token Creator* on the SA than keep a key file. Still add the SA to the consoles as above |
|
|
125
125
|
| `gcloud-user` | `gcloud auth print-access-token` for your own login | Rarely works: gcloud's default client does not carry the Search Console or Analytics scopes for user credentials, and Google blocks `application-default login` with those scopes. Kept for completeness |
|
|
126
126
|
| `metadata` | The runtime service account from the GCE / Cloud Run / GKE metadata server, exchanged for a scoped token | Running on Google Cloud. No key file exists, so none can leak |
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ingest/analyze_ga4.py
CHANGED
|
@@ -24,7 +24,7 @@ def rows(site, name):
|
|
|
24
24
|
p = GA_DIR / site / f"{name}.json"
|
|
25
25
|
if not p.exists():
|
|
26
26
|
return []
|
|
27
|
-
d = json.loads(p.read_text())
|
|
27
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
28
28
|
out = []
|
|
29
29
|
for r in d.get("rows", []):
|
|
30
30
|
dims = [v["value"] for v in r.get("dimensionValues", [])]
|
|
@@ -69,7 +69,7 @@ def main():
|
|
|
69
69
|
dest_dir = seo_config.INSTANCE / "docs" / "reports"
|
|
70
70
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
71
71
|
dest = dest_dir / f"ga4-findings-{date.today():%Y-%m}.md"
|
|
72
|
-
dest.write_text(text)
|
|
72
|
+
dest.write_text(text, encoding="utf-8")
|
|
73
73
|
print(text)
|
|
74
74
|
print(f"[saved to {dest}]")
|
|
75
75
|
return 0
|
package/ingest/analyze_gsc.py
CHANGED
|
@@ -19,7 +19,7 @@ EXPECTED_CTR = {1: 0.28, 2: 0.15, 3: 0.10, 4: 0.07, 5: 0.05, 6: 0.04}
|
|
|
19
19
|
|
|
20
20
|
def load(slug, dataset):
|
|
21
21
|
p = seo_config.DATA / "gsc" / slug / f"{dataset}.json"
|
|
22
|
-
return json.loads(p.read_text())["rows"] if p.exists() else []
|
|
22
|
+
return json.loads(p.read_text(encoding="utf-8"))["rows"] if p.exists() else []
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def fmt_pct(x):
|
|
@@ -126,7 +126,7 @@ def main():
|
|
|
126
126
|
dest_dir = seo_config.INSTANCE / "docs" / "reports"
|
|
127
127
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
128
128
|
dest = dest_dir / f"gsc-findings-{date.today():%Y-%m}.md"
|
|
129
|
-
dest.write_text(text)
|
|
129
|
+
dest.write_text(text, encoding="utf-8")
|
|
130
130
|
print(text)
|
|
131
131
|
print(f"\n[saved to {dest}]")
|
|
132
132
|
return 0
|
|
@@ -73,7 +73,7 @@ def main():
|
|
|
73
73
|
host = site["gscHost"]
|
|
74
74
|
cache_key = str(gsc_file)
|
|
75
75
|
if cache_key not in qp_cache:
|
|
76
|
-
qp_cache[cache_key] = json.loads(gsc_file.read_text())["rows"]
|
|
76
|
+
qp_cache[cache_key] = json.loads(gsc_file.read_text(encoding="utf-8"))["rows"]
|
|
77
77
|
rows = [r for r in qp_cache[cache_key]
|
|
78
78
|
if r["keys"][1].split("/")[2] == host]
|
|
79
79
|
|
|
@@ -149,7 +149,7 @@ def main():
|
|
|
149
149
|
print(f"{site['host']:28s} {len(pages)} pages audited, {len(findings)} with findings")
|
|
150
150
|
|
|
151
151
|
seo_config.DATA.mkdir(parents=True, exist_ok=True)
|
|
152
|
-
(seo_config.DATA / "metadata-audit.json").write_text(json.dumps(audit, indent=1))
|
|
152
|
+
(seo_config.DATA / "metadata-audit.json").write_text(json.dumps(audit, indent=1), encoding="utf-8")
|
|
153
153
|
print("saved data/metadata-audit.json")
|
|
154
154
|
return 0
|
|
155
155
|
|
package/ingest/analyze_trends.py
CHANGED
|
@@ -93,7 +93,7 @@ def main():
|
|
|
93
93
|
p = seo_config.DATA / "gsc" / slug / "dates.json"
|
|
94
94
|
if p.exists():
|
|
95
95
|
monthly = {}
|
|
96
|
-
for r in json.loads(p.read_text())["rows"]:
|
|
96
|
+
for r in json.loads(p.read_text(encoding="utf-8"))["rows"]:
|
|
97
97
|
m = r["keys"][0][:7]
|
|
98
98
|
cur = monthly.setdefault(m, {"clicks": 0, "imps": 0})
|
|
99
99
|
cur["clicks"] += r["clicks"]
|
|
@@ -122,7 +122,7 @@ def main():
|
|
|
122
122
|
|
|
123
123
|
seo_config.DATA.mkdir(parents=True, exist_ok=True)
|
|
124
124
|
dest = seo_config.DATA / f"trends-{date.today().isoformat()}.json"
|
|
125
|
-
dest.write_text(json.dumps(out, indent=1))
|
|
125
|
+
dest.write_text(json.dumps(out, indent=1), encoding="utf-8")
|
|
126
126
|
print(f"saved {dest}\n")
|
|
127
127
|
|
|
128
128
|
for site, d in out["sites"].items():
|