pagesight 0.18.0 → 0.20.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 (46) hide show
  1. package/README.md +19 -0
  2. package/docs/changes.md +118 -0
  3. package/docs/cloudflare.md +31 -0
  4. package/docs/crawl.md +64 -0
  5. package/docs/investigation.md +85 -0
  6. package/docs/measurement.md +178 -0
  7. package/docs/monitoring.md +39 -0
  8. package/docs/opportunities.md +78 -0
  9. package/docs/rendering.md +102 -0
  10. package/docs/seo-agent-workflow.md +153 -0
  11. package/docs/snapshots.md +7 -0
  12. package/docs/usage.md +25 -17
  13. package/package.json +5 -2
  14. package/src/api/assessment.ts +315 -0
  15. package/src/api/change-record.ts +31 -0
  16. package/src/api/cloudflare.ts +201 -0
  17. package/src/api/compare-snapshots.ts +5 -215
  18. package/src/api/crawl.ts +53 -0
  19. package/src/api/evaluate-change.ts +189 -0
  20. package/src/api/execute.ts +42 -0
  21. package/src/api/followup-changes.ts +264 -0
  22. package/src/api/ga-freshness.ts +20 -0
  23. package/src/api/ga-realtime.ts +40 -0
  24. package/src/api/http-url.ts +13 -0
  25. package/src/api/investigation.ts +344 -0
  26. package/src/api/opportunities.ts +317 -0
  27. package/src/api/report-table.ts +216 -0
  28. package/src/api/reports.ts +19 -1
  29. package/src/api/schema.ts +162 -11
  30. package/src/api/snapshot.ts +20 -1
  31. package/src/api/technical-changes.ts +124 -0
  32. package/src/api/ui-findings.ts +1 -1
  33. package/src/api/verify-render.ts +134 -0
  34. package/src/assessment-text.ts +55 -0
  35. package/src/cli.ts +173 -38
  36. package/src/followup-manifest.ts +42 -0
  37. package/src/followup-text.ts +45 -0
  38. package/src/investigation-text.ts +32 -0
  39. package/src/opportunities-text.ts +61 -0
  40. package/src/providers/cloudflare.ts +46 -0
  41. package/src/tools/observe.ts +1 -1
  42. package/src/web/fetch.ts +2 -1
  43. package/src/web/render-browser.ts +190 -0
  44. package/src/web/render-dom.ts +72 -0
  45. package/src/web/render-network.ts +122 -0
  46. package/src/web/site-graph.ts +443 -0
@@ -1,221 +1,9 @@
1
- import { z } from "zod";
1
+ import { canonical, normalizeReport, parseNumericValue, Incompatible, requireSame } from "./report-table.js";
2
2
  import { RequestError } from "../shared/http.js";
3
3
  import { capture, type Evidence } from "./evidence.js";
4
4
  import type { ImportedSnapshot } from "./evidence-schema.js";
