imgtune 0.1.0 → 0.3.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 (63) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +109 -5
  3. package/dist/animated.js +176 -0
  4. package/dist/audit.js +1 -0
  5. package/dist/backgrounds.js +42 -7
  6. package/dist/broken.js +5 -1
  7. package/dist/browser.js +4 -4
  8. package/dist/candidates.js +25 -0
  9. package/dist/check-report.js +56 -6
  10. package/dist/check.js +26 -5
  11. package/dist/cli.js +86 -8
  12. package/dist/egress-proxy.js +2 -1
  13. package/dist/fetch.js +9 -4
  14. package/dist/ffmpeg.js +530 -0
  15. package/dist/gpu.js +12 -0
  16. package/dist/hints.js +704 -16
  17. package/dist/image-file.js +154 -34
  18. package/dist/image-open.js +9 -0
  19. package/dist/images.js +142 -8
  20. package/dist/layout.js +7 -7
  21. package/dist/lcp.js +2 -1
  22. package/dist/locate-join.js +86 -0
  23. package/dist/locate-only.js +14 -3
  24. package/dist/locate.js +6 -2
  25. package/dist/measure.js +39 -1
  26. package/dist/media-encode.js +1054 -0
  27. package/dist/media-file.js +284 -0
  28. package/dist/media-limits.js +31 -0
  29. package/dist/media-model.js +251 -0
  30. package/dist/media-phase.js +675 -0
  31. package/dist/media-prepare.js +31 -0
  32. package/dist/px.js +11 -0
  33. package/dist/recheck.js +12 -4
  34. package/dist/remote.js +1065 -107
  35. package/dist/replay.js +20 -0
  36. package/dist/resources/audio.js +248 -0
  37. package/dist/resources/brotli.js +22 -0
  38. package/dist/resources/cache.js +59 -0
  39. package/dist/resources/capture.js +225 -0
  40. package/dist/resources/compression.js +122 -0
  41. package/dist/resources/favicon.js +130 -0
  42. package/dist/resources/font.js +440 -0
  43. package/dist/resources/index.js +8 -0
  44. package/dist/resources/lottie.js +491 -0
  45. package/dist/resources/model.js +478 -0
  46. package/dist/resources/phase.js +171 -0
  47. package/dist/resources/pool.js +47 -0
  48. package/dist/resources/report.js +57 -0
  49. package/dist/resources/sfnt.js +305 -0
  50. package/dist/resources/svg.js +178 -0
  51. package/dist/resources/types.js +1 -0
  52. package/dist/resources/zip.js +67 -0
  53. package/dist/result-cache.js +56 -3
  54. package/dist/run.js +32 -8
  55. package/dist/sizes.js +7 -5
  56. package/dist/summary.js +69 -3
  57. package/dist/vector-art.js +294 -0
  58. package/dist/vector-phase.js +222 -0
  59. package/dist/video-analysis.js +371 -0
  60. package/dist/webp-animation.js +283 -0
  61. package/package.json +25 -4
  62. package/skills/imgtune/SKILL.md +8 -4
  63. package/skills/imgtune/reference.md +1 -1
