n-seo 0.4.1 → 0.6.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/CHANGELOG.md +88 -1
- package/README.md +1 -1
- package/bin/n-seo.mjs +41 -2
- package/docs/PRD.md +44 -12
- package/docs/PROFILES.md +220 -0
- package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
- package/ingest/seo_config.py +84 -3
- package/n-seo.config.example.json +1 -0
- package/ops/__pycache__/daily.cpython-312.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
- package/ops/__pycache__/llm.cpython-312.pyc +0 -0
- package/ops/__pycache__/publish.cpython-312.pyc +0 -0
- package/ops/__pycache__/update_check.cpython-312.pyc +0 -0
- package/ops/doctor.py +26 -0
- package/package.json +2 -1
- package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
- package/profiles/aggressive/n-seo.profile.json +15 -0
- package/profiles/default/n-seo.profile.json +45 -0
- package/profiles/patient/n-seo.profile.json +16 -0
- package/public/styles.css +22 -1
- package/src/actions.ts +90 -14
- package/src/backlog.ts +2 -0
- package/src/config.ts +229 -2
- package/src/data.ts +11 -5
- package/src/profile-cli.ts +34 -0
- package/src/profile-report.ts +108 -0
- package/src/settings.tsx +41 -0
- package/src/views.tsx +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,91 @@ uses [Semantic Versioning](https://semver.org/).
|
|
|
8
8
|
|
|
9
9
|
Nothing yet.
|
|
10
10
|
|
|
11
|
+
## [0.6.0] - 2026-09-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Profiles carry principles.** Building the first real profile showed the
|
|
15
|
+
format was too thin. Measured against ten sites, one practitioner's method
|
|
16
|
+
was barely distinguishable from the engine's in *thresholds* — rows sort by
|
|
17
|
+
impressions before the cap, so the striking-distance floor never binds where
|
|
18
|
+
there is data, and lowering it would only have deleted the one marginal
|
|
19
|
+
site's two cards — and almost entirely distinguishable in *judgement*.
|
|
20
|
+
`principles` carries that: a title, a body, and a kind. `hard` is a
|
|
21
|
+
constraint an agent must not cross; `guide` is judgement it should apply.
|
|
22
|
+
They render at the top of the action queue and on Settings, and `n-seo init`
|
|
23
|
+
writes them into the instance's `CLAUDE.md`. A principle the agent never
|
|
24
|
+
reads is a note to yourself, not an operating rule.
|
|
25
|
+
- **`rules.priorities` — custom rules, declaratively.** A threshold says what
|
|
26
|
+
counts as a finding; a priority says what you care about. Match on `host`,
|
|
27
|
+
`pathMatches`, `tag` or `kind`, then `multiply` the impact that orders the
|
|
28
|
+
queue, or `drop` the card. Several compound in the order written.
|
|
29
|
+
- Two constraints the design will not bend on. **A priority may reorder or
|
|
30
|
+
hide, never invent** — it cannot create a card, so it cannot manufacture
|
|
31
|
+
evidence. And **every card it touches says so**, carrying the reason in its
|
|
32
|
+
spec and a *reweighted* chip; `why` is required and a priority without one
|
|
33
|
+
is ignored. Ordering you cannot see the reason for is ordering you cannot
|
|
34
|
+
argue with.
|
|
35
|
+
- A priority with no conditions matches nothing, a bad regular expression
|
|
36
|
+
matches nothing rather than throwing, and a nonsense multiplier is ignored
|
|
37
|
+
rather than corrupting the sort.
|
|
38
|
+
|
|
39
|
+
### Decided
|
|
40
|
+
- **Profiles still may not ship executable code.** The obvious version of
|
|
41
|
+
custom rules is letting a profile carry a function. Installing someone's
|
|
42
|
+
method would then mean running their program on the machine holding your
|
|
43
|
+
Search Console credentials and your service-account key — a large amount of
|
|
44
|
+
trust for a list of thresholds. Matching and weighting turned out to cover
|
|
45
|
+
what people actually wanted to express. A genuinely new *kind* of card, one
|
|
46
|
+
reading data no existing rule reads, remains a fork and remains the open
|
|
47
|
+
question.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- `.chip` ellipsizes at 34ch and shrinks as a flex item, which rendered the
|
|
51
|
+
word "hard" as "h…" on every principle.
|
|
52
|
+
|
|
53
|
+
## [0.5.0] - 2026-09-14
|
|
54
|
+
|
|
55
|
+
The operating model was ours, hardcoded. Now it is a file you can install.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- **Profiles.** One config key — `"profile"` — swaps every threshold and
|
|
59
|
+
policy the engine uses for someone else's. Resolves a name shipped with the
|
|
60
|
+
engine, a path, or an installed package, in that order. Three ship:
|
|
61
|
+
`default` (the engine's own model, written out so you can read what a
|
|
62
|
+
profile controls), `patient` and `aggressive`.
|
|
63
|
+
- **Three layers, and the instance always wins**: engine defaults, then the
|
|
64
|
+
profile, then this instance. That is the part that makes a profile usable
|
|
65
|
+
for client work — a client running their agency's method can always see,
|
|
66
|
+
and change, exactly where their setup departs from it. `n-seo profile`
|
|
67
|
+
prints that list; the Settings page shows it beside the profile's name.
|
|
68
|
+
- **A profile that cannot be resolved fails the load**, naming every location
|
|
69
|
+
tried, rather than falling back to the defaults. Discovering months later
|
|
70
|
+
that a client's instance quietly stopped applying your method is worse than
|
|
71
|
+
an error on the morning you typed the name wrong. `doctor` reports it too.
|
|
72
|
+
- **`rules` in config.** Every number the action engine ranks by — the
|
|
73
|
+
striking-distance band, the impression floors, the CTR ratio, the
|
|
74
|
+
engagement thresholds, the drop percentage, the effort weights — was a
|
|
75
|
+
literal in `src/actions.ts` and `src/data.ts`. Disagreeing with any of them
|
|
76
|
+
meant editing the engine and then living with the merge on every upgrade.
|
|
77
|
+
- **`operatingRules` in config**: the freeze window, the weekly batch size,
|
|
78
|
+
the decision window, the history length. One place, so changing the freeze
|
|
79
|
+
changes it everywhere it is stated rather than in six that drift.
|
|
80
|
+
- `docs/PROFILES.md`, and a section on the site for people who do this for a
|
|
81
|
+
living.
|
|
82
|
+
|
|
83
|
+
### Decided
|
|
84
|
+
- A profile is data and may not ship executable code. Installing someone's
|
|
85
|
+
method should not mean running their code on the machine that holds your
|
|
86
|
+
Search Console credentials. Custom rules — new kinds of card rather than new
|
|
87
|
+
numbers for existing ones — remain a fork, and the PRD carries the open
|
|
88
|
+
question.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
- The TypeScript and Python loaders now resolve profiles identically, asserted
|
|
92
|
+
by a test that runs both against every shipped profile. A dashboard saying
|
|
93
|
+
the freeze is 28 days while the daily log says 56 would be worse than either
|
|
94
|
+
number being wrong.
|
|
95
|
+
|
|
11
96
|
## [0.4.1] - 2026-09-13
|
|
12
97
|
|
|
13
98
|
### Added
|
|
@@ -401,7 +486,9 @@ First public release.
|
|
|
401
486
|
`n-seo upgrade` gate — without changing the reported test counts, and an
|
|
402
487
|
in-place install ran those assertions against the owner's live data.
|
|
403
488
|
|
|
404
|
-
[Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.
|
|
489
|
+
[Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.0...HEAD
|
|
490
|
+
[0.6.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...v0.6.0
|
|
491
|
+
[0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
|
|
405
492
|
[0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
|
|
406
493
|
[0.4.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.3...v0.4.0
|
|
407
494
|
[0.3.3]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.2...v0.3.3
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
An agentic, local-first control plane for growing organic traffic to your own
|
|
10
10
|
sites — classic search (SEO), answer engines (AEO) and AI assistants that cite
|
|
11
|
-
sources (GEO)
|
|
11
|
+
sources (GEO). Your sites, your data, your call.
|
|
12
12
|
|
|
13
13
|
It pulls Search Console and GA4 into local JSON, probes your live sites for the
|
|
14
14
|
things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
|
package/bin/n-seo.mjs
CHANGED
|
@@ -41,6 +41,7 @@ usage: n-seo <command> [--instance <dir>] [args...]
|
|
|
41
41
|
check engine self-test: typecheck + unit tests
|
|
42
42
|
upgrade update the engine in place, whatever way it was installed;
|
|
43
43
|
--check reports what is available without changing anything
|
|
44
|
+
profile the active profile, and every value this instance overrides
|
|
44
45
|
version engine version, commit, paths, mode
|
|
45
46
|
help this text
|
|
46
47
|
|
|
@@ -143,6 +144,41 @@ function missingDeps(cmd, what) {
|
|
|
143
144
|
return 1;
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/** A profile's principles, as markdown for the instance CLAUDE.md.
|
|
148
|
+
*
|
|
149
|
+
* The whole point of writing a method down is that the agent follows it too.
|
|
150
|
+
* A principle that lives only in the dashboard is a principle the thing
|
|
151
|
+
* doing the work never reads. */
|
|
152
|
+
function profilePrinciples(dir) {
|
|
153
|
+
const cfgPath = path.join(dir, "n-seo.config.json");
|
|
154
|
+
let spec;
|
|
155
|
+
try {
|
|
156
|
+
spec = JSON.parse(fs.readFileSync(cfgPath, "utf8")).profile;
|
|
157
|
+
} catch { return ""; }
|
|
158
|
+
if (!spec) return "";
|
|
159
|
+
|
|
160
|
+
const candidates = [
|
|
161
|
+
path.join(ROOT, "profiles", spec),
|
|
162
|
+
path.isAbsolute(spec) ? spec : path.resolve(dir, spec),
|
|
163
|
+
path.join(dir, "node_modules", spec),
|
|
164
|
+
path.join(ROOT, "node_modules", spec),
|
|
165
|
+
];
|
|
166
|
+
const found = candidates.find((c) => fs.existsSync(path.join(c, "n-seo.profile.json")));
|
|
167
|
+
if (!found) return "";
|
|
168
|
+
|
|
169
|
+
let profile;
|
|
170
|
+
try {
|
|
171
|
+
profile = JSON.parse(fs.readFileSync(path.join(found, "n-seo.profile.json"), "utf8"));
|
|
172
|
+
} catch { return ""; }
|
|
173
|
+
const list = (profile.principles ?? []).filter((p) => p?.title && p?.body);
|
|
174
|
+
if (!list.length) return "";
|
|
175
|
+
|
|
176
|
+
const lines = list.map((p) => `- **${p.title}**${p.kind === "hard" ? " (hard rule)" : ""}\n ${p.body}`);
|
|
177
|
+
return `\n## From the ${profile.name} profile\n\n` +
|
|
178
|
+
`These came with the profile this instance runs. Hard rules are constraints, not advice.\n\n` +
|
|
179
|
+
lines.join("\n") + "\n";
|
|
180
|
+
}
|
|
181
|
+
|
|
146
182
|
/* ---------- init ---------- */
|
|
147
183
|
|
|
148
184
|
/** Files that mean "this directory is an application", not a place to keep
|
|
@@ -303,7 +339,7 @@ Skills in \`.claude/skills/\` cover the routine work — start with
|
|
|
303
339
|
/actions until a human accepts them.
|
|
304
340
|
- **Site changes ship as branches and pull requests** in the site's own repo,
|
|
305
341
|
never committed straight to its main branch.
|
|
306
|
-
`);
|
|
342
|
+
${profilePrinciples(dir)}`);
|
|
307
343
|
|
|
308
344
|
const envExample = path.join(ROOT, ".env.example");
|
|
309
345
|
put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
|
|
@@ -548,6 +584,7 @@ switch (cmd) {
|
|
|
548
584
|
case "init":
|
|
549
585
|
code = init(rest.find((a) => !a.startsWith("-")) ?? flag ?? process.cwd(), rest.includes("--force"));
|
|
550
586
|
break;
|
|
587
|
+
case "profile":
|
|
551
588
|
case "start":
|
|
552
589
|
case "dev":
|
|
553
590
|
case "mcp": {
|
|
@@ -558,7 +595,9 @@ switch (cmd) {
|
|
|
558
595
|
code = missingDeps(cmd, "the engine's dependencies");
|
|
559
596
|
break;
|
|
560
597
|
}
|
|
561
|
-
const entry = cmd === "mcp" ? "src/mcp-stdio.ts"
|
|
598
|
+
const entry = cmd === "mcp" ? "src/mcp-stdio.ts"
|
|
599
|
+
: cmd === "profile" ? "src/profile-cli.ts"
|
|
600
|
+
: "src/server.tsx";
|
|
562
601
|
const args = cmd === "dev" ? ["watch", entry] : [entry];
|
|
563
602
|
code = run(process.execPath, [bin, ...args, ...rest], instance);
|
|
564
603
|
break;
|
package/docs/PRD.md
CHANGED
|
@@ -10,16 +10,19 @@ check. Status markers: **[shipped]**, **[planned]**, **[idea]**.
|
|
|
10
10
|
|
|
11
11
|
n-seo is a local-first control plane for organic growth across one or more
|
|
12
12
|
websites: classic search (SEO), answer engines (AEO) and generative engines
|
|
13
|
-
that cite sources (GEO). It replaces the monthly
|
|
13
|
+
that cite sources (GEO). It replaces the monthly read-out with a loop
|
|
14
14
|
that runs every morning on the owner's machine: pull Search Console and GA4,
|
|
15
15
|
probe the live sites, turn the data into a ranked queue of concrete actions
|
|
16
16
|
with evidence attached, and measure yesterday's changes.
|
|
17
17
|
|
|
18
18
|
## Users
|
|
19
19
|
|
|
20
|
-
- **A site owner or small team** who wants to
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
- **A site owner or small team** who wants to run their own search practice
|
|
21
|
+
and understand it: developers, indie makers, consultancies, small businesses
|
|
22
|
+
with a technical person. Note that agencies and consultancies are users
|
|
23
|
+
here, not the thing being displaced — the copy should never imply otherwise,
|
|
24
|
+
and the tool is as useful to someone doing this for clients as for
|
|
25
|
+
themselves.
|
|
23
26
|
- **An operator running several sites** (a portfolio, an agency serving its
|
|
24
27
|
own clients) who needs one queue across all of them.
|
|
25
28
|
- **An AI agent** (Claude Code, Claude Desktop, any MCP client) acting for
|
|
@@ -136,14 +139,43 @@ a fresh checkout runs.
|
|
|
136
139
|
- Acceptance: `config/backlog.json` is merged and hot-reloaded without a
|
|
137
140
|
restart, and a syntax error keeps the last good queue.
|
|
138
141
|
|
|
139
|
-
## Feature:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
- Acceptance:
|
|
146
|
-
|
|
142
|
+
## Feature: Profiles — a method you can install [shipped]
|
|
143
|
+
|
|
144
|
+
n-seo ships one operating model; a practitioner has their own. A **profile**
|
|
145
|
+
packages thresholds, policy numbers and module defaults so a method can be
|
|
146
|
+
published once and installed many times. Full reference: `docs/PROFILES.md`.
|
|
147
|
+
|
|
148
|
+
- Acceptance: `"profile": "<built-in | path | package>"` resolves in that
|
|
149
|
+
order, and every value remains overridable by the instance.
|
|
150
|
+
- Acceptance: a profile that cannot be resolved fails the load with a message
|
|
151
|
+
naming each location tried, rather than falling back to the defaults.
|
|
152
|
+
- Acceptance: `n-seo profile` prints the active profile and every value this
|
|
153
|
+
instance overrides; the Settings page shows the same, and `doctor` reports
|
|
154
|
+
an unresolvable profile before the daily run reaches it.
|
|
155
|
+
- Acceptance: the TypeScript and Python loaders resolve every shipped profile
|
|
156
|
+
to identical numbers, asserted by a test that runs both.
|
|
157
|
+
- Acceptance: `default`, `patient` and `aggressive` ship with the engine, and
|
|
158
|
+
`default` restates the engine defaults exactly — asserted, so the two cannot
|
|
159
|
+
drift.
|
|
160
|
+
- Acceptance: `rules.priorities` reweights or drops queue cards by host,
|
|
161
|
+
path, tag or kind; a matched card carries the reason in its spec and a
|
|
162
|
+
*reweighted* chip; a priority with no `why` or no conditions is ignored; a
|
|
163
|
+
bad regex or a nonsense multiplier is ignored rather than throwing or
|
|
164
|
+
corrupting the sort.
|
|
165
|
+
- Acceptance: a priority can never create a card. Everything in the queue
|
|
166
|
+
still came from data.
|
|
167
|
+
- Decided: a profile is data only and may not ship executable code. Installing
|
|
168
|
+
a method should not mean running its author's code on the machine holding
|
|
169
|
+
your Search Console credentials. `priorities` covers reordering and hiding
|
|
170
|
+
declaratively, which is what practitioners actually asked to express. A
|
|
171
|
+
genuinely new *kind* of card — one reading data no existing rule reads —
|
|
172
|
+
remains a fork, and is the open question.
|
|
173
|
+
|
|
174
|
+
## Feature: Rule thresholds in config [shipped]
|
|
175
|
+
|
|
176
|
+
- Acceptance: every threshold the action engine ranks by lives in `rules`,
|
|
177
|
+
with the engine defaults documented in `src/config.ts` and mirrored in
|
|
178
|
+
`ingest/seo_config.py`; tests cover an override changing a rule's output.
|
|
147
179
|
|
|
148
180
|
# Epic: Dashboard [shipped]
|
|
149
181
|
|
package/docs/PROFILES.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Profiles — a method you can install
|
|
2
|
+
|
|
3
|
+
n-seo ships with an opinionated model: a 28-day freeze after any metadata
|
|
4
|
+
change, roughly eight of those a week, striking distance meaning positions
|
|
5
|
+
5–15, decisions on the trailing 90 days. Those numbers are a position, not a
|
|
6
|
+
law, and if you do this for a living yours are probably different.
|
|
7
|
+
|
|
8
|
+
A **profile** is that position, packaged. One config key, and every threshold
|
|
9
|
+
and policy the engine uses comes from your profile instead of ours.
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
{
|
|
13
|
+
"profile": "patient",
|
|
14
|
+
"sites": [ ... ]
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## The three layers
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
engine defaults what n-seo believes, in src/config.ts
|
|
22
|
+
↓ overridden by
|
|
23
|
+
the profile what you or your agency believes
|
|
24
|
+
↓ overridden by
|
|
25
|
+
this instance what this one client needs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The instance always wins. That matters more than it sounds: a client running
|
|
29
|
+
their agency's profile can always see, and change, exactly where their setup
|
|
30
|
+
departs from it. `n-seo profile` prints that list, and the Settings page shows
|
|
31
|
+
it beside the profile's name.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
$ n-seo profile
|
|
35
|
+
profile Patient 1.0.0
|
|
36
|
+
spec patient
|
|
37
|
+
from /opt/n-seo/profiles/patient
|
|
38
|
+
|
|
39
|
+
this instance overrides 2 value(s):
|
|
40
|
+
|
|
41
|
+
operatingRules.titleFreezeDays 56 → 30
|
|
42
|
+
rules.strikingDistance.maxRows 6 → 9
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What a profile can set
|
|
46
|
+
|
|
47
|
+
`n-seo.profile.json`, at the root of a directory or a package:
|
|
48
|
+
|
|
49
|
+
```jsonc
|
|
50
|
+
{
|
|
51
|
+
"name": "Acme Search",
|
|
52
|
+
"description": "How we run search for retail clients.",
|
|
53
|
+
"version": "1.2.0",
|
|
54
|
+
|
|
55
|
+
// The policy a human follows.
|
|
56
|
+
"operatingRules": {
|
|
57
|
+
"titleFreezeDays": 28, // hands off a page's metadata this long
|
|
58
|
+
"metadataChangesPerWeek": 8, // batch size across the portfolio
|
|
59
|
+
"decisionWindowDays": 90, // decisions ride this window
|
|
60
|
+
"historyMonths": 16 // the long view, for totals only
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// The numbers the action engine ranks by.
|
|
64
|
+
"rules": {
|
|
65
|
+
"effortWeight": { "S": 1, "M": 2.5, "L": 5 },
|
|
66
|
+
"strikingDistance": { "minPosition": 5, "maxPosition": 15, "minImpressions": 10,
|
|
67
|
+
"maxRows": 12, "impactPerImpression": 0.06 },
|
|
68
|
+
"ctrGap": { "minImpressions": 30, "belowExpectedRatio": 0.5 },
|
|
69
|
+
"engagement": { "minSessions": 30, "maxEngagement": 0.25, "maxCards": 3 },
|
|
70
|
+
"trafficDrop": { "minPriorSessions": 50, "dropRatio": 0.75 },
|
|
71
|
+
"probe": { "minVisibleTextBytes": 500 },
|
|
72
|
+
"metadata": { "maxFindings": 5 }
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Module defaults. The instance can still switch any of them.
|
|
76
|
+
"modules": {
|
|
77
|
+
"indexNow": { "enabled": true }
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// The parts of a method that are not a number.
|
|
81
|
+
"principles": [
|
|
82
|
+
{
|
|
83
|
+
"kind": "hard",
|
|
84
|
+
"title": "Never write to a CMS without per-item approval",
|
|
85
|
+
"body": "Show the exact before and after, get a yes on each one, then write."
|
|
86
|
+
},
|
|
87
|
+
{ "title": "Judge by the path to a signup, not by clicks", "body": "..." }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Principles
|
|
93
|
+
|
|
94
|
+
The most useful thing we learned building the first real profile: a
|
|
95
|
+
practitioner's method is barely distinguishable from ours in *thresholds* and
|
|
96
|
+
almost entirely distinguishable in *judgement*. The striking-distance floor
|
|
97
|
+
turned out never to bind once rows are sorted by impressions. What actually
|
|
98
|
+
differed was what to optimise for, what needs a human's approval, and what to
|
|
99
|
+
never automate.
|
|
100
|
+
|
|
101
|
+
None of that is a number, so `principles` carries it. Each has a `title`, a
|
|
102
|
+
`body`, and a `kind`:
|
|
103
|
+
|
|
104
|
+
- **`hard`** — a constraint. An agent must not cross it.
|
|
105
|
+
- **`guide`** — judgement it should apply.
|
|
106
|
+
|
|
107
|
+
They appear at the top of the action queue, on the Settings page, and — this
|
|
108
|
+
is the part that matters — `n-seo init` writes them into the instance's
|
|
109
|
+
`CLAUDE.md`. A principle the agent never reads is not an operating rule, it is
|
|
110
|
+
a note to yourself.
|
|
111
|
+
|
|
112
|
+
Set only what you disagree with. Anything absent is inherited, so a profile
|
|
113
|
+
that changes one threshold is three lines long.
|
|
114
|
+
|
|
115
|
+
## Shipped with the engine
|
|
116
|
+
|
|
117
|
+
| spec | what it is for |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `default` | The engine's own model, written out so you can read what a profile controls. |
|
|
120
|
+
| `patient` | Low traffic, long sales cycle, or an owner who would rather ship three good changes a month than thirty. Longer freeze, smaller batches, a higher bar before anything is called a problem. |
|
|
121
|
+
| `aggressive` | A large site with traffic to spare, where missing an opportunity costs more than a wasted afternoon. Wider band, lower floors, longer queue. Keeps the 28-day freeze — that one is not a preference. |
|
|
122
|
+
|
|
123
|
+
`n-seo profile` lists them.
|
|
124
|
+
|
|
125
|
+
## Writing your own
|
|
126
|
+
|
|
127
|
+
A profile is a directory with an `n-seo.profile.json` in it. Three ways to
|
|
128
|
+
point at one:
|
|
129
|
+
|
|
130
|
+
```jsonc
|
|
131
|
+
"profile": "patient" // shipped with the engine
|
|
132
|
+
"profile": "./profiles/acme" // a path, relative to the instance
|
|
133
|
+
"profile": "n-seo-profile-acme" // an installed package
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
To publish one, make it a package whose root holds the file:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
n-seo-profile-acme/
|
|
140
|
+
├── package.json
|
|
141
|
+
└── n-seo.profile.json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then `npm i n-seo-profile-acme` in the instance and name it in the config.
|
|
145
|
+
Versioning the package versions the method, which is the point: "we moved you
|
|
146
|
+
to Acme Search 2.0" is a sentence a client can check.
|
|
147
|
+
|
|
148
|
+
## Priorities — the declarative half of custom rules
|
|
149
|
+
|
|
150
|
+
A threshold says *what counts as a finding*. A priority says *what you care
|
|
151
|
+
about*. They are different questions, and the second one is where a method
|
|
152
|
+
usually lives.
|
|
153
|
+
|
|
154
|
+
```jsonc
|
|
155
|
+
"rules": {
|
|
156
|
+
"priorities": [
|
|
157
|
+
{
|
|
158
|
+
"why": "leads somewhere: a signup, a tool, an account",
|
|
159
|
+
"when": { "pathMatches": "^/(tools|pricing|signup)" },
|
|
160
|
+
"multiply": 1.5
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"why": "we have decided not to work on these",
|
|
164
|
+
"when": { "pathMatches": "^/legal/" },
|
|
165
|
+
"drop": true
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`when` matches on `host`, `pathMatches` (a regular expression against the
|
|
172
|
+
page's path), `tag`, and `kind`. A priority with no conditions matches
|
|
173
|
+
nothing — applying to the whole queue is never what anyone means, and is a
|
|
174
|
+
miserable way to learn the shape of the config.
|
|
175
|
+
|
|
176
|
+
`multiply` scales the impact that orders the queue. `drop` removes the card.
|
|
177
|
+
Several priorities compound, in the order they are written.
|
|
178
|
+
|
|
179
|
+
### Two rules the design will not bend on
|
|
180
|
+
|
|
181
|
+
**A priority may reorder or hide, never invent.** It cannot create a card, so
|
|
182
|
+
it cannot manufacture evidence. Everything in the queue still came from data.
|
|
183
|
+
|
|
184
|
+
**Every card it touches says so.** The impact number decides the order, so a
|
|
185
|
+
card whose number was adjusted carries the reason on its face and a
|
|
186
|
+
*reweighted* chip. `why` is required; a priority without one is ignored.
|
|
187
|
+
Ordering you cannot see the reason for is ordering you cannot argue with, and
|
|
188
|
+
the whole product rests on showing its working.
|
|
189
|
+
|
|
190
|
+
### Why this is not a plugin API
|
|
191
|
+
|
|
192
|
+
The obvious version of "custom rules" is letting a profile ship code. We are
|
|
193
|
+
not doing that, and the reason is not squeamishness about JavaScript:
|
|
194
|
+
installing someone's method would mean running their program on the machine
|
|
195
|
+
that holds your Search Console credentials and your service-account key. That
|
|
196
|
+
is a large amount of trust to ask for a list of thresholds.
|
|
197
|
+
|
|
198
|
+
Matching and weighting turned out to cover what people actually want to
|
|
199
|
+
express. A genuinely new *kind* of card — one that reads data no existing rule
|
|
200
|
+
reads — is still a fork, and `docs/PRD.md` carries that as an open question
|
|
201
|
+
rather than a decided one.
|
|
202
|
+
|
|
203
|
+
## When a profile cannot be found
|
|
204
|
+
|
|
205
|
+
The run fails, loudly, naming every location that was tried. It does **not**
|
|
206
|
+
fall back to the engine defaults. Someone running a client's portfolio on
|
|
207
|
+
their agency's method should never discover it quietly stopped applying —
|
|
208
|
+
a wrong answer you can see beats a plausible one you cannot.
|
|
209
|
+
|
|
210
|
+
`n-seo doctor` reports the same thing before the daily run gets there.
|
|
211
|
+
|
|
212
|
+
## What a profile cannot do
|
|
213
|
+
|
|
214
|
+
It cannot ship executable code. A profile is data: thresholds, policy numbers
|
|
215
|
+
and module defaults. Installing someone's method should not mean running
|
|
216
|
+
their code on the machine that holds your Search Console credentials.
|
|
217
|
+
|
|
218
|
+
Custom *rules* — new kinds of card, not new numbers for existing ones — are a
|
|
219
|
+
real gap, and the honest answer today is to fork the engine. `docs/PRD.md`
|
|
220
|
+
carries the open question.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ingest/seo_config.py
CHANGED
|
@@ -57,6 +57,70 @@ MODULE_DEFAULTS = {
|
|
|
57
57
|
"delete": False, "dryRun": False, "env": {}},
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
PROFILE_FILE = "n-seo.profile.json"
|
|
61
|
+
|
|
62
|
+
# Mirrors DEFAULT_RULES / DEFAULT_OPERATING_RULES in src/config.ts. The
|
|
63
|
+
# TypeScript side is where the action engine reads them; Python needs them so
|
|
64
|
+
# the daily log, the digests and doctor state the same numbers the dashboard
|
|
65
|
+
# does, rather than a second set that drifts.
|
|
66
|
+
DEFAULT_RULES = {
|
|
67
|
+
"effortWeight": {"S": 1, "M": 2.5, "L": 5},
|
|
68
|
+
"strikingDistance": {"minPosition": 5, "maxPosition": 15, "minImpressions": 10,
|
|
69
|
+
"maxRows": 12, "impactPerImpression": 0.06},
|
|
70
|
+
"ctrGap": {"minImpressions": 30, "belowExpectedRatio": 0.5},
|
|
71
|
+
"engagement": {"minSessions": 30, "maxEngagement": 0.25, "maxCards": 3},
|
|
72
|
+
"trafficDrop": {"minPriorSessions": 50, "dropRatio": 0.75},
|
|
73
|
+
"probe": {"minVisibleTextBytes": 500},
|
|
74
|
+
"metadata": {"maxFindings": 5},
|
|
75
|
+
# Ordering adjustments. Applied by the TypeScript action engine; mirrored
|
|
76
|
+
# here so the two loaders resolve identical config, which a test asserts.
|
|
77
|
+
"priorities": [],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
DEFAULT_OPERATING_RULES = {
|
|
81
|
+
"titleFreezeDays": 28,
|
|
82
|
+
"metadataChangesPerWeek": 8,
|
|
83
|
+
"decisionWindowDays": 90,
|
|
84
|
+
"historyMonths": 16,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def profile_dir(spec):
|
|
89
|
+
"""Where a profile spec resolves to, or None. Mirrors resolveProfileDir."""
|
|
90
|
+
if not spec:
|
|
91
|
+
return None
|
|
92
|
+
if "/" not in spec and "\\" not in spec:
|
|
93
|
+
built = ROOT / "profiles" / spec
|
|
94
|
+
if (built / PROFILE_FILE).exists():
|
|
95
|
+
return built
|
|
96
|
+
as_path = Path(spec) if Path(spec).is_absolute() else (INSTANCE / spec)
|
|
97
|
+
if (as_path / PROFILE_FILE).exists():
|
|
98
|
+
return as_path.resolve()
|
|
99
|
+
# An installed package, without importing node: node_modules beside the
|
|
100
|
+
# instance, then beside the engine.
|
|
101
|
+
for base in (INSTANCE, ROOT):
|
|
102
|
+
cand = base / "node_modules" / spec
|
|
103
|
+
if (cand / PROFILE_FILE).exists():
|
|
104
|
+
return cand.resolve()
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _merge(base, over):
|
|
109
|
+
"""Deep merge; `over` wins, None never overwrites, and lists replace.
|
|
110
|
+
|
|
111
|
+
Lists replace rather than concatenate so an instance that sets
|
|
112
|
+
`priorities` replaces the profile's outright. Appending would make a
|
|
113
|
+
profile's priority impossible to remove without forking the profile.
|
|
114
|
+
"""
|
|
115
|
+
out = dict(base)
|
|
116
|
+
for k, v in (over or {}).items():
|
|
117
|
+
if v is None:
|
|
118
|
+
continue
|
|
119
|
+
cur = out.get(k)
|
|
120
|
+
out[k] = _merge(cur, v) if isinstance(cur, dict) and isinstance(v, dict) else v
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
60
124
|
_cache = None
|
|
61
125
|
|
|
62
126
|
|
|
@@ -73,9 +137,22 @@ def load(force: bool = False) -> dict:
|
|
|
73
137
|
return _cache
|
|
74
138
|
path = CONFIG_PATH if CONFIG_PATH.exists() else EXAMPLE_PATH
|
|
75
139
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
140
|
+
# engine defaults <- profile <- this instance, for modules and both rule
|
|
141
|
+
# blocks. The instance always wins.
|
|
142
|
+
prof = {}
|
|
143
|
+
pdir = profile_dir(raw.get("profile"))
|
|
144
|
+
if pdir:
|
|
145
|
+
try:
|
|
146
|
+
prof = json.loads((pdir / PROFILE_FILE).read_text(encoding="utf-8"))
|
|
147
|
+
except (OSError, ValueError):
|
|
148
|
+
prof = {}
|
|
149
|
+
|
|
150
|
+
pmods = prof.get("modules") or {}
|
|
151
|
+
modules = {k: {"enabled": False, **MODULE_DEFAULTS.get(k, {}), **(pmods.get(k) or {})}
|
|
152
|
+
for k in MODULE_KEYS}
|
|
153
|
+
for k, v in {**pmods, **(raw.get("modules") or {})}.items():
|
|
154
|
+
modules[k] = {"enabled": False, **MODULE_DEFAULTS.get(k, {}),
|
|
155
|
+
**(pmods.get(k) or {}), **((raw.get("modules") or {}).get(k) or {})}
|
|
79
156
|
sites = []
|
|
80
157
|
for s in raw.get("sites") or []:
|
|
81
158
|
if not s.get("host"):
|
|
@@ -109,6 +186,10 @@ def load(force: bool = False) -> dict:
|
|
|
109
186
|
"conversions": conv if conv.get("site") else None,
|
|
110
187
|
"participation": raw.get("participation") or {},
|
|
111
188
|
"modules": modules,
|
|
189
|
+
"profile": raw.get("profile"),
|
|
190
|
+
"rules": _merge(_merge(DEFAULT_RULES, prof.get("rules")), raw.get("rules")),
|
|
191
|
+
"operatingRules": _merge(_merge(DEFAULT_OPERATING_RULES, prof.get("operatingRules")),
|
|
192
|
+
raw.get("operatingRules")),
|
|
112
193
|
"gscExtraProperties": str_list(raw.get("gscExtraProperties")),
|
|
113
194
|
"hooks": hooks,
|
|
114
195
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "My sites",
|
|
3
|
+
"_profile": "Optional. A method to inherit: a name shipped with the engine (default, patient, aggressive), a path, or an installed package. Anything you set below still wins. See docs/PROFILES.md.",
|
|
3
4
|
"port": 4600,
|
|
4
5
|
"google": {
|
|
5
6
|
"auth": "service-account-key",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ops/doctor.py
CHANGED
|
@@ -55,6 +55,30 @@ def check_engine():
|
|
|
55
55
|
report("OK", "update check has not run yet", "it runs with the daily run")
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def check_profile(cfg):
|
|
59
|
+
"""A profile that cannot be resolved must be loud.
|
|
60
|
+
|
|
61
|
+
Someone running a client's portfolio on their agency's method should never
|
|
62
|
+
discover it quietly stopped applying and the engine defaults took over.
|
|
63
|
+
"""
|
|
64
|
+
spec = cfg.get("profile")
|
|
65
|
+
if not spec:
|
|
66
|
+
return
|
|
67
|
+
print("profile")
|
|
68
|
+
d = seo_config.profile_dir(spec)
|
|
69
|
+
if not d:
|
|
70
|
+
report("FAIL", f"profile {spec!r} not found",
|
|
71
|
+
"install it, fix the path, or remove \"profile\" from the config")
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
meta = json.loads((d / "n-seo.profile.json").read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, ValueError) as exc:
|
|
76
|
+
report("FAIL", f"profile {spec!r} is unreadable", str(exc)[:120])
|
|
77
|
+
return
|
|
78
|
+
report("OK", f"{meta.get('name', spec)}" + (f" {meta['version']}" if meta.get("version") else ""))
|
|
79
|
+
report("OK", f"from {d}")
|
|
80
|
+
|
|
81
|
+
|
|
58
82
|
def check_config():
|
|
59
83
|
print("config")
|
|
60
84
|
if seo_config.using_example():
|
|
@@ -309,6 +333,8 @@ def check_modules(cfg):
|
|
|
309
333
|
def main():
|
|
310
334
|
check_engine()
|
|
311
335
|
cfg = check_config()
|
|
336
|
+
if cfg is not None:
|
|
337
|
+
check_profile(cfg)
|
|
312
338
|
check_tools()
|
|
313
339
|
if cfg is None:
|
|
314
340
|
return 1
|