Compare commits

..

1 Commits

Author SHA1 Message Date
jackwener 428d6b3ac5 fix(daemon): harden security against browser CSRF attacks (#268)
- Add Origin header check: reject HTTP/WS from non chrome-extension:// origins
- Require X-OpenCLI custom header on all HTTP requests
- Remove Access-Control-Allow-Origin: * from all responses
- Add WebSocket verifyClient to reject malicious connections at upgrade
- Add 1MB body size limit to prevent OOM
- Update file header with security model documentation

Closes #268
2026-03-22 23:54:41 +08:00
235 changed files with 2594 additions and 9894 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
zip -r ../opencli-extension.zip .
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opencli-extension-build
path: |
+1 -18
View File
@@ -54,24 +54,7 @@ jobs:
run: npm ci
- name: Run unit tests (Node ${{ matrix.node-version }}, 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
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
# ── Smoke tests (scheduled / manual only) ──
smoke-test:
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger opencli-website rebuild
uses: peter-evans/repository-dispatch@v4
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
repository: jackwener/opencli-website
+25
View File
@@ -0,0 +1,25 @@
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 }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger website rebuild
uses: peter-evans/repository-dispatch@v4
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
repository: jackwener/opencli-website
-3
View File
@@ -19,6 +19,3 @@ docs/.vitepress/cache
.windsurf
.claude
.cortex
# Database files
*.db
-128
View File
@@ -1,133 +1,5 @@
# 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)
+3 -6
View File
@@ -17,8 +17,7 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npm test
npm run test:adapter
npx vitest run src/
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -162,8 +161,7 @@ args: [
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
```bash
npm test # Core unit tests (non-adapter)
npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili
npx vitest run src/ # Unit tests
npx vitest run tests/e2e/ # E2E tests
npx vitest run # All tests
```
@@ -196,8 +194,7 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel
3. Run the checks that apply:
```bash
npx tsc --noEmit # Type check
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if you touched adapter logic)
npx vitest run src/ # Unit tests
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
+11 -70
View File
@@ -23,31 +23,11 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
- **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, etc). Zero setup.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, 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
@@ -119,25 +99,22 @@ 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` `block` `unblock` `hide-reply` | 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 |
| **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` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
| **v2ex** | `hot` `latest` `topic` `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 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | Desktop |
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `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 |
@@ -145,36 +122,26 @@ Run `opencli list` for the live registry.
| **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` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
| **wikipedia** | `search` `summary` | Public |
| **hackernews** | `top` | Public |
| **linkedin** | `search` | Browser |
| **reuters** | `search` | Browser |
| **smzdm** | `search` | Browser |
| **weibo** | `hot` `search` | Browser |
| **weibo** | `hot` | Browser |
| **yahoo-finance** | `quote` | Browser |
| **sinafinance** | `news` | 🌐 Public |
| **barchart** | `quote` `options` `greeks` `flow` | Browser |
| **chaoxing** | `assignments` `exams` | Browser |
| **grok** | `ask` | Browser |
| **grok** | `ask` | Desktop |
| **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 |
| **douban** | `search` `top250` `subject` `marks` `reviews` | Browser |
### External CLI Hub
@@ -186,8 +153,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.
@@ -212,7 +179,6 @@ 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
@@ -226,7 +192,6 @@ 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
@@ -260,9 +225,6 @@ 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
```
@@ -282,25 +244,6 @@ 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:
@@ -348,8 +291,6 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
## License
[Apache-2.0](./LICENSE)
+11 -70
View File
@@ -25,31 +25,11 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker` 等本地 CLI
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker``kubectl` 等本地 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
@@ -121,25 +101,22 @@ 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` `block` `unblock` `hide-reply` | 浏览器 |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
| **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` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `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` | 浏览器 |
@@ -147,36 +124,26 @@ npm install -g @jackwener/opencli@latest
| **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` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **wikipedia** | `search` `summary` | 公开 |
| **hackernews** | `top` | 公共 API |
| **linkedin** | `search` | 浏览器 |
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **weibo** | `hot` `search` | 浏览器 |
| **weibo** | `hot` | 浏览器 |
| **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` | 浏览器 |
| **douban** | `search` `top250` `subject` `marks` `reviews` | 浏览器 |
### 外部 CLI 枢纽
@@ -188,8 +155,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 行为。
@@ -214,7 +181,6 @@ 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) |
## 下载支持
@@ -228,7 +194,6 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
### 前置依赖
@@ -262,9 +227,6 @@ 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
```
@@ -284,25 +246,6 @@ 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,请遵守以下工作流。
@@ -346,8 +289,6 @@ opencli cascade https://api.example.com/data
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
## License
[Apache-2.0](./LICENSE)
+6 -317
View File
@@ -1,9 +1,9 @@
---
name: opencli
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 1.3.1
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
author: jackwener
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
---
# OpenCLI
@@ -182,6 +182,7 @@ 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 # 股票行情
@@ -221,14 +222,6 @@ 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 路径
@@ -239,175 +232,6 @@ 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
@@ -435,26 +259,14 @@ 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,评论"
# Validate: validate adapter definitions
opencli validate
# Verify: validate adapter definitions
opencli verify
```
## Output Formats
@@ -477,129 +289,6 @@ 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: '流程实例 IDprocInsId' },
],
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]
+67 -85
View File
@@ -18,72 +18,57 @@
测试分为三层,全部使用 **vitest** 运行:
```text
```
tests/
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
│ ├── public-commands.test.ts # 公开 API 命令
│ ├── helpers.ts # runCli() 共享工具
│ ├── 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 # 输出格式校验
├── smoke/
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
│ ├── 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 可用性检测
src/
── **/*.test.ts # 单元测试(当前 32 个文件
── *.test.ts # 单元测试(已有 8 个
```
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|---|---|---:|---|---|
| 单元测试 | `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 与注册完整性 |
| 层 | 位置 | 运行方式 | 用途 |
|---|---|---|---|
| 单元测试 | `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 健康 |
---
## 当前覆盖范围
### 单元测试(32 个文件)
### 单元测试(8 个文件)
| 领域 | 文件 |
| 文件 | 覆盖内容 |
|---|---|
| 核心运行时与输出 | `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` |
| `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` | 数据变换步骤 |
这些测试覆盖的重点包括:
### E2E 测试(~52 个用例)
- Browser Bridge、DOM snapshot、interceptor、capability routing
- manifest 生成、命令发现、插件安装与注册表
- 输出格式渲染与 snapshot formatting
- pipeline 模板求值、执行器与变换步骤
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
| 文件 | 覆盖站点/功能 | 测试数 |
|---|---|---|
| `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 |
### E2E 测试(5 个文件)
### 烟雾测试
| 文件 | 当前覆盖范围 |
|---|---|
| `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
```
公开 API 可用性(hackernews, v2ex×2, v2ex/topic+ 全站点注册完整性检查。
---
@@ -93,7 +78,7 @@ find tests/smoke -name '*.test.ts' | sort
```bash
npm ci # 安装依赖
npm run build # 编译(E2E / smoke 测试需要 dist/main.js
npm run build # 编译(E2E 测试需要 dist/main.js
```
### 运行命令
@@ -102,19 +87,18 @@ npm run build # 编译(E2E / smoke 测试需要 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/
```
@@ -122,10 +106,9 @@ npx vitest src/
### 浏览器命令本地测试须知
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- 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 扩展,再手动运行对应测试
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
---
@@ -133,8 +116,8 @@ npx vitest src/
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构
2. 根据 adapter 类型,在对应测试文件一个 `it()` block
1. **无需额外操作**`validate` 测试会自动覆盖 YAML 结构验
2. 根据 adapter 类型,在对应文件一个 `it()` block
```typescript
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
@@ -165,15 +148,15 @@ it('producthunt me fails gracefully without login', async () => {
### 新增管理命令(如 `opencli export`
`tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`
`tests/e2e/management.test.ts` 添加测试。
### 新增内部模块
对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护
`src/` 下对应位置创建 `*.test.ts`
### 决策流程图
```text
```
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
↓ 否
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
@@ -187,33 +170,32 @@ it('producthunt me fails gracefully without login', async () => {
## CI/CD 流水线
### `ci.yml`
### ci.yml(主流水线)
| Job | 触发条件 | 内容 |
|---|---|---|
| `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/` |
| **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 健康检查 |
### `e2e-headed.yml`
### e2e-headed.ymlE2E 测试)
| Job | 触发条件 | 内容 |
|---|---|---|
| `e2e-headed` | push/PR `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
### Sharding
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行
单元测试使用 vitest 内置 shard
```yaml
strategy:
matrix:
node-version: ['20', '22']
shard: [1, 2]
steps:
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
```
---
@@ -224,8 +206,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 条件 | 模式 | 使用场景 |
|---|---|---|
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
@@ -238,14 +220,14 @@ env:
## 站点兼容性
GitHub Actions 美国 runner 上,部分站点会因为地域限制登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红
GitHub Actions 美国 runner 上,部分站点地域限制登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯
| 站点 | CI 表现 | 常见原因 |
| 站点 | CI 状态 | 限制原因 |
|---|---|---|
| `hackernews``bbc``v2ex``bloomberg` | 通常返回数据 | 公开接口或公开页面 |
| `yahoo-finance``google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
| `bilibili``zhihu``weibo``xiaohongshu``xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
| `reddit``twitter``youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
| `smzdm``boss``ctrip``coupang``linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
| hackernews, bbc, v2ex | 返回数据 | 无限制 |
| yahoo-finance | 返回数据 | 无限制 |
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
| reddit, twitter, youtube | ⚠️ 空数据 | 登录cookie |
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
> 使用 self-hosted runner(国内服务器)可解决地域限制问题
-3
View File
@@ -29,7 +29,6 @@ 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' },
@@ -65,7 +64,6 @@ 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' },
@@ -81,7 +79,6 @@ export default defineConfig({
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' },
-27
View File
@@ -1,27 +0,0 @@
# 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.
+8 -18
View File
@@ -6,17 +6,19 @@
| 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 "流浪地球"
@@ -26,20 +28,8 @@ 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 top250 -f json
opencli douban movie-hot -f json
```
## Prerequisites
-35
View File
@@ -1,35 +0,0 @@
# 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`
+4 -20
View File
@@ -6,35 +6,19 @@
| Command | Description |
|---------|-------------|
| `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 |
| `opencli hackernews top` | |
## Usage Examples
```bash
# Top stories
# Quick start
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
# Sort search by date
opencli hackernews search "rust" --sort date
# Verbose mode
opencli hackernews top -v
```
## Prerequisites
-27
View File
@@ -1,27 +0,0 @@
# 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
-6
View File
@@ -7,7 +7,6 @@
| Command | Description |
|---------|-------------|
| `opencli linkedin search` | |
| `opencli linkedin timeline` | Read posts from your LinkedIn home feed |
## Usage Examples
@@ -15,14 +14,9 @@
# 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
```
+1 -1
View File
@@ -29,7 +29,7 @@
opencli tiktok profile --username tiktok
# Search videos
opencli tiktok search "cooking" --limit 10
opencli tiktok search --query "cooking" --limit 10
# Trending explore videos
opencli tiktok explore --limit 20
+10 -31
View File
@@ -6,48 +6,27 @@
| Command | Description |
|---------|-------------|
| `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) |
| `opencli v2ex hot` | |
| `opencli v2ex latest` | |
| `opencli v2ex topic` | |
| `opencli v2ex daily` | |
| `opencli v2ex me` | |
| `opencli v2ex notifications` | |
## Usage Examples
```bash
# Hot topics
# Quick start
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
-30
View File
@@ -1,30 +0,0 @@
# 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
-4
View File
@@ -7,7 +7,6 @@
| Command | Description |
|---------|-------------|
| `opencli weibo hot` | |
| `opencli weibo search` | Search Weibo posts by keyword |
## Usage Examples
@@ -18,9 +17,6 @@ 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
```
-33
View File
@@ -1,33 +0,0 @@
# 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
+9
View File
@@ -8,6 +8,8 @@
|---------|-------------|
| `opencli wikipedia search` | Search Wikipedia articles |
| `opencli wikipedia summary` | Get Wikipedia article summary |
| `opencli wikipedia random` | Get a random Wikipedia article |
| `opencli wikipedia trending` | Most-read articles (yesterday) |
## Usage Examples
@@ -18,8 +20,15 @@ opencli wikipedia search "quantum computing" --limit 10
# Get article summary
opencli wikipedia summary "Artificial intelligence"
# Get a random article
opencli wikipedia random
# Most-read articles (yesterday)
opencli wikipedia trending --limit 5
# Use with other languages
opencli wikipedia search "人工智能" --lang zh
opencli wikipedia random --lang ja
# JSON output
opencli wikipedia search "Rust" -f json
+6 -8
View File
@@ -6,7 +6,7 @@
| Command | Description |
|---------|-------------|
| `opencli xiaohongshu search` | Search notes by keyword (returns title, author, likes, URL) |
| `opencli xiaohongshu search` | |
| `opencli xiaohongshu notifications` | |
| `opencli xiaohongshu feed` | |
| `opencli xiaohongshu user` | |
@@ -20,16 +20,14 @@
## Usage Examples
```bash
# Search for notes
opencli xiaohongshu search 美食 --limit 10
# Quick start
opencli xiaohongshu search --limit 5
# JSON output
opencli xiaohongshu search 旅行 -f json
opencli xiaohongshu search -f json
# Other commands
opencli xiaohongshu feed
opencli xiaohongshu notifications
opencli xiaohongshu download <url>
# Verbose mode
opencli xiaohongshu search -v
```
## Prerequisites
-69
View File
@@ -1,69 +0,0 @@
# 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.
+3
View File
@@ -47,3 +47,6 @@ 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.
-35
View File
@@ -1,35 +0,0 @@
# 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)
+9 -21
View File
@@ -6,46 +6,36 @@ 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` `block` `unblock` `hide-reply` | 🔐 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` | 🔐 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` `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 |
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `me` `user` `download` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
| **[weibo](/adapters/browser/weibo)** | `hot` `search` | 🔐 Browser |
| **[linkedin](/adapters/browser/linkedin)** | `search` `timeline` | 🔐 Browser |
| **[weibo](/adapters/browser/weibo)** | `hot` | 🔐 Browser |
| **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser |
| **[coupang](/adapters/browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
| **[boss](/adapters/browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
| **[boss](/adapters/browser/boss)** | `search` `detail` | 🔐 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` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
| **[hackernews](/adapters/browser/hackernews)** | `top` | 🌐 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 |
@@ -54,8 +44,7 @@ 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` `random` `trending` | 🌐 Public |
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` | 🌐 Public |
## Desktop Adapters
@@ -68,4 +57,3 @@ 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` |
-4
View File
@@ -10,7 +10,6 @@ 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
@@ -44,9 +43,6 @@ 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)
-125
View File
@@ -1,125 +0,0 @@
# 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)
+2 -4
View File
@@ -17,8 +17,7 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npm test
npm run test:adapter
npx vitest run src/
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -130,8 +129,7 @@ chore: bump vitest to v4
3. Run the checks:
```bash
npx tsc --noEmit # Type check
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if adapter logic changed)
npx vitest run src/ # Unit tests
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
+69 -93
View File
@@ -18,74 +18,57 @@
测试分为三层,全部使用 **vitest** 运行:
```text
```
tests/
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
│ ├── public-commands.test.ts # 公开 API 命令
│ ├── helpers.ts # runCli() 共享工具
│ ├── 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 # 输出格式校验
├── smoke/
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
│ ├── 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 可用性检测
src/
├── **/*.test.ts # 核心单元测试(默认 `unit` project
└── clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts # 聚焦 adapter tests
├── *.test.ts # 单元测试(已有 8 个
```
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|---|---|---:|---|---|
| 单元测试 | `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 与注册完整性 |
| 层 | 位置 | 运行方式 | 用途 |
|---|---|---|---|
| 单元测试 | `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 健康 |
---
## 当前覆盖范围
### 单元测试与 Adapter 测试
### 单元测试8 个文件)
| 领域 | 文件 |
| 文件 | 覆盖内容 |
|---|---|
| 核心运行时与输出 | `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` |
| `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` | 数据变换步骤 |
这些测试覆盖的重点包括:
### E2E 测试(~52 个用例)
- Browser Bridge、DOM snapshot、interceptor、capability routing
- manifest 生成、命令发现、插件安装与注册表
- 输出格式渲染与 snapshot formatting
- pipeline 模板求值、执行器与变换步骤
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
| 文件 | 覆盖站点/功能 | 测试数 |
|---|---|---|
| `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 |
### E2E 测试(5 个文件)
### 烟雾测试
| 文件 | 当前覆盖范围 |
|---|---|
| `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
```
公开 API 可用性(hackernews, v2ex×2, v2ex/topic+ 全站点注册完整性检查。
---
@@ -95,31 +78,27 @@ find tests/smoke -name '*.test.ts' | sort
```bash
npm ci # 安装依赖
npm run build # 编译(E2E / smoke 测试需要 dist/main.js
npm run build # 编译(E2E 测试需要 dist/main.js
```
### 运行命令
```bash
# 默认核心单元测试(不含大多数 adapter tests
npm test
# 全部单元测试
npx vitest run src/
# 聚焦 adapter tests(只保留 4 个重点站点
npm run test:adapter
# 全部 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/
```
@@ -127,10 +106,9 @@ npx vitest src/
### 浏览器命令本地测试须知
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- 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 扩展,再手动运行对应测试
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
---
@@ -138,8 +116,8 @@ npx vitest src/
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构
2. 根据 adapter 类型,在对应测试文件一个 `it()` block
1. **无需额外操作**`validate` 测试会自动覆盖 YAML 结构验
2. 根据 adapter 类型,在对应文件一个 `it()` block
```typescript
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
@@ -170,15 +148,15 @@ it('producthunt me fails gracefully without login', async () => {
### 新增管理命令(如 `opencli export`
`tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`
`tests/e2e/management.test.ts` 添加测试。
### 新增内部模块
对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护
`src/` 下对应位置创建 `*.test.ts`
### 决策流程图
```text
```
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
↓ 否
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
@@ -192,35 +170,33 @@ it('producthunt me fails gracefully without login', async () => {
## CI/CD 流水线
### `ci.yml`
### ci.yml(主流水线)
| Job | 触发条件 | 内容 |
|---|---|---|
| `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/` |
| **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 健康检查 |
### `e2e-headed.yml`
### e2e-headed.ymlE2E 测试)
| Job | 触发条件 | 内容 |
|---|---|---|
| `e2e-headed` | push/PR `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
### Sharding
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行
单元测试使用 vitest 内置 shard
::: v-pre
```yaml
strategy:
matrix:
node-version: ['20', '22']
shard: [1, 2]
steps:
- run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
```
:::
@@ -232,8 +208,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 条件 | 模式 | 使用场景 |
|---|---|---|
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
@@ -248,14 +224,14 @@ env:
## 站点兼容性
GitHub Actions 美国 runner 上,部分站点会因为地域限制登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红
GitHub Actions 美国 runner 上,部分站点地域限制登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯
| 站点 | CI 表现 | 常见原因 |
| 站点 | CI 状态 | 限制原因 |
|---|---|---|
| `hackernews``bbc``v2ex``bloomberg` | 通常返回数据 | 公开接口或公开页面 |
| `yahoo-finance``google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
| `bilibili``zhihu``weibo``xiaohongshu``xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
| `reddit``twitter``youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
| `smzdm``boss``ctrip``coupang``linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
| hackernews, bbc, v2ex | 返回数据 | 无限制 |
| yahoo-finance | 返回数据 | 无限制 |
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
| reddit, twitter, youtube | ⚠️ 空数据 | 登录cookie |
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
> 使用 self-hosted runner(国内服务器)可解决地域限制问题
+444 -519
View File
File diff suppressed because it is too large Load Diff
+2 -10
View File
@@ -321,14 +321,6 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
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'
@@ -406,12 +398,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);
await executor.detach(target.id);
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);
await executor.detach(tabId);
executor.detach(tabId);
return { id: cmd.id, ok: true, data: { closed: tabId } };
}
case 'select': {
+7 -4
View File
@@ -144,10 +144,10 @@ export async function screenshot(
}
}
export async function detach(tabId: number): Promise<void> {
export function detach(tabId: number): void {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
@@ -158,9 +158,12 @@ export function registerListeners(): void {
if (source.tabId) attached.delete(source.tabId);
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
chrome.tabs.onUpdated.addListener((tabId, info) => {
if (info.url && !isDebuggableUrl(info.url)) {
await detach(tabId);
if (attached.has(tabId)) {
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
}
});
}
+157 -160
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "1.3.3",
"version": "1.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.3.3",
"version": "1.2.6",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -14,7 +14,6 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"turndown": "^7.2.2",
"ws": "^8.18.0"
},
"bin": {
@@ -23,10 +22,9 @@
"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": "^6.0.2",
"typescript": "^5.8.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.0"
},
@@ -195,6 +193,7 @@
"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 +403,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -416,9 +415,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -903,12 +902,6 @@
"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",
@@ -926,10 +919,20 @@
"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.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"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==",
"dev": true,
"license": "MIT",
"funding": {
@@ -937,9 +940,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -954,9 +957,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -971,9 +974,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -988,9 +991,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -1005,9 +1008,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"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==",
"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==",
"cpu": [
"arm"
],
@@ -1022,9 +1025,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -1039,9 +1042,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -1056,9 +1059,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"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==",
"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==",
"cpu": [
"ppc64"
],
@@ -1073,9 +1076,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"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==",
"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==",
"cpu": [
"s390x"
],
@@ -1090,9 +1093,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -1107,9 +1110,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -1124,9 +1127,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -1141,9 +1144,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"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==",
"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==",
"cpu": [
"wasm32"
],
@@ -1158,9 +1161,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -1175,9 +1178,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -1192,9 +1195,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"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==",
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"dev": true,
"license": "MIT"
},
@@ -1740,13 +1743,6 @@
"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",
@@ -1779,16 +1775,16 @@
"license": "ISC"
},
"node_modules/@vitest/expect": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
@@ -1797,13 +1793,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.1",
"@vitest/spy": "4.1.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1812,7 +1808,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -1824,9 +1820,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1837,13 +1833,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.1",
"@vitest/utils": "4.1.0",
"pathe": "^2.0.3"
},
"funding": {
@@ -1851,14 +1847,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.1",
"@vitest/utils": "4.1.1",
"@vitest/pretty-format": "4.1.0",
"@vitest/utils": "4.1.0",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1867,9 +1863,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1877,13 +1873,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.1",
"@vitest/pretty-format": "4.1.0",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
@@ -2162,6 +2158,7 @@
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.15.2",
"@algolia/client-abtesting": "5.49.2",
@@ -2490,6 +2487,7 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tabbable": "^6.4.0"
}
@@ -2618,6 +2616,7 @@
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -3089,6 +3088,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3192,14 +3192,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.11"
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3208,21 +3208,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@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"
"@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"
}
},
"node_modules/rollup": {
@@ -3477,6 +3477,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3491,21 +3492,13 @@
"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": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3625,16 +3618,18 @@
}
},
"node_modules/vite": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"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.11",
"rolldown": "1.0.0-rc.9",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -3651,7 +3646,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -4194,6 +4189,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -4249,19 +4245,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -4273,7 +4269,7 @@
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -4289,13 +4285,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.1",
"@vitest/browser-preview": "4.1.1",
"@vitest/browser-webdriverio": "4.1.1",
"@vitest/ui": "4.1.1",
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-preview": "4.1.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/ui": "4.1.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -4336,6 +4332,7 @@
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
@@ -4370,9 +4367,9 @@
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.3.3",
"version": "1.2.6",
"publishConfig": {
"access": "public"
},
@@ -30,7 +30,6 @@
"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",
@@ -54,16 +53,14 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"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": "^6.0.2",
"typescript": "^5.8.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.0"
}
-2
View File
@@ -28,8 +28,6 @@ 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
View File
@@ -1,169 +0,0 @@
/**
* 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)),
};
}
+1 -51
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
import { BrowserBridge, __test__ } from './browser/index.js';
import * as daemonClient from './browser/daemon-client.js';
describe('browser helpers', () => {
@@ -133,53 +133,3 @@ 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();
});
});
+14 -24
View File
@@ -12,7 +12,6 @@ import { WebSocket, type RawData } from 'ws';
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,
@@ -21,7 +20,6 @@ import {
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
export interface CDPTarget {
@@ -51,8 +49,6 @@ 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');
@@ -74,16 +70,9 @@ 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', async () => {
ws.on('open', () => {
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));
});
@@ -179,24 +168,19 @@ 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> {
if (!this._pageEnabled) {
await this.bridge.send('Page.enable');
this._pageEnabled = true;
}
await this.bridge.send('Page.enable');
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
.catch(() => {}); // Don't fail if load event times out — page may be an SPA
.catch(() => {}); // Don't fail if event times out
await this.bridge.send('Page.navigate', { url });
await loadPromise;
// Smart settle: use DOM stability detection instead of fixed sleep.
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
// Post-load settle: SPA frameworks need extra time to render after load event
if (options?.waitUntil !== 'none') {
const maxMs = options?.settleMs ?? 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
const settleMs = options?.settleMs ?? 1000;
await new Promise(resolve => setTimeout(resolve, settleMs));
}
}
@@ -292,7 +276,11 @@ class CDPPage implements IPage {
});
const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : '';
if (options.path) {
await saveBase64ToFile(base64, 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'));
}
return base64;
}
@@ -337,7 +325,9 @@ class CDPPage implements IPage {
}
}
import { isRecord, saveBase64ToFile } from '../utils.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isCookie(value: unknown): value is BrowserCookie {
return isRecord(value)
+3 -4
View File
@@ -4,12 +4,11 @@
* Provides a typed send() function that posts a Command and returns a Result.
*/
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_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
import type { BrowserSessionInfo } from '../types.js';
let _idCounter = 0;
function generateId(): string {
+1 -2
View File
@@ -5,7 +5,6 @@
* scanning for @playwright/mcp locations.
*/
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { isDaemonRunning } from './daemon-client.js';
export { isDaemonRunning };
@@ -18,7 +17,7 @@ export async function checkDaemonStatus(): Promise<{
extensionConnected: boolean;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
});
-34
View File
@@ -145,37 +145,3 @@ 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});
})
`;
}
+2 -2
View File
@@ -26,7 +26,7 @@
// ─── Types ───────────────────────────────────────────────────────────
export interface DomSnapshotOptions {
export interface SnapshotOptions {
/** 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: DomSnapshotOptions = {}): string {
export function generateSnapshotJs(opts: SnapshotOptions = {}): string {
const viewportExpand = opts.viewportExpand ?? 800;
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200));
const interactiveOnly = opts.interactiveOnly ?? false;
+11 -18
View File
@@ -5,38 +5,31 @@
* 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): BrowserConnectError {
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
switch (kind) {
case 'daemon-not-running':
return new BrowserConnectError(
'Cannot connect to opencli daemon.' +
(detail ? `\n\n${detail}` : ''),
return new Error(
'Cannot connect to opencli daemon.\n\n' +
'The daemon should start automatically. If it doesn\'t, try:\n' +
' node dist/daemon.js\n' +
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
'Make sure port 19825 is available.' +
(detail ? `\n\n${detail}` : ''),
);
case 'extension-not-connected':
return new BrowserConnectError(
'opencli Browser Bridge extension is not connected.' +
(detail ? `\n\n${detail}` : ''),
return new Error(
'opencli Browser Bridge extension is not connected.\n\n' +
'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',
' 4. Make sure Chrome is running' +
(detail ? `\n\n${detail}` : ''),
);
case 'command-failed':
return new BrowserConnectError(
`Browser command failed: ${detail ?? 'unknown error'}`,
);
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
default:
return new BrowserConnectError(
detail ?? 'Failed to connect to browser',
);
return new Error(detail ?? 'Failed to connect to browser');
}
}
+2 -3
View File
@@ -6,12 +6,11 @@
*/
export { Page } from './page.js';
export { BrowserBridge } from './mcp.js';
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
export { CDPBridge } from './cdp.js';
export { isDaemonRunning } from './daemon-client.js';
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
export { generateStealthJs } from './stealth.js';
export type { DomSnapshotOptions } from './dom-snapshot.js';
export type { SnapshotOptions } from './dom-snapshot.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { __test__ as cdpTest } from './cdp.js';
+4 -2
View File
@@ -9,7 +9,6 @@ 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
@@ -113,7 +112,10 @@ export class BrowserBridge {
throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
'Make sure port 19825 is available.',
);
}
}
/** @deprecated Use BrowserBridge instead */
export const PlaywrightMCP = BrowserBridge;
+37 -50
View File
@@ -14,9 +14,7 @@ import { formatSnapshot } from '../snapshotFormatter.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,
@@ -25,7 +23,6 @@ import {
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
/**
@@ -37,52 +34,37 @@ export class Page implements IPage {
/** Active tab ID, set after navigate and used in all subsequent commands */
private _tabId: number | undefined;
/** Helper: spread workspace into command params */
private _wsOpt(): { workspace: string } {
return { workspace: this.workspace };
/** 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 + tabId into command params */
private _cmdOpts(): Record<string, unknown> {
return {
workspace: this.workspace,
...(this._tabId !== undefined && { tabId: this._tabId }),
};
private _workspaceOpt(): { workspace: string } {
return { workspace: this.workspace };
}
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
const result = await sendCommand('navigate', {
url,
...this._cmdOpts(),
...this._workspaceOpt(),
...this._tabOpt(),
}) as { tabId?: number };
// Remember the tabId for subsequent exec calls
if (result?.tabId) {
this._tabId = result.tabId;
}
// 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.
// Post-load settle: the extension already waits for tab.status === 'complete',
// but SPA frameworks (React/Vue) need extra time to render after DOM load.
if (options?.waitUntil !== 'none') {
const maxMs = options?.settleMs ?? 1000;
await sendCommand('exec', {
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
...this._cmdOpts(),
});
const settleMs = options?.settleMs ?? 1000;
await new Promise(resolve => setTimeout(resolve, settleMs));
}
}
/** Close the automation window in the extension */
async closeWindow(): Promise<void> {
try {
await sendCommand('close-window', { ...this._wsOpt() });
await sendCommand('close-window', { ...this._workspaceOpt() });
} catch {
// Window may already be closed or daemon may be down
}
@@ -90,11 +72,11 @@ export class Page implements IPage {
async evaluate(js: string): Promise<unknown> {
const code = wrapForEval(js);
return sendCommand('exec', { code, ...this._cmdOpts() });
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
const result = await sendCommand('cookies', { ...this._wsOpt(), ...opts });
const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts });
return Array.isArray(result) ? result : [];
}
@@ -110,7 +92,7 @@ export class Page implements IPage {
});
try {
const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
const result = await sendCommand('exec', { code: snapshotJs, ...this._workspaceOpt(), ...this._tabOpt() });
// The advanced engine already produces a clean, pruned, LLM-friendly output.
// Do NOT pass through formatSnapshot — its format is incompatible.
return result;
@@ -150,7 +132,7 @@ export class Page implements IPage {
return buildTree(document.body, 0);
})()
`;
const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
@@ -158,27 +140,27 @@ export class Page implements IPage {
async click(ref: string): Promise<void> {
const code = clickJs(ref);
await sendCommand('exec', { code, ...this._cmdOpts() });
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
async typeText(ref: string, text: string): Promise<void> {
const code = typeTextJs(ref, text);
await sendCommand('exec', { code, ...this._cmdOpts() });
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
async pressKey(key: string): Promise<void> {
const code = pressKeyJs(key);
await sendCommand('exec', { code, ...this._cmdOpts() });
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
async scrollTo(ref: string): Promise<unknown> {
const code = scrollToRefJs(ref);
return sendCommand('exec', { code, ...this._cmdOpts() });
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
async getFormState(): Promise<Record<string, unknown>> {
const code = getFormStateJs();
return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
return (await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() })) as Record<string, unknown>;
}
async wait(options: number | WaitOptions): Promise<void> {
@@ -186,42 +168,42 @@ export class Page implements IPage {
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (typeof options.time === 'number') {
if (options.time) {
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._cmdOpts() });
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
}
async tabs(): Promise<unknown[]> {
const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
const result = await sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
return Array.isArray(result) ? result : [];
}
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(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> {
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number };
if (result?.tabId) this._tabId = result.tabId;
}
async selectTab(index: number): Promise<void> {
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) 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._cmdOpts() });
const result = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
return Array.isArray(result) ? result : [];
}
@@ -243,14 +225,19 @@ export class Page implements IPage {
*/
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
const base64 = await sendCommand('screenshot', {
...this._cmdOpts(),
...this._workspaceOpt(),
format: options.format,
quality: options.quality,
fullPage: options.fullPage,
...this._tabOpt(),
}) as string;
if (options.path) {
await saveBase64ToFile(base64, 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'));
}
return base64;
@@ -258,14 +245,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._cmdOpts() });
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
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._cmdOpts() });
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
}
async installInterceptor(pattern: string): Promise<void> {
-156
View File
@@ -1,156 +0,0 @@
/**
* 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';
})()
`;
}
+31 -6
View File
@@ -13,7 +13,6 @@ 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');
@@ -46,10 +45,37 @@ export interface ManifestEntry {
navigateBefore?: boolean | string;
}
import type { YamlCliDefinition } from './yaml-schema.js';
interface YamlArgDefinition {
type?: string;
default?: unknown;
required?: boolean;
positional?: boolean;
description?: string;
help?: string;
choices?: string[];
}
import { isRecord } from './utils.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;
}
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,
@@ -155,8 +181,7 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
choices: parseInlineChoices(body),
});
cursor = objectStart + body.length;
if (cursor <= objectStart) break; // safety: prevent infinite loop
cursor = objectStart + body.length + 2;
}
return args;
@@ -277,7 +302,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 false;
if (current.type === next.type) return true;
return current.type === 'yaml' && next.type === 'ts';
}
+1 -2
View File
@@ -145,10 +145,9 @@ export async function cascadeProbe(
url: string,
opts: { maxStrategy?: Strategy; timeout?: number } = {},
): Promise<CascadeResult> {
const rawIdx = opts.maxStrategy
const maxIdx = 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[] = [];
+23 -52
View File
@@ -173,6 +173,8 @@ 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,
@@ -181,30 +183,6 @@ 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')
@@ -255,11 +233,10 @@ 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);
await discoverPlugins();
console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
console.log(chalk.green(`✅ Plugin "${name}" installed successfully.`));
console.log(chalk.dim(` Restart opencli to use the new commands.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
@@ -281,24 +258,6 @@ 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')
@@ -406,17 +365,29 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
registerAllCommands(program, siteGroups);
// ── Unknown command fallback ──────────────────────────────────────────────
// Security: do NOT auto-discover and register arbitrary system binaries.
// Only explicitly registered external CLIs (via `opencli register`) are allowed.
const DENY_LIST = new Set([
'rm', 'sudo', 'dd', 'mkfs', 'fdisk', 'shutdown', 'reboot',
'kill', 'killall', 'chmod', 'chown', 'passwd', 'su', 'mount',
'umount', 'format', 'diskutil',
]);
program.on('command:*', (operands: string[]) => {
const binary = operands[0];
console.error(chalk.red(`error: unknown command '${binary}'`));
if (isBinaryInstalled(binary)) {
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
if (DENY_LIST.has(binary)) {
console.error(chalk.red(`Refusing to register system command '${binary}'.`));
process.exitCode = 1;
return;
}
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;
}
program.outputHelp();
process.exitCode = 1;
});
program.parse();
-117
View File
@@ -1,117 +0,0 @@
/**
* 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`,
},
];
},
});
}
+1 -1
View File
@@ -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.query}`);
const query = encodeURIComponent(`all:${args.keyword}`);
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');
+81 -23
View File
@@ -8,9 +8,18 @@
* - 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 { checkYtdlp, sanitizeFilename } from '../../download/index.js';
import { downloadMedia } from '../../download/media-download.js';
import {
ytdlpDownload,
checkYtdlp,
sanitizeFilename,
getTempDir,
exportCookiesToNetscape,
formatCookieHeader,
} from '../../download/index.js';
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
cli({
site: 'bilibili',
@@ -54,8 +63,21 @@ cli({
const title = sanitizeFilename(data?.title || 'video');
// Extract cookies for yt-dlp
const browserCookies = await page.getCookies({ domain: 'bilibili.com' });
// 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);
}
// Build yt-dlp format string based on quality
let format = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best';
@@ -67,26 +89,62 @@ cli({
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
}
const videoUrl = `https://www.bilibili.com/video/${bvid}`;
const filename = `${bvid}_${title}.mp4`;
const destPath = path.join(output, `${bvid}_${title}.mp4`);
const results = await downloadMedia(
[{ type: 'video-ytdlp', url: videoUrl, filename }],
{
output,
browserCookies,
filenamePrefix: bvid,
ytdlpExtraArgs: ['-f', format, '--merge-output-format', 'mp4', '--embed-thumbnail'],
},
);
const tracker = new DownloadProgressTracker(1, true);
const progressBar = tracker.onFileStart(`${bvid}.mp4`, 0);
// 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,
}];
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,
}];
}
},
});
-79
View File
@@ -1,79 +0,0 @@
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',
},
]);
});
});
+2 -5
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { apiGet, payloadData, getSelfUid } from './utils.js';
import { apiGet, payloadData } from './utils.js';
cli({
site: 'bilibili',
@@ -15,12 +15,9 @@ 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: uid },
params: { up_mid: 0 },
signed: true,
});
const folders = payloadData(foldersPayload)?.list ?? [];
+2 -3
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
@@ -15,7 +14,7 @@ cli({
],
columns: ['mid', 'name', 'sign', 'following', 'fans'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
if (!page) throw new Error('Requires browser');
// 1. Resolve UID (default to self)
const uid = kwargs.uid
@@ -31,7 +30,7 @@ cli({
);
if (payload.code !== 0) {
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
}
const list = payload.data?.list || [];
+7 -8
View File
@@ -1,5 +1,4 @@
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';
@@ -14,7 +13,7 @@ cli({
],
columns: ['index', 'from', 'to', 'content'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
if (!page) throw new Error('Requires browser');
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
@@ -25,7 +24,7 @@ cli({
})()`);
if (!cid) {
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
@@ -36,12 +35,12 @@ cli({
});
if (payload.code !== 0) {
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
}
const subtitles = payload.data?.subtitle?.subtitles || [];
if (subtitles.length === 0) {
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
throw new Error('此视频没有发现外挂或智能字幕。');
}
// 4. 选择目标字幕语言
@@ -51,7 +50,7 @@ cli({
const targetSubUrl = target.subtitle_url;
if (!targetSubUrl || targetSubUrl === '') {
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
}
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
@@ -82,12 +81,12 @@ cli({
const items = await page.evaluate(fetchJs);
if (items?.error) {
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
}
const finalItems = items?.data || [];
if (!Array.isArray(finalItems)) {
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
throw new Error('解析到的字幕列表对象不符合数组格式');
}
// 6. 数据映射
+2 -3
View File
@@ -3,7 +3,6 @@
*/
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,
@@ -99,7 +98,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 AuthRequiredError('bilibili.com');
if (!mid) throw new Error('Not logged in to Bilibili');
return String(mid);
}
@@ -112,5 +111,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 EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
throw new Error(`Cannot resolve UID for: ${input}`);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
import {
requirePage, navigateToChat, fetchRecommendList,
clickCandidateInList, typeAndSendMessage, verbose,
} from './utils.js';
} from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
import { requirePage, navigateToChat, fetchFriendList } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -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 './utils.js';
import { requirePage, navigateTo, bossFetch, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -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 './utils.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage, verbose,
} from './utils.js';
} from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -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 './utils.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -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 './utils.js';
import { requirePage, navigateToChat, bossFetch, verbose } from './common.js';
cli({
site: 'boss',
+2 -3
View File
@@ -7,7 +7,6 @@
*/
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,
@@ -45,7 +44,7 @@ cli({
if (entry) {
labelId = entry[1];
} else {
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
}
}
@@ -54,7 +53,7 @@ cli({
await navigateToChat(page);
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
if (!friend) throw new EmptyResultError('boss candidate search');
if (!friend) throw new Error('未找到该候选人');
const friendName = friend.name || '候选人';
const action = remove ? 'deleteMark' : 'addMark';
+1 -1
View File
@@ -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 './utils.js';
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -10,7 +10,7 @@
* .position-content → job being discussed + expectation
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './utils.js';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './common.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job search — browser cookie API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './utils.js';
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './common.js';
/** City name → BOSS Zhipin city code mapping */
const CITY_CODES: Record<string, string> = {
+3 -4
View File
@@ -9,7 +9,6 @@ import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage,
} from './common.js';
import { EmptyResultError, SelectorError } from '../../errors.js';
cli({
site: 'boss',
@@ -30,21 +29,21 @@ cli({
await navigateToChat(page, 3);
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const clicked = await clickCandidateInList(page, numericUid);
if (!clicked) {
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
}
await page.wait({ time: 2 });
const sent = await typeAndSendMessage(page, kwargs.text);
if (!sent) {
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
throw new Error('找不到消息输入框');
}
await page.wait({ time: 1 });
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 stats — job statistics overview.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './utils.js';
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './common.js';
cli({
site: 'boss',
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError } from '../../errors.js';
import {
getCourses, initSession, enterCourse, getTabIframeUrl,
parseAssignmentsFromDom, sleep,
@@ -34,7 +33,7 @@ cli({
// 2. Get courses
const courses = await getCourses(page);
if (!courses.length) throw new AuthRequiredError('mooc2-ans.chaoxing.com', '未获取到课程列表');
if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
const filtered = courseFilter
? courses.filter(c => c.title.includes(courseFilter))
-5
View File
@@ -1,6 +1,5 @@
import { execSync, spawnSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { getVisibleChatMessages } from './ax.js';
@@ -17,10 +16,6 @@ export const askCommand = cli({
],
columns: ['Role', 'Text'],
func: async (page: IPage | null, kwargs: any) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
const text = kwargs.text as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 30;
-5
View File
@@ -1,6 +1,5 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const newCommand = cli({
@@ -13,10 +12,6 @@ export const newCommand = cli({
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.5'");
+1 -6
View File
@@ -1,6 +1,5 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { getVisibleChatMessages } from './ax.js';
@@ -14,10 +13,6 @@ export const readCommand = cli({
args: [],
columns: ['Role', 'Text'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.3'");
@@ -29,7 +24,7 @@ export const readCommand = cli({
return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
} catch (err: any) {
throw new CommandExecutionError("Failed to read from ChatGPT: " + err.message);
throw new Error("Failed to read from ChatGPT: " + err.message);
}
},
});
+1 -6
View File
@@ -1,6 +1,5 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const statusCommand = cli({
@@ -13,15 +12,11 @@ export const statusCommand = cli({
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim();
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
} catch {
throw new CommandExecutionError('Error querying ChatGPT application state');
return [{ Status: 'Error querying application state' }];
}
},
});
+2 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -27,14 +26,14 @@ export const askCommand = cli({
`);
// Send message
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) return false;
if (!composer) throw new Error('Could not find input');
composer.focus();
if (composer.tagName === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
@@ -43,10 +42,8 @@ export const askCommand = cli({
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
+3 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const modelCommand = cli({
@@ -45,7 +44,7 @@ export const modelCommand = cli({
return [{ Status: 'Active', Model: currentModel }];
} else {
// Try to switch model
const opened = await page.evaluate(`
await page.evaluate(`
(function(target) {
const selectors = [
'[class*="model"]',
@@ -55,12 +54,11 @@ export const modelCommand = cli({
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return true; }
if (el) { el.click(); return; }
}
return false;
throw new Error('Could not find model selector');
})(${JSON.stringify(desiredModel)})
`);
if (!opened) throw new SelectorError('ChatWise model selector');
await page.wait(0.5);
+20 -2
View File
@@ -1,3 +1,21 @@
import { makeNewCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation');
export const newCommand = cli({
site: 'chatwise',
name: 'new',
description: 'Start a new conversation in ChatWise',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status'],
func: async (page: IPage) => {
// ChatWise uses standard Electron shortcuts
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
+32 -2
View File
@@ -1,3 +1,33 @@
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise');
export const screenshotCommand = cli({
site: 'chatwise',
name: 'screenshot',
description: 'Capture a snapshot of the current ChatWise window (DOM + Accessibility tree)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: 'Output file path (default: /tmp/chatwise-snapshot)' },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const basePath = (kwargs.output as string) || '/tmp/chatwise-snapshot';
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = basePath + '-dom.html';
const snapPath = basePath + '-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 },
];
},
});
+2 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -14,7 +13,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
// ChatWise input can be textarea or contenteditable
let composer = document.querySelector('textarea');
@@ -23,7 +22,7 @@ export const sendCommand = cli({
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) return false;
if (!composer) throw new Error('Could not find ChatWise input element');
composer.focus();
@@ -35,10 +34,8 @@ export const sendCommand = cli({
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
+24 -2
View File
@@ -1,3 +1,25 @@
import { makeStatusCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');
export const statusCommand = cli({
site: 'chatwise',
name: 'status',
description: 'Check active CDP connection to ChatWise Desktop',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
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,
},
];
},
});
+2 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -27,17 +26,15 @@ export const askCommand = cli({
`);
// Inject and send
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea');
if (!composer) return false;
if (!composer) throw new Error('Could not find Codex input');
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('Codex input element');
await page.wait(0.5);
await page.pressKey('Enter');
+27 -2
View File
@@ -1,3 +1,28 @@
import { makeDumpCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
export const dumpCommand = makeDumpCommand('codex');
export const dumpCommand = cli({
site: 'codex',
name: 'dump',
description: 'Dump the DOM and Accessibility tree of Codex for reverse-engineering',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page) => {
// Extract full HTML
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/codex-dom.html', dom);
// Get accessibility snapshot
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync('/tmp/codex-snapshot.json', JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: '/tmp/codex-dom.html, /tmp/codex-snapshot.json',
},
];
},
});
+28 -2
View File
@@ -1,3 +1,29 @@
import { makeNewCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
export const newCommand = makeNewCommand('codex', 'Codex conversation');
export const newCommand = cli({
site: 'codex',
name: 'new',
description: 'Start a new Codex conversation thread / isolated workspace',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status', 'Action'],
func: async (page) => {
// According to research, Cmd+N / Ctrl+N spins up a new thread
const isMac = process.platform === 'darwin';
const newThreadKey = isMac ? 'Meta+N' : 'Control+N';
// Simulate keyboard shortcut
await page.pressKey(newThreadKey);
// Wait a brief moment for UI animation
await page.wait(1);
return [
{
Status: 'Success',
Action: `Pressed ${newThreadKey} to trigger New Thread`,
},
];
},
});
+32 -2
View File
@@ -1,3 +1,33 @@
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const screenshotCommand = makeScreenshotCommand('codex', 'Codex');
export const screenshotCommand = cli({
site: 'codex',
name: 'screenshot',
description: 'Capture a snapshot of the current Codex window (DOM + Accessibility tree)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: 'Output file path (default: /tmp/codex-snapshot.txt)' },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || '/tmp/codex-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 },
];
},
});
+4 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -14,7 +13,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const textToInsert = kwargs.text as string;
const injected = await page.evaluate(`
await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea, [contenteditable="true"]');
@@ -23,14 +22,14 @@ export const sendCommand = cli({
composer = editables[editables.length - 1];
}
if (!composer) return false;
if (!composer) {
throw new Error('Could not find Composer input element in Codex UI');
}
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(textToInsert)})
`);
if (!injected) throw new SelectorError('Codex Composer input element');
// Wait for the UI to register the input
await page.wait(0.5);
+24 -2
View File
@@ -1,3 +1,25 @@
import { makeStatusCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const statusCommand = makeStatusCommand('codex', 'OpenAI Codex App');
export const statusCommand = cli({
site: 'codex',
name: 'status',
description: 'Check active CDP connection to OpenAI Codex App',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
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,
},
];
},
});
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -34,7 +33,7 @@ export const askCommand = cli({
})(${JSON.stringify(text)})`
);
if (!injected) throw new SelectorError('Cursor input element');
if (!injected) throw new Error('Could not find input element.');
await page.wait(0.5);
await page.pressKey('Enter');
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const composerCommand = cli({
@@ -34,7 +33,7 @@ export const composerCommand = cli({
);
if (!typed) {
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
}
await page.wait(0.5);
+27 -2
View File
@@ -1,3 +1,28 @@
import { makeDumpCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
export const dumpCommand = makeDumpCommand('cursor');
export const dumpCommand = cli({
site: 'cursor',
name: 'dump',
description: 'Dump the DOM and Accessibility tree of Cursor for reverse-engineering',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page) => {
// Extract full HTML
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/cursor-dom.html', dom);
// Get accessibility snapshot
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync('/tmp/cursor-snapshot.json', JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: '/tmp/cursor-dom.html, /tmp/cursor-snapshot.json',
},
];
},
});
+20 -2
View File
@@ -1,3 +1,21 @@
import { makeNewCommand } from '../_shared/desktop-commands.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const newCommand = makeNewCommand('cursor', 'Cursor chat or Composer');
export const newCommand = cli({
site: 'cursor',
name: 'new',
description: 'Start a new Cursor chat or Composer session',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status'],
func: async (page: IPage) => {
// Use keyboard shortcut — most robust approach, avoids brittle DOM selectors
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
+1 -2
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import { EmptyResultError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const readCommand = cli({
@@ -40,7 +39,7 @@ export const readCommand = cli({
`);
if (!history || history.length === 0) {
throw new EmptyResultError('cursor read', 'No conversation history found in Cursor.');
throw new Error('No conversation history found in Cursor.');
}
return history;

Some files were not shown because too many files have changed in this diff Show More