@skrillex1224/playwright-toolkit 3.0.25 → 3.0.26

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.js CHANGED
@@ -229,7 +229,6 @@ var ActorInfo = {
229
229
  name: "\u6587\u5FC3\u4E00\u8A00",
230
230
  domain: "wenxin.baidu.com",
231
231
  path: "/",
232
- device: Device.Mobile,
233
232
  share: {
234
233
  mode: "custom",
235
234
  prefix: "",
@@ -468,6 +467,7 @@ function createInternalLogger(moduleName, explicitLogger) {
468
467
 
469
468
  // src/internals/screenshot.js
470
469
  import delay from "delay";
470
+ import { Jimp, JimpMime, intToRGBA } from "jimp";
471
471
 
472
472
  // src/internals/constants.js
473
473
  var PageRuntimeStateKey = "__playwright_toolkit_runtime_state__";
@@ -506,9 +506,23 @@ var FORCED_FULLPAGE_TYPE = "jpeg";
506
506
  var FORCED_FULLPAGE_QUALITY = 50;
507
507
  var SUPPORTED_TYPES = /* @__PURE__ */ new Set(["png", "jpeg", "webp"]);
508
508
  var EXPANDED_SCROLLABLE_CLASS = "__pk_expanded__";
509
+ var STITCH_SCROLL_TARGET_ATTR = "data-pk-stitch-scroll-target";
509
510
  var DEFAULT_MAX_HEIGHT = 8e3;
510
511
  var DEFAULT_SETTLE_MS = 1e3;
511
512
  var DEFAULT_MOBILE_SETTLE_MS = 50;
513
+ var DEFAULT_STITCH_SETTLE_MS = 120;
514
+ var DEFAULT_STITCH_OVERLAP_PX = 24;
515
+ var MIN_VIRTUALIZED_SCROLL_RATIO = 2.2;
516
+ var MIN_SPARSE_SCROLL_RATIO = 4;
517
+ var MIN_STITCH_VISIBLE_HEIGHT_PX = 120;
518
+ var MIN_STITCH_VISIBLE_HEIGHT_RATIO = 0.22;
519
+ var MIN_STITCH_OUTPUT_HEIGHT_RATIO = 0.35;
520
+ var MIN_STITCH_OUTPUT_VIEWPORT_RATIO = 1.15;
521
+ var MOBILE_VIEWPORT_WIDTH_THRESHOLD = 520;
522
+ var DEFAULT_QUALITY_RETRY_ATTEMPTS = 2;
523
+ var DEFAULT_QUALITY_RETRY_DELAY_MS = 2500;
524
+ var MIN_TRAILING_BLANK_GAP_PX = 320;
525
+ var MIN_TRAILING_BLANK_GAP_RATIO = 0.12;
512
526
  var toPositiveNumber = (value, fallback = 0) => {
513
527
  const n = Number(value);
514
528
  if (!Number.isFinite(n) || n <= 0) return fallback;
@@ -531,7 +545,22 @@ var normalizeQuality = (value, type) => {
531
545
  if (rounded < 0 || rounded > 100) return void 0;
532
546
  return rounded;
533
547
  };
534
- var resolvePageDevice = (page) => normalizeDevice(page?.[PageRuntimeStateKey]?.device);
548
+ var resolvePageDevice = async (page) => {
549
+ const declared = normalizeDevice(page?.[PageRuntimeStateKey]?.device);
550
+ if (declared === Device.Mobile) return Device.Mobile;
551
+ const viewport = await resolveCurrentViewportSize(page).catch(() => null);
552
+ const viewportWidth = Math.round(Number(viewport?.width) || 0);
553
+ if (viewportWidth > 0 && viewportWidth <= MOBILE_VIEWPORT_WIDTH_THRESHOLD) {
554
+ return Device.Mobile;
555
+ }
556
+ return declared;
557
+ };
558
+ var resolveStitchMode = (options = {}) => {
559
+ const raw = options.stitch ?? options.stitching ?? options.strategy;
560
+ if (raw === false || raw === "off" || raw === "none" || raw === "single") return "off";
561
+ if (raw === true || raw === "force" || raw === "stitched") return "force";
562
+ return "auto";
563
+ };
535
564
  var buildFullPageClip = (metrics, viewport, maxClipHeight) => {
536
565
  const contentSize = metrics && typeof metrics === "object" ? metrics.contentSize || null : null;
537
566
  const width = Math.max(1, Math.ceil(viewport.width || contentSize?.width || 1));
@@ -878,10 +907,156 @@ var restoreAffixedElementsForExpandedScreenshot = async (page) => {
878
907
  });
879
908
  }, EXPANDED_SCROLLABLE_CLASS);
880
909
  };
910
+ var measureMeaningfulContentBounds = async (page) => {
911
+ return await page.evaluate(() => {
912
+ const body = document.body;
913
+ if (!body) return null;
914
+ const viewportWidth = window.innerWidth || document.documentElement?.clientWidth || body.clientWidth || 0;
915
+ const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || body.clientHeight || 0;
916
+ const scrollX = window.scrollX || window.pageXOffset || 0;
917
+ const scrollY = window.scrollY || window.pageYOffset || 0;
918
+ const bounds = {
919
+ left: Number.POSITIVE_INFINITY,
920
+ top: Number.POSITIVE_INFINITY,
921
+ right: 0,
922
+ bottom: 0,
923
+ nodes: 0
924
+ };
925
+ const isVisible = (el, style, rect) => {
926
+ if (!el || !style || !rect) return false;
927
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
928
+ if (Number(style.opacity) === 0) return false;
929
+ return rect.width > 1 && rect.height > 1;
930
+ };
931
+ const hasFixedAncestor = (el) => {
932
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
933
+ const position = String(window.getComputedStyle(node).position || "").toLowerCase();
934
+ if (position === "fixed") {
935
+ return true;
936
+ }
937
+ }
938
+ return false;
939
+ };
940
+ const parseRgbColor = (value) => {
941
+ const raw = String(value || "").trim();
942
+ const match = raw.match(/rgba?\(([^)]+)\)/i);
943
+ if (!match) return null;
944
+ const parts = match[1].replace(/\//g, " ").split(/[,\s]+/).map((part) => part.trim()).filter(Boolean);
945
+ if (parts.length < 3) return null;
946
+ const normalizeChannel = (part) => {
947
+ if (part.endsWith("%")) {
948
+ return Math.max(0, Math.min(255, Number.parseFloat(part) * 2.55));
949
+ }
950
+ return Math.max(0, Math.min(255, Number.parseFloat(part)));
951
+ };
952
+ const alpha = parts.length >= 4 ? parts[3].endsWith("%") ? Number.parseFloat(parts[3]) / 100 : Number.parseFloat(parts[3]) : 1;
953
+ return {
954
+ r: normalizeChannel(parts[0]),
955
+ g: normalizeChannel(parts[1]),
956
+ b: normalizeChannel(parts[2]),
957
+ a: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1
958
+ };
959
+ };
960
+ const colorLuminance = (color) => {
961
+ const channel = (value) => {
962
+ const ratio = Math.max(0, Math.min(255, value)) / 255;
963
+ return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
964
+ };
965
+ return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
966
+ };
967
+ const contrastRatio = (a, b) => {
968
+ const l1 = colorLuminance(a);
969
+ const l2 = colorLuminance(b);
970
+ const lighter = Math.max(l1, l2);
971
+ const darker = Math.min(l1, l2);
972
+ return (lighter + 0.05) / (darker + 0.05);
973
+ };
974
+ const resolveBackgroundColor = (el) => {
975
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
976
+ const background = parseRgbColor(window.getComputedStyle(node).backgroundColor);
977
+ if (background && background.a > 0.2) {
978
+ return background;
979
+ }
980
+ }
981
+ return { r: 255, g: 255, b: 255, a: 1 };
982
+ };
983
+ const isLowInformationTextPaint = (el, style) => {
984
+ const color = parseRgbColor(style?.color);
985
+ if (!color) return false;
986
+ const opacity = Number(style.opacity);
987
+ if (color.a <= 0.08 || Number.isFinite(opacity) && opacity <= 0.18) {
988
+ return true;
989
+ }
990
+ const max = Math.max(color.r, color.g, color.b);
991
+ const min = Math.min(color.r, color.g, color.b);
992
+ if (!(min >= 224 && max - min <= 24 && color.a >= 0.85)) {
993
+ return false;
994
+ }
995
+ const background = resolveBackgroundColor(el);
996
+ return colorLuminance(background) > 0.76 && contrastRatio(color, background) < 1.35;
997
+ };
998
+ const addRect = (rect) => {
999
+ if (!rect || rect.width <= 1 || rect.height <= 1) return;
1000
+ const left = Math.max(0, Math.floor(rect.left + scrollX));
1001
+ const top = Math.max(0, Math.floor(rect.top + scrollY));
1002
+ const right = Math.ceil(rect.right + scrollX);
1003
+ const bottom = Math.ceil(rect.bottom + scrollY);
1004
+ if (right <= left || bottom <= top) return;
1005
+ bounds.left = Math.min(bounds.left, left);
1006
+ bounds.top = Math.min(bounds.top, top);
1007
+ bounds.right = Math.max(bounds.right, right);
1008
+ bounds.bottom = Math.max(bounds.bottom, bottom);
1009
+ bounds.nodes += 1;
1010
+ };
1011
+ const addElement = (el, { minArea = 12, text = false } = {}) => {
1012
+ if (!el || el.nodeType !== 1 || hasFixedAncestor(el)) return;
1013
+ const style = window.getComputedStyle(el);
1014
+ if (text && isLowInformationTextPaint(el, style)) return;
1015
+ const rects = Array.from(el.getClientRects ? el.getClientRects() : []);
1016
+ rects.forEach((rect) => {
1017
+ if (!isVisible(el, style, rect)) return;
1018
+ if (rect.width * rect.height < minArea) return;
1019
+ addRect(rect);
1020
+ });
1021
+ };
1022
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
1023
+ let visitedTextNodes = 0;
1024
+ while (walker.nextNode() && visitedTextNodes < 6e3) {
1025
+ const node = walker.currentNode;
1026
+ visitedTextNodes += 1;
1027
+ const text = String(node.nodeValue || "").replace(/\s+/g, " ").trim();
1028
+ if (text.length < 2) continue;
1029
+ addElement(node.parentElement, { minArea: 8, text: true });
1030
+ }
1031
+ document.querySelectorAll("img,video,canvas,svg,picture").forEach((el) => {
1032
+ addElement(el, { minArea: 900 });
1033
+ });
1034
+ if (!bounds.nodes || !Number.isFinite(bounds.left)) return null;
1035
+ const paddingX = Math.max(16, Math.min(96, viewportWidth * 0.04));
1036
+ const paddingY = Math.max(24, Math.min(160, viewportHeight * 0.18));
1037
+ return {
1038
+ left: Math.max(0, Math.floor(bounds.left - paddingX)),
1039
+ top: Math.max(0, Math.floor(bounds.top - paddingY)),
1040
+ right: Math.ceil(bounds.right + paddingX),
1041
+ bottom: Math.ceil(bounds.bottom + paddingY),
1042
+ width: Math.max(1, Math.ceil(bounds.right - bounds.left)),
1043
+ height: Math.max(1, Math.ceil(bounds.bottom - bounds.top)),
1044
+ nodes: bounds.nodes,
1045
+ viewport: {
1046
+ width: Math.max(1, Math.ceil(viewportWidth || 1)),
1047
+ height: Math.max(1, Math.ceil(viewportHeight || 1))
1048
+ }
1049
+ };
1050
+ }).catch((error) => {
1051
+ logger.warning(`\u622A\u56FE\u5185\u5BB9\u8FB9\u754C\u6D4B\u91CF\u5931\u8D25: ${error?.message || error}`);
1052
+ return null;
1053
+ });
1054
+ };
881
1055
  var prepareExpandedFullPageScreenshot = async (page, options = {}) => {
882
1056
  const originalViewport = await resolveCurrentViewportSize(page);
883
1057
  const maxHeight = toPositiveInteger(options.maxHeight, DEFAULT_MAX_HEIGHT);
884
- const preserveViewport = options.preserveViewport ?? resolvePageDevice(page) === Device.Mobile;
1058
+ const device = await resolvePageDevice(page);
1059
+ const preserveViewport = options.preserveViewport ?? device === Device.Mobile;
885
1060
  const defaultSettleMs = preserveViewport ? DEFAULT_MOBILE_SETTLE_MS : DEFAULT_SETTLE_MS;
886
1061
  const requestedSettleMs = Math.max(0, Number(options.settleMs ?? defaultSettleMs) || 0);
887
1062
  const settleMs = preserveViewport ? Math.min(requestedSettleMs, DEFAULT_MOBILE_SETTLE_MS) : requestedSettleMs;
@@ -890,7 +1065,21 @@ var prepareExpandedFullPageScreenshot = async (page, options = {}) => {
890
1065
  visibleOnly: options.visibleOnly !== false,
891
1066
  expandDocumentElements: preserveViewport
892
1067
  });
