@xbrowser/cli 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/{browser-XNU7JQYC.js → browser-AQ2AT2YZ.js} +2 -2
  2. package/dist/{browser-Q5APBNF6.js → browser-OMGJMSBB.js} +1 -1
  3. package/dist/{browser-V3JIWSTR.js → browser-T7OVLAEC.js} +4 -3
  4. package/dist/{cdp-driver-XFTTZI5O.js → cdp-driver-ALDNOZYD.js} +1176 -13
  5. package/dist/{cdp-driver-GD6YBBGE.js → cdp-driver-HYCLE4YP.js} +2 -1
  6. package/dist/{cdp-driver-VEK6BNN6.js → cdp-driver-VS7Z7W7Y.js} +1 -1
  7. package/dist/chunk-A6LPGFAL.js +437 -0
  8. package/dist/{chunk-JEDP4PJW.js → chunk-BY5TT6WZ.js} +912 -688
  9. package/dist/{chunk-XKQVUFUS.js → chunk-CW7L53JE.js} +2 -2
  10. package/dist/{chunk-ABXMBNQ6.js → chunk-H2A5JUK5.js} +37 -466
  11. package/dist/{chunk-BNO7OKO4.js → chunk-HBMEFSTB.js} +39 -0
  12. package/dist/chunk-HD4FX2N5.js +1255 -0
  13. package/dist/{chunk-DWDXEGVK.js → chunk-NKW4A74J.js} +9 -3
  14. package/dist/{chunk-SQHSZENE.js → chunk-OMU63E6J.js} +3 -22
  15. package/dist/{chunk-7OFM755Z.js → chunk-R2POF2GP.js} +236 -13
  16. package/dist/{chunk-WHPBNUKB.js → chunk-SH6OTPXN.js} +42 -46
  17. package/dist/{chunk-XQ4HRPDJ.js → chunk-T2CNKWPV.js} +1177 -13
  18. package/dist/{chunk-MWFHZUIY.js → chunk-TEXCXIBW.js} +1 -1
  19. package/dist/{chunk-IIM5GOD7.js → chunk-ZTHE5RBZ.js} +7 -1
  20. package/dist/cli.js +1075 -569
  21. package/dist/{daemon-client-MMDRYCF5.js → daemon-client-7D6EZNOE.js} +42 -46
  22. package/dist/{daemon-client-Y2YSZCGK.js → daemon-client-HEGAXWGK.js} +1 -1
  23. package/dist/daemon-main.js +950 -499
  24. package/dist/{human-interaction-5AO42MBA.js → human-interaction-4YR2N6R6.js} +2 -2
  25. package/dist/{human-interaction-C5OEGXGO.js → human-interaction-Y5IDH6LD.js} +1 -1
  26. package/dist/{human-interaction-ISXZTAYY.js → human-interaction-ZUTR5AC2.js} +2 -2
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1086 -578
  29. package/dist/{proxy-C6CK3UH5.js → proxy-LUR4U5YF.js} +2 -1
  30. package/dist/{recovery-J2ISVGUL.js → recovery-FTZGV6VY.js} +2 -2
  31. package/dist/{recovery-ZJVVHP7N.js → recovery-L5GLDX4O.js} +1 -1
  32. package/dist/{recovery-EF33LKRJ.js → recovery-Z3RDZENA.js} +2 -2
  33. package/dist/{session-replayer-WIUYVN5J.js → session-replayer-UVH5FGY2.js} +1 -1
  34. package/package.json +1 -1
  35. package/dist/chunk-HVPUVVSA.js +0 -2194
