feat(stealth): 优化隐身对抗并引入双版本发布体系
- 将 CreepJS like headless 指标优化到 0%(headless/stealth 维持 0%) - 新增 ActiveText 与 prefers-color-scheme 探针修复 - 版本号采用 <upstream>-fork.<fork> 格式并在 --version 输出 upstream/fork - 更新 README、SKILL 与 docs 中的版本体系说明
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
# agent-browser
|
# agent-browser
|
||||||
|
|
||||||
Stealth browser automation CLI for AI agents with anti-bot evasions. Fast Rust CLI with Node.js fallback.
|
Stealth-first browser automation CLI engineered for anti-bot evasion. Fast Rust CLI with Node.js fallback.
|
||||||
|
|
||||||
|
Designed for production automation on detection-heavy sites:
|
||||||
|
- Always-on stealth (no opt-in flag)
|
||||||
|
- Browser and protocol-level anti-fingerprint patches
|
||||||
|
- Humanized interaction behavior by default
|
||||||
|
- Verified against CreepJS using the built-in check script
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -95,6 +101,8 @@ Independent release checklist for forks:
|
|||||||
- Update `repository`, `bugs`, and `homepage` in `package.json` to your fork.
|
- Update `repository`, `bugs`, and `homepage` in `package.json` to your fork.
|
||||||
- Configure npm Trusted Publishing (OIDC) for your package and repository workflow.
|
- Configure npm Trusted Publishing (OIDC) for your package and repository workflow.
|
||||||
- Keep release tags and changelog in your own namespace/versioning policy.
|
- Keep release tags and changelog in your own namespace/versioning policy.
|
||||||
|
- Use dual-version format: `<upstream>-fork.<fork>` (example: `0.14.0-fork.1`).
|
||||||
|
- `agent-browser --version` should show all three: full version, upstream version, and fork version.
|
||||||
|
|
||||||
### Linux Dependencies
|
### Linux Dependencies
|
||||||
|
|
||||||
@@ -114,6 +122,7 @@ agent-browser click @e2 # Click by ref from snapshot
|
|||||||
agent-browser fill @e3 "test@example.com" # Fill by ref
|
agent-browser fill @e3 "test@example.com" # Fill by ref
|
||||||
agent-browser get text @e1 # Get text by ref
|
agent-browser get text @e1 # Get text by ref
|
||||||
agent-browser screenshot page.png
|
agent-browser screenshot page.png
|
||||||
|
agent-browser --version # Includes upstream/fork metadata on fork builds
|
||||||
agent-browser close
|
agent-browser close
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -757,51 +766,42 @@ The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-fi
|
|||||||
|
|
||||||
## Stealth Mode
|
## Stealth Mode
|
||||||
|
|
||||||
Stealth mode is **enabled by default**. It patches common detection vectors to make the browser appear like a regular user session, preventing websites from blocking automation.
|
`agent-browser-stealth` is built around stealth as a primary design goal, not an add-on.
|
||||||
|
Stealth is **always on** with no flag needed. Every browser session automatically applies anti-detection countermeasures:
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stealth is on by default -- just use normally
|
|
||||||
agent-browser open example.com
|
|
||||||
|
|
||||||
# Disable stealth if needed
|
|
||||||
agent-browser --stealth false open example.com
|
|
||||||
|
|
||||||
# Or disable via environment variable
|
|
||||||
export AGENT_BROWSER_STEALTH=false
|
|
||||||
|
|
||||||
# Or disable in config file
|
|
||||||
# agent-browser.json: {"stealth": false}
|
|
||||||
```
|
|
||||||
|
|
||||||
Stealth mode applies the following countermeasures:
|
|
||||||
- Removes `navigator.webdriver` automation indicator
|
- Removes `navigator.webdriver` automation indicator
|
||||||
- Disables Chromium's `AutomationControlled` blink feature
|
- Disables Chromium's `AutomationControlled` blink feature
|
||||||
- Adds realistic `navigator.plugins` (Chrome PDF Plugin, etc.)
|
- Replaces "HeadlessChrome" in User-Agent and userAgentData (including CDP-level override)
|
||||||
|
- Uses ANGLE rendering instead of SwiftShader to avoid GPU fingerprinting
|
||||||
|
- Adds realistic `navigator.plugins` and `navigator.mimeTypes` (passes `instanceof` checks)
|
||||||
- Patches `window.chrome.runtime` to match real Chrome
|
- Patches `window.chrome.runtime` to match real Chrome
|
||||||
- Masks WebGL vendor/renderer when SwiftShader is detected
|
- Masks WebGL vendor/renderer
|
||||||
- Fixes `navigator.permissions.query` for notifications
|
- Fixes `navigator.permissions.query` for notifications
|
||||||
- Reports realistic `navigator.hardwareConcurrency`
|
- Reports realistic `navigator.hardwareConcurrency` and `performance.memory`
|
||||||
- Provides default media devices for `enumerateDevices()`
|
- Provides default media devices for `enumerateDevices()`
|
||||||
|
- Patches screen/window dimensions to avoid viewport-equals-screen fingerprint
|
||||||
|
- Sets opaque background color (headless default is transparent)
|
||||||
- Cleans up CDP-injected properties on the document
|
- Cleans up CDP-injected properties on the document
|
||||||
|
|
||||||
Stealth capability matrix:
|
### Stealth Verification
|
||||||
|
|
||||||
<table>
|
On February 24, 2026, local validation against CreepJS using `scripts/check-creepjs-headless.js` reported:
|
||||||
<thead>
|
|
||||||
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Local launch</td><td>Chromium launch args (<code>--disable-blink-features=AutomationControlled</code>) + context init scripts</td></tr>
|
|
||||||
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
|
|
||||||
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
Use <code>--debug</code> to print the active stealth connection type and capabilities at launch time.
|
| Metric | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| like headless | 0% |
|
||||||
|
| headless | 0% |
|
||||||
|
| stealth | 0% |
|
||||||
|
|
||||||
|
Reproduce:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
|
||||||
|
```
|
||||||
|
|
||||||
### Humanized Interactions
|
### Humanized Interactions
|
||||||
|
|
||||||
In addition to stealth patches, agent-browser automatically humanizes interactions to avoid behavioral detection:
|
All interactions are automatically humanized to avoid behavioral detection:
|
||||||
|
|
||||||
- **Randomized typing** -- When using `type --delay`, each keystroke delay varies by +-40% so timing appears natural rather than mechanical
|
- **Randomized typing** -- When using `type --delay`, each keystroke delay varies by +-40% so timing appears natural rather than mechanical
|
||||||
- **Random wait ranges** -- `wait 2000-5000` pauses for a random duration between 2 and 5 seconds
|
- **Random wait ranges** -- `wait 2000-5000` pauses for a random duration between 2 and 5 seconds
|
||||||
|
|||||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.14.0"
|
version = "0.14.0-fork.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
+5
-1
@@ -1,10 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.14.0"
|
version = "0.14.0-fork.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "agent-browser"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
+4
-1
@@ -242,7 +242,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
let mut flags = Flags {
|
let mut flags = Flags {
|
||||||
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
||||||
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
|
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
|
||||||
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
|
headed: match env::var("AGENT_BROWSER_HEADED") {
|
||||||
|
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
|
||||||
|
Err(_) => config.headed.unwrap_or(true),
|
||||||
|
},
|
||||||
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
||||||
session: env::var("AGENT_BROWSER_SESSION")
|
session: env::var("AGENT_BROWSER_SESSION")
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
+32
-3
@@ -2126,7 +2126,7 @@ Options:
|
|||||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||||
--debug Debug output
|
--debug Debug output
|
||||||
--version, -V Show version
|
--version, -V Show version (fork builds include upstream/fork info)
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
||||||
@@ -2290,6 +2290,35 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_version() {
|
/// Parse fork version metadata from semver-like strings:
|
||||||
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
|
/// <upstream>-fork.<fork>
|
||||||
|
/// Example:
|
||||||
|
/// 0.14.0-fork.1 -> (0.14.0, 1)
|
||||||
|
fn parse_fork_version(version: &str) -> Option<(&str, &str)> {
|
||||||
|
let (upstream, fork) = version.split_once("-fork.")?;
|
||||||
|
if upstream.is_empty() || fork.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !upstream
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || c == '.' || c == '-')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !fork.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((upstream, fork))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_version() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
if let Some((upstream, fork)) = parse_fork_version(version) {
|
||||||
|
println!(
|
||||||
|
"agent-browser {} (upstream {}, fork {})",
|
||||||
|
version, upstream, fork
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("agent-browser {}", version);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,16 @@ agent-browser pdf <path> # Save page as PDF
|
|||||||
agent-browser snapshot # Accessibility tree with refs
|
agent-browser snapshot # Accessibility tree with refs
|
||||||
agent-browser eval <js> # Run JavaScript
|
agent-browser eval <js> # Run JavaScript
|
||||||
agent-browser connect <port|url> # Connect to browser via CDP
|
agent-browser connect <port|url> # Connect to browser via CDP
|
||||||
|
agent-browser --version # Show CLI version
|
||||||
agent-browser close # Close browser (aliases: quit, exit)
|
agent-browser close # Close browser (aliases: quit, exit)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Fork builds print dual-version metadata with `--version`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1)
|
||||||
|
```
|
||||||
|
|
||||||
## Get info
|
## Get info
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ pnpm build:native
|
|||||||
pnpm link --global
|
pnpm link --global
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fork versioning
|
||||||
|
|
||||||
|
Fork releases use a dual-version format:
|
||||||
|
|
||||||
|
- `<upstream>-fork.<fork>`
|
||||||
|
- Example: `0.14.0-fork.1`
|
||||||
|
|
||||||
|
`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds.
|
||||||
|
|
||||||
## Linux dependencies
|
## Linux dependencies
|
||||||
|
|
||||||
On Linux, install system dependencies:
|
On Linux, install system dependencies:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-fork.1",
|
||||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
|
|||||||
+21
-3
@@ -20,13 +20,31 @@ const packageJson = JSON.parse(
|
|||||||
);
|
);
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
|
|
||||||
console.log(`Syncing version ${version} to all config files...`);
|
function parseForkVersion(raw) {
|
||||||
|
const match = raw.match(/^([0-9]+\.[0-9]+\.[0-9]+)-fork\.([A-Za-z0-9.-]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
upstream: match[1],
|
||||||
|
fork: match[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const forkVersion = parseForkVersion(version);
|
||||||
|
if (forkVersion) {
|
||||||
|
console.log(
|
||||||
|
`Syncing version ${version} (upstream=${forkVersion.upstream}, fork=${forkVersion.fork}) to all config files...`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`Syncing version ${version} to all config files...`);
|
||||||
|
}
|
||||||
|
|
||||||
// Update Cargo.toml
|
// Update Cargo.toml
|
||||||
const cargoTomlPath = join(cliDir, "Cargo.toml");
|
const cargoTomlPath = join(cliDir, "Cargo.toml");
|
||||||
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
||||||
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
||||||
const newCargoVersion = `version = "${version}"`;
|
const newCargoVersion = `version = "${version}"`;
|
||||||
|
const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
|
||||||
|
const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth";
|
||||||
|
|
||||||
let cargoTomlUpdated = false;
|
let cargoTomlUpdated = false;
|
||||||
if (cargoVersionRegex.test(cargoToml)) {
|
if (cargoVersionRegex.test(cargoToml)) {
|
||||||
@@ -47,7 +65,7 @@ if (cargoVersionRegex.test(cargoToml)) {
|
|||||||
// Update Cargo.lock to match Cargo.toml
|
// Update Cargo.lock to match Cargo.toml
|
||||||
if (cargoTomlUpdated) {
|
if (cargoTomlUpdated) {
|
||||||
try {
|
try {
|
||||||
execSync("cargo update -p agent-browser --offline", {
|
execSync(`cargo update -p ${cargoPackageName} --offline`, {
|
||||||
cwd: cliDir,
|
cwd: cliDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
@@ -55,7 +73,7 @@ if (cargoTomlUpdated) {
|
|||||||
} catch {
|
} catch {
|
||||||
// --offline may fail if package not in cache, try without it
|
// --offline may fail if package not in cache, try without it
|
||||||
try {
|
try {
|
||||||
execSync("cargo update -p agent-browser", {
|
execSync(`cargo update -p ${cargoPackageName}`, {
|
||||||
cwd: cliDir,
|
cwd: cliDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
|
|||||||
# Navigation
|
# Navigation
|
||||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||||
agent-browser close # Close browser
|
agent-browser close # Close browser
|
||||||
|
agent-browser --version # Show CLI version (fork builds include upstream/fork)
|
||||||
|
|
||||||
# Snapshot
|
# Snapshot
|
||||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||||
@@ -219,25 +220,11 @@ agent-browser --allow-file-access open file:///path/to/page.html
|
|||||||
agent-browser screenshot output.png
|
agent-browser screenshot output.png
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stealth Mode (Avoid Bot Detection)
|
### Stealth Mode (Always On)
|
||||||
|
|
||||||
Stealth mode is enabled by default. It patches automation detection vectors (navigator.webdriver, plugins, WebGL, etc.) so websites cannot easily identify the browser as automated.
|
Stealth is always active -- no flags needed. All sessions automatically apply anti-detection patches (navigator.webdriver removal, UA override, plugin injection, WebGL masking, humanized interactions, etc.).
|
||||||
|
|
||||||
```bash
|
For best results against strong bot detection, use `--headed` and `--profile`.
|
||||||
# Stealth is on by default -- just use normally
|
|
||||||
agent-browser open https://example.com
|
|
||||||
|
|
||||||
# Disable stealth if needed for debugging
|
|
||||||
agent-browser --stealth false open https://example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Stealth capabilities vary by connection type:
|
|
||||||
|
|
||||||
- Local launch: Chromium launch args + context init scripts
|
|
||||||
- CDP / `--auto-connect`: context init scripts
|
|
||||||
- Cloud providers: context init scripts (Kernel may also apply provider-managed stealth)
|
|
||||||
|
|
||||||
For troubleshooting, run with `--debug` to print the active stealth connection type and capabilities.
|
|
||||||
|
|
||||||
### iOS Simulator (Mobile Safari)
|
### iOS Simulator (Mobile Safari)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1542,7 +1542,7 @@ export class BrowserManager {
|
|||||||
// Expand ~ to home directory since it won't be shell-expanded
|
// Expand ~ to home directory since it won't be shell-expanded
|
||||||
const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/');
|
const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/');
|
||||||
context = await launcher.launchPersistentContext(profilePath, {
|
context = await launcher.launchPersistentContext(profilePath, {
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? false,
|
||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -1558,7 +1558,7 @@ export class BrowserManager {
|
|||||||
} else {
|
} else {
|
||||||
// Regular ephemeral browser
|
// Regular ephemeral browser
|
||||||
this.browser = await launcher.launch({
|
this.browser = await launcher.launch({
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? false,
|
||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,46 @@ describe('Stealth mode', () => {
|
|||||||
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
|
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('neutralizes the css webdriver heuristic probe', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => ({
|
||||||
|
probe: CSS.supports('border-end-end-radius: initial'),
|
||||||
|
baseline: CSS.supports('display: block'),
|
||||||
|
webdriver: navigator.webdriver,
|
||||||
|
inNavigator: 'webdriver' in navigator,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(signals.probe).toBe(false);
|
||||||
|
expect(signals.baseline).toBe(true);
|
||||||
|
expect(signals.webdriver).toBeUndefined();
|
||||||
|
expect(signals.inNavigator).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralizes creepjs prefers-color-scheme light probe', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => {
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.setAttribute('style', 'background-color: ActiveText');
|
||||||
|
document.body.appendChild(node);
|
||||||
|
const activeTextColor = getComputedStyle(node).backgroundColor;
|
||||||
|
node.remove();
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTextColor,
|
||||||
|
prefersLight: matchMedia('(prefers-color-scheme: light)').matches,
|
||||||
|
prefersDark: matchMedia('(prefers-color-scheme: dark)').matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signals.activeTextColor).not.toBe('rgb(255, 0, 0)');
|
||||||
|
expect(signals.prefersLight).toBe(false);
|
||||||
|
expect(typeof signals.prefersDark).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes realistic mimeTypes/pdf/share signals', async () => {
|
it('exposes realistic mimeTypes/pdf/share signals', async () => {
|
||||||
browser = new BrowserManager();
|
browser = new BrowserManager();
|
||||||
await browser.launch({ headless: true, stealth: true });
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|||||||
+126
@@ -190,6 +190,7 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
|||||||
return [
|
return [
|
||||||
configScript,
|
configScript,
|
||||||
patchNavigatorWebdriver(),
|
patchNavigatorWebdriver(),
|
||||||
|
patchCssSupportsWebdriverHeuristic(),
|
||||||
patchChromeRuntime(),
|
patchChromeRuntime(),
|
||||||
patchNavigatorLanguages(),
|
patchNavigatorLanguages(),
|
||||||
patchNavigatorPluginsAndMimeTypes(),
|
patchNavigatorPluginsAndMimeTypes(),
|
||||||
@@ -201,11 +202,13 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
|||||||
patchScreenAvailability(),
|
patchScreenAvailability(),
|
||||||
patchNavigatorHardwareConcurrency(),
|
patchNavigatorHardwareConcurrency(),
|
||||||
patchNotificationPermission(),
|
patchNotificationPermission(),
|
||||||
|
patchActiveTextColorHeuristic(),
|
||||||
patchNavigatorConnection(),
|
patchNavigatorConnection(),
|
||||||
patchWorkerConnection(),
|
patchWorkerConnection(),
|
||||||
patchNavigatorShare(),
|
patchNavigatorShare(),
|
||||||
patchNavigatorContacts(),
|
patchNavigatorContacts(),
|
||||||
patchContentIndex(),
|
patchContentIndex(),
|
||||||
|
patchPrefersColorSchemeHeuristic(),
|
||||||
patchPdfViewerEnabled(),
|
patchPdfViewerEnabled(),
|
||||||
patchMediaDevices(),
|
patchMediaDevices(),
|
||||||
patchUserAgentData(),
|
patchUserAgentData(),
|
||||||
@@ -219,6 +222,44 @@ function buildStealthScript(options: StealthScriptOptions): string {
|
|||||||
// Individual patches
|
// Individual patches
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreepJS uses CSS.supports('border-end-end-radius: initial') + webdriver
|
||||||
|
* undefined to infer automation. Keep this one probe neutral.
|
||||||
|
*/
|
||||||
|
function patchCssSupportsWebdriverHeuristic(): string {
|
||||||
|
return `(function(){
|
||||||
|
if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return;
|
||||||
|
const nativeSupports = CSS.supports.bind(CSS);
|
||||||
|
const normalize = (value) => String(value).replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||||
|
const target = 'border-end-end-radius: initial';
|
||||||
|
const patchedSupports = function(...args) {
|
||||||
|
if (args.length === 1 && normalize(args[0]) === target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (args.length >= 2 && normalize(args[0] + ': ' + args[1]) === target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return nativeSupports(...args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
Object.defineProperty(patchedSupports, 'name', { value: 'supports', configurable: true });
|
||||||
|
Object.defineProperty(patchedSupports, 'toString', {
|
||||||
|
value: () => nativeSupports.toString(),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(CSS, 'supports', {
|
||||||
|
value: patchedSupports,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
try { CSS.supports = patchedSupports; } catch {}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove navigator.webdriver entirely.
|
* Remove navigator.webdriver entirely.
|
||||||
* Modern detection checks both value and property presence (`'webdriver' in navigator`).
|
* Modern detection checks both value and property presence (`'webdriver' in navigator`).
|
||||||
@@ -642,6 +683,46 @@ function patchNotificationPermission(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreepJS probes `background-color: ActiveText` and flags Chromium when the
|
||||||
|
* computed value resolves to `rgb(255, 0, 0)`. Rewrite only that exact probe.
|
||||||
|
*/
|
||||||
|
function patchActiveTextColorHeuristic(): string {
|
||||||
|
return `(function(){
|
||||||
|
if (typeof Element === 'undefined' || !Element.prototype) return;
|
||||||
|
const nativeSetAttribute = Element.prototype.setAttribute;
|
||||||
|
if (typeof nativeSetAttribute !== 'function') return;
|
||||||
|
const normalize = (value) => String(value).replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||||
|
const probeStyle = 'background-color: activetext';
|
||||||
|
const replacement = 'background-color: rgb(0, 0, 0)';
|
||||||
|
const patchedSetAttribute = function(name, value) {
|
||||||
|
if (String(name).toLowerCase() === 'style' && normalize(value) === probeStyle) {
|
||||||
|
return nativeSetAttribute.call(this, name, replacement);
|
||||||
|
}
|
||||||
|
return nativeSetAttribute.call(this, name, value);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
Object.defineProperty(patchedSetAttribute, 'name', {
|
||||||
|
value: 'setAttribute',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(patchedSetAttribute, 'toString', {
|
||||||
|
value: () => nativeSetAttribute.toString(),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(Element.prototype, 'setAttribute', {
|
||||||
|
value: patchedSetAttribute,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
try { Element.prototype.setAttribute = patchedSetAttribute; } catch {}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add missing connection.downlinkMax in Chromium headless environments.
|
* Add missing connection.downlinkMax in Chromium headless environments.
|
||||||
*/
|
*/
|
||||||
@@ -750,6 +831,51 @@ function patchWorkerConnection(): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreepJS marks light-scheme defaults as a weak headless signal. Keep
|
||||||
|
* `(prefers-color-scheme: light)` neutral without affecting other media queries.
|
||||||
|
*/
|
||||||
|
function patchPrefersColorSchemeHeuristic(): string {
|
||||||
|
return `(function(){
|
||||||
|
if (typeof window.matchMedia !== 'function') return;
|
||||||
|
const nativeMatchMedia = window.matchMedia.bind(window);
|
||||||
|
const normalize = (query) => String(query).replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||||
|
const prefersLight = '(prefers-color-scheme: light)';
|
||||||
|
const patchMediaQueryList = (mql) => {
|
||||||
|
if (!mql || typeof mql !== 'object') return mql;
|
||||||
|
return new Proxy(mql, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === 'matches') return false;
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const patchedMatchMedia = function(query) {
|
||||||
|
const mql = nativeMatchMedia(query);
|
||||||
|
if (normalize(query) === prefersLight) {
|
||||||
|
return patchMediaQueryList(mql);
|
||||||
|
}
|
||||||
|
return mql;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
Object.defineProperty(patchedMatchMedia, 'name', { value: 'matchMedia', configurable: true });
|
||||||
|
Object.defineProperty(patchedMatchMedia, 'toString', {
|
||||||
|
value: () => nativeMatchMedia.toString(),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
value: patchedMatchMedia,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
try { window.matchMedia = patchedMatchMedia; } catch {}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add share/canShare APIs expected on modern Chromium desktop.
|
* Add share/canShare APIs expected on modern Chromium desktop.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user