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.
- package/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/n-seo.mjs +310 -0
- package/docs/ADDING-A-SITE.md +82 -0
- package/docs/ARCHITECTURE.md +213 -0
- package/docs/DEPLOY.md +300 -0
- package/docs/FAQ.md +93 -0
- package/docs/INSTANCE.md +365 -0
- package/docs/MCP.md +104 -0
- package/docs/OPERATING-RULES.md +106 -0
- package/docs/PLAYBOOK.md +122 -0
- package/docs/PRD.md +249 -0
- package/docs/RELEASING.md +189 -0
- package/docs/SCHEDULING.md +104 -0
- package/docs/SETUP-GOOGLE.md +215 -0
- package/docs/examples/campaign.json +59 -0
- package/docs/examples/draft.md +43 -0
- package/docs/screenshots/overview.png +0 -0
- package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
- package/ingest/analyze_ga4.py +79 -0
- package/ingest/analyze_gsc.py +136 -0
- package/ingest/analyze_metadata.py +158 -0
- package/ingest/analyze_trends.py +145 -0
- package/ingest/google_auth.py +238 -0
- package/ingest/http_util.py +87 -0
- package/ingest/pull_ga4.py +107 -0
- package/ingest/pull_gsc.py +111 -0
- package/ingest/pull_index_status.py +179 -0
- package/ingest/pull_timeseries.py +130 -0
- package/ingest/seo_config.py +213 -0
- package/n-seo.config.example.json +110 -0
- package/ops/__pycache__/daily.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
- package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
- package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
- package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
- package/ops/__pycache__/llm.cpython-313.pyc +0 -0
- package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
- package/ops/__pycache__/publish.cpython-313.pyc +0 -0
- package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
- package/ops/daily.py +250 -0
- package/ops/daily_diff.py +151 -0
- package/ops/demo_data.py +529 -0
- package/ops/doctor.py +266 -0
- package/ops/export_static.py +125 -0
- package/ops/hn_digest.py +169 -0
- package/ops/indexnow.py +107 -0
- package/ops/install-launchd.sh +76 -0
- package/ops/llm.py +139 -0
- package/ops/mcp-smoke-stdio.mjs +61 -0
- package/ops/opportunity_scan.py +185 -0
- package/ops/publish.py +158 -0
- package/ops/reddit_digest.py +168 -0
- package/ops/templates/n-seo-daily.service +11 -0
- package/ops/templates/n-seo-daily.timer +11 -0
- package/ops/templates/n-seo-dashboard.service +15 -0
- package/ops/templates/n-seo.cron +3 -0
- package/ops/templates/n-seo.daily.plist +29 -0
- package/ops/templates/n-seo.dashboard.plist +22 -0
- package/package.json +77 -0
- package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
- package/probes/site_probe.py +201 -0
- package/public/favicon.svg +6 -0
- package/public/styles.css +632 -0
- package/src/actions.ts +255 -0
- package/src/backlog.ts +197 -0
- package/src/config.ts +220 -0
- package/src/data.ts +895 -0
- package/src/insights.ts +22 -0
- package/src/mcp-stdio.ts +21 -0
- package/src/mcp.ts +490 -0
- package/src/server.tsx +260 -0
- package/src/settings.tsx +329 -0
- package/src/views.tsx +1487 -0
- 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.
|