pagesight 0.16.0 → 0.18.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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +26 -97
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +59 -20
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -852
  70. package/src/tools/speed.ts +0 -956
@@ -0,0 +1,83 @@
1
+ import { type BingAction, bingFetch, bingMethods } from "../providers/bing.js";
2
+ import { capture, createEvidence, fail } from "./evidence.js";
3
+
4
+ export function bingObservation(action: BingAction, site?: string) {
5
+ return capture(
6
+ "bing",
7
+ action,
8
+ site ?? "accessible-sites",
9
+ { method: bingMethods[action], ...(site === undefined ? {} : { siteUrl: site }) },
10
+ () => bingFetch(action, site),
11
+ [
12
+ "Bing returns one provider-defined response. These methods support no date range or pagination; completeness is unknown.",
13
+ ...(action === "sites"
14
+ ? ["Site verification is not evidence of indexing or traffic."]
15
+ : [
16
+ "Raw Bing Date strings are retained. Reporting timezone is unknown; snapshot requested dates do not constrain this response.",
17
+ action === "traffic"
18
+ ? "Updated daily. Since 2023-03-24 traffic includes Web, Chat, News, Images, Videos and Knowledge Panel; it is not Google Web scope or isolated AI citation evidence."
19
+ : "Top-row statistics updated weekly; missing queries/pages are unknown, not zero. Do not assume exhaustive coverage or a Web-only scope.",
20
+ ...(action === "pages" ? ["In GetPageStats, the Query field contains the page URL."] : []),
21
+ ]),
22
+ ],
23
+ );
24
+ }
25
+
26
+ export async function bingDiagnostics(
27
+ action: "crawl-stats" | "crawl-issues" | "url-info" | "link-counts" | "url-links",
28
+ site: string,
29
+ url?: string,
30
+ maxPages = 1,
31
+ ) {
32
+ const result = createEvidence("bing", action, site);
33
+ result.warnings.push(
34
+ "Provider dates, count periods and units are retained as reported. Empty reports are not proof of no issues or links.",
35
+ "Crawl issues are not the Bing UI recommendations list. Link counts do not measure domain quality. InIndex and sitemap counts have different scopes.",
36
+ "URL-info HttpStatus is provider metadata; zero is not a successful HTTP response. Indexed/crawled state is not a live fetch.",
37
+ );
38
+ const paginated = action === "link-counts" || action === "url-links";
39
+ let page = 0;
40
+ let rowsReturned = 0;
41
+ let exhausted = false;
42
+ let totalPages: number | undefined;
43
+ for (; page < (paginated ? maxPages : 1); page++) {
44
+ const parameters: Record<string, string> = {
45
+ ...(url ? { [action === "url-links" ? "link" : "url"]: url } : {}),
46
+ ...(paginated ? { page: String(page) } : {}),
47
+ };
48
+ const request = { method: bingMethods[action], siteUrl: site, ...parameters };
49
+ try {
50
+ const response = await bingFetch(action, site, parameters);
51
+ result.pages.push({ request, response });
52
+ if (!paginated) break;
53
+ const d = response.d as { TotalPages: number; Links?: unknown[]; Details?: unknown[] };
54
+ rowsReturned += (d.Links ?? d.Details ?? []).length;
55
+ if (totalPages !== undefined && totalPages !== d.TotalPages) {
56
+ result.status = "partial";
57
+ result.warnings.push(
58
+ "Provider TotalPages changed during pagination; coverage is unstable. Restart to obtain a fresh report.",
59
+ );
60
+ page++;
61
+ break;
62
+ }
63
+ totalPages = d.TotalPages;
64
+ if (page + 1 >= d.TotalPages) {
65
+ exhausted = true;
66
+ break;
67
+ }
68
+ } catch (error) {
69
+ result.failedRequest = request;
70
+ fail(result, error);
71
+ break;
72
+ }
73
+ }
74
+ if (paginated) {
75
+ result.pagination = { exhausted, nextOffset: exhausted ? null : page, rowsReturned };
76
+ result.warnings.push(
77
+ "nextOffset is a zero-based provider page, not a row offset. Exhaustion describes the returned report, not exhaustive backlink coverage.",
78
+ );
79
+ if (!exhausted && result.status === "ok") result.status = "partial";
80
+ }
81
+ result.finishedAt = new Date().toISOString();
82
+ return result;
83
+ }
@@ -0,0 +1,377 @@
1
+ import { z } from "zod";
2
+ import { RequestError } from "../shared/http.js";
3
+ import { capture, type Evidence } from "./evidence.js";
4
+ import type { ImportedSnapshot } from "./evidence-schema.js";
5
+ import { gaRequestSchema, gscRequestSchema } from "./schema.js";
6
+
7
+ const gscRow = z
8
+ .object({
9
+ keys: z.array(z.string()).optional(),
10
+ clicks: z.number().finite(),
11
+ impressions: z.number().finite(),
12
+ ctr: z.number().finite(),
13
+ position: z.number().finite(),
14
+ })
15
+ .passthrough();
16
+ const gscResponse = z
17
+ .object({
18
+ rows: z.array(gscRow).default([]),
19
+ responseAggregationType: z.string().min(1),
20
+ metadata: z.record(z.unknown()).optional(),
21
+ })
22
+ .passthrough();
23
+ const gaResponse = z
24
+ .object({
25
+ dimensionHeaders: z.array(z.object({ name: z.string() })).default([]),
26
+ metricHeaders: z.array(z.object({ name: z.string(), type: z.string().optional() })).min(1),
27
+ rows: z
28
+ .array(
29
+ z.object({
30
+ dimensionValues: z.array(z.object({ value: z.string() })).default([]),
31
+ metricValues: z.array(z.object({ value: z.string() })),
32
+ }),
33
+ )
34
+ .default([]),
35
+ metadata: z.object({ timeZone: z.string().min(1), currencyCode: z.string().optional() }).passthrough(),
36
+ })
37
+ .passthrough();
38
+ const gaComparisonDimensions = new Set([
39
+ "hostName",
40
+ "sessionDefaultChannelGroup",
41
+ "sessionSourceMedium",
42
+ "eventName",
43
+ "landingPagePlusQueryString",
44
+ "sessionSource",
45
+ ]);
46
+
47
+ function canonical(value: unknown): string {
48
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
49
+ if (value && typeof value === "object")
50
+ return `{${Object.entries(value)
51
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
52
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
53
+ .join(",")}}`;
54
+ return JSON.stringify(value) ?? "undefined";
55
+ }
56
+
57
+ type RawValue = number | string;
58
+ type Report = {
59
+ request: unknown;
60
+ start: string;
61
+ end: string;
62
+ dimensions: string[];
63
+ metrics: string[];
64
+ semantics: unknown;
65
+ rows: Map<string, { keys: string[]; values: RawValue[] }>;
66
+ warnings: string[];
67
+ };
68
+ class Incompatible extends Error {}
69
+ const requireSame = (a: unknown, b: unknown, message: string) => {
70
+ if (canonical(a) !== canonical(b)) throw new Incompatible(message);
71
+ };
72
+
73
+ type SnapshotContext = ImportedSnapshot["pages"][0]["response"]["context"];
74
+
75
+ function normalizeReport(observation: Evidence, context: SnapshotContext): Report {
76
+ let report: Report | undefined;
77
+ let offset = 0;
78
+ for (const page of observation.pages) {
79
+ let current: Omit<Report, "rows">;
80
+ let rows: Array<{ keys: string[]; values: RawValue[] }>;
81
+ if (observation.provider === "gsc") {
82
+ const q = gscRequestSchema.parse(page.request);
83
+ const response = gscResponse.parse(page.response);
84
+ if (
85
+ q.dataState !== "final" ||
86
+ Object.keys(response.metadata ?? {}).some((key) => key.startsWith("first_incomplete"))
87
+ )
88
+ throw new Incompatible("GSC data must be finalized.");
89
+ if (q.startRow !== offset)
90
+ throw new Incompatible("Report must contain contiguous pages starting at offset zero.");
91
+ if (q.dimensions.some((dimension) => dimension === "date" || dimension === "hour"))
92
+ throw new Incompatible(
93
+ "Time dimensions need a separate alignment policy; this comparison uses non-time row keys.",
94
+ );
95
+ const { startDate, endDate, startRow: _startRow, rowLimit: _rowLimit, ...scope } = q;
96
+ current = {
97
+ request: scope,
98
+ start: startDate,
99
+ end: endDate,
100
+ dimensions: q.dimensions,
101
+ metrics: ["clicks", "impressions", "ctr", "position"],
102
+ semantics: { aggregation: response.responseAggregationType, timezone: "America/Los_Angeles" },
103
+ warnings: [],
104
+ };
105
+ if (!context.observedGscDates.includes(endDate))
106
+ current.warnings.push(
107
+ "GSC has no observed date row for the requested end date; trailing days may be unavailable or have no activity.",
108
+ );
109
+ rows = response.rows.map((r) => ({ keys: r.keys ?? [], values: [r.clicks, r.impressions, r.ctr, r.position] }));
110
+ } else {
111
+ const q = gaRequestSchema.parse(page.request);
112
+ const response = gaResponse.parse(page.response);
113
+ if (q.dateRanges.length !== 1) throw new Incompatible("GA comparison requires exactly one date range.");
114
+ if (
115
+ q.dimensions.some(
116
+ (dimension) => !gaComparisonDimensions.has(dimension.name) || Object.hasOwn(dimension, "dimensionExpression"),
117
+ )
118
+ )
119
+ throw new Incompatible("GA comparison supports only snapshot dimension names without dimension expressions.");
120
+ if (q.offset !== offset) throw new Incompatible("Report must contain contiguous pages starting at offset zero.");
121
+ const { dateRanges, offset: _offset, limit: _limit, returnPropertyQuota: _quota, ...scope } = q;
122
+ const dimensions = q.dimensions.map((d) => d.name);
123
+ const metrics = q.metrics.map((m) => m.name);
124
+ if (new Set(metrics).size !== metrics.length)
125
+ throw new Incompatible("GA metric names must be unique to preserve every compared value.");
126
+ requireSame(
127
+ response.dimensionHeaders.map((h) => h.name),
128
+ dimensions,
129
+ "GA dimension headers must match the request.",
130
+ );
131
+ requireSame(
132
+ response.metricHeaders.map((h) => h.name),
133
+ metrics,
134
+ "GA metric headers must match the request.",
135
+ );
136
+ current = {
137
+ request: scope,
138
+ start: dateRanges[0].startDate,
139
+ end: dateRanges[0].endDate,
140
+ dimensions,
141
+ metrics,
142
+ semantics: {
143
+ timezone: response.metadata.timeZone,
144
+ currency: response.metadata.currencyCode ?? null,
145
+ metrics: response.metricHeaders,
146
+ },
147
+ warnings: [],
148
+ };
149
+ if (response.metadata.subjectToThresholding) current.warnings.push("GA report is subject to thresholding.");
150
+ if (response.metadata.dataLossFromOtherRow)
151
+ current.warnings.push("GA high-cardinality rows were combined into (other).");
152
+ if (Array.isArray(response.metadata.samplingMetadatas) && response.metadata.samplingMetadatas.length)
153
+ current.warnings.push("GA report is sampled.");
154
+ const collectedDate = new Intl.DateTimeFormat("en-CA", {
155
+ timeZone: response.metadata.timeZone,
156
+ year: "numeric",
157
+ month: "2-digit",
158
+ day: "2-digit",
159
+ }).format(new Date(observation.finishedAt));
160
+ if (Date.parse(collectedDate) - Date.parse(current.end) < 3 * 86400000)
161
+ current.warnings.push(
162
+ "GA was collected fewer than three property-calendar days after the requested end date; recent data may still change.",
163
+ );
164
+ if (response.metricHeaders.some((h) => h.type === "TYPE_CURRENCY") && !response.metadata.currencyCode)
165
+ throw new Incompatible("GA currency metrics require a known response currency.");
166
+ rows = response.rows.map((r) => ({
167
+ keys: r.dimensionValues.map((v) => v.value),
168
+ values: r.metricValues.map((v) => v.value),
169
+ }));
170
+ }
171
+ requireSame(
172
+ { startDate: current.start, endDate: current.end },
173
+ { startDate: context.requestedDates.startDate, endDate: context.requestedDates.endDate },
174
+ "Report period does not match the snapshot context.",
175
+ );
176
+ if (report) {
177
+ requireSame(
178
+ [report.request, report.start, report.end, report.semantics],
179
+ [current.request, current.start, current.end, current.semantics],
180
+ "Report scope or metadata changed between pages.",
181
+ );
182
+ report.warnings.push(...current.warnings);
183
+ } else report = { ...current, rows: new Map() };
184
+ for (const row of rows) {
185
+ if (row.keys.length !== current.dimensions.length || row.values.length !== current.metrics.length)
186
+ throw new Incompatible("Row shape does not match the requested dimensions and metrics.");
187
+ const key = canonical(row.keys);
188
+ if (report.rows.has(key)) throw new Incompatible("Duplicate row keys make this report ambiguous.");
189
+ report.rows.set(key, row);
190
+ }
191
+ offset += rows.length;
192
+ }
193
+ if (!report) throw new Incompatible("No report pages available.");
194
+ if (
195
+ observation.pagination &&
196
+ (observation.pagination.rowsReturned !== offset ||
197
+ observation.pagination.nextOffset !== (observation.pagination.exhausted ? null : offset))
198
+ )
199
+ throw new Incompatible("Report pagination does not match the retained rows.");
200
+ if (observation.status !== "ok" || !observation.pagination?.exhausted || observation.error)
201
+ report.warnings.push(
202
+ "Report has incomplete pagination or a provider failure; only observed common rows can be compared.",
203
+ );
204
+ return report;
205
+ }
206
+
207
+ function parseNumericValue(value: RawValue): number | null {
208
+ if (typeof value === "string" && !/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value)) return null;
209
+ const result = Number(value);
210
+ if (typeof value === "string" && !/^-?\d+$/u.test(value)) {
211
+ const mantissa = value.split(/[eE]/u)[0];
212
+ const significant = mantissa.replace(/[-.]/gu, "").replace(/^0+/u, "").replace(/0+$/u, "");
213
+ if (significant.length > 15 || (result === 0 && /[1-9]/u.test(mantissa))) return null;
214
+ }
215
+ return Number.isFinite(result) && Math.abs(result) <= Number.MAX_SAFE_INTEGER ? result : null;
216
+ }
217
+
218
+ function metric(baseline: RawValue, current: RawValue) {
219
+ const before = parseNumericValue(baseline);
220
+ const after = parseNumericValue(current);
221
+ if (before === null || after === null)
222
+ return {
223
+ baseline,
224
+ current,
225
+ delta: null,
226
+ percentChange: null,
227
+ reason: "Unsafe or invalid numeric value; raw values retained.",
228
+ };
229
+ const delta = after - before;
230
+ if (!Number.isFinite(delta) || Math.abs(delta) > Number.MAX_SAFE_INTEGER)
231
+ return { baseline, current, delta: null, percentChange: null, reason: "Numeric delta exceeds safe range." };
232
+ const percent = before === 0 ? null : (delta / before) * 100;
233
+ return { baseline, current, delta, percentChange: percent !== null && Number.isFinite(percent) ? percent : null };
234
+ }
235
+
236
+ function compareObservation(
237
+ baseline: Evidence | undefined,
238
+ current: Evidence | undefined,
239
+ maxRows: number,
240
+ beforeContext: SnapshotContext,
241
+ afterContext: SnapshotContext,
242
+ ) {
243
+ const name = baseline?.name ?? current?.name;
244
+ const warnings = [...new Set([...(baseline?.warnings ?? []), ...(current?.warnings ?? [])])];
245
+ if (!baseline || !current)
246
+ return {
247
+ name,
248
+ status: "unavailable",
249
+ reason: "Observation missing from one snapshot; absence is not zero.",
250
+ warnings,
251
+ };
252
+ if (
253
+ baseline.provider !== current.provider ||
254
+ baseline.operation !== current.operation ||
255
+ baseline.target !== current.target
256
+ )
257
+ return { name, status: "incompatible", reason: "Observation provider, operation or target changed.", warnings };
258
+ if (baseline.operation !== "report" || !["gsc", "ga"].includes(baseline.provider))
259
+ return {
260
+ name,
261
+ status: "unsupported",
262
+ reason:
263
+ "This version compares GSC and GA reports only. Bing windows and other observations require a separate comparison policy.",
264
+ warnings,
265
+ };
266
+ if (baseline.status === "error" || current.status === "error" || !baseline.pages.length || !current.pages.length)
267
+ return { name, status: "unavailable", reason: "A provider report failed or has no response pages.", warnings };
268
+ try {
269
+ const a = normalizeReport(baseline, beforeContext);
270
+ const b = normalizeReport(current, afterContext);
271
+ requireSame(a.request, b.request, "Requested dimensions, metrics, filters or scope changed.");
272
+ requireSame(
273
+ a.semantics,
274
+ b.semantics,
275
+ "Provider aggregation, metric types, currency or reporting timezone changed.",
276
+ );
277
+ const durationA = Date.parse(a.end) - Date.parse(a.start);
278
+ const durationB = Date.parse(b.end) - Date.parse(b.start);
279
+ if (durationA !== durationB || a.end >= b.start)
280
+ throw new Incompatible("Periods must have equal duration, with baseline ending before current starts.");
281
+ const common = [...a.rows.keys()].filter((key) => b.rows.has(key)).sort();
282
+ const onlyBaseline = [...a.rows.keys()].filter((key) => !b.rows.has(key)).sort();
283
+ const onlyCurrent = [...b.rows.keys()].filter((key) => !a.rows.has(key)).sort();
284
+ const limitations = [...new Set([...a.warnings, ...b.warnings])];
285
+ const rows = common.slice(0, maxRows).map((key) => {
286
+ const before = a.rows.get(key)!;
287
+ const after = b.rows.get(key)!;
288
+ return {
289
+ keys: before.keys,
290
+ metrics: Object.fromEntries(
291
+ a.metrics.map((name, index) => [name, metric(before.values[index], after.values[index])]),
292
+ ),
293
+ };
294
+ });
295
+ if (rows.some((row) => Object.values(row.metrics).some((m) => m.delta === null)))
296
+ limitations.push("Some numeric values could not be compared safely.");
297
+ return {
298
+ name,
299
+ status: limitations.length || common.length > maxRows ? "limited" : "compared",
300
+ baselinePeriod: { startDate: a.start, endDate: a.end },
301
+ currentPeriod: { startDate: b.start, endDate: b.end },
302
+ dimensions: a.dimensions,
303
+ rows,
304
+ commonRowCount: common.length,
305
+ omittedCommonRows: Math.max(0, common.length - maxRows),
306
+ rowScope: "observed-keys",
307
+ baselineOnlyObserved: {
308
+ count: onlyBaseline.length,
309
+ keys: onlyBaseline.slice(0, maxRows).map((key) => a.rows.get(key)!.keys),
310
+ },
311
+ currentOnlyObserved: {
312
+ count: onlyCurrent.length,
313
+ keys: onlyCurrent.slice(0, maxRows).map((key) => b.rows.get(key)!.keys),
314
+ },
315
+ warnings: [
316
+ ...warnings,
317
+ ...limitations,
318
+ "Only common observed rows have deltas. Missing rows are unknown; row sums are not exhaustive site totals. Position and CTR deltas retain their provider units.",
319
+ ],
320
+ };
321
+ } catch (error) {
322
+ return {
323
+ name,
324
+ status: "incompatible",
325
+ reason:
326
+ error instanceof Incompatible
327
+ ? error.message
328
+ : "Report request or response does not match the supported schema.",
329
+ warnings,
330
+ };
331
+ }
332
+ }
333
+
334
+ export function compareSnapshots(baseline: ImportedSnapshot, current: ImportedSnapshot, maxRows: number) {
335
+ if (baseline.target !== current.target)
336
+ throw new RequestError("Snapshots must identify the same site", null, "invalid_input");
337
+ const before = baseline.pages[0].response.observations;
338
+ const after = current.pages[0].response.observations;
339
+ const names = [...new Set([...before, ...after].map((o) => o.name))].sort();
340
+ const source = (snapshot: ImportedSnapshot) => ({
341
+ target: snapshot.target,
342
+ startedAt: snapshot.startedAt,
343
+ finishedAt: snapshot.finishedAt,
344
+ canonicalSha256: Bun.CryptoHasher.hash("sha256", canonical(snapshot), "hex"),
345
+ });
346
+ return capture(
347
+ "pagesight",
348
+ "compare",
349
+ baseline.target,
350
+ { baseline: source(baseline), current: source(current), maxRows },
351
+ async () => {
352
+ const observations = names.map((name) =>
353
+ compareObservation(
354
+ before.find((o) => o.name === name),
355
+ after.find((o) => o.name === name),
356
+ maxRows,
357
+ baseline.pages[0].response.context,
358
+ current.pages[0].response.context,
359
+ ),
360
+ );
361
+ return {
362
+ comparisonVersion: 1,
363
+ observations,
364
+ summary: Object.fromEntries(
365
+ ["compared", "limited", "incompatible", "unavailable", "unsupported"].map((status) => [
366
+ status,
367
+ observations.filter((o) => o.status === status).length,
368
+ ]),
369
+ ),
370
+ };
371
+ },
372
+ [
373
+ "Descriptive changes only; no causal attribution, ranking recommendations or automatic SEO changes.",
374
+ ...new Set([...baseline.warnings, ...current.warnings]),
375
+ ],
376
+ );
377
+ }
@@ -0,0 +1,39 @@
1
+ import { configSchema } from "./schema.js";
2
+ import { aggregate } from "./evidence.js";
3
+ import type { Executor } from "./execute.js";
4
+
5
+ export async function discover(url: string, providers: Array<"gsc" | "ga" | "bing">, run: Executor) {
6
+ const selected = [...new Set(providers)];
7
+ const observations = await Promise.all(
8
+ selected.map((provider) => run({ operation: provider === "ga" ? "ga.accounts" : `${provider}.sites` })),
9
+ );
10
+ const result = aggregate("discover", url, { url, providers: selected }, observations);
11
+ const gsc = observations.find((o) => o.provider === "gsc");
12
+ const bing = observations.find((o) => o.provider === "bing");
13
+ const bingSites = (bing?.pages[0]?.response as { d?: unknown[] } | undefined)?.d;
14
+ const ga = observations.find((o) => o.provider === "ga");
15
+ const sites = (gsc?.pages[0]?.response as { siteEntry?: unknown[] } | undefined)?.siteEntry;
16
+ const accounts = ga?.pages[0]?.response as
17
+ | { accountSummaries?: Array<{ propertySummaries?: unknown[] }> }
18
+ | undefined;
19
+ Object.assign(result.pages[0].response as object, {
20
+ config: configSchema.parse({ site: url }),
21
+ candidates: {
22
+ bing: bingSites ?? [],
23
+ gsc: Array.isArray(sites) ? sites : [],
24
+ ga: accounts?.accountSummaries?.flatMap((a) => a.propertySummaries ?? []) ?? [],
25
+ },
26
+ providers: Object.fromEntries(
27
+ ["gsc", "ga", "bing"].map((p) => [
28
+ p,
29
+ selected.includes(p as "gsc" | "ga" | "bing") ? "selected" : "not_selected",
30
+ ]),
31
+ ),
32
+ });
33
+ result.warnings.push(
34
+ "No provider property is automatically selected. Copy a verified site/property ID into config before collecting its reports.",
35
+ "GA display names do not establish a property's hostname. Candidates can belong to other sites; verify the property and web stream.",
36
+ "The returned config is usable for a site-only snapshot. Sitemap, objective and provider IDs must be supplied explicitly.",
37
+ );
38
+ return result;
39
+ }
@@ -0,0 +1,35 @@
1
+ import { getAuthMethod } from "../providers/gsc-auth.js";
2
+ import { getSite } from "../providers/gsc.js";
3
+ import { defaultDates } from "../shared/dates.js";
4
+ import { aggregate, capture, type Evidence } from "./evidence.js";
5
+ import type { Executor } from "./execute.js";
6
+ import type { SiteConfig } from "./schema.js";
7
+ import { providerSelection } from "./snapshot.js";
8
+
9
+ export async function doctor(config: SiteConfig, run: Executor): Promise<Evidence> {
10
+ const pending: Promise<Evidence>[] = [run({ operation: "page", url: config.site })];
11
+ const site = config.gscSite;
12
+ const property = config.gaProperty;
13
+ if (config.bingSite) pending.push(run({ operation: "bing.traffic", site: config.bingSite }));
14
+ if (site) pending.push(capture("gsc", "access", site, { site, method: getAuthMethod() }, () => getSite(site)));
15
+ if (property)
16
+ pending.push(
17
+ run({ operation: "ga.property", property }),
18
+ run({
19
+ operation: "ga.report",
20
+ property,
21
+ request: {
22
+ dateRanges: [{ startDate: defaultDates().endDate, endDate: defaultDates().endDate }],
23
+ metrics: [{ name: "sessions" }],
24
+ limit: 1,
25
+ },
26
+ }),
27
+ );
28
+ const result = aggregate("doctor", config.site, config, await Promise.all(pending));
29
+ (result.pages[0].response as Record<string, unknown>).providers = {
30
+ ...providerSelection(config),
31
+ web: "selected",
32
+ sitemap: "not_checked",
33
+ };
34
+ return result;
35
+ }
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
2
+ import { dateSchema } from "../shared/dates.js";
3
+
4
+ export const evidenceSchema = z
5
+ .object({
6
+ schemaVersion: z.literal(1),
7
+ provider: z.string().min(1),
8
+ operation: z.string().min(1),
9
+ name: z.string().min(1).optional(),
10
+ target: z.string().min(1),
11
+ startedAt: z.string().datetime(),
12
+ finishedAt: z.string().datetime(),
13
+ status: z.enum(["ok", "partial", "error"]),
14
+ pages: z.array(
15
+ z
16
+ .object({ request: z.unknown(), response: z.unknown() })
17
+ .passthrough()
18
+ .refine(
19
+ (p) => Object.hasOwn(p, "request") && Object.hasOwn(p, "response"),
20
+ "Evidence pages require a request and response",
21
+ )
22
+ .transform((p) => ({ ...p, request: p.request, response: p.response })),
23
+ ),
24
+ pagination: z
25
+ .object({
26
+ exhausted: z.boolean(),
27
+ nextOffset: z.number().int().nonnegative().nullable(),
28
+ rowsReturned: z.number().int().nonnegative(),
29
+ })
30
+ .optional(),
31
+ warnings: z.array(z.string()),
32
+ error: z
33
+ .object({
34
+ code: z.string(),
35
+ message: z.string(),
36
+ httpStatus: z.number().int().nullable(),
37
+ name: z.string().optional(),
38
+ })
39
+ .optional(),
40
+ })
41
+ .passthrough();
42
+
43
+ export const snapshotEvidenceSchema = evidenceSchema
44
+ .extend({
45
+ provider: z.literal("pagesight"),
46
+ operation: z.literal("snapshot"),
47
+ pages: z.tuple([
48
+ z
49
+ .object({
50
+ request: z.unknown(),
51
+ response: z
52
+ .object({
53
+ snapshotVersion: z.literal(1),
54
+ context: z
55
+ .object({
56
+ config: z.object({ site: z.string().url() }).passthrough(),
57
+ requestedDates: z.object({ startDate: dateSchema, endDate: dateSchema }).passthrough(),
58
+ observedGscDates: z.array(dateSchema),
59
+ })
60
+ .passthrough(),
61
+ observations: z
62
+ .array(evidenceSchema.extend({ name: z.string().min(1) }))
63
+ .min(1)
64
+ .max(100),
65
+ })
66
+ .passthrough(),
67
+ })
68
+ .passthrough(),
69
+ ]),
70
+ })
71
+ .refine((s) => s.target === s.pages[0].response.context.config.site, "Snapshot target must match its configured site")
72
+ .refine(
73
+ (s) =>
74
+ new Set(s.pages[0].response.observations.map((o) => o.name)).size === s.pages[0].response.observations.length,
75
+ "Snapshot observation names must be unique",
76
+ );
77
+
78
+ export type ImportedSnapshot = z.infer<typeof snapshotEvidenceSchema>;