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
package/src/api/schema.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { httpUrl } from "./http-url.js";
2
+ export { httpUrl } from "./http-url.js";
3
+ import { changeRecordSchema } from "./change-record.js";
1
4
  import { uiFindingsSchema } from "./ui-findings.js";
2
5
  import { z } from "zod";
3
6
  import { snapshotEvidenceSchema } from "./evidence-schema.js";
@@ -63,13 +66,43 @@ export const gaRequestSchema = z
63
66
  .strict();
64
67
  export type GaRequest = z.infer<typeof gaRequestSchema>;
65
68
 
66
- const httpUrl = z
67
- .string()
68
- .url()
69
- .refine(
70
- (v) => ["http:", "https:"].includes(new URL(v).protocol) && !new URL(v).username && !new URL(v).password,
71
- "Use an HTTP(S) URL without credentials",
72
- );
69
+ export const gaRealtimeRequestSchema = z
70
+ .object({
71
+ dimensions: z
72
+ .array(z.object({ name: z.string().min(1) }).strict())
73
+ .max(9)
74
+ .default([]),
75
+ metrics: z
76
+ .array(z.object({ name: z.string().min(1) }).strict())
77
+ .min(1)
78
+ .max(10),
79
+ dimensionFilter: z.record(z.unknown()).optional(),
80
+ metricFilter: z.record(z.unknown()).optional(),
81
+ orderBys: z.array(z.record(z.unknown())).optional(),
82
+ limit: z.coerce.number().int().min(1).max(250000).default(10000),
83
+ returnPropertyQuota: z.boolean().default(true),
84
+ minuteRanges: z
85
+ .array(
86
+ z
87
+ .object({
88
+ name: z
89
+ .string()
90
+ .min(1)
91
+ .refine((n) => !n.startsWith("date_range_") && !n.startsWith("RESERVED_"))
92
+ .optional(),
93
+ startMinutesAgo: z.number().int().min(0).max(59).default(29),
94
+ endMinutesAgo: z.number().int().min(0).max(59).default(0),
95
+ })
96
+ .strict()
97
+ .refine((r) => r.startMinutesAgo >= r.endMinutesAgo, "Start must be at least as many minutes ago as end"),
98
+ )
99
+ .min(1)
100
+ .max(2)
101
+ .default([{ startMinutesAgo: 29, endMinutesAgo: 0 }]),
102
+ })
103
+ .strict();
104
+ export type GaRealtimeRequest = z.infer<typeof gaRealtimeRequestSchema>;
105
+
73
106
  export const configSchema = z
