pagesight 0.18.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.
@@ -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,6 +21,8 @@ function metric(baseline: RawValue, current: RawValue) {
233
21
  return { baseline, current, delta, percentChange: percent !== null && Number.isFinite(percent) ? percent : null };
234
22
  }
235
23
 
24
+ type SnapshotContext = ImportedSnapshot["pages"][0]["response"]["context"];
25
+
236
26
  function compareObservation(
237
27
  baseline: Evidence | undefined,
238
28
  current: Evidence | undefined,
@@ -1,3 +1,6 @@
1
+ import { assessSnapshot } from "./assessment.js";
2
+ import { opportunities } from "./opportunities.js";
3
+ import { gaRealtime } from "./ga-realtime.js";
1
4
  import { importUiFindings } from "./ui-findings.js";
2
5
  import { doctor } from "./doctor.js";
3
6
  import { ZodError } from "zod";
@@ -45,6 +48,14 @@ export async function execute(input: unknown): Promise<Evidence> {
45
48
 
46
49
  async function dispatch(op: ParsedOperation): Promise<Evidence> {
47
50
  switch (op.operation) {
51
+ case "opportunities":
52
+ return opportunities(op.snapshot, {
53
+ minImpressions: op.minImpressions,
54
+ maxClicks: op.maxClicks,
55
+ maxRows: op.maxRows,
56
+ });
57
+ case "assess":
58
+ return assessSnapshot(op.snapshot, op.maxRows);
48
59
  case "evidence.import":
49
60
  return importUiFindings(op.document);
50
61
  case "compare":
@@ -109,6 +120,8 @@ async function dispatch(op: ParsedOperation): Promise<Evidence> {
109
120
  "Configured key events are not necessarily validated product outcomes; inspect event names. Check nextPageToken.",
110
121
  ],
111
122
  );
123
+ case "ga.realtime":
124
+ return gaRealtime(op.property, op.request);
112
125
  case "ga.report":
113
126
  return gaReport(op.property, op.request, op.maxPages);
114
127
  case "page":
@@ -0,0 +1,20 @@
1
+ export function gaFreshnessWarnings(endDates: string[], timeZone: unknown, collectedAt: string): string[] {
2
+ if (typeof timeZone !== "string" || !timeZone)
3
+ return ["GA property timezone is unavailable; report freshness could not be assessed."];
4
+ let collectedDate: string;
5
+ try {
6
+ collectedDate = new Intl.DateTimeFormat("en-CA", {
7
+ timeZone,
8
+ year: "numeric",
9
+ month: "2-digit",
10
+ day: "2-digit",
11
+ }).format(new Date(collectedAt));
12
+ } catch {
13
+ return ["GA property timezone or collection time is invalid; report freshness could not be assessed."];
14
+ }
15
+ return endDates.some((end) => Date.parse(collectedDate) - Date.parse(end) < 3 * 86400000)
16
+ ? [
17
+ "GA was collected fewer than three property-calendar days after the requested end date; recent data may still change. This precaution is not a finalization guarantee for older data.",
18
+ ]
19
+ : [];
20
+ }
@@ -0,0 +1,40 @@
1
+ import { gaFetch, normalizeGaProperty, type GaReport } from "../providers/ga.js";
2
+ import { RequestError } from "../shared/http.js";
3
+ import { createEvidence, fail, type Evidence } from "./evidence.js";
4
+ import type { GaRealtimeRequest } from "./schema.js";
5
+
6
+ export async function gaRealtime(
7
+ property: string,
8
+ request: GaRealtimeRequest,
9
+ query = (r: GaRealtimeRequest) => gaFetch<GaReport>(`${normalizeGaProperty(property)}:runRealtimeReport`, r),
10
+ ): Promise<Evidence> {
11
+ const result = createEvidence("ga", "realtime", normalizeGaProperty(property));
12
+ result.warnings.push(
13
+ "Realtime is a moving window of reported property activity, not a historical or finalized report. Requested filters determine scope; no production hostname filter is added.",
14
+ "Activity does not identify a particular browser test or prove event counts are correct. Empty rows do not prove collection failed.",
15
+ "Realtime dimensions and metrics differ from standard reports. Windows beyond 29 minutes ago require Analytics 360; overlapping ranges count overlapping events in both ranges.",
16
+ );
17
+ try {
18
+ const response = await query(request);
19
+ result.pages.push({ request, response });
20
+ if (response.rows !== undefined && !Array.isArray(response.rows))
21
+ throw new RequestError("Invalid realtime rows", null, "invalid_response");
22
+ const count = response.rows?.length ?? 0;
23
+ const total = response.rowCount ?? (count === 0 && Array.isArray(response.metricHeaders) ? 0 : undefined);
24
+ if (!Number.isSafeInteger(total) || Number(total) < count)
25
+ throw new RequestError("Invalid realtime rowCount", null, "invalid_response");
26
+ const exhausted = total === count;
27
+ result.pagination = { exhausted, nextOffset: null, rowsReturned: count };
28
+ if (!exhausted) {
29
+ result.status = "partial";
30
+ result.warnings.push(
31
+ "Realtime rows were truncated. The API has no offset or page token; narrow dimensions or raise the limit. Missing rows are unknown, not zero.",
32
+ );
33
+ }
34
+ } catch (error) {
35
+ result.failedRequest = request;
36
+ fail(result, error);
37
+ }
38
+ result.finishedAt = new Date().toISOString();
39
+ return result;
40
+ }
@@ -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
+ }