package/dist/replay.js ADDED
@@ -0,0 +1,20 @@
1
+ // Replay of recorded pages (development tooling, not a user option). With IMGTUNE_REPLAY_HAR=<dir> set, a scan serves each
2
+ // page from the HAR that scripts/record-sites.mjs recorded for it (one per URL and viewport), so reruns need no network.
3
+ // A request the HAR does not hold is aborted. Unset, nothing changes.
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ /** The file-name key of a page URL. */
8
+ export function harKey(url) {
9
+ return createHash("sha1").update(url).digest("hex").slice(0, 12);
10
+ }
11
+ /** The HAR file of a page at a viewport, or undefined when replay is off. Throws when replay is on and the HAR is missing. */
12
+ export function replayHarPath(pageUrl, viewport, env = process.env) {
13
+ const dir = env.IMGTUNE_REPLAY_HAR;
14
+ if (dir === undefined || dir === "")
15
+ return undefined;
16
+ const file = join(dir, `${harKey(pageUrl)}-${viewport}.har`);
17
+ if (!existsSync(file))
18
+ throw new Error(`no recorded HAR for ${pageUrl} at ${viewport} in ${dir}`);
19
+ return file;
20
+ }
@@ -0,0 +1,248 @@
1
+ // Audio analyzer: the audio files a page loads (mp3, wav, ogg, m4a, aac, flac, opus). Each file is probed with the system
2
+ // ffprobe; a WAV or FLAC, a lossy file over 128 kbps stereo (64 kbps mono) and a stereo file whose channels are the same
3
+ // get a re-encode to Opus. The saving is measured on a 10 s sample, extrapolated to the whole file and labelled an
4
+ // estimate. A file that the page downloads on load (autoplay or preload="auto") also gets the lazy-loading fix. Without
5
+ // ffmpeg, the analyzer reports the bytes and the extension only. It never throws.
6
+ import { createHash } from "node:crypto";
7
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { extname, join } from "node:path";
10
+ import { probeAudio, probeFfmpeg, runFfmpeg } from "../ffmpeg.js";
11
+ import { heavyValue } from "./cache.js";
12
+ /** File extensions treated as audio when the content type is not audio/*. */
13
+ const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "oga", "m4a", "aac", "flac", "opus"]);
14
+ /** Adaptive-stream pieces (HLS, DASH): out of scope, the bytes of one segment say nothing about the stream. */
15
+ const SEGMENT_PATH = /(^|\/)(hls|dash|segments?|chunks?)(\/|$)|\.(ts|m4s|m3u8)$/i;
16
+ const LOSSLESS_EXTS = new Set(["wav", "flac", "aif", "aiff"]);
17
+ const LOSSLESS_CODEC = /^(pcm_|flac|alac|wavpack|ape|tta)/;
18
+ /** The length of the sample the re-encodes are measured on, in seconds. */
19
+ export const SAMPLE_S = 10;
20
+ /** The re-encode targets: Opus 96 kbps stereo, Opus 64 kbps mono; AAC 128 kbps as the fallback. */
21
+ export const OPUS_STEREO_KBPS = 96;
22
+ export const OPUS_MONO_KBPS = 64;
23
+ export const AAC_KBPS = 128;
24
+ export const AAC_MONO_KBPS = 64;
25
+ /** Bitrates above which a file is re-encoded: 128 kbps stereo, 64 kbps mono. */
26
+ const STEREO_LIMIT_KBPS = 128;
27
+ const MONO_LIMIT_KBPS = 64;
28
+ /** A re-encode is reported only when it saves this much, absolutely and as a share of the bytes per view. */
29
+ export const MIN_SAVING_BYTES = 5 * 1024;
30
+ export const MIN_SAVING_SHARE = 0.1;
31
+ /** Side-to-mid energy ratio under which a stereo file counts as one channel (-40 dB). */
32
+ const NEAR_MONO_RATIO = 1e-4;
33
+ const PROBE_TIMEOUT_MS = 10_000;
34
+ const ENCODE_TIMEOUT_MS = 30_000;
35
+ function hashUrl(url) {
36
+ return createHash("sha1").update(url).digest("hex").slice(0, 12);
37
+ }
38
+ function fileName(url) {
39
+ try {
40
+ const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
41
+ return name || "audio";
42
+ }
43
+ catch {
44
+ return "audio";
45
+ }
46
+ }
47
+ function extOf(url) {
48
+ try {
49
+ return extname(new URL(url).pathname).slice(1).toLowerCase();
50
+ }
51
+ catch {
52
+ return "";
53
+ }
54
+ }
55
+ function formatBytes(n) {
56
+ if (n < 1024)
57
+ return `${Math.round(n)} B`;
58
+ if (n < 1024 * 1024)
59
+ return `${Math.round(n / 1024)} KB`;
60
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
61
+ }
62
+ function isAudioRecord(r) {
63
+ if (SEGMENT_PATH.test(new URL(r.url).pathname))
64
+ return false;
65
+ return r.contentType.startsWith("audio/") || AUDIO_EXTS.has(extOf(r.url));
66
+ }
67
+ /** Encodes the first `sampleS` seconds of `file`; the encoded length in bytes, or undefined when ffmpeg fails. */
68
+ async function encodedSampleBytes(file, o) {
69
+ const codecArgs = o.codec === "opus"
70
+ ? ["-c:a", "libopus", "-b:a", `${o.kbps}k`, "-f", "ogg"]
71
+ : ["-c:a", "aac", "-b:a", `${o.kbps}k`, "-f", "adts"];
72
+ const r = await runFfmpeg(["-i", file, "-t", String(o.sampleS), "-vn", "-ac", o.mono ? "1" : "2", ...codecArgs, "pipe:1"], { timeoutMs: ENCODE_TIMEOUT_MS });
73
+ return r.code === 0 && r.stdout.length > 0 ? r.stdout.length : undefined;
74
+ }
75
+ /** True when the two channels of the first SAMPLE_S seconds are the same signal (the side is 40 dB below the mid). */
76
+ async function isNearMono(file) {
77
+ const r = await runFfmpeg(["-i", file, "-t", String(SAMPLE_S), "-vn", "-ac", "2", "-ar", "16000", "-f", "f32le", "pipe:1"], { timeoutMs: ENCODE_TIMEOUT_MS });
78
+ if (r.code !== 0 || r.stdout.length < 8)
79
+ return false;
80
+ const copy = r.stdout.buffer.slice(r.stdout.byteOffset, r.stdout.byteOffset + r.stdout.byteLength);
81
+ const samples = new Float32Array(copy, 0, Math.floor(copy.byteLength / 4));
82
+ let mid = 0;
83
+ let side = 0;
84
+ for (let i = 0; i + 1 < samples.length; i += 2) {
85
+ const m = (samples[i] + samples[i + 1]) / 2;
86
+ const s = (samples[i] - samples[i + 1]) / 2;
87
+ mid += m * m;
88
+ side += s * s;
89
+ }
90
+ return mid > 0 && side / mid < NEAR_MONO_RATIO;
91
+ }
92
+ /** The finding for a file that could not be probed (no ffmpeg, no body, or an unreadable file): bytes only. */
93
+ function unprobedFinding(r, eager, selector, reason) {
94
+ const name = fileName(r.url);
95
+ return {
96
+ id: `audio:${hashUrl(r.url)}`,
97
+ kind: "audio",
98
+ url: r.url,
99
+ bytes: r.bytes,
100
+ savedBytes: 0,
101
+ estimate: true,
102
+ title: `audio ${name}: ${formatBytes(r.bytes)} per view${eager ? ", fetched on load" : ""}; ${reason}`,
103
+ fix: ["install ffmpeg to measure the re-encode (ffmpeg enables the full hints)"],
104
+ details: { fetchedBytes: r.bytes, eager, reason },
105
+ ...(selector !== undefined ? { locator: { selector } } : {}),
106
+ };
107
+ }
108
+ async function analyzeOne(r, ctx, ffmpegOk) {
109
+ const el = ctx.audio.find((a) => a.src === r.url);
110
+ const eager = el !== undefined && (el.autoplay || el.preload === "auto");
111
+ const selector = el?.selector;
112
+ if (!r.body)
113
+ return unprobedFinding(r, eager, selector, "no body in the scan (over 20 MB or not captured), not probed");
114
+ if (!ffmpegOk)
115
+ return unprobedFinding(r, eager, selector, "ffmpeg not found, bitrate not measured");
116
+ const dir = await mkdtemp(join(tmpdir(), "imgtune-audio-"));
117
+ try {
118
+ const ext = extOf(r.url) || "bin";
119
+ const file = join(dir, `in.${ext}`);
120
+ await writeFile(file, r.body);
121
+ const probe = await probeAudio(file, PROBE_TIMEOUT_MS);
122
+ if (!probe)
123
+ return unprobedFinding(r, eager, selector, "ffprobe could not read it");
124
+ const name = fileName(r.url);
125
+ const stereo = probe.channels >= 2;
126
+ const bitrate = probe.bitrate ?? (probe.durationS > 0 ? (r.bytes * 8) / probe.durationS : undefined);
127
+ const kbps = bitrate === undefined ? undefined : Math.round(bitrate / 1000);
128
+ // The whole file: the container's bitrate times the length, when both are given (a partial fetch then shows).
129
+ const fullBytes = probe.bitrate !== undefined && probe.durationS > 0
130
+ ? Math.max(r.bytes, Math.round((probe.bitrate * probe.durationS) / 8))
131
+ : r.bytes;
132
+ const perView = eager ? fullBytes : r.bytes;
133
+ const lossless = LOSSLESS_EXTS.has(ext) || LOSSLESS_CODEC.test(probe.codec);
134
+ const overLimit = kbps !== undefined && kbps > (stereo ? STEREO_LIMIT_KBPS : MONO_LIMIT_KBPS);
135
+ const nearMono = stereo ? await isNearMono(file) : false;
136
+ const wantsReencode = lossless || overLimit || nearMono;
137
+ const mono = !stereo || nearMono;
138
+ const kbpsTarget = mono ? OPUS_MONO_KBPS : OPUS_STEREO_KBPS;
139
+ const details = {
140
+ codec: probe.codec,
141
+ channels: probe.channels,
142
+ sampleRate: probe.sampleRate,
143
+ durationS: probe.durationS,
144
+ bitrateKbps: kbps,
145
+ lossless,
146
+ nearMono,
147
+ fetchedBytes: r.bytes,
148
+ fullBytes,
149
+ eager,
150
+ preload: el?.preload,
151
+ autoplay: el?.autoplay ?? false,
152
+ };
153
+ let encodedFull;
154
+ let aacFull;
155
+ let saved = 0;
156
+ let reportReencode = false;
157
+ if (wantsReencode && probe.durationS > 0 && fullBytes > 0) {
158
+ const sampleS = Math.min(SAMPLE_S, probe.durationS);
159
+ // The sample encodes are heavy and depend on the file and the settings: once per run, and not once the budget is spent
160
+ // (then no re-encode is claimed for the file).
161
+ const sampleKey = `audio:${hashUrl(r.url)}:${r.bytes}:${mono ? 1 : 0}:${sampleS}`;
162
+ const opusSample = await heavyValue(`${sampleKey}:opus:${kbpsTarget}`, () => encodedSampleBytes(file, { codec: "opus", mono, kbps: kbpsTarget, sampleS }));
163
+ const aacKbps = mono ? AAC_MONO_KBPS : AAC_KBPS;
164
+ const aacSample = await heavyValue(`${sampleKey}:aac:${aacKbps}`, () => encodedSampleBytes(file, { codec: "aac", mono, kbps: aacKbps, sampleS }));
165
+ if (opusSample !== undefined) {
166
+ encodedFull = Math.round((opusSample * probe.durationS) / sampleS);
167
+ aacFull = aacSample === undefined ? undefined : Math.round((aacSample * probe.durationS) / sampleS);
168
+ const encodedPerView = Math.round(encodedFull * (perView / fullBytes));
169
+ saved = perView - encodedPerView;
170
+ reportReencode = saved >= Math.max(MIN_SAVING_BYTES, MIN_SAVING_SHARE * perView);
171
+ details.opusFullBytes = encodedFull;
172
+ details.aacFullBytes = aacFull;
173
+ details.targetKbps = kbpsTarget;
174
+ details.targetChannels = mono ? 1 : 2;
175
+ }
176
+ }
177
+ if (!reportReencode && !eager)
178
+ return undefined;
179
+ const channelsArg = mono ? " -ac 1" : "";
180
+ const fix = [];
181
+ if (reportReencode && encodedFull !== undefined) {
182
+ fix.push(`re-encode to Opus: ffmpeg -i ${name} -vn${channelsArg} -c:a libopus -b:a ${kbpsTarget}k out.webm ` +
183
+ `(expected ~${formatBytes(encodedFull)} for the whole file)`);
184
+ if (aacFull !== undefined) {
185
+ fix.push(`or AAC for older Safari: ffmpeg -i ${name} -vn${channelsArg} -c:a aac -b:a ${mono ? AAC_MONO_KBPS : AAC_KBPS}k -movflags +faststart out.m4a ` +
186
+ `(expected ~${formatBytes(aacFull)})`);
187
+ }
188
+ }
189
+ if (eager) {
190
+ fix.push(`lazy load: <audio preload="none"> (or drop autoplay) so the file is fetched on play, not on load; ` +
191
+ `saves ${formatBytes(fullBytes)} per view on load`);
192
+ }
193
+ const eagerNote = eager ? ", fetched on load" : "";
194
+ const channelsNote = nearMono ? "stereo with identical channels" : stereo ? "stereo" : "mono";
195
+ const savedBytes = reportReencode ? saved : fullBytes;
196
+ return {
197
+ id: `audio:${hashUrl(r.url)}`,
198
+ kind: "audio",
199
+ url: r.url,
200
+ bytes: perView,
201
+ savedBytes,
202
+ estimate: true,
203
+ title: `audio ${name}: ${probe.codec}${kbps === undefined ? "" : ` ${kbps} kbps`} ${channelsNote}, ` +
204
+ `${formatBytes(perView)} per view${eagerNote}` +
205
+ (reportReencode && encodedFull !== undefined ? `; Opus ${kbpsTarget}k ~${formatBytes(encodedFull)}` : ""),
206
+ fix,
207
+ details,
208
+ ...(selector !== undefined ? { locator: { selector } } : {}),
209
+ };
210
+ }
211
+ finally {
212
+ await rm(dir, { recursive: true, force: true });
213
+ }
214
+ }
215
+ export const analyzer = {
216
+ kind: "audio",
217
+ wants(r) {
218
+ try {
219
+ return isAudioRecord(r);
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ },
225
+ async analyze(records, ctx) {
226
+ try {
227
+ const caps = await probeFfmpeg();
228
+ const ffmpegOk = caps.ffmpeg && caps.ffprobe;
229
+ const out = [];
230
+ for (const r of records) {
231
+ try {
232
+ if (!isAudioRecord(r))
233
+ continue;
234
+ const f = await analyzeOne(r, ctx, ffmpegOk);
235
+ if (f)
236
+ out.push(f);
237
+ }
238
+ catch {
239
+ // One unreadable file does not drop the others.
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+ catch {
245
+ return [];
246
+ }
247
+ },
248
+ };
@@ -0,0 +1,22 @@
1
+ import { brotliCompressSync, constants } from "node:zlib";
2
+ /** Bodies up to this size are compressed at brotli quality 11; bigger ones at quality 9 and the figure is an estimate. */
3
+ export const BROTLI_FULL_MAX = 512 * 1024;
4
+ /** Brotli size of a buffer, the wire size an origin with brotli would send. */
5
+ export function brotliSize(buf) {
6
+ const full = buf.length <= BROTLI_FULL_MAX;
7
+ const out = brotliCompressSync(buf, {
8
+ params: {
9
+ [constants.BROTLI_PARAM_QUALITY]: full ? 11 : 9,
10
+ [constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
11
+ },
12
+ });
13
+ return { bytes: out.length, estimate: !full };
14
+ }
15
+ /**
16
+ * Brotli size of a buffer at quality 5: about 20 times faster than quality 11, for the analyzers that compare two sizes of
17
+ * the same kind (the rounded and the served JSON), where the ratio is what the finding reports. Always an estimate.
18
+ */
19
+ export function brotliSizeFast(buf) {
20
+ const out = brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 5, [constants.BROTLI_PARAM_SIZE_HINT]: buf.length } });
21
+ return { bytes: out.length, estimate: true };
22
+ }
@@ -0,0 +1,59 @@
1
+ // The resource analyzers' heavy work, shared across the run. A heavy step (a brotli-11 encode, a WOFF2 encode, a re-encode, an
2
+ // SVGO run, a sample encode) asks for its value by a key that holds the content (a digest), so a resource that two viewports
3
+ // or two pages load is computed once. The values are kept in the run's memo (in process) and in the result cache (across
4
+ // runs, when the cache is on). The run's resource budget (--resource-time) bounds the time spent computing them: once it is
5
+ // spent, a step that is not already known is not computed, and the caller reports the item as not measured.
6
+ import { createHash } from "node:crypto";
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+ import { measure } from "../timing.js";
9
+ /** The default time budget of the heavy resource work per run, in milliseconds (the spec: 60 s total). */
10
+ export const DEFAULT_RESOURCE_BUDGET_MS = 60_000;
11
+ /** A fresh run state with the given budget (default DEFAULT_RESOURCE_BUDGET_MS). */
12
+ export function newResourceRun(budgetMs = DEFAULT_RESOURCE_BUDGET_MS) {
13
+ return { budgetMs, spentMs: 0, memo: new Map() };
14
+ }
15
+ const scopes = new AsyncLocalStorage();
16
+ /** Runs `fn` with the run's state (a fresh one when none is given) and the run's result cache (undefined: no disk cache). */
17
+ export function withResourceScope(cache, fn, run = newResourceRun()) {
18
+ return scopes.run({ cache, run }, fn);
19
+ }
20
+ /**
21
+ * The value for `key`: memoised for the run, read from the result cache, else computed (and stored). Undefined only when the
22
+ * value is not known and the budget is spent (`budgeted` true); `compute` is then not run. Never throws by itself.
23
+ */
24
+ export async function heavyValue(key, compute, budgeted = true) {
25
+ const scope = scopes.getStore();
26
+ const run = scope?.run ?? newResourceRun();
27
+ const fullKey = `resources:v1:${key}`;
28
+ const known = run.memo.get(fullKey);
29
+ if (known !== undefined)
30
+ return (await known);
31
+ if (budgeted && run.spentMs >= run.budgetMs)
32
+ return undefined;
33
+ const pending = (async () => {
34
+ const stored = scope?.cache ? await scope.cache.getJson(fullKey) : undefined;
35
+ if (stored !== undefined)
36
+ return stored;
37
+ const start = performance.now();
38
+ const value = await measure("resources heavy", compute);
39
+ run.spentMs += performance.now() - start;
40
+ await scope?.cache?.putJson(fullKey, value);
41
+ return value;
42
+ })();
43
+ run.memo.set(fullKey, pending);
44
+ try {
45
+ return await pending;
46
+ }
47
+ catch (error) {
48
+ run.memo.delete(fullKey);
49
+ throw error;
50
+ }
51
+ }
52
+ /** The value for `key` with no budget (always computed once per run). The cheap steps use this. */
53
+ export function cachedValue(key, compute) {
54
+ return heavyValue(key, compute, false);
55
+ }
56
+ /** A short digest of a buffer or text, for cache keys. */
57
+ export function digestOf(data) {
58
+ return createHash("sha256").update(data).digest("hex");
59
+ }
@@ -0,0 +1,225 @@
1
+ import { isFirstParty } from "../third-party.js";
2
+ import { ANALYZERS } from "./index.js";
3
+ /** A body larger than this is never kept (the record keeps its bytes): see ResourceRecord.body. */
4
+ export const RESOURCE_BODY_MAX = 20 * 1024 * 1024;
5
+ /** The content type without parameters, lower-cased ("text/css; charset=utf-8" is "text/css"). */
6
+ export function contentTypeOf(header) {
7
+ return (header ?? "").split(";")[0].trim().toLowerCase();
8
+ }
9
+ /** The content encoding the server applied, lower-cased ("br", "gzip", ...), or undefined for an identity body. */
10
+ export function contentEncodingOf(header) {
11
+ const value = (header ?? "").split(",")[0].trim().toLowerCase();
12
+ return value === "" || value === "identity" ? undefined : value;
13
+ }
14
+ /** Whether any registered analyzer wants a response with this record (decided before its body is read). */
15
+ export function anyAnalyzerWants(record) {
16
+ return ANALYZERS.some((analyzer) => analyzer.wants(record));
17
+ }
18
+ /**
19
+ * The record of a response. Its bytes are the Content-Length header when the response has one, so a record whose body is still
20
+ * streaming at the settle keeps its size (and its analyses can use the size alone); readResourceBody replaces it with the
21
+ * measured size once the body is read.
22
+ */
23
+ export function resourceRecordOf(response, pageUrl, viewport, contentType) {
24
+ const headers = response.headers();
25
+ const declared = Number(headers["content-length"]);
26
+ return {
27
+ url: response.url(),
28
+ pageUrl,
29
+ viewport,
30
+ resourceType: response.request().resourceType(),
31
+ contentType: contentTypeOf(contentType),
32
+ contentEncoding: contentEncodingOf(headers["content-encoding"]),
33
+ bytes: Number.isFinite(declared) && declared >= 0 ? declared : 0,
34
+ status: response.status(),
35
+ thirdParty: !isFirstParty(pageUrl, response.url()),
36
+ };
37
+ }
38
+ /**
39
+ * Settles a resource record once its response has finished: the bytes (the encoded size the browser counted, else the
40
+ * Content-Length), and the decoded body when an analyzer wants it and it is within RESOURCE_BODY_MAX.
41
+ */
42
+ /** How long a scan waits for the resource bodies of a page view before it moves on (a body still streaming is fetched again by the phase). */
43
+ export const RESOURCE_BODY_SETTLE_MS = 1500;
44
+ export async function readResourceBody(record, response) {
45
+ await response.finished().catch(() => null);
46
+ let encoded = 0;
47
+ try {
48
+ encoded = (await response.request().sizes()).responseBodySize;
49
+ }
50
+ catch {
51
+ encoded = 0;
52
+ }
53
+ const length = Number(response.headers()["content-length"]);
54
+ record.bytes = encoded > 0 ? encoded : Number.isFinite(length) && length >= 0 ? length : 0;
55
+ if (record.bytes > RESOURCE_BODY_MAX || !anyAnalyzerWants(record))
56
+ return;
57
+ const body = await response.body().catch(() => undefined);
58
+ if (body === undefined)
59
+ return;
60
+ // Playwright's headers can lack a Content-Encoding the server sent (seen on an SVG served as br): a body that decodes
61
+ // to more than the browser counted on the wire was encoded, so it is not a candidate for compression.
62
+ // A wire size under half the decoded body is a compressed response whose Content-Encoding Playwright did not report. A wire
63
+ // size near the body is an uncompressed one (the reported size of a replayed or cached response can differ a little, and
64
+ // must not turn it into "compressed": that dropped the findings of uncompressed JSON on two real sites).
65
+ if (record.contentEncoding === undefined && encoded > 0 && encoded * 2 < body.length)
66
+ record.contentEncoding = "unknown";
67
+ record.bytes = body.length;
68
+ if (body.length <= RESOURCE_BODY_MAX)
69
+ record.body = body;
70
+ }
71
+ /** What the page shows, read in one page.evaluate after the scroll (see types.ts PageContext). Never throws. */
72
+ export async function readPageContext(page) {
73
+ return page
74
+ .evaluate(() => {
75
+ const TEXT_MAX = 200_000;
76
+ const MAX_TEXT_ELEMENTS = 6000;
77
+ const unquote = (value) => value.trim().replace(/^["']|["']$/g, "").toLowerCase();
78
+ const selectorOf = (el) => {
79
+ const id = /^[A-Za-z][\w-]*$/.test(el.id) ? `#${el.id}` : "";
80
+ const cls = Array.from(el.classList).slice(0, 2).map((c) => `.${c}`).join("");
81
+ return `${el.tagName.toLowerCase()}${id}${cls}`;
82
+ };
83
+ const rectOf = (el) => {
84
+ const r = el.getBoundingClientRect();
85
+ return { width: Math.round(r.width), height: Math.round(r.height) };
86
+ };
87
+ // The visible text, capped.
88
+ const text = (document.body?.innerText ?? "").slice(0, TEXT_MAX);
89
+ // Font faces the rendered text uses: the first family of each text-bearing element's computed font stack.
90
+ const usedKeys = new Set();
91
+ const usedFonts = [];
92
+ let scanned = 0;
93
+ for (const el of Array.from(document.body?.querySelectorAll("*") ?? [])) {
94
+ if (scanned >= MAX_TEXT_ELEMENTS)
95
+ break;
96
+ const hasText = Array.from(el.childNodes).some((n) => n.nodeType === Node.TEXT_NODE && (n.textContent ?? "").trim() !== "");
97
+ if (!hasText)
98
+ continue;
99
+ scanned++;
100
+ const style = getComputedStyle(el);
101
+ const family = unquote(style.fontFamily.split(",")[0] ?? "");
102
+ const weight = Number(style.fontWeight) || 400;
103
+ const fontStyle = style.fontStyle === "normal" ? "normal" : style.fontStyle;
104
+ const key = `${family}|${weight}|${fontStyle}`;
105
+ if (family !== "" && !usedKeys.has(key)) {
106
+ usedKeys.add(key);
107
+ usedFonts.push({ family, weight, style: fontStyle });
108
+ }
109
+ }
110
+ const faceRules = new Map();
111
+ for (const sheet of Array.from(document.styleSheets)) {
112
+ let rules;
113
+ try {
114
+ rules = sheet.cssRules;
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ for (const rule of Array.from(rules)) {
120
+ if (!(rule instanceof CSSFontFaceRule))
121
+ continue;
122
+ const family = unquote(rule.style.getPropertyValue("font-family"));
123
+ const weight = rule.style.getPropertyValue("font-weight") || "400";
124
+ const fontStyle = rule.style.getPropertyValue("font-style") || "normal";
125
+ const src = rule.style.getPropertyValue("src");
126
+ const match = /url\(\s*["']?([^"')]+)["']?\s*\)/.exec(src);
127
+ const info = {
128
+ url: match ? new URL(match[1], sheet.href ?? document.baseURI).href : undefined,
129
+ unicodeRange: rule.style.getPropertyValue("unicode-range") || undefined,
130
+ };
131
+ faceRules.set(`${family}|${weight}|${fontStyle}`, info);
132
+ }
133
+ }
134
+ const fontFaces = [];
135
+ document.fonts.forEach((face) => {
136
+ const family = unquote(face.family);
137
+ const info = faceRules.get(`${family}|${face.weight}|${face.style}`);
138
+ fontFaces.push({
139
+ family,
140
+ weight: face.weight,
141
+ style: face.style,
142
+ status: face.status,
143
+ ...(info?.url !== undefined ? { url: info.url } : {}),
144
+ ...(info?.unicodeRange !== undefined ? { unicodeRange: info.unicodeRange } : {}),
145
+ });
146
+ });
147
+ const icons = Array.from(document.querySelectorAll("link[rel*='icon']"))
148
+ .filter((link) => link.href !== "")
149
+ .map((link) => ({
150
+ rel: link.rel,
151
+ href: link.href,
152
+ ...(link.getAttribute("sizes") ? { sizes: link.getAttribute("sizes") } : {}),
153
+ ...(link.type ? { type: link.type } : {}),
154
+ }));
155
+ const audio = Array.from(document.querySelectorAll("audio")).map((el) => ({
156
+ src: el.currentSrc || el.src || el.querySelector("source")?.src || "",
157
+ autoplay: el.autoplay,
158
+ loop: el.loop,
159
+ preload: el.preload,
160
+ muted: el.muted,
161
+ selector: selectorOf(el),
162
+ }));
163
+ const lottie = Array.from(document.querySelectorAll("lottie-player, dotlottie-player, dotlottie-wc, [data-animation-path], [data-lottie-src]")).map((el) => {
164
+ const src = el.getAttribute("src") ?? el.getAttribute("data-animation-path") ?? el.getAttribute("data-lottie-src") ?? undefined;
165
+ return { ...(src ? { src: new URL(src, document.baseURI).href } : {}), selector: selectorOf(el), ...rectOf(el) };
166
+ });
167
+ const models = Array.from(document.querySelectorAll("model-viewer")).map((el) => {
168
+ const src = el.getAttribute("src") ?? undefined;
169
+ return { ...(src ? { src: new URL(src, document.baseURI).href } : {}), selector: selectorOf(el), ...rectOf(el) };
170
+ });
171
+ return { text, usedFonts, fontFaces, icons, audio, lottie, models };
172
+ })
173
+ .catch(() => undefined);
174
+ }
175
+ /**
176
+ * The @font-face rules of a stylesheet's text, with each src URL made absolute against the sheet's URL. Used for the
177
+ * sheets the browser's CSSOM does not show (cross-origin ones), read from the captured CSS response bodies.
178
+ */
179
+ export function fontFaceRulesOf(css, cssUrl) {
180
+ const out = [];
181
+ for (const match of css.matchAll(/@font-face\s*\{([^}]*)\}/gi)) {
182
+ const body = match[1];
183
+ const prop = (name) => {
184
+ const found = new RegExp(`(?:^|[;\\s])${name}\\s*:\\s*([^;]+)`, "i").exec(body);
185
+ return found ? found[1].trim() : undefined;
186
+ };
187
+ const src = /url\(\s*["']?([^"')]+)["']?\s*\)/i.exec(prop("src") ?? "");
188
+ if (!src)
189
+ continue;
190
+ let url;
191
+ try {
192
+ url = new URL(src[1], cssUrl).href;
193
+ }
194
+ catch {
195
+ continue;
196
+ }
197
+ out.push({
198
+ family: (prop("font-family") ?? "").replace(/["']/g, "").trim().toLowerCase(),
199
+ weight: prop("font-weight") ?? "normal",
200
+ style: prop("font-style") ?? "normal",
201
+ unicodeRange: prop("unicode-range"),
202
+ url,
203
+ });
204
+ }
205
+ return out;
206
+ }
207
+ const normalWeight = (w) => (w === "normal" ? "400" : w === "bold" ? "700" : w.replace(/\s+/g, " ").trim());
208
+ /**
209
+ * The faces of a page with the URL and unicode-range the rules declare, where the page's own face (from the CSSOM) has
210
+ * none: matched by family, weight and style, and by unicode-range when several rules share them. A face no rule matches
211
+ * is returned as it is.
212
+ */
213
+ export function completeFontFaces(faces, rules) {
214
+ return faces.map((face) => {
215
+ if (face.url !== undefined)
216
+ return face;
217
+ const family = face.family.replace(/["']/g, "").trim().toLowerCase();
218
+ const same = rules.filter((r) => r.family === family && normalWeight(r.weight) === normalWeight(face.weight) && r.style.split(" ")[0] === face.style.split(" ")[0]);
219
+ const rangeOf = (r) => (r ?? "").replace(/\s+/g, "").toUpperCase();
220
+ const pick = same.find((r) => rangeOf(r.unicodeRange) === rangeOf(face.unicodeRange)) ?? same[0];
221
+ if (pick === undefined)
222
+ return face;
223
+ return { ...face, url: pick.url, ...(face.unicodeRange === undefined && pick.unicodeRange !== undefined ? { unicodeRange: pick.unicodeRange } : {}) };
224
+ });
225
+ }