74
107
  .object({
75
108
  site: httpUrl,
@@ -113,7 +146,113 @@ export const configSchema = z
113
146
  "Select at least one page or provider",
114
147
  );
115
148
  export type SiteConfig = z.infer<typeof configSchema>;
149
+ export const assessedSnapshotSchema = snapshotEvidenceSchema.superRefine((snapshot, ctx) => {
150
+ const config = configSchema.safeParse(snapshot.pages[0].response.context.config);
151
+ if (!config.success)
152
+ for (const issue of config.error.issues)
153
+ ctx.addIssue({ ...issue, path: ["pages", 0, "response", "context", "config", ...issue.path] });
154
+ });
155
+ const renderRequestSchema = z
156
+ .object({
157
+ operation: z.literal("page.verify"),
158
+ url: httpUrl,
159
+ navigation: z
160
+ .object({ fromUrl: httpUrl, linkSelector: z.string().min(1).max(500) })
161
+ .strict()
162
+ .optional(),
163
+ settleMs: z.number().int().min(0).max(5000).default(1000),
164
+ timeoutMs: z.number().int().min(1000).max(60000).default(20000),
165
+ viewport: z
166
+ .object({ width: z.number().int().min(320).max(2560), height: z.number().int().min(240).max(2160) })
167
+ .strict()
168
+ .default({ width: 1280, height: 800 }),
169
+ })
170
+ .strict();
116
171
  const operationVariants = z.discriminatedUnion("operation", [
172
+ renderRequestSchema,
173
+ z
174
+ .object({
175
+ operation: z.literal("change.followup"),
176
+ experiments: z
177
+ .array(z.unknown())
178
+ .min(1)
179
+ .max(50)
180
+ .describe(
181
+ "Entries: {label, record: change record, baseline: configured snapshot, current?: configured snapshot}. Each entry is validated independently; use embedded objects, not filesystem paths.",
182
+ ),
183
+ asOf: z.string().datetime().optional(),
184
+ lagDays: z.number().int().min(1).max(30).default(3),
185
+ })
186
+ .strict(),
187
+
188
+ z
189
+ .object({
190
+ operation: z.literal("cloudflare.audit"),
191
+ zone: z.string().regex(/^[a-f0-9]{32}$/),
192
+ hostname: z
193
+ .string()
194
+ .min(1)
195
+ .max(253)
196
+ .regex(/^[a-zA-Z0-9.-]+$/),
197
+ startTime: z.string().datetime(),
198
+ endTime: z.string().datetime(),
199
+ limit: z.number().int().min(1).max(100).default(50),
200
+ })
201
+ .strict(),
202
+ z
203
+ .object({
204
+ operation: z.literal("change.evaluate"),
205
+ record: changeRecordSchema,
206
+ baseline: assessedSnapshotSchema,
207
+ current: assessedSnapshotSchema.optional(),
208
+ maxRows: z.number().int().min(1).max(100).default(20),
209
+ })
210
+ .strict(),
211
+ z
212
+ .object({
213
+ operation: z.literal("crawl"),
214
+ config: configSchema,
215
+ maxPages: z.number().int().min(1).max(100).default(20),
216
+ maxDepth: z.number().int().min(0).max(10).default(3),
217
+ maxLinks: z.number().int().min(1).max(1000).default(500),
218
+ includeQuery: z.boolean().default(false),
219
+ inspectLimit: z.number().int().min(0).max(10).default(3),
220
+ })
221
+ .strict(),
222
+ z
223
+ .object({
224
+ operation: z.literal("technical.compare"),
225
+ baseline: assessedSnapshotSchema.optional(),
226
+ current: assessedSnapshotSchema,
227
+ })
228
+ .strict(),
229
+ z
230
+ .object({
231
+ operation: z.literal("investigate"),
232
+ config: configSchema,
233
+ url: httpUrl,
234
+ startDate: dateSchema,
235
+ endDate: dateSchema,
236
+ maxPages: maxPagesSchema.default(1),
237
+ maxRows: z.number().int().min(1).max(100).default(28),
238
+ })
239
+ .strict(),
240
+ z
241
+ .object({
242
+ operation: z.literal("opportunities"),
243
+ snapshot: assessedSnapshotSchema,
244
+ minImpressions: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(20),
245
+ maxClicks: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(2),
246
+ maxRows: z.number().int().min(1).max(100).default(10),
247
+ })
248
+ .strict(),
249
+ z
250
+ .object({
251
+ operation: z.literal("assess"),
252
+ snapshot: assessedSnapshotSchema,
253
+ maxRows: z.number().int().min(1).max(100).default(10),
254
+ })
255
+ .strict(),
117
256
  z.object({ operation: z.literal("evidence.import"), document: uiFindingsSchema }).strict(),
118
257
  z
119
258
  .object({
@@ -154,6 +293,7 @@ const operationVariants = z.discriminatedUnion("operation", [
154
293
  maxPages: maxPagesSchema,
155
294
  })
156
295
  .strict(),
296
+ z.object({ operation: z.literal("ga.realtime"), property: z.string(), request: gaRealtimeRequestSchema }).strict(),
157
297
  z.object({ operation: z.literal("ga.accounts") }).strict(),
158
298
  z.object({ operation: z.literal("ga.property"), property: z.string() }).strict(),
159
299
  z.object({ operation: z.literal("ga.key-events"), property: z.string() }).strict(),
@@ -200,8 +340,19 @@ const operationVariants = z.discriminatedUnion("operation", [
200
340
  })
201
341
  .strict(),
202
342
  ]);
203
- export const operationSchema = operationVariants.refine(
204
- (op) => op.operation !== "snapshot" || (op.startDate <= op.endDate && op.endDate <= pacificDate()),
205
- "Invalid or future snapshot interval",
206
- );
343
+ export const operationSchema = operationVariants
344
+ .refine(
345
+ (op) =>
346
+ op.operation !== "cloudflare.audit" ||
347
+ (Date.parse(op.startTime) < Date.parse(op.endTime) &&
348
+ Date.parse(op.endTime) - Date.parse(op.startTime) <= 86400000 &&
349
+ Date.parse(op.endTime) <= Date.now()),
350
+ "Cloudflare requires a past UTC interval of at most 24 hours",
351
+ )
352
+ .refine(
353
+ (op) =>
354
+ (op.operation !== "snapshot" && op.operation !== "investigate") ||
355
+ (op.startDate <= op.endDate && op.endDate <= pacificDate()),
356
+ "Invalid or future reporting interval",
357
+ );
207
358
  export type Operation = z.input<typeof operationSchema>;
@@ -91,6 +91,21 @@ export function snapshotOperations(
91
91
  },
92
92
  },
93
93
  }),
