Skip to content

Commit f2f833c

Browse files
committed
feat: swap Playwright for Patchright + bot-detection utilities
Patchright (drop-in Playwright replacement) removes the Runtime.enable CDP leak, Console.enable leak, and --enable-automation flag — the main signals that authenticated sites like LinkedIn use to detect automation. New utilities in @browserkit/core/adapter-utils: - detectRateLimit(page): throws on /checkpoint URLs or rate-limit body text; wired into wrapToolCall so every tool call is protected - dismissModals(page): closes blocking popups via ARIA-stable selectors - scrollContainer(page, anchorSelector): finds nearest scrollable ancestor and scrolls it iteratively (correct fix for nested-scroll LinkedIn layouts) LinkedIn adapter isLoggedIn now dismisses the "Remember Me / Stay signed in" prompt that previously blocked all navigation after first login. Made-with: Cursor
1 parent eac1023 commit f2f833c

14 files changed

Lines changed: 232 additions & 46 deletions

.cursor/hooks/state/continual-learning-index.json

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,47 @@
33
"transcripts": {
44
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/7ff80e1e-d316-4cd1-9f0f-6dc4e044e981/7ff80e1e-d316-4cd1-9f0f-6dc4e044e981.jsonl": {
55
"mtimeMs": 1774268759000,
6-
"lastProcessedAt": "2026-03-23T16:45:00.000Z"
6+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
77
},
88
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/abb7fe3a-e2fb-4d6a-96c9-af2132261e36/abb7fe3a-e2fb-4d6a-96c9-af2132261e36.jsonl": {
99
"mtimeMs": 1774276015000,
10-
"lastProcessedAt": "2026-03-23T16:45:00.000Z"
10+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
1111
},
1212
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/48c31d0a-13ce-4e19-88ec-0225ad4494ad/48c31d0a-13ce-4e19-88ec-0225ad4494ad.jsonl": {
1313
"mtimeMs": 1774280028000,
14-
"lastProcessedAt": "2026-03-23T16:45:00.000Z"
14+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
1515
},
1616
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/4abb768d-dfd3-4278-8f22-5f3ccfb3a959/4abb768d-dfd3-4278-8f22-5f3ccfb3a959.jsonl": {
17-
"mtimeMs": 1774280712000,
18-
"lastProcessedAt": "2026-03-23T16:45:00.000Z"
17+
"mtimeMs": 1774281374000,
18+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
19+
},
20+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/7110620f-271a-4db1-87b4-279c6adfcfb7/7110620f-271a-4db1-87b4-279c6adfcfb7.jsonl": {
21+
"mtimeMs": 1774286264000,
22+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
23+
},
24+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/b82fce76-2196-4db3-9013-49280b49e97b/b82fce76-2196-4db3-9013-49280b49e97b.jsonl": {
25+
"mtimeMs": 1774286078000,
26+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
27+
},
28+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/d7b149aa-4f29-4d71-9c3f-19e2c665da59/d7b149aa-4f29-4d71-9c3f-19e2c665da59.jsonl": {
29+
"mtimeMs": 1774286530000,
30+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
31+
},
32+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/7b307ef7-47b9-4311-b527-3318ac9231d3/7b307ef7-47b9-4311-b527-3318ac9231d3.jsonl": {
33+
"mtimeMs": 1774301611000,
34+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
35+
},
36+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/50512271-3552-41e1-88d4-9d4c141964c4/50512271-3552-41e1-88d4-9d4c141964c4.jsonl": {
37+
"mtimeMs": 1774303070000,
38+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
39+
},
40+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/5e728ef6-2360-48b8-9a4d-e9f09b8932a6/5e728ef6-2360-48b8-9a4d-e9f09b8932a6.jsonl": {
41+
"mtimeMs": 1774305471000,
42+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
43+
},
44+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/742cd137-f286-473f-b685-4441da6c55db/742cd137-f286-473f-b685-4441da6c55db.jsonl": {
45+
"mtimeMs": 1774339903000,
46+
"lastProcessedAt": "2026-03-24T00:00:00.000Z"
1947
}
2048
}
2149
}
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"version": 1,
3-
"lastRunAtMs": 1774300358160,
4-
"turnsSinceLastRun": 13,
5-
"lastTranscriptMtimeMs": 1774300357852,
6-
"lastProcessedGenerationId": "3172194f-8eaa-494f-9a93-a27a7216b2e6",
3+
"lastRunAtMs": 1774339889907,
4+
"turnsSinceLastRun": 1,
5+
"lastTranscriptMtimeMs": 1774339889511,
6+
"lastProcessedGenerationId": "09febc9d-9e78-4049-9955-4ff9b30f944d",
77
"trialStartedAtMs": null
88
}

AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ Durable facts and correction patterns for this workspace. Updated by continual-l
55
## Project: browserkit
66

77
- Project is named **browserkit** — decided and final. npm scope is `@browserkit`. GitHub org is `browserkit-dev` (`browserkit` org was taken on GitHub, available on npm).
8-
- GitHub repos: `browserkit-dev/browserkit` (framework — `@browserkit/core` + `@browserkit/core/testing`), `browserkit-dev/adapter-hackernews` (standalone adapter repo)
8+
- GitHub repos: `browserkit-dev/browserkit` (framework — `@browserkit/core` + `@browserkit/core/testing`), `browserkit-dev/adapter-hackernews` (standalone adapter repo), `browserkit-dev/adapter-google-discover` (private — not ready)
99
- Language is TypeScript, not Python
1010
- MCP transport is HTTP (`StreamableHTTPServerTransport`), not stdio — preferred for multi-agent deployment
1111
- Each adapter gets its own HTTP port; each connecting MCP client gets its own `McpServer + StreamableHTTPServerTransport` pair (per-session factory inside the HTTP handler). Shared state (browser, lock, rate limiter) lives outside the McpServer.
12+
- All adapters share one daemon process — restarting the daemon to reload one adapter takes all adapters down. Use `browserkit reload <site>` to restart just one adapter's MCP server without stopping the daemon (browser session preserved).
1213
- Adapter packages are plain npm packages with no naming convention — config keys are npm package names, resolved via `require(key)`
1314
- Adapters live in external git repos as standalone packages; the monorepo's `adapter-linkedin` is a reference implementation only
1415
- No abstract base class for adapters — `SiteAdapter` is an interface, shared logic is standalone utility functions (composition over inheritance)
@@ -26,12 +27,15 @@ Durable facts and correction patterns for this workspace. Updated by continual-l
2627

2728
## Browser Control
2829

29-
- Browser mode switching (`headless` / `watch` / `paused`), screenshot, page state, and navigate are MCP tools auto-registered on every adapter server — not CLI commands
30+
- Browser mode switching (`headless` / `watch` / `paused`), screenshot, page state, and navigate are consolidated into a single `browser` MCP tool with an `action` parameter — user explicitly asked to reduce tool count ("too many tools"); do NOT revert to 5 separate management tools
3031
- Management tools bypass the LockManager; regular automation tools go through it
3132
- "Raw" Playwright access means exposing the CDP WebSocket URL (`wsEndpoint()`) of each adapter's browser — external agents (Claude Code, Cursor) attach to the already-authenticated session and write their own Playwright scripts via shell
3233
- The Playwright skill pattern: AI writes a script to `/tmp`, executes it via shell — no `run(code)` MCP tool needed
3334
- MCP resources use `page://${site}/snapshot` (site name dynamic) — user pushed back when the URI appeared to hardcode the adapter name
3435
- Testing utilities (`createTestAdapterServer`, `createTestMcpClient`) live at `@browserkit/core/testing` subpath — a separate harness package was explicitly rejected ("I don't think we need it, it should be in either adapter or in core")
36+
- Real Chrome (`channel: "chrome"`) is required for Google-based adapters — Playwright's bundled Chromium is blocked by Google's login with "This browser or app may not be secure". `isLoggedIn` must NOT navigate during login polling or it redirects the user away from the sign-in page.
37+
- Google Discover has NO infinite scroll in automated browser contexts — confirmed with Pixel 5, Pixel 7, both headless and watch mode, both `window.scrollBy` and `mouse.wheel`. ~10 articles is the practical ceiling per call. Do NOT mention this limitation in marketing content.
38+
- Patchright (drop-in Playwright replacement) is the next step for LinkedIn adapter — removes `Runtime.enable` CDP leak and other automation signals that authenticated sites detect. `channel: "chrome"` already covers some of the same ground for Google.
3539

3640
## Design Process Preferences
3741