893
- const targetHeight = Math.min(maxScrollHeight, maxHeight);
1068
+ const contentBounds = await measureMeaningfulContentBounds(page);
1069
+ let targetHeight = Math.min(maxScrollHeight, maxHeight);
1070
+ if (contentBounds?.bottom > 0) {
1071
+ const contentBottom = Math.min(Math.ceil(contentBounds.bottom), maxHeight);
1072
+ const trailingGap = targetHeight - contentBottom;
1073
+ const minTrailingGap = Math.max(
1074
+ MIN_TRAILING_BLANK_GAP_PX,
1075
+ Math.floor(targetHeight * MIN_TRAILING_BLANK_GAP_RATIO),
1076
+ Math.floor(originalViewport.height * 0.45)
1077
+ );
1078
+ if (trailingGap >= minTrailingGap && contentBottom >= originalViewport.height * 0.65) {
1079
+ targetHeight = Math.max(1, contentBottom);
1080
+ logger.info(`\u957F\u622A\u56FE\u88C1\u6389\u4F4E\u4FE1\u606F\u5C3E\u90E8: contentBottom=${contentBottom}, originalHeight=${Math.min(maxScrollHeight, maxHeight)}`);
1081
+ }
1082
+ }
894
1083
  let viewportResized = false;
895
1084
  if (!preserveViewport) {
896
1085
  await page.setViewportSize({
@@ -910,6 +1099,7 @@ var prepareExpandedFullPageScreenshot = async (page, options = {}) => {
910
1099
  originalViewport,
911
1100
  maxScrollHeight,
912
1101
  targetHeight,
1102
+ contentBounds,
913
1103
  preserveViewport,
914
1104
  viewportResized
915
1105
  };
@@ -942,6 +1132,436 @@ var restoreExpandedFullPageScreenshot = async (page, state2 = {}) => {
942
1132
  await page.setViewportSize(state2.originalViewport);
943
1133
  }
944
1134
  };
1135
+ var resolveVirtualizedScrollTarget = async (page, options = {}) => {
1136
+ const stitchMode = resolveStitchMode(options);
1137
+ if (stitchMode === "off") return null;
1138
+ const device = await resolvePageDevice(page);
1139
+ if (stitchMode !== "force" && device !== Device.Mobile) {
1140
+ return null;
1141
+ }
1142
+ return await page.evaluate((config) => {
1143
+ const viewportWidth = window.innerWidth || document.documentElement?.clientWidth || document.body?.clientWidth || 0;
1144
+ const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || document.body?.clientHeight || 0;
1145
+ const root = document.documentElement;
1146
+ const body = document.body;
1147
+ const scrollingElement = document.scrollingElement;
1148
+ const candidates = [];
1149
+ const seen = /* @__PURE__ */ new Set();
1150
+ const scrollableOverflow = /* @__PURE__ */ new Set(["auto", "scroll", "overlay"]);
1151
+ const clippingOverflow = /* @__PURE__ */ new Set(["hidden", "clip"]);
1152
+ const pushCandidate = (el2) => {
1153
+ if (!el2 || seen.has(el2)) return;
1154
+ seen.add(el2);
1155
+ candidates.push(el2);
1156
+ };
1157
+ pushCandidate(scrollingElement);
1158
+ pushCandidate(root);
1159
+ pushCandidate(body);
1160
+ document.querySelectorAll("*").forEach(pushCandidate);
1161
+ const isDocumentElement = (el2) => el2 === root || el2 === body || el2 === scrollingElement;
1162
+ const isVisible = (el2, style, rect) => {
1163
+ if (!el2 || !style || !rect) return false;
1164
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
1165
+ if (Number(style.opacity) === 0) return false;
1166
+ return rect.width > 0 && rect.height > 0;
1167
+ };
1168
+ const hasOverflowValue = (style, values) => {
1169
+ const overflowY = String(style?.overflowY || "").toLowerCase();
1170
+ const overflow = String(style?.overflow || "").toLowerCase();
1171
+ return values.has(overflowY) || values.has(overflow);
1172
+ };
1173
+ const looksScrollable = (el2, style) => {
1174
+ if (!el2 || el2.scrollHeight <= el2.clientHeight + 1) return false;
1175
+ if (isDocumentElement(el2)) return true;
1176
+ return hasOverflowValue(style, scrollableOverflow) || hasOverflowValue(style, clippingOverflow);
1177
+ };
1178
+ const textHeightFor = (el2) => {
1179
+ const nodes = isDocumentElement(el2) ? document.querySelectorAll("body *") : el2.querySelectorAll("*");
1180
+ let textHeight = 0;
1181
+ let textNodes = 0;
1182
+ const maxNodes = 3500;
1183
+ for (const node of nodes) {
1184
+ if (textNodes >= maxNodes) break;
1185
+ const text = String(node.textContent || "").replace(/\s+/g, " ").trim();
1186
+ if (text.length < 2) continue;
1187
+ let childHasText = false;
1188
+ for (const child of node.children || []) {
1189
+ if (String(child.textContent || "").replace(/\s+/g, " ").trim().length >= 2) {
1190
+ childHasText = true;
1191
+ break;
1192
+ }
1193
+ }
1194
+ if (childHasText) continue;
1195
+ const style = window.getComputedStyle(node);
1196
+ const rect = node.getBoundingClientRect();
1197
+ if (!isVisible(node, style, rect)) continue;
1198
+ textNodes += 1;
1199
+ textHeight += Math.min(Math.ceil(rect.height || 0), 900);
1200
+ }
1201
+ return { textHeight, textNodes };
1202
+ };
1203
+ const resolveEdgeOcclusion = () => {
1204
+ let top = 0;
1205
+ let bottom = 0;
1206
+ const maxHeight = Math.max(48, viewportHeight * 0.45);
1207
+ document.querySelectorAll("*").forEach((el2) => {
1208
+ const style = window.getComputedStyle(el2);
1209
+ const position = String(style.position || "").toLowerCase();
1210
+ if (position !== "fixed" && position !== "sticky") return;
1211
+ const rect = el2.getBoundingClientRect();
1212
+ if (!isVisible(el2, style, rect)) return;
1213
+ if (rect.height <= 0 || rect.height > maxHeight) return;
1214
+ if (rect.width < viewportWidth * 0.35) return;
1215
+ if (rect.top <= Math.max(16, viewportHeight * 0.08)) {
1216
+ top = Math.max(top, Math.ceil(rect.bottom));
1217
+ }
1218
+ if (rect.bottom >= viewportHeight - Math.max(16, viewportHeight * 0.08)) {
1219
+ bottom = Math.max(bottom, Math.ceil(viewportHeight - rect.top));
1220
+ }
1221
+ });
1222
+ return {
1223
+ top: Math.max(0, Math.min(Math.ceil(top), Math.floor(viewportHeight * 0.45))),
1224
+ bottom: Math.max(0, Math.min(Math.ceil(bottom), Math.floor(viewportHeight * 0.45)))
1225
+ };
1226
+ };
1227
+ let best = null;
1228
+ candidates.forEach((el2) => {
1229
+ const style = window.getComputedStyle(el2);
1230
+ const rect = isDocumentElement(el2) ? {
1231
+ top: 0,
1232
+ bottom: viewportHeight,
1233
+ left: 0,
1234
+ right: viewportWidth,
1235
+ width: viewportWidth,
1236
+ height: viewportHeight
1237
+ } : el2.getBoundingClientRect();
1238
+ if (!isDocumentElement(el2) && !isVisible(el2, style, rect)) return;
1239
+ if (!looksScrollable(el2, style)) return;
1240
+ const scrollHeight = Math.ceil(el2.scrollHeight || 0);
1241
+ const clientHeight = Math.ceil(el2.clientHeight || viewportHeight || 0);
1242
+ if (scrollHeight < Math.max(900, clientHeight * config.minScrollRatio)) return;
1243
+ const visibleTop = Math.max(0, Math.round(rect.top || 0));
1244
+ const visibleBottom = Math.min(viewportHeight, Math.round(rect.bottom || viewportHeight));
1245
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
1246
+ const minVisibleHeight = Math.max(
1247
+ config.minVisibleHeightPx,
1248
+ Math.floor(viewportHeight * config.minVisibleHeightRatio)
1249
+ );
1250
+ if (!config.force && !isDocumentElement(el2) && visibleHeight < minVisibleHeight) return;
1251
+ const { textHeight, textNodes } = textHeightFor(el2);
1252
+ const sparseRatio = scrollHeight / Math.max(1, textHeight);
1253
+ const sparse = config.force || textNodes > 0 && sparseRatio >= config.minSparseRatio && scrollHeight - textHeight > clientHeight;
1254
+ if (!sparse) return;
1255
+ const visibleWidth = Math.max(1, Math.ceil(rect.width || viewportWidth || 1));
1256
+ const score2 = scrollHeight * Math.min(visibleWidth, viewportWidth || visibleWidth) * Math.min(1, visibleHeight / Math.max(1, viewportHeight));
1257
+ if (!best || score2 > best.score) {
1258
+ best = {
1259
+ el: el2,
1260
+ score: score2,
1261
+ kind: isDocumentElement(el2) ? "document" : "element",
1262
+ scrollHeight,
1263
+ clientHeight,
1264
+ scrollTop: Math.max(0, Math.round(el2.scrollTop || 0)),
1265
+ rect: {
1266
+ top: Math.max(0, Math.round(rect.top || 0)),
1267
+ bottom: Math.min(viewportHeight, Math.round(rect.bottom || viewportHeight)),
1268
+ left: Math.max(0, Math.round(rect.left || 0)),
1269
+ width: Math.max(1, Math.round(rect.width || viewportWidth || 1)),
1270
+ height: Math.max(1, Math.round(rect.height || viewportHeight || 1))
1271
+ },
1272
+ visibleHeight,
1273
+ textHeight,
1274
+ textNodes,
1275
+ sparseRatio
1276
+ };
1277
+ }
1278
+ });
1279
+ if (!best) return null;
1280
+ document.querySelectorAll(`[${config.attrName}="1"]`).forEach((node) => {
1281
+ node.removeAttribute(config.attrName);
1282
+ });
1283
+ if (best.kind === "element") {
1284
+ best.el.setAttribute(config.attrName, "1");
1285
+ }
1286
+ const { el, score, ...target } = best;
1287
+ return {
1288
+ ...target,
1289
+ viewport: {
1290
+ width: Math.max(1, Math.ceil(viewportWidth || best.rect.width || 1)),
1291
+ height: Math.max(1, Math.ceil(viewportHeight || best.rect.height || 1))
1292
+ },
1293
+ edgeOcclusion: resolveEdgeOcclusion()
1294
+ };
1295
+ }, {
1296
+ attrName: STITCH_SCROLL_TARGET_ATTR,
1297
+ force: stitchMode === "force",
1298
+ minScrollRatio: MIN_VIRTUALIZED_SCROLL_RATIO,
1299
+ minSparseRatio: MIN_SPARSE_SCROLL_RATIO,
1300
+ minVisibleHeightPx: MIN_STITCH_VISIBLE_HEIGHT_PX,
1301
+ minVisibleHeightRatio: MIN_STITCH_VISIBLE_HEIGHT_RATIO
1302
+ }).catch((error) => {
1303
+ logger.warning(`\u865A\u62DF\u6EDA\u52A8\u622A\u56FE\u63A2\u6D4B\u5931\u8D25: ${error?.message || error}`);
1304
+ return null;
1305
+ });
1306
+ };
1307
+ var setStitchScrollTop = async (page, target, scrollTop) => {
1308
+ await page.evaluate(({ attrName, kind, top }) => {
1309
+ const el = kind === "document" ? document.scrollingElement || document.documentElement || document.body : document.querySelector(`[${attrName}="1"]`);
1310
+ if (!el) return;
1311
+ el.scrollTop = Math.max(0, Math.round(Number(top) || 0));
1312
+ el.dispatchEvent(new Event("scroll", { bubbles: true }));
1313
+ window.dispatchEvent(new Event("scroll"));
1314
+ }, {
1315
+ attrName: STITCH_SCROLL_TARGET_ATTR,
1316
+ kind: target.kind,
1317
+ top: scrollTop
1318
+ });
1319
+ };
1320
+ var cleanupStitchTarget = async (page) => {
1321
+ await page.evaluate((attrName) => {
1322
+ document.querySelectorAll(`[${attrName}="1"]`).forEach((node) => {
1323
+ node.removeAttribute(attrName);
1324
+ });
1325
+ }, STITCH_SCROLL_TARGET_ATTR).catch(() => {
1326
+ });
1327
+ };
1328
+ var cropImage = (image, crop) => image.clone().crop({
1329
+ x: Math.max(0, Math.round(crop.x || 0)),
1330
+ y: Math.max(0, Math.round(crop.y || 0)),
1331
+ w: Math.max(1, Math.round(crop.w || 1)),
1332
+ h: Math.max(1, Math.round(crop.h || 1))
1333
+ });
1334
+ var captureStitchedScrollableScreenshot = async (page, target, options = {}) => {
1335
+ const maxHeight = toPositiveInteger(options.maxHeight, DEFAULT_MAX_HEIGHT);
1336
+ const settleMs = Math.max(0, Number(options.stitchSettleMs ?? DEFAULT_STITCH_SETTLE_MS) || 0);
1337
+ const overlapPx = Math.max(0, Math.round(Number(options.stitchOverlapPx ?? DEFAULT_STITCH_OVERLAP_PX) || 0));
1338
+ const viewport = target.viewport || await resolveCurrentViewportSize(page);
1339
+ const rect = target.rect || {
1340
+ top: 0,
1341
+ bottom: viewport.height,
1342
+ left: 0,
1343
+ width: viewport.width,
1344
+ height: viewport.height
1345
+ };
1346
+ const edge = target.edgeOcclusion || { top: 0, bottom: 0 };
1347
+ const topCrop = Math.max(0, Math.min(rect.top, viewport.height - 1));
1348
+ const topOverlay = Math.max(topCrop, Math.min(edge.top || 0, viewport.height - 1));
1349
+ const bottomOverlay = Math.max(0, Math.min(edge.bottom || 0, viewport.height - topOverlay - 1));
1350
+ const middleCropY = Math.max(topCrop, topOverlay);
1351
+ const middleCropBottom = Math.max(
1352
+ middleCropY + 1,
1353
+ Math.min(rect.bottom || viewport.height, viewport.height - bottomOverlay)
1354
+ );
1355
+ const middleCropHeight = Math.max(1, middleCropBottom - middleCropY);
1356
+ const scrollStep = Math.max(120, middleCropHeight - overlapPx);
1357
+ const maxScrollTop = Math.max(0, Math.ceil(target.scrollHeight - target.clientHeight));
1358
+ const positions = [];
1359
+ for (let top = 0; top < maxScrollTop; top += scrollStep) {
1360
+ positions.push(Math.round(top));
1361
+ }
1362
+ if (!positions.includes(maxScrollTop)) {
1363
+ positions.push(maxScrollTop);
1364
+ }
1365
+ if (positions.length === 0) {
1366
+ positions.push(0);
1367
+ }
1368
+ const segments = [];
1369
+ let totalHeight = 0;
1370
+ let outputWidth = Math.max(1, Math.round(viewport.width || rect.width || 1));
1371
+ try {
1372
+ for (let index = 0; index < positions.length && totalHeight < maxHeight; index += 1) {
1373
+ const isFirst = index === 0;
1374
+ const isLast = index === positions.length - 1;
1375
+ await setStitchScrollTop(page, target, positions[index]);
1376
+ if (settleMs > 0) {
1377
+ await delay(settleMs);
1378
+ }
1379
+ const buffer = await capturePageScreenshot(page, {
1380
+ type: options.type || "png",
1381
+ quality: options.quality,
1382
+ timeout: options.timeout
1383
+ });
1384
+ const image = await Jimp.read(buffer);
1385
+ outputWidth = Math.min(outputWidth, image.bitmap.width);
1386
+ const cropY = isFirst ? 0 : middleCropY;
1387
+ const cropBottom = isLast ? image.bitmap.height : middleCropBottom;
1388
+ let cropHeight = Math.max(1, Math.min(image.bitmap.height, cropBottom) - cropY);
1389
+ const remaining = maxHeight - totalHeight;
1390
+ if (cropHeight > remaining) {
1391
+ cropHeight = remaining;
1392
+ }
1393
+ segments.push(cropImage(image, {
1394
+ x: 0,
1395
+ y: cropY,
1396
+ w: outputWidth,
1397
+ h: cropHeight
1398
+ }));
1399
+ totalHeight += cropHeight;
1400
+ }
1401
+ const canvas = new Jimp({
1402
+ width: outputWidth,
1403
+ height: Math.max(1, totalHeight),
1404
+ color: 4294967295
1405
+ });
1406
+ let y = 0;
1407
+ for (const segment of segments) {
1408
+ canvas.composite(segment, 0, y);
1409
+ y += segment.bitmap.height;
1410
+ }
1411
+ logger.info(
1412
+ `\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)}`
1413
+ );
1414
+ const expectedHeight = Math.min(maxHeight, Math.max(target.scrollHeight || 0, viewport.height || 0));
1415
+ const minPlausibleHeight = Math.max(
1416
+ Math.floor((viewport.height || rect.height || 0) * MIN_STITCH_OUTPUT_VIEWPORT_RATIO),
1417
+ Math.floor(expectedHeight * MIN_STITCH_OUTPUT_HEIGHT_RATIO)
1418
+ );
1419
+ if (positions.length > 1 && expectedHeight > (viewport.height || rect.height || 0) * 1.3 && totalHeight < minPlausibleHeight) {
1420
+ logger.warning(
1421
+ `\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}`
1422
+ );
1423
+ return null;
1424
+ }
1425
+ return await canvas.getBuffer(JimpMime.png);
1426
+ } finally {
1427
+ await setStitchScrollTop(page, target, target.scrollTop || 0).catch(() => {
1428
+ });
1429
+ await cleanupStitchTarget(page);
1430
+ }
1431
+ };
1432
+ var isLightBlankPixel = ({ r, g, b }) => {
1433
+ const max = Math.max(r, g, b);
1434
+ const min = Math.min(r, g, b);
1435
+ return max >= 238 && max - min <= 18;
1436
+ };
1437
+ var isDarkBlankPixel = ({ r, g, b }) => {
1438
+ const max = Math.max(r, g, b);
1439
+ const min = Math.min(r, g, b);
1440
+ return max <= 34 && max - min <= 10;
1441
+ };
1442
+ var isLowInfoPixel = ({ r, g, b }) => {
1443
+ const max = Math.max(r, g, b);
1444
+ const min = Math.min(r, g, b);
1445
+ return max - min <= 12;
1446
+ };
1447
+ var analyzeScreenshotBuffer = async (buffer) => {
1448
+ const image = await Jimp.read(buffer);
1449
+ const width = image.bitmap.width;
1450
+ const height = image.bitmap.height;
1451
+ const stepY = Math.max(1, Math.floor(height / 900));
1452
+ const stepX = Math.max(1, Math.floor(width / 420));
1453
+ const rows = [];
1454
+ let lightRows = 0;
1455
+ let darkRows = 0;
1456
+ let lowInfoRows = 0;
1457
+ let loadingLikeRows = 0;
1458
+ const rowFlags = [];
1459
+ for (let y = 0; y < height; y += stepY) {
1460
+ let light = 0;
1461
+ let dark = 0;
1462
+ let lowInfo = 0;
1463
+ let edge = 0;
1464
+ let count = 0;
1465
+ let prev = null;
1466
+ for (let x = 0; x < width; x += stepX) {
1467
+ const rgba = intToRGBA(image.getPixelColor(x, y));
1468
+ count += 1;
1469
+ if (isLightBlankPixel(rgba)) light += 1;
1470
+ if (isDarkBlankPixel(rgba)) dark += 1;
1471
+ if (isLowInfoPixel(rgba)) lowInfo += 1;
1472
+ if (prev) {
1473
+ const diff = Math.abs(rgba.r - prev.r) + Math.abs(rgba.g - prev.g) + Math.abs(rgba.b - prev.b);
1474
+ if (diff > 45) edge += 1;
1475
+ }
1476
+ prev = rgba;
1477
+ }
1478
+ const stat = {
1479
+ y,
1480
+ lightRatio: light / Math.max(1, count),
1481
+ darkRatio: dark / Math.max(1, count),
1482
+ lowInfoRatio: lowInfo / Math.max(1, count),
1483
+ edgeRatio: edge / Math.max(1, count - 1)
1484
+ };
1485
+ rows.push(stat);
1486
+ const lightBlank = stat.lightRatio >= 0.965 && stat.edgeRatio <= 0.015;
1487
+ const darkBlank = stat.darkRatio >= 0.965 && stat.edgeRatio <= 0.012;
1488
+ const lowInfoBlank = stat.lowInfoRatio >= 0.965 && stat.edgeRatio <= 0.012;
1489
+ const loadingLike = stat.darkRatio >= 0.88 && stat.edgeRatio <= 0.025;
1490
+ if (lightBlank) lightRows += 1;
1491
+ if (darkBlank) darkRows += 1;
1492
+ if (lowInfoBlank) lowInfoRows += 1;
1493
+ if (loadingLike) loadingLikeRows += 1;
1494
+ rowFlags.push({ lightBlank, darkBlank, lowInfoBlank, loadingLike });
1495
+ }
1496
+ const sampledRows = Math.max(1, rows.length);
1497
+ return {
1498
+ image,
1499
+ width,
1500
+ height,
1501
+ lightBlankRowsRatio: lightRows / sampledRows,
1502
+ darkBlankRowsRatio: darkRows / sampledRows,
1503
+ lowInfoRowsRatio: lowInfoRows / sampledRows,
1504
+ loadingLikeRowsRatio: loadingLikeRows / sampledRows,
1505
+ rowFlags
1506
+ };
1507
+ };
1508
+ var resolveScreenshotQualityIssue = (analysis) => {
1509
+ if (!analysis) return null;
1510
+ if (analysis.loadingLikeRowsRatio >= 0.92 || analysis.darkBlankRowsRatio >= 0.72) {
1511
+ return "loading-like-dark-screenshot";
1512
+ }
1513
+ return null;
1514
+ };
1515
+ var cropBufferToContentBounds = async (buffer, bounds, options = {}) => {
1516
+ if (!bounds || bounds.nodes <= 0) return buffer;
1517
+ const image = options.image || await Jimp.read(buffer);
1518
+ const width = image.bitmap.width;
1519
+ const height = image.bitmap.height;
1520
+ const safeLeft = Math.max(0, Math.min(width - 1, Math.floor(bounds.left || 0)));
1521
+ const safeRight = Math.max(safeLeft + 1, Math.min(width, Math.ceil(bounds.right || width)));
1522
+ const safeTop = Math.max(0, Math.min(height - 1, Math.floor(bounds.top || 0)));
1523
+ const safeBottom = Math.max(safeTop + 1, Math.min(height, Math.ceil(bounds.bottom || height)));
1524
+ const cropW = safeRight - safeLeft;
1525
+ const cropH = safeBottom - safeTop;
1526
+ const shouldCropX = cropW > 80 && cropW <= width * 0.82 && (safeLeft > width * 0.04 || width - safeRight > width * 0.04);
1527
+ const shouldCropY = cropH > 160 && cropH <= height * 0.88 && height - safeBottom > Math.max(320, height * 0.1);
1528
+ if (!shouldCropX && !shouldCropY) {
1529
+ return buffer;
1530
+ }
1531
+ const x = shouldCropX ? safeLeft : 0;
1532
+ const y = shouldCropY ? safeTop : 0;
1533
+ const w = shouldCropX ? cropW : width;
1534
+ const h = shouldCropY ? cropH : height;
1535
+ logger.info(`\u5185\u5BB9\u611F\u77E5\u88C1\u526A\u622A\u56FE: x=${x}, y=${y}, w=${w}, h=${h}, original=${width}x${height}`);
1536
+ return await cropImage(image, { x, y, w, h }).getBuffer(JimpMime.png);
1537
+ };
1538
+ var captureExpandedFullPageScreenshotOnce = async (page, options = {}) => {
1539
+ const stitchedTarget = await resolveVirtualizedScrollTarget(page, options);
1540
+ if (stitchedTarget) {
1541
+ const buffer = await captureStitchedScrollableScreenshot(page, stitchedTarget, options);
1542
+ if (buffer) {
1543
+ return buffer;
1544
+ }
1545
+ }
1546
+ const state2 = await prepareExpandedFullPageScreenshot(page, options);
1547
+ try {
1548
+ const buffer = await capturePageScreenshot(page, {
1549
+ fullPage: true,
1550
+ type: options.type || "png",
1551
+ quality: options.quality,
1552
+ timeout: options.timeout,
1553
+ maxClipHeight: state2.targetHeight
1554
+ });
1555
+ return await cropBufferToContentBounds(buffer, state2.contentBounds);
1556
+ } finally {
1557
+ await restoreAffixedElementsForExpandedScreenshot(page).catch((error) => {
1558
+ logger.warning(`\u79FB\u52A8\u7AEF\u5438\u9644\u5143\u7D20\u6062\u590D\u5931\u8D25: ${error?.message || error}`);
1559
+ });
1560
+ if (options.restore) {
1561
+ await restoreExpandedFullPageScreenshot(page, state2);
1562
+ }
1563
+ }
1564
+ };
945
1565
  var capturePageScreenshot = async (page, options = {}) => {
946
1566
  const fullPage = Boolean(options.fullPage);
947
1567
  const type = fullPage ? FORCED_FULLPAGE_TYPE : normalizeType(options.type);
@@ -996,23 +1616,35 @@ var capturePageScreenshot = async (page, options = {}) => {
996
1616
  }
997
1617
  };
998
1618
  var captureExpandedFullPageScreenshot = async (page, options = {}) => {
999
- const state2 = await prepareExpandedFullPageScreenshot(page, options);
1000
- try {
1001
- return await capturePageScreenshot(page, {
1002
- fullPage: true,
1003
- type: options.type || "png",
1004
- quality: options.quality,
1005
- timeout: options.timeout,
1006
- maxClipHeight: state2.targetHeight
1007
- });
1008
- } finally {
1009
- await restoreAffixedElementsForExpandedScreenshot(page).catch((error) => {
1010
- logger.warning(`\u79FB\u52A8\u7AEF\u5438\u9644\u5143\u7D20\u6062\u590D\u5931\u8D25: ${error?.message || error}`);
1619
+ const attempts = Math.max(1, toPositiveInteger(options.qualityRetryAttempts, DEFAULT_QUALITY_RETRY_ATTEMPTS));
1620
+ const retryDelayMs = Math.max(0, Number(options.qualityRetryDelayMs ?? DEFAULT_QUALITY_RETRY_DELAY_MS) || 0);
1621
+ let lastBuffer = null;
1622
+ let lastAnalysis = null;
1623
+ let lastIssue = null;
1624
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1625
+ const buffer = await captureExpandedFullPageScreenshotOnce(page, options);
1626
+ const analysis = await analyzeScreenshotBuffer(buffer).catch((error) => {
1627
+ logger.warning(`\u622A\u56FE\u8D28\u91CF\u5206\u6790\u5931\u8D25: ${error?.message || error}`);
1628
+ return null;
1011
1629
  });
1012
- if (options.restore) {
1013
- await restoreExpandedFullPageScreenshot(page, state2);
1630
+ const issue = resolveScreenshotQualityIssue(analysis);
1631
+ lastBuffer = buffer;
1632
+ lastAnalysis = analysis;
1633
+ lastIssue = issue;
1634
+ if (!issue) {
1635
+ return buffer;
1636
+ }
1637
+ if (attempt < attempts && retryDelayMs > 0) {
1638
+ logger.warning(`\u622A\u56FE\u7591\u4F3C\u5F02\u5E38(${issue})\uFF0C\u7B49\u5F85\u540E\u91CD\u8BD5: attempt=${attempt}/${attempts}`);
1639
+ await delay(retryDelayMs);
1014
1640
  }
1015
1641
  }
1642
+ if (lastIssue && lastAnalysis) {
1643
+ logger.warning(
1644
+ `\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)}`
1645
+ );
1646
+ }
1647
+ return lastBuffer;
1016
1648
  };
1017
1649
 
1018
1650
  // src/errors.js
@@ -9696,7 +10328,7 @@ var watermarkifyScreenshotBuffer = async (buffer, meta, page = null, options = {
9696
10328
  };
9697
10329
 
9698
10330
  // src/internals/compression.js
9699
- import { Jimp, JimpMime, ResizeStrategy } from "jimp";
10331
+ import { Jimp as Jimp2, JimpMime as JimpMime2, ResizeStrategy } from "jimp";
9700
10332
  var logger15 = createInternalLogger("Compression");
9701
10333
  var DEFAULT_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
9702
10334
  var DEFAULT_SCREENSHOT_OUTPUT_TYPE = "jpeg";
@@ -9766,7 +10398,7 @@ var encodeJpeg = async (sourceImage, compression, scale, quality) => {
9766
10398
  mode: ResizeStrategy.BILINEAR
9767
10399
  });
9768
10400
  }
9769
- const buffer = await image.getBuffer(JimpMime.jpeg, { quality });
10401
+ const buffer = await image.getBuffer(JimpMime2.jpeg, { quality });
9770
10402
  return {
9771
10403
  buffer,
9772
10404
  bytes: getBase64BytesFromBuffer(buffer),
@@ -9778,7 +10410,7 @@ var encodeJpeg = async (sourceImage, compression, scale, quality) => {
9778
10410
  };
9779
10411
  };
9780
10412
  var compressImageBuffer = async (buffer, compression) => {
9781
- const sourceImage = await Jimp.read(buffer);
10413
+ const sourceImage = await Jimp2.read(buffer);
9782
10414
  const maxQuality = toJpegQuality(compression.quality);
9783
10415
  const minQuality = Math.min(maxQuality, toJpegQuality(compression.minQuality));
9784
10416
  let quality = maxQuality;