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
package/.env.example
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copy to .env — everything here is optional.
|
|
2
|
+
|
|
3
|
+
# Reddit "script" app credentials (https://www.reddit.com/prefs/apps) — only
|
|
4
|
+
# needed when modules.reddit.enabled is true; Reddit blocks anonymous JSON.
|
|
5
|
+
REDDIT_CLIENT_ID=
|
|
6
|
+
REDDIT_CLIENT_SECRET=
|
|
7
|
+
|
|
8
|
+
# Bearer token for the HTTP MCP endpoint (POST /mcp). Leave empty to keep the
|
|
9
|
+
# endpoint disabled (it returns 503). stdio MCP needs no token.
|
|
10
|
+
SEO_MCP_TOKEN=
|
|
11
|
+
|
|
12
|
+
# Override the config file location (default: ./n-seo.config.json)
|
|
13
|
+
# N_SEO_CONFIG=
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Daniel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# n-seo
|
|
2
|
+
|
|
3
|
+
**An SEO, self-hosted.** Website: https://en-dash-consulting.github.io/n-seo/ · From the makers of [n-dx](https://n-dx.dev).
|
|
4
|
+
|
|
5
|
+
A local-first control plane for growing organic traffic to your own sites —
|
|
6
|
+
classic search (SEO), answer engines (AEO) and AI assistants that cite sources
|
|
7
|
+
(GEO) — without paying an agency to read Search Console for you.
|
|
8
|
+
|
|
9
|
+
It pulls Search Console and GA4 into local JSON, probes your live sites for the
|
|
10
|
+
things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
|
|
11
|
+
JS-only shells), and turns all of it into a **ranked queue of concrete actions**:
|
|
12
|
+
which page to retitle, which query to answer, which page never got crawled.
|
|
13
|
+
It runs on hardware you control, on a schedule, and shows you the result on a dashboard.
|
|
14
|
+
|
|
15
|
+
- **Your data stays on your own hardware.** Nothing is sent anywhere except the
|
|
16
|
+
Google APIs you authorize and, optionally, a local LLM command you choose.
|
|
17
|
+
- **It briefs you; it never posts for you.** Community modules find threads
|
|
18
|
+
and write a briefing. The words are always yours.
|
|
19
|
+
- **It proposes; you ship.** No module edits your site. The queue tells you
|
|
20
|
+
what to change and why, with the numbers; you make the change in your own
|
|
21
|
+
repo and the next day's data tells you whether it worked.
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
## Quickstart (5 minutes, no Google setup)
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
git clone https://github.com/en-dash-consulting/n-seo.git
|
|
29
|
+
cd n-seo
|
|
30
|
+
npm install
|
|
31
|
+
npm run demo # writes a synthetic dataset for example.com under data/
|
|
32
|
+
npm start # dashboard → http://localhost:4600
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Open http://localhost:4600. Every page is populated from the demo data, so you
|
|
36
|
+
can see what the tool does before you connect anything.
|
|
37
|
+
|
|
38
|
+
## Connect your real sites
|
|
39
|
+
|
|
40
|
+
1. Copy the config and edit the `sites` list:
|
|
41
|
+
```sh
|
|
42
|
+
cp n-seo.config.example.json n-seo.config.json
|
|
43
|
+
```
|
|
44
|
+
2. Give the tool read access to Search Console and GA4 — a service account
|
|
45
|
+
with a JSON key, added as a user in both consoles. Follow
|
|
46
|
+
[docs/SETUP-GOOGLE.md](docs/SETUP-GOOGLE.md).
|
|
47
|
+
3. Check the setup:
|
|
48
|
+
```sh
|
|
49
|
+
python3 ops/doctor.py
|
|
50
|
+
```
|
|
51
|
+
4. Run the pipeline once, then look at the dashboard:
|
|
52
|
+
```sh
|
|
53
|
+
python3 ops/daily.py
|
|
54
|
+
npm start
|
|
55
|
+
```
|
|
56
|
+
5. Schedule it to run every morning: [docs/SCHEDULING.md](docs/SCHEDULING.md).
|
|
57
|
+
|
|
58
|
+
Adding another site later is one entry in the config —
|
|
59
|
+
[docs/ADDING-A-SITE.md](docs/ADDING-A-SITE.md).
|
|
60
|
+
|
|
61
|
+
Running several people's sites, or want upgrades to be a `git pull`? Keep your
|
|
62
|
+
config in its own directory — see [docs/INSTANCE.md](docs/INSTANCE.md).
|
|
63
|
+
|
|
64
|
+
## What you get
|
|
65
|
+
|
|
66
|
+
| Page | What it shows |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `/` Overview | A **Today** board (fix / approve / publish / comment / ship) and a one-row-per-site strip: sessions, 28-day trend, Google clicks, AI-referral share, probe health |
|
|
69
|
+
| `/actions` | The full ranked queue: machine proposals awaiting your accept, active items, and shipped items being watched. Searchable; every card opens a spec |
|
|
70
|
+
| `/insights` | Rising and falling queries (84 days vs the prior 84), branded vs generic split, AI-referral sessions by month, plus your own written briefing if you keep one |
|
|
71
|
+
| `/trends` | Daily clicks and sessions per site with a per-page heatmap; one time window drives every row |
|
|
72
|
+
| `/content` | Participation briefings (HN, Reddit — opt-in), publish-ready drafts, and outreach campaigns with targets and templates |
|
|
73
|
+
| `/site/:host` | Per-site detail: striking-distance queries, CTR gaps, top queries and pages, landing-page engagement, AI referral sources |
|
|
74
|
+
| `/indexing` | Search Console's verdict on every sitemap URL — indexed, discovered-never-crawled, soft 404 — with stale verdicts flagged |
|
|
75
|
+
| `/probes` | The latest live-site health snapshot for every site |
|
|
76
|
+
| `/logs` | The daily log (one dated entry per run, ALERT lines for regressions) and raw run output |
|
|
77
|
+
| `/settings` | Module switches and digest topics; writes `n-seo.config.json` |
|
|
78
|
+
|
|
79
|
+
## The action engine
|
|
80
|
+
|
|
81
|
+
`src/actions.ts` runs a small set of rules over the trailing 90 days of data
|
|
82
|
+
and emits actions with the evidence, the concrete move, a spec, an impact
|
|
83
|
+
estimate (clicks per month, used **only to order the queue**) and an effort
|
|
84
|
+
size. The rules: pages whose title or description miss the queries they rank
|
|
85
|
+
for; queries ranking well but rarely clicked (CTR gaps); queries at position
|
|
86
|
+
5–15 (striking distance); probe hygiene failures; landing pages with high
|
|
87
|
+
traffic and low engagement; 28-day traffic drops. These merge with your own
|
|
88
|
+
strategic backlog in `config/backlog.json`, ranked by impact per unit of
|
|
89
|
+
effort. When something ships you mark it *watching* — it stays in view until
|
|
90
|
+
the data says whether it worked.
|
|
91
|
+
|
|
92
|
+
## Modules
|
|
93
|
+
|
|
94
|
+
Everything below is off by default except the three data steps. Toggle them
|
|
95
|
+
on the Settings page or in `n-seo.config.json`.
|
|
96
|
+
|
|
97
|
+
| Module | What it does | Needs | Default |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| `indexStatus` | Asks the URL Inspection API whether each sitemap URL is indexed | Search Console access | on |
|
|
100
|
+
| `metadataAudit` | Fetches each ranking page's live title/description and scores them against its queries | nothing extra | on |
|
|
101
|
+
| `opportunityScan` | Refreshes trend analysis; flags rising queries no queue item covers | nothing extra | on |
|
|
102
|
+
| `llm` | Runs a local command (default: the `claude` CLI) for scan proposals and digest briefings | any CLI that reads a prompt on stdin | off |
|
|
103
|
+
| `hackerNews` | Finds fresh HN threads in your expertise areas and briefs you | your HN username (optional) | off |
|
|
104
|
+
| `reddit` | The same for subreddits | a free Reddit "script" app's credentials in `.env` | off |
|
|
105
|
+
| `indexNow` | Generates a key and pings Bing/Copilot/Yandex with changed URLs | nothing (does nothing for Google) | off |
|
|
106
|
+
| `staticExport` | Snapshots the dashboard into `site/` as static HTML | nothing | off |
|
|
107
|
+
| `gitAutoCommit` | Commits (and pushes) the daily log and export after each run | a git remote, if you want the push | off |
|
|
108
|
+
| `notifications` | macOS notification when a daily step fails | macOS | off |
|
|
109
|
+
|
|
110
|
+
## Working with an AI agent
|
|
111
|
+
|
|
112
|
+
The repo ships an MCP server over the same data the dashboard reads, so an
|
|
113
|
+
agent can answer "what should I do first this week?" from the actual queue
|
|
114
|
+
instead of scraping pages. `.mcp.json` registers it for Claude Code
|
|
115
|
+
automatically; Claude Desktop and HTTP clients are covered in
|
|
116
|
+
[docs/MCP.md](docs/MCP.md). It is read-only by design, and `CLAUDE.md` holds
|
|
117
|
+
the operating rules an agent working here has to follow.
|
|
118
|
+
|
|
119
|
+
It also ships skills for the work itself, so the rules are enforced rather
|
|
120
|
+
than merely documented:
|
|
121
|
+
|
|
122
|
+
| Skill | Use it when |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `orient` | First thing in a fresh session — get current in a few reads |
|
|
125
|
+
| `n-seo-setup` | Fresh clone to first real daily run, including Google access |
|
|
126
|
+
| `n-seo-add-site` | Adding a site: property form, `gscHost`, GA4 id, brand regex, grants |
|
|
127
|
+
| `n-seo-triage` | "What should I work on today" from the queue and the last run |
|
|
128
|
+
| `n-seo-ship` | Implement one queue card in the site's repo, then record it as watching |
|
|
129
|
+
| `n-seo-review` | The weekly pass: judge watching items, retire what is done, refresh insights |
|
|
130
|
+
|
|
131
|
+
`.claude/skills/README.md` explains which skills operate an instance and which
|
|
132
|
+
are contributor tooling for developing the engine with [n-dx](https://n-dx.dev).
|
|
133
|
+
|
|
134
|
+
## Working with n-dx
|
|
135
|
+
|
|
136
|
+
The repo is wired for [n-dx](https://n-dx.dev): `docs/PRD.md` is the product
|
|
137
|
+
definition, `.rex/` holds the PRD tree generated from it (`ndx status .` to
|
|
138
|
+
see it, `ndx next .` for the next actionable task), and `.rex/workflow.md`
|
|
139
|
+
carries the project's execution rules. Run `ndx init .` once after cloning to
|
|
140
|
+
create the local analysis caches (they are gitignored).
|
|
141
|
+
|
|
142
|
+
## Repo layout
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
n-seo.config.json your sites, auth, modules (copy from the .example)
|
|
146
|
+
config/ backlog.json (your strategic queue) · insights.json
|
|
147
|
+
content/ drafts/*.md · campaigns/*.json — shown on /content
|
|
148
|
+
bin/n-seo.mjs the CLI: init an instance, run it, upgrade the engine
|
|
149
|
+
src/ dashboard + MCP (Hono, hono/jsx SSR, tsx runtime, no bundler)
|
|
150
|
+
config.ts data.ts actions.ts backlog.ts views.tsx server.tsx mcp.ts
|
|
151
|
+
ingest/ pull_gsc.py · pull_ga4.py · pull_timeseries.py · pull_index_status.py
|
|
152
|
+
analyze_metadata.py · analyze_trends.py · analyze_gsc.py · analyze_ga4.py
|
|
153
|
+
google_auth.py · seo_config.py · http_util.py
|
|
154
|
+
probes/site_probe.py no-auth live health probe
|
|
155
|
+
ops/ daily.py · daily_diff.py · opportunity_scan.py · hn_digest.py · reddit_digest.py
|
|
156
|
+
export_static.py · indexnow.py · doctor.py · demo_data.py · install-launchd.sh · templates/
|
|
157
|
+
docs/ setup, scheduling, playbook, operating rules, PRD.md
|
|
158
|
+
daily-log.md and reports/ (instance-owned)
|
|
159
|
+
tests/ TypeScript (node --test via tsx) + Python (unittest)
|
|
160
|
+
.rex/ n-dx PRD tree, generated from docs/PRD.md
|
|
161
|
+
www/ the marketing site published to GitHub Pages
|
|
162
|
+
data/ machine-refreshed snapshots (gitignored, regenerable)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Operating rules
|
|
166
|
+
|
|
167
|
+
The tool encodes a few rules that keep SEO work honest. The short version:
|
|
168
|
+
decisions ride the 90-day window; impact numbers order the queue and are not
|
|
169
|
+
forecasts; after changing a title, leave it alone for 28 days; no more than
|
|
170
|
+
about eight metadata changes a week across all sites; shipped work becomes
|
|
171
|
+
*watching*, never deleted; proposals never enter the queue without your
|
|
172
|
+
accept; community participation is human. The reasoning is in
|
|
173
|
+
[docs/OPERATING-RULES.md](docs/OPERATING-RULES.md); the strategy is in
|
|
174
|
+
[docs/PLAYBOOK.md](docs/PLAYBOOK.md).
|
|
175
|
+
|
|
176
|
+
## Requirements
|
|
177
|
+
|
|
178
|
+
Node 20 or newer, Python 3.10 or newer, `curl`, `openssl`. macOS or Linux.
|
|
179
|
+
Python is stdlib-only (no pip); the TypeScript app runs under `tsx` with no
|
|
180
|
+
build step.
|
|
181
|
+
|
|
182
|
+
## Contributing and license
|
|
183
|
+
|
|
184
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). MIT — see [LICENSE](LICENSE).
|
package/bin/n-seo.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* n-seo — run the engine against an instance directory.
|
|
4
|
+
*
|
|
5
|
+
* The engine is wherever this file lives (a git checkout or an npm install);
|
|
6
|
+
* the instance is `--instance <path>`, else $N_SEO_INSTANCE, else the current
|
|
7
|
+
* directory. Every command below just sets N_SEO_INSTANCE and runs the
|
|
8
|
+
* engine's own npm script or Python entry point with cwd = the engine, so the
|
|
9
|
+
* engine can be upgraded without touching a single instance file.
|
|
10
|
+
*
|
|
11
|
+
* n-seo init [dir] scaffold an instance directory
|
|
12
|
+
* n-seo start dashboard for the instance in cwd
|
|
13
|
+
* n-seo daily --only probe
|
|
14
|
+
* n-seo upgrade git pull + npm ci + npm run check, with a rollback hint
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync, execFileSync } from "node:child_process";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
25
|
+
|
|
26
|
+
const USAGE = `n-seo ${PKG.version} — an SEO on your machine
|
|
27
|
+
|
|
28
|
+
usage: n-seo <command> [--instance <dir>] [args...]
|
|
29
|
+
|
|
30
|
+
init [dir] scaffold an instance directory (default: cwd); never overwrites
|
|
31
|
+
start run the dashboard for the instance
|
|
32
|
+
dev dashboard with reload on engine code changes
|
|
33
|
+
daily the morning run (args pass through: --only, --skip, --list, …)
|
|
34
|
+
doctor setup checker (--offline for local checks only)
|
|
35
|
+
demo write a synthetic dataset for the instance's configured sites
|
|
36
|
+
mcp the read-only MCP server on stdio (for .mcp.json)
|
|
37
|
+
export static export of the dashboard into <instance>/site/
|
|
38
|
+
check engine self-test: typecheck + unit tests
|
|
39
|
+
upgrade update the engine (git pull / npm ci / check) with a rollback hint
|
|
40
|
+
version engine version, commit, paths, mode
|
|
41
|
+
help this text
|
|
42
|
+
|
|
43
|
+
instance = --instance <dir>, else $N_SEO_INSTANCE, else the current directory.
|
|
44
|
+
engine = ${ROOT}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const out = { cmd: argv[0] ?? "help", instance: undefined, rest: [] };
|
|
49
|
+
for (let i = 1; i < argv.length; i++) {
|
|
50
|
+
if (argv[i] === "--instance") {
|
|
51
|
+
const value = argv[++i];
|
|
52
|
+
// Silently falling back to the cwd here would point `daily` — and its
|
|
53
|
+
// afterRun hooks — at the wrong directory.
|
|
54
|
+
if (!value || value.startsWith("-")) {
|
|
55
|
+
console.error("--instance needs a directory, e.g. --instance ~/my-sites");
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
out.instance = value;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (argv[i].startsWith("--instance=")) { out.instance = argv[i].slice("--instance=".length); continue; }
|
|
62
|
+
out.rest.push(argv[i]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function gitCommit() {
|
|
68
|
+
try {
|
|
69
|
+
return execFileSync("git", ["-C", ROOT, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isGitEngine = () => fs.existsSync(path.join(ROOT, ".git"));
|
|
76
|
+
|
|
77
|
+
function resolveInstance(flag) {
|
|
78
|
+
const p = flag ?? process.env.N_SEO_INSTANCE ?? process.cwd();
|
|
79
|
+
return path.resolve(p);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function run(cmd, args, instance, opts = {}) {
|
|
83
|
+
const r = spawnSync(cmd, args, {
|
|
84
|
+
cwd: ROOT,
|
|
85
|
+
stdio: "inherit",
|
|
86
|
+
env: { ...process.env, N_SEO_INSTANCE: instance, ...(opts.env ?? {}) },
|
|
87
|
+
});
|
|
88
|
+
if (r.error) {
|
|
89
|
+
console.error(`could not run ${cmd}: ${r.error.message}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
return r.status ?? 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Resolve the engine's own dependencies.
|
|
96
|
+
*
|
|
97
|
+
* A git checkout has them in <engine>/node_modules; an npm install has them
|
|
98
|
+
* hoisted into the CONSUMER's node_modules, and <engine>/node_modules does not
|
|
99
|
+
* exist at all. Testing for that directory therefore refused to start a
|
|
100
|
+
* perfectly good `npm i n-seo` install. Resolution works for both layouts. */
|
|
101
|
+
const engineRequire = createRequire(path.join(ROOT, "package.json"));
|
|
102
|
+
|
|
103
|
+
function resolvePkgDir(name) {
|
|
104
|
+
try {
|
|
105
|
+
return path.dirname(engineRequire.resolve(`${name}/package.json`));
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The engine runs TypeScript through tsx, with no build step. */
|
|
112
|
+
function tsxBin() {
|
|
113
|
+
const dir = resolvePkgDir("tsx");
|
|
114
|
+
if (!dir) return null;
|
|
115
|
+
try {
|
|
116
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
117
|
+
const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.tsx;
|
|
118
|
+
return rel ? path.join(dir, rel) : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function missingDeps(cmd, what) {
|
|
125
|
+
const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
|
|
126
|
+
console.error(`${cmd} needs ${what}.`);
|
|
127
|
+
console.error(installed
|
|
128
|
+
? ` the package looks incomplete — reinstall it: npm i n-seo`
|
|
129
|
+
: ` run: npm ci (in ${ROOT})`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---------- init ---------- */
|
|
134
|
+
|
|
135
|
+
function init(dirArg) {
|
|
136
|
+
const dir = path.resolve(dirArg ?? process.cwd());
|
|
137
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
const name = path.basename(dir);
|
|
139
|
+
const put = (rel, content) => {
|
|
140
|
+
const p = path.join(dir, rel);
|
|
141
|
+
if (fs.existsSync(p)) { console.log(` exists, kept ${rel}`); return; }
|
|
142
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
143
|
+
fs.writeFileSync(p, content);
|
|
144
|
+
console.log(` created ${rel}`);
|
|
145
|
+
};
|
|
146
|
+
const example = JSON.parse(fs.readFileSync(path.join(ROOT, "n-seo.config.example.json"), "utf8"));
|
|
147
|
+
example.name = name;
|
|
148
|
+
put("n-seo.config.json", JSON.stringify(example, null, 2) + "\n");
|
|
149
|
+
put("config/backlog.json", JSON.stringify({
|
|
150
|
+
_comment: "Your curated strategic queue. The dashboard merges these with data-derived actions. Add items with evidence (why) and a spec; when something ships, set `watching` to a dated note instead of deleting it. `shippedWatch` maps page URLs to notes so data-derived cards on those pages show as watching too.",
|
|
151
|
+
actions: [],
|
|
152
|
+
shippedWatch: {},
|
|
153
|
+
}, null, 2) + "\n");
|
|
154
|
+
put("config/insights.json", JSON.stringify({
|
|
155
|
+
_comment: "Optional hand-written marketer briefing shown on /insights above the live trend tables. verdict: opportunity | warning | momentum | deprioritize. Leave the list empty to show only the live tables.",
|
|
156
|
+
date: "",
|
|
157
|
+
insights: [],
|
|
158
|
+
}, null, 2) + "\n");
|
|
159
|
+
for (const rel of ["content/drafts/README.md", "content/campaigns/README.md"]) {
|
|
160
|
+
const src = path.join(ROOT, rel);
|
|
161
|
+
put(rel, fs.existsSync(src) ? fs.readFileSync(src, "utf8") : `# ${rel}\n`);
|
|
162
|
+
}
|
|
163
|
+
const envExample = path.join(ROOT, ".env.example");
|
|
164
|
+
put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
|
|
165
|
+
put(".gitignore", "data/\nsite/\n.env\n__pycache__/\nnode_modules/\n");
|
|
166
|
+
put(".mcp.json", JSON.stringify({
|
|
167
|
+
mcpServers: {
|
|
168
|
+
"n-seo": {
|
|
169
|
+
command: "node",
|
|
170
|
+
args: [path.join(ROOT, "bin", "n-seo.mjs"), "mcp"],
|
|
171
|
+
env: { N_SEO_INSTANCE: dir },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
}, null, 2) + "\n");
|
|
175
|
+
put("README.md", `# ${name} — an n-seo instance
|
|
176
|
+
|
|
177
|
+
This directory holds one n-seo instance: the config, the curated queue
|
|
178
|
+
(\`config/backlog.json\`), content (\`content/\`), and the data the daily run
|
|
179
|
+
writes (\`data/\`, gitignored). The engine — the code — lives at:
|
|
180
|
+
|
|
181
|
+
${ROOT}
|
|
182
|
+
|
|
183
|
+
Run everything from here with the \`n-seo\` CLI (it sets \`N_SEO_INSTANCE\`
|
|
184
|
+
to this directory):
|
|
185
|
+
|
|
186
|
+
\`\`\`sh
|
|
187
|
+
n-seo doctor # is the setup complete?
|
|
188
|
+
n-seo demo # synthetic data to see the dashboard working
|
|
189
|
+
n-seo start # dashboard on the configured port
|
|
190
|
+
n-seo daily # the morning run
|
|
191
|
+
n-seo upgrade # update the engine; your files here are untouched
|
|
192
|
+
\`\`\`
|
|
193
|
+
|
|
194
|
+
Edit \`n-seo.config.json\` for sites, Google access, modules and hooks.
|
|
195
|
+
Docs: ${path.join(ROOT, "docs")}
|
|
196
|
+
`);
|
|
197
|
+
console.log(`
|
|
198
|
+
instance ready at ${dir}
|
|
199
|
+
|
|
200
|
+
next:
|
|
201
|
+
cd ${dir}
|
|
202
|
+
edit n-seo.config.json (sites, google.serviceAccountKey — see ${path.join(ROOT, "docs", "SETUP-GOOGLE.md")})
|
|
203
|
+
n-seo doctor
|
|
204
|
+
n-seo demo && n-seo start (or n-seo daily once the key is in place)
|
|
205
|
+
`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ---------- upgrade ---------- */
|
|
210
|
+
|
|
211
|
+
const sha256 = (p) => (fs.existsSync(p) ? createHash("sha256").update(fs.readFileSync(p)).digest("hex") : "");
|
|
212
|
+
|
|
213
|
+
function upgrade(instance) {
|
|
214
|
+
if (!isGitEngine()) {
|
|
215
|
+
console.log("engine installed from npm — run: npm update n-seo");
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const before = gitCommit();
|
|
219
|
+
const lockBefore = sha256(path.join(ROOT, "package-lock.json"));
|
|
220
|
+
console.log(`engine ${ROOT} at ${before ?? "?"} — pulling`);
|
|
221
|
+
let r = spawnSync("git", ["-C", ROOT, "pull", "--ff-only"], { stdio: "inherit" });
|
|
222
|
+
if (r.status !== 0) {
|
|
223
|
+
console.error("git pull --ff-only failed — the engine has local changes or diverged; resolve in the engine checkout and retry");
|
|
224
|
+
return r.status ?? 1;
|
|
225
|
+
}
|
|
226
|
+
const after = gitCommit();
|
|
227
|
+
if (after === before) {
|
|
228
|
+
console.log("already up to date");
|
|
229
|
+
}
|
|
230
|
+
if (sha256(path.join(ROOT, "package-lock.json")) !== lockBefore || !fs.existsSync(path.join(ROOT, "node_modules"))) {
|
|
231
|
+
console.log("lockfile changed — npm ci");
|
|
232
|
+
r = spawnSync("npm", ["ci"], { cwd: ROOT, stdio: "inherit" });
|
|
233
|
+
if (r.status !== 0) {
|
|
234
|
+
console.error(`npm ci failed. Roll back with:\n git -C ${ROOT} reset --hard ${before}`);
|
|
235
|
+
return r.status ?? 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
console.log("running the engine's checks");
|
|
239
|
+
r = spawnSync("npm", ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
|
|
240
|
+
if (r.status !== 0) {
|
|
241
|
+
console.error(`\nengine checks FAILED at ${after ?? "?"}. Previous commit: ${before ?? "?"}. Roll back with:\n git -C ${ROOT} reset --hard ${before}\n (then npm ci in ${ROOT} if the lockfile moved)`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
console.log(`\nengine ${before ?? "?"} → ${after ?? "?"}: checks pass. Restart the dashboard to pick it up.`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ---------- main ---------- */
|
|
249
|
+
|
|
250
|
+
const { cmd, instance: flag, rest } = parseArgs(process.argv.slice(2));
|
|
251
|
+
const instance = resolveInstance(flag);
|
|
252
|
+
const py = process.env.PYTHON ?? "python3";
|
|
253
|
+
let code = 0;
|
|
254
|
+
|
|
255
|
+
switch (cmd) {
|
|
256
|
+
case "init":
|
|
257
|
+
code = init(rest[0] ?? flag ?? process.cwd());
|
|
258
|
+
break;
|
|
259
|
+
case "start":
|
|
260
|
+
case "dev":
|
|
261
|
+
case "mcp": {
|
|
262
|
+
// Spawn tsx directly rather than through `npm run`, so the command does
|
|
263
|
+
// not depend on npm resolving a bin from inside node_modules/n-seo.
|
|
264
|
+
const bin = tsxBin();
|
|
265
|
+
if (!bin) {
|
|
266
|
+
code = missingDeps(cmd, "the engine's dependencies");
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
const entry = cmd === "mcp" ? "src/mcp-stdio.ts" : "src/server.tsx";
|
|
270
|
+
const args = cmd === "dev" ? ["watch", entry] : [entry];
|
|
271
|
+
code = run(process.execPath, [bin, ...args, ...rest], instance);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "check":
|
|
275
|
+
// The self-test needs devDependencies, which an npm install omits.
|
|
276
|
+
code = resolvePkgDir("typescript")
|
|
277
|
+
? run("npm", ["run", "check", "--silent", "--", ...rest], instance)
|
|
278
|
+
: missingDeps(cmd, "the engine's dev dependencies (it is the engine's own test suite)");
|
|
279
|
+
break;
|
|
280
|
+
case "daily":
|
|
281
|
+
code = run(py, [path.join(ROOT, "ops", "daily.py"), ...rest], instance);
|
|
282
|
+
break;
|
|
283
|
+
case "doctor":
|
|
284
|
+
code = run(py, [path.join(ROOT, "ops", "doctor.py"), ...rest], instance);
|
|
285
|
+
break;
|
|
286
|
+
case "demo":
|
|
287
|
+
code = run(py, [path.join(ROOT, "ops", "demo_data.py"), ...rest], instance);
|
|
288
|
+
break;
|
|
289
|
+
case "export":
|
|
290
|
+
code = run(py, [path.join(ROOT, "ops", "export_static.py"), ...rest], instance);
|
|
291
|
+
break;
|
|
292
|
+
case "upgrade":
|
|
293
|
+
code = upgrade(instance);
|
|
294
|
+
break;
|
|
295
|
+
case "version": {
|
|
296
|
+
const mode = instance === ROOT ? "in-place" : "instance";
|
|
297
|
+
console.log(`n-seo ${PKG.version}${gitCommit() ? ` (${gitCommit()})` : ""}\nengine: ${ROOT}\ninstance: ${instance}\nmode: ${mode}`);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case "help":
|
|
301
|
+
case "--help":
|
|
302
|
+
case "-h":
|
|
303
|
+
process.stdout.write(USAGE);
|
|
304
|
+
break;
|
|
305
|
+
default:
|
|
306
|
+
console.error(`unknown command: ${cmd}\n`);
|
|
307
|
+
process.stdout.write(USAGE);
|
|
308
|
+
code = 2;
|
|
309
|
+
}
|
|
310
|
+
process.exit(code);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Adding a site
|
|
2
|
+
|
|
3
|
+
One entry in `n-seo.config.json`. There are no per-script site lists:
|
|
4
|
+
every pull, probe, audit, page and export reads the same `sites` array.
|
|
5
|
+
|
|
6
|
+
```json
|
|
7
|
+
{
|
|
8
|
+
"host": "docs.example.com",
|
|
9
|
+
"label": "docs",
|
|
10
|
+
"gscProperty": "sc-domain:example.com",
|
|
11
|
+
"gscHost": "docs.example.com",
|
|
12
|
+
"ga4Property": "987654321",
|
|
13
|
+
"brand": "example|exmpl",
|
|
14
|
+
"repo": "../example-docs",
|
|
15
|
+
"hosting": "GitHub Pages"
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Field | Required | Meaning |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `host` | yes | Canonical hostname. The URL slug in the dashboard (`/site/docs.example.com`) and the directory name under `data/ga4/` |
|
|
22
|
+
| `label` | no | Short display name; defaults to `host` |
|
|
23
|
+
| `gscProperty` | for GSC data | The Search Console property exactly as the console shows it: `sc-domain:example.com` (domain property) or `https://www.example.com/` (URL-prefix property, trailing slash included). Leave empty for a site without Search Console; it will still be probed |
|
|
24
|
+
| `gscHost` | no | Hostname to filter page URLs by when the property covers more than this host (see below). Defaults to `host`. Use the hostname the site actually serves on — `www.example.com` if the apex redirects to www |
|
|
25
|
+
| `ga4Property` | for GA4 data | Numeric GA4 property id (Admin → Property details). Not the `G-…` measurement id. Leave empty for no GA4 |
|
|
26
|
+
| `brand` | no | Case-insensitive regex matching your branded queries, for the branded/generic split on `/insights`. Several hosts on one property are OR-ed together |
|
|
27
|
+
| `repo` | no | Where the site's code lives, shown on the site page so actions can name it |
|
|
28
|
+
| `hosting` | no | Free text, shown on the site page |
|
|
29
|
+
|
|
30
|
+
## Domain properties and subdomains
|
|
31
|
+
|
|
32
|
+
A `sc-domain:` property covers every subdomain and protocol. If
|
|
33
|
+
`example.com` and `docs.example.com` are separate sites for you but one
|
|
34
|
+
property in Search Console, give both entries the same `gscProperty` and a
|
|
35
|
+
different `gscHost`. The pull happens once per property (into
|
|
36
|
+
`data/gsc/example.com/`) and each site's pages are filtered by `gscHost` when
|
|
37
|
+
read. URL-prefix properties are per host by nature; each gets its own
|
|
38
|
+
directory.
|
|
39
|
+
|
|
40
|
+
## Access grants
|
|
41
|
+
|
|
42
|
+
The service account needs to be a user on the new property in Search Console
|
|
43
|
+
(Full) and on the GA4 property (Viewer). If it is already Viewer at the GA4
|
|
44
|
+
*account* level, new properties under that account inherit it. Details and
|
|
45
|
+
the DNS-verification alternative for stubborn domain properties are in
|
|
46
|
+
[SETUP-GOOGLE.md](SETUP-GOOGLE.md).
|
|
47
|
+
|
|
48
|
+
## Check, then wait for the next run
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
python3 ops/doctor.py
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The doctor confirms the service account can see the property and the GA4
|
|
55
|
+
id resolves. Then either wait for the next scheduled run or run it now:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
python3 ops/daily.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The site appears on the overview strip, gets its own `/site/<host>` page, is
|
|
62
|
+
probed, audited, swept for index coverage, and shows on `/trends` — with a
|
|
63
|
+
flat "no traffic recorded yet" row rather than silently missing if the data
|
|
64
|
+
is empty, so a brand-new site is visibly connected even before it ranks.
|
|
65
|
+
|
|
66
|
+
## Removing a site
|
|
67
|
+
|
|
68
|
+
Delete its entry. Its data under `data/` stays until you remove it (or run
|
|
69
|
+
`npm run demo -- --clean`, which clears the whole `data/` tree). Backlog
|
|
70
|
+
items for that host in `config/backlog.json` remain and still render on
|
|
71
|
+
`/actions`; retire or delete them.
|
|
72
|
+
|
|
73
|
+
## Adding a site to the watch list
|
|
74
|
+
|
|
75
|
+
`watchPages` is a flat list of URLs whose GSC numbers appear in every daily
|
|
76
|
+
log entry. Add the pages you have just changed there so the log tracks them:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
"watchPages": [
|
|
80
|
+
"https://docs.example.com/getting-started"
|
|
81
|
+
]
|
|
82
|
+
```
|