479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
import type { InspectWebPageInput } from "../domain/browserObservation.js";
|
|
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
|
|
import {
|
|
allowedSanitizedUrl,
|
|
numberValue,
|
|
recordValue,
|
|
recordsValue,
|
|
requiredRecord,
|
|
cdpStringValue,
|
|
type UnknownRecord,
|
|
} from "./CdpCaptureValues.js";
|
|
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
|
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
|
|
|
export type CapturedResource = Omit<
|
|
WebPageInspection["resources"][number],
|
|
"resource_key"
|
|
> & { readonly rawUrl: string };
|
|
|
|
/** Normalize every allowed frame, optionally selecting a leading frame. */
|
|
export const captureFrames = (
|
|
result: unknown,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
maximum?: number,
|
|
completeness?: CdpCaptureCompleteness,
|
|
): {
|
|
readonly items: WebPageInspection["frames"];
|
|
} => {
|
|
const root = recordValue(recordValue(result)?.frameTree);
|
|
if (root === undefined) {
|
|
completeness?.exclude("frames", "invalid_protocol_value");
|
|
return { items: [] };
|
|
}
|
|
const items: WebPageInspection["frames"] = [];
|
|
for (const tree of walkFrameTrees(root, () =>
|
|
completeness?.exclude("frames", "invalid_protocol_value"),
|
|
)) {
|
|
const frame = recordValue(tree.frame);
|
|
const frameId = cdpStringValue(frame?.id);
|
|
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
|
|
if (frame === undefined || frameId === undefined) {
|
|
completeness?.exclude("frames", "invalid_protocol_value");
|
|
continue;
|
|
}
|
|
if (sanitized === undefined || sanitized.origin === null) {
|
|
completeness?.exclude(
|
|
"frames",
|
|
exclusionReasonForUrl(cdpStringValue(frame.url)),
|
|
);
|
|
continue;
|
|
}
|
|
if (maximum !== undefined && items.length >= maximum) continue;
|
|
items.push({
|
|
frame_id: frameId,
|
|
parent_frame_id: cdpStringValue(frame.parentId) ?? null,
|
|
url: sanitized.url,
|
|
origin: sanitized.origin,
|
|
});
|
|
}
|
|
return { items };
|
|
};
|
|
|
|
/** Read the current main-frame URL from an untrusted Page.getFrameTree result. */
|
|
export const mainFrameUrl = (result: unknown): string | undefined =>
|
|
cdpStringValue(
|
|
recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url,
|
|
);
|
|
|
|
/** Normalize every allowed resource. */
|
|
export const captureResources = (
|
|
result: unknown,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
completeness?: CdpCaptureCompleteness,
|
|
): {
|
|
readonly items: readonly CapturedResource[];
|
|
} => {
|
|
const root = recordValue(recordValue(result)?.frameTree);
|
|
if (root === undefined) {
|
|
completeness?.exclude("resources", "invalid_protocol_value");
|
|
return { items: [] };
|
|
}
|
|
const items: CapturedResource[] = [];
|
|
for (const tree of walkFrameTrees(root, () =>
|
|
completeness?.exclude("resources", "invalid_protocol_value"),
|
|
)) {
|
|
for (const resource of recordsValue(tree.resources)) {
|
|
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
|
|
if (url === undefined || url.origin === null) {
|
|
completeness?.exclude(
|
|
"resources",
|
|
exclusionReasonForUrl(cdpStringValue(resource.url)),
|
|
);
|
|
continue;
|
|
}
|
|
const contentSize = numberValue(resource.contentSize);
|
|
items.push({
|
|
rawUrl: cdpStringValue(resource.url) ?? "",
|
|
url: url.url,
|
|
origin: url.origin,
|
|
type: cdpStringValue(resource.type) ?? "Other",
|
|
mime_type: cdpStringValue(resource.mimeType) ?? "",
|
|
content_size:
|
|
contentSize === undefined ? null : Math.max(0, contentSize),
|
|
});
|
|
}
|
|
}
|
|
return { items };
|
|
};
|
|
|
|
export const walkFrameTrees = function* (
|
|
root: UnknownRecord,
|
|
onMalformedChild?: () => void,
|
|
): Generator<UnknownRecord> {
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const tree = pending.pop();
|
|
if (tree === undefined) return;
|
|
const rawChildren = tree.childFrames;
|
|
if (rawChildren !== undefined && !Array.isArray(rawChildren))
|
|
onMalformedChild?.();
|
|
const children = Array.isArray(rawChildren)
|
|
? rawChildren.flatMap((child) => {
|
|
const parsed = recordValue(child);
|
|
if (parsed === undefined) onMalformedChild?.();
|
|
return parsed === undefined ? [] : [parsed];
|
|
})
|
|
: [];
|
|
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
const child = children[index];
|
|
if (child !== undefined) pending.push(child);
|
|
}
|
|
yield tree;
|
|
}
|
|
};
|
|
|
|
export const captureDom = (
|
|
result: unknown,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
input: InspectWebPageInput,
|
|
completeness?: CdpCaptureCompleteness,
|
|
): {
|
|
readonly total: number;
|
|
readonly nodes: WebPageInspection["dom"]["nodes"];
|
|
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
|
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
|
readonly excludedUrls: number;
|
|
} => {
|
|
const root = requiredRecord(result);
|
|
const strings = Array.isArray(root.strings)
|
|
? root.strings.map((value) => cdpStringValue(value) ?? "")
|
|
: [];
|
|
const nodes: WebPageInspection["dom"]["nodes"] = [];
|
|
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
|
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
|
let excludedUrls = 0;
|
|
let total = 0;
|
|
for (const document of recordsValue(root.documents)) {
|
|
const documentUrl = indexedString(strings, document.documentURL);
|
|
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
|
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
|
|
continue;
|
|
}
|
|
const baseUrl = indexedString(strings, document.baseURL) || documentUrl;
|
|
const documentNodes = recordValue(document.nodes);
|
|
if (documentNodes === undefined) continue;
|
|
const nodeTypes = numberArray(documentNodes.nodeType);
|
|
const nodeNames = numberArray(documentNodes.nodeName);
|
|
const nodeValues = numberArray(documentNodes.nodeValue);
|
|
const parents = numberArray(documentNodes.parentIndex);
|
|
const attributes = Array.isArray(documentNodes.attributes)
|
|
? documentNodes.attributes
|
|
: [];
|
|
const baseIndex = nodes.length;
|
|
total += nodeTypes.length;
|
|
for (let index = 0; index < nodeTypes.length; index += 1) {
|
|
const attributeIndexes = numberArray(attributes[index]);
|
|
const parent = Math.trunc(parents[index] ?? -1);
|
|
const nodeIndex = nodes.length;
|
|
const nodeName = indexedString(strings, nodeNames[index]);
|
|
const rawAttributeNames = attributeIndexes
|
|
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
|
|
.map((value) => indexedString(strings, value));
|
|
const seenAttributeNames = new Set<string>();
|
|
const attributeNames: string[] = [];
|
|
for (const name of rawAttributeNames) {
|
|
if (!seenAttributeNames.has(name)) {
|
|
seenAttributeNames.add(name);
|
|
attributeNames.push(name);
|
|
}
|
|
}
|
|
nodes.push({
|
|
index: nodeIndex,
|
|
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
|
node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
|
|
node_name: nodeName,
|
|
node_value_length: indexedString(strings, nodeValues[index]).length,
|
|
attribute_names: attributeNames,
|
|
});
|
|
const metadata = domMetadata({
|
|
strings,
|
|
attributes: attributeIndexes,
|
|
// The BASE element itself resolves against the document fallback URL.
|
|
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
|
documentUrl,
|
|
nodeIndex,
|
|
nodeName,
|
|
allowedOrigins,
|
|
});
|
|
for (const url of metadata.urls) {
|
|
urls.push(url);
|
|
if (url.destination_scope !== "approved") {
|
|
excludedUrls += 1;
|
|
completeness?.exclude(
|
|
"metadata",
|
|
url.destination_scope === "outside_policy"
|
|
? "disallowed_origin"
|
|
: "unsupported_url",
|
|
);
|
|
}
|
|
}
|
|
agentHints.push(...metadata.agentHints);
|
|
}
|
|
}
|
|
return {
|
|
total,
|
|
nodes,
|
|
urls,
|
|
agentHints,
|
|
excludedUrls,
|
|
};
|
|
};
|
|
|
|
export const captureAccessibility = (
|
|
results: readonly unknown[],
|
|
options: {
|
|
readonly includeText: boolean;
|
|
readonly unavailable?: boolean;
|
|
},
|
|
): {
|
|
readonly total: number;
|
|
readonly nodes: WebPageInspection["accessibility"]["nodes"];
|
|
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
|
|
readonly treeIncomplete: boolean;
|
|
} => {
|
|
const all = results.flatMap((result) =>
|
|
recordsValue(requiredRecord(result).nodes),
|
|
);
|
|
const nodeIds = new Set(
|
|
all.flatMap((node) => {
|
|
const nodeId = cdpStringValue(node.nodeId);
|
|
return nodeId === undefined ? [] : [nodeId];
|
|
}),
|
|
);
|
|
let excludedFields = 0;
|
|
const nodes = all.map((node) => {
|
|
const captureText = (value: unknown): string | null => {
|
|
const raw = cdpStringValue(recordValue(value)?.value);
|
|
if (raw === undefined) return null;
|
|
if (!options.includeText) {
|
|
excludedFields += 1;
|
|
return null;
|
|
}
|
|
return raw;
|
|
};
|
|
return {
|
|
node_id: cdpStringValue(node.nodeId) ?? "",
|
|
parent_id: cdpStringValue(node.parentId) ?? null,
|
|
role: axText(node.role),
|
|
name: captureText(node.name),
|
|
description: captureText(node.description),
|
|
ignored: node.ignored === true,
|
|
states: accessibilityStates(node.properties),
|
|
};
|
|
});
|
|
return {
|
|
total: all.length,
|
|
nodes,
|
|
treeIncomplete: all.some(
|
|
(node) =>
|
|
Array.isArray(node.childIds) &&
|
|
node.childIds.some((childId) => {
|
|
const id = cdpStringValue(childId);
|
|
return id !== undefined && !nodeIds.has(id);
|
|
}),
|
|
),
|
|
textCapture: {
|
|
status: options.unavailable
|
|
? "unavailable"
|
|
: !options.includeText
|
|
? "not_approved"
|
|
: "included",
|
|
retained_bytes: nodes.reduce(
|
|
(total, node) =>
|
|
total +
|
|
Buffer.byteLength(node.name ?? "") +
|
|
Buffer.byteLength(node.description ?? ""),
|
|
0,
|
|
),
|
|
excluded_fields: excludedFields,
|
|
},
|
|
};
|
|
};
|
|
|
|
const ACCESSIBILITY_STATE_NAMES = new Set([
|
|
"atomic",
|
|
"autocomplete",
|
|
"busy",
|
|
"checked",
|
|
"disabled",
|
|
"editable",
|
|
"expanded",
|
|
"focusable",
|
|
"focused",
|
|
"hasPopup",
|
|
"hidden",
|
|
"invalid",
|
|
"level",
|
|
"live",
|
|
"modal",
|
|
"multiline",
|
|
"multiselectable",
|
|
"orientation",
|
|
"pressed",
|
|
"readonly",
|
|
"required",
|
|
"selected",
|
|
"valuemax",
|
|
"valuemin",
|
|
]);
|
|
|
|
const accessibilityStates = (
|
|
value: unknown,
|
|
): WebPageInspection["accessibility"]["nodes"][number]["states"] => {
|
|
const states = new Map<string, boolean | number | string>();
|
|
for (const property of recordsValue(value)) {
|
|
const name = cdpStringValue(property.name);
|
|
if (name === undefined || !ACCESSIBILITY_STATE_NAMES.has(name)) continue;
|
|
const raw = recordValue(property.value)?.value;
|
|
const state =
|
|
typeof raw === "boolean"
|
|
? raw
|
|
: typeof raw === "number"
|
|
? numberValue(raw)
|
|
: typeof raw === "string"
|
|
? raw
|
|
: undefined;
|
|
if (state !== undefined) states.set(name, state);
|
|
}
|
|
return [...states]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([name, state]) => ({ name, value: state }));
|
|
};
|
|
|
|
const axText = (value: unknown): string | null =>
|
|
cdpStringValue(recordValue(value)?.value) ?? null;
|
|
|
|
const indexedString = (strings: readonly string[], index: unknown): string => {
|
|
const integer = numberValue(index);
|
|
return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
|
|
};
|
|
|
|
const numberArray = (value: unknown): readonly number[] =>
|
|
Array.isArray(value)
|
|
? value.flatMap((item) => {
|
|
const number = numberValue(item);
|
|
return number === undefined ? [] : [number];
|
|
})
|
|
: [];
|
|
|
|
interface DomMetadataOptions {
|
|
readonly strings: readonly string[];
|
|
readonly attributes: readonly number[];
|
|
readonly baseUrl: string;
|
|
readonly documentUrl: string;
|
|
readonly nodeIndex: number;
|
|
readonly nodeName: string;
|
|
readonly allowedOrigins: ReadonlySet<string>;
|
|
}
|
|
|
|
const domMetadata = (
|
|
options: DomMetadataOptions,
|
|
): {
|
|
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
|
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
|
} => {
|
|
const {
|
|
strings,
|
|
attributes,
|
|
baseUrl,
|
|
documentUrl,
|
|
nodeIndex,
|
|
nodeName,
|
|
allowedOrigins,
|
|
} = options;
|
|
const pairs = new Map<string, string>();
|
|
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
|
// Chrome already folds HTML names; SVG/XML names remain case-sensitive.
|
|
const name = indexedString(strings, attributes[index]);
|
|
const value = indexedString(strings, attributes[index + 1]);
|
|
// HTML keeps the first declaration. A later repeat must not move dom_urls.
|
|
if (!pairs.has(name)) pairs.set(name, value);
|
|
}
|
|
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
|
for (const attribute of domUrlAttributes) {
|
|
const value = pairs.get(attribute);
|
|
if (value === undefined) continue;
|
|
const tagName = nodeName.toLowerCase();
|
|
// Chrome strips HTML whitespace for FORM.action, while submit controls
|
|
// use the document URL only for an exactly empty formaction attribute.
|
|
const usesDocumentUrl =
|
|
(tagName === "form" &&
|
|
attribute === "action" &&
|
|
/^[\t\n\f\r ]*$/u.test(value)) ||
|
|
((tagName === "button" || tagName === "input") &&
|
|
attribute === "formaction" &&
|
|
value === "");
|
|
const destination = domDestination(
|
|
usesDocumentUrl ? documentUrl : value,
|
|
baseUrl,
|
|
allowedOrigins,
|
|
);
|
|
urls.push({
|
|
node_index: nodeIndex,
|
|
attribute,
|
|
url: destination.url,
|
|
destination_scope: destination.scope,
|
|
});
|
|
}
|
|
const rel = (pairs.get("rel") ?? "")
|
|
.toLowerCase()
|
|
.split(/\s+/u)
|
|
.filter((value) => agentRelValues.includes(value));
|
|
const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null;
|
|
const agentHints =
|
|
nodeName.toLowerCase() === "link" && rel.length > 0
|
|
? rel.map((declaration) => ({
|
|
mechanism: "dom_link_rel" as const,
|
|
declaration,
|
|
url: href,
|
|
trust: "page-declared-untrusted" as const,
|
|
}))
|
|
: [];
|
|
return { urls, agentHints };
|
|
};
|
|
|
|
const domDestination = (
|
|
value: string,
|
|
baseUrl: string,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
): {
|
|
readonly url: string | null;
|
|
readonly scope: "approved" | "outside_policy" | "unsupported";
|
|
} => {
|
|
try {
|
|
const parsed = new URL(value, baseUrl);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
return { url: null, scope: "unsupported" };
|
|
if (!allowedOrigins.has(parsed.origin))
|
|
return { url: null, scope: "outside_policy" };
|
|
return {
|
|
url: allowedSanitizedUrl(parsed.href, allowedOrigins)?.url ?? null,
|
|
scope: "approved",
|
|
};
|
|
} catch (cause: unknown) {
|
|
// Unparseable URLs are out of scope, not failures.
|
|
void cause;
|
|
return { url: null, scope: "unsupported" };
|
|
}
|
|
};
|
|
|
|
const domUrlAttributes = [
|
|
"href",
|
|
"src",
|
|
"action",
|
|
"formaction",
|
|
"poster",
|
|
] as const;
|
|
const agentRelValues = ["mcp", "model-context", "ai-plugin", "service-desc"];
|