5
- import { gaRequestSchema, gscRequestSchema } from "./schema.js";
6
5
 
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) {
6
+ function metric(baseline: number | string, current: number | string) {
219
7
  const before = parseNumericValue(baseline);
220
8
  const after = parseNumericValue(current);
221
9
  if (before === null || after === null)
@@ -233,7 +21,9 @@ function metric(baseline: RawValue, current: RawValue) {
233
21
  return { baseline, current, delta, percentChange: percent !== null && Number.isFinite(percent) ? percent : null };
234
22
  }
235
23
 
236
- function compareObservation(
24
+ type SnapshotContext = ImportedSnapshot["pages"][0]["response"]["context"];
25
+
26
+ export function compareObservation(
237
27
  baseline: Evidence | undefined,
238
28
  current: Evidence | undefined,
239
29
  maxRows: number,
@@ -0,0 +1,53 @@
1
+ import { aggregate, capture, type Evidence } from "./evidence.js";
2
+ import { crawlSite } from "../web/site-graph.js";
3
+ import type { SiteConfig } from "./schema.js";
4
+ import type { Executor } from "./execute.js";
5
+
6
+ type Input = {
7
+ config: SiteConfig;
8
+ maxPages: number;
9
+ maxDepth: number;
10
+ maxLinks: number;
11
+ includeQuery: boolean;
12
+ inspectLimit: number;
13
+ };
14
+ export async function crawl(input: Input, run: Executor): Promise<Evidence> {
15
+ const { config } = input;
16
+ const request = {
17
+ site: config.site,
18
+ seeds: config.pages,
19
+ sitemap: config.sitemap,
20
+ maxPages: input.maxPages,
21
+ maxDepth: input.maxDepth,
22
+ maxLinks: input.maxLinks,
23
+ includeQuery: input.includeQuery,
24
+ };
25
+ const graph = await capture("web", "site-graph", config.site, request, () => crawlSite(request));
26
+ graph.name = "crawl.graph";
27
+ const data = graph.pages[0]?.response as Awaited<ReturnType<typeof crawlSite>> | undefined;
28
+ if (data && !data.complete) graph.status = "partial";
29
+ const observations = [graph];
30
+ const urls =
31
+ data?.pages
32
+ .filter((p) => p.status === 200 && p.contentType?.toLowerCase().includes("text/html"))
33
+ .slice(0, input.inspectLimit)
34
+ .map((p) => p.url) ?? [];
35
+ if (config.gscSite)
36
+ for (const url of urls)
37
+ observations.push({
38
+ ...(await run({ operation: "gsc.inspect", site: config.gscSite, url })),
39
+ name: `gsc.inspect:${url}`,
40
+ });
41
+ const result = aggregate("crawl", config.site, input, observations);
42
+ Object.assign(result.pages[0].response as object, {
43
+ context: config.context,
44
+ indexingSample: {
45
+ selected: config.gscSite ? urls : [],
46
+ limit: input.inspectLimit,
47
+ available: Boolean(config.gscSite),
48
+ meaning:
49
+ "Stored Google state for selected fetched HTML pages only; not a site indexing count. Missing inspection is unknown.",
50
+ },
51
+ });
52
+ return result;
53
+ }
@@ -0,0 +1,189 @@
1
+ import type { ChangeRecord } from "./change-record.js";
2
+ import { compareObservation } from "./compare-snapshots.js";
3
+ import { capture, type Evidence } from "./evidence.js";
4
+ import type { ImportedSnapshot } from "./evidence-schema.js";
5
+ import { canonical, normalizeReport, requireSame, Incompatible } from "./report-table.js";
6
+ import { configSchema, gaRequestSchema, gscRequestSchema } from "./schema.js";
7
+ import { snapshotOperations, observationName } from "./snapshot.js";
8
+ import { normalizeGaProperty } from "../providers/ga.js";
9
+ import { RequestError } from "../shared/http.js";
10
+
11
+ export function localDay(at: string, timeZone: string): string {
12
+ const parts = new Intl.DateTimeFormat("en-US", {
13
+ timeZone,
14
+ year: "numeric",
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ }).formatToParts(new Date(at));
18
+ const value = (name: string) => parts.find((p) => p.type === name)!.value;
19
+ return `${value("year")}-${value("month")}-${value("day")}`;
20
+ }
21
+ function scope(provider: string, request: unknown) {
22
+ if (provider === "ga") {
23
+ const { offset: _offset, limit: _limit, returnPropertyQuota: _quota, ...rest } = gaRequestSchema.parse(request);
24
+ return rest;
25
+ }
26
+ const { startRow: _offset, rowLimit: _limit, ...rest } = gscRequestSchema.parse(request);
27
+ return rest;
28
+ }
29
+ export function boundReport(snapshot: ImportedSnapshot, name: string) {
30
+ const { context, observations } = snapshot.pages[0].response;
31
+ const config = configSchema.parse(context.config);
32
+ const expected = snapshotOperations(config, context.requestedDates.startDate, context.requestedDates.endDate, 1).find(
33
+ (op) => observationName(op) === name,
34
+ );
35
+ const observation = observations.find((o) => o.name === name);
36
+ if (!expected || !["ga.report", "gsc.report"].includes(expected.operation))
37
+ throw new Incompatible("Not a configured snapshot report.");
38
+ if (!observation || observation.status === "error" || !observation.pages.length)
39
+ throw new Incompatible("Report unavailable; absence is not zero.");
40
+ const provider = expected.operation === "ga.report" ? "ga" : "gsc";
41
+ const target =
42
+ expected.operation === "ga.report"
43
+ ? normalizeGaProperty(expected.property)
44
+ : expected.operation === "gsc.report"
45
+ ? expected.site
46
+ : "";
47
+ if (observation.provider !== provider || observation.operation !== "report" || observation.target !== target)
48
+ throw new Incompatible("Report identity does not match configured provider/property.");
49
+ if (expected.operation !== "ga.report" && expected.operation !== "gsc.report")
50
+ throw new Incompatible("Unsupported report.");
51
+ for (const page of observation.pages)
52
+ requireSame(
53
+ scope(provider, page.request),
54
+ scope(provider, expected.request),
55
+ "Report request differs from configured scope.",
56
+ );
57
+ return { observation, report: normalizeReport(observation, context) };
58
+ }
59
+ export const fingerprint = (value: unknown) => Bun.CryptoHasher.hash("sha256", canonical(value), "hex");
60
+
61
+ export async function evaluateChange(
62
+ record: ChangeRecord,
63
+ baseline: ImportedSnapshot,
64
+ current: ImportedSnapshot | undefined,
65
+ maxRows: number,
66
+ ): Promise<Evidence> {
67
+ if (baseline.target !== record.site || (current && current.target !== record.site))
68
+ throw new RequestError("Change record and snapshots must identify the same exact site", null, "invalid_input");
69
+ const before = baseline.pages[0].response;
70
+ const after = current?.pages[0].response;
71
+ const names = [
72
+ ...new Set(
73
+ [...before.observations, ...(after?.observations ?? [])]
74
+ .filter((o) => o.operation === "report" && ["gsc", "ga"].includes(o.provider))
75
+ .map((o) => o.name),
76
+ ),
77
+ ].sort();
78
+ const result = await capture(
79
+ "pagesight",
80
+ "change.evaluate",
81
+ record.site,
82
+ {
83
+ recordSha256: fingerprint(record),
84
+ baselineSha256: fingerprint(baseline),
85
+ currentSha256: current ? fingerprint(current) : null,
86
+ maxRows,
87
+ },
88
+ async () => {
89
+ const observations = names.map((name) => {
90
+ try {
91
+ const a = boundReport(baseline, name);
92
+ const timezone = (a.report.semantics as { timezone: string }).timezone;
93
+ const deploymentDay = localDay(record.deployedAt, timezone);
94
+ if (a.report.end >= deploymentDay)
95
+ throw new Incompatible("Baseline must end before the deployment day in this report's timezone.");
96
+ if (!current || !after)
97
+ return {
98
+ name,
99
+ status: "pending",
100
+ reason: "After snapshot not supplied; no outcome is available.",
101
+ timezone,
102
+ deploymentDay,
103
+ };
104
+ const b = boundReport(current, name);
105
+ if (b.report.start <= deploymentDay)
106
+ throw new Incompatible("After period must start after the deployment day in this report's timezone.");
107
+ const identity = (snapshot: ImportedSnapshot) => {
108
+ const config = configSchema.parse(snapshot.pages[0].response.context.config);
109
+ return {
110
+ site: config.site,
111
+ gscSite: config.gscSite,
112
+ gaProperty: config.gaProperty ? normalizeGaProperty(config.gaProperty) : null,
113
+ productionHostname: config.productionHostname,
114
+ };
115
+ };
116
+ requireSame(
117
+ identity(baseline),
118
+ identity(current),
119
+ "Configured site, provider identity or production hostname changed.",
120
+ );
121
+ if (
122
+ a.observation.provider === "ga" &&
123
+ record.measurementChanges.some((change) => {
124
+ const day = localDay(change.at, timezone);
125
+ return day >= a.report.start && day <= b.report.end;
126
+ })
127
+ )
128
+ throw new Incompatible(
129
+ "A declared measurement change intersects the combined periods; GA deltas are withheld.",
130
+ );
131
+ return {
132
+ ...compareObservation(a.observation, b.observation, maxRows, before.context, after.context),
133
+ timezone,
134
+ deploymentDay,
135
+ gapDays: (Date.parse(b.report.start) - Date.parse(a.report.end)) / 86400000 - 1,
136
+ weekdays: {
137
+ baselineStart: new Date(a.report.start).getUTCDay(),
138
+ currentStart: new Date(b.report.start).getUTCDay(),
139
+ },
140
+ };
141
+ } catch (error) {
142
+ return {
143
+ name,
144
+ status: "incompatible",
145
+ reason:
146
+ error instanceof Incompatible
147
+ ? error.message
148
+ : "Unsupported report or reporting timezone; no delta calculated.",
149
+ };
150
+ }
151
+ });
152
+ return {
153
+ evaluationVersion: 1,
154
+ record,
155
+ recordVerification: "User-supplied deployment and change context, not independently verified",
156
+ scope: "Original configured snapshot reports; affectedUrls are annotations and do not filter report rows",
157
+ state: !current
158
+ ? observations.some((o) => o.status === "pending")
159
+ ? "pending"
160
+ : "unavailable"
161
+ : observations.some((o) => ["compared", "limited"].includes(o.status))
162
+ ? "descriptive"
163
+ : "unavailable",
164
+ observations,
165
+ confounded: record.overlappingChanges.length > 0,
166
+ contextChanged: Boolean(
167
+ after &&
168
+ canonical(configSchema.parse(before.context.config)) !== canonical(configSchema.parse(after.context.config)),
169
+ ),
170
+ limitations: [
171
+ "Cloudflare, Bing, crawl graphs, HTML, sitemaps and inspection evidence are unsupported; no cross-provider funnel is calculated.",
172
+ "Declared overlapping changes confound attribution. Configuration-context changes remain visible and can alter event interpretation.",
173
+ "Descriptive comparison only. This does not establish causality, statistical significance or the change's effect on rankings.",
174
+ "Deployment day is excluded in each report's timezone. Equal windows can still differ in weekdays, seasonality, demand or search-system changes.",
175
+ "Only declared measurement and overlapping changes are known. An empty list is not proof that no other changes occurred.",
176
+ "Affected URLs do not change property, hostname, channel or row scope. Missing rows remain unknown, never zero.",
177
+ "Collected timestamps do not prove immutable historical data; source hashes identify the supplied artifacts, not their authenticity.",
178
+ ],
179
+ };
180
+ },
181
+ );
182
+ const data = result.pages[0]?.response as { observations: Array<{ status: string }> } | undefined;
183
+ if (
184
+ result.status === "ok" &&
185
+ (!current || !data?.observations.length || data.observations.some((o) => o.status !== "compared"))
186
+ )
187
+ result.status = "partial";
188
+ return result;
189
+ }
@@ -1,3 +1,14 @@
1
+ import { verifyRender } from "./verify-render.js";
2
+ import { followupChanges } from "./followup-changes.js";
3
+ import { cloudflareAudit } from "./cloudflare.js";
4
+
5
+ import { evaluateChange } from "./evaluate-change.js";
6
+ import { crawl } from "./crawl.js";
7
+ import { technicalChanges } from "./technical-changes.js";
8
+ import { investigate } from "./investigation.js";
9
+ import { assessSnapshot } from "./assessment.js";
10
+ import { opportunities } from "./opportunities.js";
11
+ import { gaRealtime } from "./ga-realtime.js";
1
12
  import { importUiFindings } from "./ui-findings.js";
2
13
  import { doctor } from "./doctor.js";
3
14
  import { ZodError } from "zod";
@@ -31,6 +42,12 @@ export async function execute(input: unknown): Promise<Evidence> {
31
42
  "invalid_input",
32
43
  );
33
44
  }
45
+ if (
46
+ op.operation === "page.verify" &&
47
+ op.navigation &&
48
+ new URL(op.navigation.fromUrl).origin !== new URL(op.url).origin
49
+ )
50
+ throw new RequestError("Navigation source must share the target origin", null, "invalid_input");
34
51
  const result = await dispatch(op);
35
52
  if (op.operation.startsWith("ga.")) result.credential = await gaCredentialInfo();
36
53
  if (op.operation.startsWith("bing."))
@@ -45,6 +62,29 @@ export async function execute(input: unknown): Promise<Evidence> {
45
62
 
46
63
  async function dispatch(op: ParsedOperation): Promise<Evidence> {
47
64
  switch (op.operation) {
65
+ case "page.verify":
66
+ return verifyRender(op);
67
+ case "cloudflare.audit":
68
+ return cloudflareAudit(op);
69
+
70
+ case "change.followup":
71
+ return followupChanges(op.experiments, op.asOf ?? new Date().toISOString(), op.lagDays);
72
+ case "change.evaluate":
73
+ return evaluateChange(op.record, op.baseline, op.current, op.maxRows);
74
+ case "crawl":
75
+ return crawl(op, execute);
76
+ case "technical.compare":
77
+ return technicalChanges(op.baseline, op.current);
78
+ case "investigate":
79
+ return investigate(op, execute);
80
+ case "opportunities":
81
+ return opportunities(op.snapshot, {
82
+ minImpressions: op.minImpressions,
83
+ maxClicks: op.maxClicks,
84
+ maxRows: op.maxRows,
85
+ });
86
+ case "assess":
87
+ return assessSnapshot(op.snapshot, op.maxRows);
48
88
  case "evidence.import":
49
89
  return importUiFindings(op.document);
50
90
  case "compare":
@@ -109,6 +149,8 @@ async function dispatch(op: ParsedOperation): Promise<Evidence> {
109
149
  "Configured key events are not necessarily validated product outcomes; inspect event names. Check nextPageToken.",
110
150
  ],
111
151
  );
152
+ case "ga.realtime":
153
+ return gaRealtime(op.property, op.request);
112
154
  case "ga.report":
113
155
  return gaReport(op.property, op.request, op.maxPages);
114
156
  case "page":