@jackwener/opencli 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/README.zh-CN.md +7 -7
- package/SKILL.md +0 -1
- package/dist/browser/cdp.js +10 -1
- package/dist/browser/daemon-client.js +2 -1
- package/dist/browser/discover.js +2 -1
- package/dist/browser/errors.js +2 -1
- package/dist/browser/index.d.ts +1 -0
- package/dist/browser/index.js +1 -0
- package/dist/browser/page.js +12 -0
- package/dist/browser/stealth.d.ts +18 -0
- package/dist/browser/stealth.js +140 -0
- package/dist/browser.test.js +47 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/daemon.js +2 -1
- package/dist/doctor.js +2 -1
- package/docs/adapters/browser/douban.md +18 -8
- package/docs/adapters/browser/wikipedia.md +0 -9
- package/docs/adapters/desktop/antigravity.md +0 -3
- package/docs/adapters/index.md +8 -8
- package/package.json +1 -1
- package/src/browser/cdp.ts +9 -1
- package/src/browser/daemon-client.ts +4 -3
- package/src/browser/discover.ts +2 -1
- package/src/browser/errors.ts +2 -1
- package/src/browser/index.ts +1 -0
- package/src/browser/page.ts +11 -0
- package/src/browser/stealth.ts +142 -0
- package/src/browser.test.ts +51 -1
- package/src/build-manifest.ts +0 -1
- package/src/constants.ts +3 -0
- package/src/daemon.ts +2 -1
- package/src/doctor.ts +2 -1
- package/src/validate.ts +0 -1
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ Run `opencli list` for the live registry.
|
|
|
99
99
|
|
|
100
100
|
| Site | Commands | Mode |
|
|
101
101
|
|------|----------|------|
|
|
102
|
-
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | Browser |
|
|
102
|
+
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | Browser |
|
|
103
103
|
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
|
104
104
|
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
|
105
105
|
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
|
@@ -111,7 +111,7 @@ Run `opencli list` for the live registry.
|
|
|
111
111
|
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
|
112
112
|
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
|
|
113
113
|
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
|
|
114
|
-
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch`
|
|
114
|
+
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
|
115
115
|
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
|
|
116
116
|
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
|
|
117
117
|
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
|
@@ -126,7 +126,7 @@ Run `opencli list` for the live registry.
|
|
|
126
126
|
| **ctrip** | `search` | Browser |
|
|
127
127
|
| **devto** | `top` `tag` `user` | Public |
|
|
128
128
|
| **arxiv** | `search` `paper` | Public |
|
|
129
|
-
| **wikipedia** | `search` `summary` | Public |
|
|
129
|
+
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
|
130
130
|
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
|
131
131
|
| **linkedin** | `search` | Browser |
|
|
132
132
|
| **reuters** | `search` | Browser |
|
|
@@ -145,14 +145,14 @@ Run `opencli list` for the live registry.
|
|
|
145
145
|
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
|
|
146
146
|
| **steam** | `top-sellers` | Public |
|
|
147
147
|
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
|
|
148
|
-
| **douban** | `search` `top250` `subject` `marks` `reviews` | Browser |
|
|
148
|
+
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
|
|
149
149
|
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
|
|
150
150
|
| **google** | `news` `search` `suggest` `trends` | Public |
|
|
151
151
|
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
|
|
152
152
|
| **lobsters** | `hot` `newest` `active` `tag` | Public |
|
|
153
|
-
| **medium** | `feed` `search` `user`
|
|
154
|
-
| **sinablog** | `hot` `search` `article` `user`
|
|
155
|
-
| **substack** | `feed` `search` `publication`
|
|
153
|
+
| **medium** | `feed` `search` `user` | Browser |
|
|
154
|
+
| **sinablog** | `hot` `search` `article` `user` | Browser |
|
|
155
|
+
| **substack** | `feed` `search` `publication` | Browser |
|
|
156
156
|
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
|
|
157
157
|
|
|
158
158
|
|
package/README.zh-CN.md
CHANGED
|
@@ -101,7 +101,7 @@ npm install -g @jackwener/opencli@latest
|
|
|
101
101
|
|
|
102
102
|
| 站点 | 命令 | 模式 |
|
|
103
103
|
|------|------|------|
|
|
104
|
-
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
|
|
104
|
+
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
|
|
105
105
|
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
|
106
106
|
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
|
107
107
|
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
|
@@ -113,7 +113,7 @@ npm install -g @jackwener/opencli@latest
|
|
|
113
113
|
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
|
114
114
|
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
|
|
115
115
|
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
|
|
116
|
-
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch`
|
|
116
|
+
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
|
117
117
|
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
|
|
118
118
|
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
|
119
119
|
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
|
@@ -128,7 +128,7 @@ npm install -g @jackwener/opencli@latest
|
|
|
128
128
|
| **ctrip** | `search` | 浏览器 |
|
|
129
129
|
| **devto** | `top` `tag` `user` | 公开 |
|
|
130
130
|
| **arxiv** | `search` `paper` | 公开 |
|
|
131
|
-
| **wikipedia** | `search` `summary` | 公开 |
|
|
131
|
+
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
|
132
132
|
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
|
133
133
|
| **linkedin** | `search` | 浏览器 |
|
|
134
134
|
| **reuters** | `search` | 浏览器 |
|
|
@@ -147,14 +147,14 @@ npm install -g @jackwener/opencli@latest
|
|
|
147
147
|
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
|
148
148
|
| **steam** | `top-sellers` | 公开 |
|
|
149
149
|
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
|
150
|
-
| **douban** | `search` `top250` `subject` `marks` `reviews` | 浏览器 |
|
|
150
|
+
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
|
151
151
|
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
|
|
152
152
|
| **google** | `news` `search` `suggest` `trends` | 公开 |
|
|
153
153
|
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
|
|
154
154
|
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
|
|
155
|
-
| **medium** | `feed` `search` `user`
|
|
156
|
-
| **sinablog** | `hot` `search` `article` `user`
|
|
157
|
-
| **substack** | `feed` `search` `publication`
|
|
155
|
+
| **medium** | `feed` `search` `user` | 浏览器 |
|
|
156
|
+
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
|
|
157
|
+
| **substack** | `feed` `search` `publication` | 浏览器 |
|
|
158
158
|
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
|
159
159
|
|
|
160
160
|
|
package/SKILL.md
CHANGED
|
@@ -182,7 +182,6 @@ opencli antigravity dump # 导出 DOM 和快照调试信息
|
|
|
182
182
|
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
|
|
183
183
|
opencli antigravity model claude # 切换底层模型
|
|
184
184
|
opencli antigravity watch # 流式监听增量消息
|
|
185
|
-
opencli antigravity serve --port 8082 # 启动 Anthropic 兼容代理
|
|
186
185
|
|
|
187
186
|
# Barchart (browser)
|
|
188
187
|
opencli barchart quote --symbol AAPL # 股票行情
|
package/dist/browser/cdp.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { WebSocket } from 'ws';
|
|
11
11
|
import { wrapForEval } from './utils.js';
|
|
12
12
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
13
|
+
import { generateStealthJs } from './stealth.js';
|
|
13
14
|
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
14
15
|
const CDP_SEND_TIMEOUT = 30_000; // 30s per command
|
|
15
16
|
export class CDPBridge {
|
|
@@ -38,9 +39,17 @@ export class CDPBridge {
|
|
|
38
39
|
const ws = new WebSocket(wsUrl);
|
|
39
40
|
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
|
|
40
41
|
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
|
|
41
|
-
ws.on('open', () => {
|
|
42
|
+
ws.on('open', async () => {
|
|
42
43
|
clearTimeout(timeout);
|
|
43
44
|
this._ws = ws;
|
|
45
|
+
// Register stealth script to run before any page JS on every navigation.
|
|
46
|
+
try {
|
|
47
|
+
await this.send('Page.enable');
|
|
48
|
+
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Non-fatal: stealth is best-effort
|
|
52
|
+
}
|
|
44
53
|
resolve(new CDPPage(this));
|
|
45
54
|
});
|
|
46
55
|
ws.on('error', (err) => {
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides a typed send() function that posts a Command and returns a Result.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
7
|
+
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
7
8
|
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
8
9
|
let _idCounter = 0;
|
|
9
10
|
function generateId() {
|
package/dist/browser/discover.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Only needs to check if the daemon is running. No more file system
|
|
5
5
|
* scanning for @playwright/mcp locations.
|
|
6
6
|
*/
|
|
7
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
7
8
|
import { isDaemonRunning } from './daemon-client.js';
|
|
8
9
|
export { isDaemonRunning };
|
|
9
10
|
/**
|
|
@@ -11,7 +12,7 @@ export { isDaemonRunning };
|
|
|
11
12
|
*/
|
|
12
13
|
export async function checkDaemonStatus() {
|
|
13
14
|
try {
|
|
14
|
-
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ??
|
|
15
|
+
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
15
16
|
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
16
17
|
headers: { 'X-OpenCLI': '1' },
|
|
17
18
|
});
|
package/dist/browser/errors.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
|
|
6
6
|
*/
|
|
7
7
|
import { BrowserConnectError } from '../errors.js';
|
|
8
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
8
9
|
export function formatBrowserConnectError(kind, detail) {
|
|
9
10
|
switch (kind) {
|
|
10
11
|
case 'daemon-not-running':
|
|
11
12
|
return new BrowserConnectError('Cannot connect to opencli daemon.' +
|
|
12
13
|
(detail ? `\n\n${detail}` : ''), 'The daemon should start automatically. If it doesn\'t, try:\n' +
|
|
13
14
|
' node dist/daemon.js\n' +
|
|
14
|
-
|
|
15
|
+
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`);
|
|
15
16
|
case 'extension-not-connected':
|
|
16
17
|
return new BrowserConnectError('opencli Browser Bridge extension is not connected.' +
|
|
17
18
|
(detail ? `\n\n${detail}` : ''), 'Please install the extension:\n' +
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
|
|
9
9
|
export { CDPBridge } from './cdp.js';
|
|
10
10
|
export { isDaemonRunning } from './daemon-client.js';
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
|
+
export { generateStealthJs } from './stealth.js';
|
|
12
13
|
export type { SnapshotOptions } from './dom-snapshot.js';
|
|
13
14
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
15
|
import { withTimeoutMs } from '../runtime.js';
|
package/dist/browser/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
|
|
9
9
|
export { CDPBridge } from './cdp.js';
|
|
10
10
|
export { isDaemonRunning } from './daemon-client.js';
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
|
+
export { generateStealthJs } from './stealth.js';
|
|
12
13
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
13
14
|
import { __test__ as cdpTest } from './cdp.js';
|
|
14
15
|
import { withTimeoutMs } from '../runtime.js';
|
package/dist/browser/page.js
CHANGED
|
@@ -13,6 +13,7 @@ import { formatSnapshot } from '../snapshotFormatter.js';
|
|
|
13
13
|
import { sendCommand } from './daemon-client.js';
|
|
14
14
|
import { wrapForEval } from './utils.js';
|
|
15
15
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
16
|
+
import { generateStealthJs } from './stealth.js';
|
|
16
17
|
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
17
18
|
/**
|
|
18
19
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
@@ -41,6 +42,17 @@ export class Page {
|
|
|
41
42
|
if (result?.tabId) {
|
|
42
43
|
this._tabId = result.tabId;
|
|
43
44
|
}
|
|
45
|
+
// Inject stealth anti-detection patches (guard flag prevents double-injection).
|
|
46
|
+
try {
|
|
47
|
+
await sendCommand('exec', {
|
|
48
|
+
code: generateStealthJs(),
|
|
49
|
+
...this._workspaceOpt(),
|
|
50
|
+
...this._tabOpt(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Non-fatal: stealth is best-effort
|
|
55
|
+
}
|
|
44
56
|
// Smart settle: use DOM stability detection instead of fixed sleep.
|
|
45
57
|
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
46
58
|
if (options?.waitUntil !== 'none') {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth anti-detection module.
|
|
3
|
+
*
|
|
4
|
+
* Generates JS code that patches browser globals to hide automation
|
|
5
|
+
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
|
|
6
|
+
* plugin list). Injected before page scripts run so that websites cannot
|
|
7
|
+
* detect CDP / extension-based control.
|
|
8
|
+
*
|
|
9
|
+
* Inspired by puppeteer-extra-plugin-stealth.
|
|
10
|
+
*/
|
|
11
|
+
/** Guard flag set on `window` to prevent double-injection. */
|
|
12
|
+
export declare const STEALTH_GUARD = "__opencli_stealth_applied";
|
|
13
|
+
/**
|
|
14
|
+
* Return a self-contained JS string that, when evaluated in a page context,
|
|
15
|
+
* applies all stealth patches. Safe to call multiple times — the guard flag
|
|
16
|
+
* ensures patches are applied only once.
|
|
17
|
+
*/
|
|
18
|
+
export declare function generateStealthJs(): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth anti-detection module.
|
|
3
|
+
*
|
|
4
|
+
* Generates JS code that patches browser globals to hide automation
|
|
5
|
+
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
|
|
6
|
+
* plugin list). Injected before page scripts run so that websites cannot
|
|
7
|
+
* detect CDP / extension-based control.
|
|
8
|
+
*
|
|
9
|
+
* Inspired by puppeteer-extra-plugin-stealth.
|
|
10
|
+
*/
|
|
11
|
+
/** Guard flag set on `window` to prevent double-injection. */
|
|
12
|
+
export const STEALTH_GUARD = '__opencli_stealth_applied';
|
|
13
|
+
/**
|
|
14
|
+
* Return a self-contained JS string that, when evaluated in a page context,
|
|
15
|
+
* applies all stealth patches. Safe to call multiple times — the guard flag
|
|
16
|
+
* ensures patches are applied only once.
|
|
17
|
+
*/
|
|
18
|
+
export function generateStealthJs() {
|
|
19
|
+
return `
|
|
20
|
+
(() => {
|
|
21
|
+
// Guard: skip if already applied
|
|
22
|
+
if (window.${STEALTH_GUARD}) return 'skipped';
|
|
23
|
+
// Use defineProperty so the guard flag is non-enumerable (not a detection vector).
|
|
24
|
+
Object.defineProperty(window, '${STEALTH_GUARD}', { value: true, configurable: true });
|
|
25
|
+
|
|
26
|
+
// 1. navigator.webdriver → undefined
|
|
27
|
+
// Most common check; Playwright/Puppeteer/CDP set this to true.
|
|
28
|
+
try {
|
|
29
|
+
Object.defineProperty(navigator, 'webdriver', {
|
|
30
|
+
get: () => undefined,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
} catch {}
|
|
34
|
+
|
|
35
|
+
// 2. window.chrome stub
|
|
36
|
+
// Real Chrome exposes window.chrome with runtime, loadTimes, csi.
|
|
37
|
+
// Headless/automated Chrome may not have it.
|
|
38
|
+
try {
|
|
39
|
+
if (!window.chrome) {
|
|
40
|
+
window.chrome = {
|
|
41
|
+
runtime: {
|
|
42
|
+
onConnect: { addListener: () => {}, removeListener: () => {} },
|
|
43
|
+
onMessage: { addListener: () => {}, removeListener: () => {} },
|
|
44
|
+
},
|
|
45
|
+
loadTimes: () => ({}),
|
|
46
|
+
csi: () => ({}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
// 3. navigator.plugins — fake population only if empty
|
|
52
|
+
// Real user browser already has plugins; only patch in automated/headless
|
|
53
|
+
// contexts where the list is empty (overwriting real plugins with fakes
|
|
54
|
+
// would be counterproductive and detectable).
|
|
55
|
+
try {
|
|
56
|
+
if (!navigator.plugins || navigator.plugins.length === 0) {
|
|
57
|
+
const fakePlugins = [
|
|
58
|
+
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
|
59
|
+
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
60
|
+
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
61
|
+
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
62
|
+
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
|
|
63
|
+
];
|
|
64
|
+
fakePlugins.item = (i) => fakePlugins[i] || null;
|
|
65
|
+
fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null;
|
|
66
|
+
fakePlugins.refresh = () => {};
|
|
67
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
68
|
+
get: () => fakePlugins,
|
|
69
|
+
configurable: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
// 4. navigator.languages — guarantee non-empty
|
|
75
|
+
// Some automated contexts return undefined or empty array.
|
|
76
|
+
try {
|
|
77
|
+
if (!navigator.languages || navigator.languages.length === 0) {
|
|
78
|
+
Object.defineProperty(navigator, 'languages', {
|
|
79
|
+
get: () => ['en-US', 'en'],
|
|
80
|
+
configurable: true,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
// 5. Permissions.query — normalize notification permission
|
|
86
|
+
// Headless Chrome throws on Permissions.query({ name: 'notifications' }).
|
|
87
|
+
try {
|
|
88
|
+
const origQuery = window.Permissions?.prototype?.query;
|
|
89
|
+
if (origQuery) {
|
|
90
|
+
window.Permissions.prototype.query = function (parameters) {
|
|
91
|
+
if (parameters?.name === 'notifications') {
|
|
92
|
+
return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
93
|
+
}
|
|
94
|
+
return origQuery.call(this, parameters);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
// 6. Clean automation artifacts
|
|
100
|
+
// Remove properties left by Playwright, Puppeteer, or CDP injection.
|
|
101
|
+
try {
|
|
102
|
+
delete window.__playwright;
|
|
103
|
+
delete window.__puppeteer;
|
|
104
|
+
// ChromeDriver injects cdc_ prefixed globals; the suffix varies by version,
|
|
105
|
+
// so scan window for any matching property rather than hardcoding names.
|
|
106
|
+
for (const prop of Object.getOwnPropertyNames(window)) {
|
|
107
|
+
if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) {
|
|
108
|
+
try { delete window[prop]; } catch {}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
// 7. CDP stack trace cleanup
|
|
114
|
+
// Runtime.evaluate injects scripts whose source URLs appear in Error
|
|
115
|
+
// stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://).
|
|
116
|
+
// Websites detect automation by doing: new Error().stack and inspecting it.
|
|
117
|
+
// We override the stack property getter on Error.prototype to filter them.
|
|
118
|
+
// Note: Error.prepareStackTrace is V8/Node-only and not available in
|
|
119
|
+
// browser page context, so we use a property descriptor approach instead.
|
|
120
|
+
try {
|
|
121
|
+
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
|
|
122
|
+
const _cdpPatterns = ['puppeteer_evaluation_script', 'pptr:', 'debugger://', '__opencli'];
|
|
123
|
+
if (_origDescriptor && _origDescriptor.get) {
|
|
124
|
+
Object.defineProperty(Error.prototype, 'stack', {
|
|
125
|
+
get: function () {
|
|
126
|
+
const raw = _origDescriptor.get.call(this);
|
|
127
|
+
if (typeof raw !== 'string') return raw;
|
|
128
|
+
return raw.split('\\n').filter(line =>
|
|
129
|
+
!_cdpPatterns.some(p => line.includes(p))
|
|
130
|
+
).join('\\n');
|
|
131
|
+
},
|
|
132
|
+
configurable: true,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
|
|
137
|
+
return 'applied';
|
|
138
|
+
})()
|
|
139
|
+
`;
|
|
140
|
+
}
|
package/dist/browser.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { BrowserBridge, __test__ } from './browser/index.js';
|
|
2
|
+
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
|
|
3
|
+
import { STEALTH_GUARD } from './browser/stealth.js';
|
|
3
4
|
import * as daemonClient from './browser/daemon-client.js';
|
|
4
5
|
describe('browser helpers', () => {
|
|
5
6
|
it('extracts tab entries from string snapshots', () => {
|
|
@@ -102,3 +103,48 @@ describe('BrowserBridge state', () => {
|
|
|
102
103
|
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
describe('stealth anti-detection', () => {
|
|
107
|
+
it('generates non-empty JS string', () => {
|
|
108
|
+
const js = generateStealthJs();
|
|
109
|
+
expect(typeof js).toBe('string');
|
|
110
|
+
expect(js.length).toBeGreaterThan(100);
|
|
111
|
+
});
|
|
112
|
+
it('contains all 7 anti-detection patches', () => {
|
|
113
|
+
const js = generateStealthJs();
|
|
114
|
+
// 1. webdriver
|
|
115
|
+
expect(js).toContain('navigator');
|
|
116
|
+
expect(js).toContain('webdriver');
|
|
117
|
+
// 2. chrome stub
|
|
118
|
+
expect(js).toContain('window.chrome');
|
|
119
|
+
// 3. plugins
|
|
120
|
+
expect(js).toContain('plugins');
|
|
121
|
+
expect(js).toContain('PDF Viewer');
|
|
122
|
+
// 4. languages
|
|
123
|
+
expect(js).toContain('languages');
|
|
124
|
+
// 5. permissions
|
|
125
|
+
expect(js).toContain('Permissions');
|
|
126
|
+
expect(js).toContain('notifications');
|
|
127
|
+
// 6. automation artifacts (dynamic cdc_ scan)
|
|
128
|
+
expect(js).toContain('__playwright');
|
|
129
|
+
expect(js).toContain('__puppeteer');
|
|
130
|
+
expect(js).toContain('getOwnPropertyNames');
|
|
131
|
+
expect(js).toContain('cdc_');
|
|
132
|
+
// 7. CDP stack trace cleanup
|
|
133
|
+
expect(js).toContain('Error.prototype');
|
|
134
|
+
expect(js).toContain('puppeteer_evaluation_script');
|
|
135
|
+
expect(js).toContain('getOwnPropertyDescriptor');
|
|
136
|
+
});
|
|
137
|
+
it('includes guard flag to prevent double-injection', () => {
|
|
138
|
+
const js = generateStealthJs();
|
|
139
|
+
expect(js).toContain(STEALTH_GUARD);
|
|
140
|
+
// Guard should check early and return 'skipped'
|
|
141
|
+
expect(js).toContain("return 'skipped'");
|
|
142
|
+
// Normal path returns 'applied'
|
|
143
|
+
expect(js).toContain("return 'applied'");
|
|
144
|
+
});
|
|
145
|
+
it('generates syntactically valid JS', () => {
|
|
146
|
+
const js = generateStealthJs();
|
|
147
|
+
// Should not throw when parsed
|
|
148
|
+
expect(() => new Function(js)).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
});
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared constants used across explore, synthesize, and pipeline modules.
|
|
3
3
|
*/
|
|
4
|
+
/** Default daemon port for HTTP/WebSocket communication with browser extension */
|
|
5
|
+
export declare const DEFAULT_DAEMON_PORT = 19825;
|
|
4
6
|
/** URL query params that are volatile/ephemeral and should be stripped from patterns */
|
|
5
7
|
export declare const VOLATILE_PARAMS: Set<string>;
|
|
6
8
|
/** Search-related query parameter names */
|
package/dist/constants.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared constants used across explore, synthesize, and pipeline modules.
|
|
3
3
|
*/
|
|
4
|
+
/** Default daemon port for HTTP/WebSocket communication with browser extension */
|
|
5
|
+
export const DEFAULT_DAEMON_PORT = 19825;
|
|
4
6
|
/** URL query params that are volatile/ephemeral and should be stripped from patterns */
|
|
5
7
|
export const VOLATILE_PARAMS = new Set([
|
|
6
8
|
'w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign',
|
package/dist/daemon.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { createServer } from 'node:http';
|
|
22
22
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
23
|
-
|
|
23
|
+
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
24
|
+
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
24
25
|
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
25
26
|
// ─── State ───────────────────────────────────────────────────────────
|
|
26
27
|
let extensionWs = null;
|
package/dist/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* MCP path discovery, or config file scanning.
|
|
6
6
|
*/
|
|
7
7
|
import chalk from 'chalk';
|
|
8
|
+
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
8
9
|
import { checkDaemonStatus } from './browser/discover.js';
|
|
9
10
|
import { BrowserBridge } from './browser/index.js';
|
|
10
11
|
import { listSessions } from './browser/daemon-client.js';
|
|
@@ -75,7 +76,7 @@ export function renderBrowserDoctorReport(report) {
|
|
|
75
76
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
76
77
|
// Daemon status
|
|
77
78
|
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
|
|
78
|
-
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ?
|
|
79
|
+
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running'}`);
|
|
79
80
|
// Extension status
|
|
80
81
|
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
|
|
81
82
|
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
|
|
@@ -6,19 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
| Command | Description |
|
|
8
8
|
|---------|-------------|
|
|
9
|
+
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
|
10
|
+
| `opencli douban top250` | 豆瓣电影 Top 250 |
|
|
11
|
+
| `opencli douban subject` | 条目详情 |
|
|
12
|
+
| `opencli douban marks` | 我的标记 |
|
|
13
|
+
| `opencli douban reviews` | 我的短评 |
|
|
9
14
|
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
|
|
10
15
|
| `opencli douban book-hot` | 豆瓣图书热门榜单 |
|
|
11
|
-
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
|
12
16
|
|
|
13
17
|
## Usage Examples
|
|
14
18
|
|
|
15
19
|
```bash
|
|
16
|
-
# 电影热门
|
|
17
|
-
opencli douban movie-hot --limit 10
|
|
18
|
-
|
|
19
|
-
# 图书热门
|
|
20
|
-
opencli douban book-hot --limit 10
|
|
21
|
-
|
|
22
20
|
# 搜索电影
|
|
23
21
|
opencli douban search "流浪地球"
|
|
24
22
|
|
|
@@ -28,8 +26,20 @@ opencli douban search --type book "三体"
|
|
|
28
26
|
# 搜索音乐
|
|
29
27
|
opencli douban search --type music "周杰伦"
|
|
30
28
|
|
|
29
|
+
# 电影 Top 250
|
|
30
|
+
opencli douban top250 --limit 10
|
|
31
|
+
|
|
32
|
+
# 条目详情
|
|
33
|
+
opencli douban subject 1292052
|
|
34
|
+
|
|
35
|
+
# 电影热门
|
|
36
|
+
opencli douban movie-hot --limit 10
|
|
37
|
+
|
|
38
|
+
# 图书热门
|
|
39
|
+
opencli douban book-hot --limit 10
|
|
40
|
+
|
|
31
41
|
# JSON output
|
|
32
|
-
opencli douban
|
|
42
|
+
opencli douban top250 -f json
|
|
33
43
|
```
|
|
34
44
|
|
|
35
45
|
## Prerequisites
|
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
|---------|-------------|
|
|
9
9
|
| `opencli wikipedia search` | Search Wikipedia articles |
|
|
10
10
|
| `opencli wikipedia summary` | Get Wikipedia article summary |
|
|
11
|
-
| `opencli wikipedia random` | Get a random Wikipedia article |
|
|
12
|
-
| `opencli wikipedia trending` | Most-read articles (yesterday) |
|
|
13
11
|
|
|
14
12
|
## Usage Examples
|
|
15
13
|
|
|
@@ -20,15 +18,8 @@ opencli wikipedia search "quantum computing" --limit 10
|
|
|
20
18
|
# Get article summary
|
|
21
19
|
opencli wikipedia summary "Artificial intelligence"
|
|
22
20
|
|
|
23
|
-
# Get a random article
|
|
24
|
-
opencli wikipedia random
|
|
25
|
-
|
|
26
|
-
# Most-read articles (yesterday)
|
|
27
|
-
opencli wikipedia trending --limit 5
|
|
28
|
-
|
|
29
21
|
# Use with other languages
|
|
30
22
|
opencli wikipedia search "人工智能" --lang zh
|
|
31
|
-
opencli wikipedia random --lang ja
|
|
32
23
|
|
|
33
24
|
# JSON output
|
|
34
25
|
opencli wikipedia search "Rust" -f json
|
|
@@ -47,6 +47,3 @@ Quickly target and switch the active LLM engine. Example: `opencli antigravity m
|
|
|
47
47
|
|
|
48
48
|
### `opencli antigravity watch`
|
|
49
49
|
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
|
|
50
|
-
|
|
51
|
-
### `opencli antigravity serve --port 8082`
|
|
52
|
-
Start an Anthropic-compatible `/v1/messages` proxy backed by the local Antigravity app. Useful when you want external tools to talk to Antigravity through an API-shaped interface.
|
package/docs/adapters/index.md
CHANGED
|
@@ -6,12 +6,12 @@ Run `opencli list` for the live registry.
|
|
|
6
6
|
|
|
7
7
|
| Site | Commands | Mode |
|
|
8
8
|
|------|----------|------|
|
|
9
|
-
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 🔐 Browser |
|
|
9
|
+
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
|
|
10
10
|
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
|
11
11
|
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
|
12
12
|
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
|
13
|
-
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `
|
|
14
|
-
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
|
13
|
+
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
|
|
14
|
+
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 🔐 Browser |
|
|
15
15
|
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
|
|
16
16
|
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
|
|
17
17
|
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
|
|
@@ -30,12 +30,12 @@ Run `opencli list` for the live registry.
|
|
|
30
30
|
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
|
|
31
31
|
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
|
|
32
32
|
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
|
|
33
|
-
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` | 🔐 Browser |
|
|
33
|
+
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
|
|
34
34
|
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
|
|
35
35
|
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
|
|
36
|
-
| **[medium](/adapters/browser/medium)** | `feed` `search` `user`
|
|
37
|
-
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user`
|
|
38
|
-
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication`
|
|
36
|
+
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
|
|
37
|
+
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
|
|
38
|
+
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
|
|
39
39
|
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
|
|
40
40
|
|
|
41
41
|
## Public API Adapters
|
|
@@ -53,7 +53,7 @@ Run `opencli list` for the live registry.
|
|
|
53
53
|
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
|
|
54
54
|
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
|
|
55
55
|
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
|
|
56
|
-
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` | 🌐 Public |
|
|
56
|
+
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
|
|
57
57
|
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
|
|
58
58
|
|
|
59
59
|
## Desktop Adapters
|
package/package.json
CHANGED
package/src/browser/cdp.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { WebSocket, type RawData } from 'ws';
|
|
|
12
12
|
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
|
13
13
|
import { wrapForEval } from './utils.js';
|
|
14
14
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
15
|
+
import { generateStealthJs } from './stealth.js';
|
|
15
16
|
import {
|
|
16
17
|
clickJs,
|
|
17
18
|
typeTextJs,
|
|
@@ -71,9 +72,16 @@ export class CDPBridge {
|
|
|
71
72
|
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
|
|
72
73
|
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
|
|
73
74
|
|
|
74
|
-
ws.on('open', () => {
|
|
75
|
+
ws.on('open', async () => {
|
|
75
76
|
clearTimeout(timeout);
|
|
76
77
|
this._ws = ws;
|
|
78
|
+
// Register stealth script to run before any page JS on every navigation.
|
|
79
|
+
try {
|
|
80
|
+
await this.send('Page.enable');
|
|
81
|
+
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
|
|
82
|
+
} catch {
|
|
83
|
+
// Non-fatal: stealth is best-effort
|
|
84
|
+
}
|
|
77
85
|
resolve(new CDPPage(this));
|
|
78
86
|
});
|
|
79
87
|
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* Provides a typed send() function that posts a Command and returns a Result.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
9
|
-
|
|
7
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
10
8
|
import type { BrowserSessionInfo } from '../types.js';
|
|
11
9
|
|
|
10
|
+
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
11
|
+
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
12
|
+
|
|
12
13
|
let _idCounter = 0;
|
|
13
14
|
|
|
14
15
|
function generateId(): string {
|
package/src/browser/discover.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* scanning for @playwright/mcp locations.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
8
9
|
import { isDaemonRunning } from './daemon-client.js';
|
|
9
10
|
|
|
10
11
|
export { isDaemonRunning };
|
|
@@ -17,7 +18,7 @@ export async function checkDaemonStatus(): Promise<{
|
|
|
17
18
|
extensionConnected: boolean;
|
|
18
19
|
}> {
|
|
19
20
|
try {
|
|
20
|
-
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ??
|
|
21
|
+
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
21
22
|
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
22
23
|
headers: { 'X-OpenCLI': '1' },
|
|
23
24
|
});
|
package/src/browser/errors.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { BrowserConnectError } from '../errors.js';
|
|
9
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
9
10
|
|
|
10
11
|
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str
|
|
|
17
18
|
(detail ? `\n\n${detail}` : ''),
|
|
18
19
|
'The daemon should start automatically. If it doesn\'t, try:\n' +
|
|
19
20
|
' node dist/daemon.js\n' +
|
|
20
|
-
|
|
21
|
+
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
|
21
22
|
);
|
|
22
23
|
case 'extension-not-connected':
|
|
23
24
|
return new BrowserConnectError(
|
package/src/browser/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
|
|
10
10
|
export { CDPBridge } from './cdp.js';
|
|
11
11
|
export { isDaemonRunning } from './daemon-client.js';
|
|
12
12
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
13
|
+
export { generateStealthJs } from './stealth.js';
|
|
13
14
|
export type { SnapshotOptions } from './dom-snapshot.js';
|
|
14
15
|
|
|
15
16
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
package/src/browser/page.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOpti
|
|
|
15
15
|
import { sendCommand } from './daemon-client.js';
|
|
16
16
|
import { wrapForEval } from './utils.js';
|
|
17
17
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
18
|
+
import { generateStealthJs } from './stealth.js';
|
|
18
19
|
import {
|
|
19
20
|
clickJs,
|
|
20
21
|
typeTextJs,
|
|
@@ -54,6 +55,16 @@ export class Page implements IPage {
|
|
|
54
55
|
if (result?.tabId) {
|
|
55
56
|
this._tabId = result.tabId;
|
|
56
57
|
}
|
|
58
|
+
// Inject stealth anti-detection patches (guard flag prevents double-injection).
|
|
59
|
+
try {
|
|
60
|
+
await sendCommand('exec', {
|
|
61
|
+
code: generateStealthJs(),
|
|
62
|
+
...this._workspaceOpt(),
|
|
63
|
+
...this._tabOpt(),
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// Non-fatal: stealth is best-effort
|
|
67
|
+
}
|
|
57
68
|
// Smart settle: use DOM stability detection instead of fixed sleep.
|
|
58
69
|
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
59
70
|
if (options?.waitUntil !== 'none') {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth anti-detection module.
|
|
3
|
+
*
|
|
4
|
+
* Generates JS code that patches browser globals to hide automation
|
|
5
|
+
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
|
|
6
|
+
* plugin list). Injected before page scripts run so that websites cannot
|
|
7
|
+
* detect CDP / extension-based control.
|
|
8
|
+
*
|
|
9
|
+
* Inspired by puppeteer-extra-plugin-stealth.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Guard flag set on `window` to prevent double-injection. */
|
|
13
|
+
export const STEALTH_GUARD = '__opencli_stealth_applied';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Return a self-contained JS string that, when evaluated in a page context,
|
|
17
|
+
* applies all stealth patches. Safe to call multiple times — the guard flag
|
|
18
|
+
* ensures patches are applied only once.
|
|
19
|
+
*/
|
|
20
|
+
export function generateStealthJs(): string {
|
|
21
|
+
return `
|
|
22
|
+
(() => {
|
|
23
|
+
// Guard: skip if already applied
|
|
24
|
+
if (window.${STEALTH_GUARD}) return 'skipped';
|
|
25
|
+
// Use defineProperty so the guard flag is non-enumerable (not a detection vector).
|
|
26
|
+
Object.defineProperty(window, '${STEALTH_GUARD}', { value: true, configurable: true });
|
|
27
|
+
|
|
28
|
+
// 1. navigator.webdriver → undefined
|
|
29
|
+
// Most common check; Playwright/Puppeteer/CDP set this to true.
|
|
30
|
+
try {
|
|
31
|
+
Object.defineProperty(navigator, 'webdriver', {
|
|
32
|
+
get: () => undefined,
|
|
33
|
+
configurable: true,
|
|
34
|
+
});
|
|
35
|
+
} catch {}
|
|
36
|
+
|
|
37
|
+
// 2. window.chrome stub
|
|
38
|
+
// Real Chrome exposes window.chrome with runtime, loadTimes, csi.
|
|
39
|
+
// Headless/automated Chrome may not have it.
|
|
40
|
+
try {
|
|
41
|
+
if (!window.chrome) {
|
|
42
|
+
window.chrome = {
|
|
43
|
+
runtime: {
|
|
44
|
+
onConnect: { addListener: () => {}, removeListener: () => {} },
|
|
45
|
+
onMessage: { addListener: () => {}, removeListener: () => {} },
|
|
46
|
+
},
|
|
47
|
+
loadTimes: () => ({}),
|
|
48
|
+
csi: () => ({}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
|
|
53
|
+
// 3. navigator.plugins — fake population only if empty
|
|
54
|
+
// Real user browser already has plugins; only patch in automated/headless
|
|
55
|
+
// contexts where the list is empty (overwriting real plugins with fakes
|
|
56
|
+
// would be counterproductive and detectable).
|
|
57
|
+
try {
|
|
58
|
+
if (!navigator.plugins || navigator.plugins.length === 0) {
|
|
59
|
+
const fakePlugins = [
|
|
60
|
+
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
|
61
|
+
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
62
|
+
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
63
|
+
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
64
|
+
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
|
|
65
|
+
];
|
|
66
|
+
fakePlugins.item = (i) => fakePlugins[i] || null;
|
|
67
|
+
fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null;
|
|
68
|
+
fakePlugins.refresh = () => {};
|
|
69
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
70
|
+
get: () => fakePlugins,
|
|
71
|
+
configurable: true,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
|
|
76
|
+
// 4. navigator.languages — guarantee non-empty
|
|
77
|
+
// Some automated contexts return undefined or empty array.
|
|
78
|
+
try {
|
|
79
|
+
if (!navigator.languages || navigator.languages.length === 0) {
|
|
80
|
+
Object.defineProperty(navigator, 'languages', {
|
|
81
|
+
get: () => ['en-US', 'en'],
|
|
82
|
+
configurable: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
|
|
87
|
+
// 5. Permissions.query — normalize notification permission
|
|
88
|
+
// Headless Chrome throws on Permissions.query({ name: 'notifications' }).
|
|
89
|
+
try {
|
|
90
|
+
const origQuery = window.Permissions?.prototype?.query;
|
|
91
|
+
if (origQuery) {
|
|
92
|
+
window.Permissions.prototype.query = function (parameters) {
|
|
93
|
+
if (parameters?.name === 'notifications') {
|
|
94
|
+
return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
95
|
+
}
|
|
96
|
+
return origQuery.call(this, parameters);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
} catch {}
|
|
100
|
+
|
|
101
|
+
// 6. Clean automation artifacts
|
|
102
|
+
// Remove properties left by Playwright, Puppeteer, or CDP injection.
|
|
103
|
+
try {
|
|
104
|
+
delete window.__playwright;
|
|
105
|
+
delete window.__puppeteer;
|
|
106
|
+
// ChromeDriver injects cdc_ prefixed globals; the suffix varies by version,
|
|
107
|
+
// so scan window for any matching property rather than hardcoding names.
|
|
108
|
+
for (const prop of Object.getOwnPropertyNames(window)) {
|
|
109
|
+
if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) {
|
|
110
|
+
try { delete window[prop]; } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {}
|
|
114
|
+
|
|
115
|
+
// 7. CDP stack trace cleanup
|
|
116
|
+
// Runtime.evaluate injects scripts whose source URLs appear in Error
|
|
117
|
+
// stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://).
|
|
118
|
+
// Websites detect automation by doing: new Error().stack and inspecting it.
|
|
119
|
+
// We override the stack property getter on Error.prototype to filter them.
|
|
120
|
+
// Note: Error.prepareStackTrace is V8/Node-only and not available in
|
|
121
|
+
// browser page context, so we use a property descriptor approach instead.
|
|
122
|
+
try {
|
|
123
|
+
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
|
|
124
|
+
const _cdpPatterns = ['puppeteer_evaluation_script', 'pptr:', 'debugger://', '__opencli'];
|
|
125
|
+
if (_origDescriptor && _origDescriptor.get) {
|
|
126
|
+
Object.defineProperty(Error.prototype, 'stack', {
|
|
127
|
+
get: function () {
|
|
128
|
+
const raw = _origDescriptor.get.call(this);
|
|
129
|
+
if (typeof raw !== 'string') return raw;
|
|
130
|
+
return raw.split('\\n').filter(line =>
|
|
131
|
+
!_cdpPatterns.some(p => line.includes(p))
|
|
132
|
+
).join('\\n');
|
|
133
|
+
},
|
|
134
|
+
configurable: true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
return 'applied';
|
|
140
|
+
})()
|
|
141
|
+
`;
|
|
142
|
+
}
|
package/src/browser.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { BrowserBridge, __test__ } from './browser/index.js';
|
|
2
|
+
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
|
|
3
|
+
import { STEALTH_GUARD } from './browser/stealth.js';
|
|
3
4
|
import * as daemonClient from './browser/daemon-client.js';
|
|
4
5
|
|
|
5
6
|
describe('browser helpers', () => {
|
|
@@ -133,3 +134,52 @@ describe('BrowserBridge state', () => {
|
|
|
133
134
|
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
|
|
134
135
|
});
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
describe('stealth anti-detection', () => {
|
|
139
|
+
it('generates non-empty JS string', () => {
|
|
140
|
+
const js = generateStealthJs();
|
|
141
|
+
expect(typeof js).toBe('string');
|
|
142
|
+
expect(js.length).toBeGreaterThan(100);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('contains all 7 anti-detection patches', () => {
|
|
146
|
+
const js = generateStealthJs();
|
|
147
|
+
// 1. webdriver
|
|
148
|
+
expect(js).toContain('navigator');
|
|
149
|
+
expect(js).toContain('webdriver');
|
|
150
|
+
// 2. chrome stub
|
|
151
|
+
expect(js).toContain('window.chrome');
|
|
152
|
+
// 3. plugins
|
|
153
|
+
expect(js).toContain('plugins');
|
|
154
|
+
expect(js).toContain('PDF Viewer');
|
|
155
|
+
// 4. languages
|
|
156
|
+
expect(js).toContain('languages');
|
|
157
|
+
// 5. permissions
|
|
158
|
+
expect(js).toContain('Permissions');
|
|
159
|
+
expect(js).toContain('notifications');
|
|
160
|
+
// 6. automation artifacts (dynamic cdc_ scan)
|
|
161
|
+
expect(js).toContain('__playwright');
|
|
162
|
+
expect(js).toContain('__puppeteer');
|
|
163
|
+
expect(js).toContain('getOwnPropertyNames');
|
|
164
|
+
expect(js).toContain('cdc_');
|
|
165
|
+
// 7. CDP stack trace cleanup
|
|
166
|
+
expect(js).toContain('Error.prototype');
|
|
167
|
+
expect(js).toContain('puppeteer_evaluation_script');
|
|
168
|
+
expect(js).toContain('getOwnPropertyDescriptor');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('includes guard flag to prevent double-injection', () => {
|
|
172
|
+
const js = generateStealthJs();
|
|
173
|
+
expect(js).toContain(STEALTH_GUARD);
|
|
174
|
+
// Guard should check early and return 'skipped'
|
|
175
|
+
expect(js).toContain("return 'skipped'");
|
|
176
|
+
// Normal path returns 'applied'
|
|
177
|
+
expect(js).toContain("return 'applied'");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('generates syntactically valid JS', () => {
|
|
181
|
+
const js = generateStealthJs();
|
|
182
|
+
// Should not throw when parsed
|
|
183
|
+
expect(() => new Function(js)).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|
package/src/build-manifest.ts
CHANGED
package/src/constants.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Shared constants used across explore, synthesize, and pipeline modules.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/** Default daemon port for HTTP/WebSocket communication with browser extension */
|
|
6
|
+
export const DEFAULT_DAEMON_PORT = 19825;
|
|
7
|
+
|
|
5
8
|
/** URL query params that are volatile/ephemeral and should be stripped from patterns */
|
|
6
9
|
export const VOLATILE_PARAMS = new Set([
|
|
7
10
|
'w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign',
|
package/src/daemon.ts
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
23
23
|
import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
|
24
|
+
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
24
25
|
|
|
25
|
-
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ??
|
|
26
|
+
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
26
27
|
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
27
28
|
|
|
28
29
|
// ─── State ───────────────────────────────────────────────────────────
|
package/src/doctor.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
|
+
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
9
10
|
import { checkDaemonStatus } from './browser/discover.js';
|
|
10
11
|
import { BrowserBridge } from './browser/index.js';
|
|
11
12
|
import { listSessions } from './browser/daemon-client.js';
|
|
@@ -107,7 +108,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
107
108
|
|
|
108
109
|
// Daemon status
|
|
109
110
|
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
|
|
110
|
-
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ?
|
|
111
|
+
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running'}`);
|
|
111
112
|
|
|
112
113
|
// Extension status
|
|
113
114
|
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
|
package/src/validate.ts
CHANGED
|
@@ -39,7 +39,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
|
|
43
42
|
export function validateClisWithTarget(dirs: string[], target?: string): ValidationReport {
|
|
44
43
|
const results: FileValidationResult[] = [];
|
|
45
44
|
let errors = 0; let warnings = 0; let files = 0;
|