94
+ ga(["landingPagePlusQueryString", "sessionSource", "eventName"], ["eventCount"], true, {
95
+ dimensionFilter: {
96
+ andGroup: {
97
+ expressions: [
98
+ hostFilter,
99
+ {
100
+ filter: {
101
+ fieldName: "sessionDefaultChannelGroup",
102
+ stringFilter: { matchType: "EXACT", value: "Organic Search" },
103
+ },
104
+ },
105
+ ],
106
+ },
107
+ },
108
+ }),
94
109
  );
95
110
  }
96
111
  operations.push(...config.pages.map((url) => ({ operation: "page" as const, url })));
@@ -158,10 +173,14 @@ export async function snapshot(
158
173
  );
159
174
  if (!config.context.successEvents.length)
160
175
  result.warnings.push("No validated success events configured. Do not optimize total keyEvents as conversions.");
176
+ if (config.context.successEvents.length)
177
+ result.warnings.push(
178
+ "Configured success events are caller-designated; Pagesight has not independently validated their tracking or business meaning.",
179
+ );
161
180
  return result;
162
181
  }
163
182
 
164
- function observationName(op: Operation): string {
183
+ export function observationName(op: Operation): string {
165
184
  if (op.operation === "gsc.report") return `gsc.report.${op.request.dimensions?.join("+") || "property"}`;
166
185
  if (op.operation === "ga.report") {
167
186
  const dimensions = op.request.dimensions?.map((d) => d.name).join("+") || "property";
@@ -0,0 +1,124 @@
1
+ import { z } from "zod";
2
+ import { capture } from "./evidence.js";
3
+ import type { ImportedSnapshot } from "./evidence-schema.js";
4
+ import { configSchema } from "./schema.js";
5
+ import { canonical } from "./report-table.js";
6
+ const pageSchema = z.object({
7
+ url: z.string().url(),
8
+ finalUrl: z.string().url(),
9
+ status: z.number().int(),
10
+ canonical: z.string().nullable(),
11
+ robots: z.array(z.string()),
12
+ xRobotsTag: z.string().nullable(),
13
+ title: z.string().nullable(),
14
+ description: z.string().nullable(),
15
+ sha256: z.string(),
16
+ redirects: z.array(z.unknown()),
17
+ });
18
+
19
+ export function technicalChanges(baseline: ImportedSnapshot | undefined, current: ImportedSnapshot) {
20
+ const hash = (v: unknown) => Bun.CryptoHasher.hash("sha256", canonical(v), "hex");
21
+ const before = baseline?.pages[0].response;
22
+ const after = current.pages[0].response;
23
+ const sameScope = Boolean(
24
+ baseline &&
25
+ baseline.target === current.target &&
26
+ canonical(before?.context.config) === canonical(after.context.config),
27
+ );
28
+ return capture(
29
+ "pagesight",
30
+ "technical.compare",
31
+ current.target,
32
+ { baselineSha256: baseline ? hash(baseline) : null, currentSha256: hash(current) },
33
+ async () => {
34
+ const alerts: Array<{ kind: string; source: string; message: string; before?: unknown; after?: unknown }> = [];
35
+ for (const url of configSchema.parse(after.context.config).pages) {
36
+ if (!after.observations.some((o) => o.provider === "web" && o.operation === "page" && o.target === url))
37
+ alerts.push({
38
+ kind: "unknown",
39
+ source: `page:${url}`,
40
+ message: "Configured page observation is missing; no regression inferred",
41
+ });
42
+ }
43
+ for (const observation of after.observations) {
44
+ if (observation.status === "error" || observation.error) {
45
+ alerts.push({
46
+ kind: "availability",
47
+ source: observation.name,
48
+ message: `Observation unavailable/incomplete: ${observation.error?.code ?? observation.status}`,
49
+ });
50
+ continue;
51
+ }
52
+ if (observation.provider !== "web" || observation.operation !== "page") continue;
53
+ const parsed = pageSchema.safeParse(observation.pages[0]?.response);
54
+ const request = observation.pages[0]?.request as { url?: string } | undefined;
55
+ if (!parsed.success || request?.url !== parsed.data.url || observation.target !== parsed.data.url) {
56
+ alerts.push({
57
+ kind: "unknown",
58
+ source: observation.name,
59
+ message: "Page response is unavailable or not bound to the requested URL",
60
+ });
61
+ continue;
62
+ }
63
+ if (parsed.data.status >= 400)
64
+ alerts.push({
65
+ kind: "http-error",
66
+ source: observation.name,
67
+ message: `HTTP ${parsed.data.status} observed`,
68
+ after: parsed.data.status,
69
+ });
70
+ if (!sameScope) continue;
71
+ const previous = before?.observations.find(
72
+ (o) => o.provider === "web" && o.operation === "page" && o.target === observation.target,
73
+ );
74
+ const prior = pageSchema.safeParse(previous?.pages[0]?.response);
75
+ const priorRequest = previous?.pages[0]?.request as { url?: string } | undefined;
76
+ if (
77
+ !prior.success ||
78
+ priorRequest?.url !== parsed.data.url ||
79
+ prior.data.url !== parsed.data.url ||
80
+ previous?.status === "error" ||
81
+ previous?.error
82
+ ) {
83
+ alerts.push({
84
+ kind: "unknown",
85
+ source: observation.name,
86
+ message: "No usable matching prior page; no regression inferred",
87
+ });
88
+ continue;
89
+ }
90
+ for (const field of [
91
+ "status",
92
+ "finalUrl",
93
+ "canonical",
94
+ "robots",
95
+ "xRobotsTag",
96
+ "redirects",
97
+ "title",
98
+ "description",
99
+ "sha256",
100
+ ] as const) {
101
+ if (canonical(prior.data[field]) !== canonical(parsed.data[field]))
102
+ alerts.push({
103
+ kind: ["title", "description", "sha256"].includes(field) ? "content-change" : "technical-change",
104
+ source: observation.name,
105
+ message: `Observed ${field} changed; verify against intended route policy`,
106
+ before: prior.data[field],
107
+ after: parsed.data[field],
108
+ });
109
+ }
110
+ }
111
+ return {
112
+ comparisonVersion: 1,
113
+ baselineState: !baseline ? "first-run" : sameScope ? "compatible" : "scope-reset",
114
+ alerts,
115
+ limitations: [
116
+ "Exact common requested URLs and equal configuration only. Added or removed URLs reset the baseline.",
117
+ "Changes are observations, not automatically defects; noindex and canonical policy may be intentional.",
118
+ "Daily reporting windows overlap. No traffic deltas, disappearance claims, ranking impact or cross-provider comparisons are calculated.",
119
+ "HTML only; rendered content and Google's stored indexing state require separate checks.",
120
+ ],
121
+ };
122
+ },
123
+ );
124
+ }
@@ -12,7 +12,7 @@ const url = z
12
12
  const timestamp = z.string().datetime({ offset: true });
13
13
  export const uiFindingsSchema = z
14
14
  .object({
15
- provider: z.enum(["bing", "gsc", "other"]),
15
+ provider: z.enum(["bing", "gsc", "ga", "other"]),
16
16
  site: url,
17
17
  source: z
18
18
  .object({
@@ -0,0 +1,134 @@
1
+ import { startRenderNetwork } from "../web/render-network.js";
2
+ import { capture } from "./evidence.js";
3
+ import { fetchText } from "../web/fetch.js";
4
+ import { launchRenderer, parseServer, captureRendered, type RenderOptions } from "../web/render-browser.js";
5
+ import type { SeoDom } from "../web/render-dom.js";
6
+ import { RequestError } from "../shared/http.js";
7
+
8
+ function compare(a: SeoDom | undefined, b: SeoDom | undefined) {
9
+ if (!a || !b || a.url !== b.url || a.truncated || b.truncated)
10
+ return { status: "unavailable", reason: "Both complete captures must identify the same exact final URL." };
11
+ const fields = ["baseHref", "titles", "descriptions", "canonicals", "robots", "h1", "jsonLd", "links"] as const;
12
+ const differences = fields
13
+ .filter((field) => JSON.stringify(a[field]) !== JSON.stringify(b[field]))
14
+ .map((field) => ({ field, before: a[field], after: b[field] }));
15
+ return {
16
+ status: differences.length ? "different" : "equal",
17
+ scope: "Extracted DOM fields only; HTTP response evidence is compared separately.",
18
+ differences,
19
+ };
20
+ }
21
+ export async function verifyRender(options: RenderOptions) {
22
+ return capture("pagesight", "page.verify", options.url, options, async () => {
23
+ const network = await startRenderNetwork(options.url);
24
+ try {
25
+ const browser = await launchRenderer(network.proxy);
26
+ try {
27
+ const server = await capture("web", "server_html", options.url, { url: options.url }, async () => {
28
+ const { body, finalUrl, response, redirects } = await fetchText(
29
+ options.url,
30
+ 2_000_000,
31
+ new URL(options.url).origin,
32
+ network.proxy,
33
+ );
34
+ if (!response.headers.get("content-type")?.includes("text/html"))
35
+ throw new RequestError("Server response is not HTML.", response.status, "not_html");
36
+ return {
37
+ finalUrl,
38
+ status: response.status,
39
+ redirects,
40
+ xRobotsTag: response.headers.get("x-robots-tag"),
41
+ contentType: response.headers.get("content-type"),
42
+ sha256: Bun.CryptoHasher.hash("sha256", body, "hex"),
43
+ bytes: Buffer.byteLength(body),
44
+ dom: await parseServer(browser, body, finalUrl),
45
+ };
46
+ });
47
+ const direct = await capture("chromium", "direct", options.url, options, () =>
48
+ captureRendered(browser, options, false),
49
+ );
50
+ const navigation = options.navigation
51
+ ? await capture("chromium", "internal_navigation", options.url, options, () =>
52
+ captureRendered(browser, options, true),
53
+ )
54
+ : null;
55
+ const getDom = (e: typeof server | null) => (e?.pages[0]?.response as { dom?: SeoDom } | undefined)?.dom;
56
+ return {
57
+ verificationVersion: 1,
58
+ browser: { engine: "chromium", version: browser.version(), viewport: options.viewport },
59
+ capturePolicy: {
60
+ waitUntil: "domcontentloaded",
61
+ settleMs: options.settleMs,
62
+ timeoutMsPerBrowserPath: options.timeoutMs,
63
+ anonymousFreshContextPerPath: true,
64
+ maxRequestsPerPath: 500,
65
+ allowedMethods: ["GET", "HEAD"],
66
+ serviceWorkers: "blocked",
67
+ webSockets: "blocked",
68
+ topLevelOrigin: new URL(options.url).origin,
69
+ },
70
+ network: {
71
+ blockedRequests: network.blockedRequests,
72
+ policy:
73
+ "Public addresses only; an explicitly requested literal private IP permits that exact host and port.",
74
+ },
75
+ observations: { server, direct, navigation },
76
+ documentComparison: (() => {
77
+ const a = server.pages[0]?.response as { status: number; xRobotsTag: string | null } | undefined;
78
+ const b = (
79
+ direct.pages[0]?.response as
80
+ | { targetDocumentResponse?: { status: number; xRobotsTag: string | null } }
81
+ | undefined
82
+ )?.targetDocumentResponse;
83
+ if (!a || !b)
84
+ return {
85
+ status: "unavailable",
86
+ reason: "Both server and target browser document responses are required.",
87
+ };
88
+ const fields = ["status", "xRobotsTag"] as const;
89
+ return {
90
+ status: fields.every((f) => a[f] === b[f]) ? "equal" : "different",
91
+ fields: fields.map((field) => ({ field, server: a[field], browser: b[field] })),
92
+ };
93
+ })(),
94
+ comparisons: {
95
+ serverToDirect: compare(getDom(server), getDom(direct)),
96
+ directToNavigation: navigation ? compare(getDom(direct), getDom(navigation)) : { status: "not_requested" },
97
+ },
98
+ limitations: [
99
+ "Bounded anonymous Chromium observation, not Google rendering or indexing evidence.",
100
+ "A fixed settle interval does not prove hydration or network completion. Captures occur at different times and can see different deployments or personalized content.",
101
+ "Differences are observations, not automatic SEO defects. Equal extracted fields do not prove visible layout, complete content or functional correctness.",
102
+ "Only GET/HEAD requests run; service workers and cross-origin top-level navigation are blocked. This may alter application behavior; ordinary page requests can still reach analytics.",
103
+ "Server HTML is fetched independently with Pagesight's user agent, then parsed inertly in Chromium. A client route may have no target document response; source-document HTTP status is not target status.",
104
+ "No authenticated profile, storage state, browser actions other than one validated anchor click, or arbitrary evaluation scripts are accepted.",
105
+ ],
106
+ };
107
+ } finally {
108
+ await browser.close();
109
+ }
110
+ } finally {
111
+ await network.close();
112
+ }
113
+ }).then((result) => {
114
+ const data = result.pages[0]?.response as
115
+ | {
116
+ observations: Record<
117
+ string,
118
+ { status: string; pages: Array<{ response: { dom?: SeoDom; documentsTruncated?: boolean } }> } | null
119
+ >;
120
+ comparisons: Record<string, { status: string }>;
121
+ }
122
+ | undefined;
123
+ if (
124
+ data &&
125
+ (Object.values(data.observations).some(
126
+ (e) =>
127
+ e && (e.status !== "ok" || e.pages[0]?.response.dom?.truncated || e.pages[0]?.response.documentsTruncated),
128
+ ) ||
129
+ Object.values(data.comparisons).some((c) => c.status === "unavailable"))
130
+ )
131
+ result.status = "partial";
132
+ return result;
133
+ });
134
+ }
@@ -0,0 +1,55 @@
1
+ import type { Evidence } from "./api/evidence.js";
2
+
3
+ // Provider values are untrusted text, including terminal control characters.
4
+ const text = (value: unknown) =>
5
+ JSON.stringify(value).replace(
6
+ /[\u007f-\u009f\u2028\u2029]/gu,
7
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
8
+ );
9
+ export function renderAssessment(result: Evidence): string {
10
+ const assessment = result.pages[0]?.response as
11
+ | {
12
+ site: string;
13
+ snapshotSha256: string;
14
+ requestedDates: { startDate: string; endDate: string };
15
+ collectedAt: string;
16
+ findings: Array<{ level: string; message: string; sources: string[]; nextCheck: string }>;
17
+ tables: Array<{
18
+ observation: string;
19
+ scope: string;
20
+ dimensions: string[];
21
+ metrics: string[];
22
+ rows: Array<{ keys: string[]; values: Array<string | number> }>;
23
+ observedRows: number;
24
+ displayedRows: number;
25
+ complete: boolean;
26
+ limitations: string[];
27
+ }>;
28
+ limitations: string[];
29
+ }
30
+ | undefined;
31
+ if (!assessment) return `${JSON.stringify(result, null, 2)}\n`;
32
+ const lines = [
33
+ `Pagesight assessment: ${text(assessment.site)} (${result.status})`,
34
+ `Report period: ${assessment.requestedDates.startDate}–${assessment.requestedDates.endDate}`,
35
+ `Snapshot collected: ${assessment.collectedAt}; supplied evidence, not reverified.`,
36
+ `Snapshot hash (normalized JSON): ${assessment.snapshotSha256}`,
37
+ "",
38
+ ...assessment.findings.flatMap((f) => [
39
+ `- [${text(f.level)}] ${text(f.message)}`,
40
+ ` Source: ${f.sources.map(text).join(", ")}`,
41
+ ` Next check: ${text(f.nextCheck)}`,
42
+ ]),
43
+ ];
44
+ for (const table of assessment.tables) {
45
+ lines.push(
46
+ "",
47
+ `${text(table.observation)} (${table.scope}; showing ${table.displayedRows}/${table.observedRows} observed rows; ${table.complete ? "pagination exhausted" : "incomplete report"})`,
48
+ );
49
+ lines.push(` ${[...table.dimensions, ...table.metrics].map(text).join(" | ")}`);
50
+ for (const row of table.rows) lines.push(` ${[...row.keys, ...row.values].map(text).join(" | ")}`);
51
+ for (const limitation of table.limitations) lines.push(` Limit: ${text(limitation)}`);
52
+ }
53
+ lines.push("", ...assessment.limitations.map((l) => `Limit: ${text(l)}`));
54
+ return `${lines.join("\n")}\n`;
55
+ }