@cloudhal/mcp-server 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/README.md +18 -2
- package/dist/src/analytics-reports.d.ts +164 -0
- package/dist/src/analytics-reports.js +632 -0
- package/dist/src/analytics-tools.d.ts +4 -0
- package/dist/src/analytics-tools.js +597 -0
- package/dist/src/asset-tools.d.ts +5 -0
- package/dist/src/asset-tools.js +685 -0
- package/dist/src/body-tools.d.ts +46 -0
- package/dist/src/body-tools.js +708 -0
- package/dist/src/changes.d.ts +10 -0
- package/dist/src/changes.js +112 -0
- package/dist/src/chrome-tools.d.ts +8 -0
- package/dist/src/chrome-tools.js +207 -0
- package/dist/src/comment-tools.d.ts +9 -0
- package/dist/src/comment-tools.js +743 -0
- package/dist/src/comments.d.ts +184 -0
- package/dist/src/comments.js +408 -0
- package/dist/src/common.d.ts +258 -0
- package/dist/src/common.js +488 -0
- package/dist/src/content-shared.d.ts +50 -0
- package/dist/src/content-shared.js +102 -0
- package/dist/src/design-tools.d.ts +9 -0
- package/dist/src/design-tools.js +349 -0
- package/dist/src/elevation-tools.d.ts +3 -0
- package/dist/src/elevation-tools.js +331 -0
- package/dist/src/entry-terms.d.ts +14 -0
- package/dist/src/entry-terms.js +51 -0
- package/dist/src/entry-tools.d.ts +4 -0
- package/dist/src/entry-tools.js +442 -0
- package/dist/src/http.d.ts +48 -0
- package/dist/src/http.js +101 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/menu-tools.d.ts +41 -0
- package/dist/src/menu-tools.js +1058 -0
- package/dist/src/page-tools.d.ts +5 -0
- package/dist/src/page-tools.js +311 -0
- package/dist/src/preview-doc.d.ts +9 -0
- package/dist/src/preview-doc.js +56 -0
- package/dist/src/preview-view.d.ts +19 -0
- package/dist/src/preview-view.js +71 -0
- package/dist/src/publish-tools.d.ts +10 -0
- package/dist/src/publish-tools.js +248 -0
- package/dist/src/render/cdp.d.ts +29 -0
- package/dist/src/render/cdp.js +190 -0
- package/dist/src/render/checks/components.d.ts +5 -0
- package/dist/src/render/checks/components.js +95 -0
- package/dist/src/render/checks/content.d.ts +5 -0
- package/dist/src/render/checks/content.js +63 -0
- package/dist/src/render/checks/contrast.d.ts +5 -0
- package/dist/src/render/checks/contrast.js +72 -0
- package/dist/src/render/checks/index.d.ts +1 -0
- package/dist/src/render/checks/index.js +29 -0
- package/dist/src/render/checks/layout.d.ts +5 -0
- package/dist/src/render/checks/layout.js +44 -0
- package/dist/src/render/checks/motion.d.ts +5 -0
- package/dist/src/render/checks/motion.js +66 -0
- package/dist/src/render/checks/navigation.d.ts +5 -0
- package/dist/src/render/checks/navigation.js +78 -0
- package/dist/src/render/checks/prelude.d.ts +1 -0
- package/dist/src/render/checks/prelude.js +64 -0
- package/dist/src/render/limiter.d.ts +11 -0
- package/dist/src/render/limiter.js +29 -0
- package/dist/src/render/page.d.ts +19 -0
- package/dist/src/render/page.js +135 -0
- package/dist/src/render/types.d.ts +89 -0
- package/dist/src/render/types.js +140 -0
- package/dist/src/render-tools.d.ts +14 -0
- package/dist/src/render-tools.js +314 -0
- package/dist/src/render.d.ts +14 -0
- package/dist/src/render.js +33 -0
- package/dist/src/schedule-tools.d.ts +2 -0
- package/dist/src/schedule-tools.js +1596 -0
- package/dist/src/schedules.d.ts +292 -0
- package/dist/src/schedules.js +890 -0
- package/dist/src/schema-transforms.d.ts +14 -0
- package/dist/src/schema-transforms.js +50 -0
- package/dist/src/search-tools.d.ts +2 -0
- package/dist/src/search-tools.js +112 -0
- package/dist/src/server-approvals.d.ts +51 -0
- package/dist/src/server-approvals.js +273 -0
- package/dist/src/server-call.d.ts +28 -0
- package/dist/src/server-call.js +259 -0
- package/dist/src/server-instructions.d.ts +4 -0
- package/dist/src/server-instructions.js +74 -0
- package/dist/src/server-resources.d.ts +4 -0
- package/dist/src/server-resources.js +83 -0
- package/dist/src/server-schema.d.ts +20 -0
- package/dist/src/server-schema.js +82 -0
- package/dist/src/server.d.ts +80 -0
- package/dist/src/server.js +135 -0
- package/dist/src/stdio.d.ts +9 -0
- package/dist/src/stdio.js +18 -0
- package/dist/src/surface-tools.d.ts +4 -0
- package/dist/src/surface-tools.js +357 -0
- package/dist/src/taxonomy-tools.d.ts +16 -0
- package/dist/src/taxonomy-tools.js +1433 -0
- package/dist/src/terms.d.ts +47 -0
- package/dist/src/terms.js +271 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +511 -0
- package/dist/src/translation-tools.d.ts +3 -0
- package/dist/src/translation-tools.js +422 -0
- package/package.json +86 -3
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics reports shared by the REST routes (`packages/api/src/routes/analytics.ts`) and the MCP
|
|
3
|
+
* tools (`analytics-tools.ts`), RFC-0006 §9: queries of the model through `AnalyticsService`, joined
|
|
4
|
+
* to the **current** graph by node id (a renamed article shows its new title and path; a deleted
|
|
5
|
+
* one reads as gone). Every string returned is graph data, an enum, a hostname, a UTM token or —
|
|
6
|
+
* for render reports only — a percent-encoded requested path (§5.5, invariant A9).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
import { ID_RE, parseRange, privacySignalShare } from "@cloudhal/analytics";
|
|
23
|
+
import { previousRange } from "@cloudhal/analytics/service";
|
|
24
|
+
import { entryPath } from "@cloudhal/astro-renderer/routing";
|
|
25
|
+
import { archivePath } from "@cloudhal/astro-renderer/taxonomy";
|
|
26
|
+
|
|
27
|
+
import { termName } from "@cloudhal/core-ts";
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/** A node of analytics rows as the current graph knows it. */
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/** Where a combined answer came from (the REST envelope's `source`, `estimated`, `partial`). */
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
export function combineMeta(results ) {
|
|
53
|
+
const sources = new Set(results.map((r) => r.source));
|
|
54
|
+
const source =
|
|
55
|
+
sources.size === 1
|
|
56
|
+
? ([...sources][0] )
|
|
57
|
+
: sources.size === 0
|
|
58
|
+
? "live"
|
|
59
|
+
: "mixed";
|
|
60
|
+
const partial = results.some((r) => r.partial === true);
|
|
61
|
+
return {
|
|
62
|
+
source,
|
|
63
|
+
estimated: results.some((r) => r.estimated),
|
|
64
|
+
...(partial ? { partial: true } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const KIND_BY_PREFIX = {
|
|
69
|
+
page: "page",
|
|
70
|
+
entry: "entry",
|
|
71
|
+
term: "archive",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function nodeKindOf(id ) {
|
|
75
|
+
return KIND_BY_PREFIX[id.slice(0, id.indexOf("_"))] ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Individual reads up to this many entries; beyond it one list read (D1 caps queries per call). */
|
|
79
|
+
const ENTRY_GETS = 20;
|
|
80
|
+
|
|
81
|
+
/** The current graph's view of analytics node ids, in a bounded number of reads. */
|
|
82
|
+
export async function analyticsNodes(
|
|
83
|
+
store ,
|
|
84
|
+
site ,
|
|
85
|
+
ids ,
|
|
86
|
+
) {
|
|
87
|
+
const unique = [...new Set(ids)].filter((id) => ID_RE.test(id));
|
|
88
|
+
const byKind = (k ) => unique.filter((id) => nodeKindOf(id) === k);
|
|
89
|
+
const out = new Map ();
|
|
90
|
+
const gone = (nodeId ) => ({
|
|
91
|
+
nodeId,
|
|
92
|
+
kind: nodeKindOf(nodeId),
|
|
93
|
+
exists: false,
|
|
94
|
+
title: null,
|
|
95
|
+
path: null,
|
|
96
|
+
status: null,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const pageIds = byKind("page");
|
|
100
|
+
if (pageIds.length > 0) {
|
|
101
|
+
const pages = new Map ((await store.listPages(site.id)).map((p) => [p.id, p]));
|
|
102
|
+
for (const id of pageIds) {
|
|
103
|
+
const p = pages.get(id);
|
|
104
|
+
out.set(
|
|
105
|
+
id,
|
|
106
|
+
p
|
|
107
|
+
? {
|
|
108
|
+
nodeId: id,
|
|
109
|
+
kind: "page",
|
|
110
|
+
exists: true,
|
|
111
|
+
title: p.title,
|
|
112
|
+
path: p.path,
|
|
113
|
+
status: p.status,
|
|
114
|
+
}
|
|
115
|
+
: gone(id),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entryIds = byKind("entry");
|
|
121
|
+
if (entryIds.length > 0) {
|
|
122
|
+
let entries ;
|
|
123
|
+
if (entryIds.length <= ENTRY_GETS) {
|
|
124
|
+
const found = await Promise.all(entryIds.map((id) => store.getContentEntry(id)));
|
|
125
|
+
entries = new Map(
|
|
126
|
+
found.filter((e) => e?.siteId === site.id).map((e) => [e.id, e]),
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
const wanted = new Set(entryIds);
|
|
130
|
+
entries = new Map(
|
|
131
|
+
(await store.listContentEntries(site.id))
|
|
132
|
+
.filter((e) => wanted.has(e.id))
|
|
133
|
+
.map((e) => [e.id, e]),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
for (const id of entryIds) {
|
|
137
|
+
const e = entries.get(id);
|
|
138
|
+
out.set(
|
|
139
|
+
id,
|
|
140
|
+
e
|
|
141
|
+
? {
|
|
142
|
+
nodeId: id,
|
|
143
|
+
kind: "entry",
|
|
144
|
+
exists: true,
|
|
145
|
+
title: e.title,
|
|
146
|
+
path: entryPath(e),
|
|
147
|
+
status: e.status,
|
|
148
|
+
}
|
|
149
|
+
: gone(id),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const termIds = byKind("archive");
|
|
155
|
+
if (termIds.length > 0) {
|
|
156
|
+
const [terms, taxonomies] = await Promise.all([
|
|
157
|
+
store.listTerms(site.id),
|
|
158
|
+
store.listTaxonomies(site.id),
|
|
159
|
+
]);
|
|
160
|
+
const termById = new Map (terms.map((t) => [t.id, t]));
|
|
161
|
+
const taxById = new Map (taxonomies.map((t) => [t.id, t]));
|
|
162
|
+
for (const id of termIds) {
|
|
163
|
+
const t = termById.get(id);
|
|
164
|
+
const tax = t ? taxById.get(t.taxonomyId) : undefined;
|
|
165
|
+
out.set(
|
|
166
|
+
id,
|
|
167
|
+
t
|
|
168
|
+
? {
|
|
169
|
+
nodeId: id,
|
|
170
|
+
kind: "archive",
|
|
171
|
+
exists: true,
|
|
172
|
+
title: termName(t, site.defaultLocale),
|
|
173
|
+
path: tax?.archive.enabled ? archivePath(tax, t.slug) : null,
|
|
174
|
+
status: t.status,
|
|
175
|
+
}
|
|
176
|
+
: gone(id),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const id of ids) if (!out.has(id)) out.set(id, gone(id));
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ───────────────────────────── summary ─────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
const ratio = (cur , prev ) =>
|
|
212
|
+
cur === null || prev === null || prev === 0
|
|
213
|
+
? null
|
|
214
|
+
: Math.round(((cur - prev) / prev) * 1000) / 1000;
|
|
215
|
+
|
|
216
|
+
export async function summaryReport(
|
|
217
|
+
analytics ,
|
|
218
|
+
siteId ,
|
|
219
|
+
range ,
|
|
220
|
+
options = {},
|
|
221
|
+
) {
|
|
222
|
+
const filter = options.filter ? { filter: options.filter } : {};
|
|
223
|
+
const current = await analytics.query({ kind: "summary", siteId, range, ...filter });
|
|
224
|
+
const cur = current.data;
|
|
225
|
+
const base = {
|
|
226
|
+
...cur,
|
|
227
|
+
viewsPerVisit: cur.visits > 0 ? Math.round((cur.pageviews / cur.visits) * 100) / 100 : null,
|
|
228
|
+
cacheHitRatio: analytics.cacheHitRatio(cur),
|
|
229
|
+
privacySignalShare: privacySignalShare(cur),
|
|
230
|
+
};
|
|
231
|
+
if (!options.compare) return { data: base, results: [current] };
|
|
232
|
+
const prevRange = previousRange(range);
|
|
233
|
+
const previous = await analytics.query({ kind: "summary", siteId, range: prevRange, ...filter });
|
|
234
|
+
const prev = previous.data;
|
|
235
|
+
return {
|
|
236
|
+
data: {
|
|
237
|
+
...base,
|
|
238
|
+
previous: { ...prev, range: prevRange },
|
|
239
|
+
change: {
|
|
240
|
+
pageviews: ratio(cur.pageviews, prev.pageviews),
|
|
241
|
+
visits: ratio(cur.visits, prev.visits),
|
|
242
|
+
visitors: ratio(cur.visitors, prev.visitors),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
results: [current, previous],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ───────────────────────────── possible bot traffic ─────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** A reason of the signal; `single_page` carries the page as the current graph knows it. */
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The "possible bot traffic" signal (RFC-0006 §5.5) for REST `…/analytics/bots` and
|
|
260
|
+
* `get_analytics`: the period's last 7 days by the hour; a `single_page` reason is joined to the
|
|
261
|
+
* graph (title and current path), so people and agents see which page drew the burst.
|
|
262
|
+
*/
|
|
263
|
+
export async function botTrafficReport(
|
|
264
|
+
analytics ,
|
|
265
|
+
store ,
|
|
266
|
+
site ,
|
|
267
|
+
range ,
|
|
268
|
+
) {
|
|
269
|
+
const { signal, results } = await analytics.botTraffic(site.id, range);
|
|
270
|
+
const page = signal.reasons.find((r) => r.code === "single_page")?.key;
|
|
271
|
+
if (!page) return { data: signal, results };
|
|
272
|
+
const nodes = await analyticsNodes(store, site, [page]);
|
|
273
|
+
return {
|
|
274
|
+
data: {
|
|
275
|
+
...signal,
|
|
276
|
+
reasons: signal.reasons.map((r) =>
|
|
277
|
+
r.code === "single_page" && r.key ? { ...r, node: nodes.get(r.key) } : r,
|
|
278
|
+
),
|
|
279
|
+
},
|
|
280
|
+
results,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ───────────────────────────── top lists ─────────────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
/** Top content joined to the graph. `kind` and `channel` narrow it; `compare` adds the change. */
|
|
294
|
+
export async function topContentReport(
|
|
295
|
+
analytics ,
|
|
296
|
+
store ,
|
|
297
|
+
site ,
|
|
298
|
+
range ,
|
|
299
|
+
options
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
,
|
|
305
|
+
) {
|
|
306
|
+
// A kind filter is applied after the join, so ask for more rows than shown.
|
|
307
|
+
const ask = options.kind ? Math.min(100, options.limit * 4) : options.limit;
|
|
308
|
+
const filter = options.filter ? { filter: options.filter } : {};
|
|
309
|
+
const top = await analytics.query({
|
|
310
|
+
kind: "top",
|
|
311
|
+
siteId: site.id,
|
|
312
|
+
range,
|
|
313
|
+
dimension: "node",
|
|
314
|
+
limit: ask,
|
|
315
|
+
...filter,
|
|
316
|
+
});
|
|
317
|
+
const results = [top];
|
|
318
|
+
let rows = top.data.rows.filter((r) => !options.kind || nodeKindOf(r.key) === options.kind);
|
|
319
|
+
rows = rows.slice(0, options.limit);
|
|
320
|
+
let before ;
|
|
321
|
+
if (options.compare && rows.length > 0) {
|
|
322
|
+
const prev = await analytics.query({
|
|
323
|
+
kind: "top",
|
|
324
|
+
siteId: site.id,
|
|
325
|
+
range: previousRange(range),
|
|
326
|
+
dimension: "node",
|
|
327
|
+
limit: Math.min(1000, ask * 4),
|
|
328
|
+
...filter,
|
|
329
|
+
});
|
|
330
|
+
results.push(prev);
|
|
331
|
+
before = new Map(prev.data.rows.map((r) => [r.key, r.pageviews]));
|
|
332
|
+
}
|
|
333
|
+
const nodes = await analyticsNodes(
|
|
334
|
+
store,
|
|
335
|
+
site,
|
|
336
|
+
rows.map((r) => r.key),
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
rows: rows.map((r) => ({
|
|
340
|
+
...(nodes.get(r.key) ),
|
|
341
|
+
pageviews: r.pageviews,
|
|
342
|
+
visits: r.visits,
|
|
343
|
+
...(before ? { change: ratio(r.pageviews, before.get(r.key) ?? 0) } : {}),
|
|
344
|
+
})),
|
|
345
|
+
results,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A plain top list (no join): channels, referrers, countries, devices, … */
|
|
350
|
+
export async function topList(
|
|
351
|
+
analytics ,
|
|
352
|
+
siteId ,
|
|
353
|
+
range ,
|
|
354
|
+
dimension ,
|
|
355
|
+
limit ,
|
|
356
|
+
filter ,
|
|
357
|
+
) {
|
|
358
|
+
return analytics.query({
|
|
359
|
+
kind: "top",
|
|
360
|
+
siteId,
|
|
361
|
+
range,
|
|
362
|
+
dimension,
|
|
363
|
+
limit,
|
|
364
|
+
...(filter ? { filter } : {}),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ───────────────────────────── one node ─────────────────────────────
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
/** Hourly buckets for ranges of at most two days, daily otherwise (§10.1). */
|
|
400
|
+
export function seriesInterval(range ) {
|
|
401
|
+
const r = parseRange(range, 731);
|
|
402
|
+
return r.to.getTime() - r.from.getTime() <= 2 * 86_400_000 ? "hour" : "day";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function nodeReport(
|
|
406
|
+
analytics ,
|
|
407
|
+
store ,
|
|
408
|
+
site ,
|
|
409
|
+
nodeId ,
|
|
410
|
+
range ,
|
|
411
|
+
options = {},
|
|
412
|
+
) {
|
|
413
|
+
const filter = { node: nodeId };
|
|
414
|
+
const interval = seriesInterval(range);
|
|
415
|
+
const [nodes, summary, series, channels, referrers, countries, devices, vitals, redirects] =
|
|
416
|
+
await Promise.all([
|
|
417
|
+
analyticsNodes(store, site, [nodeId]),
|
|
418
|
+
summaryReport(analytics, site.id, range, { filter, compare: options.compare }),
|
|
419
|
+
analytics.query({ kind: "series", siteId: site.id, range, interval, filter }),
|
|
420
|
+
topList(analytics, site.id, range, "channel", 10, filter),
|
|
421
|
+
topList(analytics, site.id, range, "refHost", 10, filter),
|
|
422
|
+
topList(analytics, site.id, range, "country", 10, filter),
|
|
423
|
+
topList(analytics, site.id, range, "device", 10, filter),
|
|
424
|
+
analytics.query({ kind: "vitals", siteId: site.id, range, filter }),
|
|
425
|
+
analytics.query({
|
|
426
|
+
kind: "renders",
|
|
427
|
+
siteId: site.id,
|
|
428
|
+
range,
|
|
429
|
+
statuses: "redirects",
|
|
430
|
+
limit: 1000,
|
|
431
|
+
}),
|
|
432
|
+
]);
|
|
433
|
+
const redirectRows = redirects.data.rows ;
|
|
434
|
+
const history = (await store.listRedirects(site.id)).filter((r) => r.source?.id === nodeId);
|
|
435
|
+
const hitsFrom = (path ) =>
|
|
436
|
+
redirectRows.filter((r) => r.path === path).reduce((n, r) => n + r.hits, 0);
|
|
437
|
+
const oldPaths = history.map((r) => ({
|
|
438
|
+
from: r.from,
|
|
439
|
+
to: r.to,
|
|
440
|
+
status: r.status,
|
|
441
|
+
reason: r.reason,
|
|
442
|
+
hits: hitsFrom(r.from),
|
|
443
|
+
}));
|
|
444
|
+
const froms = new Set(history.map((r) => r.from));
|
|
445
|
+
const redirectHits = redirectRows
|
|
446
|
+
.filter((r) => r.nodeId === nodeId || froms.has(r.path))
|
|
447
|
+
.reduce((n, r) => n + r.hits, 0);
|
|
448
|
+
return {
|
|
449
|
+
data: {
|
|
450
|
+
node: nodes.get(nodeId) ,
|
|
451
|
+
summary: summary.data,
|
|
452
|
+
series: series.data,
|
|
453
|
+
channels: channels.data.rows,
|
|
454
|
+
referrers: referrers.data.rows,
|
|
455
|
+
countries: countries.data.rows,
|
|
456
|
+
devices: devices.data.rows,
|
|
457
|
+
vitals: vitals.data,
|
|
458
|
+
oldPaths,
|
|
459
|
+
redirectHits,
|
|
460
|
+
},
|
|
461
|
+
meta: combineMeta([
|
|
462
|
+
...summary.results,
|
|
463
|
+
series,
|
|
464
|
+
channels,
|
|
465
|
+
referrers,
|
|
466
|
+
countries,
|
|
467
|
+
devices,
|
|
468
|
+
vitals,
|
|
469
|
+
redirects,
|
|
470
|
+
]),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ───────────────────────────── broken links ─────────────────────────────
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
function lastSegment(path ) {
|
|
493
|
+
let p = path;
|
|
494
|
+
try {
|
|
495
|
+
p = decodeURIComponent(path);
|
|
496
|
+
} catch {
|
|
497
|
+
// keep it encoded
|
|
498
|
+
}
|
|
499
|
+
const parts = p
|
|
500
|
+
.toLowerCase()
|
|
501
|
+
.replace(/[?#].*$/, "")
|
|
502
|
+
.split("/")
|
|
503
|
+
.filter(Boolean);
|
|
504
|
+
return parts.at(-1) ?? "";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function editDistance(a , b , max ) {
|
|
508
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
509
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
510
|
+
for (let i = 1; i <= a.length; i++) {
|
|
511
|
+
const cur = [i];
|
|
512
|
+
let best = i;
|
|
513
|
+
for (let j = 1; j <= b.length; j++) {
|
|
514
|
+
const v = Math.min(
|
|
515
|
+
(prev[j] ) + 1,
|
|
516
|
+
(cur[j - 1] ) + 1,
|
|
517
|
+
(prev[j - 1] ) + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
518
|
+
);
|
|
519
|
+
cur.push(v);
|
|
520
|
+
best = Math.min(best, v);
|
|
521
|
+
}
|
|
522
|
+
if (best > max) return max + 1;
|
|
523
|
+
prev = cur;
|
|
524
|
+
}
|
|
525
|
+
return prev[b.length] ;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 404 and 410 paths with hits, the pages linking to them, and a suggested target. */
|
|
529
|
+
export async function brokenLinksReport(
|
|
530
|
+
analytics ,
|
|
531
|
+
store ,
|
|
532
|
+
site ,
|
|
533
|
+
range ,
|
|
534
|
+
limit ,
|
|
535
|
+
) {
|
|
536
|
+
const errors = await analytics.query({
|
|
537
|
+
kind: "renders",
|
|
538
|
+
siteId: site.id,
|
|
539
|
+
range,
|
|
540
|
+
statuses: "errors",
|
|
541
|
+
limit: 1000,
|
|
542
|
+
});
|
|
543
|
+
const groups = new Map
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
();
|
|
553
|
+
for (const r of errors.data.rows ) {
|
|
554
|
+
if (r.status !== 404 && r.status !== 410) continue;
|
|
555
|
+
const key = `${r.status} ${r.path}`;
|
|
556
|
+
const g = groups.get(key) ?? {
|
|
557
|
+
path: r.path,
|
|
558
|
+
status: r.status,
|
|
559
|
+
hits: 0,
|
|
560
|
+
refs: new Map (),
|
|
561
|
+
lastSeen: null,
|
|
562
|
+
};
|
|
563
|
+
g.hits += r.hits;
|
|
564
|
+
if (r.refPath) g.refs.set(r.refPath, (g.refs.get(r.refPath) ?? 0) + r.hits);
|
|
565
|
+
if (r.lastSeen && (!g.lastSeen || r.lastSeen > g.lastSeen)) g.lastSeen = r.lastSeen;
|
|
566
|
+
groups.set(key, g);
|
|
567
|
+
}
|
|
568
|
+
const top = [...groups.values()]
|
|
569
|
+
.sort((a, b) => b.hits - a.hits || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
570
|
+
.slice(0, limit);
|
|
571
|
+
|
|
572
|
+
// Candidates for "similar slug": published pages and entries (two reads).
|
|
573
|
+
let candidates ;
|
|
574
|
+
const loadCandidates = async () => {
|
|
575
|
+
if (candidates) return candidates;
|
|
576
|
+
const [pages, entries] = await Promise.all([
|
|
577
|
+
store.listPages(site.id),
|
|
578
|
+
store.listContentEntries(site.id, { status: ["published"] }),
|
|
579
|
+
]);
|
|
580
|
+
candidates = [
|
|
581
|
+
...pages
|
|
582
|
+
.filter((p) => p.status === "published")
|
|
583
|
+
.map((p) => ({ path: p.path, nodeId: p.id, segment: lastSegment(p.path) })),
|
|
584
|
+
...entries.map((e) => ({ path: entryPath(e), nodeId: e.id, segment: e.slug })),
|
|
585
|
+
];
|
|
586
|
+
return candidates;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const rows = [];
|
|
590
|
+
for (const g of top) {
|
|
591
|
+
let suggestedTarget = null;
|
|
592
|
+
let decoded ;
|
|
593
|
+
try {
|
|
594
|
+
decoded = decodeURIComponent(g.path).toLowerCase();
|
|
595
|
+
} catch {
|
|
596
|
+
decoded = undefined;
|
|
597
|
+
}
|
|
598
|
+
const match = decoded
|
|
599
|
+
? await store.redirectFor(site.id, decoded).catch(() => undefined)
|
|
600
|
+
: undefined;
|
|
601
|
+
if (match) suggestedTarget = { path: match.location, reason: "redirect" };
|
|
602
|
+
else {
|
|
603
|
+
const seg = lastSegment(g.path);
|
|
604
|
+
if (seg.length >= 3) {
|
|
605
|
+
let best ;
|
|
606
|
+
for (const c of await loadCandidates()) {
|
|
607
|
+
if (!c.segment) continue;
|
|
608
|
+
const d =
|
|
609
|
+
c.segment === seg
|
|
610
|
+
? 0
|
|
611
|
+
: c.segment.includes(seg) || seg.includes(c.segment)
|
|
612
|
+
? 1
|
|
613
|
+
: editDistance(seg, c.segment, 3);
|
|
614
|
+
if (d <= 3 && (!best || d < best.d)) best = { path: c.path, nodeId: c.nodeId, d };
|
|
615
|
+
}
|
|
616
|
+
if (best) suggestedTarget = { path: best.path, nodeId: best.nodeId, reason: "similar" };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
rows.push({
|
|
620
|
+
path: g.path,
|
|
621
|
+
status: g.status,
|
|
622
|
+
hits: g.hits,
|
|
623
|
+
linkedFrom: [...g.refs.entries()]
|
|
624
|
+
.sort((a, b) => b[1] - a[1])
|
|
625
|
+
.slice(0, 5)
|
|
626
|
+
.map(([p]) => p),
|
|
627
|
+
lastSeen: g.lastSeen,
|
|
628
|
+
suggestedTarget,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return { rows, meta: combineMeta([errors]) };
|
|
632
|
+
}
|