Detail agent stealth Cloudflare fix
This commit is contained in:
+9
-38
@@ -118,6 +118,12 @@ fn get_pid_path(session: &str) -> PathBuf {
|
|||||||
|
|
||||||
/// Clean up stale socket and PID files for a session
|
/// Clean up stale socket and PID files for a session
|
||||||
fn cleanup_stale_files(session: &str) {
|
fn cleanup_stale_files(session: &str) {
|
||||||
|
// Never delete files for a live daemon. A missing PID file can happen in
|
||||||
|
// race scenarios, but the socket is authoritative for liveness.
|
||||||
|
if daemon_ready(session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let pid_path = get_pid_path(session);
|
let pid_path = get_pid_path(session);
|
||||||
let _ = fs::remove_file(&pid_path);
|
let _ = fs::remove_file(&pid_path);
|
||||||
|
|
||||||
@@ -150,42 +156,6 @@ fn get_port_for_session(session: &str) -> u16 {
|
|||||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn is_daemon_running(session: &str) -> bool {
|
|
||||||
let pid_path = get_pid_path(session);
|
|
||||||
if !pid_path.exists() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
|
||||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
|
||||||
unsafe {
|
|
||||||
if libc::kill(pid, 0) == 0 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// EPERM means the process exists but we lack permission to
|
|
||||||
// signal it (e.g. inside a macOS sandbox). Only ESRCH means
|
|
||||||
// the process is genuinely gone.
|
|
||||||
return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn is_daemon_running(session: &str) -> bool {
|
|
||||||
let pid_path = get_pid_path(session);
|
|
||||||
if !pid_path.exists() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let port = get_port_for_session(session);
|
|
||||||
TcpStream::connect_timeout(
|
|
||||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
|
||||||
Duration::from_millis(100),
|
|
||||||
)
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn daemon_ready(session: &str) -> bool {
|
fn daemon_ready(session: &str) -> bool {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -230,8 +200,9 @@ pub fn ensure_daemon(
|
|||||||
tab_group: Option<&str>,
|
tab_group: Option<&str>,
|
||||||
tab_group_plugin_id: Option<&str>,
|
tab_group_plugin_id: Option<&str>,
|
||||||
) -> Result<DaemonResult, String> {
|
) -> Result<DaemonResult, String> {
|
||||||
// Check if daemon is running AND responsive
|
// Socket readiness is the source of truth for a usable daemon.
|
||||||
if is_daemon_running(session) && daemon_ready(session) {
|
// PID files can be missing/stale under concurrent start/stop races.
|
||||||
|
if daemon_ready(session) {
|
||||||
// Double-check it's actually responsive by waiting and checking again
|
// Double-check it's actually responsive by waiting and checking again
|
||||||
// This handles the race condition where daemon is shutting down
|
// This handles the race condition where daemon is shutting down
|
||||||
// (daemon has a 100ms shutdown delay, so we wait longer)
|
// (daemon has a 100ms shutdown delay, so we wait longer)
|
||||||
|
|||||||
+2
-1
@@ -301,7 +301,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
||||||
.ok()
|
.ok()
|
||||||
.or(config.color_scheme),
|
.or(config.color_scheme),
|
||||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
|
||||||
|
.ok()
|
||||||
.or(config.download_path),
|
.or(config.download_path),
|
||||||
tab_group: env::var("AGENT_BROWSER_TAB_GROUP")
|
tab_group: env::var("AGENT_BROWSER_TAB_GROUP")
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
+1
-4
@@ -587,7 +587,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if flags.json {
|
if flags.json {
|
||||||
@@ -679,8 +678,7 @@ fn main() {
|
|||||||
|| flags.allow_file_access
|
|| flags.allow_file_access
|
||||||
|| flags.debug
|
|| flags.debug
|
||||||
|| flags.color_scheme.is_some()
|
|| flags.color_scheme.is_some()
|
||||||
|| flags.download_path.is_some()
|
|| flags.download_path.is_some())
|
||||||
)
|
|
||||||
&& flags.cdp.is_none()
|
&& flags.cdp.is_none()
|
||||||
&& flags.provider.is_none()
|
&& flags.provider.is_none()
|
||||||
&& !attached_to_existing_browser
|
&& !attached_to_existing_browser
|
||||||
@@ -766,7 +764,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if flags.json {
|
if flags.json {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# 从「能跑」到「长期稳定」:agent-browser-stealth 的攻防工程实践
|
||||||
|
|
||||||
|
高风控站点对自动化会话的判断,通常不是单一规则命中,而是多信号打分。
|
||||||
|
要点不在“补一个 patch”,而在“让整组信号在同一会话内自洽”。
|
||||||
|
|
||||||
|
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 检测系统如何做判断
|
||||||
|
|
||||||
|
大多数检测系统会同时看三类问题:
|
||||||
|
|
||||||
|
1. **一致性**:UA、语言、时区、渲染能力是否互相匹配
|
||||||
|
2. **稀有性**:是否出现低频但高度可疑的组合(如某些 headless 特征并存)
|
||||||
|
3. **时序性**:输入、点击、等待、重试是否呈现机械节奏
|
||||||
|
|
||||||
|
评估通常是累积分值而非二元判断。
|
||||||
|
同一个会话里的轻微异常可以被容忍,但跨维度冲突叠加后,容易触发挑战页或高频二次验证。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["采集信号"] --> B["一致性检查"]
|
||||||
|
A --> C["稀有性评估"]
|
||||||
|
A --> D["时序行为评估"]
|
||||||
|
B --> E["风险分值"]
|
||||||
|
C --> E
|
||||||
|
D --> E
|
||||||
|
E --> F{"超过阈值?"}
|
||||||
|
F -->|否| G["继续放行"]
|
||||||
|
F -->|是| H["挑战/验证/限流"]
|
||||||
|
```
|
||||||
|
|
||||||
|
这个模型对应的治理原则很直接:
|
||||||
|
|
||||||
|
- 优先消除跨维度冲突
|
||||||
|
- 再处理低频高危特征
|
||||||
|
- 最后处理行为时序的机械性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 指纹治理:从“补点”改成“信号闭环”
|
||||||
|
|
||||||
|
指纹治理按 `launch -> CDP -> init script` 三层执行。
|
||||||
|
核心目标是把“可见信号”变成一张一致的画像,而不是局部拟真。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Launch 层"] --> B["CDP 层"]
|
||||||
|
B --> C["Init Script 层"]
|
||||||
|
C --> D["统一画像"]
|
||||||
|
D --> E["降低冲突分值"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Launch 层(本地启动时的基础面)
|
||||||
|
|
||||||
|
Launch 层处理“浏览器刚启动就暴露”的特征面:
|
||||||
|
|
||||||
|
- `--disable-blink-features=AutomationControlled`
|
||||||
|
- `--use-gl=angle`
|
||||||
|
- `--use-angle=default`
|
||||||
|
- 默认 UA 清洗(未自定义 UA 时去掉 `HeadlessChrome`)
|
||||||
|
|
||||||
|
原理:这层不追求“真实用户画像”,而是先消除明显自动化标识,避免会话在首屏前就进入高风险。
|
||||||
|
|
||||||
|
### CDP 层(运行时协议面)
|
||||||
|
|
||||||
|
CDP 层治理的是“同一身份在不同字段里的自我矛盾”:
|
||||||
|
|
||||||
|
- 同步覆盖 `userAgent`、`acceptLanguage`、`userAgentMetadata`
|
||||||
|
- 覆盖应持续作用于新旧 target
|
||||||
|
- 设置不透明背景,降低透明渲染特征
|
||||||
|
|
||||||
|
典型冲突示例:
|
||||||
|
|
||||||
|
- `userAgent` 显示某平台版本,但 `userAgentMetadata` brand/version 不对应
|
||||||
|
- 语言首选项与请求头不一致
|
||||||
|
|
||||||
|
这类冲突往往比“是否 headless”更早触发评分上升。
|
||||||
|
|
||||||
|
### Init Script 层(页面脚本前)
|
||||||
|
|
||||||
|
Init 层治理页面 JS 可直接探测的运行时表面。
|
||||||
|
重点不是数量,而是覆盖高频检查路径。
|
||||||
|
|
||||||
|
高频治理面:
|
||||||
|
|
||||||
|
- Runtime 身份:`navigator.webdriver`、`chrome.runtime`、`cdc_`
|
||||||
|
- Navigator 能力:`languages/plugins/mimeTypes/permissions/userAgentData`
|
||||||
|
- 渲染能力:WebGL vendor/renderer
|
||||||
|
- 窗口屏幕:`outer*` / `screen*` / `avail*`
|
||||||
|
- 能力暴露:`share/contacts/contentIndex/mediaDevices/pdfViewerEnabled`
|
||||||
|
- 边缘特征:`connection/hardwareConcurrency/performance.memory`
|
||||||
|
|
||||||
|
### 指纹排障优先级
|
||||||
|
|
||||||
|
| 优先级 | 信号面 | 常见现象 | 先做什么 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P0 | UA + metadata + language | 首屏挑战页 | 先统一三者,再看其它项 |
|
||||||
|
| P1 | webdriver/runtime 痕迹 | 关键动作前即拦截 | 验证 init 注入是否在页面脚本前生效 |
|
||||||
|
| P2 | WebGL/screen | 间歇性二次验证 | 对齐渲染与窗口参数 |
|
||||||
|
| P3 | connection/memory 等边缘面 | 长链路后段异常 | 增量修复并对照回归 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 行为治理:让时序分布接近真实交互
|
||||||
|
|
||||||
|
行为检测通常不关心单次点击,而关注一段时间序列。
|
||||||
|
真正会被命中的,是“低方差、强周期、强同步”的机器节奏。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["动作计划"] --> B["输入节奏分布"]
|
||||||
|
A --> C["鼠标轨迹分布"]
|
||||||
|
A --> D["等待/思考时间分布"]
|
||||||
|
A --> E["重试退避分布"]
|
||||||
|
B --> F["执行时序"]
|
||||||
|
C --> F
|
||||||
|
D --> F
|
||||||
|
E --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输入节奏
|
||||||
|
|
||||||
|
固定字符延迟(例如全程 100ms)很容易形成可分辨模式。
|
||||||
|
更稳妥的做法是“基线 + 抖动 + 语义停顿”:
|
||||||
|
|
||||||
|
- 基线延迟围绕输入场景变化
|
||||||
|
- 每字符有扰动,不保持等间隔
|
||||||
|
- 词边界、字段切换处出现较长停顿
|
||||||
|
|
||||||
|
### 鼠标轨迹
|
||||||
|
|
||||||
|
坐标瞬移与恒速直线是高风险模式。
|
||||||
|
轨迹应包含:
|
||||||
|
|
||||||
|
- 曲线路径
|
||||||
|
- 中间采样点
|
||||||
|
- 速度变化(起步、调整、收敛)
|
||||||
|
|
||||||
|
### 等待与思考时间
|
||||||
|
|
||||||
|
固定等待常量会形成明显周期。
|
||||||
|
建议使用区间采样,让同类操作在时间上有自然波动。
|
||||||
|
|
||||||
|
### 重试退避
|
||||||
|
|
||||||
|
命中风险后继续等间隔重试,通常会放大风险分值。
|
||||||
|
退避策略应具备:
|
||||||
|
|
||||||
|
- 间隔递增
|
||||||
|
- 抖动扰动
|
||||||
|
- 次数上限
|
||||||
|
|
||||||
|
### 行为反模式
|
||||||
|
|
||||||
|
- 全链路固定输入延迟
|
||||||
|
- 点击前无移动直接命中目标
|
||||||
|
- 所有等待都是同一个常量
|
||||||
|
- 重试间隔完全一致
|
||||||
|
- 所有站点使用同一动作模板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03 实战更新:Cloudflare 验证页恢复策略
|
||||||
|
|
||||||
|
在真实使用中,`dash.cloudflare.com` 一类站点常见 `Just a moment... / Performing security verification` 挑战页。
|
||||||
|
关键问题不只是“被识别”,还包括“客户端过早刷新把挑战流程重置”,导致长期卡在验证中。
|
||||||
|
|
||||||
|
本次修复的关键点:
|
||||||
|
|
||||||
|
1. **风险信号增强**:从 `URL + Title` 扩展为 `URL + Title + PageText`
|
||||||
|
覆盖 `Performing security verification`、`This website uses a security service...` 等文本证据。
|
||||||
|
2. **恢复策略调整**:`risk-mode=warn` 先等待挑战自动放行,再进入重试
|
||||||
|
避免“重试即刷新”打断 Cloudflare 的挑战倒计时。
|
||||||
|
3. **会话稳定化**:未显式传 `--session-name` 时,默认跟随 `--session`
|
||||||
|
降低会话漂移造成的重复挑战概率。
|
||||||
|
4. **挑战窗口友好化**:减少过早刷新导致的挑战重置
|
||||||
|
让验证流程有足够时间自动完成,降低反复卡住的概率。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结语
|
||||||
|
|
||||||
|
指纹治理的重点是跨维度一致性。
|
||||||
|
行为治理的重点是时间分布去机械化。
|
||||||
|
把这两部分统一治理,通常能显著降低攻防波动。
|
||||||
|
|
||||||
|
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 使用 agent-browser-stealth 代替 agent-browser
|
||||||
|
|
||||||
|
很多人已经把 AI Agent 接入了浏览器自动化,但上线后会遇到同一个问题:
|
||||||
|
|
||||||
|
同样流程在不同网站表现不一致;流程写完了,关键站点还是过不去。
|
||||||
|
|
||||||
|
问题通常不在“会不会自动化”,而在“能不能在真实网站里稳定自动化”。
|
||||||
|
|
||||||
|
这就是为什么要从 `agent-browser` 升级到 `agent-browser-stealth`。
|
||||||
|
|
||||||
|
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么要替换
|
||||||
|
|
||||||
|
### 1) 让 AI 真正可以使用浏览器
|
||||||
|
|
||||||
|
很多网站已经有反爬和自动化检测策略。
|
||||||
|
在这些场景里,传统自动化链路会出现高频验证、中断、重试失败,最终让 AI 任务卡在关键步骤。
|
||||||
|
|
||||||
|
`agent-browser-stealth` 的目标很明确:让 AI 在真实网站环境中保持更高可用性。
|
||||||
|
|
||||||
|
### 2) 应对“限制 AI 浏览器”的站点策略
|
||||||
|
|
||||||
|
部分站点会对自动化浏览器做额外限制,包括:
|
||||||
|
|
||||||
|
- 触发挑战页
|
||||||
|
- 关键页面二次验证
|
||||||
|
- 会话中途降权或限流
|
||||||
|
|
||||||
|
`agent-browser-stealth` 提供更完整的防识别能力,降低这类限制对任务成功率的影响。
|
||||||
|
|
||||||
|
### 3) 让 Agent 和用户共享同一个浏览器
|
||||||
|
|
||||||
|
很多自动化失败发生在“登录前后状态切换”环节。
|
||||||
|
`agent-browser-stealth` 支持 Agent 复用用户正在使用的浏览器会话,直接继承已登录状态,减少重复登录和验证码干扰。
|
||||||
|
|
||||||
|
对业务流程的价值是直接的:
|
||||||
|
|
||||||
|
- 缩短执行路径
|
||||||
|
- 降低登录步骤失败率
|
||||||
|
- 提升整体成功率与执行速度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 典型站点效果(示例)
|
||||||
|
|
||||||
|
以亚马逊这类高风控电商站点为例,很多 AI 浏览器流程过去常见的问题是:
|
||||||
|
|
||||||
|
- 能打开首页,但关键操作前触发验证
|
||||||
|
- 搜索、跳转、加购这类连续动作中途被打断
|
||||||
|
- 会话偶发失效,任务难以完整执行
|
||||||
|
|
||||||
|
切换到 `agent-browser-stealth` 后,可显著提升这类流程的可执行性,常见可完成动作包括:
|
||||||
|
|
||||||
|
- 商品搜索与详情浏览
|
||||||
|
- 购物车相关操作
|
||||||
|
- 已登录状态下的页面导航与信息读取
|
||||||
|
|
||||||
|
除了电商站点,下面两类场景也常见明显改善:
|
||||||
|
|
||||||
|
- 社媒/内容平台:多步骤跳转流程更稳定
|
||||||
|
- SaaS 后台系统:登录后连续操作中断率降低
|
||||||
|
|
||||||
|
说明:不同账号状态、网络环境、站点实时策略会影响最终效果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 适合哪些场景
|
||||||
|
|
||||||
|
- AI 客服或运营 Agent 需要在多站点执行后台操作
|
||||||
|
- 自动化流程经常卡在登录、验证、跳转环节
|
||||||
|
- 需要“人机协同”:用户和 Agent 共用一个会话处理复杂任务
|
||||||
|
- 对稳定性要求高的生产任务(不是 Demo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移成本高吗
|
||||||
|
|
||||||
|
迁移成本通常很低,命令习惯可以保持一致。
|
||||||
|
多数场景可以先做“无侵入替换”,再按业务流程逐步优化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一张表看差异
|
||||||
|
|
||||||
|
| 维度 | agent-browser | agent-browser-stealth |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 目标 | 标准自动化能力 | 面向真实风控环境的稳定自动化 |
|
||||||
|
| 站点兼容性 | 普通站点可用 | 高风控站点可用性更高 |
|
||||||
|
| AI 执行稳定性 | 受验证页影响明显 | 对验证/限制策略更稳 |
|
||||||
|
| 登录链路 | 常需重复处理登录步骤 | 支持复用用户浏览器状态,减少登录干扰 |
|
||||||
|
| 生产可用性 | 适合基础自动化 | 更适合生产级 AI 浏览器任务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
如果目标是“让 AI 在真实网站里稳定完成任务”,`agent-browser-stealth` 是更合适的选择。
|
||||||
|
如果目标只是“脚本在理想环境跑通”,`agent-browser` 已经足够。
|
||||||
|
|
||||||
|
在生产环境里,真正的差异通常体现在四个字:**可用与稳定**。
|
||||||
|
|
||||||
|
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
|
||||||
@@ -50,6 +50,8 @@ export AGENT_BROWSER_SESSION_NAME=twitter
|
|||||||
agent-browser open twitter.com
|
agent-browser open twitter.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `--session-name` is omitted, it defaults to `--session` (or `default`).
|
||||||
|
|
||||||
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
||||||
|
|
||||||
### Session name rules
|
### Session name rules
|
||||||
|
|||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression check for daemon liveness when <session>.pid is missing.
|
||||||
|
*
|
||||||
|
* What it verifies:
|
||||||
|
* 1) A daemon session is reachable.
|
||||||
|
* 2) Deleting <session>.pid does not break the next command.
|
||||||
|
* 3) The session socket is not recreated (inode unchanged on Unix),
|
||||||
|
* meaning we reused the live daemon instead of tearing it down.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/check-daemon-pid-recovery.js
|
||||||
|
* node scripts/check-daemon-pid-recovery.js --session default
|
||||||
|
* node scripts/check-daemon-pid-recovery.js --binary ./bin/agent-browser-darwin-arm64
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path, { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const getArgValue = (name, fallback) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||||
|
return args[index + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSocketDir() {
|
||||||
|
if (process.env.AGENT_BROWSER_SOCKET_DIR && process.env.AGENT_BROWSER_SOCKET_DIR.length > 0) {
|
||||||
|
return process.env.AGENT_BROWSER_SOCKET_DIR;
|
||||||
|
}
|
||||||
|
if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) {
|
||||||
|
return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
|
||||||
|
}
|
||||||
|
return path.join(os.homedir(), '.agent-browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultBinary() {
|
||||||
|
const osKey = os.platform() === 'win32' ? 'win32' : os.platform() === 'darwin' ? 'darwin' : 'linux';
|
||||||
|
const archKey = os.arch() === 'arm64' ? 'arm64' : 'x64';
|
||||||
|
const ext = os.platform() === 'win32' ? '.exe' : '';
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
join(rootDir, 'bin', `agent-browser-${osKey}-${archKey}${ext}`),
|
||||||
|
join(rootDir, 'cli', 'target', 'release', `agent-browser${ext}`),
|
||||||
|
join(rootDir, 'bin', `agent-browser-local${ext}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(binary, commandArgs, allowFailure = false) {
|
||||||
|
const result = spawnSync(binary, commandArgs, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (result.status !== 0 && !allowFailure) {
|
||||||
|
const stderr = (result.stderr || '').trim();
|
||||||
|
const stdout = (result.stdout || '').trim();
|
||||||
|
throw new Error(
|
||||||
|
`Command failed: ${binary} ${commandArgs.join(' ')}\n${stderr || stdout || `exit code ${result.status}`}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const session = getArgValue('--session', 'default');
|
||||||
|
const binary = getArgValue('--binary', resolveDefaultBinary());
|
||||||
|
const socketDir = resolveSocketDir();
|
||||||
|
const isWindows = os.platform() === 'win32';
|
||||||
|
|
||||||
|
const pidPath = join(socketDir, `${session}.pid`);
|
||||||
|
const socketPath = isWindows ? null : join(socketDir, `${session}.sock`);
|
||||||
|
const portPath = isWindows ? join(socketDir, `${session}.port`) : null;
|
||||||
|
|
||||||
|
if (!fs.existsSync(binary)) {
|
||||||
|
throw new Error(`Binary not found: ${binary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure daemon/session is live before we simulate pid loss.
|
||||||
|
runCommand(binary, ['--session', session, 'get', 'url']);
|
||||||
|
|
||||||
|
let socketInodeBefore = null;
|
||||||
|
if (!isWindows) {
|
||||||
|
if (!socketPath || !fs.existsSync(socketPath)) {
|
||||||
|
throw new Error(`Socket file not found: ${socketPath}`);
|
||||||
|
}
|
||||||
|
socketInodeBefore = fs.statSync(socketPath).ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pidExistedBefore = fs.existsSync(pidPath);
|
||||||
|
if (pidExistedBefore) {
|
||||||
|
fs.unlinkSync(pidPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the critical step: should still work even though pid file is gone.
|
||||||
|
const second = runCommand(binary, ['--session', session, 'get', 'title']);
|
||||||
|
const secondOutput = (second.stdout || '').trim();
|
||||||
|
|
||||||
|
let socketInodeAfter = null;
|
||||||
|
let socketUnchanged = true;
|
||||||
|
if (!isWindows) {
|
||||||
|
if (!socketPath || !fs.existsSync(socketPath)) {
|
||||||
|
throw new Error(`Socket file missing after pid removal: ${socketPath}`);
|
||||||
|
}
|
||||||
|
socketInodeAfter = fs.statSync(socketPath).ino;
|
||||||
|
socketUnchanged = socketInodeBefore === socketInodeAfter;
|
||||||
|
} else if (portPath && !fs.existsSync(portPath)) {
|
||||||
|
throw new Error(`Port file missing after pid removal: ${portPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pidExistsAfter = fs.existsSync(pidPath);
|
||||||
|
const passed = socketUnchanged;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
passed,
|
||||||
|
session,
|
||||||
|
binary,
|
||||||
|
socketDir,
|
||||||
|
pidPath,
|
||||||
|
pidExistedBefore,
|
||||||
|
pidExistsAfter,
|
||||||
|
socketPath,
|
||||||
|
socketInodeBefore,
|
||||||
|
socketInodeAfter,
|
||||||
|
socketUnchanged,
|
||||||
|
secondCommandOutput: secondOutput,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
if (!passed) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+3
-2
@@ -38,6 +38,7 @@ import {
|
|||||||
STEALTH_CHROMIUM_ARGS,
|
STEALTH_CHROMIUM_ARGS,
|
||||||
applyStealthScripts,
|
applyStealthScripts,
|
||||||
applyBrowserLevelStealth,
|
applyBrowserLevelStealth,
|
||||||
|
wrapCDPSessionSourceUrlSanitizer,
|
||||||
type StealthScriptOptions,
|
type StealthScriptOptions,
|
||||||
} from './stealth.js';
|
} from './stealth.js';
|
||||||
|
|
||||||
@@ -319,7 +320,7 @@ export class BrowserManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const page = this.getPage();
|
const page = this.getPage();
|
||||||
const cdp = await page.context().newCDPSession(page);
|
const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page));
|
||||||
|
|
||||||
if (!envTimezone) {
|
if (!envTimezone) {
|
||||||
await cdp
|
await cdp
|
||||||
@@ -3089,7 +3090,7 @@ export class BrowserManager {
|
|||||||
const context = page.context();
|
const context = page.context();
|
||||||
|
|
||||||
// Create a new CDP session attached to the page
|
// Create a new CDP session attached to the page
|
||||||
this.cdpSession = await context.newCDPSession(page);
|
this.cdpSession = wrapCDPSessionSourceUrlSanitizer(await context.newCDPSession(page));
|
||||||
return this.cdpSession;
|
return this.cdpSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ describe('Stealth mode', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps navigator.vendor aligned with Chrome', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const vendorSignals = await browser.getPage().evaluate(() => ({
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
vendor: navigator.vendor,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (vendorSignals.userAgent.includes('Chrome/')) {
|
||||||
|
expect(vendorSignals.vendor).toBe('Google Inc.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
|
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
|
||||||
browser = new BrowserManager();
|
browser = new BrowserManager();
|
||||||
await browser.launch({ headless: true, stealth: true });
|
await browser.launch({ headless: true, stealth: true });
|
||||||
@@ -170,6 +184,124 @@ describe('Stealth mode', () => {
|
|||||||
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
|
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes legacy chrome.app/csi/loadTimes APIs', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => {
|
||||||
|
const chromeObj = (window as any).chrome;
|
||||||
|
const csi = chromeObj && typeof chromeObj.csi === 'function' ? chromeObj.csi() : null;
|
||||||
|
const loadTimes =
|
||||||
|
chromeObj && typeof chromeObj.loadTimes === 'function' ? chromeObj.loadTimes() : null;
|
||||||
|
return {
|
||||||
|
hasChrome: !!chromeObj,
|
||||||
|
hasApp: !!(chromeObj && chromeObj.app),
|
||||||
|
appInstalled: chromeObj?.app?.isInstalled,
|
||||||
|
appRunningState: chromeObj?.app?.runningState?.(),
|
||||||
|
hasCsi: typeof chromeObj?.csi === 'function',
|
||||||
|
hasLoadTimes: typeof chromeObj?.loadTimes === 'function',
|
||||||
|
csiHasOnloadT: csi && typeof csi.onloadT === 'number',
|
||||||
|
csiHasPageT: csi && typeof csi.pageT === 'number',
|
||||||
|
loadTimesHasRequestTime: loadTimes && typeof loadTimes.requestTime === 'number',
|
||||||
|
loadTimesHasConnectionInfo: loadTimes && typeof loadTimes.connectionInfo === 'string',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signals.hasChrome).toBe(true);
|
||||||
|
expect(signals.hasApp).toBe(true);
|
||||||
|
expect(signals.appInstalled).toBe(false);
|
||||||
|
expect(signals.appRunningState).toBe('cannot_run');
|
||||||
|
expect(signals.hasCsi).toBe(true);
|
||||||
|
expect(signals.hasLoadTimes).toBe(true);
|
||||||
|
expect(signals.csiHasOnloadT).toBe(true);
|
||||||
|
expect(signals.csiHasPageT).toBe(true);
|
||||||
|
expect(signals.loadTimesHasRequestTime).toBe(true);
|
||||||
|
expect(signals.loadTimesHasConnectionInfo).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spoofs high-signal media codec probes', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const codecs = await browser.getPage().evaluate(() => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
return {
|
||||||
|
mp4Avc: video.canPlayType('video/mp4; codecs="avc1.42E01E"'),
|
||||||
|
xM4a: audio.canPlayType('audio/x-m4a;'),
|
||||||
|
aac: audio.canPlayType('audio/aac'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codecs.mp4Avc).toBe('probably');
|
||||||
|
expect(codecs.xM4a).toBe('maybe');
|
||||||
|
expect(codecs.aac).toBe('probably');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches srcdoc iframe.contentWindow probes', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const iframeSignals = await browser.getPage().evaluate(() => {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.srcdoc = '<!doctype html><html><body>ok</body></html>';
|
||||||
|
const win = iframe.contentWindow;
|
||||||
|
return {
|
||||||
|
hasContentWindow: !!win,
|
||||||
|
selfEqualsWindow: win ? win.self === win : false,
|
||||||
|
selfEqualsTop: win ? win.self === window.top : null,
|
||||||
|
frameElementMatches: win ? win.frameElement === iframe : false,
|
||||||
|
zeroSlotType: typeof (win as any)?.[0],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(iframeSignals.hasContentWindow).toBe(true);
|
||||||
|
expect(iframeSignals.selfEqualsWindow).toBe(true);
|
||||||
|
expect(iframeSignals.selfEqualsTop).toBe(false);
|
||||||
|
expect(iframeSignals.frameElementMatches).toBe(true);
|
||||||
|
expect(iframeSignals.zeroSlotType).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes Playwright sourceURL markers in error stacks', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const stacks = await browser.getPage().evaluate(() => {
|
||||||
|
const explicitEvalStack = eval(
|
||||||
|
`(() => { try { throw new Error('explicit'); } catch (error) { return String(error.stack || ''); } })()\n//# sourceURL=__playwright_evaluation_script__`
|
||||||
|
);
|
||||||
|
let directStack = '';
|
||||||
|
try {
|
||||||
|
throw new Error('direct');
|
||||||
|
} catch (error) {
|
||||||
|
directStack = String((error as Error).stack || '');
|
||||||
|
}
|
||||||
|
return { explicitEvalStack, directStack };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stacks.explicitEvalStack).not.toContain('__playwright_evaluation_script__');
|
||||||
|
expect(stacks.explicitEvalStack).not.toContain('__puppeteer_evaluation_script__');
|
||||||
|
expect(stacks.explicitEvalStack).not.toContain('sourceURL=');
|
||||||
|
expect(stacks.directStack).not.toContain('__playwright_evaluation_script__');
|
||||||
|
expect(stacks.directStack).not.toContain('__puppeteer_evaluation_script__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes sourceURL markers in direct CDP Runtime.evaluate payloads', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const cdp = await browser.getCDPSession();
|
||||||
|
const response = await cdp.send('Runtime.evaluate', {
|
||||||
|
expression:
|
||||||
|
"(() => { throw new Error('cdp'); })()\\n//# sourceURL=__playwright_evaluation_script__",
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
const raw = JSON.stringify(response);
|
||||||
|
expect(raw).not.toContain('__playwright_evaluation_script__');
|
||||||
|
expect(raw).not.toContain('__puppeteer_evaluation_script__');
|
||||||
|
expect(raw).not.toContain('sourceURL=');
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes contacts manager and content index APIs', async () => {
|
it('exposes contacts manager and content index APIs', async () => {
|
||||||
browser = new BrowserManager();
|
browser = new BrowserManager();
|
||||||
await browser.launch({ headless: true, stealth: true });
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|||||||
+562
-5
@@ -12,6 +12,7 @@ export interface StealthScriptOptions {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
acceptLanguage?: string;
|
acceptLanguage?: string;
|
||||||
|
allowWebGLContextFallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,90 @@ export const STEALTH_CHROMIUM_ARGS: string[] = [
|
|||||||
'--use-angle=default',
|
'--use-angle=default',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CDP_SOURCE_URL_SANITIZED = Symbol('ab.cdpSourceUrlSanitized');
|
||||||
|
|
||||||
|
interface CDPSessionLike {
|
||||||
|
send(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
||||||
|
[CDP_SOURCE_URL_SANITIZED]?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSourceUrlLabels(input: string): string {
|
||||||
|
let output = input;
|
||||||
|
output = output.replace(/\n?\s*\/\/[@#]\s*sourceURL=[^\n\r]*/gi, '');
|
||||||
|
output = output.replace(/\n?\s*\/\*[@#]\s*sourceURL=[\s\S]*?\*\//gi, '');
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCdpPayload(
|
||||||
|
method: string,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!params || typeof params !== 'object') return params;
|
||||||
|
const sanitizeField = (
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
field: 'expression' | 'functionDeclaration' | 'source'
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const value = payload[field];
|
||||||
|
if (typeof value !== 'string') return payload;
|
||||||
|
const cleaned = stripSourceUrlLabels(value);
|
||||||
|
if (cleaned === value) return payload;
|
||||||
|
return { ...payload, [field]: cleaned };
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'Runtime.evaluate':
|
||||||
|
case 'Runtime.compileScript':
|
||||||
|
return sanitizeField(params, 'expression');
|
||||||
|
case 'Runtime.callFunctionOn':
|
||||||
|
return sanitizeField(params, 'functionDeclaration');
|
||||||
|
case 'Page.addScriptToEvaluateOnNewDocument':
|
||||||
|
return sanitizeField(params, 'source');
|
||||||
|
default:
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch CDPSession.send so Runtime/Page script payloads no longer carry
|
||||||
|
* sourceURL labels that reveal automation internals.
|
||||||
|
*/
|
||||||
|
export function wrapCDPSessionSourceUrlSanitizer<T extends CDPSessionLike>(session: T): T {
|
||||||
|
if (!session || typeof session.send !== 'function') return session;
|
||||||
|
if (session[CDP_SOURCE_URL_SANITIZED]) return session;
|
||||||
|
|
||||||
|
const nativeSend = session.send.bind(session);
|
||||||
|
const wrappedSend = (method: string, params?: Record<string, unknown>) => {
|
||||||
|
return nativeSend(method, sanitizeCdpPayload(method, params));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperty(session, 'send', {
|
||||||
|
value: wrappedSend,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
(session as any).send = wrappedSend;
|
||||||
|
} catch {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperty(session, CDP_SOURCE_URL_SANITIZED, {
|
||||||
|
value: true,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
(session as any)[CDP_SOURCE_URL_SANITIZED] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply all stealth patches to a BrowserContext.
|
* Apply all stealth patches to a BrowserContext.
|
||||||
* Must be called BEFORE any page is created / navigated.
|
* Must be called BEFORE any page is created / navigated.
|
||||||
@@ -51,7 +136,7 @@ export async function applyBrowserLevelStealth(
|
|||||||
options: StealthScriptOptions = {}
|
options: StealthScriptOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cdp = await (browser as any).newBrowserCDPSession();
|
const cdp = wrapCDPSessionSourceUrlSanitizer(await (browser as any).newBrowserCDPSession());
|
||||||
const version = await cdp.send('Browser.getVersion');
|
const version = await cdp.send('Browser.getVersion');
|
||||||
const rawUA = version?.userAgent ?? '';
|
const rawUA = version?.userAgent ?? '';
|
||||||
const explicitUA = options.userAgent?.trim();
|
const explicitUA = options.userAgent?.trim();
|
||||||
@@ -93,7 +178,7 @@ async function applyCDPStealthToPage(
|
|||||||
options: StealthScriptOptions = {}
|
options: StealthScriptOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cdp = await page.context().newCDPSession(page);
|
const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page));
|
||||||
const ua = await cdp.send('Browser.getVersion').catch(() => null);
|
const ua = await cdp.send('Browser.getVersion').catch(() => null);
|
||||||
const rawUA = ua?.userAgent ?? '';
|
const rawUA = ua?.userAgent ?? '';
|
||||||
const explicitUA = options.userAgent?.trim();
|
const explicitUA = options.userAgent?.trim();
|
||||||
@@ -196,7 +281,11 @@ function deriveLanguages(locale?: string): string[] {
|
|||||||
function buildStealthScript(options: StealthScriptOptions): string {
|
function buildStealthScript(options: StealthScriptOptions): string {
|
||||||
const locale = normalizeLocale(options.locale) ?? 'en-US';
|
const locale = normalizeLocale(options.locale) ?? 'en-US';
|
||||||
const languages = deriveLanguages(locale);
|
const languages = deriveLanguages(locale);
|
||||||
const configScript = `const __abStealth = ${JSON.stringify({ locale, languages })};`;
|
const configScript = `const __abStealth = ${JSON.stringify({
|
||||||
|
locale,
|
||||||
|
languages,
|
||||||
|
allowWebGLContextFallback: options.allowWebGLContextFallback === true,
|
||||||
|
})};`;
|
||||||
|
|
||||||
// Each patch is an IIFE so variable scoping is clean
|
// Each patch is an IIFE so variable scoping is clean
|
||||||
return [
|
return [
|
||||||
@@ -204,11 +293,15 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
|||||||
patchNavigatorWebdriver(),
|
patchNavigatorWebdriver(),
|
||||||
patchCssSupportsWebdriverHeuristic(),
|
patchCssSupportsWebdriverHeuristic(),
|
||||||
patchChromeRuntime(),
|
patchChromeRuntime(),
|
||||||
|
patchChromeLegacyApis(),
|
||||||
|
patchIframeContentWindow(),
|
||||||
patchNavigatorLanguages(),
|
patchNavigatorLanguages(),
|
||||||
|
patchNavigatorVendor(),
|
||||||
patchNavigatorPluginsAndMimeTypes(),
|
patchNavigatorPluginsAndMimeTypes(),
|
||||||
patchNavigatorPermissions(),
|
patchNavigatorPermissions(),
|
||||||
patchWebGLVendor(),
|
patchWebGLVendor(),
|
||||||
patchCdcProperties(),
|
patchCdcProperties(),
|
||||||
|
patchSourceUrlStackTraces(),
|
||||||
patchWindowDimensions(),
|
patchWindowDimensions(),
|
||||||
patchScreenDimensions(),
|
patchScreenDimensions(),
|
||||||
patchScreenAvailability(),
|
patchScreenAvailability(),
|
||||||
@@ -222,6 +315,7 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
|||||||
patchContentIndex(),
|
patchContentIndex(),
|
||||||
patchPrefersColorSchemeHeuristic(),
|
patchPrefersColorSchemeHeuristic(),
|
||||||
patchPdfViewerEnabled(),
|
patchPdfViewerEnabled(),
|
||||||
|
patchMediaCodecs(),
|
||||||
patchMediaDevices(),
|
patchMediaDevices(),
|
||||||
patchUserAgentData(),
|
patchUserAgentData(),
|
||||||
patchUserAgent(),
|
patchUserAgent(),
|
||||||
@@ -339,6 +433,223 @@ function patchChromeRuntime(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add deprecated-but-still-probed Chrome APIs: chrome.app, chrome.csi, chrome.loadTimes.
|
||||||
|
*/
|
||||||
|
function patchChromeLegacyApis(): string {
|
||||||
|
return `(function(){
|
||||||
|
const chromeObject = ('chrome' in window && window.chrome) ? window.chrome : null;
|
||||||
|
if (!chromeObject) return;
|
||||||
|
const nativeNow = Date.now;
|
||||||
|
const nativeToString = Function.prototype.toString;
|
||||||
|
const timing = window.performance && window.performance.timing ? window.performance.timing : null;
|
||||||
|
const getNavigationEntry = () => {
|
||||||
|
try {
|
||||||
|
return performance.getEntriesByType('navigation')[0] || { nextHopProtocol: 'h2', type: 'other' };
|
||||||
|
} catch {
|
||||||
|
return { nextHopProtocol: 'h2', type: 'other' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const defineValue = (target, key, value) => {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(target, key, {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const patchFunctionShape = (fn, name) => {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(fn, 'name', { value: name, configurable: true });
|
||||||
|
Object.defineProperty(fn, 'toString', {
|
||||||
|
value: () => nativeToString.call(nativeNow).replace('now', name),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!('app' in chromeObject)) {
|
||||||
|
const invokeError = (name) => new TypeError('Error in invocation of app.' + name + '()');
|
||||||
|
const app = {
|
||||||
|
isInstalled: false,
|
||||||
|
InstallState: {
|
||||||
|
DISABLED: 'disabled',
|
||||||
|
INSTALLED: 'installed',
|
||||||
|
NOT_INSTALLED: 'not_installed',
|
||||||
|
},
|
||||||
|
RunningState: {
|
||||||
|
CANNOT_RUN: 'cannot_run',
|
||||||
|
READY_TO_RUN: 'ready_to_run',
|
||||||
|
RUNNING: 'running',
|
||||||
|
},
|
||||||
|
getDetails: function getDetails() {
|
||||||
|
if (arguments.length) throw invokeError('getDetails');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getIsInstalled: function getIsInstalled() {
|
||||||
|
if (arguments.length) throw invokeError('getIsInstalled');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
runningState: function runningState() {
|
||||||
|
if (arguments.length) throw invokeError('runningState');
|
||||||
|
return 'cannot_run';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
defineValue(chromeObject, 'app', app);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('csi' in chromeObject) && timing) {
|
||||||
|
const csi = function csi() {
|
||||||
|
return {
|
||||||
|
onloadT: timing.domContentLoadedEventEnd,
|
||||||
|
startE: timing.navigationStart,
|
||||||
|
pageT: Date.now() - timing.navigationStart,
|
||||||
|
tran: 15,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
patchFunctionShape(csi, 'csi');
|
||||||
|
defineValue(chromeObject, 'csi', csi);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('loadTimes' in chromeObject) && timing) {
|
||||||
|
const toFixed = (num, fixed) => {
|
||||||
|
const matcher = new RegExp('^-?\\\\d+(?:.\\\\d{0,' + (fixed || -1) + '})?');
|
||||||
|
const match = String(num).match(matcher);
|
||||||
|
return match ? match[0] : String(num);
|
||||||
|
};
|
||||||
|
const loadTimes = function loadTimes() {
|
||||||
|
const navigationEntry = getNavigationEntry();
|
||||||
|
const nextHopProtocol = navigationEntry.nextHopProtocol || 'h2';
|
||||||
|
let firstPaint = timing.loadEventEnd / 1000;
|
||||||
|
try {
|
||||||
|
const paintEntries = performance.getEntriesByType('paint');
|
||||||
|
if (paintEntries && paintEntries[0] && typeof paintEntries[0].startTime === 'number') {
|
||||||
|
firstPaint = (paintEntries[0].startTime + performance.timeOrigin) / 1000;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
connectionInfo: nextHopProtocol,
|
||||||
|
npnNegotiatedProtocol: ['h2', 'hq'].includes(nextHopProtocol) ? nextHopProtocol : 'unknown',
|
||||||
|
navigationType: navigationEntry.type || 'other',
|
||||||
|
wasAlternateProtocolAvailable: false,
|
||||||
|
wasFetchedViaSpdy: ['h2', 'hq'].includes(nextHopProtocol),
|
||||||
|
wasNpnNegotiated: ['h2', 'hq'].includes(nextHopProtocol),
|
||||||
|
firstPaintAfterLoadTime: 0,
|
||||||
|
requestTime: timing.navigationStart / 1000,
|
||||||
|
startLoadTime: timing.navigationStart / 1000,
|
||||||
|
commitLoadTime: timing.responseStart / 1000,
|
||||||
|
finishDocumentLoadTime: timing.domContentLoadedEventEnd / 1000,
|
||||||
|
finishLoadTime: timing.loadEventEnd / 1000,
|
||||||
|
firstPaintTime: toFixed(firstPaint, 3),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
patchFunctionShape(loadTimes, 'loadTimes');
|
||||||
|
defineValue(chromeObject, 'loadTimes', loadTimes);
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix srcdoc iframe.contentWindow signals used by classic HEADCHR_IFRAME checks.
|
||||||
|
* We only intercept iframe creation and srcdoc assignment to keep impact minimal.
|
||||||
|
*/
|
||||||
|
function patchIframeContentWindow(): string {
|
||||||
|
return `(function(){
|
||||||
|
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
|
||||||
|
const nativeCreateElement = document.createElement.bind(document);
|
||||||
|
const nativeSrcdocDescriptor =
|
||||||
|
typeof HTMLIFrameElement !== 'undefined'
|
||||||
|
? Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc')
|
||||||
|
: null;
|
||||||
|
const srcdocGetter = nativeSrcdocDescriptor && nativeSrcdocDescriptor.get;
|
||||||
|
const srcdocSetter = nativeSrcdocDescriptor && nativeSrcdocDescriptor.set;
|
||||||
|
const iframeProxyMap = new WeakMap();
|
||||||
|
const patchedIframes = new WeakSet();
|
||||||
|
|
||||||
|
const ensureContentWindowProxy = (iframe) => {
|
||||||
|
if (!iframe || iframeProxyMap.has(iframe)) return;
|
||||||
|
try {
|
||||||
|
if (iframe.contentWindow) return;
|
||||||
|
} catch {}
|
||||||
|
const proxy = new Proxy(window, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'self') return proxy;
|
||||||
|
if (key === 'frameElement') return iframe;
|
||||||
|
if (key === '0') return undefined;
|
||||||
|
return Reflect.get(target, key, target);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
iframeProxyMap.set(iframe, proxy);
|
||||||
|
try {
|
||||||
|
Object.defineProperty(iframe, 'contentWindow', {
|
||||||
|
get: () => proxy,
|
||||||
|
set: () => undefined,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchIframeSrcdoc = (iframe) => {
|
||||||
|
if (!iframe || patchedIframes.has(iframe)) return;
|
||||||
|
patchedIframes.add(iframe);
|
||||||
|
try {
|
||||||
|
Object.defineProperty(iframe, 'srcdoc', {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
if (typeof srcdocGetter === 'function') {
|
||||||
|
return srcdocGetter.call(this);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
ensureContentWindowProxy(this);
|
||||||
|
if (typeof srcdocSetter === 'function') {
|
||||||
|
srcdocSetter.call(this, value);
|
||||||
|
} else {
|
||||||
|
this.setAttribute('srcdoc', String(value ?? ''));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchedCreateElement = function(...args) {
|
||||||
|
const element = nativeCreateElement(...args);
|
||||||
|
try {
|
||||||
|
const name = args && args.length > 0 ? String(args[0]).toLowerCase() : '';
|
||||||
|
if (name === 'iframe') {
|
||||||
|
patchIframeSrcdoc(element);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
Object.defineProperty(patchedCreateElement, 'name', {
|
||||||
|
value: 'createElement',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(patchedCreateElement, 'toString', {
|
||||||
|
value: () => nativeCreateElement.toString(),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(document, 'createElement', {
|
||||||
|
value: patchedCreateElement,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
try { document.createElement = patchedCreateElement; } catch {}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep navigator.language + navigator.languages aligned with launch locale.
|
* Keep navigator.language + navigator.languages aligned with launch locale.
|
||||||
*/
|
*/
|
||||||
@@ -362,6 +673,38 @@ function patchNavigatorLanguages(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep navigator.vendor aligned with regular Chrome.
|
||||||
|
*/
|
||||||
|
function patchNavigatorVendor(): string {
|
||||||
|
return `(function(){
|
||||||
|
const ua = String(navigator.userAgent || '');
|
||||||
|
if (!/Chrome\\//.test(ua) || /Firefox\\//.test(ua)) return;
|
||||||
|
const target = 'Google Inc.';
|
||||||
|
const proto = Object.getPrototypeOf(navigator);
|
||||||
|
try {
|
||||||
|
if (navigator.vendor === target) return;
|
||||||
|
} catch {}
|
||||||
|
const defineVendor = (targetObj) => {
|
||||||
|
if (!targetObj) return false;
|
||||||
|
try {
|
||||||
|
Object.defineProperty(targetObj, 'vendor', {
|
||||||
|
get: () => target,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (defineVendor(proto)) {
|
||||||
|
try { delete (navigator).vendor; } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defineVendor(navigator);
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject realistic navigator.plugins and navigator.mimeTypes arrays.
|
* Inject realistic navigator.plugins and navigator.mimeTypes arrays.
|
||||||
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
|
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
|
||||||
@@ -500,8 +843,104 @@ function patchNavigatorPermissions(): string {
|
|||||||
function patchWebGLVendor(): string {
|
function patchWebGLVendor(): string {
|
||||||
return `(function(){
|
return `(function(){
|
||||||
const getCtx = HTMLCanvasElement.prototype.getContext;
|
const getCtx = HTMLCanvasElement.prototype.getContext;
|
||||||
|
const WEBGL_VENDOR = 'Intel Inc.';
|
||||||
|
const WEBGL_RENDERER = 'Intel Iris OpenGL Engine';
|
||||||
|
const DEBUG_RENDERER_INFO = {
|
||||||
|
UNMASKED_VENDOR_WEBGL: 0x9245,
|
||||||
|
UNMASKED_RENDERER_WEBGL: 0x9246,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFallbackWebGLContext = (canvas, requestedType) => {
|
||||||
|
const isWebGL2 = requestedType === 'webgl2';
|
||||||
|
const ctx = {
|
||||||
|
__abFallbackWebGLContext: true,
|
||||||
|
canvas,
|
||||||
|
drawingBufferWidth: canvas.width || 300,
|
||||||
|
drawingBufferHeight: canvas.height || 150,
|
||||||
|
VENDOR: 0x1F00,
|
||||||
|
RENDERER: 0x1F01,
|
||||||
|
VERSION: 0x1F02,
|
||||||
|
SHADING_LANGUAGE_VERSION: 0x8B8C,
|
||||||
|
getExtension(name) {
|
||||||
|
if (name === 'WEBGL_debug_renderer_info') return DEBUG_RENDERER_INFO;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getSupportedExtensions() {
|
||||||
|
return ['WEBGL_debug_renderer_info'];
|
||||||
|
},
|
||||||
|
getContextAttributes() {
|
||||||
|
return {
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
depth: true,
|
||||||
|
desynchronized: false,
|
||||||
|
failIfMajorPerformanceCaveat: false,
|
||||||
|
powerPreference: 'default',
|
||||||
|
premultipliedAlpha: true,
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
stencil: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getParameter(param) {
|
||||||
|
if (param === DEBUG_RENDERER_INFO.UNMASKED_VENDOR_WEBGL || param === this.VENDOR) {
|
||||||
|
return WEBGL_VENDOR;
|
||||||
|
}
|
||||||
|
if (param === DEBUG_RENDERER_INFO.UNMASKED_RENDERER_WEBGL || param === this.RENDERER) {
|
||||||
|
return WEBGL_RENDERER;
|
||||||
|
}
|
||||||
|
if (param === this.VERSION) {
|
||||||
|
return isWebGL2
|
||||||
|
? 'WebGL 2.0 (OpenGL ES 3.0 Chromium)'
|
||||||
|
: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)';
|
||||||
|
}
|
||||||
|
if (param === this.SHADING_LANGUAGE_VERSION) {
|
||||||
|
return isWebGL2
|
||||||
|
? 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)'
|
||||||
|
: 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)';
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getError() { return 0; },
|
||||||
|
clear() {},
|
||||||
|
clearColor() {},
|
||||||
|
createBuffer() { return {}; },
|
||||||
|
bindBuffer() {},
|
||||||
|
bufferData() {},
|
||||||
|
createProgram() { return {}; },
|
||||||
|
createShader() { return {}; },
|
||||||
|
shaderSource() {},
|
||||||
|
compileShader() {},
|
||||||
|
attachShader() {},
|
||||||
|
linkProgram() {},
|
||||||
|
useProgram() {},
|
||||||
|
viewport() {},
|
||||||
|
drawArrays() {},
|
||||||
|
readPixels() {},
|
||||||
|
finish() {},
|
||||||
|
flush() {},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const proto =
|
||||||
|
requestedType === 'webgl2' && typeof WebGL2RenderingContext !== 'undefined'
|
||||||
|
? WebGL2RenderingContext.prototype
|
||||||
|
: typeof WebGLRenderingContext !== 'undefined'
|
||||||
|
? WebGLRenderingContext.prototype
|
||||||
|
: null;
|
||||||
|
if (proto) Object.setPrototypeOf(ctx, proto);
|
||||||
|
} catch {}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
|
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
|
||||||
const ctx = getCtx.call(this, type, attrs);
|
const ctx = getCtx.call(this, type, attrs);
|
||||||
|
if (
|
||||||
|
(type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') &&
|
||||||
|
!ctx &&
|
||||||
|
__abStealth &&
|
||||||
|
__abStealth.allowWebGLContextFallback === true
|
||||||
|
) {
|
||||||
|
return createFallbackWebGLContext(this, type);
|
||||||
|
}
|
||||||
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
||||||
const origGetParameter = ctx.getParameter.bind(ctx);
|
const origGetParameter = ctx.getParameter.bind(ctx);
|
||||||
ctx.getParameter = function(param) {
|
ctx.getParameter = function(param) {
|
||||||
@@ -509,13 +948,15 @@ function patchWebGLVendor(): string {
|
|||||||
if (ext) {
|
if (ext) {
|
||||||
if (param === ext.UNMASKED_VENDOR_WEBGL) {
|
if (param === ext.UNMASKED_VENDOR_WEBGL) {
|
||||||
const real = origGetParameter(param);
|
const real = origGetParameter(param);
|
||||||
return (real && real.includes('SwiftShader')) ? 'Intel Inc.' : real;
|
return (real && real.includes('SwiftShader')) ? WEBGL_VENDOR : real;
|
||||||
}
|
}
|
||||||
if (param === ext.UNMASKED_RENDERER_WEBGL) {
|
if (param === ext.UNMASKED_RENDERER_WEBGL) {
|
||||||
const real = origGetParameter(param);
|
const real = origGetParameter(param);
|
||||||
return (real && real.includes('SwiftShader')) ? 'Intel Iris OpenGL Engine' : real;
|
return (real && real.includes('SwiftShader')) ? WEBGL_RENDERER : real;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (param === ctx.VENDOR) return WEBGL_VENDOR;
|
||||||
|
if (param === ctx.RENDERER) return WEBGL_RENDERER;
|
||||||
return origGetParameter(param);
|
return origGetParameter(param);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -542,6 +983,60 @@ function patchCdcProperties(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Playwright/Puppeteer sourceURL tokens from error stacks.
|
||||||
|
* This mirrors the intent of the sourceurl evasion: reduce obvious
|
||||||
|
* automation-only script labels in stack traces.
|
||||||
|
*/
|
||||||
|
function patchSourceUrlStackTraces(): string {
|
||||||
|
return `(function(){
|
||||||
|
if (typeof Error === 'undefined') return;
|
||||||
|
const sanitizeStack = (value) => {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let stack = value;
|
||||||
|
stack = stack.replace(/\\/\\/# sourceURL=.*$/gm, '');
|
||||||
|
stack = stack.replace(/__playwright_evaluation_script__/g, '<anonymous>');
|
||||||
|
stack = stack.replace(/__puppeteer_evaluation_script__/g, '<anonymous>');
|
||||||
|
stack = stack.replace(/__pw_evaluation_script__/g, '<anonymous>');
|
||||||
|
return stack;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nativePrepare = Error.prepareStackTrace;
|
||||||
|
Error.prepareStackTrace = function(error, structuredStackTrace) {
|
||||||
|
let stackString;
|
||||||
|
if (typeof nativePrepare === 'function') {
|
||||||
|
stackString = nativePrepare.call(this, error, structuredStackTrace);
|
||||||
|
} else {
|
||||||
|
const name = error && error.name ? String(error.name) : 'Error';
|
||||||
|
const message = error && error.message ? String(error.message) : '';
|
||||||
|
const header = message ? name + ': ' + message : name;
|
||||||
|
const frames = Array.isArray(structuredStackTrace)
|
||||||
|
? structuredStackTrace.map((frame) => ' at ' + String(frame))
|
||||||
|
: [];
|
||||||
|
stackString = [header].concat(frames).join('\\n');
|
||||||
|
}
|
||||||
|
return sanitizeStack(String(stackString));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof Error.captureStackTrace === 'function') {
|
||||||
|
const nativeCapture = Error.captureStackTrace;
|
||||||
|
Error.captureStackTrace = function(targetObject, constructorOpt) {
|
||||||
|
nativeCapture.call(this, targetObject, constructorOpt);
|
||||||
|
try {
|
||||||
|
const stack = targetObject && targetObject.stack;
|
||||||
|
if (typeof stack === 'string') {
|
||||||
|
Object.defineProperty(targetObject, 'stack', {
|
||||||
|
value: sanitizeStack(stack),
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* contentWindow on cross-origin iframes: Playwright sometimes returns null
|
* contentWindow on cross-origin iframes: Playwright sometimes returns null
|
||||||
* where real browsers return a (restricted) Window object.
|
* where real browsers return a (restricted) Window object.
|
||||||
@@ -1016,6 +1511,68 @@ function patchPdfViewerEnabled(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromium headless can under-report support for common media codecs.
|
||||||
|
* Patch canPlayType for a narrow set of high-signal probes.
|
||||||
|
*/
|
||||||
|
function patchMediaCodecs(): string {
|
||||||
|
return `(function(){
|
||||||
|
if (typeof HTMLMediaElement === 'undefined' || !HTMLMediaElement.prototype) return;
|
||||||
|
const nativeCanPlayType = HTMLMediaElement.prototype.canPlayType;
|
||||||
|
if (typeof nativeCanPlayType !== 'function') return;
|
||||||
|
const parseInput = (value) => {
|
||||||
|
const input = String(value || '').trim();
|
||||||
|
const [mimePart, codecPart] = input.split(';');
|
||||||
|
const mime = String(mimePart || '').trim().toLowerCase();
|
||||||
|
const codecs = [];
|
||||||
|
if (codecPart && codecPart.includes('codecs=')) {
|
||||||
|
const normalized = codecPart
|
||||||
|
.replace(/^[^=]*=/, '')
|
||||||
|
.replace(/^\\s*["']?/, '')
|
||||||
|
.replace(/["']?\\s*$/, '');
|
||||||
|
normalized
|
||||||
|
.split(',')
|
||||||
|
.map((codec) => codec.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((codec) => codecs.push(codec));
|
||||||
|
}
|
||||||
|
return { mime, codecs };
|
||||||
|
};
|
||||||
|
const patchedCanPlayType = function(type) {
|
||||||
|
const { mime, codecs } = parseInput(type);
|
||||||
|
if (mime === 'video/mp4' && codecs.includes('avc1.42e01e')) {
|
||||||
|
return 'probably';
|
||||||
|
}
|
||||||
|
if (mime === 'audio/x-m4a' && codecs.length === 0) {
|
||||||
|
return 'maybe';
|
||||||
|
}
|
||||||
|
if (mime === 'audio/aac' && codecs.length === 0) {
|
||||||
|
return 'probably';
|
||||||
|
}
|
||||||
|
return nativeCanPlayType.call(this, type);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
Object.defineProperty(patchedCanPlayType, 'name', {
|
||||||
|
value: 'canPlayType',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(patchedCanPlayType, 'toString', {
|
||||||
|
value: () => nativeCanPlayType.toString(),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'canPlayType', {
|
||||||
|
value: patchedCanPlayType,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
try { HTMLMediaElement.prototype.canPlayType = patchedCanPlayType; } catch {}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* navigator.mediaDevices.enumerateDevices should return at least some devices
|
* navigator.mediaDevices.enumerateDevices should return at least some devices
|
||||||
* instead of an empty array (headless default).
|
* instead of an empty array (headless default).
|
||||||
|
|||||||
Reference in New Issue
Block a user