feat: support proxy dict with bypass field (#24)

The `proxy` parameter now accepts a Playwright proxy dict
({server, bypass, username, password}) in addition to URL strings.
Dict proxies are passed directly to Playwright, enabling bypass
lists and other advanced proxy options.

- Add ProxySettings TypedDict for Python type safety
- Extract server URL from dict proxies for geoip resolution
- Handle dict proxy args/auth in Puppeteer wrapper
- Strip inline credentials from dict proxy server URL in Puppeteer
- Fix JS launchContext() double-setting timezone (binary flag + context)
- Remove unnecessary non-null assertions in TS geoip helpers
- Use nullish coalescing for password fallbacks
- Add unit tests for geoip with dict proxy input
This commit is contained in:
Cloak-HQ
2026-03-04 21:16:35 +01:00
parent ca5cce2222
commit bd22e51bc2
12 changed files with 223 additions and 30 deletions
+21 -1
View File
@@ -67,6 +67,11 @@ const browser = await launch({
proxy: 'http://user:pass@proxy:8080',
});
// With proxy object (bypass, separate auth fields)
const browser = await launch({
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
});
// Headed mode (visible browser window)
const browser = await launch({ headless: false });
@@ -95,7 +100,7 @@ const context = await launchContext({
timezoneId: 'America/New_York',
});
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection
// Persistent profile — stay logged in, bypass incognito detection, load extensions
const ctx = await launchPersistentContext({
userDataDir: './chrome-profile',
headless: false,
@@ -195,6 +200,21 @@ const page = await browser.newPage();
## Troubleshooting
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
```javascript
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.5",
"version": "0.3.6",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
+13 -4
View File
@@ -34,7 +34,9 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions,
});
@@ -62,7 +64,10 @@ export async function launchContext(
): Promise<BrowserContext> {
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
const browser = await launch({ ...options, ...resolved, geoip: false });
// Skip --fingerprint-timezone binary flag: it only applies to the default
// context and interferes with Playwright's timezoneId on new contexts.
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
let context: BrowserContext;
try {
@@ -123,7 +128,9 @@ export async function launchPersistentContext(
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
@@ -146,7 +153,9 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
+23 -5
View File
@@ -34,10 +34,26 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// so we strip them and use page.authenticate() instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password || "" };
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
// Strip any inline credentials from the server URL — Chromium's
// --proxy-server doesn't support them; use page.authenticate() instead.
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
proxyAuth = { username, password: password ?? "" };
}
}
}
@@ -74,7 +90,9 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
+7 -2
View File
@@ -5,8 +5,13 @@
export interface LaunchOptions {
/** Run in headless mode (default: true). */
headless?: boolean;
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
proxy?: string;
/**
* Proxy server — URL string or Playwright proxy object.
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
* — passed directly to Playwright.
*/
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
/** Additional Chromium CLI arguments. */
args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
+35
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => {
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
});
});
describe("proxy dict type", () => {
it("accepts string proxy in LaunchOptions", () => {
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
expect(typeof opts.proxy).toBe("string");
});
it("accepts dict proxy with bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
};
expect(typeof opts.proxy).toBe("object");
if (typeof opts.proxy === "object") {
expect(opts.proxy.server).toBe("http://proxy:8080");
expect(opts.proxy.bypass).toBe(".google.com,localhost");
}
});
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: {
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".example.com",
},
};
if (typeof opts.proxy === "object") {
expect(opts.proxy.username).toBe("user");
expect(opts.proxy.password).toBe("pass");
expect(opts.proxy.bypass).toBe(".example.com");
}
});
});