Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90971d4c1a | |||
| 4945f10ac8 | |||
| 454964010d | |||
| 94c3ef9af1 | |||
| 86b59d91a6 | |||
| e916c164b6 | |||
| 1af0f48023 | |||
| 2fb7ed131b | |||
| 14672ddf9b | |||
| 77814553cf | |||
| 9018713749 | |||
| 3d39574501 | |||
| b1067b64ee | |||
| 376c63c7db | |||
| ccfc0ed0de | |||
| 3a7a5e135b | |||
| 618dae9148 | |||
| 11df7181b3 | |||
| 2b24f517fe | |||
| d44a0ab256 | |||
| 9f2aa3b711 | |||
| 505c86bae9 | |||
| b8b4fa011b | |||
| 8869d3b457 | |||
| 7c9caa81f8 | |||
| 7c808fd339 | |||
| fb562fa1e9 | |||
| f6466db39a | |||
| 37c7ea41b8 | |||
| d58e1a74cd | |||
| 1b3f74cd6a | |||
| bdcffd147f | |||
| 53699eb807 | |||
| 56d4326646 | |||
| 4c9a2b1fde | |||
| 57fcc50c1b | |||
| 689cd1ebb3 | |||
| 167c7c784a | |||
| f852da3af4 | |||
| 15d9ef814f | |||
| 89a20e11bc | |||
| 760b91e7b3 | |||
| b6a02f82ed | |||
| a170873ad6 | |||
| 75f42371ca | |||
| 41aedf68cd | |||
| 8bf750c4ea | |||
| c9b3568594 | |||
| b4d64cad6e | |||
| 966f6e5019 | |||
| ea8324257d | |||
| dff0fe510c | |||
| 4343ec07e0 | |||
| f00a5d1929 | |||
| c7895eaf8e | |||
| a83027d19c | |||
| f8bf66390d | |||
| 22f5c7ade0 | |||
| 8ab0cd2a50 | |||
| d4b06be049 | |||
| 7073645e30 | |||
| 3a21be624e | |||
| 127a974ecd | |||
| fcb5a9d409 | |||
| 66c4b841f2 | |||
| 2a52906ed2 | |||
| 9cdc1274b2 | |||
| a6d993f37f | |||
| b7c6c02370 | |||
| 722c180a0a | |||
| 788126198b | |||
| 98ecfab8ad | |||
| 4b976da04f | |||
| 3bedaccc25 | |||
| 40bd11dfbe | |||
| 9a77dba139 | |||
| 49c2dc7426 | |||
| e4a13cb6f0 | |||
| 637161f0ab | |||
| a778f617ca | |||
| b4a8089224 | |||
| 428b831f85 | |||
| 7ebe8134cc | |||
| c44bc62b60 | |||
| 7f57e76485 | |||
| e9818c1b41 | |||
| 3e91876d13 | |||
| 7c02588105 | |||
| 112fdefa8d | |||
| e077ad2336 | |||
| 81384ede00 | |||
| 71b2c3961b | |||
| b3b9892836 | |||
| 520622ac75 | |||
| 2d1b8c1e76 | |||
| 70651d3ba8 | |||
| 9696db9ed4 | |||
| c76f86c9cb | |||
| 4cd0409ded | |||
| ea113a6471 | |||
| a439286398 | |||
| 1d56dd77a8 | |||
| e98cf756e9 |
@@ -15,10 +15,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
@@ -45,35 +45,20 @@ jobs:
|
||||
cd extension-package
|
||||
zip -r ../opencli-extension.zip .
|
||||
|
||||
- name: Create Extension CRX
|
||||
run: |
|
||||
npm install -g crx3
|
||||
if [ -n "${{ secrets.CRX_PRIVATE_KEY }}" ]; then
|
||||
echo "Found CRX_PRIVATE_KEY, signing extension..."
|
||||
echo "${{ secrets.CRX_PRIVATE_KEY }}" > crx-key.pem
|
||||
crx3 pack extension-package -o opencli-extension.crx -p crx-key.pem
|
||||
rm crx-key.pem
|
||||
else
|
||||
echo "No CRX_PRIVATE_KEY configured. Generating CRX with a temporary random key..."
|
||||
crx3 pack extension-package -o opencli-extension.crx
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts (Action Run)
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opencli-extension-build
|
||||
path: |
|
||||
opencli-extension.zip
|
||||
opencli-extension.crx
|
||||
retention-days: 7
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
with:
|
||||
files: |
|
||||
opencli-extension.zip
|
||||
opencli-extension.crx
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
@@ -54,7 +54,24 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
|
||||
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
adapter-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run focused adapter tests
|
||||
run: npm run test:adapter -- --reporter=verbose
|
||||
|
||||
# ── Smoke tests (scheduled / manual only) ──
|
||||
smoke-test:
|
||||
@@ -62,9 +79,9 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
doc-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Check adapter doc coverage
|
||||
run: bash scripts/check-doc-coverage.sh --strict
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
docs-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger opencli-website rebuild
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
|
||||
repository: jackwener/opencli-website
|
||||
|
||||
@@ -16,9 +16,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -13,9 +13,9 @@ jobs:
|
||||
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Ensure release-please token is configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.RELEASE_PLEASE_TOKEN }}" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN secret is required so release PRs can trigger downstream CI workflows." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
release-type: node
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
@@ -13,9 +13,9 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -35,3 +35,10 @@ jobs:
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger website rebuild
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
|
||||
repository: jackwener/opencli-website
|
||||
event-type: version-released
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -19,3 +19,6 @@ docs/.vitepress/cache
|
||||
.windsurf
|
||||
.claude
|
||||
.cortex
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# Browser State Sync: Chrome → Camoufox
|
||||
|
||||
## 目标
|
||||
|
||||
`opencli` 无缝对接 camoufox 无头浏览器:登录态从 Chrome 实时导出,注入 camoufox 后直接自动化。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Chrome Extension ──WebSocket──→ opencli daemon ←──HTTP──→ CLI
|
||||
│
|
||||
playwright-core ──Juggler WS──→ Camoufox
|
||||
```
|
||||
|
||||
- **Chrome 侧**: 现有 Extension + daemon 架构不变,新增 `export-state` / `import-state` action
|
||||
- **Camoufox 侧**: `playwright-core`(Node.js 轻量 client ~3MB)通过 Juggler WS 直连,**不需要 Extension**
|
||||
- **CLI**: `CamoufoxBridge` 实现 `IBrowserFactory`,所有现有 adapter 自动兼容 camoufox
|
||||
|
||||
## 浏览器状态覆盖
|
||||
|
||||
| 类型 | 导出(Chrome Extension) | 导入(Camoufox Playwright) |
|
||||
|------|---|---|
|
||||
| Cookie | `chrome.cookies.getAll()` | `context.addCookies()` |
|
||||
| localStorage | CDP `Runtime.evaluate` 遍历 | `page.evaluate(setItem)` |
|
||||
| sessionStorage | CDP `Runtime.evaluate` 遍历 | `page.evaluate(setItem)` |
|
||||
| IndexedDB | CDP `Runtime.evaluate` 全量读取 | `page.evaluate(indexedDB.put)` |
|
||||
|
||||
> 覆盖 99%+ 登录态场景。Playwright `page.evaluate()` 走 Juggler 协议,**完全绕过 CSP**。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### Phase 1: Chrome Extension — export-state / import-state
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `extension/src/protocol.ts` | `export-state`/`import-state` actions + IndexedDB 类型 |
|
||||
| `extension/manifest.json` | `storage` permission + `host_permissions: ["<all_urls>"]` |
|
||||
| `extension/src/background.ts` | +200 行: `handleExportState` / `handleImportState` |
|
||||
|
||||
### Phase 2: CLI Types & IPage
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/types.ts` | `BrowserState`, `IndexedDBSnapshot` 接口 + `IPage.exportState/importState` |
|
||||
| `src/browser/daemon-client.ts` | 新 action 类型 + `state` 字段 |
|
||||
| `src/browser/page.ts` | `exportState()` / `importState()` 通过 daemon 调用 |
|
||||
| `src/browser/cdp.ts` | Stub methods (CDP 模式不支持) |
|
||||
|
||||
### Phase 3: CamoufoxBridge (playwright-core)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/browser/camoufox-page.ts` | **[NEW]** 376 行 — 完整 `IPage` via Playwright Juggler |
|
||||
| `src/browser/camoufox-bridge.ts` | **[NEW]** 43 行 — `IBrowserFactory` |
|
||||
| `src/runtime.ts` | `OPENCLI_CAMOUFOX_WS` 环境变量自动切换后端 |
|
||||
| `src/browser/index.ts` | 导出 `CamoufoxBridge` / `CamoufoxPage` |
|
||||
|
||||
### Phase 4: CLI Commands
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/cli.ts` | +212 行: `browser` + `camoufox` 命令组 |
|
||||
|
||||
### Phase 5: Dependencies
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `package.json` | `playwright-core` (~3MB, 不含浏览器二进制) |
|
||||
|
||||
---
|
||||
|
||||
## CLI 命令
|
||||
|
||||
### Browser 状态管理
|
||||
|
||||
```bash
|
||||
# 一次性快照导出/导入
|
||||
opencli browser export-state --domain github.com -o github-state.json
|
||||
opencli browser import-state github-state.json
|
||||
|
||||
# 一键同步 Chrome → Camoufox
|
||||
opencli browser sync --domain github.com
|
||||
|
||||
# 🔥 实时同步(长驻进程,Chrome cookie 变化自动推送到 camoufox)
|
||||
opencli browser watch --domain github.com,twitter.com
|
||||
```
|
||||
|
||||
### Camoufox 生命周期
|
||||
|
||||
```bash
|
||||
opencli camoufox setup # 安装 camoufox
|
||||
opencli camoufox start [--no-headless] # 启动 server
|
||||
opencli camoufox start --import state.json # 启动并导入状态
|
||||
opencli camoufox status # 查看状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔥 实时同步架构
|
||||
|
||||
解决"登录态过期"的核心方案——**Chrome cookie 变化实时推送到 camoufox**:
|
||||
|
||||
```
|
||||
chrome.cookies.onChanged ──→ Extension ──WS──→ Daemon ──/sync WS──→ LiveSyncService
|
||||
↓
|
||||
context.addCookies() / evaluate()
|
||||
↓
|
||||
Camoufox
|
||||
```
|
||||
|
||||
| 层 | 文件 | 职责 |
|
||||
|----|------|------|
|
||||
| Extension | `background.ts` | 监听 `chrome.cookies.onChanged`,按 domain 过滤,推送 `SyncEvent` |
|
||||
| Daemon | `daemon.ts` | `/sync` WS path 接收订阅者,fan-out `state-change` 事件 |
|
||||
| CLI | `live-sync.ts` | 订阅 daemon,实时 `context.addCookies()` 到 camoufox |
|
||||
|
||||
**特性**:
|
||||
- domain 过滤:只同步你关心的站点
|
||||
- 自动重连:daemon 或 camoufox 断线后自动恢复
|
||||
- Cookie 新增/修改/删除 全覆盖
|
||||
- 统计信息:`Ctrl+C` 退出时显示同步计数
|
||||
|
||||
---
|
||||
|
||||
## 端到端使用流
|
||||
|
||||
```bash
|
||||
# 1. 安装 camoufox(一次性)
|
||||
opencli camoufox setup
|
||||
|
||||
# 2. 启动 camoufox 无头服务
|
||||
opencli camoufox start
|
||||
|
||||
# 3. 一次性同步当前登录态
|
||||
opencli browser sync --domain github.com
|
||||
|
||||
# 4. 启动实时同步(保持运行)
|
||||
opencli browser watch --domain github.com &
|
||||
|
||||
# 5. 在 camoufox 上运行任何 adapter!
|
||||
OPENCLI_CAMOUFOX_WS=ws://... opencli run github/notifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 导出格式 BrowserState
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"url": "https://github.com/jackwener",
|
||||
"domain": "github.com",
|
||||
"timestamp": 1711094210000,
|
||||
"cookies": [
|
||||
{ "name": "user_session", "value": "abc...", "domain": ".github.com",
|
||||
"path": "/", "secure": true, "httpOnly": true, "expirationDate": 1742630210 }
|
||||
],
|
||||
"localStorage": { "colorMode": "{\"mode\":\"dark\"}" },
|
||||
"sessionStorage": {},
|
||||
"indexedDB": [
|
||||
{
|
||||
"name": "firebaseLocalStorageDb",
|
||||
"version": 1,
|
||||
"objectStores": [
|
||||
{ "name": "firebaseLocalStorage", "keyPath": "fbase_key",
|
||||
"autoIncrement": false,
|
||||
"records": [{ "key": "...", "value": { "token": "..." } }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
1. **Juggler 而非 Firefox Extension**: Camoufox 原生支持 Playwright Server,通过 Juggler WS 连接
|
||||
2. **playwright-core 而非 playwright**: 只装协议 client(~3MB),不含浏览器二进制
|
||||
3. **IPage 统一接口**: CamoufoxPage 和 Page 都实现 IPage,所有 adapter 零修改
|
||||
4. **实时同步 > 快照**: `chrome.cookies.onChanged` 流式推送,Cookie 永不过期
|
||||
5. **CSP 绕过**: Playwright evaluate 走 Juggler 协议,等同 CDP
|
||||
6. **Python launcher**: `launch_server()` JSON 输出 WS endpoint,不猜测端口/path
|
||||
+128
@@ -1,5 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **error-handling:** refine error handling with semantic error types and emoji-coded output ([#312](https://github.com/jackwener/opencli/issues/312)) ([b4d64ca](https://github.com/jackwener/opencli/commit/b4d64ca))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** replace execSync with execFileSync to prevent command injection ([#309](https://github.com/jackwener/opencli/issues/309)) ([41aedf6](https://github.com/jackwener/opencli/commit/41aedf6))
|
||||
* remove duplicate getErrorMessage import in discovery.ts ([#315](https://github.com/jackwener/opencli/issues/315)) ([75f4237](https://github.com/jackwener/opencli/commit/75f4237))
|
||||
* **e2e:** broaden xiaoyuzhou skip logic for overseas CI runners ([#316](https://github.com/jackwener/opencli/issues/316)) ([a170873](https://github.com/jackwener/opencli/commit/a170873))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **SKILL.md:** sync command reference — add missing sites and desktop adapters ([#314](https://github.com/jackwener/opencli/issues/314)) ([8bf750c](https://github.com/jackwener/opencli/commit/8bf750c))
|
||||
|
||||
|
||||
### Chores
|
||||
|
||||
* pre-release cleanup — fix dependencies, sync docs, reduce code duplication ([#311](https://github.com/jackwener/opencli/issues/311)) ([c9b3568](https://github.com/jackwener/opencli/commit/c9b3568))
|
||||
|
||||
|
||||
## [1.3.1](https://github.com/jackwener/opencli/compare/v1.3.0...v1.3.1) (2026-03-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **plugin:** add update command, hot reload after install, README section ([#307](https://github.com/jackwener/opencli/issues/307)) ([966f6e5](https://github.com/jackwener/opencli/commit/966f6e5))
|
||||
* **yollomi:** add new commands and update documentation ([#235](https://github.com/jackwener/opencli/issues/235)) ([ea83242](https://github.com/jackwener/opencli/commit/ea83242))
|
||||
* **record:** add live recording command for API capture ([#300](https://github.com/jackwener/opencli/issues/300)) ([dff0fe5](https://github.com/jackwener/opencli/commit/dff0fe5))
|
||||
* **weibo:** add weibo search command ([#299](https://github.com/jackwener/opencli/issues/299)) ([c7895ea](https://github.com/jackwener/opencli/commit/c7895ea))
|
||||
* **v2ex:** add node, user, member, replies, nodes commands ([#282](https://github.com/jackwener/opencli/issues/282)) ([a83027d](https://github.com/jackwener/opencli/commit/a83027d))
|
||||
* **hackernews:** add new, best, ask, show, jobs, search, user commands ([#290](https://github.com/jackwener/opencli/issues/290)) ([127a974](https://github.com/jackwener/opencli/commit/127a974))
|
||||
* **doubao-app:** add Doubao AI desktop app CLI adapter ([#289](https://github.com/jackwener/opencli/issues/289)) ([66c4b84](https://github.com/jackwener/opencli/commit/66c4b84))
|
||||
* **doubao:** add doubao browser adapter ([#277](https://github.com/jackwener/opencli/issues/277)) ([9cdc127](https://github.com/jackwener/opencli/commit/9cdc127))
|
||||
* **xiaohongshu:** add publish command for 图文 note automation ([#276](https://github.com/jackwener/opencli/issues/276)) ([a6d993f](https://github.com/jackwener/opencli/commit/a6d993f))
|
||||
* **weixin:** add weixin article download adapter & abstract download helpers ([#280](https://github.com/jackwener/opencli/issues/280)) ([b7c6c02](https://github.com/jackwener/opencli/commit/b7c6c02))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tests:** use positional arg syntax in browser search tests ([#302](https://github.com/jackwener/opencli/issues/302)) ([4343ec0](https://github.com/jackwener/opencli/commit/4343ec0))
|
||||
* **xiaohongshu:** improve search login-wall handling and detail output ([#298](https://github.com/jackwener/opencli/issues/298)) ([f8bf663](https://github.com/jackwener/opencli/commit/f8bf663))
|
||||
* ensure standard PATH is available for external CLIs ([#285](https://github.com/jackwener/opencli/issues/285)) ([22f5c7a](https://github.com/jackwener/opencli/commit/22f5c7a))
|
||||
* **xiaohongshu:** scope image selector to avoid downloading avatars ([#293](https://github.com/jackwener/opencli/issues/293)) ([3a21be6](https://github.com/jackwener/opencli/commit/3a21be6))
|
||||
* add turndown dependency to package.json ([#288](https://github.com/jackwener/opencli/issues/288)) ([2a52906](https://github.com/jackwener/opencli/commit/2a52906))
|
||||
|
||||
|
||||
## [1.3.0](https://github.com/jackwener/opencli/compare/v1.2.3...v1.3.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **daemon:** harden security against browser CSRF attacks ([#268](https://github.com/jackwener/opencli/issues/268)) ([40bd11d](https://github.com/jackwener/opencli/commit/40bd11d))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* smart page settle via DOM stability detection ([#271](https://github.com/jackwener/opencli/issues/271)) ([4b976da](https://github.com/jackwener/opencli/commit/4b976da))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* doctor defaults to live mode, remove setup command entirely ([#263](https://github.com/jackwener/opencli/issues/263)) ([b4a8089](https://github.com/jackwener/opencli/commit/b4a8089))
|
||||
|
||||
|
||||
## [1.2.3](https://github.com/jackwener/opencli/compare/v1.2.2...v1.2.3) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace all about:blank with data: URI to prevent New Tab Override interception ([#257](https://github.com/jackwener/opencli/issues/257)) ([3e91876](https://github.com/jackwener/opencli/commit/3e91876))
|
||||
* harden resolveTabId against New Tab Override extension interception ([#255](https://github.com/jackwener/opencli/issues/255)) ([112fdef](https://github.com/jackwener/opencli/commit/112fdef))
|
||||
|
||||
|
||||
## [1.2.2](https://github.com/jackwener/opencli/compare/v1.2.1...v1.2.2) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* harden browser automation pipeline (resolves [#249](https://github.com/jackwener/opencli/issues/249)) ([#251](https://github.com/jackwener/opencli/issues/251)) ([71b2c39](https://github.com/jackwener/opencli/commit/71b2c39))
|
||||
|
||||
|
||||
## [1.2.1](https://github.com/jackwener/opencli/compare/v1.2.0...v1.2.1) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **twitter:** harden timeline review findings ([#236](https://github.com/jackwener/opencli/issues/236)) ([4cd0409](https://github.com/jackwener/opencli/commit/4cd0409))
|
||||
* **wikipedia:** fix search arg name + add random and trending commands ([#231](https://github.com/jackwener/opencli/issues/231)) ([1d56dd7](https://github.com/jackwener/opencli/commit/1d56dd7))
|
||||
* resolve inconsistent doctor --live report (fix [#121](https://github.com/jackwener/opencli/issues/121)) ([#224](https://github.com/jackwener/opencli/issues/224)) ([387aa0d](https://github.com/jackwener/opencli/commit/387aa0d))
|
||||
|
||||
|
||||
## [1.2.0](https://github.com/jackwener/opencli/compare/v1.1.0...v1.2.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **douban:** add movie adapter with search, top250, subject, marks, reviews commands ([#239](https://github.com/jackwener/opencli/issues/239)) ([70651d3](https://github.com/jackwener/opencli/commit/70651d3))
|
||||
* **devto:** add devto adapter ([#234](https://github.com/jackwener/opencli/issues/234)) ([ea113a6](https://github.com/jackwener/opencli/commit/ea113a6))
|
||||
* **twitter:** add --type flag to timeline command ([#83](https://github.com/jackwener/opencli/issues/83)) ([e98cf75](https://github.com/jackwener/opencli/commit/e98cf75))
|
||||
* **google:** add search, suggest, news, and trends adapters ([#184](https://github.com/jackwener/opencli/issues/184)) ([4e32599](https://github.com/jackwener/opencli/commit/4e32599))
|
||||
* add douban, sinablog, substack adapters; upgrade medium to TS ([#185](https://github.com/jackwener/opencli/issues/185)) ([bdf5967](https://github.com/jackwener/opencli/commit/bdf5967))
|
||||
* **xueqiu:** add earnings-date command ([#211](https://github.com/jackwener/opencli/issues/211)) ([fae1dce](https://github.com/jackwener/opencli/commit/fae1dce))
|
||||
* **browser:** advanced DOM snapshot engine with 13-layer pruning pipeline ([#210](https://github.com/jackwener/opencli/issues/210)) ([d831b04](https://github.com/jackwener/opencli/commit/d831b04))
|
||||
* **instagram,facebook:** add write actions and extended commands ([#201](https://github.com/jackwener/opencli/issues/201)) ([eb0ccaf](https://github.com/jackwener/opencli/commit/eb0ccaf))
|
||||
* **grok:** add opt-in --web flow for grok ask ([#193](https://github.com/jackwener/opencli/issues/193)) ([fcff2e4](https://github.com/jackwener/opencli/commit/fcff2e4))
|
||||
* **tiktok:** add TikTok adapter with 15 commands ([#202](https://github.com/jackwener/opencli/issues/202)) ([4391ccf](https://github.com/jackwener/opencli/commit/4391ccf))
|
||||
* add Lobste.rs, Instagram, and Facebook adapters ([#199](https://github.com/jackwener/opencli/issues/199)) ([ce484c2](https://github.com/jackwener/opencli/commit/ce484c2))
|
||||
* **medium:** add medium adapter ([#190](https://github.com/jackwener/opencli/issues/190)) ([06c902a](https://github.com/jackwener/opencli/commit/06c902a))
|
||||
* plugin system (Stage 0-2) ([1d39295](https://github.com/jackwener/opencli/commit/1d39295))
|
||||
* make primary args positional across all CLIs ([#242](https://github.com/jackwener/opencli/issues/242)) ([9696db9](https://github.com/jackwener/opencli/commit/9696db9))
|
||||
* **xueqiu:** make primary args positional ([#213](https://github.com/jackwener/opencli/issues/213)) ([fb2a145](https://github.com/jackwener/opencli/commit/fb2a145))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* replace hardcoded skipPreNav with declarative navigateBefore field ([#208](https://github.com/jackwener/opencli/issues/208)) ([a228758](https://github.com/jackwener/opencli/commit/a228758))
|
||||
* **boss:** extract common.ts utilities, fix missing login detection ([#200](https://github.com/jackwener/opencli/issues/200)) ([ae30763](https://github.com/jackwener/opencli/commit/ae30763))
|
||||
* type discovery core ([#219](https://github.com/jackwener/opencli/issues/219)) ([bd274ce](https://github.com/jackwener/opencli/commit/bd274ce))
|
||||
* type browser core ([#218](https://github.com/jackwener/opencli/issues/218)) ([28c393e](https://github.com/jackwener/opencli/commit/28c393e))
|
||||
* type pipeline core ([#217](https://github.com/jackwener/opencli/issues/217)) ([8a4ea41](https://github.com/jackwener/opencli/commit/8a4ea41))
|
||||
* reduce core any usage ([#216](https://github.com/jackwener/opencli/issues/216)) ([45cee57](https://github.com/jackwener/opencli/commit/45cee57))
|
||||
* fail fast on invalid pipeline steps ([#237](https://github.com/jackwener/opencli/issues/237)) ([c76f86c](https://github.com/jackwener/opencli/commit/c76f86c))
|
||||
|
||||
## [1.1.0](https://github.com/jackwener/opencli/compare/v1.0.6...v1.1.0) (2026-03-20)
|
||||
|
||||
|
||||
|
||||
+45
-4
@@ -17,7 +17,8 @@ npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
npm test
|
||||
npm run test:adapter
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
@@ -40,6 +41,11 @@ strategy: public # public | cookie | header
|
||||
browser: false # true if browser session is needed
|
||||
|
||||
args:
|
||||
query:
|
||||
positional: true
|
||||
type: str
|
||||
required: true
|
||||
description: Search keyword
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
@@ -76,7 +82,7 @@ cli({
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', required: true, help: 'Search query' },
|
||||
{ name: 'query', positional: true, required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['title', 'url', 'date'],
|
||||
@@ -118,12 +124,46 @@ opencli <site> <command> --limit 3 -f json
|
||||
opencli <site> <command> -v
|
||||
```
|
||||
|
||||
## Arg Design Convention
|
||||
|
||||
Use **positional** for the primary, required argument of a command (the "what" — query, symbol, id, url, username). Use **named options** (`--flag`) for secondary/optional configuration (limit, format, sort, page, filters, language, date).
|
||||
|
||||
**Rule of thumb**: Think about how the user will type the command. `opencli xueqiu stock SH600519` is more natural than `opencli xueqiu stock --symbol SH600519`.
|
||||
|
||||
| Arg type | Positional? | Examples |
|
||||
|----------|-------------|----------|
|
||||
| Main target (query, symbol, id, url, username) | ✅ `positional: true` | `search '茅台'`, `stock SH600519`, `download BV1xxx` |
|
||||
| Configuration (limit, format, sort, page, type, filters) | ❌ Named `--flag` | `--limit 10`, `--format json`, `--sort hot`, `--location seattle` |
|
||||
|
||||
Do **not** convert an argument to positional just because it appears first in the file. If the argument is optional, acts like a filter, or selects a mode/configuration, it should usually stay a named option.
|
||||
|
||||
YAML example:
|
||||
```yaml
|
||||
args:
|
||||
query:
|
||||
positional: true # ← primary arg, user types it directly
|
||||
type: str
|
||||
required: true
|
||||
limit:
|
||||
type: int # ← config arg, user types --limit 10
|
||||
default: 20
|
||||
```
|
||||
|
||||
TS example:
|
||||
```typescript
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
]
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
|
||||
|
||||
```bash
|
||||
npx vitest run src/ # Unit tests
|
||||
npm test # Core unit tests (non-adapter)
|
||||
npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili
|
||||
npx vitest run tests/e2e/ # E2E tests
|
||||
npx vitest run # All tests
|
||||
```
|
||||
@@ -156,7 +196,8 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel
|
||||
3. Run the checks that apply:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npx vitest run src/ # Unit tests
|
||||
npm test # Core unit tests
|
||||
npm run test:adapter # Focused adapter tests (if you touched adapter logic)
|
||||
opencli validate # YAML validation (if applicable)
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
|
||||
@@ -18,35 +18,36 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Highlights](#highlights)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Built-in Commands](#built-in-commands)
|
||||
- [Desktop App Adapters](#desktop-app-adapters)
|
||||
- [External CLI Hub](#external-cli-hub)
|
||||
- [Download Support](#download-support)
|
||||
- [Output Formats](#output-formats)
|
||||
- [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide)
|
||||
- [Remote Chrome (Server/Headless)](#remote-chrome-serverheadless)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Releasing New Versions](#releasing-new-versions)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Why opencli?
|
||||
|
||||
There are many great browser automation tools. Here's when opencli is the right choice:
|
||||
|
||||
| Your need | Best tool | Why |
|
||||
|-----------|-----------|-----|
|
||||
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
|
||||
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
|
||||
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
|
||||
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
|
||||
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
|
||||
|
||||
**What makes opencli different:**
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
|
||||
- **Broad coverage** — 50+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
@@ -61,11 +62,11 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
|
||||
You can install the extension via either method:
|
||||
|
||||
**Method 1: Download Pre-built Release (Recommended)**
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
|
||||
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
|
||||
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
|
||||
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
|
||||
3. Click **Load unpacked** and select the unzipped folder.
|
||||
|
||||
**Method 2: Load Unpacked Source (For Developers)**
|
||||
**Method 2: Load Source (For Developers)**
|
||||
1. Open `chrome://extensions` and enable **Developer mode**.
|
||||
2. Click **Load unpacked** and select the `extension/` directory from this repository.
|
||||
|
||||
@@ -74,7 +75,6 @@ That's it! The daemon auto-starts when you run any browser command. No tokens, n
|
||||
> **Tip**: Use `opencli doctor` for ongoing diagnosis:
|
||||
> ```bash
|
||||
> opencli doctor # Check extension + daemon connectivity
|
||||
> opencli doctor --live # Also test live browser commands
|
||||
> ```
|
||||
|
||||
## Quick Start
|
||||
@@ -119,49 +119,63 @@ Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 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 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` | Browser |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
|
||||
| **zhihu** | `hot` `search` `question` `download` | Browser |
|
||||
| **weixin** | `download` | Browser |
|
||||
| **youtube** | `search` `video` `transcript` | Browser |
|
||||
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
|
||||
| **coupang** | `search` `add-to-cart` | Browser |
|
||||
| **bbc** | `news` | Public |
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
|
||||
| **ctrip** | `search` | Browser |
|
||||
| **devto** | `top` `tag` `user` | Public |
|
||||
| **dictionary** | `search` `synonyms` `examples` | Public |
|
||||
| **arxiv** | `search` `paper` | Public |
|
||||
| **wikipedia** | `search` `summary` | Public |
|
||||
| **hackernews** | `top` | Public |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
||||
| **linkedin** | `search` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
| **smzdm** | `search` | Browser |
|
||||
| **weibo** | `hot` | Browser |
|
||||
| **weibo** | `hot` `search` | Browser |
|
||||
| **yahoo-finance** | `quote` | Browser |
|
||||
| **sinafinance** | `news` | 🌐 Public |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | Browser |
|
||||
| **chaoxing** | `assignments` `exams` | Browser |
|
||||
| **grok** | `ask` | Desktop |
|
||||
| **grok** | `ask` | Browser |
|
||||
| **hf** | `top` | Public |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | Browser |
|
||||
| **jimeng** | `generate` `history` | Browser |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | Browser |
|
||||
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | Public |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
|
||||
| **steam** | `top-sellers` | Public |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
|
||||
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
|
||||
| **google** | `news` `search` `suggest` `trends` | Public |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | Public |
|
||||
| **medium** | `feed` `search` `user` | Browser |
|
||||
| **sinablog** | `hot` `search` `article` `user` | Browser |
|
||||
| **substack** | `feed` `search` `publication` | Browser |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
|
||||
|
||||
> **Bloomberg note**: The RSS-backed Bloomberg listing commands (`main`, section feeds, `feeds`) work without a browser. `bloomberg news` is for standard Bloomberg story/article pages that your current Chrome session can already access. Audio and some other non-standard pages may fail, and OpenCLI does not bypass Bloomberg paywall or entitlement checks.
|
||||
|
||||
### External CLI Hub
|
||||
|
||||
@@ -172,8 +186,8 @@ OpenCLI acts as a universal hub for your existing command-line tools. It provide
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker command-line interface | `opencli docker ps` |
|
||||
| **kubectl** | Kubernetes command-line tool | `opencli kubectl get pods` |
|
||||
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
|
||||
**Zero Configuration**: OpenCLI purely passes your inputs to the underlying binary via standard I/O streams. The external CLI works exactly as it naturally would, maintaining its standard output formats.
|
||||
|
||||
@@ -198,6 +212,7 @@ Each desktop adapter has its own detailed documentation with commands reference,
|
||||
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
|
||||
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
|
||||
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
|
||||
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
|
||||
|
||||
## Download Support
|
||||
|
||||
@@ -211,6 +226,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -227,11 +243,11 @@ brew install yt-dlp
|
||||
|
||||
```bash
|
||||
# Download images/videos from Xiaohongshu note
|
||||
opencli xiaohongshu download --note-id abc123 --output ./xhs
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
|
||||
# Download Bilibili video (requires yt-dlp)
|
||||
opencli bilibili download --bvid BV1xxx --output ./bilibili
|
||||
opencli bilibili download --bvid BV1xxx --quality 1080p # Specify quality
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
opencli bilibili download BV1xxx --quality 1080p # Specify quality
|
||||
|
||||
# Download Twitter media from user
|
||||
opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
@@ -244,22 +260,12 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# Export with local images
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
|
||||
# Export WeChat article to Markdown
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
```
|
||||
|
||||
### Pipeline Step (for YAML adapters)
|
||||
|
||||
The `download` step can be used in YAML pipelines:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
- fetch: https://api.example.com/media
|
||||
- download:
|
||||
url: ${{ item.imageUrl }}
|
||||
dir: ./downloads
|
||||
filename: ${{ item.title | sanitize }}.jpg
|
||||
concurrency: 5
|
||||
skip_existing: true
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
@@ -276,6 +282,25 @@ opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # Verbose: show pipeline debug steps
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS format as built-in commands and are automatically discovered at startup.
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # Install
|
||||
opencli plugin list # List installed
|
||||
opencli plugin update my-tool # Update to latest
|
||||
opencli plugin uninstall my-tool # Remove
|
||||
```
|
||||
|
||||
| Plugin | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
|
||||
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles |
|
||||
|
||||
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
|
||||
|
||||
## For AI Agents (Developer Guide)
|
||||
|
||||
If you are an AI assistant tasked with creating a new command adapter for `opencli`, please follow the AI Agent workflow below:
|
||||
@@ -302,26 +327,14 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
|
||||
|
||||
## Testing
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
|
||||
|
||||
- Current test coverage (unit + E2E tests across browser and desktop adapters)
|
||||
- How to run tests locally
|
||||
- How to add tests when creating new adapters
|
||||
- CI/CD pipeline with sharding
|
||||
- Headless browser mode (`OPENCLI_HEADLESS=1`)
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
npm run build
|
||||
npx vitest run # All tests
|
||||
npx vitest run src/ # Unit tests only
|
||||
npx vitest run tests/e2e/ # E2E tests
|
||||
```
|
||||
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Extension not connected"**
|
||||
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"**
|
||||
- Another Chrome extension (e.g. youmind, New Tab Override, or AI assistant extensions) may be interfering. Try **disabling other extensions** temporarily, then retry.
|
||||
- **Empty data returns or 'Unauthorized' error**
|
||||
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
|
||||
- **Node API errors**
|
||||
@@ -330,15 +343,12 @@ npx vitest run tests/e2e/ # E2E tests
|
||||
- Check daemon status: `curl localhost:19825/status`
|
||||
- View extension logs: `curl localhost:19825/logs`
|
||||
|
||||
## Releasing New Versions
|
||||
|
||||
```bash
|
||||
npm version patch # 0.1.0 → 0.1.1
|
||||
npm version minor # 0.1.0 → 0.2.0
|
||||
git push --follow-tags
|
||||
```
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#jackwener/opencli&Date)
|
||||
|
||||
|
||||
The CI will automatically build, create a GitHub release, and publish to npm.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+83
-58
@@ -20,34 +20,36 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [亮点](#亮点)
|
||||
- [前置要求](#前置要求)
|
||||
- [快速开始](#快速开始)
|
||||
- [内置命令](#内置命令)
|
||||
- [桌面应用适配器](#桌面应用适配器)
|
||||
- [外部 CLI 枢纽](#外部-cli-枢纽)
|
||||
- [下载支持](#下载支持)
|
||||
- [输出格式](#输出格式)
|
||||
- [致 AI Agent(开发者指南)](#致-ai-agent开发者指南)
|
||||
- [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境)
|
||||
- [常见问题排查](#常见问题排查)
|
||||
- [版本发布](#版本发布)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## 亮点
|
||||
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker`、`kubectl` 等本地 CLI
|
||||
- **自修复配置** — `opencli setup` 检查 Browser Bridge 连通性;`opencli doctor` 诊断 daemon、扩展和浏览器连接状态
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
## 为什么选 opencli?
|
||||
|
||||
浏览器自动化工具很多,opencli 适合什么场景?
|
||||
|
||||
| 你的需求 | 最佳工具 | 原因 |
|
||||
|----------|----------|------|
|
||||
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
|
||||
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
|
||||
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
|
||||
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
|
||||
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
|
||||
|
||||
**opencli 的核心差异:**
|
||||
|
||||
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
|
||||
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
|
||||
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
|
||||
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
@@ -62,9 +64,9 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
|
||||
你可以选择以下任一方式安装扩展:
|
||||
|
||||
**方式一:下载构建好的安装包(推荐)**
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip` 或 `opencli-extension.crx`。
|
||||
2. 打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
3. 将 `.crx` 拖入浏览器窗口,或将解压后的文件夹拖入即可完成安装。
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`。
|
||||
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹。
|
||||
|
||||
**方式二:加载源码(针对开发者)**
|
||||
1. 同样在 `chrome://extensions` 开启 **开发者模式**。
|
||||
@@ -75,7 +77,6 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
|
||||
> **Tip**:后续诊断用 `opencli doctor`:
|
||||
> ```bash
|
||||
> opencli doctor # 检查扩展和 daemon 连通性
|
||||
> opencli doctor --live # 额外测试浏览器命令
|
||||
> ```
|
||||
|
||||
## 快速开始
|
||||
@@ -120,49 +121,63 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
|
||||
| **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` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` | 浏览器 |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
|
||||
| **weixin** | `download` | 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` | 浏览器 |
|
||||
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 浏览器 |
|
||||
| **bbc** | `news` | 公共 API |
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
|
||||
| **ctrip** | `search` | 浏览器 |
|
||||
| **devto** | `top` `tag` `user` | 公开 |
|
||||
| **dictionary** | `search` `synonyms` `examples` | 公开 |
|
||||
| **arxiv** | `search` `paper` | 公开 |
|
||||
| **wikipedia** | `search` `summary` | 公开 |
|
||||
| **hackernews** | `top` | 公共 API |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
| **linkedin** | `search` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **weibo** | `hot` | 浏览器 |
|
||||
| **weibo** | `hot` `search` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
| **sinafinance** | `news` | 🌐 公开 |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
|
||||
| **chaoxing** | `assignments` `exams` | 浏览器 |
|
||||
| **grok** | `ask` | 桌面端 |
|
||||
| **grok** | `ask` | 浏览器 |
|
||||
| **hf** | `top` | 公开 |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
|
||||
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | 公开 |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
||||
| **steam** | `top-sellers` | 公开 |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
||||
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
|
||||
| **google** | `news` `search` `suggest` `trends` | 公开 |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
|
||||
| **medium** | `feed` `search` `user` | 浏览器 |
|
||||
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
|
||||
| **substack** | `feed` `search` `publication` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
|
||||
> **Bloomberg 说明**:Bloomberg 的 RSS 列表命令(`main`、各栏目 feed、`feeds`)无需浏览器即可使用。`bloomberg news` 适用于当前 Chrome 会话本身就能访问的标准 Bloomberg 文章页。音频页和部分非标准页面可能失败,OpenCLI 也不会绕过 Bloomberg 的付费墙、登录或权限校验。
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
@@ -173,8 +188,8 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker 命令行工具 | `opencli docker ps` |
|
||||
| **kubectl** | Kubernetes CLI | `opencli kubectl get pods` |
|
||||
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
|
||||
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
|
||||
|
||||
@@ -199,6 +214,7 @@ opencli register mycli
|
||||
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
|
||||
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
|
||||
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
|
||||
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
|
||||
|
||||
## 下载支持
|
||||
|
||||
@@ -212,6 +228,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
|
||||
| **B站** | 视频 | 需要安装 `yt-dlp` |
|
||||
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
|
||||
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
|
||||
| **微信公众号** | 文章(Markdown) | 导出微信公众号文章为 Markdown |
|
||||
|
||||
### 前置依赖
|
||||
|
||||
@@ -228,11 +245,11 @@ brew install yt-dlp
|
||||
|
||||
```bash
|
||||
# 下载小红书笔记中的图片/视频
|
||||
opencli xiaohongshu download --note-id abc123 --output ./xhs
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
|
||||
# 下载B站视频(需要 yt-dlp)
|
||||
opencli bilibili download --bvid BV1xxx --output ./bilibili
|
||||
opencli bilibili download --bvid BV1xxx --quality 1080p # 指定画质
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
opencli bilibili download BV1xxx --quality 1080p # 指定画质
|
||||
|
||||
# 下载 Twitter 用户的媒体
|
||||
opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
@@ -245,22 +262,12 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# 导出并下载图片
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
|
||||
# 导出微信公众号文章为 Markdown
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
```
|
||||
|
||||
### Pipeline Step(用于 YAML 适配器)
|
||||
|
||||
`download` step 可以在 YAML 管线中使用:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
- fetch: https://api.example.com/media
|
||||
- download:
|
||||
url: ${{ item.imageUrl }}
|
||||
dir: ./downloads
|
||||
filename: ${{ item.title | sanitize }}.jpg
|
||||
concurrency: 5
|
||||
skip_existing: true
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -277,6 +284,25 @@ opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # 详细模式:展示管线执行步骤调试信息
|
||||
```
|
||||
|
||||
## 插件
|
||||
|
||||
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # 安装
|
||||
opencli plugin list # 查看已安装
|
||||
opencli plugin update my-tool # 更新到最新
|
||||
opencli plugin uninstall my-tool # 卸载
|
||||
```
|
||||
|
||||
| 插件 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
|
||||
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | 多平台热榜聚合 |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金热门文章 |
|
||||
|
||||
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
|
||||
|
||||
## 致 AI Agent(开发者指南)
|
||||
|
||||
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
|
||||
@@ -305,6 +331,8 @@ opencli cascade https://api.example.com/data
|
||||
|
||||
- **"Extension not connected" 报错**
|
||||
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
|
||||
- 其他 Chrome 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
|
||||
- **返回空数据,或者报错 "Unauthorized"**
|
||||
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 (如 parseArgs, fs 等)**
|
||||
@@ -313,15 +341,12 @@ opencli cascade https://api.example.com/data
|
||||
- 检查 daemon 状态:`curl localhost:19825/status`
|
||||
- 查看扩展日志:`curl localhost:19825/logs`
|
||||
|
||||
## 版本发布
|
||||
|
||||
```bash
|
||||
npm version patch # 0.1.0 → 0.1.1
|
||||
npm version minor # 0.1.0 → 0.2.0
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#jackwener/opencli&Date)
|
||||
|
||||
|
||||
# 推送 tag,GitHub Actions 将自动执行发版和 npm 发布
|
||||
git push --follow-tags
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 150+ commands across 30+ sites."
|
||||
version: 1.1.0
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 1.3.1
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
|
||||
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
|
||||
---
|
||||
|
||||
# OpenCLI
|
||||
@@ -182,7 +182,6 @@ opencli antigravity dump # 导出 DOM 和快照调试信息
|
||||
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
|
||||
opencli antigravity model claude # 切换底层模型
|
||||
opencli antigravity watch # 流式监听增量消息
|
||||
opencli antigravity serve --port 8082 # 启动 Anthropic 兼容代理
|
||||
|
||||
# Barchart (browser)
|
||||
opencli barchart quote --symbol AAPL # 股票行情
|
||||
@@ -222,6 +221,14 @@ opencli weread ranking --limit 10 # 排行榜
|
||||
opencli jimeng generate --prompt "描述" # AI 生图
|
||||
opencli jimeng history --limit 10 # 生成历史
|
||||
|
||||
# Yollomi yollomi.com (browser — 需在 Chrome 登录 yollomi.com,复用站点 session)
|
||||
opencli yollomi models --type image # 列出图像模型与积分
|
||||
opencli yollomi generate "提示词" --model z-image-turbo # 文生图
|
||||
opencli yollomi video "提示词" --model kling-2-1 # 视频
|
||||
opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用
|
||||
opencli yollomi remove-bg <image-url> # 去背景(免费)
|
||||
opencli yollomi edit <image-url> "改成油画风格" # Qwen 图像编辑
|
||||
|
||||
# Grok (default + explicit web)
|
||||
opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径)
|
||||
opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路径
|
||||
@@ -232,6 +239,175 @@ opencli hf top --limit 10 # 热门模型
|
||||
# 超星学习通 (browser)
|
||||
opencli chaoxing assignments # 作业列表
|
||||
opencli chaoxing exams # 考试列表
|
||||
|
||||
# Douban 豆瓣 (browser)
|
||||
opencli douban search "三体" # 搜索 (query positional)
|
||||
opencli douban top250 # 豆瓣 Top 250
|
||||
opencli douban subject 1234567 # 条目详情 (id positional)
|
||||
opencli douban marks --limit 10 # 我的标记
|
||||
opencli douban reviews --limit 10 # 短评
|
||||
|
||||
# Facebook (browser)
|
||||
opencli facebook feed --limit 10 # 动态流
|
||||
opencli facebook profile username # 用户资料 (id positional)
|
||||
opencli facebook search "AI" # 搜索 (query positional)
|
||||
opencli facebook friends # 好友列表
|
||||
opencli facebook groups # 群组
|
||||
opencli facebook events # 活动
|
||||
opencli facebook notifications # 通知
|
||||
opencli facebook memories # 回忆
|
||||
opencli facebook add-friend username # 添加好友 (id positional)
|
||||
opencli facebook join-group groupid # 加入群组 (id positional)
|
||||
|
||||
# Instagram (browser)
|
||||
opencli instagram explore # 探索
|
||||
opencli instagram profile username # 用户资料 (id positional)
|
||||
opencli instagram search "AI" # 搜索 (query positional)
|
||||
opencli instagram user username # 用户详情 (id positional)
|
||||
opencli instagram followers username # 粉丝 (id positional)
|
||||
opencli instagram following username # 关注 (id positional)
|
||||
opencli instagram follow username # 关注用户 (id positional)
|
||||
opencli instagram unfollow username # 取消关注 (id positional)
|
||||
opencli instagram like postid # 点赞 (id positional)
|
||||
opencli instagram unlike postid # 取消点赞 (id positional)
|
||||
opencli instagram comment postid "评论" # 评论 (id + text positional)
|
||||
opencli instagram save postid # 收藏 (id positional)
|
||||
opencli instagram unsave postid # 取消收藏 (id positional)
|
||||
opencli instagram saved # 已收藏列表
|
||||
|
||||
# TikTok (browser)
|
||||
opencli tiktok explore # 探索
|
||||
opencli tiktok search "AI" # 搜索 (query positional)
|
||||
opencli tiktok profile username # 用户资料 (id positional)
|
||||
opencli tiktok user username # 用户详情 (id positional)
|
||||
opencli tiktok following username # 关注列表 (id positional)
|
||||
opencli tiktok follow username # 关注 (id positional)
|
||||
opencli tiktok unfollow username # 取消关注 (id positional)
|
||||
opencli tiktok like videoid # 点赞 (id positional)
|
||||
opencli tiktok unlike videoid # 取消点赞 (id positional)
|
||||
opencli tiktok comment videoid "评论" # 评论 (id + text positional)
|
||||
opencli tiktok save videoid # 收藏 (id positional)
|
||||
opencli tiktok unsave videoid # 取消收藏 (id positional)
|
||||
opencli tiktok live # 直播
|
||||
opencli tiktok notifications # 通知
|
||||
opencli tiktok friends # 朋友
|
||||
|
||||
# Medium (browser)
|
||||
opencli medium feed --limit 10 # 动态流
|
||||
opencli medium search "AI" # 搜索 (query positional)
|
||||
opencli medium user username # 用户主页 (id positional)
|
||||
|
||||
# Substack (browser)
|
||||
opencli substack feed --limit 10 # 订阅动态
|
||||
opencli substack search "AI" # 搜索 (query positional)
|
||||
opencli substack publication name # 出版物详情 (id positional)
|
||||
|
||||
# Sinablog 新浪博客 (browser)
|
||||
opencli sinablog hot --limit 10 # 热门
|
||||
opencli sinablog search "AI" # 搜索 (query positional)
|
||||
opencli sinablog article url # 文章详情
|
||||
opencli sinablog user username # 用户主页 (id positional)
|
||||
|
||||
# Lobsters (public)
|
||||
opencli lobsters hot --limit 10 # 热门
|
||||
opencli lobsters newest --limit 10 # 最新
|
||||
opencli lobsters active --limit 10 # 活跃
|
||||
opencli lobsters tag rust # 按标签筛选 (tag positional)
|
||||
|
||||
# Google (public)
|
||||
opencli google news --limit 10 # 新闻
|
||||
opencli google search "AI" # 搜索 (query positional)
|
||||
opencli google suggest "AI" # 搜索建议 (query positional)
|
||||
opencli google trends # 趋势
|
||||
|
||||
# DEV.to (public)
|
||||
opencli devto top --limit 10 # 热门文章
|
||||
opencli devto tag javascript --limit 10 # 按标签 (tag positional)
|
||||
opencli devto user username # 用户文章 (username positional)
|
||||
|
||||
# Steam (public)
|
||||
opencli steam top-sellers --limit 10 # 热销游戏
|
||||
|
||||
# Wikipedia (public)
|
||||
opencli wikipedia search "AI" # 搜索 (query positional)
|
||||
opencli wikipedia summary "Python" # 摘要 (title positional)
|
||||
```
|
||||
|
||||
### Desktop Adapter Commands
|
||||
|
||||
```bash
|
||||
# Cursor (desktop — CDP via Electron)
|
||||
opencli cursor status # 检查连接
|
||||
opencli cursor send "message" # 发送消息
|
||||
opencli cursor read # 读取回复
|
||||
opencli cursor new # 新建对话
|
||||
opencli cursor dump # 导出 DOM 调试信息
|
||||
opencli cursor composer # Composer 模式
|
||||
opencli cursor model claude # 切换模型
|
||||
opencli cursor extract-code # 提取代码块
|
||||
opencli cursor ask "question" # 一键提问并等回复
|
||||
opencli cursor screenshot # 截图
|
||||
opencli cursor history # 对话历史
|
||||
opencli cursor export # 导出对话
|
||||
|
||||
# Codex (desktop — headless CLI agent)
|
||||
opencli codex status # 检查连接
|
||||
opencli codex send "message" # 发送消息
|
||||
opencli codex read # 读取回复
|
||||
opencli codex new # 新建对话
|
||||
opencli codex dump # 导出调试信息
|
||||
opencli codex extract-diff # 提取 diff
|
||||
opencli codex model gpt-4 # 切换模型
|
||||
opencli codex ask "question" # 一键提问并等回复
|
||||
opencli codex screenshot # 截图
|
||||
opencli codex history # 对话历史
|
||||
opencli codex export # 导出对话
|
||||
|
||||
# ChatGPT (desktop — macOS AppleScript/CDP)
|
||||
opencli chatgpt status # 检查应用状态
|
||||
opencli chatgpt new # 新建对话
|
||||
opencli chatgpt send "message" # 发送消息
|
||||
opencli chatgpt read # 读取回复
|
||||
opencli chatgpt ask "question" # 一键提问并等回复
|
||||
|
||||
# ChatWise (desktop — multi-LLM client)
|
||||
opencli chatwise status # 检查连接
|
||||
opencli chatwise new # 新建对话
|
||||
opencli chatwise send "message" # 发送消息
|
||||
opencli chatwise read # 读取回复
|
||||
opencli chatwise ask "question" # 一键提问并等回复
|
||||
opencli chatwise model claude # 切换模型
|
||||
opencli chatwise history # 对话历史
|
||||
opencli chatwise export # 导出对话
|
||||
opencli chatwise screenshot # 截图
|
||||
|
||||
# Notion (desktop — CDP via Electron)
|
||||
opencli notion status # 检查连接
|
||||
opencli notion search "keyword" # 搜索页面
|
||||
opencli notion read # 读取当前页面
|
||||
opencli notion new # 新建页面
|
||||
opencli notion write "content" # 写入内容
|
||||
opencli notion sidebar # 侧边栏导航
|
||||
opencli notion favorites # 收藏列表
|
||||
opencli notion export # 导出
|
||||
|
||||
# Discord App (desktop — CDP via Electron)
|
||||
opencli discord-app status # 检查连接
|
||||
opencli discord-app send "message" # 发送消息
|
||||
opencli discord-app read # 读取消息
|
||||
opencli discord-app channels # 频道列表
|
||||
opencli discord-app servers # 服务器列表
|
||||
opencli discord-app search "keyword" # 搜索
|
||||
opencli discord-app members # 成员列表
|
||||
|
||||
# Doubao App 豆包桌面版 (desktop — CDP via Electron)
|
||||
opencli doubao-app status # 检查连接
|
||||
opencli doubao-app new # 新建对话
|
||||
opencli doubao-app send "message" # 发送消息
|
||||
opencli doubao-app read # 读取回复
|
||||
opencli doubao-app ask "question" # 一键提问并等回复
|
||||
opencli doubao-app screenshot # 截图
|
||||
opencli doubao-app dump # 导出 DOM 调试信息
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
@@ -244,9 +420,7 @@ opencli install <name> # Auto-install an external CLI (e.g., gh, obsidian)
|
||||
opencli register <name> # Register a local custom CLI for unified discovery
|
||||
opencli validate # Validate all CLI definitions
|
||||
opencli validate bilibili # Validate specific site
|
||||
opencli setup # Interactive Browser Bridge setup and connectivity check
|
||||
opencli doctor # Diagnose daemon, extension, and browser connectivity
|
||||
opencli doctor --live # Also test live browser connectivity
|
||||
opencli doctor # Diagnose browser bridge (auto-starts daemon, includes live test)
|
||||
```
|
||||
|
||||
### AI Agent Workflow
|
||||
@@ -261,14 +435,26 @@ opencli synthesize <site>
|
||||
# Generate: one-shot explore → synthesize → register
|
||||
opencli generate <url> --goal "hot"
|
||||
|
||||
# Record: YOU operate the page, opencli captures every API call → YAML candidates
|
||||
# Opens the URL in automation window, injects fetch/XHR interceptor into ALL tabs,
|
||||
# polls every 2s, auto-stops after 60s (or press Enter to stop early).
|
||||
opencli record <url> # 录制,site name 从域名推断
|
||||
opencli record <url> --site mysite # 指定 site name
|
||||
opencli record <url> --timeout 120000 # 自定义超时(毫秒,默认 60000)
|
||||
opencli record <url> --poll 1000 # 缩短轮询间隔(毫秒,默认 2000)
|
||||
opencli record <url> --out .opencli/record/x # 自定义输出目录
|
||||
# Output:
|
||||
# .opencli/record/<site>/captured.json ← 原始捕获数据(带 url/method/body)
|
||||
# .opencli/record/<site>/candidates/*.yaml ← 高置信度候选适配器(score ≥ 8,有 array 结果)
|
||||
|
||||
# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
|
||||
opencli cascade <api-url>
|
||||
|
||||
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
|
||||
opencli explore <url> --auto --click "字幕,CC,评论"
|
||||
|
||||
# Verify: validate adapter definitions
|
||||
opencli verify
|
||||
# Validate: validate adapter definitions
|
||||
opencli validate
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
@@ -291,6 +477,129 @@ opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # Show each pipeline step and data flow
|
||||
```
|
||||
|
||||
## Record Workflow
|
||||
|
||||
`record` 是为「无法用 `explore` 自动发现」的页面(需要登录操作、复杂交互、SPA 内路由)准备的手动录制方案。
|
||||
|
||||
### 工作原理
|
||||
|
||||
```
|
||||
opencli record <url>
|
||||
→ 打开 automation window 并导航到目标 URL
|
||||
→ 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入)
|
||||
→ 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区
|
||||
→ 超时(默认 60s)或按 Enter 停止
|
||||
→ 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML
|
||||
```
|
||||
|
||||
**拦截器特性**:
|
||||
- 同时 patch `window.fetch` 和 `XMLHttpRequest`
|
||||
- 只捕获 `Content-Type: application/json` 的响应
|
||||
- 过滤纯对象少于 2 个 key 的响应(避免 tracking/ping)
|
||||
- 跨 tab 隔离:每个 tab 独立缓冲区,轮询时分别 drain
|
||||
- 幂等注入:同一 tab 二次注入时先 restore 原始函数再重新 patch,不丢失已捕获数据
|
||||
|
||||
### 使用步骤
|
||||
|
||||
```bash
|
||||
# 1. 启动录制(建议 --timeout 给足操作时间)
|
||||
opencli record "https://example.com/page" --timeout 120000
|
||||
|
||||
# 2. 在弹出的 automation window 里正常操作页面:
|
||||
# - 打开列表、搜索、点击条目、切换 Tab
|
||||
# - 凡是触发网络请求的操作都会被捕获
|
||||
|
||||
# 3. 完成操作后按 Enter 停止(或等超时自动停止)
|
||||
|
||||
# 4. 查看结果
|
||||
cat .opencli/record/<site>/captured.json # 原始捕获
|
||||
ls .opencli/record/<site>/candidates/ # 候选 YAML
|
||||
```
|
||||
|
||||
### 页面类型与捕获预期
|
||||
|
||||
| 页面类型 | 预期捕获量 | 说明 |
|
||||
|---------|-----------|------|
|
||||
| 列表/搜索页 | 多(5~20+) | 每次搜索/翻页都会触发新请求 |
|
||||
| 详情页(只读) | 少(1~5) | 首屏数据一次性返回,后续操作走 form/redirect |
|
||||
| SPA 内路由跳转 | 中等 | 路由切换会触发新接口,但首屏请求在注入前已发出 |
|
||||
| 需要登录的页面 | 视操作而定 | 确保 Chrome 已登录目标网站 |
|
||||
|
||||
> **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。
|
||||
> 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。
|
||||
|
||||
### 候选 YAML → TS CLI 转换
|
||||
|
||||
生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统):
|
||||
|
||||
**候选 YAML 结构**(自动生成):
|
||||
```yaml
|
||||
site: tae
|
||||
name: getList # 从 URL path 推断的名称
|
||||
strategy: cookie
|
||||
browser: true
|
||||
pipeline:
|
||||
- navigate: https://...
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const res = await fetch('/approval/getList.json?procInsId=...', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
return (data?.content?.operatorRecords || []).map(item => ({ ... }));
|
||||
})()
|
||||
```
|
||||
|
||||
**转换为 TS CLI**(参考 `src/clis/tae/add-expense.ts` 风格):
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'tae',
|
||||
name: 'get-approval',
|
||||
description: '查看报销单审批流程和操作记录',
|
||||
domain: 'tae.alibaba-inc.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'proc_ins_id', type: 'string', required: true, positional: true, help: '流程实例 ID(procInsId)' },
|
||||
],
|
||||
columns: ['step', 'operator', 'action', 'time'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://tae.alibaba-inc.com/expense/pc.html?_authType=SAML');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
const res = await fetch('/approval/getList.json?taskId=&procInsId=${kwargs.proc_ins_id}', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.content?.operatorRecords || [];
|
||||
})()`);
|
||||
return (result as any[]).map((r, i) => ({
|
||||
step: i + 1,
|
||||
operator: r.operatorName || r.userId,
|
||||
action: r.operationType,
|
||||
time: r.operateTime,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**转换要点**:
|
||||
1. URL 中的动态 ID(`procInsId`、`taskId` 等)提取为 `args`
|
||||
2. `captured.json` 里的真实 body 结构用于确定正确的数据路径(如 `content.operatorRecords`)
|
||||
3. tae 系统统一用 `{ success, content, errorCode, errorMsg }` 外层包裹,取数据要走 `content.*`
|
||||
4. 认证方式:cookie(`credentials: 'include'`),不需要额外 header
|
||||
5. 文件放入 `src/clis/<site>/`,无需手动注册,`npm run build` 后自动发现
|
||||
|
||||
### 故障排查
|
||||
|
||||
| 现象 | 原因 | 解法 |
|
||||
|------|------|------|
|
||||
| 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` |
|
||||
| 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 |
|
||||
| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI |
|
||||
| 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` |
|
||||
| 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 |
|
||||
|
||||
## Creating Adapters
|
||||
|
||||
> [!TIP]
|
||||
|
||||
+85
-67
@@ -18,57 +18,72 @@
|
||||
|
||||
测试分为三层,全部使用 **vitest** 运行:
|
||||
|
||||
```
|
||||
```text
|
||||
tests/
|
||||
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
||||
│ ├── helpers.ts # runCli() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
|
||||
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令
|
||||
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试)
|
||||
│ ├── management.test.ts # 管理命令(list, validate, verify, help)
|
||||
│ └── output-formats.test.ts # 输出格式(json/yaml/csv/md)
|
||||
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
|
||||
│ └── api-health.test.ts # 外部 API 可用性检测
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure)
|
||||
│ ├── management.test.ts # 管理命令(list / validate / verify / help)
|
||||
│ └── output-formats.test.ts # 输出格式校验
|
||||
├── smoke/
|
||||
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
|
||||
src/
|
||||
├── *.test.ts # 单元测试(已有 8 个)
|
||||
└── **/*.test.ts # 单元测试(当前 32 个文件)
|
||||
```
|
||||
|
||||
| 层 | 位置 | 运行方式 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
|
||||
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|
||||
|---|---|---:|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | 32 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
|
||||
|
||||
---
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### 单元测试(8 个文件)
|
||||
### 单元测试(32 个文件)
|
||||
|
||||
| 文件 | 覆盖内容 |
|
||||
| 领域 | 文件 |
|
||||
|---|---|
|
||||
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
|
||||
| `engine.test.ts` | 命令发现与执行 |
|
||||
| `registry.test.ts` | 命令注册与策略分配 |
|
||||
| `output.test.ts` | 输出格式渲染 |
|
||||
| `doctor.test.ts` | Token 诊断 |
|
||||
| `coupang.test.ts` | 数据归一化 |
|
||||
| `pipeline/template.test.ts` | 模板表达式求值 |
|
||||
| `pipeline/transform.test.ts` | 数据变换步骤 |
|
||||
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
|
||||
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
|
||||
| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/search.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` |
|
||||
|
||||
### E2E 测试(~52 个用例)
|
||||
这些测试覆盖的重点包括:
|
||||
|
||||
| 文件 | 覆盖站点/功能 | 测试数 |
|
||||
|---|---|---|
|
||||
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
|
||||
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
|
||||
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
|
||||
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
|
||||
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
|
||||
- Browser Bridge、DOM snapshot、interceptor、capability routing
|
||||
- manifest 生成、命令发现、插件安装与注册表
|
||||
- 输出格式渲染与 snapshot formatting
|
||||
- pipeline 模板求值、执行器与变换步骤
|
||||
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
|
||||
|
||||
### 烟雾测试
|
||||
### E2E 测试(5 个文件)
|
||||
|
||||
公开 API 可用性(hackernews, v2ex×2, v2ex/topic)+ 全站点注册完整性检查。
|
||||
| 文件 | 当前覆盖范围 |
|
||||
|---|---|
|
||||
| `tests/e2e/public-commands.test.ts` | `bloomberg`、`apple-podcasts`、`hackernews`、`v2ex`、`xiaoyuzhou`、`google suggest` 等公开命令 |
|
||||
| `tests/e2e/browser-public.test.ts` | `bbc`、`bloomberg`、`bilibili`、`weibo`、`zhihu`、`reddit`、`twitter`、`xueqiu`、`reuters`、`youtube`、`smzdm`、`boss`、`ctrip`、`coupang`、`xiaohongshu`、`google`、`yahoo-finance`、`v2ex daily` |
|
||||
| `tests/e2e/browser-auth.test.ts` | `bilibili`、`twitter`、`v2ex`、`xueqiu`、`linux-do`、`xiaohongshu` 的需登录命令 graceful failure |
|
||||
| `tests/e2e/management.test.ts` | `list`、`validate`、`verify`、`--version`、`--help`、unknown command |
|
||||
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
|
||||
|
||||
### 烟雾测试(1 个文件)
|
||||
|
||||
| 文件 | 当前覆盖范围 |
|
||||
|---|---|
|
||||
| `tests/smoke/api-health.test.ts` | `hackernews`、`v2ex` 公开 API 可用性,`validate` 全量 adapter 校验,以及命令注册表基础完整性 |
|
||||
|
||||
### 快速核对命令
|
||||
|
||||
需要刷新测试清单时,直接以仓库文件为准:
|
||||
|
||||
```bash
|
||||
find src -name '*.test.ts' | sort
|
||||
find tests/e2e -name '*.test.ts' | sort
|
||||
find tests/smoke -name '*.test.ts' | sort
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +93,7 @@ src/
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run build # 编译(E2E 测试需要 dist/main.js)
|
||||
npm run build # 编译(E2E / smoke 测试需要 dist/main.js)
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
@@ -87,18 +102,19 @@ npm run build # 编译(E2E 测试需要 dist/main.js)
|
||||
# 全部单元测试
|
||||
npx vitest run src/
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API)
|
||||
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
|
||||
npx vitest run tests/e2e/
|
||||
|
||||
# 全部 smoke 测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run src/clis/apple-podcasts/commands.test.ts
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试(单元 + E2E)
|
||||
# 全部测试
|
||||
npx vitest run
|
||||
|
||||
# 烟雾测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# watch 模式(开发时推荐)
|
||||
npx vitest src/
|
||||
```
|
||||
@@ -106,9 +122,10 @@ npx vitest src/
|
||||
### 浏览器命令本地测试须知
|
||||
|
||||
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
|
||||
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
|
||||
|
||||
---
|
||||
|
||||
@@ -116,8 +133,8 @@ npx vitest src/
|
||||
|
||||
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
||||
|
||||
1. **无需额外操作**:`validate` 测试会自动覆盖 YAML 结构验证
|
||||
2. 根据 adapter 类型,在对应文件加一个 `it()` block:
|
||||
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验
|
||||
2. 根据 adapter 类型,在对应测试文件补一个 `it()` block
|
||||
|
||||
```typescript
|
||||
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
||||
@@ -148,15 +165,15 @@ it('producthunt me fails gracefully without login', async () => {
|
||||
|
||||
### 新增管理命令(如 `opencli export`)
|
||||
|
||||
在 `tests/e2e/management.test.ts` 添加测试。
|
||||
在 `tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`。
|
||||
|
||||
### 新增内部模块
|
||||
|
||||
在 `src/` 下对应位置创建 `*.test.ts`。
|
||||
在对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护。
|
||||
|
||||
### 决策流程图
|
||||
|
||||
```
|
||||
```text
|
||||
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
||||
↓ 否
|
||||
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
||||
@@ -170,32 +187,33 @@ it('producthunt me fails gracefully without login', async () => {
|
||||
|
||||
## CI/CD 流水线
|
||||
|
||||
### ci.yml(主流水线)
|
||||
### `ci.yml`
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **build** | push/PR to main,dev | typecheck + build |
|
||||
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
|
||||
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
|
||||
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
|
||||
| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 |
|
||||
| `smoke-test` | `schedule` 或 `workflow_dispatch` | 安装真实 Chrome,`xvfb-run` 执行 `tests/smoke/` |
|
||||
|
||||
### e2e-headed.yml(E2E 测试)
|
||||
### `e2e-headed.yml`
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
|
||||
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
|
||||
|
||||
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
|
||||
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径。
|
||||
|
||||
### Sharding
|
||||
|
||||
单元测试使用 vitest 内置 shard:
|
||||
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行:
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
|
||||
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
|
||||
---
|
||||
@@ -206,8 +224,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
|
||||
|
||||
| 条件 | 模式 | 使用场景 |
|
||||
|---|---|---|
|
||||
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
|
||||
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
@@ -220,14 +238,14 @@ env:
|
||||
|
||||
## 站点兼容性
|
||||
|
||||
在 GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
|
||||
GitHub Actions 的美国 runner 上,部分站点会因为地域限制、登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红。
|
||||
|
||||
| 站点 | CI 状态 | 限制原因 |
|
||||
| 站点 | CI 表现 | 常见原因 |
|
||||
|---|---|---|
|
||||
| hackernews, bbc, v2ex | ✅ 返回数据 | 无限制 |
|
||||
| yahoo-finance | ✅ 返回数据 | 无限制 |
|
||||
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
|
||||
| reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
|
||||
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
|
||||
| `hackernews`、`bbc`、`v2ex`、`bloomberg` | 通常返回数据 | 公开接口或公开页面 |
|
||||
| `yahoo-finance`、`google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
|
||||
| `bilibili`、`zhihu`、`weibo`、`xiaohongshu`、`xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
|
||||
| `reddit`、`twitter`、`youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
|
||||
| `smzdm`、`boss`、`ctrip`、`coupang`、`linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
|
||||
|
||||
> 使用 self-hosted runner(国内服务器)可解决地域限制问题。
|
||||
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Getting Started', link: '/guide/getting-started' },
|
||||
{ text: 'Installation', link: '/guide/installation' },
|
||||
{ text: 'Comparison', link: '/comparison' },
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
{ text: 'Plugins', link: '/guide/plugins' },
|
||||
@@ -64,6 +65,7 @@ export default defineConfig({
|
||||
{ text: 'SMZDM', link: '/adapters/browser/smzdm' },
|
||||
{ text: 'Jike', link: '/adapters/browser/jike' },
|
||||
{ text: 'Jimeng', link: '/adapters/browser/jimeng' },
|
||||
{ text: 'Yollomi', link: '/adapters/browser/yollomi' },
|
||||
{ text: 'LINUX DO', link: '/adapters/browser/linux-do' },
|
||||
{ text: 'Chaoxing', link: '/adapters/browser/chaoxing' },
|
||||
{ text: 'Grok', link: '/adapters/browser/grok' },
|
||||
@@ -78,6 +80,8 @@ export default defineConfig({
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'HackerNews', link: '/adapters/browser/hackernews' },
|
||||
{ text: 'Dev.to', link: '/adapters/browser/devto' },
|
||||
{ text: 'Dictionary', link: '/adapters/browser/dictionary' },
|
||||
{ text: 'BBC', link: '/adapters/browser/bbc' },
|
||||
{ text: 'Apple Podcasts', link: '/adapters/browser/apple-podcasts' },
|
||||
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
|
||||
@@ -101,9 +105,6 @@ export default defineConfig({
|
||||
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
|
||||
{ text: 'Notion', link: '/adapters/desktop/notion' },
|
||||
{ text: 'Discord', link: '/adapters/desktop/discord' },
|
||||
{ text: 'Feishu', link: '/adapters/desktop/feishu' },
|
||||
{ text: 'WeChat', link: '/adapters/desktop/wechat' },
|
||||
{ text: 'NeteaseMusic', link: '/adapters/desktop/neteasemusic' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Dev.to
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `dev.to`
|
||||
|
||||
Fetch the latest and greatest developer articles from the DEV community without needing an API key.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli devto top` | Top DEV.to articles of the day |
|
||||
| `opencli devto tag` | Latest articles for a specific tag |
|
||||
| `opencli devto user` | Recent articles from a specific user |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Top articles today
|
||||
opencli devto top --limit 5
|
||||
|
||||
# Articles by tag (positional argument)
|
||||
opencli devto tag javascript
|
||||
opencli devto tag python --limit 20
|
||||
|
||||
# Articles by a specific author
|
||||
opencli devto user ben
|
||||
opencli devto user thepracticaldev --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli devto top -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses the public DEV.to API
|
||||
@@ -0,0 +1,27 @@
|
||||
# Dictionary
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `api.dictionaryapi.dev`
|
||||
|
||||
Search the open dictionary to quickly fetch native definitions, part of speech contexts, and phonetic pronunciations directly in your IDE terminal.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli dictionary search` | Fetch the exact definition of a word |
|
||||
| `opencli dictionary synonyms` | Find related synonyms for a word |
|
||||
| `opencli dictionary examples` | Read real-world sentence usage examples |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Look up a complex term
|
||||
opencli dictionary search serendipity
|
||||
|
||||
# Discover phonetics
|
||||
opencli dictionary search ephemeral
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — utilizes the fast, open JSON definitions API.
|
||||
@@ -6,19 +6,17 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
||||
| `opencli douban top250` | 豆瓣电影 Top 250 |
|
||||
| `opencli douban subject` | 条目详情 |
|
||||
| `opencli douban marks` | 我的标记 |
|
||||
| `opencli douban reviews` | 我的短评 |
|
||||
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
|
||||
| `opencli douban book-hot` | 豆瓣图书热门榜单 |
|
||||
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# 电影热门
|
||||
opencli douban movie-hot --limit 10
|
||||
|
||||
# 图书热门
|
||||
opencli douban book-hot --limit 10
|
||||
|
||||
# 搜索电影
|
||||
opencli douban search "流浪地球"
|
||||
|
||||
@@ -28,8 +26,20 @@ opencli douban search --type book "三体"
|
||||
# 搜索音乐
|
||||
opencli douban search --type music "周杰伦"
|
||||
|
||||
# 电影 Top 250
|
||||
opencli douban top250 --limit 10
|
||||
|
||||
# 条目详情
|
||||
opencli douban subject 1292052
|
||||
|
||||
# 电影热门
|
||||
opencli douban movie-hot --limit 10
|
||||
|
||||
# 图书热门
|
||||
opencli douban book-hot --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli douban movie-hot -f json
|
||||
opencli douban top250 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# doubao
|
||||
|
||||
Browser adapter for [Doubao Chat](https://www.doubao.com/chat).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli doubao status` | Check whether the page is reachable and whether Doubao appears logged in |
|
||||
| `opencli doubao new` | Start a new Doubao conversation |
|
||||
| `opencli doubao send "..."` | Send a message to the current Doubao chat |
|
||||
| `opencli doubao read` | Read the visible Doubao conversation |
|
||||
| `opencli doubao ask "..."` | Send a prompt and wait for a reply |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome is running
|
||||
- You are already logged into [doubao.com](https://www.doubao.com/)
|
||||
- Playwright MCP Bridge / browser bridge is configured for OpenCLI
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
opencli doubao status
|
||||
opencli doubao new
|
||||
opencli doubao send "帮我总结这段文档"
|
||||
opencli doubao read
|
||||
opencli doubao ask "请写一个 Python 快速排序示例" --timeout 90
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The adapter targets the web chat page at `https://www.doubao.com/chat`
|
||||
- `new` first tries the visible "New Chat / 新对话" button, then falls back to the new-thread route
|
||||
- `ask` uses DOM polling, so very long generations may need a larger `--timeout`
|
||||
@@ -6,19 +6,35 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli hackernews top` | |
|
||||
| `opencli hackernews top` | Hacker News top stories |
|
||||
| `opencli hackernews new` | Hacker News newest stories |
|
||||
| `opencli hackernews best` | Hacker News best stories |
|
||||
| `opencli hackernews ask` | Hacker News Ask HN posts |
|
||||
| `opencli hackernews show` | Hacker News Show HN posts |
|
||||
| `opencli hackernews jobs` | Hacker News job postings |
|
||||
| `opencli hackernews search <query>` | Search Hacker News stories |
|
||||
| `opencli hackernews user <username>` | Hacker News user profile |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
# Top stories
|
||||
opencli hackernews top --limit 5
|
||||
|
||||
# Newest stories
|
||||
opencli hackernews new --limit 10
|
||||
|
||||
# Search stories
|
||||
opencli hackernews search "machine learning" --limit 5
|
||||
|
||||
# User profile
|
||||
opencli hackernews user pg
|
||||
|
||||
# JSON output
|
||||
opencli hackernews top -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli hackernews top -v
|
||||
# Sort search by date
|
||||
opencli hackernews search "rust" --sort date
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# JD.com
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `item.jd.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Get product details by SKU
|
||||
opencli jd item 100291143898
|
||||
|
||||
# Limit detail images
|
||||
opencli jd item 100291143898 --images 5
|
||||
|
||||
# JSON output
|
||||
opencli jd item 100291143898 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** jd.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -7,6 +7,7 @@
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli linkedin search` | |
|
||||
| `opencli linkedin timeline` | Read posts from your LinkedIn home feed |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -14,9 +15,14 @@
|
||||
# Quick start
|
||||
opencli linkedin search --limit 5
|
||||
|
||||
# Read your home timeline
|
||||
opencli linkedin timeline --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli linkedin search -f json
|
||||
|
||||
opencli linkedin timeline -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli linkedin search -v
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
opencli tiktok profile --username tiktok
|
||||
|
||||
# Search videos
|
||||
opencli tiktok search --query "cooking" --limit 10
|
||||
opencli tiktok search "cooking" --limit 10
|
||||
|
||||
# Trending explore videos
|
||||
opencli tiktok explore --limit 20
|
||||
|
||||
@@ -6,27 +6,48 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli v2ex hot` | |
|
||||
| `opencli v2ex latest` | |
|
||||
| `opencli v2ex topic` | |
|
||||
| `opencli v2ex daily` | |
|
||||
| `opencli v2ex me` | |
|
||||
| `opencli v2ex notifications` | |
|
||||
| `opencli v2ex hot` | Hot topics |
|
||||
| `opencli v2ex latest` | Latest topics |
|
||||
| `opencli v2ex topic <id>` | Topic detail |
|
||||
| `opencli v2ex node <name>` | Topics by node |
|
||||
| `opencli v2ex user <username>` | Topics by user |
|
||||
| `opencli v2ex member <username>` | User profile |
|
||||
| `opencli v2ex replies <id>` | Topic replies |
|
||||
| `opencli v2ex nodes` | All nodes (sorted by topic count) |
|
||||
| `opencli v2ex daily` | Daily hot |
|
||||
| `opencli v2ex me` | My profile (auth required) |
|
||||
| `opencli v2ex notifications` | My notifications (auth required) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
# Hot topics
|
||||
opencli v2ex hot --limit 5
|
||||
|
||||
# Browse topics in a node
|
||||
opencli v2ex node python
|
||||
|
||||
# View topic replies
|
||||
opencli v2ex replies 1000
|
||||
|
||||
# User's topics
|
||||
opencli v2ex user Livid
|
||||
|
||||
# User profile
|
||||
opencli v2ex member Livid
|
||||
|
||||
# List all nodes
|
||||
opencli v2ex nodes --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli v2ex hot -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli v2ex hot -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Most commands (`hot`, `latest`, `topic`, `node`, `user`, `member`, `replies`, `nodes`) use the public V2EX API and **require no browser or login**.
|
||||
|
||||
For `daily`, `me`, and `notifications`:
|
||||
|
||||
- Chrome running and **logged into** v2ex.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Web
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: any URL
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli web read <url>` | Fetch any web page and export as Markdown |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Read a web page and save as Markdown
|
||||
opencli web read https://example.com/article
|
||||
|
||||
# Custom output directory
|
||||
opencli web read https://example.com/article --output ./my-articles
|
||||
|
||||
# Skip image download
|
||||
opencli web read https://example.com/article --download-images false
|
||||
|
||||
# JSON output
|
||||
opencli web read https://example.com/article -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -7,6 +7,7 @@
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli weibo hot` | |
|
||||
| `opencli weibo search` | Search Weibo posts by keyword |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -17,6 +18,9 @@ opencli weibo hot --limit 5
|
||||
# JSON output
|
||||
opencli weibo hot -f json
|
||||
|
||||
# Search
|
||||
opencli weibo search "OpenAI" --limit 5
|
||||
|
||||
# Verbose mode
|
||||
opencli weibo hot -v
|
||||
```
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# WeChat (微信公众号)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `mp.weixin.qq.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli weixin download` | 下载微信公众号文章为 Markdown 格式 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Export article to Markdown
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
|
||||
# Export with locally downloaded images
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --download-images
|
||||
|
||||
# Export without images
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --no-download-images
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Downloads to `<output>/<article-title>/`:
|
||||
- `<article-title>.md` — Markdown with frontmatter (title, author, publish time, source URL)
|
||||
- `images/` — Downloaded images (if `--download-images` is enabled, default: true)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** mp.weixin.qq.com (for articles behind login wall)
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -16,9 +16,9 @@
|
||||
opencli wikipedia search "quantum computing" --limit 10
|
||||
|
||||
# Get article summary
|
||||
opencli wikipedia summary --title "Artificial intelligence"
|
||||
opencli wikipedia summary "Artificial intelligence"
|
||||
|
||||
# Search in other languages
|
||||
# Use with other languages
|
||||
opencli wikipedia search "人工智能" --lang zh
|
||||
|
||||
# JSON output
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xiaohongshu search` | |
|
||||
| `opencli xiaohongshu search` | Search notes by keyword (returns title, author, likes, URL) |
|
||||
| `opencli xiaohongshu notifications` | |
|
||||
| `opencli xiaohongshu feed` | |
|
||||
| `opencli xiaohongshu user` | |
|
||||
@@ -20,14 +20,16 @@
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli xiaohongshu search --limit 5
|
||||
# Search for notes
|
||||
opencli xiaohongshu search 美食 --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli xiaohongshu search -f json
|
||||
opencli xiaohongshu search 旅行 -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli xiaohongshu search -v
|
||||
# Other commands
|
||||
opencli xiaohongshu feed
|
||||
opencli xiaohongshu notifications
|
||||
opencli xiaohongshu download <url>
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Yollomi
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `yollomi.com`
|
||||
|
||||
AI image/video generation and editing on [yollomi.com](https://yollomi.com). Uses the same `/api/ai/*` routes as the web app; authentication is your **logged-in Chrome session** (NextAuth cookies).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli yollomi generate` | Text-to-image / image-to-image |
|
||||
| `opencli yollomi video` | Text-to-video / image-to-video |
|
||||
| `opencli yollomi edit` | Qwen image edit (prompt + image) |
|
||||
| `opencli yollomi upload` | Upload a local file → public URL for other commands |
|
||||
| `opencli yollomi models` | List image / video / tool models and credit costs |
|
||||
| `opencli yollomi remove-bg` | Remove background (free) |
|
||||
| `opencli yollomi upscale` | Image upscaling |
|
||||
| `opencli yollomi face-swap` | Face swap between two images |
|
||||
| `opencli yollomi restore` | Photo restoration |
|
||||
| `opencli yollomi try-on` | Virtual try-on |
|
||||
| `opencli yollomi background` | AI background for product/object images |
|
||||
| `opencli yollomi object-remover` | Remove objects (image + mask URLs) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# List models
|
||||
opencli yollomi models --type image
|
||||
|
||||
# Text-to-image (default model: z-image-turbo)
|
||||
opencli yollomi generate "a red apple on a wooden table"
|
||||
|
||||
# Choose model and aspect ratio
|
||||
opencli yollomi generate "sunset" --model flux-schnell --ratio 16:9
|
||||
|
||||
# Image-to-image: upload first, then pass URL
|
||||
opencli yollomi upload ./photo.png
|
||||
opencli yollomi generate "oil painting style" --model flux-2-pro --image "https://..."
|
||||
|
||||
# Video
|
||||
opencli yollomi video "waves on a beach" --model kling-2-1
|
||||
|
||||
# Tools
|
||||
opencli yollomi remove-bg https://example.com/image.png
|
||||
opencli yollomi upscale https://example.com/image.png --scale 4
|
||||
opencli yollomi edit https://example.com/in.png "make it vintage"
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
| Option | Applies to | Description |
|
||||
|--------|------------|-------------|
|
||||
| `--model` | `generate`, `video` | Model id (see `yollomi models`) |
|
||||
| `--ratio` | `generate`, `video` | Aspect ratio, e.g. `1:1`, `16:9` |
|
||||
| `--image` | `generate`, `video` | Image URL for img2img / i2v |
|
||||
| `--output` | Most | Output directory (default `./yollomi-output`) |
|
||||
| `--no-download` | Several | Print URLs only, skip saving files |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** [yollomi.com](https://yollomi.com) (Google OAuth)
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed; daemon connects on first command
|
||||
|
||||
The CLI ensures the automation tab is on `yollomi.com` before calling APIs (same-origin `fetch` with session cookies).
|
||||
|
||||
## Notes
|
||||
|
||||
- **Credits**: Each model consumes account credits; insufficient credits returns HTTP 402.
|
||||
- **Upload**: Local paths for tools are not accepted directly — use `yollomi upload` to get a URL, or pass an existing HTTPS image URL.
|
||||
@@ -47,6 +47,3 @@ Quickly target and switch the active LLM engine. Example: `opencli antigravity m
|
||||
|
||||
### `opencli antigravity watch`
|
||||
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
|
||||
|
||||
### `opencli antigravity serve --port 8082`
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Doubao App (豆包桌面版)
|
||||
|
||||
Control the **Doubao AI Desktop App** via Chrome DevTools Protocol (CDP).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Launch Doubao Desktop with remote debugging enabled:
|
||||
```bash
|
||||
/Applications/Doubao.app/Contents/MacOS/Doubao --remote-debugging-port=9225
|
||||
```
|
||||
2. Set the CDP endpoint:
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9225"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli doubao-app status` | Check CDP connection status |
|
||||
| `opencli doubao-app new` | Start a new conversation |
|
||||
| `opencli doubao-app send "message"` | Send a message to the current chat |
|
||||
| `opencli doubao-app read` | Read the latest assistant reply |
|
||||
| `opencli doubao-app ask "message"` | Send a prompt and wait for the reply |
|
||||
| `opencli doubao-app screenshot` | Capture a screenshot of the app window |
|
||||
| `opencli doubao-app dump` | Export DOM and snapshot debug info |
|
||||
|
||||
## How It Works
|
||||
|
||||
Connects to the Doubao Electron app via CDP, injecting JavaScript into the renderer process to control the chat UI — sending messages, reading replies, and capturing screenshots.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires Doubao Desktop to be launched with `--remote-debugging-port`
|
||||
- macOS / Linux / Windows (Electron-based, platform independent)
|
||||
+22
-9
@@ -6,35 +6,46 @@ Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[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 |
|
||||
| **[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 |
|
||||
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
||||
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `me` `user` `download` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 🔐 Browser |
|
||||
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
|
||||
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
|
||||
| **[weibo](/adapters/browser/weibo)** | `hot` | 🔐 Browser |
|
||||
| **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser |
|
||||
| **[weibo](/adapters/browser/weibo)** | `hot` `search` | 🔐 Browser |
|
||||
| **[linkedin](/adapters/browser/linkedin)** | `search` `timeline` | 🔐 Browser |
|
||||
| **[coupang](/adapters/browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **[boss](/adapters/browser/boss)** | `search` `detail` | 🔐 Browser |
|
||||
| **[boss](/adapters/browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
|
||||
| **[ctrip](/adapters/browser/ctrip)** | `search` | 🔐 Browser |
|
||||
| **[reuters](/adapters/browser/reuters)** | `search` | 🔐 Browser |
|
||||
| **[smzdm](/adapters/browser/smzdm)** | `search` | 🔐 Browser |
|
||||
| **[jike](/adapters/browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
|
||||
| **[jimeng](/adapters/browser/jimeng)** | `generate` `history` | 🔐 Browser |
|
||||
| **[yollomi](/adapters/browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
|
||||
| **[linux-do](/adapters/browser/linux-do)** | `hot` `latest` `categories` `category` `search` `topic` | 🔐 Browser |
|
||||
| **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
|
||||
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
|
||||
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
|
||||
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
|
||||
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
|
||||
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
|
||||
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
|
||||
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
|
||||
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
|
||||
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
|
||||
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
|
||||
|
||||
## Public API Adapters
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[hackernews](/adapters/browser/hackernews)** | `top` | 🌐 Public |
|
||||
| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
|
||||
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
|
||||
| **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public |
|
||||
| **[dictionary](/adapters/browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
|
||||
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
@@ -43,7 +54,8 @@ Run `opencli list` for the live registry.
|
||||
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
|
||||
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
|
||||
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
|
||||
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` | 🌐 Public |
|
||||
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
|
||||
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
|
||||
|
||||
## Desktop Adapters
|
||||
|
||||
@@ -56,3 +68,4 @@ Run `opencli list` for the live registry.
|
||||
| **[ChatWise](/adapters/desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
|
||||
| **[Notion](/adapters/desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
|
||||
| **[Discord](/adapters/desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
|
||||
| **[Doubao App](/adapters/desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
|
||||
|
||||
@@ -10,6 +10,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -43,6 +44,9 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# Export with local images
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
|
||||
# Export WeChat article to Markdown
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
```
|
||||
|
||||
## Pipeline Step (YAML Adapters)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Comparison Guide
|
||||
|
||||
OpenCLI occupies a specific niche in the browser automation ecosystem. This guide honestly evaluates where opencli excels, where it's a viable option, and where other tools are a better fit.
|
||||
|
||||
## At a Glance
|
||||
|
||||
| Tool | Approach | Best for |
|
||||
|------|----------|----------|
|
||||
| **opencli** | Pre-built adapters (YAML/TS) | Deterministic site commands, broad platform coverage, desktop apps |
|
||||
| **Browser-Use** | LLM-driven browser control | General-purpose AI browser automation |
|
||||
| **Crawl4AI** | Async web crawler | Large-scale data crawling |
|
||||
| **Firecrawl** | Scraping API / self-hosted | Clean markdown extraction, managed or self-hosted infrastructure |
|
||||
| **agent-browser** | Browser primitive CLI | Token-efficient AI agent browsing |
|
||||
| **Stagehand** | AI browser framework | Developer-friendly browser automation |
|
||||
| **Skyvern** | Visual AI automation | Cross-site generalized workflows |
|
||||
|
||||
## Scenario Comparison
|
||||
|
||||
### 1. Scheduled Batch Data Extraction
|
||||
|
||||
> "I want to pull trending posts from Bilibili/Reddit/HackerNews every hour into my pipeline."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | One command, structured JSON output, zero runtime cost. Runs in cron/CI without tokens or API keys. |
|
||||
| Crawl4AI | Good | Strong for large-scale crawling, but requires writing extraction logic per site. |
|
||||
| Firecrawl | Viable | Managed service with clean output, but costs scale with volume. |
|
||||
| Browser-Use / Stagehand | Poor | LLM inference on every run is slow, expensive, and non-deterministic for repeated tasks. |
|
||||
|
||||
**Why opencli wins here:** A command like `opencli bilibili hot -f json` returns the same structured schema every time, costs nothing to run, and finishes in seconds. For recurring data extraction from known sites, pre-built adapters beat LLM-driven approaches on cost, speed, and reliability.
|
||||
|
||||
### 2. AI Agent Site Operations
|
||||
|
||||
> "My AI agent needs to search Twitter, read Reddit threads, or post to Xiaohongshu."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | Structured JSON output, fast deterministic execution, hundreds of commands ready to use. |
|
||||
| agent-browser | Good | Token-efficient browser primitives, but requires LLM reasoning for every step. |
|
||||
| Browser-Use | Viable | General-purpose, but each operation costs tokens and takes 10-60s. |
|
||||
| Stagehand | Viable | Good DX, but same LLM-per-action cost model. |
|
||||
|
||||
**Why opencli wins here:** When your agent needs `twitter search "AI news" -f json`, a deterministic command that returns in seconds is strictly better than an LLM clicking through a webpage. The agent saves tokens for reasoning, not navigation.
|
||||
|
||||
### 3. Authenticated Operations (Login-Required Sites)
|
||||
|
||||
> "I need to access my bookmarks, post content, or interact with sites that require login."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | Reuses your Chrome login session via Browser Bridge. No credentials stored or transmitted. |
|
||||
| Browser-Use | Viable | Can use browser profiles, but credential management is manual. |
|
||||
| Firecrawl | Poor | Cloud service cannot access your authenticated sessions. |
|
||||
| Crawl4AI | Poor | Requires manual cookie/session injection. |
|
||||
|
||||
**Why opencli wins here:** The Browser Bridge extension reuses your existing Chrome login state in real-time. You log in once in Chrome, and opencli commands work immediately. No OAuth setup, no API keys, no credential files.
|
||||
|
||||
### 4. General Web Browsing & Exploration
|
||||
|
||||
> "I need to explore an unknown website, fill forms, or navigate complex multi-step flows."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| Browser-Use | Best | LLM-driven, handles arbitrary websites and flows. |
|
||||
| Stagehand | Best | Clean API for `act()`, `extract()`, `observe()` on any page. |
|
||||
| agent-browser | Good | Token-efficient primitives for AI agents. |
|
||||
| Skyvern | Good | Visual AI that generalizes across sites. |
|
||||
| **opencli** | Poor | Only works with sites that have pre-built adapters. Cannot handle arbitrary websites. |
|
||||
|
||||
**opencli is not the right tool here.** If you need to explore unknown websites or handle one-off tasks on sites without adapters, use an LLM-driven browser tool. opencli trades generality for determinism and cost.
|
||||
|
||||
### 5. Desktop App Control
|
||||
|
||||
> "I want to script Cursor, ChatGPT, Notion, or other Electron apps from the terminal."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | 8 desktop adapters via CDP + AppleScript. The only CLI tool with this capability. |
|
||||
| All others | N/A | Browser automation tools cannot control desktop applications. |
|
||||
|
||||
**This is unique to opencli.** No other tool in this comparison can send a prompt to ChatGPT desktop, extract code from Cursor, or write to Notion pages via CLI.
|
||||
|
||||
## Key Trade-offs
|
||||
|
||||
### opencli's Strengths
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times for free.
|
||||
- **Deterministic output** — Same command always returns the same schema. Pipeable, scriptable, CI-friendly.
|
||||
- **Speed** — Adapter commands return in seconds, not minutes.
|
||||
- **Broad platform coverage** — 50+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more.
|
||||
- **Easy to extend** — Drop a `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
|
||||
|
||||
### opencli's Limitations
|
||||
|
||||
- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a YAML or TypeScript adapter.
|
||||
- **Adapter maintenance** — When a website updates its DOM or API, the corresponding adapter may need updating. The community maintains these, but breakage is possible.
|
||||
- **Not general-purpose** — Cannot handle arbitrary websites. For unknown sites, pair opencli with a general browser tool as a fallback.
|
||||
|
||||
## Complementary Usage
|
||||
|
||||
opencli works best alongside general-purpose browser tools, not as a replacement:
|
||||
|
||||
```
|
||||
Has adapter? ──yes──▶ opencli (fast, free, deterministic)
|
||||
│
|
||||
no
|
||||
│
|
||||
▼
|
||||
One-off task? ──yes──▶ Browser-Use / Stagehand (LLM-driven)
|
||||
│
|
||||
no
|
||||
│
|
||||
▼
|
||||
Recurring? ──yes──▶ Write an opencli adapter, then use opencli
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](./developer/architecture.md)
|
||||
- [Writing a YAML Adapter](./developer/yaml-adapter.md)
|
||||
- [Writing a TypeScript Adapter](./developer/ts-adapter.md)
|
||||
- [Testing Guide](./developer/testing.md)
|
||||
- [AI Workflow](./developer/ai-workflow.md)
|
||||
- [Contributing Guide](./developer/contributing.md)
|
||||
@@ -17,7 +17,8 @@ npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
npm test
|
||||
npm run test:adapter
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
@@ -129,7 +130,8 @@ chore: bump vitest to v4
|
||||
3. Run the checks:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npx vitest run src/ # Unit tests
|
||||
npm test # Core unit tests
|
||||
npm run test:adapter # Focused adapter tests (if adapter logic changed)
|
||||
opencli validate # YAML validation (if applicable)
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
|
||||
+93
-69
@@ -18,57 +18,74 @@
|
||||
|
||||
测试分为三层,全部使用 **vitest** 运行:
|
||||
|
||||
```
|
||||
```text
|
||||
tests/
|
||||
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
||||
│ ├── helpers.ts # runCli() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
|
||||
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令
|
||||
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试)
|
||||
│ ├── management.test.ts # 管理命令(list, validate, verify, help)
|
||||
│ └── output-formats.test.ts # 输出格式(json/yaml/csv/md)
|
||||
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
|
||||
│ └── api-health.test.ts # 外部 API 可用性检测
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure)
|
||||
│ ├── management.test.ts # 管理命令(list / validate / verify / help)
|
||||
│ └── output-formats.test.ts # 输出格式校验
|
||||
├── smoke/
|
||||
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
|
||||
src/
|
||||
├── *.test.ts # 单元测试(已有 8 个)
|
||||
├── **/*.test.ts # 核心单元测试(默认 `unit` project)
|
||||
└── clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts # 聚焦 adapter tests
|
||||
```
|
||||
|
||||
| 层 | 位置 | 运行方式 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
|
||||
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|
||||
|---|---|---:|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts`(排除 `src/clis/**`) | - | `npm test` | 内部模块、pipeline、runtime |
|
||||
| Adapter 测试 | `src/clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts` | - | `npm run test:adapter` | 保留 4 个重点站点的 adapter 覆盖 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
|
||||
|
||||
---
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### 单元测试(8 个文件)
|
||||
### 单元测试与 Adapter 测试
|
||||
|
||||
| 文件 | 覆盖内容 |
|
||||
| 领域 | 文件 |
|
||||
|---|---|
|
||||
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
|
||||
| `engine.test.ts` | 命令发现与执行 |
|
||||
| `registry.test.ts` | 命令注册与策略分配 |
|
||||
| `output.test.ts` | 输出格式渲染 |
|
||||
| `doctor.test.ts` | Token 诊断 |
|
||||
| `coupang.test.ts` | 数据归一化 |
|
||||
| `pipeline/template.test.ts` | 模板表达式求值 |
|
||||
| `pipeline/transform.test.ts` | 数据变换步骤 |
|
||||
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
|
||||
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
|
||||
| 聚焦 adapter 逻辑 | `src/clis/zhihu/download.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/reddit/read.test.ts`, `src/clis/bilibili/dynamic.test.ts` |
|
||||
|
||||
### E2E 测试(~52 个用例)
|
||||
这些测试覆盖的重点包括:
|
||||
|
||||
| 文件 | 覆盖站点/功能 | 测试数 |
|
||||
|---|---|---|
|
||||
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
|
||||
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
|
||||
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
|
||||
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
|
||||
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
|
||||
- Browser Bridge、DOM snapshot、interceptor、capability routing
|
||||
- manifest 生成、命令发现、插件安装与注册表
|
||||
- 输出格式渲染与 snapshot formatting
|
||||
- pipeline 模板求值、执行器与变换步骤
|
||||
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
|
||||
|
||||
### 烟雾测试
|
||||
### E2E 测试(5 个文件)
|
||||
|
||||
公开 API 可用性(hackernews, v2ex×2, v2ex/topic)+ 全站点注册完整性检查。
|
||||
| 文件 | 当前覆盖范围 |
|
||||
|---|---|
|
||||
| `tests/e2e/public-commands.test.ts` | `bloomberg`、`apple-podcasts`、`hackernews`、`v2ex`、`xiaoyuzhou`、`google suggest` 等公开命令 |
|
||||
| `tests/e2e/browser-public.test.ts` | `bbc`、`bloomberg`、`bilibili`、`weibo`、`zhihu`、`reddit`、`twitter`、`xueqiu`、`reuters`、`youtube`、`smzdm`、`boss`、`ctrip`、`coupang`、`xiaohongshu`、`google`、`yahoo-finance`、`v2ex daily` |
|
||||
| `tests/e2e/browser-auth.test.ts` | `bilibili`、`twitter`、`v2ex`、`xueqiu`、`linux-do`、`xiaohongshu` 的需登录命令 graceful failure |
|
||||
| `tests/e2e/management.test.ts` | `list`、`validate`、`verify`、`--version`、`--help`、unknown command |
|
||||
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
|
||||
|
||||
### 烟雾测试(1 个文件)
|
||||
|
||||
| 文件 | 当前覆盖范围 |
|
||||
|---|---|
|
||||
| `tests/smoke/api-health.test.ts` | `hackernews`、`v2ex` 公开 API 可用性,`validate` 全量 adapter 校验,以及命令注册表基础完整性 |
|
||||
|
||||
### 快速核对命令
|
||||
|
||||
需要刷新测试清单时,直接以仓库文件为准:
|
||||
|
||||
```bash
|
||||
find src -name '*.test.ts' | sort
|
||||
find tests/e2e -name '*.test.ts' | sort
|
||||
find tests/smoke -name '*.test.ts' | sort
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -78,27 +95,31 @@ src/
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run build # 编译(E2E 测试需要 dist/main.js)
|
||||
npm run build # 编译(E2E / smoke 测试需要 dist/main.js)
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 全部单元测试
|
||||
npx vitest run src/
|
||||
# 默认核心单元测试(不含大多数 adapter tests)
|
||||
npm test
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API)
|
||||
# 聚焦 adapter tests(只保留 4 个重点站点)
|
||||
npm run test:adapter
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
|
||||
npx vitest run tests/e2e/
|
||||
|
||||
# 全部 smoke 测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run src/clis/apple-podcasts/commands.test.ts
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试(单元 + E2E)
|
||||
# 全部测试
|
||||
npx vitest run
|
||||
|
||||
# 烟雾测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# watch 模式(开发时推荐)
|
||||
npx vitest src/
|
||||
```
|
||||
@@ -106,9 +127,10 @@ npx vitest src/
|
||||
### 浏览器命令本地测试须知
|
||||
|
||||
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
|
||||
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
|
||||
|
||||
---
|
||||
|
||||
@@ -116,8 +138,8 @@ npx vitest src/
|
||||
|
||||
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
||||
|
||||
1. **无需额外操作**:`validate` 测试会自动覆盖 YAML 结构验证
|
||||
2. 根据 adapter 类型,在对应文件加一个 `it()` block:
|
||||
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验
|
||||
2. 根据 adapter 类型,在对应测试文件补一个 `it()` block
|
||||
|
||||
```typescript
|
||||
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
||||
@@ -148,15 +170,15 @@ it('producthunt me fails gracefully without login', async () => {
|
||||
|
||||
### 新增管理命令(如 `opencli export`)
|
||||
|
||||
在 `tests/e2e/management.test.ts` 添加测试。
|
||||
在 `tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`。
|
||||
|
||||
### 新增内部模块
|
||||
|
||||
在 `src/` 下对应位置创建 `*.test.ts`。
|
||||
在对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护。
|
||||
|
||||
### 决策流程图
|
||||
|
||||
```
|
||||
```text
|
||||
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
||||
↓ 否
|
||||
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
||||
@@ -170,33 +192,35 @@ it('producthunt me fails gracefully without login', async () => {
|
||||
|
||||
## CI/CD 流水线
|
||||
|
||||
### ci.yml(主流水线)
|
||||
### `ci.yml`
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **build** | push/PR to main,dev | typecheck + build |
|
||||
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
|
||||
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
|
||||
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
|
||||
| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行核心 `unit` tests,按 `2` shard 并行 |
|
||||
| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 运行聚焦的 `zhihu/twitter/reddit/bilibili` adapter tests |
|
||||
| `smoke-test` | `schedule` 或 `workflow_dispatch` | 安装真实 Chrome,`xvfb-run` 执行 `tests/smoke/` |
|
||||
|
||||
### e2e-headed.yml(E2E 测试)
|
||||
### `e2e-headed.yml`
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
|
||||
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
|
||||
|
||||
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
|
||||
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径。
|
||||
|
||||
### Sharding
|
||||
|
||||
单元测试使用 vitest 内置 shard:
|
||||
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
|
||||
- run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
:::
|
||||
|
||||
@@ -208,8 +232,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
|
||||
|
||||
| 条件 | 模式 | 使用场景 |
|
||||
|---|---|---|
|
||||
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
|
||||
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
@@ -224,14 +248,14 @@ env:
|
||||
|
||||
## 站点兼容性
|
||||
|
||||
在 GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
|
||||
GitHub Actions 的美国 runner 上,部分站点会因为地域限制、登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红。
|
||||
|
||||
| 站点 | CI 状态 | 限制原因 |
|
||||
| 站点 | CI 表现 | 常见原因 |
|
||||
|---|---|---|
|
||||
| hackernews, bbc, v2ex | ✅ 返回数据 | 无限制 |
|
||||
| yahoo-finance | ✅ 返回数据 | 无限制 |
|
||||
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
|
||||
| reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
|
||||
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
|
||||
| `hackernews`、`bbc`、`v2ex`、`bloomberg` | 通常返回数据 | 公开接口或公开页面 |
|
||||
| `yahoo-finance`、`google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
|
||||
| `bilibili`、`zhihu`、`weibo`、`xiaohongshu`、`xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
|
||||
| `reddit`、`twitter`、`youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
|
||||
| `smzdm`、`boss`、`ctrip`、`coupang`、`linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
|
||||
|
||||
> 使用 self-hosted runner(国内服务器)可解决地域限制问题。
|
||||
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
|
||||
|
||||
@@ -8,9 +8,9 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
|
||||
|
||||
### Method 1: Download Pre-built Release (Recommended)
|
||||
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
|
||||
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
|
||||
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
|
||||
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
|
||||
3. Click **Load unpacked** and select the unzipped folder.
|
||||
|
||||
### Method 2: Load Unpacked Source (For Developers)
|
||||
|
||||
@@ -23,7 +23,6 @@ That's it! The daemon auto-starts when you run any browser command. No tokens, n
|
||||
|
||||
```bash
|
||||
opencli doctor # Check extension + daemon connectivity
|
||||
opencli doctor --live # Also test live browser commands
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -14,7 +14,7 @@ OpenCLI turns **any website** or **Electron app** into a command-line interface
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
|
||||
- **Self-healing setup** — `opencli doctor` auto-starts the daemon and diagnoses extension + live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version
|
||||
|------|------|-------------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
|
||||
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator (zhihu, weibo, bilibili, v2ex, stackoverflow, reddit, linux-do) |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles, categories, and article feed |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -53,4 +53,4 @@ npx tsc --noEmit
|
||||
## Getting Help
|
||||
|
||||
- [GitHub Issues](https://github.com/jackwener/opencli/issues) — Bug reports and feature requests
|
||||
- Run `opencli doctor --live` for comprehensive diagnostics
|
||||
- Run `opencli doctor` for comprehensive diagnostics
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ features:
|
||||
details: Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections for maximum flexibility.
|
||||
- icon: 🔧
|
||||
title: Self-Healing Setup
|
||||
details: "opencli setup verifies Browser Bridge connectivity. opencli doctor diagnoses daemon, extension, and live browser."
|
||||
details: "opencli doctor auto-starts the daemon and diagnoses extension + live browser connectivity."
|
||||
- icon: 📦
|
||||
title: Dynamic Loader
|
||||
details: Simply drop .ts or .yaml adapters into the clis/ folder for auto-registration. Zero boilerplate.
|
||||
|
||||
@@ -8,9 +8,9 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
|
||||
|
||||
### 方法 1:下载预构建版本(推荐)
|
||||
|
||||
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip` 或 `opencli-extension.crx`。
|
||||
2. 打开 `chrome://extensions`,启用**开发者模式**。
|
||||
3. 拖放 `.crx` 文件或解压后的文件夹到扩展页面。
|
||||
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`。
|
||||
2. 解压后打开 `chrome://extensions`,启用**开发者模式**。
|
||||
3. 点击**加载已解压的扩展程序**,选择解压后的文件夹。
|
||||
|
||||
### 方法 2:加载源码(开发者)
|
||||
|
||||
@@ -21,5 +21,4 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
|
||||
|
||||
```bash
|
||||
opencli doctor # 检查扩展 + 守护进程连接
|
||||
opencli doctor --live # 同时测试实时浏览器命令
|
||||
```
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# 插件
|
||||
|
||||
OpenCLI 支持社区贡献的 plugins。你可以从 GitHub 安装第三方 adapters,它们会和内置 commands 一起在启动时自动发现。
|
||||
|
||||
## 安装插件
|
||||
|
||||
```bash
|
||||
# 安装插件
|
||||
opencli plugin install github:ByteYue/opencli-plugin-github-trending
|
||||
|
||||
# 列出已安装插件
|
||||
opencli plugin list
|
||||
|
||||
# 使用插件(本质上就是普通 command)
|
||||
opencli github-trending today
|
||||
|
||||
# 卸载插件
|
||||
opencli plugin uninstall github-trending
|
||||
```
|
||||
|
||||
## 插件目录结构
|
||||
|
||||
Plugins 存放在 `~/.opencli/plugins/<name>/`。每个子目录都会在启动时扫描 `.yaml`、`.ts`、`.js` 命令文件,格式与内置 adapters 相同。
|
||||
|
||||
## 安装来源
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/repo
|
||||
opencli plugin install https://github.com/user/repo
|
||||
```
|
||||
|
||||
如果仓库名带 `opencli-plugin-` 前缀,本地目录会自动去掉这个前缀。例如 `opencli-plugin-hot-digest` 会变成 `hot-digest`。
|
||||
|
||||
## YAML plugin 示例
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
hot.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
site: my-plugin
|
||||
name: hot
|
||||
description: Example plugin command
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
pipeline:
|
||||
- evaluate: |
|
||||
() => [{ title: 'hello', url: 'https://example.com' }]
|
||||
|
||||
columns: [title, url]
|
||||
```
|
||||
|
||||
## TypeScript plugin 示例
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
index.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "opencli-plugin-my-plugin",
|
||||
"type": "module"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'my-plugin',
|
||||
name: 'hot',
|
||||
description: 'Example TS plugin command',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
columns: ['title', 'url'],
|
||||
func: async () => [{ title: 'hello', url: 'https://example.com' }],
|
||||
});
|
||||
```
|
||||
|
||||
运行 `opencli plugin install` 时,TS plugins 会自动完成基础设置:
|
||||
|
||||
1. 安装 plugin 自身依赖
|
||||
2. 补齐 TypeScript 运行环境
|
||||
3. 将宿主 `@jackwener/opencli` 链接到 plugin 的 `node_modules/`,保证 `@jackwener/opencli/registry` 指向当前宿主版本
|
||||
|
||||
## 示例 plugins
|
||||
|
||||
- `opencli-plugin-github-trending`:GitHub Trending 仓库
|
||||
- `opencli-plugin-hot-digest`:多平台热点聚合(zhihu、weibo、bilibili、v2ex、stackoverflow、reddit、linux-do)
|
||||
- `opencli-plugin-juejin`:稀土掘金热榜、分类和文章流
|
||||
|
||||
## 排查问题
|
||||
|
||||
### TS plugin import 报错
|
||||
|
||||
如果看到 `Cannot find module '@jackwener/opencli/registry'`,通常是宿主 symlink 失效。重新安装 plugin 即可:
|
||||
|
||||
```bash
|
||||
opencli plugin uninstall my-plugin
|
||||
opencli plugin install github:user/opencli-plugin-my-plugin
|
||||
```
|
||||
|
||||
安装或卸载 plugin 后,建议重新打开一个终端,确保启动时重新发现命令。
|
||||
Vendored
+521
-633
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenCLI",
|
||||
"version": "0.2.0",
|
||||
"version": "1.2.6",
|
||||
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"tabs",
|
||||
"cookies",
|
||||
"storage",
|
||||
"activeTab",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "dist/background.js",
|
||||
"type": "module"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "0.2.0",
|
||||
"version": "1.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+95
-302
@@ -5,7 +5,7 @@
|
||||
* dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
|
||||
*/
|
||||
|
||||
import type { Command, Result, SyncEvent } from './protocol';
|
||||
import type { Command, Result } from './protocol';
|
||||
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
||||
import * as executor from './cdp';
|
||||
|
||||
@@ -88,7 +88,7 @@ function scheduleReconnect(): void {
|
||||
// ─── Automation window isolation ─────────────────────────────────────
|
||||
// All opencli operations happen in a dedicated Chrome window so the
|
||||
// user's active browsing session is never touched.
|
||||
// The window auto-closes after 30s of idle (no commands).
|
||||
// The window auto-closes after 120s of idle (no commands).
|
||||
|
||||
type AutomationSession = {
|
||||
windowId: number;
|
||||
@@ -97,7 +97,7 @@ type AutomationSession = {
|
||||
};
|
||||
|
||||
const automationSessions = new Map<string, AutomationSession>();
|
||||
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
|
||||
const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines
|
||||
|
||||
function getWorkspaceKey(workspace?: string): string {
|
||||
return workspace?.trim() || 'default';
|
||||
@@ -135,9 +135,10 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new window with about:blank (not chrome://newtab which blocks scripting)
|
||||
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
|
||||
// Using about:blank would be hijacked by extensions like "New Tab Override".
|
||||
const win = await chrome.windows.create({
|
||||
url: 'about:blank',
|
||||
url: 'data:text/html,<html></html>',
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
@@ -151,6 +152,8 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
// Brief delay to let Chrome load the initial data: URI tab
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return session.windowId;
|
||||
}
|
||||
|
||||
@@ -212,14 +215,6 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
||||
return await handleCloseWindow(cmd, workspace);
|
||||
case 'sessions':
|
||||
return await handleSessions(cmd);
|
||||
case 'export-state':
|
||||
return await handleExportState(cmd, workspace);
|
||||
case 'import-state':
|
||||
return await handleImportState(cmd, workspace);
|
||||
case 'watch-state':
|
||||
return handleWatchState(cmd);
|
||||
case 'unwatch-state':
|
||||
return handleUnwatchState(cmd);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
@@ -236,7 +231,7 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
||||
|
||||
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
||||
}
|
||||
|
||||
@@ -246,28 +241,46 @@ function isDebuggableUrl(url?: string): boolean {
|
||||
* Otherwise, find or create a tab in the dedicated automation window.
|
||||
*/
|
||||
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
|
||||
if (tabId !== undefined) return tabId;
|
||||
// Even when an explicit tabId is provided, validate it is still debuggable.
|
||||
// This prevents issues when extensions hijack the tab URL to chrome-extension://
|
||||
// or when the tab has been closed by the user.
|
||||
if (tabId !== undefined) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (isDebuggableUrl(tab.url)) return tabId;
|
||||
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
||||
} catch {
|
||||
// Tab was closed — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get (or create) the automation window
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
|
||||
// Prefer an existing debuggable tab (about:blank, http://, https://, etc.)
|
||||
// Prefer an existing debuggable tab
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
|
||||
if (debuggableTab?.id) return debuggableTab.id;
|
||||
|
||||
// No debuggable tab found — this typically happens when a "New Tab Override"
|
||||
// extension replaces about:blank with a chrome-extension:// page.
|
||||
// Reuse the first existing tab by navigating it to about:blank (avoids
|
||||
// accumulating orphan tabs if chrome.tabs.create is also intercepted).
|
||||
// No debuggable tab — another extension may have hijacked the tab URL.
|
||||
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
|
||||
const reuseTab = tabs.find(t => t.id);
|
||||
if (reuseTab?.id) {
|
||||
await chrome.tabs.update(reuseTab.id, { url: 'about:blank' });
|
||||
return reuseTab.id;
|
||||
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
try {
|
||||
const updated = await chrome.tabs.get(reuseTab.id);
|
||||
if (isDebuggableUrl(updated.url)) return reuseTab.id;
|
||||
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
||||
} catch {
|
||||
// Tab was closed during navigation
|
||||
}
|
||||
}
|
||||
|
||||
// Window has no tabs at all — create one
|
||||
const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
|
||||
// Fallback: create a new tab
|
||||
const newTab = await chrome.tabs.create({ windowId, url: 'data:text/html,<html></html>', active: true });
|
||||
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
||||
return newTab.id;
|
||||
}
|
||||
@@ -302,31 +315,70 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
||||
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.update(tabId, { url: cmd.url });
|
||||
|
||||
// Wait for page to finish loading, checking current status first to avoid race
|
||||
// Capture the current URL before navigation to detect actual URL change
|
||||
const beforeTab = await chrome.tabs.get(tabId);
|
||||
const beforeUrl = beforeTab.url ?? '';
|
||||
const targetUrl = cmd.url;
|
||||
|
||||
// Detach any existing debugger before top-level navigation.
|
||||
// Some sites (observed on creator.xiaohongshu.com flows) can invalidate the
|
||||
// current inspected target during navigation, which leaves a stale CDP attach
|
||||
// state and causes the next Runtime.evaluate to fail with
|
||||
// "Inspected target navigated or closed". Resetting here forces a clean
|
||||
// re-attach after navigation.
|
||||
await executor.detach(tabId);
|
||||
|
||||
await chrome.tabs.update(tabId, { url: targetUrl });
|
||||
|
||||
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
|
||||
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
|
||||
let timedOut = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
// Check if already complete (e.g. cached pages)
|
||||
chrome.tabs.get(tabId).then(tab => {
|
||||
if (tab.status === 'complete') { resolve(); return; }
|
||||
let urlChanged = false;
|
||||
|
||||
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (id === tabId && info.status === 'complete') {
|
||||
const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
|
||||
if (id !== tabId) return;
|
||||
|
||||
// Track URL change (new URL differs from the one before navigation)
|
||||
if (info.url && info.url !== beforeUrl) {
|
||||
urlChanged = true;
|
||||
}
|
||||
|
||||
// Only resolve when both URL has changed AND status is complete
|
||||
if (urlChanged && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
|
||||
// Also check if the tab already navigated (e.g. instant cache hit)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const currentTab = await chrome.tabs.get(tabId);
|
||||
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}, 15000);
|
||||
});
|
||||
} catch { /* tab gone */ }
|
||||
}, 100);
|
||||
|
||||
// Timeout fallback with warning
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
timedOut = true;
|
||||
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
||||
resolve();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
data: { title: tab.title, url: tab.url, tabId, timedOut },
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
@@ -345,7 +397,7 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
}
|
||||
case 'new': {
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true });
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'data:text/html,<html></html>', active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case 'close': {
|
||||
@@ -354,12 +406,12 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
executor.detach(target.id);
|
||||
await executor.detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
executor.detach(tabId);
|
||||
await executor.detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
}
|
||||
case 'select': {
|
||||
@@ -436,265 +488,6 @@ async function handleSessions(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
// ─── State export/import handlers ──────────────────────────────────────────
|
||||
|
||||
/** JS code to read all key-value pairs from localStorage or sessionStorage */
|
||||
const readStorageJs = (type: 'localStorage' | 'sessionStorage') => `
|
||||
(() => {
|
||||
try {
|
||||
const s = window.${type};
|
||||
const result = {};
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const key = s.key(i);
|
||||
if (key !== null) result[key] = s.getItem(key);
|
||||
}
|
||||
return result;
|
||||
} catch (e) { return {}; }
|
||||
})()
|
||||
`;
|
||||
|
||||
/** JS code to enumerate all IndexedDB databases and read all records */
|
||||
const READ_INDEXEDDB_JS = `
|
||||
(async () => {
|
||||
try {
|
||||
if (!window.indexedDB || !window.indexedDB.databases) return [];
|
||||
const dbs = await window.indexedDB.databases();
|
||||
const results = [];
|
||||
for (const dbInfo of dbs) {
|
||||
if (!dbInfo.name) continue;
|
||||
try {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onupgradeneeded = () => { req.transaction.abort(); reject(new Error('upgrade')); };
|
||||
});
|
||||
const storeNames = Array.from(db.objectStoreNames);
|
||||
const objectStores = [];
|
||||
for (const storeName of storeNames) {
|
||||
try {
|
||||
const tx = db.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const records = await new Promise((resolve, reject) => {
|
||||
const allReq = store.getAll();
|
||||
const keysReq = store.getAllKeys();
|
||||
allReq.onsuccess = () => {
|
||||
keysReq.onsuccess = () => {
|
||||
const r = [];
|
||||
for (let i = 0; i < allReq.result.length; i++) {
|
||||
r.push({ key: keysReq.result[i], value: allReq.result[i] });
|
||||
}
|
||||
resolve(r);
|
||||
};
|
||||
keysReq.onerror = () => resolve([]);
|
||||
};
|
||||
allReq.onerror = () => reject(allReq.error);
|
||||
});
|
||||
objectStores.push({
|
||||
name: storeName,
|
||||
keyPath: store.keyPath,
|
||||
autoIncrement: store.autoIncrement,
|
||||
records
|
||||
});
|
||||
} catch { /* skip unreadable stores */ }
|
||||
}
|
||||
db.close();
|
||||
results.push({ name: dbInfo.name, version: dbInfo.version || 1, objectStores });
|
||||
} catch { /* skip unreadable databases */ }
|
||||
}
|
||||
return results;
|
||||
} catch { return []; }
|
||||
})()
|
||||
`;
|
||||
|
||||
async function handleExportState(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
|
||||
try {
|
||||
// 1. Get current tab info
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const url = tab.url || '';
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname; } catch {}
|
||||
|
||||
// 2. Read cookies
|
||||
const cookieQuery: chrome.cookies.GetAllDetails = {};
|
||||
if (cmd.domain) cookieQuery.domain = cmd.domain;
|
||||
else if (url) cookieQuery.url = url;
|
||||
const rawCookies = await chrome.cookies.getAll(cookieQuery);
|
||||
const cookies = rawCookies.map(c => ({
|
||||
name: c.name, value: c.value, domain: c.domain, path: c.path,
|
||||
secure: c.secure, httpOnly: c.httpOnly, expirationDate: c.expirationDate,
|
||||
}));
|
||||
|
||||
// 3. Read localStorage
|
||||
const localStorage = await executor.evaluate(tabId, readStorageJs('localStorage')) as Record<string, string> ?? {};
|
||||
|
||||
// 4. Read sessionStorage
|
||||
const sessionStorage = await executor.evaluate(tabId, readStorageJs('sessionStorage')) as Record<string, string> ?? {};
|
||||
|
||||
// 5. Read IndexedDB
|
||||
const indexedDB = await executor.evaluate(tabId, READ_INDEXEDDB_JS) ?? [];
|
||||
|
||||
return {
|
||||
id: cmd.id, ok: true,
|
||||
data: { version: 1, url, domain: cmd.domain || domain, timestamp: Date.now(),
|
||||
cookies, localStorage, sessionStorage, indexedDB },
|
||||
};
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportState(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.state) return { id: cmd.id, ok: false, error: 'Missing state data' };
|
||||
const { cookies, localStorage, sessionStorage, indexedDB, url } = cmd.state;
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. Navigate to target URL (required for same-origin storage access)
|
||||
if (url) {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.update(tabId, { url });
|
||||
await new Promise<void>((resolve) => {
|
||||
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (id === tabId && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Import cookies
|
||||
if (cookies?.length) {
|
||||
for (const c of cookies) {
|
||||
try {
|
||||
await chrome.cookies.set({
|
||||
url: c.url || `http${c.secure ? 's' : ''}://${c.domain.replace(/^\\./, '')}${c.path || '/'}`,
|
||||
name: c.name, value: c.value, domain: c.domain, path: c.path || '/',
|
||||
secure: c.secure, httpOnly: c.httpOnly, expirationDate: c.expirationDate,
|
||||
});
|
||||
} catch (e) { errors.push(`Cookie ${c.name}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Import localStorage / sessionStorage via CDP
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
for (const [type, data] of [['localStorage', localStorage], ['sessionStorage', sessionStorage]] as const) {
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
try {
|
||||
const entries = JSON.stringify(data);
|
||||
await executor.evaluate(tabId, `(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.${type}.setItem(k,v); return Object.keys(e).length; })()`);
|
||||
} catch (e) { errors.push(`${type}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Import IndexedDB
|
||||
if (indexedDB?.length) {
|
||||
const idbData = JSON.stringify(indexedDB);
|
||||
try {
|
||||
await executor.evaluate(tabId, `
|
||||
(async () => {
|
||||
const dbs = ${idbData};
|
||||
for (const dbInfo of dbs) {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
|
||||
req.onupgradeneeded = () => {
|
||||
const d = req.result;
|
||||
for (const s of dbInfo.objectStores) {
|
||||
if (!d.objectStoreNames.contains(s.name)) {
|
||||
d.createObjectStore(s.name, { keyPath: s.keyPath || undefined, autoIncrement: s.autoIncrement });
|
||||
}
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
for (const s of dbInfo.objectStores) {
|
||||
if (!db.objectStoreNames.contains(s.name)) continue;
|
||||
const tx = db.transaction(s.name, 'readwrite');
|
||||
const os = tx.objectStore(s.name);
|
||||
for (const r of s.records) os.put(r.value, s.keyPath ? undefined : r.key);
|
||||
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
return { ok: true };
|
||||
})()
|
||||
`);
|
||||
} catch (e) { errors.push(`IndexedDB: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
}
|
||||
|
||||
return { id: cmd.id, ok: true, data: { imported: true, ...(errors.length ? { errors } : {}) } };
|
||||
}
|
||||
|
||||
// ─── Real-time state sync ──────────────────────────────────────────────
|
||||
|
||||
/** Set of domains being watched for state changes. Empty = all domains. */
|
||||
let watchedDomains: Set<string> | null = null; // null = not watching
|
||||
|
||||
function handleWatchState(cmd: Command): Result {
|
||||
if (cmd.domains?.length) {
|
||||
watchedDomains = new Set(cmd.domains);
|
||||
} else {
|
||||
watchedDomains = new Set(); // empty = watch all
|
||||
}
|
||||
console.log(`[opencli] Watching state changes for: ${watchedDomains.size > 0 ? [...watchedDomains].join(', ') : 'all domains'}`);
|
||||
return { id: cmd.id, ok: true, data: { watching: true, domains: cmd.domains ?? [] } };
|
||||
}
|
||||
|
||||
function handleUnwatchState(cmd: Command): Result {
|
||||
watchedDomains = null;
|
||||
console.log('[opencli] Stopped watching state changes');
|
||||
return { id: cmd.id, ok: true, data: { watching: false } };
|
||||
}
|
||||
|
||||
function isDomainWatched(domain: string): boolean {
|
||||
if (watchedDomains === null) return false;
|
||||
if (watchedDomains.size === 0) return true; // watch all
|
||||
// Check exact and parent domain match (e.g. .github.com matches github.com)
|
||||
const clean = domain.replace(/^\./, '');
|
||||
for (const watched of watchedDomains) {
|
||||
if (clean === watched || clean.endsWith('.' + watched)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sendSyncEvent(event: SyncEvent): void {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(event));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Cookie change listener — fires whenever any cookie is set, deleted, or modified
|
||||
chrome.cookies.onChanged.addListener((changeInfo) => {
|
||||
const { cookie, removed, cause } = changeInfo;
|
||||
if (!isDomainWatched(cookie.domain)) return;
|
||||
|
||||
const event: SyncEvent = {
|
||||
type: 'state-change',
|
||||
changeType: 'cookie',
|
||||
domain: cookie.domain.replace(/^\./, ''),
|
||||
cookie: {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
expirationDate: cookie.expirationDate,
|
||||
removed,
|
||||
cause,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
sendSyncEvent(event);
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
handleTabs,
|
||||
handleSessions,
|
||||
|
||||
+46
-5
@@ -8,22 +8,57 @@
|
||||
|
||||
const attached = new Set<number>();
|
||||
|
||||
/** Check if a URL can be attached via CDP */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
||||
}
|
||||
|
||||
async function ensureAttached(tabId: number): Promise<void> {
|
||||
if (attached.has(tabId)) return;
|
||||
// Verify the tab URL is debuggable before attempting attach
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!isDebuggableUrl(tab.url)) {
|
||||
// Invalidate cache if previously attached
|
||||
attached.delete(tabId);
|
||||
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Re-throw our own error, catch only chrome.tabs.get failures
|
||||
if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
|
||||
attached.delete(tabId);
|
||||
throw new Error(`Tab ${tabId} no longer exists`);
|
||||
}
|
||||
|
||||
if (attached.has(tabId)) {
|
||||
// Verify the debugger is still actually attached by sending a harmless command
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1', returnByValue: true,
|
||||
});
|
||||
return; // Still attached and working
|
||||
} catch {
|
||||
// Stale cache entry — need to re-attach
|
||||
attached.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const hint = msg.includes('chrome-extension://')
|
||||
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
||||
: '';
|
||||
if (msg.includes('Another debugger is already attached')) {
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
} catch {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
throw new Error(`attach failed: ${msg}${hint}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
throw new Error(`attach failed: ${msg}${hint}`);
|
||||
}
|
||||
}
|
||||
attached.add(tabId);
|
||||
@@ -109,10 +144,10 @@ export async function screenshot(
|
||||
}
|
||||
}
|
||||
|
||||
export function detach(tabId: number): void {
|
||||
export async function detach(tabId: number): Promise<void> {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function registerListeners(): void {
|
||||
@@ -122,4 +157,10 @@ export function registerListeners(): void {
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
});
|
||||
// Invalidate attached cache when tab URL changes to non-debuggable
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
||||
if (info.url && !isDebuggableUrl(info.url)) {
|
||||
await detach(tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* opencli browser protocol — shared types between daemon, extension, and CLI.
|
||||
*
|
||||
* Actions: exec, navigate, tabs, cookies, screenshot, export-state, import-state.
|
||||
* 5 actions: exec, navigate, tabs, cookies, screenshot.
|
||||
* Everything else is just JS code sent via 'exec'.
|
||||
*/
|
||||
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'export-state' | 'import-state' | 'watch-state' | 'unwatch-state';
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
||||
|
||||
export interface Command {
|
||||
/** Unique request ID */
|
||||
@@ -32,66 +32,6 @@ export interface Command {
|
||||
quality?: number;
|
||||
/** Whether to capture full page (not just viewport) */
|
||||
fullPage?: boolean;
|
||||
/** Storage type to export (export-state action) */
|
||||
storageType?: 'localStorage' | 'sessionStorage' | 'all';
|
||||
/** Domain filter for watch-state */
|
||||
domains?: string[];
|
||||
/** Browser state to import (import-state action) */
|
||||
state?: {
|
||||
cookies?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
expirationDate?: number;
|
||||
url?: string;
|
||||
}>;
|
||||
localStorage?: Record<string, string>;
|
||||
sessionStorage?: Record<string, string>;
|
||||
indexedDB?: Array<{
|
||||
name: string;
|
||||
version: number;
|
||||
objectStores: Array<{
|
||||
name: string;
|
||||
keyPath: string | string[] | null;
|
||||
autoIncrement: boolean;
|
||||
records: Array<{ key: unknown; value: unknown }>;
|
||||
}>;
|
||||
}>;
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Real-time state change event pushed from extension → daemon → sync subscribers */
|
||||
export interface SyncEvent {
|
||||
type: 'state-change';
|
||||
changeType: 'cookie' | 'localStorage' | 'sessionStorage';
|
||||
/** The domain this change applies to */
|
||||
domain: string;
|
||||
/** For cookies: the changed cookie */
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
expirationDate?: number;
|
||||
removed: boolean;
|
||||
/** Reason for removal: 'expired', 'explicit', 'overwrite', etc */
|
||||
cause?: string;
|
||||
};
|
||||
/** For storage: key-value change */
|
||||
storage?: {
|
||||
key: string;
|
||||
newValue: string | null;
|
||||
oldValue: string | null;
|
||||
storageArea: 'localStorage' | 'sessionStorage';
|
||||
url: string;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
|
||||
Generated
+160
-170
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"playwright-core": "^1.58.2",
|
||||
"turndown": "^7.2.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -23,9 +23,10 @@
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
@@ -194,7 +195,6 @@
|
||||
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.49.2",
|
||||
"@algolia/requester-browser-xhr": "5.49.2",
|
||||
@@ -404,9 +404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -416,9 +416,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -903,6 +903,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -920,20 +926,10 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/runtime": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
|
||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -941,9 +937,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -958,9 +954,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -975,9 +971,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -992,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1009,9 +1005,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1026,9 +1022,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1043,9 +1039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1060,9 +1056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1077,9 +1073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1094,9 +1090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1111,9 +1107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1128,9 +1124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1145,9 +1141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -1162,9 +1158,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1179,9 +1175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1196,9 +1192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1744,6 +1740,13 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -1776,16 +1779,16 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
|
||||
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
|
||||
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
@@ -1794,13 +1797,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
|
||||
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
|
||||
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1809,7 +1812,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -1821,9 +1824,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
|
||||
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
|
||||
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1834,13 +1837,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
|
||||
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
|
||||
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1848,14 +1851,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
|
||||
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
|
||||
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1864,9 +1867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
|
||||
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
|
||||
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1874,13 +1877,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
|
||||
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
|
||||
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
@@ -2159,7 +2162,6 @@
|
||||
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.15.2",
|
||||
"@algolia/client-abtesting": "5.49.2",
|
||||
@@ -2488,7 +2490,6 @@
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
@@ -2617,7 +2618,6 @@
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -3089,7 +3089,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3097,18 +3096,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -3205,14 +3192,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.115.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.9"
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.11"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -3221,21 +3208,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.11",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.11",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -3490,7 +3477,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -3505,13 +3491,21 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
|
||||
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3631,18 +3625,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
|
||||
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/runtime": "0.115.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.9",
|
||||
"rolldown": "1.0.0-rc.11",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3659,7 +3651,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.0.0-alpha.31",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -4202,7 +4194,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -4258,19 +4249,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
|
||||
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.0",
|
||||
"@vitest/mocker": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/runner": "4.1.0",
|
||||
"@vitest/snapshot": "4.1.0",
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/expect": "4.1.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/runner": "4.1.1",
|
||||
"@vitest/snapshot": "4.1.1",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -4282,7 +4273,7 @@
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4298,13 +4289,13 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.0",
|
||||
"@vitest/browser-preview": "4.1.0",
|
||||
"@vitest/browser-webdriverio": "4.1.0",
|
||||
"@vitest/ui": "4.1.0",
|
||||
"@vitest/browser-playwright": "4.1.1",
|
||||
"@vitest/browser-preview": "4.1.1",
|
||||
"@vitest/browser-webdriverio": "4.1.1",
|
||||
"@vitest/ui": "4.1.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
@@ -4345,7 +4336,6 @@
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
@@ -4380,9 +4370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.3",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -30,6 +30,7 @@
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run --project unit",
|
||||
"test:adapter": "vitest run --project adapter",
|
||||
"test:all": "vitest run",
|
||||
"test:e2e": "vitest run --project e2e",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
@@ -53,15 +54,16 @@
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"playwright-core": "^1.58.2",
|
||||
"turndown": "^7.2.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Camoufox server launcher for opencli.
|
||||
|
||||
Starts a Camoufox browser in server mode and outputs the WebSocket endpoint
|
||||
as JSON for the Node.js caller to parse.
|
||||
|
||||
Usage:
|
||||
python3 scripts/camoufox_server.py [--headless] [--port PORT]
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
def main():
|
||||
headless = "--headless" in sys.argv
|
||||
port = None
|
||||
if "--port" in sys.argv:
|
||||
idx = sys.argv.index("--port")
|
||||
if idx + 1 < len(sys.argv):
|
||||
port = int(sys.argv[idx + 1])
|
||||
|
||||
try:
|
||||
from camoufox.server import launch_server
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "camoufox not installed. Run: pip3 install camoufox"}), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
kwargs = {"headless": headless}
|
||||
if port is not None:
|
||||
kwargs["port"] = port
|
||||
|
||||
ws_endpoint = launch_server(**kwargs)
|
||||
print(json.dumps({"ws_endpoint": ws_endpoint}), flush=True)
|
||||
|
||||
# Keep the process running — the server lives as long as this process
|
||||
# Use threading.Event instead of signal.pause for cross-platform compat
|
||||
import threading
|
||||
threading.Event().wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,6 +28,8 @@ total=0
|
||||
|
||||
for adapter_dir in "$SRC_DIR"/*/; do
|
||||
adapter_name="$(basename "$adapter_dir")"
|
||||
# Skip internal directories (e.g., _shared)
|
||||
[[ "$adapter_name" == _* ]] && continue
|
||||
total=$((total + 1))
|
||||
|
||||
# Check if doc exists in browser/ or desktop/ subdirectories
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Shared API analysis helpers used by both explore.ts and record.ts.
|
||||
*
|
||||
* Extracts common logic for:
|
||||
* - URL pattern normalization
|
||||
* - Array path discovery in JSON responses
|
||||
* - Field role detection
|
||||
* - Auth indicator inference
|
||||
* - Capability name inference
|
||||
* - Strategy inference
|
||||
*/
|
||||
|
||||
import {
|
||||
VOLATILE_PARAMS,
|
||||
SEARCH_PARAMS,
|
||||
PAGINATION_PARAMS,
|
||||
FIELD_ROLES,
|
||||
} from './constants.js';
|
||||
|
||||
// ── URL pattern normalization ───────────────────────────────────────────────
|
||||
|
||||
/** Normalize a full URL into a pattern (replace IDs, strip volatile params). */
|
||||
export function urlToPattern(url: string): string {
|
||||
try {
|
||||
const p = new URL(url);
|
||||
const pathNorm = p.pathname
|
||||
.replace(/\/\d+/g, '/{id}')
|
||||
.replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}')
|
||||
.replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
|
||||
const params: string[] = [];
|
||||
p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); });
|
||||
return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
|
||||
} catch { return url; }
|
||||
}
|
||||
|
||||
// ── Array discovery in JSON responses ───────────────────────────────────────
|
||||
|
||||
export interface ArrayDiscovery {
|
||||
path: string;
|
||||
items: unknown[];
|
||||
}
|
||||
|
||||
/** Find the best (largest) array of objects in a JSON response body. */
|
||||
export function findArrayPath(obj: unknown, depth = 0): ArrayDiscovery | null {
|
||||
if (depth > 5 || !obj || typeof obj !== 'object') return null;
|
||||
if (Array.isArray(obj)) {
|
||||
if (obj.length >= 2 && obj.some(i => i && typeof i === 'object' && !Array.isArray(i))) {
|
||||
return { path: '', items: obj };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let best: ArrayDiscovery | null = null;
|
||||
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const found = findArrayPath(val, depth + 1);
|
||||
if (found) {
|
||||
const fullPath = found.path ? `${key}.${found.path}` : key;
|
||||
const candidate = { path: fullPath, items: found.items };
|
||||
if (!best || candidate.items.length > best.items.length) best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Field flattening & role detection ───────────────────────────────────────
|
||||
|
||||
/** Flatten nested object keys up to maxDepth. */
|
||||
export function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[] {
|
||||
if (maxDepth <= 0 || !obj || typeof obj !== 'object') return [];
|
||||
const names: string[] = [];
|
||||
const record = obj as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
const full = prefix ? `${prefix}.${key}` : key;
|
||||
names.push(full);
|
||||
const val = record[key];
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) names.push(...flattenFields(val, full, maxDepth - 1));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Detect semantic field roles (title, url, author, etc.) from sample fields. */
|
||||
export function detectFieldRoles(sampleFields: string[]): Record<string, string> {
|
||||
const detectedFields: Record<string, string> = {};
|
||||
for (const [role, aliases] of Object.entries(FIELD_ROLES)) {
|
||||
for (const f of sampleFields) {
|
||||
if (aliases.includes(f.split('.').pop()?.toLowerCase() ?? '')) {
|
||||
detectedFields[role] = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return detectedFields;
|
||||
}
|
||||
|
||||
// ── Capability name inference ───────────────────────────────────────────────
|
||||
|
||||
/** Infer a CLI capability name from a URL. */
|
||||
export function inferCapabilityName(url: string, goal?: string): string {
|
||||
if (goal) return goal;
|
||||
const u = url.toLowerCase();
|
||||
if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending')) return 'hot';
|
||||
if (u.includes('search')) return 'search';
|
||||
if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic')) return 'feed';
|
||||
if (u.includes('comment') || u.includes('reply')) return 'comments';
|
||||
if (u.includes('history')) return 'history';
|
||||
if (u.includes('profile') || u.includes('userinfo') || u.includes('/me')) return 'me';
|
||||
if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark')) return 'favorite';
|
||||
try {
|
||||
const segs = new URL(url).pathname
|
||||
.split('/')
|
||||
.filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i) && !s.match(/^v\d+$/));
|
||||
if (segs.length) return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
} catch {}
|
||||
return 'data';
|
||||
}
|
||||
|
||||
// ── Strategy inference ──────────────────────────────────────────────────────
|
||||
|
||||
/** Infer auth strategy from detected indicators. */
|
||||
export function inferStrategy(authIndicators: string[]): string {
|
||||
if (authIndicators.includes('signature')) return 'intercept';
|
||||
if (authIndicators.includes('bearer') || authIndicators.includes('csrf')) return 'header';
|
||||
return 'cookie';
|
||||
}
|
||||
|
||||
// ── Auth indicator detection ────────────────────────────────────────────────
|
||||
|
||||
/** Detect auth indicators from HTTP headers. */
|
||||
export function detectAuthFromHeaders(headers?: Record<string, string>): string[] {
|
||||
if (!headers) return [];
|
||||
const indicators: string[] = [];
|
||||
const keys = Object.keys(headers).map(k => k.toLowerCase());
|
||||
if (keys.some(k => k === 'authorization')) indicators.push('bearer');
|
||||
if (keys.some(k => k.startsWith('x-csrf') || k.startsWith('x-xsrf'))) indicators.push('csrf');
|
||||
if (keys.some(k => k.startsWith('x-s') || k === 'x-t' || k === 'x-s-common')) indicators.push('signature');
|
||||
return indicators;
|
||||
}
|
||||
|
||||
/** Detect auth indicators from URL and response body (heuristic). */
|
||||
export function detectAuthFromContent(url: string, body: unknown): string[] {
|
||||
const indicators: string[] = [];
|
||||
if (body && typeof body === 'object') {
|
||||
const keys = Object.keys(body as object).map(k => k.toLowerCase());
|
||||
if (keys.some(k => k.includes('sign') || k === 'w_rid' || k.includes('token'))) {
|
||||
indicators.push('signature');
|
||||
}
|
||||
}
|
||||
if (url.includes('/wbi/') || url.includes('w_rid=')) indicators.push('signature');
|
||||
if (url.includes('bearer') || url.includes('access_token')) indicators.push('bearer');
|
||||
return indicators;
|
||||
}
|
||||
|
||||
// ── Query param classification ──────────────────────────────────────────────
|
||||
|
||||
/** Extract non-volatile query params and classify them. */
|
||||
export function classifyQueryParams(url: string): {
|
||||
params: string[];
|
||||
hasSearch: boolean;
|
||||
hasPagination: boolean;
|
||||
hasLimit: boolean;
|
||||
} {
|
||||
const params: string[] = [];
|
||||
try { new URL(url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); }); } catch {}
|
||||
return {
|
||||
params,
|
||||
hasSearch: params.some(p => SEARCH_PARAMS.has(p)),
|
||||
hasPagination: params.some(p => PAGINATION_PARAMS.has(p)),
|
||||
hasLimit: params.some(p => SEARCH_PARAMS.has(p)),
|
||||
};
|
||||
}
|
||||
+51
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, __test__ } from './browser/index.js';
|
||||
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
|
||||
describe('browser helpers', () => {
|
||||
@@ -133,3 +133,53 @@ describe('BrowserBridge state', () => {
|
||||
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stealth anti-detection', () => {
|
||||
it('generates non-empty JS string', () => {
|
||||
const js = generateStealthJs();
|
||||
expect(typeof js).toBe('string');
|
||||
expect(js.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('contains all 7 anti-detection patches', () => {
|
||||
const js = generateStealthJs();
|
||||
// 1. webdriver
|
||||
expect(js).toContain('navigator');
|
||||
expect(js).toContain('webdriver');
|
||||
// 2. chrome stub
|
||||
expect(js).toContain('window.chrome');
|
||||
// 3. plugins
|
||||
expect(js).toContain('plugins');
|
||||
expect(js).toContain('PDF Viewer');
|
||||
// 4. languages
|
||||
expect(js).toContain('languages');
|
||||
// 5. permissions
|
||||
expect(js).toContain('Permissions');
|
||||
expect(js).toContain('notifications');
|
||||
// 6. automation artifacts (dynamic cdc_ scan)
|
||||
expect(js).toContain('__playwright');
|
||||
expect(js).toContain('__puppeteer');
|
||||
expect(js).toContain('getOwnPropertyNames');
|
||||
expect(js).toContain('cdc_');
|
||||
// 7. CDP stack trace cleanup
|
||||
expect(js).toContain('Error.prototype');
|
||||
expect(js).toContain('puppeteer_evaluation_script');
|
||||
expect(js).toContain('getOwnPropertyDescriptor');
|
||||
});
|
||||
|
||||
it('includes guard flag to prevent double-injection', () => {
|
||||
const js = generateStealthJs();
|
||||
// Guard uses a non-enumerable property on a built-in prototype
|
||||
expect(js).toContain("EventTarget.prototype");
|
||||
// Guard should check early and return 'skipped'
|
||||
expect(js).toContain("return 'skipped'");
|
||||
// Normal path returns 'applied'
|
||||
expect(js).toContain("return 'applied'");
|
||||
});
|
||||
|
||||
it('generates syntactically valid JS', () => {
|
||||
const js = generateStealthJs();
|
||||
// Should not throw when parsed
|
||||
expect(() => new Function(js)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* CamoufoxBridge — IBrowserFactory that connects to a running Camoufox server
|
||||
* via playwright-core's Juggler (Firefox) WebSocket protocol.
|
||||
*
|
||||
* Uses CamoufoxPool for shared context — ensures LiveSyncService cookies
|
||||
* are visible to adapter pages.
|
||||
*/
|
||||
|
||||
import * as CamoufoxPool from './camoufox-pool.js';
|
||||
import type { IBrowserFactory } from '../runtime.js';
|
||||
import type { IPage } from '../types.js';
|
||||
import { CamoufoxPage } from './camoufox-page.js';
|
||||
|
||||
export class CamoufoxBridge implements IBrowserFactory {
|
||||
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
|
||||
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS ?? 'ws://127.0.0.1:19826';
|
||||
const context = await CamoufoxPool.acquire(wsEndpoint, { timeout: opts?.timeout });
|
||||
const page = await context.newPage();
|
||||
return new CamoufoxPage(page, context);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Release our reference — pool stays alive if other users (LiveSyncService) hold refs
|
||||
await CamoufoxPool.release();
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* CamoufoxPage — implements IPage by talking to Camoufox via playwright-core Juggler protocol.
|
||||
*
|
||||
* This is a drop-in replacement for the daemon-based Page class.
|
||||
* All browser interactions go through Playwright's Firefox protocol (Juggler),
|
||||
* which means page.evaluate() bypasses CSP just like CDP does.
|
||||
*
|
||||
* The DOM snapshot engine, click/type/scroll helpers are reused from the shared
|
||||
* dom-snapshot.ts and dom-helpers.ts — same JS code, different transport.
|
||||
*/
|
||||
|
||||
import type { Page as PwPage, BrowserContext } from 'playwright-core';
|
||||
import type { BrowserCookie, BrowserState, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
||||
import { formatSnapshot } from '../snapshotFormatter.js';
|
||||
import { wrapForEval } from './utils.js';
|
||||
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
import {
|
||||
clickJs,
|
||||
typeTextJs,
|
||||
pressKeyJs,
|
||||
waitForTextJs,
|
||||
scrollJs,
|
||||
autoScrollJs,
|
||||
networkRequestsJs,
|
||||
} from './dom-helpers.js';
|
||||
|
||||
export class CamoufoxPage implements IPage {
|
||||
constructor(
|
||||
private readonly page: PwPage,
|
||||
private readonly context: BrowserContext,
|
||||
) {}
|
||||
|
||||
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
|
||||
await this.page.goto(url, {
|
||||
waitUntil: options?.waitUntil === 'none' ? undefined : 'load',
|
||||
timeout: 30000,
|
||||
});
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const settleMs = options?.settleMs ?? 1000;
|
||||
await this.page.waitForTimeout(settleMs);
|
||||
}
|
||||
}
|
||||
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
// Apply same IIFE wrapping as the daemon-based Page class.
|
||||
// This ensures adapter code that passes arrow functions or statements
|
||||
// works identically across both backends.
|
||||
const code = wrapForEval(js);
|
||||
return this.page.evaluate(code);
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
|
||||
const urls = opts.url ? [opts.url] : undefined;
|
||||
const cookies = await this.context.cookies(urls);
|
||||
const filtered = opts.domain
|
||||
? cookies.filter(c => c.domain.includes(opts.domain!))
|
||||
: cookies;
|
||||
return filtered.map(c => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
expirationDate: c.expires !== -1 ? c.expires : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
|
||||
const snapshotJs = generateSnapshotJs({
|
||||
viewportExpand: opts.viewportExpand ?? 800,
|
||||
maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
|
||||
interactiveOnly: opts.interactive ?? false,
|
||||
maxTextLength: opts.maxTextLength ?? 120,
|
||||
includeScrollInfo: true,
|
||||
bboxDedup: true,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.page.evaluate(snapshotJs);
|
||||
} catch {
|
||||
// Fallback: basic snapshot
|
||||
const raw = await this.page.evaluate(`
|
||||
(function() {
|
||||
function buildTree(node, depth) {
|
||||
if (depth > 50) return '';
|
||||
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
|
||||
const name = node.getAttribute?.('aria-label') || node.textContent?.trim().slice(0, 80) || '';
|
||||
let line = ' '.repeat(depth) + role;
|
||||
if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
|
||||
let result = line + '\\n';
|
||||
if (node.children) { for (const child of node.children) result += buildTree(child, depth + 1); }
|
||||
return result;
|
||||
}
|
||||
return buildTree(document.body, 0);
|
||||
})()
|
||||
`);
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.page.evaluate(clickJs(ref));
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
await this.page.evaluate(typeTextJs(ref, text));
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.page.evaluate(pressKeyJs(key));
|
||||
}
|
||||
|
||||
async scrollTo(ref: string): Promise<unknown> {
|
||||
return this.page.evaluate(scrollToRefJs(ref));
|
||||
}
|
||||
|
||||
async getFormState(): Promise<Record<string, unknown>> {
|
||||
return (await this.page.evaluate(getFormStateJs())) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async wait(options: number | WaitOptions): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
await this.page.waitForTimeout(options * 1000);
|
||||
return;
|
||||
}
|
||||
if (options.time) {
|
||||
await this.page.waitForTimeout(options.time * 1000);
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
await this.page.evaluate(waitForTextJs(options.text, timeout));
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<unknown[]> {
|
||||
return this.context.pages().map((p, i) => ({
|
||||
index: i,
|
||||
url: p.url(),
|
||||
title: '', // title() is async, simplify
|
||||
active: p === this.page,
|
||||
}));
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
const pages = this.context.pages();
|
||||
const target = index !== undefined ? pages[index] : this.page;
|
||||
if (target) await target.close();
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await this.context.newPage();
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
const pages = this.context.pages();
|
||||
if (pages[index]) await pages[index].bringToFront();
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
|
||||
const result = await this.page.evaluate(networkRequestsJs(includeStatic));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async consoleMessages(_level?: string): Promise<unknown[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
|
||||
const buffer = await this.page.screenshot({
|
||||
type: options.format ?? 'png',
|
||||
quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined,
|
||||
fullPage: options.fullPage ?? false,
|
||||
});
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
if (options.path) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.dirname(options.path);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(options.path, buffer);
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
await this.page.evaluate(scrollJs(direction, amount));
|
||||
}
|
||||
|
||||
async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
|
||||
const times = options?.times ?? 3;
|
||||
const delayMs = options?.delayMs ?? 2000;
|
||||
await this.page.evaluate(autoScrollJs(times, delayMs));
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
const { generateInterceptorJs } = await import('../interceptor.js');
|
||||
await this.page.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
||||
arrayName: '__opencli_xhr',
|
||||
patchGuard: '__opencli_interceptor_patched',
|
||||
}));
|
||||
}
|
||||
|
||||
async getInterceptedRequests(): Promise<unknown[]> {
|
||||
const { generateReadInterceptedJs } = await import('../interceptor.js');
|
||||
const result = await this.page.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async exportState(opts: { domain?: string } = {}): Promise<BrowserState> {
|
||||
const url = this.page.url();
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname; } catch {}
|
||||
|
||||
// Cookies — via Playwright context API
|
||||
const rawCookies = await this.context.cookies(opts.domain ? undefined : [url]);
|
||||
const allCookies = opts.domain
|
||||
? rawCookies.filter(c => c.domain.includes(opts.domain!))
|
||||
: rawCookies;
|
||||
const cookies = allCookies.map(c => ({
|
||||
name: c.name, value: c.value, domain: c.domain, path: c.path,
|
||||
secure: c.secure, httpOnly: c.httpOnly,
|
||||
expirationDate: c.expires !== -1 ? c.expires : undefined,
|
||||
}));
|
||||
|
||||
// localStorage
|
||||
const localStorage = await this.page.evaluate(`
|
||||
(() => {
|
||||
try { const r = {}; for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); if (k) r[k] = window.localStorage.getItem(k); } return r; }
|
||||
catch { return {}; }
|
||||
})()
|
||||
`) as Record<string, string>;
|
||||
|
||||
// sessionStorage
|
||||
const sessionStorage = await this.page.evaluate(`
|
||||
(() => {
|
||||
try { const r = {}; for (let i = 0; i < window.sessionStorage.length; i++) { const k = window.sessionStorage.key(i); if (k) r[k] = window.sessionStorage.getItem(k); } return r; }
|
||||
catch { return {}; }
|
||||
})()
|
||||
`) as Record<string, string>;
|
||||
|
||||
// IndexedDB
|
||||
const indexedDB = await this.page.evaluate(`
|
||||
(async () => {
|
||||
try {
|
||||
if (!window.indexedDB || !window.indexedDB.databases) return [];
|
||||
const dbs = await window.indexedDB.databases();
|
||||
const results = [];
|
||||
for (const dbInfo of dbs) {
|
||||
if (!dbInfo.name) continue;
|
||||
try {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onupgradeneeded = () => { req.transaction.abort(); reject(new Error('upgrade')); };
|
||||
});
|
||||
const storeNames = Array.from(db.objectStoreNames);
|
||||
const objectStores = [];
|
||||
for (const storeName of storeNames) {
|
||||
try {
|
||||
const tx = db.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const records = await new Promise((resolve, reject) => {
|
||||
const allReq = store.getAll();
|
||||
const keysReq = store.getAllKeys();
|
||||
allReq.onsuccess = () => {
|
||||
keysReq.onsuccess = () => {
|
||||
const r = [];
|
||||
for (let i = 0; i < allReq.result.length; i++) r.push({ key: keysReq.result[i], value: allReq.result[i] });
|
||||
resolve(r);
|
||||
};
|
||||
keysReq.onerror = () => resolve([]);
|
||||
};
|
||||
allReq.onerror = () => reject(allReq.error);
|
||||
});
|
||||
objectStores.push({ name: storeName, keyPath: store.keyPath, autoIncrement: store.autoIncrement, records });
|
||||
} catch {}
|
||||
}
|
||||
db.close();
|
||||
results.push({ name: dbInfo.name, version: dbInfo.version || 1, objectStores });
|
||||
} catch {}
|
||||
}
|
||||
return results;
|
||||
} catch { return []; }
|
||||
})()
|
||||
`) as BrowserState['indexedDB'];
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
url,
|
||||
domain: opts.domain || domain,
|
||||
timestamp: Date.now(),
|
||||
cookies,
|
||||
localStorage: localStorage ?? {},
|
||||
sessionStorage: sessionStorage ?? {},
|
||||
indexedDB: indexedDB ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async importState(state: BrowserState): Promise<void> {
|
||||
// 1. Cookies FIRST — before navigation, so the initial request has cookies
|
||||
if (state.cookies?.length) {
|
||||
const pwCookies = state.cookies.map(c => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path || '/',
|
||||
secure: c.secure ?? false,
|
||||
httpOnly: c.httpOnly ?? false,
|
||||
expires: c.expirationDate ?? -1,
|
||||
}));
|
||||
await this.context.addCookies(pwCookies);
|
||||
}
|
||||
|
||||
// 2. Navigate — now the first request already carries cookies
|
||||
if (state.url) {
|
||||
await this.page.goto(state.url, { waitUntil: 'load', timeout: 30000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// 3. Import localStorage via Playwright storageState-compatible path +
|
||||
// manual evaluate for completeness (storageState only covers origins)
|
||||
if (state.localStorage && Object.keys(state.localStorage).length > 0) {
|
||||
const entries = JSON.stringify(state.localStorage);
|
||||
await this.page.evaluate(`(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.localStorage.setItem(k,v); })()`);
|
||||
}
|
||||
|
||||
// 4. Import sessionStorage
|
||||
if (state.sessionStorage && Object.keys(state.sessionStorage).length > 0) {
|
||||
const entries = JSON.stringify(state.sessionStorage);
|
||||
await this.page.evaluate(`(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.sessionStorage.setItem(k,v); })()`);
|
||||
}
|
||||
|
||||
// 5. Import IndexedDB
|
||||
if (state.indexedDB?.length) {
|
||||
const idbData = JSON.stringify(state.indexedDB);
|
||||
await this.page.evaluate(`
|
||||
(async () => {
|
||||
const dbs = ${idbData};
|
||||
for (const dbInfo of dbs) {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
|
||||
req.onupgradeneeded = () => {
|
||||
const d = req.result;
|
||||
for (const s of dbInfo.objectStores) {
|
||||
if (!d.objectStoreNames.contains(s.name)) {
|
||||
d.createObjectStore(s.name, { keyPath: s.keyPath || undefined, autoIncrement: s.autoIncrement });
|
||||
}
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
for (const s of dbInfo.objectStores) {
|
||||
if (!db.objectStoreNames.contains(s.name)) continue;
|
||||
const tx = db.transaction(s.name, 'readwrite');
|
||||
const os = tx.objectStore(s.name);
|
||||
for (const r of s.records) os.put(r.value, s.keyPath ? undefined : r.key);
|
||||
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
// 6. Reload to apply all injected state
|
||||
await this.page.reload({ waitUntil: 'load', timeout: 30000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* CamoufoxPool — Global singleton for shared Camoufox browser/context.
|
||||
*
|
||||
* Solves the context isolation problem: LiveSyncService and CamoufoxBridge
|
||||
* must share the same BrowserContext so that cookies synced in real-time
|
||||
* are visible to adapter pages.
|
||||
*
|
||||
* Usage pattern:
|
||||
* const ctx = await CamoufoxPool.acquire(wsEndpoint);
|
||||
* const page = await ctx.newPage();
|
||||
* // ... use page ...
|
||||
* // Don't close context — pool manages lifecycle
|
||||
*/
|
||||
|
||||
import { firefox } from 'playwright-core';
|
||||
import type { Browser, BrowserContext } from 'playwright-core';
|
||||
|
||||
let _browser: Browser | null = null;
|
||||
let _context: BrowserContext | null = null;
|
||||
let _wsEndpoint: string | null = null;
|
||||
let _refCount = 0;
|
||||
|
||||
/**
|
||||
* Acquire the shared Camoufox context. Creates browser/context on first call,
|
||||
* reuses on subsequent calls. Increments reference count.
|
||||
*/
|
||||
export async function acquire(wsEndpoint: string, opts?: { timeout?: number }): Promise<BrowserContext> {
|
||||
// If already connected to a different endpoint, close old connection
|
||||
if (_browser && _wsEndpoint !== wsEndpoint) {
|
||||
await release(true);
|
||||
}
|
||||
|
||||
if (!_browser || !_context) {
|
||||
const timeoutMs = (opts?.timeout ?? 30) * 1000;
|
||||
_browser = await firefox.connect(wsEndpoint, { timeout: timeoutMs });
|
||||
_context = await _browser.newContext();
|
||||
_wsEndpoint = wsEndpoint;
|
||||
|
||||
// Handle browser disconnect
|
||||
_browser.on('disconnected', () => {
|
||||
_browser = null;
|
||||
_context = null;
|
||||
_wsEndpoint = null;
|
||||
_refCount = 0;
|
||||
});
|
||||
}
|
||||
|
||||
_refCount++;
|
||||
return _context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a reference to the pool. When all references are released,
|
||||
* optionally close the context (default: keep alive for future use).
|
||||
* Pass force=true to close immediately regardless of refcount.
|
||||
*/
|
||||
export async function release(force = false): Promise<void> {
|
||||
_refCount = Math.max(0, _refCount - 1);
|
||||
|
||||
if (force || _refCount === 0) {
|
||||
try {
|
||||
await _context?.close();
|
||||
} catch { /* already closed */ }
|
||||
_context = null;
|
||||
// Don't close browser — camoufox server manages its lifecycle
|
||||
_browser = null;
|
||||
_wsEndpoint = null;
|
||||
_refCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the current shared context without incrementing refcount (for inspection). */
|
||||
export function getContext(): BrowserContext | null {
|
||||
return _context;
|
||||
}
|
||||
|
||||
/** Check if pool has an active connection. */
|
||||
export function isConnected(): boolean {
|
||||
return _browser !== null && _context !== null;
|
||||
}
|
||||
|
||||
/** Get connection info. */
|
||||
export function getInfo(): { wsEndpoint: string | null; refCount: number; connected: boolean } {
|
||||
return {
|
||||
wsEndpoint: _wsEndpoint,
|
||||
refCount: _refCount,
|
||||
connected: isConnected(),
|
||||
};
|
||||
}
|
||||
+25
-23
@@ -9,9 +9,10 @@
|
||||
*/
|
||||
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
import type { BrowserCookie, BrowserState, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
||||
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
||||
import { wrapForEval } from './utils.js';
|
||||
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
import { generateStealthJs } from './stealth.js';
|
||||
import {
|
||||
clickJs,
|
||||
typeTextJs,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
scrollJs,
|
||||
autoScrollJs,
|
||||
networkRequestsJs,
|
||||
waitForDomStableJs,
|
||||
} from './dom-helpers.js';
|
||||
|
||||
export interface CDPTarget {
|
||||
@@ -49,6 +51,8 @@ export class CDPBridge {
|
||||
private _eventListeners = new Map<string, Set<(params: unknown) => void>>();
|
||||
|
||||
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
|
||||
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');
|
||||
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
|
||||
|
||||
@@ -70,9 +74,16 @@ export class CDPBridge {
|
||||
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
|
||||
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.on('open', async () => {
|
||||
clearTimeout(timeout);
|
||||
this._ws = ws;
|
||||
// Register stealth script to run before any page JS on every navigation.
|
||||
try {
|
||||
await this.send('Page.enable');
|
||||
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
resolve(new CDPPage(this));
|
||||
});
|
||||
|
||||
@@ -168,19 +179,24 @@ export class CDPBridge {
|
||||
}
|
||||
|
||||
class CDPPage implements IPage {
|
||||
private _pageEnabled = false;
|
||||
constructor(private bridge: CDPBridge) {}
|
||||
|
||||
/** Navigate with proper load event waiting (P1 fix #3) */
|
||||
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
|
||||
await this.bridge.send('Page.enable');
|
||||
if (!this._pageEnabled) {
|
||||
await this.bridge.send('Page.enable');
|
||||
this._pageEnabled = true;
|
||||
}
|
||||
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
|
||||
.catch(() => {}); // Don't fail if event times out
|
||||
.catch(() => {}); // Don't fail if load event times out — page may be an SPA
|
||||
await this.bridge.send('Page.navigate', { url });
|
||||
await loadPromise;
|
||||
// Post-load settle: SPA frameworks need extra time to render after load event
|
||||
// Smart settle: use DOM stability detection instead of fixed sleep.
|
||||
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const settleMs = options?.settleMs ?? 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, settleMs));
|
||||
const maxMs = options?.settleMs ?? 1000;
|
||||
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,11 +292,7 @@ class CDPPage implements IPage {
|
||||
});
|
||||
const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : '';
|
||||
if (options.path) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.dirname(options.path);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
|
||||
await saveBase64ToFile(base64, options.path);
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
@@ -323,19 +335,9 @@ class CDPPage implements IPage {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async exportState(_opts?: { domain?: string }): Promise<BrowserState> {
|
||||
throw new Error('exportState is not supported in CDP mode. Use daemon mode instead.');
|
||||
}
|
||||
|
||||
async importState(_state: BrowserState): Promise<void> {
|
||||
throw new Error('importState is not supported in CDP mode. Use daemon mode instead.');
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
import { isRecord, saveBase64ToFile } from '../utils.js';
|
||||
|
||||
function isCookie(value: unknown): value is BrowserCookie {
|
||||
return isRecord(value)
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* Provides a typed send() function that posts a Command and returns a Result.
|
||||
*/
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import type { BrowserSessionInfo } from '../types.js';
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
||||
|
||||
let _idCounter = 0;
|
||||
@@ -15,7 +18,7 @@ function generateId(): string {
|
||||
|
||||
export interface DaemonCommand {
|
||||
id: string;
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'export-state' | 'import-state';
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
||||
tabId?: number;
|
||||
code?: string;
|
||||
workspace?: string;
|
||||
@@ -26,7 +29,6 @@ export interface DaemonCommand {
|
||||
format?: 'png' | 'jpeg';
|
||||
quality?: number;
|
||||
fullPage?: boolean;
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
export interface DaemonResult {
|
||||
@@ -43,7 +45,10 @@ export async function isDaemonRunning(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
|
||||
const res = await fetch(`${DAEMON_URL}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return res.ok;
|
||||
} catch {
|
||||
@@ -58,7 +63,10 @@ export async function isExtensionConnected(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
|
||||
const res = await fetch(`${DAEMON_URL}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json() as { extensionConnected?: boolean };
|
||||
@@ -70,24 +78,26 @@ export async function isExtensionConnected(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Send a command to the daemon and wait for a result.
|
||||
* Retries up to 3 times with 500ms delay for transient failures.
|
||||
* Retries up to 4 times: network errors retry at 500ms,
|
||||
* transient extension errors retry at 1500ms.
|
||||
*/
|
||||
export async function sendCommand(
|
||||
action: DaemonCommand['action'],
|
||||
params: Omit<DaemonCommand, 'id' | 'action'> = {},
|
||||
): Promise<unknown> {
|
||||
const id = generateId();
|
||||
const command: DaemonCommand = { id, action, ...params };
|
||||
const maxRetries = 3;
|
||||
const maxRetries = 4;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// Generate a fresh ID per attempt to avoid daemon-side duplicate detection
|
||||
const id = generateId();
|
||||
const command: DaemonCommand = { id, action, ...params };
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const res = await fetch(`${DAEMON_URL}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
|
||||
body: JSON.stringify(command),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -96,6 +106,17 @@ export async function sendCommand(
|
||||
const result = (await res.json()) as DaemonResult;
|
||||
|
||||
if (!result.ok) {
|
||||
// Check if error is a transient extension issue worth retrying
|
||||
const errMsg = result.error ?? '';
|
||||
const isTransient = errMsg.includes('Extension disconnected')
|
||||
|| errMsg.includes('Extension not connected')
|
||||
|| errMsg.includes('attach failed')
|
||||
|| errMsg.includes('no longer exists');
|
||||
if (isTransient && attempt < maxRetries) {
|
||||
// Longer delay for extension recovery (service worker restart)
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
continue;
|
||||
}
|
||||
throw new Error(result.error ?? 'Daemon command failed');
|
||||
}
|
||||
|
||||
@@ -118,4 +139,4 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
|
||||
const result = await sendCommand('sessions');
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
import type { BrowserSessionInfo } from '../types.js';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* scanning for @playwright/mcp locations.
|
||||
*/
|
||||
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import { isDaemonRunning } from './daemon-client.js';
|
||||
|
||||
export { isDaemonRunning };
|
||||
@@ -17,8 +18,10 @@ export async function checkDaemonStatus(): Promise<{
|
||||
extensionConnected: boolean;
|
||||
}> {
|
||||
try {
|
||||
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status`);
|
||||
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
|
||||
return { running: true, extensionConnected: data.extensionConnected };
|
||||
} catch {
|
||||
|
||||
@@ -145,3 +145,37 @@ export function networkRequestsJs(includeStatic: boolean): string {
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`),
|
||||
* with a hard cap at `maxMs`. Uses MutationObserver in the browser.
|
||||
*
|
||||
* Returns as soon as the page stops changing, avoiding unnecessary fixed waits.
|
||||
* If document.body is not available, falls back to a fixed sleep of maxMs.
|
||||
*/
|
||||
export function waitForDomStableJs(maxMs: number, quietMs: number): string {
|
||||
return `
|
||||
new Promise(resolve => {
|
||||
if (!document.body) {
|
||||
setTimeout(() => resolve('nobody'), ${maxMs});
|
||||
return;
|
||||
}
|
||||
let timer = null;
|
||||
let cap = null;
|
||||
const done = (reason) => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(cap);
|
||||
obs.disconnect();
|
||||
resolve(reason);
|
||||
};
|
||||
const resetQuiet = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => done('quiet'), ${quietMs});
|
||||
};
|
||||
const obs = new MutationObserver(resetQuiet);
|
||||
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
|
||||
resetQuiet();
|
||||
cap = setTimeout(() => done('capped'), ${maxMs});
|
||||
})
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface SnapshotOptions {
|
||||
export interface DomSnapshotOptions {
|
||||
/** Extra pixels beyond viewport to include (default 800) */
|
||||
viewportExpand?: number;
|
||||
/** Maximum DOM depth to traverse (default 50) */
|
||||
@@ -175,7 +175,7 @@ export function getFormStateJs(): string {
|
||||
* - `|iframe|` — iframe content
|
||||
* - `|table|` — markdown table rendering
|
||||
*/
|
||||
export function generateSnapshotJs(opts: SnapshotOptions = {}): string {
|
||||
export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
const viewportExpand = opts.viewportExpand ?? 800;
|
||||
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200));
|
||||
const interactiveOnly = opts.interactiveOnly ?? false;
|
||||
|
||||
+18
-11
@@ -5,31 +5,38 @@
|
||||
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
|
||||
*/
|
||||
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
||||
|
||||
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
|
||||
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
|
||||
switch (kind) {
|
||||
case 'daemon-not-running':
|
||||
return new Error(
|
||||
'Cannot connect to opencli daemon.\n\n' +
|
||||
return new BrowserConnectError(
|
||||
'Cannot connect to opencli daemon.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'The daemon should start automatically. If it doesn\'t, try:\n' +
|
||||
' node dist/daemon.js\n' +
|
||||
'Make sure port 19825 is available.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
);
|
||||
case 'extension-not-connected':
|
||||
return new Error(
|
||||
'opencli Browser Bridge extension is not connected.\n\n' +
|
||||
return new BrowserConnectError(
|
||||
'opencli Browser Bridge extension is not connected.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'Please install the extension:\n' +
|
||||
' 1. Download from GitHub Releases\n' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
' 3. Click "Load unpacked" → select the extension folder\n' +
|
||||
' 4. Make sure Chrome is running' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
' 4. Make sure Chrome is running',
|
||||
);
|
||||
case 'command-failed':
|
||||
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
|
||||
return new BrowserConnectError(
|
||||
`Browser command failed: ${detail ?? 'unknown error'}`,
|
||||
);
|
||||
default:
|
||||
return new Error(detail ?? 'Failed to connect to browser');
|
||||
return new BrowserConnectError(
|
||||
detail ?? 'Failed to connect to browser',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@
|
||||
*/
|
||||
|
||||
export { Page } from './page.js';
|
||||
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
||||
export { BrowserBridge } from './mcp.js';
|
||||
export { CDPBridge } from './cdp.js';
|
||||
export { CamoufoxBridge } from './camoufox-bridge.js';
|
||||
export { CamoufoxPage } from './camoufox-page.js';
|
||||
export * as CamoufoxPool from './camoufox-pool.js';
|
||||
export { isDaemonRunning } from './daemon-client.js';
|
||||
export { LiveSyncService } from './live-sync.js';
|
||||
export type { SyncServiceOptions } from './live-sync.js';
|
||||
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
export type { SnapshotOptions } from './dom-snapshot.js';
|
||||
export { generateStealthJs } from './stealth.js';
|
||||
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
||||
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { __test__ as cdpTest } from './cdp.js';
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* Live state sync service: Chrome → Camoufox.
|
||||
*
|
||||
* Three-phase approach:
|
||||
* 1. Initial full sync: export-state from Chrome → import-state to Camoufox
|
||||
* 2. Real-time watch: subscribe to daemon /sync WS for cookie changes
|
||||
* 3. Debounced batching: accumulate changes over 50ms windows, apply in bulk
|
||||
*
|
||||
* Uses CamoufoxPool for shared context — adapters see the same cookies.
|
||||
*/
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
import type { BrowserContext } from 'playwright-core';
|
||||
import * as CamoufoxPool from './camoufox-pool.js';
|
||||
|
||||
export interface SyncServiceOptions {
|
||||
/** Daemon WebSocket URL base (default: ws://127.0.0.1:19825) */
|
||||
daemonUrl?: string;
|
||||
/** Camoufox WebSocket endpoint */
|
||||
camoufoxWs: string;
|
||||
/** Domains to watch (empty = all) */
|
||||
domains?: string[];
|
||||
/** Skip initial full sync (only watch changes) */
|
||||
skipInitialSync?: boolean;
|
||||
/** Callback on each synced event */
|
||||
onSync?: (event: SyncEvent) => void;
|
||||
/** Callback on initial sync complete */
|
||||
onInitialSync?: (stats: { cookies: number; localStorage: number }) => void;
|
||||
/** Callback on error */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface SyncEvent {
|
||||
type: 'state-change';
|
||||
changeType: 'cookie' | 'localStorage' | 'sessionStorage';
|
||||
domain: string;
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
expirationDate?: number;
|
||||
removed: boolean;
|
||||
cause?: string;
|
||||
};
|
||||
storage?: {
|
||||
key: string;
|
||||
newValue: string | null;
|
||||
oldValue: string | null;
|
||||
storageArea: 'localStorage' | 'sessionStorage';
|
||||
url: string;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Pending cookie change for batching */
|
||||
interface PendingCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
expires: number;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
export class LiveSyncService {
|
||||
private daemonWs: WebSocket | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private running = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stats = { cookies: 0, storage: 0, errors: 0, initialSync: false };
|
||||
|
||||
// Debounce batching
|
||||
private pendingCookies: PendingCookie[] = [];
|
||||
private batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly BATCH_WINDOW_MS = 50;
|
||||
|
||||
constructor(private readonly opts: SyncServiceOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.running = true;
|
||||
|
||||
// Acquire shared context from pool
|
||||
this.context = await CamoufoxPool.acquire(this.opts.camoufoxWs);
|
||||
|
||||
// Phase 1: Initial full sync (unless skipped)
|
||||
if (!this.opts.skipInitialSync) {
|
||||
await this.initialSync();
|
||||
}
|
||||
|
||||
// Phase 2: Start real-time watch
|
||||
this.connectToDaemon();
|
||||
await this.sendWatchCommand();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
|
||||
await this.sendUnwatchCommand().catch(() => {});
|
||||
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.batchTimer) clearTimeout(this.batchTimer);
|
||||
this.daemonWs?.close();
|
||||
this.daemonWs = null;
|
||||
|
||||
// Release pool reference (don't force-close — other users may hold refs)
|
||||
await CamoufoxPool.release();
|
||||
this.context = null;
|
||||
}
|
||||
|
||||
getStats() { return { ...this.stats }; }
|
||||
|
||||
// ── Phase 1: Initial full sync ───────────────────────────────────────
|
||||
|
||||
private async initialSync(): Promise<void> {
|
||||
const daemonHttp = this.getDaemonHttp();
|
||||
|
||||
// Export state from Chrome via daemon
|
||||
const domains = this.opts.domains ?? [];
|
||||
const domain = domains[0]; // Use first domain for navigation
|
||||
|
||||
const exportBody = JSON.stringify({
|
||||
id: `init-export-${Date.now()}`,
|
||||
action: 'export-state',
|
||||
...(domain ? { domain } : {}),
|
||||
});
|
||||
|
||||
const exportResp = await fetch(`${daemonHttp}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: exportBody,
|
||||
});
|
||||
const exportResult = await exportResp.json() as { ok: boolean; data?: any; error?: string };
|
||||
|
||||
if (!exportResult.ok) {
|
||||
this.opts.onError?.(new Error(`Initial export failed: ${exportResult.error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const state = exportResult.data;
|
||||
if (!state || !this.context) return;
|
||||
|
||||
// Import cookies to camoufox context
|
||||
if (state.cookies?.length) {
|
||||
const pwCookies = state.cookies.map((c: any) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path || '/',
|
||||
secure: c.secure ?? false,
|
||||
httpOnly: c.httpOnly ?? false,
|
||||
expires: c.expirationDate ?? -1,
|
||||
}));
|
||||
await this.context.addCookies(pwCookies);
|
||||
}
|
||||
|
||||
// Import localStorage — need a page on the target domain
|
||||
if (state.localStorage && Object.keys(state.localStorage).length > 0 && state.url) {
|
||||
let tempPage;
|
||||
try {
|
||||
tempPage = await this.context.newPage();
|
||||
await tempPage.goto(state.url, { waitUntil: 'load', timeout: 15000 });
|
||||
const entries = JSON.stringify(state.localStorage);
|
||||
await tempPage.evaluate(`(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.localStorage.setItem(k,v); })()`);
|
||||
} catch { /* non-critical */ }
|
||||
finally {
|
||||
try { await tempPage?.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
this.stats.initialSync = true;
|
||||
this.opts.onInitialSync?.({
|
||||
cookies: state.cookies?.length ?? 0,
|
||||
localStorage: Object.keys(state.localStorage ?? {}).length,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase 2: Real-time watch ─────────────────────────────────────────
|
||||
|
||||
private connectToDaemon(): void {
|
||||
const base = this.opts.daemonUrl ?? 'ws://127.0.0.1:19825';
|
||||
const syncUrl = `${base}/sync`;
|
||||
|
||||
this.daemonWs = new WebSocket(syncUrl);
|
||||
|
||||
this.daemonWs.on('message', (data) => {
|
||||
try {
|
||||
const event: SyncEvent = JSON.parse(data.toString());
|
||||
if (event.type === 'state-change') {
|
||||
this.handleSyncEvent(event);
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
});
|
||||
|
||||
this.daemonWs.on('close', () => {
|
||||
if (this.running) {
|
||||
this.reconnectTimer = setTimeout(() => this.connectToDaemon(), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
this.daemonWs.on('error', () => { /* triggers close */ });
|
||||
}
|
||||
|
||||
// ── Phase 3: Debounced batching ──────────────────────────────────────
|
||||
|
||||
private handleSyncEvent(event: SyncEvent): void {
|
||||
if (event.changeType === 'cookie' && event.cookie) {
|
||||
// Accumulate in batch
|
||||
this.pendingCookies.push({
|
||||
name: event.cookie.name,
|
||||
value: event.cookie.removed ? '' : event.cookie.value,
|
||||
domain: event.cookie.domain,
|
||||
path: event.cookie.path,
|
||||
secure: event.cookie.secure,
|
||||
httpOnly: event.cookie.httpOnly,
|
||||
expires: event.cookie.removed ? 0 : (event.cookie.expirationDate ?? -1),
|
||||
removed: event.cookie.removed,
|
||||
});
|
||||
|
||||
// Schedule batch flush
|
||||
if (!this.batchTimer) {
|
||||
this.batchTimer = setTimeout(() => this.flushCookieBatch(), this.BATCH_WINDOW_MS);
|
||||
}
|
||||
|
||||
this.opts.onSync?.(event);
|
||||
}
|
||||
|
||||
if (event.changeType === 'localStorage' && event.storage) {
|
||||
this.handleStorageEvent(event).catch(err => {
|
||||
this.stats.errors++;
|
||||
this.opts.onError?.(err);
|
||||
});
|
||||
this.opts.onSync?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
private async flushCookieBatch(): Promise<void> {
|
||||
this.batchTimer = null;
|
||||
if (!this.context || this.pendingCookies.length === 0) return;
|
||||
|
||||
const batch = this.pendingCookies.splice(0);
|
||||
|
||||
// Deduplicate: keep last change per name+domain+path
|
||||
const deduped = new Map<string, PendingCookie>();
|
||||
for (const c of batch) {
|
||||
deduped.set(`${c.name}|${c.domain}|${c.path}`, c);
|
||||
}
|
||||
|
||||
const cookies = [...deduped.values()].map(c => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
expires: c.expires,
|
||||
}));
|
||||
|
||||
try {
|
||||
await this.context.addCookies(cookies);
|
||||
this.stats.cookies += cookies.length;
|
||||
} catch (err) {
|
||||
this.stats.errors++;
|
||||
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStorageEvent(event: SyncEvent): Promise<void> {
|
||||
if (!this.context || !event.storage) return;
|
||||
|
||||
const pages = this.context.pages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageUrl = page.url();
|
||||
if (!pageUrl.includes(event.domain)) continue;
|
||||
if (event.storage.newValue !== null) {
|
||||
await page.evaluate(`window.localStorage.setItem(${JSON.stringify(event.storage.key)}, ${JSON.stringify(event.storage.newValue)})`);
|
||||
} else {
|
||||
await page.evaluate(`window.localStorage.removeItem(${JSON.stringify(event.storage.key)})`);
|
||||
}
|
||||
this.stats.storage++;
|
||||
} catch { /* page might be navigating */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private getDaemonHttp(): string {
|
||||
return (this.opts.daemonUrl ?? 'ws://127.0.0.1:19825').replace('ws://', 'http://');
|
||||
}
|
||||
|
||||
private async sendWatchCommand(): Promise<void> {
|
||||
const body = JSON.stringify({
|
||||
id: `watch-${Date.now()}`,
|
||||
action: 'watch-state',
|
||||
domains: this.opts.domains ?? [],
|
||||
});
|
||||
const resp = await fetch(`${this.getDaemonHttp()}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const result = await resp.json() as { ok: boolean; error?: string };
|
||||
if (!result.ok) throw new Error(`watch-state failed: ${result.error}`);
|
||||
}
|
||||
|
||||
private async sendUnwatchCommand(): Promise<void> {
|
||||
const body = JSON.stringify({
|
||||
id: `unwatch-${Date.now()}`,
|
||||
action: 'unwatch-state',
|
||||
});
|
||||
await fetch(`${this.getDaemonHttp()}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
|
||||
import type { IPage } from '../types.js';
|
||||
import { Page } from './page.js';
|
||||
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
||||
|
||||
@@ -55,7 +56,9 @@ export class BrowserBridge {
|
||||
}
|
||||
|
||||
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
|
||||
const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
|
||||
// Use default if not provided, zero, or negative
|
||||
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
||||
const timeoutMs = effectiveSeconds * 1000;
|
||||
|
||||
if (await isExtensionConnected()) return;
|
||||
if (await isDaemonRunning()) {
|
||||
@@ -110,10 +113,7 @@ export class BrowserBridge {
|
||||
throw new Error(
|
||||
'Failed to start opencli daemon. Try running manually:\n' +
|
||||
` node ${daemonPath}\n` +
|
||||
'Make sure port 19825 is available.',
|
||||
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use BrowserBridge instead */
|
||||
export const PlaywrightMCP = BrowserBridge;
|
||||
|
||||
+56
-61
@@ -11,10 +11,12 @@
|
||||
*/
|
||||
|
||||
import { formatSnapshot } from '../snapshotFormatter.js';
|
||||
import type { BrowserCookie, BrowserState, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
||||
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
||||
import { sendCommand } from './daemon-client.js';
|
||||
import { wrapForEval } from './utils.js';
|
||||
import { saveBase64ToFile } from '../utils.js';
|
||||
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
import { generateStealthJs } from './stealth.js';
|
||||
import {
|
||||
clickJs,
|
||||
typeTextJs,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
scrollJs,
|
||||
autoScrollJs,
|
||||
networkRequestsJs,
|
||||
waitForDomStableJs,
|
||||
} from './dom-helpers.js';
|
||||
|
||||
/**
|
||||
@@ -34,37 +37,52 @@ export class Page implements IPage {
|
||||
/** Active tab ID, set after navigate and used in all subsequent commands */
|
||||
private _tabId: number | undefined;
|
||||
|
||||
/** Helper: spread tabId into command params if we have one */
|
||||
private _tabOpt(): { tabId: number } | Record<string, never> {
|
||||
return this._tabId !== undefined ? { tabId: this._tabId } : {};
|
||||
/** Helper: spread workspace into command params */
|
||||
private _wsOpt(): { workspace: string } {
|
||||
return { workspace: this.workspace };
|
||||
}
|
||||
|
||||
private _workspaceOpt(): { workspace: string } {
|
||||
return { workspace: this.workspace };
|
||||
/** Helper: spread workspace + tabId into command params */
|
||||
private _cmdOpts(): Record<string, unknown> {
|
||||
return {
|
||||
workspace: this.workspace,
|
||||
...(this._tabId !== undefined && { tabId: this._tabId }),
|
||||
};
|
||||
}
|
||||
|
||||
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
|
||||
const result = await sendCommand('navigate', {
|
||||
url,
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
...this._cmdOpts(),
|
||||
}) as { tabId?: number };
|
||||
// Remember the tabId for subsequent exec calls
|
||||
if (result?.tabId) {
|
||||
this._tabId = result.tabId;
|
||||
}
|
||||
// Post-load settle: the extension already waits for tab.status === 'complete',
|
||||
// but SPA frameworks (React/Vue) need extra time to render after DOM load.
|
||||
// Inject stealth anti-detection patches (guard flag prevents double-injection).
|
||||
try {
|
||||
await sendCommand('exec', {
|
||||
code: generateStealthJs(),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
// Smart settle: use DOM stability detection instead of fixed sleep.
|
||||
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const settleMs = options?.settleMs ?? 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, settleMs));
|
||||
const maxMs = options?.settleMs ?? 1000;
|
||||
await sendCommand('exec', {
|
||||
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the automation window in the extension */
|
||||
async closeWindow(): Promise<void> {
|
||||
try {
|
||||
await sendCommand('close-window', { ...this._workspaceOpt() });
|
||||
await sendCommand('close-window', { ...this._wsOpt() });
|
||||
} catch {
|
||||
// Window may already be closed or daemon may be down
|
||||
}
|
||||
@@ -72,11 +90,11 @@ export class Page implements IPage {
|
||||
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
const code = wrapForEval(js);
|
||||
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
return sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
|
||||
const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts });
|
||||
const result = await sendCommand('cookies', { ...this._wsOpt(), ...opts });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
@@ -92,7 +110,7 @@ export class Page implements IPage {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await sendCommand('exec', { code: snapshotJs, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
|
||||
// The advanced engine already produces a clean, pruned, LLM-friendly output.
|
||||
// Do NOT pass through formatSnapshot — its format is incompatible.
|
||||
return result;
|
||||
@@ -132,7 +150,7 @@ export class Page implements IPage {
|
||||
return buildTree(document.body, 0);
|
||||
})()
|
||||
`;
|
||||
const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
if (opts.raw) return raw;
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
@@ -140,27 +158,27 @@ export class Page implements IPage {
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
const code = clickJs(ref);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
const code = typeTextJs(ref, text);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
const code = pressKeyJs(key);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async scrollTo(ref: string): Promise<unknown> {
|
||||
const code = scrollToRefJs(ref);
|
||||
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
return sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async getFormState(): Promise<Record<string, unknown>> {
|
||||
const code = getFormStateJs();
|
||||
return (await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() })) as Record<string, unknown>;
|
||||
return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async wait(options: number | WaitOptions): Promise<void> {
|
||||
@@ -168,37 +186,42 @@ export class Page implements IPage {
|
||||
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.time) {
|
||||
if (typeof options.time === 'number') {
|
||||
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
const code = waitForTextJs(options.text, timeout);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<unknown[]> {
|
||||
const result = await sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
|
||||
const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
|
||||
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
|
||||
// Invalidate cached tabId — the closed tab might have been our active one.
|
||||
// We can't know for sure (close-by-index doesn't return tabId), so reset.
|
||||
this._tabId = undefined;
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
|
||||
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
|
||||
if (result?.tabId) this._tabId = result.tabId;
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
|
||||
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
|
||||
if (result?.selected) this._tabId = result.selected;
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
|
||||
const code = networkRequestsJs(includeStatic);
|
||||
const result = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
const result = await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
@@ -220,19 +243,14 @@ export class Page implements IPage {
|
||||
*/
|
||||
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
|
||||
const base64 = await sendCommand('screenshot', {
|
||||
...this._workspaceOpt(),
|
||||
...this._cmdOpts(),
|
||||
format: options.format,
|
||||
quality: options.quality,
|
||||
fullPage: options.fullPage,
|
||||
...this._tabOpt(),
|
||||
}) as string;
|
||||
|
||||
if (options.path) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.dirname(options.path);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
|
||||
await saveBase64ToFile(base64, options.path);
|
||||
}
|
||||
|
||||
return base64;
|
||||
@@ -240,14 +258,14 @@ export class Page implements IPage {
|
||||
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
const code = scrollJs(direction, amount);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
|
||||
const times = options.times ?? 3;
|
||||
const delayMs = options.delayMs ?? 2000;
|
||||
const code = autoScrollJs(times, delayMs);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
@@ -266,29 +284,6 @@ export class Page implements IPage {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async exportState(opts: { domain?: string } = {}): Promise<BrowserState> {
|
||||
const result = await sendCommand('export-state', {
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
...(opts.domain ? { domain: opts.domain } : {}),
|
||||
});
|
||||
return result as BrowserState;
|
||||
}
|
||||
|
||||
async importState(state: BrowserState): Promise<void> {
|
||||
await sendCommand('import-state', {
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
state: {
|
||||
cookies: state.cookies,
|
||||
localStorage: state.localStorage,
|
||||
sessionStorage: state.sessionStorage,
|
||||
indexedDB: state.indexedDB,
|
||||
url: state.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (End of file)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Stealth anti-detection module.
|
||||
*
|
||||
* Generates JS code that patches browser globals to hide automation
|
||||
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
|
||||
* plugin list). Injected before page scripts run so that websites cannot
|
||||
* detect CDP / extension-based control.
|
||||
*
|
||||
* Inspired by puppeteer-extra-plugin-stealth.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return a self-contained JS string that, when evaluated in a page context,
|
||||
* applies all stealth patches. Safe to call multiple times — the guard flag
|
||||
* ensures patches are applied only once.
|
||||
*/
|
||||
export function generateStealthJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
// Guard: prevent double-injection across separate CDP evaluations.
|
||||
// We cannot use a closure variable (each eval is a fresh scope), and
|
||||
// window properties / Symbols are discoverable by anti-bot scripts.
|
||||
// Instead, stash the flag in a non-enumerable getter on a built-in
|
||||
// prototype that fingerprinters are unlikely to scan.
|
||||
const _gProto = EventTarget.prototype;
|
||||
const _gKey = '__lsn'; // looks like an internal listener cache
|
||||
if (_gProto[_gKey]) return 'skipped';
|
||||
try {
|
||||
Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true });
|
||||
} catch {}
|
||||
|
||||
// 1. navigator.webdriver → false
|
||||
// Most common check; Playwright/Puppeteer/CDP set this to true.
|
||||
// Real Chrome returns false (not undefined) — returning undefined is
|
||||
// itself a detection signal for advanced fingerprinters.
|
||||
try {
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// 2. window.chrome stub
|
||||
// Real Chrome exposes window.chrome with runtime, loadTimes, csi.
|
||||
// Headless/automated Chrome may not have it.
|
||||
try {
|
||||
if (!window.chrome) {
|
||||
window.chrome = {
|
||||
runtime: {
|
||||
onConnect: { addListener: () => {}, removeListener: () => {} },
|
||||
onMessage: { addListener: () => {}, removeListener: () => {} },
|
||||
},
|
||||
loadTimes: () => ({}),
|
||||
csi: () => ({}),
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 3. navigator.plugins — fake population only if empty
|
||||
// Real user browser already has plugins; only patch in automated/headless
|
||||
// contexts where the list is empty (overwriting real plugins with fakes
|
||||
// would be counterproductive and detectable).
|
||||
try {
|
||||
if (!navigator.plugins || navigator.plugins.length === 0) {
|
||||
const fakePlugins = [
|
||||
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
|
||||
];
|
||||
fakePlugins.item = (i) => fakePlugins[i] || null;
|
||||
fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null;
|
||||
fakePlugins.refresh = () => {};
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => fakePlugins,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 4. navigator.languages — guarantee non-empty
|
||||
// Some automated contexts return undefined or empty array.
|
||||
try {
|
||||
if (!navigator.languages || navigator.languages.length === 0) {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en'],
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 5. Permissions.query — normalize notification permission
|
||||
// Headless Chrome throws on Permissions.query({ name: 'notifications' }).
|
||||
try {
|
||||
const origQuery = window.Permissions?.prototype?.query;
|
||||
if (origQuery) {
|
||||
window.Permissions.prototype.query = function (parameters) {
|
||||
if (parameters?.name === 'notifications') {
|
||||
return Promise.resolve({ state: Notification.permission, onchange: null });
|
||||
}
|
||||
return origQuery.call(this, parameters);
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 6. Clean automation artifacts
|
||||
// Remove properties left by Playwright, Puppeteer, or CDP injection.
|
||||
try {
|
||||
delete window.__playwright;
|
||||
delete window.__puppeteer;
|
||||
// ChromeDriver injects cdc_ prefixed globals; the suffix varies by version,
|
||||
// so scan window for any matching property rather than hardcoding names.
|
||||
for (const prop of Object.getOwnPropertyNames(window)) {
|
||||
if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) {
|
||||
try { delete window[prop]; } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 7. CDP stack trace cleanup
|
||||
// Runtime.evaluate injects scripts whose source URLs appear in Error
|
||||
// stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://).
|
||||
// Websites detect automation by doing: new Error().stack and inspecting it.
|
||||
// We override the stack property getter on Error.prototype to filter them.
|
||||
// Note: Error.prepareStackTrace is V8/Node-only and not available in
|
||||
// browser page context, so we use a property descriptor approach instead.
|
||||
// We use generic protocol patterns instead of product-specific names to
|
||||
// also catch our own injected code frames without leaking identifiers.
|
||||
try {
|
||||
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
|
||||
const _cdpPatterns = [
|
||||
'puppeteer_evaluation_script',
|
||||
'pptr:',
|
||||
'debugger://',
|
||||
'__playwright',
|
||||
'__puppeteer',
|
||||
];
|
||||
if (_origDescriptor && _origDescriptor.get) {
|
||||
Object.defineProperty(Error.prototype, 'stack', {
|
||||
get: function () {
|
||||
const raw = _origDescriptor.get.call(this);
|
||||
if (typeof raw !== 'string') return raw;
|
||||
return raw.split('\\n').filter(line =>
|
||||
!_cdpPatterns.some(p => line.includes(p))
|
||||
).join('\\n');
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return 'applied';
|
||||
})()
|
||||
`;
|
||||
}
|
||||
+6
-31
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLIS_DIR = path.resolve(__dirname, 'clis');
|
||||
@@ -45,37 +46,10 @@ export interface ManifestEntry {
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
interface YamlArgDefinition {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
description?: string;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
import type { YamlCliDefinition } from './yaml-schema.js';
|
||||
|
||||
interface YamlCliDefinition {
|
||||
site?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
domain?: string;
|
||||
strategy?: string;
|
||||
browser?: boolean;
|
||||
args?: Record<string, YamlArgDefinition>;
|
||||
columns?: string[];
|
||||
pipeline?: Record<string, unknown>[];
|
||||
timeout?: number;
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function extractBalancedBlock(
|
||||
source: string,
|
||||
@@ -181,7 +155,8 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
|
||||
choices: parseInlineChoices(body),
|
||||
});
|
||||
|
||||
cursor = objectStart + body.length + 2;
|
||||
cursor = objectStart + body.length;
|
||||
if (cursor <= objectStart) break; // safety: prevent infinite loop
|
||||
}
|
||||
|
||||
return args;
|
||||
@@ -302,7 +277,7 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
|
||||
* prefer the TS version (it self-registers and typically has richer logic).
|
||||
*/
|
||||
export function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean {
|
||||
if (current.type === next.type) return true;
|
||||
if (current.type === next.type) return false;
|
||||
return current.type === 'yaml' && next.type === 'ts';
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -145,9 +145,10 @@ export async function cascadeProbe(
|
||||
url: string,
|
||||
opts: { maxStrategy?: Strategy; timeout?: number } = {},
|
||||
): Promise<CascadeResult> {
|
||||
const maxIdx = opts.maxStrategy
|
||||
const rawIdx = opts.maxStrategy
|
||||
? CASCADE_ORDER.indexOf(opts.maxStrategy)
|
||||
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
|
||||
const maxIdx = rawIdx === -1 ? CASCADE_ORDER.indexOf(Strategy.HEADER) : rawIdx;
|
||||
|
||||
const probes: ProbeResult[] = [];
|
||||
|
||||
|
||||
+53
-341
@@ -173,8 +173,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const r = await generateCliFromUrl({
|
||||
url,
|
||||
BrowserFactory: getBrowserFactory(),
|
||||
builtinClis: BUILTIN_CLIS,
|
||||
userClis: USER_CLIS,
|
||||
goal: opts.goal,
|
||||
site: opts.site,
|
||||
workspace,
|
||||
@@ -183,6 +181,30 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
process.exitCode = r.ok ? 0 : 1;
|
||||
});
|
||||
|
||||
// ── Built-in: record ─────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('record')
|
||||
.description('Record API calls from a live browser session → generate YAML candidates')
|
||||
.argument('<url>', 'URL to open and record')
|
||||
.option('--site <name>', 'Site name (inferred from URL if omitted)')
|
||||
.option('--out <dir>', 'Output directory for candidates')
|
||||
.option('--poll <ms>', 'Poll interval in milliseconds', '2000')
|
||||
.option('--timeout <ms>', 'Auto-stop after N milliseconds (default: 60000)', '60000')
|
||||
.action(async (url, opts) => {
|
||||
const { recordSession, renderRecordSummary } = await import('./record.js');
|
||||
const result = await recordSession({
|
||||
BrowserFactory: getBrowserFactory(),
|
||||
url,
|
||||
site: opts.site,
|
||||
outDir: opts.out,
|
||||
pollMs: parseInt(opts.poll, 10),
|
||||
timeoutMs: parseInt(opts.timeout, 10),
|
||||
});
|
||||
console.log(renderRecordSummary(result));
|
||||
process.exitCode = result.candidateCount > 0 ? 0 : 1;
|
||||
});
|
||||
|
||||
program
|
||||
.command('cascade')
|
||||
.description('Strategy cascade: find simplest working strategy')
|
||||
@@ -202,12 +224,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log(renderCascadeResult(result));
|
||||
});
|
||||
|
||||
// ── Built-in: doctor / setup / completion ─────────────────────────────────
|
||||
// ── Built-in: doctor / completion ──────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Diagnose opencli browser bridge connectivity')
|
||||
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
||||
.option('--no-live', 'Skip live browser connectivity test')
|
||||
.option('--sessions', 'Show active automation sessions', false)
|
||||
.action(async (opts) => {
|
||||
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
|
||||
@@ -215,14 +237,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log(renderBrowserDoctorReport(report));
|
||||
});
|
||||
|
||||
program
|
||||
.command('setup')
|
||||
.description('Interactive setup: verify browser bridge connectivity')
|
||||
.action(async () => {
|
||||
const { runSetup } = await import('./setup.js');
|
||||
await runSetup({ cliVersion: PKG_VERSION });
|
||||
});
|
||||
|
||||
program
|
||||
.command('completion')
|
||||
.description('Output shell completion script')
|
||||
@@ -231,315 +245,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
printCompletionScript(shell);
|
||||
});
|
||||
|
||||
// ── Built-in: browser state management ────────────────────────────────────
|
||||
|
||||
const browserCmd = program.command('browser').description('Browser state management');
|
||||
|
||||
browserCmd
|
||||
.command('export-state')
|
||||
.description('Export browser state (cookies, storage, IndexedDB) to JSON')
|
||||
.option('-d, --domain <domain>', 'Filter cookies by domain')
|
||||
.option('-o, --output <file>', 'Output file path', 'browser-state.json')
|
||||
.action(async (opts) => {
|
||||
const fs = await import('node:fs');
|
||||
const BrowserFactory = getBrowserFactory();
|
||||
const state = await browserSession(BrowserFactory, async (page) => {
|
||||
if (opts.domain) {
|
||||
try { await page.goto(`https://${opts.domain}`); await page.wait(2); } catch {}
|
||||
}
|
||||
return page.exportState({ domain: opts.domain });
|
||||
}, { workspace: 'state:export' });
|
||||
fs.writeFileSync(opts.output, JSON.stringify(state, null, 2));
|
||||
const stats = {
|
||||
cookies: state.cookies?.length ?? 0,
|
||||
localStorage: Object.keys(state.localStorage ?? {}).length,
|
||||
sessionStorage: Object.keys(state.sessionStorage ?? {}).length,
|
||||
indexedDB: (state.indexedDB ?? []).length,
|
||||
};
|
||||
console.log(chalk.green(`✅ Browser state exported to ${opts.output}`));
|
||||
console.log(chalk.dim(` ${stats.cookies} cookies, ${stats.localStorage} localStorage, ${stats.sessionStorage} sessionStorage, ${stats.indexedDB} IndexedDB databases`));
|
||||
});
|
||||
|
||||
browserCmd
|
||||
.command('import-state')
|
||||
.description('Import browser state from JSON file')
|
||||
.argument('<file>', 'Path to browser state JSON file')
|
||||
.action(async (file) => {
|
||||
const fs = await import('node:fs');
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error(chalk.red(`File not found: ${file}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const state = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const BrowserFactory = getBrowserFactory();
|
||||
await browserSession(BrowserFactory, async (page) => {
|
||||
await page.importState(state);
|
||||
}, { workspace: 'state:import' });
|
||||
console.log(chalk.green(`✅ Browser state imported from ${file}`));
|
||||
});
|
||||
|
||||
browserCmd
|
||||
.command('sync')
|
||||
.description('Sync browser state from Chrome to Camoufox')
|
||||
.option('-d, --domain <domain>', 'Filter by domain')
|
||||
.action(async (opts) => {
|
||||
// Step 1: Export from Chrome (daemon mode)
|
||||
const { BrowserBridge } = await import('./browser/index.js');
|
||||
const chromeBridge = new BrowserBridge();
|
||||
let state;
|
||||
try {
|
||||
const chromePage = await chromeBridge.connect({ workspace: 'state:sync-export' });
|
||||
if (opts.domain) {
|
||||
try { await chromePage.goto(`https://${opts.domain}`); await chromePage.wait(2); } catch {}
|
||||
}
|
||||
state = await chromePage.exportState({ domain: opts.domain });
|
||||
console.log(chalk.cyan(`📤 Exported from Chrome: ${state.cookies.length} cookies, ${Object.keys(state.localStorage).length} localStorage entries`));
|
||||
} finally {
|
||||
await chromeBridge.close().catch(() => {});
|
||||
}
|
||||
|
||||
// Step 2: Import to Camoufox
|
||||
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS;
|
||||
if (!wsEndpoint) {
|
||||
// Save to file if camoufox is not running
|
||||
const fs = await import('node:fs');
|
||||
const outFile = `${opts.domain || 'browser'}-state.json`;
|
||||
fs.writeFileSync(outFile, JSON.stringify(state, null, 2));
|
||||
console.log(chalk.yellow(`⚠️ Camoufox not running (OPENCLI_CAMOUFOX_WS not set). State saved to ${outFile}`));
|
||||
console.log(chalk.dim(` Start camoufox, then: opencli browser import-state ${outFile}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const { CamoufoxBridge } = await import('./browser/index.js');
|
||||
const cfBridge = new CamoufoxBridge();
|
||||
try {
|
||||
const cfPage = await cfBridge.connect({ workspace: 'state:sync-import' });
|
||||
await cfPage.importState(state);
|
||||
console.log(chalk.green(`📥 Imported to Camoufox — login state synced!`));
|
||||
} finally {
|
||||
await cfBridge.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
browserCmd
|
||||
.command('watch')
|
||||
.description('Live sync: initial full sync + stream changes from Chrome → Camoufox')
|
||||
.option('-d, --domain <domains>', 'Domains to watch (comma-separated)', '')
|
||||
.option('--skip-initial', 'Skip initial full sync, only watch changes')
|
||||
.action(async (opts) => {
|
||||
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS;
|
||||
if (!wsEndpoint) {
|
||||
console.error(chalk.red('OPENCLI_CAMOUFOX_WS is not set. Start camoufox first: opencli camoufox start'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const domains = opts.domain ? opts.domain.split(',').map((d: string) => d.trim()).filter(Boolean) : [];
|
||||
const { LiveSyncService } = await import('./browser/index.js');
|
||||
|
||||
const service = new LiveSyncService({
|
||||
camoufoxWs: wsEndpoint,
|
||||
domains,
|
||||
skipInitialSync: opts.skipInitial,
|
||||
onInitialSync: (stats) => {
|
||||
console.log(chalk.green(`📦 Initial sync done: ${stats.cookies} cookies, ${stats.localStorage} localStorage entries`));
|
||||
},
|
||||
onSync: (event) => {
|
||||
if (event.changeType === 'cookie' && event.cookie) {
|
||||
const action = event.cookie.removed ? chalk.red('DEL') : chalk.green('SET');
|
||||
console.log(`${chalk.dim(new Date().toLocaleTimeString())} ${action} cookie ${chalk.cyan(event.cookie.name)} @ ${event.domain}`);
|
||||
}
|
||||
if (event.changeType === 'localStorage' && event.storage) {
|
||||
console.log(`${chalk.dim(new Date().toLocaleTimeString())} ${chalk.yellow('UPD')} localStorage ${chalk.cyan(event.storage.key)} @ ${event.domain}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(chalk.red(`Sync error: ${err.message}`));
|
||||
},
|
||||
});
|
||||
|
||||
console.log(chalk.cyan(`🔄 Live sync starting: Chrome → Camoufox`));
|
||||
if (domains.length) console.log(chalk.dim(` Watching: ${domains.join(', ')}`));
|
||||
else console.log(chalk.dim(` Watching: all domains`));
|
||||
|
||||
await service.start();
|
||||
|
||||
console.log(chalk.cyan('👀 Watching for changes... (Ctrl+C to stop)'));
|
||||
|
||||
// Keep running until Ctrl+C
|
||||
process.on('SIGINT', async () => {
|
||||
console.log(chalk.yellow('\n🛑 Stopping live sync...'));
|
||||
const stats = service.getStats();
|
||||
await service.stop();
|
||||
console.log(chalk.dim(` Synced ${stats.cookies} cookies, ${stats.storage} storage changes, ${stats.errors} errors`));
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Built-in: camoufox lifecycle ──────────────────────────────────────────
|
||||
|
||||
const camoufoxCmd = program.command('camoufox').description('Manage Camoufox browser');
|
||||
|
||||
camoufoxCmd
|
||||
.command('setup')
|
||||
.description('Install Camoufox (requires Python)')
|
||||
.action(async () => {
|
||||
const { execSync } = await import('node:child_process');
|
||||
try {
|
||||
// Check Python
|
||||
try {
|
||||
execSync('python3 --version', { stdio: 'pipe' });
|
||||
} catch {
|
||||
console.error(chalk.red('Python 3 is required but not found. Install it first.'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.cyan('📦 Installing camoufox...'));
|
||||
execSync('pip3 install -U camoufox', { stdio: 'inherit' });
|
||||
|
||||
console.log(chalk.cyan('📥 Fetching camoufox browser...'));
|
||||
execSync('python3 -m camoufox fetch', { stdio: 'inherit' });
|
||||
|
||||
console.log(chalk.green('✅ Camoufox installed successfully!'));
|
||||
console.log(chalk.dim(' Start with: opencli camoufox start'));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Setup failed: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
camoufoxCmd
|
||||
.command('start')
|
||||
.description('Start Camoufox server')
|
||||
.option('-p, --port <port>', 'WebSocket port')
|
||||
.option('--no-headless', 'Run with visible GUI')
|
||||
.option('--import <file>', 'Import state file after starting')
|
||||
.action(async (opts) => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const path = await import('node:path');
|
||||
const url = await import('node:url');
|
||||
|
||||
console.log(chalk.cyan(`🦊 Starting Camoufox server...`));
|
||||
|
||||
// Use our Python launcher script for reliable WS endpoint parsing
|
||||
const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const launcherScript = path.resolve(scriptDir, '..', 'scripts', 'camoufox_server.py');
|
||||
const args = [launcherScript];
|
||||
if (opts.headless) args.push('--headless');
|
||||
if (opts.port) args.push('--port', opts.port);
|
||||
|
||||
const child = spawn('python3', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// Parse the first line of stdout — JSON with ws_endpoint
|
||||
const wsEndpoint = await new Promise<string>((resolve, reject) => {
|
||||
let output = '';
|
||||
const timeout = setTimeout(() => reject(new Error('Camoufox failed to start within 30 seconds')), 30000);
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const data = JSON.parse(trimmed);
|
||||
clearTimeout(timeout);
|
||||
if (data.error) { reject(new Error(data.error)); return; }
|
||||
if (data.ws_endpoint) { resolve(data.ws_endpoint); return; }
|
||||
} catch { /* not JSON yet, keep reading */ }
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code !== 0) reject(new Error(`Camoufox exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
console.log(chalk.green(`✅ Camoufox running at ${wsEndpoint}`));
|
||||
console.log(chalk.dim(` Set: export OPENCLI_CAMOUFOX_WS=${wsEndpoint}`));
|
||||
console.log(chalk.dim(` PID: ${child.pid}`));
|
||||
|
||||
// Import state if requested
|
||||
if (opts.import) {
|
||||
const fs = await import('node:fs');
|
||||
if (fs.existsSync(opts.import)) {
|
||||
const state = JSON.parse(fs.readFileSync(opts.import, 'utf-8'));
|
||||
const { CamoufoxBridge } = await import('./browser/index.js');
|
||||
process.env.OPENCLI_CAMOUFOX_WS = wsEndpoint;
|
||||
const bridge = new CamoufoxBridge();
|
||||
try {
|
||||
const page = await bridge.connect({ workspace: 'camoufox:import' });
|
||||
await page.importState(state);
|
||||
console.log(chalk.green(`📥 State imported from ${opts.import}`));
|
||||
} finally {
|
||||
await bridge.close().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
console.error(chalk.yellow(`⚠️ State file not found: ${opts.import}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
camoufoxCmd
|
||||
.command('status')
|
||||
.description('Check Camoufox server status')
|
||||
.action(async () => {
|
||||
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS;
|
||||
if (!wsEndpoint) {
|
||||
console.log(chalk.yellow(`❌ OPENCLI_CAMOUFOX_WS is not set`));
|
||||
console.log(chalk.dim(' Start with: opencli camoufox start'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { firefox } = await import('playwright-core');
|
||||
const browser = await firefox.connect(wsEndpoint, { timeout: 3000 });
|
||||
const version = browser.version();
|
||||
await browser.close();
|
||||
console.log(chalk.green(`✅ Camoufox running at ${wsEndpoint}`));
|
||||
console.log(chalk.dim(` Browser version: ${version}`));
|
||||
} catch {
|
||||
console.log(chalk.yellow(`❌ Camoufox not reachable at ${wsEndpoint}`));
|
||||
console.log(chalk.dim(' Restart with: opencli camoufox start'));
|
||||
}
|
||||
});
|
||||
|
||||
camoufoxCmd
|
||||
.command('stop')
|
||||
.description('Stop a running Camoufox server')
|
||||
.action(async () => {
|
||||
const { execSync } = await import('node:child_process');
|
||||
try {
|
||||
// Find camoufox server processes
|
||||
const result = execSync("ps aux | grep 'camoufox_server\\|camoufox.*server' | grep -v grep", { encoding: 'utf-8' }).trim();
|
||||
if (!result) {
|
||||
console.log(chalk.yellow('No camoufox server process found'));
|
||||
return;
|
||||
}
|
||||
const lines = result.split('\n');
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[1];
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(parseInt(pid, 10), 'SIGTERM');
|
||||
console.log(chalk.green(`✅ Stopped camoufox server (PID: ${pid})`));
|
||||
} catch { /* already dead */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.log(chalk.yellow('No camoufox server process found'));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Plugin management ──────────────────────────────────────────────────────
|
||||
|
||||
const pluginCmd = program.command('plugin').description('Manage opencli plugins');
|
||||
@@ -550,10 +255,11 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
.argument('<source>', 'Plugin source (e.g. github:user/repo)')
|
||||
.action(async (source: string) => {
|
||||
const { installPlugin } = await import('./plugin.js');
|
||||
const { discoverPlugins } = await import('./discovery.js');
|
||||
try {
|
||||
const name = installPlugin(source);
|
||||
console.log(chalk.green(`✅ Plugin "${name}" installed successfully.`));
|
||||
console.log(chalk.dim(` Restart opencli to use the new commands.`));
|
||||
await discoverPlugins();
|
||||
console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
@@ -575,6 +281,24 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
}
|
||||
});
|
||||
|
||||
pluginCmd
|
||||
.command('update')
|
||||
.description('Update a plugin to the latest version')
|
||||
.argument('<name>', 'Plugin name')
|
||||
.action(async (name: string) => {
|
||||
const { updatePlugin } = await import('./plugin.js');
|
||||
const { discoverPlugins } = await import('./discovery.js');
|
||||
try {
|
||||
updatePlugin(name);
|
||||
await discoverPlugins();
|
||||
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
pluginCmd
|
||||
.command('list')
|
||||
.description('List installed plugins')
|
||||
@@ -682,29 +406,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
registerAllCommands(program, siteGroups);
|
||||
|
||||
// ── Unknown command fallback ──────────────────────────────────────────────
|
||||
|
||||
const DENY_LIST = new Set([
|
||||
'rm', 'sudo', 'dd', 'mkfs', 'fdisk', 'shutdown', 'reboot',
|
||||
'kill', 'killall', 'chmod', 'chown', 'passwd', 'su', 'mount',
|
||||
'umount', 'format', 'diskutil',
|
||||
]);
|
||||
// Security: do NOT auto-discover and register arbitrary system binaries.
|
||||
// Only explicitly registered external CLIs (via `opencli register`) are allowed.
|
||||
|
||||
program.on('command:*', (operands: string[]) => {
|
||||
const binary = operands[0];
|
||||
if (DENY_LIST.has(binary)) {
|
||||
console.error(chalk.red(`Refusing to register system command '${binary}'.`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`error: unknown command '${binary}'`));
|
||||
if (isBinaryInstalled(binary)) {
|
||||
console.log(chalk.cyan(`🔹 Auto-discovered local CLI '${binary}'. Registering...`));
|
||||
registerExternalCli(binary);
|
||||
passthroughExternal(binary);
|
||||
} else {
|
||||
console.error(chalk.red(`error: unknown command '${binary}'`));
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
|
||||
}
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Shared command factories for Electron/desktop app adapters.
|
||||
* Eliminates duplicate screenshot/status/new/dump implementations
|
||||
* across cursor, codex, chatwise, etc.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Factory: capture DOM HTML + accessibility snapshot.
|
||||
*/
|
||||
export function makeScreenshotCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'screenshot',
|
||||
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
|
||||
],
|
||||
columns: ['Status', 'File'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
|
||||
|
||||
const snap = await page.snapshot({ compact: true });
|
||||
const html = await page.evaluate('document.documentElement.outerHTML');
|
||||
|
||||
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
|
||||
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
|
||||
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{ Status: 'Success', File: htmlPath },
|
||||
{ Status: 'Success', File: snapPath },
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: check CDP connection status.
|
||||
*/
|
||||
export function makeStatusCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'status',
|
||||
description: `Check active CDP connection to ${label}`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
return [{ Status: 'Connected', Url: url, Title: title }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: start a new session via Cmd/Ctrl+N.
|
||||
*/
|
||||
export function makeNewCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'new',
|
||||
description: `Start a new ${label} session`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
return [{ Status: 'Success' }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: dump DOM + snapshot for reverse-engineering.
|
||||
*/
|
||||
export function makeDumpCommand(site: string) {
|
||||
return cli({
|
||||
site,
|
||||
name: 'dump',
|
||||
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['action', 'files'],
|
||||
func: async (page: IPage) => {
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
|
||||
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'Dom extraction finished',
|
||||
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ cli({
|
||||
columns: ['id', 'title', 'authors', 'published'],
|
||||
func: async (_page, args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
const query = encodeURIComponent(`all:${args.keyword}`);
|
||||
const query = encodeURIComponent(`all:${args.query}`);
|
||||
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
|
||||
const entries = parseEntries(xml);
|
||||
if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
|
||||
|
||||
@@ -8,18 +8,9 @@
|
||||
* - yt-dlp must be installed: pip install yt-dlp
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
ytdlpDownload,
|
||||
checkYtdlp,
|
||||
sanitizeFilename,
|
||||
getTempDir,
|
||||
exportCookiesToNetscape,
|
||||
formatCookieHeader,
|
||||
} from '../../download/index.js';
|
||||
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
||||
import { checkYtdlp, sanitizeFilename } from '../../download/index.js';
|
||||
import { downloadMedia } from '../../download/media-download.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -63,21 +54,8 @@ cli({
|
||||
|
||||
const title = sanitizeFilename(data?.title || 'video');
|
||||
|
||||
// Extract cookies for authenticated downloads
|
||||
const cookies = await page.getCookies({ domain: 'bilibili.com' });
|
||||
const cookieString = formatCookieHeader(cookies);
|
||||
|
||||
// Create output directory
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
|
||||
// Export cookies to Netscape format for yt-dlp
|
||||
let cookiesFile: string | undefined;
|
||||
if (cookies.length > 0) {
|
||||
const tempDir = getTempDir();
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
cookiesFile = path.join(tempDir, `bilibili_cookies_${Date.now()}.txt`);
|
||||
exportCookiesToNetscape(cookies, cookiesFile);
|
||||
}
|
||||
// Extract cookies for yt-dlp
|
||||
const browserCookies = await page.getCookies({ domain: 'bilibili.com' });
|
||||
|
||||
// Build yt-dlp format string based on quality
|
||||
let format = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best';
|
||||
@@ -89,62 +67,26 @@ cli({
|
||||
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
|
||||
}
|
||||
|
||||
const destPath = path.join(output, `${bvid}_${title}.mp4`);
|
||||
const videoUrl = `https://www.bilibili.com/video/${bvid}`;
|
||||
const filename = `${bvid}_${title}.mp4`;
|
||||
|
||||
const tracker = new DownloadProgressTracker(1, true);
|
||||
const progressBar = tracker.onFileStart(`${bvid}.mp4`, 0);
|
||||
const results = await downloadMedia(
|
||||
[{ type: 'video-ytdlp', url: videoUrl, filename }],
|
||||
{
|
||||
output,
|
||||
browserCookies,
|
||||
filenamePrefix: bvid,
|
||||
ytdlpExtraArgs: ['-f', format, '--merge-output-format', 'mp4', '--embed-thumbnail'],
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await ytdlpDownload(
|
||||
`https://www.bilibili.com/video/${bvid}`,
|
||||
destPath,
|
||||
{
|
||||
cookiesFile,
|
||||
format,
|
||||
extraArgs: [
|
||||
'--merge-output-format', 'mp4',
|
||||
'--embed-thumbnail',
|
||||
],
|
||||
onProgress: (percent) => {
|
||||
if (progressBar) progressBar.update(percent, 100);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
|
||||
}
|
||||
|
||||
tracker.onFileComplete(result.success);
|
||||
tracker.finish();
|
||||
|
||||
// Cleanup cookies file
|
||||
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
||||
fs.unlinkSync(cookiesFile);
|
||||
}
|
||||
|
||||
return [{
|
||||
bvid,
|
||||
title: data?.title || 'video',
|
||||
status: result.success ? 'success' : 'failed',
|
||||
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
||||
}];
|
||||
} catch (err: any) {
|
||||
if (progressBar) progressBar.fail(err.message);
|
||||
tracker.onFileComplete(false);
|
||||
tracker.finish();
|
||||
|
||||
// Cleanup cookies file
|
||||
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
||||
fs.unlinkSync(cookiesFile);
|
||||
}
|
||||
|
||||
return [{
|
||||
bvid,
|
||||
title: data?.title || 'video',
|
||||
status: 'failed',
|
||||
size: err.message,
|
||||
}];
|
||||
}
|
||||
// Map results to bilibili-specific columns
|
||||
const r = results[0] || { status: 'failed', size: '-' };
|
||||
return [{
|
||||
bvid,
|
||||
title: data?.title || 'video',
|
||||
status: r.status,
|
||||
size: r.size,
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './dynamic.js';
|
||||
|
||||
describe('bilibili dynamic adapter', () => {
|
||||
const command = getRegistry().get('bilibili/dynamic');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
});
|
||||
|
||||
it('maps desc text rows from the dynamic feed payload', async () => {
|
||||
mockApiGet.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id_str: '123',
|
||||
modules: {
|
||||
module_author: { name: 'Alice' },
|
||||
module_dynamic: { desc: { text: 'hello world' } },
|
||||
module_stat: { like: { count: 9 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await command!.func!({} as any, { limit: 5 });
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith({}, '/x/polymer/web-dynamic/v1/feed/all', { params: {}, signed: false });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: '123',
|
||||
author: 'Alice',
|
||||
text: 'hello world',
|
||||
likes: 9,
|
||||
url: 'https://t.bilibili.com/123',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to archive title when desc text is absent', async () => {
|
||||
mockApiGet.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id_str: '456',
|
||||
modules: {
|
||||
module_author: { name: 'Bob' },
|
||||
module_dynamic: { major: { archive: { title: 'Video title' } } },
|
||||
module_stat: { like: { count: 3 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await command!.func!({} as any, { limit: 5 });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: '456',
|
||||
author: 'Bob',
|
||||
text: 'Video title',
|
||||
likes: 3,
|
||||
url: 'https://t.bilibili.com/456',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { apiGet, payloadData } from './utils.js';
|
||||
import { apiGet, payloadData, getSelfUid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -15,9 +15,12 @@ cli({
|
||||
func: async (page, kwargs) => {
|
||||
const { limit = 20, page: pageNum = 1 } = kwargs;
|
||||
|
||||
// Get current user's UID
|
||||
const uid = await getSelfUid(page);
|
||||
|
||||
// Get default favorite folder ID
|
||||
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
|
||||
params: { up_mid: 0 },
|
||||
params: { up_mid: uid },
|
||||
signed: true,
|
||||
});
|
||||
const folders = payloadData(foldersPayload)?.list ?? [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
|
||||
|
||||
@@ -8,13 +9,13 @@ cli({
|
||||
description: '获取 Bilibili 用户的关注列表',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'uid', required: false, help: '目标用户 ID(默认为当前登录用户)' },
|
||||
{ name: 'uid', positional: true, required: false, help: '目标用户 ID(默认为当前登录用户)' },
|
||||
{ name: 'page', type: 'int', required: false, default: 1, help: '页码' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 50, help: '每页数量 (最大 50)' },
|
||||
],
|
||||
columns: ['mid', 'name', 'sign', 'following', 'fans'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
|
||||
|
||||
// 1. Resolve UID (default to self)
|
||||
const uid = kwargs.uid
|
||||
@@ -30,7 +31,7 @@ cli({
|
||||
);
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
|
||||
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const list = payload.data?.list || [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { apiGet } from './utils.js';
|
||||
|
||||
@@ -13,7 +14,7 @@ cli({
|
||||
],
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
|
||||
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
|
||||
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
|
||||
|
||||
@@ -24,7 +25,7 @@ cli({
|
||||
})()`);
|
||||
|
||||
if (!cid) {
|
||||
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
}
|
||||
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
@@ -35,12 +36,12 @@ cli({
|
||||
});
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const subtitles = payload.data?.subtitle?.subtitles || [];
|
||||
if (subtitles.length === 0) {
|
||||
throw new Error('此视频没有发现外挂或智能字幕。');
|
||||
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
|
||||
}
|
||||
|
||||
// 4. 选择目标字幕语言
|
||||
@@ -50,7 +51,7 @@ cli({
|
||||
|
||||
const targetSubUrl = target.subtitle_url;
|
||||
if (!targetSubUrl || targetSubUrl === '') {
|
||||
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
}
|
||||
|
||||
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
|
||||
@@ -81,12 +82,12 @@ cli({
|
||||
const items = await page.evaluate(fetchJs);
|
||||
|
||||
if (items?.error) {
|
||||
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
}
|
||||
|
||||
const finalItems = items?.data || [];
|
||||
if (!Array.isArray(finalItems)) {
|
||||
throw new Error('解析到的字幕列表对象不符合数组格式');
|
||||
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
|
||||
}
|
||||
|
||||
// 6. 数据映射
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
const MIXIN_KEY_ENC_TAB = [
|
||||
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
|
||||
@@ -98,7 +99,7 @@ export async function fetchJson(page: IPage, url: string): Promise<any> {
|
||||
export async function getSelfUid(page: IPage): Promise<string> {
|
||||
const nav = await getNavData(page);
|
||||
const mid = nav?.data?.mid;
|
||||
if (!mid) throw new Error('Not logged in to Bilibili');
|
||||
if (!mid) throw new AuthRequiredError('bilibili.com');
|
||||
return String(mid);
|
||||
}
|
||||
|
||||
@@ -111,5 +112,5 @@ export async function resolveUid(page: IPage, input: string): Promise<string> {
|
||||
});
|
||||
const results = payload?.data?.result ?? [];
|
||||
if (results.length > 0) return String(results[0].mid);
|
||||
throw new Error(`Cannot resolve UID for: ${input}`);
|
||||
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
requirePage, navigateToChat, fetchRecommendList,
|
||||
clickCandidateInList, typeAndSendMessage, verbose,
|
||||
} from './common.js';
|
||||
} from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, fetchFriendList } from './common.js';
|
||||
import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './common.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './common.js';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
@@ -13,7 +13,7 @@ cli({
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'security-id', required: true, help: 'Security ID from search results (securityId field)' },
|
||||
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (securityId field)' },
|
||||
],
|
||||
columns: [
|
||||
'name', 'salary', 'experience', 'degree', 'city', 'district',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 exchange — request phone/wechat exchange with a candidate.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
requirePage, navigateToChat, findFriendByUid,
|
||||
clickCandidateInList, typeAndSendMessage, verbose,
|
||||
} from './common.js';
|
||||
} from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
@@ -16,7 +16,7 @@ cli({
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate (from recommend)' },
|
||||
{ name: 'uid', positional: true, required: true, help: 'Encrypted UID of the candidate (from recommend)' },
|
||||
{ name: 'security-id', required: true, help: 'Security ID of the candidate' },
|
||||
{ name: 'job-id', required: true, help: 'Encrypted job ID' },
|
||||
{ name: 'text', default: '', help: 'Custom greeting message (uses default template if empty)' },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 invite — send interview invitation to a candidate.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
@@ -13,7 +13,7 @@ cli({
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
|
||||
{ name: 'uid', positional: true, required: true, help: 'Encrypted UID of the candidate' },
|
||||
{ name: 'time', required: true, help: 'Interview time (e.g. 2025-04-01 14:00)' },
|
||||
{ name: 'address', default: '', help: 'Interview address (uses saved address if empty)' },
|
||||
{ name: 'contact', default: '', help: 'Contact person name (uses saved contact if empty)' },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 job list — list my published jobs via boss API.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, verbose } from './common.js';
|
||||
import { requirePage, navigateToChat, bossFetch, verbose } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
import { ArgumentError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
const LABEL_MAP: Record<string, number> = {
|
||||
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
|
||||
@@ -22,7 +23,7 @@ cli({
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
|
||||
{ name: 'uid', positional: true, required: true, help: 'Encrypted UID of the candidate' },
|
||||
{ name: 'label', required: true, help: 'Label name (新招呼/沟通中/已约面/已获取简历/已交换电话/已交换微信/不合适/收藏) or label ID' },
|
||||
{ name: 'remove', type: 'boolean', default: false, help: 'Remove the label instead of adding' },
|
||||
],
|
||||
@@ -44,7 +45,7 @@ cli({
|
||||
if (entry) {
|
||||
labelId = entry[1];
|
||||
} else {
|
||||
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
|
||||
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ cli({
|
||||
await navigateToChat(page);
|
||||
|
||||
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
|
||||
if (!friend) throw new Error('未找到该候选人');
|
||||
if (!friend) throw new EmptyResultError('boss candidate search');
|
||||
|
||||
const friendName = friend.name || '候选人';
|
||||
const action = remove ? 'deleteMark' : 'addMark';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 recommend — view recommended candidates (新招呼/greet sort list).
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './common.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* .position-content → job being discussed + expectation
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './common.js';
|
||||
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 job search — browser cookie API.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './common.js';
|
||||
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './utils.js';
|
||||
|
||||
/** City name → BOSS Zhipin city code mapping */
|
||||
const CITY_CODES: Record<string, string> = {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
requirePage, navigateToChat, findFriendByUid,
|
||||
clickCandidateInList, typeAndSendMessage,
|
||||
} from './common.js';
|
||||
import { EmptyResultError, SelectorError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
@@ -19,7 +20,7 @@ cli({
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
|
||||
{ name: 'uid', positional: true, required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
|
||||
{ name: 'text', required: true, positional: true, help: 'Message text to send' },
|
||||
],
|
||||
columns: ['status', 'detail'],
|
||||
@@ -29,21 +30,21 @@ cli({
|
||||
await navigateToChat(page, 3);
|
||||
|
||||
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
|
||||
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
|
||||
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
|
||||
|
||||
const numericUid = friend.uid;
|
||||
const friendName = friend.name || '候选人';
|
||||
|
||||
const clicked = await clickCandidateInList(page, numericUid);
|
||||
if (!clicked) {
|
||||
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
|
||||
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
}
|
||||
|
||||
await page.wait({ time: 2 });
|
||||
|
||||
const sent = await typeAndSendMessage(page, kwargs.text);
|
||||
if (!sent) {
|
||||
throw new Error('找不到消息输入框');
|
||||
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
}
|
||||
|
||||
await page.wait({ time: 1 });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 stats — job statistics overview.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './common.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
import {
|
||||
getCourses, initSession, enterCourse, getTabIframeUrl,
|
||||
parseAssignmentsFromDom, sleep,
|
||||
@@ -33,7 +34,7 @@ cli({
|
||||
|
||||
// 2. Get courses
|
||||
const courses = await getCourses(page);
|
||||
if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
|
||||
if (!courses.length) throw new AuthRequiredError('mooc2-ans.chaoxing.com', '未获取到课程列表');
|
||||
|
||||
const filtered = courseFilter
|
||||
? courses.filter(c => c.title.includes(courseFilter))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user