n-seo 0.1.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 (88) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/bin/n-seo.mjs +310 -0
  5. package/docs/ADDING-A-SITE.md +82 -0
  6. package/docs/ARCHITECTURE.md +213 -0
  7. package/docs/DEPLOY.md +300 -0
  8. package/docs/FAQ.md +93 -0
  9. package/docs/INSTANCE.md +365 -0
  10. package/docs/MCP.md +104 -0
  11. package/docs/OPERATING-RULES.md +106 -0
  12. package/docs/PLAYBOOK.md +122 -0
  13. package/docs/PRD.md +249 -0
  14. package/docs/RELEASING.md +189 -0
  15. package/docs/SCHEDULING.md +104 -0
  16. package/docs/SETUP-GOOGLE.md +215 -0
  17. package/docs/examples/campaign.json +59 -0
  18. package/docs/examples/draft.md +43 -0
  19. package/docs/screenshots/overview.png +0 -0
  20. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  21. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  22. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  23. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  24. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  25. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  26. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  27. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  28. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  29. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  30. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  31. package/ingest/analyze_ga4.py +79 -0
  32. package/ingest/analyze_gsc.py +136 -0
  33. package/ingest/analyze_metadata.py +158 -0
  34. package/ingest/analyze_trends.py +145 -0
  35. package/ingest/google_auth.py +238 -0
  36. package/ingest/http_util.py +87 -0
  37. package/ingest/pull_ga4.py +107 -0
  38. package/ingest/pull_gsc.py +111 -0
  39. package/ingest/pull_index_status.py +179 -0
  40. package/ingest/pull_timeseries.py +130 -0
  41. package/ingest/seo_config.py +213 -0
  42. package/n-seo.config.example.json +110 -0
  43. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  44. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  45. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  46. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  48. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  49. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  50. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  51. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  52. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  53. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  54. package/ops/daily.py +250 -0
  55. package/ops/daily_diff.py +151 -0
  56. package/ops/demo_data.py +529 -0
  57. package/ops/doctor.py +266 -0
  58. package/ops/export_static.py +125 -0
  59. package/ops/hn_digest.py +169 -0
  60. package/ops/indexnow.py +107 -0
  61. package/ops/install-launchd.sh +76 -0
  62. package/ops/llm.py +139 -0
  63. package/ops/mcp-smoke-stdio.mjs +61 -0
  64. package/ops/opportunity_scan.py +185 -0
  65. package/ops/publish.py +158 -0
  66. package/ops/reddit_digest.py +168 -0
  67. package/ops/templates/n-seo-daily.service +11 -0
  68. package/ops/templates/n-seo-daily.timer +11 -0
  69. package/ops/templates/n-seo-dashboard.service +15 -0
  70. package/ops/templates/n-seo.cron +3 -0
  71. package/ops/templates/n-seo.daily.plist +29 -0
  72. package/ops/templates/n-seo.dashboard.plist +22 -0
  73. package/package.json +77 -0
  74. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
  75. package/probes/site_probe.py +201 -0
  76. package/public/favicon.svg +6 -0
  77. package/public/styles.css +632 -0
  78. package/src/actions.ts +255 -0
  79. package/src/backlog.ts +197 -0
  80. package/src/config.ts +220 -0
  81. package/src/data.ts +895 -0
  82. package/src/insights.ts +22 -0
  83. package/src/mcp-stdio.ts +21 -0
  84. package/src/mcp.ts +490 -0
  85. package/src/server.tsx +260 -0
  86. package/src/settings.tsx +329 -0
  87. package/src/views.tsx +1487 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,213 @@
