@crouton-kit/capture 1.3.8 → 1.3.9
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/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `media.json` collector — `<img>`/`<video>`/`<canvas>`/`<svg>`/`<iframe>`
|
|
3
|
+
* element facts: intrinsic vs rendered size, current source, load/decode
|
|
4
|
+
* state, and (for `object-fit`-bearing elements) computed crop/letterbox
|
|
5
|
+
* facts. One `Runtime.evaluate` gathers raw per-element facts in-page;
|
|
6
|
+
* `backendNodeId` correlation reuses `resolveNodeIds` from `./styles.js`
|
|
7
|
+
* (this file issues its own `DOM.getDocument` call — collectors run
|
|
8
|
+
* concurrently, so no CDP call is shared across files).
|
|
9
|
+
*
|
|
10
|
+
* This is a `phase: 'baseline'` collector, so it must not create or pin
|
|
11
|
+
* any page-observable state: `canvas.getContext(...)` is deliberately NOT
|
|
12
|
+
* called here — the first call to it on a given canvas creates and
|
|
13
|
+
* permanently pins that canvas to the requested context type (a page can
|
|
14
|
+
* observe this), which a baseline read is not allowed to do. `contextType`
|
|
15
|
+
* is therefore always `null` — an honest "not probed", not a measured
|
|
16
|
+
* absence — rather than a probed value.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { resolveNodeIds } from './styles.js';
|
|
20
|
+
import { sanitizeString } from '../redaction.js';
|
|
21
|
+
import type { Collector } from '../types.js';
|
|
22
|
+
|
|
23
|
+
const MEDIA_MAX_ELEMENTS = 200;
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Output shape
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export interface CropFactCover {
|
|
30
|
+
mode: 'cover';
|
|
31
|
+
croppedLeftPx: number;
|
|
32
|
+
croppedRightPx: number;
|
|
33
|
+
croppedTopPx: number;
|
|
34
|
+
croppedBottomPx: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CropFactContain {
|
|
38
|
+
mode: 'contain';
|
|
39
|
+
letterboxTopPx: number;
|
|
40
|
+
letterboxBottomPx: number;
|
|
41
|
+
pillarboxLeftPx: number;
|
|
42
|
+
pillarboxRightPx: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CropFactFill {
|
|
46
|
+
mode: 'fill';
|
|
47
|
+
distorted: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CropFactOther {
|
|
51
|
+
mode: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type CropFact = CropFactCover | CropFactContain | CropFactFill | CropFactOther;
|
|
55
|
+
|
|
56
|
+
interface Rect {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
width: number;
|
|
60
|
+
height: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MediaElementRecord {
|
|
64
|
+
id: string;
|
|
65
|
+
selector: string;
|
|
66
|
+
/** `null` when this element's identity could not be resolved (see {@link identityUnresolved}) — never simply omitted (I-3), even when {@link MediaReport.identity} is otherwise `available:true`. */
|
|
67
|
+
backendNodeId: number | null;
|
|
68
|
+
/** `true` when {@link backendNodeId} is `null` because this element's `cssPath` selector did not resolve to a CDP node — absent (not `false`) when resolved, matching hittest.ts's convention. */
|
|
69
|
+
identityUnresolved?: true;
|
|
70
|
+
tag: string;
|
|
71
|
+
rect: Rect;
|
|
72
|
+
/** `null` when {@link styleUnavailable} is `true` — a thrown `getComputedStyle` read means visibility genuinely could not be determined, distinct from `false` ("we read the style and it is hidden") or `true` ("we read the style and it is not hidden"). */
|
|
73
|
+
visible: boolean | null;
|
|
74
|
+
naturalWidth: number | null;
|
|
75
|
+
naturalHeight: number | null;
|
|
76
|
+
renderedWidth: number;
|
|
77
|
+
renderedHeight: number;
|
|
78
|
+
devicePixelRatio: number;
|
|
79
|
+
currentSrc: string | null;
|
|
80
|
+
/** `img`: `'complete' | 'loading'`; `video`: a `readyState` label; `canvas`/`svg`/`iframe`: `null`. */
|
|
81
|
+
decodeState: string | null;
|
|
82
|
+
intrinsicAspectRatio: number | null;
|
|
83
|
+
objectFit: string | null;
|
|
84
|
+
objectPosition: string | null;
|
|
85
|
+
crop: CropFact | null;
|
|
86
|
+
/** MARK #62 (I-4/I-5): `true` when the in-page `getComputedStyle(el)` call threw — {@link visible}, {@link objectFit}, {@link objectPosition}, and {@link crop} are then all unavailable-not-observed (never fabricated from rect geometry alone), never simply omitted. Absent (not `false`) on a successful style read. */
|
|
87
|
+
styleUnavailable?: true;
|
|
88
|
+
/** MARK #63 (I-4/I-5): `true` (svg only) when the in-page `el.viewBox.baseVal` read threw — {@link naturalWidth}/{@link naturalHeight} staying `null` is then "could not read", distinguishable from a genuinely absent/unset `viewBox`. Absent (not `false`) on a successful read (or for non-svg tags). */
|
|
89
|
+
intrinsicDimsUnavailable?: true;
|
|
90
|
+
/** Canvas only. Always `null` — baseline collection never probes `getContext` (see file header); this is an honest "not probed" fact, not a measured absence. */
|
|
91
|
+
contextType: string | null;
|
|
92
|
+
backingWidth: number | null;
|
|
93
|
+
backingHeight: number | null;
|
|
94
|
+
/** Video only. */
|
|
95
|
+
poster?: string | null;
|
|
96
|
+
/** Iframe only. */
|
|
97
|
+
src?: string | null;
|
|
98
|
+
crossOrigin?: boolean | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface MediaReport {
|
|
102
|
+
elements: MediaElementRecord[];
|
|
103
|
+
/** Present only when {@link totalCount} is `available:true` AND the page had more `img`/`video`/`canvas`/`svg`/`iframe` elements than {@link MEDIA_MAX_ELEMENTS} — the count dropped past the cap. Absent when `totalCount.available` is `false` too: an unknown total means truncation is unknown, NOT "none" (I-5) — check {@link totalCount} before reading absence here as "not truncated". */
|
|
104
|
+
elementsTruncated?: number;
|
|
105
|
+
/** Explicit total-count availability fact (I-5), DISTINCT from {@link identity}: `available:false` means either the companion `MEDIA_TOTAL_SCRIPT` read failed/returned a non-number, or (when the primary inventory itself was unavailable) it was never attempted at all — either way, whether the kept `elements`/`elementsTruncated` are an exhaustive enumeration is unknown, never coerced to `elements.length` (which would falsely claim no truncation). */
|
|
106
|
+
totalCount: { available: true; total: number } | { available: false; reason: MediaTotalCountUnavailableReason };
|
|
107
|
+
/** Explicit scope fact (D5): the in-page walk queries the top document only — media inside iframes / shadow roots is absent, a stated scope boundary rather than a negative fact. */
|
|
108
|
+
coverage: { scope: 'top-document' };
|
|
109
|
+
/** Explicit identity-resolution availability fact (I-4): `available:false` means `backendNodeId` was never attempted for ANY element this run (`DOM.getDocument`/`resolveNodeIds` failed) — distinct from a per-element `backendNodeId` simply being `null` because that one element's selector didn't resolve while the system was healthy. */
|
|
110
|
+
identity: { available: true } | { available: false; reason: string };
|
|
111
|
+
/** `false` when the `MEDIA_SCRIPT` inventory `Runtime.evaluate` itself failed (threw, or returned no `value`) — `elements: []` is then "could not collect", not "genuinely no media elements" (I-5). Always `true` on a normal run. DISTINCT from {@link identity}, which is about `backendNodeId` resolution, not the inventory read itself. */
|
|
112
|
+
available: boolean;
|
|
113
|
+
/** Present only when `available` is `false`. */
|
|
114
|
+
unavailableReason?: MediaUnavailableReason;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Fixed, factual reason `MEDIA_SCRIPT`'s `Runtime.evaluate` could not be read (never a raw exception message, which is unbounded/page-influenced) — present only when {@link MediaReport.available} is `false`. */
|
|
118
|
+
export type MediaUnavailableReason = 'media-evaluate-returned-no-value' | 'media-evaluate-threw';
|
|
119
|
+
|
|
120
|
+
/** Fixed, factual reason `MEDIA_TOTAL_SCRIPT`'s cap-count read could not be trusted — present only when {@link MediaReport.totalCount}'s `available` is `false`. `media-total-not-attempted-primary-unavailable` covers the short-circuit case: the primary `MEDIA_SCRIPT` inventory itself failed, so `MEDIA_TOTAL_SCRIPT` was never even sent — distinct from `media-total-evaluate-returned-non-number`, where the read was sent but its result was unusable. */
|
|
121
|
+
export type MediaTotalCountUnavailableReason =
|
|
122
|
+
| 'media-total-evaluate-returned-non-number'
|
|
123
|
+
| 'media-total-not-attempted-primary-unavailable';
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// computeObjectFitCrop — pure, directly unit-testable
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
function parsePercent(token: string | undefined): number | undefined {
|
|
130
|
+
if (!token) return undefined;
|
|
131
|
+
const match = /^(-?[\d.]+)%$/.exec(token);
|
|
132
|
+
if (!match) return undefined;
|
|
133
|
+
return Number(match[1]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parses a CSS `object-position` value into `{posX, posY}` percentages, AXIS-AWARE: `left`/`right` only
|
|
138
|
+
* ever set the horizontal axis and `top`/`bottom` only ever set the vertical axis, regardless of token
|
|
139
|
+
* order, so `bottom left` and `left bottom` both resolve the same way. `center` and bare
|
|
140
|
+
* percentage/length tokens fill whichever axis is still unassigned, in order (matches the common 1-2
|
|
141
|
+
* token forms; the full 4-value `<edge-offset>` syntax isn't modeled). An axis left unspecified by any
|
|
142
|
+
* token — including a single bare keyword like `left` (horizontal only) — defaults to 50%.
|
|
143
|
+
*/
|
|
144
|
+
function parseObjectPosition(objectPosition: string | null | undefined): { posX: number; posY: number } {
|
|
145
|
+
const raw = (objectPosition ?? '').trim();
|
|
146
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
147
|
+
|
|
148
|
+
let posX: number | undefined;
|
|
149
|
+
let posY: number | undefined;
|
|
150
|
+
const unassigned: string[] = [];
|
|
151
|
+
|
|
152
|
+
for (const token of tokens) {
|
|
153
|
+
if (token === 'left') posX = 0;
|
|
154
|
+
else if (token === 'right') posX = 100;
|
|
155
|
+
else if (token === 'top') posY = 0;
|
|
156
|
+
else if (token === 'bottom') posY = 100;
|
|
157
|
+
else unassigned.push(token);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const token of unassigned) {
|
|
161
|
+
const pct = token === 'center' ? 50 : parsePercent(token);
|
|
162
|
+
if (pct === undefined) continue;
|
|
163
|
+
if (posX === undefined) posX = pct;
|
|
164
|
+
else if (posY === undefined) posY = pct;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { posX: posX ?? 50, posY: posY ?? 50 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function round(n: number): number {
|
|
171
|
+
return Math.round(n * 100) / 100;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Computes `object-fit` crop/letterbox facts from natural (intrinsic) and
|
|
176
|
+
* rendered (box) dimensions. Pure — no DOM, no CDP — directly testable.
|
|
177
|
+
* Returns `null` when natural dimensions are unknown or zero (nothing to
|
|
178
|
+
* compute a crop against).
|
|
179
|
+
*/
|
|
180
|
+
export function computeObjectFitCrop(
|
|
181
|
+
natural: { w: number; h: number } | null,
|
|
182
|
+
rendered: { w: number; h: number },
|
|
183
|
+
objectFit: string | null | undefined,
|
|
184
|
+
objectPosition: string | null | undefined,
|
|
185
|
+
): CropFact | null {
|
|
186
|
+
if (!natural || !natural.w || !natural.h || !rendered.w || !rendered.h) return null;
|
|
187
|
+
|
|
188
|
+
const fit = objectFit || 'fill';
|
|
189
|
+
const effectiveMode =
|
|
190
|
+
fit === 'scale-down' ? (natural.w <= rendered.w && natural.h <= rendered.h ? 'none' : 'contain') : fit;
|
|
191
|
+
|
|
192
|
+
const { posX, posY } = parseObjectPosition(objectPosition);
|
|
193
|
+
|
|
194
|
+
if (effectiveMode === 'cover') {
|
|
195
|
+
const scale = Math.max(rendered.w / natural.w, rendered.h / natural.h);
|
|
196
|
+
const scaledW = natural.w * scale;
|
|
197
|
+
const scaledH = natural.h * scale;
|
|
198
|
+
const cropW = (scaledW - rendered.w) / scale;
|
|
199
|
+
const cropH = (scaledH - rendered.h) / scale;
|
|
200
|
+
const cropLeft = cropW * (posX / 100);
|
|
201
|
+
const cropTop = cropH * (posY / 100);
|
|
202
|
+
return {
|
|
203
|
+
mode: 'cover',
|
|
204
|
+
croppedLeftPx: round(cropLeft),
|
|
205
|
+
croppedRightPx: round(cropW - cropLeft),
|
|
206
|
+
croppedTopPx: round(cropTop),
|
|
207
|
+
croppedBottomPx: round(cropH - cropTop),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (effectiveMode === 'contain') {
|
|
212
|
+
const scale = Math.min(rendered.w / natural.w, rendered.h / natural.h);
|
|
213
|
+
const scaledW = natural.w * scale;
|
|
214
|
+
const scaledH = natural.h * scale;
|
|
215
|
+
const padW = rendered.w - scaledW;
|
|
216
|
+
const padH = rendered.h - scaledH;
|
|
217
|
+
const pillarLeft = padW * (posX / 100);
|
|
218
|
+
const letterTop = padH * (posY / 100);
|
|
219
|
+
return {
|
|
220
|
+
mode: 'contain',
|
|
221
|
+
letterboxTopPx: round(letterTop),
|
|
222
|
+
letterboxBottomPx: round(padH - letterTop),
|
|
223
|
+
pillarboxLeftPx: round(pillarLeft),
|
|
224
|
+
pillarboxRightPx: round(padW - pillarLeft),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (effectiveMode === 'fill') {
|
|
229
|
+
const naturalRatio = natural.w / natural.h;
|
|
230
|
+
const renderedRatio = rendered.w / rendered.h;
|
|
231
|
+
return { mode: 'fill', distorted: Math.abs(naturalRatio - renderedRatio) > 0.01 };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 'none' (literal, or scale-down decided it behaves like none). No crop
|
|
235
|
+
// numbers — reporting the effective behavior, not the raw CSS keyword,
|
|
236
|
+
// is the more useful measurement fact here.
|
|
237
|
+
return { mode: effectiveMode };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// In-page script
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
interface MediaFact {
|
|
245
|
+
tag: string;
|
|
246
|
+
cssPath: string;
|
|
247
|
+
rect: Rect;
|
|
248
|
+
visible: boolean | null;
|
|
249
|
+
naturalWidth: number | null;
|
|
250
|
+
naturalHeight: number | null;
|
|
251
|
+
currentSrc: string | null;
|
|
252
|
+
decodeState: string | null;
|
|
253
|
+
poster: string | null;
|
|
254
|
+
objectFit: string | null;
|
|
255
|
+
objectPosition: string | null;
|
|
256
|
+
/** MARK #62: `true` when the in-page `getComputedStyle(el)` call threw for this element. */
|
|
257
|
+
styleUnavailable: boolean;
|
|
258
|
+
/** MARK #63: `true` (svg only) when the in-page `el.viewBox.baseVal` read threw for this element. Absent (not set) for non-svg tags, mirroring how the in-page script only ever assigns it inside the `svg` branch. */
|
|
259
|
+
intrinsicDimsUnavailable?: boolean;
|
|
260
|
+
contextType: string | null;
|
|
261
|
+
backingWidth: number | null;
|
|
262
|
+
backingHeight: number | null;
|
|
263
|
+
src: string | null;
|
|
264
|
+
crossOrigin: boolean | null;
|
|
265
|
+
dpr: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const MEDIA_SCRIPT = `/* __captureMediaInventory */
|
|
269
|
+
(function() {
|
|
270
|
+
function cssPathFromBody(el) {
|
|
271
|
+
var parts = [];
|
|
272
|
+
var node = el;
|
|
273
|
+
var depth = 0;
|
|
274
|
+
while (node && node.nodeType === 1 && node !== document.body && depth < 40) {
|
|
275
|
+
var parent = node.parentElement;
|
|
276
|
+
if (!parent) { parts.unshift(node.tagName.toLowerCase()); break; }
|
|
277
|
+
var same = Array.prototype.filter.call(parent.children, function(c) { return c.tagName === node.tagName; });
|
|
278
|
+
var idx = same.indexOf(node) + 1;
|
|
279
|
+
parts.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + idx + ')');
|
|
280
|
+
node = parent;
|
|
281
|
+
depth++;
|
|
282
|
+
}
|
|
283
|
+
return parts.join(' > ');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var READY_STATE_LABELS = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
|
|
287
|
+
var dpr = window.devicePixelRatio;
|
|
288
|
+
var els = document.querySelectorAll('img, video, canvas, svg, iframe');
|
|
289
|
+
var out = [];
|
|
290
|
+
|
|
291
|
+
for (var i = 0; i < els.length && out.length < ${MEDIA_MAX_ELEMENTS}; i++) {
|
|
292
|
+
var el = els[i];
|
|
293
|
+
var tag = el.tagName.toLowerCase();
|
|
294
|
+
var rect = el.getBoundingClientRect();
|
|
295
|
+
var cs = null;
|
|
296
|
+
var styleUnavailable = false;
|
|
297
|
+
try { cs = getComputedStyle(el); } catch (e) { styleUnavailable = true; }
|
|
298
|
+
// MARK #62 (I-4/I-5): a failed getComputedStyle read must NOT be silently treated as "style
|
|
299
|
+
// did not hide this element" -- that fabricates a visible/crop observation the style read never
|
|
300
|
+
// actually produced. visible/objectFit/objectPosition/crop all go unavailable together, paired
|
|
301
|
+
// with the explicit styleUnavailable marker below, rather than falling back to a rect-only guess.
|
|
302
|
+
var visible = styleUnavailable ? null : (rect.width > 0 && rect.height > 0 && (!cs || (cs.visibility !== 'hidden' && cs.display !== 'none')));
|
|
303
|
+
|
|
304
|
+
var record = {
|
|
305
|
+
tag: tag,
|
|
306
|
+
cssPath: cssPathFromBody(el),
|
|
307
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
308
|
+
visible: visible,
|
|
309
|
+
naturalWidth: null,
|
|
310
|
+
naturalHeight: null,
|
|
311
|
+
currentSrc: null,
|
|
312
|
+
decodeState: null,
|
|
313
|
+
poster: null,
|
|
314
|
+
objectFit: cs ? cs.objectFit : null,
|
|
315
|
+
objectPosition: cs ? cs.objectPosition : null,
|
|
316
|
+
styleUnavailable: styleUnavailable,
|
|
317
|
+
contextType: null,
|
|
318
|
+
backingWidth: null,
|
|
319
|
+
backingHeight: null,
|
|
320
|
+
src: null,
|
|
321
|
+
crossOrigin: null,
|
|
322
|
+
dpr: dpr,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (tag === 'img') {
|
|
326
|
+
record.naturalWidth = el.naturalWidth || null;
|
|
327
|
+
record.naturalHeight = el.naturalHeight || null;
|
|
328
|
+
record.currentSrc = el.currentSrc || null;
|
|
329
|
+
record.decodeState = el.complete ? 'complete' : 'loading';
|
|
330
|
+
} else if (tag === 'video') {
|
|
331
|
+
record.naturalWidth = el.videoWidth || null;
|
|
332
|
+
record.naturalHeight = el.videoHeight || null;
|
|
333
|
+
record.currentSrc = el.currentSrc || null;
|
|
334
|
+
record.decodeState = READY_STATE_LABELS[el.readyState] || null;
|
|
335
|
+
record.poster = el.poster || null;
|
|
336
|
+
} else if (tag === 'canvas') {
|
|
337
|
+
// No getContext(...) probe: the first call on a canvas creates and
|
|
338
|
+
// permanently pins its context type, a page-observable side effect a
|
|
339
|
+
// baseline collector must not cause (I-1/I-6). contextType stays null.
|
|
340
|
+
record.backingWidth = el.width || null;
|
|
341
|
+
record.backingHeight = el.height || null;
|
|
342
|
+
} else if (tag === 'svg') {
|
|
343
|
+
try {
|
|
344
|
+
if (el.viewBox && el.viewBox.baseVal && (el.viewBox.baseVal.width || el.viewBox.baseVal.height)) {
|
|
345
|
+
record.naturalWidth = el.viewBox.baseVal.width || null;
|
|
346
|
+
record.naturalHeight = el.viewBox.baseVal.height || null;
|
|
347
|
+
}
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// MARK #63 (I-4/I-5): a thrown viewBox.baseVal read must not be indistinguishable from a
|
|
350
|
+
// genuinely absent viewBox (naturalWidth/Height staying null either way) -- flag it explicitly.
|
|
351
|
+
record.intrinsicDimsUnavailable = true;
|
|
352
|
+
}
|
|
353
|
+
} else if (tag === 'iframe') {
|
|
354
|
+
record.src = el.getAttribute('src') || null;
|
|
355
|
+
try {
|
|
356
|
+
record.crossOrigin = el.contentDocument ? false : true;
|
|
357
|
+
} catch (e) {
|
|
358
|
+
record.crossOrigin = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
out.push(record);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
})();`;
|
|
366
|
+
|
|
367
|
+
/** Cheap companion script: just the total match count, so the collector can report whether {@link MEDIA_MAX_ELEMENTS} truncated `MEDIA_SCRIPT`'s output without re-walking every element's box/computed-style. */
|
|
368
|
+
const MEDIA_TOTAL_SCRIPT = `/* __captureMediaTotal */
|
|
369
|
+
document.querySelectorAll('img, video, canvas, svg, iframe').length;`;
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Collector
|
|
373
|
+
// ============================================================================
|
|
374
|
+
|
|
375
|
+
/** Caps an optional page-controlled string (URL/selector/CSS keyword) through the shared `sanitizeString` length cap, preserving `null`. */
|
|
376
|
+
function sanitizeOptional(value: string | null | undefined): string | null {
|
|
377
|
+
if (value === null || value === undefined) return null;
|
|
378
|
+
return sanitizeString(value);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Honest `{ backendNodeId, identityUnresolved }` pair for an element-bearing record — mirrors hittest.ts's `resolvedIdentity` helper (I-3/I-5, inlined locally per collector rather than imported/shared): a resolved identity carries just `backendNodeId`; an unresolved one carries `backendNodeId: null` + `identityUnresolved: true`, never a silently-omitted field. */
|
|
382
|
+
function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
|
|
383
|
+
return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export const collectMedia: Collector = async (ctx) => {
|
|
387
|
+
const { client } = ctx;
|
|
388
|
+
|
|
389
|
+
// I-5: a missing `value` (the eval failed/returned nothing) is currently coerced into an empty
|
|
390
|
+
// inventory, indistinguishable from a genuinely media-free page unless the failure itself is
|
|
391
|
+
// surfaced as an explicit report-level fact. DISTINCT from `identity` below, which is about
|
|
392
|
+
// backendNodeId resolution, not this inventory read.
|
|
393
|
+
let facts: MediaFact[];
|
|
394
|
+
let available = true;
|
|
395
|
+
let unavailableReason: MediaUnavailableReason | undefined;
|
|
396
|
+
try {
|
|
397
|
+
const evalResponse = (await client.send('Runtime.evaluate', {
|
|
398
|
+
expression: MEDIA_SCRIPT,
|
|
399
|
+
returnByValue: true,
|
|
400
|
+
})) as { result?: { value?: MediaFact[] } };
|
|
401
|
+
const value = evalResponse.result?.value;
|
|
402
|
+
if (value === undefined) {
|
|
403
|
+
facts = [];
|
|
404
|
+
available = false;
|
|
405
|
+
unavailableReason = 'media-evaluate-returned-no-value';
|
|
406
|
+
} else {
|
|
407
|
+
facts = value;
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
facts = [];
|
|
411
|
+
available = false;
|
|
412
|
+
unavailableReason = 'media-evaluate-threw';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let elementsTruncated: number | undefined;
|
|
416
|
+
let documentNodeId: number | undefined;
|
|
417
|
+
let identity: MediaReport['identity'] = { available: true };
|
|
418
|
+
// I-5: a missing/non-numeric `MEDIA_TOTAL_SCRIPT` result is a FAILED cap-count read, not the
|
|
419
|
+
// fact "the page had exactly `facts.length` elements" — coercing it to `facts.length` would
|
|
420
|
+
// silently suppress `elementsTruncated` and over-claim the kept elements as an exhaustive
|
|
421
|
+
// enumeration. Surface the failure explicitly instead (mirrors `identity` below). Defaults to
|
|
422
|
+
// unavailable/not-attempted here: when `available` is false the `if (available)` block below
|
|
423
|
+
// never runs and this default is what ships, so it must NOT be a success-shaped `facts.length`
|
|
424
|
+
// guess (facts is already the synthetic empty-failure array in that case) — every path that
|
|
425
|
+
// actually reads `MEDIA_TOTAL_SCRIPT` overwrites this before the report is written.
|
|
426
|
+
let totalCount: MediaReport['totalCount'] = {
|
|
427
|
+
available: false,
|
|
428
|
+
reason: 'media-total-not-attempted-primary-unavailable',
|
|
429
|
+
};
|
|
430
|
+
let resolved: Array<{ backendNodeId?: number } | undefined> = [];
|
|
431
|
+
|
|
432
|
+
// The primary inventory read failed — short-circuit to the unavailable report rather than
|
|
433
|
+
// spending further CDP round trips (total count, identity resolution) on an already-empty facts set.
|
|
434
|
+
if (available) {
|
|
435
|
+
const totalResponse = (await client.send('Runtime.evaluate', {
|
|
436
|
+
expression: MEDIA_TOTAL_SCRIPT,
|
|
437
|
+
returnByValue: true,
|
|
438
|
+
})) as { result?: { value?: number } };
|
|
439
|
+
const totalValue = totalResponse.result?.value;
|
|
440
|
+
if (typeof totalValue === 'number') {
|
|
441
|
+
totalCount = { available: true, total: totalValue };
|
|
442
|
+
elementsTruncated = totalValue > MEDIA_MAX_ELEMENTS ? totalValue - MEDIA_MAX_ELEMENTS : undefined;
|
|
443
|
+
} else {
|
|
444
|
+
totalCount = { available: false, reason: 'media-total-evaluate-returned-non-number' };
|
|
445
|
+
// elementsTruncated stays undefined here too — but that now means "unknown", not "none",
|
|
446
|
+
// because `totalCount.available === false` tells a downstream reader not to trust the absence.
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const docResponse = (await client.send('DOM.getDocument', { depth: 0 })) as { root?: { nodeId: number } };
|
|
451
|
+
documentNodeId = docResponse.root?.nodeId;
|
|
452
|
+
if (documentNodeId === undefined) {
|
|
453
|
+
identity = { available: false, reason: 'dom-getdocument-unavailable' };
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
documentNodeId = undefined;
|
|
457
|
+
identity = { available: false, reason: 'dom-getdocument-unavailable' };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (documentNodeId !== undefined) {
|
|
461
|
+
try {
|
|
462
|
+
resolved = await resolveNodeIds(client, documentNodeId, facts.map((f) => f.cssPath));
|
|
463
|
+
} catch {
|
|
464
|
+
resolved = facts.map(() => undefined);
|
|
465
|
+
identity = { available: false, reason: 'resolve-node-ids-failed' };
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
resolved = facts.map(() => undefined);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const elements: MediaElementRecord[] = facts.map((fact, index) => {
|
|
473
|
+
const natural =
|
|
474
|
+
fact.naturalWidth !== null && fact.naturalHeight !== null ? { w: fact.naturalWidth, h: fact.naturalHeight } : null;
|
|
475
|
+
const intrinsicAspectRatio = natural && natural.w > 0 && natural.h > 0 ? natural.w / natural.h : null;
|
|
476
|
+
// MARK #62: when the style read failed, objectFit/objectPosition are unknown (not "unset, so
|
|
477
|
+
// treat as fill") -- computeObjectFitCrop's `objectFit || 'fill'` fallback would otherwise
|
|
478
|
+
// fabricate a 'fill' crop from real intrinsic/rendered dimensions using a guessed mode never
|
|
479
|
+
// actually observed. Withhold the crop entirely rather than guess.
|
|
480
|
+
const crop = fact.styleUnavailable
|
|
481
|
+
? null
|
|
482
|
+
: computeObjectFitCrop(natural, { w: fact.rect.width, h: fact.rect.height }, fact.objectFit, fact.objectPosition);
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
id: `m-${index}`,
|
|
486
|
+
selector: fact.cssPath ? sanitizeString(fact.cssPath) : '',
|
|
487
|
+
...resolvedIdentity(resolved[index]?.backendNodeId),
|
|
488
|
+
tag: fact.tag,
|
|
489
|
+
rect: fact.rect,
|
|
490
|
+
visible: fact.visible,
|
|
491
|
+
naturalWidth: fact.naturalWidth,
|
|
492
|
+
naturalHeight: fact.naturalHeight,
|
|
493
|
+
renderedWidth: fact.rect.width,
|
|
494
|
+
renderedHeight: fact.rect.height,
|
|
495
|
+
devicePixelRatio: fact.dpr,
|
|
496
|
+
currentSrc: sanitizeOptional(fact.currentSrc),
|
|
497
|
+
decodeState: fact.decodeState,
|
|
498
|
+
intrinsicAspectRatio,
|
|
499
|
+
objectFit: sanitizeOptional(fact.objectFit),
|
|
500
|
+
objectPosition: sanitizeOptional(fact.objectPosition),
|
|
501
|
+
crop,
|
|
502
|
+
styleUnavailable: fact.styleUnavailable || undefined,
|
|
503
|
+
intrinsicDimsUnavailable: fact.intrinsicDimsUnavailable || undefined,
|
|
504
|
+
contextType: fact.contextType,
|
|
505
|
+
backingWidth: fact.backingWidth,
|
|
506
|
+
backingHeight: fact.backingHeight,
|
|
507
|
+
poster: fact.tag === 'video' ? sanitizeOptional(fact.poster) : undefined,
|
|
508
|
+
src: fact.tag === 'iframe' ? sanitizeOptional(fact.src) : undefined,
|
|
509
|
+
crossOrigin: fact.tag === 'iframe' ? fact.crossOrigin : undefined,
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
ctx.write.json('media.json', {
|
|
514
|
+
elements,
|
|
515
|
+
elementsTruncated,
|
|
516
|
+
totalCount,
|
|
517
|
+
coverage: { scope: 'top-document' },
|
|
518
|
+
identity,
|
|
519
|
+
available,
|
|
520
|
+
...(unavailableReason ? { unavailableReason } : {}),
|
|
521
|
+
} satisfies MediaReport);
|
|
522
|
+
};
|