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:
+70
-2
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user