@xbrowser/cli 1.9.7 → 1.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{anti-bot-MCVM6IFB.js → anti-bot-DR56Y63V.js} +1 -1
- package/dist/{browser-JXIJF4VL.js → browser-FFYPFTBV.js} +1 -1
- package/dist/{browser-ALPLJNH2.js → browser-RHWUAMTB.js} +2 -2
- package/dist/{browser-ZPQP6VW7.js → browser-YCO4GJMZ.js} +2 -2
- package/dist/{cdp-driver-MI7VCE44.js → cdp-driver-2T4P4ZE2.js} +103 -2
- package/dist/{cdp-driver-6EVYLHL2.js → cdp-driver-J3YQ4LJV.js} +1 -1
- package/dist/chunk-3OD76SUE.js +20 -0
- package/dist/chunk-43TEMG4U.js +9 -0
- package/dist/{chunk-CH5ZQV64.js → chunk-4452SPFI.js} +928 -18
- package/dist/{chunk-C4O73DQL.js → chunk-5UGR6MUK.js} +928 -18
- package/dist/{chunk-37SHZSUR.js → chunk-6OZWKXSW.js} +15 -16
- package/dist/{chunk-7ZDWM6T2.js → chunk-BNO7OKO4.js} +5 -6
- package/dist/{chunk-24SY6PE5.js → chunk-CG3D3CHY.js} +1 -8
- package/dist/{chunk-ZJHQPMCY.js → chunk-DWDXEGVK.js} +5 -6
- package/dist/{chunk-74S7UUI2.js → chunk-GUTBIYFW.js} +117 -17
- package/dist/{chunk-DPJNCMLC.js → chunk-INTQPBYF.js} +7 -0
- package/dist/{human-interaction-OU7LZ6OZ.js → chunk-MWFHZUIY.js} +6 -7
- package/dist/chunk-Q2DFJQGS.js +128 -0
- package/dist/{chunk-VJNMAWPZ.js → chunk-RRBXV7KE.js} +103 -2
- package/dist/{chunk-5JSOGINQ.js → chunk-RZM5CNIY.js} +15 -16
- package/dist/{human-interaction-HJTXFVQS.js → chunk-SQHSZENE.js} +2 -2
- package/dist/chunk-TZPKFUBT.js +20 -0
- package/dist/{chunk-YAUG3NSI.js → chunk-YMUSHPU4.js} +16 -128
- package/dist/cli.js +41 -28
- package/dist/{daemon-client-4HJENICO.js → daemon-client-GKEPT4NY.js} +15 -3
- package/dist/{daemon-client-CMRAWXTN.js → daemon-client-O6BYVRXV.js} +4 -1
- package/dist/daemon-main.js +661 -70
- package/dist/human-interaction-5AO42MBA.js +8 -0
- package/dist/{human-interaction-2DZK4MW7.js → human-interaction-C5OEGXGO.js} +1 -1
- package/dist/human-interaction-ISXZTAYY.js +8 -0
- package/dist/index.d.ts +172 -2
- package/dist/index.js +173 -63
- package/dist/recovery-EF33LKRJ.js +77 -0
- package/dist/recovery-J2ISVGUL.js +77 -0
- package/dist/recovery-ZJVVHP7N.js +76 -0
- package/dist/{session-recorder-7GXI42FO.js → session-recorder-H3KEYU26.js} +1 -1
- package/dist/{session-recorder-RFOPX4SF.js → session-recorder-QRZMKFVL.js} +1 -1
- package/dist/{session-replayer-3HEIJBJ2.js → session-replayer-LJUC4TI7.js} +25 -8
- package/package.json +1 -1
|
@@ -431,6 +431,22 @@ function getSelectorGeneratorScript() {
|
|
|
431
431
|
/^semi-[a-z]{4,}/
|
|
432
432
|
];
|
|
433
433
|
var STABLE_ATTRS = ['data-testid', 'data-test-id', 'data-cy', 'data-qa', 'data-el'];
|
|
434
|
+
// Dynamic attrs known to change per page load (skip even if unique)
|
|
435
|
+
var UNSTABLE_ATTRS = [
|
|
436
|
+
'data-spm-anchor-id', 'data-reactid', 'data-reactroot',
|
|
437
|
+
'data-n-head', 'data-server-rendered',
|
|
438
|
+
];
|
|
439
|
+
// Patterns catching other dynamic attrs by name shape
|
|
440
|
+
var UNSTABLE_ATTR_PATTERNS = [
|
|
441
|
+
/^data-spm/, /^data-track/, /^data-log/, /^data-pf/, /^data-key$/, /^data-rbd/,
|
|
442
|
+
];
|
|
443
|
+
function isUnstableAttr(name) {
|
|
444
|
+
if (UNSTABLE_ATTRS.indexOf(name) !== -1) return true;
|
|
445
|
+
for (var i = 0; i < UNSTABLE_ATTR_PATTERNS.length; i++) {
|
|
446
|
+
if (UNSTABLE_ATTR_PATTERNS[i].test(name)) return true;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
434
450
|
var MAX_DEPTH = 5;
|
|
435
451
|
|
|
436
452
|
function isStableClass(cls) {
|
|
@@ -478,6 +494,8 @@ function getSelectorGeneratorScript() {
|
|
|
478
494
|
var a = el.attributes[ai];
|
|
479
495
|
if (!a.name.startsWith('data-')) continue;
|
|
480
496
|
if (STABLE_ATTRS.indexOf(a.name) !== -1) continue;
|
|
497
|
+
// Skip dynamic attrs (e.g. data-spm-anchor-id changes per page load)
|
|
498
|
+
if (isUnstableAttr(a.name)) continue;
|
|
481
499
|
if (!a.value || a.value.length > 60 || a.value.indexOf('http') === 0) continue;
|
|
482
500
|
var s = '[' + a.name + '="' + esc(a.value) + '"]';
|
|
483
501
|
if (isUnique(root, s)) return { selector: s, strategy: 'data-attr', confidence: 'high' };
|
|
@@ -782,8 +800,11 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
782
800
|
selector = uniqueSelector(el);
|
|
783
801
|
}
|
|
784
802
|
|
|
785
|
-
// For low-confidence selectors (nth-of-type)
|
|
786
|
-
//
|
|
803
|
+
// For low-confidence selectors (nth-of-type) OR selectors from dynamic
|
|
804
|
+
// attributes (which may not survive page reload), generate a text-based
|
|
805
|
+
// fallback when the element has short, unique text (e.g. menu items
|
|
806
|
+
// "\u5220\u9664", "\u786E\u8BA4", "\u6700\u65B0"). This lets replay fall back to text matching
|
|
807
|
+
// when the primary selector can't be found.
|
|
787
808
|
var textFallback;
|
|
788
809
|
var popupContext;
|
|
789
810
|
|
|
@@ -793,7 +814,18 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
793
814
|
popupEl = el.closest('[role="menu"], [role="listbox"], [role="dialog"], [role="tooltip"], [role="list"], [class*="popover"], [class*="popup"], [class*="dropdown"], [class*="menu"], [class*="modal"], [id*="menu"], [id*="dropdown"], [id*="popup"], [id*="modal"]');
|
|
794
815
|
} catch(e) {}
|
|
795
816
|
|
|
796
|
-
if
|
|
817
|
+
// Determine if primary selector is "fragile" \u2014 won't survive page reload
|
|
818
|
+
// because it comes from a dynamic attribute (e.g. data-spm-anchor-id).
|
|
819
|
+
var selectorIsFragile = false;
|
|
820
|
+
if (selector && confidence === 'high' && strategy === 'data-attr') {
|
|
821
|
+
// Fragile if the selector references a known dynamic attribute
|
|
822
|
+
selectorIsFragile = selector.indexOf('[data-spm-anchor-id') !== -1
|
|
823
|
+
|| selector.indexOf('[data-reactid') !== -1
|
|
824
|
+
|| selector.indexOf('[data-track') !== -1
|
|
825
|
+
|| selector.indexOf('[data-log') !== -1;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (confidence === 'low' || selectorIsFragile) {
|
|
797
829
|
var rawText = (el.textContent || '').trim();
|
|
798
830
|
if (rawText && rawText.length >= 1 && rawText.length <= 30 && el.children.length === 0) {
|
|
799
831
|
try {
|
|
@@ -1035,6 +1067,10 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
1035
1067
|
'.popover','.popup','.dropdown','.menu','.modal','.tooltip','.panel',
|
|
1036
1068
|
'[class*="popover"]','[class*="popup"]','[class*="dropdown"]','[class*="menu"]','[class*="tooltip"]',
|
|
1037
1069
|
'[class*="modal"]','[class*="panel"]','[class*="overlay"]','[class*="sheet"]',
|
|
1070
|
+
// Modern SPA conventions: many libraries expose the open menu via class
|
|
1071
|
+
// fragments like "items-container", "options", "list", "select-popup".
|
|
1072
|
+
'[class*="items-container"]','[class*="options"]','[class*="select-popup"]',
|
|
1073
|
+
'[class*="select-items"]','[class*="menu-items"]','[class*="list-box"]',
|
|
1038
1074
|
'[data-popup]','[data-dropdown]','[data-menu]','[data-popover]',
|
|
1039
1075
|
'.semi-dropdown','.semi-popover','.semi-modal','.semi-select-option',
|
|
1040
1076
|
'.ant-dropdown','.ant-popover','.ant-modal','.ant-select-dropdown',
|
|
@@ -1076,6 +1112,11 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
1076
1112
|
if (child.getAttribute('aria-disabled') === 'true') childInfo.disabled = true;
|
|
1077
1113
|
if (child.tagName) childInfo.tag = child.tagName.toLowerCase();
|
|
1078
1114
|
if (child.href) childInfo.href = child.href.substring(0, 80);
|
|
1115
|
+
// Attach a selector so replay / AI can target this item precisely.
|
|
1116
|
+
try {
|
|
1117
|
+
var childSel = uniqueSelector(child);
|
|
1118
|
+
if (childSel) childInfo.selector = childSel;
|
|
1119
|
+
} catch(e) {}
|
|
1079
1120
|
items.push(childInfo);
|
|
1080
1121
|
}
|
|
1081
1122
|
result.appeared.push({
|
|
@@ -1137,6 +1178,9 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
1137
1178
|
|
|
1138
1179
|
pushAction('click', { element: describe(resolveMeaningful(e)), x: cx, y: cy });
|
|
1139
1180
|
|
|
1181
|
+
// Mark this element as user-interacted in discovered_filters (proactive sensing)
|
|
1182
|
+
try { __xb_markTriggerInteracted(e.target); } catch(_) {}
|
|
1183
|
+
|
|
1140
1184
|
// After 200ms, capture what changed
|
|
1141
1185
|
setTimeout(function() {
|
|
1142
1186
|
try {
|
|
@@ -1275,12 +1319,102 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
1275
1319
|
}, true);
|
|
1276
1320
|
|
|
1277
1321
|
// \u2500\u2500 Hover (throttled to 800ms) \u2500\u2500
|
|
1322
|
+
// After a hover we asynchronously sample for popups/dropdowns that appear
|
|
1323
|
+
// (200/500/1000ms) and run a short-lived MutationObserver. Any popup that
|
|
1324
|
+
// surfaces is recorded on the hover action as hoverContext, mirroring the
|
|
1325
|
+
// clickContext flow used after clicks.
|
|
1278
1326
|
var __xb_last_hover = 0;
|
|
1327
|
+
var __xb_hover_observer = null; // current hover's MutationObserver
|
|
1328
|
+
var __xb_hover_observer_timer = null; // auto-disconnect timer
|
|
1329
|
+
var __xb_hover_action_ref = null; // ref to the hover action being enriched
|
|
1330
|
+
var __xb_hover_seen_popups = {}; // dedup popup selectors for current hover
|
|
1331
|
+
var __xb_hover_cx = 0, __xb_hover_cy = 0;
|
|
1332
|
+
|
|
1333
|
+
function __xb_merge_hover_context(ctx) {
|
|
1334
|
+
if (!__xb_hover_action_ref || !ctx) return;
|
|
1335
|
+
if (ctx.appeared.length === 0 && ctx.stateChanges.length === 0) return;
|
|
1336
|
+
if (!__xb_hover_action_ref.hoverContext) {
|
|
1337
|
+
__xb_hover_action_ref.hoverContext = { appeared: [], disappeared: [], stateChanges: [] };
|
|
1338
|
+
}
|
|
1339
|
+
var hc = __xb_hover_action_ref.hoverContext;
|
|
1340
|
+
ctx.appeared.forEach(function(p) {
|
|
1341
|
+
var key = p.selector || p.text;
|
|
1342
|
+
if (!key || __xb_hover_seen_popups[key]) return;
|
|
1343
|
+
__xb_hover_seen_popups[key] = true;
|
|
1344
|
+
hc.appeared.push(p);
|
|
1345
|
+
});
|
|
1346
|
+
ctx.stateChanges.forEach(function(s) {
|
|
1347
|
+
hc.stateChanges.push(s);
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function __xb_capture_hover_popups() {
|
|
1352
|
+
if (!__xb_hover_action_ref) return;
|
|
1353
|
+
try {
|
|
1354
|
+
var ctx = captureVisibleContext(__xb_hover_cx, __xb_hover_cy);
|
|
1355
|
+
__xb_merge_hover_context(ctx);
|
|
1356
|
+
} catch(e) {}
|
|
1357
|
+
}
|
|
1358
|
+
// Expose for flush-time sweep (server-side flushPendingActions calls this
|
|
1359
|
+
// before draining the pending queue so any in-flight hover enrichment lands).
|
|
1360
|
+
window.__xb_capture_hover_popups = __xb_capture_hover_popups;
|
|
1361
|
+
|
|
1362
|
+
function __xb_start_hover_observer() {
|
|
1363
|
+
if (!('MutationObserver' in window)) return;
|
|
1364
|
+
if (__xb_hover_observer) {
|
|
1365
|
+
__xb_hover_observer.disconnect();
|
|
1366
|
+
__xb_hover_observer = null;
|
|
1367
|
+
}
|
|
1368
|
+
if (__xb_hover_observer_timer) {
|
|
1369
|
+
clearTimeout(__xb_hover_observer_timer);
|
|
1370
|
+
__xb_hover_observer_timer = null;
|
|
1371
|
+
}
|
|
1372
|
+
__xb_hover_observer = new MutationObserver(function(mutations) {
|
|
1373
|
+
if (!__xb_hover_action_ref) return;
|
|
1374
|
+
for (var i = 0; i < mutations.length; i++) {
|
|
1375
|
+
var added = mutations[i].addedNodes;
|
|
1376
|
+
for (var j = 0; j < added.length; j++) {
|
|
1377
|
+
var node = added[j];
|
|
1378
|
+
if (node.nodeType !== 1) continue;
|
|
1379
|
+
var matches = false;
|
|
1380
|
+
// Is the added node itself a popover?
|
|
1381
|
+
for (var k = 0; k < POPOVER_SELECTORS.length; k++) {
|
|
1382
|
+
try { if (node.matches && node.matches(POPOVER_SELECTORS[k])) { matches = true; break; } } catch(e) {}
|
|
1383
|
+
}
|
|
1384
|
+
// Or does it contain a popover?
|
|
1385
|
+
if (!matches && node.querySelector) {
|
|
1386
|
+
for (var k2 = 0; k2 < POPOVER_SELECTORS.length; k2++) {
|
|
1387
|
+
try { if (node.querySelector(POPOVER_SELECTORS[k2])) { matches = true; break; } } catch(e) {}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (matches) {
|
|
1391
|
+
__xb_capture_hover_popups();
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
try {
|
|
1398
|
+
__xb_hover_observer.observe(document.body, { childList: true, subtree: true });
|
|
1399
|
+
} catch(e) {}
|
|
1400
|
+
// Auto-disconnect after 1.2s \u2014 hover popups rarely appear later than this.
|
|
1401
|
+
__xb_hover_observer_timer = setTimeout(function() {
|
|
1402
|
+
if (__xb_hover_observer) {
|
|
1403
|
+
__xb_hover_observer.disconnect();
|
|
1404
|
+
__xb_hover_observer = null;
|
|
1405
|
+
}
|
|
1406
|
+
__xb_hover_observer_timer = null;
|
|
1407
|
+
}, 1200);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1279
1410
|
document.addEventListener('mouseover', function(e) {
|
|
1280
1411
|
if (Date.now() - __xb_last_hover < 800) return;
|
|
1281
|
-
__xb_last_hover = Date.now();
|
|
1282
1412
|
var target = resolveMeaningful(e);
|
|
1283
|
-
// Only record hover on interactive elements
|
|
1413
|
+
// Only record hover on interactive elements. Modern SPAs often implement
|
|
1414
|
+
// clickable items as <span>/<div> with onClick handlers and no semantic
|
|
1415
|
+
// role, so we also accept elements whose computed style suggests they are
|
|
1416
|
+
// meant to be interacted with (pointer cursor, or a class name that looks
|
|
1417
|
+
// like a UI control).
|
|
1284
1418
|
var tag = target.tagName && target.tagName.toLowerCase();
|
|
1285
1419
|
var isInteractive = tag === 'a' || tag === 'button' || tag === 'input'
|
|
1286
1420
|
|| tag === 'select' || tag === 'textarea' || tag === 'summary'
|
|
@@ -1290,12 +1424,69 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
1290
1424
|
|| target.getAttribute('role') === 'tab'
|
|
1291
1425
|
|| target.getAttribute('role') === 'option'
|
|
1292
1426
|
|| !!target.closest('[role="menu"], [role="menubar"], [role="tablist"], [role="listbox"], [role="tree"], nav, menu');
|
|
1427
|
+
// Heuristic fallback for span/div/li that look interactive (onClick,
|
|
1428
|
+
// pointer cursor, or class names hinting at a UI control like
|
|
1429
|
+
// "select", "trigger", "tab", "sort", "menu", "item").
|
|
1430
|
+
if (!isInteractive && (tag === 'span' || tag === 'div' || tag === 'li')) {
|
|
1431
|
+
try {
|
|
1432
|
+
if (target.onclick) isInteractive = true;
|
|
1433
|
+
} catch(e2) {}
|
|
1434
|
+
if (!isInteractive) {
|
|
1435
|
+
try {
|
|
1436
|
+
var cs = window.getComputedStyle(target);
|
|
1437
|
+
if (cs.cursor === 'pointer') isInteractive = true;
|
|
1438
|
+
} catch(e3) {}
|
|
1439
|
+
}
|
|
1440
|
+
if (!isInteractive) {
|
|
1441
|
+
try {
|
|
1442
|
+
var cls = (target.className && typeof target.className === 'string')
|
|
1443
|
+
? target.className.toLowerCase() : '';
|
|
1444
|
+
if (/(?:^|[-_])(select|trigger|tab|sort|menu|item|dropdown|popover|title|toggle|action|btn)(?:[-_]|$)/.test(cls)) {
|
|
1445
|
+
// But only if an ancestor looks like a toolbar/menu/sort bar \u2014
|
|
1446
|
+
// otherwise this would match almost every list item on the web.
|
|
1447
|
+
if (target.closest('[class*="select"], [class*="sort"], [class*="tab"], [class*="menu"], [class*="toolbar"], [class*="filter"], [class*="dropdown"]')) {
|
|
1448
|
+
isInteractive = true;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
} catch(e4) {}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1293
1454
|
if (isInteractive) {
|
|
1455
|
+
// Reset state for the new hover target
|
|
1456
|
+
if (__xb_hover_observer) {
|
|
1457
|
+
__xb_hover_observer.disconnect();
|
|
1458
|
+
__xb_hover_observer = null;
|
|
1459
|
+
}
|
|
1460
|
+
if (__xb_hover_observer_timer) {
|
|
1461
|
+
clearTimeout(__xb_hover_observer_timer);
|
|
1462
|
+
__xb_hover_observer_timer = null;
|
|
1463
|
+
}
|
|
1464
|
+
__xb_hover_seen_popups = {};
|
|
1465
|
+
|
|
1466
|
+
var cx = e.clientX, cy = e.clientY;
|
|
1467
|
+
__xb_hover_cx = cx;
|
|
1468
|
+
__xb_hover_cy = cy;
|
|
1469
|
+
|
|
1294
1470
|
pushAction('hover', {
|
|
1295
1471
|
element: describe(target),
|
|
1296
|
-
x:
|
|
1297
|
-
y:
|
|
1472
|
+
x: cx,
|
|
1473
|
+
y: cy,
|
|
1298
1474
|
});
|
|
1475
|
+
// Mark this element as user-interacted in discovered_filters
|
|
1476
|
+
try { __xb_markTriggerInteracted(target); } catch(_) {}
|
|
1477
|
+
__xb_last_hover = Date.now();
|
|
1478
|
+
|
|
1479
|
+
// Grab a reference to the just-pushed action so we can enrich it async.
|
|
1480
|
+
var pending = window.__xb_pending_actions;
|
|
1481
|
+
__xb_hover_action_ref = pending.length > 0 ? pending[pending.length - 1] : null;
|
|
1482
|
+
|
|
1483
|
+
// 1. Async sampling: 200/500/1000ms covers CSS transitions + JS render delays.
|
|
1484
|
+
setTimeout(__xb_capture_hover_popups, 200);
|
|
1485
|
+
setTimeout(__xb_capture_hover_popups, 500);
|
|
1486
|
+
setTimeout(__xb_capture_hover_popups, 1000);
|
|
1487
|
+
|
|
1488
|
+
// 2. Short-lived MutationObserver for popups that appear on their own schedule.
|
|
1489
|
+
__xb_start_hover_observer();
|
|
1299
1490
|
}
|
|
1300
1491
|
}, true);
|
|
1301
1492
|
|
|
@@ -1404,6 +1595,511 @@ var ACTION_SIGNAL_SCRIPT = `
|
|
|
1404
1595
|
visibility: { state: document.hidden ? 'hidden' : 'visible' },
|
|
1405
1596
|
});
|
|
1406
1597
|
}, true);
|
|
1598
|
+
|
|
1599
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1600
|
+
// PROACTIVE SENSING \u2014 discover interactive regions without user action.
|
|
1601
|
+
//
|
|
1602
|
+
// Three layers:
|
|
1603
|
+
// 1. __xb_scanFilters() \u2014 baseline scan, called on start/navigation
|
|
1604
|
+
// 2. Long-lived MutationObserver \u2014 watches popover visible transitions
|
|
1605
|
+
// 3. mousemove throttled diff \u2014 senses triggers near the cursor
|
|
1606
|
+
//
|
|
1607
|
+
// Results:
|
|
1608
|
+
// - 'discovered_filters' action \u2192 merged into RecordingData.discoveredFilters
|
|
1609
|
+
// - 'popup_appear' action \u2192 added to actions stream (with cause tag)
|
|
1610
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1611
|
+
|
|
1612
|
+
var FILTER_CONTAINER_SELECTORS = [
|
|
1613
|
+
'[role="tablist"]', '[role="menu"]', '[role="menubar"]', '[role="toolbar"]',
|
|
1614
|
+
'[role="navigation"]', '[role="listbox"]',
|
|
1615
|
+
'[class*="filter"]', '[class*="sort"]', '[class*="toolbar"]',
|
|
1616
|
+
'[class*="select-bar"]', '[class*="tabs"]', '[class*="nav-bar"]',
|
|
1617
|
+
'[class*="sidebar"]', '[class*="menu-bar"]', '[class*="select-container"]',
|
|
1618
|
+
'fieldset',
|
|
1619
|
+
];
|
|
1620
|
+
|
|
1621
|
+
var TRIGGER_SELECTORS = [
|
|
1622
|
+
'[role="tab"]', '[role="menuitem"]', '[role="menuitemcheckbox"]',
|
|
1623
|
+
'[role="menuitemradio"]', '[role="option"]', '[role="button"]',
|
|
1624
|
+
'[role="link"]', '[role="switch"]', '[role="checkbox"]',
|
|
1625
|
+
'button', 'a', 'select',
|
|
1626
|
+
// Modern SPA span/div triggers (cursor:pointer is a strong signal)
|
|
1627
|
+
'span[class*="title"]', 'div[class*="title"]',
|
|
1628
|
+
'div[class*="trigger"]', 'span[class*="trigger"]',
|
|
1629
|
+
'div[class*="item"]', 'li',
|
|
1630
|
+
];
|
|
1631
|
+
|
|
1632
|
+
/** Categorize a container/trigger by class name + role heuristics. */
|
|
1633
|
+
function categorizeElement(el) {
|
|
1634
|
+
try {
|
|
1635
|
+
var cls = ((el.className && typeof el.className === 'string') ? el.className : '').toLowerCase();
|
|
1636
|
+
var role = (el.getAttribute && el.getAttribute('role')) || '';
|
|
1637
|
+
if (role === 'tab' || /(^|[-_])tab(s|list)?([-_]|$)/.test(cls)) return 'tab';
|
|
1638
|
+
if (role === 'menuitem' || role === 'menu' || /menu/.test(cls)) return 'menu';
|
|
1639
|
+
if (/sort/.test(cls)) return 'sort';
|
|
1640
|
+
if (/filter/.test(cls)) return 'filter';
|
|
1641
|
+
if (role === 'navigation' || /nav([-_]|$)/.test(cls)) return 'navigation';
|
|
1642
|
+
// Common compound patterns
|
|
1643
|
+
if (/search-filter|search-select|filter-up|filter-select|filter-checkbox/.test(cls)) return 'filter';
|
|
1644
|
+
if (/sort-bar|sort-container|sort-trigger/.test(cls)) return 'sort';
|
|
1645
|
+
if (/checkbox-group|radio-group/.test(cls)) return 'filter';
|
|
1646
|
+
if (/sidebar/.test(cls)) return 'navigation';
|
|
1647
|
+
} catch(e) {}
|
|
1648
|
+
return 'unknown';
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/** Build a DiscoveredTrigger from an element. */
|
|
1652
|
+
function buildTrigger(el) {
|
|
1653
|
+
var text = (el.textContent || '').trim();
|
|
1654
|
+
var trigger = {
|
|
1655
|
+
selector: uniqueSelector(el),
|
|
1656
|
+
text: text.substring(0, 40),
|
|
1657
|
+
role: el.getAttribute ? (el.getAttribute('role') || undefined) : undefined,
|
|
1658
|
+
category: categorizeElement(el),
|
|
1659
|
+
hasPopup: false,
|
|
1660
|
+
userInteracted: false,
|
|
1661
|
+
explored: false,
|
|
1662
|
+
};
|
|
1663
|
+
return trigger;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/** All DiscoveredFilters found so far. Keyed by containerSelector for dedup. */
|
|
1667
|
+
var __xb_discovered_filters = {};
|
|
1668
|
+
/** True if a 'discovered_filters' push is pending (debounced). */
|
|
1669
|
+
var __xb_scan_scheduled = false;
|
|
1670
|
+
/** Track which DOM nodes we've already scanned (by identity, not selector). */
|
|
1671
|
+
var __xb_scanned_nodes = (typeof WeakSet !== 'undefined') ? new WeakSet() : null;
|
|
1672
|
+
|
|
1673
|
+
/** Scan the page for filter/sort/tab/menu containers + their triggers.
|
|
1674
|
+
* Dedup by DOM node identity (WeakSet) so the same element reached via
|
|
1675
|
+
* different selectors is recorded once. Nested containers are skipped \u2014
|
|
1676
|
+
* the outermost container wins. */
|
|
1677
|
+
function __xb_scanFilters() {
|
|
1678
|
+
try {
|
|
1679
|
+
// First pass: collect all candidate container nodes (dedup by identity)
|
|
1680
|
+
var candidates = [];
|
|
1681
|
+
var seenNodes = new Set(); // fallback if WeakSet unavailable
|
|
1682
|
+
for (var ci = 0; ci < FILTER_CONTAINER_SELECTORS.length; ci++) {
|
|
1683
|
+
var containers;
|
|
1684
|
+
try { containers = document.querySelectorAll(FILTER_CONTAINER_SELECTORS[ci]); } catch(e) { continue; }
|
|
1685
|
+
for (var cj = 0; cj < containers.length; cj++) {
|
|
1686
|
+
var c = containers[cj];
|
|
1687
|
+
// Dedup by identity
|
|
1688
|
+
if (__xb_scanned_nodes) {
|
|
1689
|
+
if (__xb_scanned_nodes.has(c)) continue;
|
|
1690
|
+
} else {
|
|
1691
|
+
if (seenNodes.has(c)) continue;
|
|
1692
|
+
seenNodes.add(c);
|
|
1693
|
+
}
|
|
1694
|
+
// Skip non-visible containers
|
|
1695
|
+
if (!c.offsetParent && c.tagName.toLowerCase() !== 'body') continue;
|
|
1696
|
+
// Skip if an ancestor is already a candidate (avoid nested dup)
|
|
1697
|
+
var isNested = false;
|
|
1698
|
+
for (var ck = 0; ck < candidates.length; ck++) {
|
|
1699
|
+
if (candidates[ck].contains(c)) { isNested = true; break; }
|
|
1700
|
+
}
|
|
1701
|
+
if (isNested) continue;
|
|
1702
|
+
// If this candidate contains existing ones, remove them
|
|
1703
|
+
candidates = candidates.filter(function(p) { return !c.contains(p); });
|
|
1704
|
+
candidates.push(c);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Second pass: for each candidate, mark as scanned + extract triggers
|
|
1709
|
+
for (var ck2 = 0; ck2 < candidates.length; ck2++) {
|
|
1710
|
+
var c2 = candidates[ck2];
|
|
1711
|
+
if (__xb_scanned_nodes) __xb_scanned_nodes.add(c2);
|
|
1712
|
+
|
|
1713
|
+
var containerSel = uniqueSelector(c2);
|
|
1714
|
+
// If we already have this in discovered map (by selector), skip
|
|
1715
|
+
if (__xb_discovered_filters[containerSel]) continue;
|
|
1716
|
+
|
|
1717
|
+
// Extract triggers: prefer leaf-ish elements with short text
|
|
1718
|
+
var triggers = [];
|
|
1719
|
+
var seenTrigSels = {};
|
|
1720
|
+
// Use a single querySelectorAll with comma-joined selectors for speed
|
|
1721
|
+
var allCandidates = c2.querySelectorAll(TRIGGER_SELECTORS.join(', '));
|
|
1722
|
+
for (var ti = 0; ti < allCandidates.length; ti++) {
|
|
1723
|
+
var el = allCandidates[ti];
|
|
1724
|
+
if (!el.offsetParent) continue;
|
|
1725
|
+
// Skip elements whose nearest ancestor in the container is also a
|
|
1726
|
+
// candidate (would create nested triggers).
|
|
1727
|
+
var skip = false;
|
|
1728
|
+
var p = el.parentElement;
|
|
1729
|
+
while (p && p !== c2) {
|
|
1730
|
+
if (seenTrigSels[uniqueSelector(p)]) { skip = true; break; }
|
|
1731
|
+
p = p.parentElement;
|
|
1732
|
+
}
|
|
1733
|
+
if (skip) continue;
|
|
1734
|
+
var txt = (el.textContent || '').trim();
|
|
1735
|
+
if (!txt || txt.length === 0 || txt.length > 40) continue;
|
|
1736
|
+
// Skip if this looks like a full item card (long combined text)
|
|
1737
|
+
if (el.children.length > 3) continue;
|
|
1738
|
+
var sel = uniqueSelector(el);
|
|
1739
|
+
if (seenTrigSels[sel]) continue;
|
|
1740
|
+
seenTrigSels[sel] = true;
|
|
1741
|
+
triggers.push(buildTrigger(el));
|
|
1742
|
+
if (triggers.length >= 30) break;
|
|
1743
|
+
}
|
|
1744
|
+
if (triggers.length === 0) continue;
|
|
1745
|
+
|
|
1746
|
+
__xb_discovered_filters[containerSel] = {
|
|
1747
|
+
containerSelector: containerSel,
|
|
1748
|
+
category: categorizeElement(c2),
|
|
1749
|
+
containerText: (c2.textContent || '').trim().substring(0, 60),
|
|
1750
|
+
triggers: triggers,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
// Third pass: dedup containers whose trigger set is a subset of another.
|
|
1754
|
+
// This handles the case where two overlapping containers (e.g. outer
|
|
1755
|
+
// ".filter-up-container" and inner ".filter-select-container") share
|
|
1756
|
+
// triggers \u2014 keep the larger one, drop the redundant subset.
|
|
1757
|
+
var newFilters = [];
|
|
1758
|
+
var addedKeys = [];
|
|
1759
|
+
for (var key in __xb_discovered_filters) {
|
|
1760
|
+
var f = __xb_discovered_filters[key];
|
|
1761
|
+
var isSubset = false;
|
|
1762
|
+
for (var otherKey in __xb_discovered_filters) {
|
|
1763
|
+
if (otherKey === key) continue;
|
|
1764
|
+
var other = __xb_discovered_filters[otherKey];
|
|
1765
|
+
if (f.triggers.length > other.triggers.length) continue;
|
|
1766
|
+
// Check if every trigger in f is also in other (by selector)
|
|
1767
|
+
var allInOther = true;
|
|
1768
|
+
for (var ti2 = 0; ti2 < f.triggers.length; ti2++) {
|
|
1769
|
+
var found = false;
|
|
1770
|
+
for (var to2 = 0; to2 < other.triggers.length; to2++) {
|
|
1771
|
+
if (other.triggers[to2].selector === f.triggers[ti2].selector) { found = true; break; }
|
|
1772
|
+
}
|
|
1773
|
+
if (!found) { allInOther = false; break; }
|
|
1774
|
+
}
|
|
1775
|
+
if (allInOther && f.triggers.length < other.triggers.length) {
|
|
1776
|
+
isSubset = true;
|
|
1777
|
+
break;
|
|
1778
|
+
}
|
|
1779
|
+
// Same size + same triggers \u2014 keep the one with a more "container-y" selector
|
|
1780
|
+
if (allInOther && f.triggers.length === other.triggers.length && key > otherKey) {
|
|
1781
|
+
isSubset = true;
|
|
1782
|
+
break;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (!isSubset) {
|
|
1786
|
+
newFilters.push(f);
|
|
1787
|
+
addedKeys.push(key);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
// Rebuild the discovered map with deduped entries
|
|
1791
|
+
__xb_discovered_filters = {};
|
|
1792
|
+
for (var ni = 0; ni < newFilters.length; ni++) {
|
|
1793
|
+
__xb_discovered_filters[addedKeys[ni]] = newFilters[ni];
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Push a discovered_filters action so the server picks it up.
|
|
1797
|
+
__xb_pushDiscovered();
|
|
1798
|
+
} catch(e) { /* scan failed, don't crash recorder */ }
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** Push current discovered_filters snapshot to pending actions (debounced). */
|
|
1802
|
+
function __xb_pushDiscovered() {
|
|
1803
|
+
if (__xb_scan_scheduled) return;
|
|
1804
|
+
__xb_scan_scheduled = true;
|
|
1805
|
+
setTimeout(function() {
|
|
1806
|
+
__xb_scan_scheduled = false;
|
|
1807
|
+
var list = Object.keys(__xb_discovered_filters).map(function(k) {
|
|
1808
|
+
return __xb_discovered_filters[k];
|
|
1809
|
+
});
|
|
1810
|
+
window.__xb_pending_actions.push({
|
|
1811
|
+
type: 'discovered_filters',
|
|
1812
|
+
ts: Date.now(),
|
|
1813
|
+
url: location.href,
|
|
1814
|
+
title: document.title,
|
|
1815
|
+
discoveredFilters: list,
|
|
1816
|
+
});
|
|
1817
|
+
}, 200);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** Mark a trigger as user-interacted (called from click/hover handlers). */
|
|
1821
|
+
function __xb_markTriggerInteracted(target) {
|
|
1822
|
+
try {
|
|
1823
|
+
var sel = uniqueSelector(target);
|
|
1824
|
+
for (var k in __xb_discovered_filters) {
|
|
1825
|
+
var f = __xb_discovered_filters[k];
|
|
1826
|
+
for (var i = 0; i < f.triggers.length; i++) {
|
|
1827
|
+
if (f.triggers[i].selector === sel) {
|
|
1828
|
+
f.triggers[i].userInteracted = true;
|
|
1829
|
+
// Schedule a push so server gets the updated state
|
|
1830
|
+
__xb_pushDiscovered();
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
// Also accept ancestor match (target may be a child of the trigger)
|
|
1834
|
+
var triggerEl = document.querySelector(f.triggers[i].selector);
|
|
1835
|
+
if (triggerEl && triggerEl.contains(target)) {
|
|
1836
|
+
f.triggers[i].userInteracted = true;
|
|
1837
|
+
__xb_pushDiscovered();
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
} catch(e) {}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** Expose scan + capture for server-side triggers (post-navigation, flush-time). */
|
|
1846
|
+
window.__xb_scanFilters = __xb_scanFilters;
|
|
1847
|
+
window.__xb_capture_discovered_filters = function() {
|
|
1848
|
+
return Object.keys(__xb_discovered_filters).map(function(k) {
|
|
1849
|
+
return __xb_discovered_filters[k];
|
|
1850
|
+
});
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
/** Find the nearest trigger element for a popup (looking up the DOM). */
|
|
1854
|
+
function findTriggerForPopup(popupEl) {
|
|
1855
|
+
try {
|
|
1856
|
+
// Walk up looking for known container types, then pick the trigger
|
|
1857
|
+
// nearest to the popup's top.
|
|
1858
|
+
var popupRect = popupEl.getBoundingClientRect();
|
|
1859
|
+
var ancestor = popupEl;
|
|
1860
|
+
for (var depth = 0; depth < 8 && ancestor; depth++) {
|
|
1861
|
+
ancestor = ancestor.parentElement;
|
|
1862
|
+
if (!ancestor) break;
|
|
1863
|
+
var triggersInAncestor = ancestor.querySelectorAll(
|
|
1864
|
+
'[role="tab"], [role="menuitem"], [role="option"], [role="button"], button, a, [class*="title"]'
|
|
1865
|
+
);
|
|
1866
|
+
// Pick the trigger whose x-center is closest to the popup's x-center
|
|
1867
|
+
// and is above the popup.
|
|
1868
|
+
var best = null, bestDist = Infinity;
|
|
1869
|
+
for (var i = 0; i < triggersInAncestor.length; i++) {
|
|
1870
|
+
var t = triggersInAncestor[i];
|
|
1871
|
+
if (!t.offsetParent) continue;
|
|
1872
|
+
var r = t.getBoundingClientRect();
|
|
1873
|
+
if (r.y + r.height > popupRect.y + 5) continue; // must be above popup
|
|
1874
|
+
var dist = Math.abs((r.x + r.width / 2) - (popupRect.x + popupRect.width / 2));
|
|
1875
|
+
if (dist < bestDist) {
|
|
1876
|
+
bestDist = dist;
|
|
1877
|
+
best = t;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (best) {
|
|
1881
|
+
// Mark the trigger as hasPopup in our discovered map
|
|
1882
|
+
var bestSel = uniqueSelector(best);
|
|
1883
|
+
for (var k in __xb_discovered_filters) {
|
|
1884
|
+
var f = __xb_discovered_filters[k];
|
|
1885
|
+
for (var j = 0; j < f.triggers.length; j++) {
|
|
1886
|
+
if (f.triggers[j].selector === bestSel) {
|
|
1887
|
+
f.triggers[j].hasPopup = true;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
return {
|
|
1892
|
+
selector: bestSel,
|
|
1893
|
+
text: (best.textContent || '').trim().substring(0, 40),
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
} catch(e) {}
|
|
1898
|
+
return undefined;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** Extract popup items (reuses the same item-extraction logic as captureVisibleContext). */
|
|
1902
|
+
function extractPopupItems(popupEl) {
|
|
1903
|
+
var items = [];
|
|
1904
|
+
try {
|
|
1905
|
+
var children = popupEl.querySelectorAll(
|
|
1906
|
+
'a,button,[role="menuitem"],[role="option"],[role="treeitem"],li,div[class*="item"],span[class*="item"]'
|
|
1907
|
+
);
|
|
1908
|
+
for (var k = 0; k < Math.min(children.length, 20); k++) {
|
|
1909
|
+
var child = children[k];
|
|
1910
|
+
var childText = (child.textContent || '').trim();
|
|
1911
|
+
if (!childText || childText.length > 60) continue;
|
|
1912
|
+
var info = { text: childText.substring(0, 60) };
|
|
1913
|
+
if (child.disabled) info.disabled = true;
|
|
1914
|
+
try { info.selector = uniqueSelector(child); } catch(e) {}
|
|
1915
|
+
items.push(info);
|
|
1916
|
+
}
|
|
1917
|
+
} catch(e) {}
|
|
1918
|
+
return items;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/** Check whether an element is currently visible (has layout box). */
|
|
1922
|
+
function isVisibleEl(el) {
|
|
1923
|
+
try {
|
|
1924
|
+
var r = el.getBoundingClientRect();
|
|
1925
|
+
if (r.width === 0 || r.height === 0) return false;
|
|
1926
|
+
var cs = window.getComputedStyle(el);
|
|
1927
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
1928
|
+
return true;
|
|
1929
|
+
} catch(e) { return false; }
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/** Push a 'popup_appear' sensing action. */
|
|
1933
|
+
function __xb_pushPopupAppear(popupEl, cause) {
|
|
1934
|
+
try {
|
|
1935
|
+
if (!popupEl || !isVisibleEl(popupEl)) return;
|
|
1936
|
+
var r = popupEl.getBoundingClientRect();
|
|
1937
|
+
// Heuristic: a real popup/menu should be relatively small (not a full
|
|
1938
|
+
// page banner) and have menu-like children. Page banners / hero sections
|
|
1939
|
+
// often match [class*="popup"] / [class*="modal"] but aren't real menus.
|
|
1940
|
+
var MAX_POPUP_AREA = 600 * 600; // 360,000 px\xB2
|
|
1941
|
+
if (r.width * r.height > MAX_POPUP_AREA) return;
|
|
1942
|
+
// Must have at least 1 child that looks like an item (link/button/li/div with class*="item")
|
|
1943
|
+
var itemChildren = popupEl.querySelectorAll(
|
|
1944
|
+
'a, button, [role="menuitem"], [role="option"], li, [class*="item"]'
|
|
1945
|
+
);
|
|
1946
|
+
if (itemChildren.length === 0) return;
|
|
1947
|
+
// Dedup against recent popup_appear actions (same selector within 3s)
|
|
1948
|
+
var popupSel = uniqueSelector(popupEl);
|
|
1949
|
+
var now = Date.now();
|
|
1950
|
+
var recent = window.__xb_pending_actions.filter(function(a) {
|
|
1951
|
+
return a.type === 'popup_appear' && (now - a.ts) < 3000;
|
|
1952
|
+
});
|
|
1953
|
+
for (var i = 0; i < recent.length; i++) {
|
|
1954
|
+
if (recent[i].popupAppear && recent[i].popupAppear.popup.selector === popupSel) return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
var triggerInfo = findTriggerForPopup(popupEl);
|
|
1958
|
+
window.__xb_pending_actions.push({
|
|
1959
|
+
type: 'popup_appear',
|
|
1960
|
+
ts: now,
|
|
1961
|
+
url: location.href,
|
|
1962
|
+
title: document.title,
|
|
1963
|
+
popupAppear: {
|
|
1964
|
+
trigger: triggerInfo,
|
|
1965
|
+
popup: {
|
|
1966
|
+
selector: popupSel,
|
|
1967
|
+
text: (popupEl.textContent || '').trim().substring(0, 100),
|
|
1968
|
+
rect: {
|
|
1969
|
+
x: Math.round(r.x), y: Math.round(r.y),
|
|
1970
|
+
w: Math.round(r.width), h: Math.round(r.height),
|
|
1971
|
+
},
|
|
1972
|
+
items: extractPopupItems(popupEl),
|
|
1973
|
+
},
|
|
1974
|
+
cause: cause || 'auto',
|
|
1975
|
+
userTriggered: cause === 'user-hover' || cause === 'user-click',
|
|
1976
|
+
},
|
|
1977
|
+
});
|
|
1978
|
+
// Also schedule a discovered_filters push (hasPopup flags may have updated)
|
|
1979
|
+
__xb_pushDiscovered();
|
|
1980
|
+
} catch(e) {}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Expose for the hover handler to call when it observes a popup (avoid double-record)
|
|
1984
|
+
window.__xb_pushPopupAppear = __xb_pushPopupAppear;
|
|
1985
|
+
|
|
1986
|
+
// \u2500\u2500 Layer 2: long-lived MutationObserver for popover visible transitions \u2500\u2500
|
|
1987
|
+
var __xb_long_observer = null;
|
|
1988
|
+
var __xb_observer_debounce = null;
|
|
1989
|
+
// Set of selectors we've already recorded as popups (avoid re-pushing)
|
|
1990
|
+
var __xb_seen_popups = {};
|
|
1991
|
+
|
|
1992
|
+
function __xb_startLongObserver() {
|
|
1993
|
+
if (!('MutationObserver' in window)) return;
|
|
1994
|
+
if (__xb_long_observer) __xb_long_observer.disconnect();
|
|
1995
|
+
|
|
1996
|
+
__xb_long_observer = new MutationObserver(function(mutations) {
|
|
1997
|
+
// Debounce \u2014 collect then process once
|
|
1998
|
+
if (__xb_observer_debounce) clearTimeout(__xb_observer_debounce);
|
|
1999
|
+
__xb_observer_debounce = setTimeout(function() {
|
|
2000
|
+
__xb_observer_debounce = null;
|
|
2001
|
+
// Scan all known popover containers; any newly visible one \u2192 push
|
|
2002
|
+
for (var i = 0; i < POPOVER_SELECTORS.length; i++) {
|
|
2003
|
+
var els;
|
|
2004
|
+
try { els = document.querySelectorAll(POPOVER_SELECTORS[i]); } catch(e) { continue; }
|
|
2005
|
+
for (var j = 0; j < els.length; j++) {
|
|
2006
|
+
var el = els[j];
|
|
2007
|
+
var sel = uniqueSelector(el);
|
|
2008
|
+
if (__xb_seen_popups[sel]) continue;
|
|
2009
|
+
if (!isVisibleEl(el)) continue;
|
|
2010
|
+
__xb_seen_popups[sel] = Date.now();
|
|
2011
|
+
__xb_pushPopupAppear(el, 'auto');
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}, 100);
|
|
2015
|
+
});
|
|
2016
|
+
try {
|
|
2017
|
+
__xb_long_observer.observe(document.body, {
|
|
2018
|
+
childList: true,
|
|
2019
|
+
subtree: true,
|
|
2020
|
+
attributes: true,
|
|
2021
|
+
attributeFilter: ['class', 'style', 'hidden'],
|
|
2022
|
+
});
|
|
2023
|
+
} catch(e) {}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// Auto-cleanup seen_popups every 10s so transient popups can re-trigger
|
|
2027
|
+
setInterval(function() {
|
|
2028
|
+
var now = Date.now();
|
|
2029
|
+
for (var k in __xb_seen_popups) {
|
|
2030
|
+
if (now - __xb_seen_popups[k] > 10000) delete __xb_seen_popups[k];
|
|
2031
|
+
}
|
|
2032
|
+
}, 10000);
|
|
2033
|
+
|
|
2034
|
+
// \u2500\u2500 Layer 3: mousemove throttled diff (senses triggers near cursor) \u2500\u2500
|
|
2035
|
+
var __xb_last_scan_move = 0;
|
|
2036
|
+
var __xb_move_stop_timer = null;
|
|
2037
|
+
var __xb_last_pos = { x: 0, y: 0 };
|
|
2038
|
+
|
|
2039
|
+
document.addEventListener('mousemove', function(e) {
|
|
2040
|
+
__xb_last_pos = { x: e.clientX, y: e.clientY };
|
|
2041
|
+
var now = Date.now();
|
|
2042
|
+
if (now - __xb_last_scan_move < 300) return;
|
|
2043
|
+
__xb_last_scan_move = now;
|
|
2044
|
+
|
|
2045
|
+
// Reset the "stop" timer \u2014 when mouse stops moving for 800ms, we scan
|
|
2046
|
+
if (__xb_move_stop_timer) clearTimeout(__xb_move_stop_timer);
|
|
2047
|
+
__xb_move_stop_timer = setTimeout(function() {
|
|
2048
|
+
__xb_move_stop_timer = null;
|
|
2049
|
+
__xb_scanNearCursor(__xb_last_pos.x, __xb_last_pos.y, 'user-hover');
|
|
2050
|
+
}, 800);
|
|
2051
|
+
}, true);
|
|
2052
|
+
|
|
2053
|
+
/** Scan popups near the cursor; mark nearby triggers as explored. */
|
|
2054
|
+
function __xb_scanNearCursor(cx, cy, cause) {
|
|
2055
|
+
try {
|
|
2056
|
+
// 1. Check for any visible popover near cursor
|
|
2057
|
+
for (var i = 0; i < POPOVER_SELECTORS.length; i++) {
|
|
2058
|
+
var els;
|
|
2059
|
+
try { els = document.querySelectorAll(POPOVER_SELECTORS[i]); } catch(e) { continue; }
|
|
2060
|
+
for (var j = 0; j < els.length; j++) {
|
|
2061
|
+
var el = els[j];
|
|
2062
|
+
if (!isVisibleEl(el)) continue;
|
|
2063
|
+
var r = el.getBoundingClientRect();
|
|
2064
|
+
// Near = within 400px of cursor
|
|
2065
|
+
if (Math.abs((r.x + r.width / 2) - cx) > 400) continue;
|
|
2066
|
+
if (Math.abs((r.y + r.height / 2) - cy) > 400) continue;
|
|
2067
|
+
var sel = uniqueSelector(el);
|
|
2068
|
+
var now = Date.now();
|
|
2069
|
+
if (__xb_seen_popups[sel] && now - __xb_seen_popups[sel] < 2000) continue;
|
|
2070
|
+
__xb_seen_popups[sel] = now;
|
|
2071
|
+
__xb_pushPopupAppear(el, cause);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// 2. Mark nearby triggers as explored (mouse lingered without click)
|
|
2076
|
+
var near = document.elementsFromPoint(cx, cy);
|
|
2077
|
+
for (var k = 0; k < near.length && k < 5; k++) {
|
|
2078
|
+
var t = near[k];
|
|
2079
|
+
var tSel = uniqueSelector(t);
|
|
2080
|
+
for (var fk in __xb_discovered_filters) {
|
|
2081
|
+
var f = __xb_discovered_filters[fk];
|
|
2082
|
+
for (var ti = 0; ti < f.triggers.length; ti++) {
|
|
2083
|
+
if (f.triggers[ti].selector === tSel) {
|
|
2084
|
+
f.triggers[ti].explored = true;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
__xb_pushDiscovered();
|
|
2090
|
+
} catch(e) {}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// \u2500\u2500 Trigger initial baseline scan (after DOM is ready) \u2500\u2500
|
|
2094
|
+
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
2095
|
+
setTimeout(__xb_scanFilters, 500);
|
|
2096
|
+
setTimeout(__xb_startLongObserver, 800);
|
|
2097
|
+
} else {
|
|
2098
|
+
window.addEventListener('DOMContentLoaded', function() {
|
|
2099
|
+
setTimeout(__xb_scanFilters, 500);
|
|
2100
|
+
setTimeout(__xb_startLongObserver, 800);
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
1407
2103
|
})();
|
|
1408
2104
|
`;
|
|
1409
2105
|
var CHECKPOINT_OVERLAY_SCRIPT = `
|
|
@@ -1651,6 +2347,9 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1651
2347
|
network = [];
|
|
1652
2348
|
contextChanges = [];
|
|
1653
2349
|
checkpoints = [];
|
|
2350
|
+
/** Proactive baseline scan of filter/sort/tab/menu regions. Keyed by
|
|
2351
|
+
* containerSelector so re-scans after navigation replace rather than dup. */
|
|
2352
|
+
discoveredFilters = /* @__PURE__ */ new Map();
|
|
1654
2353
|
actionCounter = 0;
|
|
1655
2354
|
networkCounter = 0;
|
|
1656
2355
|
contextCounter = 0;
|
|
@@ -1671,6 +2370,9 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1671
2370
|
_lastRequestTs = 0;
|
|
1672
2371
|
_isRecording = false;
|
|
1673
2372
|
streamMode = "clean";
|
|
2373
|
+
/** Snapshot throttle: prevents too many screenshots during rapid inputs.
|
|
2374
|
+
* Keyed by 'type|elementSelector', value = last snapshot timestamp. */
|
|
2375
|
+
snapshotThrottle = /* @__PURE__ */ new Map();
|
|
1674
2376
|
/** Ambient action types: filtered out in clean mode */
|
|
1675
2377
|
static AMBIENT_TYPES = /* @__PURE__ */ new Set([
|
|
1676
2378
|
"hover",
|
|
@@ -1720,6 +2422,8 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1720
2422
|
type: action.type,
|
|
1721
2423
|
element: actionToPush.element
|
|
1722
2424
|
});
|
|
2425
|
+
await this.captureAutoSnapshot(this.page, actionToPush, "after").catch(() => {
|
|
2426
|
+
});
|
|
1723
2427
|
this.lastActionTs = ts;
|
|
1724
2428
|
this.dedupMap.set(dedupFromAction, Date.now() + 2e3);
|
|
1725
2429
|
this.dedupActionCount++;
|
|
@@ -1766,6 +2470,10 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1766
2470
|
get recordingsDir() {
|
|
1767
2471
|
return _SessionRecorder.getRecordingsDir(this.sessionName);
|
|
1768
2472
|
}
|
|
2473
|
+
/** Directory for auto-captured snapshots (PNG screenshots, stored in .tmp for easy cleanup). */
|
|
2474
|
+
get snapshotsDir() {
|
|
2475
|
+
return join2(this.recordingsDir, ".tmp", "snapshots");
|
|
2476
|
+
}
|
|
1769
2477
|
static getRecordingsDir(sessionName) {
|
|
1770
2478
|
return join2(homedir2(), ".xbrowser", "sessions", sessionName, "recordings");
|
|
1771
2479
|
}
|
|
@@ -1957,9 +2665,18 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1957
2665
|
}
|
|
1958
2666
|
}
|
|
1959
2667
|
// ==================== Private ====================
|
|
2668
|
+
/** Whether the initial action-signal script injection succeeded. Check this
|
|
2669
|
+
* after start() returns — if false, user actions will NOT be recorded and
|
|
2670
|
+
* the caller should surface a clear error to the user instead of silently
|
|
2671
|
+
* pretending recording is working. */
|
|
2672
|
+
_injectionFailed = false;
|
|
2673
|
+
get injectionFailed() {
|
|
2674
|
+
return this._injectionFailed;
|
|
2675
|
+
}
|
|
1960
2676
|
async injectActionScript(page) {
|
|
1961
2677
|
let success = false;
|
|
1962
2678
|
const maxAttempts = 3;
|
|
2679
|
+
let lastError = "";
|
|
1963
2680
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1964
2681
|
try {
|
|
1965
2682
|
await page.evaluate(getSelectorGeneratorScript());
|
|
@@ -1972,17 +2689,20 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1972
2689
|
success = true;
|
|
1973
2690
|
break;
|
|
1974
2691
|
}
|
|
2692
|
+
lastError = "verification failed (script did not set __xb_action_signal flag)";
|
|
1975
2693
|
console.error(`[recorder] Action signal injection verification failed (attempt ${attempt}/${maxAttempts})`);
|
|
1976
2694
|
} catch (e) {
|
|
1977
|
-
const
|
|
1978
|
-
|
|
2695
|
+
const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
|
|
2696
|
+
lastError = err.message;
|
|
2697
|
+
console.error(`[recorder] Action signal injection error (attempt ${attempt}/${maxAttempts}): ${err.message}`);
|
|
1979
2698
|
}
|
|
1980
2699
|
if (attempt < maxAttempts) {
|
|
1981
2700
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
1982
2701
|
}
|
|
1983
2702
|
}
|
|
1984
2703
|
if (!success) {
|
|
1985
|
-
|
|
2704
|
+
this._injectionFailed = true;
|
|
2705
|
+
console.error(`[recorder] WARNING: Action signal injection FAILED after ${maxAttempts} attempts. User actions will NOT be recorded. Last error: ${lastError}`);
|
|
1986
2706
|
}
|
|
1987
2707
|
try {
|
|
1988
2708
|
await page.evaluate(CHECKPOINT_OVERLAY_SCRIPT);
|
|
@@ -1990,6 +2710,16 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
1990
2710
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1991
2711
|
console.error(`[recorder] Checkpoint overlay injection failed (non-fatal): ${msg}`);
|
|
1992
2712
|
}
|
|
2713
|
+
if (success) {
|
|
2714
|
+
try {
|
|
2715
|
+
await page.evaluate(`(function() {
|
|
2716
|
+
if (typeof window.__xb_scanFilters === 'function') {
|
|
2717
|
+
setTimeout(window.__xb_scanFilters, 100);
|
|
2718
|
+
}
|
|
2719
|
+
})()`);
|
|
2720
|
+
} catch {
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
1993
2723
|
try {
|
|
1994
2724
|
await page.evaluate(`
|
|
1995
2725
|
(function() {
|
|
@@ -2141,9 +2871,9 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2141
2871
|
return;
|
|
2142
2872
|
}
|
|
2143
2873
|
} catch (e) {
|
|
2144
|
-
const
|
|
2874
|
+
const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
|
|
2145
2875
|
if (attempt < 4) {
|
|
2146
|
-
console.error(`[recorder] injectActionScript retry ${attempt + 1}/5 failed: ${
|
|
2876
|
+
console.error(`[recorder] injectActionScript retry ${attempt + 1}/5 failed: ${err.message}`);
|
|
2147
2877
|
}
|
|
2148
2878
|
}
|
|
2149
2879
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
@@ -2165,8 +2895,8 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2165
2895
|
try {
|
|
2166
2896
|
await this.injectActionScript(page);
|
|
2167
2897
|
} catch (e) {
|
|
2168
|
-
const
|
|
2169
|
-
console.error(`[recorder] injectActionScript after navigation failed: ${
|
|
2898
|
+
const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
|
|
2899
|
+
console.error(`[recorder] injectActionScript after navigation failed: ${err.message}`);
|
|
2170
2900
|
}
|
|
2171
2901
|
}
|
|
2172
2902
|
});
|
|
@@ -2287,6 +3017,14 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2287
3017
|
try {
|
|
2288
3018
|
pending = await page.evaluate(`(function() {
|
|
2289
3019
|
var w = window;
|
|
3020
|
+
// Flush-time sweep: if there is a hover action still being enriched
|
|
3021
|
+
// by async samplers, force one last capture so its hoverContext lands
|
|
3022
|
+
// before the action is drained from the pending queue.
|
|
3023
|
+
try {
|
|
3024
|
+
if (typeof w.__xb_capture_hover_popups === 'function') {
|
|
3025
|
+
w.__xb_capture_hover_popups();
|
|
3026
|
+
}
|
|
3027
|
+
} catch(e) {}
|
|
2290
3028
|
var actions = w.__xb_pending_actions || [];
|
|
2291
3029
|
w.__xb_pending_actions = [];
|
|
2292
3030
|
|
|
@@ -2351,11 +3089,58 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2351
3089
|
});
|
|
2352
3090
|
}
|
|
2353
3091
|
this.lastKnownUrl = currentUrl;
|
|
3092
|
+
try {
|
|
3093
|
+
page.evaluate(`(function() {
|
|
3094
|
+
if (typeof window.__xb_scanFilters === 'function') {
|
|
3095
|
+
setTimeout(window.__xb_scanFilters, 500);
|
|
3096
|
+
}
|
|
3097
|
+
})()`).catch(() => {
|
|
3098
|
+
});
|
|
3099
|
+
} catch {
|
|
3100
|
+
}
|
|
2354
3101
|
}
|
|
2355
3102
|
} catch {
|
|
2356
3103
|
}
|
|
2357
3104
|
for (const raw of pending) {
|
|
2358
3105
|
if (raw.ts <= this.lastActionTs) continue;
|
|
3106
|
+
if (raw.type === "discovered_filters") {
|
|
3107
|
+
if (Array.isArray(raw.discoveredFilters)) {
|
|
3108
|
+
for (const f of raw.discoveredFilters) {
|
|
3109
|
+
if (f && f.containerSelector) {
|
|
3110
|
+
const existing = this.discoveredFilters.get(f.containerSelector);
|
|
3111
|
+
if (existing) {
|
|
3112
|
+
for (const newT of f.triggers) {
|
|
3113
|
+
const oldT = existing.triggers.find((t) => t.selector === newT.selector);
|
|
3114
|
+
if (oldT) {
|
|
3115
|
+
newT.userInteracted = newT.userInteracted || oldT.userInteracted;
|
|
3116
|
+
newT.explored = newT.explored || oldT.explored;
|
|
3117
|
+
newT.hasPopup = newT.hasPopup || oldT.hasPopup;
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
this.discoveredFilters.set(f.containerSelector, f);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
continue;
|
|
3126
|
+
}
|
|
3127
|
+
if (raw.type === "popup_appear") {
|
|
3128
|
+
if (!raw.popupAppear?.userTriggered) continue;
|
|
3129
|
+
this.actionCounter++;
|
|
3130
|
+
const pushed2 = {
|
|
3131
|
+
id: this.actionCounter,
|
|
3132
|
+
type: "popup_appear",
|
|
3133
|
+
timestamp: raw.ts,
|
|
3134
|
+
url: raw.url || page.url(),
|
|
3135
|
+
pageTitle: raw.title || "",
|
|
3136
|
+
popupAppear: raw.popupAppear
|
|
3137
|
+
};
|
|
3138
|
+
this.actions.push(pushed2);
|
|
3139
|
+
await this.captureAutoSnapshot(page, pushed2, "after").catch(() => {
|
|
3140
|
+
});
|
|
3141
|
+
this.lastActionTs = raw.ts;
|
|
3142
|
+
continue;
|
|
3143
|
+
}
|
|
2359
3144
|
const dedupFromSignal = this.dedupKey(raw.type, raw.element?.selector, raw.element?.tag, raw.value);
|
|
2360
3145
|
if (dedupFromSignal) {
|
|
2361
3146
|
const expires = this.dedupMap.get(dedupFromSignal);
|
|
@@ -2364,7 +3149,19 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2364
3149
|
}
|
|
2365
3150
|
}
|
|
2366
3151
|
if (this.streamMode === "clean" && _SessionRecorder.AMBIENT_TYPES.has(raw.type)) {
|
|
2367
|
-
|
|
3152
|
+
if (raw.type === "hover") {
|
|
3153
|
+
const hc = raw.hoverContext;
|
|
3154
|
+
if (!hc || hc.appeared.length === 0) continue;
|
|
3155
|
+
const hasSubstantialPopup = hc.appeared.some((p) => p.items && p.items.length > 0);
|
|
3156
|
+
if (!hasSubstantialPopup) continue;
|
|
3157
|
+
} else if (raw.type === "scroll") {
|
|
3158
|
+
const dx = raw.scrollX || 0;
|
|
3159
|
+
const dy = raw.scrollY || 0;
|
|
3160
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
3161
|
+
if (dist < 50) continue;
|
|
3162
|
+
} else {
|
|
3163
|
+
continue;
|
|
3164
|
+
}
|
|
2368
3165
|
}
|
|
2369
3166
|
this.actionCounter++;
|
|
2370
3167
|
let clickContext;
|
|
@@ -2385,10 +3182,17 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2385
3182
|
scrollX: raw.scrollX,
|
|
2386
3183
|
scrollY: raw.scrollY,
|
|
2387
3184
|
clickContext,
|
|
3185
|
+
// Browser-side hover enrichment writes hoverContext onto the pending
|
|
3186
|
+
// action asynchronously (200/500/1000ms after mouseover). Flush may
|
|
3187
|
+
// run before the last sample lands — passthrough keeps whatever has
|
|
3188
|
+
// been collected so far.
|
|
3189
|
+
hoverContext: raw.hoverContext,
|
|
2388
3190
|
files: raw.files
|
|
2389
3191
|
});
|
|
2390
3192
|
const pushed = this.actions[this.actions.length - 1];
|
|
2391
3193
|
pushed.elementScreenshot = await this.captureElementScreenshot(page, raw);
|
|
3194
|
+
await this.captureAutoSnapshot(page, pushed, "after").catch(() => {
|
|
3195
|
+
});
|
|
2392
3196
|
this.lastActionTs = raw.ts;
|
|
2393
3197
|
const dedupKey = this.dedupKey(raw.type, raw.element?.selector, raw.element?.tag, raw.value);
|
|
2394
3198
|
if (dedupKey) {
|
|
@@ -2417,21 +3221,120 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2417
3221
|
* selector or the screenshot fails (non-critical — never blocks recording).
|
|
2418
3222
|
*
|
|
2419
3223
|
* Only captures for: click, input, change, dblclick, and their CDP counterparts.
|
|
3224
|
+
*
|
|
3225
|
+
* Retry strategy (3 attempts, 3s each):
|
|
3226
|
+
* 1. element screenshot via locator
|
|
3227
|
+
* 2. retry element screenshot (element may have been animating/detached)
|
|
3228
|
+
* 3. fallback to viewport screenshot (so we always get *something*)
|
|
2420
3229
|
*/
|
|
2421
3230
|
async captureElementScreenshot(page, action) {
|
|
2422
3231
|
const keyTypes = ["click", "input", "change", "dblclick", "cdp-click", "cdp-fill", "filechooser"];
|
|
2423
3232
|
if (!keyTypes.includes(action.type)) return;
|
|
2424
3233
|
if (!action.element?.selector) return;
|
|
3234
|
+
const selector = action.element.selector;
|
|
3235
|
+
try {
|
|
3236
|
+
const buffer = await page.locator(selector).first().screenshot({
|
|
3237
|
+
type: "png",
|
|
3238
|
+
timeout: 3e3
|
|
3239
|
+
});
|
|
3240
|
+
return buffer.toString("base64");
|
|
3241
|
+
} catch {
|
|
3242
|
+
}
|
|
2425
3243
|
try {
|
|
2426
|
-
|
|
3244
|
+
await page.waitForSelector(selector, { state: "visible", timeout: 2e3 });
|
|
3245
|
+
const buffer = await page.locator(selector).first().screenshot({
|
|
2427
3246
|
type: "png",
|
|
2428
|
-
timeout:
|
|
3247
|
+
timeout: 3e3
|
|
2429
3248
|
});
|
|
2430
3249
|
return buffer.toString("base64");
|
|
3250
|
+
} catch {
|
|
3251
|
+
}
|
|
3252
|
+
try {
|
|
3253
|
+
const buffer = await page.screenshot({ type: "png", timeout: 3e3 });
|
|
3254
|
+
return buffer.toString("base64");
|
|
2431
3255
|
} catch {
|
|
2432
3256
|
return;
|
|
2433
3257
|
}
|
|
2434
3258
|
}
|
|
3259
|
+
/**
|
|
3260
|
+
* Action types that trigger auto-snapshot capture.
|
|
3261
|
+
* Hover is excluded — it's too noisy and rarely changes the page visually.
|
|
3262
|
+
* Popup_appear captures the popup state (after only).
|
|
3263
|
+
*/
|
|
3264
|
+
static SNAPSHOT_TYPES = /* @__PURE__ */ new Set([
|
|
3265
|
+
"click",
|
|
3266
|
+
"dblclick",
|
|
3267
|
+
"navigation",
|
|
3268
|
+
"goto",
|
|
3269
|
+
"submit",
|
|
3270
|
+
"cdp-click",
|
|
3271
|
+
"cdp-fill",
|
|
3272
|
+
"input",
|
|
3273
|
+
"change",
|
|
3274
|
+
"filechooser",
|
|
3275
|
+
"popup_appear"
|
|
3276
|
+
]);
|
|
3277
|
+
/**
|
|
3278
|
+
* Throttle key for snapshot capture to avoid too many during rapid inputs.
|
|
3279
|
+
* Returns true if this action should be throttled (skip snapshot).
|
|
3280
|
+
*/
|
|
3281
|
+
isSnapshotThrottled(type, selector) {
|
|
3282
|
+
if (type === "input" || type === "change" || type === "cdp-fill") {
|
|
3283
|
+
const key = `${type}|${selector || ""}`;
|
|
3284
|
+
const last = this.snapshotThrottle.get(key);
|
|
3285
|
+
const now = Date.now();
|
|
3286
|
+
if (last && now - last < 2e3) return true;
|
|
3287
|
+
this.snapshotThrottle.set(key, now);
|
|
3288
|
+
}
|
|
3289
|
+
return false;
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Capture a viewport screenshot + compact accessibility tree for a key action.
|
|
3293
|
+
* - PNG stored in .tmp/snapshots/ (temporary, easy to clean up)
|
|
3294
|
+
* - aria tree stored inline as compact text on the action (searchable via grep)
|
|
3295
|
+
*
|
|
3296
|
+
* Non-blocking (errors silently caught). Throttled for rapid inputs.
|
|
3297
|
+
*/
|
|
3298
|
+
async captureAutoSnapshot(page, action, suffix = "after") {
|
|
3299
|
+
if (!_SessionRecorder.SNAPSHOT_TYPES.has(action.type)) return;
|
|
3300
|
+
if (this.isSnapshotThrottled(action.type, action.element?.selector)) return;
|
|
3301
|
+
const snapDir = this.snapshotsDir;
|
|
3302
|
+
mkdirSync2(snapDir, { recursive: true });
|
|
3303
|
+
const stepId = String(action.id).padStart(3, "0");
|
|
3304
|
+
const snapshot = {};
|
|
3305
|
+
try {
|
|
3306
|
+
const pngPath = join2(snapDir, `step-${stepId}-${suffix}.png`);
|
|
3307
|
+
const buffer = await page.screenshot({ type: "png", timeout: 5e3 });
|
|
3308
|
+
writeFileSync2(pngPath, buffer);
|
|
3309
|
+
snapshot.png = `.tmp/snapshots/step-${stepId}-${suffix}.png`;
|
|
3310
|
+
} catch {
|
|
3311
|
+
}
|
|
3312
|
+
try {
|
|
3313
|
+
snapshot.aria = await page.evaluate(`
|
|
3314
|
+
(function() {
|
|
3315
|
+
try {
|
|
3316
|
+
var parts = [];
|
|
3317
|
+
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
|
|
3318
|
+
var node;
|
|
3319
|
+
while (node = walker.nextNode()) {
|
|
3320
|
+
var tag = node.tagName.toLowerCase();
|
|
3321
|
+
var role = node.getAttribute('role') || '';
|
|
3322
|
+
var text = (node.textContent || '').trim().substring(0, 60);
|
|
3323
|
+
var ariaLabel = node.getAttribute('aria-label') || '';
|
|
3324
|
+
if (role || tag === 'button' || tag === 'a' || tag === 'input' || tag === 'select' || tag === 'textarea') {
|
|
3325
|
+
parts.push((role || tag) + ': ' + (ariaLabel || text).substring(0, 40));
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
return parts.slice(0, 100).join(' | ');
|
|
3329
|
+
} catch(e) { return ''; }
|
|
3330
|
+
})()
|
|
3331
|
+
`);
|
|
3332
|
+
} catch {
|
|
3333
|
+
}
|
|
3334
|
+
if (snapshot.png || snapshot.aria) {
|
|
3335
|
+
action.snapshots = snapshot;
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
2435
3338
|
/**
|
|
2436
3339
|
* After a click, wait 300ms then scan for popover/dropdown/menu elements
|
|
2437
3340
|
* near the click position. This runs server-side to avoid race conditions
|
|
@@ -2631,12 +3534,18 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2631
3534
|
JSON.stringify(data, null, 2),
|
|
2632
3535
|
"utf-8"
|
|
2633
3536
|
);
|
|
3537
|
+
const jsonlPath = join2(this.recordingsDir, "recording.jsonl");
|
|
3538
|
+
const lines = data.actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
3539
|
+
writeFileSync2(jsonlPath, lines, "utf-8");
|
|
2634
3540
|
} catch {
|
|
2635
3541
|
}
|
|
2636
3542
|
}
|
|
2637
3543
|
writeFinalOutput(data, summary) {
|
|
2638
3544
|
mkdirSync2(this.recordingsDir, { recursive: true });
|
|
2639
3545
|
writeFileSync2(join2(this.recordingsDir, "recording.json"), JSON.stringify(data, null, 2), "utf-8");
|
|
3546
|
+
const jsonlPath = join2(this.recordingsDir, "recording.jsonl");
|
|
3547
|
+
const lines = data.actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
3548
|
+
writeFileSync2(jsonlPath, lines, "utf-8");
|
|
2640
3549
|
writeFileSync2(join2(this.recordingsDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
|
2641
3550
|
writeFileSync2(join2(this.recordingsDir, "summary.md"), this.buildMarkdownSummary(data, summary), "utf-8");
|
|
2642
3551
|
}
|
|
@@ -2648,7 +3557,8 @@ var SessionRecorder = class _SessionRecorder {
|
|
|
2648
3557
|
actions: [...this.actions],
|
|
2649
3558
|
network: [...this.network],
|
|
2650
3559
|
contextChanges: [...this.contextChanges],
|
|
2651
|
-
checkpoints: [...this.checkpoints]
|
|
3560
|
+
checkpoints: [...this.checkpoints],
|
|
3561
|
+
discoveredFilters: [...this.discoveredFilters.values()]
|
|
2652
3562
|
};
|
|
2653
3563
|
}
|
|
2654
3564
|
// ─── Summary builder with ref compression + input→network matching ──
|