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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n-seo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,45 @@
|
|
|
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. It sets no priorities: an out-of-the-box queue should be ordered by the data alone.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"operatingRules": {
|
|
6
|
+
"titleFreezeDays": 28,
|
|
7
|
+
"metadataChangesPerWeek": 8,
|
|
8
|
+
"decisionWindowDays": 90,
|
|
9
|
+
"historyMonths": 16
|
|
10
|
+
},
|
|
11
|
+
"rules": {
|
|
12
|
+
"effortWeight": {
|
|
13
|
+
"S": 1,
|
|
14
|
+
"M": 2.5,
|
|
15
|
+
"L": 5
|
|
16
|
+
},
|
|
17
|
+
"strikingDistance": {
|
|
18
|
+
"minPosition": 5,
|
|
19
|
+
"maxPosition": 15,
|
|
20
|
+
"minImpressions": 10,
|
|
21
|
+
"maxRows": 12,
|
|
22
|
+
"impactPerImpression": 0.06
|
|
23
|
+
},
|
|
24
|
+
"ctrGap": {
|
|
25
|
+
"minImpressions": 30,
|
|
26
|
+
"belowExpectedRatio": 0.5
|
|
27
|
+
},
|
|
28
|
+
"engagement": {
|
|
29
|
+
"minSessions": 30,
|
|
30
|
+
"maxEngagement": 0.25,
|
|
31
|
+
"maxCards": 3
|
|
32
|
+
},
|
|
33
|
+
"trafficDrop": {
|
|
34
|
+
"minPriorSessions": 50,
|
|
35
|
+
"dropRatio": 0.75
|
|
36
|
+
},
|
|
37
|
+
"probe": {
|
|
38
|
+
"minVisibleTextBytes": 500
|
|
39
|
+
},
|
|
40
|
+
"metadata": {
|
|
41
|
+
"maxFindings": 5
|
|
42
|
+
},
|
|
43
|
+
"priorities": []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -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,28 @@ 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"], .prio-notes { list-style: none; margin: 8px 0 0; padding: 8px 12px; background: var(--warn-soft); border-radius: 8px; }
|
|
586
|
+
.prio-notes li { font-size: 0.78rem; color: var(--warn); line-height: 1.5; }
|
|
587
|
+
|
|
588
|
+
.prof-princ { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; font-size: 0.78rem; color: var(--muted); }
|
|
589
|
+
.prof-princ .chip { font-size: 0.62rem; }
|
|
590
|
+
.princ { margin: 14px 0 0; }
|
|
591
|
+
.princ-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; padding: 12px 16px 16px; }
|
|
592
|
+
.princ-item { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
|
593
|
+
.princ-item.hard { border-color: var(--bad-soft); }
|
|
594
|
+
.princ-h { display: flex; align-items: baseline; gap: 7px; font-size: 0.86rem; margin-bottom: 5px; }
|
|
595
|
+
/* .chip ellipsizes at 34ch, and as a flex item it shrinks below that —
|
|
596
|
+
which turned "hard" into "h…". It is a two-word label; never shrink it. */
|
|
597
|
+
.princ-h .chip { flex: none; }
|
|
598
|
+
.princ-item p { margin: 0; font-size: 0.82rem; color: var(--muted); line-height: 1.55; }
|
|
599
|
+
|
|
600
|
+
.prof-diff { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
601
|
+
.prof-diff li { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 0.76rem; }
|
|
602
|
+
.prof-diff .mono { flex: 1 1 100%; color: var(--muted); }
|
|
603
|
+
.prof-was { font-family: var(--mono); color: var(--muted); text-decoration: line-through; }
|
|
604
|
+
.prof-now { font-family: var(--mono); font-weight: 700; color: var(--accent); }
|
|
605
|
+
|
|
606
|
+
.s-field textarea {
|
|
586
607
|
font: inherit; font-size: 0.84rem; color: var(--ink); background: var(--panel);
|
|
587
608
|
border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; width: 100%; font-weight: 400;
|
|
588
609
|
}
|
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, type Priority } 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];
|
|
@@ -242,12 +250,80 @@ export function actionsFor(site: SiteCfg): Action[] {
|
|
|
242
250
|
return [...generated, ...backlog].sort((a, b) => score(b) - score(a));
|
|
243
251
|
}
|
|
244
252
|
|
|
253
|
+
/** The page a card is about, if it names one. Rule-derived cards put
|
|
254
|
+
* "Page: <url>" first in their spec; hygiene and trend cards are about a
|
|
255
|
+
* whole site and have no path to match. */
|
|
256
|
+
function pageOf(a: Action): string | null {
|
|
257
|
+
const line = a.spec?.find((l) => l.startsWith("Page: "));
|
|
258
|
+
if (!line) return null;
|
|
259
|
+
const url = line.slice("Page: ".length).trim();
|
|
260
|
+
try {
|
|
261
|
+
return new URL(url).pathname;
|
|
262
|
+
} catch {
|
|
263
|
+
return url.startsWith("/") ? url : null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function matches(p: Priority, a: Action): boolean {
|
|
268
|
+
const w = p.when ?? {};
|
|
269
|
+
if (w.host && w.host !== a.host) return false;
|
|
270
|
+
if (w.tag && w.tag !== a.tag) return false;
|
|
271
|
+
if (w.kind && !a.kind?.toLowerCase().includes(w.kind.toLowerCase())) return false;
|
|
272
|
+
if (w.pathMatches) {
|
|
273
|
+
const path = pageOf(a);
|
|
274
|
+
if (path === null) return false;
|
|
275
|
+
try {
|
|
276
|
+
if (!new RegExp(w.pathMatches).test(path)) return false;
|
|
277
|
+
} catch {
|
|
278
|
+
// A bad pattern must not silently match everything, and must not take
|
|
279
|
+
// the queue down either.
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// A priority with no conditions would apply to the whole queue, which is
|
|
284
|
+
// never what someone means and is a very confusing way to find out.
|
|
285
|
+
return Object.keys(w).length > 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Apply the configured priorities: drop, reweight, and always explain.
|
|
289
|
+
*
|
|
290
|
+
* Order is preserved as a pipeline — later priorities see the impact earlier
|
|
291
|
+
* ones produced — so two boosts on the same card compound, which is what
|
|
292
|
+
* reading them top to bottom implies. */
|
|
293
|
+
export function prioritize(actions: Action[]): Action[] {
|
|
294
|
+
const priorities = (config().rules.priorities ?? []).filter((p) => p?.why && p?.when);
|
|
295
|
+
if (!priorities.length) return actions;
|
|
296
|
+
|
|
297
|
+
const out: Action[] = [];
|
|
298
|
+
for (const a of actions) {
|
|
299
|
+
let card = a;
|
|
300
|
+
let dropped = false;
|
|
301
|
+
const notes: string[] = [];
|
|
302
|
+
for (const p of priorities) {
|
|
303
|
+
if (!matches(p, card)) continue;
|
|
304
|
+
if (p.drop) { dropped = true; break; }
|
|
305
|
+
const by = Number(p.multiply);
|
|
306
|
+
if (!Number.isFinite(by) || by <= 0 || by === 1) continue;
|
|
307
|
+
const before = Number.isFinite(card.impact) ? card.impact : 0;
|
|
308
|
+
card = { ...card, impact: Math.max(0, Math.round(before * by)) };
|
|
309
|
+
notes.push(`${by > 1 ? "Raised" : "Lowered"} ${before} → ${card.impact}: ${p.why}`);
|
|
310
|
+
}
|
|
311
|
+
if (dropped) continue;
|
|
312
|
+
// The card carries its own adjustment. Ordering you cannot see the reason
|
|
313
|
+
// for is ordering you cannot argue with.
|
|
314
|
+
if (notes.length) card = { ...card, spec: [...(card.spec ?? []), ...notes], priorityNotes: notes };
|
|
315
|
+
out.push(card);
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
245
320
|
export function allActions(): Action[] {
|
|
246
321
|
const sites = SITES();
|
|
247
322
|
const known = new Set(sites.map((s) => s.host));
|
|
248
323
|
// Backlog items for hosts no longer in the config still deserve a place.
|
|
249
324
|
const orphans = BACKLOG().filter((b) => !known.has(b.host));
|
|
250
|
-
|
|
325
|
+
const all = [...sites.flatMap((s) => actionsFor(s)), ...orphans];
|
|
326
|
+
return prioritize(all).sort((a, b) => score(b) - score(a));
|
|
251
327
|
}
|
|
252
328
|
|
|
253
329
|
export function actionById(id: string): Action | undefined {
|
package/src/backlog.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface Action {
|
|
|
39
39
|
tag: string;
|
|
40
40
|
/** set when a fix already shipped and the data is being watched */
|
|
41
41
|
watching?: string;
|
|
42
|
+
/** why a configured priority raised or lowered this card's impact */
|
|
43
|
+
priorityNotes?: string[];
|
|
42
44
|
/** rule = derived from data each request; backlog = curated in config/backlog.json */
|
|
43
45
|
source?: "rule" | "backlog" | "proposal";
|
|
44
46
|
}
|
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,147 @@ 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
|
+
/** Ordering adjustments. Applied after the rules run, before the sort. */
|
|
122
|
+
priorities: Priority[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The policy a human follows, as opposed to the numbers a rule sorts by.
|
|
126
|
+
*
|
|
127
|
+
* Read by the dashboard, the daily log and the agent skills, so changing the
|
|
128
|
+
* freeze here changes it everywhere it is stated — rather than in six places
|
|
129
|
+
* that drift apart. */
|
|
130
|
+
export interface OperatingRules {
|
|
131
|
+
titleFreezeDays: number;
|
|
132
|
+
metadataChangesPerWeek: number;
|
|
133
|
+
/** Decisions ride this window; the long history is for totals only. */
|
|
134
|
+
decisionWindowDays: number;
|
|
135
|
+
historyMonths: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A named bundle of the two above, plus module defaults and skills.
|
|
139
|
+
* Resolved from `profile` in the config. See docs/PROFILES.md. */
|
|
140
|
+
export interface Profile {
|
|
141
|
+
name: string;
|
|
142
|
+
description?: string;
|
|
143
|
+
version?: string;
|
|
144
|
+
operatingRules?: Partial<OperatingRules>;
|
|
145
|
+
rules?: DeepPartial<Rules>;
|
|
146
|
+
modules?: Record<string, ModuleCfg>;
|
|
147
|
+
/** Directory of SKILL.md folders, relative to the profile, copied by `n-seo init`. */
|
|
148
|
+
skills?: string;
|
|
149
|
+
/** The parts of a method that are not a number.
|
|
150
|
+
*
|
|
151
|
+
* Building the first real profile is what surfaced this. A practitioner's
|
|
152
|
+
* method turned out to be barely distinguishable from ours in thresholds —
|
|
153
|
+
* the striking-distance floor never binds once rows are sorted by
|
|
154
|
+
* impressions — and almost entirely distinguishable in judgement: what to
|
|
155
|
+
* optimise for, what needs a human's approval, what never to automate.
|
|
156
|
+
* None of that is expressible as a threshold, and a profile that cannot
|
|
157
|
+
* carry it is not a method, just a settings file.
|
|
158
|
+
*
|
|
159
|
+
* Shown on the dashboard and written into the instance's CLAUDE.md, so the
|
|
160
|
+
* owner and their agent read the same rules. */
|
|
161
|
+
principles?: ProfilePrinciple[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ProfilePrinciple {
|
|
165
|
+
title: string;
|
|
166
|
+
body: string;
|
|
167
|
+
/** `hard` rules are constraints an agent must not cross; `guide` is
|
|
168
|
+
* judgement it should apply. The dashboard shows them apart because they
|
|
169
|
+
* are different kinds of claim. */
|
|
170
|
+
kind?: "hard" | "guide";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
|
|
174
|
+
|
|
175
|
+
export const DEFAULT_RULES: Rules = {
|
|
176
|
+
effortWeight: { S: 1, M: 2.5, L: 5 },
|
|
177
|
+
strikingDistance: { minPosition: 5, maxPosition: 15, minImpressions: 10, maxRows: 12, impactPerImpression: 0.06 },
|
|
178
|
+
ctrGap: { minImpressions: 30, belowExpectedRatio: 0.5 },
|
|
179
|
+
engagement: { minSessions: 30, maxEngagement: 0.25, maxCards: 3 },
|
|
180
|
+
trafficDrop: { minPriorSessions: 50, dropRatio: 0.75 },
|
|
181
|
+
probe: { minVisibleTextBytes: 500 },
|
|
182
|
+
metadata: { maxFindings: 5 },
|
|
183
|
+
priorities: [],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const DEFAULT_OPERATING_RULES: OperatingRules = {
|
|
187
|
+
titleFreezeDays: 28,
|
|
188
|
+
metadataChangesPerWeek: 8,
|
|
189
|
+
decisionWindowDays: 90,
|
|
190
|
+
historyMonths: 16,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** A declarative adjustment to the queue's ordering.
|
|
194
|
+
*
|
|
195
|
+
* This is n-seo's answer to "custom rules", and it is deliberately not a
|
|
196
|
+
* plugin API. Building the first real profile showed what a practitioner
|
|
197
|
+
* actually wants to express: "prefer pages that lead somewhere", "stop
|
|
198
|
+
* showing me /legal". That is matching and weighting, not arbitrary code —
|
|
199
|
+
* and a profile that ships code means installing someone's method runs their
|
|
200
|
+
* program next to your Search Console credentials.
|
|
201
|
+
*
|
|
202
|
+
* Two hard constraints on the design:
|
|
203
|
+
*
|
|
204
|
+
* - A priority may reorder or hide, never invent. It cannot fabricate a
|
|
205
|
+
* card, so it cannot manufacture evidence.
|
|
206
|
+
* - Every card it touches says so, and says why. A queue that silently
|
|
207
|
+
* reorders itself is a queue whose order you cannot trust, and the whole
|
|
208
|
+
* product rests on showing its working. */
|
|
209
|
+
export interface Priority {
|
|
210
|
+
/** Shown on every card this touches. Required: an unexplained boost is the
|
|
211
|
+
* one thing this must never be. */
|
|
212
|
+
why: string;
|
|
213
|
+
when: {
|
|
214
|
+
/** Exact host, e.g. "example.com". */
|
|
215
|
+
host?: string;
|
|
216
|
+
/** Regular expression against the URL path of the page the card is about. */
|
|
217
|
+
pathMatches?: string;
|
|
218
|
+
/** Rule tag: metadata, striking, ctr-gap, engagement, hygiene, trend. */
|
|
219
|
+
tag?: string;
|
|
220
|
+
/** Substring of the card's `kind`, case-insensitive. */
|
|
221
|
+
kind?: string;
|
|
222
|
+
};
|
|
223
|
+
/** Multiply the impact used for ordering. 1.5 = half again; 0.5 = half. */
|
|
224
|
+
multiply?: number;
|
|
225
|
+
/** Remove the card entirely. For pages you have decided not to work on. */
|
|
226
|
+
drop?: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
87
229
|
export interface Config {
|
|
88
230
|
name: string;
|
|
89
231
|
port: number;
|
|
@@ -102,6 +244,10 @@ export interface Config {
|
|
|
102
244
|
/** extra Search Console properties pulled into data/gsc/<slug>/ but not shown as sites */
|
|
103
245
|
gscExtraProperties: string[];
|
|
104
246
|
hooks: Hooks;
|
|
247
|
+
/** Built-in name ("default"), a path, or an installed package. */
|
|
248
|
+
profile?: string;
|
|
249
|
+
rules: Rules;
|
|
250
|
+
operatingRules: OperatingRules;
|
|
105
251
|
}
|
|
106
252
|
|
|
107
253
|
/** Per-module defaults, so a half-written block cannot make a step guess.
|
|
@@ -133,10 +279,82 @@ function readJson<T>(p: string): T {
|
|
|
133
279
|
}
|
|
134
280
|
|
|
135
281
|
/** Fill in defaults so the rest of the app can assume the shape. */
|
|
282
|
+
|
|
283
|
+
/** Where a profile spec resolves to, or null if it does not.
|
|
284
|
+
*
|
|
285
|
+
* Three forms, in the order they are tried:
|
|
286
|
+
* "default" a profile shipped with the engine
|
|
287
|
+
* "./x" or "/x" a directory in or near the instance
|
|
288
|
+
* "n-seo-profile-acme" an installed package
|
|
289
|
+
*
|
|
290
|
+
* Resolution is deliberately explicit rather than clever: a profile that
|
|
291
|
+
* cannot be found must be an error the owner sees, never a silent fallback
|
|
292
|
+
* to our defaults. Someone running a client's portfolio on their agency's
|
|
293
|
+
* method should not discover it quietly stopped applying. */
|
|
294
|
+
export function resolveProfileDir(spec: string): string | null {
|
|
295
|
+
const builtin = path.join(ROOT, "profiles", spec);
|
|
296
|
+
if (!spec.includes("/") && !spec.includes("\\") && fs.existsSync(path.join(builtin, PROFILE_FILE))) return builtin;
|
|
297
|
+
|
|
298
|
+
const asPath = path.isAbsolute(spec) ? spec : path.resolve(INSTANCE, spec);
|
|
299
|
+
if (fs.existsSync(path.join(asPath, PROFILE_FILE))) return asPath;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const req = createRequire(path.join(INSTANCE, "package.json"));
|
|
303
|
+
return path.dirname(req.resolve(`${spec}/${PROFILE_FILE}`));
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const PROFILE_FILE = "n-seo.profile.json";
|
|
310
|
+
|
|
311
|
+
/** The resolved profile, or null when none is configured. Throws when one is
|
|
312
|
+
* configured and cannot be found — see resolveProfileDir. */
|
|
313
|
+
export function loadProfile(spec: string | undefined): { profile: Profile; dir: string } | null {
|
|
314
|
+
if (!spec) return null;
|
|
315
|
+
const dir = resolveProfileDir(spec);
|
|
316
|
+
if (!dir) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`profile "${spec}" could not be resolved.\n` +
|
|
319
|
+
` tried: a profile shipped with the engine (${path.join(ROOT, "profiles", spec)}),\n` +
|
|
320
|
+
` a directory relative to the instance (${path.resolve(INSTANCE, spec)}),\n` +
|
|
321
|
+
` and an installed package.\n` +
|
|
322
|
+
` install it, fix the path, or remove "profile" from the config.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const profile = readJson<Profile>(path.join(dir, PROFILE_FILE));
|
|
326
|
+
return { profile: { ...profile, name: profile.name || spec }, dir };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Recursive merge for the plain-object config trees. Later wins; undefined
|
|
330
|
+
* never overwrites, so a profile may set one threshold without restating the
|
|
331
|
+
* block it lives in. */
|
|
332
|
+
function merge<T>(base: T, ...layers: unknown[]): T {
|
|
333
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
334
|
+
for (const layer of layers) {
|
|
335
|
+
if (!layer || typeof layer !== "object") continue;
|
|
336
|
+
for (const [k, v] of Object.entries(layer as Record<string, unknown>)) {
|
|
337
|
+
if (v === undefined) continue;
|
|
338
|
+
const cur = out[k];
|
|
339
|
+
out[k] = cur && typeof cur === "object" && !Array.isArray(cur) && v && typeof v === "object" && !Array.isArray(v)
|
|
340
|
+
? merge(cur, v)
|
|
341
|
+
: v;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out as T;
|
|
345
|
+
}
|
|
346
|
+
|
|
136
347
|
function normalize(raw: Partial<Config>): Config {
|
|
348
|
+
const loaded = loadProfile(raw.profile);
|
|
349
|
+
const fromProfile = loaded?.profile;
|
|
350
|
+
|
|
137
351
|
const modules: Record<string, ModuleCfg> = {};
|
|
138
|
-
for (const m of MODULE_INFO)
|
|
139
|
-
|
|
352
|
+
for (const m of MODULE_INFO) {
|
|
353
|
+
modules[m.key] = { enabled: false, ...(MODULE_DEFAULTS[m.key] ?? {}), ...(fromProfile?.modules?.[m.key] ?? {}), ...(raw.modules?.[m.key] ?? {}) };
|
|
354
|
+
}
|
|
355
|
+
for (const [k, v] of Object.entries({ ...(fromProfile?.modules ?? {}), ...(raw.modules ?? {}) })) {
|
|
356
|
+
if (!modules[k]) modules[k] = { ...v, enabled: !!v?.enabled };
|
|
357
|
+
}
|
|
140
358
|
const sites = (raw.sites ?? []).map((s) => ({
|
|
141
359
|
...s,
|
|
142
360
|
label: s.label || s.host,
|
|
@@ -160,6 +378,15 @@ function normalize(raw: Partial<Config>): Config {
|
|
|
160
378
|
modules,
|
|
161
379
|
gscExtraProperties: strList(raw.gscExtraProperties),
|
|
162
380
|
hooks: { beforeRun: strList(rawHooks.beforeRun), afterRun: strList(rawHooks.afterRun), afterStep },
|
|
381
|
+
profile: raw.profile,
|
|
382
|
+
// engine defaults <- profile <- this instance. The instance always wins,
|
|
383
|
+
// so a client can always see, and override, where they depart from the
|
|
384
|
+
// method they installed.
|
|
385
|
+
// `merge` replaces arrays rather than concatenating, so an instance that
|
|
386
|
+
// sets `priorities` replaces the profile's outright. Appending would make
|
|
387
|
+
// a profile's priority impossible to remove without forking it.
|
|
388
|
+
rules: merge(DEFAULT_RULES, fromProfile?.rules, raw.rules),
|
|
389
|
+
operatingRules: merge(DEFAULT_OPERATING_RULES, fromProfile?.operatingRules, raw.operatingRules),
|
|
163
390
|
};
|
|
164
391
|
}
|
|
165
392
|
|
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
|
+
}
|