n-seo 0.1.1 → 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 +104 -41
- 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/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 +15 -8
- 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/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# n-seo
|
|
1
|
+
# n-seo — En Dash SEO
|
|
2
2
|
|
|
3
3
|
**An SEO, self-hosted.** Website: https://n-seo.dev/ · From the makers of [n-dx](https://n-dx.dev).
|
|
4
4
|
|
|
@@ -6,23 +6,30 @@
|
|
|
6
6
|
[](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
classic search (SEO), answer engines (AEO) and AI assistants that cite
|
|
11
|
-
(GEO) — without paying an agency to read Search Console for you.
|
|
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.
|
|
12
12
|
|
|
13
13
|
It pulls Search Console and GA4 into local JSON, probes your live sites for the
|
|
14
14
|
things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
|
|
15
15
|
JS-only shells), and turns all of it into a **ranked queue of concrete actions**:
|
|
16
16
|
which page to retitle, which query to answer, which page never got crawled.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
26
33
|
|
|
27
34
|

|
|
28
35
|
|
|
@@ -84,6 +91,85 @@ Adding another site later is one entry in the config —
|
|
|
84
91
|
Running several people's sites, or want upgrades to be a `git pull`? Keep your
|
|
85
92
|
config in its own directory — see [docs/INSTANCE.md](docs/INSTANCE.md).
|
|
86
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
|
+
|
|
87
173
|
## What you get
|
|
88
174
|
|
|
89
175
|
| Page | What it shows |
|
|
@@ -122,7 +208,7 @@ on the Settings page or in `n-seo.config.json`.
|
|
|
122
208
|
| `indexStatus` | Asks the URL Inspection API whether each sitemap URL is indexed | Search Console access | on |
|
|
123
209
|
| `metadataAudit` | Fetches each ranking page's live title/description and scores them against its queries | nothing extra | on |
|
|
124
210
|
| `opportunityScan` | Refreshes trend analysis; flags rising queries no queue item covers | nothing extra | on |
|
|
125
|
-
| `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 |
|
|
126
212
|
| `hackerNews` | Finds fresh HN threads in your expertise areas and briefs you | your HN username (optional) | off |
|
|
127
213
|
| `reddit` | The same for subreddits | a free Reddit "script" app's credentials in `.env` | off |
|
|
128
214
|
| `indexNow` | Generates a key and pings Bing/Copilot/Yandex with changed URLs | nothing (does nothing for Google) | off |
|
|
@@ -130,30 +216,6 @@ on the Settings page or in `n-seo.config.json`.
|
|
|
130
216
|
| `gitAutoCommit` | Commits (and pushes) the daily log and export after each run | a git remote, if you want the push | off |
|
|
131
217
|
| `notifications` | macOS notification when a daily step fails | macOS | off |
|
|
132
218
|
|
|
133
|
-
## Working with an AI agent
|
|
134
|
-
|
|
135
|
-
The repo ships an MCP server over the same data the dashboard reads, so an
|
|
136
|
-
agent can answer "what should I do first this week?" from the actual queue
|
|
137
|
-
instead of scraping pages. `.mcp.json` registers it for Claude Code
|
|
138
|
-
automatically; Claude Desktop and HTTP clients are covered in
|
|
139
|
-
[docs/MCP.md](docs/MCP.md). It is read-only by design, and `CLAUDE.md` holds
|
|
140
|
-
the operating rules an agent working here has to follow.
|
|
141
|
-
|
|
142
|
-
It also ships skills for the work itself, so the rules are enforced rather
|
|
143
|
-
than merely documented:
|
|
144
|
-
|
|
145
|
-
| Skill | Use it when |
|
|
146
|
-
|---|---|
|
|
147
|
-
| `orient` | First thing in a fresh session — get current in a few reads |
|
|
148
|
-
| `n-seo-setup` | Fresh clone to first real daily run, including Google access |
|
|
149
|
-
| `n-seo-add-site` | Adding a site: property form, `gscHost`, GA4 id, brand regex, grants |
|
|
150
|
-
| `n-seo-triage` | "What should I work on today" from the queue and the last run |
|
|
151
|
-
| `n-seo-ship` | Implement one queue card in the site's repo, then record it as watching |
|
|
152
|
-
| `n-seo-review` | The weekly pass: judge watching items, retire what is done, refresh insights |
|
|
153
|
-
|
|
154
|
-
`.claude/skills/README.md` explains which skills operate an instance and which
|
|
155
|
-
are contributor tooling for developing the engine with [n-dx](https://n-dx.dev).
|
|
156
|
-
|
|
157
219
|
## Working with n-dx
|
|
158
220
|
|
|
159
221
|
The repo is wired for [n-dx](https://n-dx.dev): `docs/PRD.md` is the product
|
|
@@ -198,9 +260,10 @@ accept; community participation is human. The reasoning is in
|
|
|
198
260
|
|
|
199
261
|
## Requirements
|
|
200
262
|
|
|
201
|
-
Node 20 or newer, Python 3.10 or newer, `curl
|
|
202
|
-
|
|
203
|
-
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.
|
|
204
267
|
|
|
205
268
|
## Contributing and license
|
|
206
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/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():
|
package/ingest/google_auth.py
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
service-account-key (default, recommended)
|
|
4
4
|
A service-account JSON key on disk (config google.serviceAccountKey,
|
|
5
5
|
or $GOOGLE_APPLICATION_CREDENTIALS). We build the OAuth JWT ourselves
|
|
6
|
-
and sign it with
|
|
7
|
-
dependency. Add the service account's email to
|
|
8
|
-
property (Full user) and your GA4 property
|
|
9
|
-
access it gets.
|
|
6
|
+
and sign it with node's crypto module, so there is no gcloud, no pip
|
|
7
|
+
dependency and no openssl binary. Add the service account's email to
|
|
8
|
+
your Search Console property (Full user) and your GA4 property
|
|
9
|
+
(Viewer) — that is all the access it gets.
|
|
10
10
|
|
|
11
11
|
gcloud-impersonate
|
|
12
12
|
`gcloud auth print-access-token --impersonate-service-account=<sa>`.
|
|
@@ -33,9 +33,10 @@ inspection, analytics.readonly for GA4.
|
|
|
33
33
|
import base64
|
|
34
34
|
import json
|
|
35
35
|
import os
|
|
36
|
+
import shutil
|
|
36
37
|
import subprocess
|
|
37
|
-
import tempfile
|
|
38
38
|
import time
|
|
39
|
+
from pathlib import Path
|
|
39
40
|
|
|
40
41
|
import seo_config
|
|
41
42
|
from http_util import curl_json
|
|
@@ -58,6 +59,50 @@ def _b64url(b: bytes) -> str:
|
|
|
58
59
|
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
# RS256 without an openssl binary. Node is already required (the dashboard
|
|
63
|
+
# runs on it) and its crypto module produces byte-identical signatures, so
|
|
64
|
+
# signing through node drops an external dependency on every platform — and
|
|
65
|
+
# makes Windows work, where openssl is not installed by default.
|
|
66
|
+
#
|
|
67
|
+
# Key and payload go in over stdin, never on the command line: argv is
|
|
68
|
+
# readable by any other process on the machine.
|
|
69
|
+
_NODE_SIGN_JS = (
|
|
70
|
+
"let s='';"
|
|
71
|
+
"process.stdin.setEncoding('utf8')"
|
|
72
|
+
".on('data', d => s += d)"
|
|
73
|
+
".on('end', () => {"
|
|
74
|
+
" const { key, data } = JSON.parse(s);"
|
|
75
|
+
" const sig = require('node:crypto')"
|
|
76
|
+
" .sign('sha256', Buffer.from(data, 'base64'), key);"
|
|
77
|
+
" process.stdout.write(sig.toString('base64'));"
|
|
78
|
+
"});"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def node_bin() -> str:
|
|
83
|
+
"""The node executable. $NODE overrides, mirroring $PYTHON for the CLI."""
|
|
84
|
+
return os.environ.get("NODE") or "node"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sign_rs256(private_key_pem: str, data: bytes) -> bytes:
|
|
88
|
+
"""RSASSA-PKCS1-v1_5 over SHA-256, the `RS256` a Google JWT needs."""
|
|
89
|
+
node = node_bin()
|
|
90
|
+
if not shutil.which(node):
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"{node} is not on PATH, and the service-account JWT is signed with it. "
|
|
93
|
+
"Install Node 20+ (it is required for the dashboard anyway), or set "
|
|
94
|
+
"$NODE to its path.")
|
|
95
|
+
payload = json.dumps({"key": private_key_pem,
|
|
96
|
+
"data": base64.b64encode(data).decode()})
|
|
97
|
+
p = subprocess.run([node, "-e", _NODE_SIGN_JS],
|
|
98
|
+
input=payload, capture_output=True, text=True)
|
|
99
|
+
if p.returncode != 0 or not p.stdout.strip():
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"could not sign the service-account JWT — the key file's "
|
|
102
|
+
f"private_key may be malformed: {(p.stderr or '').strip()[:200]}")
|
|
103
|
+
return base64.b64decode(p.stdout.strip())
|
|
104
|
+
|
|
105
|
+
|
|
61
106
|
def key_path() -> str | None:
|
|
62
107
|
"""The service-account key to sign with.
|
|
63
108
|
|
|
@@ -82,7 +127,7 @@ def _sa_key_token(scope: str) -> str:
|
|
|
82
127
|
raise RuntimeError(
|
|
83
128
|
"google.auth is service-account-key but no key file was found at "
|
|
84
129
|
f"{kp or '(unset)'} — see docs/SETUP-GOOGLE.md")
|
|
85
|
-
key = json.loads(
|
|
130
|
+
key = json.loads(Path(kp).read_text(encoding="utf-8"))
|
|
86
131
|
# Backdate slightly: Google rejects a JWT issued in its future, so a
|
|
87
132
|
# machine whose clock runs a few seconds fast otherwise fails to
|
|
88
133
|
# authenticate at all, with an error that names nothing useful.
|
|
@@ -93,19 +138,7 @@ def _sa_key_token(scope: str) -> str:
|
|
|
93
138
|
"iat": iat, "exp": iat + 3600,
|
|
94
139
|
}).encode())
|
|
95
140
|
signing_input = f"{header}.{claims}".encode()
|
|
96
|
-
|
|
97
|
-
fd, tmp = tempfile.mkstemp(prefix="n-seo-", suffix=".pem")
|
|
98
|
-
try:
|
|
99
|
-
os.fchmod(fd, 0o600)
|
|
100
|
-
with os.fdopen(fd, "w") as f:
|
|
101
|
-
f.write(key["private_key"])
|
|
102
|
-
sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", tmp],
|
|
103
|
-
input=signing_input, capture_output=True, check=True).stdout
|
|
104
|
-
finally:
|
|
105
|
-
try:
|
|
106
|
-
os.unlink(tmp)
|
|
107
|
-
except OSError:
|
|
108
|
-
pass
|
|
141
|
+
sig = _sign_rs256(key["private_key"], signing_input)
|
|
109
142
|
assertion = signing_input.decode() + "." + _b64url(sig)
|
|
110
143
|
resp = curl_json([
|
|
111
144
|
"-X", "POST", "-d", f"grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={assertion}",
|
|
@@ -222,7 +255,7 @@ def service_account_email() -> str | None:
|
|
|
222
255
|
kp = key_path()
|
|
223
256
|
if kp and os.path.exists(kp):
|
|
224
257
|
try:
|
|
225
|
-
return json.loads(
|
|
258
|
+
return json.loads(Path(kp).read_text(encoding="utf-8")).get("client_email")
|
|
226
259
|
except (OSError, json.JSONDecodeError):
|
|
227
260
|
return None
|
|
228
261
|
return None
|
package/ingest/pull_ga4.py
CHANGED
|
@@ -96,7 +96,7 @@ def main():
|
|
|
96
96
|
continue
|
|
97
97
|
(site_dir / f"{name}.json").write_text(json.dumps(
|
|
98
98
|
{"site": host, "property": f"properties/{prop}",
|
|
99
|
-
"pulled": date.today().isoformat(), **resp}))
|
|
99
|
+
"pulled": date.today().isoformat(), **resp}), encoding="utf-8")
|
|
100
100
|
print(f"{host:28s} {name:8s} {resp.get('rowCount', 0)} rows")
|
|
101
101
|
|
|
102
102
|
print(f"\nSaved under {out_root}")
|