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/data.ts
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
/** Data layer: reads the JSON snapshots the ingest scripts write under data/,
|
|
2
|
+
* plus the committed, hand-curated inputs under config/ and content/.
|
|
3
|
+
* Everything is re-read per call — the files are small and this keeps a
|
|
4
|
+
* long-lived process (dashboard, MCP server) honest about what is on disk. */
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { ROOT, INSTANCE, config, gscDataSlug, type SiteCfg } from "./config.js";
|
|
8
|
+
import { BACKLOG_PATH } from "./backlog.js";
|
|
9
|
+
|
|
10
|
+
const DATA = path.join(INSTANCE, "data");
|
|
11
|
+
|
|
12
|
+
// ---------- GSC ----------
|
|
13
|
+
|
|
14
|
+
export interface GscRow {
|
|
15
|
+
keys: string[];
|
|
16
|
+
clicks: number;
|
|
17
|
+
impressions: number;
|
|
18
|
+
ctr: number;
|
|
19
|
+
position: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readJson<T>(p: string): T | null {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(p, "utf8")) as T;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const gscDir = (site: SiteCfg): string | undefined =>
|
|
31
|
+
site.gscProperty ? gscDataSlug(site.gscProperty) : undefined;
|
|
32
|
+
|
|
33
|
+
function gscRows(dir: string | undefined, dataset: string): GscRow[] {
|
|
34
|
+
if (!dir) return [];
|
|
35
|
+
const d = readJson<{ rows?: GscRow[] }>(path.join(DATA, "gsc", dir, `${dataset}.json`));
|
|
36
|
+
return d?.rows ?? [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Recent-window (90d) rows, falling back to the full 16-month pull if the
|
|
40
|
+
* recent file doesn't exist yet. Decisions should use recent; totals use full. */
|
|
41
|
+
function gscRowsRecent(dir: string | undefined, dataset: string): { rows: GscRow[]; recent: boolean } {
|
|
42
|
+
if (!dir) return { rows: [], recent: false };
|
|
43
|
+
const r = readJson<{ rows?: GscRow[] }>(path.join(DATA, "gsc", dir, `${dataset}_90d.json`));
|
|
44
|
+
if (r?.rows) return { rows: r.rows, recent: true };
|
|
45
|
+
return { rows: gscRows(dir, dataset), recent: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hostOf = (url: string): string => url.split("/")[2] ?? "";
|
|
49
|
+
|
|
50
|
+
/** query+page rows for this site's host (used by the action engine to name pages). */
|
|
51
|
+
export function rawQueryPage(site: SiteCfg, recent = false): GscRow[] {
|
|
52
|
+
const dir = gscDir(site);
|
|
53
|
+
const src = recent ? gscRowsRecent(dir, "query_page").rows : gscRows(dir, "query_page");
|
|
54
|
+
return src.filter((r) => hostOf(r.keys[1]) === site.gscHost);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Queries for one host. A domain property covers subdomains, so aggregate query_page filtered by page host. */
|
|
58
|
+
export function queries(site: SiteCfg, recent = false): GscRow[] {
|
|
59
|
+
const qp = rawQueryPage(site, recent);
|
|
60
|
+
const byQuery = new Map<string, { clicks: number; impressions: number; posW: number }>();
|
|
61
|
+
for (const r of qp) {
|
|
62
|
+
const cur = byQuery.get(r.keys[0]) ?? { clicks: 0, impressions: 0, posW: 0 };
|
|
63
|
+
cur.clicks += r.clicks;
|
|
64
|
+
cur.impressions += r.impressions;
|
|
65
|
+
cur.posW += r.position * r.impressions;
|
|
66
|
+
byQuery.set(r.keys[0], cur);
|
|
67
|
+
}
|
|
68
|
+
return [...byQuery.entries()]
|
|
69
|
+
.map(([q, v]) => ({
|
|
70
|
+
keys: [q],
|
|
71
|
+
clicks: v.clicks,
|
|
72
|
+
impressions: v.impressions,
|
|
73
|
+
ctr: v.impressions ? v.clicks / v.impressions : 0,
|
|
74
|
+
position: v.impressions ? v.posW / v.impressions : 0,
|
|
75
|
+
}))
|
|
76
|
+
.sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pages(site: SiteCfg): GscRow[] {
|
|
80
|
+
return gscRows(gscDir(site), "pages")
|
|
81
|
+
.filter((r) => hostOf(r.keys[0]) === site.gscHost)
|
|
82
|
+
.sort((a, b) => b.clicks - a.clicks);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface GscSummary {
|
|
86
|
+
clicks: number;
|
|
87
|
+
impressions: number;
|
|
88
|
+
ctr: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function gscSummary(site: SiteCfg): GscSummary {
|
|
92
|
+
const ps = pages(site);
|
|
93
|
+
const clicks = ps.reduce((s, r) => s + r.clicks, 0);
|
|
94
|
+
const impressions = ps.reduce((s, r) => s + r.impressions, 0);
|
|
95
|
+
return { clicks, impressions, ctr: impressions ? clicks / impressions : 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
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.
|
|
101
|
+
return queries(site, true)
|
|
102
|
+
.filter((r) => r.position >= 5 && r.position <= 15 && r.impressions >= minImpressions)
|
|
103
|
+
.sort((a, b) => b.impressions - a.impressions);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Industry-midpoint CTR by position. Must stay in step with EXPECTED_CTR in
|
|
107
|
+
* ingest/analyze_metadata.py: the audit judged positions 1-15 while this
|
|
108
|
+
* stopped at 6, so the "CTR gaps" table silently covered nothing past page
|
|
109
|
+
* one while claiming to show pages that rank well and are rarely clicked. */
|
|
110
|
+
export const EXPECTED_CTR: Record<number, number> = {
|
|
111
|
+
1: 0.28, 2: 0.15, 3: 0.1, 4: 0.07, 5: 0.05, 6: 0.04,
|
|
112
|
+
7: 0.035, 8: 0.03, 9: 0.026, 10: 0.022,
|
|
113
|
+
11: 0.018, 12: 0.016, 13: 0.014, 14: 0.012, 15: 0.011,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Ranking well but rarely clicked — title/snippet problems. */
|
|
117
|
+
export function ctrGaps(site: SiteCfg, minImpressions = 30): (GscRow & { expected: number })[] {
|
|
118
|
+
// Recent window: a page fixed last week must stop being accused within 90 days.
|
|
119
|
+
return queries(site, true)
|
|
120
|
+
.flatMap((r) => {
|
|
121
|
+
const expected = EXPECTED_CTR[Math.round(r.position)];
|
|
122
|
+
return expected && r.impressions >= minImpressions && r.ctr < expected * 0.5
|
|
123
|
+
? [{ ...r, expected }]
|
|
124
|
+
: [];
|
|
125
|
+
})
|
|
126
|
+
.sort((a, b) => b.impressions * (b.expected - b.ctr) - a.impressions * (a.expected - a.ctr));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- GA4 ----------
|
|
130
|
+
|
|
131
|
+
interface Ga4Report {
|
|
132
|
+
rows?: { dimensionValues: { value: string }[]; metricValues: { value: string }[] }[];
|
|
133
|
+
pulled?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ga4Rows(site: SiteCfg, report: string): { dims: string[]; mets: number[] }[] {
|
|
137
|
+
const d = readJson<Ga4Report>(path.join(DATA, "ga4", site.host, `${report}.json`));
|
|
138
|
+
return (d?.rows ?? []).map((r) => ({
|
|
139
|
+
dims: r.dimensionValues.map((v) => v.value),
|
|
140
|
+
mets: r.metricValues.map((v) => Number(v.value)),
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const AI_SOURCE = /chatgpt|chat\.openai|openai\.com|perplexity|claude\.ai|copilot|gemini\.google|edgeservices|you\.com|poe\.com|phind|kagi|mistral|deepseek/i;
|
|
145
|
+
export const SEARCH_SOURCE = /google|bing|duckduckgo|yahoo|ecosia|brave|yandex|baidu/i;
|
|
146
|
+
const SOCIAL_SOURCE = /facebook|instagram|linkedin|^t\.co$|twitter|x\.com|reddit|youtube|tiktok|pinterest|mastodon|bsky|bluesky|threads/i;
|
|
147
|
+
|
|
148
|
+
/** The traffic buckets the charts use, in stacking order. */
|
|
149
|
+
export const SOURCE_GROUPS = ["AI assistants", "Search", "Direct", "Referral", "Social", "Other"] as const;
|
|
150
|
+
export type SourceGroup = (typeof SOURCE_GROUPS)[number];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One classifier, shared by the site page and the trends page, so the two can
|
|
154
|
+
* never disagree about what counts as an AI referral.
|
|
155
|
+
*
|
|
156
|
+
* AI wins outright: an assistant arrives as `chatgpt.com / referral`, and
|
|
157
|
+
* `gemini.google.com` would otherwise be swallowed by the search pattern —
|
|
158
|
+
* counting either anywhere else would hide the number this tool exists to
|
|
159
|
+
* surface. Search is next, because a search engine is a search engine
|
|
160
|
+
* whatever medium GA4 attaches. Only then does medium decide.
|
|
161
|
+
*/
|
|
162
|
+
export function classifySource(source: string, medium: string): SourceGroup {
|
|
163
|
+
const s = (source ?? "").trim();
|
|
164
|
+
const m = (medium ?? "").trim().toLowerCase();
|
|
165
|
+
if (AI_SOURCE.test(s)) return "AI assistants";
|
|
166
|
+
if (SEARCH_SOURCE.test(s)) return "Search";
|
|
167
|
+
if (m === "(none)" || m === "none" || m === "direct" || s === "(direct)") return "Direct";
|
|
168
|
+
if (m.includes("social") || SOCIAL_SOURCE.test(s)) return "Social";
|
|
169
|
+
if (m === "referral") return "Referral";
|
|
170
|
+
return "Other";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface TrafficMix {
|
|
174
|
+
sessions: number;
|
|
175
|
+
ai: number;
|
|
176
|
+
search: number;
|
|
177
|
+
aiSources: { source: string; sessions: number }[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function trafficMix(site: SiteCfg): TrafficMix {
|
|
181
|
+
const rows = ga4Rows(site, "sources");
|
|
182
|
+
let sessions = 0,
|
|
183
|
+
ai = 0,
|
|
184
|
+
search = 0;
|
|
185
|
+
const aiSources: { source: string; sessions: number }[] = [];
|
|
186
|
+
for (const { dims, mets } of rows) {
|
|
187
|
+
const [source, medium] = dims;
|
|
188
|
+
sessions += mets[0];
|
|
189
|
+
if (AI_SOURCE.test(source)) {
|
|
190
|
+
ai += mets[0];
|
|
191
|
+
aiSources.push({ source: `${source}/${medium}`, sessions: mets[0] });
|
|
192
|
+
} else if (SEARCH_SOURCE.test(source)) {
|
|
193
|
+
search += mets[0];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
aiSources.sort((a, b) => b.sessions - a.sessions);
|
|
197
|
+
return { sessions, ai, search, aiSources };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Sessions in the trailing 28 days vs the 28 before, from the GA4 daily report. */
|
|
201
|
+
export function sessionTrend(site: SiteCfg): { recent: number; prior: number } {
|
|
202
|
+
const rows = ga4Rows(site, "daily")
|
|
203
|
+
.map(({ dims, mets }) => ({ date: dims[0], sessions: mets[0] }))
|
|
204
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
205
|
+
const recent = rows.slice(-28).reduce((s, r) => s + r.sessions, 0);
|
|
206
|
+
const prior = rows.slice(-56, -28).reduce((s, r) => s + r.sessions, 0);
|
|
207
|
+
return { recent, prior };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function landingPages(site: SiteCfg): { page: string; sessions: number; engagement: number }[] {
|
|
211
|
+
return ga4Rows(site, "landing").map(({ dims, mets }) => ({
|
|
212
|
+
page: dims[0],
|
|
213
|
+
sessions: mets[0],
|
|
214
|
+
engagement: mets[1],
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Sessions referred from your OTHER configured sites — measures cross-linking. */
|
|
219
|
+
export function crossReferrals(site: SiteCfg): { source: string; sessions: number }[] {
|
|
220
|
+
// Exact host match (www stripped): with a subdomain layout, a substring
|
|
221
|
+
// test would count docs.example.com's own traffic as a referral from example.com.
|
|
222
|
+
const others = new Set(config().sites.filter((s) => s.host !== site.host).map((s) => s.host.replace(/^www\./, "")));
|
|
223
|
+
return ga4Rows(site, "sources")
|
|
224
|
+
.filter(({ dims }) => others.has(dims[0].toLowerCase().replace(/^www\./, "")))
|
|
225
|
+
.map(({ dims, mets }) => ({ source: dims[0], sessions: mets[0] }))
|
|
226
|
+
.sort((a, b) => b.sessions - a.sessions);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------- probes ----------
|
|
230
|
+
|
|
231
|
+
export interface ProbeSite {
|
|
232
|
+
site: string;
|
|
233
|
+
robots: { status?: number | null; exists?: boolean; sitemap_declared?: boolean; ai_crawlers_blocked?: string[] };
|
|
234
|
+
sitemap: { status?: number | null; exists?: boolean; url_count?: number; newest_lastmod?: string | null };
|
|
235
|
+
"llms.txt": { exists?: boolean; bytes?: number };
|
|
236
|
+
"llms-full.txt": { exists?: boolean; bytes?: number };
|
|
237
|
+
homepage: {
|
|
238
|
+
status?: number | null;
|
|
239
|
+
title?: string;
|
|
240
|
+
meta_description?: string | null;
|
|
241
|
+
canonical?: string | null;
|
|
242
|
+
og_tags?: number;
|
|
243
|
+
jsonld_types?: string[];
|
|
244
|
+
h1_count?: number;
|
|
245
|
+
lang?: string | null;
|
|
246
|
+
visible_text_bytes?: number;
|
|
247
|
+
};
|
|
248
|
+
soft_404: { status?: number | null; real_404?: boolean };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface IndexProblem {
|
|
252
|
+
url: string;
|
|
253
|
+
coverage: string;
|
|
254
|
+
lastCrawl?: string | null;
|
|
255
|
+
verdict?: string;
|
|
256
|
+
robots?: string;
|
|
257
|
+
canonicalMismatch?: boolean;
|
|
258
|
+
googleCanonical?: string | null;
|
|
259
|
+
detail?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface IndexStatus {
|
|
263
|
+
generated: string;
|
|
264
|
+
sites: Record<
|
|
265
|
+
string,
|
|
266
|
+
{
|
|
267
|
+
property: string;
|
|
268
|
+
checked: number;
|
|
269
|
+
indexed: number;
|
|
270
|
+
neverCrawled: number;
|
|
271
|
+
sitemap?: {
|
|
272
|
+
submitted: number;
|
|
273
|
+
entries: {
|
|
274
|
+
path?: string;
|
|
275
|
+
lastSubmitted?: string | null;
|
|
276
|
+
lastDownloaded?: string | null;
|
|
277
|
+
pending?: boolean;
|
|
278
|
+
errors?: number;
|
|
279
|
+
warnings?: number;
|
|
280
|
+
}[];
|
|
281
|
+
};
|
|
282
|
+
problems: IndexProblem[];
|
|
283
|
+
}
|
|
284
|
+
>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Search Console's own verdict on each sitemap URL — written by
|
|
288
|
+
* ingest/pull_index_status.py. Distinguishes "nobody searches for this"
|
|
289
|
+
* from "Google has never fetched this", which look identical in GSC's
|
|
290
|
+
* performance data because both are zero impressions. */
|
|
291
|
+
export function indexStatus(): IndexStatus | null {
|
|
292
|
+
return readJson(path.join(DATA, "index-status.json"));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function latestProbe(): { probed_at: string; sites: ProbeSite[] } | null {
|
|
296
|
+
const dir = path.join(DATA, "probes");
|
|
297
|
+
let files: string[] = [];
|
|
298
|
+
try {
|
|
299
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const last = files.at(-1);
|
|
304
|
+
return last ? readJson(path.join(dir, last)) : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function probeFor(site: SiteCfg | string): ProbeSite | undefined {
|
|
308
|
+
const hosts = typeof site === "string" ? [site] : [site.gscHost, site.host];
|
|
309
|
+
const sites = latestProbe()?.sites ?? [];
|
|
310
|
+
return sites.find((s) => hosts.some((h) => s.site === `https://${h}` || s.site === `http://${h}`));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The checks that make a site "healthy" — the same list ops/daily_diff.py counts. */
|
|
314
|
+
export function probeFindings(p: ProbeSite | undefined): string[] {
|
|
315
|
+
if (!p) return ["no probe data"];
|
|
316
|
+
const out: string[] = [];
|
|
317
|
+
if (!p.robots.exists) out.push("robots.txt missing");
|
|
318
|
+
if (!p.sitemap.exists) out.push("sitemap.xml missing");
|
|
319
|
+
if (!p["llms.txt"].exists) out.push("llms.txt missing");
|
|
320
|
+
if (!p.soft_404.real_404) out.push("soft 404 (missing pages do not return 404)");
|
|
321
|
+
if (p.homepage.status !== 200) out.push(`homepage returned ${p.homepage.status ?? "no status"}`);
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const probeHealthy = (p: ProbeSite | undefined): boolean => !!p && probeFindings(p).length === 0;
|
|
326
|
+
|
|
327
|
+
export function dataFreshness(): { label: string; path: string; mtime: string }[] {
|
|
328
|
+
const spots = [
|
|
329
|
+
{ label: "Search Console pull", path: path.join(DATA, "gsc") },
|
|
330
|
+
{ label: "GA4 pull", path: path.join(DATA, "ga4") },
|
|
331
|
+
{ label: "Site probe", path: path.join(DATA, "probes") },
|
|
332
|
+
{ label: "Metadata audit", path: path.join(DATA, "metadata-audit.json") },
|
|
333
|
+
{ label: "Index status", path: path.join(DATA, "index-status.json") },
|
|
334
|
+
];
|
|
335
|
+
return spots.map((s) => {
|
|
336
|
+
let mtime = "never";
|
|
337
|
+
try {
|
|
338
|
+
const st = fs.statSync(s.path);
|
|
339
|
+
let newest = st.mtime.getTime();
|
|
340
|
+
if (st.isDirectory()) {
|
|
341
|
+
newest =
|
|
342
|
+
fs
|
|
343
|
+
.readdirSync(s.path, { recursive: true, encoding: "utf8" })
|
|
344
|
+
.map((f) => fs.statSync(path.join(s.path, f)).mtime.getTime())
|
|
345
|
+
.sort()
|
|
346
|
+
.at(-1) ?? 0;
|
|
347
|
+
}
|
|
348
|
+
if (newest) mtime = new Date(newest).toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
349
|
+
} catch {
|
|
350
|
+
/* stays "never" */
|
|
351
|
+
}
|
|
352
|
+
return { label: s.label, path: s.path, mtime };
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** How long this process has been running, and how old the queue file is.
|
|
357
|
+
* Everything in dataFreshness() is re-read per call, so a long-lived reader
|
|
358
|
+
* can report fresh data while serving a stale queue if hot-reload ever
|
|
359
|
+
* breaks. Surfacing both makes the mismatch visible instead of silent. */
|
|
360
|
+
export function processFreshness(): {
|
|
361
|
+
startedAt: string;
|
|
362
|
+
uptimeHours: number;
|
|
363
|
+
queueMtime: string;
|
|
364
|
+
} {
|
|
365
|
+
const startedMs = Date.now() - process.uptime() * 1000;
|
|
366
|
+
let queueMs = 0;
|
|
367
|
+
try {
|
|
368
|
+
queueMs = fs.statSync(BACKLOG_PATH).mtime.getTime();
|
|
369
|
+
} catch {
|
|
370
|
+
/* leave 0 */
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
startedAt: new Date(startedMs).toISOString().slice(0, 16).replace("T", " ") + " UTC",
|
|
374
|
+
uptimeHours: Math.round((process.uptime() / 3600) * 10) / 10,
|
|
375
|
+
queueMtime: queueMs ? new Date(queueMs).toISOString().slice(0, 16).replace("T", " ") + " UTC" : "unknown",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------- trends (analysis runs) ----------
|
|
380
|
+
|
|
381
|
+
export interface TrendsFile {
|
|
382
|
+
generated: string;
|
|
383
|
+
sites: Record<string, {
|
|
384
|
+
recent_split?: { branded_clicks: number; generic_clicks: number; branded_imps: number; generic_imps: number };
|
|
385
|
+
prior_split?: { branded_clicks: number; generic_clicks: number; branded_imps: number; generic_imps: number };
|
|
386
|
+
rising?: { query: string; recent_imps: number; prior_imps: number; recent_pos: number; recent_clicks: number }[];
|
|
387
|
+
falling?: { query: string; recent_imps: number; prior_imps: number }[];
|
|
388
|
+
monthly?: Record<string, { clicks: number; imps: number }>;
|
|
389
|
+
}>;
|
|
390
|
+
ai_referrals?: Record<string, { ai: Record<string, number>; total: Record<string, number> }>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function latestTrends(): TrendsFile | null {
|
|
394
|
+
let files: string[] = [];
|
|
395
|
+
try {
|
|
396
|
+
files = fs.readdirSync(DATA).filter((f) => f.startsWith("trends-") && f.endsWith(".json")).sort();
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
const last = files.at(-1);
|
|
401
|
+
return last ? readJson<TrendsFile>(path.join(DATA, last)) : null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------- community digests (opt-in modules) ----------
|
|
405
|
+
|
|
406
|
+
export interface HnDigest {
|
|
407
|
+
generated: string;
|
|
408
|
+
stats?: { user: string; karma?: number; created?: number; comments?: number };
|
|
409
|
+
picks: { id?: string; title: string; url: string; story_url?: string; comments: number; points: number; why: string; briefing?: string; commented?: boolean }[];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function hnDigest(): HnDigest | null {
|
|
413
|
+
return readJson<HnDigest>(path.join(DATA, "hn-digest.json"));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface RedditDigest {
|
|
417
|
+
generated: string;
|
|
418
|
+
user?: string | null;
|
|
419
|
+
auth?: boolean;
|
|
420
|
+
picks: { id: string; title: string; url: string; sub: string; comments: number; score: number; age_days: number; why: string; briefing?: string; commented?: boolean }[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function redditDigest(): RedditDigest | null {
|
|
424
|
+
return readJson<RedditDigest>(path.join(DATA, "reddit-digest.json"));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Reddit search links derived from modules.reddit.topics ([sub, query, why]).
|
|
428
|
+
* Reddit blocks anonymous API reads, so these open Reddit's own past-week
|
|
429
|
+
* search in your logged-in browser — useful even with the digest off. */
|
|
430
|
+
export function redditHunts(): { sub: string; q: string; why: string }[] {
|
|
431
|
+
const topics = (config().modules.reddit?.topics as unknown[] | undefined) ?? [];
|
|
432
|
+
return topics
|
|
433
|
+
.filter((t): t is string[] => Array.isArray(t) && t.length >= 2 && !!t[0])
|
|
434
|
+
.map((t) => ({ sub: String(t[0]).replace(/^r\//, ""), q: String(t[1] ?? ""), why: String(t[2] ?? "") }))
|
|
435
|
+
.filter((t) => !/^subreddit$/i.test(t.sub)); // skip the example placeholder
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------- distribution drafts (content/drafts/*.md) ----------
|
|
439
|
+
|
|
440
|
+
export interface Draft {
|
|
441
|
+
slug: string;
|
|
442
|
+
title: string;
|
|
443
|
+
channel: string;
|
|
444
|
+
status: string;
|
|
445
|
+
/** publish sequence — the checklist is ordered by this */
|
|
446
|
+
order: number;
|
|
447
|
+
/** one imperative line: exactly what you do with this draft */
|
|
448
|
+
action?: string;
|
|
449
|
+
tags?: string;
|
|
450
|
+
notes?: string;
|
|
451
|
+
body: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Curated, committed launch assets: markdown files with simple front-matter.
|
|
455
|
+
* Unlike data/ (gitignored, machine-refreshed), content/drafts/ is
|
|
456
|
+
* hand-curated and versioned — a draft leaves by being deleted or
|
|
457
|
+
* status-flipped. Files starting with "_" and README.md are ignored. */
|
|
458
|
+
export function drafts(): Draft[] {
|
|
459
|
+
const dir = path.join(INSTANCE, "content", "drafts");
|
|
460
|
+
let files: string[] = [];
|
|
461
|
+
try {
|
|
462
|
+
files = fs
|
|
463
|
+
.readdirSync(dir)
|
|
464
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_") && f.toLowerCase() !== "readme.md")
|
|
465
|
+
.sort();
|
|
466
|
+
} catch {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const out: Draft[] = [];
|
|
470
|
+
for (const f of files) {
|
|
471
|
+
let text = "";
|
|
472
|
+
try {
|
|
473
|
+
text = fs.readFileSync(path.join(dir, f), "utf8");
|
|
474
|
+
} catch {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
478
|
+
const meta: Record<string, string> = {};
|
|
479
|
+
let body = text;
|
|
480
|
+
if (m) {
|
|
481
|
+
body = text.slice(m[0].length).trim();
|
|
482
|
+
const lines = m[1].split("\n");
|
|
483
|
+
let key = "";
|
|
484
|
+
for (const line of lines) {
|
|
485
|
+
const kv = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
486
|
+
if (kv) {
|
|
487
|
+
key = kv[1];
|
|
488
|
+
// ">"-folded scalars accumulate from the indented lines below
|
|
489
|
+
meta[key] = kv[2] === ">" ? "" : kv[2].replace(/^"|"$/g, "");
|
|
490
|
+
} else if (key && /^\s+\S/.test(line)) {
|
|
491
|
+
meta[key] = (meta[key] ? meta[key] + " " : "") + line.trim();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
out.push({
|
|
496
|
+
slug: f.replace(/\.md$/, ""),
|
|
497
|
+
title: meta.title || f,
|
|
498
|
+
channel: meta.channel || "—",
|
|
499
|
+
status: meta.status || "draft",
|
|
500
|
+
order: Number(meta.order) || 99,
|
|
501
|
+
action: meta.action,
|
|
502
|
+
tags: meta.tags,
|
|
503
|
+
notes: meta.notes,
|
|
504
|
+
body,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return out.sort((a, b) => a.order - b.order);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function draftBySlug(slug: string): Draft | undefined {
|
|
511
|
+
return drafts().find((d) => d.slug === slug);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------- campaigns (content/campaigns/*.json) ----------
|
|
515
|
+
|
|
516
|
+
export interface Campaign {
|
|
517
|
+
slug: string;
|
|
518
|
+
name: string;
|
|
519
|
+
site?: string;
|
|
520
|
+
summary?: string;
|
|
521
|
+
voice?: string;
|
|
522
|
+
targets: { rank: number; name: string; category: string; url: string; contact: string; angle: string; value: string; likelihood: string; evidence?: string; status?: string }[];
|
|
523
|
+
templates: { id: string; audience: string; subject: string; body: string }[];
|
|
524
|
+
plan: { day: string; action: string; template: string; notes: string }[];
|
|
525
|
+
week2?: string;
|
|
526
|
+
later?: string;
|
|
527
|
+
cautions: string[];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function campaigns(): Campaign[] {
|
|
531
|
+
const dir = path.join(INSTANCE, "content", "campaigns");
|
|
532
|
+
let files: string[] = [];
|
|
533
|
+
try {
|
|
534
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith("_")).sort();
|
|
535
|
+
} catch {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
const out: Campaign[] = [];
|
|
539
|
+
for (const f of files) {
|
|
540
|
+
const raw = readJson<Partial<Campaign>>(path.join(dir, f));
|
|
541
|
+
if (!raw) continue;
|
|
542
|
+
const slug = raw.slug || f.replace(/\.json$/, "");
|
|
543
|
+
out.push({
|
|
544
|
+
slug,
|
|
545
|
+
name: raw.name || slug,
|
|
546
|
+
site: raw.site,
|
|
547
|
+
summary: raw.summary,
|
|
548
|
+
voice: raw.voice,
|
|
549
|
+
targets: raw.targets ?? [],
|
|
550
|
+
templates: raw.templates ?? [],
|
|
551
|
+
plan: raw.plan ?? [],
|
|
552
|
+
week2: raw.week2,
|
|
553
|
+
later: raw.later,
|
|
554
|
+
cautions: raw.cautions ?? [],
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function campaignBySlug(slug: string): Campaign | undefined {
|
|
561
|
+
return campaigns().find((c) => c.slug === slug);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------- opportunity scan (automated discovery) ----------
|
|
565
|
+
|
|
566
|
+
export interface ScanOutput {
|
|
567
|
+
generated: string;
|
|
568
|
+
candidates: { host: string; query: string; prior_imps: number; recent_imps: number; recent_pos: number }[];
|
|
569
|
+
proposals: { host: string; title: string; kind: string; why: string; how: string; spec: string[]; impact: number; effort: string; tag: string }[];
|
|
570
|
+
verdicts: { title: string; verdict: string; evidence: string }[];
|
|
571
|
+
inference_ran: boolean;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function opportunityScan(): ScanOutput | null {
|
|
575
|
+
const s = readJson<Partial<ScanOutput>>(path.join(DATA, "opportunity-proposals.json"));
|
|
576
|
+
if (!s) return null;
|
|
577
|
+
return {
|
|
578
|
+
generated: s.generated ?? "",
|
|
579
|
+
candidates: s.candidates ?? [],
|
|
580
|
+
proposals: s.proposals ?? [],
|
|
581
|
+
verdicts: s.verdicts ?? [],
|
|
582
|
+
inference_ran: !!s.inference_ran,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---------- conversions (your real goal, from GA4 key events) ----------
|
|
587
|
+
|
|
588
|
+
export interface FunnelDay { date: string; event: string; sourceApp: string; count: number }
|
|
589
|
+
|
|
590
|
+
export function funnelEvents(site: SiteCfg): FunnelDay[] {
|
|
591
|
+
const rows = ga4Rows(site, "funnel");
|
|
592
|
+
return rows.map(({ dims, mets }) => ({
|
|
593
|
+
date: `${dims[0].slice(0, 4)}-${dims[0].slice(4, 6)}-${dims[0].slice(6, 8)}`,
|
|
594
|
+
event: dims[1],
|
|
595
|
+
sourceApp: dims[2] ?? "(source dimension not registered)",
|
|
596
|
+
count: mets[0],
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export interface FunnelSummary {
|
|
601
|
+
configured: boolean;
|
|
602
|
+
instrumented: boolean;
|
|
603
|
+
site?: string;
|
|
604
|
+
events: string[];
|
|
605
|
+
total28: Record<string, number>;
|
|
606
|
+
bySource: Record<string, number>;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function funnelSummary(): FunnelSummary {
|
|
610
|
+
const conv = config().conversions;
|
|
611
|
+
if (!conv) return { configured: false, instrumented: false, events: [], total28: {}, bySource: {} };
|
|
612
|
+
const site = config().sites.find((s) => s.host === conv.site) ?? ({ host: conv.site, gscHost: conv.site, label: conv.site } as SiteCfg);
|
|
613
|
+
const rows = funnelEvents(site);
|
|
614
|
+
const cutoff = new Date(Date.now() - 28 * 86400000).toISOString().slice(0, 10);
|
|
615
|
+
const recent = rows.filter((r) => r.date >= cutoff);
|
|
616
|
+
const total28: Record<string, number> = {};
|
|
617
|
+
const bySource: Record<string, number> = {};
|
|
618
|
+
const primary = conv.events[0];
|
|
619
|
+
for (const r of recent) {
|
|
620
|
+
total28[r.event] = (total28[r.event] ?? 0) + r.count;
|
|
621
|
+
if (r.event === primary) bySource[r.sourceApp || "(unset)"] = (bySource[r.sourceApp || "(unset)"] ?? 0) + r.count;
|
|
622
|
+
}
|
|
623
|
+
return { configured: true, instrumented: rows.length > 0, site: conv.site, events: conv.events, total28, bySource };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---------- the Today board: every move waiting on you, derived live ----------
|
|
627
|
+
|
|
628
|
+
export interface TodayItem { title: string; href: string; chip?: string; }
|
|
629
|
+
export interface TodayGroup { verb: string; blurb: string; items: TodayItem[]; }
|
|
630
|
+
|
|
631
|
+
/** Aggregates "what should I do right now" across every data source the
|
|
632
|
+
* dashboard already has — probes, the daily run, drafts, digests, the scan —
|
|
633
|
+
* so the answer lives on one board instead of five pages. The queue's top
|
|
634
|
+
* items (Ship) are appended by the view, which owns the action engine. */
|
|
635
|
+
export function todayBoard(): TodayGroup[] {
|
|
636
|
+
const groups: TodayGroup[] = [];
|
|
637
|
+
const mods = config().modules;
|
|
638
|
+
|
|
639
|
+
// FIX — system health first
|
|
640
|
+
const fix: TodayItem[] = [];
|
|
641
|
+
const lr = lastRun();
|
|
642
|
+
if (lr && lr.failures.trim()) fix.push({ title: `The daily run failed: ${lr.failures.trim()}`, href: "/logs", chip: "run" });
|
|
643
|
+
const probe = latestProbe();
|
|
644
|
+
for (const p of probe?.sites ?? []) {
|
|
645
|
+
const missing: string[] = [];
|
|
646
|
+
if (!p.robots.exists) missing.push("robots.txt");
|
|
647
|
+
if (!p.sitemap.exists) missing.push("sitemap");
|
|
648
|
+
if (!p["llms.txt"].exists) missing.push("llms.txt");
|
|
649
|
+
if (!p.soft_404.real_404) missing.push("soft 404s");
|
|
650
|
+
if (missing.length) fix.push({ title: `${p.site.replace(/^https?:\/\//, "")} — ${missing.join(", ")} missing`, href: "/probes", chip: "probe" });
|
|
651
|
+
}
|
|
652
|
+
if (fix.length) groups.push({ verb: "Fix", blurb: "health regressions the probes or the daily run flagged", items: fix });
|
|
653
|
+
|
|
654
|
+
// APPROVE — things blocked on your sign-off
|
|
655
|
+
const approve: TodayItem[] = [];
|
|
656
|
+
for (const d of drafts().filter((d) => d.status.includes("approval"))) {
|
|
657
|
+
approve.push({ title: d.title, href: `/drafts/${d.slug}`, chip: d.channel });
|
|
658
|
+
}
|
|
659
|
+
const scan = opportunityScan();
|
|
660
|
+
if (scan?.proposals.length) approve.push({ title: `${scan.proposals.length} scan proposal${scan.proposals.length > 1 ? "s" : ""} from the daily riser scan`, href: "/actions#proposed", chip: "scan" });
|
|
661
|
+
if (approve.length) groups.push({ verb: "Approve", blurb: "waiting on your yes/no — the cheapest unblocks on the board", items: approve });
|
|
662
|
+
|
|
663
|
+
// PUBLISH — drafts, readiest first
|
|
664
|
+
const pub = drafts().filter((d) => !d.status.includes("approval"));
|
|
665
|
+
if (pub.length) {
|
|
666
|
+
groups.push({
|
|
667
|
+
verb: "Publish",
|
|
668
|
+
blurb: "finished assets — open one, make it yours, post it",
|
|
669
|
+
items: pub.map((d) => ({ title: d.title, href: `/drafts/${d.slug}`, chip: d.status.startsWith("ready") ? "ready" : "voice pass" })),
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// COMMENT — today's participation (only with a digest module on)
|
|
674
|
+
if (mods.hackerNews?.enabled || mods.reddit?.enabled) {
|
|
675
|
+
const comment: TodayItem[] = [];
|
|
676
|
+
if (mods.hackerNews?.enabled) {
|
|
677
|
+
const hn = hnDigest();
|
|
678
|
+
const fresh = (hn?.picks ?? []).filter((p) => !p.commented).length;
|
|
679
|
+
if (fresh) comment.push({ title: `${fresh} fresh HN thread${fresh > 1 ? "s" : ""} briefed for you`, href: "/content#participate", chip: "HN" });
|
|
680
|
+
}
|
|
681
|
+
if (mods.reddit?.enabled) {
|
|
682
|
+
const rd = redditDigest();
|
|
683
|
+
const fresh = (rd?.picks ?? []).filter((p) => !p.commented).length;
|
|
684
|
+
comment.push({ title: fresh ? `${fresh} Reddit thread${fresh > 1 ? "s" : ""} briefed` : "Reddit hunt links — one click per community", href: "/content#participate", chip: "Reddit" });
|
|
685
|
+
}
|
|
686
|
+
if (comment.length) groups.push({ verb: "Comment", blurb: "a few genuine comments a day; briefings ready, the words are yours", items: comment });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return groups;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------- stylesheet cache-busting ----------
|
|
693
|
+
|
|
694
|
+
/** mtime-derived version for /styles.css?v=… — a CSS change invalidates
|
|
695
|
+
* browser/mirror caches without manual purges. */
|
|
696
|
+
export function styleVersion(): string {
|
|
697
|
+
try {
|
|
698
|
+
return String(Math.floor(fs.statSync(path.join(ROOT, "public", "styles.css")).mtimeMs)).slice(-8);
|
|
699
|
+
} catch {
|
|
700
|
+
return "0";
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---------- logs ----------
|
|
705
|
+
|
|
706
|
+
/** docs/daily-log.md split into dated sections, newest first */
|
|
707
|
+
export function dailyLogSections(): { heading: string; lines: string[] }[] {
|
|
708
|
+
let text = "";
|
|
709
|
+
try {
|
|
710
|
+
text = fs.readFileSync(path.join(INSTANCE, "docs", "daily-log.md"), "utf8");
|
|
711
|
+
} catch {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
714
|
+
const sections: { heading: string; lines: string[] }[] = [];
|
|
715
|
+
let cur: { heading: string; lines: string[] } | null = null;
|
|
716
|
+
for (const line of text.split("\n")) {
|
|
717
|
+
if (line.startsWith("## ")) {
|
|
718
|
+
if (cur) sections.push(cur);
|
|
719
|
+
cur = { heading: line.slice(3).trim(), lines: [] };
|
|
720
|
+
} else if (cur && line.trim()) {
|
|
721
|
+
cur.lines.push(line.trim());
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (cur) sections.push(cur);
|
|
725
|
+
return sections.reverse();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function opsLogTail(maxLines = 150): string {
|
|
729
|
+
try {
|
|
730
|
+
const text = fs.readFileSync(path.join(DATA, "daily-ops.log"), "utf8");
|
|
731
|
+
return text.split("\n").slice(-maxLines).join("\n");
|
|
732
|
+
} catch {
|
|
733
|
+
return "(no daily-ops.log yet — ops/daily.py writes it)";
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export interface LastRun { ts: string; failures: string; steps?: { name: string; ok: boolean; seconds?: number }[] }
|
|
738
|
+
|
|
739
|
+
export function lastRun(): LastRun | null {
|
|
740
|
+
const r = readJson<Partial<LastRun>>(path.join(DATA, "last-run.json"));
|
|
741
|
+
if (!r) return null;
|
|
742
|
+
return { ts: r.ts ?? "", failures: r.failures ?? "", steps: r.steps };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ---------- time series (Trends view) ----------
|
|
746
|
+
|
|
747
|
+
export interface DayPage { date: string; page: string; clicks: number; impressions: number }
|
|
748
|
+
|
|
749
|
+
/** GSC date×page rows for this site's host, trailing ~180d */
|
|
750
|
+
export function gscTimeseries(site: SiteCfg): DayPage[] {
|
|
751
|
+
const dir = gscDir(site);
|
|
752
|
+
if (!dir) return [];
|
|
753
|
+
const d = readJson<{ rows?: { keys: string[]; clicks: number; impressions: number }[] }>(
|
|
754
|
+
path.join(DATA, "timeseries", `gsc-${dir}.json`));
|
|
755
|
+
return (d?.rows ?? [])
|
|
756
|
+
.filter((r) => hostOf(r.keys[1]) === site.gscHost)
|
|
757
|
+
.map((r) => ({ date: r.keys[0], page: r.keys[1], clicks: r.clicks, impressions: r.impressions }));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** GA4 date×pagePath sessions, trailing 180d (dates as YYYYMMDD in source) */
|
|
761
|
+
export function ga4Timeseries(site: SiteCfg): { date: string; page: string; sessions: number }[] {
|
|
762
|
+
const d = readJson<{ rows?: { date: string; page: string; sessions: number }[] }>(
|
|
763
|
+
path.join(DATA, "timeseries", `ga4-${site.host}.json`));
|
|
764
|
+
return (d?.rows ?? []).map((r) => ({
|
|
765
|
+
date: `${r.date.slice(0, 4)}-${r.date.slice(4, 6)}-${r.date.slice(6, 8)}`,
|
|
766
|
+
page: r.page,
|
|
767
|
+
sessions: r.sessions,
|
|
768
|
+
}));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export interface SourceMixDay {
|
|
772
|
+
date: string;
|
|
773
|
+
total: number;
|
|
774
|
+
/** sessions per group, every group present (0 when none) */
|
|
775
|
+
groups: Record<SourceGroup, number>;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Daily sessions per traffic group, from data/timeseries/ga4-sources-<host>.json.
|
|
780
|
+
* Returns [] when the site has no GA4 property or the pull has not run — the
|
|
781
|
+
* caller says so rather than drawing an empty chart that reads as zero traffic.
|
|
782
|
+
*/
|
|
783
|
+
/** Every configured site's source mix, summed per day.
|
|
784
|
+
* The per-site tiles on /trends are collapsed by default, so without this the
|
|
785
|
+
* page answers "where is my traffic coming from" only after a click. */
|
|
786
|
+
export function portfolioSourceMix(): SourceMixDay[] {
|
|
787
|
+
const byDate = new Map<string, SourceMixDay>();
|
|
788
|
+
for (const site of config().sites) {
|
|
789
|
+
for (const d of sourceMixTimeseries(site)) {
|
|
790
|
+
let day = byDate.get(d.date);
|
|
791
|
+
if (!day) {
|
|
792
|
+
day = { date: d.date, total: 0, groups: Object.fromEntries(SOURCE_GROUPS.map((g) => [g, 0])) as Record<SourceGroup, number> };
|
|
793
|
+
byDate.set(d.date, day);
|
|
794
|
+
}
|
|
795
|
+
for (const g of SOURCE_GROUPS) day.groups[g] += d.groups[g];
|
|
796
|
+
day.total += d.total;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function sourceMixTimeseries(site: SiteCfg): SourceMixDay[] {
|
|
803
|
+
const d = readJson<{ rows?: { date: string; source: string; medium: string; sessions: number }[] }>(
|
|
804
|
+
path.join(DATA, "timeseries", `ga4-sources-${site.host}.json`));
|
|
805
|
+
const byDate = new Map<string, SourceMixDay>();
|
|
806
|
+
for (const r of d?.rows ?? []) {
|
|
807
|
+
const date = `${r.date.slice(0, 4)}-${r.date.slice(4, 6)}-${r.date.slice(6, 8)}`;
|
|
808
|
+
let day = byDate.get(date);
|
|
809
|
+
if (!day) {
|
|
810
|
+
day = { date, total: 0, groups: Object.fromEntries(SOURCE_GROUPS.map((g) => [g, 0])) as Record<SourceGroup, number> };
|
|
811
|
+
byDate.set(date, day);
|
|
812
|
+
}
|
|
813
|
+
const n = Number(r.sessions) || 0;
|
|
814
|
+
day.groups[classifySource(r.source, r.medium)] += n;
|
|
815
|
+
day.total += n;
|
|
816
|
+
}
|
|
817
|
+
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function lastNDates(n: number, endOffsetDays = 3): string[] {
|
|
821
|
+
const out: string[] = [];
|
|
822
|
+
const end = new Date(Date.now() - endOffsetDays * 86400000);
|
|
823
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
824
|
+
out.push(new Date(end.getTime() - i * 86400000).toISOString().slice(0, 10));
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ---------- metadata audit ----------
|
|
830
|
+
|
|
831
|
+
export interface MetaFinding {
|
|
832
|
+
page: string; title: string; description: string;
|
|
833
|
+
imps: number; clicks: number; issues: string[];
|
|
834
|
+
top_queries: { q: string; imps: number; clicks: number; pos: number; ctr: number }[];
|
|
835
|
+
missed_clicks_window: number;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function metadataAudit(): { generated: string; sites: Record<string, MetaFinding[]> } | null {
|
|
839
|
+
return readJson(path.join(DATA, "metadata-audit.json"));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ---------- markdown docs (rendered in-app) ----------
|
|
843
|
+
|
|
844
|
+
const escapeHtml = (t: string) =>
|
|
845
|
+
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
846
|
+
|
|
847
|
+
/** Minimal markdown → HTML: headings, tables, lists, bold, links, code, paragraphs. */
|
|
848
|
+
export function mdToHtml(md: string): string {
|
|
849
|
+
const inline = (t: string) =>
|
|
850
|
+
t.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
|
851
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
852
|
+
.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
853
|
+
const lines = md.split("\n");
|
|
854
|
+
const out: string[] = [];
|
|
855
|
+
let i = 0, inList = false;
|
|
856
|
+
const closeList = () => { if (inList) { out.push("</ul>"); inList = false; } };
|
|
857
|
+
while (i < lines.length) {
|
|
858
|
+
const raw = lines[i];
|
|
859
|
+
const line = escapeHtml(raw);
|
|
860
|
+
if (/^\s*\|/.test(raw) && /^\s*\|/.test(lines[i + 1] ?? "") ) {
|
|
861
|
+
closeList();
|
|
862
|
+
const rows: string[][] = [];
|
|
863
|
+
while (i < lines.length && /^\s*\|/.test(lines[i])) {
|
|
864
|
+
const cells = lines[i].trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
865
|
+
if (!cells.every((c) => /^:?-+:?$/.test(c))) rows.push(cells.map(escapeHtml));
|
|
866
|
+
i++;
|
|
867
|
+
}
|
|
868
|
+
out.push('<div class="tbl-wrap"><table><thead><tr>' +
|
|
869
|
+
rows[0].map((c) => `<th>${inline(c)}</th>`).join("") + "</tr></thead><tbody>" +
|
|
870
|
+
rows.slice(1).map((r) => "<tr>" + r.map((c) => `<td>${inline(c)}</td>`).join("") + "</tr>").join("") +
|
|
871
|
+
"</tbody></table></div>");
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (/^### /.test(raw)) { closeList(); out.push(`<h4>${inline(line.slice(4))}</h4>`); }
|
|
875
|
+
else if (/^## /.test(raw)) { closeList(); out.push(`<h3>${inline(line.slice(3))}</h3>`); }
|
|
876
|
+
else if (/^# /.test(raw)) { closeList(); out.push(`<h3>${inline(line.slice(2))}</h3>`); }
|
|
877
|
+
else if (/^\s*[-*] /.test(raw)) {
|
|
878
|
+
if (!inList) { out.push('<ul class="md-list">'); inList = true; }
|
|
879
|
+
out.push(`<li>${inline(line.replace(/^\s*[-*] /, ""))}</li>`);
|
|
880
|
+
}
|
|
881
|
+
else if (raw.trim() === "") { closeList(); }
|
|
882
|
+
else { closeList(); out.push(`<p>${inline(line)}</p>`); }
|
|
883
|
+
i++;
|
|
884
|
+
}
|
|
885
|
+
closeList();
|
|
886
|
+
return out.join("\n");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export function docHtml(name: string): string | null {
|
|
890
|
+
try {
|
|
891
|
+
return mdToHtml(fs.readFileSync(path.join(ROOT, "docs", name), "utf8"));
|
|
892
|
+
} catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|