@@ -130,8 +130,8 @@ function buildStealthInitScript() {
130
130
  " if(!e||e.constructor===FocusEvent||e.constructor===KeyboardEvent)return f.call(this,e);",
131
131
  " return f.call(this,new Proxy(e,{get:function(k,p){",
132
132
  ' if(p==="sourceCapabilities")return fc;',
133
- ' if(p==="isTrusted")return true;',
134
- ' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isFinite(k[p])){',
133
+ ' if(p==="isTrusted")return k.isTrusted;',
134
+ ' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
135
135
  " var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;",
136
136
  " return k[p]+_f;",
137
137
  " }",
@@ -149,7 +149,160 @@ function buildStealthInitScript() {
149
149
  ' Object.defineProperty(Screen.prototype,"height",{get:_gh,configurable:true});',
150
150
  ' Object.defineProperty(Screen.prototype,"availWidth",{get:_gw,configurable:true});',
151
151
  ' Object.defineProperty(Screen.prototype,"availHeight",{get:_gah,configurable:true});',
152
- " document.hasFocus=function(){return true};",
152
+ // hasFocus 与 visibilityState 联动(d29):恒 true 在 hidden 标签下暴露
153
+ // —— 真实浏览器失焦/后台时 hasFocus=false
154
+ ' document.hasFocus=function(){return document.visibilityState==="visible";};',
155
+ // 4. Canvas/WebGL fingerprint: per-session stable noise (d20).
156
+ // Headless software raster differs subtly from Chrome GPU raster —
157
+ // toDataURL hashes fingerprint the rasterizer. Inject a stable
158
+ // (per-page-load) subpixel shift into fillText so hashes look like a
159
+ // distinct-but-consistent real GPU, and hide the HEADLESS tell in
160
+ // WebGL renderer strings.
161
+ " var _seed=Math.floor(Math.random()*2147483647);",
162
+ " var _prng=function(){_seed=(_seed*48271)%2147483647;return _seed/2147483647;};",
163
+ " var _dx=(_prng()*0.4-0.2).toFixed(3)*1, _dy=(_prng()*0.4-0.2).toFixed(3)*1;",
164
+ " var _fillText=CanvasRenderingContext2D.prototype.fillText;",
165
+ " CanvasRenderingContext2D.prototype.fillText=function(t,x,y,m){",
166
+ " return _fillText.call(this,t,x+_dx,y+_dy,m);",
167
+ " };",
168
+ " var _toDataURL=HTMLCanvasElement.prototype.toDataURL;",
169
+ " HTMLCanvasElement.prototype.toDataURL=function(){",
170
+ ' var ctx=this.getContext("2d");',
171
+ " if(ctx){var d=ctx.getImageData(0,0,Math.min(this.width,2),Math.min(this.height,2));",
172
+ " for(var i=0;i<d.data.length;i+=4){if(d.data[i+3]>0){d.data[i]^=1;break;}}",
173
+ " ctx.putImageData(d,0,0);}",
174
+ " return _toDataURL.apply(this,arguments);",
175
+ " };",
176
+ " try{",
177
+ " var _gl=HTMLCanvasElement.prototype.getContext;",
178
+ " HTMLCanvasElement.prototype.getContext=function(t,o){",
179
+ " var c=_gl.call(this,t,o);",
180
+ ' if(c&&(t==="webgl"||t==="experimental-webgl")&&c.getParameter){',
181
+ " var _gp=c.getParameter.bind(c);",
182
+ " c.getParameter=function(p){",
183
+ " var v=_gp(p);",
184
+ ' if(typeof v==="string"&&/SwiftShader|Software|Rasterizer|Headless/i.test(v))',
185
+ ' return "ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version)";',
186
+ " return v;};}",
187
+ " return c;};",
188
+ " }catch(e){}",
189
+ // 5. AudioContext fingerprint: per-load stable micro-noise on channel
190
+ // data (d21). DSP sum differences between headless software audio and
191
+ // real hardware audio are a classic fingerprint — add ±1e-7 level
192
+ // noise (inaudible, changes the sum hash).
193
+ " try{",
194
+ " var _gcd=AudioBuffer.prototype.getChannelData;",
195
+ " AudioBuffer.prototype.getChannelData=function(ch){",
196
+ " var d=_gcd.call(this,ch);",
197
+ ' var key="__xb_audio_"+ch;',
198
+ " if(!this[key]){",
199
+ " this[key]=true;",
200
+ " for(var i=0;i<d.length;i+=997){d[i]=d[i]+(_prng()-0.5)*2e-7;}",
201
+ " }",
202
+ " return d;",
203
+ " };",
204
+ " var _gffd=AnalyserNode.prototype.getFloatFrequencyData;",
205
+ " AnalyserNode.prototype.getFloatFrequencyData=function(arr){",
206
+ " _gffd.call(this,arr);",
207
+ " for(var i=0;i<arr.length;i+=31){arr[i]=arr[i]+(_prng()-0.5)*0.01;}",
208
+ " };",
209
+ " var _gfbd=AnalyserNode.prototype.getByteFrequencyData;",
210
+ " AnalyserNode.prototype.getByteFrequencyData=function(arr){",
211
+ " _gfbd.call(this,arr);",
212
+ " for(var i=0;i<arr.length;i+=31){arr[i]=(arr[i]+((_prng()*3)|0))&255;}",
213
+ " };",
214
+ " }catch(e){}",
215
+ // 3b. Font metrics + speechSynthesis + battery (d22): headless tells
216
+ " try{",
217
+ " var _mt=CanvasRenderingContext2D.prototype.measureText;",
218
+ ' var _tmw=Object.getOwnPropertyDescriptor(TextMetrics.prototype,"width");',
219
+ " CanvasRenderingContext2D.prototype.measureText=function(t){",
220
+ " var m=_mt.call(this,t);",
221
+ ' try{Object.defineProperty(m,"width",{get:function(){return _tmw.get.call(m)+_dx*0.01;},configurable:true});}catch(e){}',
222
+ " return m;",
223
+ " };",
224
+ " }catch(e){}",
225
+ " try{",
226
+ ' var _fakeVoices=["Alex","Daniel","Karen","Moira","Ralph","Samantha","Ting-Ting","Mei-Jia","Sinji","Yunda"];',
227
+ " var _sv=speechSynthesis.getVoices.bind(speechSynthesis);",
228
+ " speechSynthesis.getVoices=function(){",
229
+ " var real=_sv();",
230
+ " if(real&&real.length>50)return real;",
231
+ // 注意阈值从 0 改 50:iframe 原生约 10 个 —— 原阈值下 iframe 返回原生 10 个
232
+ // 而不是伪装 180(d33 暴露)。>50 = 主文档伪装列表已生效,其余返回伪装。
233
+ ' 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};});',
234
+ " };",
235
+ // d33 修复:iframe 的 speechSynthesis 是独立实例(主文档 hook 不可达)——
236
+ // 轮询同源 iframe,对其 contentWindow.speechSynthesis 同样 patch
237
+ " try{",
238
+ " var _piv=function(){",
239
+ ' var frs=document.querySelectorAll("iframe");',
240
+ " for(var i=0;i<frs.length;i++){try{",
241
+ " var cw=frs[i].contentWindow;",
242
+ " if(!cw||!cw.speechSynthesis||cw.speechSynthesis.__xbP)continue;",
243
+ " cw.speechSynthesis.__xbP=true;",
244
+ " var _sv2=cw.speechSynthesis.getVoices.bind(cw.speechSynthesis);",
245
+ " cw.speechSynthesis.getVoices=function(){var r=_sv2();if(r&&r.length>50)return r;",
246
+ ' return _fakeVoices.map(function(n,j){return{name:n,lang:j<2?"en-US":j<6?"en-GB":"zh-TW",localService:true,default:j===0,voiceURI:n};});};',
247
+ " }catch(e2){}}",
248
+ " };",
249
+ " setInterval(_piv,2000);",
250
+ " var _mo=new MutationObserver(function(muts){",
251
+ " for(var k=0;k<muts.length;k++){var ns=muts[k].addedNodes;",
252
+ ' for(var q=0;q<ns.length;q++){if(ns[q].tagName==="IFRAME")setTimeout(_piv,20);}}',
253
+ " });",
254
+ " _mo.observe(document.documentElement,{childList:true,subtree:true});",
255
+ " }catch(e){}",
256
+ " }catch(e){}",
257
+ " try{",
258
+ " var _gb=navigator.getBattery.bind(navigator);",
259
+ " navigator.getBattery=function(){",
260
+ " return _gb().then(function(b){",
261
+ ' Object.defineProperty(b,"charging",{get:function(){return true;},configurable:true});',
262
+ " return b;});",
263
+ " };",
264
+ " }catch(e){}",
265
+ // 3c. WebRTC local IP leak guard (d23): headless STUN candidates can
266
+ // expose host IPs; mDNS-only candidates are the modern Chrome default.
267
+ " try{",
268
+ " var _oc=RTCPeerConnection.prototype.createOffer;",
269
+ " RTCPeerConnection.prototype.createOffer=function(){",
270
+ " var p=_oc.apply(this,arguments);",
271
+ " var self=this;",
272
+ " return p.then(function(offer){",
273
+ ' offer.sdp=offer.sdp.split(String.fromCharCode(10)).filter(function(l){return l.indexOf("typ host")<0}).join(String.fromCharCode(10));',
274
+ " return offer;});",
275
+ " };",
276
+ " }catch(e){}",
277
+ // 3d-2. performance.now precision clamp (d34): full-precision timestamps
278
+ // (11 decimals) differ from site-isolation-clamped real Chrome (~100μs).
279
+ " try{",
280
+ " var _pn=performance.now.bind(performance);",
281
+ " performance.now=function(){return Math.round(_pn()*10)/10;};",
282
+ ' var _pto=Object.getOwnPropertyDescriptor(Performance.prototype,"timeOrigin");',
283
+ ' if(_pto&&_pto.get){Object.defineProperty(performance,"timeOrigin",{get:function(){return _pto.get.call(performance);},configurable:true});}',
284
+ " }catch(e){}",
285
+ // 3d. Chrome object depth (d24): automation fakes usually only set
286
+ // window.chrome = {}; deep checks hit app.run/runtime/csi/loadTimes.
287
+ " try{",
288
+ " if(window.chrome){",
289
+ ' 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"}};',
290
+ ' 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};',
291
+ " if(!window.chrome.csi)window.chrome.csi=function(){return{};}",
292
+ " if(!window.chrome.loadTimes)window.chrome.loadTimes=function(){return{};}",
293
+ " }",
294
+ " }catch(e){}",
295
+ // 3e. Font availability patch (d25): headless Chromium misses some system
296
+ // fonts (e.g. Menlo on macOS) that real Chrome has. Register a FontFace
297
+ // aliasing the missing font to a local() equivalent so offsetWidth-based
298
+ // font probing sees the same availability as the faked environment.
299
+ " try{",
300
+ ' var _fontAliases=[["Menlo","Courier New"],["SF Mono","Menlo"],["Segoe UI","Helvetica"]];',
301
+ " _fontAliases.forEach(function(pa){",
302
+ ' try{var ff=new FontFace(pa[0],"local("+JSON.stringify(pa[1])+")");',
303
+ " document.fonts.add(ff);ff.load();}catch(e2){}",
304
+ " });",
305
+ " }catch(e){}",
153
306
  // 3. toString disguise (name-list based)
154
307
  " var _ts=Function.prototype.toString;",
155
308
  " var _hf=document.hasFocus;",
@@ -159,12 +312,16 @@ function buildStealthInitScript() {
159
312
  ' if(this===_gw)return"function get width() { [native code] }";',
160
313
  ' if(this===_gh)return"function get height() { [native code] }";',
161
314
  ' if(this===_gah)return"function get availHeight() { [native code] }";',
315
+ ' if(this===CanvasRenderingContext2D.prototype.fillText)return"function fillText() { [native code] }";',
316
+ ' if(this===HTMLCanvasElement.prototype.toDataURL)return"function toDataURL() { [native code] }";',
317
+ ' if(this===AnalyserNode.prototype.getFloatFrequencyData)return"function getFloatFrequencyData() { [native code] }";',
318
+ ' if(this===AudioBuffer.prototype.getChannelData)return"function getChannelData() { [native code] }";',
162
319
  " return _ts.call(this);",
163
320
  " };",
164
321
  // 4. onclick prototype hijack (dual-stream consistency)
165
322
  " var _ba=function(k,p){",
166
- ' if(p==="isTrusted")return true;',
167
- ' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isFinite(k[p])){',
323
+ ' if(p==="isTrusted")return k.isTrusted;',
324
+ ' if((p==="clientX"||p==="clientY")&&k.type==="click"&&Number.isInteger(k[p])&&k.isTrusted===true){',
168
325
  " var _f=((k.timeStamp||Date.now())%89)/89*0.7+0.15;return k[p]+_f;",
169
326
  " }",
170
327
  ' var v=Reflect.get(k,p);return typeof v==="function"?v.bind(k):v;',
@@ -200,7 +357,7 @@ var XBMouseImpl = class {
200
357
  }
201
358
  async click(x, y, opts = {}) {
202
359
  const button = opts.button ?? "left";
203
- const stealth = opts.stealth ?? true;
360
+ const stealth = opts.stealth ?? process.env.XBROWSER_STEALTH !== "off";
204
361
  let tx = x, ty = y;
205
362
  if (stealth && opts.elementWidth !== void 0 && opts.elementHeight !== void 0) {
206
363
  const off = landingOffset(opts.elementWidth, opts.elementHeight);
@@ -209,12 +366,23 @@ var XBMouseImpl = class {
209
366
  }
210
367
  if (stealth) {
211
368
  const traj = bezierTrajectory(this._x, this._y, tx, ty);
369
+ const _tb = Date.now();
370
+ let _truncated = false;
212
371
  for (const p of traj) {
372
+ if (Date.now() - _tb > 5e3) {
373
+ _truncated = true;
374
+ break;
375
+ }
213
376
  await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y, button: this._button });
214
377
  this._x = p.x;
215
378
  this._y = p.y;
216
379
  await sleep(p.delay);
217
380
  }
381
+ this._x = tx;
382
+ this._y = ty;
383
+ if (_truncated) {
384
+ await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: tx, y: ty, button: this._button });
385
+ }
218
386
  await sleep(rand(...DEFAULT_STEALTH_CONFIG.aimPause));
219
387
  } else {
220
388
  await this.move(tx, ty);
@@ -319,12 +487,20 @@ var XBKeyboardImpl = class {
319
487
  }
320
488
  await this.dispatchKeyEvent(downParams);
321
489
  if (mapping.text) {
490
+ if (process.env.XBROWSER_STEALTH !== "off") {
491
+ await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
492
+ }
322
493
  await this.dispatchKeyEvent({
323
494
  type: "char",
324
495
  text: mapping.text
325
496
  });
326
497
  }
327
498
  if (delay > 0) await sleep2(delay);
499
+ else {
500
+ if (process.env.XBROWSER_STEALTH !== "off") {
501
+ await sleep2(rand(...DEFAULT_STEALTH_CONFIG.keyPressDuration));
502
+ }
503
+ }
328
504
  const upParams = {
329
505
  type: "keyUp",
330
506
  key: mapping.key,
@@ -2034,6 +2210,10 @@ Last error: ${lastError.message}` : "";
2034
2210
  this.sessionId
2035
2211
  );
2036
2212
  }
2213
+ /** 覆盖 User-Agent(headless 环境对抗 UA 检测的标配能力) */
2214
+ async setUserAgent(userAgent) {
2215
+ await this._setUserAgent(userAgent);
2216
+ }
2037
2217
  /** Internal: set extra HTTP headers */
2038
2218
  async _setExtraHTTPHeaders(headers) {
2039
2219
  await this.setExtraHTTPHeaders(headers);
@@ -3163,534 +3343,259 @@ var XBBrowserImpl = class {
3163
3343
  // src/cdp-driver/connection.ts
3164
3344
  import { EventEmitter as EventEmitter4 } from "events";
3165
3345
  import { WebSocket } from "ws";
3166
- var CDPConnection = class extends EventEmitter4 {
3167
- ws;
3168
- nextId = 1;
3169
- pending = /* @__PURE__ */ new Map();
3170
- closed = false;
3171
- closeReason = null;
3172
- /** Default session ID for flat session protocol (Target.attachToTarget) */
3173
- defaultSessionId;
3174
- constructor(wsOrUrl, sessionId) {
3175
- super();
3176
- this.setMaxListeners(0);
3177
- this.defaultSessionId = sessionId;
3178
- if (typeof wsOrUrl === "string") {
3179
- const wsOptions = /^wss:\/\/\d+\.\d+\.\d+\.\d+/.test(wsOrUrl) ? { rejectUnauthorized: false } : void 0;
3180
- this.ws = new WebSocket(wsOrUrl, wsOptions);
3181
- } else {
3182
- this.ws = wsOrUrl;
3346
+
3347
+ // src/cdp-interceptor/rules/shared.ts
3348
+ var PLAYWRIGHT_INTERNAL_MARKERS = [
3349
+ "__commonJS",
3350
+ "module.exports",
3351
+ "__require",
3352
+ "__toESM",
3353
+ "inject_utils"
3354
+ ];
3355
+ function isPlaywrightInternal(code) {
3356
+ return PLAYWRIGHT_INTERNAL_MARKERS.some((marker) => code.includes(marker));
3357
+ }
3358
+ function extractUserCode(ctx) {
3359
+ if (ctx.method === "Runtime.evaluate") {
3360
+ const expr = ctx.params.expression;
3361
+ if (typeof expr === "string") {
3362
+ if (isPlaywrightInternal(expr)) return null;
3363
+ return expr;
3183
3364
  }
3184
- this.bindWebSocket();
3185
- this.startKeepalive();
3186
3365
  }
3187
- /** Send periodic WS pings to prevent idle-timeout disconnects (e.g. CF's 100s).
3188
- * Also detects dead connections: if a pong isn't received within 10s of a
3189
- * ping, the connection is considered dead and forcibly closed. */
3190
- keepaliveTimer = null;
3191
- pongTimer = null;
3192
- startKeepalive() {
3193
- this.ws.on("pong", () => {
3194
- if (this.pongTimer) {
3195
- clearTimeout(this.pongTimer);
3196
- this.pongTimer = null;
3197
- }
3198
- });
3199
- this.keepaliveTimer = setInterval(() => {
3200
- if (this.ws.readyState === WebSocket.OPEN) {
3201
- if (!this.pongTimer) {
3202
- this.pongTimer = setTimeout(() => {
3203
- if (!this.closed) {
3204
- this.closed = true;
3205
- this.closeReason = "keepalive timeout (no pong in 10s)";
3206
- try {
3207
- this.ws.terminate();
3208
- } catch {
3209
- }
3210
- for (const [, pending] of this.pending) {
3211
- clearTimeout(pending.timeout);
3212
- pending.reject(new Error("Connection dead: keepalive timeout"));
3213
- }
3214
- this.pending.clear();
3215
- this.emit("disconnect");
3216
- }
3217
- }, 1e4);
3218
- }
3219
- this.ws.ping?.();
3220
- } else if (this.closed) {
3221
- if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
3222
- this.keepaliveTimer = null;
3223
- }
3224
- }, 3e4);
3366
+ if (ctx.method === "Runtime.callFunctionOn") {
3367
+ const decl = ctx.params.functionDeclaration;
3368
+ if (typeof decl === "string" && decl.includes("utilityScript.evaluate")) {
3369
+ return extractAllStrings(ctx.params.arguments);
3370
+ }
3371
+ if (typeof decl === "string") return decl;
3225
3372
  }
3226
- /** Wait for the connection to be fully open */
3227
- async ready() {
3228
- if (this.ws.readyState === WebSocket.OPEN) return;
3229
- if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
3230
- throw new Error(`WebSocket already closed: ${this.closeReason ?? "unknown"}`);
3373
+ return null;
3374
+ }
3375
+ function extractAllStrings(rawArgs) {
3376
+ if (!Array.isArray(rawArgs)) return null;
3377
+ const strings = [];
3378
+ for (const arg of rawArgs) {
3379
+ if (arg && typeof arg === "object" && "value" in arg) {
3380
+ const val = arg.value;
3381
+ if (typeof val === "string" && val.length > 5 && !["true", "false"].includes(val)) {
3382
+ strings.push(val);
3383
+ }
3231
3384
  }
3232
- return new Promise((resolve, reject) => {
3233
- const onOpen = () => {
3234
- this.ws.off("error", onError);
3235
- resolve();
3236
- };
3237
- const onError = (err) => {
3238
- this.ws.off("open", onOpen);
3239
- reject(err);
3240
- };
3241
- this.ws.once("open", onOpen);
3242
- this.ws.once("error", onError);
3243
- });
3244
3385
  }
3245
- /** Is the underlying WebSocket alive? */
3246
- get isOpen() {
3247
- return !this.closed && this.ws.readyState === WebSocket.OPEN;
3386
+ return strings.length > 0 ? strings.join("\n") : null;
3387
+ }
3388
+
3389
+ // src/cdp-interceptor/rules/dom-mutation.ts
3390
+ var DOM_PATTERNS = [
3391
+ // ── P0: Value/Checked (bypasses React onChange) ────────────
3392
+ { 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." },
3393
+ { pattern: /\.checked\s*=\s*(?:true|false)/, name: ".checked =", severity: "danger", action: "pass", errorCode: -32001, suggestion: "Use page.check(selector) or page.uncheck(selector)." },
3394
+ { pattern: /\.indeterminate\s*=\s*true/, name: ".indeterminate =", severity: "warn", action: "pass", errorCode: -32021, suggestion: "No human can set indeterminate state \u2014 remove this call." },
3395
+ { pattern: /\.valueAsDate\s*=/, name: ".valueAsDate =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with formatted date string instead." },
3396
+ { pattern: /\.valueAsNumber\s*=/, name: ".valueAsNumber =", severity: "warn", action: "pass", errorCode: -32022, suggestion: "Use page.fill() with numeric string instead." },
3397
+ // ── P1: Select/Option (bypasses React onChange on select) ─
3398
+ { pattern: /\.selectedIndex\s*=\s*\d+/, name: ".selectedIndex =", severity: "danger", action: "pass", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
3399
+ { 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)." },
3400
+ { pattern: /\.value\s*=\s*["'][^"']*["']\s*[;)]?\s*$/, name: "selectElement.value =", severity: "warn", action: "pass", errorCode: -32023, suggestion: "For select elements, use page.selectOption(selector, value)." },
3401
+ // ── P2: Content properties (bypasses virtual DOM diffing) ──
3402
+ { pattern: /\.innerHTML\s*=/, name: ".innerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: ".innerHTML bypasses React/Vue diffing. Use component state or page.setContent()." },
3403
+ { pattern: /\.outerHTML\s*=/, name: ".outerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: "outerHTML replacement destroys React fiber tree. Use page.setContent()." },
3404
+ { pattern: /\.innerText\s*=/, name: ".innerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Framework components should be updated via state, not innerText." },
3405
+ { pattern: /\.textContent\s*=/, name: ".textContent =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "textContent bypasses React/Vue diffing. Use component state instead." },
3406
+ { pattern: /\.outerText\s*=/, name: ".outerText =", severity: "warn", action: "pass", errorCode: -32024, suggestion: "Non-standard property \u2014 use proper framework update methods." },
3407
+ { pattern: /nodeValue\s*=/, name: "node.nodeValue =", severity: "info", action: "pass", errorCode: -32025, suggestion: "Direct text node mutation. Use textContent instead if needed." },
3408
+ // ── P3: Style properties ──────────────────────────────────
3409
+ { 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".' },
3410
+ { 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." },
3411
+ { pattern: /\.style\.setProperty\s*\(/, name: ".style.setProperty()", severity: "info", action: "pass", errorCode: -32008, suggestion: "Direct style property set bypasses CSS transitions." },
3412
+ { pattern: /\.style\.removeProperty\s*\(/, name: ".style.removeProperty()", severity: "info", action: "pass", errorCode: -32026, suggestion: "Style removal without user interaction is suspicious." },
3413
+ { pattern: /\.style\.animation\s*=/, name: ".style.animation =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Forcing animation state via CDP is detectable." },
3414
+ { pattern: /\.style\.transition\s*=/, name: ".style.transition =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Manipulating CSS transitions is rare in normal browsing." },
3415
+ // ── P4: Class/Attribute properties ────────────────────────
3416
+ { pattern: /\.className\s*=/, name: ".className =", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or a Playwright locator instead." },
3417
+ { pattern: /\.classList\.add\s*\(/, name: ".classList.add()", severity: "info", action: "pass", errorCode: -32009, suggestion: "classList manipulation via CDP bypasses framework state tracking." },
3418
+ { pattern: /\.classList\.remove\s*\(/, name: ".classList.remove()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or attribute selectors instead." },
3419
+ { pattern: /\.classList\.toggle\s*\(/, name: ".classList.toggle()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Toggle without user interaction is detectable." },
3420
+ { pattern: /\.classList\.replace\s*\(/, name: ".classList.replace()", severity: "info", action: "pass", errorCode: -32028, suggestion: "Rare operation \u2014 likely automated." },
3421
+ // ── P5: Attribute manipulation ────────────────────────────
3422
+ { pattern: /\.setAttribute\s*\(/, name: ".setAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "setAttribute bypasses framework attribute tracking. Use component state." },
3423
+ { pattern: /\.removeAttribute\s*\(/, name: ".removeAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "Attribute removal without user interaction is suspicious." },
3424
+ { pattern: /\.toggleAttribute\s*\(/, name: ".toggleAttribute()", severity: "info", action: "pass", errorCode: -32029, suggestion: "Attribute toggling via CDP is detectable." },
3425
+ { pattern: /\.dataset\.\w+\s*=/, name: ".dataset.* =", severity: "info", action: "pass", errorCode: -32030, suggestion: "dataset mutations via evaluate bypass native mutation observers." },
3426
+ // ── P6: Focus/Selection properties ────────────────────────
3427
+ { pattern: /\.selectionStart\s*=/, name: ".selectionStart =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Setting cursor position without focus is detectable." },
3428
+ { pattern: /\.selectionEnd\s*=/, name: ".selectionEnd =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Selection range manipulation without user input is suspicious." },
3429
+ { pattern: /\.selectionDirection\s*=/, name: ".selectionDirection =", severity: "info", action: "pass", errorCode: -32031, suggestion: "Selection direction changes normally via mouse drag." },
3430
+ // ── P7: Boolean properties ────────────────────────────────
3431
+ { pattern: /\.disabled\s*=/, name: ".disabled =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Disabling elements via CDP mid-interaction is detectable." },
3432
+ { pattern: /\.readOnly\s*=/, name: ".readOnly =", severity: "info", action: "pass", errorCode: -32032, suggestion: "readOnly changes without user action." },
3433
+ { pattern: /\.hidden\s*=/, name: ".hidden =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Hiding elements is a common scraper tactic \u2014 detectable." },
3434
+ { pattern: /\.required\s*=/, name: ".required =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Validation constraint changes mid-session." },
3435
+ { pattern: /\.multiple\s*=/, name: ".multiple =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Multiple attribute toggle is rare in normal browsing." },
3436
+ { pattern: /\.autofocus\s*=/, name: ".autofocus =", severity: "info", action: "pass", errorCode: -32032, suggestion: "autofocus changes mid-session are suspicious." },
3437
+ // ── P8: Frame/Navigation properties ───────────────────────
3438
+ { 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." },
3439
+ { pattern: /\.href\s*=/, name: ".href =", severity: "info", action: "pass", errorCode: -32033, suggestion: "Changing anchor href via evaluate \u2014 use page.click() instead." },
3440
+ { pattern: /\.action\s*=/, name: "form.action =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Changing form action URL is highly suspicious." },
3441
+ { pattern: /\.method\s*=/, name: "form.method =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Form method changes without user interaction." },
3442
+ { pattern: /\.target\s*=/, name: "link/area.target =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Link target manipulation via CDP." },
3443
+ // ── P9: Media properties ──────────────────────────────────
3444
+ { pattern: /\.currentTime\s*=/, name: "media.currentTime = (seeking)", severity: "info", action: "pass", errorCode: -32014, suggestion: "Video seeking without user interaction \u2014 common scraper pattern." },
3445
+ { pattern: /\.playbackRate\s*=/, name: "media.playbackRate =", severity: "info", action: "pass", errorCode: -32014, suggestion: "Changing playback speed is detectable automation signal." },
3446
+ { pattern: /\.volume\s*=/, name: "media.volume =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Volume setting via evaluate is suspicious." },
3447
+ { pattern: /\.muted\s*=/, name: "media.muted =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Muting media without user click is suspicious." },
3448
+ // ── P10: Element geometry ─────────────────────────────────
3449
+ { pattern: /\.scrollTop\s*=/, name: ".scrollTop =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Programmatic scroll without user gesture. Use page.mouse.wheel()." },
3450
+ { pattern: /\.scrollLeft\s*=/, name: ".scrollLeft =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Horizontal scroll without user gesture." },
3451
+ { pattern: /\.scrollTo\s*\(/, name: ".scrollTo()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollTo bypasses user scroll detection." },
3452
+ { pattern: /\.scrollBy\s*\(/, name: ".scrollBy()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollBy without user gesture." },
3453
+ { pattern: /\.scrollIntoView\s*\(/, name: ".scrollIntoView()", severity: "info", action: "pass", errorCode: -32015, suggestion: "scrollIntoView is a common bot pattern. Let Playwright handle scrolling." },
3454
+ // ── P11: Shadow DOM ───────────────────────────────────────
3455
+ { pattern: /\.shadowRoot\s*=/, name: ".shadowRoot = (override)", severity: "info", action: "pass", errorCode: -32036, suggestion: "ShadowRoot is read-only \u2014 this set attempt is detectable." },
3456
+ // ── P12: Force reflow / layout thrashing ──────────────────
3457
+ { 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." },
3458
+ { pattern: /\.offsetWidth\b(?!\s*===?\s*)/, name: ".offsetWidth read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetWidth triggers forced reflow \u2014 detectable." },
3459
+ { pattern: /getBoundingClientRect\s*\(/, name: "getBoundingClientRect()", severity: "warn", action: "pass", errorCode: -32037, suggestion: "getBoundingClientRect triggers reflow. Minimize calls." },
3460
+ { pattern: /getComputedStyle\s*\(/, name: "getComputedStyle()", severity: "info", action: "pass", errorCode: -32038, suggestion: "getComputedStyle can trigger style recalculation." },
3461
+ { pattern: /\.clientHeight\b/, name: ".clientHeight read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientHeight read triggers layout." },
3462
+ { pattern: /\.clientWidth\b/, name: ".clientWidth read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientWidth read triggers layout." }
3463
+ ];
3464
+ var domMutationRule = {
3465
+ id: "dom-mutation",
3466
+ name: "DOM Property Mutation Detection (50+ setters)",
3467
+ priority: 10,
3468
+ canHandle(ctx) {
3469
+ return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
3470
+ },
3471
+ evaluate(ctx) {
3472
+ const userCode = extractUserCode(ctx);
3473
+ if (!userCode) return null;
3474
+ for (const p of DOM_PATTERNS) {
3475
+ if (p.pattern.test(userCode)) {
3476
+ return {
3477
+ ruleId: "dom-mutation",
3478
+ action: p.action,
3479
+ severity: p.severity,
3480
+ reason: `Direct DOM property setter: "${p.name}". This bypasses framework event systems and is detectable as automation.`,
3481
+ suggestion: p.suggestion,
3482
+ errorCode: p.errorCode,
3483
+ errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 bypasses framework reactivity` : `[CDP Firewall] ${p.name} detected \u2014 use proper interaction API`
3484
+ };
3485
+ }
3486
+ }
3487
+ return null;
3248
3488
  }