1
+ # Architecture
2
+
3
+ n-seo is a local-first control plane for organic growth across one or
4
+ more websites: **SEO** (classic search), **AEO** (answer engines: featured
5
+ snippets, AI Overviews) and **GEO** (being cited by ChatGPT, Claude,
6
+ Perplexity). It runs on a machine you control, keeps all data in local JSON files, and
7
+ turns them into a ranked queue of concrete actions.
8
+
9
+ ```
10
+ ┌──────────── daily run (ops/daily.py) ────────────┐
11
+ Search Console ──┤ ingest/pull_gsc.py → data/gsc/… │
12
+ GA4 ─────────────┤ ingest/pull_ga4.py → data/ga4/… │
13
+ your live sites ─┤ probes/site_probe.py → data/probes/… │
14
+ │ ingest/pull_timeseries.py → data/timeseries/… │
15
+ │ ingest/analyze_metadata.py → data/metadata-audit.json
16
+ │ ingest/pull_index_status.py→ data/index-status.json
17
+ │ ops/opportunity_scan.py → data/trends-*.json, data/opportunity-proposals.json
18
+ │ ops/daily_diff.py → docs/daily-log.md │
19
+ │ ops/hn_digest.py (opt-in) → data/hn-digest.json │
20
+ │ ops/reddit_digest.py (opt) → data/reddit-digest.json
21
+ │ ops/export_static.py (opt) → site/ │
22
+ │ ops/publish.py (opt) → your bucket/host │
23
+ └─────────────────────────────────────────────────┘
24
+
25
+ dashboard (src/server.tsx, Hono + hono/jsx SSR)
26
+ reads data/ + config/ + content/ on every request
27
+
28
+ MCP server (src/mcp.ts) — the same data for agents
29
+ ```
30
+
31
+ ## One config file
32
+
33
+ `n-seo.config.json` (copy from `n-seo.config.example.json`; path
34
+ overridable with `$N_SEO_CONFIG`) is read by the TypeScript app
35
+ (`src/config.ts`) **and** every Python script (`ingest/seo_config.py`). If
36
+ the file does not exist, both fall back to the example so a fresh checkout
37
+ can run the demo. Nothing else carries a site list.
38
+
39
+ | Key | Meaning |
40
+ |---|---|
41
+ | `name` | Shown in the dashboard header |
42
+ | `port` | Dashboard port (env `SEO_PORT` overrides) |
43
+ | `google.auth` | `service-account-key` (default) · `gcloud-impersonate` · `gcloud-user` |
44
+ | `google.serviceAccountKey` | Path to the SA JSON key (`~` ok). `$GOOGLE_APPLICATION_CREDENTIALS` also works |
45
+ | `google.impersonate` | SA email for the impersonation mode |
46
+ | `sites[]` | `host`, `label`, `gscProperty` (`sc-domain:x` or `https://x/`), `gscHost` (page-URL host filter; defaults to host), `ga4Property` (numeric id), `brand` (regex for branded queries), `repo`, `hosting` |
47
+ | `watchPages[]` | URLs whose GSC numbers the daily log reports every day |
48
+ | `conversions` | `{ site, events[], sourceDimension }` — GA4 key events that are your real goal (signups, leads). Optional |
49
+ | `participation.expertise` | Who you are / what you know first-hand. The only context the digest briefings get |
50
+ | `modules.<key>.enabled` | See "Modules" |
51
+
52
+ Derived helpers (identical in both languages): `gscSlug(property)` strips
53
+ `sc-domain:` / the scheme and the trailing slash and maps `/`→`_`;
54
+ `gscDataSlug(property)` is the `data/gsc/<slug>/` directory name — the same,
55
+ plus a `-urlprefix` suffix for url-prefix (`https://…`) properties so they
56
+ never share a directory with the domain property of the same host
57
+ (`sc-domain:example.com` → `example.com`, `https://www.example.com/` →
58
+ `www.example.com-urlprefix`).
59
+
60
+ ## Data files (all under `data/`, gitignored, regenerable)
61
+
62
+ | Path | Written by | Shape |
63
+ |---|---|---|
64
+ | `gsc/<slug>/{queries,pages,query_page,dates}.json` | pull_gsc.py | `{site, dimensions, startDate, endDate, rowCount, rows:[{keys[], clicks, impressions, ctr, position}]}` — 16-month window |
65
+ | `gsc/<slug>/{queries,pages,query_page}_90d.json` | pull_gsc.py | same, trailing 90 days (**the decision window**) |
66
+ | `ga4/<host>/daily.json` | pull_ga4.py | raw GA4 runReport: dims `[date]`, mets `[sessions,totalUsers]`, 90d |
67
+ | `ga4/<host>/sources.json` | pull_ga4.py | dims `[sessionSource, sessionMedium]`, mets `[sessions,totalUsers]` |
68
+ | `ga4/<host>/landing.json` | pull_ga4.py | dims `[landingPage]`, mets `[sessions, engagementRate]` |
69
+ | `ga4/<host>/funnel.json` | pull_ga4.py (only for `conversions.site`) | dims `[date, eventName, <sourceDimension>]` (falls back to `[date,eventName]`), mets `[eventCount]` |
70
+ | `timeseries/gsc-<slug>.json` | pull_timeseries.py | `{site, startDate, endDate, rows:[{keys:[date,page], clicks, impressions}]}` 180d |
71
+ | `timeseries/ga4-<host>.json` | pull_timeseries.py | `{site, rows:[{date:"YYYYMMDD", page, sessions}]}` 180d |
72
+ | `timeseries/ga4-sources-<host>.json` | pull_timeseries.py | `{site, rows:[{date:"YYYYMMDD", source, medium, sessions}]}` 180d — daily traffic by source, bucketed into six groups (AI assistants, Search, Direct, Referral, Social, Other) by `classifySource` in `src/data.ts` |
73
+ | `probes/probe-YYYYmmdd-HHMMSS.json` | site_probe.py | `{probed_at, sites:[{site:"https://host", robots{status,exists,sitemap_declared,ai_crawlers_blocked[]}, sitemap{status,exists,url_count,newest_lastmod}, "llms.txt"{status,exists,bytes}, "llms-full.txt"{…}, homepage{status,title,meta_description,canonical,og_tags,jsonld_types[],h1_count,lang,visible_text_bytes}, soft_404{status,real_404}}]}` |
74
+ | `metadata-audit.json` | analyze_metadata.py | `{generated, window:"90d", sites:{host:[{page,title,description,imps,clicks,issues[],top_queries[{q,imps,clicks,pos,ctr}],missed_clicks_window}]}}` |
75
+ | `index-status.json` | pull_index_status.py | `{generated, sites:{host:{property, checked, indexed, neverCrawled, sitemap{submitted, entries[{path,lastSubmitted,lastDownloaded,pending,errors,warnings}]}, problems:[{url,coverage,lastCrawl,verdict,robots,canonicalMismatch,googleCanonical,detail}]}}}` |
76
+ | `trends-YYYY-MM-DD.json` | analyze_trends.py | `{generated, sites:{"<gscProperty>":{recent_split, prior_split, rising[], falling[], monthly{}}}, ai_referrals:{host:{ai{ym:n}, total{ym:n}}}}` |
77
+ | `opportunity-proposals.json` | opportunity_scan.py | `{generated, candidates[], proposals[{host,title,kind,why,how,spec[],impact,effort,tag}], verdicts[{title,verdict,evidence}], inference_ran}` |
78
+ | `hn-digest.json` | hn_digest.py | `{generated, stats{user,karma,created,comments}, picks[{id,title,url,story_url,comments,points,why,briefing,commented}]}` |
79
+ | `reddit-digest.json` | reddit_digest.py | `{generated, user, auth, picks[{id,title,url,sub,comments,score,age_days,why,briefing,commented}]}` |
80
+ | `last-run.json` | daily.py | `{ts, failures, steps:[{name, ok, seconds}]}` |
81
+ | `daily-ops.log` | daily.py | appended stdout of each run |
82
+
83
+ Committed, human-curated inputs:
84
+
85
+ | Path | Purpose |
86
+ |---|---|
87
+ | `config/backlog.json` | `{actions:[Action…], shippedWatch:{url:note}}` — your strategic queue. `Action` = `{id, host, title, kind, why, how, spec[], impact, effort:"S"\|"M"\|"L", tag, watching?}` |
88
+ | `config/insights.json` | `{date, insights:[{title, verdict, body[], move}]}` — optional narrative briefing |
89
+ | `content/drafts/*.md` | Distribution drafts with front-matter `title, order, action, channel, status, tags, notes` |
90
+ | `content/campaigns/*.json` | `{slug, name, site, summary, voice, targets[{rank,name,category,url,contact,angle,value,likelihood,evidence,status}], templates[{id,audience,subject,body}], plan[{day,action,template,notes}], week2, later, cautions[]}` |
91
+ | `docs/daily-log.md` | Appended by the daily run; one `## YYYY-MM-DD` section per day |
92
+
93
+ ## The action engine (src/actions.ts)
94
+
95
+ Rules over the 90-day window, each producing `Action`s with an evidence
96
+ string, a concrete move, a spec, an impact estimate (clicks/month, **for
97
+ ordering only**) and an effort size. Score = impact / effort weight
98
+ (S=1, M=2.5, L=5). Rules: metadata-audit findings, CTR gaps, striking
99
+ distance (pos 5–15), probe hygiene, landing-page engagement mismatch, 28-day
100
+ traffic drop. Merged with `config/backlog.json`; any action whose page is in
101
+ `shippedWatch` is shown as *watching* instead of *active*.
102
+
103
+ The backlog file is hot-reloaded (stat polling) so edits — by hand or via
104
+ the dashboard's accept/watch/retire buttons — show up without a restart.
105
+
106
+ ## Modules (opt-in, `modules.<key>.enabled`)
107
+
108
+ | Key | Default | What it turns on |
109
+ |---|---|---|
110
+ | `indexStatus` | on | URL Inspection sweep of each sitemap |
111
+ | `metadataAudit` | on | live title/description audit |
112
+ | `opportunityScan` | on | trend refresh + uncovered-riser detection |
113
+ | `llm` | off | local inference command (`command`, `fastCommand`; prompt on stdin, reply on stdout). Enables proposals/verdicts in the scan and briefings in the digests |
114
+ | `hackerNews` | off | HN thread digest (`user`, `topics:[[query, why]]`) |
115
+ | `reddit` | off | Reddit thread digest (`user`, `topics:[[sub, query, why]]`; creds in `.env`) |
116
+ | `indexNow` | off | key generation + pings (`keyFile`) |
117
+ | `staticExport` | off | dashboard → `site/` after each run (`signOutUrl`, `signOutLabel` add a sign-out link to every exported page) |
118
+ | `publish` | off | copy `site/` to `destination` (`target`: `gcs` \| `s3` \| `rsync` \| `command`; `delete`, `dryRun`, `env`) |
119
+ | `gitAutoCommit` | off | commit (+push) log/export after each run |
120
+ | `notifications` | off | macOS notification on step failure |
121
+
122
+ The dashboard's **Settings** page toggles these and edits the digest topics,
123
+ writing back to `n-seo.config.json`. Nothing in any module posts, sends,
124
+ or publishes on your behalf: digests produce briefings, the scan produces
125
+ proposals, campaigns produce templates. You act.
126
+
127
+ ## Dashboard routes
128
+
129
+ `/` overview (Today board + portfolio strip) · `/actions` (proposed → active
130
+ → watching, searchable) · `/insights` · `/trends[/N]` · `/content` (+
131
+ `/drafts/:slug`, `/campaigns/:slug`) · `/site/:host` · `/indexing` ·
132
+ `/probes` · `/logs` · `/settings` · `/api/actions` (JSON) · `/mcp` (HTTP
133
+ MCP, bearer token) · write endpoints: `POST /api/backlog/accept`,
134
+ `POST /api/backlog/:id/watch`, `POST /api/backlog/:id/retire`,
135
+ `POST /settings`.
136
+
137
+ ## Engine and instance
138
+
139
+ The checkout is the **engine**: code, public assets, engine docs. An
140
+ **instance** is one user's config, queue, content and data. By default they
141
+ are the same directory ("in-place" mode — a plain clone works unchanged).
142
+ Set `N_SEO_INSTANCE=/path/to/instance` (or use the `n-seo` CLI, which sets it
143
+ for you) to keep them apart; upgrading the engine is then a `git pull` or
144
+ `npm update` that never touches instance files.
145
+
146
+ | Owned by | Paths |
147
+ |---|---|
148
+ | engine (`ROOT`) | `src/`, `ingest/`, `ops/`, `probes/`, `public/`, `bin/`, `tests/`, `n-seo.config.example.json`, `docs/PLAYBOOK.md`, `docs/OPERATING-RULES.md`, `docs/ARCHITECTURE.md` |
149
+ | instance (`INSTANCE`) | `n-seo.config.json` (`N_SEO_CONFIG` still overrides), `.env`, `config/backlog.json`, `config/insights.json`, `content/drafts/`, `content/campaigns/`, `data/`, `site/`, `docs/daily-log.md`, `docs/reports/`, the IndexNow key file (`modules.indexNow.keyFile`, relative to the instance) |
150
+
151
+ Both loaders expose the split: `src/config.ts` → `ROOT`, `INSTANCE`,
152
+ `engineInfo()`; `ingest/seo_config.py` → `ROOT`, `INSTANCE`, `DATA`,
153
+ `engine_info()`. Engine scripts always run with `cwd=ROOT` and find the
154
+ instance through the environment, never through the working directory.
155
+ Git auto-commit commits in the instance. `engine_info` (MCP), the Engine
156
+ card on `/settings`, `n-seo version` and the first block of `doctor` all
157
+ report version · commit · mode · engine path · instance path.
158
+
159
+ ### Hooks
160
+
161
+ ```json
162
+ "hooks": {
163
+ "beforeRun": ["python3 my/prep.py"],
164
+ "afterStep": { "daily-diff": ["python3 my/sync.py"] },
165
+ "afterRun": ["rsync -a site/ user@host:/srv/mirror/"]
166
+ }
167
+ ```
168
+
169
+ Each entry is a shell string run by `ops/daily.py` with `cwd=INSTANCE` and
170
+ `N_SEO_ROOT`, `N_SEO_INSTANCE`, `N_SEO_STEP` (afterStep only) in the
171
+ environment. Output is teed to `data/daily-ops.log` like a step and each hook
172
+ is recorded in `last-run.json` as `hook:before:<i>`, `hook:<step>:<i>` or
173
+ `hook:after:<i>`. A failing hook counts as a failure (and notifies) but never
174
+ aborts the run. `afterRun` hooks see a complete `last-run.json`. `--skip hooks`
175
+ runs steps only; `--list` shows hooks in order.
176
+
177
+ Two things worth knowing before you put something destructive in a hook.
178
+ Hooks are run-level, not step-level: `beforeRun` and `afterRun` fire even
179
+ under `--only`, so a one-step smoke test still runs them (the command prints
180
+ a note when it is about to). And `afterStep` fires whether or not its step
181
+ succeeded, so a hook that publishes something should check the step's own
182
+ output rather than assume it ran.
183
+
184
+ ### `gscExtraProperties`
185
+
186
+ ```json
187
+ "gscExtraProperties": ["https://example.com/"]
188
+ ```
189
+
190
+ Extra Search Console properties (for example a url-prefix property that
191
+ duplicates a domain property) pulled by `pull_gsc.py` and `pull_timeseries.py`
192
+ into `data/gsc/<gscDataSlug>/` with the same windows and datasets (a url-prefix
193
+ property lands in `<host>-urlprefix/`, never colliding with the domain
194
+ property's directory), and checked by `doctor`. They are not sites: nothing in the dashboard, the trend analysis or
195
+ the reports shows them.
196
+
197
+ ### The `n-seo` CLI (`bin/n-seo.mjs`)
198
+
199
+ | Command | Does |
200
+ |---|---|
201
+ | `n-seo init [dir]` | Scaffold an instance: config from the example, empty `config/`, `content/`, `.env`, `.gitignore`, `.mcp.json` pointing at this engine, README. Never overwrites |
202
+ | `n-seo start` · `dev` · `daily` · `doctor` · `demo` · `mcp` · `check` · `export` | Run the engine's command against the instance (`--instance <path>`, else `$N_SEO_INSTANCE`, else cwd); remaining args pass through |
203
+ | `n-seo upgrade` | Git engine: `git pull --ff-only`, `npm ci` if the lockfile changed, `npm run check`; prints the exact `git reset --hard <sha>` if the check fails. npm engine: says to `npm update n-seo` |
204
+ | `n-seo version` | engine version, commit, engine path, instance path, mode |
205
+
206
+ ## Operating rules the tooling encodes
207
+
208
+ - Decisions ride the 90-day window; 16-month data is for totals and history.
209
+ - Impact numbers order the queue. They are not forecasts.
210
+ - After changing a page's title/description, freeze that page's metadata for
211
+ 28 days. Stagger metadata changes (≈8/week max across all sites).
212
+ - Shipped work becomes *watching*, never deleted — the data decides next.
213
+ - Community participation is human: the tool briefs, you write.
package/docs/DEPLOY.md ADDED
@@ -0,0 +1,300 @@
1
+ # Deploying n-seo somewhere other than your laptop
2
+
3
+ n-seo is local-first, not laptop-first. "Local" means the data lives on
4
+ infrastructure you control rather than in someone's SaaS; it does not have to
5
+ mean the machine you are typing on. A laptop is a poor host for a daily job:
6
+ it sleeps, it travels, and the run silently skips a day.
7
+
8
+ This document is about moving it to a box that is always on.
9
+
10
+ ## The shape
11
+
12
+ One host runs two long-lived things — the dashboard and a scheduler that
13
+ fires the daily run — against one instance directory. That directory is the
14
+ only state.
15
+
16
+ ```
17
+ ┌─────────────────────── your host ────────────────────────┐
18
+ │ docker compose │
19
+ │ dashboard ──┐ │
20
+ │ scheduler ──┴──► /instance config, queue, │
21
+ │ (daily at 07:00) content, data, logs │
22
+ └──────────────────────────────────────────────────────────┘
23
+ │ tunnel (IAP / SSH / Tailscale) │ publish step
24
+ ▼ ▼
25
+ you, on :4600 a bucket, served read-only
26
+ behind IAP — the mirror
27
+ ```
28
+
29
+ Two audiences, two doors. **You** get the live dashboard, always through a
30
+ tunnel, never over the open internet. **Everyone else** — colleagues, a
31
+ client — gets the static mirror: yesterday's HTML with no application behind
32
+ it, behind whatever sign-in you already have.
33
+
34
+ ## Rules, not suggestions
35
+
36
+ - **Never publish the dashboard's port.** It has write endpoints, and the
37
+ Settings page sets the LLM command that the daily run executes. Reaching
38
+ the dashboard is equivalent to running code on the host. It binds
39
+ `127.0.0.1` by default and the container publishes to loopback only; keep
40
+ it that way and tunnel in.
41
+ - **If you must have a hostname, put auth in front.** The Caddy overlay in
42
+ `docker/` does basic auth and TLS. IAP or Cloudflare Access are better.
43
+ Nothing goes in front of it means nothing is protecting it.
44
+ - **No secret in the image or in git.** Mount the service-account key
45
+ read-only, or on GCP use no key at all (see below). `.env` stays out of
46
+ version control; `n-seo init` already gitignores it.
47
+ - **The mirror is the thing you share.** It is static and read-only by
48
+ construction. Share that, not the dashboard.
49
+
50
+ ## Pick a host
51
+
52
+ | | Good when | Notes |
53
+ |---|---|---|
54
+ | **Any Linux box with Docker** | you already have a VPS, a NAS, a home server | `deploy/vm/startup.sh` sets it up. Cheapest path. |
55
+ | **A small GCE VM** | you are already on GCP, want IAP and disk snapshots | `deploy/gcp/setup.sh` builds the whole thing. `e2-small` is plenty. |
56
+ | **Cloud Run for the engine** | — | Don't. See below. |
57
+
58
+ ### Why not Cloud Run for the engine
59
+
60
+ It is the obvious idea and it does not work, for a specific reason:
61
+ `ops/export_static.py` builds the site into a staging directory and finishes
62
+ with an atomic `Path.rename()`, so a failed export can never publish an empty
63
+ mirror. GCS FUSE — the only way to give Cloud Run persistent storage — has no
64
+ atomic directory rename. You would trade a real guarantee for a scale-to-zero
65
+ you do not need for a job that runs once a day.
66
+
67
+ Cloud Run is still the right tool for *serving the mirror*, where the bucket
68
+ is mounted read-only and nothing renames anything. That is what
69
+ `deploy/gcp/cloud-run-mirror/` is.
70
+
71
+ ## Any Linux box
72
+
73
+ ```sh
74
+ curl -fsSLO https://raw.githubusercontent.com/en-dash-consulting/n-seo/main/deploy/vm/startup.sh
75
+ sudo bash startup.sh
76
+
77
+ sudo -u n-seo /opt/n-seo/manage init
78
+ sudo -u n-seo vi /srv/n-seo/instance/n-seo.config.json
79
+ sudo -u n-seo /opt/n-seo/manage doctor
80
+ sudo -u n-seo /opt/n-seo/manage daily
81
+ ```
82
+
83
+ Then tunnel to the dashboard:
84
+
85
+ ```sh
86
+ ssh -N -L 4600:localhost:4600 you@your-host
87
+ ```
88
+
89
+ Details, commands and backup instructions: `deploy/vm/README.md`.
90
+
91
+ ## GCP, end to end
92
+
93
+ ```sh
94
+ gcloud config set project YOUR_PROJECT
95
+ cd deploy/gcp
96
+
97
+ ./setup.sh --dry-run # read what it will do
98
+ ./setup.sh # do it
99
+ ```
100
+
101
+ That creates a service account with Token Creator on itself, a private bucket
102
+ for the mirror, a persistent data disk, a VM with the SA attached and **no
103
+ external IP**, a firewall that allows only IAP's SSH range, and the Cloud Run
104
+ mirror behind IAP. Every value at the top of the script is overridable
105
+ (`REGION=europe-west1 ./setup.sh`).
106
+
107
+ It cannot do four things, and prints them when it finishes:
108
+
109
+ 1. **Search Console** — add the service account as a **Full** user on every
110
+ property. Domain properties sometimes reject a service-account address in
111
+ that UI; `docs/SETUP-GOOGLE.md` covers verifying it as an owner instead.
112
+ 2. **GA4** — add the same address with the **Viewer** role.
113
+ 3. **IAP members** — who may open the mirror.
114
+ 4. **Your instance** — the config, the queue, the content. Yours to write.
115
+
116
+ ### Auth with no key file
117
+
118
+ On GCP, set:
119
+
120
+ ```json
121
+ "google": { "auth": "metadata" }
122
+ ```
123
+
124
+ The engine takes the VM's own identity from the metadata server and exchanges
125
+ it, through IAM Credentials, for a token scoped to Search Console or GA4 —
126
+ the metadata server's default token is `cloud-platform` scoped, which the
127
+ Search Console API rejects. That exchange is why the service account needs
128
+ `roles/iam.serviceAccountTokenCreator` **on itself**, which `setup.sh`
129
+ grants. No key is created, downloaded or stored.
130
+
131
+ Off GCP, keep a key file mounted read-only and point
132
+ `google.serviceAccountKey` at it.
133
+
134
+ ### Publishing the mirror
135
+
136
+ `staticExport` builds `site/`; `publish` copies it wherever people read it.
137
+ Both are steps in the daily run, so they are logged, retried once and
138
+ recorded in `last-run.json` like everything else.
139
+
140
+ ```json
141
+ "modules": {
142
+ "staticExport": { "enabled": true, "signOutUrl": "/_gcp_iap/clear_login_cookie" },
143
+ "publish": {
144
+ "enabled": true,
145
+ "target": "gcs",
146
+ "destination": "gs://YOUR_PROJECT-n-seo-mirror",
147
+ "delete": true,
148
+ "dryRun": false,
149
+ "env": {}
150
+ }
151
+ }
152
+ ```
153
+
154
+ The VM's service account already has write access to that bucket, so nothing
155
+ here carries a credential. `deploy/gcp/cloud-run-mirror/README.md` has the
156
+ serving side.
157
+
158
+ **Four targets.** Each needs its own tool on `PATH`, and the step says so
159
+ plainly when one is missing.
160
+
161
+ | `target` | Runs | `delete` adds |
162
+ |---|---|---|
163
+ | `gcs` | `gcloud storage rsync site <destination> --recursive` | `--delete-unmatched-destination-objects` |
164
+ | `s3` | `aws s3 sync site <destination>` | `--delete` |
165
+ | `rsync` | `rsync -a site/ <destination>` | `--delete` |
166
+ | `command` | `modules.publish.command`, as a shell string | nothing — your command decides |
167
+
168
+ `delete` is what makes the mirror match the export instead of accumulating
169
+ pages you have since removed. It also means a broken export would delete a
170
+ working mirror, which is why `ops/export_static.py` builds into a staging
171
+ directory and only swaps it in on success.
172
+
173
+ **Rehearse first.** Set `dryRun` and the step prints the exact command
174
+ without running it:
175
+
176
+ ```
177
+ publish: env CLOUDSDK_CONFIG
178
+ publish: DRY RUN — would run:
179
+ gcloud storage rsync /srv/n-seo/site gs://acme-n-seo-mirror --recursive --delete-unmatched-destination-objects
180
+ publish: set modules.publish.dryRun to false to publish for real
181
+ ```
182
+
183
+ Read that line before you clear `dryRun`, especially with `delete` on and a
184
+ bucket that is already serving something.
185
+
186
+ **Credentials.** `env` is merged into the publish command's environment only,
187
+ so a deployment that keeps its cloud config somewhere specific can point at
188
+ it without exporting that for the whole run:
189
+
190
+ ```json
191
+ "env": { "CLOUDSDK_CONFIG": "~/.config/acme/gcloud" }
192
+ ```
193
+
194
+ `~` and `$VARS` are expanded. Only the key names are ever logged.
195
+
196
+ ### A sign-out link on the mirror
197
+
198
+ The mirror sits behind an auth proxy, and a proxy needs a way out. Set the
199
+ URL yours uses and every exported page gets a sign-out link in the topbar:
200
+
201
+ ```json
202
+ "staticExport": { "enabled": true, "signOutUrl": "/oauth2/sign_out", "signOutLabel": "Sign out" }
203
+ ```
204
+
205
+ IAP uses `/_gcp_iap/clear_login_cookie`, oauth2-proxy `/oauth2/sign_out`,
206
+ Cloudflare Access `/cdn-cgi/access/logout`. The live dashboard renders
207
+ nothing for it — there is nothing to sign out of on your own machine.
208
+
209
+ ### Reaching the dashboard on GCP
210
+
211
+ ```sh
212
+ gcloud compute start-iap-tunnel n-seo 4600 \
213
+ --local-host-port=localhost:4600 --zone us-east1-b
214
+ open http://localhost:4600
215
+ ```
216
+
217
+ No public IP, no open port, no password to leak.
218
+
219
+ ## The LLM module on a headless host
220
+
221
+ `modules.llm` shells out to a local CLI by default (`claude -p`). On a server
222
+ that binary usually is not there and is not signed in, and the opportunity
223
+ scan and the digests quietly fall back to no proposals and no briefings.
224
+
225
+ Either install and authenticate the CLI in the image, or configure the HTTP
226
+ path so no CLI is needed:
227
+
228
+ ```json
229
+ "modules": {
230
+ "llm": {
231
+ "enabled": true,
232
+ "http": {
233
+ "provider": "anthropic",
234
+ "model": "claude-sonnet-5",
235
+ "fastModel": "claude-haiku-4-5-20251001",
236
+ "apiKeyEnv": "ANTHROPIC_API_KEY"
237
+ }
238
+ }
239
+ }
240
+ ```
241
+
242
+ with `ANTHROPIC_API_KEY` in the instance's `.env` (never in the image).
243
+ `doctor` reports which path it will use.
244
+
245
+ ## Backups
246
+
247
+ The instance directory is the whole story, and if you keep it in its own git
248
+ repo — which `docs/INSTANCE.md` recommends — most of it is already backed up
249
+ on every daily run when `gitAutoCommit` is on. `data/` is regenerable; the
250
+ next run rewrites it.
251
+
252
+ ```sh
253
+ gcloud compute disks snapshot n-seo-data --zone us-east1-b # GCE
254
+
255
+ sudo tar czf n-seo-instance-$(date +%F).tgz \
256
+ --exclude=data --exclude=site -C /srv/n-seo instance # anywhere
257
+ ```
258
+
259
+ Restoring is putting the directory back and running `manage daily` once.
260
+
261
+ ## Upgrades
262
+
263
+ ```sh
264
+ sudo -u n-seo /opt/n-seo/manage upgrade
265
+ ```
266
+
267
+ Pulls the engine, rebuilds the image, restarts the stack, re-runs the offline
268
+ checks. The instance directory is never touched by an upgrade — that
269
+ separation is the point of `docs/INSTANCE.md`. Roll back by checking out the
270
+ previous engine commit and running `manage up`.
271
+
272
+ ## Watching it
273
+
274
+ | Where | What |
275
+ |---|---|
276
+ | `/` (run chip) | green or red for the last run |
277
+ | `/logs` | the daily entries and raw run output |
278
+ | `data/last-run.json` | per-step timing and the names of failed steps |
279
+ | `docs/daily-log.md` | the human-readable entry, one section a day |
280
+ | `manage logs` | container stdout, both services |
281
+
282
+ `modules.notifications` is macOS-only. On a server, notice failures by
283
+ watching the run chip, or add an `afterRun` hook that posts `last-run.json`
284
+ somewhere you look — Slack, email, a health-check ping. A hook that fires on
285
+ failure is three lines:
286
+
287
+ ```json
288
+ "hooks": {
289
+ "afterRun": [
290
+ "grep -q '\"failures\": \"\"' data/last-run.json || curl -fsS -X POST -d @data/last-run.json \"$ALERT_WEBHOOK\""
291
+ ]
292
+ }
293
+ ```
294
+
295
+ ## An agent can do all of this
296
+
297
+ `.claude/skills/n-seo-deploy/SKILL.md` walks an agent through choosing a
298
+ shape, running the scaffolding, verifying it, and handing you the console
299
+ steps it cannot do. It will not expose the dashboard for you, and it will not
300
+ print a secret.
package/docs/FAQ.md ADDED
@@ -0,0 +1,93 @@
1
+ # FAQ
2
+
3
+ **What does it cost?**
4
+ Nothing. The Search Console and GA4 APIs are free within quotas that ordinary
5
+ use never approaches. The optional LLM module runs whatever local command you
6
+ give it; if that is a paid CLI, that is your cost, not the tool's.
7
+
8
+ **Does it change my site?**
9
+ No. It reads your data, probes your pages, and produces a queue of actions
10
+ with evidence and specs. You make the change in your own repo and ship it.
11
+ The next daily run tells you whether it worked. The only files the app ever
12
+ writes are its own config, `config/backlog.json` (when you click accept /
13
+ watch / retire), and files under `data/`, `site/` and `docs/`.
14
+
15
+ **Can it post to Hacker News or Reddit for me?**
16
+ No, by design, and the docs ask any AI agent using the repo not to either.
17
+ The community modules find threads where your experience applies and write a
18
+ briefing — what the piece says, what the thread is debating, where you
19
+ genuinely connect. Generated participation is detectable and gets accounts
20
+ banned; a briefing gets you to the thread quickly and the words stay yours.
21
+
22
+ **Do I need Claude, or any LLM?**
23
+ No. Every data step and every page works without one. The `llm` module is
24
+ off by default; when on, it runs a shell command that reads a prompt on
25
+ stdin and prints a reply — the `claude` CLI by default, but any CLI with
26
+ that shape works (`llm`, `ollama run …`, your own script). It adds
27
+ proposals and verdicts to the opportunity scan and briefings to the digests.
28
+ Nothing it produces is applied automatically.
29
+
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.
33
+
34
+ **Where does my data go?**
35
+ Into `data/` on the machine that runs the pipeline, as JSON. The only network
36
+ calls are to the Google APIs you authorized, your own sites (the probe and
37
+ the metadata audit fetch pages like a browser would), and — if you enable
38
+ them — the HN Algolia API, Reddit's API, IndexNow, and your LLM command. The
39
+ static-export module writes HTML to `site/`; where you host that, and behind
40
+ what auth, is up to you. Nothing phones home.
41
+
42
+ **Can several people use one install?**
43
+ The dashboard is a plain HTTP server on the configured port with no accounts.
44
+ On a shared machine or a tunnel, everyone who can reach the port sees
45
+ everything and can click accept / watch / retire and change Settings. Put it
46
+ behind your own auth (a VPN, an identity-aware proxy, an SSH tunnel) if that
47
+ matters. The MCP HTTP endpoint is the one part with its own gate: a bearer
48
+ token, and it refuses to serve without one.
49
+
50
+ **How is "impact" estimated?**
51
+ Roughly, and only to order the queue. CTR gaps: impressions × (the CTR the
52
+ position should earn − the CTR it gets), per month. Striking distance: a
53
+ small fraction of impressions assumed to convert to clicks if the page
54
+ reaches the top five. Engagement mismatches: a share of the sessions that
55
+ currently bounce. Backlog items carry whatever estimate you typed. Impact ÷
56
+ effort (S=1, M=2.5, L=5) sorts the list. These are not forecasts and the
57
+ dashboard says so on every page that shows them.
58
+
59
+ **Why 90 days?**
60
+ Because a page you fixed last week must stop being accused within a season,
61
+ and a query cluster that died in the spring must stop looking alive in the
62
+ fall. Sixteen months is kept for totals and history; every decision uses the
63
+ trailing 90 days.
64
+
65
+ **Why the 28-day title freeze?**
66
+ Search engines re-evaluate a page after a title change and need weeks of
67
+ impression data to settle. Iterating inside that window destroys the
68
+ measurement, and a stream of title changes reads as manipulation.
69
+
70
+ **Search Console shows the last three days but the tool doesn't.**
71
+ The pipeline requests finalized data only (`dataState: final`), and Google
72
+ finalizes each day about three days late. The trailing three days always
73
+ fill in on later runs.
74
+
75
+ **The Trends page says "no traffic recorded yet" for a site.**
76
+ That is a connected site with empty data — new, or not yet ranking. It is
77
+ shown flat rather than omitted so you can tell "connected and quiet" from
78
+ "missing from the pipeline."
79
+
80
+ **The indexing page lists a "Soft 404" for a page that is fine.**
81
+ Check the "last crawled" column. A verdict is only as current as the crawl
82
+ behind it; the page flags verdicts older than 90 days as stale. Request
83
+ indexing and re-check rather than chasing a template bug.
84
+
85
+ **A daily step failed. What now?**
86
+ Open `/logs` (or `data/daily-ops.log`), then `python3 ops/doctor.py`. A 401
87
+ or 403 from Google means the service account lost access or the key file
88
+ moved. A run that fails every network step at once was offline; the next run
89
+ will recover. Steps are independent — one failing does not stop the others.
90
+
91
+ **Can I run it for clients?**
92
+ Yes; one config per install, or several sites in one config. The license is
93
+ MIT.