pagesight 0.17.0 → 0.19.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 (79) hide show
  1. package/README.md +20 -82
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/measurement.md +178 -0
  5. package/docs/opportunities.md +78 -0
  6. package/docs/snapshots.md +130 -0
  7. package/docs/usage.md +135 -0
  8. package/package.json +26 -29
  9. package/src/api/assessment.ts +315 -0
  10. package/src/api/bing.ts +83 -0
  11. package/src/api/compare-snapshots.ts +167 -0
  12. package/src/api/discover.ts +39 -0
  13. package/src/api/doctor.ts +35 -0
  14. package/src/api/evidence-schema.ts +78 -0
  15. package/src/api/evidence.ts +99 -0
  16. package/src/api/execute.ts +160 -0
  17. package/src/api/ga-freshness.ts +20 -0
  18. package/src/api/ga-realtime.ts +40 -0
  19. package/src/api/index.ts +5 -0
  20. package/src/api/opportunities.ts +317 -0
  21. package/src/api/report-table.ts +216 -0
  22. package/src/api/reports.ts +108 -0
  23. package/src/api/schema.ts +271 -0
  24. package/src/api/snapshot.ts +202 -0
  25. package/src/api/ui-findings.ts +68 -0
  26. package/src/assessment-text.ts +55 -0
  27. package/src/cli.ts +217 -0
  28. package/src/http.ts +39 -0
  29. package/src/index.ts +8 -25
  30. package/src/mcp-server.ts +27 -0
  31. package/src/mcp.ts +5 -0
  32. package/src/opportunities-text.ts +61 -0
  33. package/src/providers/bing.ts +48 -0
  34. package/src/{lib → providers}/crux.ts +4 -9
  35. package/src/providers/ga.ts +114 -0
  36. package/src/providers/google-tokens.ts +86 -0
  37. package/src/providers/gsc-auth.ts +93 -0
  38. package/src/{lib → providers}/gsc.ts +27 -24
  39. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  40. package/src/shared/dates.ts +32 -0
  41. package/src/shared/http.ts +63 -0
  42. package/src/tools/ai.ts +19 -27
  43. package/src/tools/audit.ts +18 -98
  44. package/src/tools/observe.ts +28 -0
  45. package/src/tools/page/analyze.ts +194 -0
  46. package/src/tools/page/batch.ts +163 -0
  47. package/src/tools/page/contrast.ts +128 -0
  48. package/src/tools/page/links.ts +200 -0
  49. package/src/tools/page/metadata.ts +225 -0
  50. package/src/tools/page/structured-data.ts +288 -0
  51. package/src/tools/page/tool.ts +48 -0
  52. package/src/tools/search/actions.ts +56 -0
  53. package/src/tools/search/analytics.ts +266 -0
  54. package/src/tools/search/coverage.ts +247 -0
  55. package/src/tools/search/gaps.ts +129 -0
  56. package/src/tools/search/inspection.ts +160 -0
  57. package/src/tools/search/result.ts +3 -0
  58. package/src/tools/search/sample.ts +110 -0
  59. package/src/tools/search/schema.ts +62 -0
  60. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  61. package/src/tools/search/sites.ts +86 -0
  62. package/src/tools/search/tool.ts +11 -0
  63. package/src/tools/setup.ts +1 -1
  64. package/src/tools/speed/analyze.ts +191 -0
  65. package/src/tools/speed/batch.ts +265 -0
  66. package/src/tools/speed/crux.ts +176 -0
  67. package/src/tools/speed/pagespeed.ts +273 -0
  68. package/src/tools/speed/schema.ts +39 -0
  69. package/src/tools/speed/tool.ts +11 -0
  70. package/src/web/fetch.ts +31 -0
  71. package/src/web/images.ts +62 -0
  72. package/src/web/page-observation.ts +69 -0
  73. package/src/{lib → web}/robots.ts +20 -12
  74. package/src/web/sitemap-inventory.ts +64 -0
  75. package/src/web/sitemap-parser.ts +59 -0
  76. package/src/lib/auth.ts +0 -187
  77. package/src/tools/page.ts +0 -1241
  78. package/src/tools/search.ts +0 -1118
  79. package/src/tools/speed.ts +0 -956