3249
- /**
3250
- * Send a CDP command and await its response.
3251
- *
3252
- * @param method — CDP domain.method (e.g. "Page.navigate")
3253
- * @param params — method parameters
3254
- * @param sessionId — optional flat session ID for sub-targets
3255
- * @param timeoutMs — response timeout (default: 30s)
3256
- * @returns the `result` field from the CDP response
3257
- */
3258
- async send(method, params, sessionId, timeoutMs = 3e4) {
3259
- if (this.closed) {
3260
- throw new Error(`CDP connection closed: ${this.closeReason ?? "unknown"}`);
3489
+ };
3490
+
3491
+ // src/cdp-interceptor/rules/mouse-trajectory.ts
3492
+ var TRACKER_KEY = "mouse-trajectory-tracker";
3493
+ var MAX_SAMPLES = 200;
3494
+ var MIN_SAMPLES_FOR_ANALYSIS = 5;
3495
+ var mouseTrajectoryRule = {
3496
+ id: "mouse-trajectory",
3497
+ name: "Mouse Trajectory Analysis",
3498
+ priority: 20,
3499
+ canHandle(ctx) {
3500
+ return ctx.method === "Input.dispatchMouseEvent";
3501
+ },
3502
+ evaluate(ctx) {
3503
+ const type = ctx.params.type;
3504
+ const x = ctx.params.x;
3505
+ const y = ctx.params.y;
3506
+ if (typeof x !== "number" || typeof y !== "number") return null;
3507
+ let tracker = ctx.sessionState.get(TRACKER_KEY);
3508
+ if (!tracker) {
3509
+ tracker = { samples: [], lastMouseDownAt: 0, isDragging: false };
3510
+ ctx.sessionState.set(TRACKER_KEY, tracker);
3261
3511
  }
3262
- if (!this.isOpen) {
3263
- throw new Error(`CDP connection not open (state: ${this.ws.readyState})`);
3512
+ if (type === "mousePressed") {
3513
+ const priorResult = analyzeTrajectory(tracker.samples);
3514
+ tracker.lastMouseDownAt = Date.now();
3515
+ tracker.isDragging = true;
3516
+ tracker.samples = [];
3517
+ return priorResult;
3264
3518
  }
3265
- const id = this.nextId++;
3266
- const sid = sessionId ?? this.defaultSessionId;
3267
- const message = { id, method };
3268
- if (params !== void 0) message.params = params;
3269
- if (sid !== void 0) message.sessionId = sid;
3270
- return new Promise((resolve, reject) => {
3271
- const timeout = setTimeout(() => {
3272
- this.pending.delete(id);
3273
- reject(new Error(`CDP timeout: ${method} (${timeoutMs}ms)`));
3274
- }, timeoutMs);
3275
- this.pending.set(id, {
3276
- resolve: (v) => {
3277
- clearTimeout(timeout);
3278
- this.pending.delete(id);
3279
- resolve(v);
3280
- },
3281
- reject: (err) => {
3282
- clearTimeout(timeout);
3283
- this.pending.delete(id);
3284
- reject(err);
3285
- },
3286
- method,
3287
- timeout
3288
- });
3289
- const data = JSON.stringify(message);
3290
- try {
3291
- this.ws.send(data);
3292
- } catch (err) {
3293
- clearTimeout(timeout);
3294
- this.pending.delete(id);
3295
- reject(new Error(`CDP send failed: ${method} \u2014 ${err instanceof Error ? err.message : String(err)}`));
3296
- }
3297
- });
3298
- }
3299
- /**
3300
- * Subscribe to a CDP event.
3301
- *
3302
- * @param event — full event name (e.g. "Page.frameNavigated")
3303
- * @param handler — called with the event params
3304
- * @param sessionId — optional session filter
3305
- */
3306
- on(event, handler) {
3307
- return super.on(event, handler);
3308
- }
3309
- once(event, handler) {
3310
- return super.once(event, handler);
3311
- }
3312
- /** Remove an event listener */
3313
- off(event, handler) {
3314
- super.off(event, handler);
3315
- return this;
3316
- }
3317
- /**
3318
- * Subscribe to a CDP event for a specific session.
3319
- * Returns an unsubscribe function.
3320
- */
3321
- subscribe(event, sessionId, handler) {
3322
- const wrapper = (params, sid) => {
3323
- if (sid === sessionId || !sessionId && !sid) handler(params);
3324
- };
3325
- this.on(event, wrapper);
3326
- return () => this.off(event, wrapper);
3327
- }
3328
- /** Close the WebSocket */
3329
- async close() {
3330
- if (this.closed) return;
3331
- this.closed = true;
3332
- this.closeReason = "closed by caller";
3333
- if (this.keepaliveTimer) {
3334
- clearInterval(this.keepaliveTimer);
3335
- this.keepaliveTimer = null;
3336
- }
3337
- if (this.pongTimer) {
3338
- clearTimeout(this.pongTimer);
3339
- this.pongTimer = null;
3519
+ if (type === "mouseReleased") {
3520
+ tracker.isDragging = false;
3521
+ const result = analyzeTrajectory(tracker.samples);
3522
+ tracker.samples = [];
3523
+ return result;
3340
3524
  }
3341
- for (const [id, pending] of this.pending) {
3342
- clearTimeout(pending.timeout);
3343
- pending.reject(new Error(`Connection closed: ${pending.method}`));
3344
- this.pending.delete(id);
3525
+ if (type === "mouseMoved") {
3526
+ const now = Date.now();
3527
+ const prevSample = tracker.samples[tracker.samples.length - 1];
3528
+ const distanceTraveled = prevSample ? prevSample.distanceTraveled + distance(prevSample.x, prevSample.y, x, y) : 0;
3529
+ const sample = { x, y, timestamp: now, distanceTraveled };
3530
+ tracker.samples.push(sample);
3531
+ if (tracker.samples.length > MAX_SAMPLES) {
3532
+ tracker.samples.shift();
3533
+ }
3534
+ return null;
3345
3535
  }
3346
- if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
3347
- this.ws.close(1e3, "normal closure");
3536
+ if (type === "mouseReleased" && tracker.samples.length < MIN_SAMPLES_FOR_ANALYSIS) {
3537
+ tracker.samples = [];
3538
+ return null;
3348
3539
  }
3540
+ return null;
3349
3541
  }
3350
- /** Set the default session ID for flat protocol */
3351
- setDefaultSessionId(sid) {
3352
- this.defaultSessionId = sid;
3542
+ };
3543
+ function analyzeTrajectory(samples) {
3544
+ if (samples.length < MIN_SAMPLES_FOR_ANALYSIS) return null;
3545
+ const issues = [];
3546
+ const startX = samples[0].x;
3547
+ const startY = samples[0].y;
3548
+ const endX = samples[samples.length - 1].x;
3549
+ const endY = samples[samples.length - 1].y;
3550
+ const lineLength = distance(startX, startY, endX, endY);
3551
+ const collinearityResult = checkCollinearity(samples, startX, startY, endX, endY, lineLength);
3552
+ if (collinearityResult) {
3553
+ issues.push(collinearityResult);
3353
3554
  }
3354
- // ── Private ─────────────────────────────────────────────────
3355
- bindWebSocket() {
3356
- this.ws.on("message", (raw) => {
3357
- let msg;
3358
- try {
3359
- msg = JSON.parse(raw.toString());
3360
- } catch {
3361
- return;
3362
- }
3363
- if (msg.id !== void 0) {
3364
- const pending = this.pending.get(msg.id);
3365
- if (!pending) return;
3366
- if (msg.error) {
3367
- pending.reject(new CDPProtocolError(msg.error.code, msg.error.message, pending.method));
3368
- } else {
3369
- pending.resolve(msg.result ?? {});
3370
- }
3371
- return;
3372
- }
3373
- if (msg.method) {
3374
- this.emit(msg.method, msg.params ?? {}, msg.sessionId);
3375
- this.emit("*", msg.method, msg.params ?? {}, msg.sessionId);
3376
- }
3377
- });
3378
- this.ws.on("close", (code, reason) => {
3379
- if (this.closed) return;
3380
- this.closed = true;
3381
- this.closeReason = `WebSocket closed: ${code} ${reason?.toString() ?? ""}`.trim();
3382
- if (this.keepaliveTimer) {
3383
- clearInterval(this.keepaliveTimer);
3384
- this.keepaliveTimer = null;
3385
- }
3386
- for (const [id, pending] of this.pending) {
3387
- clearTimeout(pending.timeout);
3388
- pending.reject(new Error(`Connection closed: ${pending.method}`));
3389
- this.pending.delete(id);
3390
- }
3391
- this.emit("disconnect");
3392
- });
3393
- this.ws.on("error", (err) => {
3394
- if (this.closed) return;
3395
- this.emit("ws-error", err);
3396
- });
3555
+ const velocityResult = checkConstantVelocity(samples);
3556
+ if (velocityResult) {
3557
+ issues.push(velocityResult);
3397
3558
  }
3398
- };
3399
- var CDPProtocolError = class extends Error {
3400
- code;
3401
- method;
3402
- data;
3403
- constructor(code, message, method, data) {
3404
- super(`CDP error [${code}] in ${method}: ${message}`);
3405
- this.name = "CDPProtocolError";
3406
- this.code = code;
3407
- this.method = method;
3408
- this.data = data;
3559
+ const jitterResult = checkJitter(samples);
3560
+ if (jitterResult) {
3561
+ issues.push(jitterResult);
3409
3562
  }
3410
- };
3563
+ if (issues.length === 0) return null;
3564
+ const stopX = samples[samples.length - 1].x;
3565
+ const stopY = samples[samples.length - 1].y;
3566
+ return {
3567
+ ruleId: "mouse-trajectory",
3568
+ action: "pass",
3569
+ severity: "danger",
3570
+ reason: `Suspicious mouse trajectory: ${issues.join("; ")}`,
3571
+ suggestion: `This mouse movement appears automated (straight line A\u2192B, no natural variation).
3572
+ Use a humanized mouse API that generates:
3573
+ - Bezier curves instead of straight lines
3574
+ - Random acceleration/deceleration
3575
+ - 1-3px micro-jitter per sample
3411
3576
 
3412
- // src/cdp-driver/index.ts
3413
- async function launch(options = {}) {
3414
- let wsEndpoint;
3415
- let childProcess;
3416
- let tmpDir;
3417
- if (options.cdpEndpoint) {
3418
- wsEndpoint = await connectToCDP(options.cdpEndpoint);
3419
- } else {
3420
- const result = await launchChrome({
3421
- executablePath: options.executablePath,
3422
- headless: options.headless,
3423
- args: options.args,
3424
- userDataDir: options.userDataDir,
3425
- timeout: options.timeout,
3426
- env: options.env
3427
- });
3428
- wsEndpoint = result.wsEndpoint;
3429
- childProcess = result.process;
3430
- tmpDir = result.tmpDir;
3431
- }
3432
- const conn = new CDPConnection(wsEndpoint);
3433
- await conn.ready();
3434
- const httpEndpoint = options.cdpEndpoint && !options.cdpEndpoint.startsWith("ws") ? options.cdpEndpoint : void 0;
3435
- const browser = new XBBrowserImpl(conn, childProcess, tmpDir, httpEndpoint);
3436
- return { browser, wsEndpoint };
3577
+ Example: The 'faker' or 'ghost-cursor' libraries generate realistic mouse paths.
3578
+ Target was: (${Math.round(startX)}, ${Math.round(startY)}) \u2192 (${Math.round(stopX)}, ${Math.round(stopY)})`,
3579
+ errorCode: -32002,
3580
+ errorMessage: "[CDP Firewall] Automated mouse trajectory blocked \u2014 appears non-human"
3581
+ };
3437
3582
  }
3438
-
3439
- // src/cdp-interceptor/proxy.ts
3440
- import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
3441
-
3442
- // src/cdp-interceptor/rules/shared.ts
3443
- var PLAYWRIGHT_INTERNAL_MARKERS = [
3444
- "__commonJS",
3445
- "module.exports",
3446
- "__require",
3447
- "__toESM",
3448
- "inject_utils"
3449
- ];
3450
- function isPlaywrightInternal(code) {
3451
- return PLAYWRIGHT_INTERNAL_MARKERS.some((marker) => code.includes(marker));
3583
+ function distance(x1, y1, x2, y2) {
3584
+ return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
3452
3585
  }
3453
- function extractUserCode(ctx) {
3454
- if (ctx.method === "Runtime.evaluate") {
3455
- const expr = ctx.params.expression;
3456
- if (typeof expr === "string") {
3457
- if (isPlaywrightInternal(expr)) return null;
3458
- return expr;
3459
- }
3586
+ function checkCollinearity(samples, x1, y1, x2, y2, lineLength) {
3587
+ if (lineLength < 5) return null;
3588
+ let maxDeviation = 0;
3589
+ const dx = x2 - x1;
3590
+ const dy = y2 - y1;
3591
+ for (let i = 1; i < samples.length - 1; i++) {
3592
+ const { x, y } = samples[i];
3593
+ const cross = Math.abs(dy * x - dx * y + x2 * y1 - y2 * x1);
3594
+ const dev = cross / lineLength;
3595
+ if (dev > maxDeviation) maxDeviation = dev;
3460
3596
  }
3461
- if (ctx.method === "Runtime.callFunctionOn") {
3462
- const decl = ctx.params.functionDeclaration;
3463
- if (typeof decl === "string" && decl.includes("utilityScript.evaluate")) {
3464
- return extractAllStrings(ctx.params.arguments);
3465
- }
3466
- if (typeof decl === "string") return decl;
3467
- }
3468
- return null;
3469
- }
3470
- function extractAllStrings(rawArgs) {
3471
- if (!Array.isArray(rawArgs)) return null;
3472
- const strings = [];
3473
- for (const arg of rawArgs) {
3474
- if (arg && typeof arg === "object" && "value" in arg) {
3475
- const val = arg.value;
3476
- if (typeof val === "string" && val.length > 5 && !["true", "false"].includes(val)) {
3477
- strings.push(val);
3478
- }
3479
- }
3480
- }
3481
- return strings.length > 0 ? strings.join("\n") : null;
3482
- }
3483
-
3484
- // src/cdp-interceptor/rules/dom-mutation.ts
3485
- var DOM_PATTERNS = [
3486
- // ── P0: Value/Checked (bypasses React onChange) ────────────
3487
- { pattern: /\.value\s*=\s*(?!["'\s]*$)/, name: ".value =", severity: "danger", action: "block", errorCode: -32001, suggestion: "Use page.fill(selector, value) which dispatches proper input/change events." },
3488
- { pattern: /\.checked\s*=\s*(?:true|false)/, name: ".checked =", severity: "danger", action: "block", errorCode: -32001, suggestion: "Use page.check(selector) or page.uncheck(selector)." },
3489
- { pattern: /\.indeterminate\s*=\s*true/, name: ".indeterminate =", severity: "warn", action: "block", errorCode: -32021, suggestion: "No human can set indeterminate state \u2014 remove this call." },
3490
- { pattern: /\.valueAsDate\s*=/, name: ".valueAsDate =", severity: "warn", action: "block", errorCode: -32022, suggestion: "Use page.fill() with formatted date string instead." },
3491
- { pattern: /\.valueAsNumber\s*=/, name: ".valueAsNumber =", severity: "warn", action: "block", errorCode: -32022, suggestion: "Use page.fill() with numeric string instead." },
3492
- // ── P1: Select/Option (bypasses React onChange on select) ─
3493
- { pattern: /\.selectedIndex\s*=\s*\d+/, name: ".selectedIndex =", severity: "danger", action: "block", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
3494
- { pattern: /(?:options|children)\s*\[[^\]]*\]\s*\.\s*selected\s*=\s*(?:true|false)/, name: "options[N].selected =", severity: "danger", action: "block", errorCode: -32003, suggestion: "Use page.selectOption(selector, value)." },
3495
- { pattern: /\.value\s*=\s*["'][^"']*["']\s*[;)]?\s*$/, name: "selectElement.value =", severity: "warn", action: "block", errorCode: -32023, suggestion: "For select elements, use page.selectOption(selector, value)." },
3496
- // ── P2: Content properties (bypasses virtual DOM diffing) ──
3497
- { pattern: /\.innerHTML\s*=/, name: ".innerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: ".innerHTML bypasses React/Vue diffing. Use component state or page.setContent()." },
3498
- { pattern: /\.outerHTML\s*=/, name: ".outerHTML =", severity: "info", action: "pass", errorCode: -32007, suggestion: "outerHTML replacement destroys React fiber tree. Use page.setContent()." },
3499
- { pattern: /\.innerText\s*=/, name: ".innerText =", severity: "warn", action: "block", errorCode: -32024, suggestion: "Framework components should be updated via state, not innerText." },
3500
- { pattern: /\.textContent\s*=/, name: ".textContent =", severity: "warn", action: "block", errorCode: -32024, suggestion: "textContent bypasses React/Vue diffing. Use component state instead." },
3501
- { pattern: /\.outerText\s*=/, name: ".outerText =", severity: "warn", action: "block", errorCode: -32024, suggestion: "Non-standard property \u2014 use proper framework update methods." },
3502
- { pattern: /nodeValue\s*=/, name: "node.nodeValue =", severity: "info", action: "block", errorCode: -32025, suggestion: "Direct text node mutation. Use textContent instead if needed." },
3503
- // ── P3: Style properties ──────────────────────────────────
3504
- { 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".' },
3505
- { 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." },
3506
- { pattern: /\.style\.setProperty\s*\(/, name: ".style.setProperty()", severity: "info", action: "pass", errorCode: -32008, suggestion: "Direct style property set bypasses CSS transitions." },
3507
- { pattern: /\.style\.removeProperty\s*\(/, name: ".style.removeProperty()", severity: "info", action: "pass", errorCode: -32026, suggestion: "Style removal without user interaction is suspicious." },
3508
- { pattern: /\.style\.animation\s*=/, name: ".style.animation =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Forcing animation state via CDP is detectable." },
3509
- { pattern: /\.style\.transition\s*=/, name: ".style.transition =", severity: "info", action: "pass", errorCode: -32027, suggestion: "Manipulating CSS transitions is rare in normal browsing." },
3510
- // ── P4: Class/Attribute properties ────────────────────────
3511
- { pattern: /\.className\s*=/, name: ".className =", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or a Playwright locator instead." },
3512
- { pattern: /\.classList\.add\s*\(/, name: ".classList.add()", severity: "info", action: "pass", errorCode: -32009, suggestion: "classList manipulation via CDP bypasses framework state tracking." },
3513
- { pattern: /\.classList\.remove\s*\(/, name: ".classList.remove()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Use component state or attribute selectors instead." },
3514
- { pattern: /\.classList\.toggle\s*\(/, name: ".classList.toggle()", severity: "info", action: "pass", errorCode: -32009, suggestion: "Toggle without user interaction is detectable." },
3515
- { pattern: /\.classList\.replace\s*\(/, name: ".classList.replace()", severity: "info", action: "pass", errorCode: -32028, suggestion: "Rare operation \u2014 likely automated." },
3516
- // ── P5: Attribute manipulation ────────────────────────────
3517
- { pattern: /\.setAttribute\s*\(/, name: ".setAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "setAttribute bypasses framework attribute tracking. Use component state." },
3518
- { pattern: /\.removeAttribute\s*\(/, name: ".removeAttribute()", severity: "info", action: "pass", errorCode: -32010, suggestion: "Attribute removal without user interaction is suspicious." },
3519
- { pattern: /\.toggleAttribute\s*\(/, name: ".toggleAttribute()", severity: "info", action: "pass", errorCode: -32029, suggestion: "Attribute toggling via CDP is detectable." },
3520
- { pattern: /\.dataset\.\w+\s*=/, name: ".dataset.* =", severity: "info", action: "pass", errorCode: -32030, suggestion: "dataset mutations via evaluate bypass native mutation observers." },
3521
- // ── P6: Focus/Selection properties ────────────────────────
3522
- { pattern: /\.selectionStart\s*=/, name: ".selectionStart =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Setting cursor position without focus is detectable." },
3523
- { pattern: /\.selectionEnd\s*=/, name: ".selectionEnd =", severity: "info", action: "pass", errorCode: -32011, suggestion: "Selection range manipulation without user input is suspicious." },
3524
- { pattern: /\.selectionDirection\s*=/, name: ".selectionDirection =", severity: "info", action: "pass", errorCode: -32031, suggestion: "Selection direction changes normally via mouse drag." },
3525
- // ── P7: Boolean properties ────────────────────────────────
3526
- { pattern: /\.disabled\s*=/, name: ".disabled =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Disabling elements via CDP mid-interaction is detectable." },
3527
- { pattern: /\.readOnly\s*=/, name: ".readOnly =", severity: "info", action: "pass", errorCode: -32032, suggestion: "readOnly changes without user action." },
3528
- { pattern: /\.hidden\s*=/, name: ".hidden =", severity: "info", action: "pass", errorCode: -32012, suggestion: "Hiding elements is a common scraper tactic \u2014 detectable." },
3529
- { pattern: /\.required\s*=/, name: ".required =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Validation constraint changes mid-session." },
3530
- { pattern: /\.multiple\s*=/, name: ".multiple =", severity: "info", action: "pass", errorCode: -32032, suggestion: "Multiple attribute toggle is rare in normal browsing." },
3531
- { pattern: /\.autofocus\s*=/, name: ".autofocus =", severity: "info", action: "pass", errorCode: -32032, suggestion: "autofocus changes mid-session are suspicious." },
3532
- // ── P8: Frame/Navigation properties ───────────────────────
3533
- { 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." },
3534
- { pattern: /\.href\s*=/, name: ".href =", severity: "info", action: "pass", errorCode: -32033, suggestion: "Changing anchor href via evaluate \u2014 use page.click() instead." },
3535
- { pattern: /\.action\s*=/, name: "form.action =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Changing form action URL is highly suspicious." },
3536
- { pattern: /\.method\s*=/, name: "form.method =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Form method changes without user interaction." },
3537
- { pattern: /\.target\s*=/, name: "link/area.target =", severity: "info", action: "pass", errorCode: -32034, suggestion: "Link target manipulation via CDP." },
3538
- // ── P9: Media properties ──────────────────────────────────
3539
- { pattern: /\.currentTime\s*=/, name: "media.currentTime = (seeking)", severity: "info", action: "pass", errorCode: -32014, suggestion: "Video seeking without user interaction \u2014 common scraper pattern." },
3540
- { pattern: /\.playbackRate\s*=/, name: "media.playbackRate =", severity: "info", action: "pass", errorCode: -32014, suggestion: "Changing playback speed is detectable automation signal." },
3541
- { pattern: /\.volume\s*=/, name: "media.volume =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Volume setting via evaluate is suspicious." },
3542
- { pattern: /\.muted\s*=/, name: "media.muted =", severity: "info", action: "pass", errorCode: -32035, suggestion: "Muting media without user click is suspicious." },
3543
- // ── P10: Element geometry ─────────────────────────────────
3544
- { pattern: /\.scrollTop\s*=/, name: ".scrollTop =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Programmatic scroll without user gesture. Use page.mouse.wheel()." },
3545
- { pattern: /\.scrollLeft\s*=/, name: ".scrollLeft =", severity: "info", action: "pass", errorCode: -32015, suggestion: "Horizontal scroll without user gesture." },
3546
- { pattern: /\.scrollTo\s*\(/, name: ".scrollTo()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollTo bypasses user scroll detection." },
3547
- { pattern: /\.scrollBy\s*\(/, name: ".scrollBy()", severity: "info", action: "pass", errorCode: -32015, suggestion: "ScrollBy without user gesture." },
3548
- { pattern: /\.scrollIntoView\s*\(/, name: ".scrollIntoView()", severity: "info", action: "pass", errorCode: -32015, suggestion: "scrollIntoView is a common bot pattern. Let Playwright handle scrolling." },
3549
- // ── P11: Shadow DOM ───────────────────────────────────────
3550
- { pattern: /\.shadowRoot\s*=/, name: ".shadowRoot = (override)", severity: "info", action: "block", errorCode: -32036, suggestion: "ShadowRoot is read-only \u2014 this set attempt is detectable." },
3551
- // ── P12: Force reflow / layout thrashing ──────────────────
3552
- { 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." },
3553
- { pattern: /\.offsetWidth\b(?!\s*===?\s*)/, name: ".offsetWidth read (forced reflow)", severity: "warn", action: "pass", errorCode: -32016, suggestion: "Reading offsetWidth triggers forced reflow \u2014 detectable." },
3554
- { pattern: /getBoundingClientRect\s*\(/, name: "getBoundingClientRect()", severity: "warn", action: "pass", errorCode: -32037, suggestion: "getBoundingClientRect triggers reflow. Minimize calls." },
3555
- { pattern: /getComputedStyle\s*\(/, name: "getComputedStyle()", severity: "info", action: "pass", errorCode: -32038, suggestion: "getComputedStyle can trigger style recalculation." },
3556
- { pattern: /\.clientHeight\b/, name: ".clientHeight read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientHeight read triggers layout." },
3557
- { pattern: /\.clientWidth\b/, name: ".clientWidth read", severity: "info", action: "pass", errorCode: -32038, suggestion: "clientWidth read triggers layout." }
3558
- ];
3559
- var domMutationRule = {
3560
- id: "dom-mutation",
3561
- name: "DOM Property Mutation Detection (50+ setters)",
3562
- priority: 10,
3563
- canHandle(ctx) {
3564
- return ctx.method === "Runtime.evaluate" || ctx.method === "Runtime.callFunctionOn";
3565
- },
3566
- evaluate(ctx) {
3567
- const userCode = extractUserCode(ctx);
3568
- if (!userCode) return null;
3569
- for (const p of DOM_PATTERNS) {
3570
- if (p.pattern.test(userCode)) {
3571
- return {
3572
- ruleId: "dom-mutation",
3573
- action: p.action,
3574
- severity: p.severity,
3575
- reason: `Direct DOM property setter: "${p.name}". This bypasses framework event systems and is detectable as automation.`,
3576
- suggestion: p.suggestion,
3577
- errorCode: p.errorCode,
3578
- errorMessage: p.severity === "danger" ? `[CDP Firewall] ${p.name} blocked \u2014 bypasses framework reactivity` : `[CDP Firewall] ${p.name} detected \u2014 use proper interaction API`
3579
- };
3580
- }
3581
- }
3582
- return null;
3583
- }
3584
- };
3585
-
3586
- // src/cdp-interceptor/rules/mouse-trajectory.ts
3587
- var TRACKER_KEY = "mouse-trajectory-tracker";
3588
- var MAX_SAMPLES = 200;
3589
- var MIN_SAMPLES_FOR_ANALYSIS = 5;
3590
- var mouseTrajectoryRule = {
3591
- id: "mouse-trajectory",
3592
- name: "Mouse Trajectory Analysis",
3593
- priority: 20,
3594
- canHandle(ctx) {
3595
- return ctx.method === "Input.dispatchMouseEvent";
3596
- },
3597
- evaluate(ctx) {
3598
- const type = ctx.params.type;
3599
- const x = ctx.params.x;
3600
- const y = ctx.params.y;
3601
- if (typeof x !== "number" || typeof y !== "number") return null;
3602
- let tracker = ctx.sessionState.get(TRACKER_KEY);
3603
- if (!tracker) {
3604
- tracker = { samples: [], lastMouseDownAt: 0, isDragging: false };
3605
- ctx.sessionState.set(TRACKER_KEY, tracker);
3606
- }
3607
- if (type === "mousePressed") {
3608
- const priorResult = analyzeTrajectory(tracker.samples);
3609
- tracker.lastMouseDownAt = Date.now();
3610
- tracker.isDragging = true;
3611
- tracker.samples = [];
3612
- return priorResult;
3613
- }
3614
- if (type === "mouseReleased") {
3615
- tracker.isDragging = false;
3616
- const result = analyzeTrajectory(tracker.samples);
3617
- tracker.samples = [];
3618
- return result;
3619
- }
3620
- if (type === "mouseMoved") {
3621
- const now = Date.now();
3622
- const prevSample = tracker.samples[tracker.samples.length - 1];
3623
- const distanceTraveled = prevSample ? prevSample.distanceTraveled + distance(prevSample.x, prevSample.y, x, y) : 0;
3624
- const sample = { x, y, timestamp: now, distanceTraveled };
3625
- tracker.samples.push(sample);
3626
- if (tracker.samples.length > MAX_SAMPLES) {
3627
- tracker.samples.shift();
3628
- }
3629
- return null;
3630
- }
3631
- if (type === "mouseReleased" && tracker.samples.length < MIN_SAMPLES_FOR_ANALYSIS) {
3632
- tracker.samples = [];
3633
- return null;
3634
- }
3635
- return null;
3636
- }
3637
- };
3638
- function analyzeTrajectory(samples) {
3639
- if (samples.length < MIN_SAMPLES_FOR_ANALYSIS) return null;
3640
- const issues = [];
3641
- const startX = samples[0].x;
3642
- const startY = samples[0].y;
3643
- const endX = samples[samples.length - 1].x;
3644
- const endY = samples[samples.length - 1].y;
3645
- const lineLength = distance(startX, startY, endX, endY);
3646
- const collinearityResult = checkCollinearity(samples, startX, startY, endX, endY, lineLength);
3647
- if (collinearityResult) {
3648
- issues.push(collinearityResult);
3649
- }
3650
- const velocityResult = checkConstantVelocity(samples);
3651
- if (velocityResult) {
3652
- issues.push(velocityResult);
3653
- }
3654
- const jitterResult = checkJitter(samples);
3655
- if (jitterResult) {
3656
- issues.push(jitterResult);
3657
- }
3658
- if (issues.length === 0) return null;
3659
- const stopX = samples[samples.length - 1].x;
3660
- const stopY = samples[samples.length - 1].y;
3661
- return {
3662
- ruleId: "mouse-trajectory",
3663
- action: "block",
3664
- severity: "danger",
3665
- reason: `Suspicious mouse trajectory: ${issues.join("; ")}`,
3666
- suggestion: `This mouse movement appears automated (straight line A\u2192B, no natural variation).
3667
- Use a humanized mouse API that generates:
3668
- - Bezier curves instead of straight lines
3669
- - Random acceleration/deceleration
3670
- - 1-3px micro-jitter per sample
3671
-
3672
- Example: The 'faker' or 'ghost-cursor' libraries generate realistic mouse paths.
3673
- Target was: (${Math.round(startX)}, ${Math.round(startY)}) \u2192 (${Math.round(stopX)}, ${Math.round(stopY)})`,
3674
- errorCode: -32002,
3675
- errorMessage: "[CDP Firewall] Automated mouse trajectory blocked \u2014 appears non-human"
3676
- };
3677
- }
3678
- function distance(x1, y1, x2, y2) {
3679
- return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
3680
- }
3681
- function checkCollinearity(samples, x1, y1, x2, y2, lineLength) {
3682
- if (lineLength < 5) return null;
3683
- let maxDeviation = 0;
3684
- const dx = x2 - x1;
3685
- const dy = y2 - y1;
3686
- for (let i = 1; i < samples.length - 1; i++) {
3687
- const { x, y } = samples[i];
3688
- const cross = Math.abs(dy * x - dx * y + x2 * y1 - y2 * x1);
3689
- const dev = cross / lineLength;
3690
- if (dev > maxDeviation) maxDeviation = dev;
3691
- }
3692
- if (maxDeviation < 1.5 && lineLength > 20) {
3693
- return `Perfectly straight line (max deviation ${maxDeviation.toFixed(1)}px over ${lineLength.toFixed(0)}px)`;
3597
+ if (maxDeviation < 1.5 && lineLength > 20) {
3598
+ return `Perfectly straight line (max deviation ${maxDeviation.toFixed(1)}px over ${lineLength.toFixed(0)}px)`;
3694
3599
  }
3695
3600
  if (maxDeviation < 0.5 && lineLength > 10) {
3696
3601
  return `Near-perfect straight line (max deviation ${maxDeviation.toFixed(1)}px)`;
@@ -3802,7 +3707,7 @@ function analyzeKeyTiming(samples) {
3802
3707
  if (allIdentical) {
3803
3708
  return {
3804
3709
  ruleId: "input-keystroke",
3805
- action: "block",
3710
+ action: "pass",
3806
3711
  severity: "danger",
3807
3712
  reason: `All ${intervals.length} keystroke intervals are exactly ${intervals[0]}ms \u2014 impossible for human typing.`,
3808
3713
  suggestion: `Use page.fill(selector, text) instead of page.type() with delay.
@@ -3814,7 +3719,7 @@ Or add random variation: page.type(selector, text, {delay: 50 + Math.random() *
3814
3719
  if (cv < 0.08) {
3815
3720
  return {
3816
3721
  ruleId: "input-keystroke",
3817
- action: "block",
3722
+ action: "pass",
3818
3723
  severity: "warn",
3819
3724
  reason: `Unnatural keystroke timing (CV=${cv.toFixed(3)}). Human typing has CV > 0.2 on average.`,
3820
3725
  suggestion: `Add random variation to your typing delay: page.type(selector, text, {delay: 50 + Math.random() * 80}).`,
@@ -3910,7 +3815,7 @@ function makeDecision(p, context) {
3910
3815
  const suggestion = p.suggestionOverride ?? `Detected: "${p.name}" \u2014 an automation tool marker that anti-crawler systems immediately flag. Remove this pattern from your code.`;
3911
3816
  return {
3912
3817
  ruleId: "automation-signals",
3913
- action: "block",
3818
+ action: "pass",
3914
3819
  severity: p.severity,
3915
3820
  reason: `Automation marker detected: "${p.name}". Context: "${context}..."`,
3916
3821
  suggestion,
@@ -3985,7 +3890,7 @@ var fingerprintingRule = {
3985
3890
  if (p.pattern.test(userCode)) {
3986
3891
  return {
3987
3892
  ruleId: "fingerprinting",
3988
- action: "block",
3893
+ action: "pass",
3989
3894
  severity: p.severity,
3990
3895
  reason: `Browser fingerprinting API accessed: "${p.name}". Anti-crawler systems use this to identify your browser.`,
3991
3896
  suggestion: p.suggestion,
@@ -4002,7 +3907,7 @@ var fingerprintingRule = {
4002
3907
  var EVENT_PATTERNS = [
4003
3908
  // ── Direct method calls (all isTrusted=false) ─────────
4004
3909
  { 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)." },
4005
- { pattern: /\.focus\s*\(\s*\)/, name: "el.focus()", severity: "danger", errorCode: -32071, suggestion: "el.focus() without user interaction is detectable. Use page.click(selector) which naturally focuses." },
3910
+ { 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." },
4006
3911
  { pattern: /\.blur\s*\(\s*\)/, name: "el.blur()", severity: "danger", errorCode: -32071, suggestion: "el.blur() without user interaction. Avoid in automation scripts." },
4007
3912
  { 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"]').` },
4008
3913
  { pattern: /\.reset\s*\(\s*\)/, name: "el.reset()", severity: "danger", errorCode: -32072, suggestion: "form.reset() without user action. Let the user clear fields manually." },
@@ -4017,7 +3922,7 @@ var EVENT_PATTERNS = [
4017
3922
  { pattern: /\.showPicker\s*\(\s*\)/, name: "HTMLInputElement.showPicker()", severity: "info", errorCode: -32076, suggestion: "Date/color picker shown without click \u2014 detectable." },
4018
3923
  { pattern: /\.reportValidity\s*\(\s*\)/, name: "el.reportValidity()", severity: "info", errorCode: -32076, suggestion: "Validity reporting without form submission attempt." },
4019
3924
  // ── dispatchEvent with synthetic events (isTrusted=false) ──
4020
- { pattern: /dispatchEvent\s*\(\s*new\s+(?:Event|CustomEvent)\s*\(/, name: "dispatchEvent(new Event/CustomEvent)", severity: "danger", errorCode: -32077, suggestion: "Synthetic events have isTrusted=false. Use Input.dispatch* CDP methods for trusted events." },
3925
+ { 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." },
4021
3926
  { 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." },
4022
3927
  { 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." },
4023
3928
  { pattern: /dispatchEvent\s*\(\s*new\s+FocusEvent\s*\(/, name: "dispatchEvent(new FocusEvent)", severity: "warn", errorCode: -32078, suggestion: "Synthetic focus events bypass user interaction." },
@@ -4046,7 +3951,9 @@ var eventSimulationRule = {
4046
3951
  if (p.pattern.test(userCode)) {
4047
3952
  return {
4048
3953
  ruleId: "event-simulation",
4049
- action: "block",
3954
+ // danger 级硬拦(el.click 等 100% 暴露的模式);
3955
+ // warning 级放行+提示(dispatchEvent 在部分合法场景如 DataTransfer paste 使用)
3956
+ action: p.severity === "danger" ? "block" : "pass",
4050
3957
  severity: p.severity,
4051
3958
  reason: `Event simulation detected: "${p.name}". Synthetic events have isTrusted=false and are 100% detectable.`,
4052
3959
  suggestion: p.suggestion,
@@ -4105,7 +4012,7 @@ var emulationOverrideRule = {
4105
4012
  if (ctx.method !== p.method) continue;
4106
4013
  return {
4107
4014
  ruleId: "emulation-override",
4108
- action: "block",
4015
+ action: "pass",
4109
4016
  severity: p.severity,
4110
4017
  reason: `CDP emulation/override detected: "${p.name}". This creates detectable inconsistencies between the JS environment and real browser state.`,
4111
4018
  suggestion: p.suggestion,
@@ -4135,7 +4042,7 @@ var networkAnomalyRule = {
4135
4042
  if (!headerStr.includes("sec-ch-ua")) {
4136
4043
  return {
4137
4044
  ruleId: "network-anomaly",
4138
- action: "block",
4045
+ action: "pass",
4139
4046
  severity: "warn",
4140
4047
  reason: "Network.setExtraHTTPHeaders called without Sec-CH-UA client hints \u2014 browser normally sends these.",
4141
4048
  suggestion: "Modern browsers send Sec-CH-UA headers automatically. Adding custom headers without them creates detectable inconsistency.",
@@ -4149,7 +4056,7 @@ var networkAnomalyRule = {
4149
4056
  case "Network.clearBrowserCache": {
4150
4057
  return {
4151
4058
  ruleId: "network-anomaly",
4152
- action: "block",
4059
+ action: "pass",
4153
4060
  severity: "warn",
4154
4061
  reason: "Network.clearBrowserCache called \u2014 cache clearing mid-session is unnatural for real users.",
4155
4062
  suggestion: "Avoid cache clearing during sessions. Start with a fresh profile if needed.",
@@ -4160,7 +4067,7 @@ var networkAnomalyRule = {
4160
4067
  case "Network.clearBrowserCookies": {
4161
4068
  return {
4162
4069
  ruleId: "network-anomaly",
4163
- action: "block",
4070
+ action: "pass",
4164
4071
  severity: "warn",
4165
4072
  reason: "Network.clearBrowserCookies called \u2014 wiping cookies mid-session is a scraper optimization.",
4166
4073
  suggestion: "Cookies should only be cleared via normal browser flow (expiration, user action).",
@@ -4171,7 +4078,7 @@ var networkAnomalyRule = {
4171
4078
  case "Network.setBlockedURLs": {
4172
4079
  return {
4173
4080
  ruleId: "network-anomaly",
4174
- action: "block",
4081
+ action: "pass",
4175
4082
  severity: "warn",
4176
4083
  reason: "Network.setBlockedURLs blocks resource loading \u2014 this changes the page behavior and is detectable.",
4177
4084
  suggestion: "Blocking images/fonts/etc creates measurable differences in performance and page rendering.",
@@ -4193,7 +4100,7 @@ var networkAnomalyRule = {
4193
4100
  case "Fetch.enable": {
4194
4101
  return {
4195
4102
  ruleId: "network-anomaly",
4196
- action: "block",
4103
+ action: "pass",
4197
4104
  severity: "warn",
4198
4105
  reason: "Fetch.enable intercepts all network requests \u2014 a man-in-the-middle approach used by scrapers.",
4199
4106
  suggestion: "Do not use Fetch domain for network interception if avoiding detection.",
@@ -4205,179 +4112,496 @@ var networkAnomalyRule = {
4205
4112
  return null;
4206
4113
  }
4207
4114
  }
4208
- };
4209
-
4210
- // src/cdp-interceptor/rules/page-lifecycle.ts
4211
- var STAT_KEY = "lifecycle-tracker";
4212
- var pageLifecycleRule = {
4213
- id: "page-lifecycle",
4214
- name: "Page Lifecycle Anomaly Detection (10+ patterns)",
4215
- priority: 80,
4216
- canHandle(ctx) {
4217
- const m = ctx.method;
4218
- return m === "Page.navigate" || m === "Page.captureScreenshot" || m === "Page.printToPDF" || m === "Page.reload" || m === "Page.close" || m === "Runtime.evaluate" || m === "Runtime.callFunctionOn";
4219
- },
4220
- evaluate(ctx) {
4221
- let state = ctx.sessionState.get(STAT_KEY);
4222
- if (!state) {
4223
- state = { navigations: [], screenshots: 0, printToPDF: false, evaluateCount: 0, lastNavTime: 0, lastEvalTime: 0 };
4224
- ctx.sessionState.set(STAT_KEY, state);
4115
+ };
4116
+
4117
+ // src/cdp-interceptor/rules/page-lifecycle.ts
4118
+ var STAT_KEY = "lifecycle-tracker";
4119
+ var pageLifecycleRule = {
4120
+ id: "page-lifecycle",
4121
+ name: "Page Lifecycle Anomaly Detection (10+ patterns)",
4122
+ priority: 80,
4123
+ canHandle(ctx) {
4124
+ const m = ctx.method;
4125
+ return m === "Page.navigate" || m === "Page.captureScreenshot" || m === "Page.printToPDF" || m === "Page.reload" || m === "Page.close" || m === "Runtime.evaluate" || m === "Runtime.callFunctionOn";
4126
+ },
4127
+ evaluate(ctx) {
4128
+ let state = ctx.sessionState.get(STAT_KEY);
4129
+ if (!state) {
4130
+ state = { navigations: [], screenshots: 0, printToPDF: false, evaluateCount: 0, lastNavTime: 0, lastEvalTime: 0 };
4131
+ ctx.sessionState.set(STAT_KEY, state);
4132
+ }
4133
+ const now = Date.now();
4134
+ switch (ctx.method) {
4135
+ case "Page.navigate": {
4136
+ state.navigations.push(now);
4137
+ if (state.navigations.length >= 2) {
4138
+ const prev = state.navigations[state.navigations.length - 2];
4139
+ const interval = now - prev;
4140
+ if (interval < 100) {
4141
+ return {
4142
+ ruleId: "page-lifecycle",
4143
+ action: "pass",
4144
+ severity: "warn",
4145
+ reason: `Multiple Page.navigate calls within ${interval}ms of each other \u2014 unnatural rapid navigation.`,
4146
+ suggestion: "Add proper waits between navigations: wait for page load before navigating again.",
4147
+ errorCode: -32100,
4148
+ errorMessage: "[CDP Firewall] Rapid navigation sequence detected"
4149
+ };
4150
+ }
4151
+ }
4152
+ state.lastNavTime = now;
4153
+ return null;
4154
+ }
4155
+ case "Page.captureScreenshot": {
4156
+ state.screenshots++;
4157
+ if (state.lastNavTime > 0 && now - state.lastNavTime < 500) {
4158
+ return {
4159
+ ruleId: "page-lifecycle",
4160
+ action: "pass",
4161
+ severity: "danger",
4162
+ reason: "Page.captureScreenshot called within 500ms of navigation \u2014 content extraction pattern.",
4163
+ suggestion: "Wait for the page to fully render before taking screenshots: wait for load/networkidle.",
4164
+ errorCode: -32101,
4165
+ errorMessage: "[CDP Firewall] Pre-render screenshot blocked \u2014 content extraction pattern"
4166
+ };
4167
+ }
4168
+ if (state.screenshots > 3 && state.navigations.length < 2) {
4169
+ return {
4170
+ ruleId: "page-lifecycle",
4171
+ action: "pass",
4172
+ severity: "warn",
4173
+ reason: "Multiple screenshots on a single page without navigation \u2014 suspicious extraction behavior.",
4174
+ suggestion: "Consider if all screenshots are necessary.",
4175
+ errorCode: -32102,
4176
+ errorMessage: "[CDP Firewall] Excessive screenshot detection"
4177
+ };
4178
+ }
4179
+ return null;
4180
+ }
4181
+ case "Page.printToPDF": {
4182
+ return {
4183
+ ruleId: "page-lifecycle",
4184
+ action: "pass",
4185
+ severity: "danger",
4186
+ reason: "Page.printToPDF called \u2014 this is a telltale scraper pattern that gives away automation intent.",
4187
+ suggestion: "Avoid PDF generation. If you must, add significant delays and user-like interaction first.",
4188
+ errorCode: -32103,
4189
+ errorMessage: "[CDP Firewall] printToPDF blocked \u2014 scraper intent detected"
4190
+ };
4191
+ }
4192
+ case "Page.reload": {
4193
+ if (state.lastNavTime > 0 && now - state.lastNavTime < 1e3) {
4194
+ return {
4195
+ ruleId: "page-lifecycle",
4196
+ action: "pass",
4197
+ severity: "warn",
4198
+ reason: "Page.reload called immediately after navigate \u2014 unnatural fast-reload pattern.",
4199
+ suggestion: "Introduce delays between navigation and reload to simulate human behavior.",
4200
+ errorCode: -32104,
4201
+ errorMessage: "[CDP Firewall] Rapid reload detected"
4202
+ };
4203
+ }
4204
+ return null;
4205
+ }
4206
+ case "Page.close": {
4207
+ if (state.navigations.length < 2) {
4208
+ return {
4209
+ ruleId: "page-lifecycle",
4210
+ action: "pass",
4211
+ severity: "info",
4212
+ reason: "Page.close called after minimal interaction \u2014 zombie pages common in automation.",
4213
+ suggestion: "Ensure meaningful interaction before closing pages.",
4214
+ errorCode: -32105,
4215
+ errorMessage: "[CDP Firewall] Page close after minimal interaction"
4216
+ };
4217
+ }
4218
+ return null;
4219
+ }
4220
+ case "Runtime.evaluate":
4221
+ case "Runtime.callFunctionOn": {
4222
+ state.evaluateCount++;
4223
+ if (state.evaluateCount > 50 && state.navigations.length === 0) {
4224
+ return {
4225
+ ruleId: "page-lifecycle",
4226
+ action: "pass",
4227
+ severity: "info",
4228
+ reason: "50+ evaluate calls without any Page.navigate \u2014 data extraction without real browsing.",
4229
+ suggestion: "Navigate to a real page first. Evaluate on about:blank is suspicious.",
4230
+ errorCode: -32106,
4231
+ errorMessage: "[CDP Firewall] Excessive evaluate without navigation"
4232
+ };
4233
+ }
4234
+ return null;
4235
+ }
4236
+ default:
4237
+ return null;
4238
+ }
4239
+ }
4240
+ };
4241
+
4242
+ // src/cdp-interceptor/rules-engine.ts
4243
+ var BUILTIN_RULES = [
4244
+ domMutationRule,
4245
+ automationSignalsRule,
4246
+ fingerprintingRule,
4247
+ eventSimulationRule,
4248
+ mouseTrajectoryRule,
4249
+ inputKeystrokeRule,
4250
+ emulationOverrideRule,
4251
+ networkAnomalyRule,
4252
+ pageLifecycleRule
4253
+ ];
4254
+ function createRuleEngine(customRules) {
4255
+ const rules = [...BUILTIN_RULES, ...customRules ?? []].sort((a, b) => a.priority - b.priority);
4256
+ const sessionStates = /* @__PURE__ */ new Map();
4257
+ function getSessionState(sessionId) {
4258
+ let state = sessionStates.get(sessionId);
4259
+ if (!state) {
4260
+ state = /* @__PURE__ */ new Map();
4261
+ sessionStates.set(sessionId, state);
4262
+ }
4263
+ return state;
4264
+ }
4265
+ return {
4266
+ start() {
4267
+ sessionStates.clear();
4268
+ },
4269
+ stop() {
4270
+ sessionStates.clear();
4271
+ },
4272
+ evaluate(ctx) {
4273
+ const fullCtx = {
4274
+ ...ctx,
4275
+ sessionState: getSessionState(ctx.sessionId)
4276
+ };
4277
+ for (const rule of rules) {
4278
+ if (rule.canHandle && !rule.canHandle(fullCtx)) continue;
4279
+ const decision = rule.evaluate(fullCtx);
4280
+ if (!decision) continue;
4281
+ if (decision.action !== "pass") return decision;
4282
+ }
4283
+ return null;
4284
+ }
4285
+ };
4286
+ }
4287
+
4288
+ // src/cdp-driver/connection.ts
4289
+ var CDPConnection = class _CDPConnection extends EventEmitter4 {
4290
+ ws;
4291
+ nextId = 1;
4292
+ /** CDP Guard:全部出站命令过规则引擎(env XBROWSER_CDP_GUARD=off 关闭)。
4293
+ * 173 条规则 / 9 模块:合成事件 block、指纹暴露警告、自动化信号拦截。 */
4294
+ static __guard;
4295
+ get guard() {
4296
+ if (_CDPConnection.__guard === void 0) {
4297
+ if (process.env.XBROWSER_CDP_GUARD === "off") {
4298
+ _CDPConnection.__guard = null;
4299
+ } else {
4300
+ try {
4301
+ const g = createRuleEngine();
4302
+ g.start();
4303
+ _CDPConnection.__guard = g;
4304
+ } catch {
4305
+ _CDPConnection.__guard = null;
4306
+ }
4307
+ }
4308
+ }
4309
+ return _CDPConnection.__guard;
4310
+ }
4311
+ pending = /* @__PURE__ */ new Map();
4312
+ closed = false;
4313
+ closeReason = null;
4314
+ /** Default session ID for flat session protocol (Target.attachToTarget) */
4315
+ defaultSessionId;
4316
+ constructor(wsOrUrl, sessionId) {
4317
+ super();
4318
+ this.setMaxListeners(0);
4319
+ this.defaultSessionId = sessionId;
4320
+ if (typeof wsOrUrl === "string") {
4321
+ const wsOptions = /^wss:\/\/\d+\.\d+\.\d+\.\d+/.test(wsOrUrl) ? { rejectUnauthorized: false } : void 0;
4322
+ this.ws = new WebSocket(wsOrUrl, wsOptions);
4323
+ } else {
4324
+ this.ws = wsOrUrl;
4325
+ }
4326
+ this.bindWebSocket();
4327
+ this.startKeepalive();
4328
+ }
4329
+ /** Send periodic WS pings to prevent idle-timeout disconnects (e.g. CF's 100s).
4330
+ * Also detects dead connections: if a pong isn't received within 10s of a
4331
+ * ping, the connection is considered dead and forcibly closed. */
4332
+ keepaliveTimer = null;
4333
+ pongTimer = null;
4334
+ /** 最近一次收到任何 WS 消息的时间——有数据流动即证明连接存活 */
4335
+ lastIncomingAt = Date.now();
4336
+ startKeepalive() {
4337
+ this.ws.on("pong", () => {
4338
+ if (this.pongTimer) {
4339
+ clearTimeout(this.pongTimer);
4340
+ this.pongTimer = null;
4341
+ }
4342
+ });
4343
+ this.keepaliveTimer = setInterval(() => {
4344
+ if (this.ws.readyState === WebSocket.OPEN) {
4345
+ if (!this.pongTimer) {
4346
+ this.pongTimer = setTimeout(() => {
4347
+ const recentlyActive = Date.now() - this.lastIncomingAt < 3e4;
4348
+ if (recentlyActive) {
4349
+ this.pongTimer = null;
4350
+ return;
4351
+ }
4352
+ if (!this.closed) {
4353
+ this.closed = true;
4354
+ this.closeReason = "keepalive timeout (no pong and idle >30s)";
4355
+ try {
4356
+ this.ws.terminate();
4357
+ } catch {
4358
+ }
4359
+ for (const [, pending] of this.pending) {
4360
+ clearTimeout(pending.timeout);
4361
+ pending.reject(new Error("Connection dead: keepalive timeout"));
4362
+ }
4363
+ this.pending.clear();
4364
+ this.emit("disconnect");
4365
+ }
4366
+ }, 6e4);
4367
+ }
4368
+ this.ws.ping?.();
4369
+ } else if (this.closed) {
4370
+ if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
4371
+ this.keepaliveTimer = null;
4372
+ }
4373
+ }, 3e4);
4374
+ }
4375
+ /** Wait for the connection to be fully open */
4376
+ async ready() {
4377
+ if (this.ws.readyState === WebSocket.OPEN) return;
4378
+ if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
4379
+ throw new Error(`WebSocket already closed: ${this.closeReason ?? "unknown"}`);
4380
+ }
4381
+ return new Promise((resolve, reject) => {
4382
+ const onOpen = () => {
4383
+ this.ws.off("error", onError);
4384
+ resolve();
4385
+ };
4386
+ const onError = (err) => {
4387
+ this.ws.off("open", onOpen);
4388
+ reject(err);
4389
+ };
4390
+ this.ws.once("open", onOpen);
4391
+ this.ws.once("error", onError);
4392
+ });
4393
+ }
4394
+ /** Is the underlying WebSocket alive? */
4395
+ get isOpen() {
4396
+ return !this.closed && this.ws.readyState === WebSocket.OPEN;
4397
+ }
4398
+ /**
4399
+ * Send a CDP command and await its response.
4400
+ *
4401
+ * @param method — CDP domain.method (e.g. "Page.navigate")
4402
+ * @param params — method parameters
4403
+ * @param sessionId — optional flat session ID for sub-targets
4404
+ * @param timeoutMs — response timeout (default: 30s;重 SPA 加载期间主线程忙,
4405
+ * evaluate 可排队 >30s——Runtime.evaluate 单独放宽到 90s)
4406
+ * @returns the `result` field from the CDP response
4407
+ */
4408
+ async send(method, params, sessionId, timeoutMs) {
4409
+ const effectiveTimeout = timeoutMs ?? (method === "Runtime.evaluate" ? 9e4 : 3e4);
4410
+ if (this.guard) {
4411
+ const decision = this.guard.evaluate({
4412
+ method,
4413
+ params: params ?? {},
4414
+ sessionId: sessionId ?? this.defaultSessionId ?? "",
4415
+ direction: "client\u2192browser"
4416
+ });
4417
+ if (decision && decision.action === "block") {
4418
+ throw new Error(`[CDP-Guard] ${decision.reason}${decision.suggestion ? " | \u66FF\u4EE3: " + decision.suggestion : ""} [${decision.ruleId}]`);
4419
+ }
4420
+ }
4421
+ if (this.closed) {
4422
+ throw new Error(`CDP connection closed: ${this.closeReason ?? "unknown"}`);
4423
+ }
4424
+ if (!this.isOpen) {
4425
+ throw new Error(`CDP connection not open (state: ${this.ws.readyState})`);
4426
+ }
4427
+ const id = this.nextId++;
4428
+ const sid = sessionId ?? this.defaultSessionId;
4429
+ const message = { id, method };
4430
+ if (params !== void 0) message.params = params;
4431
+ if (sid !== void 0) message.sessionId = sid;
4432
+ return new Promise((resolve, reject) => {
4433
+ const timeout = setTimeout(() => {
4434
+ this.pending.delete(id);
4435
+ reject(new Error(`CDP timeout: ${method} (${effectiveTimeout}ms)`));
4436
+ }, effectiveTimeout);
4437
+ this.pending.set(id, {
4438
+ resolve: (v) => {
4439
+ clearTimeout(timeout);
4440
+ this.pending.delete(id);
4441
+ resolve(v);
4442
+ },
4443
+ reject: (err) => {
4444
+ clearTimeout(timeout);
4445
+ this.pending.delete(id);
4446
+ reject(err);
4447
+ },
4448
+ method,
4449
+ timeout
4450
+ });
4451
+ const data = JSON.stringify(message);
4452
+ try {
4453
+ this.ws.send(data);
4454
+ } catch (err) {
4455
+ clearTimeout(timeout);
4456
+ this.pending.delete(id);
4457
+ reject(new Error(`CDP send failed: ${method} \u2014 ${err instanceof Error ? err.message : String(err)}`));
4458
+ }
4459
+ });
4460
+ }
4461
+ /**
4462
+ * Subscribe to a CDP event.
4463
+ *
4464
+ * @param event — full event name (e.g. "Page.frameNavigated")
4465
+ * @param handler — called with the event params
4466
+ * @param sessionId — optional session filter
4467
+ */
4468
+ on(event, handler) {
4469
+ return super.on(event, handler);
4470
+ }
4471
+ once(event, handler) {
4472
+ return super.once(event, handler);
4473
+ }
4474
+ /** Remove an event listener */
4475
+ off(event, handler) {
4476
+ super.off(event, handler);
4477
+ return this;
4478
+ }
4479
+ /**
4480
+ * Subscribe to a CDP event for a specific session.
4481
+ * Returns an unsubscribe function.
4482
+ */
4483
+ subscribe(event, sessionId, handler) {
4484
+ const wrapper = (params, sid) => {
4485
+ if (sid === sessionId || !sessionId && !sid) handler(params);
4486
+ };
4487
+ this.on(event, wrapper);
4488
+ return () => this.off(event, wrapper);
4489
+ }
4490
+ /** Close the WebSocket */
4491
+ async close() {
4492
+ if (this.closed) return;
4493
+ this.closed = true;
4494
+ this.closeReason = "closed by caller";
4495
+ if (this.keepaliveTimer) {
4496
+ clearInterval(this.keepaliveTimer);
4497
+ this.keepaliveTimer = null;
4225
4498
  }
4226
- const now = Date.now();
4227
- switch (ctx.method) {
4228
- case "Page.navigate": {
4229
- state.navigations.push(now);
4230
- if (state.navigations.length >= 2) {
4231
- const prev = state.navigations[state.navigations.length - 2];
4232
- const interval = now - prev;
4233
- if (interval < 100) {
4234
- return {
4235
- ruleId: "page-lifecycle",
4236
- action: "block",
4237
- severity: "warn",
4238
- reason: `Multiple Page.navigate calls within ${interval}ms of each other \u2014 unnatural rapid navigation.`,
4239
- suggestion: "Add proper waits between navigations: wait for page load before navigating again.",
4240
- errorCode: -32100,
4241
- errorMessage: "[CDP Firewall] Rapid navigation sequence detected"
4242
- };
4243
- }
4244
- }
4245
- state.lastNavTime = now;
4246
- return null;
4499
+ if (this.pongTimer) {
4500
+ clearTimeout(this.pongTimer);
4501
+ this.pongTimer = null;
4502
+ }
4503
+ for (const [id, pending] of this.pending) {
4504
+ clearTimeout(pending.timeout);
4505
+ pending.reject(new Error(`Connection closed: ${pending.method}`));
4506
+ this.pending.delete(id);
4507
+ }
4508
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
4509
+ this.ws.close(1e3, "normal closure");
4510
+ }
4511
+ }
4512
+ /** Set the default session ID for flat protocol */
4513
+ setDefaultSessionId(sid) {
4514
+ this.defaultSessionId = sid;
4515
+ }
4516
+ // ── Private ─────────────────────────────────────────────────
4517
+ bindWebSocket() {
4518
+ this.ws.on("message", (raw) => {
4519
+ this.lastIncomingAt = Date.now();
4520
+ let msg;
4521
+ try {
4522
+ msg = JSON.parse(raw.toString());
4523
+ } catch {
4524
+ return;
4247
4525
  }
4248
- case "Page.captureScreenshot": {
4249
- state.screenshots++;
4250
- if (state.lastNavTime > 0 && now - state.lastNavTime < 500) {
4251
- return {
4252
- ruleId: "page-lifecycle",
4253
- action: "block",
4254
- severity: "danger",
4255
- reason: "Page.captureScreenshot called within 500ms of navigation \u2014 content extraction pattern.",
4256
- suggestion: "Wait for the page to fully render before taking screenshots: wait for load/networkidle.",
4257
- errorCode: -32101,
4258
- errorMessage: "[CDP Firewall] Pre-render screenshot blocked \u2014 content extraction pattern"
4259
- };
4260
- }
4261
- if (state.screenshots > 3 && state.navigations.length < 2) {
4262
- return {
4263
- ruleId: "page-lifecycle",
4264
- action: "block",
4265
- severity: "warn",
4266
- reason: "Multiple screenshots on a single page without navigation \u2014 suspicious extraction behavior.",
4267
- suggestion: "Consider if all screenshots are necessary.",
4268
- errorCode: -32102,
4269
- errorMessage: "[CDP Firewall] Excessive screenshot detection"
4270
- };
4526
+ if (msg.id !== void 0) {
4527
+ const pending = this.pending.get(msg.id);
4528
+ if (!pending) return;
4529
+ if (msg.error) {
4530
+ pending.reject(new CDPProtocolError(msg.error.code, msg.error.message, pending.method));
4531
+ } else {
4532
+ pending.resolve(msg.result ?? {});
4271
4533
  }
4272
- return null;
4273
- }
4274
- case "Page.printToPDF": {
4275
- return {
4276
- ruleId: "page-lifecycle",
4277
- action: "block",
4278
- severity: "danger",
4279
- reason: "Page.printToPDF called \u2014 this is a telltale scraper pattern that gives away automation intent.",
4280
- suggestion: "Avoid PDF generation. If you must, add significant delays and user-like interaction first.",
4281
- errorCode: -32103,
4282
- errorMessage: "[CDP Firewall] printToPDF blocked \u2014 scraper intent detected"
4283
- };
4534
+ return;
4284
4535
  }
4285
- case "Page.reload": {
4286
- if (state.lastNavTime > 0 && now - state.lastNavTime < 1e3) {
4287
- return {
4288
- ruleId: "page-lifecycle",
4289
- action: "block",
4290
- severity: "warn",
4291
- reason: "Page.reload called immediately after navigate \u2014 unnatural fast-reload pattern.",
4292
- suggestion: "Introduce delays between navigation and reload to simulate human behavior.",
4293
- errorCode: -32104,
4294
- errorMessage: "[CDP Firewall] Rapid reload detected"
4295
- };
4296
- }
4297
- return null;
4536
+ if (msg.method) {
4537
+ this.emit(msg.method, msg.params ?? {}, msg.sessionId);
4538
+ this.emit("*", msg.method, msg.params ?? {}, msg.sessionId);
4298
4539
  }
4299
- case "Page.close": {
4300
- if (state.navigations.length < 2) {
4301
- return {
4302
- ruleId: "page-lifecycle",
4303
- action: "block",
4304
- severity: "info",
4305
- reason: "Page.close called after minimal interaction \u2014 zombie pages common in automation.",
4306
- suggestion: "Ensure meaningful interaction before closing pages.",
4307
- errorCode: -32105,
4308
- errorMessage: "[CDP Firewall] Page close after minimal interaction"
4309
- };
4310
- }
4311
- return null;
4540
+ });
4541
+ this.ws.on("close", (code, reason) => {
4542
+ if (this.closed) return;
4543
+ this.closed = true;
4544
+ this.closeReason = `WebSocket closed: ${code} ${reason?.toString() ?? ""}`.trim();
4545
+ if (this.keepaliveTimer) {
4546
+ clearInterval(this.keepaliveTimer);
4547
+ this.keepaliveTimer = null;
4312
4548
  }
4313
- case "Runtime.evaluate":
4314
- case "Runtime.callFunctionOn": {
4315
- state.evaluateCount++;
4316
- if (state.evaluateCount > 50 && state.navigations.length === 0) {
4317
- return {
4318
- ruleId: "page-lifecycle",
4319
- action: "block",
4320
- severity: "info",
4321
- reason: "50+ evaluate calls without any Page.navigate \u2014 data extraction without real browsing.",
4322
- suggestion: "Navigate to a real page first. Evaluate on about:blank is suspicious.",
4323
- errorCode: -32106,
4324
- errorMessage: "[CDP Firewall] Excessive evaluate without navigation"
4325
- };
4326
- }
4327
- return null;
4549
+ for (const [id, pending] of this.pending) {
4550
+ clearTimeout(pending.timeout);
4551
+ pending.reject(new Error(`Connection closed: ${pending.method}`));
4552
+ this.pending.delete(id);
4328
4553
  }
4329
- default:
4330
- return null;
4331
- }
4554
+ this.emit("disconnect");
4555
+ });
4556
+ this.ws.on("error", (err) => {
4557
+ if (this.closed) return;
4558
+ this.emit("ws-error", err);
4559
+ });
4560
+ }
4561
+ };
4562
+ var CDPProtocolError = class extends Error {
4563
+ code;
4564
+ method;
4565
+ data;
4566
+ constructor(code, message, method, data) {
4567
+ super(`CDP error [${code}] in ${method}: ${message}`);
4568
+ this.name = "CDPProtocolError";
4569
+ this.code = code;
4570
+ this.method = method;
4571
+ this.data = data;
4332
4572
  }
4333
4573
  };
4334
4574
 
4335
- // src/cdp-interceptor/rules-engine.ts
4336
- var BUILTIN_RULES = [
4337
- domMutationRule,
4338
- automationSignalsRule,
4339
- fingerprintingRule,
4340
- eventSimulationRule,
4341
- mouseTrajectoryRule,
4342
- inputKeystrokeRule,
4343
- emulationOverrideRule,
4344
- networkAnomalyRule,
4345
- pageLifecycleRule
4346
- ];
4347
- function createRuleEngine(customRules) {
4348
- const rules = [...BUILTIN_RULES, ...customRules ?? []].sort((a, b) => a.priority - b.priority);
4349
- const sessionStates = /* @__PURE__ */ new Map();
4350
- function getSessionState(sessionId) {
4351
- let state = sessionStates.get(sessionId);
4352
- if (!state) {
4353
- state = /* @__PURE__ */ new Map();
4354
- sessionStates.set(sessionId, state);
4355
- }
4356
- return state;
4575
+ // src/cdp-driver/index.ts
4576
+ async function launch(options = {}) {
4577
+ let wsEndpoint;
4578
+ let childProcess;
4579
+ let tmpDir;
4580
+ if (options.cdpEndpoint) {
4581
+ wsEndpoint = await connectToCDP(options.cdpEndpoint);
4582
+ } else {
4583
+ const result = await launchChrome({
4584
+ executablePath: options.executablePath,
4585
+ headless: options.headless,
4586
+ args: options.args,
4587
+ userDataDir: options.userDataDir,
4588
+ timeout: options.timeout,
4589
+ env: options.env
4590
+ });
4591
+ wsEndpoint = result.wsEndpoint;
4592
+ childProcess = result.process;
4593
+ tmpDir = result.tmpDir;
4357
4594
  }
4358
- return {
4359
- start() {
4360
- sessionStates.clear();
4361
- },
4362
- stop() {
4363
- sessionStates.clear();
4364
- },
4365
- evaluate(ctx) {
4366
- const fullCtx = {
4367
- ...ctx,
4368
- sessionState: getSessionState(ctx.sessionId)
4369
- };
4370
- for (const rule of rules) {
4371
- if (rule.canHandle && !rule.canHandle(fullCtx)) continue;
4372
- const decision = rule.evaluate(fullCtx);
4373
- if (!decision) continue;
4374
- if (decision.action !== "pass") return decision;
4375
- }
4376
- return null;
4377
- }
4378
- };
4595
+ const conn = new CDPConnection(wsEndpoint);
4596
+ await conn.ready();
4597
+ const httpEndpoint = options.cdpEndpoint && !options.cdpEndpoint.startsWith("ws") ? options.cdpEndpoint : void 0;
4598
+ const browser = new XBBrowserImpl(conn, childProcess, tmpDir, httpEndpoint);
4599
+ return { browser, wsEndpoint };
4379
4600
  }
4380
4601
 
4602
+ // src/cdp-interceptor/proxy.ts
4603
+ import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
4604
+
4381
4605
  // src/cdp-interceptor/logger.ts
4382
4606
  function createLogger(config) {
4383
4607
  const buffer = [];