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.
- package/README.md +19 -0
- package/docs/changes.md +118 -0
- package/docs/cloudflare.md +31 -0
- package/docs/crawl.md +64 -0
- package/docs/investigation.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/monitoring.md +39 -0
- package/docs/opportunities.md +78 -0
- package/docs/rendering.md +102 -0
- package/docs/seo-agent-workflow.md +153 -0
- package/docs/snapshots.md +7 -0
- package/docs/usage.md +25 -17
- package/package.json +5 -2
- package/src/api/assessment.ts +315 -0
- package/src/api/change-record.ts +31 -0
- package/src/api/cloudflare.ts +201 -0
- package/src/api/compare-snapshots.ts +5 -215
- package/src/api/crawl.ts +53 -0
- package/src/api/evaluate-change.ts +189 -0
- package/src/api/execute.ts +42 -0
- package/src/api/followup-changes.ts +264 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/http-url.ts +13 -0
- package/src/api/investigation.ts +344 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +19 -1
- package/src/api/schema.ts +162 -11
- package/src/api/snapshot.ts +20 -1
- package/src/api/technical-changes.ts +124 -0
- package/src/api/ui-findings.ts +1 -1
- package/src/api/verify-render.ts +134 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +173 -38
- package/src/followup-manifest.ts +42 -0
- package/src/followup-text.ts +45 -0
- package/src/investigation-text.ts +32 -0
- package/src/opportunities-text.ts +61 -0
- package/src/providers/cloudflare.ts +46 -0
- package/src/tools/observe.ts +1 -1
- package/src/web/fetch.ts +2 -1
- package/src/web/render-browser.ts +190 -0
- package/src/web/render-dom.ts +72 -0
- package/src/web/render-network.ts +122 -0
- 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
|
|
67
|
-
.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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>;
|
package/src/api/snapshot.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/api/ui-findings.ts
CHANGED
|
@@ -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
|
+
}
|