Skip to content

Commit 26ba528

Browse files
committed
release: 0.6.5 humanlike captcha recovery
1 parent 84e63f1 commit 26ba528

11 files changed

Lines changed: 119 additions & 39 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [0.6.5]
4+
5+
### Changed
6+
- CAPTCHA recovery seeds the original query instead of `hello world`.
7+
- Recovery forces humanlike inline mode: random per-char typing, Tab+Enter variability, then 1-3 result visits with dwell, scroll, goBack.
8+
- Removed `SURF_CAPTCHA_GRACE_MS`. `SURF_CAPTCHA_TIMEOUT_MS` default 240s to 180s.
9+
310
## [0.6.4]
411

512
### Fixed

manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "0.3",
33
"name": "google-surf-mcp",
44
"display_name": "Google Surf",
5-
"version": "0.6.4",
5+
"version": "0.6.5",
66
"description": "Free Google search MCP that actually works — no API key, no proxies, no solvers. Search + extraction in one MCP.",
77
"long_description": "Free Google search MCP that actually works — no API key, no proxies, no solvers (most free Google search MCPs fail in practice; this one drives a warm Chrome profile via Playwright + stealth). Search + page extraction in one MCP: `search_extract` returns SERP results already enriched with article bodies — replaces the usual search-MCP + fetch-MCP combo. CAPTCHA opens a visible Chrome window for a human to solve (shared-IP reputation protection); `SURF_CLOUD_MODE=true` for headless/serverless fail-fast.",
88
"author": {
@@ -36,7 +36,7 @@
3636
"command": "npx",
3737
"args": [
3838
"-y",
39-
"google-surf-mcp@0.6.4"
39+
"google-surf-mcp@0.6.5"
4040
],
4141
"env": {
4242
"CHROME_PATH": "${user_config.chrome_path}",

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "google-surf-mcp",
3-
"version": "0.6.4",
3+
"version": "0.6.5",
44
"mcpName": "io.github.HarimxChoi/google-surf-mcp",
55
"description": "MCP server for Google search via warm Chrome profile. No API key.",
66
"type": "module",

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"url": "https://github.com/HarimxChoi/google-surf-mcp",
77
"source": "github"
88
},
9-
"version": "0.6.4",
9+
"version": "0.6.5",
1010
"packages": [
1111
{
1212
"registryType": "npm",
1313
"identifier": "google-surf-mcp",
14-
"version": "0.6.4",
14+
"version": "0.6.5",
1515
"transport": {
1616
"type": "stdio"
1717
},

src/agent.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface Deps {
4141
acquirePool: (mode: StealthMode) => Promise<PoolHandle>;
4242
closeSeq: () => Promise<void>;
4343
resetPool: () => Promise<void>;
44-
recoverHuman: () => Promise<void>;
44+
recoverHuman: (seedQuery?: string) => Promise<void>;
4545
getPoolHealth: () => PoolHealthSnapshot;
4646
}
4747

@@ -60,18 +60,19 @@ export function initDeps(env: NodeJS.ProcessEnv = process.env): Pick<Deps, 'conf
6060
return { config, cache, cascade, limiter, tel, healing };
6161
}
6262

63-
function tier3Recovery(deps: Deps): () => Promise<void> {
63+
function tier3Recovery(deps: Deps, seedQuery?: string): () => Promise<void> {
6464
return async () => {
6565
if (deps.config.cloudMode) {
6666
throw new CaptchaError('cloud-mode: tier-3 unavailable');
6767
}
68-
await deps.recoverHuman();
68+
await deps.recoverHuman(seedQuery);
6969
};
7070
}
7171

7272
async function executeSeqWithCascade<T>(
7373
deps: Deps,
7474
op: (ctx: BrowserContext) => Promise<T>,
75+
seedQuery?: string,
7576
): Promise<T> {
7677
if (deps.config.cascadeDisabled) {
7778
const ctx = await deps.acquireSeqCtx(deps.config.useStealth ? 'on' : 'off');
@@ -84,7 +85,7 @@ async function executeSeqWithCascade<T>(
8485
return await op(ctx);
8586
},
8687
resetContext: async () => { await deps.closeSeq(); },
87-
tier3Recovery: tier3Recovery(deps),
88+
tier3Recovery: tier3Recovery(deps, seedQuery),
8889
isCaptchaError: (e) => e instanceof CaptchaError,
8990
onTransition: (from, to, reason) => {
9091
console.error(`[cascade] ${from}${to}: ${reason}`);
@@ -95,6 +96,7 @@ async function executeSeqWithCascade<T>(
9596
async function executePoolWithCascade<T>(
9697
deps: Deps,
9798
op: (pool: PoolHandle) => Promise<T>,
99+
seedQuery?: string,
98100
): Promise<T> {
99101
if (deps.config.cascadeDisabled) {
100102
const initialMode = deps.config.useStealth ? 'on' : 'off';
@@ -113,7 +115,7 @@ async function executePoolWithCascade<T>(
113115
deps.closeSeq().catch(() => {}),
114116
]);
115117
},
116-
tier3Recovery: tier3Recovery(deps),
118+
tier3Recovery: tier3Recovery(deps, seedQuery),
117119
isCaptchaError: (e) => e instanceof CaptchaError,
118120
onTransition: (from, to, reason) => {
119121
console.error(`[cascade pool] ${from}${to}: ${reason}`);
@@ -161,7 +163,7 @@ export async function searchTool(
161163
await behavior.simulateBrowsing(page, []).catch(() => {});
162164
}
163165
return r;
164-
});
166+
}, query);
165167

166168
const meta = {
167169
strategy: 'legacy-v0.4',
@@ -216,7 +218,7 @@ export async function searchParallelTool(
216218
for (let i = 0; i < queries.length; i++) await deps.limiter.acquire();
217219
const results = await executePoolWithCascade(deps, async (pool) => {
218220
return await pool.runMany(queries, limit, { locale: deps.config.locale, healing: deps.healing });
219-
});
221+
}, queries[0]);
220222

221223
const elapsed = Date.now() - t0;
222224
for (const r of results) {

src/captchaRecover.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { launch, getPage, PROFILE_MAIN, isBlocked } from './browser.js';
44
import { CaptchaError } from './search.js';
55
import type { CaptchaMode } from './captchaMode.js';
66
import { osNotify } from './notify.js';
7+
import { HumanlikeBehavior, generateBehaviorParams } from './humanlike.js';
78

89
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
10+
const rand = (a: number, b: number) => a + Math.random() * (b - a);
11+
const randInt = (a: number, b: number) => a + Math.floor(Math.random() * (b - a + 1));
912

1013
const NOTIFY_TITLE = 'google-surf-mcp: CAPTCHA';
1114
const NOTIFY_BODY = 'Google CAPTCHA detected. A browser window will open — solve it to resume.';
@@ -30,7 +33,7 @@ function remoteDebugGuidance(): string {
3033
export interface RecoverOptions {
3134
mode?: CaptchaMode;
3235
timeoutMs?: number;
33-
graceMs?: number;
36+
seedQuery?: string;
3437
}
3538

3639
let recoveryInFlight: Promise<void> | null = null;
@@ -44,8 +47,8 @@ function parseMs(v: string | undefined, fallback: number): number {
4447
export async function recoverFromCaptcha(opts: RecoverOptions | number = {}): Promise<void> {
4548
const o: RecoverOptions = typeof opts === 'number' ? { timeoutMs: opts } : opts;
4649
const mode: CaptchaMode = o.mode ?? 'notify_spawn';
47-
const timeoutMs = o.timeoutMs ?? parseMs(process.env.SURF_CAPTCHA_TIMEOUT_MS, 240_000);
48-
const graceMs = o.graceMs ?? parseMs(process.env.SURF_CAPTCHA_GRACE_MS, 120_000);
50+
const timeoutMs = o.timeoutMs ?? parseMs(process.env.SURF_CAPTCHA_TIMEOUT_MS, 180_000);
51+
const seedQuery = (o.seedQuery ?? '').trim() || 'hello world';
4952

5053
if (mode === 'cloud_fail_fast') {
5154
throw new CaptchaError('cloud-mode: tier-3 unavailable');
@@ -63,41 +66,42 @@ export async function recoverFromCaptcha(opts: RecoverOptions | number = {}): Pr
6366
await osNotify(NOTIFY_TITLE, NOTIFY_BODY).catch(() => {});
6467
}
6568
const ctx = await launch({ profileDir: PROFILE_MAIN, headless: false, blockResources: false });
69+
const behavior = new HumanlikeBehavior(generateBehaviorParams(), 'inline');
6670
try {
6771
const page = await getPage(ctx);
6872
await page.goto('https://www.google.com/', { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
6973
try { await (page as { bringToFront?: () => Promise<void> }).bringToFront?.(); } catch {}
70-
// headed often isn't served /sorry/ even when headless was; auto-search to land on /search?
74+
// headed often isn't served /sorry/ even when headless was; seed the real query to reach /search?
7175
if (!isBlocked(page.url())) {
7276
try {
73-
await sleep(800);
77+
await sleep(rand(600, 1400));
7478
const sb = page.locator('textarea[name="q"], input[name="q"]').first();
7579
await sb.click();
76-
await sleep(200);
77-
await page.keyboard.type('hello world', { delay: 60 });
78-
await sleep(300);
79-
await page.keyboard.press('Enter');
80+
await sleep(rand(150, 450));
81+
await behavior.typeQuery(page, seedQuery);
82+
await behavior.submitQuery(page);
8083
} catch {}
8184
}
8285
const start = Date.now();
83-
let landedAt: number | null = null;
86+
let browsed = false;
8487
while (Date.now() - start < timeoutMs) {
8588
const u = page.url();
8689
if (isBlocked(u)) {
87-
landedAt = null;
90+
browsed = false;
8891
await sleep(1500);
8992
continue;
9093
}
9194
if (u.includes('/search?')) {
92-
if (landedAt === null) {
93-
landedAt = Date.now();
94-
console.error(`[google-surf-mcp] captcha cleared; keeping window open ${Math.round(graceMs / 1000)}s for any follow-up CAPTCHA (override via SURF_CAPTCHA_GRACE_MS)`);
95+
if (!browsed) {
96+
browsed = true;
97+
console.error('[google-surf-mcp] captcha cleared; running humanlike browse before close');
98+
await behavior.visitRandomResults(page, randInt(1, 3)).catch(() => {});
99+
continue;
95100
}
96-
if (Date.now() - landedAt >= graceMs) return;
101+
return;
97102
}
98103
await sleep(1500);
99104
}
100-
if (landedAt !== null) return;
101105
throw new Error(`captcha not solved within ${Math.round(timeoutMs / 1000)}s`);
102106
} finally {
103107
await ctx.close().catch(() => {});

src/humanlike.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,73 @@ export class HumanlikeBehavior {
6666
}
6767
}
6868

69+
async submitQuery(page: Page): Promise<void> {
70+
await sleep(rand(300, 1200));
71+
if (Math.random() < 0.6) {
72+
const tabCount = randInt(1, 3);
73+
for (let i = 0; i < tabCount; i++) {
74+
await page.keyboard.press('Tab');
75+
await sleep(rand(150, 420));
76+
}
77+
}
78+
await page.keyboard.press('Enter');
79+
}
80+
81+
async visitRandomResults(page: Page, n: number): Promise<void> {
82+
if (n <= 0) return;
83+
try {
84+
await page.waitForSelector('a[href^="http"] h3', { timeout: 10_000 });
85+
} catch { return; }
86+
87+
await sleep(rand(800, 2000));
88+
89+
for (let i = 0; i < n; i++) {
90+
const links = page.locator('a[href^="http"]:has(h3)');
91+
const count = await links.count().catch(() => 0);
92+
if (count === 0) break;
93+
94+
// top-results bias: squared random concentrates idx near 0
95+
const cap = Math.min(9, count - 1);
96+
const idx = Math.floor(Math.random() ** 2 * (cap + 1));
97+
98+
const link = links.nth(idx);
99+
try { await link.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
100+
await sleep(rand(400, 1200));
101+
try { await link.hover({ timeout: 3000 }); } catch {}
102+
await sleep(rand(300, 900));
103+
104+
try {
105+
await Promise.all([
106+
page.waitForLoadState('domcontentloaded', { timeout: 12_000 }).catch(() => {}),
107+
link.click({ timeout: 5000 }),
108+
]);
109+
} catch { continue; }
110+
111+
await this.readPage(page);
112+
113+
await sleep(rand(400, 1200));
114+
try { await page.goBack({ timeout: 10_000, waitUntil: 'domcontentloaded' }); } catch {}
115+
await sleep(rand(800, 2200));
116+
}
117+
}
118+
119+
private async readPage(page: Page): Promise<void> {
120+
const dwellMs = randInt(4_000, 12_000);
121+
const end = Date.now() + dwellMs;
122+
await sleep(rand(500, 1500));
123+
while (Date.now() < end) {
124+
const r = Math.random();
125+
if (r < 0.7) {
126+
await page.mouse.wheel(0, randInt(120, 520)).catch(() => {});
127+
} else if (r < 0.9) {
128+
await page.mouse.wheel(0, -randInt(80, 240)).catch(() => {});
129+
} else {
130+
await page.mouse.move(rand(100, 900), rand(100, 600)).catch(() => {});
131+
}
132+
await sleep(rand(700, 2400));
133+
}
134+
}
135+
69136
async waitAfterSearch(): Promise<void> {
70137
if (this.mode === 'off') {
71138
await sleep(rand(50, 110));

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ function buildDeps(): Deps {
334334
headless: baseDeps.config.headless,
335335
remoteDebug: baseDeps.config.remoteDebug,
336336
});
337-
const recoverHuman = async () => {
337+
const recoverHuman = async (seedQuery?: string) => {
338338
// remote_debug: keep Chromium alive across DevTools attach window
339339
if (captchaMode === 'remote_debug') {
340340
suspendIdleClose();
@@ -344,7 +344,7 @@ function buildDeps(): Deps {
344344
closeSequential().catch(() => {}),
345345
]);
346346
}
347-
await recoverFromCaptcha({ mode: captchaMode });
347+
await recoverFromCaptcha({ mode: captchaMode, seedQuery });
348348
};
349349

350350
return {

test/captchaRecover.modes.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,14 @@ describe('recoverFromCaptcha modes', () => {
5656
it('notify_spawn invokes osNotify', async () => {
5757
const notify = await import('../src/notify.js');
5858
const { recoverFromCaptcha } = await import('../src/captchaRecover.js');
59-
await recoverFromCaptcha({ mode: 'notify_spawn', timeoutMs: 5_000, graceMs: 0 });
59+
await recoverFromCaptcha({ mode: 'notify_spawn', timeoutMs: 5_000 });
6060
expect(notify.osNotify).toHaveBeenCalledOnce();
6161
});
6262

6363
it('always_headed skips notification but still recovers', async () => {
6464
const notify = await import('../src/notify.js');
6565
const { recoverFromCaptcha } = await import('../src/captchaRecover.js');
66-
await recoverFromCaptcha({ mode: 'always_headed', timeoutMs: 5_000, graceMs: 0 });
66+
await recoverFromCaptcha({ mode: 'always_headed', timeoutMs: 5_000 });
6767
expect(notify.osNotify).not.toHaveBeenCalled();
6868
});
6969
});

0 commit comments

Comments
 (0)