n-seo 0.4.1 → 0.5.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 +45 -1
- package/README.md +1 -1
- package/bin/n-seo.mjs +5 -1
- package/docs/PRD.md +35 -12
- package/docs/PROFILES.md +135 -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 +76 -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 +20 -0
- package/profiles/patient/n-seo.profile.json +16 -0
- package/public/styles.css +7 -1
- package/src/actions.ts +21 -13
- package/src/config.ts +165 -2
- package/src/data.ts +11 -5
- package/src/profile-cli.ts +34 -0
- package/src/profile-report.ts +106 -0
- package/src/settings.tsx +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,49 @@ uses [Semantic Versioning](https://semver.org/).
|
|
|
8
8
|
|
|
9
9
|
Nothing yet.
|
|
10
10
|
|
|
11
|
+
## [0.5.0] - 2026-09-14
|
|
12
|
+
|
|
13
|
+
The operating model was ours, hardcoded. Now it is a file you can install.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Profiles.** One config key — `"profile"` — swaps every threshold and
|
|
17
|
+
policy the engine uses for someone else's. Resolves a name shipped with the
|
|
18
|
+
engine, a path, or an installed package, in that order. Three ship:
|
|
19
|
+
`default` (the engine's own model, written out so you can read what a
|
|
20
|
+
profile controls), `patient` and `aggressive`.
|
|
21
|
+
- **Three layers, and the instance always wins**: engine defaults, then the
|
|
22
|
+
profile, then this instance. That is the part that makes a profile usable
|
|
23
|
+
for client work — a client running their agency's method can always see,
|
|
24
|
+
and change, exactly where their setup departs from it. `n-seo profile`
|
|
25
|
+
prints that list; the Settings page shows it beside the profile's name.
|
|
26
|
+
- **A profile that cannot be resolved fails the load**, naming every location
|
|
27
|
+
tried, rather than falling back to the defaults. Discovering months later
|
|
28
|
+
that a client's instance quietly stopped applying your method is worse than
|
|
29
|
+
an error on the morning you typed the name wrong. `doctor` reports it too.
|
|
30
|
+
- **`rules` in config.** Every number the action engine ranks by — the
|
|
31
|
+
striking-distance band, the impression floors, the CTR ratio, the
|
|
32
|
+
engagement thresholds, the drop percentage, the effort weights — was a
|
|
33
|
+
literal in `src/actions.ts` and `src/data.ts`. Disagreeing with any of them
|
|
34
|
+
meant editing the engine and then living with the merge on every upgrade.
|
|
35
|
+
- **`operatingRules` in config**: the freeze window, the weekly batch size,
|
|
36
|
+
the decision window, the history length. One place, so changing the freeze
|
|
37
|
+
changes it everywhere it is stated rather than in six that drift.
|
|
38
|
+
- `docs/PROFILES.md`, and a section on the site for people who do this for a
|
|
39
|
+
living.
|
|
40
|
+
|
|
41
|
+
### Decided
|
|
42
|
+
- A profile is data and may not ship executable code. Installing someone's
|
|
43
|
+
method should not mean running their code on the machine that holds your
|
|
44
|
+
Search Console credentials. Custom rules — new kinds of card rather than new
|
|
45
|
+
numbers for existing ones — remain a fork, and the PRD carries the open
|
|
46
|
+
question.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
- The TypeScript and Python loaders now resolve profiles identically, asserted
|
|
50
|
+
by a test that runs both against every shipped profile. A dashboard saying
|
|
51
|
+
the freeze is 28 days while the daily log says 56 would be worse than either
|
|
52
|
+
number being wrong.
|
|
53
|
+
|
|
11
54
|
## [0.4.1] - 2026-09-13
|
|
12
55
|
|
|
13
56
|
### Added
|
|
@@ -401,7 +444,8 @@ First public release.
|
|
|
401
444
|
`n-seo upgrade` gate — without changing the reported test counts, and an
|
|
402
445
|
in-place install ran those assertions against the owner's live data.
|
|
403
446
|
|
|
404
|
-
[Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.
|
|
447
|
+
[Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...HEAD
|
|
448
|
+
[0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
|
|
405
449
|
[0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
|
|
406
450
|
[0.4.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.3...v0.4.0
|
|
407
451
|
[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
|
|
|
@@ -548,6 +549,7 @@ switch (cmd) {
|
|
|
548
549
|
case "init":
|
|
549
550
|
code = init(rest.find((a) => !a.startsWith("-")) ?? flag ?? process.cwd(), rest.includes("--force"));
|
|
550
551
|
break;
|
|
552
|
+
case "profile":
|
|
551
553
|
case "start":
|
|
552
554
|
case "dev":
|
|
553
555
|
case "mcp": {
|
|
@@ -558,7 +560,9 @@ switch (cmd) {
|
|
|
558
560
|
code = missingDeps(cmd, "the engine's dependencies");
|
|
559
561
|
break;
|
|
560
562
|
}
|
|
561
|
-
const entry = cmd === "mcp" ? "src/mcp-stdio.ts"
|
|
563
|
+
const entry = cmd === "mcp" ? "src/mcp-stdio.ts"
|
|
564
|
+
: cmd === "profile" ? "src/profile-cli.ts"
|
|
565
|
+
: "src/server.tsx";
|
|
562
566
|
const args = cmd === "dev" ? ["watch", entry] : [entry];
|
|
563
567
|
code = run(process.execPath, [bin, ...args, ...rest], instance);
|
|
564
568
|
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,34 @@ 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
|
+
- Decided: a profile is data only and may not ship executable code. Installing
|
|
161
|
+
a method should not mean running its author's code on the machine holding
|
|
162
|
+
your Search Console credentials. Custom *rules* — new kinds of card rather
|
|
163
|
+
than new numbers — remain a fork, and are the open question.
|
|
164
|
+
|
|
165
|
+
## Feature: Rule thresholds in config [shipped]
|
|
166
|
+
|
|
167
|
+
- Acceptance: every threshold the action engine ranks by lives in `rules`,
|
|
168
|
+
with the engine defaults documented in `src/config.ts` and mirrored in
|
|
169
|
+
`ingest/seo_config.py`; tests cover an override changing a rule's output.
|
|
147
170
|
|
|
148
171
|
# Epic: Dashboard [shipped]
|
|
149
172
|
|
package/docs/PROFILES.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
```
|
|
81
|
+
|
|
82
|
+
Set only what you disagree with. Anything absent is inherited, so a profile
|
|
83
|
+
that changes one threshold is three lines long.
|
|
84
|
+
|
|
85
|
+
## Shipped with the engine
|
|
86
|
+
|
|
87
|
+
| spec | what it is for |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `default` | The engine's own model, written out so you can read what a profile controls. |
|
|
90
|
+
| `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. |
|
|
91
|
+
| `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. |
|
|
92
|
+
|
|
93
|
+
`n-seo profile` lists them.
|
|
94
|
+
|
|
95
|
+
## Writing your own
|
|
96
|
+
|
|
97
|
+
A profile is a directory with an `n-seo.profile.json` in it. Three ways to
|
|
98
|
+
point at one:
|
|
99
|
+
|
|
100
|
+
```jsonc
|
|
101
|
+
"profile": "patient" // shipped with the engine
|
|
102
|
+
"profile": "./profiles/acme" // a path, relative to the instance
|
|
103
|
+
"profile": "n-seo-profile-acme" // an installed package
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
To publish one, make it a package whose root holds the file:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
n-seo-profile-acme/
|
|
110
|
+
├── package.json
|
|
111
|
+
└── n-seo.profile.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then `npm i n-seo-profile-acme` in the instance and name it in the config.
|
|
115
|
+
Versioning the package versions the method, which is the point: "we moved you
|
|
116
|
+
to Acme Search 2.0" is a sentence a client can check.
|
|
117
|
+
|
|
118
|
+
## When a profile cannot be found
|
|
119
|
+
|
|
120
|
+
The run fails, loudly, naming every location that was tried. It does **not**
|
|
121
|
+
fall back to the engine defaults. Someone running a client's portfolio on
|
|
122
|
+
their agency's method should never discover it quietly stopped applying —
|
|
123
|
+
a wrong answer you can see beats a plausible one you cannot.
|
|
124
|
+
|
|
125
|
+
`n-seo doctor` reports the same thing before the daily run gets there.
|
|
126
|
+
|
|
127
|
+
## What a profile cannot do
|
|
128
|
+
|
|
129
|
+
It cannot ship executable code. A profile is data: thresholds, policy numbers
|
|
130
|
+
and module defaults. Installing someone's method should not mean running
|
|
131
|
+
their code on the machine that holds your Search Console credentials.
|
|
132
|
+
|
|
133
|
+
Custom *rules* — new kinds of card, not new numbers for existing ones — are a
|
|
134
|
+
real gap, and the honest answer today is to fork the engine. `docs/PRD.md`
|
|
135
|
+
carries the open question.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ingest/seo_config.py
CHANGED
|
@@ -57,6 +57,62 @@ 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
|
+
}
|
|
76
|
+
|
|
77
|
+
DEFAULT_OPERATING_RULES = {
|
|
78
|
+
"titleFreezeDays": 28,
|
|
79
|
+
"metadataChangesPerWeek": 8,
|
|
80
|
+
"decisionWindowDays": 90,
|
|
81
|
+
"historyMonths": 16,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def profile_dir(spec):
|
|
86
|
+
"""Where a profile spec resolves to, or None. Mirrors resolveProfileDir."""
|
|
87
|
+
if not spec:
|
|
88
|
+
return None
|
|
89
|
+
if "/" not in spec and "\\" not in spec:
|
|
90
|
+
built = ROOT / "profiles" / spec
|
|
91
|
+
if (built / PROFILE_FILE).exists():
|
|
92
|
+
return built
|
|
93
|
+
as_path = Path(spec) if Path(spec).is_absolute() else (INSTANCE / spec)
|
|
94
|
+
if (as_path / PROFILE_FILE).exists():
|
|
95
|
+
return as_path.resolve()
|
|
96
|
+
# An installed package, without importing node: node_modules beside the
|
|
97
|
+
# instance, then beside the engine.
|
|
98
|
+
for base in (INSTANCE, ROOT):
|
|
99
|
+
cand = base / "node_modules" / spec
|
|
100
|
+
if (cand / PROFILE_FILE).exists():
|
|
101
|
+
return cand.resolve()
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _merge(base, over):
|
|
106
|
+
"""Deep merge; `over` wins, and None never overwrites."""
|
|
107
|
+
out = dict(base)
|
|
108
|
+
for k, v in (over or {}).items():
|
|
109
|
+
if v is None:
|
|
110
|
+
continue
|
|
111
|
+
cur = out.get(k)
|
|
112
|
+
out[k] = _merge(cur, v) if isinstance(cur, dict) and isinstance(v, dict) else v
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
60
116
|
_cache = None
|
|
61
117
|
|
|
62
118
|
|
|
@@ -73,9 +129,22 @@ def load(force: bool = False) -> dict:
|
|
|
73
129
|
return _cache
|
|
74
130
|
path = CONFIG_PATH if CONFIG_PATH.exists() else EXAMPLE_PATH
|
|
75
131
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
132
|
+
# engine defaults <- profile <- this instance, for modules and both rule
|
|
133
|
+
# blocks. The instance always wins.
|
|
134
|
+
prof = {}
|
|
135
|
+
pdir = profile_dir(raw.get("profile"))
|
|
136
|
+
if pdir:
|
|
137
|
+
try:
|
|
138
|
+
prof = json.loads((pdir / PROFILE_FILE).read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, ValueError):
|
|
140
|
+
prof = {}
|
|
141
|
+
|
|
142
|
+
pmods = prof.get("modules") or {}
|
|
143
|
+
modules = {k: {"enabled": False, **MODULE_DEFAULTS.get(k, {}), **(pmods.get(k) or {})}
|
|
144
|
+
for k in MODULE_KEYS}
|
|
145
|
+
for k, v in {**pmods, **(raw.get("modules") or {})}.items():
|
|
146
|
+
modules[k] = {"enabled": False, **MODULE_DEFAULTS.get(k, {}),
|
|
147
|
+
**(pmods.get(k) or {}), **((raw.get("modules") or {}).get(k) or {})}
|
|
79
148
|
sites = []
|
|
80
149
|
for s in raw.get("sites") or []:
|
|
81
150
|
if not s.get("host"):
|
|
@@ -109,6 +178,10 @@ def load(force: bool = False) -> dict:
|
|
|
109
178
|
"conversions": conv if conv.get("site") else None,
|
|
110
179
|
"participation": raw.get("participation") or {},
|
|
111
180
|
"modules": modules,
|
|
181
|
+
"profile": raw.get("profile"),
|
|
182
|
+
"rules": _merge(_merge(DEFAULT_RULES, prof.get("rules")), raw.get("rules")),
|
|
183
|
+
"operatingRules": _merge(_merge(DEFAULT_OPERATING_RULES, prof.get("operatingRules")),
|
|
184
|
+
raw.get("operatingRules")),
|
|
112
185
|
"gscExtraProperties": str_list(raw.get("gscExtraProperties")),
|
|
113
186
|
"hooks": hooks,
|
|
114
187
|
}
|
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n-seo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Agentic, local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and ranks the next moves. Your LLM writes the proposals; your coding agent works the queue over MCP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "En Dash Consulting (https://endash.us)",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"ingest/",
|
|
45
45
|
"ops/",
|
|
46
46
|
"probes/",
|
|
47
|
+
"profiles/",
|
|
47
48
|
"public/",
|
|
48
49
|
"n-seo.config.example.json",
|
|
49
50
|
".env.example",
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Aggressive",
|
|
3
|
+
"description": "For a large site with traffic to spare, where the cost of missing an opportunity beats the cost of a wasted afternoon. A wider striking-distance band, a lower impression floor, and a longer queue. Keeps the 28-day freeze: that one is not a preference, it is how Google reads title churn.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"operatingRules": {
|
|
6
|
+
"metadataChangesPerWeek": 15
|
|
7
|
+
},
|
|
8
|
+
"rules": {
|
|
9
|
+
"strikingDistance": { "minPosition": 4, "maxPosition": 20, "minImpressions": 5, "maxRows": 25 },
|
|
10
|
+
"ctrGap": { "minImpressions": 15, "belowExpectedRatio": 0.65 },
|
|
11
|
+
"engagement": { "minSessions": 15, "maxCards": 6 },
|
|
12
|
+
"trafficDrop": { "minPriorSessions": 25, "dropRatio": 0.85 },
|
|
13
|
+
"metadata": { "maxFindings": 10 }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "n-seo default",
|
|
3
|
+
"description": "The model n-seo ships with. Evidence before advice, a 28-day freeze after any metadata change, roughly eight of those a week, and decisions on the trailing 90 days. Every value here is the engine default, written out so you can see what a profile controls.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"operatingRules": {
|
|
6
|
+
"titleFreezeDays": 28,
|
|
7
|
+
"metadataChangesPerWeek": 8,
|
|
8
|
+
"decisionWindowDays": 90,
|
|
9
|
+
"historyMonths": 16
|
|
10
|
+
},
|
|
11
|
+
"rules": {
|
|
12
|
+
"effortWeight": { "S": 1, "M": 2.5, "L": 5 },
|
|
13
|
+
"strikingDistance": { "minPosition": 5, "maxPosition": 15, "minImpressions": 10, "maxRows": 12, "impactPerImpression": 0.06 },
|
|
14
|
+
"ctrGap": { "minImpressions": 30, "belowExpectedRatio": 0.5 },
|
|
15
|
+
"engagement": { "minSessions": 30, "maxEngagement": 0.25, "maxCards": 3 },
|
|
16
|
+
"trafficDrop": { "minPriorSessions": 50, "dropRatio": 0.75 },
|
|
17
|
+
"probe": { "minVisibleTextBytes": 500 },
|
|
18
|
+
"metadata": { "maxFindings": 5 }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Patient",
|
|
3
|
+
"description": "For a site with low traffic, a 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, and a shorter queue so the list stays readable.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"operatingRules": {
|
|
6
|
+
"titleFreezeDays": 56,
|
|
7
|
+
"metadataChangesPerWeek": 3
|
|
8
|
+
},
|
|
9
|
+
"rules": {
|
|
10
|
+
"strikingDistance": { "minImpressions": 40, "maxRows": 6 },
|
|
11
|
+
"ctrGap": { "minImpressions": 100, "belowExpectedRatio": 0.4 },
|
|
12
|
+
"engagement": { "minSessions": 100, "maxCards": 2 },
|
|
13
|
+
"trafficDrop": { "minPriorSessions": 150, "dropRatio": 0.6 },
|
|
14
|
+
"metadata": { "maxFindings": 3 }
|
|
15
|
+
}
|
|
16
|
+
}
|
package/public/styles.css
CHANGED
|
@@ -582,7 +582,13 @@ footer .foot-upd a { color: inherit; text-decoration: underline; }
|
|
|
582
582
|
.s-help { margin: 0; font-size: 0.75rem; color: var(--muted); }
|
|
583
583
|
.s-field { display: flex; flex-direction: column; gap: 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); margin-top: 4px; flex: 1; }
|
|
584
584
|
.s-field small { font-weight: 500; }
|
|
585
|
-
.s-field input[type="text"], .
|
|
585
|
+
.s-field input[type="text"], .prof-diff { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
586
|
+
.prof-diff li { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 0.76rem; }
|
|
587
|
+
.prof-diff .mono { flex: 1 1 100%; color: var(--muted); }
|
|
588
|
+
.prof-was { font-family: var(--mono); color: var(--muted); text-decoration: line-through; }
|
|
589
|
+
.prof-now { font-family: var(--mono); font-weight: 700; color: var(--accent); }
|
|
590
|
+
|
|
591
|
+
.s-field textarea {
|
|
586
592
|
font: inherit; font-size: 0.84rem; color: var(--ink); background: var(--panel);
|
|
587
593
|
border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; width: 100%; font-weight: 400;
|
|
588
594
|
}
|
package/src/actions.ts
CHANGED
|
@@ -4,22 +4,27 @@
|
|
|
4
4
|
* then merged with the curated queue in config/backlog.json. Pages listed in
|
|
5
5
|
* the backlog's `shippedWatch` map turn their rule-derived cards into
|
|
6
6
|
* "watching" entries — the fix shipped; the data decides what happens next. */
|
|
7
|
-
import { SITES, type SiteCfg } from "./config.js";
|
|
7
|
+
import { SITES, config, type SiteCfg } from "./config.js";
|
|
8
8
|
import * as data from "./data.js";
|
|
9
9
|
import { BACKLOG, SHIPPED_WATCH, slug, type Action, type Effort } from "./backlog.js";
|
|
10
10
|
|
|
11
11
|
export type { Action, Effort } from "./backlog.js";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** Read per call, not captured at import: the config is a live view, so a
|
|
14
|
+
* profile change or a hand edit takes effect without a restart. */
|
|
15
|
+
const rules = () => config().rules;
|
|
14
16
|
|
|
15
17
|
/** Impact per unit of effort. Never returns NaN: one unrecognised effort or a
|
|
16
18
|
* non-numeric impact would otherwise make the sort comparator return NaN and
|
|
17
19
|
* leave the order of the entire queue undefined. backlog.ts coerces on load;
|
|
18
20
|
* this is the second line of defence for any other producer. */
|
|
19
|
-
export const score = (a: Action) =>
|
|
20
|
-
|
|
21
|
+
export const score = (a: Action) => {
|
|
22
|
+
const w = rules().effortWeight;
|
|
23
|
+
return (Number.isFinite(a.impact) ? a.impact : 0) / (w[a.effort] ?? w.M);
|
|
24
|
+
};
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
/** The decision window in months, from operatingRules.decisionWindowDays. */
|
|
27
|
+
const windowMonths = () => Math.max(1, config().operatingRules.decisionWindowDays / 30);
|
|
23
28
|
|
|
24
29
|
const pathOf = (url: string): string => "/" + url.split("/").slice(3).join("/");
|
|
25
30
|
const gid = (tag: string, page: string) => `gen-${tag}-${slug(page) || "root"}`;
|
|
@@ -45,7 +50,7 @@ function ctrGapActions(site: SiteCfg): Action[] {
|
|
|
45
50
|
byPage.set(page, [...(byPage.get(page) ?? []), g]);
|
|
46
51
|
}
|
|
47
52
|
return [...byPage.entries()].map(([page, qs]) => {
|
|
48
|
-
const missed = qs.reduce((s, q) => s + (q.impressions /
|
|
53
|
+
const missed = qs.reduce((s, q) => s + (q.impressions / windowMonths()) * (q.expected - q.ctr), 0);
|
|
49
54
|
const top = qs.slice(0, 3).map((q) => `“${q.keys[0]}” (${q.impressions.toLocaleString()} imps, ${(100 * q.ctr).toFixed(1)}% CTR at pos ${q.position.toFixed(1)})`);
|
|
50
55
|
const watching = watch[page];
|
|
51
56
|
return {
|
|
@@ -73,7 +78,8 @@ function ctrGapActions(site: SiteCfg): Action[] {
|
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
function strikingActions(site: SiteCfg): Action[] {
|
|
76
|
-
const
|
|
81
|
+
const r = rules().strikingDistance;
|
|
82
|
+
const rows = data.strikingDistance(site).slice(0, r.maxRows);
|
|
77
83
|
if (!rows.length) return [];
|
|
78
84
|
const watch = SHIPPED_WATCH();
|
|
79
85
|
const pageFor = topPageForQueries(site);
|
|
@@ -84,7 +90,7 @@ function strikingActions(site: SiteCfg): Action[] {
|
|
|
84
90
|
}
|
|
85
91
|
return [...byPage.entries()].map(([page, qs]) => {
|
|
86
92
|
const imps = qs.reduce((s, q) => s + q.impressions, 0);
|
|
87
|
-
const impact = Math.round((imps /
|
|
93
|
+
const impact = Math.round((imps / windowMonths()) * r.impactPerImpression);
|
|
88
94
|
const top = qs.slice(0, 3).map((q) => `“${q.keys[0]}” pos ${q.position.toFixed(1)} (${q.impressions.toLocaleString()} imps)`);
|
|
89
95
|
const watching = watch[page];
|
|
90
96
|
return {
|
|
@@ -127,17 +133,18 @@ function probeActions(site: SiteCfg): Action[] {
|
|
|
127
133
|
if (!p.soft_404.real_404) out.push(mk("soft404", "Fix soft 404s", "Unknown paths must return HTTP 404, not 200 — soft 404s waste crawl budget and dilute the index."));
|
|
128
134
|
if ((p.robots.ai_crawlers_blocked?.length ?? 0) > 0)
|
|
129
135
|
out.push(mk("ai-block", `Unblock AI crawlers (${p.robots.ai_crawlers_blocked!.join(", ")})`, "Remove the Disallow rules — blocked AI crawlers can't cite the site."));
|
|
130
|
-
if ((p.homepage.visible_text_bytes ?? 0) <
|
|
136
|
+
if ((p.homepage.visible_text_bytes ?? 0) < rules().probe.minVisibleTextBytes && p.homepage.status === 200)
|
|
131
137
|
out.push({ ...mk("ssr", "Server-render homepage content", "AI crawlers don't run JS — add static H1 + intro text to the shell."), impact: 15, effort: "M" });
|
|
132
138
|
return out;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
function engagementActions(site: SiteCfg): Action[] {
|
|
142
|
+
const e = rules().engagement;
|
|
136
143
|
const watch = SHIPPED_WATCH();
|
|
137
144
|
return data
|
|
138
145
|
.landingPages(site)
|
|
139
|
-
.filter((l) => l.sessions >=
|
|
140
|
-
.slice(0,
|
|
146
|
+
.filter((l) => l.sessions >= e.minSessions && l.engagement < e.maxEngagement && l.page !== "(not set)")
|
|
147
|
+
.slice(0, e.maxCards)
|
|
141
148
|
.map((l) => {
|
|
142
149
|
const url = `https://${site.gscHost}${l.page}`;
|
|
143
150
|
// GA landing paths carry no trailing slash; shippedWatch keys may have one
|
|
@@ -167,7 +174,8 @@ function engagementActions(site: SiteCfg): Action[] {
|
|
|
167
174
|
|
|
168
175
|
function trendActions(site: SiteCfg): Action[] {
|
|
169
176
|
const t = data.sessionTrend(site);
|
|
170
|
-
|
|
177
|
+
const d = rules().trafficDrop;
|
|
178
|
+
if (t.prior >= d.minPriorSessions && t.recent < t.prior * d.dropRatio) {
|
|
171
179
|
return [{
|
|
172
180
|
id: `gen-trend-${slug(site.host)}`,
|
|
173
181
|
host: site.host,
|
|
@@ -199,7 +207,7 @@ function metadataActions(site: SiteCfg): Action[] {
|
|
|
199
207
|
const audit = data.metadataAudit();
|
|
200
208
|
const findings = audit?.sites[site.host] ?? audit?.sites[site.gscHost] ?? [];
|
|
201
209
|
const watch = SHIPPED_WATCH();
|
|
202
|
-
return findings.slice(0,
|
|
210
|
+
return findings.slice(0, rules().metadata.maxFindings).map((f) => {
|
|
203
211
|
const pagePath = f.page.replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
204
212
|
const monthly = Math.max(1, Math.round(f.missed_clicks_window / 3)); // audit window is 90d
|
|
205
213
|
const watching = watch[f.page];
|
package/src/config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
12
13
|
|
|
13
14
|
import { execFileSync } from "node:child_process";
|
|
14
15
|
|
|
@@ -84,6 +85,86 @@ export interface Hooks {
|
|
|
84
85
|
afterStep: Record<string, string[]>;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
|
|
89
|
+
/** The numbers the action engine ranks by.
|
|
90
|
+
*
|
|
91
|
+
* These were literals scattered through actions.ts and data.ts, which meant
|
|
92
|
+
* disagreeing with any of them required editing the engine — and then living
|
|
93
|
+
* with the merge every time you upgraded. A practitioner's judgement about
|
|
94
|
+
* what counts as striking distance is exactly the thing they should be able
|
|
95
|
+
* to change without forking. */
|
|
96
|
+
export interface Rules {
|
|
97
|
+
/** Impact is divided by this to rank. Higher = the effort costs more. */
|
|
98
|
+
effortWeight: { S: number; M: number; L: number };
|
|
99
|
+
strikingDistance: {
|
|
100
|
+
minPosition: number;
|
|
101
|
+
maxPosition: number;
|
|
102
|
+
minImpressions: number;
|
|
103
|
+
/** How many query rows to consider per site. */
|
|
104
|
+
maxRows: number;
|
|
105
|
+
/** Monthly clicks assumed per impression if the query moves into the top 5. */
|
|
106
|
+
impactPerImpression: number;
|
|
107
|
+
};
|
|
108
|
+
ctrGap: {
|
|
109
|
+
minImpressions: number;
|
|
110
|
+
/** Flagged when actual CTR is below expected × this. */
|
|
111
|
+
belowExpectedRatio: number;
|
|
112
|
+
};
|
|
113
|
+
engagement: { minSessions: number; maxEngagement: number; maxCards: number };
|
|
114
|
+
trafficDrop: {
|
|
115
|
+
minPriorSessions: number;
|
|
116
|
+
/** Flagged when recent < prior × this. */
|
|
117
|
+
dropRatio: number;
|
|
118
|
+
};
|
|
119
|
+
probe: { minVisibleTextBytes: number };
|
|
120
|
+
metadata: { maxFindings: number };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The policy a human follows, as opposed to the numbers a rule sorts by.
|
|
124
|
+
*
|
|
125
|
+
* Read by the dashboard, the daily log and the agent skills, so changing the
|
|
126
|
+
* freeze here changes it everywhere it is stated — rather than in six places
|
|
127
|
+
* that drift apart. */
|
|
128
|
+
export interface OperatingRules {
|
|
129
|
+
titleFreezeDays: number;
|
|
130
|
+
metadataChangesPerWeek: number;
|
|
131
|
+
/** Decisions ride this window; the long history is for totals only. */
|
|
132
|
+
decisionWindowDays: number;
|
|
133
|
+
historyMonths: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A named bundle of the two above, plus module defaults and skills.
|
|
137
|
+
* Resolved from `profile` in the config. See docs/PROFILES.md. */
|
|
138
|
+
export interface Profile {
|
|
139
|
+
name: string;
|
|
140
|
+
description?: string;
|
|
141
|
+
version?: string;
|
|
142
|
+
operatingRules?: Partial<OperatingRules>;
|
|
143
|
+
rules?: DeepPartial<Rules>;
|
|
144
|
+
modules?: Record<string, ModuleCfg>;
|
|
145
|
+
/** Directory of SKILL.md folders, relative to the profile, copied by `n-seo init`. */
|
|
146
|
+
skills?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
|
|
150
|
+
|
|
151
|
+
export const DEFAULT_RULES: Rules = {
|
|
152
|
+
effortWeight: { S: 1, M: 2.5, L: 5 },
|
|
153
|
+
strikingDistance: { minPosition: 5, maxPosition: 15, minImpressions: 10, maxRows: 12, impactPerImpression: 0.06 },
|
|
154
|
+
ctrGap: { minImpressions: 30, belowExpectedRatio: 0.5 },
|
|
155
|
+
engagement: { minSessions: 30, maxEngagement: 0.25, maxCards: 3 },
|
|
156
|
+
trafficDrop: { minPriorSessions: 50, dropRatio: 0.75 },
|
|
157
|
+
probe: { minVisibleTextBytes: 500 },
|
|
158
|
+
metadata: { maxFindings: 5 },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const DEFAULT_OPERATING_RULES: OperatingRules = {
|
|
162
|
+
titleFreezeDays: 28,
|
|
163
|
+
metadataChangesPerWeek: 8,
|
|
164
|
+
decisionWindowDays: 90,
|
|
165
|
+
historyMonths: 16,
|
|
166
|
+
};
|
|
167
|
+
|
|
87
168
|
export interface Config {
|
|
88
169
|
name: string;
|
|
89
170
|
port: number;
|
|
@@ -102,6 +183,10 @@ export interface Config {
|
|
|
102
183
|
/** extra Search Console properties pulled into data/gsc/<slug>/ but not shown as sites */
|
|
103
184
|
gscExtraProperties: string[];
|
|
104
185
|
hooks: Hooks;
|
|
186
|
+
/** Built-in name ("default"), a path, or an installed package. */
|
|
187
|
+
profile?: string;
|
|
188
|
+
rules: Rules;
|
|
189
|
+
operatingRules: OperatingRules;
|
|
105
190
|
}
|
|
106
191
|
|
|
107
192
|
/** Per-module defaults, so a half-written block cannot make a step guess.
|
|
@@ -133,10 +218,82 @@ function readJson<T>(p: string): T {
|
|
|
133
218
|
}
|
|
134
219
|
|
|
135
220
|
/** Fill in defaults so the rest of the app can assume the shape. */
|
|
221
|
+
|
|
222
|
+
/** Where a profile spec resolves to, or null if it does not.
|
|
223
|
+
*
|
|
224
|
+
* Three forms, in the order they are tried:
|
|
225
|
+
* "default" a profile shipped with the engine
|
|
226
|
+
* "./x" or "/x" a directory in or near the instance
|
|
227
|
+
* "n-seo-profile-acme" an installed package
|
|
228
|
+
*
|
|
229
|
+
* Resolution is deliberately explicit rather than clever: a profile that
|
|
230
|
+
* cannot be found must be an error the owner sees, never a silent fallback
|
|
231
|
+
* to our defaults. Someone running a client's portfolio on their agency's
|
|
232
|
+
* method should not discover it quietly stopped applying. */
|
|
233
|
+
export function resolveProfileDir(spec: string): string | null {
|
|
234
|
+
const builtin = path.join(ROOT, "profiles", spec);
|
|
235
|
+
if (!spec.includes("/") && !spec.includes("\\") && fs.existsSync(path.join(builtin, PROFILE_FILE))) return builtin;
|
|
236
|
+
|
|
237
|
+
const asPath = path.isAbsolute(spec) ? spec : path.resolve(INSTANCE, spec);
|
|
238
|
+
if (fs.existsSync(path.join(asPath, PROFILE_FILE))) return asPath;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const req = createRequire(path.join(INSTANCE, "package.json"));
|
|
242
|
+
return path.dirname(req.resolve(`${spec}/${PROFILE_FILE}`));
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const PROFILE_FILE = "n-seo.profile.json";
|
|
249
|
+
|
|
250
|
+
/** The resolved profile, or null when none is configured. Throws when one is
|
|
251
|
+
* configured and cannot be found — see resolveProfileDir. */
|
|
252
|
+
export function loadProfile(spec: string | undefined): { profile: Profile; dir: string } | null {
|
|
253
|
+
if (!spec) return null;
|
|
254
|
+
const dir = resolveProfileDir(spec);
|
|
255
|
+
if (!dir) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`profile "${spec}" could not be resolved.\n` +
|
|
258
|
+
` tried: a profile shipped with the engine (${path.join(ROOT, "profiles", spec)}),\n` +
|
|
259
|
+
` a directory relative to the instance (${path.resolve(INSTANCE, spec)}),\n` +
|
|
260
|
+
` and an installed package.\n` +
|
|
261
|
+
` install it, fix the path, or remove "profile" from the config.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const profile = readJson<Profile>(path.join(dir, PROFILE_FILE));
|
|
265
|
+
return { profile: { ...profile, name: profile.name || spec }, dir };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Recursive merge for the plain-object config trees. Later wins; undefined
|
|
269
|
+
* never overwrites, so a profile may set one threshold without restating the
|
|
270
|
+
* block it lives in. */
|
|
271
|
+
function merge<T>(base: T, ...layers: unknown[]): T {
|
|
272
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
273
|
+
for (const layer of layers) {
|
|
274
|
+
if (!layer || typeof layer !== "object") continue;
|
|
275
|
+
for (const [k, v] of Object.entries(layer as Record<string, unknown>)) {
|
|
276
|
+
if (v === undefined) continue;
|
|
277
|
+
const cur = out[k];
|
|
278
|
+
out[k] = cur && typeof cur === "object" && !Array.isArray(cur) && v && typeof v === "object" && !Array.isArray(v)
|
|
279
|
+
? merge(cur, v)
|
|
280
|
+
: v;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out as T;
|
|
284
|
+
}
|
|
285
|
+
|
|
136
286
|
function normalize(raw: Partial<Config>): Config {
|
|
287
|
+
const loaded = loadProfile(raw.profile);
|
|
288
|
+
const fromProfile = loaded?.profile;
|
|
289
|
+
|
|
137
290
|
const modules: Record<string, ModuleCfg> = {};
|
|
138
|
-
for (const m of MODULE_INFO)
|
|
139
|
-
|
|
291
|
+
for (const m of MODULE_INFO) {
|
|
292
|
+
modules[m.key] = { enabled: false, ...(MODULE_DEFAULTS[m.key] ?? {}), ...(fromProfile?.modules?.[m.key] ?? {}), ...(raw.modules?.[m.key] ?? {}) };
|
|
293
|
+
}
|
|
294
|
+
for (const [k, v] of Object.entries({ ...(fromProfile?.modules ?? {}), ...(raw.modules ?? {}) })) {
|
|
295
|
+
if (!modules[k]) modules[k] = { ...v, enabled: !!v?.enabled };
|
|
296
|
+
}
|
|
140
297
|
const sites = (raw.sites ?? []).map((s) => ({
|
|
141
298
|
...s,
|
|
142
299
|
label: s.label || s.host,
|
|
@@ -160,6 +317,12 @@ function normalize(raw: Partial<Config>): Config {
|
|
|
160
317
|
modules,
|
|
161
318
|
gscExtraProperties: strList(raw.gscExtraProperties),
|
|
162
319
|
hooks: { beforeRun: strList(rawHooks.beforeRun), afterRun: strList(rawHooks.afterRun), afterStep },
|
|
320
|
+
profile: raw.profile,
|
|
321
|
+
// engine defaults <- profile <- this instance. The instance always wins,
|
|
322
|
+
// so a client can always see, and override, where they depart from the
|
|
323
|
+
// method they installed.
|
|
324
|
+
rules: merge(DEFAULT_RULES, fromProfile?.rules, raw.rules),
|
|
325
|
+
operatingRules: merge(DEFAULT_OPERATING_RULES, fromProfile?.operatingRules, raw.operatingRules),
|
|
163
326
|
};
|
|
164
327
|
}
|
|
165
328
|
|
package/src/data.ts
CHANGED
|
@@ -96,10 +96,14 @@ export function gscSummary(site: SiteCfg): GscSummary {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/** Position 5–15 queries with meaningful impressions — the cheapest ranking wins. */
|
|
99
|
-
export function strikingDistance(site: SiteCfg, minImpressions
|
|
100
|
-
// Recent window: decisions ride the
|
|
99
|
+
export function strikingDistance(site: SiteCfg, minImpressions?: number): GscRow[] {
|
|
100
|
+
// Recent window: decisions ride the trailing window, not 16-month history.
|
|
101
|
+
// The band is a rule, so a practitioner who thinks striking distance is
|
|
102
|
+
// 4-12 rather than 5-15 changes a config key instead of the engine.
|
|
103
|
+
const r = config().rules.strikingDistance;
|
|
104
|
+
const min = minImpressions ?? r.minImpressions;
|
|
101
105
|
return queries(site, true)
|
|
102
|
-
.filter((
|
|
106
|
+
.filter((q) => q.position >= r.minPosition && q.position <= r.maxPosition && q.impressions >= min)
|
|
103
107
|
.sort((a, b) => b.impressions - a.impressions);
|
|
104
108
|
}
|
|
105
109
|
|
|
@@ -114,12 +118,14 @@ export const EXPECTED_CTR: Record<number, number> = {
|
|
|
114
118
|
};
|
|
115
119
|
|
|
116
120
|
/** Ranking well but rarely clicked — title/snippet problems. */
|
|
117
|
-
export function ctrGaps(site: SiteCfg, minImpressions
|
|
121
|
+
export function ctrGaps(site: SiteCfg, minImpressions?: number): (GscRow & { expected: number })[] {
|
|
122
|
+
const rule = config().rules.ctrGap;
|
|
123
|
+
const min = minImpressions ?? rule.minImpressions;
|
|
118
124
|
// Recent window: a page fixed last week must stop being accused within 90 days.
|
|
119
125
|
return queries(site, true)
|
|
120
126
|
.flatMap((r) => {
|
|
121
127
|
const expected = EXPECTED_CTR[Math.round(r.position)];
|
|
122
|
-
return expected && r.impressions >=
|
|
128
|
+
return expected && r.impressions >= min && r.ctr < expected * rule.belowExpectedRatio
|
|
123
129
|
? [{ ...r, expected }]
|
|
124
130
|
: [];
|
|
125
131
|
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** `n-seo profile` — what method is running, and where you depart from it. */
|
|
2
|
+
import { profileReport, builtinProfiles } from "./profile-report.js";
|
|
3
|
+
|
|
4
|
+
const r = profileReport();
|
|
5
|
+
const built = builtinProfiles();
|
|
6
|
+
|
|
7
|
+
console.log(`profile ${r.name}${r.version ? ` ${r.version}` : ""}${r.spec ? "" : " (no profile set)"}`);
|
|
8
|
+
if (r.spec) console.log(`spec ${r.spec}`);
|
|
9
|
+
if (r.dir) console.log(`from ${r.dir}`);
|
|
10
|
+
if (r.description) {
|
|
11
|
+
console.log("");
|
|
12
|
+
for (const line of r.description.match(/.{1,72}(\s|$)/g) ?? []) console.log(` ${line.trim()}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log("");
|
|
16
|
+
if (r.departures.length === 0) {
|
|
17
|
+
console.log(r.spec
|
|
18
|
+
? "this instance changes nothing — it runs the profile as published"
|
|
19
|
+
: "this instance changes nothing — it runs the engine defaults");
|
|
20
|
+
} else {
|
|
21
|
+
console.log(`this instance overrides ${r.departures.length} value(s):\n`);
|
|
22
|
+
const w = Math.max(...r.departures.map((d) => d.key.length));
|
|
23
|
+
for (const d of r.departures) {
|
|
24
|
+
console.log(` ${d.key.padEnd(w)} ${JSON.stringify(d.inherited)} → ${JSON.stringify(d.instance)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (built.length) {
|
|
29
|
+
console.log(`\nshipped with this engine:`);
|
|
30
|
+
const w = Math.max(...built.map((b) => b.spec.length));
|
|
31
|
+
for (const b of built) console.log(` ${b.spec.padEnd(w)} ${b.name}`);
|
|
32
|
+
console.log(`\nset one with "profile": "<name>" in n-seo.config.json, or point it at`);
|
|
33
|
+
console.log(`a directory or an installed package. See docs/PROFILES.md.`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** What profile is active, and exactly where this instance departs from it.
|
|
2
|
+
*
|
|
3
|
+
* The second half is the point. Someone running a client's portfolio on an
|
|
4
|
+
* agency's method needs to see, at a glance, which numbers came from the
|
|
5
|
+
* agency and which they changed themselves — otherwise "we follow your
|
|
6
|
+
* model" is an unverifiable claim. */
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
CONFIG_PATH, EXAMPLE_CONFIG_PATH, PROFILE_FILE, DEFAULT_RULES, DEFAULT_OPERATING_RULES,
|
|
11
|
+
ROOT, config, loadProfile, type Profile,
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
|
|
14
|
+
export interface Departure {
|
|
15
|
+
/** Dotted path, e.g. "rules.strikingDistance.maxRows". */
|
|
16
|
+
key: string;
|
|
17
|
+
/** What the layer beneath says: the profile, or the engine defaults. */
|
|
18
|
+
inherited: unknown;
|
|
19
|
+
/** What this instance sets instead. */
|
|
20
|
+
instance: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProfileReport {
|
|
24
|
+
/** The `profile` value from the config, if any. */
|
|
25
|
+
spec?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
version?: string;
|
|
29
|
+
dir?: string;
|
|
30
|
+
departures: Departure[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Flatten a nested tree to dotted leaves, so two layers can be diffed. */
|
|
34
|
+
function flatten(v: unknown, prefix = "", out: Record<string, unknown> = {}) {
|
|
35
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
36
|
+
for (const [k, val] of Object.entries(v)) flatten(val, prefix ? `${prefix}.${k}` : k, out);
|
|
37
|
+
} else if (prefix) {
|
|
38
|
+
out[prefix] = v;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mergeDeep<T>(base: T, over: unknown): T {
|
|
44
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
45
|
+
if (over && typeof over === "object") {
|
|
46
|
+
for (const [k, v] of Object.entries(over as Record<string, unknown>)) {
|
|
47
|
+
if (v === undefined) continue;
|
|
48
|
+
const cur = out[k];
|
|
49
|
+
out[k] = cur && typeof cur === "object" && v && typeof v === "object" && !Array.isArray(v)
|
|
50
|
+
? mergeDeep(cur, v)
|
|
51
|
+
: v;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out as T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The config as written, not the normalized one. Normalization has already
|
|
58
|
+
* merged the layers, so it can no longer say who set what. */
|
|
59
|
+
function rawConfig(): Record<string, unknown> {
|
|
60
|
+
const p = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : EXAMPLE_CONFIG_PATH;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function profileReport(): ProfileReport {
|
|
69
|
+
const cfg = config();
|
|
70
|
+
const raw = rawConfig();
|
|
71
|
+
const loaded = cfg.profile ? loadProfile(cfg.profile) : null;
|
|
72
|
+
const profile = loaded?.profile ?? null;
|
|
73
|
+
|
|
74
|
+
const inherited = flatten({
|
|
75
|
+
rules: mergeDeep(DEFAULT_RULES, profile?.rules),
|
|
76
|
+
operatingRules: mergeDeep(DEFAULT_OPERATING_RULES, profile?.operatingRules),
|
|
77
|
+
});
|
|
78
|
+
const instanceSet = flatten({ rules: raw.rules, operatingRules: raw.operatingRules });
|
|
79
|
+
|
|
80
|
+
const departures = Object.entries(instanceSet)
|
|
81
|
+
.filter(([key, value]) => inherited[key] !== value)
|
|
82
|
+
.map(([key, value]) => ({ key, inherited: inherited[key], instance: value }))
|
|
83
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
spec: cfg.profile,
|
|
87
|
+
name: profile?.name ?? "engine defaults",
|
|
88
|
+
description: profile?.description,
|
|
89
|
+
version: profile?.version,
|
|
90
|
+
dir: loaded?.dir,
|
|
91
|
+
departures,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Profiles shipped with the engine. */
|
|
96
|
+
export function builtinProfiles(): { spec: string; name: string; description?: string }[] {
|
|
97
|
+
const dir = path.join(ROOT, "profiles");
|
|
98
|
+
if (!fs.existsSync(dir)) return [];
|
|
99
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
100
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, PROFILE_FILE)))
|
|
101
|
+
.map((e) => {
|
|
102
|
+
const p = JSON.parse(fs.readFileSync(path.join(dir, e.name, PROFILE_FILE), "utf8")) as Profile;
|
|
103
|
+
return { spec: e.name, name: p.name || e.name, description: p.description };
|
|
104
|
+
})
|
|
105
|
+
.sort((a, b) => a.spec.localeCompare(b.spec));
|
|
106
|
+
}
|
package/src/settings.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import type { FC } from "hono/jsx";
|
|
8
8
|
import { CONFIG_PATH, MODULE_INFO, config, saveConfig, USING_EXAMPLE_CONFIG, loadConfig, engineInfo } from "./config.js";
|
|
9
9
|
import * as data from "./data.js";
|
|
10
|
+
import { profileReport, builtinProfiles } from "./profile-report.js";
|
|
10
11
|
|
|
11
12
|
const expand = (p: string) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
12
13
|
|
|
@@ -32,6 +33,8 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
|
|
|
32
33
|
const lr = data.lastRun();
|
|
33
34
|
const eng = engineInfo();
|
|
34
35
|
const upd = data.updateCheck();
|
|
36
|
+
const prof = profileReport();
|
|
37
|
+
const builtins = builtinProfiles();
|
|
35
38
|
const hookCount = cfg.hooks.beforeRun.length + cfg.hooks.afterRun.length + Object.values(cfg.hooks.afterStep).reduce((n, l) => n + l.length, 0);
|
|
36
39
|
const str = (v: unknown) => (v == null ? "" : String(v));
|
|
37
40
|
return (
|
|
@@ -75,6 +78,34 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
|
|
|
75
78
|
</p>
|
|
76
79
|
)}
|
|
77
80
|
</div>
|
|
81
|
+
<div class="s-card">
|
|
82
|
+
<div class="s-title">
|
|
83
|
+
Profile: {prof.name}{prof.version ? ` ${prof.version}` : ""}
|
|
84
|
+
{prof.departures.length > 0 && <span class="chip"> {prof.departures.length} override{prof.departures.length === 1 ? "" : "s"}</span>}
|
|
85
|
+
</div>
|
|
86
|
+
{prof.description && <p class="sub">{prof.description}</p>}
|
|
87
|
+
{!prof.spec && (
|
|
88
|
+
<p class="sub">
|
|
89
|
+
No profile set, so the engine defaults apply. Shipped with this engine:{" "}
|
|
90
|
+
{builtins.map((b, i) => <>{i > 0 ? ", " : ""}<code>{b.spec}</code></>)}. Set one with{" "}
|
|
91
|
+
<code>"profile"</code> in the config, or point it at a directory or an installed package.
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
{prof.departures.length > 0 && (
|
|
95
|
+
<>
|
|
96
|
+
<p class="sub">Changed here, against the {prof.spec ? "profile" : "engine defaults"}:</p>
|
|
97
|
+
<ul class="prof-diff">
|
|
98
|
+
{prof.departures.map((d) => (
|
|
99
|
+
<li>
|
|
100
|
+
<span class="mono">{d.key}</span>
|
|
101
|
+
<span class="prof-was">{JSON.stringify(d.inherited)}</span>
|
|
102
|
+
<span class="prof-now">{JSON.stringify(d.instance)}</span>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
78
109
|
<div class="s-card">
|
|
79
110
|
<div class="s-title">Instance path</div>
|
|
80
111
|
<div class="mono">{eng.instance}</div>
|