@@ -45,3 +49,6 @@ Durable facts and correction patterns for this workspace. Updated by continual-l
4549
- Bugs found during testing should be fixed inline ("fix issues on the go"), not deferred to a follow-up task
4650
- Adapter developers should minimize visible dependency on the framework — adapters should feel like standalone npm packages, not framework plugins
4751
- Documentation for AI agents building adapters is a first-class concern — README must include the full `SiteAdapter` interface, testing pattern (`@browserkit/core/testing`), and a link to the HN adapter as a reference
52+
- Cursor uses `.cursor/mcp.json` for project-level MCP config; `.mcp.json` is the Claude Code format — these are different files serving different tools
53+
- E2E install tests are wanted: spin up a clean environment, install core + HN adapter, verify tools work, install Google Discover adapter, verify it starts but returns auth error (no login)
54+
- Squash CI fix commits to keep git history clean — user noticed multiple "fix CI" commits and asked to squash

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@
2424
"test": "vitest run",
2525
"test:watch": "vitest",
2626
"lint": "tsc --noEmit",
27-
"postinstall": "playwright install chromium --with-deps || true"
27+
"postinstall": "patchright install chromium --with-deps || true"
2828
},
2929
"dependencies": {
3030
"@modelcontextprotocol/sdk": "^1.10.2",
3131
"pino": "^9.6.0",
32-
"playwright": "^1.51.1",
32+
"patchright": "^1.51.1",
3333
"zod": "^3.24.2"
3434
},
3535
"devDependencies": {

packages/core/src/adapter-server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import crypto from "node:crypto";
33
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
55
import { z } from "zod";
6-
import type { Page } from "playwright";
6+
import type { Page } from "patchright";
77
import type {
88
SiteAdapter,
99
AdapterConfig,
@@ -15,7 +15,7 @@ import { SessionManager } from "./session-manager.js";
1515
import { LockManager } from "./lock-manager.js";
1616
import { RateLimiter } from "./rate-limiter.js";
1717
import { buildHandoffResult, handleAuthFailure, isBackgroundLoginInProgress } from "./human-handoff.js";
18-
import { screenshotOnError, screenshotToContent } from "./adapter-utils.js";
18+
import { screenshotOnError, screenshotToContent, detectRateLimit } from "./adapter-utils.js";
1919
import { getLogger } from "./logger.js";
2020

2121
const log = getLogger("adapter-server");
@@ -97,6 +97,9 @@ export async function createAdapterServer(
9797
let result: ToolResult;
9898
try {
9999
result = await tool.handler(page, input);
100+
// Check for rate limiting after each tool call — throws if detected,
101+
// which propagates to the outer catch and returns isError:true
102+
await detectRateLimit(page);
100103
} catch (err) {
101104
log.error({ site, tool: toolName, err }, "tool handler error");
102105
const screenshotContent = await screenshotToContent(page).catch(() => null);

packages/core/src/adapter-utils.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import type { Page, Locator } from "playwright";
3+
import type { Page, Locator } from "patchright";
44
import type { SelectorReport, ToolContent } from "./types.js";
55

66
/**
@@ -139,3 +139,147 @@ export async function screenshotOnError(
139139
function sleep(ms: number): Promise<void> {
140140
return new Promise((resolve) => setTimeout(resolve, ms));
141141
}
142+
143+
/**
144+
* Detect rate limiting or security challenges after a navigation.
145+
*
146+
* Modeled on stickerdaniel/linkedin-mcp-server's approach:
147+
* 1. URL-based check: /checkpoint or authwall in the URL = security challenge
148+
* 2. Content-based check: only runs on error-shaped pages (no <main> element,
149+
* body text < 2000 chars). Guards against false positives on real content pages
150+
* that incidentally contain phrases like "slow down".
151+
*
152+
* Throws an Error if rate limiting is detected — the caller (wrapToolCall) will
153+
* catch this and return isError:true so the AI knows to wait before retrying.
154+
*/
155+
export async function detectRateLimit(page: Page): Promise<void> {
156+
const url = page.url();
157+
158+
// URL-based: security checkpoints always redirect to known paths
159+
if (url.includes("/checkpoint") || url.includes("authwall")) {
160+
throw new Error(
161+
`Rate limit or security challenge detected at: ${url}. ` +
162+
"Wait a few minutes before retrying. If this persists, run `browserkit login <site>` to re-authenticate."
163+
);
164+
}
165+
166+
// Content-based: only run on error-shaped pages (minimal, no <main>)
167+
try {
168+
const hasMain = await page.locator("main").count() > 0;
169+
if (hasMain) return; // real content page — skip heuristic
170+
171+
const bodyText = await page.locator("body").innerText({ timeout: 1000 }).catch(() => "");
172+
if (bodyText && bodyText.length < 2_000) {
173+
const lower = bodyText.toLowerCase();
174+
const rateLimitPhrases = ["too many requests", "rate limit", "slow down", "try again later"];
175+
if (rateLimitPhrases.some((p) => lower.includes(p))) {
176+
throw new Error(
177+
`Rate limit message detected on page (${url}). ` +
178+
"Wait before retrying."
179+
);
180+
}
181+
}
182+
} catch (err) {
183+
// Re-throw rate limit errors; swallow page read errors
184+
if (err instanceof Error && err.message.includes("Rate limit")) throw err;
185+
}
186+
}
187+
188+
/**
189+
* Dismiss popup modals that may be blocking content.
190+
*
191+
* Tries a set of ARIA-stable selectors in order. Returns true if a modal
192+
* was dismissed, false if nothing was found. Failures are silently swallowed.
193+
*
194+
* The artdeco selector is LinkedIn-specific but harmless on other sites.
195+
*/
196+
export async function dismissModals(page: Page): Promise<boolean> {
197+
const dismissSelectors = [
198+
'button[aria-label="Dismiss"]',
199+
'button[aria-label="Close"]',
200+
'button[aria-label="Dismiss dialog"]',
201+
"button.artdeco-modal__dismiss",
202+
];
203+
204+
for (const selector of dismissSelectors) {
205+
try {
206+
const btn = page.locator(selector).first();
207+
if (await btn.isVisible({ timeout: 800 })) {
208+
await btn.click();
209+
await sleep(400);
210+
return true;
211+
}
212+
} catch {
213+
// try next selector
214+
}
215+
}
216+
return false;
217+
}
218+
219+
/**
220+
* Scroll the nearest scrollable ancestor of `anchorSelector` until no new
221+
* content loads or `maxScrolls` is reached.
222+
*
223+
* This is the correct approach for sites with nested scrollable containers
224+
* (LinkedIn job sidebar, LinkedIn feed, etc.) where `window.scrollBy` has
225+
* no effect because the scrollable element is not the window.
226+
*
227+
* @param anchorSelector CSS selector for any element inside the container
228+
* @param options.pauseMs ms to wait between scrolls (default: 1000)
229+
* @param options.maxScrolls maximum number of scroll attempts (default: 10)
230+
* @returns number of scrolls performed (-1 if no scrollable container found)
231+
*/
232+
export async function scrollContainer(
233+
page: Page,
234+
anchorSelector: string,
235+
options: { pauseMs?: number; maxScrolls?: number } = {}
236+
): Promise<number> {
237+
const { pauseMs = 1000, maxScrolls = 10 } = options;
238+
239+
const scrollCount = await page.evaluate(
240+
({ sel, pauseTime, maxScrolls: max }) => {
241+
// Find the anchor element, then walk up to the first scrollable ancestor
242+
const anchor = document.querySelector(sel);
243+
if (!anchor) return -1;
244+
245+
let container: Element | null = anchor.parentElement;
246+
while (container && container !== document.body) {
247+
const style = window.getComputedStyle(container);
248+
const overflowY = style.overflowY;
249+
if (
250+
(overflowY === "auto" || overflowY === "scroll") &&
251+
container.scrollHeight > container.clientHeight
252+
) {
253+
break;
254+
}
255+
container = container.parentElement;
256+
}
257+
258+
if (!container || container === document.body) return -1;
259+
260+
// Scroll iteratively until content stops growing
261+
let count = 0;
262+
const scroll = (): Promise<number> =>
263+
new Promise((resolve) => {
264+
let i = 0;
265+
const step = () => {
266+
if (i >= max) { resolve(count); return; }
267+
const prev = container!.scrollHeight;
268+
container!.scrollTop = container!.scrollHeight;
269+
setTimeout(() => {
270+
if (container!.scrollHeight === prev) { resolve(count); return; }
271+
count++;
272+
i++;
273+
step();
274+
}, pauseTime);
275+
};
276+
step();
277+
});
278+
279+
return scroll();
280+
},
281+
{ sel: anchorSelector, pauseTime: pauseMs, maxScrolls }
282+
);
283+
284+
return scrollCount;
285+
}

packages/core/src/create-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ function indexTs(name: string): string {
155155
const loginUrl = `https://www.${domain}/login`;
156156
return `import { defineAdapter } from "@browserkit/core";
157157
import { z } from "zod";
158-
import type { Page } from "playwright";
158+
import type { Page } from "patchright";
159159
import { SELECTORS } from "./selectors.js";
160160
161161
export default defineAdapter({

packages/core/src/human-handoff.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os from "node:os";
22
import path from "node:path";
33
import fs from "node:fs";
4-
import { chromium } from "playwright";
4+
import { chromium } from "patchright";
55
import type { HandoffResult, SiteAdapter, ToolResult } from "./types.js";
66
import type { SessionManager } from "./session-manager.js";
77
import type { SessionConfig } from "./types.js";

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export {
3535
extractByRole,
3636
screenshotToContent,
3737
screenshotOnError,
38+
detectRateLimit,
39+
dismissModals,
40+
scrollContainer,
3841
} from "./adapter-utils.js";
3942

4043
// ─── Observability ────────────────────────────────────────────────────────────

packages/core/src/observability.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import type { BrowserContext, Page } from "playwright";
3+
import type { BrowserContext, Page } from "patchright";
44
import { getLogger } from "./logger.js";
55

66
const log = getLogger("observability");

0 commit comments

Comments
 (0)