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,167 @@
1
+ import { canonical, normalizeReport, parseNumericValue, Incompatible, requireSame } from "./report-table.js";
2
+ import { RequestError } from "../shared/http.js";
3
+ import { capture, type Evidence } from "./evidence.js";
4
+ import type { ImportedSnapshot } from "./evidence-schema.js";
5
+
6
+ function metric(baseline: number | string, current: number | string) {
7
+ const before = parseNumericValue(baseline);
8
+ const after = parseNumericValue(current);
9
+ if (before === null || after === null)
10
+ return {
11
+ baseline,
12
+ current,
13
+ delta: null,
14
+ percentChange: null,
15
+ reason: "Unsafe or invalid numeric value; raw values retained.",
16
+ };
17
+ const delta = after - before;
18
+ if (!Number.isFinite(delta) || Math.abs(delta) > Number.MAX_SAFE_INTEGER)
19
+ return { baseline, current, delta: null, percentChange: null, reason: "Numeric delta exceeds safe range." };
20
+ const percent = before === 0 ? null : (delta / before) * 100;
21
+ return { baseline, current, delta, percentChange: percent !== null && Number.isFinite(percent) ? percent : null };
22
+ }
23
+
24
+ type SnapshotContext = ImportedSnapshot["pages"][0]["response"]["context"];
25
+
26
+ function compareObservation(
27
+ baseline: Evidence | undefined,
28
+ current: Evidence | undefined,
29
+ maxRows: number,
30
+ beforeContext: SnapshotContext,
31
+ afterContext: SnapshotContext,
32
+ ) {
33
+ const name = baseline?.name ?? current?.name;
34
+ const warnings = [...new Set([...(baseline?.warnings ?? []), ...(current?.warnings ?? [])])];
35
+ if (!baseline || !current)
36
+ return {
37
+ name,
38
+ status: "unavailable",
39
+ reason: "Observation missing from one snapshot; absence is not zero.",
40
+ warnings,
41
+ };
42
+ if (
43
+ baseline.provider !== current.provider ||
44
+ baseline.operation !== current.operation ||
45
+ baseline.target !== current.target
46
+ )
47
+ return { name, status: "incompatible", reason: "Observation provider, operation or target changed.", warnings };
48
+ if (baseline.operation !== "report" || !["gsc", "ga"].includes(baseline.provider))
49
+ return {
50
+ name,
51
+ status: "unsupported",
52
+ reason:
53
+ "This version compares GSC and GA reports only. Bing windows and other observations require a separate comparison policy.",
54
+ warnings,
55
+ };
56
+ if (baseline.status === "error" || current.status === "error" || !baseline.pages.length || !current.pages.length)
57
+ return { name, status: "unavailable", reason: "A provider report failed or has no response pages.", warnings };
58
+ try {
59
+ const a = normalizeReport(baseline, beforeContext);
60
+ const b = normalizeReport(current, afterContext);
61
+ requireSame(a.request, b.request, "Requested dimensions, metrics, filters or scope changed.");
62
+ requireSame(
63
+ a.semantics,
64
+ b.semantics,
65
+ "Provider aggregation, metric types, currency or reporting timezone changed.",
66
+ );
67
+ const durationA = Date.parse(a.end) - Date.parse(a.start);
68
+ const durationB = Date.parse(b.end) - Date.parse(b.start);
69
+ if (durationA !== durationB || a.end >= b.start)
70
+ throw new Incompatible("Periods must have equal duration, with baseline ending before current starts.");
71
+ const common = [...a.rows.keys()].filter((key) => b.rows.has(key)).sort();
72
+ const onlyBaseline = [...a.rows.keys()].filter((key) => !b.rows.has(key)).sort();
73
+ const onlyCurrent = [...b.rows.keys()].filter((key) => !a.rows.has(key)).sort();
74
+ const limitations = [...new Set([...a.warnings, ...b.warnings])];
75
+ const rows = common.slice(0, maxRows).map((key) => {
76
+ const before = a.rows.get(key)!;
77
+ const after = b.rows.get(key)!;
78
+ return {
79
+ keys: before.keys,
80
+ metrics: Object.fromEntries(
81
+ a.metrics.map((name, index) => [name, metric(before.values[index], after.values[index])]),
82
+ ),
83
+ };
84
+ });
85
+ if (rows.some((row) => Object.values(row.metrics).some((m) => m.delta === null)))
86
+ limitations.push("Some numeric values could not be compared safely.");
87
+ return {
88
+ name,
89
+ status: limitations.length || common.length > maxRows ? "limited" : "compared",
90
+ baselinePeriod: { startDate: a.start, endDate: a.end },
91
+ currentPeriod: { startDate: b.start, endDate: b.end },
92
+ dimensions: a.dimensions,
93
+ rows,
94
+ commonRowCount: common.length,
95
+ omittedCommonRows: Math.max(0, common.length - maxRows),
96
+ rowScope: "observed-keys",
97
+ baselineOnlyObserved: {
98
+ count: onlyBaseline.length,
99
+ keys: onlyBaseline.slice(0, maxRows).map((key) => a.rows.get(key)!.keys),
100
+ },
101
+ currentOnlyObserved: {
102
+ count: onlyCurrent.length,
103
+ keys: onlyCurrent.slice(0, maxRows).map((key) => b.rows.get(key)!.keys),
104
+ },
105
+ warnings: [
106
+ ...warnings,
107
+ ...limitations,
108
+ "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.",
109
+ ],
110
+ };
111
+ } catch (error) {
112
+ return {
113
+ name,
114
+ status: "incompatible",
115
+ reason:
116
+ error instanceof Incompatible
117
+ ? error.message
118
+ : "Report request or response does not match the supported schema.",
119
+ warnings,
120
+ };
121
+ }
122
+ }
123
+
124
+ export function compareSnapshots(baseline: ImportedSnapshot, current: ImportedSnapshot, maxRows: number) {
125
+ if (baseline.target !== current.target)
126
+ throw new RequestError("Snapshots must identify the same site", null, "invalid_input");
127
+ const before = baseline.pages[0].response.observations;
128
+ const after = current.pages[0].response.observations;
129
+ const names = [...new Set([...before, ...after].map((o) => o.name))].sort();
130
+ const source = (snapshot: ImportedSnapshot) => ({
131
+ target: snapshot.target,
132
+ startedAt: snapshot.startedAt,
133
+ finishedAt: snapshot.finishedAt,
134
+ canonicalSha256: Bun.CryptoHasher.hash("sha256", canonical(snapshot), "hex"),
135
+ });
136
+ return capture(
137
+ "pagesight",
138
+ "compare",
139
+ baseline.target,
140
+ { baseline: source(baseline), current: source(current), maxRows },
141
+ async () => {
142
+ const observations = names.map((name) =>
143
+ compareObservation(
144
+ before.find((o) => o.name === name),
145
+ after.find((o) => o.name === name),
146
+ maxRows,
147
+ baseline.pages[0].response.context,
148
+ current.pages[0].response.context,
149
+ ),
150
+ );
151
+ return {
152
+ comparisonVersion: 1,
153
+ observations,
154
+ summary: Object.fromEntries(
155
+ ["compared", "limited", "incompatible", "unavailable", "unsupported"].map((status) => [
156
+ status,
157
+ observations.filter((o) => o.status === status).length,
158
+ ]),
159
+ ),
160
+ };
161
+ },
162
+ [
163
+ "Descriptive changes only; no causal attribution, ranking recommendations or automatic SEO changes.",
164
+ ...new Set([...baseline.warnings, ...current.warnings]),
165
+ ],
166
+ );
167
+ }
@@ -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>;
@@ -0,0 +1,99 @@
1
+ import { RequestError } from "../shared/http.js";
2
+
3
+ export interface Evidence {
4
+ schemaVersion: 1;
5
+ provider: string;
6
+ operation: string;
7
+ name?: string;
8
+ target: string;
9
+ startedAt: string;
10
+ finishedAt: string;
11
+ status: "ok" | "partial" | "error";
12
+ pages: Array<{ request: unknown; response: unknown }>;
13
+ pagination?: { exhausted: boolean; nextOffset: number | null; rowsReturned: number };
14
+ warnings: string[];
15
+ credential?: { source: string; type: string | null; clientEmail: string | null };
16
+ failedRequest?: unknown;
17
+ error?: { code: string; message: string; httpStatus: number | null; name?: string };
18
+ }
19
+
20
+ export function createEvidence(provider: string, operation: string, target: string): Evidence {
21
+ return {
22
+ schemaVersion: 1,
23
+ provider,
24
+ operation,
25
+ target,
26
+ startedAt: new Date().toISOString(),
27
+ finishedAt: "",
28
+ status: "ok",
29
+ pages: [],
30
+ warnings: [],
31
+ };
32
+ }
33
+
34
+ export function fail(result: Evidence, error: unknown): void {
35
+ result.status = result.pages.length ? "partial" : "error";
36
+ result.error =
37
+ error instanceof RequestError
38
+ ? { code: error.code, message: error.message, httpStatus: error.status }
39
+ : {
40
+ code: "operation_failed",
41
+ message: "Operation failed; check credentials, request and provider availability",
42
+ httpStatus: null,
43
+ name: error instanceof Error ? error.name : "UnknownError",
44
+ };
45
+ }
46
+
47
+ export async function capture(
48
+ provider: string,
49
+ operation: string,
50
+ target: string,
51
+ request: unknown,
52
+ run: () => Promise<unknown>,
53
+ warnings: string[] = [],
54
+ ): Promise<Evidence> {
55
+ const result = createEvidence(provider, operation, target);
56
+ result.warnings.push(...warnings);
57
+ try {
58
+ result.pages.push({ request, response: await run() });
59
+ } catch (error) {
60
+ result.failedRequest = request;
61
+ fail(result, error);
62
+ }
63
+ result.finishedAt = new Date().toISOString();
64
+ return result;
65
+ }
66
+
67
+ export function aggregate(operation: string, target: string, request: unknown, observations: Evidence[]): Evidence {
68
+ if (!observations.length) throw new RequestError("Select at least one observation", null, "invalid_input");
69
+ return {
70
+ schemaVersion: 1,
71
+ provider: "pagesight",
72
+ operation,
73
+ target,
74
+ startedAt: observations.map((o) => o.startedAt).sort()[0] ?? new Date().toISOString(),
75
+ finishedAt: new Date().toISOString(),
76
+ status: observations.every((o) => o.status === "ok")
77
+ ? "ok"
78
+ : observations.every((o) => o.status === "error")
79
+ ? "error"
80
+ : "partial",
81
+ pages: [
82
+ {
83
+ request,
84
+ response: {
85
+ observations,
86
+ summary: observations.map((o) => ({
87
+ provider: o.provider,
88
+ operation: o.operation,
89
+ ...(o.name ? { name: o.name } : {}),
90
+ target: o.target,
91
+ status: o.status,
92
+ errorCode: o.error?.code ?? null,
93
+ })),
94
+ },
95
+ },
96
+ ],
97
+ warnings: [],
98
+ };
99
+ }
@@ -0,0 +1,160 @@
1
+ import { assessSnapshot } from "./assessment.js";
2
+ import { opportunities } from "./opportunities.js";
3
+ import { gaRealtime } from "./ga-realtime.js";
4
+ import { importUiFindings } from "./ui-findings.js";
5
+ import { doctor } from "./doctor.js";
6
+ import { ZodError } from "zod";
7
+ import { queryCrux, queryCruxHistory } from "../providers/crux.js";
8
+ import { gaCredentialInfo, gaFetch, normalizeGaProperty } from "../providers/ga.js";
9
+ import { inspectUrlResponse, listSitemapsResponse, listSitesResponse } from "../providers/gsc.js";
10
+ import { RequestError } from "../shared/http.js";
11
+ import { runPagespeed } from "../providers/pagespeed.js";
12
+ import { bingDiagnostics, bingObservation } from "./bing.js";
13
+ import { compareSnapshots } from "./compare-snapshots.js";
14
+ import { capture, type Evidence } from "./evidence.js";
15
+ import { gaReport, gscReport } from "./reports.js";
16
+ import { operationSchema } from "./schema.js";
17
+ import { discover } from "./discover.js";
18
+ import { snapshot } from "./snapshot.js";
19
+ import { observePage } from "../web/page-observation.js";
20
+
21
+ export type Executor = (input: unknown) => Promise<Evidence>;
22
+ type ParsedOperation = ReturnType<typeof operationSchema.parse>;
23
+
24
+ export async function execute(input: unknown): Promise<Evidence> {
25
+ let op: ParsedOperation;
26
+ try {
27
+ op = operationSchema.parse(input);
28
+ } catch (error) {
29
+ throw new RequestError(
30
+ error instanceof ZodError
31
+ ? error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
32
+ : "Invalid operation",
33
+ null,
34
+ "invalid_input",
35
+ );
36
+ }
37
+ const result = await dispatch(op);
38
+ if (op.operation.startsWith("ga.")) result.credential = await gaCredentialInfo();
39
+ if (op.operation.startsWith("bing."))
40
+ result.credential = { source: "BING_WEBMASTER_API_KEY", type: "api_key", clientEmail: null };
41
+ const response = result.pages[0]?.response as { nextPageToken?: string } | undefined;
42
+ if (response?.nextPageToken) {
43
+ result.status = "partial";
44
+ result.warnings.push("More metadata pages are available; nextPageToken is preserved in the response.");
45
+ }
46
+ return result;
47
+ }
48
+
49
+ async function dispatch(op: ParsedOperation): Promise<Evidence> {
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);
59
+ case "evidence.import":
60
+ return importUiFindings(op.document);
61
+ case "compare":
62
+ return compareSnapshots(op.baseline, op.current, op.maxRows);
63
+ case "discover":
64
+ return discover(op.url, op.providers, execute);
65
+ case "bing.crawl-stats":
66
+ case "bing.crawl-issues":
67
+ return bingDiagnostics(op.operation === "bing.crawl-stats" ? "crawl-stats" : "crawl-issues", op.site);
68
+ case "bing.url-info":
69
+ return bingDiagnostics("url-info", op.site, op.url);
70
+ case "bing.link-counts":
71
+ return bingDiagnostics("link-counts", op.site, undefined, op.maxPages);
72
+ case "bing.url-links":
73
+ return bingDiagnostics("url-links", op.site, op.url, op.maxPages);
74
+ case "bing.sites":
75
+ return bingObservation("sites");
76
+ case "bing.queries":
77
+ return bingObservation("queries", op.site);
78
+ case "bing.pages":
79
+ return bingObservation("pages", op.site);
80
+ case "bing.traffic":
81
+ return bingObservation("traffic", op.site);
82
+ case "gsc.sites":
83
+ return capture("gsc", "sites", "accessible-properties", {}, listSitesResponse);
84
+ case "gsc.sitemaps":
85
+ return capture("gsc", "sitemaps", op.site, { site: op.site }, () => listSitemapsResponse(op.site), [
86
+ "contents[].indexed is deprecated and must not be interpreted.",
87
+ ]);
88
+ case "gsc.inspect":
89
+ return capture(
90
+ "gsc",
91
+ "inspect",
92
+ op.site,
93
+ { inspectionUrl: op.url, siteUrl: op.site },
94
+ () => inspectUrlResponse(op.url, op.site),
95
+ ["Inspection describes Google's indexed state, not a live fetch. This is one URL, not site coverage."],
96
+ );
97
+ case "gsc.report":
98
+ return gscReport(op.site, op.request, op.maxPages);
99
+ case "ga.accounts":
100
+ return capture(
101
+ "ga",
102
+ "accounts",
103
+ "accessible-properties",
104
+ { pageSize: 200 },
105
+ () => gaFetch("accountSummaries?pageSize=200", undefined, true),
106
+ ["If nextPageToken is present, this discovery response is incomplete."],
107
+ );
108
+ case "ga.property":
109
+ return capture("ga", "property", normalizeGaProperty(op.property), {}, () =>
110
+ gaFetch(normalizeGaProperty(op.property), undefined, true),
111
+ );
112
+ case "ga.key-events":
113
+ return capture(
114
+ "ga",
115
+ "key-events",
116
+ normalizeGaProperty(op.property),
117
+ { pageSize: 200 },
118
+ () => gaFetch(`${normalizeGaProperty(op.property)}/keyEvents?pageSize=200`, undefined, true),
119
+ [
120
+ "Configured key events are not necessarily validated product outcomes; inspect event names. Check nextPageToken.",
121
+ ],
122
+ );
123
+ case "ga.realtime":
124
+ return gaRealtime(op.property, op.request);
125
+ case "ga.report":
126
+ return gaReport(op.property, op.request, op.maxPages);
127
+ case "page":
128
+ return capture("web", "page", op.url, { url: op.url }, () => observePage(op.url));
129
+ case "speed.psi":
130
+ return capture(
131
+ "psi",
132
+ "run",
133
+ op.url,
134
+ { url: op.url, strategy: op.strategy, categories: ["performance", "seo"] },
135
+ () => runPagespeed(op.url, { strategy: op.strategy, categories: ["performance", "seo"] }),
136
+ ["Single Lighthouse lab run; not field performance or ranking evidence."],
137
+ );
138
+ case "speed.crux":
139
+ case "speed.history": {
140
+ const request = {
141
+ ...(op.origin ? { origin: new URL(op.url).origin } : { url: op.url }),
142
+ formFactor: op.formFactor,
143
+ };
144
+ return capture(
145
+ "crux",
146
+ op.operation,
147
+ op.url,
148
+ request,
149
+ () => (op.operation === "speed.crux" ? queryCrux(request) : queryCruxHistory(request)),
150
+ [
151
+ "NOT_FOUND means no record for the requested scope, not zero performance. CrUX aggregates 28-day windows; history periods overlap.",
152
+ ],
153
+ );
154
+ }
155
+ case "doctor":
156
+ return doctor(op.config, execute);
157
+ case "snapshot":
158
+ return snapshot(op, execute);
159
+ }
160
+ }
@@ -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,5 @@
1
+ export { execute } from "./execute.js";
2
+ export type { Evidence } from "./evidence.js";
3
+ export { configSchema, type Operation, operationSchema } from "./schema.js";
4
+ export { evidenceSchema, snapshotEvidenceSchema } from "./evidence-schema.js";
5
+ export { RequestError } from "../shared/http.js";