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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.4.1",
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",
@@ -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"], .s-field textarea {
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
- const EFFORT_WEIGHT: Record<Effort, number> = { S: 1, M: 2.5, L: 5 };
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
- (Number.isFinite(a.impact) ? a.impact : 0) / (EFFORT_WEIGHT[a.effort] ?? EFFORT_WEIGHT.M);
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
- const WINDOW_MONTHS = 3; // decision window = trailing 90 days
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 / WINDOW_MONTHS) * (q.expected - q.ctr), 0);
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 rows = data.strikingDistance(site).slice(0, 12);
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 / WINDOW_MONTHS) * 0.06);
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) < 500 && p.homepage.status === 200)
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 >= 30 && l.engagement < 0.25 && l.page !== "(not set)")
140
- .slice(0, 3)
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
- if (t.prior >= 50 && t.recent < t.prior * 0.75) {
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, 5).map((f) => {
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
- return [...sites.flatMap((s) => actionsFor(s)), ...orphans].sort((a, b) => score(b) - score(a));
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) modules[m.key] = { enabled: false, ...(MODULE_DEFAULTS[m.key] ?? {}), ...(raw.modules?.[m.key] ?? {}) };
139
- for (const [k, v] of Object.entries(raw.modules ?? {})) if (!modules[k]) modules[k] = { ...v, enabled: !!v?.enabled };
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 = 10): GscRow[] {
100
- // Recent window: decisions ride the last 90 days, not 16-month history.
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((r) => r.position >= 5 && r.position <= 15 && r.impressions >= minImpressions)
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 = 30): (GscRow & { expected: number })[] {
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 >= minImpressions && r.ctr < expected * 0.5
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
+ }