n-seo 0.1.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/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/n-seo.mjs +310 -0
- package/docs/ADDING-A-SITE.md +82 -0
- package/docs/ARCHITECTURE.md +213 -0
- package/docs/DEPLOY.md +300 -0
- package/docs/FAQ.md +93 -0
- package/docs/INSTANCE.md +365 -0
- package/docs/MCP.md +104 -0
- package/docs/OPERATING-RULES.md +106 -0
- package/docs/PLAYBOOK.md +122 -0
- package/docs/PRD.md +249 -0
- package/docs/RELEASING.md +189 -0
- package/docs/SCHEDULING.md +104 -0
- package/docs/SETUP-GOOGLE.md +215 -0
- package/docs/examples/campaign.json +59 -0
- package/docs/examples/draft.md +43 -0
- package/docs/screenshots/overview.png +0 -0
- package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
- package/ingest/analyze_ga4.py +79 -0
- package/ingest/analyze_gsc.py +136 -0
- package/ingest/analyze_metadata.py +158 -0
- package/ingest/analyze_trends.py +145 -0
- package/ingest/google_auth.py +238 -0
- package/ingest/http_util.py +87 -0
- package/ingest/pull_ga4.py +107 -0
- package/ingest/pull_gsc.py +111 -0
- package/ingest/pull_index_status.py +179 -0
- package/ingest/pull_timeseries.py +130 -0
- package/ingest/seo_config.py +213 -0
- package/n-seo.config.example.json +110 -0
- package/ops/__pycache__/daily.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
- package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
- package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
- package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
- package/ops/__pycache__/llm.cpython-313.pyc +0 -0
- package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
- package/ops/__pycache__/publish.cpython-313.pyc +0 -0
- package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
- package/ops/daily.py +250 -0
- package/ops/daily_diff.py +151 -0
- package/ops/demo_data.py +529 -0
- package/ops/doctor.py +266 -0
- package/ops/export_static.py +125 -0
- package/ops/hn_digest.py +169 -0
- package/ops/indexnow.py +107 -0
- package/ops/install-launchd.sh +76 -0
- package/ops/llm.py +139 -0
- package/ops/mcp-smoke-stdio.mjs +61 -0
- package/ops/opportunity_scan.py +185 -0
- package/ops/publish.py +158 -0
- package/ops/reddit_digest.py +168 -0
- package/ops/templates/n-seo-daily.service +11 -0
- package/ops/templates/n-seo-daily.timer +11 -0
- package/ops/templates/n-seo-dashboard.service +15 -0
- package/ops/templates/n-seo.cron +3 -0
- package/ops/templates/n-seo.daily.plist +29 -0
- package/ops/templates/n-seo.dashboard.plist +22 -0
- package/package.json +77 -0
- package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
- package/probes/site_probe.py +201 -0
- package/public/favicon.svg +6 -0
- package/public/styles.css +632 -0
- package/src/actions.ts +255 -0
- package/src/backlog.ts +197 -0
- package/src/config.ts +220 -0
- package/src/data.ts +895 -0
- package/src/insights.ts +22 -0
- package/src/mcp-stdio.ts +21 -0
- package/src/mcp.ts +490 -0
- package/src/server.tsx +260 -0
- package/src/settings.tsx +329 -0
- package/src/views.tsx +1487 -0
- package/tsconfig.json +15 -0
package/src/actions.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/** The action engine: turns snapshot data into a ranked, concrete to-do queue.
|
|
2
|
+
*
|
|
3
|
+
* Rule-derived actions are recomputed on every call from the 90-day window,
|
|
4
|
+
* then merged with the curated queue in config/backlog.json. Pages listed in
|
|
5
|
+
* the backlog's `shippedWatch` map turn their rule-derived cards into
|
|
6
|
+
* "watching" entries — the fix shipped; the data decides what happens next. */
|
|
7
|
+
import { SITES, type SiteCfg } from "./config.js";
|
|
8
|
+
import * as data from "./data.js";
|
|
9
|
+
import { BACKLOG, SHIPPED_WATCH, slug, type Action, type Effort } from "./backlog.js";
|
|
10
|
+
|
|
11
|
+
export type { Action, Effort } from "./backlog.js";
|
|
12
|
+
|
|
13
|
+
const EFFORT_WEIGHT: Record<Effort, number> = { S: 1, M: 2.5, L: 5 };
|
|
14
|
+
|
|
15
|
+
/** Impact per unit of effort. Never returns NaN: one unrecognised effort or a
|
|
16
|
+
* non-numeric impact would otherwise make the sort comparator return NaN and
|
|
17
|
+
* leave the order of the entire queue undefined. backlog.ts coerces on load;
|
|
18
|
+
* 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
|
+
|
|
22
|
+
const WINDOW_MONTHS = 3; // decision window = trailing 90 days
|
|
23
|
+
|
|
24
|
+
const pathOf = (url: string): string => "/" + url.split("/").slice(3).join("/");
|
|
25
|
+
const gid = (tag: string, page: string) => `gen-${tag}-${slug(page) || "root"}`;
|
|
26
|
+
|
|
27
|
+
/** query -> the page that ranks for it (top impressions), host-filtered */
|
|
28
|
+
function topPageForQueries(site: SiteCfg): Map<string, string> {
|
|
29
|
+
const best = new Map<string, { page: string; imps: number }>();
|
|
30
|
+
for (const r of data.rawQueryPage(site, true)) {
|
|
31
|
+
const cur = best.get(r.keys[0]);
|
|
32
|
+
if (!cur || r.impressions > cur.imps) best.set(r.keys[0], { page: r.keys[1], imps: r.impressions });
|
|
33
|
+
}
|
|
34
|
+
return new Map([...best.entries()].map(([q, v]) => [q, v.page]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ctrGapActions(site: SiteCfg): Action[] {
|
|
38
|
+
const gaps = data.ctrGaps(site);
|
|
39
|
+
if (!gaps.length) return [];
|
|
40
|
+
const watch = SHIPPED_WATCH();
|
|
41
|
+
const pageFor = topPageForQueries(site);
|
|
42
|
+
const byPage = new Map<string, (data.GscRow & { expected: number })[]>();
|
|
43
|
+
for (const g of gaps) {
|
|
44
|
+
const page = pageFor.get(g.keys[0]) ?? `https://${site.gscHost}/`;
|
|
45
|
+
byPage.set(page, [...(byPage.get(page) ?? []), g]);
|
|
46
|
+
}
|
|
47
|
+
return [...byPage.entries()].map(([page, qs]) => {
|
|
48
|
+
const missed = qs.reduce((s, q) => s + (q.impressions / WINDOW_MONTHS) * (q.expected - q.ctr), 0);
|
|
49
|
+
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
|
+
const watching = watch[page];
|
|
51
|
+
return {
|
|
52
|
+
id: gid("ctr-gap", page),
|
|
53
|
+
host: site.host,
|
|
54
|
+
title: `Rewrite title/description: ${pathOf(page)}`,
|
|
55
|
+
kind: "Meta/template change — no new content",
|
|
56
|
+
why: `Ranks but rarely clicked (90d) — ${top.join("; ")}`,
|
|
57
|
+
how: `Make the <title> restate the query as its answer; description = 150-char direct answer. Then request reindexing in Search Console.`,
|
|
58
|
+
spec: [
|
|
59
|
+
`Page: ${page}`,
|
|
60
|
+
`Queries being lost: ${qs.map((q) => `“${q.keys[0]}” (${q.impressions.toLocaleString()} imps, ${(100 * q.ctr).toFixed(1)}% CTR, pos ${q.position.toFixed(1)})`).join(" · ")}`,
|
|
61
|
+
`Title formula: [query phrased as its answer] — [differentiator]. Keep under ~60 chars; front-load the query words.`,
|
|
62
|
+
`Description: one ~150-char sentence that directly answers the query (this becomes the SERP snippet).`,
|
|
63
|
+
`Ship: meta change only — no body-content edits needed. Then Search Console → URL Inspection → Request Indexing.`,
|
|
64
|
+
`Success: CTR on these queries reaches ≥ half of position-expected within 28 days (watch on this dashboard). Then leave the title alone — title churn resets Google's evaluation.`,
|
|
65
|
+
],
|
|
66
|
+
impact: Math.round(missed),
|
|
67
|
+
effort: "S" as Effort,
|
|
68
|
+
tag: "ctr-gap",
|
|
69
|
+
source: "rule" as const,
|
|
70
|
+
...(watching ? { watching } : {}),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function strikingActions(site: SiteCfg): Action[] {
|
|
76
|
+
const rows = data.strikingDistance(site).slice(0, 12);
|
|
77
|
+
if (!rows.length) return [];
|
|
78
|
+
const watch = SHIPPED_WATCH();
|
|
79
|
+
const pageFor = topPageForQueries(site);
|
|
80
|
+
const byPage = new Map<string, data.GscRow[]>();
|
|
81
|
+
for (const r of rows) {
|
|
82
|
+
const page = pageFor.get(r.keys[0]) ?? `https://${site.gscHost}/`;
|
|
83
|
+
byPage.set(page, [...(byPage.get(page) ?? []), r]);
|
|
84
|
+
}
|
|
85
|
+
return [...byPage.entries()].map(([page, qs]) => {
|
|
86
|
+
const imps = qs.reduce((s, q) => s + q.impressions, 0);
|
|
87
|
+
const impact = Math.round((imps / WINDOW_MONTHS) * 0.06);
|
|
88
|
+
const top = qs.slice(0, 3).map((q) => `“${q.keys[0]}” pos ${q.position.toFixed(1)} (${q.impressions.toLocaleString()} imps)`);
|
|
89
|
+
const watching = watch[page];
|
|
90
|
+
return {
|
|
91
|
+
id: gid("striking", page),
|
|
92
|
+
host: site.host,
|
|
93
|
+
title: `Push to page 1: ${pathOf(page)}`,
|
|
94
|
+
kind: "Content addition to an existing page",
|
|
95
|
+
why: `Position 5–15 in the last 90d for ${top.join("; ")}`,
|
|
96
|
+
how: `Add a section that answers these queries verbatim (H2 phrased as the query + direct answer), add 2–3 internal links to this page from related pages, refresh dateModified.`,
|
|
97
|
+
spec: [
|
|
98
|
+
`Page: ${page}`,
|
|
99
|
+
`Target queries: ${qs.map((q) => `“${q.keys[0]}” (pos ${q.position.toFixed(1)}, ${q.impressions.toLocaleString()} imps)`).join(" · ")}`,
|
|
100
|
+
`Add one H2 per query cluster, phrased as the query, followed by a 40–60 word direct answer, then detail.`,
|
|
101
|
+
`Internal links: 2–3 links to this page from the site's highest-authority related pages, using the query as anchor text.`,
|
|
102
|
+
`Freshness: a real content change → dateModified updates → sitemap lastmod moves.`,
|
|
103
|
+
`Success: these queries move from pos 5–15 into the top 5 within ~6 weeks.`,
|
|
104
|
+
],
|
|
105
|
+
impact,
|
|
106
|
+
effort: "M" as Effort,
|
|
107
|
+
tag: "striking",
|
|
108
|
+
source: "rule" as const,
|
|
109
|
+
...(watching ? { watching } : {}),
|
|
110
|
+
};
|
|
111
|
+
}).filter((a) => a.impact >= 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function probeActions(site: SiteCfg): Action[] {
|
|
115
|
+
const p = data.probeFor(site);
|
|
116
|
+
if (!p) return [];
|
|
117
|
+
const out: Action[] = [];
|
|
118
|
+
const mk = (key: string, title: string, how: string): Action => ({
|
|
119
|
+
id: `gen-hygiene-${slug(site.host)}-${key}`,
|
|
120
|
+
host: site.host, title, kind: "Config change", why: "Live probe failing on this check", how,
|
|
121
|
+
spec: [how, `Verify with: python3 probes/site_probe.py after deploy.`],
|
|
122
|
+
impact: 5, effort: "S", tag: "hygiene", source: "rule",
|
|
123
|
+
});
|
|
124
|
+
if (!p.robots.exists) out.push(mk("robots", "Add robots.txt", "Serve robots.txt with a Sitemap: pointer and explicit AI-crawler allowances."));
|
|
125
|
+
if (!p.sitemap.exists) out.push(mk("sitemap", "Add sitemap.xml", "Generate one with honest lastmod dates, reference it from robots.txt, submit it in Search Console."));
|
|
126
|
+
if (!p["llms.txt"].exists) out.push(mk("llms", "Add llms.txt", "A markdown site summary with links to the key pages — the surface AI crawlers read first."));
|
|
127
|
+
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
|
+
if ((p.robots.ai_crawlers_blocked?.length ?? 0) > 0)
|
|
129
|
+
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)
|
|
131
|
+
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
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function engagementActions(site: SiteCfg): Action[] {
|
|
136
|
+
const watch = SHIPPED_WATCH();
|
|
137
|
+
return data
|
|
138
|
+
.landingPages(site)
|
|
139
|
+
.filter((l) => l.sessions >= 30 && l.engagement < 0.25 && l.page !== "(not set)")
|
|
140
|
+
.slice(0, 3)
|
|
141
|
+
.map((l) => {
|
|
142
|
+
const url = `https://${site.gscHost}${l.page}`;
|
|
143
|
+
// GA landing paths carry no trailing slash; shippedWatch keys may have one
|
|
144
|
+
const watching = watch[url] ?? watch[`${url}/`];
|
|
145
|
+
return {
|
|
146
|
+
id: gid("engagement", url),
|
|
147
|
+
host: site.host,
|
|
148
|
+
title: `Fix content mismatch: ${l.page}`,
|
|
149
|
+
kind: "Content rewrite of an existing page",
|
|
150
|
+
why: `${l.sessions.toLocaleString()} sessions/90d land here but only ${(100 * l.engagement).toFixed(0)}% engage — the page isn't delivering what the click promised`,
|
|
151
|
+
how: `Read the top queries landing here, rewrite the opening to deliver that answer immediately, move setup/context below.`,
|
|
152
|
+
spec: [
|
|
153
|
+
`Page: ${url}`,
|
|
154
|
+
`Evidence: ${l.sessions.toLocaleString()} sessions/90d, ${(100 * l.engagement).toFixed(0)}% engagement (a healthy content page sits around 40–70%).`,
|
|
155
|
+
`Check this site's queries for the ones landing on this path — the rewrite must answer THOSE in the first screenful.`,
|
|
156
|
+
`Keep the URL; rewrite opening + structure, don't relocate.`,
|
|
157
|
+
`Success: engagement above 35% within 28 days of shipping.`,
|
|
158
|
+
],
|
|
159
|
+
impact: Math.round((l.sessions / 3) * 0.3),
|
|
160
|
+
effort: "M" as Effort,
|
|
161
|
+
tag: "engagement",
|
|
162
|
+
source: "rule" as const,
|
|
163
|
+
...(watching ? { watching } : {}),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function trendActions(site: SiteCfg): Action[] {
|
|
169
|
+
const t = data.sessionTrend(site);
|
|
170
|
+
if (t.prior >= 50 && t.recent < t.prior * 0.75) {
|
|
171
|
+
return [{
|
|
172
|
+
id: `gen-trend-${slug(site.host)}`,
|
|
173
|
+
host: site.host,
|
|
174
|
+
title: `Investigate traffic drop`,
|
|
175
|
+
kind: "Investigation",
|
|
176
|
+
why: `Sessions down ${(100 * (1 - t.recent / t.prior)).toFixed(0)}% — ${t.recent.toLocaleString()} last 28d vs ${t.prior.toLocaleString()} prior`,
|
|
177
|
+
how: `Check Search Console for position/impression drops by page, the probe for regressions, and recent deploys in the change window.`,
|
|
178
|
+
spec: [
|
|
179
|
+
`Compare the pages report for the two windows — find which pages lost impressions vs position.`,
|
|
180
|
+
`Check docs/daily-log.md and data/probes/ for regressions in the window.`,
|
|
181
|
+
`Cross-reference deploy dates (git log in the site repo) against the drop start.`,
|
|
182
|
+
`The impact figure is a capped estimate of monthly traffic that recovering this drop would return — it ranks the card, it is not a forecast.`,
|
|
183
|
+
],
|
|
184
|
+
// Every other rule emits recoverable clicks per month; this one starts
|
|
185
|
+
// from sessions lost across a 28-day window, so normalize before it can
|
|
186
|
+
// be compared. Discounted because an investigation is not a fix, and
|
|
187
|
+
// capped: an unbounded loss on effort S would pin this card to the top
|
|
188
|
+
// of the queue until the traffic came back on its own.
|
|
189
|
+
impact: Math.min(60, Math.max(1, Math.round(((t.prior - t.recent) / 28) * 30 * 0.3))),
|
|
190
|
+
effort: "S",
|
|
191
|
+
tag: "trend",
|
|
192
|
+
source: "rule",
|
|
193
|
+
}];
|
|
194
|
+
}
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function metadataActions(site: SiteCfg): Action[] {
|
|
199
|
+
const audit = data.metadataAudit();
|
|
200
|
+
const findings = audit?.sites[site.host] ?? audit?.sites[site.gscHost] ?? [];
|
|
201
|
+
const watch = SHIPPED_WATCH();
|
|
202
|
+
return findings.slice(0, 5).map((f) => {
|
|
203
|
+
const pagePath = f.page.replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
204
|
+
const monthly = Math.max(1, Math.round(f.missed_clicks_window / 3)); // audit window is 90d
|
|
205
|
+
const watching = watch[f.page];
|
|
206
|
+
return {
|
|
207
|
+
id: gid("metadata", f.page),
|
|
208
|
+
host: site.host,
|
|
209
|
+
title: `Metadata fix: ${pagePath}`,
|
|
210
|
+
kind: "Title/meta rewrite — audit finding",
|
|
211
|
+
why: `${f.imps.toLocaleString()} impressions in 90d; issues: ${f.issues.join("; ")}`,
|
|
212
|
+
how: `Rewrite the title to carry the ranking-query language and the description as a ~150-char direct answer; respect the ~60-char title budget.`,
|
|
213
|
+
spec: [
|
|
214
|
+
`Page: ${f.page}`,
|
|
215
|
+
`Current title: "${f.title}"`,
|
|
216
|
+
f.description ? `Current description: "${f.description}"` : "Current description: (none)",
|
|
217
|
+
`Top queries: ${f.top_queries.map((q) => `“${q.q}” (${q.imps} imps, pos ${q.pos}, ${(100 * q.ctr).toFixed(1)}% CTR)`).join(" · ")}`,
|
|
218
|
+
...f.issues.map((i) => `Issue: ${i}`),
|
|
219
|
+
`Success: CTR on the listed queries reaches ≥ half of position-expected within 28 days. Freeze the title for those 28 days.`,
|
|
220
|
+
],
|
|
221
|
+
impact: monthly,
|
|
222
|
+
effort: "S" as Effort,
|
|
223
|
+
tag: "metadata",
|
|
224
|
+
source: "rule" as const,
|
|
225
|
+
...(watching ? { watching } : {}),
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function actionsFor(site: SiteCfg): Action[] {
|
|
231
|
+
const metaActions = metadataActions(site);
|
|
232
|
+
const metaPages = new Set(metaActions.map((a) => a.spec[0]?.replace("Page: ", "")));
|
|
233
|
+
const generated = [
|
|
234
|
+
...metaActions,
|
|
235
|
+
...ctrGapActions(site).filter((a) => !metaPages.has(a.spec[0]?.replace("Page: ", ""))),
|
|
236
|
+
...strikingActions(site),
|
|
237
|
+
...probeActions(site),
|
|
238
|
+
...engagementActions(site),
|
|
239
|
+
...trendActions(site),
|
|
240
|
+
];
|
|
241
|
+
const backlog = BACKLOG().filter((b) => b.host === site.host);
|
|
242
|
+
return [...generated, ...backlog].sort((a, b) => score(b) - score(a));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function allActions(): Action[] {
|
|
246
|
+
const sites = SITES();
|
|
247
|
+
const known = new Set(sites.map((s) => s.host));
|
|
248
|
+
// Backlog items for hosts no longer in the config still deserve a place.
|
|
249
|
+
const orphans = BACKLOG().filter((b) => !known.has(b.host));
|
|
250
|
+
return [...sites.flatMap((s) => actionsFor(s)), ...orphans].sort((a, b) => score(b) - score(a));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function actionById(id: string): Action | undefined {
|
|
254
|
+
return allActions().find((a) => a.id === id);
|
|
255
|
+
}
|
package/src/backlog.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curated queue: config/backlog.json.
|
|
3
|
+
*
|
|
4
|
+
* { actions: Action[], shippedWatch: { [pageUrl]: note } }
|
|
5
|
+
*
|
|
6
|
+
* Why this is hot-reloaded: a static import binds once for the life of the
|
|
7
|
+
* process. Everything in data.ts is re-read from disk per call, so a
|
|
8
|
+
* long-lived reader (the stdio MCP server, spawned once by its client and
|
|
9
|
+
* left running for days) would look healthy — fresh probes, fresh GSC —
|
|
10
|
+
* while serving a queue frozen at startup. Re-reading on change keeps the
|
|
11
|
+
* dashboard and the MCP server honest.
|
|
12
|
+
*
|
|
13
|
+
* watchFile (stat polling) rather than watch (inode): editors save by
|
|
14
|
+
* writing a new file and renaming over the old one, which detaches an inode
|
|
15
|
+
* watcher after the first edit. persistent:false so this never holds a CLI
|
|
16
|
+
* open.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { INSTANCE } from "./config.js";
|
|
21
|
+
|
|
22
|
+
export type Effort = "S" | "M" | "L";
|
|
23
|
+
|
|
24
|
+
export interface Action {
|
|
25
|
+
id: string;
|
|
26
|
+
host: string;
|
|
27
|
+
title: string;
|
|
28
|
+
/** what kind of work this is — "New page", "Meta/template change", "Content rewrite", … */
|
|
29
|
+
kind: string;
|
|
30
|
+
/** the data making the case — numbers included */
|
|
31
|
+
why: string;
|
|
32
|
+
/** the concrete move */
|
|
33
|
+
how: string;
|
|
34
|
+
/** full spec, shown in the detail modal: URL, outline, schema, links, success metric */
|
|
35
|
+
spec: string[];
|
|
36
|
+
/** estimated organic clicks/month gained — for ORDERING, not a forecast */
|
|
37
|
+
impact: number;
|
|
38
|
+
effort: Effort;
|
|
39
|
+
tag: string;
|
|
40
|
+
/** set when a fix already shipped and the data is being watched */
|
|
41
|
+
watching?: string;
|
|
42
|
+
/** rule = derived from data each request; backlog = curated in config/backlog.json */
|
|
43
|
+
source?: "rule" | "backlog" | "proposal";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface BacklogFile {
|
|
47
|
+
_comment?: string;
|
|
48
|
+
actions: Action[];
|
|
49
|
+
shippedWatch: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const BACKLOG_PATH = path.join(INSTANCE, "config", "backlog.json");
|
|
53
|
+
const PROPOSALS_PATH = path.join(INSTANCE, "data", "opportunity-proposals.json");
|
|
54
|
+
|
|
55
|
+
export const slug = (s: string) =>
|
|
56
|
+
s.toLowerCase().replace(/https?:\/\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
57
|
+
|
|
58
|
+
const EMPTY: BacklogFile = { actions: [], shippedWatch: {} };
|
|
59
|
+
|
|
60
|
+
const EFFORTS = new Set<Effort>(["S", "M", "L"]);
|
|
61
|
+
|
|
62
|
+
/** config/backlog.json is edited by hand — that is the documented workflow —
|
|
63
|
+
* so it is untrusted input. An effort outside S|M|L used to give the whole
|
|
64
|
+
* queue a NaN comparator (score = impact / weight), and V8 then leaves the
|
|
65
|
+
* order of every other card arbitrary. Coerce here, the same way
|
|
66
|
+
* acceptProposal() already does on the write path. */
|
|
67
|
+
function readBacklog(): BacklogFile {
|
|
68
|
+
const raw = JSON.parse(fs.readFileSync(BACKLOG_PATH, "utf8")) as Partial<BacklogFile>;
|
|
69
|
+
const seen = new Map<string, number>();
|
|
70
|
+
const actions = (raw.actions ?? []).map((a) => {
|
|
71
|
+
const impact = Number(a.impact);
|
|
72
|
+
const base = a.id || slug(`${a.host}-${a.title}`);
|
|
73
|
+
// slug() truncates at 80 chars, so two long titles can collide; a shared
|
|
74
|
+
// id would make retire() delete both and setWatching() annotate whichever
|
|
75
|
+
// came first. Suffix duplicates so every id addresses exactly one card.
|
|
76
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
77
|
+
seen.set(base, n);
|
|
78
|
+
return {
|
|
79
|
+
...a,
|
|
80
|
+
id: n === 1 ? base : `${base}-${n}`,
|
|
81
|
+
spec: a.spec ?? [],
|
|
82
|
+
impact: Number.isFinite(impact) ? impact : 0,
|
|
83
|
+
effort: EFFORTS.has(a.effort) ? a.effort : ("M" as Effort),
|
|
84
|
+
source: "backlog" as const,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
return { _comment: raw._comment, actions, shippedWatch: raw.shippedWatch ?? {} };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let current: BacklogFile = EMPTY;
|
|
91
|
+
let loadedMtime = 0;
|
|
92
|
+
|
|
93
|
+
function reload(): void {
|
|
94
|
+
try {
|
|
95
|
+
current = readBacklog();
|
|
96
|
+
loadedMtime = fs.statSync(BACKLOG_PATH).mtimeMs;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// A missing file is the normal state of an instance before its first
|
|
99
|
+
// curated item; only a real problem (a syntax error mid-edit) is worth
|
|
100
|
+
// reporting, and it must not take down the dashboard or the MCP server.
|
|
101
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") current = EMPTY;
|
|
102
|
+
else console.error("[backlog] keeping the last good queue:", (err as Error).message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
reload();
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
fs.watchFile(BACKLOG_PATH, { interval: 2000, persistent: false }, (curr, prev) => {
|
|
109
|
+
if (curr.mtimeMs !== prev.mtimeMs) reload();
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
// A watch-less environment still gets the startup snapshot.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Cheap staleness guard for callers between poll ticks (e.g. right after a write). */
|
|
116
|
+
function ensureFresh(): void {
|
|
117
|
+
try {
|
|
118
|
+
if (fs.statSync(BACKLOG_PATH).mtimeMs !== loadedMtime) reload();
|
|
119
|
+
} catch {
|
|
120
|
+
/* file missing: keep last good */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function BACKLOG(): Action[] {
|
|
125
|
+
ensureFresh();
|
|
126
|
+
return current.actions;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function SHIPPED_WATCH(): Record<string, string> {
|
|
130
|
+
ensureFresh();
|
|
131
|
+
return current.shippedWatch;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- writes (the dashboard's accept / watch / retire buttons) ----------
|
|
135
|
+
|
|
136
|
+
function write(file: BacklogFile): void {
|
|
137
|
+
const out = {
|
|
138
|
+
...(file._comment ? { _comment: file._comment } : {}),
|
|
139
|
+
actions: file.actions.map(({ source: _s, ...a }) => a),
|
|
140
|
+
shippedWatch: file.shippedWatch,
|
|
141
|
+
};
|
|
142
|
+
fs.writeFileSync(BACKLOG_PATH, JSON.stringify(out, null, 2) + "\n");
|
|
143
|
+
reload();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Move proposal #index from data/opportunity-proposals.json into the queue. */
|
|
147
|
+
export function acceptProposal(index: number): Action | null {
|
|
148
|
+
let scan: { proposals?: Omit<Action, "id">[] } & Record<string, unknown>;
|
|
149
|
+
try {
|
|
150
|
+
scan = JSON.parse(fs.readFileSync(PROPOSALS_PATH, "utf8"));
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const proposals = scan.proposals ?? [];
|
|
155
|
+
const p = proposals[index];
|
|
156
|
+
if (!p) return null;
|
|
157
|
+
const file = readBacklog();
|
|
158
|
+
const base = slug(`${p.host}-${p.title}`);
|
|
159
|
+
let id = base;
|
|
160
|
+
for (let n = 2; file.actions.some((a) => a.id === id); n++) id = `${base}-${n}`;
|
|
161
|
+
const action: Action = {
|
|
162
|
+
id,
|
|
163
|
+
host: p.host,
|
|
164
|
+
title: p.title,
|
|
165
|
+
kind: p.kind || "Proposed by the opportunity scan",
|
|
166
|
+
why: p.why || "",
|
|
167
|
+
how: p.how || "",
|
|
168
|
+
spec: p.spec ?? [],
|
|
169
|
+
impact: Number(p.impact) || 0,
|
|
170
|
+
effort: (["S", "M", "L"].includes(String(p.effort)) ? p.effort : "M") as Effort,
|
|
171
|
+
tag: p.tag || "content",
|
|
172
|
+
};
|
|
173
|
+
file.actions.push(action);
|
|
174
|
+
write(file);
|
|
175
|
+
proposals.splice(index, 1);
|
|
176
|
+
fs.writeFileSync(PROPOSALS_PATH, JSON.stringify({ ...scan, proposals }, null, 1) + "\n");
|
|
177
|
+
return action;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function setWatching(id: string, note: string): boolean {
|
|
181
|
+
const file = readBacklog();
|
|
182
|
+
const a = file.actions.find((x) => x.id === id);
|
|
183
|
+
if (!a) return false;
|
|
184
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
185
|
+
a.watching = note.trim() ? `${stamp}: ${note.trim()}` : `${stamp}: shipped — watching the data`;
|
|
186
|
+
write(file);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function retire(id: string): boolean {
|
|
191
|
+
const file = readBacklog();
|
|
192
|
+
const before = file.actions.length;
|
|
193
|
+
file.actions = file.actions.filter((x) => x.id !== id);
|
|
194
|
+
if (file.actions.length === before) return false;
|
|
195
|
+
write(file);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration: one JSON file drives everything.
|
|
3
|
+
*
|
|
4
|
+
* n-seo.config.json (or $N_SEO_CONFIG) is read by BOTH this app and
|
|
5
|
+
* every Python script (ingest/seo_config.py). One site list, one module
|
|
6
|
+
* switchboard, one auth block — adding a site here adds it to every pull,
|
|
7
|
+
* probe, audit and page. The example file documents every field.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
/** The engine checkout: code, public assets, engine docs. */
|
|
16
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
/** The instance: one user's config, queue, content and data. Defaults to the
|
|
18
|
+
* engine checkout ("in-place" mode) so a plain clone works unchanged; set
|
|
19
|
+
* N_SEO_INSTANCE to keep them apart so upgrading the engine is a git pull. */
|
|
20
|
+
export const INSTANCE = process.env.N_SEO_INSTANCE
|
|
21
|
+
? path.resolve(process.env.N_SEO_INSTANCE)
|
|
22
|
+
: ROOT;
|
|
23
|
+
export const CONFIG_PATH = process.env.N_SEO_CONFIG
|
|
24
|
+
? path.resolve(process.env.N_SEO_CONFIG)
|
|
25
|
+
: path.join(INSTANCE, "n-seo.config.json");
|
|
26
|
+
export const EXAMPLE_CONFIG_PATH = path.join(ROOT, "n-seo.config.example.json");
|
|
27
|
+
|
|
28
|
+
export const ENGINE_VERSION: string = (() => {
|
|
29
|
+
try {
|
|
30
|
+
return (JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")) as { version?: string }).version ?? "0.0.0";
|
|
31
|
+
} catch {
|
|
32
|
+
return "0.0.0";
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
export interface EngineInfo {
|
|
37
|
+
version: string;
|
|
38
|
+
commit: string | null;
|
|
39
|
+
root: string;
|
|
40
|
+
instance: string;
|
|
41
|
+
mode: "in-place" | "instance";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What is running: engine version + commit, where the engine and the
|
|
45
|
+
* instance live. Shown on Settings, by `n-seo version` and the MCP tool. */
|
|
46
|
+
export function engineInfo(): EngineInfo {
|
|
47
|
+
let commit: string | null = null;
|
|
48
|
+
try {
|
|
49
|
+
commit = execFileSync("git", ["-C", ROOT, "rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] })
|
|
50
|
+
.toString().trim() || null;
|
|
51
|
+
} catch {
|
|
52
|
+
commit = null;
|
|
53
|
+
}
|
|
54
|
+
return { version: ENGINE_VERSION, commit, root: ROOT, instance: INSTANCE, mode: INSTANCE === ROOT ? "in-place" : "instance" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SiteCfg {
|
|
58
|
+
/** canonical hostname — the URL slug in this app and the key in data/ga4/<host>/ */
|
|
59
|
+
host: string;
|
|
60
|
+
label: string;
|
|
61
|
+
/** Search Console property: "sc-domain:example.com" or "https://www.example.com/" */
|
|
62
|
+
gscProperty?: string;
|
|
63
|
+
/** hostname to filter GSC page URLs by (a domain property covers subdomains) */
|
|
64
|
+
gscHost: string;
|
|
65
|
+
/** numeric GA4 property id, e.g. "123456789" */
|
|
66
|
+
ga4Property?: string;
|
|
67
|
+
/** regex (case-insensitive) matching branded queries, for the branded/generic split */
|
|
68
|
+
brand?: string;
|
|
69
|
+
repo?: string;
|
|
70
|
+
hosting?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ModuleCfg {
|
|
74
|
+
enabled: boolean;
|
|
75
|
+
[k: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Hooks {
|
|
79
|
+
/** shell commands run in the instance dir before the first step */
|
|
80
|
+
beforeRun: string[];
|
|
81
|
+
/** … after the last step (and after last-run.json is written) */
|
|
82
|
+
afterRun: string[];
|
|
83
|
+
/** … after a named step, e.g. { "daily-diff": ["python3 my_sync.py"] } */
|
|
84
|
+
afterStep: Record<string, string[]>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Config {
|
|
88
|
+
name: string;
|
|
89
|
+
port: number;
|
|
90
|
+
google: {
|
|
91
|
+
/** `metadata` is the GCE / Cloud Run / GKE runtime service account: no
|
|
92
|
+
* key file anywhere. See docs/SETUP-GOOGLE.md. */
|
|
93
|
+
auth: "service-account-key" | "gcloud-impersonate" | "gcloud-user" | "metadata";
|
|
94
|
+
serviceAccountKey?: string;
|
|
95
|
+
impersonate?: string;
|
|
96
|
+
};
|
|
97
|
+
sites: SiteCfg[];
|
|
98
|
+
watchPages: string[];
|
|
99
|
+
conversions?: { site: string; events: string[]; sourceDimension?: string };
|
|
100
|
+
participation?: { expertise?: string };
|
|
101
|
+
modules: Record<string, ModuleCfg>;
|
|
102
|
+
/** extra Search Console properties pulled into data/gsc/<slug>/ but not shown as sites */
|
|
103
|
+
gscExtraProperties: string[];
|
|
104
|
+
hooks: Hooks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Per-module defaults, so a half-written block cannot make a step guess.
|
|
108
|
+
* Mirrors MODULE_DEFAULTS in ingest/seo_config.py. */
|
|
109
|
+
export const MODULE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
110
|
+
staticExport: { signOutUrl: "", signOutLabel: "Sign out" },
|
|
111
|
+
publish: { target: "gcs", destination: "", command: "", delete: false, dryRun: false, env: {} },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const MODULE_INFO: { key: string; title: string; blurb: string; needs?: string }[] = [
|
|
115
|
+
{ key: "indexStatus", title: "Index coverage sweep", blurb: "Ask Search Console's URL Inspection API whether each sitemap URL is actually indexed. ~1 call per URL, 2,000/day quota per property." },
|
|
116
|
+
{ key: "metadataAudit", title: "Metadata audit", blurb: "Fetch each ranking page's live title/description and judge them against the queries it ranks for." },
|
|
117
|
+
{ key: "opportunityScan", title: "Opportunity scan", blurb: "Refresh the 84-day trend analysis and detect rising queries no queue item covers. With the LLM module on, also drafts proposals and verdicts." },
|
|
118
|
+
{ key: "llm", title: "LLM inference", blurb: "Drafts briefings and proposals, either through a local command or an HTTP endpoint. Nothing is auto-applied — output lands as proposals you accept or ignore.", needs: "a CLI that reads a prompt on stdin, or an `http` block with an API key (the only option on a server)" },
|
|
119
|
+
{ key: "hackerNews", title: "Hacker News digest", blurb: "Find fresh HN threads in your expertise areas and brief you on each. Briefings only — no comment text is ever generated.", needs: "your HN username (to mark threads you already joined)" },
|
|
120
|
+
{ key: "reddit", title: "Reddit digest", blurb: "Same idea for subreddits. Reddit blocks anonymous API reads, so this needs a free 'script' app's credentials in .env.", needs: "REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET" },
|
|
121
|
+
{ key: "indexNow", title: "IndexNow", blurb: "Generate a key and ping Bing/Copilot/Yandex with changed URLs on publish. Free and instant; does nothing for Google." },
|
|
122
|
+
{ key: "staticExport", title: "Static export", blurb: "Snapshot the dashboard into site/ as static HTML after each daily run, for hosting a read-only mirror behind your own auth. `signOutUrl` adds a sign-out link to every exported page." },
|
|
123
|
+
{ key: "publish", title: "Publish the mirror", blurb: "Copy site/ to a bucket, an object store or a box over ssh after the export — a real pipeline step, so it is logged and retried like the rest. `dryRun` prints the command without running it, which is how you rehearse a cutover.", needs: "gcloud, aws or rsync on PATH, depending on the target" },
|
|
124
|
+
{ key: "gitAutoCommit", title: "Git auto-commit", blurb: "Commit the daily log and export after each run (and push if a remote is set)." },
|
|
125
|
+
{ key: "notifications", title: "Desktop notifications", blurb: "macOS notification when a daily step fails (osascript)." },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
function readJson<T>(p: string): T {
|
|
129
|
+
return JSON.parse(fs.readFileSync(p, "utf8")) as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Fill in defaults so the rest of the app can assume the shape. */
|
|
133
|
+
function normalize(raw: Partial<Config>): Config {
|
|
134
|
+
const modules: Record<string, ModuleCfg> = {};
|
|
135
|
+
for (const m of MODULE_INFO) modules[m.key] = { enabled: false, ...(MODULE_DEFAULTS[m.key] ?? {}), ...(raw.modules?.[m.key] ?? {}) };
|
|
136
|
+
for (const [k, v] of Object.entries(raw.modules ?? {})) if (!modules[k]) modules[k] = { ...v, enabled: !!v?.enabled };
|
|
137
|
+
const sites = (raw.sites ?? []).map((s) => ({
|
|
138
|
+
...s,
|
|
139
|
+
label: s.label || s.host,
|
|
140
|
+
gscHost: s.gscHost || s.host,
|
|
141
|
+
gscProperty: s.gscProperty || undefined,
|
|
142
|
+
ga4Property: s.ga4Property || undefined,
|
|
143
|
+
brand: s.brand || undefined,
|
|
144
|
+
}));
|
|
145
|
+
const strList = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "") : []);
|
|
146
|
+
const rawHooks = (raw.hooks ?? {}) as Partial<Record<string, unknown>>;
|
|
147
|
+
const afterStep: Record<string, string[]> = {};
|
|
148
|
+
for (const [k, v] of Object.entries((rawHooks.afterStep as Record<string, unknown>) ?? {})) afterStep[k] = strList(v);
|
|
149
|
+
return {
|
|
150
|
+
name: raw.name || "n-seo",
|
|
151
|
+
port: Number(process.env.SEO_PORT ?? raw.port ?? 4600),
|
|
152
|
+
google: { ...(raw.google ?? {}), auth: raw.google?.auth || "service-account-key" } as Config["google"],
|
|
153
|
+
sites,
|
|
154
|
+
watchPages: raw.watchPages ?? [],
|
|
155
|
+
conversions: raw.conversions?.site ? raw.conversions : undefined,
|
|
156
|
+
participation: raw.participation,
|
|
157
|
+
modules,
|
|
158
|
+
gscExtraProperties: strList(raw.gscExtraProperties),
|
|
159
|
+
hooks: { beforeRun: strList(rawHooks.beforeRun), afterRun: strList(rawHooks.afterRun), afterStep },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One KEY=value from the instance's .env (quotes stripped). */
|
|
164
|
+
export function dotEnv(key: string): string | undefined {
|
|
165
|
+
try {
|
|
166
|
+
for (const line of fs.readFileSync(path.join(INSTANCE, ".env"), "utf8").split("\n")) {
|
|
167
|
+
const t = line.trim();
|
|
168
|
+
if (t.startsWith(`${key}=`)) return t.slice(key.length + 1).trim().replace(/^["']|["']$/g, "") || undefined;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
/* no .env */
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True when the user has not created their own config yet — the app then
|
|
177
|
+
* runs on the example so the UI can explain what to do instead of crashing. */
|
|
178
|
+
export let USING_EXAMPLE_CONFIG = !fs.existsSync(CONFIG_PATH);
|
|
179
|
+
|
|
180
|
+
export function loadConfig(): Config {
|
|
181
|
+
USING_EXAMPLE_CONFIG = !fs.existsSync(CONFIG_PATH);
|
|
182
|
+
const p = USING_EXAMPLE_CONFIG ? EXAMPLE_CONFIG_PATH : CONFIG_PATH;
|
|
183
|
+
return normalize(readJson<Partial<Config>>(p));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Rewrite the config file, keeping unknown keys. Creates it from the example
|
|
187
|
+
* on first save (that is how the Settings page bootstraps a new install). */
|
|
188
|
+
export function saveConfig(mutate: (raw: Record<string, unknown>) => void): Config {
|
|
189
|
+
const src = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : EXAMPLE_CONFIG_PATH;
|
|
190
|
+
const raw = readJson<Record<string, unknown>>(src);
|
|
191
|
+
mutate(raw);
|
|
192
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
|
|
193
|
+
return loadConfig();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Live view: re-read on every access so a hand edit shows up without a
|
|
197
|
+
* restart (the file is tiny; the cost is nothing next to the data reads). */
|
|
198
|
+
export const config = (): Config => loadConfig();
|
|
199
|
+
export const SITES = (): SiteCfg[] => loadConfig().sites;
|
|
200
|
+
export const PORT = loadConfig().port;
|
|
201
|
+
|
|
202
|
+
/** data/gsc/<slug>/ — must match ingest/seo_config.py gsc_slug(). */
|
|
203
|
+
export function gscSlug(property: string): string {
|
|
204
|
+
return property
|
|
205
|
+
.replace(/^sc-domain:/, "")
|
|
206
|
+
.replace(/^https?:\/\//, "")
|
|
207
|
+
.replace(/\/+$/, "")
|
|
208
|
+
.replace(/\//g, "_");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The data/gsc/ directory for a property. A url-prefix property
|
|
212
|
+
* ("https://example.com/") would slug to the same name as the domain
|
|
213
|
+
* property ("sc-domain:example.com"), so it gets a "-urlprefix" suffix.
|
|
214
|
+
* Must match ingest/seo_config.py gsc_data_slug(). */
|
|
215
|
+
export function gscDataSlug(property: string): string {
|
|
216
|
+
return /^https?:\/\//i.test(property) ? gscSlug(property) + "-urlprefix" : gscSlug(property);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const siteByHost = (host: string): SiteCfg | undefined =>
|
|
220
|
+
SITES().find((s) => s.host === host || s.gscHost === host);
|