@skrillex1224/playwright-toolkit 3.0.23 → 3.0.24

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/dist/index.cjs CHANGED
@@ -256,6 +256,7 @@ var ActorInfo = {
256
256
  name: "\u6587\u5FC3\u4E00\u8A00",
257
257
  domain: "wenxin.baidu.com",
258
258
  path: "/",
259
+ device: Device.Mobile,
259
260
  share: {
260
261
  mode: "custom",
261
262
  prefix: "",
@@ -494,6 +495,7 @@ function createInternalLogger(moduleName, explicitLogger) {
494
495
 
495
496
  // src/internals/screenshot.js
496
497
  var import_delay = __toESM(require("delay"), 1);
498
+ var import_jimp = require("jimp");
497
499
 
498
500
  // src/internals/constants.js
499
501
  var PageRuntimeStateKey = "__playwright_toolkit_runtime_state__";
@@ -532,9 +534,23 @@ var FORCED_FULLPAGE_TYPE = "jpeg";
532
534
  var FORCED_FULLPAGE_QUALITY = 50;
533
535
  var SUPPORTED_TYPES = /* @__PURE__ */ new Set(["png", "jpeg", "webp"]);
534
536
  var EXPANDED_SCROLLABLE_CLASS = "__pk_expanded__";
537
+ var STITCH_SCROLL_TARGET_ATTR = "data-pk-stitch-scroll-target";
535
538
  var DEFAULT_MAX_HEIGHT = 8e3;
536
539
  var DEFAULT_SETTLE_MS = 1e3;
537
540
  var DEFAULT_MOBILE_SETTLE_MS = 50;
541
+ var DEFAULT_STITCH_SETTLE_MS = 120;
542
+ var DEFAULT_STITCH_OVERLAP_PX = 24;
543
+ var MIN_VIRTUALIZED_SCROLL_RATIO = 2.2;
544
+ var MIN_SPARSE_SCROLL_RATIO = 4;
545
+ var MIN_STITCH_VISIBLE_HEIGHT_PX = 120;
546
+ var MIN_STITCH_VISIBLE_HEIGHT_RATIO = 0.22;
547
+ var MIN_STITCH_OUTPUT_HEIGHT_RATIO = 0.35;
548
+ var MIN_STITCH_OUTPUT_VIEWPORT_RATIO = 1.15;
549
+ var MOBILE_VIEWPORT_WIDTH_THRESHOLD = 520;
550
+ var DEFAULT_QUALITY_RETRY_ATTEMPTS = 2;
551
+ var DEFAULT_QUALITY_RETRY_DELAY_MS = 2500;
552
+ var MIN_TRAILING_BLANK_GAP_PX = 320;
553
+ var MIN_TRAILING_BLANK_GAP_RATIO = 0.12;
538
554
  var toPositiveNumber = (value, fallback = 0) => {
539
555
  const n = Number(value);
540
556
  if (!Number.isFinite(n) || n <= 0) return fallback;
@@ -557,7 +573,22 @@ var normalizeQuality = (value, type) => {
557
573
  if (rounded < 0 || rounded > 100) return void 0;
558
574
  return rounded;
559
575
  };
560
- var resolvePageDevice = (page) => normalizeDevice(page?.[PageRuntimeStateKey]?.device);
576
+ var resolvePageDevice = async (page) => {
577
+ const declared = normalizeDevice(page?.[PageRuntimeStateKey]?.device);
578
+ if (declared === Device.Mobile) return Device.Mobile;
579
+ const viewport = await resolveCurrentViewportSize(page).catch(() => null);
580
+ const viewportWidth = Math.round(Number(viewport?.width) || 0);
581
+ if (viewportWidth > 0 && viewportWidth <= MOBILE_VIEWPORT_WIDTH_THRESHOLD) {
582
+ return Device.Mobile;
583
+ }
584
+ return declared;
585
+ };
586
+ var resolveStitchMode = (options = {}) => {
587
+ const raw = options.stitch ?? options.stitching ?? options.strategy;
588
+ if (raw === false || raw === "off" || raw === "none" || raw === "single") return "off";
589
+ if (raw === true || raw === "force" || raw === "stitched") return "force";
590
+ return "auto";
591
+ };
561
592
  var buildFullPageClip = (metrics, viewport, maxClipHeight) => {
562
593
  const contentSize = metrics && typeof metrics === "object" ? metrics.contentSize || null : null;
563
594
  const width = Math.max(1, Math.ceil(viewport.width || contentSize?.width || 1));
@@ -904,10 +935,156 @@ var restoreAffixedElementsForExpandedScreenshot = async (page) => {
904
935
  });
905
936
  }, EXPANDED_SCROLLABLE_CLASS);
906
937
  };
938
+ var measureMeaningfulContentBounds = async (page) => {
939
+ return await page.evaluate(() => {
940
+ const body = document.body;
941
+ if (!body) return null;
942
+ const viewportWidth = window.innerWidth || document.documentElement?.clientWidth || body.clientWidth || 0;
943
+ const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || body.clientHeight || 0;
944
+ const scrollX = window.scrollX || window.pageXOffset || 0;
945
+ const scrollY = window.scrollY || window.pageYOffset || 0;
946
+ const bounds = {
947
+ left: Number.POSITIVE_INFINITY,
948
+ top: Number.POSITIVE_INFINITY,
949
+ right: 0,
950
+ bottom: 0,
951
+ nodes: 0
952
+ };
953
+ const isVisible = (el, style, rect) => {
954
+ if (!el || !style || !rect) return false;
955
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
956
+ if (Number(style.opacity) === 0) return false;
957
+ return rect.width > 1 && rect.height > 1;
958
+ };
959
+ const hasFixedAncestor = (el) => {
960
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
961
+ const position = String(window.getComputedStyle(node).position || "").toLowerCase();
962
+ if (position === "fixed") {
963
+ return true;
964
+ }
965
+ }
966
+ return false;
967
+ };
968
+ const parseRgbColor = (value) => {
969
+ const raw = String(value || "").trim();
970
+ const match = raw.match(/rgba?\(([^)]+)\)/i);
971
+ if (!match) return null;
972
+ const parts = match[1].replace(/\//g, " ").split(/[,\s]+/).map((part) => part.trim()).filter(Boolean);
973
+ if (parts.length < 3) return null;
974
+ const normalizeChannel = (part) => {
975
+ if (part.endsWith("%")) {
976
+ return Math.max(0, Math.min(255, Number.parseFloat(part) * 2.55));
977
+ }
978
+ return Math.max(0, Math.min(255, Number.parseFloat(part)));
979
+ };
980
+ const alpha = parts.length >= 4 ? parts[3].endsWith("%") ? Number.parseFloat(parts[3]) / 100 : Number.parseFloat(parts[3]) : 1;
981
+ return {
982
+ r: normalizeChannel(parts[0]),
983
+ g: normalizeChannel(parts[1]),
984
+ b: normalizeChannel(parts[2]),
985
+ a: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1
986
+ };
987
+ };
988
+ const colorLuminance = (color) => {
989
+ const channel = (value) => {
990
+ const ratio = Math.max(0, Math.min(255, value)) / 255;
991
+ return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
992
+ };
993
+ return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
994
+ };
995
+ const contrastRatio = (a, b) => {
996
+ const l1 = colorLuminance(a);
997
+ const l2 = colorLuminance(b);
998
+ const lighter = Math.max(l1, l2);
999
+ const darker = Math.min(l1, l2);
1000
+ return (lighter + 0.05) / (darker + 0.05);
1001
+ };
1002
+ const resolveBackgroundColor = (el) => {
1003
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
1004
+ const background = parseRgbColor(window.getComputedStyle(node).backgroundColor);
1005
+ if (background && background.a > 0.2) {
1006
+ return background;
1007
+ }
1008
+ }
1009
+ return { r: 255, g: 255, b: 255, a: 1 };
1010
+ };
1011
+ const isLowInformationTextPaint = (el, style) => {
1012
+ const color = parseRgbColor(style?.color);
1013
+ if (!color) return false;
1014
+ const opacity = Number(style.opacity);
1015
+ if (color.a <= 0.08 || Number.isFinite(opacity) && opacity <= 0.18) {
1016
+ return true;
1017
+ }
1018
+ const max = Math.max(color.r, color.g, color.b);
1019
+ const min = Math.min(color.r, color.g, color.b);
1020
+ if (!(min >= 224 && max - min <= 24 && color.a >= 0.85)) {
1021
+ return false;
1022
+ }
1023
+ const background = resolveBackgroundColor(el);
1024
+ return colorLuminance(background) > 0.76 && contrastRatio(color, background) < 1.35;
1025
+ };
1026
+ const addRect = (rect) => {
1027
+ if (!rect || rect.width <= 1 || rect.height <= 1) return;
1028
+ const left = Math.max(0, Math.floor(rect.left + scrollX));
1029
+ const top = Math.max(0, Math.floor(rect.top + scrollY));
1030
+ const right = Math.ceil(rect.right + scrollX);
1031
+ const bottom = Math.ceil(rect.bottom + scrollY);
1032
+ if (right <= left || bottom <= top) return;
1033
+ bounds.left = Math.min(bounds.left, left);
1034
+ bounds.top = Math.min(bounds.top, top);
1035
+ bounds.right = Math.max(bounds.right, right);
1036
+ bounds.bottom = Math.max(bounds.bottom, bottom);
1037
+ bounds.nodes += 1;
1038
+ };
1039
+ const addElement = (el, { minArea = 12, text = false } = {}) => {
1040
+ if (!el || el.nodeType !== 1 || hasFixedAncestor(el)) return;
1041
+ const style = window.getComputedStyle(el);
1042
+ if (text && isLowInformationTextPaint(el, style)) return;
1043
+ const rects = Array.from(el.getClientRects ? el.getClientRects() : []);
1044
+ rects.forEach((rect) => {
1045
+ if (!isVisible(el, style, rect)) return;
1046
+ if (rect.width * rect.height < minArea) return;
1047
+ addRect(rect);
1048
+ });
1049
+ };
1050
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
1051
+ let visitedTextNodes = 0;
1052
+ while (walker.nextNode() && visitedTextNodes < 6e3) {
1053
+ const node = walker.currentNode;
1054
+ visitedTextNodes += 1;
1055
+ const text = String(node.nodeValue || "").replace(/\s+/g, " ").trim();
1056
+ if (text.length < 2) continue;
1057
+ addElement(node.parentElement, { minArea: 8, text: true });
1058
+ }
1059
+ document.querySelectorAll("img,video,canvas,svg,picture").forEach((el) => {
1060
+ addElement(el, { minArea: 900 });
1061
+ });
1062
+ if (!bounds.nodes || !Number.isFinite(bounds.left)) return null;
1063
+ const paddingX = Math.max(16, Math.min(96, viewportWidth * 0.04));
1064
+ const paddingY = Math.max(24, Math.min(160, viewportHeight * 0.18));
1065
+ return {
1066
+ left: Math.max(0, Math.floor(bounds.left - paddingX)),
1067
+ top: Math.max(0, Math.floor(bounds.top - paddingY)),
1068
+ right: Math.ceil(bounds.right + paddingX),
1069
+ bottom: Math.ceil(bounds.bottom + paddingY),
1070
+ width: Math.max(1, Math.ceil(bounds.right - bounds.left)),
1071
+ height: Math.max(1, Math.ceil(bounds.bottom - bounds.top)),
1072
+ nodes: bounds.nodes,
1073
+ viewport: {
1074
+ width: Math.max(1, Math.ceil(viewportWidth || 1)),
1075
+ height: Math.max(1, Math.ceil(viewportHeight || 1))
1076
+ }
1077
+ };
1078
+ }).catch((error) => {
1079
+ logger.warning(`\u622A\u56FE\u5185\u5BB9\u8FB9\u754C\u6D4B\u91CF\u5931\u8D25: ${error?.message || error}`);
1080
+ return null;
1081
+ });
1082
+ };
907
1083
  var prepareExpandedFullPageScreenshot = async (page, options = {}) => {
908
1084
  const originalViewport = await resolveCurrentViewportSize(page);
909
1085
  const maxHeight = toPositiveInteger(options.maxHeight, DEFAULT_MAX_HEIGHT);
910
- const preserveViewport = options.preserveViewport ?? resolvePageDevice(page) === Device.Mobile;
1086
+ const device = await resolvePageDevice(page);
1087
+ const preserveViewport = options.preserveViewport ?? device === Device.Mobile;
911
1088
  const defaultSettleMs = preserveViewport ? DEFAULT_MOBILE_SETTLE_MS : DEFAULT_SETTLE_MS;
912
1089
  const requestedSettleMs = Math.max(0, Number(options.settleMs ?? defaultSettleMs) || 0);
913
1090
  const settleMs = preserveViewport ? Math.min(requestedSettleMs, DEFAULT_MOBILE_SETTLE_MS) : requestedSettleMs;
@@ -916,7 +1093,21 @@ var prepareExpandedFullPageScreenshot = async (page, options = {}) => {
916
1093
  visibleOnly: options.visibleOnly !== false,
917
1094
  expandDocumentElements: preserveViewport
918
1095
  });
