n-seo 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude/skills/n-seo-add-site/SKILL.md +125 -0
  2. package/.claude/skills/n-seo-deploy/SKILL.md +171 -0
  3. package/.claude/skills/n-seo-review/SKILL.md +106 -0
  4. package/.claude/skills/n-seo-setup/SKILL.md +139 -0
  5. package/.claude/skills/n-seo-ship/SKILL.md +122 -0
  6. package/.claude/skills/n-seo-triage/SKILL.md +90 -0
  7. package/.claude/skills/orient/SKILL.md +54 -0
  8. package/README.md +93 -15
  9. package/bin/n-seo.mjs +163 -6
  10. package/docs/ADDING-A-SITE.md +4 -0
  11. package/docs/DEPLOY.md +4 -0
  12. package/docs/FAQ.md +32 -3
  13. package/docs/INSTANCE.md +54 -0
  14. package/docs/PRD.md +23 -5
  15. package/docs/SCHEDULING.md +4 -0
  16. package/docs/SETUP-GOOGLE.md +7 -0
  17. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  18. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  19. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  20. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  21. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  22. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  23. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  24. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  25. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  26. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  27. package/ops/demo_data.py +6 -0
  28. package/ops/doctor.py +9 -0
  29. package/ops/export_static.py +5 -0
  30. package/package.json +10 -2
  31. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  32. package/public/fonts/OFL.txt +105 -0
  33. package/public/fonts/montserrat-latin-var.woff2 +0 -0
  34. package/public/styles.css +18 -1
  35. package/src/server.tsx +22 -1
  36. package/src/views.tsx +70 -6
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: n-seo-add-site
3
+ description: Add one site to an n-seo instance correctly — the Search Console property form, gscHost, the numeric GA4 id, a brand regex, and the two access grants. Use whenever someone wants a new site tracked.
4
+ ---
5
+
6
+ # Add a site
7
+
8
+ One entry in `n-seo.config.json`. There are no per-script site lists, so this
9
+ is the only edit. `docs/ADDING-A-SITE.md` is the reference; this is the order
10
+ to do it in.
11
+
12
+ ```sh
13
+ E=<engine checkout>/bin/n-seo.mjs
14
+ I=<instance dir>
15
+ ```
16
+
17
+ ## 1. Collect four facts
18
+
19
+ Ask for whatever you cannot see, and do not guess any of them.
20
+
21
+ **The property, exactly as Search Console shows it.** This is where most
22
+ mistakes happen:
23
+
24
+ | In Search Console | `gscProperty` |
25
+ |---|---|
26
+ | Domain property `example.com` | `sc-domain:example.com` |
27
+ | URL-prefix property | `https://www.example.com/` — scheme and **trailing slash** included |
28
+
29
+ **Whether the property covers more than this site.** A `sc-domain:` property
30
+ covers every subdomain and both protocols. So:
31
+
32
+ - One site on its own domain property → `gscHost` can be omitted.
33
+ - Several sites sharing one domain property → every entry gets the **same**
34
+ `gscProperty` and a **different** `gscHost`. The pull happens once per
35
+ property; each site's rows are filtered by `gscHost` when read.
36
+ - The site serves on `www` and the apex redirects → `gscHost` is
37
+ `www.example.com` even though `host` is `example.com`. Getting this wrong
38
+ means the probe and the metadata audit look at the wrong hostname and the
39
+ site silently shows no data.
40
+
41
+ **The numeric GA4 property id.** GA4 → Admin → Property details → Property
42
+ ID, nine or ten digits. It is **not** the `G-XXXXXXX` measurement id. Leave
43
+ `ga4Property` empty for a site with no analytics; it will still be probed and
44
+ pulled from Search Console.
45
+
46
+ **A brand regex.** Case-insensitive, matching what people type when they mean
47
+ this site by name, used for the branded/generic split on `/insights`. For
48
+ `example.com` something like `example|exmpl`. Omit it only if the site has no
49
+ brand terms — a site with a `gscProperty` and no `brand` counts **all** of
50
+ its traffic as generic, which quietly skews that split.
51
+
52
+ ## 2. Add the entry
53
+
54
+ Append to `sites` in `$I/n-seo.config.json`:
55
+
56
+ ```json
57
+ {
58
+ "host": "docs.example.com",
59
+ "label": "docs",
60
+ "gscProperty": "sc-domain:example.com",
61
+ "gscHost": "docs.example.com",
62
+ "ga4Property": "987654321",
63
+ "brand": "example|exmpl",
64
+ "repo": "../example-docs",
65
+ "hosting": "GitHub Pages"
66
+ }
67
+ ```
68
+
69
+ `label`, `repo` and `hosting` are display-only. Keep the JSON valid — the
70
+ dashboard falls back to the last good config and logs a parse error otherwise.
71
+
72
+ ## 3. Grant access to the service account
73
+
74
+ Both consoles, using the service-account email (`node $E doctor --instance
75
+ "$I"` prints it, or read `client_email` from the key file):
76
+
77
+ - **Search Console** → the new property → Settings → Users and permissions →
78
+ Add user → **Full**.
79
+ - **GA4** → Admin → Property access management → **Viewer**. If the service
80
+ account is already Viewer at the *account* level, a new property under that
81
+ account inherits it and there is nothing to do.
82
+
83
+ On `metadata` auth the same grants apply to the runtime service account.
84
+
85
+ If Search Console's Add-user dialog rejects a service-account address on a
86
+ domain property, the fallback is the Site Verification API and a DNS TXT
87
+ record, described in `docs/SETUP-GOOGLE.md`.
88
+
89
+ ## 4. Verify
90
+
91
+ ```sh
92
+ node $E doctor --instance "$I"
93
+ ```
94
+
95
+ The new property must appear under `search console` with a permission level,
96
+ and its GA4 id must resolve. A missing property means the grant has not
97
+ landed yet (give it a few minutes) or the property string does not match.
98
+
99
+ Then let the next scheduled run pick it up, or run it now:
100
+
101
+ ```sh
102
+ node $E daily --instance "$I"
103
+ ```
104
+
105
+ ## 5. Confirm it landed
106
+
107
+ - The sites strip on `/` lists it.
108
+ - `/site/<host>` exists and the probe pills render.
109
+ - `/trends` shows it — a brand-new site gets a flat "no traffic recorded yet"
110
+ row rather than being silently absent, so an empty row is a connected site,
111
+ not a broken one.
112
+
113
+ Zero rows from Search Console on a new site is correct, not a fault: there is
114
+ no search history yet.
115
+
116
+ ## Optional: watch a page
117
+
118
+ `watchPages` is a flat list of URLs whose numbers appear in every daily-log
119
+ entry. Add pages you are about to change so the log tracks them.
120
+
121
+ ## Removing a site
122
+
123
+ Delete the entry. Its `data/` stays until removed. Backlog items for that host
124
+ in `config/backlog.json` still render on `/actions` — retire them with the
125
+ Retire button or via `POST /api/backlog/<id>/retire`.
@@ -0,0 +1,171 @@
1
+ ---
2
+ name: n-seo-deploy
3
+ description: Deploy n-seo to a host that is always on — a VPS, a NAS, or a GCE VM — with the dashboard reachable only through a tunnel and the static mirror behind sign-in. Use when someone wants n-seo off their laptop, running on a schedule, or asks how to host it.
4
+ ---
5
+
6
+ # Deploy n-seo to an always-on host
7
+
8
+ Read `docs/DEPLOY.md` for the reasoning. This is the procedure.
9
+
10
+ ## Rules you may not break
11
+
12
+ 1. **Never publish the dashboard's port to the internet, and never suggest
13
+ it.** It has write endpoints, and its Settings page sets the LLM command
14
+ the daily run executes — reaching it is running code on the host. Access
15
+ is a tunnel (IAP, `ssh -L`, Tailscale) or a proxy that authenticates
16
+ first. If the operator asks for a public dashboard, say what it exposes
17
+ and offer the mirror or the Caddy overlay instead.
18
+ 2. **Never print, echo, cat or commit a secret** — API keys, service-account
19
+ JSON, `.env` contents. Reference paths, not values.
20
+ 3. **Do not create paid cloud resources without explicit confirmation.** Run
21
+ `./setup.sh --dry-run` first and show the operator what it would create.
22
+ 4. **Do not invent gcloud flags.** If unsure, `gcloud <group> <cmd> --help`.
23
+
24
+ ## 1. Establish what they have
25
+
26
+ Ask only what you cannot detect. Detect what you can:
27
+
28
+ ```sh
29
+ command -v docker gcloud git node python3
30
+ gcloud config get-value project 2>/dev/null
31
+ uname -s
32
+ ```
33
+
34
+ You need to know, in this order:
35
+
36
+ - **Where should it run?** An existing Linux box they can SSH to, a new GCE
37
+ VM, or something else with Docker.
38
+ - **Is this a fresh instance, or an existing one to move?** If moving, the
39
+ instance directory comes with them and step 4 becomes a copy, not an
40
+ `init`.
41
+ - **Is the mirror wanted?** A read-only static copy others can see. Needs a
42
+ bucket plus something to serve it.
43
+ - **On GCP already?** Decides `auth: "metadata"` (no key file) versus a
44
+ mounted key.
45
+
46
+ ## 2. Pick the shape
47
+
48
+ | They said | Do |
49
+ |---|---|
50
+ | "I have a VPS / NAS / server" | `deploy/vm/startup.sh` on that box |
51
+ | "We're on GCP" | `deploy/gcp/setup.sh`, then the VM steps |
52
+ | "Cloud Run" | Explain why the engine cannot: `export_static.py` finishes with an atomic directory rename and GCS FUSE has none. Offer the VM, and Cloud Run for the mirror only. |
53
+ | "Kubernetes" | Not scaffolded. The image and the volume contract are in `docker/README.md`; one Deployment plus one CronJob sharing a PVC is the shape. |
54
+
55
+ ## 3. Run the scaffolding
56
+
57
+ **Any Linux box** (as root on that box):
58
+
59
+ ```sh
60
+ curl -fsSLO https://raw.githubusercontent.com/en-dash-consulting/n-seo/main/deploy/vm/startup.sh
61
+ sudo bash startup.sh
62
+ ```
63
+
64
+ **GCP** (from a checkout, on their workstation):
65
+
66
+ ```sh
67
+ gcloud config set project THEIR_PROJECT
68
+ cd deploy/gcp
69
+ ./setup.sh --dry-run # show them this output, get a yes
70
+ ./setup.sh
71
+ ```
72
+
73
+ `setup.sh` creates the service account with Token Creator on itself, the
74
+ private mirror bucket, a data disk, a VM with no external IP, an IAP-only
75
+ firewall, and the Cloud Run mirror. It prints the manual steps at the end —
76
+ keep them for step 6.
77
+
78
+ ## 4. The instance
79
+
80
+ Fresh:
81
+
82
+ ```sh
83
+ sudo -u n-seo /opt/n-seo/manage init
84
+ sudo -u n-seo vi /srv/n-seo/instance/n-seo.config.json
85
+ ```
86
+
87
+ Moving an existing one: copy the directory to `/srv/n-seo/instance`
88
+ (excluding `data/`, which regenerates), keep ownership as the `n-seo` user,
89
+ and confirm `n-seo.config.json`, `config/`, `content/` arrived.
90
+
91
+ Config points to settle, in order of how often they are wrong:
92
+
93
+ - `sites[]` — `gscProperty` is `sc-domain:example.com` for a domain property
94
+ or `https://example.com/` for a url-prefix one. `ga4Property` is the
95
+ numeric id from GA4 Admin → Property details, not the `G-` tag.
96
+ - `google.auth` — `"metadata"` on GCP (no key). Anywhere else,
97
+ `"service-account-key"` with the key mounted read-only.
98
+ - `modules.llm` — on a headless host the `claude -p` CLI usually is not
99
+ there. Use the HTTP path with `apiKeyEnv` and put the key in the
100
+ instance's `.env`, or leave the module off and accept no proposals or
101
+ briefings.
102
+ - `modules.staticExport` plus an `afterRun` hook if they want the mirror.
103
+ - `port` — leave it. The container publishes to loopback only.
104
+
105
+ ## 5. Verify, in this order
106
+
107
+ ```sh
108
+ sudo -u n-seo /opt/n-seo/manage doctor
109
+ sudo -u n-seo /opt/n-seo/manage daily --only probe # no credentials needed
110
+ sudo -u n-seo /opt/n-seo/manage daily # the real run
111
+ sudo cat /srv/n-seo/instance/data/last-run.json # failures should be ""
112
+ ```
113
+
114
+ `doctor` failing on Search Console or GA4 before step 6 is expected — the
115
+ console grants have not happened yet. Everything else should pass.
116
+
117
+ Then confirm the dashboard, through a tunnel and never otherwise:
118
+
119
+ ```sh
120
+ # GCP
121
+ gcloud compute start-iap-tunnel n-seo 4600 --local-host-port=localhost:4600 --zone ZONE
122
+ # anywhere
123
+ ssh -N -L 4600:localhost:4600 user@host
124
+ ```
125
+
126
+ ```sh
127
+ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4600/
128
+ curl -s http://localhost:4600/api/actions | head -c 200
129
+ ```
130
+
131
+ ## 6. Hand over what only they can do
132
+
133
+ These are console steps. List them explicitly; do not pretend they are done.
134
+
135
+ 1. **Search Console** → Settings → Users and permissions → add the service
136
+ account address with **Full**. If the UI rejects it on a domain property,
137
+ point them at `docs/SETUP-GOOGLE.md` for the DNS-verification route.
138
+ 2. **GA4** → Admin → Property access management → add the same address as
139
+ **Viewer**.
140
+ 3. **IAP members**, if the mirror is deployed:
141
+ ```sh
142
+ gcloud beta iap web add-iam-policy-binding \
143
+ --resource-type=cloud-run --service=n-seo-mirror --region=REGION \
144
+ --member='user:someone@example.com' \
145
+ --role='roles/iap.httpsResourceAccessor'
146
+ ```
147
+ 4. **Re-run `manage doctor`** once those land. It should be all green.
148
+
149
+ ## 7. Leave it healthy
150
+
151
+ - Confirm the scheduler service is up: `sudo -u n-seo /opt/n-seo/manage ps`.
152
+ The daily time is `N_SEO_DAILY_AT` in `/srv/n-seo/.env` (default 07:00 in
153
+ the host's `TZ`).
154
+ - Tell them where to look when it breaks: `/logs` on the dashboard,
155
+ `data/last-run.json`, `manage logs`.
156
+ - Tell them the upgrade command: `sudo -u n-seo /opt/n-seo/manage upgrade`.
157
+ - Recommend the instance live in its own git repo (`docs/INSTANCE.md`) —
158
+ that is the backup, and `gitAutoCommit` keeps it current.
159
+ - If they want failure alerts on a server, `modules.notifications` is
160
+ macOS-only; offer the `afterRun` webhook hook in `docs/DEPLOY.md`.
161
+
162
+ ## Troubleshooting
163
+
164
+ | Symptom | Cause |
165
+ |---|---|
166
+ | `doctor` says the metadata token was rejected | the SA lacks `roles/iam.serviceAccountTokenCreator` **on itself**; `setup.sh` grants it, re-run that binding |
167
+ | Search Console 403 on every property | the SA was never added as a user in the console (step 6.1) |
168
+ | GA4 returns no rows | `ga4Property` is the `G-` tag instead of the numeric property id |
169
+ | Dashboard unreachable through the tunnel | container not up (`manage ps`), or the tunnel points at the wrong port |
170
+ | Scan and digests produce nothing | `modules.llm` is off, or the CLI is missing on a headless host — use the HTTP path |
171
+ | Mirror is empty | `staticExport` off, or the `afterRun` rsync hook missing or failing (check `data/last-run.json`) |
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: n-seo-review
3
+ description: The weekly n-seo pass — what shipped, what moved, judge each watching item succeeded/failed/still cooking, retire what is done, refresh the insights briefing. Use for a weekly or monthly review.
4
+ ---
5
+
6
+ # Weekly review
7
+
8
+ The loop only closes here. Shipped work sits in *watching* until someone
9
+ reads the data and says whether it worked.
10
+
11
+ ```sh
12
+ E=<engine checkout>/bin/n-seo.mjs
13
+ I=<instance dir>
14
+ PORT=$(python3 -c "import json;print(json.load(open('$I/n-seo.config.json'))['port'])" 2>/dev/null || echo 4600)
15
+ ```
16
+
17
+ Read the outputs; do not re-run the pipeline to review it.
18
+
19
+ ## 1. Gather
20
+
21
+ ```sh
22
+ cat "$I/data/last-run.json" # were the runs healthy this week
23
+ sed -n '/^## /,$p' "$I/docs/daily-log.md" | tail -120 # the week's entries
24
+ curl -s "http://localhost:$PORT/api/actions" # or MCP list_actions status=watching
25
+ cat "$I/data/opportunity-proposals.json" # verdicts + proposals
26
+ ```
27
+
28
+ Also useful: MCP `trends_timeseries` for a page-level before/after, and
29
+ `git log --oneline --since='1 week ago'` in each site repo for what actually
30
+ shipped.
31
+
32
+ ## 2. Judge every watching item
33
+
34
+ For each item with a `watching` note, find its page in the daily-log lines
35
+ across the week and compare against the note's own success criterion — CTR,
36
+ position, impressions, sessions, engagement, whichever the card named.
37
+
38
+ Assign one of three, and write the evidence next to it:
39
+
40
+ - **Succeeded** — the criterion is met. Say by how much.
41
+ - **Failed** — the window has passed and the number did not move, or moved the
42
+ wrong way. Say what the data shows and propose the next move; a failed
43
+ metadata change is a candidate for a different angle, not a repeat.
44
+ - **Still cooking** — the 28-day window has not closed, or impressions are too
45
+ thin to read. Leave it and note when to look again.
46
+
47
+ The scan's own verdicts in `opportunity-proposals.json` are *review triggers*,
48
+ not conclusions. Read them, then judge from the data yourself.
49
+
50
+ Beware a page whose demand collapsed for reasons unrelated to the change —
51
+ falling query volume in the trends file means the fix can be fine while the
52
+ number still drops. Say that rather than scoring it failed.
53
+
54
+ ## 3. Retire what is done
55
+
56
+ Only after it is judged succeeded, and only when there is nothing left to
57
+ watch:
58
+
59
+ ```sh
60
+ curl -sX POST "http://localhost:$PORT/api/backlog/<id>/retire"
61
+ ```
62
+
63
+ For a *failed* item, do not retire it. Either update its `watching` note with
64
+ what you learned and the next move, or replace it with a new card carrying
65
+ that evidence.
66
+
67
+ For rule-derived cards, remove or update the page's entry in `shippedWatch`
68
+ in `$I/config/backlog.json` — dropping the entry lets the card return as
69
+ active work if the underlying gap is still there, which is usually what you
70
+ want after a failure.
71
+
72
+ ## 4. Sweep the rest
73
+
74
+ - **Proposals** — present each with its evidence; accept the good ones
75
+ (Accept on `/actions`, or `POST /api/backlog/accept` with `index`) and say
76
+ plainly which you are ignoring and why. Never accept silently.
77
+ - **Health** — probe regressions and index-coverage problems from the log.
78
+ Pages Google has never crawled are a different problem from pages nobody
79
+ searches for; the `/indexing` page separates them.
80
+ - **Freezes expiring** — list pages whose 28-day window closes in the coming
81
+ week. Those are next week's metadata budget.
82
+ - **Conversions** — if `conversions` is configured, whether events are
83
+ arriving (`conversions_status`).
84
+
85
+ ## 5. Refresh the briefing if the picture changed
86
+
87
+ `$I/config/insights.json` is the hand-written narrative on `/insights`:
88
+ `{ "date": "YYYY-MM-DD", "insights": [ { "title", "verdict", "body": [], "move" } ] }`
89
+ with `verdict` one of `opportunity`, `warning`, `momentum`, `deprioritize`.
90
+
91
+ Rewrite it only when the picture actually changed — a new rising cluster, a
92
+ collapsing one, a bet that paid off. Update `date` when you do. Leave it alone
93
+ in a quiet week rather than churning it.
94
+
95
+ ## 6. Write the summary
96
+
97
+ Short, for the owner:
98
+
99
+ - **Shipped this week** — what, and the verdict where the data allows one.
100
+ - **Moved** — the numbers that changed, with the caveat where demand shifted.
101
+ - **Next week** — the top few, with the metadata budget already applied.
102
+ - **Needs you** — proposals to accept, approvals, anything blocked.
103
+
104
+ Impact numbers order the queue; they are never reported as expected results.
105
+ Recalibrate them against what you just observed if a card's estimate was
106
+ clearly wrong.
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: n-seo-setup
3
+ description: Take an n-seo install from a fresh clone to its first real daily run — config, Google service account, both console grants, doctor, first pull. Use when the dashboard is empty, doctor reports problems, or someone says they just cloned this.
4
+ ---
5
+
6
+ # Setup — clone to first real run
7
+
8
+ Goal: a green `doctor` and one completed `daily` run. Work top to bottom and
9
+ stop at each **ASK** — those are the owner's decisions, not yours.
10
+
11
+ Throughout, `E` is the engine and `I` is the instance:
12
+
13
+ ```sh
14
+ E=<engine checkout>/bin/n-seo.mjs # the directory holding src/ and bin/
15
+ I=<instance dir> # in-place mode: the same directory
16
+ node $E version --instance "$I" # prints engine, instance, and mode
17
+ ```
18
+
19
+ If `n-seo` is on PATH (`npm link` in the engine), `n-seo <cmd>` works instead.
20
+
21
+ ## 1. Establish the layout
22
+
23
+ Run `node $E version --instance "$I"`. It prints `mode: in-place` or
24
+ `mode: instance`.
25
+
26
+ - **in-place** — the checkout is the instance. Config lives beside `src/`.
27
+ - **instance** — config lives in a separate directory. If that directory is
28
+ empty, scaffold it: `node $E init "$I"`, which never overwrites.
29
+
30
+ `docs/INSTANCE.md` in the engine explains the split if the owner asks.
31
+
32
+ ## 2. Offer the demo first when there is no Google access yet
33
+
34
+ If the owner does not have a service account (or does not want to make one
35
+ right now), do this before anything else so they see the tool working:
36
+
37
+ ```sh
38
+ node $E demo --instance "$I" # synthetic dataset for the configured sites
39
+ node $E start --instance "$I" # dashboard on the configured port
40
+ ```
41
+
42
+ Every page populates. Say plainly that this is synthetic data, that
43
+ `node $E demo --instance "$I" -- --clean` removes it, and that the real setup
44
+ resumes at step 3. **ASK** whether to continue to real data now.
45
+
46
+ ## 3. Write the config
47
+
48
+ ```sh
49
+ cp <engine>/n-seo.config.example.json "$I/n-seo.config.json"
50
+ ```
51
+
52
+ Fill in `sites` first. For each site the owner names, collect:
53
+
54
+ | Field | Where it comes from |
55
+ |---|---|
56
+ | `host` | the canonical hostname |
57
+ | `gscProperty` | Search Console, **exactly** as shown: `sc-domain:example.com` or `https://www.example.com/` with the trailing slash |
58
+ | `gscHost` | only when the property covers more hosts than this site, or the site serves on `www` |
59
+ | `ga4Property` | GA4 Admin → Property details → the numeric Property ID, not the `G-…` measurement id |
60
+ | `brand` | a case-insensitive regex matching their branded queries |
61
+
62
+ Use `/n-seo-add-site` for each site if there are several — it covers the
63
+ domain-vs-URL-prefix and shared-property cases properly. Set `port` if 4600
64
+ is taken.
65
+
66
+ ## 4. Service account and the two grants
67
+
68
+ Full detail is `docs/SETUP-GOOGLE.md`; the shape is:
69
+
70
+ 1. A Google Cloud project with the Search Console API, Google Analytics Data
71
+ API and Google Analytics Admin API enabled.
72
+ 2. A service account with **no project roles**. Create a JSON key and store
73
+ it outside the repo:
74
+ ```sh
75
+ mkdir -p ~/.config/n-seo
76
+ mv ~/Downloads/<downloaded>.json ~/.config/n-seo/service-account.json
77
+ chmod 600 ~/.config/n-seo/service-account.json
78
+ ```
79
+ Point `google.serviceAccountKey` at that path.
80
+ 3. **Search Console** → property → Settings → Users and permissions → Add
81
+ user → the service-account email → **Full**.
82
+ 4. **GA4** → Admin → Property access management → Add users → the same email
83
+ → **Viewer**. Account-level Viewer covers every property under it.
84
+
85
+ **ASK** before creating cloud resources or a key on the owner's behalf, and
86
+ never paste a key's contents into the transcript. If the install runs on GCP,
87
+ `google.auth: "metadata"` avoids the key file entirely — see
88
+ `docs/SETUP-GOOGLE.md`.
89
+
90
+ ## 5. Doctor, and fix what it says
91
+
92
+ ```sh
93
+ node $E doctor --instance "$I"
94
+ ```
95
+
96
+ Work its output item by item; it names the fix for each. The common ones:
97
+
98
+ - *key file not found* — wrong path in `serviceAccountKey`, or the file is
99
+ not readable by this user.
100
+ - *a configured property is missing from the accessible list* — the grant in
101
+ step 4 has not landed. It can take a few minutes; if it never lands, the
102
+ property string does not match Search Console exactly.
103
+ - *`no ga4Property`* — expected for sites you deliberately left without GA4.
104
+ - *dashboard not running* — expected until step 7.
105
+
106
+ Do not continue while a FAIL remains.
107
+
108
+ ## 6. First run
109
+
110
+ Start narrow, then go wide:
111
+
112
+ ```sh
113
+ node $E daily --instance "$I" --only probe # no auth, proves the config's hosts
114
+ node $E daily --instance "$I" # the full run
115
+ cat "$I/data/last-run.json" # failures should be ""
116
+ tail -20 "$I/docs/daily-log.md" # today's entry
117
+ ```
118
+
119
+ The index-coverage sweep dominates the wall time; several minutes is normal.
120
+
121
+ ## 7. Dashboard, then scheduling
122
+
123
+ ```sh
124
+ node $E start --instance "$I"
125
+ ```
126
+
127
+ Open the configured port. Confirm the sites strip lists every configured site
128
+ and the queue has cards.
129
+
130
+ Then hand off scheduling. On this machine that is `docs/SCHEDULING.md` —
131
+ launchd, cron, systemd, and the installer script under `ops/templates/`. If it
132
+ should run on a server instead of a laptop, use `/n-seo-deploy`.
133
+
134
+ ## Rules
135
+
136
+ - Never print or commit a key, a token, or a `.env` value.
137
+ - Do not enable publishing modules (`indexNow`, `staticExport`,
138
+ `gitAutoCommit`) during setup. They belong to a deliberate decision later.
139
+ - Read `docs/OPERATING-RULES.md` before recommending any change to a site.
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: n-seo-ship
3
+ description: Implement one card from the n-seo queue in the site's own repo and record it as watching. Use when someone picks an action to do. Enforces the 28-day metadata freeze and the weekly batch limit.
4
+ ---
5
+
6
+ # Ship one queue card
7
+
8
+ One card, one branch, one PR, then record it. If the owner has not named a
9
+ card, run `/n-seo-triage` first and let them choose.
10
+
11
+ ```sh
12
+ E=<engine checkout>/bin/n-seo.mjs
13
+ I=<instance dir>
14
+ PORT=$(python3 -c "import json;print(json.load(open('$I/n-seo.config.json'))['port'])" 2>/dev/null || echo 4600)
15
+ ```
16
+
17
+ ## 1. Read the whole card
18
+
19
+ ```sh
20
+ curl -s "http://localhost:$PORT/api/actions" | python3 -m json.tool
21
+ ```
22
+
23
+ Or MCP `get_action` with a title substring. Use `spec` — it names the page,
24
+ the target queries, the shape of the change and the success criterion. Note
25
+ `id`, `host`, `tag` and whether it is rule-derived or curated (`source`).
26
+
27
+ ## 2. Freeze and batch check — STOP HERE IF EITHER FAILS
28
+
29
+ Only for cards that change a title or meta description (`tag` of `metadata`
30
+ or `ctr-gap`, or any card whose `how` rewrites metadata):
31
+
32
+ 1. **28-day freeze.** Look up the page in `shippedWatch` and in the dated
33
+ `watching` notes in `$I/config/backlog.json`. If its metadata changed
34
+ within 28 days, **stop and ask.** Say when the window opens. Iterating
35
+ inside it destroys the measurement the last change was making.
36
+ 2. **Weekly batch.** Count metadata changes already recorded this week across
37
+ every site. At roughly eight, **stop and ask** before adding another.
38
+
39
+ Content additions, internal links, schema, and hygiene fixes are not metadata
40
+ changes and are not limited by either rule — but a page inside its freeze
41
+ still gets no title or description edit while you are in there.
42
+
43
+ ## 3. Work in the site's repo, never in the instance
44
+
45
+ The site's code is in its own repository (`repo` on the site's page and in
46
+ the config). The instance holds config and content only.
47
+
48
+ ```sh
49
+ cd <site repo>
50
+ git status --porcelain # if dirty, use a worktree; never touch uncommitted work
51
+ git checkout main && git pull
52
+ git checkout -b seo-<short-slug>
53
+ ```
54
+
55
+ If the checkout is dirty, `git worktree add ../<repo>-seo-<slug> -b seo-<slug>`
56
+ and work there instead.
57
+
58
+ Make exactly what the spec describes. For the common kinds:
59
+
60
+ - **Metadata fix / CTR gap** — the `<title>` restates the ranking query as its
61
+ answer, front-loaded, under about 60 characters. The description is one
62
+ ~150-character sentence that answers the query directly. Metadata only.
63
+ - **Striking distance ("push to page 1")** — a section that answers the target
64
+ queries verbatim: an H2 phrased as the query, then a 40–60 word direct
65
+ answer, then detail. Two or three internal links to the page from related
66
+ pages using the query as anchor text.
67
+ - **Engagement mismatch** — rewrite the opening so it delivers what the click
68
+ promised; keep the URL.
69
+ - **Hygiene** — robots.txt, sitemap, `llms.txt`, real 404s, unblocking AI
70
+ crawlers, server-rendering the shell. Verify with
71
+ `node $E daily --instance "$I" --only probe` after deploy.
72
+
73
+ ## 4. Verify before opening the PR
74
+
75
+ Run whatever the site repo uses (build, tests, lint). Check the rendered page
76
+ locally. Confirm the change is actually in the served HTML, not only in
77
+ source — the metadata audit reads the live page.
78
+
79
+ Push and open a PR whose description carries the card's evidence: the queries,
80
+ the numbers, the success criterion. That description is how the next daily run
81
+ gets attributed. Never push to `main` directly.
82
+
83
+ ## 5. Record it — the step people skip
84
+
85
+ After it is merged and live:
86
+
87
+ **Mark the card watching** with a dated note saying what changed and what you
88
+ are waiting for:
89
+
90
+ ```sh
91
+ curl -sX POST "http://localhost:$PORT/api/backlog/<id>/watch" \
92
+ --data-urlencode "note=title rewritten for the widgets cluster, frozen until <date>, watching CTR"
93
+ ```
94
+
95
+ The endpoint prefixes today's date. Rule-derived cards have no backlog entry
96
+ to write to — for those, add the page to `shippedWatch` in
97
+ `$I/config/backlog.json` instead, with the same dated note, so the card shows
98
+ as watching rather than reappearing as new work:
99
+
100
+ ```json
101
+ "shippedWatch": {
102
+ "https://example.com/pricing": "2026-05-04: title rewritten, frozen until 2026-06-01, watching CTR"
103
+ }
104
+ ```
105
+
106
+ **Add the page to `watchPages`** in `n-seo.config.json` if it is not there, so
107
+ its numbers appear in every daily-log entry.
108
+
109
+ Do not delete the card. Shipped work becomes watching; deleting it is how the
110
+ same page gets "fixed" again in six weeks by someone reading the same gap.
111
+
112
+ ## 6. Report
113
+
114
+ Say what shipped, the PR link, the success criterion, and the date the freeze
115
+ lifts. The next runs decide whether it worked.
116
+
117
+ ## Never
118
+
119
+ - Never edit `config/backlog.json` except to add a `shippedWatch` entry or via
120
+ the accept / watch / retire endpoints.
121
+ - Never touch a page inside its freeze window without the owner saying so.
122
+ - Never commit a secret, and never push to a site's `main`.