@xbrowser/cli 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{anti-bot-DR56Y63V.js → anti-bot-GTTYNEFB.js} +1 -1
- package/dist/{browser-WQZ3D6AE.js → browser-3HQKDZQ6.js} +4 -2
- package/dist/{browser-T3V3JWVH.js → browser-ILXTEWH4.js} +1 -1
- package/dist/{browser-HBL72GPZ.js → browser-XPDMY2NX.js} +3 -3
- package/dist/{cdp-driver-J3YQ4LJV.js → cdp-driver-ESH3PDE6.js} +2 -1
- package/dist/cdp-driver-N2E2ZNSC.js +4644 -0
- package/dist/cdp-driver-YIKR4BUX.js +49 -0
- package/dist/chunk-3FWLW7FS.js +106 -0
- package/dist/chunk-A6LPGFAL.js +437 -0
- package/dist/{chunk-RRBXV7KE.js → chunk-DKM2JOZL.js} +633 -63
- package/dist/{chunk-ABXMBNQ6.js → chunk-H2A5JUK5.js} +37 -466
- package/dist/{chunk-5UGR6MUK.js → chunk-HBHOKZPN.js} +51 -16
- package/dist/{chunk-BNO7OKO4.js → chunk-HBMEFSTB.js} +39 -0
- package/dist/{chunk-INTQPBYF.js → chunk-JKVUFP3G.js} +6 -2
- package/dist/chunk-KJTABK3Z.js +1255 -0
- package/dist/{chunk-DWDXEGVK.js → chunk-NKW4A74J.js} +9 -3
- package/dist/{chunk-4452SPFI.js → chunk-NODRQGOK.js} +51 -16
- package/dist/{chunk-SQHSZENE.js → chunk-OMU63E6J.js} +3 -22
- package/dist/{chunk-NITFVWWS.js → chunk-OVW2UEWN.js} +981 -407
- package/dist/{chunk-WSCP7QCJ.js → chunk-R6IJ6PIV.js} +14 -11
- package/dist/{chunk-YMUSHPU4.js → chunk-SH6OTPXN.js} +46 -16
- package/dist/{chunk-MWFHZUIY.js → chunk-TEXCXIBW.js} +1 -1
- package/dist/{cdp-driver-2T4P4ZE2.js → chunk-Z6CUR3VG.js} +1542 -134
- package/dist/{chunk-IIM5GOD7.js → chunk-ZTHE5RBZ.js} +7 -1
- package/dist/cli.js +1136 -584
- package/dist/{daemon-client-GKEPT4NY.js → daemon-client-7D6EZNOE.js} +46 -16
- package/dist/{daemon-client-O6BYVRXV.js → daemon-client-HEGAXWGK.js} +1 -1
- package/dist/daemon-main.js +1031 -522
- package/dist/{human-interaction-5AO42MBA.js → human-interaction-4YR2N6R6.js} +2 -2
- package/dist/{human-interaction-C5OEGXGO.js → human-interaction-Y5IDH6LD.js} +1 -1
- package/dist/{human-interaction-ISXZTAYY.js → human-interaction-ZUTR5AC2.js} +2 -2
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1147 -593
- package/dist/{proxy-C6CK3UH5.js → proxy-LUR4U5YF.js} +2 -1
- package/dist/{recovery-J2ISVGUL.js → recovery-FTZGV6VY.js} +2 -2
- package/dist/{recovery-ZJVVHP7N.js → recovery-L5GLDX4O.js} +1 -1
- package/dist/{recovery-EF33LKRJ.js → recovery-Z3RDZENA.js} +2 -2
- package/dist/{session-recorder-H3KEYU26.js → session-recorder-3BEVWHOK.js} +1 -1
- package/dist/{session-recorder-QRZMKFVL.js → session-recorder-SLDBENVF.js} +1 -1
- package/dist/{session-replayer-LJUC4TI7.js → session-replayer-K4A6OI4M.js} +90 -2
- package/package.json +1 -1
- package/dist/chunk-BAHSRZIX.js +0 -2191
|
@@ -0,0 +1,4644 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connectToCDP,
|
|
3
|
+
errMsg,
|
|
4
|
+
findChrome,
|
|
5
|
+
getCDPTargets,
|
|
6
|
+
killChrome,
|
|
7
|
+
launchChrome
|
|
8
|
+
} from "./chunk-AMI64BSD.js";
|
|
9
|
+
import {
|
|
10
|
+
__require
|
|
11
|
+
} from "./chunk-3RG5ZIWI.js";
|
|
12
|
+
|
|
13
|
+
// src/cdp-driver/browser.ts
|
|
14
|
+
import { EventEmitter as EventEmitter3 } from "events";
|
|
15
|
+
|
|
16
|
+
// src/cdp-driver/context.ts
|
|
17
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
18
|
+
|
|
19
|
+
// src/cdp-driver/page.ts
|
|
20
|
+
import { EventEmitter } from "events";
|
|
21
|
+
|
|
22
|
+
// src/cdp-driver/stealth.ts
|
|
23
|
+
var DEFAULT_STEALTH_CONFIG = {
|
|
24
|
+
bezierCurvature: [0.35, 0.6],
|
|
25
|
+
noiseAmplitude: 5.5,
|
|
26
|
+
overshootRange: [6, 14],
|
|
27
|
+
aimPause: [150, 400],
|
|
28
|
+
pressDuration: [60, 140],
|
|
29
|
+
releaseDrift: [0.8, 2.5],
|
|
30
|
+
landingOffsetSmall: [0.3, 2.5],
|
|
31
|
+
landingOffsetLarge: [1.5, 7],
|
|
32
|
+
smallElementThreshold: 30,
|
|
33
|
+
typingRhythm: {
|
|
34
|
+
fastProb: 0.22,
|
|
35
|
+
fastRange: [25, 60],
|
|
36
|
+
normalRange: [50, 350],
|
|
37
|
+
pauseProb: 0.18,
|
|
38
|
+
pauseRange: [400, 1200]
|
|
39
|
+
},
|
|
40
|
+
keyPressDuration: [50, 110],
|
|
41
|
+
typoProbability: 0.06,
|
|
42
|
+
wheelPeak: 180,
|
|
43
|
+
wheelDecayRate: 0.4
|
|
44
|
+
};
|
|
45
|
+
function rand(min, max) {
|
|
46
|
+
return min + Math.random() * (max - min);
|
|
47
|
+
}
|
|
48
|
+
function cosineEase(t) {
|
|
49
|
+
return 0.5 - 0.5 * Math.cos(Math.PI * t);
|
|
50
|
+
}
|
|
51
|
+
function bezierTrajectory(x0, y0, x1, y1, config = DEFAULT_STEALTH_CONFIG) {
|
|
52
|
+
const dist = Math.hypot(x1 - x0, y1 - y0);
|
|
53
|
+
const n = Math.max(10, Math.min(28, Math.round(dist / 15)));
|
|
54
|
+
const shortMove = dist < 120;
|
|
55
|
+
const curvature = shortMove ? rand(2, 6) : Math.max(dist * rand(...config.bezierCurvature), rand(18, 35));
|
|
56
|
+
const dir = Math.random() < 0.5 ? 1 : -1;
|
|
57
|
+
const d = dist || 1;
|
|
58
|
+
const dx = x1 - x0, dy = y1 - y0;
|
|
59
|
+
const c1x = x0 + dx * 0.3 - dy / d * curvature * 0.5 * dir;
|
|
60
|
+
const c1y = y0 + dy * 0.3 + dx / d * curvature * 0.5 * dir;
|
|
61
|
+
const c2x = x0 + dx * 0.7 - dy / d * curvature * 0.8 * dir;
|
|
62
|
+
const c2y = y0 + dy * 0.7 + dx / d * curvature * 0.8 * dir;
|
|
63
|
+
const points = [];
|
|
64
|
+
for (let i = 1; i <= n; i++) {
|
|
65
|
+
const t = cosineEase(i / n);
|
|
66
|
+
const mt = 1 - t;
|
|
67
|
+
let px = mt ** 3 * x0 + 3 * mt ** 2 * t * c1x + 3 * mt * t ** 2 * c2x + t ** 3 * x1;
|
|
68
|
+
let py = mt ** 3 * y0 + 3 * mt ** 2 * t * c1y + 3 * mt * t ** 2 * c2y + t ** 3 * y1;
|
|
69
|
+
const amp = shortMove ? Math.min(2, config.noiseAmplitude) : config.noiseAmplitude;
|
|
70
|
+
px += rand(-amp, amp);
|
|
71
|
+
py += rand(-amp, amp);
|
|
72
|
+
points.push({ x: px, y: py, delay: rand(9, 16) });
|
|
73
|
+
}
|
|
74
|
+
if (!shortMove) {
|
|
75
|
+
const over = rand(...config.overshootRange);
|
|
76
|
+
const ox = x1 + dx / d * over + rand(-2, 2);
|
|
77
|
+
const oy = y1 + dy / d * over + rand(-2, 2);
|
|
78
|
+
points.push({ x: ox, y: oy, delay: rand(14, 30) });
|
|
79
|
+
points.push({
|
|
80
|
+
x: x1 + dx / d * over * 0.4,
|
|
81
|
+
y: y1 + dy / d * over * 0.4,
|
|
82
|
+
delay: rand(14, 30)
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
points.push({ x: x1 + rand(-1, 1), y: y1 + rand(-1, 1), delay: rand(14, 30) });
|
|
86
|
+
return points;
|
|
87
|
+
}
|
|
88
|
+
function landingOffset(width, height, config = DEFAULT_STEALTH_CONFIG) {
|
|
89
|
+
const isSmall = Math.min(width, height) < config.smallElementThreshold;
|
|
90
|
+
const range = isSmall ? config.landingOffsetSmall : config.landingOffsetLarge;
|
|
91
|
+
const dx = rand(...range) * (Math.random() < 0.5 ? -1 : 1);
|
|
92
|
+
const dy = rand(...range) * (Math.random() < 0.5 ? -1 : 1);
|
|
93
|
+
return { dx, dy };
|
|
94
|
+
}
|
|
95
|
+
var KEY_MAP = {};
|
|
96
|
+
for (let i = 97; i <= 122; i++) {
|
|
97
|
+
const ch = String.fromCharCode(i);
|
|
98
|
+
KEY_MAP[ch] = { key: ch, code: "Key" + ch.toUpperCase(), vk: i - 32 };
|
|
99
|
+
}
|
|
100
|
+
for (let i = 65; i <= 90; i++) {
|
|
101
|
+
const ch = String.fromCharCode(i);
|
|
102
|
+
KEY_MAP[ch] = { key: ch, code: "Key" + ch, vk: i, shift: true };
|
|
103
|
+
}
|
|
104
|
+
for (let i = 48; i <= 57; i++) {
|
|
105
|
+
const ch = String.fromCharCode(i);
|
|
106
|
+
KEY_MAP[ch] = { key: ch, code: "Digit" + ch, vk: i };
|
|
107
|
+
}
|
|
108
|
+
Object.assign(KEY_MAP, {
|
|
109
|
+
" ": { key: " ", code: "Space", vk: 32 },
|
|
110
|
+
".": { key: ".", code: "Period", vk: 190 },
|
|
111
|
+
"-": { key: "-", code: "Minus", vk: 189 },
|
|
112
|
+
"@": { key: "@", code: "Digit2", vk: 50, shift: true },
|
|
113
|
+
"_": { key: "_", code: "Minus", vk: 189, shift: true }
|
|
114
|
+
});
|
|
115
|
+
function buildStealthInitScript() {
|
|
116
|
+
return [
|
|
117
|
+
"(function(){",
|
|
118
|
+
// 1. AEL event proxy
|
|
119
|
+
" var o=EventTarget.prototype.addEventListener;",
|
|
120
|
+
" var fc=new InputDeviceCapabilities({firesTouchEvents:false});",
|
|
121
|
+
" var _ael=function(t,f){",
|
|
122
|
+
" var op=arguments[2];",
|
|
123
|
+
' if(typeof f!=="function")return o.call(this,t,f,op);',
|
|
124
|
+
" var w=function(e){",
|
|
125
|
+
" if(!e||e.constructor===FocusEvent||e.constructor===KeyboardEvent)return f.call(this,e);",
|
|
126
|
+
" return f.call(this,new Proxy(e,{get:function(k,p){",
|
|
127
|
+
' if(p==="sourceCapabilities")return fc;',
|
|
128
|
+
' if(p==="isTrusted")return k.isTrusted;',
|
|
129
|
+
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
|
|
130
|
+
" var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;",
|
|
131
|
+
" return k[p]+_f;",
|
|
132
|
+
" }",
|
|
133
|
+
' var v=Reflect.get(k,p);return typeof v==="function"?v.bind(k):v;',
|
|
134
|
+
" }}));",
|
|
135
|
+
" };",
|
|
136
|
+
" return o.call(this,t,w,op);",
|
|
137
|
+
" };",
|
|
138
|
+
" EventTarget.prototype.addEventListener=_ael;",
|
|
139
|
+
// 2. Screen override (prototype-level, not instance-level)
|
|
140
|
+
" var _gw=function(){return 1728};",
|
|
141
|
+
" var _gh=function(){return 1117};",
|
|
142
|
+
" var _gah=function(){return 1092};",
|
|
143
|
+
' Object.defineProperty(Screen.prototype,"width",{get:_gw,configurable:true});',
|
|
144
|
+
' Object.defineProperty(Screen.prototype,"height",{get:_gh,configurable:true});',
|
|
145
|
+
' Object.defineProperty(Screen.prototype,"availWidth",{get:_gw,configurable:true});',
|
|
146
|
+
' Object.defineProperty(Screen.prototype,"availHeight",{get:_gah,configurable:true});',
|
|
147
|
+
" document.hasFocus=function(){return true};",
|
|
148
|
+
// 4. Canvas/WebGL fingerprint: per-session stable noise (d20).
|
|
149
|
+
// Headless software raster differs subtly from Chrome GPU raster —
|
|
150
|
+
// toDataURL hashes fingerprint the rasterizer. Inject a stable
|
|
151
|
+
// (per-page-load) subpixel shift into fillText so hashes look like a
|
|
152
|
+
// distinct-but-consistent real GPU, and hide the HEADLESS tell in
|
|
153
|
+
// WebGL renderer strings.
|
|
154
|
+
" var _seed=Math.floor(Math.random()*2147483647);",
|
|
155
|
+
" var _prng=function(){_seed=(_seed*48271)%2147483647;return _seed/2147483647;};",
|
|
156
|
+
" var _dx=(_prng()*0.4-0.2).toFixed(3)*1, _dy=(_prng()*0.4-0.2).toFixed(3)*1;",
|
|
157
|
+
" var _fillText=CanvasRenderingContext2D.prototype.fillText;",
|
|
158
|
+
" CanvasRenderingContext2D.prototype.fillText=function(t,x,y,m){",
|
|
159
|
+
" return _fillText.call(this,t,x+_dx,y+_dy,m);",
|
|
160
|
+
" };",
|
|
161
|
+
" var _toDataURL=HTMLCanvasElement.prototype.toDataURL;",
|
|
162
|
+
" HTMLCanvasElement.prototype.toDataURL=function(){",
|
|
163
|
+
' var ctx=this.getContext("2d");',
|
|
164
|
+
" if(ctx){var d=ctx.getImageData(0,0,Math.min(this.width,2),Math.min(this.height,2));",
|
|
165
|
+
" for(var i=0;i<d.data.length;i+=4){if(d.data[i+3]>0){d.data[i]^=1;break;}}",
|
|
166
|
+
" ctx.putImageData(d,0,0);}",
|
|
167
|
+
" return _toDataURL.apply(this,arguments);",
|
|
168
|
+
" };",
|
|
169
|
+
" try{",
|
|
170
|
+
" var _gl=HTMLCanvasElement.prototype.getContext;",
|
|
171
|
+
" HTMLCanvasElement.prototype.getContext=function(t,o){",
|
|
172
|
+
" var c=_gl.call(this,t,o);",
|
|
173
|
+
' if(c&&(t==="webgl"||t==="experimental-webgl")&&c.getParameter){',
|
|
174
|
+
" var _gp=c.getParameter.bind(c);",
|
|
175
|
+
" c.getParameter=function(p){",
|
|
176
|
+
" var v=_gp(p);",
|
|
177
|
+
' if(typeof v==="string"&&/SwiftShader|Software|Rasterizer|Headless/i.test(v))',
|
|
178
|
+
' return "ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version)";',
|
|
179
|
+
" return v;};}",
|
|
180
|
+
" return c;};",
|
|
181
|
+
" }catch(e){}",
|
|
182
|
+
// 5. AudioContext fingerprint: per-load stable micro-noise on channel
|
|
183
|
+
// data (d21). DSP sum differences between headless software audio and
|
|
184
|
+
// real hardware audio are a classic fingerprint — add ±1e-7 level
|
|
185
|
+
// noise (inaudible, changes the sum hash).
|
|
186
|
+
" try{",
|
|
187
|
+
" var _gcd=AudioBuffer.prototype.getChannelData;",
|
|
188
|
+
" AudioBuffer.prototype.getChannelData=function(ch){",
|
|
189
|
+
" var d=_gcd.call(this,ch);",
|
|
190
|
+
' var key="__xb_audio_"+ch;',
|
|
191
|
+
" if(!this[key]){",
|
|
192
|
+
" this[key]=true;",
|
|
193
|
+
" for(var i=0;i<d.length;i+=997){d[i]=d[i]+(_prng()-0.5)*2e-7;}",
|
|
194
|
+
" }",
|
|
195
|
+
" return d;",
|
|
196
|
+
" };",
|
|
197
|
+
" var _gffd=AnalyserNode.prototype.getFloatFrequencyData;",
|
|
198
|
+
" AnalyserNode.prototype.getFloatFrequencyData=function(arr){",
|
|
199
|
+
" _gffd.call(this,arr);",
|
|
200
|
+
" for(var i=0;i<arr.length;i+=31){arr[i]=arr[i]+(_prng()-0.5)*0.01;}",
|
|
201
|
+
" };",
|
|
202
|
+
" var _gfbd=AnalyserNode.prototype.getByteFrequencyData;",
|
|
203
|
+
" AnalyserNode.prototype.getByteFrequencyData=function(arr){",
|
|
204
|
+
" _gfbd.call(this,arr);",
|
|
205
|
+
" for(var i=0;i<arr.length;i+=31){arr[i]=(arr[i]+((_prng()*3)|0))&255;}",
|
|
206
|
+
" };",
|
|
207
|
+
" }catch(e){}",
|
|
208
|
+
// 3b. Font metrics + speechSynthesis + battery (d22): headless tells
|
|
209
|
+
" try{",
|
|
210
|
+
" var _mt=CanvasRenderingContext2D.prototype.measureText;",
|
|
211
|
+
' var _tmw=Object.getOwnPropertyDescriptor(TextMetrics.prototype,"width");',
|
|
212
|
+
" CanvasRenderingContext2D.prototype.measureText=function(t){",
|
|
213
|
+
" var m=_mt.call(this,t);",
|
|
214
|
+
' try{Object.defineProperty(m,"width",{get:function(){return _tmw.get.call(m)+_dx*0.01;},configurable:true});}catch(e){}',
|
|
215
|
+
" return m;",
|
|
216
|
+
" };",
|
|
217
|
+
" }catch(e){}",
|
|
218
|
+
" try{",
|
|
219
|
+
' var _fakeVoices=["Alex","Daniel","Karen","Moira","Ralph","Samantha","Ting-Ting","Mei-Jia","Sinji","Yunda"];',
|
|
220
|
+
" var _sv=speechSynthesis.getVoices.bind(speechSynthesis);",
|
|
221
|
+
" speechSynthesis.getVoices=function(){",
|
|
222
|
+
" var real=_sv();",
|
|
223
|
+
" if(real&&real.length)return real;",
|
|
224
|
+
' return _fakeVoices.map(function(n,i){return {name:n,lang:i<2?"en-US":i<6?"en-GB":"zh-TW",localService:true,default:i===0,voiceURI:n};});',
|
|
225
|
+
" };",
|
|
226
|
+
" }catch(e){}",
|
|
227
|
+
" try{",
|
|
228
|
+
" var _gb=navigator.getBattery.bind(navigator);",
|
|
229
|
+
" navigator.getBattery=function(){",
|
|
230
|
+
" return _gb().then(function(b){",
|
|
231
|
+
' Object.defineProperty(b,"charging",{get:function(){return true;},configurable:true});',
|
|
232
|
+
" return b;});",
|
|
233
|
+
" };",
|
|
234
|
+
" }catch(e){}",
|
|
235
|
+
// 3c. WebRTC local IP leak guard (d23): headless STUN candidates can
|
|
236
|
+
// expose host IPs; mDNS-only candidates are the modern Chrome default.
|
|
237
|
+
" try{",
|
|
238
|
+
" var _oc=RTCPeerConnection.prototype.createOffer;",
|
|
239
|
+
" RTCPeerConnection.prototype.createOffer=function(){",
|
|
240
|
+
" var p=_oc.apply(this,arguments);",
|
|
241
|
+
" var self=this;",
|
|
242
|
+
" return p.then(function(offer){",
|
|
243
|
+
' offer.sdp=offer.sdp.split(String.fromCharCode(10)).filter(function(l){return l.indexOf("typ host")<0}).join(String.fromCharCode(10));',
|
|
244
|
+
" return offer;});",
|
|
245
|
+
" };",
|
|
246
|
+
" }catch(e){}",
|
|
247
|
+
// 3d. Chrome object depth (d24): automation fakes usually only set
|
|
248
|
+
// window.chrome = {}; deep checks hit app.run/runtime/csi/loadTimes.
|
|
249
|
+
" try{",
|
|
250
|
+
" if(window.chrome){",
|
|
251
|
+
' if(!window.chrome.app||!window.chrome.app.run)window.chrome.app={run:function(){},load:function(){},getDetails:function(){return null},InstallState:{DISABLED:"disabled",INSTALLED:"installed",NOT_INSTALLED:"not_installed"},RunningState:{CANNOT_RUN:"cannot_run",READY_TO_RUN:"ready_to_run",RUNNING:"running"}};',
|
|
252
|
+
' if(!window.chrome.runtime)window.chrome.runtime={OnInstalledReason:{CHROME_UPDATE:"chrome_update",INSTALL:"install",UPDATE:"update"},PlatformOs:{ANDROID:"android",CROS:"cros",LINUX:"linux",MAC:"mac",OPENBSD:"openbsd",WIN:"win"},connect:function(){},sendMessage:function(){},id:undefined};',
|
|
253
|
+
" if(!window.chrome.csi)window.chrome.csi=function(){return{};}",
|
|
254
|
+
" if(!window.chrome.loadTimes)window.chrome.loadTimes=function(){return{};}",
|
|
255
|
+
" }",
|
|
256
|
+
" }catch(e){}",
|
|
257
|
+
// 3e. Font availability patch (d25): headless Chromium misses some system
|
|
258
|
+
// fonts (e.g. Menlo on macOS) that real Chrome has. Register a FontFace
|
|
259
|
+
// aliasing the missing font to a local() equivalent so offsetWidth-based
|
|
260
|
+
// font probing sees the same availability as the faked environment.
|
|
261
|
+
" try{",
|
|
262
|
+
' var _fontAliases=[["Menlo","Courier New"],["SF Mono","Menlo"],["Segoe UI","Helvetica"]];',
|
|
263
|
+
" _fontAliases.forEach(function(pa){",
|
|
264
|
+
' try{var ff=new FontFace(pa[0],"local("+JSON.stringify(pa[1])+")");',
|
|
265
|
+
" document.fonts.add(ff);ff.load();}catch(e2){}",
|
|
266
|
+
" });",
|
|
267
|
+
" }catch(e){}",
|
|
268
|
+
// 3. toString disguise (name-list based)
|
|
269
|
+
" var _ts=Function.prototype.toString;",
|
|
270
|
+
" var _hf=document.hasFocus;",
|
|
271
|
+
" Function.prototype.toString=function(){",
|
|
272
|
+
' if(this===_ael)return"function addEventListener(type, callback) { [native code] }";',
|
|
273
|
+
' if(this===_hf)return"function hasFocus() { [native code] }";',
|
|
274
|
+
' if(this===_gw)return"function get width() { [native code] }";',
|
|
275
|
+
' if(this===_gh)return"function get height() { [native code] }";',
|
|
276
|
+
' if(this===_gah)return"function get availHeight() { [native code] }";',
|
|
277
|
+
' if(this===CanvasRenderingContext2D.prototype.fillText)return"function fillText() { [native code] }";',
|
|
278
|
+
' if(this===HTMLCanvasElement.prototype.toDataURL)return"function toDataURL() { [native code] }";',
|
|
279
|
+
' if(this===AnalyserNode.prototype.getFloatFrequencyData)return"function getFloatFrequencyData() { [native code] }";',
|
|
280
|
+
' if(this===AudioBuffer.prototype.getChannelData)return"function getChannelData() { [native code] }";',
|
|
281
|
+
" return _ts.call(this);",
|
|
282
|
+
" };",
|
|
283
|
+
// 4. onclick prototype hijack (dual-stream consistency)
|
|
284
|
+
" var _ba=function(k,p){",
|
|
285
|
+
' if(p==="isTrusted")return k.isTrusted;',
|
|
286
|
+
' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
|
|
287
|
+
" var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;return k[p]+_f;",
|
|
288
|
+
" }",
|
|
289
|
+
' var v=Reflect.get(k,p);return typeof v==="function"?v.bind(k):v;',
|
|
290
|
+
" };",
|
|
291
|
+
' Object.defineProperty(Document.prototype,"onclick",{',
|
|
292
|
+
" configurable:true,",
|
|
293
|
+
" get:function(){var raw=this.__ocRaw||null;if(!raw)return null;var self=this;",
|
|
294
|
+
" return function(e){return raw.call(self,new Proxy(e,{get:function(k,p){return _ba(k,p)}}))}},",
|
|
295
|
+
" set:function(fn){this.__ocRaw=fn}",
|
|
296
|
+
" });",
|
|
297
|
+
"})()"
|
|
298
|
+
].join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/cdp-driver/mouse.ts
|
|
302
|
+
var XBMouseImpl = class {
|
|
303
|
+
conn;
|
|
304
|
+
sessionId;
|
|
305
|
+
_x = 0;
|
|
306
|
+
_y = 0;
|
|
307
|
+
_button = "none";
|
|
308
|
+
constructor(conn, sessionId) {
|
|
309
|
+
this.conn = conn;
|
|
310
|
+
this.sessionId = sessionId;
|
|
311
|
+
}
|
|
312
|
+
/** Current cursor X position */
|
|
313
|
+
get x() {
|
|
314
|
+
return this._x;
|
|
315
|
+
}
|
|
316
|
+
/** Current cursor Y position */
|
|
317
|
+
get y() {
|
|
318
|
+
return this._y;
|
|
319
|
+
}
|
|
320
|
+
async click(x, y, opts = {}) {
|
|
321
|
+
const button = opts.button ?? "left";
|
|
322
|
+
const stealth = opts.stealth ?? process.env.XBROWSER_STEALTH !== "off";
|
|
323
|
+
let tx = x, ty = y;
|
|
324
|
+
if (stealth && opts.elementWidth !== void 0 && opts.elementHeight !== void 0) {
|
|
325
|
+
const off = landingOffset(opts.elementWidth, opts.elementHeight);
|
|
326
|
+
tx += off.dx;
|
|
327
|
+
ty += off.dy;
|
|
328
|
+
}
|
|
329
|
+
if (stealth) {
|
|
330
|
+
const traj = bezierTrajectory(this._x, this._y, tx, ty);
|
|
331
|
+
const _tb = Date.now();
|
|
332
|
+
let _truncated = false;
|
|
333
|
+
for (const p of traj) {
|
|
334
|
+
if (Date.now() - _tb > 5e3) {
|
|
335
|
+
_truncated = true;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y, button: this._button });
|
|
339
|
+
this._x = p.x;
|
|
340
|
+
this._y = p.y;
|
|
341
|
+
await sleep(p.delay);
|
|
342
|
+
}
|
|
343
|
+
this._x = tx;
|
|
344
|
+
this._y = ty;
|
|
345
|
+
if (_truncated) {
|
|
346
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: tx, y: ty, button: this._button });
|
|
347
|
+
}
|
|
348
|
+
await sleep(rand(...DEFAULT_STEALTH_CONFIG.aimPause));
|
|
349
|
+
} else {
|
|
350
|
+
await this.move(tx, ty);
|
|
351
|
+
}
|
|
352
|
+
await this.down({ button });
|
|
353
|
+
await sleep(stealth ? rand(...DEFAULT_STEALTH_CONFIG.pressDuration) : opts.delay ?? 0);
|
|
354
|
+
const rx = stealth ? this._x + rand(...DEFAULT_STEALTH_CONFIG.releaseDrift) * (Math.random() < 0.5 ? -1 : 1) : this._x;
|
|
355
|
+
const ry = stealth ? this._y + rand(...DEFAULT_STEALTH_CONFIG.releaseDrift) * (Math.random() < 0.5 ? -1 : 1) : this._y;
|
|
356
|
+
this._x = rx;
|
|
357
|
+
this._y = ry;
|
|
358
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: rx, y: ry, button, clickCount: opts.clickCount ?? 1 });
|
|
359
|
+
for (let i = 1; i < (opts.clickCount ?? 1); i++) {
|
|
360
|
+
if (opts.delay) await sleep(opts.delay);
|
|
361
|
+
await this.down({ button });
|
|
362
|
+
await this.up({ button });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async dblclick(x, y, opts = {}) {
|
|
366
|
+
await this.click(x, y, { clickCount: 2, button: opts.button });
|
|
367
|
+
}
|
|
368
|
+
async down(opts = {}) {
|
|
369
|
+
const button = opts.button ?? "left";
|
|
370
|
+
this._button = button;
|
|
371
|
+
await this.send("Input.dispatchMouseEvent", {
|
|
372
|
+
type: "mousePressed",
|
|
373
|
+
x: this._x,
|
|
374
|
+
y: this._y,
|
|
375
|
+
button,
|
|
376
|
+
clickCount: 1
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async up(opts = {}) {
|
|
380
|
+
const button = opts.button ?? "left";
|
|
381
|
+
this._button = "none";
|
|
382
|
+
await this.send("Input.dispatchMouseEvent", {
|
|
383
|
+
type: "mouseReleased",
|
|
384
|
+
x: this._x,
|
|
385
|
+
y: this._y,
|
|
386
|
+
button,
|
|
387
|
+
clickCount: 1
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
async move(x, y, opts = {}) {
|
|
391
|
+
const steps = Math.max(1, opts.steps ?? 1);
|
|
392
|
+
const fromX = this._x;
|
|
393
|
+
const fromY = this._y;
|
|
394
|
+
const dx = x - fromX;
|
|
395
|
+
const dy = y - fromY;
|
|
396
|
+
for (let i = 1; i <= steps; i++) {
|
|
397
|
+
const t = i / steps;
|
|
398
|
+
this._x = fromX + dx * t;
|
|
399
|
+
this._y = fromY + dy * t;
|
|
400
|
+
await this.send("Input.dispatchMouseEvent", {
|
|
401
|
+
type: "mouseMoved",
|
|
402
|
+
x: this._x,
|
|
403
|
+
y: this._y,
|
|
404
|
+
button: this._button
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
this._x = x;
|
|
408
|
+
this._y = y;
|
|
409
|
+
}
|
|
410
|
+
async wheel(deltaX, deltaY) {
|
|
411
|
+
await this.send("Input.dispatchMouseEvent", {
|
|
412
|
+
type: "mouseWheel",
|
|
413
|
+
x: this._x,
|
|
414
|
+
y: this._y,
|
|
415
|
+
deltaX,
|
|
416
|
+
deltaY
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
async send(method, params) {
|
|
420
|
+
await this.conn.send(method, params, this.sessionId);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
function sleep(ms) {
|
|
424
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/cdp-driver/keyboard.ts
|
|
428
|
+
var XBKeyboardImpl = class {
|
|
429
|
+
conn;
|
|
430
|
+
sessionId;
|
|
431
|
+
constructor(conn, sessionId) {
|
|
432
|
+
this.conn = conn;
|
|
433
|
+
this.sessionId = sessionId;
|
|
434
|
+
}
|
|
435
|
+
async press(key, opts = {}) {
|
|
436
|
+
const delay = opts.delay ?? 0;
|
|
437
|
+
const mapping = resolveKeyMapping(key);
|
|
438
|
+
const downParams = {
|
|
439
|
+
type: "rawKeyDown",
|
|
440
|
+
key: mapping.key,
|
|
441
|
+
code: mapping.code
|
|
442
|
+
};
|
|
443
|
+
if (mapping.text) {
|
|
444
|
+
downParams.text = mapping.text;
|
|
445
|
+
downParams.unmodifiedText = mapping.text;
|
|
446
|
+
}
|
|
447
|
+
if (mapping.keyCode) {
|
|
448
|
+
downParams.windowsVirtualKeyCode = mapping.keyCode;
|
|
449
|
+
}
|
|
450
|
+
await this.dispatchKeyEvent(downParams);
|
|
451
|
+
if (mapping.text) {
|
|
452
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
453
|
+
await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
|
|
454
|
+
}
|
|
455
|
+
await this.dispatchKeyEvent({
|
|
456
|
+
type: "char",
|
|
457
|
+
text: mapping.text
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (delay > 0) await sleep2(delay);
|
|
461
|
+
else {
|
|
462
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
463
|
+
await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const upParams = {
|
|
467
|
+
type: "keyUp",
|
|
468
|
+
key: mapping.key,
|
|
469
|
+
code: mapping.code
|
|
470
|
+
};
|
|
471
|
+
if (mapping.keyCode) {
|
|
472
|
+
upParams.windowsVirtualKeyCode = mapping.keyCode;
|
|
473
|
+
}
|
|
474
|
+
await this.dispatchKeyEvent(upParams);
|
|
475
|
+
}
|
|
476
|
+
async down(key) {
|
|
477
|
+
const mapping = resolveKeyMapping(key);
|
|
478
|
+
const params = {
|
|
479
|
+
type: "rawKeyDown",
|
|
480
|
+
key: mapping.key,
|
|
481
|
+
code: mapping.code
|
|
482
|
+
};
|
|
483
|
+
if (mapping.text) {
|
|
484
|
+
params.text = mapping.text;
|
|
485
|
+
params.unmodifiedText = mapping.text;
|
|
486
|
+
}
|
|
487
|
+
if (mapping.keyCode) {
|
|
488
|
+
params.windowsVirtualKeyCode = mapping.keyCode;
|
|
489
|
+
}
|
|
490
|
+
await this.dispatchKeyEvent(params);
|
|
491
|
+
}
|
|
492
|
+
async up(key) {
|
|
493
|
+
const mapping = resolveKeyMapping(key);
|
|
494
|
+
const params = {
|
|
495
|
+
type: "keyUp",
|
|
496
|
+
key: mapping.key,
|
|
497
|
+
code: mapping.code
|
|
498
|
+
};
|
|
499
|
+
if (mapping.keyCode) {
|
|
500
|
+
params.windowsVirtualKeyCode = mapping.keyCode;
|
|
501
|
+
}
|
|
502
|
+
await this.dispatchKeyEvent(params);
|
|
503
|
+
}
|
|
504
|
+
async type(text, opts = {}) {
|
|
505
|
+
const delay = opts.delay ?? 0;
|
|
506
|
+
for (const char of text) {
|
|
507
|
+
if (delay > 0) await sleep2(delay);
|
|
508
|
+
const mapping = resolveKeyMapping(char);
|
|
509
|
+
const downParams = {
|
|
510
|
+
type: "rawKeyDown",
|
|
511
|
+
key: mapping.key,
|
|
512
|
+
code: mapping.code
|
|
513
|
+
};
|
|
514
|
+
if (mapping.text) {
|
|
515
|
+
downParams.text = mapping.text;
|
|
516
|
+
downParams.unmodifiedText = mapping.text;
|
|
517
|
+
}
|
|
518
|
+
if (mapping.keyCode) {
|
|
519
|
+
downParams.windowsVirtualKeyCode = mapping.keyCode;
|
|
520
|
+
}
|
|
521
|
+
await this.dispatchKeyEvent(downParams);
|
|
522
|
+
if (mapping.text) {
|
|
523
|
+
await this.dispatchKeyEvent({
|
|
524
|
+
type: "char",
|
|
525
|
+
text: mapping.text
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
await this.dispatchKeyEvent({
|
|
529
|
+
type: "keyUp",
|
|
530
|
+
key: mapping.key,
|
|
531
|
+
code: mapping.code,
|
|
532
|
+
...mapping.keyCode ? { windowsVirtualKeyCode: mapping.keyCode } : {}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async insertText(text) {
|
|
537
|
+
await this.conn.send(
|
|
538
|
+
"Input.insertText",
|
|
539
|
+
{ text },
|
|
540
|
+
this.sessionId
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
async dispatchKeyEvent(params) {
|
|
544
|
+
await this.conn.send("Input.dispatchKeyEvent", params, this.sessionId);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
function resolveKeyMapping(key) {
|
|
548
|
+
if (KEY_MAP2[key]) return KEY_MAP2[key];
|
|
549
|
+
if (key.length === 1) {
|
|
550
|
+
const lower = key.toLowerCase();
|
|
551
|
+
if (lower >= "a" && lower <= "z") {
|
|
552
|
+
const code = `Key${lower.toUpperCase()}`;
|
|
553
|
+
const keyCode = lower.charCodeAt(0) - 32;
|
|
554
|
+
return { key, code, text: key, keyCode };
|
|
555
|
+
}
|
|
556
|
+
if (key >= "0" && key <= "9") {
|
|
557
|
+
const code = `Digit${key}`;
|
|
558
|
+
const keyCode = key.charCodeAt(0);
|
|
559
|
+
return { key, code, text: key, keyCode };
|
|
560
|
+
}
|
|
561
|
+
return { key, code: key, text: key };
|
|
562
|
+
}
|
|
563
|
+
return { key, code: key };
|
|
564
|
+
}
|
|
565
|
+
var KEY_MAP2 = {
|
|
566
|
+
Enter: { key: "Enter", code: "Enter", text: "\r", keyCode: 13 },
|
|
567
|
+
Tab: { key: "Tab", code: "Tab", text: " ", keyCode: 9 },
|
|
568
|
+
Escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
569
|
+
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
|
|
570
|
+
Delete: { key: "Delete", code: "Delete", keyCode: 46 },
|
|
571
|
+
Space: { key: " ", code: "Space", text: " ", keyCode: 32 },
|
|
572
|
+
ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
573
|
+
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
574
|
+
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
575
|
+
ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
576
|
+
Home: { key: "Home", code: "Home", keyCode: 36 },
|
|
577
|
+
End: { key: "End", code: "End", keyCode: 35 },
|
|
578
|
+
PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 },
|
|
579
|
+
PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 },
|
|
580
|
+
Control: { key: "Control", code: "ControlLeft", keyCode: 17 },
|
|
581
|
+
Shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
|
|
582
|
+
Alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
|
|
583
|
+
Meta: { key: "Meta", code: "MetaLeft", keyCode: 91 },
|
|
584
|
+
F1: { key: "F1", code: "F1", keyCode: 112 },
|
|
585
|
+
F2: { key: "F2", code: "F2", keyCode: 113 },
|
|
586
|
+
F3: { key: "F3", code: "F3", keyCode: 114 },
|
|
587
|
+
F4: { key: "F4", code: "F4", keyCode: 115 },
|
|
588
|
+
F5: { key: "F5", code: "F5", keyCode: 116 },
|
|
589
|
+
F6: { key: "F6", code: "F6", keyCode: 117 },
|
|
590
|
+
F7: { key: "F7", code: "F7", keyCode: 118 },
|
|
591
|
+
F8: { key: "F8", code: "F8", keyCode: 119 },
|
|
592
|
+
F9: { key: "F9", code: "F9", keyCode: 120 },
|
|
593
|
+
F10: { key: "F10", code: "F10", keyCode: 121 },
|
|
594
|
+
F11: { key: "F11", code: "F11", keyCode: 122 },
|
|
595
|
+
F12: { key: "F12", code: "F12", keyCode: 123 }
|
|
596
|
+
};
|
|
597
|
+
function sleep2(ms) {
|
|
598
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/cdp-driver/selector-utils.ts
|
|
602
|
+
function queryJS(selector) {
|
|
603
|
+
return `(${deepQueryIIFE})( ${JSON.stringify(queryMainJS(selector))} )`;
|
|
604
|
+
}
|
|
605
|
+
var deepQueryIIFE = `(function(mainExpr) {
|
|
606
|
+
const run = (root) => {
|
|
607
|
+
try { return new Function('document', 'return (' + mainExpr + ')')(root); }
|
|
608
|
+
catch (e) { return null; }
|
|
609
|
+
};
|
|
610
|
+
const scanRoot = (root) => {
|
|
611
|
+
const direct = run(root);
|
|
612
|
+
if (direct) return direct;
|
|
613
|
+
let all;
|
|
614
|
+
try { all = root.querySelectorAll('*'); } catch (e) { return null; }
|
|
615
|
+
for (const el of all) {
|
|
616
|
+
if (el.shadowRoot) {
|
|
617
|
+
const r = scanRoot(el.shadowRoot);
|
|
618
|
+
if (r) return r;
|
|
619
|
+
}
|
|
620
|
+
if (el.tagName === 'IFRAME') {
|
|
621
|
+
let inner = null;
|
|
622
|
+
try { inner = el.contentDocument; } catch (e) { /* cross-origin */ }
|
|
623
|
+
if (inner) {
|
|
624
|
+
const r = scanRoot(inner);
|
|
625
|
+
if (r) return r;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
};
|
|
631
|
+
return scanRoot(document);
|
|
632
|
+
})`;
|
|
633
|
+
function queryMainJS(selector) {
|
|
634
|
+
if (selector.startsWith("xpath=")) {
|
|
635
|
+
const xpath = JSON.stringify(selector.slice(6));
|
|
636
|
+
return `document.evaluate(${xpath}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
|
|
637
|
+
}
|
|
638
|
+
if (selector.startsWith("text=")) {
|
|
639
|
+
const raw = selector.slice(5);
|
|
640
|
+
const exact = raw.startsWith('"') && raw.endsWith('"');
|
|
641
|
+
const text = exact ? raw.slice(1, -1) : raw;
|
|
642
|
+
return `(() => {
|
|
643
|
+
const target = ${JSON.stringify(text)};
|
|
644
|
+
const exact = ${exact};
|
|
645
|
+
// Match on OWN text nodes (not strict leaf elements): search-result
|
|
646
|
+
// titles mix text with inline highlight <em> marks \u2014 a strict leaf filter
|
|
647
|
+
// finds nothing there (real-world juejin). Own-text keeps the match
|
|
648
|
+
// precise (descendant-only text doesn't count) while tolerating markup.
|
|
649
|
+
const ownText = (e) => Array.prototype.filter.call(e.childNodes, (n) => n.nodeType === 3)
|
|
650
|
+
.map((n) => n.textContent).join('').trim();
|
|
651
|
+
const els = [...document.querySelectorAll('*')].filter(e => {
|
|
652
|
+
if (e.offsetParent === null && e.tagName !== 'BODY') return false;
|
|
653
|
+
const t = ownText(e);
|
|
654
|
+
if (!t) return false;
|
|
655
|
+
return exact ? t === target : t.toLowerCase().includes(target.toLowerCase());
|
|
656
|
+
});
|
|
657
|
+
// Rank instead of raw DOM order: exact text beats substring, interactive
|
|
658
|
+
// elements (button/a/[onclick]/inputs) beat prose. Prevents matching a
|
|
659
|
+
// description paragraph that merely MENTIONS the target label
|
|
660
|
+
// (rec-duel d06: header text "\u76EE\u6807\u9879\u300C\u7B2C 87 \u53F7\u300D" hijacked text=\u7B2C 87 \u53F7).
|
|
661
|
+
const isInteractive = (e) => {
|
|
662
|
+
const tag = e.tagName;
|
|
663
|
+
return tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' || tag === 'SELECT'
|
|
664
|
+
|| e.hasAttribute('onclick') || e.getAttribute('role') === 'button';
|
|
665
|
+
};
|
|
666
|
+
els.sort((a, b) => {
|
|
667
|
+
const ta = ownText(a), tb = ownText(b);
|
|
668
|
+
const ea = ta === target ? 0 : 1, eb = tb === target ? 0 : 1;
|
|
669
|
+
if (ea !== eb) return ea - eb;
|
|
670
|
+
const ia = isInteractive(a) ? 0 : 1, ib = isInteractive(b) ? 0 : 1;
|
|
671
|
+
if (ia !== ib) return ia - ib;
|
|
672
|
+
return 0; // stable \u2014 preserve DOM order
|
|
673
|
+
});
|
|
674
|
+
return els[0] || null;
|
|
675
|
+
})()`;
|
|
676
|
+
}
|
|
677
|
+
if (selector.startsWith("popup-text=")) {
|
|
678
|
+
const text = selector.slice("popup-text=".length);
|
|
679
|
+
return `(() => {
|
|
680
|
+
const target = ${JSON.stringify(text)};
|
|
681
|
+
const els = [...document.querySelectorAll('*')].filter(e => {
|
|
682
|
+
if (e.children.length > 0) return false;
|
|
683
|
+
if (e.offsetParent === null) return false;
|
|
684
|
+
if ((e.textContent || '').trim() !== target) return false;
|
|
685
|
+
return true;
|
|
686
|
+
});
|
|
687
|
+
return els[0] || null;
|
|
688
|
+
})()`;
|
|
689
|
+
}
|
|
690
|
+
return `document.querySelector(${JSON.stringify(selector)})`;
|
|
691
|
+
}
|
|
692
|
+
function queryAllJS(selector) {
|
|
693
|
+
if (selector.startsWith("xpath=")) {
|
|
694
|
+
const xpath = JSON.stringify(selector.slice(6));
|
|
695
|
+
return `(() => { const it = document.evaluate(${xpath}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); const r=[]; for(let i=0;i<it.snapshotLength;i++) r.push(it.snapshotItem(i)); return r; })()`;
|
|
696
|
+
}
|
|
697
|
+
if (selector.startsWith("text=") || selector.startsWith("popup-text=")) {
|
|
698
|
+
return `(() => { const el = ${queryJS(selector)}; return el ? [el] : []; })()`;
|
|
699
|
+
}
|
|
700
|
+
return `document.querySelectorAll(${JSON.stringify(selector)})`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/cdp-driver/actionability.ts
|
|
704
|
+
async function waitForActionable(page, selector, opts = {}) {
|
|
705
|
+
const timeout = opts.timeout ?? 3e4;
|
|
706
|
+
if (opts.force) {
|
|
707
|
+
const deadline2 = Date.now() + timeout;
|
|
708
|
+
let lastError;
|
|
709
|
+
while (Date.now() < deadline2) {
|
|
710
|
+
const rect = await page.evaluate(`
|
|
711
|
+
(function() {
|
|
712
|
+
const el = ${queryJS(selector)};
|
|
713
|
+
if (!el) return null;
|
|
714
|
+
const r = el.getBoundingClientRect();
|
|
715
|
+
let x = r.x, y = r.y;
|
|
716
|
+
let doc = el.ownerDocument;
|
|
717
|
+
while (doc !== document) {
|
|
718
|
+
let host = null;
|
|
719
|
+
const scan = (d) => {
|
|
720
|
+
let frames;
|
|
721
|
+
try { frames = d.querySelectorAll('iframe'); } catch (e) { return null; }
|
|
722
|
+
for (const f of frames) {
|
|
723
|
+
let inner = null;
|
|
724
|
+
try { inner = f.contentDocument; } catch (e) { continue; }
|
|
725
|
+
if (!inner) continue;
|
|
726
|
+
if (inner === doc) return f;
|
|
727
|
+
const rr = scan(inner);
|
|
728
|
+
if (rr) return rr;
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
};
|
|
732
|
+
host = scan(document);
|
|
733
|
+
if (!host) break;
|
|
734
|
+
const hr = host.getBoundingClientRect();
|
|
735
|
+
x += hr.x; y += hr.y;
|
|
736
|
+
doc = host.ownerDocument;
|
|
737
|
+
}
|
|
738
|
+
return { x, y, width: r.width, height: r.height };
|
|
739
|
+
})()
|
|
740
|
+
`).catch(() => null);
|
|
741
|
+
if (rect && rect.width > 0 && rect.height > 0) return { nodeId: 0, rect };
|
|
742
|
+
lastError = `Element not visible (zero size): ${selector}`;
|
|
743
|
+
lastError = `Element not found: ${selector}`;
|
|
744
|
+
await page.waitForTimeout(200);
|
|
745
|
+
}
|
|
746
|
+
throw new Error(lastError || `Element not found: ${selector}`);
|
|
747
|
+
}
|
|
748
|
+
const deadline = Date.now() + timeout;
|
|
749
|
+
while (Date.now() < deadline) {
|
|
750
|
+
const result = await checkActionable(page, selector);
|
|
751
|
+
if (result.ok && result.rect) {
|
|
752
|
+
const nodeId = await page.querySelector(selector).catch(() => 0) ?? 0;
|
|
753
|
+
return { nodeId, rect: result.rect };
|
|
754
|
+
}
|
|
755
|
+
await page.waitForTimeout(50);
|
|
756
|
+
}
|
|
757
|
+
throw new Error(`Actionability timeout: element '${selector}' not ready after ${timeout}ms`);
|
|
758
|
+
}
|
|
759
|
+
async function checkActionable(page, selector) {
|
|
760
|
+
const result = await page.evaluate(`
|
|
761
|
+
(function() {
|
|
762
|
+
const el = ${queryJS(selector)};
|
|
763
|
+
if (!el) return { ok: false, reason: 'not_found' };
|
|
764
|
+
|
|
765
|
+
// Check attached to DOM
|
|
766
|
+
if (!el.isConnected) return { ok: false, reason: 'detached' };
|
|
767
|
+
|
|
768
|
+
// Check visibility
|
|
769
|
+
const style = window.getComputedStyle(el);
|
|
770
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
771
|
+
return { ok: false, reason: 'invisible' };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Check non-zero size
|
|
775
|
+
const rect = el.getBoundingClientRect();
|
|
776
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
777
|
+
return { ok: false, reason: 'zero_size' };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Check enabled (for form elements)
|
|
781
|
+
if (el.disabled) return { ok: false, reason: 'disabled' };
|
|
782
|
+
if (el.tagName === 'OPTION' && el.closest('select')?.disabled) {
|
|
783
|
+
return { ok: false, reason: 'parent_disabled' };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Check not covered by another element at center.
|
|
787
|
+
// elementFromPoint must run in the element's OWN document: for iframe-
|
|
788
|
+
// internal elements the main-document hit-test returns the <iframe>
|
|
789
|
+
// host itself, which falsely reports "covered" (rec-duel d01).
|
|
790
|
+
// For shadow-internal elements the hit-test retargets to the shadow
|
|
791
|
+
// HOST \u2014 walk the host chain before declaring coverage (rec-duel d04).
|
|
792
|
+
const cx = rect.x + rect.width / 2;
|
|
793
|
+
const cy = rect.y + rect.height / 2;
|
|
794
|
+
const topEl = el.ownerDocument.elementFromPoint(cx, cy);
|
|
795
|
+
if (topEl && topEl !== el && !el.contains(topEl) && !topEl.contains(el)) {
|
|
796
|
+
let hostChain = [];
|
|
797
|
+
let rootNode = el.getRootNode();
|
|
798
|
+
while (rootNode && rootNode.host) {
|
|
799
|
+
hostChain.push(rootNode.host);
|
|
800
|
+
rootNode = rootNode.host.getRootNode();
|
|
801
|
+
}
|
|
802
|
+
if (!hostChain.includes(topEl)) {
|
|
803
|
+
return { ok: false, reason: 'covered', rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
ok: true,
|
|
809
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
810
|
+
};
|
|
811
|
+
})()
|
|
812
|
+
`);
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
815
|
+
async function scrollIntoView(page, selector) {
|
|
816
|
+
await page.evaluate(`
|
|
817
|
+
(function() {
|
|
818
|
+
const el = ${queryJS(selector)};
|
|
819
|
+
if (el) el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
820
|
+
})()
|
|
821
|
+
`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/cdp-driver/locator.ts
|
|
825
|
+
var XBLocatorImpl = class _XBLocatorImpl {
|
|
826
|
+
page;
|
|
827
|
+
selector;
|
|
828
|
+
constructor(page, selector) {
|
|
829
|
+
this.page = page;
|
|
830
|
+
this.selector = selector;
|
|
831
|
+
}
|
|
832
|
+
/** Resolve selector to a JS expression that finds a single element (CSS or xpath). */
|
|
833
|
+
_q(sel) {
|
|
834
|
+
return queryJS(sel);
|
|
835
|
+
}
|
|
836
|
+
/** Resolve selector to a JS expression that finds all matching elements (CSS or xpath). */
|
|
837
|
+
_qa(sel) {
|
|
838
|
+
return queryAllJS(sel);
|
|
839
|
+
}
|
|
840
|
+
// ── Actions ─────────────────────────────────────────────────
|
|
841
|
+
async click(opts = {}) {
|
|
842
|
+
const { rect } = await waitForActionable(this.page, this.selector, opts);
|
|
843
|
+
await scrollIntoView(this.page, this.selector);
|
|
844
|
+
const updatedRect = await this.page.evaluate(`
|
|
845
|
+
(function() {
|
|
846
|
+
const el = ${this._q(this.selector)};
|
|
847
|
+
if (!el) return null;
|
|
848
|
+
const rect = el.getBoundingClientRect();
|
|
849
|
+
let x = rect.x, y = rect.y;
|
|
850
|
+
let doc = el.ownerDocument;
|
|
851
|
+
while (doc !== document) {
|
|
852
|
+
let host = null;
|
|
853
|
+
const scan = (d) => {
|
|
854
|
+
let frames;
|
|
855
|
+
try { frames = d.querySelectorAll('iframe'); } catch (e) { return null; }
|
|
856
|
+
for (const f of frames) {
|
|
857
|
+
let inner = null;
|
|
858
|
+
try { inner = f.contentDocument; } catch (e) { continue; }
|
|
859
|
+
if (!inner) continue;
|
|
860
|
+
if (inner === doc) return f;
|
|
861
|
+
const r = scan(inner);
|
|
862
|
+
if (r) return r;
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
};
|
|
866
|
+
host = scan(document);
|
|
867
|
+
if (!host) break;
|
|
868
|
+
const hr = host.getBoundingClientRect();
|
|
869
|
+
x += hr.x; y += hr.y;
|
|
870
|
+
doc = host.ownerDocument;
|
|
871
|
+
}
|
|
872
|
+
return { x, y, width: rect.width, height: rect.height };
|
|
873
|
+
})()
|
|
874
|
+
`);
|
|
875
|
+
const finalRect = updatedRect ?? rect;
|
|
876
|
+
const cx = finalRect.x + finalRect.width / 2;
|
|
877
|
+
const cy = finalRect.y + finalRect.height / 2;
|
|
878
|
+
await this.page.mouse.click(cx, cy, {
|
|
879
|
+
stealth: true,
|
|
880
|
+
elementWidth: finalRect.width,
|
|
881
|
+
elementHeight: finalRect.height,
|
|
882
|
+
...{ button: opts.button ?? "left", clickCount: opts.clickCount ?? 1, delay: opts.delay }
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
async fill(value, opts = {}) {
|
|
886
|
+
await waitForActionable(this.page, this.selector, opts);
|
|
887
|
+
await scrollIntoView(this.page, this.selector);
|
|
888
|
+
await this.click({ ...opts });
|
|
889
|
+
await this.page.keyboard.type(value, { stealth: true });
|
|
890
|
+
return;
|
|
891
|
+
await this.page.evaluate(`
|
|
892
|
+
(function() {
|
|
893
|
+
const el = ${this._q(this.selector)};
|
|
894
|
+
if (!el) throw new Error('Element not found: ${this.selector.replace(/'/g, "\\'")}');
|
|
895
|
+
el.focus();
|
|
896
|
+
el.value = '';
|
|
897
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
898
|
+
})()
|
|
899
|
+
`);
|
|
900
|
+
await this.page.keyboard.insertText(value);
|
|
901
|
+
await this.page.evaluate(`
|
|
902
|
+
(function() {
|
|
903
|
+
const el = ${this._q(this.selector)};
|
|
904
|
+
if (el) {
|
|
905
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
906
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
907
|
+
}
|
|
908
|
+
})()
|
|
909
|
+
`);
|
|
910
|
+
}
|
|
911
|
+
async press(key, opts = {}) {
|
|
912
|
+
await waitForActionable(this.page, this.selector, { timeout: opts.timeout });
|
|
913
|
+
await scrollIntoView(this.page, this.selector);
|
|
914
|
+
await this.page.evaluate(`
|
|
915
|
+
(function() {
|
|
916
|
+
const el = ${this._q(this.selector)};
|
|
917
|
+
if (el) el.focus();
|
|
918
|
+
})()
|
|
919
|
+
`);
|
|
920
|
+
await this.page.keyboard.press(key);
|
|
921
|
+
}
|
|
922
|
+
async pressSequentially(text, opts = {}) {
|
|
923
|
+
await waitForActionable(this.page, this.selector, { timeout: opts.timeout });
|
|
924
|
+
await scrollIntoView(this.page, this.selector);
|
|
925
|
+
await this.page.evaluate(`
|
|
926
|
+
(function() {
|
|
927
|
+
const el = ${this._q(this.selector)};
|
|
928
|
+
if (el) el.focus();
|
|
929
|
+
})()
|
|
930
|
+
`);
|
|
931
|
+
await this.page.keyboard.type(text, { delay: opts.delay });
|
|
932
|
+
}
|
|
933
|
+
async hover(opts = {}) {
|
|
934
|
+
if (!opts.force) {
|
|
935
|
+
const { rect } = await waitForActionable(this.page, this.selector, { timeout: opts.timeout });
|
|
936
|
+
await scrollIntoView(this.page, this.selector);
|
|
937
|
+
const cx = rect.x + rect.width / 2;
|
|
938
|
+
const cy = rect.y + rect.height / 2;
|
|
939
|
+
await this.page.mouse.move(cx, cy);
|
|
940
|
+
} else {
|
|
941
|
+
await scrollIntoView(this.page, this.selector);
|
|
942
|
+
const rect = await this.page.evaluate(`
|
|
943
|
+
(function() {
|
|
944
|
+
const el = ${this._q(this.selector)};
|
|
945
|
+
if (!el) return { x: 0, y: 0, width: 0, height: 0 };
|
|
946
|
+
const r = el.getBoundingClientRect();
|
|
947
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
948
|
+
})()
|
|
949
|
+
`);
|
|
950
|
+
const cx = rect.x + rect.width / 2;
|
|
951
|
+
const cy = rect.y + rect.height / 2;
|
|
952
|
+
await this.page.mouse.move(cx, cy);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async type(text, opts = {}) {
|
|
956
|
+
await this.pressSequentially(text, opts);
|
|
957
|
+
}
|
|
958
|
+
async check(opts = {}) {
|
|
959
|
+
await waitForActionable(this.page, this.selector, { timeout: opts.timeout });
|
|
960
|
+
const isChecked = await this.page.evaluate(`
|
|
961
|
+
(function() {
|
|
962
|
+
const el = ${this._q(this.selector)};
|
|
963
|
+
return el?.checked === true;
|
|
964
|
+
})()
|
|
965
|
+
`);
|
|
966
|
+
if (!isChecked) {
|
|
967
|
+
await this.click({ timeout: opts.timeout });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async uncheck(opts = {}) {
|
|
971
|
+
await waitForActionable(this.page, this.selector, { timeout: opts.timeout });
|
|
972
|
+
const isChecked = await this.page.evaluate(`
|
|
973
|
+
(function() {
|
|
974
|
+
const el = ${this._q(this.selector)};
|
|
975
|
+
return el?.checked === true;
|
|
976
|
+
})()
|
|
977
|
+
`);
|
|
978
|
+
if (isChecked) {
|
|
979
|
+
await this.click({ timeout: opts.timeout });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async selectOption(value) {
|
|
983
|
+
await waitForActionable(this.page, this.selector);
|
|
984
|
+
const values = Array.isArray(value) ? value : [value];
|
|
985
|
+
const selected = await this.page.evaluate(`
|
|
986
|
+
(function() {
|
|
987
|
+
const el = ${this._q(this.selector)};
|
|
988
|
+
if (!el || el.tagName !== 'SELECT') throw new Error('Not a select element');
|
|
989
|
+
|
|
990
|
+
const values = ${JSON.stringify(values)};
|
|
991
|
+
const selectedValues = [];
|
|
992
|
+
|
|
993
|
+
for (const opt of el.options) {
|
|
994
|
+
for (const v of values) {
|
|
995
|
+
if (typeof v === 'object') {
|
|
996
|
+
if (v.label && opt.label === v.label) { opt.selected = true; selectedValues.push(opt.value); }
|
|
997
|
+
else if (v.value && opt.value === v.value) { opt.selected = true; selectedValues.push(opt.value); }
|
|
998
|
+
else if (v.index !== undefined && opt.index === v.index) { opt.selected = true; selectedValues.push(opt.value); }
|
|
999
|
+
} else if (opt.value === v || opt.label === v) {
|
|
1000
|
+
opt.selected = true;
|
|
1001
|
+
selectedValues.push(opt.value);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1007
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1008
|
+
return selectedValues;
|
|
1009
|
+
})()
|
|
1010
|
+
`);
|
|
1011
|
+
return selected;
|
|
1012
|
+
}
|
|
1013
|
+
async screenshot(opts = {}) {
|
|
1014
|
+
await waitForActionable(this.page, this.selector);
|
|
1015
|
+
const box = await this.page.evaluate(`
|
|
1016
|
+
(function() {
|
|
1017
|
+
const el = ${this._q(this.selector)};
|
|
1018
|
+
if (!el) return null;
|
|
1019
|
+
const r = el.getBoundingClientRect();
|
|
1020
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
1021
|
+
})()
|
|
1022
|
+
`);
|
|
1023
|
+
if (!box) throw new Error(`Element not found: ${this.selector}`);
|
|
1024
|
+
return this.page.screenshot({
|
|
1025
|
+
...opts,
|
|
1026
|
+
clip: { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
// ── State checks ────────────────────────────────────────────
|
|
1030
|
+
async waitFor(opts = {}) {
|
|
1031
|
+
await this.page.waitForSelector(this.selector, opts);
|
|
1032
|
+
}
|
|
1033
|
+
async count() {
|
|
1034
|
+
return this.page.evaluate(`
|
|
1035
|
+
${this._qa(this.selector)}.length
|
|
1036
|
+
`);
|
|
1037
|
+
}
|
|
1038
|
+
async isVisible() {
|
|
1039
|
+
try {
|
|
1040
|
+
const result = await this.page.evaluate(`
|
|
1041
|
+
(function() {
|
|
1042
|
+
const el = ${this._q(this.selector)};
|
|
1043
|
+
if (!el) return false;
|
|
1044
|
+
if (!el.isConnected) return false;
|
|
1045
|
+
const style = window.getComputedStyle(el);
|
|
1046
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
1047
|
+
const rect = el.getBoundingClientRect();
|
|
1048
|
+
return rect.width > 0 && rect.height > 0;
|
|
1049
|
+
})()
|
|
1050
|
+
`);
|
|
1051
|
+
return Boolean(result);
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
async isHidden() {
|
|
1057
|
+
return !await this.isVisible();
|
|
1058
|
+
}
|
|
1059
|
+
async isEnabled() {
|
|
1060
|
+
return this.page.evaluate(`
|
|
1061
|
+
(function() {
|
|
1062
|
+
const el = ${this._q(this.selector)};
|
|
1063
|
+
if (!el) return false;
|
|
1064
|
+
return !el.disabled;
|
|
1065
|
+
})()
|
|
1066
|
+
`);
|
|
1067
|
+
}
|
|
1068
|
+
async isDisabled() {
|
|
1069
|
+
return !await this.isEnabled();
|
|
1070
|
+
}
|
|
1071
|
+
async boundingBox() {
|
|
1072
|
+
return this.page.evaluate(`
|
|
1073
|
+
(function() {
|
|
1074
|
+
const el = ${this._q(this.selector)};
|
|
1075
|
+
if (!el) return null;
|
|
1076
|
+
const r = el.getBoundingClientRect();
|
|
1077
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
1078
|
+
})()
|
|
1079
|
+
`);
|
|
1080
|
+
}
|
|
1081
|
+
// ── Text/HTML ───────────────────────────────────────────────
|
|
1082
|
+
async textContent() {
|
|
1083
|
+
return this.page.evaluate(`
|
|
1084
|
+
(function() {
|
|
1085
|
+
const el = ${this._q(this.selector)};
|
|
1086
|
+
return el?.textContent ?? null;
|
|
1087
|
+
})()
|
|
1088
|
+
`);
|
|
1089
|
+
}
|
|
1090
|
+
async innerText() {
|
|
1091
|
+
return this.page.evaluate(`
|
|
1092
|
+
(function() {
|
|
1093
|
+
const el = ${this._q(this.selector)};
|
|
1094
|
+
if (!el) throw new Error('Element not found');
|
|
1095
|
+
return el.innerText;
|
|
1096
|
+
})()
|
|
1097
|
+
`);
|
|
1098
|
+
}
|
|
1099
|
+
async innerHTML() {
|
|
1100
|
+
return this.page.evaluate(`
|
|
1101
|
+
(function() {
|
|
1102
|
+
const el = ${this._q(this.selector)};
|
|
1103
|
+
if (!el) throw new Error('Element not found');
|
|
1104
|
+
return el.innerHTML;
|
|
1105
|
+
})()
|
|
1106
|
+
`);
|
|
1107
|
+
}
|
|
1108
|
+
async getAttribute(name) {
|
|
1109
|
+
return this.page.evaluate(`
|
|
1110
|
+
(function() {
|
|
1111
|
+
const el = ${this._q(this.selector)};
|
|
1112
|
+
return el?.getAttribute(${JSON.stringify(name)}) ?? null;
|
|
1113
|
+
})()
|
|
1114
|
+
`);
|
|
1115
|
+
}
|
|
1116
|
+
// ── Evaluate ────────────────────────────────────────────────
|
|
1117
|
+
async evaluate(fn, ...args) {
|
|
1118
|
+
const fnBody = typeof fn === "function" ? fn.toString() : fn;
|
|
1119
|
+
const sel = JSON.stringify(this.selector);
|
|
1120
|
+
const xpathPrefix = this.selector.startsWith("xpath=") ? JSON.stringify(this.selector.slice(6)) : "null";
|
|
1121
|
+
return this.page.evaluate(
|
|
1122
|
+
`(function(sel, xpathExpr, fnStr, ...evalArgs) {
|
|
1123
|
+
const el = xpathExpr
|
|
1124
|
+
? document.evaluate(xpathExpr, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
|
|
1125
|
+
: document.querySelector(sel);
|
|
1126
|
+
if (!el) throw new Error('No element found for selector: ' + sel);
|
|
1127
|
+
const fn = new Function('return ' + fnStr)();
|
|
1128
|
+
return fn(el, ...evalArgs);
|
|
1129
|
+
})(${sel}, ${xpathPrefix}, ${JSON.stringify(fnBody)}${args.length > 0 ? ", " + args.map((a) => JSON.stringify(a)).join(", ") : ""})`
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
async ariaSnapshot() {
|
|
1133
|
+
const result = await this.page._cdpSend(
|
|
1134
|
+
"Accessibility.getFullAXTree"
|
|
1135
|
+
);
|
|
1136
|
+
return result.nodes.map((n) => `${n.role?.value}: ${n.name?.value ?? ""}`).join("\n");
|
|
1137
|
+
}
|
|
1138
|
+
// ── Filtering ───────────────────────────────────────────────
|
|
1139
|
+
first() {
|
|
1140
|
+
return new FilteredLocator(this.page, this.selector, 0);
|
|
1141
|
+
}
|
|
1142
|
+
last() {
|
|
1143
|
+
return new FilteredLocator(this.page, this.selector, -1);
|
|
1144
|
+
}
|
|
1145
|
+
nth(index) {
|
|
1146
|
+
return new FilteredLocator(this.page, this.selector, index);
|
|
1147
|
+
}
|
|
1148
|
+
filter(opts) {
|
|
1149
|
+
if (opts.visible) {
|
|
1150
|
+
return new VisibleFilteredLocator(this.page, this.selector);
|
|
1151
|
+
}
|
|
1152
|
+
return new _XBLocatorImpl(this.page, this.selector);
|
|
1153
|
+
}
|
|
1154
|
+
async all() {
|
|
1155
|
+
const n = await this.page.evaluate(`
|
|
1156
|
+
${this._qa(this.selector)}.length
|
|
1157
|
+
`);
|
|
1158
|
+
const locators = [];
|
|
1159
|
+
for (let i = 0; i < n; i++) {
|
|
1160
|
+
locators.push(new FilteredLocator(this.page, this.selector, i));
|
|
1161
|
+
}
|
|
1162
|
+
return locators;
|
|
1163
|
+
}
|
|
1164
|
+
async focus() {
|
|
1165
|
+
await this.page.evaluate(`
|
|
1166
|
+
(function() {
|
|
1167
|
+
const el = ${this._q(this.selector)};
|
|
1168
|
+
if (el) el.focus();
|
|
1169
|
+
})()
|
|
1170
|
+
`);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
var FilteredLocator = class extends XBLocatorImpl {
|
|
1174
|
+
index;
|
|
1175
|
+
constructor(page, selector, index) {
|
|
1176
|
+
const indexedSelector = selector.startsWith("xpath=") ? selector : index === -1 ? `${selector}:last-of-type` : `${selector}:nth-of-type(${index + 1})`;
|
|
1177
|
+
super(page, indexedSelector);
|
|
1178
|
+
this.index = index;
|
|
1179
|
+
this._rawSelector = selector;
|
|
1180
|
+
}
|
|
1181
|
+
/** Original selector before index filtering */
|
|
1182
|
+
_rawSelector;
|
|
1183
|
+
/** For xpath selectors, override _q to return the nth element from evaluate results */
|
|
1184
|
+
_q(sel) {
|
|
1185
|
+
if (!sel.startsWith("xpath=")) return super._q(sel);
|
|
1186
|
+
const xpath = JSON.stringify(this._rawSelector.slice(6));
|
|
1187
|
+
return `(() => { const it = document.evaluate(${xpath}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); return ${this.index === -1 ? "it.snapshotItem(it.snapshotLength - 1)" : `it.snapshotItem(${this.index})`}; })()`;
|
|
1188
|
+
}
|
|
1189
|
+
/** For xpath selectors, override _qa to return all matching elements from evaluate */
|
|
1190
|
+
_qa(sel) {
|
|
1191
|
+
if (!sel.startsWith("xpath=")) return super._qa(sel);
|
|
1192
|
+
const xpath = JSON.stringify(this._rawSelector.slice(6));
|
|
1193
|
+
return `(() => { const it = document.evaluate(${xpath}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); const r=[]; for(let i=0;i<it.snapshotLength;i++) r.push(it.snapshotItem(i)); return r; })()`;
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
var VisibleFilteredLocator = class extends XBLocatorImpl {
|
|
1197
|
+
async _withVisibleTag(fn) {
|
|
1198
|
+
const tag = `data-xb-vt-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
1199
|
+
const found = await this.page.evaluate(`
|
|
1200
|
+
(function() {
|
|
1201
|
+
const els = ${this._qa(this.selector)};
|
|
1202
|
+
for (const el of els) {
|
|
1203
|
+
if (!el.isConnected) continue;
|
|
1204
|
+
const style = window.getComputedStyle(el);
|
|
1205
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
|
1206
|
+
const rect = el.getBoundingClientRect();
|
|
1207
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
1208
|
+
el.setAttribute(${JSON.stringify(tag)}, '');
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
return false;
|
|
1212
|
+
})()
|
|
1213
|
+
`);
|
|
1214
|
+
if (!found) throw new Error(`No visible element found for: ${this.selector}`);
|
|
1215
|
+
try {
|
|
1216
|
+
return await fn(`[${tag}]`);
|
|
1217
|
+
} finally {
|
|
1218
|
+
await this.page.evaluate(`
|
|
1219
|
+
${this._qa(`[${tag}]`)}.forEach(el => el.removeAttribute(${JSON.stringify(tag)}))
|
|
1220
|
+
`);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async click(opts) {
|
|
1224
|
+
return this._withVisibleTag((tagSel) => new XBLocatorImpl(this.page, tagSel).click(opts));
|
|
1225
|
+
}
|
|
1226
|
+
async fill(value, opts) {
|
|
1227
|
+
return this._withVisibleTag((tagSel) => new XBLocatorImpl(this.page, tagSel).fill(value, opts));
|
|
1228
|
+
}
|
|
1229
|
+
async press(key, opts) {
|
|
1230
|
+
return this._withVisibleTag((tagSel) => new XBLocatorImpl(this.page, tagSel).press(key, opts));
|
|
1231
|
+
}
|
|
1232
|
+
async hover(opts) {
|
|
1233
|
+
return this._withVisibleTag((tagSel) => new XBLocatorImpl(this.page, tagSel).hover(opts));
|
|
1234
|
+
}
|
|
1235
|
+
async count() {
|
|
1236
|
+
return this.page.evaluate(`
|
|
1237
|
+
(function() {
|
|
1238
|
+
let count = 0;
|
|
1239
|
+
const els = ${this._qa(this.selector)};
|
|
1240
|
+
for (const el of els) {
|
|
1241
|
+
if (!el.isConnected) continue;
|
|
1242
|
+
const style = window.getComputedStyle(el);
|
|
1243
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
1244
|
+
const rect = el.getBoundingClientRect();
|
|
1245
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
1246
|
+
count++;
|
|
1247
|
+
}
|
|
1248
|
+
return count;
|
|
1249
|
+
})()
|
|
1250
|
+
`);
|
|
1251
|
+
}
|
|
1252
|
+
async isVisible() {
|
|
1253
|
+
try {
|
|
1254
|
+
const result = await this.page.evaluate(`
|
|
1255
|
+
(function() {
|
|
1256
|
+
const els = ${this._qa(this.selector)};
|
|
1257
|
+
for (const el of els) {
|
|
1258
|
+
if (!el.isConnected) continue;
|
|
1259
|
+
const style = window.getComputedStyle(el);
|
|
1260
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
1261
|
+
const rect = el.getBoundingClientRect();
|
|
1262
|
+
if (rect.width > 0 && rect.height > 0) return true;
|
|
1263
|
+
}
|
|
1264
|
+
return false;
|
|
1265
|
+
})()
|
|
1266
|
+
`);
|
|
1267
|
+
return result;
|
|
1268
|
+
} catch {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
async textContent() {
|
|
1273
|
+
return this._withVisibleTag((tagSel) => new XBLocatorImpl(this.page, tagSel).textContent());
|
|
1274
|
+
}
|
|
1275
|
+
async innerText() {
|
|
1276
|
+
return this._withVisibleTag((tagSel) => new XBLocatorImpl(this.page, tagSel).innerText());
|
|
1277
|
+
}
|
|
1278
|
+
async waitFor(opts) {
|
|
1279
|
+
const deadline = Date.now() + (opts?.timeout ?? 3e4);
|
|
1280
|
+
while (Date.now() < deadline) {
|
|
1281
|
+
if (await this.isVisible()) return;
|
|
1282
|
+
await this.page.waitForTimeout(50);
|
|
1283
|
+
}
|
|
1284
|
+
throw new Error(`Timeout waiting for visible element: ${this.selector}`);
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
// src/cdp-driver/element-handle.ts
|
|
1289
|
+
var XBElementHandleImpl = class {
|
|
1290
|
+
page;
|
|
1291
|
+
nodeId;
|
|
1292
|
+
disposed = false;
|
|
1293
|
+
constructor(page, nodeId) {
|
|
1294
|
+
this.page = page;
|
|
1295
|
+
this.nodeId = nodeId;
|
|
1296
|
+
}
|
|
1297
|
+
get _nodeId() {
|
|
1298
|
+
return this.nodeId;
|
|
1299
|
+
}
|
|
1300
|
+
async click(opts = {}) {
|
|
1301
|
+
if (this.disposed) throw new Error("Element handle disposed");
|
|
1302
|
+
const box = await this.boundingBox();
|
|
1303
|
+
if (!box) throw new Error("Element has no box");
|
|
1304
|
+
await this.scrollIntoViewIfNeeded();
|
|
1305
|
+
const cx = box.x + box.width / 2;
|
|
1306
|
+
const cy = box.y + box.height / 2;
|
|
1307
|
+
await this.page.mouse.click(cx, cy, {
|
|
1308
|
+
button: opts.button ?? "left",
|
|
1309
|
+
clickCount: opts.clickCount ?? 1,
|
|
1310
|
+
delay: opts.delay
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
async fill(value, _opts = {}) {
|
|
1314
|
+
if (this.disposed) throw new Error("Element handle disposed");
|
|
1315
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1316
|
+
await this.page.callFunctionOn(
|
|
1317
|
+
objectId,
|
|
1318
|
+
`function(value) {
|
|
1319
|
+
this.focus();
|
|
1320
|
+
this.value = '';
|
|
1321
|
+
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1322
|
+
}`,
|
|
1323
|
+
[value]
|
|
1324
|
+
);
|
|
1325
|
+
await this.page.keyboard.insertText(value);
|
|
1326
|
+
await this.page.callFunctionOn(
|
|
1327
|
+
objectId,
|
|
1328
|
+
`function() {
|
|
1329
|
+
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1330
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1331
|
+
}`
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
async hover() {
|
|
1335
|
+
const box = await this.boundingBox();
|
|
1336
|
+
if (!box) throw new Error("Element has no box");
|
|
1337
|
+
await this.scrollIntoViewIfNeeded();
|
|
1338
|
+
await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
1339
|
+
}
|
|
1340
|
+
async press(key) {
|
|
1341
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1342
|
+
await this.page.callFunctionOn(objectId, "function() { this.focus(); }");
|
|
1343
|
+
await this.page.keyboard.press(key);
|
|
1344
|
+
}
|
|
1345
|
+
async screenshot(opts = {}) {
|
|
1346
|
+
const box = await this.boundingBox();
|
|
1347
|
+
if (!box) throw new Error("Element has no box");
|
|
1348
|
+
return this.page.screenshot({
|
|
1349
|
+
...opts,
|
|
1350
|
+
clip: box
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
async boundingBox() {
|
|
1354
|
+
return this.page.getBoxModel(this.nodeId);
|
|
1355
|
+
}
|
|
1356
|
+
async isVisible() {
|
|
1357
|
+
try {
|
|
1358
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1359
|
+
const result = await this.page.callFunctionOn(
|
|
1360
|
+
objectId,
|
|
1361
|
+
`function() {
|
|
1362
|
+
if (!this.isConnected) return false;
|
|
1363
|
+
const style = window.getComputedStyle(this);
|
|
1364
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
1365
|
+
const rect = this.getBoundingClientRect();
|
|
1366
|
+
return rect.width > 0 && rect.height > 0;
|
|
1367
|
+
}`
|
|
1368
|
+
);
|
|
1369
|
+
return Boolean(result);
|
|
1370
|
+
} catch {
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
async isEnabled() {
|
|
1375
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1376
|
+
return this.page.callFunctionOn(objectId, "function() { return !this.disabled; }");
|
|
1377
|
+
}
|
|
1378
|
+
async textContent() {
|
|
1379
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1380
|
+
return this.page.callFunctionOn(objectId, "function() { return this.textContent; }");
|
|
1381
|
+
}
|
|
1382
|
+
async innerText() {
|
|
1383
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1384
|
+
return this.page.callFunctionOn(objectId, "function() { return this.innerText; }");
|
|
1385
|
+
}
|
|
1386
|
+
async innerHTML() {
|
|
1387
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1388
|
+
return this.page.callFunctionOn(objectId, "function() { return this.innerHTML; }");
|
|
1389
|
+
}
|
|
1390
|
+
async getAttribute(name) {
|
|
1391
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1392
|
+
return this.page.callFunctionOn(objectId, `function() { return this.getAttribute(${JSON.stringify(name)}); }`);
|
|
1393
|
+
}
|
|
1394
|
+
async scrollIntoViewIfNeeded() {
|
|
1395
|
+
if (this.disposed) return;
|
|
1396
|
+
const objectId = await this.page.resolveNode(this.nodeId);
|
|
1397
|
+
await this.page.callFunctionOn(
|
|
1398
|
+
objectId,
|
|
1399
|
+
'function() { this.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); }'
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
dispose() {
|
|
1403
|
+
this.disposed = true;
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
// src/cdp-driver/page-helpers.ts
|
|
1408
|
+
function globToRegex(glob) {
|
|
1409
|
+
let pattern = glob.replace(/[\\^${}()|[\]+]/g, "\\$&").replace(/\./g, "\\.").replace(/\*\*/g, "{{DOUBLESTAR}}").replace(/\*/g, "[^/]*").replace(/{{DOUBLESTAR}}/g, ".*").replace(/\?/g, ".");
|
|
1410
|
+
if (!pattern.startsWith("http") && !pattern.startsWith("\\.")) {
|
|
1411
|
+
pattern = ".*" + pattern;
|
|
1412
|
+
}
|
|
1413
|
+
return new RegExp("^" + pattern + "$", "i");
|
|
1414
|
+
}
|
|
1415
|
+
function matchGlob(pattern, url) {
|
|
1416
|
+
return globToRegex(pattern).test(url);
|
|
1417
|
+
}
|
|
1418
|
+
function createResponsePredicate(urlOrPredicate) {
|
|
1419
|
+
if (typeof urlOrPredicate === "function") {
|
|
1420
|
+
return urlOrPredicate;
|
|
1421
|
+
}
|
|
1422
|
+
if (urlOrPredicate instanceof RegExp) {
|
|
1423
|
+
return (resp) => urlOrPredicate.test(resp.url());
|
|
1424
|
+
}
|
|
1425
|
+
const regex = globToRegex(urlOrPredicate);
|
|
1426
|
+
return (resp) => regex.test(resp.url());
|
|
1427
|
+
}
|
|
1428
|
+
function createRequestPredicate(urlOrPredicate) {
|
|
1429
|
+
if (typeof urlOrPredicate === "function") {
|
|
1430
|
+
return urlOrPredicate;
|
|
1431
|
+
}
|
|
1432
|
+
if (urlOrPredicate instanceof RegExp) {
|
|
1433
|
+
return (req) => urlOrPredicate.test(req.url());
|
|
1434
|
+
}
|
|
1435
|
+
const regex = globToRegex(urlOrPredicate);
|
|
1436
|
+
return (req) => regex.test(req.url());
|
|
1437
|
+
}
|
|
1438
|
+
function createXBResponse(data, conn, sessionId, requestData) {
|
|
1439
|
+
const request = requestData ? createXBRequest(null, requestData) : createXBRequest(null, { requestId: data.requestId, url: data.url, method: "GET", headers: {}, postData: null, resourceType: "other" });
|
|
1440
|
+
return {
|
|
1441
|
+
status: () => data.status,
|
|
1442
|
+
statusText: () => "",
|
|
1443
|
+
url: () => data.url,
|
|
1444
|
+
headers: () => data.headers,
|
|
1445
|
+
ok: () => data.status >= 200 && data.status < 300,
|
|
1446
|
+
body: async () => {
|
|
1447
|
+
if (!conn) throw new Error("Response body not available");
|
|
1448
|
+
try {
|
|
1449
|
+
const resp = await conn.send(
|
|
1450
|
+
"Network.getResponseBody",
|
|
1451
|
+
{ requestId: data.requestId },
|
|
1452
|
+
sessionId
|
|
1453
|
+
);
|
|
1454
|
+
return Buffer.from(resp.body, resp.base64Encoded ? "base64" : "utf8");
|
|
1455
|
+
} catch {
|
|
1456
|
+
throw new Error("Response body not available");
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
text: async () => {
|
|
1460
|
+
if (!conn) throw new Error("Response body not available");
|
|
1461
|
+
try {
|
|
1462
|
+
const resp = await conn.send(
|
|
1463
|
+
"Network.getResponseBody",
|
|
1464
|
+
{ requestId: data.requestId },
|
|
1465
|
+
sessionId
|
|
1466
|
+
);
|
|
1467
|
+
return resp.base64Encoded ? Buffer.from(resp.body, "base64").toString("utf8") : resp.body;
|
|
1468
|
+
} catch {
|
|
1469
|
+
throw new Error("Response body not available");
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
json: async () => {
|
|
1473
|
+
const text = await (async () => {
|
|
1474
|
+
if (!conn) throw new Error("Response body not available");
|
|
1475
|
+
try {
|
|
1476
|
+
const resp = await conn.send(
|
|
1477
|
+
"Network.getResponseBody",
|
|
1478
|
+
{ requestId: data.requestId },
|
|
1479
|
+
sessionId
|
|
1480
|
+
);
|
|
1481
|
+
return resp.base64Encoded ? Buffer.from(resp.body, "base64").toString("utf8") : resp.body;
|
|
1482
|
+
} catch {
|
|
1483
|
+
throw new Error("Response body not available");
|
|
1484
|
+
}
|
|
1485
|
+
})();
|
|
1486
|
+
return JSON.parse(text);
|
|
1487
|
+
},
|
|
1488
|
+
request: () => request
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function createXBRequest(page, data) {
|
|
1492
|
+
return {
|
|
1493
|
+
url: () => data.url,
|
|
1494
|
+
method: () => data.method,
|
|
1495
|
+
headers: () => data.headers,
|
|
1496
|
+
postData: () => data.postData,
|
|
1497
|
+
resourceType: () => data.resourceType,
|
|
1498
|
+
response: async () => {
|
|
1499
|
+
if (!page?._networkResponses) return null;
|
|
1500
|
+
const resp = page._networkResponses.get(data.requestId);
|
|
1501
|
+
if (!resp) return null;
|
|
1502
|
+
return createXBResponse(resp, page._connection, page.sessionId, data);
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
function createXBRouteFetch(conn, sessionId, params, emitter) {
|
|
1507
|
+
const request = createXBRequest(null, {
|
|
1508
|
+
requestId: params.requestId,
|
|
1509
|
+
url: params.request.url,
|
|
1510
|
+
method: params.request.method,
|
|
1511
|
+
headers: params.request.headers,
|
|
1512
|
+
postData: params.request.postData ?? null,
|
|
1513
|
+
resourceType: params.resourceType
|
|
1514
|
+
});
|
|
1515
|
+
return {
|
|
1516
|
+
request: () => request,
|
|
1517
|
+
abort: async (errorCode) => {
|
|
1518
|
+
await conn.send("Fetch.failRequest", {
|
|
1519
|
+
requestId: params.requestId,
|
|
1520
|
+
errorReason: errorCode || "Failed"
|
|
1521
|
+
}, sessionId);
|
|
1522
|
+
},
|
|
1523
|
+
continue: async (opts) => {
|
|
1524
|
+
await conn.send("Fetch.continueRequest", {
|
|
1525
|
+
requestId: params.requestId,
|
|
1526
|
+
url: opts?.url,
|
|
1527
|
+
method: opts?.method,
|
|
1528
|
+
headers: opts?.headers ? Object.entries(opts.headers).map(([k, v]) => ({ name: k, value: v })) : void 0,
|
|
1529
|
+
postData: opts?.postData ? Buffer.from(opts.postData).toString("base64") : void 0
|
|
1530
|
+
}, sessionId);
|
|
1531
|
+
},
|
|
1532
|
+
fulfill: async (opts) => {
|
|
1533
|
+
const bodyStr = typeof opts.body === "string" ? opts.body : opts.body ? opts.body.toString("utf8") : "";
|
|
1534
|
+
const bodyBytes = Buffer.from(bodyStr, "utf8");
|
|
1535
|
+
const headers = { ...opts.headers };
|
|
1536
|
+
if (opts.contentType) headers["content-type"] = opts.contentType;
|
|
1537
|
+
headers["access-control-allow-origin"] = "*";
|
|
1538
|
+
await conn.send("Fetch.fulfillRequest", {
|
|
1539
|
+
requestId: params.requestId,
|
|
1540
|
+
responseCode: opts.status ?? 200,
|
|
1541
|
+
responseHeaders: Object.entries(headers).map(([k, v]) => ({ name: k, value: v })),
|
|
1542
|
+
body: bodyBytes.toString("base64")
|
|
1543
|
+
}, sessionId);
|
|
1544
|
+
if (emitter) {
|
|
1545
|
+
const responseData = {
|
|
1546
|
+
requestId: params.requestId,
|
|
1547
|
+
status: opts.status ?? 200,
|
|
1548
|
+
url: params.request.url,
|
|
1549
|
+
headers
|
|
1550
|
+
};
|
|
1551
|
+
const response = createXBResponse(responseData, conn, sessionId);
|
|
1552
|
+
emitter.emit("response", response);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// src/cdp-driver/page.ts
|
|
1559
|
+
var XBPageImpl = class _XBPageImpl {
|
|
1560
|
+
conn;
|
|
1561
|
+
_emitter = new EventEmitter();
|
|
1562
|
+
_subscriptions = [];
|
|
1563
|
+
sessionId;
|
|
1564
|
+
_targetId;
|
|
1565
|
+
_contextImpl;
|
|
1566
|
+
_browserImpl;
|
|
1567
|
+
_closed = false;
|
|
1568
|
+
_url = "about:blank";
|
|
1569
|
+
_title = "";
|
|
1570
|
+
_viewportSize = null;
|
|
1571
|
+
_loadState = {
|
|
1572
|
+
loadFired: true,
|
|
1573
|
+
domContentFired: true,
|
|
1574
|
+
networkIdle: true
|
|
1575
|
+
};
|
|
1576
|
+
mouse;
|
|
1577
|
+
keyboard;
|
|
1578
|
+
// Network tracking for waitForLoadState('networkidle')
|
|
1579
|
+
inflightRequests = /* @__PURE__ */ new Set();
|
|
1580
|
+
networkIdleResolve = null;
|
|
1581
|
+
networkIdleTimer = null;
|
|
1582
|
+
static NETWORK_IDLE_MS = 500;
|
|
1583
|
+
constructor(conn, sessionId, targetId, context, browser) {
|
|
1584
|
+
this.conn = conn;
|
|
1585
|
+
this.sessionId = sessionId;
|
|
1586
|
+
this._targetId = targetId;
|
|
1587
|
+
this._contextImpl = context;
|
|
1588
|
+
this._browserImpl = browser;
|
|
1589
|
+
this.mouse = new XBMouseImpl(conn, sessionId);
|
|
1590
|
+
this.keyboard = new XBKeyboardImpl(conn, sessionId);
|
|
1591
|
+
}
|
|
1592
|
+
_emit(event, ...args) {
|
|
1593
|
+
this._emitter.emit(event, ...args);
|
|
1594
|
+
}
|
|
1595
|
+
/** Internal initialization — must be called after construction */
|
|
1596
|
+
async _init() {
|
|
1597
|
+
await this.conn.send("Page.enable", void 0, this.sessionId);
|
|
1598
|
+
await this.conn.send("Runtime.enable", void 0, this.sessionId);
|
|
1599
|
+
await this.conn.send("Network.enable", void 0, this.sessionId);
|
|
1600
|
+
await this.conn.send("DOM.enable", void 0, this.sessionId).catch(() => {
|
|
1601
|
+
});
|
|
1602
|
+
this.setupPageEvents();
|
|
1603
|
+
this.setupNetworkEvents();
|
|
1604
|
+
this.setupConsoleEvents();
|
|
1605
|
+
await this.conn.send("Runtime.runIfWaitingForDebugger", void 0, this.sessionId).catch(() => {
|
|
1606
|
+
});
|
|
1607
|
+
try {
|
|
1608
|
+
const info = await this.conn.send(
|
|
1609
|
+
"Target.getTargetInfo",
|
|
1610
|
+
{ targetId: this._targetId }
|
|
1611
|
+
);
|
|
1612
|
+
this._url = info.url;
|
|
1613
|
+
this._title = info.title;
|
|
1614
|
+
if (info.url && info.url !== "about:blank" && info.url !== "") {
|
|
1615
|
+
this._loadState = { loadFired: true, domContentFired: true, networkIdle: true };
|
|
1616
|
+
}
|
|
1617
|
+
} catch {
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
get _connection() {
|
|
1621
|
+
return this.conn;
|
|
1622
|
+
}
|
|
1623
|
+
// ── Navigation ──────────────────────────────────────────────
|
|
1624
|
+
async goto(url, opts = {}) {
|
|
1625
|
+
if (this._closed) throw new Error("Page is closed");
|
|
1626
|
+
const waitUntil = opts.waitUntil ?? "load";
|
|
1627
|
+
const timeout = opts.timeout ?? 3e4;
|
|
1628
|
+
this._loadState = { loadFired: false, domContentFired: false, networkIdle: false };
|
|
1629
|
+
if (process.env.XBROWSER_STEALTH !== "off") {
|
|
1630
|
+
try {
|
|
1631
|
+
await this.conn.send(
|
|
1632
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
1633
|
+
{ source: buildStealthInitScript() },
|
|
1634
|
+
this.sessionId
|
|
1635
|
+
);
|
|
1636
|
+
} catch {
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const result = await this.conn.send(
|
|
1640
|
+
"Page.navigate",
|
|
1641
|
+
{ url, referrer: opts.referer },
|
|
1642
|
+
this.sessionId
|
|
1643
|
+
);
|
|
1644
|
+
if (result.errorText) {
|
|
1645
|
+
throw new Error(`Navigation failed: ${result.errorText}`);
|
|
1646
|
+
}
|
|
1647
|
+
if (waitUntil === "commit") {
|
|
1648
|
+
this._url = url;
|
|
1649
|
+
return {
|
|
1650
|
+
status: () => 0,
|
|
1651
|
+
ok: () => false,
|
|
1652
|
+
url: () => url,
|
|
1653
|
+
headers: () => ({})
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
await this.waitForLoadState(waitUntil, timeout);
|
|
1657
|
+
this._url = url;
|
|
1658
|
+
const statusCode = 200;
|
|
1659
|
+
const finalUrl = url;
|
|
1660
|
+
const headers = {};
|
|
1661
|
+
return {
|
|
1662
|
+
status: () => statusCode,
|
|
1663
|
+
ok: () => statusCode >= 200 && statusCode < 300,
|
|
1664
|
+
url: () => finalUrl,
|
|
1665
|
+
headers: () => headers
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
async goBack(opts = {}) {
|
|
1669
|
+
try {
|
|
1670
|
+
const navHistory = await this.conn.send("Page.getNavigationHistory", void 0, this.sessionId);
|
|
1671
|
+
if (navHistory.currentIndex > 0) {
|
|
1672
|
+
const prevUrl = navHistory.entries[navHistory.currentIndex - 1]?.url;
|
|
1673
|
+
if (prevUrl && prevUrl !== "about:blank") {
|
|
1674
|
+
await this.conn.send("Page.navigate", { url: prevUrl }, this.sessionId);
|
|
1675
|
+
await this.waitForLoadState(opts.waitUntil ?? "domcontentloaded", opts.timeout ?? 1e4).catch(() => {
|
|
1676
|
+
});
|
|
1677
|
+
this._url = prevUrl;
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
} catch {
|
|
1682
|
+
}
|
|
1683
|
+
await this.evaluate("() => history.back()");
|
|
1684
|
+
await this.waitForTimeout(3e3);
|
|
1685
|
+
this._url = await this.evaluate("location.href").catch(() => this._url);
|
|
1686
|
+
}
|
|
1687
|
+
async goForward(opts = {}) {
|
|
1688
|
+
try {
|
|
1689
|
+
const navHistory = await this.conn.send("Page.getNavigationHistory", void 0, this.sessionId);
|
|
1690
|
+
if (navHistory.currentIndex < navHistory.entries.length - 1) {
|
|
1691
|
+
const nextUrl = navHistory.entries[navHistory.currentIndex + 1]?.url;
|
|
1692
|
+
if (nextUrl && nextUrl !== "about:blank") {
|
|
1693
|
+
await this.conn.send("Page.navigate", { url: nextUrl }, this.sessionId);
|
|
1694
|
+
await this.waitForLoadState(opts.waitUntil ?? "domcontentloaded", opts.timeout ?? 1e4).catch(() => {
|
|
1695
|
+
});
|
|
1696
|
+
this._url = nextUrl;
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
} catch {
|
|
1701
|
+
}
|
|
1702
|
+
await this.evaluate("() => history.forward()");
|
|
1703
|
+
await this.waitForTimeout(3e3);
|
|
1704
|
+
this._url = await this.evaluate("location.href").catch(() => this._url);
|
|
1705
|
+
}
|
|
1706
|
+
async reload(opts = {}) {
|
|
1707
|
+
this._loadState = { loadFired: false, domContentFired: false, networkIdle: false };
|
|
1708
|
+
await this.conn.send("Page.reload", void 0, this.sessionId);
|
|
1709
|
+
await this.waitForLoadState(opts.waitUntil ?? "load", opts.timeout);
|
|
1710
|
+
}
|
|
1711
|
+
async waitForLoadState(state = "load", timeout = 3e4) {
|
|
1712
|
+
if (this._closed) throw new Error("Page is closed");
|
|
1713
|
+
const checkState = () => {
|
|
1714
|
+
switch (state) {
|
|
1715
|
+
case "domcontentloaded":
|
|
1716
|
+
return this._loadState.domContentFired;
|
|
1717
|
+
case "load":
|
|
1718
|
+
return this._loadState.loadFired;
|
|
1719
|
+
case "networkidle":
|
|
1720
|
+
return this._loadState.networkIdle;
|
|
1721
|
+
case "commit":
|
|
1722
|
+
return this._loadState.domContentFired;
|
|
1723
|
+
default:
|
|
1724
|
+
return true;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
if (checkState()) return;
|
|
1728
|
+
return new Promise((resolve, reject) => {
|
|
1729
|
+
const timer = setTimeout(() => {
|
|
1730
|
+
reject(new Error(`waitForLoadState('${state}') timeout after ${timeout}ms`));
|
|
1731
|
+
}, timeout);
|
|
1732
|
+
const check = () => {
|
|
1733
|
+
if (checkState()) {
|
|
1734
|
+
clearTimeout(timer);
|
|
1735
|
+
resolve();
|
|
1736
|
+
} else {
|
|
1737
|
+
setTimeout(check, 50);
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
check();
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
async waitForTimeout(ms) {
|
|
1744
|
+
await new Promise((resolve) => {
|
|
1745
|
+
const timer = setTimeout(resolve, ms);
|
|
1746
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
async waitForSelector(selector, opts = {}) {
|
|
1750
|
+
const state = opts.state ?? "visible";
|
|
1751
|
+
const timeout = opts.timeout ?? 3e4;
|
|
1752
|
+
const deadline = Date.now() + timeout;
|
|
1753
|
+
while (Date.now() < deadline) {
|
|
1754
|
+
const exists = await this.evaluate(
|
|
1755
|
+
`(function() { const el = ${queryJS(selector)}; return !!el; })()`
|
|
1756
|
+
);
|
|
1757
|
+
if (state === "attached" && exists) return;
|
|
1758
|
+
if (state === "detached" && !exists) return;
|
|
1759
|
+
if (state === "visible" && exists) {
|
|
1760
|
+
const visible = await this.evaluate(
|
|
1761
|
+
`(function() { const el = ${queryJS(selector)}; if (!el) return false; const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; })()`
|
|
1762
|
+
);
|
|
1763
|
+
if (visible) return;
|
|
1764
|
+
}
|
|
1765
|
+
if (state === "hidden") {
|
|
1766
|
+
if (!exists) return;
|
|
1767
|
+
const visible = await this.evaluate(
|
|
1768
|
+
`(function() { const el = ${queryJS(selector)}; if (!el) return false; const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; })()`
|
|
1769
|
+
);
|
|
1770
|
+
if (!visible) return;
|
|
1771
|
+
}
|
|
1772
|
+
await this.waitForTimeout(100);
|
|
1773
|
+
}
|
|
1774
|
+
throw new Error(`waitForSelector('${selector}', state='${state}') timeout after ${timeout}ms`);
|
|
1775
|
+
}
|
|
1776
|
+
async waitForFunction(fn, opts = {}, ...args) {
|
|
1777
|
+
const timeout = opts.timeout ?? 3e4;
|
|
1778
|
+
const polling = opts.polling ?? 100;
|
|
1779
|
+
const deadline = Date.now() + timeout;
|
|
1780
|
+
const fnBody = typeof fn === "function" ? fn.toString() : fn;
|
|
1781
|
+
let lastError = null;
|
|
1782
|
+
while (Date.now() < deadline) {
|
|
1783
|
+
try {
|
|
1784
|
+
const result = await this.evaluate(
|
|
1785
|
+
`(function(fnStr, ...evalArgs) { const fn = new Function('return ' + fnStr); return fn(...evalArgs); })(${JSON.stringify(fnBody)}${args.length > 0 ? ", " + args.map((a) => JSON.stringify(a)).join(", ") : ""})`
|
|
1786
|
+
);
|
|
1787
|
+
if (result) return result;
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
lastError = err instanceof Error ? err : new Error(errMsg(err));
|
|
1790
|
+
}
|
|
1791
|
+
const pollMs = typeof polling === "number" ? polling : 16;
|
|
1792
|
+
await this.waitForTimeout(pollMs);
|
|
1793
|
+
}
|
|
1794
|
+
const detail = lastError ? `
|
|
1795
|
+
Last error: ${lastError.message}` : "";
|
|
1796
|
+
throw new Error(`waitForFunction timeout after ${timeout}ms${detail}`);
|
|
1797
|
+
}
|
|
1798
|
+
url() {
|
|
1799
|
+
return this._url;
|
|
1800
|
+
}
|
|
1801
|
+
async title() {
|
|
1802
|
+
for (let i = 0; i < 20; i++) {
|
|
1803
|
+
try {
|
|
1804
|
+
this._title = await this.evaluate("document.title");
|
|
1805
|
+
if (this._title) return this._title;
|
|
1806
|
+
} catch {
|
|
1807
|
+
}
|
|
1808
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1809
|
+
}
|
|
1810
|
+
try {
|
|
1811
|
+
const info = await this._cdpSend("Target.getTargetInfo", { targetId: this._targetId });
|
|
1812
|
+
if (info?.title) {
|
|
1813
|
+
this._title = info.title;
|
|
1814
|
+
return this._title;
|
|
1815
|
+
}
|
|
1816
|
+
} catch {
|
|
1817
|
+
}
|
|
1818
|
+
return this._title;
|
|
1819
|
+
}
|
|
1820
|
+
async content() {
|
|
1821
|
+
return this.evaluate("document.documentElement.outerHTML");
|
|
1822
|
+
}
|
|
1823
|
+
// ── Evaluation ──────────────────────────────────────────────
|
|
1824
|
+
async evaluate(fn, ...args) {
|
|
1825
|
+
if (this._closed) throw new Error("Page is closed");
|
|
1826
|
+
this.conn.send("Page.handleJavaScriptDialog", { accept: false }, this.sessionId).catch(() => {
|
|
1827
|
+
});
|
|
1828
|
+
let expression;
|
|
1829
|
+
if (typeof fn === "string") {
|
|
1830
|
+
expression = fn;
|
|
1831
|
+
} else {
|
|
1832
|
+
const argStr = args.length > 0 ? `...${JSON.stringify(args)}` : "";
|
|
1833
|
+
expression = `(()=>{const __fn=(${fn.toString()});return __fn(${argStr});})()`;
|
|
1834
|
+
}
|
|
1835
|
+
const result = await this.conn.send("Runtime.evaluate", {
|
|
1836
|
+
expression,
|
|
1837
|
+
returnByValue: true,
|
|
1838
|
+
awaitPromise: true
|
|
1839
|
+
}, this.sessionId);
|
|
1840
|
+
if (result.exceptionDetails) {
|
|
1841
|
+
const detail = result.exceptionDetails.exception?.description ?? result.exceptionDetails.exception?.value ?? result.exceptionDetails.text;
|
|
1842
|
+
throw new Error(`${detail}`);
|
|
1843
|
+
}
|
|
1844
|
+
return result.result?.value;
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* 在指定 iframe 上下文中执行表达式(攻防 D16 能力建设,2026-08-19)。
|
|
1848
|
+
*
|
|
1849
|
+
* 双路径:
|
|
1850
|
+
* 1. 同进程 iframe —— Runtime.enable 收集 executionContextCreated,
|
|
1851
|
+
* 找到目标 frameId 的 contextId,用 contextId 定向执行;
|
|
1852
|
+
* 2. 跨域 OOPIF(独立 target)—— Target.setAutoAttach(flatten) 监听
|
|
1853
|
+
* attachedToTarget 中 type==='iframe' 的会话,用其 sessionId 执行。
|
|
1854
|
+
*
|
|
1855
|
+
* 这绕过了页面同源策略(那是页面 JS 的约束,CDP 是调试通道)——
|
|
1856
|
+
* 支付窗/验证码/第三方嵌入内容的读写都靠它。
|
|
1857
|
+
*/
|
|
1858
|
+
async evaluateInFrame(urlIncludes, expression) {
|
|
1859
|
+
if (this._closed) throw new Error("Page is closed");
|
|
1860
|
+
const evalIn = async (sessionId, contextId) => {
|
|
1861
|
+
const params = { expression, returnByValue: true, awaitPromise: true };
|
|
1862
|
+
if (contextId !== void 0) params.contextId = contextId;
|
|
1863
|
+
const result = await this.conn.send("Runtime.evaluate", params, sessionId);
|
|
1864
|
+
if (result.exceptionDetails) {
|
|
1865
|
+
const detail = result.exceptionDetails.exception?.description ?? result.exceptionDetails.exception?.value ?? result.exceptionDetails.text;
|
|
1866
|
+
throw new Error(`[frame ${urlIncludes}] ${detail}`);
|
|
1867
|
+
}
|
|
1868
|
+
return result.result?.value;
|
|
1869
|
+
};
|
|
1870
|
+
try {
|
|
1871
|
+
const tg = await this.conn.send("Target.getTargets", void 0);
|
|
1872
|
+
const hit2 = (tg.targetInfos || []).find((t) => t.type === "iframe" && (t.url || "").includes(urlIncludes));
|
|
1873
|
+
if (hit2) {
|
|
1874
|
+
const att = await this.conn.send("Target.attachToTarget", { targetId: hit2.targetId, flatten: true });
|
|
1875
|
+
return evalIn(att.sessionId);
|
|
1876
|
+
}
|
|
1877
|
+
} catch {
|
|
1878
|
+
}
|
|
1879
|
+
const tree = await this.conn.send("Page.getFrameTree", void 0, this.sessionId);
|
|
1880
|
+
const all = [];
|
|
1881
|
+
const walk = (node) => {
|
|
1882
|
+
all.push({ id: node.frame.id, url: node.frame.url });
|
|
1883
|
+
for (const child of node.childFrames || []) walk(child);
|
|
1884
|
+
};
|
|
1885
|
+
walk(tree.frameTree);
|
|
1886
|
+
const mainId = tree.frameTree?.frame?.id;
|
|
1887
|
+
const target = all.find((f) => f.id !== mainId && f.url.includes(urlIncludes));
|
|
1888
|
+
if (target) {
|
|
1889
|
+
const contexts = [];
|
|
1890
|
+
const onCtx = (raw) => {
|
|
1891
|
+
const c = raw?.context;
|
|
1892
|
+
if (c?.id && c?.auxData?.frameId) contexts.push({ id: c.id, frameId: c.auxData.frameId });
|
|
1893
|
+
};
|
|
1894
|
+
this.conn.on("Runtime.executionContextCreated", onCtx);
|
|
1895
|
+
try {
|
|
1896
|
+
await this.conn.send("Runtime.enable", void 0, this.sessionId).catch(() => {
|
|
1897
|
+
});
|
|
1898
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1899
|
+
} finally {
|
|
1900
|
+
this.conn.off("Runtime.executionContextCreated", onCtx);
|
|
1901
|
+
}
|
|
1902
|
+
const ctx = contexts.find((c) => c.frameId === target.id);
|
|
1903
|
+
if (ctx) return evalIn(this.sessionId, ctx.id);
|
|
1904
|
+
}
|
|
1905
|
+
const attached = [];
|
|
1906
|
+
const onAttach = (raw) => {
|
|
1907
|
+
const ev = raw;
|
|
1908
|
+
if (ev?.sessionId && ev.targetInfo?.type === "iframe") {
|
|
1909
|
+
attached.push({ sessionId: ev.sessionId, url: ev.targetInfo.url || "" });
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
this.conn.on("Target.attachedToTarget", onAttach);
|
|
1913
|
+
try {
|
|
1914
|
+
await this.conn.send("Target.setAutoAttach", {
|
|
1915
|
+
autoAttach: true,
|
|
1916
|
+
waitForDebuggerOnStart: false,
|
|
1917
|
+
flatten: true
|
|
1918
|
+
}, this.sessionId);
|
|
1919
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1920
|
+
} finally {
|
|
1921
|
+
this.conn.off("Target.attachedToTarget", onAttach);
|
|
1922
|
+
this.conn.send("Target.setAutoAttach", { autoAttach: false, waitForDebuggerOnStart: false, flatten: true }, this.sessionId).catch(() => {
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
const hit = attached.find((a) => a.url.includes(urlIncludes));
|
|
1926
|
+
if (!hit) {
|
|
1927
|
+
throw new Error(`frame not found for "${urlIncludes}"\uFF08OOPIF target\u3001frame \u6811\u3001auto-attach \u4E09\u8DEF\u5747\u672A\u547D\u4E2D\uFF1B\u53EF\u80FD\u4ECD\u5728\u52A0\u8F7D\uFF09`);
|
|
1928
|
+
}
|
|
1929
|
+
return evalIn(hit.sessionId);
|
|
1930
|
+
}
|
|
1931
|
+
/** evaluateHandle — evaluates fn and returns a handle for element bounding box */
|
|
1932
|
+
async evaluateHandle(fn, ...args) {
|
|
1933
|
+
let expression;
|
|
1934
|
+
if (typeof fn === "string") {
|
|
1935
|
+
expression = fn;
|
|
1936
|
+
} else {
|
|
1937
|
+
const argStr = args.length > 0 ? `...${JSON.stringify(args)}` : "";
|
|
1938
|
+
expression = `(()=>{const __fn=(${fn.toString()});const __el=__fn(${argStr});if(__el&&typeof __el.getBoundingClientRect==='function'){const r=__el.getBoundingClientRect();return JSON.parse(JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height}));}return null;})()`;
|
|
1939
|
+
}
|
|
1940
|
+
const result = await this.conn.send("Runtime.evaluate", { expression, returnByValue: true }).catch(() => ({ result: { value: null } }));
|
|
1941
|
+
let box = null;
|
|
1942
|
+
try {
|
|
1943
|
+
box = JSON.parse(result.result?.value);
|
|
1944
|
+
} catch {
|
|
1945
|
+
}
|
|
1946
|
+
return {
|
|
1947
|
+
asElement: () => box ? { boundingBox: async () => box } : null
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
async $eval(selector, fn, ...args) {
|
|
1951
|
+
const fnBody = typeof fn === "function" ? fn.toString() : fn;
|
|
1952
|
+
const selJSON = JSON.stringify(selector);
|
|
1953
|
+
const xpathPrefix = selector.startsWith("xpath=") ? JSON.stringify(selector.slice(6)) : "null";
|
|
1954
|
+
return this.evaluate(
|
|
1955
|
+
`(function(sel, xpathExpr, fnStr, ...evalArgs) {
|
|
1956
|
+
const el = xpathExpr
|
|
1957
|
+
? document.evaluate(xpathExpr, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
|
|
1958
|
+
: document.querySelector(sel);
|
|
1959
|
+
if (!el) throw new Error('No element found for selector: ' + sel);
|
|
1960
|
+
const fn = new Function('return ' + fnStr)();
|
|
1961
|
+
return fn(el, ...evalArgs);
|
|
1962
|
+
})(${selJSON}, ${xpathPrefix}, ${JSON.stringify(fnBody)}${args.length > 0 ? ", " + args.map((a) => JSON.stringify(a)).join(", ") : ""})`
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
async $$eval(selector, fn, ...args) {
|
|
1966
|
+
const fnBody = typeof fn === "function" ? fn.toString() : fn;
|
|
1967
|
+
const selJSON = JSON.stringify(selector);
|
|
1968
|
+
const xpathPrefix = selector.startsWith("xpath=") ? JSON.stringify(selector.slice(6)) : "null";
|
|
1969
|
+
return this.evaluate(
|
|
1970
|
+
`(function(sel, xpathExpr, fnStr, ...evalArgs) {
|
|
1971
|
+
const els = xpathExpr
|
|
1972
|
+
? (() => { const it = document.evaluate(xpathExpr, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); const r=[]; for(let i=0;i<it.snapshotLength;i++) r.push(it.snapshotItem(i)); return r; })()
|
|
1973
|
+
: Array.from(document.querySelectorAll(sel));
|
|
1974
|
+
const fn = new Function('return ' + fnStr)();
|
|
1975
|
+
return fn(els, ...evalArgs);
|
|
1976
|
+
})(${selJSON}, ${xpathPrefix}, ${JSON.stringify(fnBody)}${args.length > 0 ? ", " + args.map((a) => JSON.stringify(a)).join(", ") : ""})`
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
// ── Locator ─────────────────────────────────────────────────
|
|
1980
|
+
locator(selector) {
|
|
1981
|
+
return new XBLocatorImpl(this, selector);
|
|
1982
|
+
}
|
|
1983
|
+
getByText(text, opts) {
|
|
1984
|
+
const escaped = text.replace(/'/g, "\\'");
|
|
1985
|
+
if (opts?.exact) {
|
|
1986
|
+
return this.locator(`xpath=//*[normalize-space(text())='${escaped}']`);
|
|
1987
|
+
}
|
|
1988
|
+
return this.locator(`xpath=//*[contains(text(),'${escaped}')]`);
|
|
1989
|
+
}
|
|
1990
|
+
getByRole(role, opts) {
|
|
1991
|
+
const ROLE_TO_TAGS = {
|
|
1992
|
+
button: ["button"],
|
|
1993
|
+
link: ["a[href]"],
|
|
1994
|
+
heading: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
|
1995
|
+
textbox: ['input[type="text"]', "input:not([type])", "textarea"],
|
|
1996
|
+
checkbox: ['input[type="checkbox"]'],
|
|
1997
|
+
radio: ['input[type="radio"]'],
|
|
1998
|
+
searchbox: ['input[type="search"]'],
|
|
1999
|
+
combobox: ["select"],
|
|
2000
|
+
img: ["img"],
|
|
2001
|
+
navigation: ["nav"],
|
|
2002
|
+
article: ["article"],
|
|
2003
|
+
banner: ["header"],
|
|
2004
|
+
contentinfo: ["footer"],
|
|
2005
|
+
main: ["main"],
|
|
2006
|
+
complementary: ["aside"]
|
|
2007
|
+
};
|
|
2008
|
+
const tags = ROLE_TO_TAGS[role] || [];
|
|
2009
|
+
const tagSel = tags.length > 0 ? tags.join(",") + "," : "";
|
|
2010
|
+
let sel = `${tagSel}[role="${role}"]`;
|
|
2011
|
+
if (opts?.name) {
|
|
2012
|
+
sel += opts.exact ? `[aria-label="${opts.name}"]` : `[aria-label*="${opts.name}"]`;
|
|
2013
|
+
}
|
|
2014
|
+
return this.locator(sel);
|
|
2015
|
+
}
|
|
2016
|
+
getByLabel(label, opts) {
|
|
2017
|
+
const escaped = label.replace(/'/g, "\\'");
|
|
2018
|
+
const sel = opts?.exact ? `xpath=//*[@aria-label='${escaped}' or @id=//label[normalize-space(text())='${escaped}']/@for]` : `xpath=//*[contains(@aria-label,'${escaped}') or @id=//label[contains(text(),'${escaped}')]/@for]`;
|
|
2019
|
+
return this.locator(sel);
|
|
2020
|
+
}
|
|
2021
|
+
getByPlaceholder(text, opts) {
|
|
2022
|
+
return this.locator(
|
|
2023
|
+
opts?.exact ? `[placeholder="${text}"]` : `[placeholder*="${text}"]`
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
getByTestId(id) {
|
|
2027
|
+
return this.locator(`[data-testid="${id}"]`);
|
|
2028
|
+
}
|
|
2029
|
+
getByAltText(text, opts) {
|
|
2030
|
+
return this.locator(opts?.exact ? `[alt="${text}"]` : `[alt*="${text}"]`);
|
|
2031
|
+
}
|
|
2032
|
+
getByTitle(title, opts) {
|
|
2033
|
+
return this.locator(
|
|
2034
|
+
opts?.exact ? `[title="${title}"]` : `[title*="${title}"]`
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
// ── Interaction shortcuts ───────────────────────────────────
|
|
2038
|
+
async click(selector, opts = {}) {
|
|
2039
|
+
await this.locator(selector).click(opts);
|
|
2040
|
+
}
|
|
2041
|
+
async dblclick(selector, opts = {}) {
|
|
2042
|
+
await this.locator(selector).click({ ...opts, clickCount: 2 });
|
|
2043
|
+
}
|
|
2044
|
+
async fill(selector, value, opts = {}) {
|
|
2045
|
+
await this.locator(selector).fill(value, opts);
|
|
2046
|
+
}
|
|
2047
|
+
async press(selector, key, opts) {
|
|
2048
|
+
await this.locator(selector).press(key, opts);
|
|
2049
|
+
}
|
|
2050
|
+
async hover(selector, opts) {
|
|
2051
|
+
await this.locator(selector).hover(opts);
|
|
2052
|
+
}
|
|
2053
|
+
async type(selector, text, opts = {}) {
|
|
2054
|
+
await this.locator(selector).type(text, opts);
|
|
2055
|
+
}
|
|
2056
|
+
async check(selector, opts) {
|
|
2057
|
+
await this.locator(selector).check(opts);
|
|
2058
|
+
}
|
|
2059
|
+
async uncheck(selector, opts) {
|
|
2060
|
+
await this.locator(selector).uncheck(opts);
|
|
2061
|
+
}
|
|
2062
|
+
async selectOption(selector, value) {
|
|
2063
|
+
return this.locator(selector).selectOption(value);
|
|
2064
|
+
}
|
|
2065
|
+
// ── Convenience selectors ───────────────────────────────────
|
|
2066
|
+
async textContent(selector) {
|
|
2067
|
+
return this.evaluate(
|
|
2068
|
+
`(function() { const el = ${queryJS(selector)}; return el?.textContent ?? null; })()`
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
async innerText(selector) {
|
|
2072
|
+
return this.evaluate(
|
|
2073
|
+
`(function() { const el = ${queryJS(selector)}; if (!el) throw new Error('Element not found'); return el.innerText; })()`
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
async innerHTML(selector) {
|
|
2077
|
+
return this.evaluate(
|
|
2078
|
+
`(function() { const el = ${queryJS(selector)}; if (!el) throw new Error('Element not found'); return el.innerHTML; })()`
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
async getAttribute(selector, name) {
|
|
2082
|
+
return this.evaluate(
|
|
2083
|
+
`(function() { const el = ${queryJS(selector)}; return el?.getAttribute(${JSON.stringify(name)}) ?? null; })()`
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
// ── Query ───────────────────────────────────────────────────
|
|
2087
|
+
async $(selector) {
|
|
2088
|
+
const nodeId = await this.querySelector(selector);
|
|
2089
|
+
if (!nodeId) return null;
|
|
2090
|
+
return new XBElementHandleImpl(this, nodeId);
|
|
2091
|
+
}
|
|
2092
|
+
async $$(selector) {
|
|
2093
|
+
const nodeIds = await this.querySelectorAll(selector);
|
|
2094
|
+
return nodeIds.map((id) => new XBElementHandleImpl(this, id));
|
|
2095
|
+
}
|
|
2096
|
+
// ── Screen ──────────────────────────────────────────────────
|
|
2097
|
+
async screenshot(opts = {}) {
|
|
2098
|
+
const params = {
|
|
2099
|
+
format: opts.type ?? "png"
|
|
2100
|
+
};
|
|
2101
|
+
if (opts.quality !== void 0 && (opts.type === "jpeg" || !opts.type && opts.quality)) {
|
|
2102
|
+
params.quality = opts.quality;
|
|
2103
|
+
}
|
|
2104
|
+
if (opts.fullPage) {
|
|
2105
|
+
params.captureBeyondViewport = true;
|
|
2106
|
+
}
|
|
2107
|
+
if (opts.clip) {
|
|
2108
|
+
const clip = {
|
|
2109
|
+
x: Math.round(opts.clip.x),
|
|
2110
|
+
y: Math.round(opts.clip.y),
|
|
2111
|
+
width: Math.round(Math.max(1, opts.clip.width)),
|
|
2112
|
+
height: Math.round(Math.max(1, opts.clip.height)),
|
|
2113
|
+
scale: 1
|
|
2114
|
+
};
|
|
2115
|
+
params.clip = clip;
|
|
2116
|
+
}
|
|
2117
|
+
if (opts.omitBackground) {
|
|
2118
|
+
params.omitBackground = true;
|
|
2119
|
+
}
|
|
2120
|
+
const result = await this.conn.send(
|
|
2121
|
+
"Page.captureScreenshot",
|
|
2122
|
+
params,
|
|
2123
|
+
this.sessionId
|
|
2124
|
+
);
|
|
2125
|
+
return Buffer.from(result.data, "base64");
|
|
2126
|
+
}
|
|
2127
|
+
async pdf(opts = {}) {
|
|
2128
|
+
const params = {};
|
|
2129
|
+
if (opts.landscape !== void 0) params.landscape = opts.landscape;
|
|
2130
|
+
if (opts.printBackground !== void 0) params.printBackground = opts.printBackground;
|
|
2131
|
+
if (opts.scale !== void 0) params.scale = opts.scale;
|
|
2132
|
+
if (opts.format) params.paperFormat = opts.format;
|
|
2133
|
+
if (opts.preferCSSPageSize !== void 0) params.preferCSSPageSize = opts.preferCSSPageSize;
|
|
2134
|
+
if (opts.margin) {
|
|
2135
|
+
if (opts.margin.top) params.marginTop = parseFloat(opts.margin.top);
|
|
2136
|
+
if (opts.margin.bottom) params.marginBottom = parseFloat(opts.margin.bottom);
|
|
2137
|
+
if (opts.margin.left) params.marginLeft = parseFloat(opts.margin.left);
|
|
2138
|
+
if (opts.margin.right) params.marginRight = parseFloat(opts.margin.right);
|
|
2139
|
+
}
|
|
2140
|
+
const result = await this.conn.send("Page.printToPDF", params, this.sessionId);
|
|
2141
|
+
return Buffer.from(result.data, "base64");
|
|
2142
|
+
}
|
|
2143
|
+
viewportSize() {
|
|
2144
|
+
return this._viewportSize ?? null;
|
|
2145
|
+
}
|
|
2146
|
+
async setViewportSize(size) {
|
|
2147
|
+
await this.conn.send(
|
|
2148
|
+
"Emulation.setDeviceMetricsOverride",
|
|
2149
|
+
{
|
|
2150
|
+
width: size.width,
|
|
2151
|
+
height: size.height,
|
|
2152
|
+
deviceScaleFactor: 1,
|
|
2153
|
+
mobile: false
|
|
2154
|
+
},
|
|
2155
|
+
this.sessionId
|
|
2156
|
+
);
|
|
2157
|
+
this._viewportSize = size;
|
|
2158
|
+
}
|
|
2159
|
+
// ── Scripts ─────────────────────────────────────────────────
|
|
2160
|
+
async addInitScript(script) {
|
|
2161
|
+
await this.conn.send(
|
|
2162
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
2163
|
+
{ source: script },
|
|
2164
|
+
this.sessionId
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
/** Internal: set user agent */
|
|
2168
|
+
async _setUserAgent(userAgent) {
|
|
2169
|
+
await this.conn.send(
|
|
2170
|
+
"Network.setUserAgentOverride",
|
|
2171
|
+
{ userAgent },
|
|
2172
|
+
this.sessionId
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
/** 覆盖 User-Agent(headless 环境对抗 UA 检测的标配能力) */
|
|
2176
|
+
async setUserAgent(userAgent) {
|
|
2177
|
+
await this._setUserAgent(userAgent);
|
|
2178
|
+
}
|
|
2179
|
+
/** Internal: set extra HTTP headers */
|
|
2180
|
+
async _setExtraHTTPHeaders(headers) {
|
|
2181
|
+
await this.setExtraHTTPHeaders(headers);
|
|
2182
|
+
}
|
|
2183
|
+
async bringToFront() {
|
|
2184
|
+
await this.conn.send("Page.bringToFront", void 0, this.sessionId);
|
|
2185
|
+
}
|
|
2186
|
+
async setExtraHTTPHeaders(headers) {
|
|
2187
|
+
await this.conn.send("Network.setExtraHTTPHeaders", { headers }, this.sessionId);
|
|
2188
|
+
}
|
|
2189
|
+
// ── Events ──────────────────────────────────────────────────
|
|
2190
|
+
on(event, handler) {
|
|
2191
|
+
this._emitter.on(event, handler);
|
|
2192
|
+
}
|
|
2193
|
+
off(event, handler) {
|
|
2194
|
+
this._emitter.off(event, handler);
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Wait for a one-shot event (Playwright-compatible subset).
|
|
2198
|
+
* Used to listen for 'filechooser', 'dialog', 'popup', 'framenavigated', etc.
|
|
2199
|
+
*/
|
|
2200
|
+
async waitForEvent(event, opts = {}) {
|
|
2201
|
+
const timeout = opts.timeout ?? 3e4;
|
|
2202
|
+
return new Promise((resolve, reject) => {
|
|
2203
|
+
const timer = setTimeout(() => {
|
|
2204
|
+
this._emitter.off(event, handler);
|
|
2205
|
+
reject(new Error(`waitForEvent('${event}') timeout after ${timeout}ms`));
|
|
2206
|
+
}, timeout);
|
|
2207
|
+
const handler = (...args) => {
|
|
2208
|
+
if (opts.predicate && !opts.predicate(...args)) return;
|
|
2209
|
+
clearTimeout(timer);
|
|
2210
|
+
this._emitter.off(event, handler);
|
|
2211
|
+
resolve(args.length === 1 ? args[0] : args);
|
|
2212
|
+
};
|
|
2213
|
+
this._emitter.on(event, handler);
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
// ── Lifecycle ───────────────────────────────────────────────
|
|
2217
|
+
async close() {
|
|
2218
|
+
if (this._closed) return;
|
|
2219
|
+
this._closed = true;
|
|
2220
|
+
if (this.networkIdleTimer) {
|
|
2221
|
+
clearTimeout(this.networkIdleTimer);
|
|
2222
|
+
this.networkIdleTimer = null;
|
|
2223
|
+
}
|
|
2224
|
+
for (const unsub of this._subscriptions) {
|
|
2225
|
+
unsub();
|
|
2226
|
+
}
|
|
2227
|
+
this._subscriptions = [];
|
|
2228
|
+
await this._browserImpl._closeTarget(this._targetId).catch(() => {
|
|
2229
|
+
});
|
|
2230
|
+
await this._browserImpl._detachFromTarget(this.sessionId).catch(() => {
|
|
2231
|
+
});
|
|
2232
|
+
this._emitter.emit("close");
|
|
2233
|
+
}
|
|
2234
|
+
isClosed() {
|
|
2235
|
+
return this._closed;
|
|
2236
|
+
}
|
|
2237
|
+
context() {
|
|
2238
|
+
return this._contextImpl;
|
|
2239
|
+
}
|
|
2240
|
+
browser() {
|
|
2241
|
+
return this._browserImpl;
|
|
2242
|
+
}
|
|
2243
|
+
mainFrame() {
|
|
2244
|
+
return {
|
|
2245
|
+
url: () => this._url,
|
|
2246
|
+
name: () => "",
|
|
2247
|
+
isDetached: () => this._closed,
|
|
2248
|
+
page: () => this,
|
|
2249
|
+
evaluate: (fn, ...args) => this.evaluate(fn, ...args),
|
|
2250
|
+
$: (sel) => this.$(sel),
|
|
2251
|
+
$$: (sel) => this.$$(sel)
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
frames() {
|
|
2255
|
+
return [this.mainFrame()];
|
|
2256
|
+
}
|
|
2257
|
+
/**
|
|
2258
|
+
* Discover all frames (main + iframes) via CDP Page.getFrameTree.
|
|
2259
|
+
* This is async because CDP doesn't maintain a frame list client-side.
|
|
2260
|
+
*/
|
|
2261
|
+
async discoverFrames() {
|
|
2262
|
+
try {
|
|
2263
|
+
const result = await this.conn.send("Page.getFrameTree", void 0, this.sessionId);
|
|
2264
|
+
const frames = [];
|
|
2265
|
+
const collect = (node) => {
|
|
2266
|
+
const frame = {
|
|
2267
|
+
name: () => node.frame.name || "",
|
|
2268
|
+
url: () => node.frame.url,
|
|
2269
|
+
isDetached: () => false,
|
|
2270
|
+
page: () => this,
|
|
2271
|
+
evaluate: (fn, ...args) => this.evaluate(fn, ...args),
|
|
2272
|
+
$: (sel) => this.$(sel),
|
|
2273
|
+
$$: (sel) => this.$$(sel)
|
|
2274
|
+
};
|
|
2275
|
+
frames.push(frame);
|
|
2276
|
+
for (const child of node.childFrames || []) {
|
|
2277
|
+
collect({ frame: child.frame, childFrames: [] });
|
|
2278
|
+
}
|
|
2279
|
+
};
|
|
2280
|
+
collect(result.frameTree);
|
|
2281
|
+
return frames;
|
|
2282
|
+
} catch {
|
|
2283
|
+
return [this.mainFrame()];
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
// ── CDP helpers exposed for locator/element ─────────────────
|
|
2287
|
+
/** Query a single element, returns CDP nodeId or 0 if not found */
|
|
2288
|
+
async querySelector(selector) {
|
|
2289
|
+
if (selector.startsWith("xpath=")) {
|
|
2290
|
+
const found = await this.evaluate(`
|
|
2291
|
+
(() => { const el = ${queryJS(selector)}; return !!el; })()
|
|
2292
|
+
`).catch(() => false);
|
|
2293
|
+
if (!found) return 0;
|
|
2294
|
+
try {
|
|
2295
|
+
const search = await this.conn.send(
|
|
2296
|
+
"DOM.performSearch",
|
|
2297
|
+
{ query: selector.slice(6) },
|
|
2298
|
+
this.sessionId
|
|
2299
|
+
);
|
|
2300
|
+
if (search.nodeId) return search.nodeId;
|
|
2301
|
+
} catch {
|
|
2302
|
+
}
|
|
2303
|
+
return 1;
|
|
2304
|
+
}
|
|
2305
|
+
const withTimeout = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(() => r(null), ms))]);
|
|
2306
|
+
const doc = await withTimeout(
|
|
2307
|
+
this.conn.send("DOM.getDocument", { depth: 0 }, this.sessionId),
|
|
2308
|
+
8e3
|
|
2309
|
+
);
|
|
2310
|
+
if (!doc) return 0;
|
|
2311
|
+
const result = await withTimeout(
|
|
2312
|
+
this.conn.send(
|
|
2313
|
+
"DOM.querySelector",
|
|
2314
|
+
{ nodeId: doc.root.nodeId, selector },
|
|
2315
|
+
this.sessionId
|
|
2316
|
+
),
|
|
2317
|
+
8e3
|
|
2318
|
+
);
|
|
2319
|
+
if (!result) return 0;
|
|
2320
|
+
return result.nodeId;
|
|
2321
|
+
}
|
|
2322
|
+
/** Query all matching elements, returns array of CDP nodeIds */
|
|
2323
|
+
async querySelectorAll(selector) {
|
|
2324
|
+
const doc = await this.conn.send(
|
|
2325
|
+
"DOM.getDocument",
|
|
2326
|
+
{ depth: 0 },
|
|
2327
|
+
this.sessionId
|
|
2328
|
+
);
|
|
2329
|
+
const result = await this.conn.send(
|
|
2330
|
+
"DOM.querySelectorAll",
|
|
2331
|
+
{ nodeId: doc.root.nodeId, selector },
|
|
2332
|
+
this.sessionId
|
|
2333
|
+
);
|
|
2334
|
+
return result.nodeIds ?? [];
|
|
2335
|
+
}
|
|
2336
|
+
/** Resolve a CDP nodeId to a RemoteObject for evaluate */
|
|
2337
|
+
async resolveNode(nodeId) {
|
|
2338
|
+
const result = await this.conn.send(
|
|
2339
|
+
"DOM.resolveNode",
|
|
2340
|
+
{ nodeId },
|
|
2341
|
+
this.sessionId
|
|
2342
|
+
);
|
|
2343
|
+
return result.object.objectId;
|
|
2344
|
+
}
|
|
2345
|
+
/** Get the box model for a nodeId */
|
|
2346
|
+
async getBoxModel(nodeId) {
|
|
2347
|
+
try {
|
|
2348
|
+
const result = await this.conn.send("DOM.getBoxModel", { nodeId }, this.sessionId);
|
|
2349
|
+
const c = result.model?.content;
|
|
2350
|
+
if (!c || c.length < 8) return null;
|
|
2351
|
+
const x1 = c[0];
|
|
2352
|
+
const y1 = c[1];
|
|
2353
|
+
const x2 = c[4];
|
|
2354
|
+
const y2 = c[5];
|
|
2355
|
+
return {
|
|
2356
|
+
x: Math.min(x1, x2),
|
|
2357
|
+
y: Math.min(y1, y2),
|
|
2358
|
+
width: Math.abs(x2 - x1),
|
|
2359
|
+
height: Math.abs(y2 - y1)
|
|
2360
|
+
};
|
|
2361
|
+
} catch {
|
|
2362
|
+
return null;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
/** Call a function on a RemoteObject */
|
|
2366
|
+
async callFunctionOn(objectId, functionDeclaration, args = []) {
|
|
2367
|
+
const result = await this.conn.send("Runtime.callFunctionOn", {
|
|
2368
|
+
objectId,
|
|
2369
|
+
functionDeclaration,
|
|
2370
|
+
arguments: args.map((a) => ({ value: a })),
|
|
2371
|
+
returnByValue: true
|
|
2372
|
+
}, this.sessionId);
|
|
2373
|
+
if (result.exceptionDetails) {
|
|
2374
|
+
throw new Error(`CallFunctionOn error: ${result.exceptionDetails.text}`);
|
|
2375
|
+
}
|
|
2376
|
+
return result.result?.value;
|
|
2377
|
+
}
|
|
2378
|
+
/** Send a CDP command scoped to this page's session */
|
|
2379
|
+
async _cdpSend(method, params) {
|
|
2380
|
+
return this.conn.send(method, params, this.sessionId);
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* 开启/关闭原生文件选择框拦截(CDP stateful 开关)。
|
|
2384
|
+
*
|
|
2385
|
+
* - enabled=true(执行期默认):点击上传按钮时不弹系统文件框,改发 Page.fileChooserOpened 事件,
|
|
2386
|
+
* page 的 'filechooser' 监听器拿到 chooser 后用 setFiles/setInputFiles 注入文件。
|
|
2387
|
+
* - enabled=false(录制期默认):真实文件选择框正常弹出,用户手动选文件;
|
|
2388
|
+
* 前端 input[type=file] 的 change 事件由 action signal 脚本捕获记录。
|
|
2389
|
+
*
|
|
2390
|
+
* 可多次调用切换状态(CDP 协议是 stateful 的)。
|
|
2391
|
+
*/
|
|
2392
|
+
async setFileDialogInterception(enabled) {
|
|
2393
|
+
await this.conn.send("Page.setInterceptFileChooserDialog", { enabled }, this.sessionId).catch((e) => console.error("[XBPage] setInterceptFileChooserDialog failed:", errMsg(e)));
|
|
2394
|
+
}
|
|
2395
|
+
/** Subscribe to a CDP event on this page's session. Returns unsubscribe function. */
|
|
2396
|
+
_subscribe(event, handler) {
|
|
2397
|
+
return this.conn.subscribe(event, this.sessionId, handler);
|
|
2398
|
+
}
|
|
2399
|
+
// ── Private: Event Setup ────────────────────────────────────
|
|
2400
|
+
setupPageEvents() {
|
|
2401
|
+
this._subscriptions.push(
|
|
2402
|
+
this.conn.subscribe("Page.frameNavigated", this.sessionId, (params) => {
|
|
2403
|
+
const p = params;
|
|
2404
|
+
if (p.frame) {
|
|
2405
|
+
this._url = p.frame.url;
|
|
2406
|
+
}
|
|
2407
|
+
this._emit("framenavigated", this.mainFrame());
|
|
2408
|
+
})
|
|
2409
|
+
);
|
|
2410
|
+
this._subscriptions.push(
|
|
2411
|
+
this.conn.subscribe("Page.loadEventFired", this.sessionId, () => {
|
|
2412
|
+
this._loadState.loadFired = true;
|
|
2413
|
+
})
|
|
2414
|
+
);
|
|
2415
|
+
this._subscriptions.push(
|
|
2416
|
+
this.conn.subscribe("Page.domContentEventFired", this.sessionId, () => {
|
|
2417
|
+
this._loadState.domContentFired = true;
|
|
2418
|
+
})
|
|
2419
|
+
);
|
|
2420
|
+
this._subscriptions.push(
|
|
2421
|
+
this.conn.subscribe("Page.javascriptDialogOpening", this.sessionId, (params) => {
|
|
2422
|
+
const p = params;
|
|
2423
|
+
const dialog = {
|
|
2424
|
+
type: p.type,
|
|
2425
|
+
message: () => p.message,
|
|
2426
|
+
defaultValue: () => p.defaultValue,
|
|
2427
|
+
accept: async (text) => {
|
|
2428
|
+
await this.conn.send("Page.handleJavaScriptDialog", {
|
|
2429
|
+
accept: true,
|
|
2430
|
+
promptText: text
|
|
2431
|
+
}, this.sessionId);
|
|
2432
|
+
},
|
|
2433
|
+
dismiss: async () => {
|
|
2434
|
+
await this.conn.send("Page.handleJavaScriptDialog", {
|
|
2435
|
+
accept: false
|
|
2436
|
+
}, this.sessionId);
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
this._emit("dialog", dialog);
|
|
2440
|
+
setTimeout(() => {
|
|
2441
|
+
this.conn.send("Page.handleJavaScriptDialog", { accept: false }, this.sessionId).catch(() => {
|
|
2442
|
+
});
|
|
2443
|
+
}, 0);
|
|
2444
|
+
})
|
|
2445
|
+
);
|
|
2446
|
+
this._subscriptions.push(
|
|
2447
|
+
this.conn.subscribe("Page.fileChooserOpened", this.sessionId, async (params) => {
|
|
2448
|
+
const p = params;
|
|
2449
|
+
let selector = "";
|
|
2450
|
+
try {
|
|
2451
|
+
const result = await this.conn.send("DOM.describeNode", { backendNodeId: p.backendNodeId }, this.sessionId);
|
|
2452
|
+
const attrs = result.node?.attributes || [];
|
|
2453
|
+
const idIdx = attrs.indexOf("id");
|
|
2454
|
+
if (idIdx >= 0) selector = "#" + attrs[idIdx + 1];
|
|
2455
|
+
} catch {
|
|
2456
|
+
}
|
|
2457
|
+
if (!selector) {
|
|
2458
|
+
try {
|
|
2459
|
+
const result = await this.conn.send("DOM.resolveNode", { backendNodeId: p.backendNodeId }, this.sessionId);
|
|
2460
|
+
const evalResult = await this.conn.send("Runtime.callFunctionOn", {
|
|
2461
|
+
objectId: result.objectId,
|
|
2462
|
+
functionDeclaration: 'function() { return this.id || this.name || "" }',
|
|
2463
|
+
returnByValue: true
|
|
2464
|
+
}, this.sessionId);
|
|
2465
|
+
if (evalResult.result?.value) selector = "#" + evalResult.result.value;
|
|
2466
|
+
} catch {
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
const fileChooser = {
|
|
2470
|
+
selector,
|
|
2471
|
+
isMultiple: p.mode === "selectMultiple",
|
|
2472
|
+
setFiles: async (files) => {
|
|
2473
|
+
const fileArray = Array.isArray(files) ? files : [files];
|
|
2474
|
+
await this.setInputFiles(selector || 'input[type="file"]', fileArray);
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
this._emit("filechooser", fileChooser);
|
|
2478
|
+
})
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
setupNetworkEvents() {
|
|
2482
|
+
this._subscriptions.push(
|
|
2483
|
+
this.conn.subscribe("Network.requestWillBeSent", this.sessionId, (params) => {
|
|
2484
|
+
const p = params;
|
|
2485
|
+
this.inflightRequests.add(p.requestId);
|
|
2486
|
+
this._storeNetworkRequest(p.requestId, {
|
|
2487
|
+
url: p.request.url,
|
|
2488
|
+
method: p.request.method,
|
|
2489
|
+
headers: p.request.headers,
|
|
2490
|
+
postData: p.request.postData ?? null,
|
|
2491
|
+
resourceType: p.type
|
|
2492
|
+
});
|
|
2493
|
+
this._emit("request", createXBRequest(
|
|
2494
|
+
null,
|
|
2495
|
+
{
|
|
2496
|
+
requestId: p.requestId,
|
|
2497
|
+
url: p.request.url,
|
|
2498
|
+
method: p.request.method,
|
|
2499
|
+
headers: p.request.headers,
|
|
2500
|
+
postData: p.request.postData ?? null,
|
|
2501
|
+
resourceType: p.type
|
|
2502
|
+
}
|
|
2503
|
+
));
|
|
2504
|
+
this.checkNetworkIdle();
|
|
2505
|
+
})
|
|
2506
|
+
);
|
|
2507
|
+
this._subscriptions.push(
|
|
2508
|
+
this.conn.subscribe("Network.responseReceived", this.sessionId, (params) => {
|
|
2509
|
+
const p = params;
|
|
2510
|
+
this._storeNetworkResponse(p.requestId, {
|
|
2511
|
+
status: p.response.status,
|
|
2512
|
+
url: p.response.url,
|
|
2513
|
+
headers: p.response.headers
|
|
2514
|
+
});
|
|
2515
|
+
this._emit("response", createXBResponse(
|
|
2516
|
+
{ requestId: p.requestId, status: p.response.status, url: p.response.url, headers: p.response.headers },
|
|
2517
|
+
this.conn,
|
|
2518
|
+
this.sessionId
|
|
2519
|
+
));
|
|
2520
|
+
})
|
|
2521
|
+
);
|
|
2522
|
+
this._subscriptions.push(
|
|
2523
|
+
this.conn.subscribe("Network.loadingFinished", this.sessionId, (params) => {
|
|
2524
|
+
const p = params;
|
|
2525
|
+
this.inflightRequests.delete(p.requestId);
|
|
2526
|
+
this._emit("requestfinished", p);
|
|
2527
|
+
this.checkNetworkIdle();
|
|
2528
|
+
})
|
|
2529
|
+
);
|
|
2530
|
+
this._subscriptions.push(
|
|
2531
|
+
this.conn.subscribe("Network.loadingFailed", this.sessionId, (params) => {
|
|
2532
|
+
const p = params;
|
|
2533
|
+
this.inflightRequests.delete(p.requestId);
|
|
2534
|
+
this.checkNetworkIdle();
|
|
2535
|
+
})
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
setupConsoleEvents() {
|
|
2539
|
+
this._subscriptions.push(
|
|
2540
|
+
this.conn.subscribe("Runtime.consoleAPICalled", this.sessionId, (params) => {
|
|
2541
|
+
const p = params;
|
|
2542
|
+
const text = p.args.map((a) => {
|
|
2543
|
+
if (a.value !== void 0) return String(a.value);
|
|
2544
|
+
return a.description ?? "";
|
|
2545
|
+
}).join(" ");
|
|
2546
|
+
const location = p.stackTrace?.callFrames?.[0] ? {
|
|
2547
|
+
url: p.stackTrace.callFrames[0].url,
|
|
2548
|
+
lineNumber: p.stackTrace.callFrames[0].lineNumber,
|
|
2549
|
+
columnNumber: p.stackTrace.callFrames[0].columnNumber
|
|
2550
|
+
} : { url: "", lineNumber: 0, columnNumber: 0 };
|
|
2551
|
+
const msg = {
|
|
2552
|
+
type: () => p.type,
|
|
2553
|
+
text: () => text,
|
|
2554
|
+
location: () => location
|
|
2555
|
+
};
|
|
2556
|
+
this._emit("console", msg);
|
|
2557
|
+
})
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
checkNetworkIdle() {
|
|
2561
|
+
if (this.inflightRequests.size === 0) {
|
|
2562
|
+
if (this.networkIdleTimer) clearTimeout(this.networkIdleTimer);
|
|
2563
|
+
this.networkIdleTimer = setTimeout(() => {
|
|
2564
|
+
if (this.inflightRequests.size === 0) {
|
|
2565
|
+
this._loadState.networkIdle = true;
|
|
2566
|
+
if (this.networkIdleResolve) {
|
|
2567
|
+
this.networkIdleResolve();
|
|
2568
|
+
this.networkIdleResolve = null;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
}, _XBPageImpl.NETWORK_IDLE_MS);
|
|
2572
|
+
} else {
|
|
2573
|
+
if (this.networkIdleTimer) {
|
|
2574
|
+
clearTimeout(this.networkIdleTimer);
|
|
2575
|
+
this.networkIdleTimer = null;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
// ── Network Data Store (for waitForResponse/waitForRequest) ──
|
|
2580
|
+
_networkResponses = /* @__PURE__ */ new Map();
|
|
2581
|
+
_networkRequests = /* @__PURE__ */ new Map();
|
|
2582
|
+
_routeHandlers = [];
|
|
2583
|
+
_interceptionEnabled = false;
|
|
2584
|
+
/** Store network data — called by browser.ts installNetworkCapture or internal event handlers */
|
|
2585
|
+
_storeNetworkRequest(requestId, data) {
|
|
2586
|
+
this._networkRequests.set(requestId, { requestId, ...data });
|
|
2587
|
+
}
|
|
2588
|
+
_storeNetworkResponse(requestId, data) {
|
|
2589
|
+
this._networkResponses.set(requestId, { requestId, ...data });
|
|
2590
|
+
}
|
|
2591
|
+
// ── waitForResponse ─────────────────────────────────────────
|
|
2592
|
+
async waitForResponse(urlOrPredicate, opts = {}) {
|
|
2593
|
+
const timeout = opts.timeout ?? 3e4;
|
|
2594
|
+
const predicate = createResponsePredicate(urlOrPredicate);
|
|
2595
|
+
for (const [, data] of this._networkResponses) {
|
|
2596
|
+
const response = createXBResponse(data, this.conn, this.sessionId);
|
|
2597
|
+
if (predicate(response)) return response;
|
|
2598
|
+
}
|
|
2599
|
+
return new Promise((resolve, reject) => {
|
|
2600
|
+
const timer = setTimeout(() => {
|
|
2601
|
+
this._emitter.removeListener("response", handler);
|
|
2602
|
+
reject(new Error(`waitForResponse timed out after ${timeout}ms`));
|
|
2603
|
+
}, timeout);
|
|
2604
|
+
const handler = (params) => {
|
|
2605
|
+
let response;
|
|
2606
|
+
const respObj = params;
|
|
2607
|
+
if (respObj.response) {
|
|
2608
|
+
const data = {
|
|
2609
|
+
requestId: respObj.requestId || "",
|
|
2610
|
+
status: respObj.response.status || 0,
|
|
2611
|
+
url: respObj.response.url || "",
|
|
2612
|
+
headers: respObj.response.headers || {}
|
|
2613
|
+
};
|
|
2614
|
+
response = createXBResponse(data, this.conn, this.sessionId);
|
|
2615
|
+
} else if (typeof params.status === "function") {
|
|
2616
|
+
response = params;
|
|
2617
|
+
} else {
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
if (predicate(response)) {
|
|
2621
|
+
clearTimeout(timer);
|
|
2622
|
+
this._emitter.removeListener("response", handler);
|
|
2623
|
+
resolve(response);
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2626
|
+
this._emitter.on("response", handler);
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
// ── waitForRequest ──────────────────────────────────────────
|
|
2630
|
+
async waitForRequest(urlOrPredicate, opts = {}) {
|
|
2631
|
+
const timeout = opts.timeout ?? 3e4;
|
|
2632
|
+
const predicate = createRequestPredicate(urlOrPredicate);
|
|
2633
|
+
for (const [, data] of this._networkRequests) {
|
|
2634
|
+
const request = createXBRequest(this, data);
|
|
2635
|
+
if (predicate(request)) return request;
|
|
2636
|
+
}
|
|
2637
|
+
return new Promise((resolve, reject) => {
|
|
2638
|
+
const timer = setTimeout(() => {
|
|
2639
|
+
this._emitter.removeListener("request", handler);
|
|
2640
|
+
reject(new Error(`waitForRequest timed out after ${timeout}ms`));
|
|
2641
|
+
}, timeout);
|
|
2642
|
+
const handler = (params) => {
|
|
2643
|
+
let request;
|
|
2644
|
+
const reqObj = params;
|
|
2645
|
+
if (reqObj.request) {
|
|
2646
|
+
request = createXBRequest(this, {
|
|
2647
|
+
requestId: reqObj.requestId || "",
|
|
2648
|
+
url: reqObj.request.url || "",
|
|
2649
|
+
method: reqObj.request.method || "",
|
|
2650
|
+
headers: reqObj.request.headers || {},
|
|
2651
|
+
postData: reqObj.request.postData ?? null,
|
|
2652
|
+
resourceType: reqObj.type || ""
|
|
2653
|
+
});
|
|
2654
|
+
} else if (typeof params.url === "function") {
|
|
2655
|
+
request = params;
|
|
2656
|
+
} else {
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
if (predicate(request)) {
|
|
2660
|
+
clearTimeout(timer);
|
|
2661
|
+
this._emitter.removeListener("request", handler);
|
|
2662
|
+
resolve(request);
|
|
2663
|
+
}
|
|
2664
|
+
};
|
|
2665
|
+
this._emitter.on("request", handler);
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
// ── waitForURL ──────────────────────────────────────────────
|
|
2669
|
+
async waitForURL(url, opts = {}) {
|
|
2670
|
+
const timeout = opts.timeout ?? 3e4;
|
|
2671
|
+
const checkFn = typeof url === "function" ? url : typeof url === "string" ? (current) => matchGlob(url, current) : (current) => url.test(current);
|
|
2672
|
+
if (checkFn(this._url)) return;
|
|
2673
|
+
return new Promise((resolve, reject) => {
|
|
2674
|
+
const timer = setTimeout(() => {
|
|
2675
|
+
this._emitter.removeListener("framenavigated", handler);
|
|
2676
|
+
reject(new Error(`waitForURL timed out after ${timeout}ms`));
|
|
2677
|
+
}, timeout);
|
|
2678
|
+
const handler = () => {
|
|
2679
|
+
if (checkFn(this._url)) {
|
|
2680
|
+
clearTimeout(timer);
|
|
2681
|
+
this._emitter.removeListener("framenavigated", handler);
|
|
2682
|
+
resolve();
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
this._emitter.on("framenavigated", handler);
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
// ── route / unroute ─────────────────────────────────────────
|
|
2689
|
+
async route(url, handler) {
|
|
2690
|
+
const regex = typeof url === "string" ? globToRegex(url) : url;
|
|
2691
|
+
this._routeHandlers.push({ pattern: String(url), regex, handler });
|
|
2692
|
+
if (!this._interceptionEnabled) {
|
|
2693
|
+
this._interceptionEnabled = true;
|
|
2694
|
+
await this.conn.send("Fetch.enable", {
|
|
2695
|
+
patterns: [{ urlPattern: "*", requestStage: "Request" }]
|
|
2696
|
+
}, this.sessionId);
|
|
2697
|
+
this._subscriptions.push(
|
|
2698
|
+
this.conn.subscribe("Fetch.requestPaused", this.sessionId, (params) => {
|
|
2699
|
+
this._handleRequestPaused(params);
|
|
2700
|
+
})
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
async unroute(url, handler) {
|
|
2705
|
+
const regex = typeof url === "string" ? globToRegex(url) : url;
|
|
2706
|
+
this._routeHandlers = this._routeHandlers.filter(
|
|
2707
|
+
(h) => !(regex.source === h.regex.source && (!handler || h.handler === handler))
|
|
2708
|
+
);
|
|
2709
|
+
if (this._routeHandlers.length === 0 && this._interceptionEnabled) {
|
|
2710
|
+
this._interceptionEnabled = false;
|
|
2711
|
+
await this.conn.send("Fetch.disable", void 0, this.sessionId).catch(() => {
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
async _handleRequestPaused(params) {
|
|
2716
|
+
const requestUrl = params.request.url;
|
|
2717
|
+
for (const { regex, handler } of this._routeHandlers) {
|
|
2718
|
+
if (regex.test(requestUrl)) {
|
|
2719
|
+
this._emit("request", createXBRequest(
|
|
2720
|
+
null,
|
|
2721
|
+
{
|
|
2722
|
+
requestId: params.requestId,
|
|
2723
|
+
url: params.request.url,
|
|
2724
|
+
method: params.request.method,
|
|
2725
|
+
headers: params.request.headers,
|
|
2726
|
+
postData: params.request.postData ?? null,
|
|
2727
|
+
resourceType: params.resourceType
|
|
2728
|
+
}
|
|
2729
|
+
));
|
|
2730
|
+
const route = createXBRouteFetch(this.conn, this.sessionId, params, this._emitter);
|
|
2731
|
+
try {
|
|
2732
|
+
await handler(route);
|
|
2733
|
+
} catch {
|
|
2734
|
+
await this.conn.send("Fetch.continueRequest", {
|
|
2735
|
+
requestId: params.requestId
|
|
2736
|
+
}, this.sessionId).catch(() => {
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
await this.conn.send("Fetch.continueRequest", {
|
|
2743
|
+
requestId: params.requestId
|
|
2744
|
+
}, this.sessionId).catch(() => {
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
// ── setInputFiles ───────────────────────────────────────────
|
|
2748
|
+
async setInputFiles(selector, files) {
|
|
2749
|
+
const fileArr = Array.isArray(files) ? files : [files];
|
|
2750
|
+
const fileList = fileArr.map((f) => ({
|
|
2751
|
+
name: f.name,
|
|
2752
|
+
type: f.mimeType,
|
|
2753
|
+
dataBase64: f.buffer.toString("base64")
|
|
2754
|
+
}));
|
|
2755
|
+
await this.evaluate(`
|
|
2756
|
+
(function() {
|
|
2757
|
+
var selector = ${JSON.stringify(selector)};
|
|
2758
|
+
var input = ${queryJS(selector)};
|
|
2759
|
+
if (!input) throw new Error('Element not found: ' + selector);
|
|
2760
|
+
|
|
2761
|
+
var fileList = ${JSON.stringify(fileList)};
|
|
2762
|
+
var dt = new DataTransfer();
|
|
2763
|
+
|
|
2764
|
+
fileList.forEach(function(f) {
|
|
2765
|
+
var binary = atob(f.dataBase64);
|
|
2766
|
+
var bytes = new Uint8Array(binary.length);
|
|
2767
|
+
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
2768
|
+
var blob = new Blob([bytes], { type: f.type });
|
|
2769
|
+
var file = new File([blob], f.name, { type: f.type });
|
|
2770
|
+
dt.items.add(file);
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
input.files = dt.files;
|
|
2774
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2775
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2776
|
+
})()
|
|
2777
|
+
`);
|
|
2778
|
+
}
|
|
2779
|
+
// ── dragAndDrop ─────────────────────────────────────────────
|
|
2780
|
+
async dragAndDrop(source, target) {
|
|
2781
|
+
const sourceRect = await this.evaluate(`
|
|
2782
|
+
(function() {
|
|
2783
|
+
const el = ${queryJS(source)};
|
|
2784
|
+
if (!el) throw new Error('Source not found: ${source}');
|
|
2785
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
2786
|
+
const r = el.getBoundingClientRect();
|
|
2787
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
2788
|
+
})()
|
|
2789
|
+
`);
|
|
2790
|
+
const targetRect = await this.evaluate(`
|
|
2791
|
+
(function() {
|
|
2792
|
+
const el = ${queryJS(target)};
|
|
2793
|
+
if (!el) throw new Error('Target not found: ${target}');
|
|
2794
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
2795
|
+
const r = el.getBoundingClientRect();
|
|
2796
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
2797
|
+
})()
|
|
2798
|
+
`);
|
|
2799
|
+
const sx = sourceRect.x + sourceRect.width / 2;
|
|
2800
|
+
const sy = sourceRect.y + sourceRect.height / 2;
|
|
2801
|
+
const tx = targetRect.x + targetRect.width / 2;
|
|
2802
|
+
const ty = targetRect.y + targetRect.height / 2;
|
|
2803
|
+
try {
|
|
2804
|
+
await this.conn.send("Input.dispatchDragEvent", {
|
|
2805
|
+
type: "dragStart",
|
|
2806
|
+
x: sx,
|
|
2807
|
+
y: sy,
|
|
2808
|
+
data: { items: [], dragOperations: ["copy", "move", "link"] }
|
|
2809
|
+
}, this.sessionId);
|
|
2810
|
+
await this.conn.send("Input.dispatchDragEvent", {
|
|
2811
|
+
type: "dragOver",
|
|
2812
|
+
x: tx,
|
|
2813
|
+
y: ty,
|
|
2814
|
+
data: { items: [], dragOperations: ["copy", "move", "link"] }
|
|
2815
|
+
}, this.sessionId);
|
|
2816
|
+
await this.conn.send("Input.dispatchDragEvent", {
|
|
2817
|
+
type: "drop",
|
|
2818
|
+
x: tx,
|
|
2819
|
+
y: ty,
|
|
2820
|
+
data: { items: [], dragOperations: ["copy", "move", "link"] }
|
|
2821
|
+
}, this.sessionId);
|
|
2822
|
+
await this.conn.send("Input.dispatchDragEvent", {
|
|
2823
|
+
type: "dragCancel",
|
|
2824
|
+
x: sx,
|
|
2825
|
+
y: sy,
|
|
2826
|
+
data: { items: [], dragOperations: ["copy", "move", "link"] }
|
|
2827
|
+
}, this.sessionId);
|
|
2828
|
+
} catch {
|
|
2829
|
+
await this.mouse.move(sx, sy);
|
|
2830
|
+
await this.mouse.down();
|
|
2831
|
+
await this.mouse.move(tx, ty, { steps: 10 });
|
|
2832
|
+
await this.mouse.up();
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
// ── setOfflineMode ──────────────────────────────────────────
|
|
2836
|
+
async setOfflineMode(offline) {
|
|
2837
|
+
await this.conn.send("Network.emulateNetworkConditions", {
|
|
2838
|
+
offline,
|
|
2839
|
+
latency: 0,
|
|
2840
|
+
downloadThroughput: offline ? 0 : -1,
|
|
2841
|
+
uploadThroughput: offline ? 0 : -1
|
|
2842
|
+
}, this.sessionId);
|
|
2843
|
+
}
|
|
2844
|
+
};
|
|
2845
|
+
|
|
2846
|
+
// src/cdp-driver/cdp-session.ts
|
|
2847
|
+
var XBCDPSessionImpl = class {
|
|
2848
|
+
conn;
|
|
2849
|
+
sessionId;
|
|
2850
|
+
constructor(conn, sessionId) {
|
|
2851
|
+
this.conn = conn;
|
|
2852
|
+
this.sessionId = sessionId;
|
|
2853
|
+
}
|
|
2854
|
+
async send(method, params) {
|
|
2855
|
+
return this.conn.send(method, params, this.sessionId);
|
|
2856
|
+
}
|
|
2857
|
+
on(event, handler) {
|
|
2858
|
+
this.conn.on(event, (params, sid) => {
|
|
2859
|
+
if (sid === this.sessionId || !this.sessionId && !sid) {
|
|
2860
|
+
handler(params);
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
off(event, handler) {
|
|
2865
|
+
this.conn.off(event, handler);
|
|
2866
|
+
}
|
|
2867
|
+
async detach() {
|
|
2868
|
+
if (!this.sessionId) return;
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
// src/cdp-driver/context.ts
|
|
2873
|
+
var XBContextImpl = class {
|
|
2874
|
+
conn;
|
|
2875
|
+
_emitter = new EventEmitter2();
|
|
2876
|
+
_browser;
|
|
2877
|
+
contextId;
|
|
2878
|
+
_pages = [];
|
|
2879
|
+
closed = false;
|
|
2880
|
+
options;
|
|
2881
|
+
targetAttachedHandler = null;
|
|
2882
|
+
_initScripts = [];
|
|
2883
|
+
constructor(conn, contextId, browser, opts) {
|
|
2884
|
+
this.conn = conn;
|
|
2885
|
+
this.contextId = contextId;
|
|
2886
|
+
this._browser = browser;
|
|
2887
|
+
this.options = opts;
|
|
2888
|
+
this.setupAutoAttach();
|
|
2889
|
+
}
|
|
2890
|
+
async newPage() {
|
|
2891
|
+
if (this.closed) throw new Error("Context is closed");
|
|
2892
|
+
const { targetId } = await this._browser._createTarget(this.contextId);
|
|
2893
|
+
const sessionId = await this._browser._attachToTarget(targetId);
|
|
2894
|
+
const page = new XBPageImpl(this.conn, sessionId, targetId, this, this._browser);
|
|
2895
|
+
await page._init();
|
|
2896
|
+
this._pages.push(page);
|
|
2897
|
+
this.forwardPageEvents(page);
|
|
2898
|
+
if (this.options.viewport) {
|
|
2899
|
+
await page.setViewportSize(this.options.viewport).catch(() => {
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
if (this.options.userAgent) {
|
|
2903
|
+
await page._setUserAgent(this.options.userAgent);
|
|
2904
|
+
}
|
|
2905
|
+
if (this.options.extraHTTPHeaders) {
|
|
2906
|
+
await page._setExtraHTTPHeaders(this.options.extraHTTPHeaders);
|
|
2907
|
+
}
|
|
2908
|
+
for (const script of this._initScripts) {
|
|
2909
|
+
await page.addInitScript(script);
|
|
2910
|
+
}
|
|
2911
|
+
return page;
|
|
2912
|
+
}
|
|
2913
|
+
pages() {
|
|
2914
|
+
return [...this._pages];
|
|
2915
|
+
}
|
|
2916
|
+
browser() {
|
|
2917
|
+
return this._browser;
|
|
2918
|
+
}
|
|
2919
|
+
async close() {
|
|
2920
|
+
if (this.closed) return;
|
|
2921
|
+
this.closed = true;
|
|
2922
|
+
for (const page of this._pages) {
|
|
2923
|
+
await page.close().catch(() => {
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
this._pages = [];
|
|
2927
|
+
if (this.targetAttachedHandler) {
|
|
2928
|
+
this.conn.off("Target.attachedToTarget", this.targetAttachedHandler);
|
|
2929
|
+
this.targetAttachedHandler = null;
|
|
2930
|
+
}
|
|
2931
|
+
if (this.contextId && this.contextId !== "default") {
|
|
2932
|
+
await this.conn.send("Target.disposeBrowserContext", {
|
|
2933
|
+
browserContextId: this.contextId
|
|
2934
|
+
}).catch(() => {
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
this._browser._removeContext(this.contextId);
|
|
2938
|
+
}
|
|
2939
|
+
async newCDPSession(page) {
|
|
2940
|
+
if (page instanceof XBPageImpl) {
|
|
2941
|
+
return new XBCDPSessionImpl(this.conn, page.sessionId);
|
|
2942
|
+
}
|
|
2943
|
+
return new XBCDPSessionImpl(this.conn);
|
|
2944
|
+
}
|
|
2945
|
+
async addInitScript(script) {
|
|
2946
|
+
this._initScripts.push(script);
|
|
2947
|
+
for (const page of this._pages) {
|
|
2948
|
+
await page.addInitScript(script);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
// ── Cookies ─────────────────────────────────────────────────
|
|
2952
|
+
async cookies(urls) {
|
|
2953
|
+
const urlList = typeof urls === "string" ? [urls] : urls;
|
|
2954
|
+
const params = urlList ? { urls: urlList } : void 0;
|
|
2955
|
+
try {
|
|
2956
|
+
const result = await this.conn.send("Storage.getCookies", params);
|
|
2957
|
+
return result.cookies;
|
|
2958
|
+
} catch {
|
|
2959
|
+
try {
|
|
2960
|
+
const result = await this.conn.send("Network.getCookies", params);
|
|
2961
|
+
return result.cookies;
|
|
2962
|
+
} catch {
|
|
2963
|
+
return [];
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
async addCookies(cookies) {
|
|
2968
|
+
const cdpCookies = cookies.map((c) => ({
|
|
2969
|
+
name: c.name,
|
|
2970
|
+
value: c.value,
|
|
2971
|
+
domain: c.domain,
|
|
2972
|
+
path: c.path || "/",
|
|
2973
|
+
expires: c.expires,
|
|
2974
|
+
httpOnly: c.httpOnly,
|
|
2975
|
+
secure: c.secure,
|
|
2976
|
+
sameSite: c.sameSite
|
|
2977
|
+
}));
|
|
2978
|
+
try {
|
|
2979
|
+
await this.conn.send("Storage.setCookies", { cookies: cdpCookies });
|
|
2980
|
+
} catch {
|
|
2981
|
+
await this.conn.send("Network.setCookies", { cookies: cdpCookies });
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
async clearCookies() {
|
|
2985
|
+
try {
|
|
2986
|
+
await this.conn.send("Storage.clearCookies");
|
|
2987
|
+
} catch {
|
|
2988
|
+
await this.conn.send("Network.clearBrowserCookies");
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
on(event, handler) {
|
|
2992
|
+
this._emitter.on(event, handler);
|
|
2993
|
+
}
|
|
2994
|
+
off(event, handler) {
|
|
2995
|
+
this._emitter.off(event, handler);
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Register a page that was attached to an existing target (discovered via
|
|
2999
|
+
* Target.getTargets). Used by XBBrowserImpl.discoverContexts() to wire up
|
|
3000
|
+
* pages from the user's existing browser session into the context wrapper
|
|
3001
|
+
* so they appear in `context.pages()` and can be reused by plugins.
|
|
3002
|
+
*/
|
|
3003
|
+
_addDiscoveredPage(page) {
|
|
3004
|
+
const exists = this._pages.some((p) => p._targetId === page._targetId);
|
|
3005
|
+
if (exists) return;
|
|
3006
|
+
this._pages.push(page);
|
|
3007
|
+
this.forwardPageEvents(page);
|
|
3008
|
+
}
|
|
3009
|
+
// ── Private ─────────────────────────────────────────────────
|
|
3010
|
+
/** Forward page-level events (request, response, etc.) to context listeners */
|
|
3011
|
+
forwardPageEvents(page) {
|
|
3012
|
+
const forward = (event) => {
|
|
3013
|
+
page.on(event, (...args) => {
|
|
3014
|
+
this._emitter.emit(event, ...args);
|
|
3015
|
+
});
|
|
3016
|
+
};
|
|
3017
|
+
forward("request");
|
|
3018
|
+
forward("response");
|
|
3019
|
+
forward("requestfailed");
|
|
3020
|
+
forward("requestfinished");
|
|
3021
|
+
}
|
|
3022
|
+
setupAutoAttach() {
|
|
3023
|
+
this.targetAttachedHandler = (paramsRaw) => {
|
|
3024
|
+
const params = paramsRaw;
|
|
3025
|
+
if (this.contextId !== "default" && params.targetInfo.browserContextId !== this.contextId) return;
|
|
3026
|
+
if (params.targetInfo.type !== "page") return;
|
|
3027
|
+
const exists = this._pages.some(
|
|
3028
|
+
(p) => p._targetId === params.targetInfo.targetId
|
|
3029
|
+
);
|
|
3030
|
+
if (exists) return;
|
|
3031
|
+
const page = new XBPageImpl(
|
|
3032
|
+
this.conn,
|
|
3033
|
+
params.sessionId,
|
|
3034
|
+
params.targetInfo.targetId,
|
|
3035
|
+
this,
|
|
3036
|
+
this._browser
|
|
3037
|
+
);
|
|
3038
|
+
this.conn.send("Runtime.runIfWaitingForDebugger", void 0, params.sessionId).catch(() => {
|
|
3039
|
+
});
|
|
3040
|
+
page._init().then(async () => {
|
|
3041
|
+
for (const script of this._initScripts) {
|
|
3042
|
+
await page.addInitScript(script).catch(() => {
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
this._pages.push(page);
|
|
3046
|
+
this.forwardPageEvents(page);
|
|
3047
|
+
this._emitter.emit("page", page);
|
|
3048
|
+
});
|
|
3049
|
+
};
|
|
3050
|
+
this.conn.on("Target.attachedToTarget", this.targetAttachedHandler);
|
|
3051
|
+
}
|
|
3052
|
+
};
|
|
3053
|
+
|
|
3054
|
+
// src/cdp-driver/browser.ts
|
|
3055
|
+
var XBBrowserImpl = class {
|
|
3056
|
+
conn;
|
|
3057
|
+
_emitter = new EventEmitter3();
|
|
3058
|
+
_contexts = /* @__PURE__ */ new Map();
|
|
3059
|
+
_disconnected = false;
|
|
3060
|
+
childProcess = null;
|
|
3061
|
+
tmpDir;
|
|
3062
|
+
_exitHandler = null;
|
|
3063
|
+
/**
|
|
3064
|
+
* Original CDP endpoint (HTTP or ws URL) used to construct this browser.
|
|
3065
|
+
* Used by discoverContexts() as a fallback to HTTP /json/list when
|
|
3066
|
+
* Target.getTargets doesn't return page-type targets (e.g. cdp-tunnel proxy).
|
|
3067
|
+
*/
|
|
3068
|
+
cdpEndpoint;
|
|
3069
|
+
constructor(conn, childProcess, tmpDir, cdpEndpoint) {
|
|
3070
|
+
this.conn = conn;
|
|
3071
|
+
this.childProcess = childProcess ?? null;
|
|
3072
|
+
this.tmpDir = tmpDir;
|
|
3073
|
+
this.cdpEndpoint = cdpEndpoint;
|
|
3074
|
+
conn.on("disconnect", () => {
|
|
3075
|
+
this._disconnected = true;
|
|
3076
|
+
this._emitter.emit("disconnected");
|
|
3077
|
+
});
|
|
3078
|
+
if (this.childProcess) {
|
|
3079
|
+
this._exitHandler = () => {
|
|
3080
|
+
try {
|
|
3081
|
+
if (this.childProcess?.exitCode === null) {
|
|
3082
|
+
this.childProcess.kill("SIGKILL");
|
|
3083
|
+
}
|
|
3084
|
+
} catch {
|
|
3085
|
+
}
|
|
3086
|
+
if (this.tmpDir) {
|
|
3087
|
+
try {
|
|
3088
|
+
const { rmSync } = __require("fs");
|
|
3089
|
+
rmSync(this.tmpDir, { recursive: true, force: true });
|
|
3090
|
+
} catch {
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
};
|
|
3094
|
+
process.on("exit", this._exitHandler);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
get disconnected() {
|
|
3098
|
+
return this._disconnected;
|
|
3099
|
+
}
|
|
3100
|
+
/** The underlying CDP connection (for advanced use) */
|
|
3101
|
+
get connection() {
|
|
3102
|
+
return this.conn;
|
|
3103
|
+
}
|
|
3104
|
+
async close() {
|
|
3105
|
+
if (this._disconnected) return;
|
|
3106
|
+
this._disconnected = true;
|
|
3107
|
+
for (const [, info] of this._contexts) {
|
|
3108
|
+
await info.context.close().catch(() => {
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
this._contexts.clear();
|
|
3112
|
+
if (this._exitHandler) {
|
|
3113
|
+
process.removeListener("exit", this._exitHandler);
|
|
3114
|
+
this._exitHandler = null;
|
|
3115
|
+
}
|
|
3116
|
+
if (this.childProcess) {
|
|
3117
|
+
const { killChrome: killChrome2 } = await import("./launcher-OZXJQPNG.js");
|
|
3118
|
+
await killChrome2(this.childProcess, this.tmpDir);
|
|
3119
|
+
}
|
|
3120
|
+
await this.conn.close();
|
|
3121
|
+
this._emitter.emit("disconnected");
|
|
3122
|
+
}
|
|
3123
|
+
async newContext(opts = {}) {
|
|
3124
|
+
if (this._disconnected) {
|
|
3125
|
+
throw new Error("Browser is disconnected");
|
|
3126
|
+
}
|
|
3127
|
+
let contextId = "default";
|
|
3128
|
+
try {
|
|
3129
|
+
const result = await this.conn.send(
|
|
3130
|
+
"Target.createBrowserContext",
|
|
3131
|
+
{ disposeOnDetach: true },
|
|
3132
|
+
void 0,
|
|
3133
|
+
1e4
|
|
3134
|
+
// 10s timeout instead of default 30s
|
|
3135
|
+
);
|
|
3136
|
+
contextId = result.browserContextId;
|
|
3137
|
+
} catch {
|
|
3138
|
+
}
|
|
3139
|
+
const context = new XBContextImpl(this.conn, contextId, this, opts);
|
|
3140
|
+
context.on("page", (page) => {
|
|
3141
|
+
this._emitter.emit("page", page);
|
|
3142
|
+
});
|
|
3143
|
+
this._contexts.set(contextId, {
|
|
3144
|
+
contextId,
|
|
3145
|
+
context
|
|
3146
|
+
});
|
|
3147
|
+
if (this.childProcess) {
|
|
3148
|
+
this._enableAutoAttach().catch(() => {
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
return context;
|
|
3152
|
+
}
|
|
3153
|
+
contexts() {
|
|
3154
|
+
return Array.from(this._contexts.values()).map((info) => info.context);
|
|
3155
|
+
}
|
|
3156
|
+
on(event, handler) {
|
|
3157
|
+
this._emitter.on(event, handler);
|
|
3158
|
+
}
|
|
3159
|
+
off(event, handler) {
|
|
3160
|
+
this._emitter.off(event, handler);
|
|
3161
|
+
}
|
|
3162
|
+
/** Called by context.close() to remove from registry */
|
|
3163
|
+
_removeContext(contextId) {
|
|
3164
|
+
this._contexts.delete(contextId);
|
|
3165
|
+
}
|
|
3166
|
+
// ── CDP helpers exposed for context/page ────────────────────
|
|
3167
|
+
/** Attach to a target and get a session ID for flat protocol */
|
|
3168
|
+
async _attachToTarget(targetId) {
|
|
3169
|
+
const result = await this.conn.send(
|
|
3170
|
+
"Target.attachToTarget",
|
|
3171
|
+
{ targetId, flatten: true }
|
|
3172
|
+
);
|
|
3173
|
+
return result.sessionId;
|
|
3174
|
+
}
|
|
3175
|
+
/** Detach from a target session */
|
|
3176
|
+
async _detachFromTarget(sessionId) {
|
|
3177
|
+
await this.conn.send("Target.detachFromTarget", { sessionId });
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Derive the HTTP /json base URL from the original cdpEndpoint for use
|
|
3181
|
+
* as a fallback when Target.getTargets doesn't return page targets.
|
|
3182
|
+
* Supports both http:// and ws:// input formats.
|
|
3183
|
+
*/
|
|
3184
|
+
_httpFallbackURL() {
|
|
3185
|
+
if (!this.cdpEndpoint) return void 0;
|
|
3186
|
+
if (this.cdpEndpoint.startsWith("http://") || this.cdpEndpoint.startsWith("https://")) {
|
|
3187
|
+
return this.cdpEndpoint;
|
|
3188
|
+
}
|
|
3189
|
+
if (this.cdpEndpoint.startsWith("ws://") || this.cdpEndpoint.startsWith("wss://")) {
|
|
3190
|
+
const url = this.cdpEndpoint.replace(/^ws/, "http");
|
|
3191
|
+
const slashIdx = url.indexOf("/", url.indexOf("//") + 2);
|
|
3192
|
+
return slashIdx >= 0 ? url.substring(0, slashIdx) : url;
|
|
3193
|
+
}
|
|
3194
|
+
return void 0;
|
|
3195
|
+
}
|
|
3196
|
+
/** Create a new page target within a browser context */
|
|
3197
|
+
async _createTarget(contextId, url = "about:blank") {
|
|
3198
|
+
const params = { url };
|
|
3199
|
+
if (contextId && contextId !== "default") {
|
|
3200
|
+
params.browserContextId = contextId;
|
|
3201
|
+
}
|
|
3202
|
+
return this.conn.send("Target.createTarget", params);
|
|
3203
|
+
}
|
|
3204
|
+
/** Close a target */
|
|
3205
|
+
async _closeTarget(targetId) {
|
|
3206
|
+
await this.conn.send("Target.closeTarget", { targetId });
|
|
3207
|
+
}
|
|
3208
|
+
/** Enable auto-attach for new targets */
|
|
3209
|
+
async _enableAutoAttach() {
|
|
3210
|
+
await this.conn.send("Target.setAutoAttach", {
|
|
3211
|
+
autoAttach: true,
|
|
3212
|
+
waitForDebuggerOnStart: false,
|
|
3213
|
+
flatten: true
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* Discover existing browser contexts and pages via Target.getTargets.
|
|
3218
|
+
*
|
|
3219
|
+
* For CDP tunnel connections (cdp-tunnel, attach scenarios), the
|
|
3220
|
+
* Target.attachedToTarget auto-attach flow is unreliable. Without this
|
|
3221
|
+
* call, `b.contexts()` would return [] and callers would fall back to
|
|
3222
|
+
* `b.newContext()` — which creates an isolated context with NO cookies
|
|
3223
|
+
* shared with the user's existing browser session (causing login failures).
|
|
3224
|
+
*
|
|
3225
|
+
* This method:
|
|
3226
|
+
* 1. Queries Target.getTargets to enumerate all page targets
|
|
3227
|
+
* 2. Groups them by browserContextId
|
|
3228
|
+
* 3. Attaches to each existing page via Target.attachToTarget
|
|
3229
|
+
* 4. Wraps the discovered pages in a XBContextImpl and registers it in
|
|
3230
|
+
* this._contexts so `contexts()` returns the user's actual contexts
|
|
3231
|
+
* 5. Enables Target.setAutoAttach for future pages
|
|
3232
|
+
*
|
|
3233
|
+
* No-op for self-launched browsers (they already populated contexts via
|
|
3234
|
+
* newContext() + childProcess-gated auto-attach).
|
|
3235
|
+
*/
|
|
3236
|
+
async discoverContexts() {
|
|
3237
|
+
if (this._disconnected) return;
|
|
3238
|
+
let targetInfos = [];
|
|
3239
|
+
try {
|
|
3240
|
+
const result = await this.conn.send(
|
|
3241
|
+
"Target.getTargets"
|
|
3242
|
+
);
|
|
3243
|
+
targetInfos = result.targetInfos ?? [];
|
|
3244
|
+
} catch {
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
const pageTargets = targetInfos.filter((t) => t.type === "page");
|
|
3248
|
+
const httpFallbackUrl = this._httpFallbackURL();
|
|
3249
|
+
if (pageTargets.length === 0 && httpFallbackUrl) {
|
|
3250
|
+
console.log(`[discoverContexts] Target.getTargets returned ${targetInfos.length} targets (0 page type). Falling back to HTTP /json/list at ${httpFallbackUrl}`);
|
|
3251
|
+
try {
|
|
3252
|
+
const { getCDPTargets: getCDPTargets2 } = await import("./launcher-OZXJQPNG.js");
|
|
3253
|
+
const httpPages = await getCDPTargets2(httpFallbackUrl);
|
|
3254
|
+
console.log(`[discoverContexts] HTTP /json/list returned ${httpPages.length} pages`);
|
|
3255
|
+
for (const p of httpPages) {
|
|
3256
|
+
if (p.type !== "page") continue;
|
|
3257
|
+
if (!p.url || p.url.startsWith("chrome://") || p.url.startsWith("devtools://")) continue;
|
|
3258
|
+
targetInfos.push({
|
|
3259
|
+
targetId: p.id,
|
|
3260
|
+
type: "page",
|
|
3261
|
+
url: p.url,
|
|
3262
|
+
title: p.title
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
console.log(`[discoverContexts] After HTTP fallback: ${targetInfos.length} total targets, ${targetInfos.filter((t) => t.type === "page").length} pages`);
|
|
3266
|
+
} catch (err) {
|
|
3267
|
+
console.log(`[discoverContexts] HTTP fallback failed: ${errMsg(err)}`);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
const pagesByContext = /* @__PURE__ */ new Map();
|
|
3271
|
+
for (const t of targetInfos) {
|
|
3272
|
+
if (t.type !== "page") continue;
|
|
3273
|
+
if (!t.url || t.url.startsWith("chrome://") || t.url.startsWith("devtools://")) {
|
|
3274
|
+
continue;
|
|
3275
|
+
}
|
|
3276
|
+
const ctxId = t.browserContextId || "default";
|
|
3277
|
+
if (!pagesByContext.has(ctxId)) pagesByContext.set(ctxId, []);
|
|
3278
|
+
pagesByContext.get(ctxId).push(t);
|
|
3279
|
+
}
|
|
3280
|
+
for (const [ctxId, pages] of pagesByContext) {
|
|
3281
|
+
if (this._contexts.has(ctxId)) continue;
|
|
3282
|
+
const context = new XBContextImpl(this.conn, ctxId, this, {});
|
|
3283
|
+
for (const p of pages) {
|
|
3284
|
+
try {
|
|
3285
|
+
const sessionId = await this._attachToTarget(p.targetId);
|
|
3286
|
+
const page = new XBPageImpl(this.conn, sessionId, p.targetId, context, this);
|
|
3287
|
+
await page._init();
|
|
3288
|
+
context._addDiscoveredPage(page);
|
|
3289
|
+
} catch {
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
this._contexts.set(ctxId, { contextId: ctxId, context });
|
|
3293
|
+
}
|
|
3294
|
+
try {
|
|
3295
|
+
await this.conn.send("Target.setAutoAttach", {
|
|
3296
|
+
autoAttach: true,
|
|
3297
|
+
waitForDebuggerOnStart: false,
|
|
3298
|
+
flatten: true
|
|
3299
|
+
});
|
|
3300
|
+
} catch {
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
|
|
3305
|
+
// src/cdp-driver/connection.ts
|
|
3306
|
+
import { EventEmitter as EventEmitter4 } from "events";
|
|
3307
|
+
import { WebSocket } from "ws";
|
|
3308
|
+
|
|
3309
|
+
// src/cdp-interceptor/rules/shared.ts
|
|
3310
|
+
var PLAYWRIGHT_INTERNAL_MARKERS = [
|
|
3311
|
+
"__commonJS",
|
|
3312
|
+
"module.exports",
|
|
3313
|
+
"__require",
|
|
3314
|
+
"__toESM",
|
|
3315
|
+
"inject_utils"
|
|
3316
|
+
];
|
|
3317
|
+
function isPlaywrightInternal(code) {
|
|
3318
|
+
return PLAYWRIGHT_INTERNAL_MARKERS.some((marker) => code.includes(marker));
|
|
3319
|
+
}
|
|
3320
|
+
function extractUserCode(ctx) {
|
|
3321
|
+
if (ctx.method === "Runtime.evaluate") {
|
|
3322
|
+
const expr = ctx.params.expression;
|
|
3323
|
+
if (typeof expr === "string") {
|
|
3324
|
+
if (isPlaywrightInternal(expr)) return null;
|
|
3325
|
+
return expr;
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
if (ctx.method === "Runtime.callFunctionOn") {
|
|
3329
|
+
const decl = ctx.params.functionDeclaration;
|
|
3330
|
+
if (typeof decl === "string" && decl.includes("utilityScript.evaluate")) {
|
|
3331
|
+
return extractAllStrings(ctx.params.arguments);
|
|
3332
|
+
}
|
|
3333
|
+
if (typeof decl === "string") return decl;
|
|
3334
|
+
}
|
|
3335
|
+
return null;
|
|
3336
|
+
}
|
|
3337
|
+
function extractAllStrings(rawArgs) {
|
|
3338
|
+
if (!Array.isArray(rawArgs)) return null;
|
|
3339
|
+
const strings = [];
|
|
3340
|
+
for (const arg of rawArgs) {
|
|
3341
|
+
if (arg && typeof arg === "object" && "value" in arg) {
|
|
3342
|
+
const val = arg.value;
|
|
3343
|
+
if (typeof val === "string" && val.length > 5 && !["true", "false"].includes(val)) {
|
|
3344
|
+
strings.push(val);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
return strings.length > 0 ? strings.join("\n") : null;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
// src/cdp-interceptor/rules/dom-mutation.ts
|
|
3352
|
+
var DOM_PATTERNS = [
|
|
3353
|
+
// ── P0: Value/Checked (bypasses React onChange) ────────────
|
|
3354
|
+
{ pattern: /\.value\s*=\s*(?!["'\s]*$)/, name: ".value =", severity: "danger", action: "pass", errorCode: -32001, suggestion: "Use page.fill(selector, value) which dispatches proper input/change events." },
|
|
3355
|
+
{ pattern: /\.checked\s*=\s*(?:true|false)/, name: ".checked =", severity: "danger", action: "pass", errorCode: -32001, suggestion: "Use page.check(selector) or page.uncheck(selector)." },
|
|
3356
|
+
{ pattern: /\.indeterminate\s*=\s*true/, name: ".indeterminate =", severity: "warn", action: "pass", errorCode: -32021, suggestion: "No human can set indeterminate state \u2014 remove this call." },
|
|
3357
|
+
{ pattern: /\.valueAsDate\s*=/, name: ".valueAsDate =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with formatted date string instead." },
|
|
3358
|
+
{ pattern: /\.valueAsNumber\s*=/, name: ".valueAsNumber =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with numeric string instead." },
|
|
3359
|
+
// ── P1: Select/Option (bypasses React onChange on select) ─
|
|
3360
|
+
{ pattern: /\.selectedIndex\s*=\s*\d+/, name: ".selectedIndex =", severity: "danger", action: "pass", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3361
|
+
{ pattern: /(?:options|children)\s*\[[^\]]*\]\s*\.\s*selected\s*=\s*(?:true|false)/, name: "options[N].selected =", severity: "danger", action: "pass", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
|
|
3362
|
+
{ pattern: /\.value\s*=\s*["'][^"']*["']\s*[;)]?\s*$/, name: "selectElement.value =", severity: "warn", action: "pass", errorCode: -32023, suggestion: "For select elements, use page.selectOption(selector, value)." },
|
|
3363
|
+
// ── P2: Content properties (bypasses virtual DOM diffing) ──
|
|
3364
|
+
{ pattern: /\.innerHTML\s*=/, name: ".innerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: ".innerHTML bypasses React/Vue diffing. Use component state or page.setContent()." },
|
|
3365
|
+
{ pattern: /\.outerHTML\s*=/, name: ".outerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: "outerHTML replacement destroys React fiber tree. Use page.setContent()." },
|
|
3366
|
+
{ pattern: /\.innerText\s*=/, name: ".innerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Framework components should be updated via state, not innerText." },
|
|
3367
|
+
{ pattern: /\.textContent\s*=/, name: ".textContent =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "textContent bypasses React/Vue diffing. Use component state instead." },
|
|
3368
|
+
{ pattern: /\.outerText\s*=/, name: ".outerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Non-standard property \u2014 use proper framework update methods." },
|
|
3369
|
+
{ pattern: /nodeValue\s*=/, name: "node.nodeValue =", severity: "info", action: "pass", errorCode: -32025, suggestion: "Direct text node mutation. Use textContent instead if needed." },
|
|
3370
|
+
// ── P3: Style properties ──────────────────────────────────
|
|
3371
|
+
{ pattern: /\.style\s*=\s*["']/, name: ".style = (string override)", severity: "info", action: "pass", errorCode: -32008, suggestion: 'Setting style as a string overwrites CSSStyleDeclaration. Use element.style.prop = "value".' },
|
|
3372
|
+
{ pattern: /\.style\.cssText\s*=/, name: ".style.cssText =", severity: "info", action: "pass", errorCode: -32008, suggestion: "style.cssText replacement destroys inline styles \u2014 use individual property set." },
|
|
3373
|
+
{ pattern: /\.style\.setProperty\s*\(/, name: ".style.setProperty()", severity: "info", action: "pass", errorCode: -32008, suggestion: "Direct style property set bypasses CSS transitions." },
|
|
3374
|
+
{ pattern: /\.style\.removeProperty\s*\(/, name: ".style.removeProperty()", severity: "info", action: "pass", errorCode: -32026, suggestion: "Style removal without user interaction is suspicious." },
|
|
3375
|
+
{ pattern: /\.style\.animation\s*=/, name: ".style.animation =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Forcing animation state via CDP is detectable." },
|
|
3376
|
+
{ pattern: /\.style\.transition\s*=/, name: ".style.transition =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Manipulating CSS transitions is rare in normal browsing." },
|
|
3377
|
+
// ── P4: Class/Attribute properties ────────────────────────
|
|
3378
|
+
{ pattern: /\.className\s*=/, name: ".className =", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or a Playwright locator instead." },
|
|
3379
|
+
{ pattern: /\.classList\.add\s*\(/, name: ".classList.add()", severity: "info", action: "pass", errorCode: -32009, suggestion: "classList manipulation via CDP bypasses framework state tracking." },
|
|
3380
|
+
{ pattern: /\.classList\.remove\s*\(/, name: ".classList.remove()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or attribute selectors instead." },
|
|
3381
|
+
{ pattern: /\.classList\.toggle\s*\(/, name: ".classList.toggle()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Toggle without user interaction is detectable." },
|
|
3382
|
+
{ pattern: /\.classList\.replace\s*\(/, name: ".classList.replace()", severity: "info", action: "pass", errorCode: -32028, suggestion: "Rare operation \u2014 likely automated." },
|
|
3383
|
+
// ── P5: Attribute manipulation ────────────────────────────
|
|
3384
|
+
{ pattern: /\.setAttribute\s*\(/, name: ".setAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "setAttribute bypasses framework attribute tracking. Use component state." },
|
|
3385
|
+
{ pattern: /\.removeAttribute\s*\(/, name: ".removeAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "Attribute removal without user interaction is suspicious." },
|
|
3386
|
+
{ pattern: /\.toggleAttribute\s*\(/, name: ".toggleAttribute()", severity: "info", action: "pass", errorCode: -32029, suggestion: "Attribute toggling via CDP is detectable." },
|
|
3387
|
+
{ pattern: /\.dataset\.\w+\s*=/, name: ".dataset.* =", severity: "info", action: "pass", errorCode: -32030, suggestion: "dataset mutations via evaluate bypass native mutation observers." },
|
|
3388
|
+
// ── P6: Focus/Selection properties ────────────────────────
|
|
3389
|
+
{ pattern: /\.selectionStart\s*=/, name: ".selectionStart =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Setting cursor position without focus is detectable." },
|
|
3390
|
+
{ pattern: /\.selectionEnd\s*=/, name: ".selectionEnd =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Selection range manipulation without user input is suspicious." },
|
|
3391
|
+
{ pattern: /\.selectionDirection\s*=/, name: ".selectionDirection =", severity: "info", action: "pass", errorCode: -32031, suggestion: "Selection direction changes normally via mouse drag." },
|
|
3392
|
+
// ── P7: Boolean properties ────────────────────────────────
|
|
3393
|
+
{ pattern: /\.disabled\s*=/, name: ".disabled =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Disabling elements via CDP mid-interaction is detectable." },
|
|
3394
|
+
{ pattern: /\.readOnly\s*=/, name: ".readOnly =", severity: "info", action: "pass", errorCode: -32032, suggestion: "readOnly changes without user action." },
|
|
3395
|
+
{ pattern: /\.hidden\s*=/, name: ".hidden =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Hiding elements is a common scraper tactic \u2014 detectable." },
|
|
3396
|
+
{ pattern: /\.required\s*=/, name: ".required =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Validation constraint changes mid-session." },
|
|
3397
|
+
{ pattern: /\.multiple\s*=/, name: ".multiple =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Multiple attribute toggle is rare in normal browsing." },
|
|
3398
|
+
{ pattern: /\.autofocus\s*=/, name: ".autofocus =", severity: "info", action: "pass", errorCode: -32032, suggestion: "autofocus changes mid-session are suspicious." },
|
|
3399
|
+
// ── P8: Frame/Navigation properties ───────────────────────
|
|
3400
|
+
{ pattern: /\.src\s*=/, name: ".src = (on img/iframe/script)", severity: "info", action: "pass", errorCode: -32013, suggestion: "Changing src via CDP bypasses user interaction. Use page.click() on the element." },
|
|
3401
|
+
{ pattern: /\.href\s*=/, name: ".href =", severity: "info", action: "pass", errorCode: -32033, suggestion: "Changing anchor href via evaluate \u2014 use page.click() instead." },
|
|
3402
|
+
{ pattern: /\.action\s*=/, name: "form.action =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Changing form action URL is highly suspicious." },
|
|
3403
|
+
{ pattern: /\.method\s*=/, name: "form.method =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Form method changes without user interaction." },
|
|
3404
|
+
{ pattern: /\.target\s*=/, name: "link/area.target =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Link target manipulation via CDP." },
|
|
3405
|
+
// ── P9: Media properties ──────────────────────────────────
|
|
3406
|
+
{ pattern: /\.currentTime\s*=/, name: "media.currentTime = (seeking)", severity: "info", action: "pass", errorCode: -32014, suggestion: "Video seeking without user interaction \u2014 common scraper pattern." },
|
|
3407
|
+
{ pattern: /\.playbackRate\s*=/, name: "media.playbackRate =", severity: "info", action: "pass", errorCode: -32014, suggestion: "Changing playback speed is detectable automation signal." },
|
|
3408
|
+
{ pattern: /\.volume\s*=/, name: "media.volume =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Volume setting via evaluate is suspicious." },
|
|
3409
|
+
{ pattern: /\.muted\s*=/, name: "media.muted =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Muting media without user click is suspicious." },
|
|
3410
|
+
// ── P10: Element geometry ─────────────────────────────────
|
|
3411
|
+
{ pattern: /\.scrollTop\s*=/, name: ".scrollTop =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Programmatic scroll without user gesture. Use page.mouse.wheel()." },
|
|
3412
|
+
{ pattern: /\.scrollLeft\s*=/, name: ".scrollLeft =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Horizontal scroll without user gesture." },
|
|
3413
|
+
{ pattern: /\.scrollTo\s*\(/, name: ".scrollTo()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollTo bypasses user scroll detection." },
|
|
3414
|
+
{ pattern: /\.scrollBy\s*\(/, name: ".scrollBy()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollBy without user gesture." },
|
|
3415
|
+
{ pattern: /\.scrollIntoView\s*\(/, name: ".scrollIntoView()", severity: "info", action: "pass", errorCode: -32015, suggestion: "scrollIntoView is a common bot pattern. Let Playwright handle scrolling." },
|
|
3416
|
+
// ── P11: Shadow DOM ───────────────────────────────────────
|
|
3417
|
+
{ pattern: /\.shadowRoot\s*=/, name: ".shadowRoot = (override)", severity: "info", action: "pass", errorCode: -32036, suggestion: "ShadowRoot is read-only \u2014 this set attempt is detectable." },
|
|
3418
|
+
// ── P12: Force reflow / layout thrashing ──────────────────
|
|
3419
|
+
{ pattern: /\.offsetHeight\b(?!\s*===?\s*)/, name: ".offsetHeight read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetHeight triggers forced reflow \u2014 anti-crawlers detect this as layout probing." },
|
|
3420
|
+
{ pattern: /\.offsetWidth\b(?!\s*===?\s*)/, name: ".offsetWidth read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetWidth triggers forced reflow \u2014 detectable." },
|
|
3421
|
+
{ pattern: /getBoundingClientRect\s*\(/, name: "getBoundingClientRect()", severity: "warn", action: "pass", errorCode: -32037, suggestion: "getBoundingClientRect triggers reflow. Minimize calls." },
|
|
3422
|
+
{ pattern: /getComputedStyle\s*\(/, name: "getComputedStyle()", severity: "info", action: "pass", errorCode: -32038, suggestion: "getComputedStyle can trigger style recalculation." },
|
|
3423
|
+
{ pattern: /\.clientHeight\b/, name: ".clientHeight read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientHeight read triggers layout." },
|
|
3424
|
+
{ pattern: /\.clientWidth\b/, name: ".clientWidth read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientWidth read triggers layout." }
|
|
3425
|
+
];
|
|
3426
|
+
var domMutationRule = {
|
|
3427
|
+
id: "dom-mutation",
|
|
3428
|
+
name: "DOM Property Mutation Detection (50+ setters)",
|
|
3429
|
+
priority: 10,
|
|
3430
|
+
canHandle(ctx) {
|
|
3431
|
+
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
|
|
3432
|
+
},
|
|
3433
|
+
evaluate(ctx) {
|
|
3434
|
+
const userCode = extractUserCode(ctx);
|
|
3435
|
+
if (!userCode) return null;
|
|
3436
|
+
for (const p of DOM_PATTERNS) {
|
|
3437
|
+
if (p.pattern.test(userCode)) {
|
|
3438
|
+
return {
|
|
3439
|
+
ruleId: "dom-mutation",
|
|
3440
|
+
action: p.action,
|
|
3441
|
+
severity: p.severity,
|
|
3442
|
+
reason: `Direct DOM property setter: "${p.name}". This bypasses framework event systems and is detectable as automation.`,
|
|
3443
|
+
suggestion: p.suggestion,
|
|
3444
|
+
errorCode: p.errorCode,
|
|
3445
|
+
errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 bypasses framework reactivity` : `[CDP Firewall] ${p.name} detected \u2014 use proper interaction API`
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
return null;
|
|
3450
|
+
}
|
|
3451
|
+
};
|
|
3452
|
+
|
|
3453
|
+
// src/cdp-interceptor/rules/mouse-trajectory.ts
|
|
3454
|
+
var TRACKER_KEY = "mouse-trajectory-tracker";
|
|
3455
|
+
var MAX_SAMPLES = 200;
|
|
3456
|
+
var MIN_SAMPLES_FOR_ANALYSIS = 5;
|
|
3457
|
+
var mouseTrajectoryRule = {
|
|
3458
|
+
id: "mouse-trajectory",
|
|
3459
|
+
name: "Mouse Trajectory Analysis",
|
|
3460
|
+
priority: 20,
|
|
3461
|
+
canHandle(ctx) {
|
|
3462
|
+
return ctx.method === "Input.dispatchMouseEvent";
|
|
3463
|
+
},
|
|
3464
|
+
evaluate(ctx) {
|
|
3465
|
+
const type = ctx.params.type;
|
|
3466
|
+
const x = ctx.params.x;
|
|
3467
|
+
const y = ctx.params.y;
|
|
3468
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
3469
|
+
let tracker = ctx.sessionState.get(TRACKER_KEY);
|
|
3470
|
+
if (!tracker) {
|
|
3471
|
+
tracker = { samples: [], lastMouseDownAt: 0, isDragging: false };
|
|
3472
|
+
ctx.sessionState.set(TRACKER_KEY, tracker);
|
|
3473
|
+
}
|
|
3474
|
+
if (type === "mousePressed") {
|
|
3475
|
+
const priorResult = analyzeTrajectory(tracker.samples);
|
|
3476
|
+
tracker.lastMouseDownAt = Date.now();
|
|
3477
|
+
tracker.isDragging = true;
|
|
3478
|
+
tracker.samples = [];
|
|
3479
|
+
return priorResult;
|
|
3480
|
+
}
|
|
3481
|
+
if (type === "mouseReleased") {
|
|
3482
|
+
tracker.isDragging = false;
|
|
3483
|
+
const result = analyzeTrajectory(tracker.samples);
|
|
3484
|
+
tracker.samples = [];
|
|
3485
|
+
return result;
|
|
3486
|
+
}
|
|
3487
|
+
if (type === "mouseMoved") {
|
|
3488
|
+
const now = Date.now();
|
|
3489
|
+
const prevSample = tracker.samples[tracker.samples.length - 1];
|
|
3490
|
+
const distanceTraveled = prevSample ? prevSample.distanceTraveled + distance(prevSample.x, prevSample.y, x, y) : 0;
|
|
3491
|
+
const sample = { x, y, timestamp: now, distanceTraveled };
|
|
3492
|
+
tracker.samples.push(sample);
|
|
3493
|
+
if (tracker.samples.length > MAX_SAMPLES) {
|
|
3494
|
+
tracker.samples.shift();
|
|
3495
|
+
}
|
|
3496
|
+
return null;
|
|
3497
|
+
}
|
|
3498
|
+
if (type === "mouseReleased" && tracker.samples.length < MIN_SAMPLES_FOR_ANALYSIS) {
|
|
3499
|
+
tracker.samples = [];
|
|
3500
|
+
return null;
|
|
3501
|
+
}
|
|
3502
|
+
return null;
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
function analyzeTrajectory(samples) {
|
|
3506
|
+
if (samples.length < MIN_SAMPLES_FOR_ANALYSIS) return null;
|
|
3507
|
+
const issues = [];
|
|
3508
|
+
const startX = samples[0].x;
|
|
3509
|
+
const startY = samples[0].y;
|
|
3510
|
+
const endX = samples[samples.length - 1].x;
|
|
3511
|
+
const endY = samples[samples.length - 1].y;
|
|
3512
|
+
const lineLength = distance(startX, startY, endX, endY);
|
|
3513
|
+
const collinearityResult = checkCollinearity(samples, startX, startY, endX, endY, lineLength);
|
|
3514
|
+
if (collinearityResult) {
|
|
3515
|
+
issues.push(collinearityResult);
|
|
3516
|
+
}
|
|
3517
|
+
const velocityResult = checkConstantVelocity(samples);
|
|
3518
|
+
if (velocityResult) {
|
|
3519
|
+
issues.push(velocityResult);
|
|
3520
|
+
}
|
|
3521
|
+
const jitterResult = checkJitter(samples);
|
|
3522
|
+
if (jitterResult) {
|
|
3523
|
+
issues.push(jitterResult);
|
|
3524
|
+
}
|
|
3525
|
+
if (issues.length === 0) return null;
|
|
3526
|
+
const stopX = samples[samples.length - 1].x;
|
|
3527
|
+
const stopY = samples[samples.length - 1].y;
|
|
3528
|
+
return {
|
|
3529
|
+
ruleId: "mouse-trajectory",
|
|
3530
|
+
action: "pass",
|
|
3531
|
+
severity: "danger",
|
|
3532
|
+
reason: `Suspicious mouse trajectory: ${issues.join("; ")}`,
|
|
3533
|
+
suggestion: `This mouse movement appears automated (straight line A\u2192B, no natural variation).
|
|
3534
|
+
Use a humanized mouse API that generates:
|
|
3535
|
+
- Bezier curves instead of straight lines
|
|
3536
|
+
- Random acceleration/deceleration
|
|
3537
|
+
- 1-3px micro-jitter per sample
|
|
3538
|
+
|
|
3539
|
+
Example: The 'faker' or 'ghost-cursor' libraries generate realistic mouse paths.
|
|
3540
|
+
Target was: (${Math.round(startX)}, ${Math.round(startY)}) \u2192 (${Math.round(stopX)}, ${Math.round(stopY)})`,
|
|
3541
|
+
errorCode: -32002,
|
|
3542
|
+
errorMessage: "[CDP Firewall] Automated mouse trajectory blocked \u2014 appears non-human"
|
|
3543
|
+
};
|
|
3544
|
+
}
|
|
3545
|
+
function distance(x1, y1, x2, y2) {
|
|
3546
|
+
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
3547
|
+
}
|
|
3548
|
+
function checkCollinearity(samples, x1, y1, x2, y2, lineLength) {
|
|
3549
|
+
if (lineLength < 5) return null;
|
|
3550
|
+
let maxDeviation = 0;
|
|
3551
|
+
const dx = x2 - x1;
|
|
3552
|
+
const dy = y2 - y1;
|
|
3553
|
+
for (let i = 1; i < samples.length - 1; i++) {
|
|
3554
|
+
const { x, y } = samples[i];
|
|
3555
|
+
const cross = Math.abs(dy * x - dx * y + x2 * y1 - y2 * x1);
|
|
3556
|
+
const dev = cross / lineLength;
|
|
3557
|
+
if (dev > maxDeviation) maxDeviation = dev;
|
|
3558
|
+
}
|
|
3559
|
+
if (maxDeviation < 1.5 && lineLength > 20) {
|
|
3560
|
+
return `Perfectly straight line (max deviation ${maxDeviation.toFixed(1)}px over ${lineLength.toFixed(0)}px)`;
|
|
3561
|
+
}
|
|
3562
|
+
if (maxDeviation < 0.5 && lineLength > 10) {
|
|
3563
|
+
return `Near-perfect straight line (max deviation ${maxDeviation.toFixed(1)}px)`;
|
|
3564
|
+
}
|
|
3565
|
+
return null;
|
|
3566
|
+
}
|
|
3567
|
+
function checkConstantVelocity(samples) {
|
|
3568
|
+
if (samples.length < 3) return null;
|
|
3569
|
+
const speeds = [];
|
|
3570
|
+
for (let i = 1; i < samples.length; i++) {
|
|
3571
|
+
const d = distance(
|
|
3572
|
+
samples[i - 1].x,
|
|
3573
|
+
samples[i - 1].y,
|
|
3574
|
+
samples[i].x,
|
|
3575
|
+
samples[i].y
|
|
3576
|
+
);
|
|
3577
|
+
const dt = samples[i].timestamp - samples[i - 1].timestamp;
|
|
3578
|
+
if (dt > 0) speeds.push(d / dt);
|
|
3579
|
+
}
|
|
3580
|
+
if (speeds.length < 2) return null;
|
|
3581
|
+
const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
|
3582
|
+
if (mean === 0) return null;
|
|
3583
|
+
const variance = speeds.reduce((sum, v) => sum + (v - mean) ** 2, 0) / speeds.length;
|
|
3584
|
+
const stddev = Math.sqrt(variance);
|
|
3585
|
+
const cv = stddev / mean;
|
|
3586
|
+
if (cv < 0.05) {
|
|
3587
|
+
return `Constant velocity (CV=${cv.toFixed(3)}, mean=${mean.toFixed(1)} px/ms)`;
|
|
3588
|
+
}
|
|
3589
|
+
return null;
|
|
3590
|
+
}
|
|
3591
|
+
function checkJitter(samples) {
|
|
3592
|
+
if (samples.length < 4) return null;
|
|
3593
|
+
let totalLateralChange = 0;
|
|
3594
|
+
for (let i = 1; i < samples.length; i++) {
|
|
3595
|
+
const d = distance(
|
|
3596
|
+
samples[i - 1].x,
|
|
3597
|
+
samples[i - 1].y,
|
|
3598
|
+
samples[i].x,
|
|
3599
|
+
samples[i].y
|
|
3600
|
+
);
|
|
3601
|
+
totalLateralChange += d;
|
|
3602
|
+
}
|
|
3603
|
+
const avgLateral = totalLateralChange / (samples.length - 1);
|
|
3604
|
+
if (avgLateral < 0.3) {
|
|
3605
|
+
return "No micro-jitter (sub-pixel precision, impossible for human)";
|
|
3606
|
+
}
|
|
3607
|
+
return null;
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
// src/cdp-interceptor/rules/input-keystroke.ts
|
|
3611
|
+
var TRACKER_KEY2 = "keystroke-tracker";
|
|
3612
|
+
var MIN_KEYS_FOR_ANALYSIS = 4;
|
|
3613
|
+
var inputKeystrokeRule = {
|
|
3614
|
+
id: "input-keystroke",
|
|
3615
|
+
name: "Input Keystroke Timing Analysis",
|
|
3616
|
+
priority: 40,
|
|
3617
|
+
canHandle(ctx) {
|
|
3618
|
+
return ctx.method === "Input.dispatchKeyEvent" || ctx.method === "Input.insertText";
|
|
3619
|
+
},
|
|
3620
|
+
evaluate(ctx) {
|
|
3621
|
+
if (ctx.method === "Input.insertText") {
|
|
3622
|
+
return {
|
|
3623
|
+
ruleId: "input-keystroke",
|
|
3624
|
+
action: "pass",
|
|
3625
|
+
severity: "info",
|
|
3626
|
+
reason: "Input.insertText bypasses native keyboard events. Playwright uses this internally for page.fill().",
|
|
3627
|
+
suggestion: "Prefer page.type() with variable delay for human-like input.",
|
|
3628
|
+
errorCode: -32004,
|
|
3629
|
+
errorMessage: "[CDP Firewall] Input.insertText detected \u2014 note: Playwright uses this for fill()"
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
let tracker = ctx.sessionState.get(TRACKER_KEY2);
|
|
3633
|
+
if (!tracker) {
|
|
3634
|
+
tracker = { samples: [] };
|
|
3635
|
+
ctx.sessionState.set(TRACKER_KEY2, tracker);
|
|
3636
|
+
}
|
|
3637
|
+
const type = ctx.params.type;
|
|
3638
|
+
const code = ctx.params.code;
|
|
3639
|
+
const key = ctx.params.key;
|
|
3640
|
+
if (type === "keyDown" && key && key.length === 1) {
|
|
3641
|
+
tracker.samples.push({
|
|
3642
|
+
code,
|
|
3643
|
+
key,
|
|
3644
|
+
timestamp: Date.now(),
|
|
3645
|
+
type
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3648
|
+
if (tracker.samples.length >= MIN_KEYS_FOR_ANALYSIS && (type === "keyUp" || type === "keyDown")) {
|
|
3649
|
+
return analyzeKeyTiming(tracker.samples);
|
|
3650
|
+
}
|
|
3651
|
+
return null;
|
|
3652
|
+
}
|
|
3653
|
+
};
|
|
3654
|
+
function analyzeKeyTiming(samples) {
|
|
3655
|
+
if (samples.length < MIN_KEYS_FOR_ANALYSIS) return null;
|
|
3656
|
+
const intervals = [];
|
|
3657
|
+
for (let i = 1; i < samples.length; i++) {
|
|
3658
|
+
const dt = samples[i].timestamp - samples[i - 1].timestamp;
|
|
3659
|
+
if (dt > 0) intervals.push(dt);
|
|
3660
|
+
}
|
|
3661
|
+
if (intervals.length < 3) return null;
|
|
3662
|
+
const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
3663
|
+
if (mean === 0) return null;
|
|
3664
|
+
const variance = intervals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / intervals.length;
|
|
3665
|
+
const stddev = Math.sqrt(variance);
|
|
3666
|
+
const cv = stddev / mean;
|
|
3667
|
+
const uniqueIntervals = new Set(intervals);
|
|
3668
|
+
const allIdentical = uniqueIntervals.size === 1;
|
|
3669
|
+
if (allIdentical) {
|
|
3670
|
+
return {
|
|
3671
|
+
ruleId: "input-keystroke",
|
|
3672
|
+
action: "pass",
|
|
3673
|
+
severity: "danger",
|
|
3674
|
+
reason: `All ${intervals.length} keystroke intervals are exactly ${intervals[0]}ms \u2014 impossible for human typing.`,
|
|
3675
|
+
suggestion: `Use page.fill(selector, text) instead of page.type() with delay.
|
|
3676
|
+
Or add random variation: page.type(selector, text, {delay: 50 + Math.random() * 80}).`,
|
|
3677
|
+
errorCode: -32004,
|
|
3678
|
+
errorMessage: "[CDP Firewall] Constant keystroke timing detected \u2014 automated typing pattern"
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3681
|
+
if (cv < 0.08) {
|
|
3682
|
+
return {
|
|
3683
|
+
ruleId: "input-keystroke",
|
|
3684
|
+
action: "pass",
|
|
3685
|
+
severity: "warn",
|
|
3686
|
+
reason: `Unnatural keystroke timing (CV=${cv.toFixed(3)}). Human typing has CV > 0.2 on average.`,
|
|
3687
|
+
suggestion: `Add random variation to your typing delay: page.type(selector, text, {delay: 50 + Math.random() * 80}).`,
|
|
3688
|
+
errorCode: -32004,
|
|
3689
|
+
errorMessage: "[CDP Firewall] Suspicious keystroke timing \u2014 likely automated"
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
return null;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// src/cdp-interceptor/rules/automation-signals.ts
|
|
3696
|
+
var AUTOMATION_PATTERNS = [
|
|
3697
|
+
// ── Playwright markers ─────────────────────────
|
|
3698
|
+
{ pattern: /window\s*\.\s*__playwright/, name: "window.__playwright", severity: "danger", errorCode: -32040 },
|
|
3699
|
+
{ pattern: /window\s*\.\s*__pw_[a-zA-Z]/, name: "window.__pw_*", severity: "danger", errorCode: -32040 },
|
|
3700
|
+
{ pattern: /window\s*\.\s*__pw_paused/, name: "window.__pw_paused", severity: "danger", errorCode: -32040 },
|
|
3701
|
+
{ pattern: /window\s*\.\s*playwright\b/, name: "window.playwright", severity: "danger", errorCode: -32040 },
|
|
3702
|
+
{ pattern: /window\s*\.\s*__pw_recorder/, name: "window.__pw_recorder", severity: "warn", errorCode: -32041 },
|
|
3703
|
+
{ pattern: /window\s*\.\s*__pw_trace/, name: "window.__pw_trace", severity: "warn", errorCode: -32041 },
|
|
3704
|
+
// ── Puppeteer markers ──────────────────────────
|
|
3705
|
+
{ pattern: /window\s*\.\s*__puppeteer\b/, name: "window.__puppeteer", severity: "danger", errorCode: -32040 },
|
|
3706
|
+
{ pattern: /window\s*\.\s*__puppeteer_evaluation_script/, name: "window.__puppeteer_evaluation_script", severity: "danger", errorCode: -32040 },
|
|
3707
|
+
{ pattern: /window\s*\.\s*__puppeteer_testId/, name: "window.__puppeteer_testId", severity: "warn", errorCode: -32041 },
|
|
3708
|
+
{ pattern: /window\s*\.\s*__puppeteer_/, name: "window.__puppeteer_*", severity: "danger", errorCode: -32040 },
|
|
3709
|
+
{ pattern: /window\s*\.\s*_puppeteer\b/, name: "window._puppeteer", severity: "warn", errorCode: -32041 },
|
|
3710
|
+
// ── Selenium / WebDriver markers ───────────────
|
|
3711
|
+
{ pattern: /window\s*\.\s*__webdriver_script_fn/, name: "window.__webdriver_script_fn", severity: "danger", errorCode: -32040 },
|
|
3712
|
+
{ pattern: /window\s*\.\s*__selenium\b/, name: "window.__selenium", severity: "danger", errorCode: -32040 },
|
|
3713
|
+
{ pattern: /window\s*\.\s*__selenium_evaluate/, name: "window.__selenium_evaluate", severity: "danger", errorCode: -32040 },
|
|
3714
|
+
{ pattern: /window\s*\.\s*__driver_evaluate/, name: "window.__driver_evaluate", severity: "warn", errorCode: -32041 },
|
|
3715
|
+
{ pattern: /window\s*\.\s*__webdriver_evaluate/, name: "window.__webdriver_evaluate", severity: "danger", errorCode: -32040 },
|
|
3716
|
+
{ pattern: /document\.\$cdc_/, name: "document.$cdc_* (Selenium marker)", severity: "danger", errorCode: -32040 },
|
|
3717
|
+
{ pattern: /document\.\$chrome_asyncScriptInfo/, name: "document.$chrome_asyncScriptInfo", severity: "danger", errorCode: -32040 },
|
|
3718
|
+
// ── navigator.webdriver detection ─────────────
|
|
3719
|
+
{ pattern: /navigator\s*\.\s*webdriver/, name: "navigator.webdriver read", severity: "danger", errorCode: -32042 },
|
|
3720
|
+
{ pattern: /navigator\[["']webdriver["']\]/, name: 'navigator["webdriver"]', severity: "danger", errorCode: -32042 },
|
|
3721
|
+
// ── Chrome headless API surface checks ────────
|
|
3722
|
+
{ pattern: /chrome\s*\.\s*app/, name: "chrome.app detection", severity: "danger", errorCode: -32043 },
|
|
3723
|
+
{ pattern: /chrome\s*\.\s*runtime/, name: "chrome.runtime detection", severity: "danger", errorCode: -32043 },
|
|
3724
|
+
{ pattern: /chrome\s*\.\s*loadTimes/, name: "chrome.loadTimes detection", severity: "warn", errorCode: -32044 },
|
|
3725
|
+
{ pattern: /chrome\s*\.\s*csi\b/, name: "chrome.csi detection", severity: "warn", errorCode: -32044 },
|
|
3726
|
+
{ pattern: /window\s*\.\s*chrome\b/, name: "window.chrome object probe", severity: "warn", errorCode: -32044 },
|
|
3727
|
+
{ pattern: /navigator\s*\.\s*plugins\b/, name: "navigator.plugins enumeration", severity: "warn", errorCode: -32044 },
|
|
3728
|
+
{ pattern: /navigator\s*\.\s*mimeTypes/, name: "navigator.mimeTypes enumeration", severity: "warn", errorCode: -32044 },
|
|
3729
|
+
{ pattern: /navigator\s*\.\s*hardwareConcurrency/, name: "navigator.hardwareConcurrency", severity: "warn", errorCode: -32044 },
|
|
3730
|
+
{ pattern: /navigator\s*\.\s*deviceMemory/, name: "navigator.deviceMemory", severity: "info", errorCode: -32045 },
|
|
3731
|
+
{ pattern: /navigator\s*\.\s*maxTouchPoints/, name: "navigator.maxTouchPoints", severity: "warn", errorCode: -32044 },
|
|
3732
|
+
{ pattern: /navigator\s*\.\s*languages/, name: "navigator.languages", severity: "info", errorCode: -32045 },
|
|
3733
|
+
{ pattern: /navigator\s*\.\s*platform/, name: "navigator.platform", severity: "info", errorCode: -32045 },
|
|
3734
|
+
// ── Anti-detection injection attempts ─────────
|
|
3735
|
+
{ pattern: /navigator\.webdriver\s*=\s*(false|undefined|null)/, name: "navigator.webdriver override attempt", severity: "danger", errorCode: -32046, suggestionOverride: "Overriding navigator.webdriver is detectable. Use CDP Page.addScriptToEvaluateOnNewDocument instead." },
|
|
3736
|
+
{ pattern: /Object\.defineProperty\s*\([^)]*webdriver/, name: "Object.defineProperty navigator.webdriver", severity: "danger", errorCode: -32046, suggestionOverride: "Object.defineProperty patches are detectable via Function.prototype.toString checks." },
|
|
3737
|
+
{ pattern: /delete\s+navigator\s*\.\s*webdriver/, name: "delete navigator.webdriver", severity: "danger", errorCode: -32046, suggestionOverride: "Deleting webdriver flag is detectable \u2014 the delete itself is visible." },
|
|
3738
|
+
// ── PhantomJS / Headless markers ──────────────
|
|
3739
|
+
{ pattern: /window\s*\.\s*callPhantom/, name: "window.callPhantom", severity: "danger", errorCode: -32040 },
|
|
3740
|
+
{ pattern: /window\s*\.\s*_phantom/, name: "window._phantom", severity: "danger", errorCode: -32040 },
|
|
3741
|
+
{ pattern: /window\s*\.\s*phantom\b/, name: "window.phantom", severity: "danger", errorCode: -32040 },
|
|
3742
|
+
{ pattern: /window\s*\.\s*Buffer\b/, name: "window.Buffer (Node.js leak)", severity: "danger", errorCode: -32040 },
|
|
3743
|
+
{ pattern: /window\s*\.\s*process\b/, name: "window.process (Node.js leak)", severity: "danger", errorCode: -32040 },
|
|
3744
|
+
{ pattern: /window\s*\.\s*global\b/, name: "window.global (Node.js leak)", severity: "danger", errorCode: -32040 },
|
|
3745
|
+
{ pattern: /window\s*\.\s*__dirname/, name: "window.__dirname (Node.js leak)", severity: "danger", errorCode: -32040 }
|
|
3746
|
+
];
|
|
3747
|
+
var automationSignalsRule = {
|
|
3748
|
+
id: "automation-signals",
|
|
3749
|
+
name: "Browser Automation Signal Detection (35+ markers)",
|
|
3750
|
+
priority: 20,
|
|
3751
|
+
canHandle(ctx) {
|
|
3752
|
+
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn" || ctx.method === "Page.addScriptToEvaluateOnNewDocument";
|
|
3753
|
+
},
|
|
3754
|
+
evaluate(ctx) {
|
|
3755
|
+
if (ctx.method === "Page.addScriptToEvaluateOnNewDocument") {
|
|
3756
|
+
const source = ctx.params.source;
|
|
3757
|
+
if (typeof source === "string") {
|
|
3758
|
+
for (const p of AUTOMATION_PATTERNS) {
|
|
3759
|
+
if (p.pattern.test(source)) {
|
|
3760
|
+
return makeDecision(p, source.substring(0, 60));
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
return null;
|
|
3765
|
+
}
|
|
3766
|
+
const userCode = extractUserCode(ctx);
|
|
3767
|
+
if (!userCode) return null;
|
|
3768
|
+
for (const p of AUTOMATION_PATTERNS) {
|
|
3769
|
+
if (p.pattern.test(userCode)) {
|
|
3770
|
+
return makeDecision(p, userCode.substring(0, 60));
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
return null;
|
|
3774
|
+
}
|
|
3775
|
+
};
|
|
3776
|
+
function makeDecision(p, context) {
|
|
3777
|
+
const suggestion = p.suggestionOverride ?? `Detected: "${p.name}" \u2014 an automation tool marker that anti-crawler systems immediately flag. Remove this pattern from your code.`;
|
|
3778
|
+
return {
|
|
3779
|
+
ruleId: "automation-signals",
|
|
3780
|
+
action: "pass",
|
|
3781
|
+
severity: p.severity,
|
|
3782
|
+
reason: `Automation marker detected: "${p.name}". Context: "${context}..."`,
|
|
3783
|
+
suggestion,
|
|
3784
|
+
errorCode: p.errorCode,
|
|
3785
|
+
errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 automation tool marker detected` : `[CDP Firewall] ${p.name} detected \u2014 potential automation signal`
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
// src/cdp-interceptor/rules/fingerprinting.ts
|
|
3790
|
+
var FP_PATTERNS = [
|
|
3791
|
+
// ── Canvas Rendering fingerprinting ────────────────────
|
|
3792
|
+
{ pattern: /\.toDataURL\s*\(/, name: "canvas.toDataURL()", severity: "danger", errorCode: -32050, suggestion: "canvas.toDataURL() returns a unique hash that identifies the browser engine. Minimize calls." },
|
|
3793
|
+
{ pattern: /\.toBlob\s*\(/, name: "canvas.toBlob()", severity: "danger", errorCode: -32050, suggestion: "canvas.toBlob() is used for canvas fingerprinting. Avoid if possible." },
|
|
3794
|
+
{ pattern: /getImageData\s*\(/, name: "CanvasRenderingContext2D.getImageData()", severity: "danger", errorCode: -32050, suggestion: "getImageData reads pixel-level data used for fingerprinting." },
|
|
3795
|
+
{ pattern: /measureText\s*\(/, name: "CanvasRenderingContext2D.measureText()", severity: "warn", errorCode: -32051, suggestion: "Font metrics reveal installed fonts \u2014 a fingerprinting vector." },
|
|
3796
|
+
{ pattern: /OffscreenCanvas\s*\(/, name: "new OffscreenCanvas()", severity: "warn", errorCode: -32051, suggestion: "OffscreenCanvas is sometimes used to avoid visibility detection." },
|
|
3797
|
+
{ pattern: /convertToBlob\s*\(/, name: "OffscreenCanvas.convertToBlob()", severity: "warn", errorCode: -32051, suggestion: "Headless OffscreenCanvas rendering differs from real browser." },
|
|
3798
|
+
// ── WebGL Fingerprinting ──────────────────────────────
|
|
3799
|
+
{ pattern: /getParameter\s*\([^)]*(?:VENDOR|RENDERER|VERSION)/, name: "WebGL getParameter(VENDOR/RENDERER)", severity: "danger", errorCode: -32052, suggestion: "WebGL VENDOR/RENDERER returns emulated values in headless. Cannot be reliably spoofed." },
|
|
3800
|
+
{ pattern: /getSupportedExtensions\s*\(/, name: "WebGL getSupportedExtensions()", severity: "warn", errorCode: -32053, suggestion: "WebGL extension list differs in headless mode." },
|
|
3801
|
+
{ pattern: /getShaderPrecisionFormat\s*\(/, name: "WebGL getShaderPrecisionFormat()", severity: "info", errorCode: -32054, suggestion: "Shader precision differs between headless and real GPU." },
|
|
3802
|
+
{ pattern: /UNMASKED_VENDOR_WEBGL/, name: "WEBGL_debug_renderer_info UNMASKED_VENDOR", severity: "warn", errorCode: -32053, suggestion: "Unmasked vendor info reveals the real GPU \u2014 blocked in many envs." },
|
|
3803
|
+
{ pattern: /UNMASKED_RENDERER_WEBGL/, name: "WEBGL_debug_renderer_info UNMASKED_RENDERER", severity: "warn", errorCode: -32053, suggestion: "Unmasked renderer string reveals the real GPU." },
|
|
3804
|
+
// ── AudioContext Fingerprinting ────────────────────────
|
|
3805
|
+
{ pattern: /AnalyserNode\s*\(/, name: "new AnalyserNode()", severity: "warn", errorCode: -32055, suggestion: "Audio fingerprinting via AnalyserNode \u2014 produces silence in headless." },
|
|
3806
|
+
{ pattern: /getFloatFrequencyData\s*\(/, name: "AnalyserNode.getFloatFrequencyData()", severity: "danger", errorCode: -32050, suggestion: "Audio frequency data in headless returns silence (all zeros) \u2014 detectable." },
|
|
3807
|
+
{ pattern: /getByteFrequencyData\s*\(/, name: "AnalyserNode.getByteFrequencyData()", severity: "danger", errorCode: -32050, suggestion: "Audio byte frequency data in headless returns zeros." },
|
|
3808
|
+
{ pattern: /getByteTimeDomainData\s*\(/, name: "AnalyserNode.getByteTimeDomainData()", severity: "danger", errorCode: -32050, suggestion: "Time domain audio data in headless is a flat line \u2014 detectable." },
|
|
3809
|
+
{ pattern: /OfflineAudioContext\s*\(/, name: "new OfflineAudioContext()", severity: "warn", errorCode: -32055, suggestion: "Offline audio rendering is a known fingerprinting method." },
|
|
3810
|
+
{ pattern: /OscillatorNode\s*\(/, name: "new OscillatorNode()", severity: "info", errorCode: -32056, suggestion: "Audio oscillator used in fingerprinting probes." },
|
|
3811
|
+
// ── Navigator property probing ───────────────────────
|
|
3812
|
+
{ pattern: /navigator\s*\.\s*connection\b/, name: "navigator.connection", severity: "info", errorCode: -32057, suggestion: 'Network connection info is used for fingerprinting (always "4g" in bots).' },
|
|
3813
|
+
{ pattern: /navigator\s*\.\s*getBattery\s*\(/, name: "navigator.getBattery()", severity: "warn", errorCode: -32058, suggestion: "Battery API is a fingerprinting vector. Returns fixed values in headless." },
|
|
3814
|
+
{ pattern: /navigator\s*\.\s*mediaDevices\s*\.\s*enumerateDevices/, name: "navigator.mediaDevices.enumerateDevices()", severity: "warn", errorCode: -32058, suggestion: "Media device enumeration returns empty/no devices in headless." },
|
|
3815
|
+
{ pattern: /navigator\s*\.\s*permissions\s*\.\s*query/, name: "navigator.permissions.query()", severity: "info", errorCode: -32059, suggestion: "Permission queries can reveal automation environment." },
|
|
3816
|
+
// ── Screen / Window geometry probing ──────────────────
|
|
3817
|
+
{ pattern: /screen\.avail(Width|Height|Left|Top)/, name: "screen.avail*", severity: "warn", errorCode: -32060, suggestion: "screen.avail* values differ in headless (no OS chrome)." },
|
|
3818
|
+
{ pattern: /window\.outerWidth\s*-?\s*window\.innerWidth/, name: "window.outerWidth - window.innerWidth", severity: "danger", errorCode: -32050, suggestion: "This difference is 0 in headless (no browser chrome) \u2014 100% detection rate." },
|
|
3819
|
+
{ pattern: /window\.outerHeight\s*-?\s*window\.innerHeight/, name: "window.outerHeight - window.innerHeight", severity: "danger", errorCode: -32050, suggestion: "This difference is 0 in headless \u2014 immediate automation detection." },
|
|
3820
|
+
{ pattern: /screen\.width\b/, name: "screen.width", severity: "info", errorCode: -32061, suggestion: "Screen dimensions can be spoofed but inconsistencies with viewport are detectable." },
|
|
3821
|
+
{ pattern: /screen\.height\b/, name: "screen.height", severity: "info", errorCode: -32061, suggestion: "Screen dimension probes for viewport inconsistency detection." },
|
|
3822
|
+
{ pattern: /window\.devicePixelRatio/, name: "window.devicePixelRatio", severity: "warn", errorCode: -32060, suggestion: "devicePixelRatio is always 1 in headless \u2014 differs from real displays." },
|
|
3823
|
+
{ pattern: /matchMedia\s*\(/, name: "window.matchMedia()", severity: "warn", errorCode: -32060, suggestion: "matchMedia can detect CDP overridden viewport dimensions." },
|
|
3824
|
+
// ── Performance / Timing API ──────────────────────────
|
|
3825
|
+
{ pattern: /performance\s*\.\s*now\s*\(/, name: "performance.now()", severity: "info", errorCode: -32062, suggestion: "High-resolution timer used for timing attacks and bot detection." },
|
|
3826
|
+
{ pattern: /performance\s*\.\s*memory/, name: "performance.memory", severity: "warn", errorCode: -32063, suggestion: "performance.memory shows VM memory limits in containers." },
|
|
3827
|
+
{ pattern: /performance\.getEntriesByType\s*\(\s*["']navigation["']\s*\)/, name: 'performance.getEntriesByType("navigation")', severity: "info", errorCode: -32062, suggestion: "Navigation timing reveals request pattern inconsistencies." },
|
|
3828
|
+
{ pattern: /performance\.getEntriesByType\s*\(\s*["']resource["']\s*\)/, name: 'performance.getEntriesByType("resource")', severity: "info", errorCode: -32062, suggestion: "Resource loading timing analysis for bot detection." },
|
|
3829
|
+
// ── Font Detection ────────────────────────────────────
|
|
3830
|
+
{ pattern: /document\.fonts\.check\s*\(/, name: "document.fonts.check()", severity: "warn", errorCode: -32064, suggestion: "Font availability checks are used for fingerprinting. Installed font list is unique per user." },
|
|
3831
|
+
{ pattern: /document\.fonts\.ready/, name: "document.fonts.ready", severity: "info", errorCode: -32065, suggestion: "Font loading state probe for fingerprinting." },
|
|
3832
|
+
// ── WebRTC / Connectivity ────────────────────────────
|
|
3833
|
+
{ pattern: /RTCPeerConnection\s*\(/, name: "new RTCPeerConnection()", severity: "warn", errorCode: -32066, suggestion: "WebRTC can leak internal IP and is used for connectivity fingerprinting." },
|
|
3834
|
+
{ pattern: /navigator\.mediaDevices\.getUserMedia/, name: "navigator.mediaDevices.getUserMedia()", severity: "info", errorCode: -32067, suggestion: "getUserMedia always fails in headless (no camera)." },
|
|
3835
|
+
// ── Feature Consistency Checks ─────────────────────────
|
|
3836
|
+
{ pattern: /Intl\.DateTimeFormat.*resolvedOptions.*timeZone/, name: "Intl.DateTimeFormat timezone check", severity: "warn", errorCode: -32068, suggestion: "Timezone from Intl API vs Emulation.setTimezoneOverride will be inconsistent when mocked." },
|
|
3837
|
+
{ pattern: /new\s+Date\s*\(\s*\)\s*\.\s*getTimezoneOffset/, name: "Date.getTimezoneOffset()", severity: "info", errorCode: -32069, suggestion: "Timezone offset used for timing consistency cross-checks." },
|
|
3838
|
+
{ pattern: /Error\s*\(\s*\)\s*\.\s*stack/, name: "Error().stack format check", severity: "info", errorCode: -32070, suggestion: "Stack trace format differs between headless and full Chrome." },
|
|
3839
|
+
{ pattern: /Function\.prototype\.toString/, name: "Function.prototype.toString on native fn", severity: "info", errorCode: -32070, suggestion: "Native function toString() format can reveal patched APIs." }
|
|
3840
|
+
];
|
|
3841
|
+
var fingerprintingRule = {
|
|
3842
|
+
id: "fingerprinting",
|
|
3843
|
+
name: "Browser Fingerprinting Access Detection (35+ APIs)",
|
|
3844
|
+
priority: 30,
|
|
3845
|
+
canHandle(ctx) {
|
|
3846
|
+
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
|
|
3847
|
+
},
|
|
3848
|
+
evaluate(ctx) {
|
|
3849
|
+
const userCode = extractUserCode(ctx);
|
|
3850
|
+
if (!userCode) return null;
|
|
3851
|
+
for (const p of FP_PATTERNS) {
|
|
3852
|
+
if (p.pattern.test(userCode)) {
|
|
3853
|
+
return {
|
|
3854
|
+
ruleId: "fingerprinting",
|
|
3855
|
+
action: "pass",
|
|
3856
|
+
severity: p.severity,
|
|
3857
|
+
reason: `Browser fingerprinting API accessed: "${p.name}". Anti-crawler systems use this to identify your browser.`,
|
|
3858
|
+
suggestion: p.suggestion,
|
|
3859
|
+
errorCode: p.errorCode,
|
|
3860
|
+
errorMessage: `[CDP Firewall] ${p.name} blocked \u2014 fingerprinting API access detected`
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
return null;
|
|
3865
|
+
}
|
|
3866
|
+
};
|
|
3867
|
+
|
|
3868
|
+
// src/cdp-interceptor/rules/event-simulation.ts
|
|
3869
|
+
var EVENT_PATTERNS = [
|
|
3870
|
+
// ── Direct method calls (all isTrusted=false) ─────────
|
|
3871
|
+
{ pattern: /\.click\s*\(\s*\)/, name: "el.click()", severity: "danger", errorCode: -32070, suggestion: "el.click() fires isTrusted=false events. Use page.click(selector) which uses Input.dispatchMouseEvent (isTrusted=true)." },
|
|
3872
|
+
{ pattern: /\.focus\s*\(\s*\)/, name: "el.focus()", severity: "warn", errorCode: -32071, suggestion: "el.focus() without user interaction is detectable. Use page.click(selector) which naturally focuses." },
|
|
3873
|
+
{ pattern: /\.blur\s*\(\s*\)/, name: "el.blur()", severity: "danger", errorCode: -32071, suggestion: "el.blur() without user interaction. Avoid in automation scripts." },
|
|
3874
|
+
{ pattern: /\.submit\s*\(\s*\)/, name: "el.submit()", severity: "danger", errorCode: -32072, suggestion: `form.submit() bypasses onSubmit handlers. Click the submit button: page.click('button[type="submit"]').` },
|
|
3875
|
+
{ pattern: /\.reset\s*\(\s*\)/, name: "el.reset()", severity: "danger", errorCode: -32072, suggestion: "form.reset() without user action. Let the user clear fields manually." },
|
|
3876
|
+
{ pattern: /\.select\s*\(\s*\)/, name: "el.select()", severity: "warn", errorCode: -32073, suggestion: "input.select() selects text without user interaction. Use page.click(selector) instead." },
|
|
3877
|
+
{ pattern: /dialog\.close\s*\(\s*\)|showModal\(\)/, name: "dialog.showModal()/close()", severity: "danger", errorCode: -32074, suggestion: "dialog.showModal() requires user gesture. Let the user open the dialog naturally." },
|
|
3878
|
+
{ pattern: /\.showPopover\s*\(\s*\)/, name: "el.showPopover()", severity: "warn", errorCode: -32073, suggestion: "Popover.show() requires user gesture. Simulate a click on the popover trigger." },
|
|
3879
|
+
{ pattern: /\.hidePopover\s*\(\s*\)/, name: "el.hidePopover()", severity: "warn", errorCode: -32073, suggestion: "Hiding popovers via CDP is detectable." },
|
|
3880
|
+
{ pattern: /\.requestFullscreen\s*\(\s*\)/, name: "el.requestFullscreen()", severity: "danger", errorCode: -32074, suggestion: "Fullscreen requests require user gesture. Cannot be triggered by automation." },
|
|
3881
|
+
{ pattern: /\.requestPointerLock\s*\(\s*\)/, name: "el.requestPointerLock()", severity: "danger", errorCode: -32074, suggestion: "Pointer lock requires user gesture. Blocked in automation." },
|
|
3882
|
+
{ pattern: /\.setSelectionRange\s*\(/, name: "el.setSelectionRange()", severity: "warn", errorCode: -32073, suggestion: "Setting selection range without user interaction is suspicious." },
|
|
3883
|
+
{ pattern: /\.setRangeText\s*\(/, name: "el.setRangeText()", severity: "info", errorCode: -32076, suggestion: "Range text replacement without user input is detectable." },
|
|
3884
|
+
{ pattern: /\.showPicker\s*\(\s*\)/, name: "HTMLInputElement.showPicker()", severity: "info", errorCode: -32076, suggestion: "Date/color picker shown without click \u2014 detectable." },
|
|
3885
|
+
{ pattern: /\.reportValidity\s*\(\s*\)/, name: "el.reportValidity()", severity: "info", errorCode: -32076, suggestion: "Validity reporting without form submission attempt." },
|
|
3886
|
+
// ── dispatchEvent with synthetic events (isTrusted=false) ──
|
|
3887
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+(?:Event|CustomEvent)\s*\(/, name: "dispatchEvent(new Event/CustomEvent)", severity: "warn", errorCode: -32077, suggestion: "Synthetic events have isTrusted=false. Use Input.dispatch* CDP methods for trusted events." },
|
|
3888
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+MouseEvent\s*\(/, name: "dispatchEvent(new MouseEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic mouse events (isTrusted=false). Use Input.dispatchMouseEvent CDP method." },
|
|
3889
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+KeyboardEvent\s*\(/, name: "dispatchEvent(new KeyboardEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic keyboard events (isTrusted=false). Use Input.dispatchKeyEvent CDP method." },
|
|
3890
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+FocusEvent\s*\(/, name: "dispatchEvent(new FocusEvent)", severity: "warn", errorCode: -32078, suggestion: "Synthetic focus events bypass user interaction." },
|
|
3891
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+InputEvent\s*\(/, name: "dispatchEvent(new InputEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic input events (isTrusted=false). Use page.fill() or page.type()." },
|
|
3892
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+(?:Event)\s*\(\s*["'](?:input|change|submit|reset)/, name: 'dispatchEvent("input"/"change"/"submit")', severity: "danger", errorCode: -32077, suggestion: "Synthetic input/change/submit events are trusted=false. Always detectable." },
|
|
3893
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+PointerEvent\s*\(/, name: "dispatchEvent(new PointerEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic pointer events bypass the trusted input pipeline." },
|
|
3894
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+TouchEvent\s*\(/, name: "dispatchEvent(new TouchEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic touch events on mobile are detectable." },
|
|
3895
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+WheelEvent\s*\(/, name: "dispatchEvent(new WheelEvent)", severity: "warn", errorCode: -32078, suggestion: "Synthetic scroll events (isTrusted=false). Use Input.dispatchMouseEvent with wheel type." },
|
|
3896
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+ClipboardEvent\s*\(/, name: "dispatchEvent(new ClipboardEvent)", severity: "warn", errorCode: -32078, suggestion: "Synthetic clipboard events are detectable." },
|
|
3897
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+DragEvent\s*\(/, name: "dispatchEvent(new DragEvent)", severity: "warn", errorCode: -32078, suggestion: "Synthetic drag events bypass real user interaction." },
|
|
3898
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+CompositionEvent\s*\(/, name: "dispatchEvent(new CompositionEvent)", severity: "info", errorCode: -32079, suggestion: "Synthetic IME composition events \u2014 detectable pattern." },
|
|
3899
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+AnimationEvent\s*\(/, name: "dispatchEvent(new AnimationEvent)", severity: "info", errorCode: -32079, suggestion: "Forcing animation state transitions via fake events." },
|
|
3900
|
+
{ pattern: /dispatchEvent\s*\(\s*new\s+TransitionEvent\s*\(/, name: "dispatchEvent(new TransitionEvent)", severity: "info", errorCode: -32079, suggestion: "Forcing CSS transition end via fake events." }
|
|
3901
|
+
];
|
|
3902
|
+
var eventSimulationRule = {
|
|
3903
|
+
id: "event-simulation",
|
|
3904
|
+
name: "Event Simulation Detection (30+ patterns)",
|
|
3905
|
+
priority: 40,
|
|
3906
|
+
canHandle(ctx) {
|
|
3907
|
+
return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
|
|
3908
|
+
},
|
|
3909
|
+
evaluate(ctx) {
|
|
3910
|
+
const userCode = extractUserCode(ctx);
|
|
3911
|
+
if (!userCode) return null;
|
|
3912
|
+
for (const p of EVENT_PATTERNS) {
|
|
3913
|
+
if (p.pattern.test(userCode)) {
|
|
3914
|
+
return {
|
|
3915
|
+
ruleId: "event-simulation",
|
|
3916
|
+
// danger 级硬拦(el.click 等 100% 暴露的模式);
|
|
3917
|
+
// warning 级放行+提示(dispatchEvent 在部分合法场景如 DataTransfer paste 使用)
|
|
3918
|
+
action: p.severity === "danger" ? "block" : "pass",
|
|
3919
|
+
severity: p.severity,
|
|
3920
|
+
reason: `Event simulation detected: "${p.name}". Synthetic events have isTrusted=false and are 100% detectable.`,
|
|
3921
|
+
suggestion: p.suggestion,
|
|
3922
|
+
errorCode: p.errorCode,
|
|
3923
|
+
errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 synthetic event (isTrusted=false)` : `[CDP Firewall] ${p.name} detected \u2014 event simulation`
|
|
3924
|
+
};
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
return null;
|
|
3928
|
+
}
|
|
3929
|
+
};
|
|
3930
|
+
|
|
3931
|
+
// src/cdp-interceptor/rules/emulation-override.ts
|
|
3932
|
+
var OVERRIDE_PATTERNS = [
|
|
3933
|
+
// ── Emulation overrides ──────────────────────────────
|
|
3934
|
+
// NOTE: Emulation.setDeviceMetricsOverride is NOT included here because
|
|
3935
|
+
// Playwright calls it internally for every new page (viewport setup).
|
|
3936
|
+
// Blocking it would break page creation.
|
|
3937
|
+
{ method: "Emulation.setUserAgentOverride", name: "Emulation.setUserAgentOverride", severity: "danger", errorCode: -32080, suggestion: "navigator.userAgent override can be detected by checking consistency with navigator.plugins, WebGL vendor, etc." },
|
|
3938
|
+
{ method: "Emulation.setTouchEmulationEnabled", name: "Emulation.setTouchEmulationEnabled", severity: "warn", errorCode: -32081, suggestion: "Touch emulation creates inconsistent touch/mouse state. Windows touch events without real touch hardware." },
|
|
3939
|
+
{ method: "Emulation.setGeolocationOverride", name: "Emulation.setGeolocationOverride", severity: "danger", errorCode: -32082, suggestion: "Geolocation changing mid-session without user travel is impossible. Detectable via IP geo vs overridden geo." },
|
|
3940
|
+
{ method: "Emulation.setLocaleOverride", name: "Emulation.setLocaleOverride", severity: "warn", errorCode: -32081, suggestion: "Locale change without browser restart detectable via navigator.languages vs Accept-Language consistency." },
|
|
3941
|
+
{ method: "Emulation.setTimezoneOverride", name: "Emulation.setTimezoneOverride", severity: "danger", errorCode: -32082, suggestion: "Timezone mismatch vs IP geolocation + Date() is 100% detectable." },
|
|
3942
|
+
{ method: "Emulation.setDisabledImageTypes", name: "Emulation.setDisabledImageTypes", severity: "info", errorCode: -32083, suggestion: "Disabling image types prevents normal resource loading \u2014 visible to performance API." },
|
|
3943
|
+
{ method: "Emulation.setScriptExecutionDisabled", name: "Emulation.setScriptExecutionDisabled", severity: "info", errorCode: -32083, suggestion: "Disabling JS mid-session kills page interactivity \u2014 immediately obvious." },
|
|
3944
|
+
{ method: "Emulation.setCPUThrottlingRate", name: "Emulation.setCPUThrottlingRate", severity: "info", errorCode: -32083, suggestion: "CPU throttling creates unrealistic performance.now() profiles." },
|
|
3945
|
+
{ method: "Emulation.setVirtualTimePolicy", name: "Emulation.setVirtualTimePolicy", severity: "info", errorCode: -32083, suggestion: "Virtual time breaks Date.now() and performance.now() based detections \u2014 detectable via timer drift." },
|
|
3946
|
+
// ── Network overrides ────────────────────────────────
|
|
3947
|
+
{ method: "Network.setUserAgentOverride", name: "Network.setUserAgentOverride (HTTP layer)", severity: "danger", errorCode: -32080, suggestion: "HTTP User-Agent vs navigator.userAgent inconsistency = immediate detection." },
|
|
3948
|
+
{ method: "Network.setExtraHTTPHeaders", name: "Network.setExtraHTTPHeaders", severity: "danger", errorCode: -32084, suggestion: "Custom headers can conflict with browser-generated headers. Missing client hints (Sec-CH-UA) are also detectable." },
|
|
3949
|
+
{ method: "Network.emulateNetworkConditions", name: "Network.emulateNetworkConditions", severity: "warn", errorCode: -32081, suggestion: "Network throttling creates unrealistic load timing patterns." },
|
|
3950
|
+
{ method: "Network.setCookie", name: "Network.setCookie", severity: "warn", errorCode: -32085, suggestion: "CDP-injected cookies are detectable via document.cookie vs Network.getCookies inconsistency." },
|
|
3951
|
+
{ method: "Network.deleteCookies", name: "Network.deleteCookies", severity: "warn", errorCode: -32085, suggestion: "Cookie deletion via CDP bypasses HTTP cookie expiration \u2014 detectable." },
|
|
3952
|
+
// ── Security overrides ──────────────────────────────
|
|
3953
|
+
{ method: "Security.setIgnoreCertificateErrors", name: "Security.setIgnoreCertificateErrors", severity: "info", errorCode: -32086, suggestion: "Ignoring certificate errors creates unusual TLS behavior visible at network level." },
|
|
3954
|
+
// ── Page overrides ──────────────────────────────────
|
|
3955
|
+
{ method: "Page.setDownloadBehavior", name: "Page.setDownloadBehavior", severity: "info", errorCode: -32087, suggestion: "Bypassing download dialogs \u2014 detectable via download event flow." },
|
|
3956
|
+
{ method: "Page.setWebLifecycleState", name: "Page.setWebLifecycleState", severity: "info", errorCode: -32087, suggestion: "Forcing page lifecycle transitions is unnatural." },
|
|
3957
|
+
// ── Storage / Permissions ───────────────────────────
|
|
3958
|
+
{ method: "Storage.clearDataForOrigin", name: "Storage.clearDataForOrigin", severity: "info", errorCode: -32089, suggestion: "Clearing storage mid-session is unnatural for real users." },
|
|
3959
|
+
{ method: "Browser.grantPermissions", name: "Browser.grantPermissions", severity: "warn", errorCode: -32090, suggestion: "Granting permissions via CDP is detectable as the permission flow skips the user prompt." },
|
|
3960
|
+
{ method: "Browser.resetPermissions", name: "Browser.resetPermissions", severity: "info", errorCode: -32089, suggestion: "Permission resets without user action are unnatural." }
|
|
3961
|
+
];
|
|
3962
|
+
var emulationOverrideRule = {
|
|
3963
|
+
id: "emulation-override",
|
|
3964
|
+
name: "CDP Emulation / Override Detection (20+ methods)",
|
|
3965
|
+
priority: 60,
|
|
3966
|
+
canHandle(ctx) {
|
|
3967
|
+
for (const p of OVERRIDE_PATTERNS) {
|
|
3968
|
+
if (ctx.method === p.method) return true;
|
|
3969
|
+
}
|
|
3970
|
+
return false;
|
|
3971
|
+
},
|
|
3972
|
+
evaluate(ctx) {
|
|
3973
|
+
for (const p of OVERRIDE_PATTERNS) {
|
|
3974
|
+
if (ctx.method !== p.method) continue;
|
|
3975
|
+
return {
|
|
3976
|
+
ruleId: "emulation-override",
|
|
3977
|
+
action: "pass",
|
|
3978
|
+
severity: p.severity,
|
|
3979
|
+
reason: `CDP emulation/override detected: "${p.name}". This creates detectable inconsistencies between the JS environment and real browser state.`,
|
|
3980
|
+
suggestion: p.suggestion,
|
|
3981
|
+
errorCode: p.errorCode,
|
|
3982
|
+
errorMessage: `[CDP Firewall] ${p.name} blocked \u2014 creates detectable browser state inconsistency`
|
|
3983
|
+
};
|
|
3984
|
+
}
|
|
3985
|
+
return null;
|
|
3986
|
+
}
|
|
3987
|
+
};
|
|
3988
|
+
|
|
3989
|
+
// src/cdp-interceptor/rules/network-anomaly.ts
|
|
3990
|
+
var networkAnomalyRule = {
|
|
3991
|
+
id: "network-anomaly",
|
|
3992
|
+
name: "Network Anomaly Detection (8+ patterns)",
|
|
3993
|
+
priority: 70,
|
|
3994
|
+
canHandle(ctx) {
|
|
3995
|
+
const m = ctx.method;
|
|
3996
|
+
return m === "Network.setExtraHTTPHeaders" || m === "Network.clearBrowserCache" || m === "Network.clearBrowserCookies" || m === "Network.setBlockedURLs" || m === "Network.setBypassServiceWorker" || m === "Fetch.enable" || m === "Network.enable";
|
|
3997
|
+
},
|
|
3998
|
+
evaluate(ctx) {
|
|
3999
|
+
switch (ctx.method) {
|
|
4000
|
+
case "Network.setExtraHTTPHeaders": {
|
|
4001
|
+
const headers = ctx.params.headers;
|
|
4002
|
+
if (headers) {
|
|
4003
|
+
const headerStr = JSON.stringify(headers).toLowerCase();
|
|
4004
|
+
if (!headerStr.includes("sec-ch-ua")) {
|
|
4005
|
+
return {
|
|
4006
|
+
ruleId: "network-anomaly",
|
|
4007
|
+
action: "pass",
|
|
4008
|
+
severity: "warn",
|
|
4009
|
+
reason: "Network.setExtraHTTPHeaders called without Sec-CH-UA client hints \u2014 browser normally sends these.",
|
|
4010
|
+
suggestion: "Modern browsers send Sec-CH-UA headers automatically. Adding custom headers without them creates detectable inconsistency.",
|
|
4011
|
+
errorCode: -32110,
|
|
4012
|
+
errorMessage: "[CDP Firewall] Missing client hints in custom headers"
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
return null;
|
|
4017
|
+
}
|
|
4018
|
+
case "Network.clearBrowserCache": {
|
|
4019
|
+
return {
|
|
4020
|
+
ruleId: "network-anomaly",
|
|
4021
|
+
action: "pass",
|
|
4022
|
+
severity: "warn",
|
|
4023
|
+
reason: "Network.clearBrowserCache called \u2014 cache clearing mid-session is unnatural for real users.",
|
|
4024
|
+
suggestion: "Avoid cache clearing during sessions. Start with a fresh profile if needed.",
|
|
4025
|
+
errorCode: -32111,
|
|
4026
|
+
errorMessage: "[CDP Firewall] Cache clearing detected"
|
|
4027
|
+
};
|
|
4028
|
+
}
|
|
4029
|
+
case "Network.clearBrowserCookies": {
|
|
4030
|
+
return {
|
|
4031
|
+
ruleId: "network-anomaly",
|
|
4032
|
+
action: "pass",
|
|
4033
|
+
severity: "warn",
|
|
4034
|
+
reason: "Network.clearBrowserCookies called \u2014 wiping cookies mid-session is a scraper optimization.",
|
|
4035
|
+
suggestion: "Cookies should only be cleared via normal browser flow (expiration, user action).",
|
|
4036
|
+
errorCode: -32112,
|
|
4037
|
+
errorMessage: "[CDP Firewall] Cookie clearing detected"
|
|
4038
|
+
};
|
|
4039
|
+
}
|
|
4040
|
+
case "Network.setBlockedURLs": {
|
|
4041
|
+
return {
|
|
4042
|
+
ruleId: "network-anomaly",
|
|
4043
|
+
action: "pass",
|
|
4044
|
+
severity: "warn",
|
|
4045
|
+
reason: "Network.setBlockedURLs blocks resource loading \u2014 this changes the page behavior and is detectable.",
|
|
4046
|
+
suggestion: "Blocking images/fonts/etc creates measurable differences in performance and page rendering.",
|
|
4047
|
+
errorCode: -32113,
|
|
4048
|
+
errorMessage: "[CDP Firewall] URL blocking detected"
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
case "Network.setBypassServiceWorker": {
|
|
4052
|
+
return {
|
|
4053
|
+
ruleId: "network-anomaly",
|
|
4054
|
+
action: "block",
|
|
4055
|
+
severity: "info",
|
|
4056
|
+
reason: "Network.setBypassServiceWorker called \u2014 bypassing service workers for content extraction.",
|
|
4057
|
+
suggestion: "Service worker bypass changes fetch behavior and is detectable server-side.",
|
|
4058
|
+
errorCode: -32114,
|
|
4059
|
+
errorMessage: "[CDP Firewall] Service worker bypass detected"
|
|
4060
|
+
};
|
|
4061
|
+
}
|
|
4062
|
+
case "Fetch.enable": {
|
|
4063
|
+
return {
|
|
4064
|
+
ruleId: "network-anomaly",
|
|
4065
|
+
action: "pass",
|
|
4066
|
+
severity: "warn",
|
|
4067
|
+
reason: "Fetch.enable intercepts all network requests \u2014 a man-in-the-middle approach used by scrapers.",
|
|
4068
|
+
suggestion: "Do not use Fetch domain for network interception if avoiding detection.",
|
|
4069
|
+
errorCode: -32115,
|
|
4070
|
+
errorMessage: "[CDP Firewall] Fetch interception detected"
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
default:
|
|
4074
|
+
return null;
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
};
|
|
4078
|
+
|
|
4079
|
+
// src/cdp-interceptor/rules/page-lifecycle.ts
|
|
4080
|
+
var STAT_KEY = "lifecycle-tracker";
|
|
4081
|
+
var pageLifecycleRule = {
|
|
4082
|
+
id: "page-lifecycle",
|
|
4083
|
+
name: "Page Lifecycle Anomaly Detection (10+ patterns)",
|
|
4084
|
+
priority: 80,
|
|
4085
|
+
canHandle(ctx) {
|
|
4086
|
+
const m = ctx.method;
|
|
4087
|
+
return m === "Page.navigate" || m === "Page.captureScreenshot" || m === "Page.printToPDF" || m === "Page.reload" || m === "Page.close" || m === "Runtime.evaluate" || m === "Runtime.callFunctionOn";
|
|
4088
|
+
},
|
|
4089
|
+
evaluate(ctx) {
|
|
4090
|
+
let state = ctx.sessionState.get(STAT_KEY);
|
|
4091
|
+
if (!state) {
|
|
4092
|
+
state = { navigations: [], screenshots: 0, printToPDF: false, evaluateCount: 0, lastNavTime: 0, lastEvalTime: 0 };
|
|
4093
|
+
ctx.sessionState.set(STAT_KEY, state);
|
|
4094
|
+
}
|
|
4095
|
+
const now = Date.now();
|
|
4096
|
+
switch (ctx.method) {
|
|
4097
|
+
case "Page.navigate": {
|
|
4098
|
+
state.navigations.push(now);
|
|
4099
|
+
if (state.navigations.length >= 2) {
|
|
4100
|
+
const prev = state.navigations[state.navigations.length - 2];
|
|
4101
|
+
const interval = now - prev;
|
|
4102
|
+
if (interval < 100) {
|
|
4103
|
+
return {
|
|
4104
|
+
ruleId: "page-lifecycle",
|
|
4105
|
+
action: "pass",
|
|
4106
|
+
severity: "warn",
|
|
4107
|
+
reason: `Multiple Page.navigate calls within ${interval}ms of each other \u2014 unnatural rapid navigation.`,
|
|
4108
|
+
suggestion: "Add proper waits between navigations: wait for page load before navigating again.",
|
|
4109
|
+
errorCode: -32100,
|
|
4110
|
+
errorMessage: "[CDP Firewall] Rapid navigation sequence detected"
|
|
4111
|
+
};
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
state.lastNavTime = now;
|
|
4115
|
+
return null;
|
|
4116
|
+
}
|
|
4117
|
+
case "Page.captureScreenshot": {
|
|
4118
|
+
state.screenshots++;
|
|
4119
|
+
if (state.lastNavTime > 0 && now - state.lastNavTime < 500) {
|
|
4120
|
+
return {
|
|
4121
|
+
ruleId: "page-lifecycle",
|
|
4122
|
+
action: "pass",
|
|
4123
|
+
severity: "danger",
|
|
4124
|
+
reason: "Page.captureScreenshot called within 500ms of navigation \u2014 content extraction pattern.",
|
|
4125
|
+
suggestion: "Wait for the page to fully render before taking screenshots: wait for load/networkidle.",
|
|
4126
|
+
errorCode: -32101,
|
|
4127
|
+
errorMessage: "[CDP Firewall] Pre-render screenshot blocked \u2014 content extraction pattern"
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
if (state.screenshots > 3 && state.navigations.length < 2) {
|
|
4131
|
+
return {
|
|
4132
|
+
ruleId: "page-lifecycle",
|
|
4133
|
+
action: "pass",
|
|
4134
|
+
severity: "warn",
|
|
4135
|
+
reason: "Multiple screenshots on a single page without navigation \u2014 suspicious extraction behavior.",
|
|
4136
|
+
suggestion: "Consider if all screenshots are necessary.",
|
|
4137
|
+
errorCode: -32102,
|
|
4138
|
+
errorMessage: "[CDP Firewall] Excessive screenshot detection"
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
return null;
|
|
4142
|
+
}
|
|
4143
|
+
case "Page.printToPDF": {
|
|
4144
|
+
return {
|
|
4145
|
+
ruleId: "page-lifecycle",
|
|
4146
|
+
action: "pass",
|
|
4147
|
+
severity: "danger",
|
|
4148
|
+
reason: "Page.printToPDF called \u2014 this is a telltale scraper pattern that gives away automation intent.",
|
|
4149
|
+
suggestion: "Avoid PDF generation. If you must, add significant delays and user-like interaction first.",
|
|
4150
|
+
errorCode: -32103,
|
|
4151
|
+
errorMessage: "[CDP Firewall] printToPDF blocked \u2014 scraper intent detected"
|
|
4152
|
+
};
|
|
4153
|
+
}
|
|
4154
|
+
case "Page.reload": {
|
|
4155
|
+
if (state.lastNavTime > 0 && now - state.lastNavTime < 1e3) {
|
|
4156
|
+
return {
|
|
4157
|
+
ruleId: "page-lifecycle",
|
|
4158
|
+
action: "pass",
|
|
4159
|
+
severity: "warn",
|
|
4160
|
+
reason: "Page.reload called immediately after navigate \u2014 unnatural fast-reload pattern.",
|
|
4161
|
+
suggestion: "Introduce delays between navigation and reload to simulate human behavior.",
|
|
4162
|
+
errorCode: -32104,
|
|
4163
|
+
errorMessage: "[CDP Firewall] Rapid reload detected"
|
|
4164
|
+
};
|
|
4165
|
+
}
|
|
4166
|
+
return null;
|
|
4167
|
+
}
|
|
4168
|
+
case "Page.close": {
|
|
4169
|
+
if (state.navigations.length < 2) {
|
|
4170
|
+
return {
|
|
4171
|
+
ruleId: "page-lifecycle",
|
|
4172
|
+
action: "pass",
|
|
4173
|
+
severity: "info",
|
|
4174
|
+
reason: "Page.close called after minimal interaction \u2014 zombie pages common in automation.",
|
|
4175
|
+
suggestion: "Ensure meaningful interaction before closing pages.",
|
|
4176
|
+
errorCode: -32105,
|
|
4177
|
+
errorMessage: "[CDP Firewall] Page close after minimal interaction"
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
return null;
|
|
4181
|
+
}
|
|
4182
|
+
case "Runtime.evaluate":
|
|
4183
|
+
case "Runtime.callFunctionOn": {
|
|
4184
|
+
state.evaluateCount++;
|
|
4185
|
+
if (state.evaluateCount > 50 && state.navigations.length === 0) {
|
|
4186
|
+
return {
|
|
4187
|
+
ruleId: "page-lifecycle",
|
|
4188
|
+
action: "pass",
|
|
4189
|
+
severity: "info",
|
|
4190
|
+
reason: "50+ evaluate calls without any Page.navigate \u2014 data extraction without real browsing.",
|
|
4191
|
+
suggestion: "Navigate to a real page first. Evaluate on about:blank is suspicious.",
|
|
4192
|
+
errorCode: -32106,
|
|
4193
|
+
errorMessage: "[CDP Firewall] Excessive evaluate without navigation"
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
return null;
|
|
4197
|
+
}
|
|
4198
|
+
default:
|
|
4199
|
+
return null;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
};
|
|
4203
|
+
|
|
4204
|
+
// src/cdp-interceptor/rules-engine.ts
|
|
4205
|
+
var BUILTIN_RULES = [
|
|
4206
|
+
domMutationRule,
|
|
4207
|
+
automationSignalsRule,
|
|
4208
|
+
fingerprintingRule,
|
|
4209
|
+
eventSimulationRule,
|
|
4210
|
+
mouseTrajectoryRule,
|
|
4211
|
+
inputKeystrokeRule,
|
|
4212
|
+
emulationOverrideRule,
|
|
4213
|
+
networkAnomalyRule,
|
|
4214
|
+
pageLifecycleRule
|
|
4215
|
+
];
|
|
4216
|
+
function createRuleEngine(customRules) {
|
|
4217
|
+
const rules = [...BUILTIN_RULES, ...customRules ?? []].sort((a, b) => a.priority - b.priority);
|
|
4218
|
+
const sessionStates = /* @__PURE__ */ new Map();
|
|
4219
|
+
function getSessionState(sessionId) {
|
|
4220
|
+
let state = sessionStates.get(sessionId);
|
|
4221
|
+
if (!state) {
|
|
4222
|
+
state = /* @__PURE__ */ new Map();
|
|
4223
|
+
sessionStates.set(sessionId, state);
|
|
4224
|
+
}
|
|
4225
|
+
return state;
|
|
4226
|
+
}
|
|
4227
|
+
return {
|
|
4228
|
+
start() {
|
|
4229
|
+
sessionStates.clear();
|
|
4230
|
+
},
|
|
4231
|
+
stop() {
|
|
4232
|
+
sessionStates.clear();
|
|
4233
|
+
},
|
|
4234
|
+
evaluate(ctx) {
|
|
4235
|
+
const fullCtx = {
|
|
4236
|
+
...ctx,
|
|
4237
|
+
sessionState: getSessionState(ctx.sessionId)
|
|
4238
|
+
};
|
|
4239
|
+
for (const rule of rules) {
|
|
4240
|
+
if (rule.canHandle && !rule.canHandle(fullCtx)) continue;
|
|
4241
|
+
const decision = rule.evaluate(fullCtx);
|
|
4242
|
+
if (!decision) continue;
|
|
4243
|
+
if (decision.action !== "pass") return decision;
|
|
4244
|
+
}
|
|
4245
|
+
return null;
|
|
4246
|
+
}
|
|
4247
|
+
};
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
// src/cdp-driver/connection.ts
|
|
4251
|
+
var CDPConnection = class _CDPConnection extends EventEmitter4 {
|
|
4252
|
+
ws;
|
|
4253
|
+
nextId = 1;
|
|
4254
|
+
/** CDP Guard:全部出站命令过规则引擎(env XBROWSER_CDP_GUARD=off 关闭)。
|
|
4255
|
+
* 173 条规则 / 9 模块:合成事件 block、指纹暴露警告、自动化信号拦截。 */
|
|
4256
|
+
static __guard;
|
|
4257
|
+
get guard() {
|
|
4258
|
+
if (_CDPConnection.__guard === void 0) {
|
|
4259
|
+
if (process.env.XBROWSER_CDP_GUARD === "off") {
|
|
4260
|
+
_CDPConnection.__guard = null;
|
|
4261
|
+
} else {
|
|
4262
|
+
try {
|
|
4263
|
+
const g = createRuleEngine();
|
|
4264
|
+
g.start();
|
|
4265
|
+
_CDPConnection.__guard = g;
|
|
4266
|
+
} catch {
|
|
4267
|
+
_CDPConnection.__guard = null;
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
return _CDPConnection.__guard;
|
|
4272
|
+
}
|
|
4273
|
+
pending = /* @__PURE__ */ new Map();
|
|
4274
|
+
closed = false;
|
|
4275
|
+
closeReason = null;
|
|
4276
|
+
/** Default session ID for flat session protocol (Target.attachToTarget) */
|
|
4277
|
+
defaultSessionId;
|
|
4278
|
+
constructor(wsOrUrl, sessionId) {
|
|
4279
|
+
super();
|
|
4280
|
+
this.setMaxListeners(0);
|
|
4281
|
+
this.defaultSessionId = sessionId;
|
|
4282
|
+
if (typeof wsOrUrl === "string") {
|
|
4283
|
+
const wsOptions = /^wss:\/\/\d+\.\d+\.\d+\.\d+/.test(wsOrUrl) ? { rejectUnauthorized: false } : void 0;
|
|
4284
|
+
this.ws = new WebSocket(wsOrUrl, wsOptions);
|
|
4285
|
+
} else {
|
|
4286
|
+
this.ws = wsOrUrl;
|
|
4287
|
+
}
|
|
4288
|
+
this.bindWebSocket();
|
|
4289
|
+
this.startKeepalive();
|
|
4290
|
+
}
|
|
4291
|
+
/** Send periodic WS pings to prevent idle-timeout disconnects (e.g. CF's 100s).
|
|
4292
|
+
* Also detects dead connections: if a pong isn't received within 10s of a
|
|
4293
|
+
* ping, the connection is considered dead and forcibly closed. */
|
|
4294
|
+
keepaliveTimer = null;
|
|
4295
|
+
pongTimer = null;
|
|
4296
|
+
/** 最近一次收到任何 WS 消息的时间——有数据流动即证明连接存活 */
|
|
4297
|
+
lastIncomingAt = Date.now();
|
|
4298
|
+
startKeepalive() {
|
|
4299
|
+
this.ws.on("pong", () => {
|
|
4300
|
+
if (this.pongTimer) {
|
|
4301
|
+
clearTimeout(this.pongTimer);
|
|
4302
|
+
this.pongTimer = null;
|
|
4303
|
+
}
|
|
4304
|
+
});
|
|
4305
|
+
this.keepaliveTimer = setInterval(() => {
|
|
4306
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
4307
|
+
if (!this.pongTimer) {
|
|
4308
|
+
this.pongTimer = setTimeout(() => {
|
|
4309
|
+
const recentlyActive = Date.now() - this.lastIncomingAt < 3e4;
|
|
4310
|
+
if (recentlyActive) {
|
|
4311
|
+
this.pongTimer = null;
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
if (!this.closed) {
|
|
4315
|
+
this.closed = true;
|
|
4316
|
+
this.closeReason = "keepalive timeout (no pong and idle >30s)";
|
|
4317
|
+
try {
|
|
4318
|
+
this.ws.terminate();
|
|
4319
|
+
} catch {
|
|
4320
|
+
}
|
|
4321
|
+
for (const [, pending] of this.pending) {
|
|
4322
|
+
clearTimeout(pending.timeout);
|
|
4323
|
+
pending.reject(new Error("Connection dead: keepalive timeout"));
|
|
4324
|
+
}
|
|
4325
|
+
this.pending.clear();
|
|
4326
|
+
this.emit("disconnect");
|
|
4327
|
+
}
|
|
4328
|
+
}, 6e4);
|
|
4329
|
+
}
|
|
4330
|
+
this.ws.ping?.();
|
|
4331
|
+
} else if (this.closed) {
|
|
4332
|
+
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
|
4333
|
+
this.keepaliveTimer = null;
|
|
4334
|
+
}
|
|
4335
|
+
}, 3e4);
|
|
4336
|
+
}
|
|
4337
|
+
/** Wait for the connection to be fully open */
|
|
4338
|
+
async ready() {
|
|
4339
|
+
if (this.ws.readyState === WebSocket.OPEN) return;
|
|
4340
|
+
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
|
4341
|
+
throw new Error(`WebSocket already closed: ${this.closeReason ?? "unknown"}`);
|
|
4342
|
+
}
|
|
4343
|
+
return new Promise((resolve, reject) => {
|
|
4344
|
+
const onOpen = () => {
|
|
4345
|
+
this.ws.off("error", onError);
|
|
4346
|
+
resolve();
|
|
4347
|
+
};
|
|
4348
|
+
const onError = (err) => {
|
|
4349
|
+
this.ws.off("open", onOpen);
|
|
4350
|
+
reject(err);
|
|
4351
|
+
};
|
|
4352
|
+
this.ws.once("open", onOpen);
|
|
4353
|
+
this.ws.once("error", onError);
|
|
4354
|
+
});
|
|
4355
|
+
}
|
|
4356
|
+
/** Is the underlying WebSocket alive? */
|
|
4357
|
+
get isOpen() {
|
|
4358
|
+
return !this.closed && this.ws.readyState === WebSocket.OPEN;
|
|
4359
|
+
}
|
|
4360
|
+
/**
|
|
4361
|
+
* Send a CDP command and await its response.
|
|
4362
|
+
*
|
|
4363
|
+
* @param method — CDP domain.method (e.g. "Page.navigate")
|
|
4364
|
+
* @param params — method parameters
|
|
4365
|
+
* @param sessionId — optional flat session ID for sub-targets
|
|
4366
|
+
* @param timeoutMs — response timeout (default: 30s;重 SPA 加载期间主线程忙,
|
|
4367
|
+
* evaluate 可排队 >30s——Runtime.evaluate 单独放宽到 90s)
|
|
4368
|
+
* @returns the `result` field from the CDP response
|
|
4369
|
+
*/
|
|
4370
|
+
async send(method, params, sessionId, timeoutMs) {
|
|
4371
|
+
const effectiveTimeout = timeoutMs ?? (method === "Runtime.evaluate" ? 9e4 : 3e4);
|
|
4372
|
+
if (this.guard) {
|
|
4373
|
+
const decision = this.guard.evaluate({
|
|
4374
|
+
method,
|
|
4375
|
+
params: params ?? {},
|
|
4376
|
+
sessionId: sessionId ?? this.defaultSessionId ?? "",
|
|
4377
|
+
direction: "client\u2192browser"
|
|
4378
|
+
});
|
|
4379
|
+
if (decision && decision.action === "block") {
|
|
4380
|
+
throw new Error(`[CDP-Guard] ${decision.reason}${decision.suggestion ? " | \u66FF\u4EE3: " + decision.suggestion : ""} [${decision.ruleId}]`);
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
if (this.closed) {
|
|
4384
|
+
throw new Error(`CDP connection closed: ${this.closeReason ?? "unknown"}`);
|
|
4385
|
+
}
|
|
4386
|
+
if (!this.isOpen) {
|
|
4387
|
+
throw new Error(`CDP connection not open (state: ${this.ws.readyState})`);
|
|
4388
|
+
}
|
|
4389
|
+
const id = this.nextId++;
|
|
4390
|
+
const sid = sessionId ?? this.defaultSessionId;
|
|
4391
|
+
const message = { id, method };
|
|
4392
|
+
if (params !== void 0) message.params = params;
|
|
4393
|
+
if (sid !== void 0) message.sessionId = sid;
|
|
4394
|
+
return new Promise((resolve, reject) => {
|
|
4395
|
+
const timeout = setTimeout(() => {
|
|
4396
|
+
this.pending.delete(id);
|
|
4397
|
+
reject(new Error(`CDP timeout: ${method} (${effectiveTimeout}ms)`));
|
|
4398
|
+
}, effectiveTimeout);
|
|
4399
|
+
this.pending.set(id, {
|
|
4400
|
+
resolve: (v) => {
|
|
4401
|
+
clearTimeout(timeout);
|
|
4402
|
+
this.pending.delete(id);
|
|
4403
|
+
resolve(v);
|
|
4404
|
+
},
|
|
4405
|
+
reject: (err) => {
|
|
4406
|
+
clearTimeout(timeout);
|
|
4407
|
+
this.pending.delete(id);
|
|
4408
|
+
reject(err);
|
|
4409
|
+
},
|
|
4410
|
+
method,
|
|
4411
|
+
timeout
|
|
4412
|
+
});
|
|
4413
|
+
const data = JSON.stringify(message);
|
|
4414
|
+
try {
|
|
4415
|
+
this.ws.send(data);
|
|
4416
|
+
} catch (err) {
|
|
4417
|
+
clearTimeout(timeout);
|
|
4418
|
+
this.pending.delete(id);
|
|
4419
|
+
reject(new Error(`CDP send failed: ${method} \u2014 ${err instanceof Error ? err.message : String(err)}`));
|
|
4420
|
+
}
|
|
4421
|
+
});
|
|
4422
|
+
}
|
|
4423
|
+
/**
|
|
4424
|
+
* Subscribe to a CDP event.
|
|
4425
|
+
*
|
|
4426
|
+
* @param event — full event name (e.g. "Page.frameNavigated")
|
|
4427
|
+
* @param handler — called with the event params
|
|
4428
|
+
* @param sessionId — optional session filter
|
|
4429
|
+
*/
|
|
4430
|
+
on(event, handler) {
|
|
4431
|
+
return super.on(event, handler);
|
|
4432
|
+
}
|
|
4433
|
+
once(event, handler) {
|
|
4434
|
+
return super.once(event, handler);
|
|
4435
|
+
}
|
|
4436
|
+
/** Remove an event listener */
|
|
4437
|
+
off(event, handler) {
|
|
4438
|
+
super.off(event, handler);
|
|
4439
|
+
return this;
|
|
4440
|
+
}
|
|
4441
|
+
/**
|
|
4442
|
+
* Subscribe to a CDP event for a specific session.
|
|
4443
|
+
* Returns an unsubscribe function.
|
|
4444
|
+
*/
|
|
4445
|
+
subscribe(event, sessionId, handler) {
|
|
4446
|
+
const wrapper = (params, sid) => {
|
|
4447
|
+
if (sid === sessionId || !sessionId && !sid) handler(params);
|
|
4448
|
+
};
|
|
4449
|
+
this.on(event, wrapper);
|
|
4450
|
+
return () => this.off(event, wrapper);
|
|
4451
|
+
}
|
|
4452
|
+
/** Close the WebSocket */
|
|
4453
|
+
async close() {
|
|
4454
|
+
if (this.closed) return;
|
|
4455
|
+
this.closed = true;
|
|
4456
|
+
this.closeReason = "closed by caller";
|
|
4457
|
+
if (this.keepaliveTimer) {
|
|
4458
|
+
clearInterval(this.keepaliveTimer);
|
|
4459
|
+
this.keepaliveTimer = null;
|
|
4460
|
+
}
|
|
4461
|
+
if (this.pongTimer) {
|
|
4462
|
+
clearTimeout(this.pongTimer);
|
|
4463
|
+
this.pongTimer = null;
|
|
4464
|
+
}
|
|
4465
|
+
for (const [id, pending] of this.pending) {
|
|
4466
|
+
clearTimeout(pending.timeout);
|
|
4467
|
+
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
4468
|
+
this.pending.delete(id);
|
|
4469
|
+
}
|
|
4470
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
4471
|
+
this.ws.close(1e3, "normal closure");
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
/** Set the default session ID for flat protocol */
|
|
4475
|
+
setDefaultSessionId(sid) {
|
|
4476
|
+
this.defaultSessionId = sid;
|
|
4477
|
+
}
|
|
4478
|
+
// ── Private ─────────────────────────────────────────────────
|
|
4479
|
+
bindWebSocket() {
|
|
4480
|
+
this.ws.on("message", (raw) => {
|
|
4481
|
+
this.lastIncomingAt = Date.now();
|
|
4482
|
+
let msg;
|
|
4483
|
+
try {
|
|
4484
|
+
msg = JSON.parse(raw.toString());
|
|
4485
|
+
} catch {
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
4488
|
+
if (msg.id !== void 0) {
|
|
4489
|
+
const pending = this.pending.get(msg.id);
|
|
4490
|
+
if (!pending) return;
|
|
4491
|
+
if (msg.error) {
|
|
4492
|
+
pending.reject(new CDPProtocolError(msg.error.code, msg.error.message, pending.method));
|
|
4493
|
+
} else {
|
|
4494
|
+
pending.resolve(msg.result ?? {});
|
|
4495
|
+
}
|
|
4496
|
+
return;
|
|
4497
|
+
}
|
|
4498
|
+
if (msg.method) {
|
|
4499
|
+
this.emit(msg.method, msg.params ?? {}, msg.sessionId);
|
|
4500
|
+
this.emit("*", msg.method, msg.params ?? {}, msg.sessionId);
|
|
4501
|
+
}
|
|
4502
|
+
});
|
|
4503
|
+
this.ws.on("close", (code, reason) => {
|
|
4504
|
+
if (this.closed) return;
|
|
4505
|
+
this.closed = true;
|
|
4506
|
+
this.closeReason = `WebSocket closed: ${code} ${reason?.toString() ?? ""}`.trim();
|
|
4507
|
+
if (this.keepaliveTimer) {
|
|
4508
|
+
clearInterval(this.keepaliveTimer);
|
|
4509
|
+
this.keepaliveTimer = null;
|
|
4510
|
+
}
|
|
4511
|
+
for (const [id, pending] of this.pending) {
|
|
4512
|
+
clearTimeout(pending.timeout);
|
|
4513
|
+
pending.reject(new Error(`Connection closed: ${pending.method}`));
|
|
4514
|
+
this.pending.delete(id);
|
|
4515
|
+
}
|
|
4516
|
+
this.emit("disconnect");
|
|
4517
|
+
});
|
|
4518
|
+
this.ws.on("error", (err) => {
|
|
4519
|
+
if (this.closed) return;
|
|
4520
|
+
this.emit("ws-error", err);
|
|
4521
|
+
});
|
|
4522
|
+
}
|
|
4523
|
+
};
|
|
4524
|
+
var CDPProtocolError = class extends Error {
|
|
4525
|
+
code;
|
|
4526
|
+
method;
|
|
4527
|
+
data;
|
|
4528
|
+
constructor(code, message, method, data) {
|
|
4529
|
+
super(`CDP error [${code}] in ${method}: ${message}`);
|
|
4530
|
+
this.name = "CDPProtocolError";
|
|
4531
|
+
this.code = code;
|
|
4532
|
+
this.method = method;
|
|
4533
|
+
this.data = data;
|
|
4534
|
+
}
|
|
4535
|
+
};
|
|
4536
|
+
|
|
4537
|
+
// src/cdp-driver/wait.ts
|
|
4538
|
+
async function waitForNetworkIdle(conn, sessionId, opts = {}) {
|
|
4539
|
+
const idleTime = opts.idleTime ?? 500;
|
|
4540
|
+
const timeout = opts.timeout ?? 3e4;
|
|
4541
|
+
const maxInflight = opts.maxInflight ?? 0;
|
|
4542
|
+
return new Promise((resolve, reject) => {
|
|
4543
|
+
let inflight = 0;
|
|
4544
|
+
let idleTimer = null;
|
|
4545
|
+
let timeoutTimer = null;
|
|
4546
|
+
let unsub1 = null;
|
|
4547
|
+
let unsub2 = null;
|
|
4548
|
+
let unsub3 = null;
|
|
4549
|
+
const cleanup = () => {
|
|
4550
|
+
if (unsub1) unsub1();
|
|
4551
|
+
if (unsub2) unsub2();
|
|
4552
|
+
if (unsub3) unsub3();
|
|
4553
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
4554
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
4555
|
+
};
|
|
4556
|
+
const checkIdle = () => {
|
|
4557
|
+
if (inflight <= maxInflight) {
|
|
4558
|
+
if (!idleTimer) {
|
|
4559
|
+
idleTimer = setTimeout(() => {
|
|
4560
|
+
cleanup();
|
|
4561
|
+
resolve();
|
|
4562
|
+
}, idleTime);
|
|
4563
|
+
}
|
|
4564
|
+
} else {
|
|
4565
|
+
if (idleTimer) {
|
|
4566
|
+
clearTimeout(idleTimer);
|
|
4567
|
+
idleTimer = null;
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
};
|
|
4571
|
+
const onRequest = () => {
|
|
4572
|
+
inflight++;
|
|
4573
|
+
if (idleTimer) {
|
|
4574
|
+
clearTimeout(idleTimer);
|
|
4575
|
+
idleTimer = null;
|
|
4576
|
+
}
|
|
4577
|
+
};
|
|
4578
|
+
const onFinish = () => {
|
|
4579
|
+
inflight = Math.max(0, inflight - 1);
|
|
4580
|
+
checkIdle();
|
|
4581
|
+
};
|
|
4582
|
+
const onFail = () => {
|
|
4583
|
+
inflight = Math.max(0, inflight - 1);
|
|
4584
|
+
checkIdle();
|
|
4585
|
+
};
|
|
4586
|
+
timeoutTimer = setTimeout(() => {
|
|
4587
|
+
cleanup();
|
|
4588
|
+
reject(new Error(`waitForNetworkIdle timeout after ${timeout}ms`));
|
|
4589
|
+
}, timeout);
|
|
4590
|
+
unsub1 = conn.subscribe("Network.requestWillBeSent", sessionId, onRequest);
|
|
4591
|
+
unsub2 = conn.subscribe("Network.loadingFinished", sessionId, onFinish);
|
|
4592
|
+
unsub3 = conn.subscribe("Network.loadingFailed", sessionId, onFail);
|
|
4593
|
+
checkIdle();
|
|
4594
|
+
});
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4597
|
+
// src/cdp-driver/index.ts
|
|
4598
|
+
async function launch(options = {}) {
|
|
4599
|
+
let wsEndpoint;
|
|
4600
|
+
let childProcess;
|
|
4601
|
+
let tmpDir;
|
|
4602
|
+
if (options.cdpEndpoint) {
|
|
4603
|
+
wsEndpoint = await connectToCDP(options.cdpEndpoint);
|
|
4604
|
+
} else {
|
|
4605
|
+
const result = await launchChrome({
|
|
4606
|
+
executablePath: options.executablePath,
|
|
4607
|
+
headless: options.headless,
|
|
4608
|
+
args: options.args,
|
|
4609
|
+
userDataDir: options.userDataDir,
|
|
4610
|
+
timeout: options.timeout,
|
|
4611
|
+
env: options.env
|
|
4612
|
+
});
|
|
4613
|
+
wsEndpoint = result.wsEndpoint;
|
|
4614
|
+
childProcess = result.process;
|
|
4615
|
+
tmpDir = result.tmpDir;
|
|
4616
|
+
}
|
|
4617
|
+
const conn = new CDPConnection(wsEndpoint);
|
|
4618
|
+
await conn.ready();
|
|
4619
|
+
const httpEndpoint = options.cdpEndpoint && !options.cdpEndpoint.startsWith("ws") ? options.cdpEndpoint : void 0;
|
|
4620
|
+
const browser = new XBBrowserImpl(conn, childProcess, tmpDir, httpEndpoint);
|
|
4621
|
+
return { browser, wsEndpoint };
|
|
4622
|
+
}
|
|
4623
|
+
export {
|
|
4624
|
+
CDPConnection,
|
|
4625
|
+
CDPProtocolError,
|
|
4626
|
+
XBBrowserImpl,
|
|
4627
|
+
XBCDPSessionImpl,
|
|
4628
|
+
XBContextImpl,
|
|
4629
|
+
XBElementHandleImpl,
|
|
4630
|
+
XBKeyboardImpl,
|
|
4631
|
+
XBLocatorImpl,
|
|
4632
|
+
XBMouseImpl,
|
|
4633
|
+
XBPageImpl,
|
|
4634
|
+
checkActionable,
|
|
4635
|
+
connectToCDP,
|
|
4636
|
+
findChrome,
|
|
4637
|
+
getCDPTargets,
|
|
4638
|
+
killChrome,
|
|
4639
|
+
launch,
|
|
4640
|
+
launchChrome,
|
|
4641
|
+
scrollIntoView,
|
|
4642
|
+
waitForActionable,
|
|
4643
|
+
waitForNetworkIdle
|
|
4644
|
+
};
|