919
- const targetHeight = Math.min(maxScrollHeight, maxHeight);
1096
+ const contentBounds = await measureMeaningfulContentBounds(page);
1097
+ let targetHeight = Math.min(maxScrollHeight, maxHeight);
1098
+ if (contentBounds?.bottom > 0) {
1099
+ const contentBottom = Math.min(Math.ceil(contentBounds.bottom), maxHeight);
1100
+ const trailingGap = targetHeight - contentBottom;
1101
+ const minTrailingGap = Math.max(
1102
+ MIN_TRAILING_BLANK_GAP_PX,
1103
+ Math.floor(targetHeight * MIN_TRAILING_BLANK_GAP_RATIO),
1104
+ Math.floor(originalViewport.height * 0.45)
1105
+ );
1106
+ if (trailingGap >= minTrailingGap && contentBottom >= originalViewport.height * 0.65) {
1107
+ targetHeight = Math.max(1, contentBottom);
1108
+ logger.info(`\u957F\u622A\u56FE\u88C1\u6389\u4F4E\u4FE1\u606F\u5C3E\u90E8: contentBottom=${contentBottom}, originalHeight=${Math.min(maxScrollHeight, maxHeight)}`);
1109
+ }
1110
+ }
920
1111
  let viewportResized = false;
921
1112
  if (!preserveViewport) {
922
1113
  await page.setViewportSize({
@@ -936,6 +1127,7 @@ var prepareExpandedFullPageScreenshot = async (page, options = {}) => {
936
1127
  originalViewport,
937
1128
  maxScrollHeight,
938
1129
  targetHeight,
1130
+ contentBounds,
939
1131
  preserveViewport,
940
1132
  viewportResized
941
1133
  };
@@ -968,6 +1160,436 @@ var restoreExpandedFullPageScreenshot = async (page, state2 = {}) => {
968
1160
  await page.setViewportSize(state2.originalViewport);
969
1161
  }
970
1162
  };
1163
+ var resolveVirtualizedScrollTarget = async (page, options = {}) => {
1164
+ const stitchMode = resolveStitchMode(options);
1165
+ if (stitchMode === "off") return null;
1166
+ const device = await resolvePageDevice(page);
1167
+ if (stitchMode !== "force" && device !== Device.Mobile) {
1168
+ return null;
1169
+ }
1170
+ return await page.evaluate((config) => {
1171
+ const viewportWidth = window.innerWidth || document.documentElement?.clientWidth || document.body?.clientWidth || 0;
1172
+ const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || document.body?.clientHeight || 0;
1173
+ const root = document.documentElement;
1174
+ const body = document.body;
1175
+ const scrollingElement = document.scrollingElement;
1176
+ const candidates = [];
1177
+ const seen = /* @__PURE__ */ new Set();
1178
+ const scrollableOverflow = /* @__PURE__ */ new Set(["auto", "scroll", "overlay"]);
1179
+ const clippingOverflow = /* @__PURE__ */ new Set(["hidden", "clip"]);
1180
+ const pushCandidate = (el2) => {
1181
+ if (!el2 || seen.has(el2)) return;
1182
+ seen.add(el2);
1183
+ candidates.push(el2);
1184
+ };
1185
+ pushCandidate(scrollingElement);
1186
+ pushCandidate(root);
1187
+ pushCandidate(body);
1188
+ document.querySelectorAll("*").forEach(pushCandidate);
1189
+ const isDocumentElement = (el2) => el2 === root || el2 === body || el2 === scrollingElement;
1190
+ const isVisible = (el2, style, rect) => {
1191
+ if (!el2 || !style || !rect) return false;
1192
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
1193
+ if (Number(style.opacity) === 0) return false;
1194
+ return rect.width > 0 && rect.height > 0;
1195
+ };
1196
+ const hasOverflowValue = (style, values) => {
1197
+ const overflowY = String(style?.overflowY || "").toLowerCase();
1198
+ const overflow = String(style?.overflow || "").toLowerCase();
1199
+ return values.has(overflowY) || values.has(overflow);
1200
+ };
1201
+ const looksScrollable = (el2, style) => {
1202
+ if (!el2 || el2.scrollHeight <= el2.clientHeight + 1) return false;
1203
+ if (isDocumentElement(el2)) return true;
1204
+ return hasOverflowValue(style, scrollableOverflow) || hasOverflowValue(style, clippingOverflow);
1205
+ };
1206
+ const textHeightFor = (el2) => {
1207
+ const nodes = isDocumentElement(el2) ? document.querySelectorAll("body *") : el2.querySelectorAll("*");
1208
+ let textHeight = 0;
1209
+ let textNodes = 0;
1210
+ const maxNodes = 3500;
1211
+ for (const node of nodes) {
1212
+ if (textNodes >= maxNodes) break;
1213
+ const text = String(node.textContent || "").replace(/\s+/g, " ").trim();
1214
+ if (text.length < 2) continue;
1215
+ let childHasText = false;
1216
+ for (const child of node.children || []) {
1217
+ if (String(child.textContent || "").replace(/\s+/g, " ").trim().length >= 2) {
1218
+ childHasText = true;
1219
+ break;
1220
+ }
1221
+ }
1222
+ if (childHasText) continue;
1223
+ const style = window.getComputedStyle(node);
1224
+ const rect = node.getBoundingClientRect();
1225
+ if (!isVisible(node, style, rect)) continue;
1226
+ textNodes += 1;
1227
+ textHeight += Math.min(Math.ceil(rect.height || 0), 900);
1228
+ }
1229
+ return { textHeight, textNodes };
1230
+ };
1231
+ const resolveEdgeOcclusion = () => {
1232
+ let top = 0;
1233
+ let bottom = 0;
1234
+ const maxHeight = Math.max(48, viewportHeight * 0.45);
1235
+ document.querySelectorAll("*").forEach((el2) => {
1236
+ const style = window.getComputedStyle(el2);
1237
+ const position = String(style.position || "").toLowerCase();
1238
+ if (position !== "fixed" && position !== "sticky") return;
1239
+ const rect = el2.getBoundingClientRect();
1240
+ if (!isVisible(el2, style, rect)) return;
1241
+ if (rect.height <= 0 || rect.height > maxHeight) return;
1242
+ if (rect.width < viewportWidth * 0.35) return;
1243
+ if (rect.top <= Math.max(16, viewportHeight * 0.08)) {
1244
+ top = Math.max(top, Math.ceil(rect.bottom));
1245
+ }
1246
+ if (rect.bottom >= viewportHeight - Math.max(16, viewportHeight * 0.08)) {
1247
+ bottom = Math.max(bottom, Math.ceil(viewportHeight - rect.top));
1248
+ }
1249
+ });
1250
+ return {
1251
+ top: Math.max(0, Math.min(Math.ceil(top), Math.floor(viewportHeight * 0.45))),
1252
+ bottom: Math.max(0, Math.min(Math.ceil(bottom), Math.floor(viewportHeight * 0.45)))
1253
+ };
1254
+ };
1255
+ let best = null;
1256
+ candidates.forEach((el2) => {
1257
+ const style = window.getComputedStyle(el2);
1258
+ const rect = isDocumentElement(el2) ? {
1259
+ top: 0,
1260
+ bottom: viewportHeight,
1261
+ left: 0,
1262
+ right: viewportWidth,
1263
+ width: viewportWidth,
1264
+ height: viewportHeight
1265
+ } : el2.getBoundingClientRect();
1266
+ if (!isDocumentElement(el2) && !isVisible(el2, style, rect)) return;
1267
+ if (!looksScrollable(el2, style)) return;
1268
+ const scrollHeight = Math.ceil(el2.scrollHeight || 0);
1269
+ const clientHeight = Math.ceil(el2.clientHeight || viewportHeight || 0);
1270
+ if (scrollHeight < Math.max(900, clientHeight * config.minScrollRatio)) return;
1271
+ const visibleTop = Math.max(0, Math.round(rect.top || 0));
1272
+ const visibleBottom = Math.min(viewportHeight, Math.round(rect.bottom || viewportHeight));
1273
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
1274
+ const minVisibleHeight = Math.max(
1275
+ config.minVisibleHeightPx,
1276
+ Math.floor(viewportHeight * config.minVisibleHeightRatio)
1277
+ );
1278
+ if (!config.force && !isDocumentElement(el2) && visibleHeight < minVisibleHeight) return;
1279
+ const { textHeight, textNodes } = textHeightFor(el2);
1280
+ const sparseRatio = scrollHeight / Math.max(1, textHeight);
1281
+ const sparse = config.force || textNodes > 0 && sparseRatio >= config.minSparseRatio && scrollHeight - textHeight > clientHeight;
1282
+ if (!sparse) return;
1283
+ const visibleWidth = Math.max(1, Math.ceil(rect.width || viewportWidth || 1));
1284
+ const score2 = scrollHeight * Math.min(visibleWidth, viewportWidth || visibleWidth) * Math.min(1, visibleHeight / Math.max(1, viewportHeight));
1285
+ if (!best || score2 > best.score) {
1286
+ best = {
1287
+ el: el2,
1288
+ score: score2,
1289
+ kind: isDocumentElement(el2) ? "document" : "element",
1290
+ scrollHeight,
1291
+ clientHeight,
1292
+ scrollTop: Math.max(0, Math.round(el2.scrollTop || 0)),
1293
+ rect: {
1294
+ top: Math.max(0, Math.round(rect.top || 0)),
1295
+ bottom: Math.min(viewportHeight, Math.round(rect.bottom || viewportHeight)),
1296
+ left: Math.max(0, Math.round(rect.left || 0)),
1297
+ width: Math.max(1, Math.round(rect.width || viewportWidth || 1)),
1298
+ height: Math.max(1, Math.round(rect.height || viewportHeight || 1))
1299
+ },
1300
+ visibleHeight,
1301
+ textHeight,
1302
+ textNodes,
1303
+ sparseRatio
1304
+ };
1305
+ }
1306
+ });
1307
+ if (!best) return null;
1308
+ document.querySelectorAll(`[${config.attrName}="1"]`).forEach((node) => {
1309
+ node.removeAttribute(config.attrName);
1310
+ });
1311
+ if (best.kind === "element") {
1312
+ best.el.setAttribute(config.attrName, "1");
1313
+ }
1314
+ const { el, score, ...target } = best;
1315
+ return {
1316
+ ...target,
1317
+ viewport: {
1318
+ width: Math.max(1, Math.ceil(viewportWidth || best.rect.width || 1)),
1319
+ height: Math.max(1, Math.ceil(viewportHeight || best.rect.height || 1))
1320
+ },
1321
+ edgeOcclusion: resolveEdgeOcclusion()
1322
+ };
1323
+ }, {
1324
+ attrName: STITCH_SCROLL_TARGET_ATTR,
1325
+ force: stitchMode === "force",
1326
+ minScrollRatio: MIN_VIRTUALIZED_SCROLL_RATIO,
1327
+ minSparseRatio: MIN_SPARSE_SCROLL_RATIO,
1328
+ minVisibleHeightPx: MIN_STITCH_VISIBLE_HEIGHT_PX,
1329
+ minVisibleHeightRatio: MIN_STITCH_VISIBLE_HEIGHT_RATIO
1330
+ }).catch((error) => {
1331
+ logger.warning(`\u865A\u62DF\u6EDA\u52A8\u622A\u56FE\u63A2\u6D4B\u5931\u8D25: ${error?.message || error}`);
1332
+ return null;
1333
+ });
1334
+ };
1335
+ var setStitchScrollTop = async (page, target, scrollTop) => {
1336
+ await page.evaluate(({ attrName, kind, top }) => {
1337
+ const el = kind === "document" ? document.scrollingElement || document.documentElement || document.body : document.querySelector(`[${attrName}="1"]`);
1338
+ if (!el) return;
1339
+ el.scrollTop = Math.max(0, Math.round(Number(top) || 0));
1340
+ el.dispatchEvent(new Event("scroll", { bubbles: true }));
1341
+ window.dispatchEvent(new Event("scroll"));
1342
+ }, {
1343
+ attrName: STITCH_SCROLL_TARGET_ATTR,
1344
+ kind: target.kind,
1345
+ top: scrollTop
1346
+ });
1347
+ };
1348
+ var cleanupStitchTarget = async (page) => {
1349
+ await page.evaluate((attrName) => {
1350
+ document.querySelectorAll(`[${attrName}="1"]`).forEach((node) => {
1351
+ node.removeAttribute(attrName);
1352
+ });
1353
+ }, STITCH_SCROLL_TARGET_ATTR).catch(() => {
1354
+ });
1355
+ };
1356
+ var cropImage = (image, crop) => image.clone().crop({
1357
+ x: Math.max(0, Math.round(crop.x || 0)),
1358
+ y: Math.max(0, Math.round(crop.y || 0)),
1359
+ w: Math.max(1, Math.round(crop.w || 1)),
1360
+ h: Math.max(1, Math.round(crop.h || 1))
1361
+ });
1362
+ var captureStitchedScrollableScreenshot = async (page, target, options = {}) => {
1363
+ const maxHeight = toPositiveInteger(options.maxHeight, DEFAULT_MAX_HEIGHT);
1364
+ const settleMs = Math.max(0, Number(options.stitchSettleMs ?? DEFAULT_STITCH_SETTLE_MS) || 0);
1365
+ const overlapPx = Math.max(0, Math.round(Number(options.stitchOverlapPx ?? DEFAULT_STITCH_OVERLAP_PX) || 0));
1366
+ const viewport = target.viewport || await resolveCurrentViewportSize(page);
1367
+ const rect = target.rect || {
1368
+ top: 0,
1369
+ bottom: viewport.height,
1370
+ left: 0,
1371
+ width: viewport.width,
1372
+ height: viewport.height
1373
+ };
1374
+ const edge = target.edgeOcclusion || { top: 0, bottom: 0 };
1375
+ const topCrop = Math.max(0, Math.min(rect.top, viewport.height - 1));
1376
+ const topOverlay = Math.max(topCrop, Math.min(edge.top || 0, viewport.height - 1));
1377
+ const bottomOverlay = Math.max(0, Math.min(edge.bottom || 0, viewport.height - topOverlay - 1));
1378
+ const middleCropY = Math.max(topCrop, topOverlay);
1379
+ const middleCropBottom = Math.max(
1380
+ middleCropY + 1,
1381
+ Math.min(rect.bottom || viewport.height, viewport.height - bottomOverlay)
1382
+ );
1383
+ const middleCropHeight = Math.max(1, middleCropBottom - middleCropY);
1384
+ const scrollStep = Math.max(120, middleCropHeight - overlapPx);
1385
+ const maxScrollTop = Math.max(0, Math.ceil(target.scrollHeight - target.clientHeight));
1386
+ const positions = [];
1387
+ for (let top = 0; top < maxScrollTop; top += scrollStep) {
1388
+ positions.push(Math.round(top));
1389
+ }
1390
+ if (!positions.includes(maxScrollTop)) {
1391
+ positions.push(maxScrollTop);
1392
+ }
1393
+ if (positions.length === 0) {
1394
+ positions.push(0);
1395
+ }
1396
+ const segments = [];
1397
+ let totalHeight = 0;
1398
+ let outputWidth = Math.max(1, Math.round(viewport.width || rect.width || 1));
1399
+ try {
1400
+ for (let index = 0; index < positions.length && totalHeight < maxHeight; index += 1) {
1401
+ const isFirst = index === 0;
1402
+ const isLast = index === positions.length - 1;
1403
+ await setStitchScrollTop(page, target, positions[index]);
1404
+ if (settleMs > 0) {
1405
+ await (0, import_delay.default)(settleMs);
1406
+ }
1407
+ const buffer = await capturePageScreenshot(page, {
1408
+ type: options.type || "png",
1409
+ quality: options.quality,
1410
+ timeout: options.timeout
1411
+ });
1412
+ const image = await import_jimp.Jimp.read(buffer);
1413
+ outputWidth = Math.min(outputWidth, image.bitmap.width);
1414
+ const cropY = isFirst ? 0 : middleCropY;
1415
+ const cropBottom = isLast ? image.bitmap.height : middleCropBottom;
1416
+ let cropHeight = Math.max(1, Math.min(image.bitmap.height, cropBottom) - cropY);
1417
+ const remaining = maxHeight - totalHeight;
1418
+ if (cropHeight > remaining) {
1419
+ cropHeight = remaining;
1420
+ }
1421
+ segments.push(cropImage(image, {
1422
+ x: 0,
1423
+ y: cropY,
1424
+ w: outputWidth,
1425
+ h: cropHeight
1426
+ }));
1427
+ totalHeight += cropHeight;
1428
+ }
1429
+ const canvas = new import_jimp.Jimp({
1430
+ width: outputWidth,
1431
+ height: Math.max(1, totalHeight),
1432
+ color: 4294967295
1433
+ });
1434
+ let y = 0;
1435
+ for (const segment of segments) {
1436
+ canvas.composite(segment, 0, y);
1437
+ y += segment.bitmap.height;
1438
+ }
1439
+ logger.info(
1440
+ `\u865A\u62DF\u6EDA\u52A8\u5206\u6BB5\u622A\u56FE: segments=${segments.length}, height=${totalHeight}, scrollHeight=${target.scrollHeight}, textNodes=${target.textNodes}, sparseRatio=${Number(target.sparseRatio || 0).toFixed(2)}`
1441
+ );
1442
+ const expectedHeight = Math.min(maxHeight, Math.max(target.scrollHeight || 0, viewport.height || 0));
1443
+ const minPlausibleHeight = Math.max(
1444
+ Math.floor((viewport.height || rect.height || 0) * MIN_STITCH_OUTPUT_VIEWPORT_RATIO),
1445
+ Math.floor(expectedHeight * MIN_STITCH_OUTPUT_HEIGHT_RATIO)
1446
+ );
1447
+ if (positions.length > 1 && expectedHeight > (viewport.height || rect.height || 0) * 1.3 && totalHeight < minPlausibleHeight) {
1448
+ logger.warning(
1449
+ `\u865A\u62DF\u6EDA\u52A8\u5206\u6BB5\u622A\u56FE\u7ED3\u679C\u8FC7\u77ED\uFF0C\u964D\u7EA7\u666E\u901A\u957F\u622A\u56FE: segments=${segments.length}, height=${totalHeight}, expectedMin=${minPlausibleHeight}, scrollHeight=${target.scrollHeight}, rect=${rect.width}x${rect.height}@${rect.top}`
1450
+ );
1451
+ return null;
1452
+ }
1453
+ return await canvas.getBuffer(import_jimp.JimpMime.png);
1454
+ } finally {
1455
+ await setStitchScrollTop(page, target, target.scrollTop || 0).catch(() => {
1456
+ });
1457
+ await cleanupStitchTarget(page);
1458
+ }
1459
+ };
1460
+ var isLightBlankPixel = ({ r, g, b }) => {
1461
+ const max = Math.max(r, g, b);
1462
+ const min = Math.min(r, g, b);
1463
+ return max >= 238 && max - min <= 18;
1464
+ };
1465
+ var isDarkBlankPixel = ({ r, g, b }) => {
1466
+ const max = Math.max(r, g, b);
1467
+ const min = Math.min(r, g, b);
1468
+ return max <= 34 && max - min <= 10;
1469
+ };
1470
+ var isLowInfoPixel = ({ r, g, b }) => {
1471
+ const max = Math.max(r, g, b);
1472
+ const min = Math.min(r, g, b);
1473
+ return max - min <= 12;
1474
+ };
1475
+ var analyzeScreenshotBuffer = async (buffer) => {
1476
+ const image = await import_jimp.Jimp.read(buffer);
1477
+ const width = image.bitmap.width;
1478
+ const height = image.bitmap.height;
1479
+ const stepY = Math.max(1, Math.floor(height / 900));
1480
+ const stepX = Math.max(1, Math.floor(width / 420));
1481
+ const rows = [];
1482
+ let lightRows = 0;
1483
+ let darkRows = 0;
1484
+ let lowInfoRows = 0;
1485
+ let loadingLikeRows = 0;
1486
+ const rowFlags = [];
1487
+ for (let y = 0; y < height; y += stepY) {
1488
+ let light = 0;
1489
+ let dark = 0;
1490
+ let lowInfo = 0;
1491
+ let edge = 0;
1492
+ let count = 0;
1493
+ let prev = null;
1494
+ for (let x = 0; x < width; x += stepX) {
1495
+ const rgba = (0, import_jimp.intToRGBA)(image.getPixelColor(x, y));
1496
+ count += 1;
1497
+ if (isLightBlankPixel(rgba)) light += 1;
1498
+ if (isDarkBlankPixel(rgba)) dark += 1;
1499
+ if (isLowInfoPixel(rgba)) lowInfo += 1;
1500
+ if (prev) {
1501
+ const diff = Math.abs(rgba.r - prev.r) + Math.abs(rgba.g - prev.g) + Math.abs(rgba.b - prev.b);
1502
+ if (diff > 45) edge += 1;
1503
+ }
1504
+ prev = rgba;
1505
+ }
1506
+ const stat = {
1507
+ y,
1508
+ lightRatio: light / Math.max(1, count),
1509
+ darkRatio: dark / Math.max(1, count),
1510
+ lowInfoRatio: lowInfo / Math.max(1, count),
1511
+ edgeRatio: edge / Math.max(1, count - 1)
1512
+ };
1513
+ rows.push(stat);
1514
+ const lightBlank = stat.lightRatio >= 0.965 && stat.edgeRatio <= 0.015;
1515
+ const darkBlank = stat.darkRatio >= 0.965 && stat.edgeRatio <= 0.012;
1516
+ const lowInfoBlank = stat.lowInfoRatio >= 0.965 && stat.edgeRatio <= 0.012;
1517
+ const loadingLike = stat.darkRatio >= 0.88 && stat.edgeRatio <= 0.025;
1518
+ if (lightBlank) lightRows += 1;
1519
+ if (darkBlank) darkRows += 1;
1520
+ if (lowInfoBlank) lowInfoRows += 1;
1521
+ if (loadingLike) loadingLikeRows += 1;
1522
+ rowFlags.push({ lightBlank, darkBlank, lowInfoBlank, loadingLike });
1523
+ }
1524
+ const sampledRows = Math.max(1, rows.length);
1525
+ return {
1526
+ image,
1527
+ width,
1528
+ height,
1529
+ lightBlankRowsRatio: lightRows / sampledRows,
1530
+ darkBlankRowsRatio: darkRows / sampledRows,
1531
+ lowInfoRowsRatio: lowInfoRows / sampledRows,
1532
+ loadingLikeRowsRatio: loadingLikeRows / sampledRows,
1533
+ rowFlags
1534
+ };
1535
+ };
1536
+ var resolveScreenshotQualityIssue = (analysis) => {
1537
+ if (!analysis) return null;
1538
+ if (analysis.loadingLikeRowsRatio >= 0.92 || analysis.darkBlankRowsRatio >= 0.72) {
1539
+ return "loading-like-dark-screenshot";
1540
+ }
1541
+ return null;
1542
+ };
1543
+ var cropBufferToContentBounds = async (buffer, bounds, options = {}) => {
1544
+ if (!bounds || bounds.nodes <= 0) return buffer;
1545
+ const image = options.image || await import_jimp.Jimp.read(buffer);
1546
+ const width = image.bitmap.width;
1547
+ const height = image.bitmap.height;
1548
+ const safeLeft = Math.max(0, Math.min(width - 1, Math.floor(bounds.left || 0)));
1549
+ const safeRight = Math.max(safeLeft + 1, Math.min(width, Math.ceil(bounds.right || width)));
1550
+ const safeTop = Math.max(0, Math.min(height - 1, Math.floor(bounds.top || 0)));
1551
+ const safeBottom = Math.max(safeTop + 1, Math.min(height, Math.ceil(bounds.bottom || height)));
1552
+ const cropW = safeRight - safeLeft;
1553
+ const cropH = safeBottom - safeTop;
1554
+ const shouldCropX = cropW > 80 && cropW <= width * 0.82 && (safeLeft > width * 0.04 || width - safeRight > width * 0.04);
1555
+ const shouldCropY = cropH > 160 && cropH <= height * 0.88 && height - safeBottom > Math.max(320, height * 0.1);
1556
+ if (!shouldCropX && !shouldCropY) {
1557
+ return buffer;
1558
+ }
1559
+ const x = shouldCropX ? safeLeft : 0;
1560
+ const y = shouldCropY ? safeTop : 0;
1561
+ const w = shouldCropX ? cropW : width;
1562
+ const h = shouldCropY ? cropH : height;
1563
+ logger.info(`\u5185\u5BB9\u611F\u77E5\u88C1\u526A\u622A\u56FE: x=${x}, y=${y}, w=${w}, h=${h}, original=${width}x${height}`);
1564
+ return await cropImage(image, { x, y, w, h }).getBuffer(import_jimp.JimpMime.png);
1565
+ };
1566
+ var captureExpandedFullPageScreenshotOnce = async (page, options = {}) => {
1567
+ const stitchedTarget = await resolveVirtualizedScrollTarget(page, options);
1568
+ if (stitchedTarget) {
1569
+ const buffer = await captureStitchedScrollableScreenshot(page, stitchedTarget, options);
1570
+ if (buffer) {
1571
+ return buffer;
1572
+ }
1573
+ }
1574
+ const state2 = await prepareExpandedFullPageScreenshot(page, options);
1575
+ try {
1576
+ const buffer = await capturePageScreenshot(page, {
1577
+ fullPage: true,
1578
+ type: options.type || "png",
1579
+ quality: options.quality,
1580
+ timeout: options.timeout,
1581
+ maxClipHeight: state2.targetHeight
1582
+ });
1583
+ return await cropBufferToContentBounds(buffer, state2.contentBounds);
1584
+ } finally {
1585
+ await restoreAffixedElementsForExpandedScreenshot(page).catch((error) => {
1586
+ logger.warning(`\u79FB\u52A8\u7AEF\u5438\u9644\u5143\u7D20\u6062\u590D\u5931\u8D25: ${error?.message || error}`);
1587
+ });
1588
+ if (options.restore) {
1589
+ await restoreExpandedFullPageScreenshot(page, state2);
1590
+ }
1591
+ }
1592
+ };
971
1593
  var capturePageScreenshot = async (page, options = {}) => {
972
1594
  const fullPage = Boolean(options.fullPage);
973
1595
  const type = fullPage ? FORCED_FULLPAGE_TYPE : normalizeType(options.type);
@@ -1022,23 +1644,35 @@ var capturePageScreenshot = async (page, options = {}) => {
1022
1644
  }
1023
1645
  };
1024
1646
  var captureExpandedFullPageScreenshot = async (page, options = {}) => {
1025
- const state2 = await prepareExpandedFullPageScreenshot(page, options);
1026
- try {
1027
- return await capturePageScreenshot(page, {
1028
- fullPage: true,
1029
- type: options.type || "png",
1030
- quality: options.quality,
1031
- timeout: options.timeout,
1032
- maxClipHeight: state2.targetHeight
1033
- });
1034
- } finally {
1035
- await restoreAffixedElementsForExpandedScreenshot(page).catch((error) => {
1036
- logger.warning(`\u79FB\u52A8\u7AEF\u5438\u9644\u5143\u7D20\u6062\u590D\u5931\u8D25: ${error?.message || error}`);
1647
+ const attempts = Math.max(1, toPositiveInteger(options.qualityRetryAttempts, DEFAULT_QUALITY_RETRY_ATTEMPTS));
1648
+ const retryDelayMs = Math.max(0, Number(options.qualityRetryDelayMs ?? DEFAULT_QUALITY_RETRY_DELAY_MS) || 0);
1649
+ let lastBuffer = null;
1650
+ let lastAnalysis = null;
1651
+ let lastIssue = null;
1652
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1653
+ const buffer = await captureExpandedFullPageScreenshotOnce(page, options);
1654
+ const analysis = await analyzeScreenshotBuffer(buffer).catch((error) => {
1655
+ logger.warning(`\u622A\u56FE\u8D28\u91CF\u5206\u6790\u5931\u8D25: ${error?.message || error}`);
1656
+ return null;
1037
1657
  });
1038
- if (options.restore) {
1039
- await restoreExpandedFullPageScreenshot(page, state2);
1658
+ const issue = resolveScreenshotQualityIssue(analysis);
1659
+ lastBuffer = buffer;
1660
+ lastAnalysis = analysis;
1661
+ lastIssue = issue;
1662
+ if (!issue) {
1663
+ return buffer;
1664
+ }
1665
+ if (attempt < attempts && retryDelayMs > 0) {
1666
+ logger.warning(`\u622A\u56FE\u7591\u4F3C\u5F02\u5E38(${issue})\uFF0C\u7B49\u5F85\u540E\u91CD\u8BD5: attempt=${attempt}/${attempts}`);
1667
+ await (0, import_delay.default)(retryDelayMs);
1040
1668
  }
1041
1669
  }
1670
+ if (lastIssue && lastAnalysis) {
1671
+ logger.warning(
1672
+ `\u622A\u56FE\u8D28\u91CF\u4ECD\u5F02\u5E38(${lastIssue})\uFF0C\u8FD4\u56DE\u6700\u4F73\u53EF\u5F97\u7ED3\u679C: light=${lastAnalysis.lightBlankRowsRatio.toFixed(2)}, dark=${lastAnalysis.darkBlankRowsRatio.toFixed(2)}, loading=${lastAnalysis.loadingLikeRowsRatio.toFixed(2)}`
1673
+ );
1674
+ }
1675
+ return lastBuffer;
1042
1676
  };
1043
1677
 
1044
1678
  // src/errors.js
@@ -9715,7 +10349,7 @@ var watermarkifyScreenshotBuffer = async (buffer, meta, page = null, options = {
9715
10349
  };
9716
10350
 
9717
10351
  // src/internals/compression.js
9718
- var import_jimp = require("jimp");
10352
+ var import_jimp2 = require("jimp");
9719
10353
  var logger15 = createInternalLogger("Compression");
9720
10354
  var DEFAULT_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
9721
10355
  var DEFAULT_SCREENSHOT_OUTPUT_TYPE = "jpeg";
@@ -9782,10 +10416,10 @@ var encodeJpeg = async (sourceImage, compression, scale, quality) => {
9782
10416
  image.resize({
9783
10417
  w: width,
9784
10418
  h: height,
9785
- mode: import_jimp.ResizeStrategy.BILINEAR
10419
+ mode: import_jimp2.ResizeStrategy.BILINEAR
9786
10420
  });
9787
10421
  }
9788
- const buffer = await image.getBuffer(import_jimp.JimpMime.jpeg, { quality });
10422
+ const buffer = await image.getBuffer(import_jimp2.JimpMime.jpeg, { quality });
9789
10423
  return {
9790
10424
  buffer,
9791
10425
  bytes: getBase64BytesFromBuffer(buffer),
@@ -9797,7 +10431,7 @@ var encodeJpeg = async (sourceImage, compression, scale, quality) => {
9797
10431
  };
9798
10432
  };
9799
10433
  var compressImageBuffer = async (buffer, compression) => {
9800
- const sourceImage = await import_jimp.Jimp.read(buffer);
10434
+ const sourceImage = await import_jimp2.Jimp.read(buffer);
9801
10435
  const maxQuality = toJpegQuality(compression.quality);
9802
10436
  const minQuality = Math.min(maxQuality, toJpegQuality(compression.minQuality));
9803
10437
  let quality = maxQuality;