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
|
@@ -0,0 +1,108 @@
|
|
|
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, type ProfilePrinciple,
|
|
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
|
+
principles: ProfilePrinciple[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Flatten a nested tree to dotted leaves, so two layers can be diffed. */
|
|
35
|
+
function flatten(v: unknown, prefix = "", out: Record<string, unknown> = {}) {
|
|
36
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
37
|
+
for (const [k, val] of Object.entries(v)) flatten(val, prefix ? `${prefix}.${k}` : k, out);
|
|
38
|
+
} else if (prefix) {
|
|
39
|
+
out[prefix] = v;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mergeDeep<T>(base: T, over: unknown): T {
|
|
45
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
46
|
+
if (over && typeof over === "object") {
|
|
47
|
+
for (const [k, v] of Object.entries(over as Record<string, unknown>)) {
|
|
48
|
+
if (v === undefined) continue;
|
|
49
|
+
const cur = out[k];
|
|
50
|
+
out[k] = cur && typeof cur === "object" && v && typeof v === "object" && !Array.isArray(v)
|
|
51
|
+
? mergeDeep(cur, v)
|
|
52
|
+
: v;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out as T;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The config as written, not the normalized one. Normalization has already
|
|
59
|
+
* merged the layers, so it can no longer say who set what. */
|
|
60
|
+
function rawConfig(): Record<string, unknown> {
|
|
61
|
+
const p = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : EXAMPLE_CONFIG_PATH;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function profileReport(): ProfileReport {
|
|
70
|
+
const cfg = config();
|
|
71
|
+
const raw = rawConfig();
|
|
72
|
+
const loaded = cfg.profile ? loadProfile(cfg.profile) : null;
|
|
73
|
+
const profile = loaded?.profile ?? null;
|
|
74
|
+
|
|
75
|
+
const inherited = flatten({
|
|
76
|
+
rules: mergeDeep(DEFAULT_RULES, profile?.rules),
|
|
77
|
+
operatingRules: mergeDeep(DEFAULT_OPERATING_RULES, profile?.operatingRules),
|
|
78
|
+
});
|
|
79
|
+
const instanceSet = flatten({ rules: raw.rules, operatingRules: raw.operatingRules });
|
|
80
|
+
|
|
81
|
+
const departures = Object.entries(instanceSet)
|
|
82
|
+
.filter(([key, value]) => inherited[key] !== value)
|
|
83
|
+
.map(([key, value]) => ({ key, inherited: inherited[key], instance: value }))
|
|
84
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
spec: cfg.profile,
|
|
88
|
+
name: profile?.name ?? "engine defaults",
|
|
89
|
+
description: profile?.description,
|
|
90
|
+
version: profile?.version,
|
|
91
|
+
dir: loaded?.dir,
|
|
92
|
+
departures,
|
|
93
|
+
principles: (profile?.principles ?? []).filter((p) => p?.title && p?.body),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Profiles shipped with the engine. */
|
|
98
|
+
export function builtinProfiles(): { spec: string; name: string; description?: string }[] {
|
|
99
|
+
const dir = path.join(ROOT, "profiles");
|
|
100
|
+
if (!fs.existsSync(dir)) return [];
|
|
101
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
102
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, PROFILE_FILE)))
|
|
103
|
+
.map((e) => {
|
|
104
|
+
const p = JSON.parse(fs.readFileSync(path.join(dir, e.name, PROFILE_FILE), "utf8")) as Profile;
|
|
105
|
+
return { spec: e.name, name: p.name || e.name, description: p.description };
|
|
106
|
+
})
|
|
107
|
+
.sort((a, b) => a.spec.localeCompare(b.spec));
|
|
108
|
+
}
|
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,44 @@ 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.principles.length > 0 && (
|
|
95
|
+
<>
|
|
96
|
+
<p class="sub">{prof.principles.length} principle{prof.principles.length === 1 ? "" : "s"}, shown in full on <a href="/actions">Actions</a>.</p>
|
|
97
|
+
<ul class="prof-princ">
|
|
98
|
+
{prof.principles.map((pr) => (
|
|
99
|
+
<li><span class={`chip ${pr.kind === "hard" ? "bad" : ""}`}>{pr.kind === "hard" ? "hard" : "guide"}</span> {pr.title}</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
{prof.departures.length > 0 && (
|
|
105
|
+
<>
|
|
106
|
+
<p class="sub">Changed here, against the {prof.spec ? "profile" : "engine defaults"}:</p>
|
|
107
|
+
<ul class="prof-diff">
|
|
108
|
+
{prof.departures.map((d) => (
|
|
109
|
+
<li>
|
|
110
|
+
<span class="mono">{d.key}</span>
|
|
111
|
+
<span class="prof-was">{JSON.stringify(d.inherited)}</span>
|
|
112
|
+
<span class="prof-now">{JSON.stringify(d.instance)}</span>
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ul>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
78
119
|
<div class="s-card">
|
|
79
120
|
<div class="s-title">Instance path</div>
|
|
80
121
|
<div class="mono">{eng.instance}</div>
|
package/src/views.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { FC, PropsWithChildren } from "hono/jsx";
|
|
3
3
|
import { SITES, ENGINE_VERSION, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
|
|
4
4
|
import * as data from "./data.js";
|
|
5
|
+
import { profileReport } from "./profile-report.js";
|
|
5
6
|
import { actionsFor, allActions, type Action } from "./actions.js";
|
|
6
7
|
import { insights as loadInsights } from "./insights.js";
|
|
7
8
|
|
|
@@ -140,7 +141,18 @@ export const ActionModal: FC<{ a: Action; id: string }> = ({ a, id }) => (
|
|
|
140
141
|
<span class={`chip effort e-${a.effort}`}>{effortLabel(a.effort, true)}</span>
|
|
141
142
|
<span class="chip tag">{a.tag}</span>
|
|
142
143
|
<span class="chip">{a.source === "backlog" ? "curated" : a.source === "proposal" ? "proposed" : "data-derived"}</span>
|
|
144
|
+
{a.priorityNotes?.length ? (
|
|
145
|
+
<span class="chip warn" title="a configured priority changed where this sits in the queue">reweighted</span>
|
|
146
|
+
) : null}
|
|
143
147
|
</div>
|
|
148
|
+
{a.priorityNotes?.length ? (
|
|
149
|
+
// The impact number decides the order, so a card whose number was
|
|
150
|
+
// adjusted has to say so on its face. Ordering you cannot see the
|
|
151
|
+
// reason for is ordering you cannot argue with.
|
|
152
|
+
<ul class="prio-notes">
|
|
153
|
+
{a.priorityNotes.map((n) => <li>{n}</li>)}
|
|
154
|
+
</ul>
|
|
155
|
+
) : null}
|
|
144
156
|
{a.watching && <p class="action-watching">⏳ {a.watching}</p>}
|
|
145
157
|
<h4>Why (the data)</h4>
|
|
146
158
|
<p>{a.why}</p>
|
|
@@ -265,6 +277,9 @@ const ProposalCard: FC<{ p: data.ScanOutput["proposals"][number]; index: number
|
|
|
265
277
|
|
|
266
278
|
export const ActionsPage: FC<{ flash?: string }> = ({ flash }) => {
|
|
267
279
|
const scan = data.opportunityScan();
|
|
280
|
+
const prof = profileReport();
|
|
281
|
+
const principles = prof.principles;
|
|
282
|
+
const profileName = prof.name;
|
|
268
283
|
return (
|
|
269
284
|
<>
|
|
270
285
|
<div class="strip-head">
|
|
@@ -274,6 +289,21 @@ export const ActionsPage: FC<{ flash?: string }> = ({ flash }) => {
|
|
|
274
289
|
<p class="sub">Data-derived rules (90-day window) + your curated queue (<code>config/backlog.json</code>). Click any item for the full spec. Impact numbers rank the queue — they are estimates for ordering, not forecasts.</p>
|
|
275
290
|
{flash && <p class="flash">{flash}</p>}
|
|
276
291
|
|
|
292
|
+
{principles.length > 0 && (
|
|
293
|
+
<details class="wb-fold princ">
|
|
294
|
+
<summary>
|
|
295
|
+
Operating principles <small>— from the {profileName} profile; {principles.filter((p) => p.kind === "hard").length} are constraints, not advice</small>
|
|
296
|
+
</summary>
|
|
297
|
+
<div class="princ-list">
|
|
298
|
+
{principles.map((p) => (
|
|
299
|
+
<div class={`princ-item ${p.kind === "hard" ? "hard" : ""}`}>
|
|
300
|
+
<div class="princ-h"><span class={`chip ${p.kind === "hard" ? "bad" : ""}`}>{p.kind === "hard" ? "hard" : "guide"}</span> <b>{p.title}</b></div>
|
|
301
|
+
<p>{p.body}</p>
|
|
302
|
+
</div>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
</details>
|
|
306
|
+
)}
|
|
277
307
|
{scan && (scan.proposals.length > 0 || scan.verdicts.length > 0) && (
|
|
278
308
|
<div class="scan-panel" id="proposed">
|
|
279
309
|
<h2>Proposed by the opportunity scan <small>· {scan.generated} — rising queries no queue item covers, turned into candidate moves. Accepting copies a proposal into your queue; nothing self-modifies it.</small></h2>
|