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.
Files changed (47) hide show
  1. package/README.md +104 -41
  2. package/bin/n-seo.mjs +14 -4
  3. package/docs/FAQ.md +14 -3
  4. package/docs/MCP.md +25 -9
  5. package/docs/PRD.md +6 -4
  6. package/docs/SCHEDULING.md +42 -5
  7. package/docs/SETUP-GOOGLE.md +1 -1
  8. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  9. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  10. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  11. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  12. package/ingest/analyze_ga4.py +2 -2
  13. package/ingest/analyze_gsc.py +2 -2
  14. package/ingest/analyze_metadata.py +2 -2
  15. package/ingest/analyze_trends.py +2 -2
  16. package/ingest/google_auth.py +53 -20
  17. package/ingest/pull_ga4.py +1 -1
  18. package/ingest/pull_gsc.py +1 -1
  19. package/ingest/pull_index_status.py +1 -1
  20. package/ingest/pull_timeseries.py +3 -3
  21. package/ingest/seo_config.py +19 -3
  22. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  23. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  24. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  25. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  26. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  27. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  28. package/ops/daily.py +2 -2
  29. package/ops/daily_diff.py +9 -8
  30. package/ops/demo_data.py +17 -17
  31. package/ops/doctor.py +21 -7
  32. package/ops/export_static.py +4 -4
  33. package/ops/hn_digest.py +1 -1
  34. package/ops/indexnow.py +3 -3
  35. package/ops/llm.py +23 -1
  36. package/ops/opportunity_scan.py +4 -4
  37. package/ops/py.mjs +40 -0
  38. package/ops/reddit_digest.py +1 -1
  39. package/ops/templates/n-seo-daily-task.xml +65 -0
  40. package/package.json +15 -8
  41. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  42. package/probes/site_probe.py +1 -1
  43. package/src/actions.ts +1 -1
  44. package/src/config.ts +1 -1
  45. package/src/mcp.ts +2 -2
  46. package/src/settings.tsx +3 -3
  47. 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
  [![CI](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml/badge.svg)](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml)
7
7
  [![license](https://img.shields.io/npm/l/n-seo)](LICENSE)
8
8
 
9
- A local-first control plane for growing organic traffic to your own sites —
10
- classic search (SEO), answer engines (AEO) and AI assistants that cite sources
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
- It runs on hardware you control, on a schedule, and shows you the result on a dashboard.
18
-
19
- - **Your data stays on your own hardware.** Nothing is sent anywhere except the
20
- Google APIs you authorize and, optionally, a local LLM command you choose.
21
- - **It briefs you; it never posts for you.** Community modules find threads
22
- and write a briefing. The words are always yours.
23
- - **It proposes; you ship.** No module edits your site. The queue tells you
24
- what to change and why, with the numbers; you make the change in your own
25
- repo and the next day's data tells you whether it worked.
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
  ![Overview](docs/screenshots/overview.png)
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` | Runs a local command (default: the `claude` CLI) for scan proposals and digest briefings | any CLI that reads a prompt on stdin | off |
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`, `openssl`. macOS or Linux.
202
- Python is stdlib-only (no pip); the TypeScript app runs under `tsx` with no
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("npm", ["ci"], { cwd: ROOT, stdio: "inherit" });
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("npm", ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
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 = process.env.PYTHON ?? "python3";
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("npm", ["run", "check", "--silent", "--", ...rest], instance)
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
- Not supported. The scripts assume `curl`, `openssl`, POSIX paths and one of
32
- launchd / cron / systemd. WSL2 is likely to work but is untested.
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 `python3 ops/doctor.py`. A 401
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 — the same data, for agents
2
-
3
- `src/mcp.ts` exposes the control plane over the Model Context Protocol so an
4
- AI agent can read the queue and the metrics directly instead of scraping the
5
- dashboard. It is **read-only by design**: the queue is edited through the
6
- dashboard's accept/watch/retire flow, so nothing here writes anything. Every
7
- tool is annotated `readOnlyHint`.
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
- - Windows is not a target (WSL likely works; untested).
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 and `openssl` on PATH,
76
- `python3 ingest/google_auth.py` mints a token and lists accessible
77
- properties.
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.
@@ -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
- python3 ops/daily.py # everything
86
- python3 ops/daily.py --list # the step names
87
- python3 ops/daily.py --only gsc,ga4 # a subset
88
- python3 ops/daily.py --skip index-status
89
- python3 ops/daily.py --no-network-wait
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
@@ -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 `openssl` | Default. No extra tools |
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 |
@@ -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
@@ -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
 
@@ -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():
@@ -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 the `openssl` CLI, so there is no gcloud and no pip
7
- dependency. Add the service account's email to your Search Console
8
- property (Full user) and your GA4 property (Viewer) — that is all the
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(open(kp).read())
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
- # openssl needs the private key in a file; keep it 0600 and short-lived.
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(open(kp).read()).get("client_email")
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
@@ -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}")