@@ -0,0 +1,317 @@
1
+ import { z } from "zod";
2
+ import { assessSnapshot, type AssessmentTable } from "./assessment.js";
3
+ import { capture } from "./evidence.js";
4
+ import type { ImportedSnapshot } from "./evidence-schema.js";
5
+ import { configSchema, httpUrl } from "./schema.js";
6
+ import { parseNumericValue } from "./report-table.js";
7
+
8
+ export interface OpportunityPolicy {
9
+ minImpressions: number;
10
+ maxClicks: number;
11
+ maxRows: number;
12
+ }
13
+ const pageSchema = z.object({
14
+ url: z.string(),
15
+ finalUrl: z.string(),
16
+ status: z.number().int(),
17
+ title: z.string().nullable(),
18
+ description: z.string().nullable(),
19
+ canonical: z.string().nullable(),
20
+ robots: z.array(z.string()),
21
+ xRobotsTag: z.string().nullable(),
22
+ warnings: z.array(z.string()).default([]),
23
+ });
24
+ const inspectionSchema = z.object({
25
+ inspectionResult: z.object({
26
+ indexStatusResult: z.object({
27
+ verdict: z.string().optional(),
28
+ coverageState: z.string().optional(),
29
+ indexingState: z.string().optional(),
30
+ robotsTxtState: z.string().optional(),
31
+ googleCanonical: z.string().optional(),
32
+ userCanonical: z.string().optional(),
33
+ lastCrawlTime: z.string().optional(),
34
+ }),
35
+ }),
36
+ });
37
+ const organicNames = [
38
+ "ga.report.landingPagePlusQueryString+sessionSource.organic",
39
+ "ga.report.landingPagePlusQueryString+sessionSource+eventName.organic",
40
+ ];
41
+ function configuredPath(raw: string, site: string): string | null {
42
+ try {
43
+ const url = new URL(raw);
44
+ if (url.origin === new URL(site).origin && url.href === raw && !raw.includes("#") && !url.username && !url.password)
45
+ return url.pathname + url.search;
46
+ } catch {
47
+ /* Invalid provider URL remains raw evidence. */
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export async function opportunities(snapshot: ImportedSnapshot, policy: OpportunityPolicy) {
53
+ const config = configSchema.parse(snapshot.pages[0].response.context.config);
54
+ const observations = snapshot.pages[0].response.observations;
55
+ // Selection must use all retained rows, not the assessment's presentation cap.
56
+ const assessment = await assessSnapshot(snapshot, Number.MAX_SAFE_INTEGER);
57
+ const assessed = assessment.pages[0]?.response as
58
+ | {
59
+ tables: AssessmentTable[];
60
+ snapshotSha256: string;
61
+ limitations: string[];
62
+ }
63
+ | undefined;
64
+ const tables = assessed?.tables ?? [];
65
+ const search = tables.find((t) => t.observation === "gsc.report.page");
66
+ const limits = [
67
+ ...(assessed?.limitations ?? []),
68
+ "Cutoffs select an investigation cohort, not a CTR benchmark, SEO score or forecast. Position and query/device/country mix can explain few clicks.",
69
+ "Candidate ordering covers retained GSC page rows only. Missing pages and missing GA rows are unknown, not zero.",
70
+ "GA is associated by configured origin and exact path/query only, not verified canonical identity. Landing page is session entry, not event location; no cross-provider funnel is computed.",
71
+ "Technical observations describe their collection times; they need not represent the historical reporting period. No live requests or imported-claim verification occur.",
72
+ ];
73
+ let unusableSearchRows = 0;
74
+ const result = await capture(
75
+ "pagesight",
76
+ "opportunities",
77
+ snapshot.target,
78
+ {
79
+ snapshotSha256: assessed?.snapshotSha256 ?? null,
80
+ ...policy,
81
+ },
82
+ async () => {
83
+ const candidates = (search?.rows ?? [])
84
+ .flatMap((row) => {
85
+ const values = Object.fromEntries(search!.metrics.map((m, i) => [m, row.values[i]]));
86
+ const impressions = parseNumericValue(values.impressions);
87
+ const clicks = parseNumericValue(values.clicks);
88
+ const ctr = parseNumericValue(values.ctr);
89
+ const position = parseNumericValue(values.position);
90
+ if (
91
+ impressions === null ||
92
+ clicks === null ||
93
+ !Number.isSafeInteger(impressions) ||
94
+ !Number.isSafeInteger(clicks) ||
95
+ impressions < 0 ||
96
+ ctr === null ||
97
+ ctr < 0 ||
98
+ ctr > 1 ||
99
+ position === null ||
100
+ position < 0 ||
101
+ clicks < 0 ||
102
+ clicks > impressions
103
+ ) {
104
+ unusableSearchRows++;
105
+ return [];
106
+ }
107
+ if (impressions < policy.minImpressions || clicks > policy.maxClicks) return [];
108
+ return [{ url: row.keys[0], values, impressions, clicks }];
109
+ })
110
+ .sort(
111
+ (a, b) =>
112
+ b.impressions - a.impressions || a.clicks - b.clicks || (a.url < b.url ? -1 : a.url > b.url ? 1 : 0),
113
+ );
114
+ const selected = candidates.slice(0, policy.maxRows).map((candidate) => {
115
+ const unknowns: string[] = [
116
+ "No page-filtered query/device/country breakdown is used for this candidate; property query totals are not page-level evidence.",
117
+ ];
118
+ const nextChecks = [
119
+ "Inspect page-filtered Search Console queries, device/country mix and position before deciding whether a title/snippet change is appropriate.",
120
+ ];
121
+ const path = configuredPath(candidate.url, config.site);
122
+ if (path === null) unknowns.push("No supported configured-origin/exact-path association for this GSC URL.");
123
+ const organic = organicNames.map((name) => {
124
+ const table = tables.find((t) => t.observation === name);
125
+ const matched = path === null ? [] : (table?.rows.filter((r) => r.keys[0] === path) ?? []);
126
+ if (!table || !matched.length)
127
+ unknowns.push(`${name}: ${table ? "no exact observed row; not zero activity" : "report unavailable"}.`);
128
+ return {
129
+ source: name,
130
+ association: path === null ? "unmatched" : "configured-origin-exact-path-not-canonical",
131
+ dimensions: table?.dimensions ?? [],
132
+ metrics: table?.metrics ?? [],
133
+ rows: matched.slice(0, policy.maxRows),
134
+ observedMatchingRows: matched.length,
135
+ omittedMatchingRows: Math.max(0, matched.length - policy.maxRows),
136
+ complete: table?.complete ?? false,
137
+ limitations: table?.limitations ?? [],
138
+ };
139
+ });
140
+ const technical = observations.flatMap((observation) => {
141
+ if (observation.status !== "ok" || observation.error || observation.pages.length !== 1) return [];
142
+ const page = observation.pages[0];
143
+ const request = page.request as Record<string, unknown> | null;
144
+ if (
145
+ observation.provider === "web" &&
146
+ observation.operation === "page" &&
147
+ observation.target === candidate.url &&
148
+ request?.url === candidate.url
149
+ ) {
150
+ const parsed = pageSchema.safeParse(page.response);
151
+ if (!parsed.success || parsed.data.url !== candidate.url) return [];
152
+ if (
153
+ parsed.data.canonical !== candidate.url ||
154
+ parsed.data.status !== 200 ||
155
+ parsed.data.finalUrl !== candidate.url ||
156
+ [...parsed.data.robots, parsed.data.xRobotsTag ?? ""].some((v) => /noindex/i.test(v))
157
+ )
158
+ nextChecks.push(
159
+ "Check redirects, canonical and indexing directives against intentional route policy before proposing content edits.",
160
+ );
161
+ return [
162
+ {
163
+ source: observation.name,
164
+ kind: "html",
165
+ collectedAt: observation.finishedAt,
166
+ evidence: parsed.data as unknown,
167
+ limitations: [
168
+ ...observation.warnings,
169
+ ...parsed.data.warnings,
170
+ "HTML only; JavaScript execution and crawler access were not tested.",
171
+ ],
172
+ },
173
+ ];
174
+ }
175
+ if (
176
+ observation.provider === "gsc" &&
177
+ observation.operation === "inspect" &&
178
+ observation.target === config.gscSite &&
179
+ request?.inspectionUrl === candidate.url &&
180
+ request.siteUrl === config.gscSite
181
+ ) {
182
+ const parsed = inspectionSchema.safeParse(page.response);
183
+ if (parsed.success) {
184
+ const index = parsed.data.inspectionResult.indexStatusResult;
185
+ if (
186
+ [index.googleCanonical, index.userCanonical].some(
187
+ (canonical) => canonical !== undefined && canonical !== candidate.url,
188
+ )
189
+ )
190
+ nextChecks.unshift(
191
+ "Check Google's reported canonical URLs against intentional route policy; canonical differences do not authorize additional GA associations or a rewritten candidate URL.",
192
+ );
193
+ if (parsed.data.inspectionResult.indexStatusResult.verdict !== "PASS")
194
+ nextChecks.unshift(
195
+ "Reconcile Google's stored inspection verdict and crawl date with the historical impressions and current route policy before a snippet experiment; these observations may describe different times.",
196
+ );
197
+ return [
198
+ {
199
+ source: observation.name,
200
+ kind: "google-indexed-state",
201
+ collectedAt: observation.finishedAt,
202
+ evidence: parsed.data.inspectionResult.indexStatusResult as unknown,
203
+ limitations: [
204
+ ...observation.warnings,
205
+ "Google's stored indexed state, not a live fetch or proof of historical state.",
206
+ ],
207
+ },
208
+ ];
209
+ }
210
+ }
211
+ return [];
212
+ });
213
+ if (!technical.some((t) => t.kind === "html")) {
214
+ unknowns.push("No usable exact-URL HTML observation.");
215
+ nextChecks.push(
216
+ "Collect this URL's HTML metadata and verify title, description, canonical and robots directives.",
217
+ );
218
+ }
219
+ if (!technical.some((t) => t.kind === "google-indexed-state")) {
220
+ unknowns.push("No usable exact-URL Google inspection.");
221
+ nextChecks.push("Inspect this exact URL in Search Console; indexed state is not a live fetch.");
222
+ }
223
+ nextChecks.push(
224
+ "Validate the meaning and instrumentation dates of relevant events; compare a later equivalent window without treating event counts as a conversion rate.",
225
+ );
226
+ return {
227
+ url: candidate.url,
228
+ reason: `${candidate.impressions} observed impressions and ${candidate.clicks} clicks meet the supplied cutoffs. Investigate; this does not establish underperformance.`,
229
+ search: {
230
+ source: search!.observation,
231
+ keys: [candidate.url],
232
+ metrics: candidate.values,
233
+ complete: search!.complete,
234
+ limitations: search!.limitations,
235
+ },
236
+ organic,
237
+ technical,
238
+ unknowns,
239
+ nextChecks,
240
+ suggestedRequests: [
241
+ {
242
+ operation: "gsc.report",
243
+ site: config.gscSite,
244
+ maxPages: 1,
245
+ request: {
246
+ ...snapshot.pages[0].response.context.requestedDates,
247
+ dimensions: ["query"],
248
+ dataState: "final",
249
+ dimensionFilterGroups: [
250
+ {
251
+ groupType: "and",
252
+ filters: [{ dimension: "page", operator: "equals", expression: candidate.url }],
253
+ },
254
+ ],
255
+ },
256
+ },
257
+ ...(httpUrl.safeParse(candidate.url).success && !technical.some((t) => t.kind === "html")
258
+ ? [{ operation: "page", url: candidate.url }]
259
+ : []),
260
+ ...(httpUrl.safeParse(candidate.url).success && !technical.some((t) => t.kind === "google-indexed-state")
261
+ ? [{ operation: "gsc.inspect", site: config.gscSite, url: candidate.url }]
262
+ : []),
263
+ ],
264
+ };
265
+ });
266
+ const associatedPaths = new Set(
267
+ selected.map((candidate) => configuredPath(candidate.url, config.site)).filter((path) => path !== null),
268
+ );
269
+ const unassociatedOrganic = organicNames.map((source) => {
270
+ const table = tables.find((table) => table.observation === source);
271
+ const rows = table?.rows.filter((row) => !associatedPaths.has(row.keys[0])) ?? [];
272
+ return {
273
+ source,
274
+ available: Boolean(table),
275
+ meaning:
276
+ "Not associated with displayed candidates; includes URL variants, special values and landings outside this selected cohort.",
277
+ dimensions: table?.dimensions ?? [],
278
+ metrics: table?.metrics ?? [],
279
+ rows: rows.slice(0, policy.maxRows),
280
+ observedRows: table ? rows.length : null,
281
+ omittedRows: Math.max(0, rows.length - policy.maxRows),
282
+ complete: table?.complete ?? false,
283
+ limitations: [
284
+ ...(table?.limitations ?? []),
285
+ "(other) aggregates and (not set) values cannot establish exact landing identity. Missing associations are not zero traffic.",
286
+ ],
287
+ };
288
+ });
289
+ return {
290
+ site: config.site,
291
+ basis: "saved-snapshot-not-reverified",
292
+ snapshotSha256: assessed?.snapshotSha256 ?? null,
293
+ collectedAt: snapshot.finishedAt,
294
+ requestedDates: snapshot.pages[0].response.context.requestedDates,
295
+ policy: {
296
+ ...policy,
297
+ ordering: "impressions-desc,clicks-asc,url-asc",
298
+ meaning: "caller-adjustable investigation cutoffs, not quality benchmarks",
299
+ },
300
+ searchReportAvailable: Boolean(search),
301
+ observedSearchRows: search?.observedRows ?? null,
302
+ unusableSearchRows,
303
+ qualifyingObservedRows: search ? candidates.length : null,
304
+ omittedCandidates: Math.max(0, candidates.length - policy.maxRows),
305
+ candidates: selected,
306
+ unassociatedOrganic,
307
+ limitations: limits,
308
+ };
309
+ },
310
+ );
311
+ if (
312
+ result.status !== "error" &&
313
+ (assessment.status !== "ok" || !search || !search.complete || unusableSearchRows > 0)
314
+ )
315
+ result.status = "partial";
316
+ return result;
317
+ }
@@ -0,0 +1,216 @@
1
+ import { z } from "zod";
2
+ import type { Evidence } from "./evidence.js";
3
+ import type { ImportedSnapshot } from "./evidence-schema.js";
4
+ import { gaRequestSchema, gscRequestSchema } from "./schema.js";
5
+ import { gaFreshnessWarnings } from "./ga-freshness.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
+ export 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
+ export class Incompatible extends Error {}
69
+ export 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
+ export 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
+ current.warnings.push(...gaFreshnessWarnings([current.end], response.metadata.timeZone, observation.finishedAt));
155
+ if (response.metricHeaders.some((h) => h.type === "TYPE_CURRENCY") && !response.metadata.currencyCode)
156
+ throw new Incompatible("GA currency metrics require a known response currency.");
157
+ rows = response.rows.map((r) => ({
158
+ keys: r.dimensionValues.map((v) => v.value),
159
+ values: r.metricValues.map((v) => v.value),
160
+ }));
161
+ }
162
+ requireSame(
163
+ { startDate: current.start, endDate: current.end },
164
+ { startDate: context.requestedDates.startDate, endDate: context.requestedDates.endDate },
165
+ "Report period does not match the snapshot context.",
166
+ );
167
+ if (report) {
168
+ requireSame(
169
+ [report.request, report.start, report.end, report.semantics],
170
+ [current.request, current.start, current.end, current.semantics],
171
+ "Report scope or metadata changed between pages.",
172
+ );
173
+ report.warnings.push(...current.warnings);
174
+ } else report = { ...current, rows: new Map() };
175
+ for (const row of rows) {
176
+ if (row.keys.length !== current.dimensions.length || row.values.length !== current.metrics.length)
177
+ throw new Incompatible("Row shape does not match the requested dimensions and metrics.");
178
+ const key = canonical(row.keys);
179
+ if (report.rows.has(key)) throw new Incompatible("Duplicate row keys make this report ambiguous.");
180
+ report.rows.set(key, row);
181
+ }
182
+ offset += rows.length;
183
+ }
184
+ if (!report) throw new Incompatible("No report pages available.");
185
+ if (observation.provider === "ga") {
186
+ const totals = observation.pages.map((p) => (p.response as { rowCount?: number }).rowCount ?? 0);
187
+ if (
188
+ totals.some((n) => !Number.isSafeInteger(n) || n < offset || n !== totals[0]) ||
189
+ (observation.pagination?.exhausted && totals[0] !== offset)
190
+ )
191
+ throw new Incompatible("GA rowCount conflicts with retained rows or completeness.");
192
+ }
193
+
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
+ export 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
+ }
@@ -0,0 +1,108 @@
1
+ import { gaFreshnessWarnings } from "./ga-freshness.js";
2
+ import { type GaReport, gaFetch, normalizeGaProperty } from "../providers/ga.js";
3
+ import { querySearchAnalytics, type SearchAnalyticsResponse } from "../providers/gsc.js";
4
+ import { RequestError } from "../shared/http.js";
5
+ import { type Evidence, createEvidence, fail } from "./evidence.js";
6
+ import type { GaRequest, GscRequest } from "./schema.js";
7
+
8
+ type Query<T> = (request: T) => Promise<SearchAnalyticsResponse | GaReport>;
9
+
10
+ async function pages<T extends GscRequest | GaRequest>(
11
+ provider: "gsc" | "ga",
12
+ target: string,
13
+ request: T,
14
+ maxPages: number,
15
+ query: Query<T>,
16
+ ): Promise<Evidence> {
17
+ const result = createEvidence(provider, "report", target);
18
+ const isGsc = provider === "gsc";
19
+ const offsetKey = isGsc ? "startRow" : "offset";
20
+ let offset = Number(isGsc ? (request as GscRequest).startRow : (request as GaRequest).offset);
21
+ const limit = Number(isGsc ? (request as GscRequest).rowLimit : (request as GaRequest).limit);
22
+ result.pagination = { exhausted: false, nextOffset: offset, rowsReturned: 0 };
23
+ if (isGsc) {
24
+ result.warnings.push(
25
+ "GSC dates are America/Los_Angeles; API top-row limits apply even after pagination is exhausted.",
26
+ );
27
+ if ((request as GscRequest).dimensions.includes("query"))
28
+ result.warnings.push("Anonymized queries are omitted. Query-row sums are not property totals.");
29
+ if ((request as GscRequest).dimensions.includes("page"))
30
+ result.warnings.push("Page aggregation differs from property aggregation; do not reconcile their row sums.");
31
+ if ((request as GscRequest).dataState !== "final")
32
+ result.warnings.push("Fresh data can be incomplete even when metadata is absent for this grouping.");
33
+ } else if (
34
+ (request as GaRequest).dimensions.some((dimension) => dimension.name === "landingPagePlusQueryString") &&
35
+ (request as GaRequest).dimensions.some((dimension) => dimension.name === "eventName")
36
+ ) {
37
+ result.warnings.push(
38
+ "Landing page is the first pageview of the session, not necessarily where an event occurred. Event counts are occurrences, not unique sessions or a conversion rate.",
39
+ "Landing URLs retain query strings and (not set)/(other) values; do not join them to Search Console canonical URLs without verified mapping.",
40
+ );
41
+ }
42
+ for (let page = 0; page < maxPages; page++) {
43
+ const effective = { ...request, [offsetKey]: offset };
44
+ try {
45
+ const response = await query(effective);
46
+ if (response.rows !== undefined && !Array.isArray(response.rows))
47
+ throw new RequestError("Invalid report rows", null, "invalid_response");
48
+ const count = response.rows?.length ?? 0;
49
+ const rowCount =
50
+ (response as GaReport).rowCount ??
51
+ (count === 0 && Array.isArray((response as GaReport).metricHeaders) ? 0 : undefined);
52
+ if (!isGsc && (!Number.isSafeInteger(rowCount) || Number(rowCount) < 0))
53
+ throw new RequestError("GA response missing a valid rowCount", null, "invalid_response");
54
+ result.pages.push({ request: effective, response });
55
+ result.pagination.rowsReturned += count;
56
+ offset += count;
57
+ const exhausted = isGsc ? count < limit : offset >= Number(rowCount);
58
+ result.pagination.exhausted = exhausted;
59
+ result.pagination.nextOffset = exhausted ? null : offset;
60
+ if (!isGsc) {
61
+ const metadata = (response as GaReport).metadata;
62
+ if (metadata?.subjectToThresholding) result.warnings.push("GA report is subject to thresholding.");
63
+ if (metadata?.dataLossFromOtherRow)
64
+ result.warnings.push("GA high-cardinality rows were combined into (other).");
65
+ if (metadata?.samplingMetadatas?.length) result.warnings.push("GA report is sampled; see response metadata.");
66
+ }
67
+ if (exhausted) break;
68
+ if (!count) throw new RequestError("Provider pagination made no progress", null, "invalid_response");
69
+ } catch (error) {
70
+ result.failedRequest = effective;
71
+ fail(result, error);
72
+ break;
73
+ }
74
+ }
75
+ if (!result.pagination.exhausted && !result.error) result.status = "partial";
76
+ if (!result.pagination.exhausted)
77
+ result.warnings.push("Pagination not exhausted. Missing rows are unknown, not zero.");
78
+ result.finishedAt = new Date().toISOString();
79
+ if (!isGsc)
80
+ for (const page of result.pages)
81
+ result.warnings.push(
82
+ ...gaFreshnessWarnings(
83
+ (request as GaRequest).dateRanges.map((range) => range.endDate),
84
+ (page.response as GaReport).metadata?.timeZone,
85
+ result.finishedAt,
86
+ ),
87
+ );
88
+ result.warnings = [...new Set(result.warnings)];
89
+ return result;
90
+ }
91
+
92
+ export function gscReport(
93
+ site: string,
94
+ request: GscRequest,
95
+ maxPages: number,
96
+ query = (r: GscRequest) => querySearchAnalytics(site, r),
97
+ ): Promise<Evidence> {
98
+ return pages("gsc", site, request, maxPages, query);
99
+ }
100
+
101
+ export function gaReport(
102
+ property: string,
103
+ request: GaRequest,
104
+ maxPages: number,
105
+ query = (r: GaRequest) => gaFetch<GaReport>(`${normalizeGaProperty(property)}:runReport`, r),
106
+ ): Promise<Evidence> {
107
+ return pages("ga", normalizeGaProperty(property), request, maxPages, query);
108
+ }