feat(cli): 强制使用用户现有浏览器并移除 profile/channel

- 禁用 --profile/AGENT_BROWSER_PROFILE 与 --channel/AGENT_BROWSER_CHANNEL,并给出项目策略提示

- 默认模式强制连接 localhost:9333,连接失败直接报错,不再自动回退新开浏览器

- 同步更新 README、技能文档、docs 与 --help 输出

- 版本升级到 0.14.0-fork.2 并同步 cli/Cargo.toml 与 Cargo.lock
This commit is contained in:
leeguooooo
2026-02-24 17:05:11 +09:00
parent 893ddfd259
commit 699ccbd3cb
19 changed files with 317 additions and 172 deletions
+70 -2
View File
@@ -529,21 +529,89 @@ async function handleNavigate(
): Promise<Response<NavigateData>> {
const page = browser.getPage();
// Set target URL for region auto-detection (locale/timezone)
await browser.setTargetUrl(command.url);
// If headers are provided, set up scoped headers for this origin
if (command.headers && Object.keys(command.headers).length > 0) {
await browser.setScopedHeaders(command.url, command.headers);
}
// Humanized navigation pacing: random short delay before navigating
const pace = 300 + Math.random() * 700;
await page.waitForTimeout(Math.round(pace));
await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load',
});
// Detect captcha/verification pages and retry with backoff
const finalUrl = page.url();
const title = await page.title();
const captchaDetected = isCaptchaPage(finalUrl, title);
if (captchaDetected) {
const maxRetries = 2;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const backoff = 3000 + Math.random() * 4000;
await page.waitForTimeout(Math.round(backoff));
await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load',
});
const retryUrl = page.url();
const retryTitle = await page.title();
if (!isCaptchaPage(retryUrl, retryTitle)) {
return successResponse(command.id, {
url: retryUrl,
title: retryTitle,
});
}
}
// All retries exhausted -- return the page as-is with a warning
return successResponse(command.id, {
url: page.url(),
title: await page.title(),
warning:
'Captcha/verification page detected. Try --headed mode or use --session-name for state persistence.',
} as NavigateData);
}
return successResponse(command.id, {
url: page.url(),
title: await page.title(),
url: finalUrl,
title,
});
}
function isCaptchaPage(url: string, title: string): boolean {
const lowerUrl = url.toLowerCase();
const lowerTitle = title.toLowerCase();
const captchaPatterns = [
'/verify/captcha',
'/captcha',
'/challenge',
'scene=crawler',
'scene=anti_bot',
'recaptcha',
'hcaptcha',
];
const titlePatterns = [
'verify',
'captcha',
'challenge',
'attention required',
'just a moment',
'checking your browser',
'access denied',
'驗證',
'验证',
'人机验证',
];
return (
captchaPatterns.some((p) => lowerUrl.includes(p)) ||
titlePatterns.some((p) => lowerTitle.includes(p))
);
}
function bezierPoint(t: number, p0: number, p1: number, p2: number, p3: number): number {
const u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
+121 -40
View File
@@ -249,13 +249,117 @@ export class BrowserManager {
return undefined;
}
// TLD -> {locale, timezone} mapping for automatic region consistency
private static readonly TLD_REGION_MAP: Record<string, { locale: string; timezone: string }> = {
tw: { locale: 'zh-TW', timezone: 'Asia/Taipei' },
cn: { locale: 'zh-CN', timezone: 'Asia/Shanghai' },
hk: { locale: 'zh-HK', timezone: 'Asia/Hong_Kong' },
jp: { locale: 'ja-JP', timezone: 'Asia/Tokyo' },
kr: { locale: 'ko-KR', timezone: 'Asia/Seoul' },
th: { locale: 'th-TH', timezone: 'Asia/Bangkok' },
vn: { locale: 'vi-VN', timezone: 'Asia/Ho_Chi_Minh' },
sg: { locale: 'en-SG', timezone: 'Asia/Singapore' },
my: { locale: 'ms-MY', timezone: 'Asia/Kuala_Lumpur' },
id: { locale: 'id-ID', timezone: 'Asia/Jakarta' },
ph: { locale: 'en-PH', timezone: 'Asia/Manila' },
br: { locale: 'pt-BR', timezone: 'America/Sao_Paulo' },
mx: { locale: 'es-MX', timezone: 'America/Mexico_City' },
ar: { locale: 'es-AR', timezone: 'America/Argentina/Buenos_Aires' },
de: { locale: 'de-DE', timezone: 'Europe/Berlin' },
fr: { locale: 'fr-FR', timezone: 'Europe/Paris' },
uk: { locale: 'en-GB', timezone: 'Europe/London' },
ru: { locale: 'ru-RU', timezone: 'Europe/Moscow' },
in: { locale: 'hi-IN', timezone: 'Asia/Kolkata' },
au: { locale: 'en-AU', timezone: 'Australia/Sydney' },
};
// Target URL set during navigation, used for region auto-detection
private targetUrl: string | undefined = undefined;
/**
* Set the target URL for region auto-detection.
* Called from navigate/open commands so locale/timezone can adapt.
* Applies CDP overrides to align locale/timezone with the target site's region.
*/
async setTargetUrl(url: string): Promise<void> {
this.targetUrl = url;
const region = this.getRegionFromUrl(url);
if (!region) return;
// Skip if user has explicitly set locale/timezone via env
const envLocale = process.env.AGENT_BROWSER_LOCALE;
const envTimezone = process.env.AGENT_BROWSER_TIMEZONE || process.env.TZ;
try {
const page = this.getPage();
const cdp = await page.context().newCDPSession(page);
if (!envTimezone) {
await cdp
.send('Emulation.setTimezoneOverride', { timezoneId: region.timezone })
.catch(() => {});
}
if (!envLocale) {
await cdp.send('Emulation.setLocaleOverride', { locale: region.locale }).catch(() => {});
// Update Accept-Language header to match
const langHeader = this.buildAcceptLanguageHeader(region.locale);
const context = page.context();
const currentHeaders = this.contextHeaders ?? {};
await context.setExtraHTTPHeaders({ ...currentHeaders, 'Accept-Language': langHeader });
}
await cdp.detach().catch(() => {});
} catch {
// CDP not available (non-Chromium), skip dynamic override
}
}
private getRegionFromUrl(url?: string): { locale: string; timezone: string } | undefined {
if (!url) return undefined;
try {
const hostname = new URL(url).hostname;
const parts = hostname.split('.');
const tld = parts[parts.length - 1];
// Check compound TLDs like co.th, com.tw, co.id
const secondLevel = parts.length >= 2 ? parts[parts.length - 2] : '';
const compoundTld = `${secondLevel}.${tld}`;
// Try compound first (e.g., "co.th" -> "th", "com.tw" -> "tw")
const compoundMatch = BrowserManager.TLD_REGION_MAP[tld];
if (
compoundMatch &&
(secondLevel === 'co' ||
secondLevel === 'com' ||
secondLevel === 'or' ||
secondLevel === 'org')
) {
return compoundMatch;
}
// Then direct TLD
if (BrowserManager.TLD_REGION_MAP[tld]) {
return BrowserManager.TLD_REGION_MAP[tld];
}
return undefined;
} catch {
return undefined;
}
}
private resolveStealthLocale(headers?: Record<string, string>): string {
const headerLocale = this.getHeaderValue(headers, 'accept-language');
const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale);
if (normalizedHeaderLocale) return normalizedHeaderLocale;
// Explicit env var takes priority
const envLocale = this.normalizeLocaleTag(process.env.AGENT_BROWSER_LOCALE);
if (envLocale) return envLocale;
// Auto-detect from target URL TLD
const urlRegion = this.getRegionFromUrl(this.targetUrl);
if (urlRegion) return urlRegion.locale;
const candidates = [
process.env.AGENT_BROWSER_LOCALE,
process.env.LC_ALL,
process.env.LC_MESSAGES,
process.env.LANG,
@@ -269,16 +373,16 @@ export class BrowserManager {
}
private resolveStealthTimezoneId(): string | undefined {
const candidates = [
process.env.AGENT_BROWSER_TIMEZONE,
process.env.TZ,
Intl.DateTimeFormat().resolvedOptions().timeZone,
];
for (const value of candidates) {
const timezone = value?.trim();
if (!timezone) continue;
if (timezone === 'UTC' || timezone.includes('/')) return timezone;
}
// Explicit env var takes priority
const envTz = process.env.AGENT_BROWSER_TIMEZONE?.trim() || process.env.TZ?.trim();
if (envTz && (envTz === 'UTC' || envTz.includes('/'))) return envTz;
// Auto-detect from target URL TLD
const urlRegion = this.getRegionFromUrl(this.targetUrl);
if (urlRegion) return urlRegion.timezone;
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (systemTz) return systemTz;
return undefined;
}
@@ -1409,23 +1513,12 @@ export class BrowserManager {
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
const hasExtensions = !!options.extensions?.length;
const hasProfile = !!options.profile;
const hasStorageState = !!options.storageState;
if (hasExtensions && cdpEndpoint) {
throw new Error('Extensions cannot be used with CDP connection');
}
if (hasProfile && cdpEndpoint) {
throw new Error('Profile cannot be used with CDP connection');
}
if (hasStorageState && hasProfile) {
throw new Error(
'Storage state cannot be used with profile (profile is already persistent storage)'
);
}
if (hasStorageState && hasExtensions) {
throw new Error(
'Storage state cannot be used with extensions (extensions require persistent context)'
@@ -1510,6 +1603,10 @@ export class BrowserManager {
const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
// Chromium launches always use the Chrome channel unless a custom executable is provided.
const chromeChannel =
browserType === 'chromium' && !options.executablePath ? 'chrome' : undefined;
const stealthPolicy = this.getStealthPolicy(browserType);
const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
@@ -1572,6 +1669,7 @@ export class BrowserManager {
{
headless: false,
executablePath: options.executablePath,
...(chromeChannel && { channel: chromeChannel }),
args: allArgs,
viewport,
extraHTTPHeaders,
@@ -1584,29 +1682,12 @@ export class BrowserManager {
}
);
this.isPersistentContext = true;
} else if (hasProfile) {
// Profile uses persistent context for durable cookies/storage
// Expand ~ to home directory since it won't be shell-expanded
const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/');
context = await launcher.launchPersistentContext(profilePath, {
headless: options.headless ?? false,
executablePath: options.executablePath,
args: baseArgs,
viewport,
extraHTTPHeaders,
userAgent: contextUserAgent,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }),
});
this.isPersistentContext = true;
} else {
// Regular ephemeral browser
this.browser = await launcher.launch({
headless: options.headless ?? false,
executablePath: options.executablePath,
...(chromeChannel && { channel: chromeChannel }),
args: baseArgs,
});
this.cdpEndpoint = null;
+1 -2
View File
@@ -466,7 +466,6 @@ export async function startDaemon(options?: {
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
@@ -480,7 +479,7 @@ export async function startDaemon(options?: {
let launchedViaDefaultCdp = false;
try {
// Keep default CDP attempt minimal. Launch-only options like profile/extensions
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause a false-negative fallback.
const cdpLaunchOptions = {
id: launchOptions.id,
-1
View File
@@ -50,7 +50,6 @@ const launchSchema = baseCommandSchema.extend({
ignoreHTTPSErrors: z.boolean().optional(),
allowFileAccess: z.boolean().optional(),
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
profile: z.string().optional(),
storageState: z.string().optional(),
});
+1 -1
View File
@@ -18,7 +18,6 @@ export interface LaunchCommand extends BaseCommand {
cdpUrl?: string;
autoConnect?: boolean; // Auto-discover and connect to running Chrome via DevToolsActivePort
extensions?: string[];
profile?: string; // Path to persistent browser profile directory
storageState?: string; // Path to storage state JSON file
proxy?: {
server: string;
@@ -1073,6 +1072,7 @@ export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
export interface NavigateData {
url: string;
title: string;
warning?: string;
}
export interface Annotation {