Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8d6319f87 | |||
| bf6207a4e2 | |||
| 4e669629fd | |||
| 6cf14e0800 | |||
| 45370b50e1 | |||
| 1af0f48023 | |||
| 2fb7ed131b | |||
| 14672ddf9b | |||
| 77814553cf | |||
| 9018713749 | |||
| 3d39574501 | |||
| b1067b64ee | |||
| 376c63c7db | |||
| ccfc0ed0de | |||
| 3a7a5e135b | |||
| 618dae9148 | |||
| 11df7181b3 | |||
| 2b24f517fe | |||
| d44a0ab256 | |||
| 9f2aa3b711 | |||
| 505c86bae9 | |||
| b8b4fa011b | |||
| 8869d3b457 | |||
| 7c9caa81f8 | |||
| 7c808fd339 | |||
| fb562fa1e9 | |||
| f6466db39a | |||
| 37c7ea41b8 | |||
| d58e1a74cd | |||
| 1b3f74cd6a | |||
| bdcffd147f | |||
| 53699eb807 | |||
| 56d4326646 | |||
| 4c9a2b1fde | |||
| 57fcc50c1b | |||
| 689cd1ebb3 | |||
| 167c7c784a | |||
| f852da3af4 | |||
| 15d9ef814f | |||
| 89a20e11bc | |||
| 760b91e7b3 | |||
| b6a02f82ed | |||
| a170873ad6 | |||
| 75f42371ca | |||
| 41aedf68cd | |||
| 8bf750c4ea | |||
| c9b3568594 | |||
| b4d64cad6e |
@@ -46,7 +46,7 @@ jobs:
|
||||
zip -r ../opencli-extension.zip .
|
||||
|
||||
- name: Upload Artifacts (Action Run)
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opencli-extension-build
|
||||
path: |
|
||||
|
||||
@@ -54,7 +54,24 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
|
||||
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
adapter-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run focused adapter tests
|
||||
run: npm run test:adapter -- --reporter=verbose
|
||||
|
||||
# ── Smoke tests (scheduled / manual only) ──
|
||||
smoke-test:
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger opencli-website rebuild
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
|
||||
repository: jackwener/opencli-website
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger website rebuild
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
|
||||
repository: jackwener/opencli-website
|
||||
|
||||
@@ -19,3 +19,6 @@ docs/.vitepress/cache
|
||||
.windsurf
|
||||
.claude
|
||||
.cortex
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
|
||||
+128
@@ -1,5 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **error-handling:** refine error handling with semantic error types and emoji-coded output ([#312](https://github.com/jackwener/opencli/issues/312)) ([b4d64ca](https://github.com/jackwener/opencli/commit/b4d64ca))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** replace execSync with execFileSync to prevent command injection ([#309](https://github.com/jackwener/opencli/issues/309)) ([41aedf6](https://github.com/jackwener/opencli/commit/41aedf6))
|
||||
* remove duplicate getErrorMessage import in discovery.ts ([#315](https://github.com/jackwener/opencli/issues/315)) ([75f4237](https://github.com/jackwener/opencli/commit/75f4237))
|
||||
* **e2e:** broaden xiaoyuzhou skip logic for overseas CI runners ([#316](https://github.com/jackwener/opencli/issues/316)) ([a170873](https://github.com/jackwener/opencli/commit/a170873))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **SKILL.md:** sync command reference — add missing sites and desktop adapters ([#314](https://github.com/jackwener/opencli/issues/314)) ([8bf750c](https://github.com/jackwener/opencli/commit/8bf750c))
|
||||
|
||||
|
||||
### Chores
|
||||
|
||||
* pre-release cleanup — fix dependencies, sync docs, reduce code duplication ([#311](https://github.com/jackwener/opencli/issues/311)) ([c9b3568](https://github.com/jackwener/opencli/commit/c9b3568))
|
||||
|
||||
|
||||
## [1.3.1](https://github.com/jackwener/opencli/compare/v1.3.0...v1.3.1) (2026-03-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **plugin:** add update command, hot reload after install, README section ([#307](https://github.com/jackwener/opencli/issues/307)) ([966f6e5](https://github.com/jackwener/opencli/commit/966f6e5))
|
||||
* **yollomi:** add new commands and update documentation ([#235](https://github.com/jackwener/opencli/issues/235)) ([ea83242](https://github.com/jackwener/opencli/commit/ea83242))
|
||||
* **record:** add live recording command for API capture ([#300](https://github.com/jackwener/opencli/issues/300)) ([dff0fe5](https://github.com/jackwener/opencli/commit/dff0fe5))
|
||||
* **weibo:** add weibo search command ([#299](https://github.com/jackwener/opencli/issues/299)) ([c7895ea](https://github.com/jackwener/opencli/commit/c7895ea))
|
||||
* **v2ex:** add node, user, member, replies, nodes commands ([#282](https://github.com/jackwener/opencli/issues/282)) ([a83027d](https://github.com/jackwener/opencli/commit/a83027d))
|
||||
* **hackernews:** add new, best, ask, show, jobs, search, user commands ([#290](https://github.com/jackwener/opencli/issues/290)) ([127a974](https://github.com/jackwener/opencli/commit/127a974))
|
||||
* **doubao-app:** add Doubao AI desktop app CLI adapter ([#289](https://github.com/jackwener/opencli/issues/289)) ([66c4b84](https://github.com/jackwener/opencli/commit/66c4b84))
|
||||
* **doubao:** add doubao browser adapter ([#277](https://github.com/jackwener/opencli/issues/277)) ([9cdc127](https://github.com/jackwener/opencli/commit/9cdc127))
|
||||
* **xiaohongshu:** add publish command for 图文 note automation ([#276](https://github.com/jackwener/opencli/issues/276)) ([a6d993f](https://github.com/jackwener/opencli/commit/a6d993f))
|
||||
* **weixin:** add weixin article download adapter & abstract download helpers ([#280](https://github.com/jackwener/opencli/issues/280)) ([b7c6c02](https://github.com/jackwener/opencli/commit/b7c6c02))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tests:** use positional arg syntax in browser search tests ([#302](https://github.com/jackwener/opencli/issues/302)) ([4343ec0](https://github.com/jackwener/opencli/commit/4343ec0))
|
||||
* **xiaohongshu:** improve search login-wall handling and detail output ([#298](https://github.com/jackwener/opencli/issues/298)) ([f8bf663](https://github.com/jackwener/opencli/commit/f8bf663))
|
||||
* ensure standard PATH is available for external CLIs ([#285](https://github.com/jackwener/opencli/issues/285)) ([22f5c7a](https://github.com/jackwener/opencli/commit/22f5c7a))
|
||||
* **xiaohongshu:** scope image selector to avoid downloading avatars ([#293](https://github.com/jackwener/opencli/issues/293)) ([3a21be6](https://github.com/jackwener/opencli/commit/3a21be6))
|
||||
* add turndown dependency to package.json ([#288](https://github.com/jackwener/opencli/issues/288)) ([2a52906](https://github.com/jackwener/opencli/commit/2a52906))
|
||||
|
||||
|
||||
## [1.3.0](https://github.com/jackwener/opencli/compare/v1.2.3...v1.3.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **daemon:** harden security against browser CSRF attacks ([#268](https://github.com/jackwener/opencli/issues/268)) ([40bd11d](https://github.com/jackwener/opencli/commit/40bd11d))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* smart page settle via DOM stability detection ([#271](https://github.com/jackwener/opencli/issues/271)) ([4b976da](https://github.com/jackwener/opencli/commit/4b976da))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* doctor defaults to live mode, remove setup command entirely ([#263](https://github.com/jackwener/opencli/issues/263)) ([b4a8089](https://github.com/jackwener/opencli/commit/b4a8089))
|
||||
|
||||
|
||||
## [1.2.3](https://github.com/jackwener/opencli/compare/v1.2.2...v1.2.3) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace all about:blank with data: URI to prevent New Tab Override interception ([#257](https://github.com/jackwener/opencli/issues/257)) ([3e91876](https://github.com/jackwener/opencli/commit/3e91876))
|
||||
* harden resolveTabId against New Tab Override extension interception ([#255](https://github.com/jackwener/opencli/issues/255)) ([112fdef](https://github.com/jackwener/opencli/commit/112fdef))
|
||||
|
||||
|
||||
## [1.2.2](https://github.com/jackwener/opencli/compare/v1.2.1...v1.2.2) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* harden browser automation pipeline (resolves [#249](https://github.com/jackwener/opencli/issues/249)) ([#251](https://github.com/jackwener/opencli/issues/251)) ([71b2c39](https://github.com/jackwener/opencli/commit/71b2c39))
|
||||
|
||||
|
||||
## [1.2.1](https://github.com/jackwener/opencli/compare/v1.2.0...v1.2.1) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **twitter:** harden timeline review findings ([#236](https://github.com/jackwener/opencli/issues/236)) ([4cd0409](https://github.com/jackwener/opencli/commit/4cd0409))
|
||||
* **wikipedia:** fix search arg name + add random and trending commands ([#231](https://github.com/jackwener/opencli/issues/231)) ([1d56dd7](https://github.com/jackwener/opencli/commit/1d56dd7))
|
||||
* resolve inconsistent doctor --live report (fix [#121](https://github.com/jackwener/opencli/issues/121)) ([#224](https://github.com/jackwener/opencli/issues/224)) ([387aa0d](https://github.com/jackwener/opencli/commit/387aa0d))
|
||||
|
||||
|
||||
## [1.2.0](https://github.com/jackwener/opencli/compare/v1.1.0...v1.2.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **douban:** add movie adapter with search, top250, subject, marks, reviews commands ([#239](https://github.com/jackwener/opencli/issues/239)) ([70651d3](https://github.com/jackwener/opencli/commit/70651d3))
|
||||
* **devto:** add devto adapter ([#234](https://github.com/jackwener/opencli/issues/234)) ([ea113a6](https://github.com/jackwener/opencli/commit/ea113a6))
|
||||
* **twitter:** add --type flag to timeline command ([#83](https://github.com/jackwener/opencli/issues/83)) ([e98cf75](https://github.com/jackwener/opencli/commit/e98cf75))
|
||||
* **google:** add search, suggest, news, and trends adapters ([#184](https://github.com/jackwener/opencli/issues/184)) ([4e32599](https://github.com/jackwener/opencli/commit/4e32599))
|
||||
* add douban, sinablog, substack adapters; upgrade medium to TS ([#185](https://github.com/jackwener/opencli/issues/185)) ([bdf5967](https://github.com/jackwener/opencli/commit/bdf5967))
|
||||
* **xueqiu:** add earnings-date command ([#211](https://github.com/jackwener/opencli/issues/211)) ([fae1dce](https://github.com/jackwener/opencli/commit/fae1dce))
|
||||
* **browser:** advanced DOM snapshot engine with 13-layer pruning pipeline ([#210](https://github.com/jackwener/opencli/issues/210)) ([d831b04](https://github.com/jackwener/opencli/commit/d831b04))
|
||||
* **instagram,facebook:** add write actions and extended commands ([#201](https://github.com/jackwener/opencli/issues/201)) ([eb0ccaf](https://github.com/jackwener/opencli/commit/eb0ccaf))
|
||||
* **grok:** add opt-in --web flow for grok ask ([#193](https://github.com/jackwener/opencli/issues/193)) ([fcff2e4](https://github.com/jackwener/opencli/commit/fcff2e4))
|
||||
* **tiktok:** add TikTok adapter with 15 commands ([#202](https://github.com/jackwener/opencli/issues/202)) ([4391ccf](https://github.com/jackwener/opencli/commit/4391ccf))
|
||||
* add Lobste.rs, Instagram, and Facebook adapters ([#199](https://github.com/jackwener/opencli/issues/199)) ([ce484c2](https://github.com/jackwener/opencli/commit/ce484c2))
|
||||
* **medium:** add medium adapter ([#190](https://github.com/jackwener/opencli/issues/190)) ([06c902a](https://github.com/jackwener/opencli/commit/06c902a))
|
||||
* plugin system (Stage 0-2) ([1d39295](https://github.com/jackwener/opencli/commit/1d39295))
|
||||
* make primary args positional across all CLIs ([#242](https://github.com/jackwener/opencli/issues/242)) ([9696db9](https://github.com/jackwener/opencli/commit/9696db9))
|
||||
* **xueqiu:** make primary args positional ([#213](https://github.com/jackwener/opencli/issues/213)) ([fb2a145](https://github.com/jackwener/opencli/commit/fb2a145))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* replace hardcoded skipPreNav with declarative navigateBefore field ([#208](https://github.com/jackwener/opencli/issues/208)) ([a228758](https://github.com/jackwener/opencli/commit/a228758))
|
||||
* **boss:** extract common.ts utilities, fix missing login detection ([#200](https://github.com/jackwener/opencli/issues/200)) ([ae30763](https://github.com/jackwener/opencli/commit/ae30763))
|
||||
* type discovery core ([#219](https://github.com/jackwener/opencli/issues/219)) ([bd274ce](https://github.com/jackwener/opencli/commit/bd274ce))
|
||||
* type browser core ([#218](https://github.com/jackwener/opencli/issues/218)) ([28c393e](https://github.com/jackwener/opencli/commit/28c393e))
|
||||
* type pipeline core ([#217](https://github.com/jackwener/opencli/issues/217)) ([8a4ea41](https://github.com/jackwener/opencli/commit/8a4ea41))
|
||||
* reduce core any usage ([#216](https://github.com/jackwener/opencli/issues/216)) ([45cee57](https://github.com/jackwener/opencli/commit/45cee57))
|
||||
* fail fast on invalid pipeline steps ([#237](https://github.com/jackwener/opencli/issues/237)) ([c76f86c](https://github.com/jackwener/opencli/commit/c76f86c))
|
||||
|
||||
## [1.1.0](https://github.com/jackwener/opencli/compare/v1.0.6...v1.1.0) (2026-03-20)
|
||||
|
||||
|
||||
|
||||
+6
-3
@@ -17,7 +17,8 @@ npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
npm test
|
||||
npm run test:adapter
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
@@ -161,7 +162,8 @@ args: [
|
||||
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
|
||||
|
||||
```bash
|
||||
npx vitest run src/ # Unit tests
|
||||
npm test # Core unit tests (non-adapter)
|
||||
npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili
|
||||
npx vitest run tests/e2e/ # E2E tests
|
||||
npx vitest run # All tests
|
||||
```
|
||||
@@ -194,7 +196,8 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel
|
||||
3. Run the checks that apply:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npx vitest run src/ # Unit tests
|
||||
npm test # Core unit tests
|
||||
npm run test:adapter # Focused adapter tests (if you touched adapter logic)
|
||||
opencli validate # YAML validation (if applicable)
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
|
||||
@@ -23,11 +23,31 @@ 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, kubectl, etc). Zero setup.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Why opencli?
|
||||
|
||||
There are many great browser automation tools. Here's when opencli is the right choice:
|
||||
|
||||
| Your need | Best tool | Why |
|
||||
|-----------|-----------|-----|
|
||||
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
|
||||
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
|
||||
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
|
||||
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
|
||||
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
|
||||
|
||||
**What makes opencli different:**
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
|
||||
- **Broad coverage** — 50+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
@@ -99,7 +119,7 @@ Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | Browser |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
||||
@@ -111,7 +131,7 @@ Run `opencli list` for the live registry.
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | Desktop |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 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 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
||||
@@ -125,8 +145,9 @@ 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` | Public |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
||||
| **linkedin** | `search` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
@@ -145,14 +166,14 @@ Run `opencli list` for the live registry.
|
||||
| **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` | 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` `shared` | Browser |
|
||||
| **sinablog** | `hot` `search` `article` `user` `shared` | Browser |
|
||||
| **substack** | `feed` `search` `publication` `shared` | Browser |
|
||||
| **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 |
|
||||
|
||||
|
||||
@@ -165,7 +186,6 @@ 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` |
|
||||
|
||||
@@ -328,7 +348,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
|
||||
[](https://star-history.com/#jackwener/opencli&Date)
|
||||
|
||||
After publishing the new version, remember to update the browser extension in the Chrome Web Store as well, so the extension release stays in sync with the CLI release.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+30
-10
@@ -25,11 +25,31 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker`、`kubectl` 等本地 CLI
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
## 为什么选 opencli?
|
||||
|
||||
浏览器自动化工具很多,opencli 适合什么场景?
|
||||
|
||||
| 你的需求 | 最佳工具 | 原因 |
|
||||
|----------|----------|------|
|
||||
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
|
||||
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
|
||||
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
|
||||
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
|
||||
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
|
||||
|
||||
**opencli 的核心差异:**
|
||||
|
||||
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
|
||||
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
|
||||
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
|
||||
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
@@ -101,7 +121,7 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
@@ -113,7 +133,7 @@ npm install -g @jackwener/opencli@latest
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **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` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
@@ -127,8 +147,9 @@ 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` | 公开 |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
| **linkedin** | `search` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
@@ -147,14 +168,14 @@ npm install -g @jackwener/opencli@latest
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
||||
| **steam** | `top-sellers` | 公开 |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
||||
| **douban** | `search` `top250` `subject` `marks` `reviews` | 浏览器 |
|
||||
| **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` `shared` | 浏览器 |
|
||||
| **sinablog** | `hot` `search` `article` `user` `shared` | 浏览器 |
|
||||
| **substack** | `feed` `search` `publication` `shared` | 浏览器 |
|
||||
| **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` | 浏览器 |
|
||||
|
||||
|
||||
@@ -167,7 +188,6 @@ 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` |
|
||||
|
||||
@@ -326,7 +346,7 @@ opencli cascade https://api.example.com/data
|
||||
|
||||
[](https://star-history.com/#jackwener/opencli&Date)
|
||||
|
||||
发版完成后,记得也要去 Chrome Web Store 更新浏览器插件,保持插件版本和 CLI 版本同步。
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 150+ commands across 30+ sites."
|
||||
version: 1.1.0
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 1.3.1
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
|
||||
---
|
||||
@@ -182,7 +182,6 @@ opencli antigravity dump # 导出 DOM 和快照调试信息
|
||||
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
|
||||
opencli antigravity model claude # 切换底层模型
|
||||
opencli antigravity watch # 流式监听增量消息
|
||||
opencli antigravity serve --port 8082 # 启动 Anthropic 兼容代理
|
||||
|
||||
# Barchart (browser)
|
||||
opencli barchart quote --symbol AAPL # 股票行情
|
||||
@@ -240,6 +239,175 @@ opencli hf top --limit 10 # 热门模型
|
||||
# 超星学习通 (browser)
|
||||
opencli chaoxing assignments # 作业列表
|
||||
opencli chaoxing exams # 考试列表
|
||||
|
||||
# Douban 豆瓣 (browser)
|
||||
opencli douban search "三体" # 搜索 (query positional)
|
||||
opencli douban top250 # 豆瓣 Top 250
|
||||
opencli douban subject 1234567 # 条目详情 (id positional)
|
||||
opencli douban marks --limit 10 # 我的标记
|
||||
opencli douban reviews --limit 10 # 短评
|
||||
|
||||
# Facebook (browser)
|
||||
opencli facebook feed --limit 10 # 动态流
|
||||
opencli facebook profile username # 用户资料 (id positional)
|
||||
opencli facebook search "AI" # 搜索 (query positional)
|
||||
opencli facebook friends # 好友列表
|
||||
opencli facebook groups # 群组
|
||||
opencli facebook events # 活动
|
||||
opencli facebook notifications # 通知
|
||||
opencli facebook memories # 回忆
|
||||
opencli facebook add-friend username # 添加好友 (id positional)
|
||||
opencli facebook join-group groupid # 加入群组 (id positional)
|
||||
|
||||
# Instagram (browser)
|
||||
opencli instagram explore # 探索
|
||||
opencli instagram profile username # 用户资料 (id positional)
|
||||
opencli instagram search "AI" # 搜索 (query positional)
|
||||
opencli instagram user username # 用户详情 (id positional)
|
||||
opencli instagram followers username # 粉丝 (id positional)
|
||||
opencli instagram following username # 关注 (id positional)
|
||||
opencli instagram follow username # 关注用户 (id positional)
|
||||
opencli instagram unfollow username # 取消关注 (id positional)
|
||||
opencli instagram like postid # 点赞 (id positional)
|
||||
opencli instagram unlike postid # 取消点赞 (id positional)
|
||||
opencli instagram comment postid "评论" # 评论 (id + text positional)
|
||||
opencli instagram save postid # 收藏 (id positional)
|
||||
opencli instagram unsave postid # 取消收藏 (id positional)
|
||||
opencli instagram saved # 已收藏列表
|
||||
|
||||
# TikTok (browser)
|
||||
opencli tiktok explore # 探索
|
||||
opencli tiktok search "AI" # 搜索 (query positional)
|
||||
opencli tiktok profile username # 用户资料 (id positional)
|
||||
opencli tiktok user username # 用户详情 (id positional)
|
||||
opencli tiktok following username # 关注列表 (id positional)
|
||||
opencli tiktok follow username # 关注 (id positional)
|
||||
opencli tiktok unfollow username # 取消关注 (id positional)
|
||||
opencli tiktok like videoid # 点赞 (id positional)
|
||||
opencli tiktok unlike videoid # 取消点赞 (id positional)
|
||||
opencli tiktok comment videoid "评论" # 评论 (id + text positional)
|
||||
opencli tiktok save videoid # 收藏 (id positional)
|
||||
opencli tiktok unsave videoid # 取消收藏 (id positional)
|
||||
opencli tiktok live # 直播
|
||||
opencli tiktok notifications # 通知
|
||||
opencli tiktok friends # 朋友
|
||||
|
||||
# Medium (browser)
|
||||
opencli medium feed --limit 10 # 动态流
|
||||
opencli medium search "AI" # 搜索 (query positional)
|
||||
opencli medium user username # 用户主页 (id positional)
|
||||
|
||||
# Substack (browser)
|
||||
opencli substack feed --limit 10 # 订阅动态
|
||||
opencli substack search "AI" # 搜索 (query positional)
|
||||
opencli substack publication name # 出版物详情 (id positional)
|
||||
|
||||
# Sinablog 新浪博客 (browser)
|
||||
opencli sinablog hot --limit 10 # 热门
|
||||
opencli sinablog search "AI" # 搜索 (query positional)
|
||||
opencli sinablog article url # 文章详情
|
||||
opencli sinablog user username # 用户主页 (id positional)
|
||||
|
||||
# Lobsters (public)
|
||||
opencli lobsters hot --limit 10 # 热门
|
||||
opencli lobsters newest --limit 10 # 最新
|
||||
opencli lobsters active --limit 10 # 活跃
|
||||
opencli lobsters tag rust # 按标签筛选 (tag positional)
|
||||
|
||||
# Google (public)
|
||||
opencli google news --limit 10 # 新闻
|
||||
opencli google search "AI" # 搜索 (query positional)
|
||||
opencli google suggest "AI" # 搜索建议 (query positional)
|
||||
opencli google trends # 趋势
|
||||
|
||||
# DEV.to (public)
|
||||
opencli devto top --limit 10 # 热门文章
|
||||
opencli devto tag javascript --limit 10 # 按标签 (tag positional)
|
||||
opencli devto user username # 用户文章 (username positional)
|
||||
|
||||
# Steam (public)
|
||||
opencli steam top-sellers --limit 10 # 热销游戏
|
||||
|
||||
# Wikipedia (public)
|
||||
opencli wikipedia search "AI" # 搜索 (query positional)
|
||||
opencli wikipedia summary "Python" # 摘要 (title positional)
|
||||
```
|
||||
|
||||
### Desktop Adapter Commands
|
||||
|
||||
```bash
|
||||
# Cursor (desktop — CDP via Electron)
|
||||
opencli cursor status # 检查连接
|
||||
opencli cursor send "message" # 发送消息
|
||||
opencli cursor read # 读取回复
|
||||
opencli cursor new # 新建对话
|
||||
opencli cursor dump # 导出 DOM 调试信息
|
||||
opencli cursor composer # Composer 模式
|
||||
opencli cursor model claude # 切换模型
|
||||
opencli cursor extract-code # 提取代码块
|
||||
opencli cursor ask "question" # 一键提问并等回复
|
||||
opencli cursor screenshot # 截图
|
||||
opencli cursor history # 对话历史
|
||||
opencli cursor export # 导出对话
|
||||
|
||||
# Codex (desktop — headless CLI agent)
|
||||
opencli codex status # 检查连接
|
||||
opencli codex send "message" # 发送消息
|
||||
opencli codex read # 读取回复
|
||||
opencli codex new # 新建对话
|
||||
opencli codex dump # 导出调试信息
|
||||
opencli codex extract-diff # 提取 diff
|
||||
opencli codex model gpt-4 # 切换模型
|
||||
opencli codex ask "question" # 一键提问并等回复
|
||||
opencli codex screenshot # 截图
|
||||
opencli codex history # 对话历史
|
||||
opencli codex export # 导出对话
|
||||
|
||||
# ChatGPT (desktop — macOS AppleScript/CDP)
|
||||
opencli chatgpt status # 检查应用状态
|
||||
opencli chatgpt new # 新建对话
|
||||
opencli chatgpt send "message" # 发送消息
|
||||
opencli chatgpt read # 读取回复
|
||||
opencli chatgpt ask "question" # 一键提问并等回复
|
||||
|
||||
# ChatWise (desktop — multi-LLM client)
|
||||
opencli chatwise status # 检查连接
|
||||
opencli chatwise new # 新建对话
|
||||
opencli chatwise send "message" # 发送消息
|
||||
opencli chatwise read # 读取回复
|
||||
opencli chatwise ask "question" # 一键提问并等回复
|
||||
opencli chatwise model claude # 切换模型
|
||||
opencli chatwise history # 对话历史
|
||||
opencli chatwise export # 导出对话
|
||||
opencli chatwise screenshot # 截图
|
||||
|
||||
# Notion (desktop — CDP via Electron)
|
||||
opencli notion status # 检查连接
|
||||
opencli notion search "keyword" # 搜索页面
|
||||
opencli notion read # 读取当前页面
|
||||
opencli notion new # 新建页面
|
||||
opencli notion write "content" # 写入内容
|
||||
opencli notion sidebar # 侧边栏导航
|
||||
opencli notion favorites # 收藏列表
|
||||
opencli notion export # 导出
|
||||
|
||||
# Discord App (desktop — CDP via Electron)
|
||||
opencli discord-app status # 检查连接
|
||||
opencli discord-app send "message" # 发送消息
|
||||
opencli discord-app read # 读取消息
|
||||
opencli discord-app channels # 频道列表
|
||||
opencli discord-app servers # 服务器列表
|
||||
opencli discord-app search "keyword" # 搜索
|
||||
opencli discord-app members # 成员列表
|
||||
|
||||
# Doubao App 豆包桌面版 (desktop — CDP via Electron)
|
||||
opencli doubao-app status # 检查连接
|
||||
opencli doubao-app new # 新建对话
|
||||
opencli doubao-app send "message" # 发送消息
|
||||
opencli doubao-app read # 读取回复
|
||||
opencli doubao-app ask "question" # 一键提问并等回复
|
||||
opencli doubao-app screenshot # 截图
|
||||
opencli doubao-app dump # 导出 DOM 调试信息
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
@@ -285,8 +453,8 @@ opencli cascade <api-url>
|
||||
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
|
||||
opencli explore <url> --auto --click "字幕,CC,评论"
|
||||
|
||||
# Verify: validate adapter definitions
|
||||
opencli verify
|
||||
# Validate: validate adapter definitions
|
||||
opencli validate
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Getting Started', link: '/guide/getting-started' },
|
||||
{ text: 'Installation', link: '/guide/installation' },
|
||||
{ text: 'Comparison', link: '/comparison' },
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
{ text: 'Plugins', link: '/guide/plugins' },
|
||||
@@ -80,6 +81,7 @@ 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' },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Dictionary
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `api.dictionaryapi.dev`
|
||||
|
||||
Search the open dictionary to quickly fetch native definitions, part of speech contexts, and phonetic pronunciations directly in your IDE terminal.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli dictionary search` | Fetch the exact definition of a word |
|
||||
| `opencli dictionary synonyms` | Find related synonyms for a word |
|
||||
| `opencli dictionary examples` | Read real-world sentence usage examples |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Look up a complex term
|
||||
opencli dictionary search serendipity
|
||||
|
||||
# Discover phonetics
|
||||
opencli dictionary search ephemeral
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — utilizes the fast, open JSON definitions API.
|
||||
@@ -6,19 +6,17 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
||||
| `opencli douban top250` | 豆瓣电影 Top 250 |
|
||||
| `opencli douban subject` | 条目详情 |
|
||||
| `opencli douban marks` | 我的标记 |
|
||||
| `opencli douban reviews` | 我的短评 |
|
||||
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
|
||||
| `opencli douban book-hot` | 豆瓣图书热门榜单 |
|
||||
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# 电影热门
|
||||
opencli douban movie-hot --limit 10
|
||||
|
||||
# 图书热门
|
||||
opencli douban book-hot --limit 10
|
||||
|
||||
# 搜索电影
|
||||
opencli douban search "流浪地球"
|
||||
|
||||
@@ -28,8 +26,20 @@ opencli douban search --type book "三体"
|
||||
# 搜索音乐
|
||||
opencli douban search --type music "周杰伦"
|
||||
|
||||
# 电影 Top 250
|
||||
opencli douban top250 --limit 10
|
||||
|
||||
# 条目详情
|
||||
opencli douban subject 1292052
|
||||
|
||||
# 电影热门
|
||||
opencli douban movie-hot --limit 10
|
||||
|
||||
# 图书热门
|
||||
opencli douban book-hot --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli douban movie-hot -f json
|
||||
opencli douban top250 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# JD.com
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `item.jd.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Get product details by SKU
|
||||
opencli jd item 100291143898
|
||||
|
||||
# Limit detail images
|
||||
opencli jd item 100291143898 --images 5
|
||||
|
||||
# JSON output
|
||||
opencli jd item 100291143898 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** jd.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -7,6 +7,7 @@
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli linkedin search` | |
|
||||
| `opencli linkedin timeline` | Read posts from your LinkedIn home feed |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -14,9 +15,14 @@
|
||||
# Quick start
|
||||
opencli linkedin search --limit 5
|
||||
|
||||
# Read your home timeline
|
||||
opencli linkedin timeline --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli linkedin search -f json
|
||||
|
||||
opencli linkedin timeline -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli linkedin search -v
|
||||
```
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Web
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: any URL
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli web read <url>` | Fetch any web page and export as Markdown |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Read a web page and save as Markdown
|
||||
opencli web read https://example.com/article
|
||||
|
||||
# Custom output directory
|
||||
opencli web read https://example.com/article --output ./my-articles
|
||||
|
||||
# Skip image download
|
||||
opencli web read https://example.com/article --download-images false
|
||||
|
||||
# JSON output
|
||||
opencli web read https://example.com/article -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -8,8 +8,6 @@
|
||||
|---------|-------------|
|
||||
| `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
|
||||
|
||||
@@ -20,15 +18,8 @@ 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
|
||||
|
||||
@@ -47,6 +47,3 @@ Quickly target and switch the active LLM engine. Example: `opencli antigravity m
|
||||
|
||||
### `opencli antigravity watch`
|
||||
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
|
||||
|
||||
### `opencli antigravity serve --port 8082`
|
||||
Start an Anthropic-compatible `/v1/messages` proxy backed by the local Antigravity app. Useful when you want external tools to talk to Antigravity through an API-shaped interface.
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
# Doubao App
|
||||
# Doubao App (豆包桌面版)
|
||||
|
||||
Drive the **Doubao (豆包) AI desktop app** via Chrome DevTools Protocol. This adapter controls the Electron-based Doubao client directly.
|
||||
Control the **Doubao AI Desktop App** via Chrome DevTools Protocol (CDP).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install the Doubao desktop app from [doubao.com](https://www.doubao.com/).
|
||||
2. Launch with remote debugging enabled:
|
||||
|
||||
```bash
|
||||
/Applications/Doubao.app/Contents/MacOS/Doubao \
|
||||
--remote-debugging-port=9226
|
||||
```
|
||||
|
||||
3. Set the CDP endpoint:
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
|
||||
```
|
||||
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 if the Doubao app is running and reachable |
|
||||
| `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 active chat |
|
||||
| `opencli doubao-app read` | Read all messages in the current 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` | Dump the current page DOM snapshot |
|
||||
| `opencli doubao-app dump` | Export DOM and snapshot debug info |
|
||||
|
||||
## How It Works
|
||||
|
||||
The adapter connects to Doubao's Electron renderer via CDP and uses `data-testid` selectors to interact with the chat UI. Text injection uses React's internal value setter for reliable textarea updates.
|
||||
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
|
||||
|
||||
- macOS only (Electron app path)
|
||||
- Requires Doubao to be launched with `--remote-debugging-port`
|
||||
- `read` returns messages visible in the current conversation only
|
||||
- Requires Doubao Desktop to be launched with `--remote-debugging-port`
|
||||
- macOS / Linux / Windows (Electron-based, platform independent)
|
||||
|
||||
+10
-9
@@ -6,17 +6,17 @@ Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 🔐 Browser |
|
||||
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
|
||||
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
||||
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `me` `user` `download` `publish` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 🔐 Browser |
|
||||
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
|
||||
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
|
||||
| **[weibo](/adapters/browser/weibo)** | `hot` `search` | 🔐 Browser |
|
||||
| **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser |
|
||||
| **[linkedin](/adapters/browser/linkedin)** | `search` `timeline` | 🔐 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 |
|
||||
| **[ctrip](/adapters/browser/ctrip)** | `search` | 🔐 Browser |
|
||||
@@ -30,12 +30,12 @@ Run `opencli list` for the live registry.
|
||||
| **[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` | 🔐 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` `shared` | 🔐 Browser |
|
||||
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` `shared` | 🔐 Browser |
|
||||
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` `shared` | 🔐 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
|
||||
@@ -45,6 +45,7 @@ Run `opencli list` for the live registry.
|
||||
| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
|
||||
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
|
||||
| **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public |
|
||||
| **[dictionary](/adapters/browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
|
||||
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
@@ -53,7 +54,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` | 🌐 Public |
|
||||
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
|
||||
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
|
||||
|
||||
## Desktop Adapters
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Comparison Guide
|
||||
|
||||
OpenCLI occupies a specific niche in the browser automation ecosystem. This guide honestly evaluates where opencli excels, where it's a viable option, and where other tools are a better fit.
|
||||
|
||||
## At a Glance
|
||||
|
||||
| Tool | Approach | Best for |
|
||||
|------|----------|----------|
|
||||
| **opencli** | Pre-built adapters (YAML/TS) | Deterministic site commands, broad platform coverage, desktop apps |
|
||||
| **Browser-Use** | LLM-driven browser control | General-purpose AI browser automation |
|
||||
| **Crawl4AI** | Async web crawler | Large-scale data crawling |
|
||||
| **Firecrawl** | Scraping API / self-hosted | Clean markdown extraction, managed or self-hosted infrastructure |
|
||||
| **agent-browser** | Browser primitive CLI | Token-efficient AI agent browsing |
|
||||
| **Stagehand** | AI browser framework | Developer-friendly browser automation |
|
||||
| **Skyvern** | Visual AI automation | Cross-site generalized workflows |
|
||||
|
||||
## Scenario Comparison
|
||||
|
||||
### 1. Scheduled Batch Data Extraction
|
||||
|
||||
> "I want to pull trending posts from Bilibili/Reddit/HackerNews every hour into my pipeline."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | One command, structured JSON output, zero runtime cost. Runs in cron/CI without tokens or API keys. |
|
||||
| Crawl4AI | Good | Strong for large-scale crawling, but requires writing extraction logic per site. |
|
||||
| Firecrawl | Viable | Managed service with clean output, but costs scale with volume. |
|
||||
| Browser-Use / Stagehand | Poor | LLM inference on every run is slow, expensive, and non-deterministic for repeated tasks. |
|
||||
|
||||
**Why opencli wins here:** A command like `opencli bilibili hot -f json` returns the same structured schema every time, costs nothing to run, and finishes in seconds. For recurring data extraction from known sites, pre-built adapters beat LLM-driven approaches on cost, speed, and reliability.
|
||||
|
||||
### 2. AI Agent Site Operations
|
||||
|
||||
> "My AI agent needs to search Twitter, read Reddit threads, or post to Xiaohongshu."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | Structured JSON output, fast deterministic execution, hundreds of commands ready to use. |
|
||||
| agent-browser | Good | Token-efficient browser primitives, but requires LLM reasoning for every step. |
|
||||
| Browser-Use | Viable | General-purpose, but each operation costs tokens and takes 10-60s. |
|
||||
| Stagehand | Viable | Good DX, but same LLM-per-action cost model. |
|
||||
|
||||
**Why opencli wins here:** When your agent needs `twitter search "AI news" -f json`, a deterministic command that returns in seconds is strictly better than an LLM clicking through a webpage. The agent saves tokens for reasoning, not navigation.
|
||||
|
||||
### 3. Authenticated Operations (Login-Required Sites)
|
||||
|
||||
> "I need to access my bookmarks, post content, or interact with sites that require login."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | Reuses your Chrome login session via Browser Bridge. No credentials stored or transmitted. |
|
||||
| Browser-Use | Viable | Can use browser profiles, but credential management is manual. |
|
||||
| Firecrawl | Poor | Cloud service cannot access your authenticated sessions. |
|
||||
| Crawl4AI | Poor | Requires manual cookie/session injection. |
|
||||
|
||||
**Why opencli wins here:** The Browser Bridge extension reuses your existing Chrome login state in real-time. You log in once in Chrome, and opencli commands work immediately. No OAuth setup, no API keys, no credential files.
|
||||
|
||||
### 4. General Web Browsing & Exploration
|
||||
|
||||
> "I need to explore an unknown website, fill forms, or navigate complex multi-step flows."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| Browser-Use | Best | LLM-driven, handles arbitrary websites and flows. |
|
||||
| Stagehand | Best | Clean API for `act()`, `extract()`, `observe()` on any page. |
|
||||
| agent-browser | Good | Token-efficient primitives for AI agents. |
|
||||
| Skyvern | Good | Visual AI that generalizes across sites. |
|
||||
| **opencli** | Poor | Only works with sites that have pre-built adapters. Cannot handle arbitrary websites. |
|
||||
|
||||
**opencli is not the right tool here.** If you need to explore unknown websites or handle one-off tasks on sites without adapters, use an LLM-driven browser tool. opencli trades generality for determinism and cost.
|
||||
|
||||
### 5. Desktop App Control
|
||||
|
||||
> "I want to script Cursor, ChatGPT, Notion, or other Electron apps from the terminal."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | 8 desktop adapters via CDP + AppleScript. The only CLI tool with this capability. |
|
||||
| All others | N/A | Browser automation tools cannot control desktop applications. |
|
||||
|
||||
**This is unique to opencli.** No other tool in this comparison can send a prompt to ChatGPT desktop, extract code from Cursor, or write to Notion pages via CLI.
|
||||
|
||||
## Key Trade-offs
|
||||
|
||||
### opencli's Strengths
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times for free.
|
||||
- **Deterministic output** — Same command always returns the same schema. Pipeable, scriptable, CI-friendly.
|
||||
- **Speed** — Adapter commands return in seconds, not minutes.
|
||||
- **Broad platform coverage** — 50+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more.
|
||||
- **Easy to extend** — Drop a `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
|
||||
|
||||
### opencli's Limitations
|
||||
|
||||
- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a YAML or TypeScript adapter.
|
||||
- **Adapter maintenance** — When a website updates its DOM or API, the corresponding adapter may need updating. The community maintains these, but breakage is possible.
|
||||
- **Not general-purpose** — Cannot handle arbitrary websites. For unknown sites, pair opencli with a general browser tool as a fallback.
|
||||
|
||||
## Complementary Usage
|
||||
|
||||
opencli works best alongside general-purpose browser tools, not as a replacement:
|
||||
|
||||
```
|
||||
Has adapter? ──yes──▶ opencli (fast, free, deterministic)
|
||||
│
|
||||
no
|
||||
│
|
||||
▼
|
||||
One-off task? ──yes──▶ Browser-Use / Stagehand (LLM-driven)
|
||||
│
|
||||
no
|
||||
│
|
||||
▼
|
||||
Recurring? ──yes──▶ Write an opencli adapter, then use opencli
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](./developer/architecture.md)
|
||||
- [Writing a YAML Adapter](./developer/yaml-adapter.md)
|
||||
- [Writing a TypeScript Adapter](./developer/ts-adapter.md)
|
||||
- [Testing Guide](./developer/testing.md)
|
||||
- [AI Workflow](./developer/ai-workflow.md)
|
||||
- [Contributing Guide](./developer/contributing.md)
|
||||
@@ -17,7 +17,8 @@ npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
npm test
|
||||
npm run test:adapter
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
@@ -129,7 +130,8 @@ chore: bump vitest to v4
|
||||
3. Run the checks:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npx vitest run src/ # Unit tests
|
||||
npm test # Core unit tests
|
||||
npm run test:adapter # Focused adapter tests (if adapter logic changed)
|
||||
opencli validate # YAML validation (if applicable)
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
|
||||
@@ -30,12 +30,14 @@ tests/
|
||||
├── smoke/
|
||||
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
|
||||
src/
|
||||
└── **/*.test.ts # 单元测试(当前 31 个文件)
|
||||
├── **/*.test.ts # 核心单元测试(默认 `unit` project)
|
||||
└── clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts # 聚焦 adapter tests
|
||||
```
|
||||
|
||||
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|
||||
|---|---|---:|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | 31 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 |
|
||||
| 单元测试 | `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 与注册完整性 |
|
||||
|
||||
@@ -43,13 +45,13 @@ src/
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### 单元测试(31 个文件)
|
||||
### 单元测试与 Adapter 测试
|
||||
|
||||
| 领域 | 文件 |
|
||||
|---|---|
|
||||
| 核心运行时与输出 | `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/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.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` |
|
||||
|
||||
这些测试覆盖的重点包括:
|
||||
|
||||
@@ -99,8 +101,11 @@ npm run build # 编译(E2E / smoke 测试需要 dist/main.js)
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 全部单元测试
|
||||
npx vitest run src/
|
||||
# 默认核心单元测试(不含大多数 adapter tests)
|
||||
npm test
|
||||
|
||||
# 聚焦 adapter tests(只保留 4 个重点站点)
|
||||
npm run test:adapter
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
|
||||
npx vitest run tests/e2e/
|
||||
@@ -192,7 +197,8 @@ it('producthunt me fails gracefully without login', async () => {
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
|
||||
| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 |
|
||||
| `unit-test` | push/PR 到 `main`,`dev` | Node `20` 与 `22` 双版本运行核心 `unit` tests,按 `2` shard 并行 |
|
||||
| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 运行聚焦的 `zhihu/twitter/reddit/bilibili` adapter tests |
|
||||
| `smoke-test` | `schedule` 或 `workflow_dispatch` | 安装真实 Chrome,`xvfb-run` 执行 `tests/smoke/` |
|
||||
|
||||
### `e2e-headed.yml`
|
||||
@@ -214,7 +220,7 @@ strategy:
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
- run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
:::
|
||||
|
||||
|
||||
Vendored
+519
-444
File diff suppressed because it is too large
Load Diff
@@ -321,6 +321,14 @@ 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'
|
||||
@@ -398,12 +406,12 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
executor.detach(target.id);
|
||||
await executor.detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
executor.detach(tabId);
|
||||
await executor.detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
}
|
||||
case 'select': {
|
||||
|
||||
@@ -144,10 +144,10 @@ export async function screenshot(
|
||||
}
|
||||
}
|
||||
|
||||
export function detach(tabId: number): void {
|
||||
export async function detach(tabId: number): Promise<void> {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function registerListeners(): void {
|
||||
@@ -158,12 +158,9 @@ 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((tabId, info) => {
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
||||
if (info.url && !isDebuggableUrl(info.url)) {
|
||||
if (attached.has(tabId)) {
|
||||
attached.delete(tabId);
|
||||
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
await detach(tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+138
-158
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/turndown": "^5.0.6",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
@@ -24,9 +23,10 @@
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
@@ -195,7 +195,6 @@
|
||||
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.49.2",
|
||||
"@algolia/requester-browser-xhr": "5.49.2",
|
||||
@@ -405,9 +404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -417,9 +416,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -927,20 +926,10 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/runtime": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
|
||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -948,9 +937,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -965,9 +954,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -982,9 +971,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -999,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1016,9 +1005,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1033,9 +1022,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1050,9 +1039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1067,9 +1056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1084,9 +1073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1101,9 +1090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1118,9 +1107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1135,9 +1124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1152,9 +1141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -1169,9 +1158,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1186,9 +1175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1203,9 +1192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1755,6 +1744,7 @@
|
||||
"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": {
|
||||
@@ -1789,16 +1779,16 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
|
||||
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
|
||||
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
@@ -1807,13 +1797,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
|
||||
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
|
||||
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1822,7 +1812,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -1834,9 +1824,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
|
||||
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
|
||||
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1847,13 +1837,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
|
||||
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
|
||||
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1861,14 +1851,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
|
||||
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
|
||||
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1877,9 +1867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
|
||||
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
|
||||
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1887,13 +1877,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
|
||||
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
|
||||
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
@@ -2172,7 +2162,6 @@
|
||||
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.15.2",
|
||||
"@algolia/client-abtesting": "5.49.2",
|
||||
@@ -2501,7 +2490,6 @@
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
@@ -2630,7 +2618,6 @@
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -3102,7 +3089,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3206,14 +3192,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.115.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.9"
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.11"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -3222,21 +3208,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.11",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.11",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.11",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -3491,7 +3477,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -3516,12 +3501,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3641,18 +3625,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
|
||||
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/runtime": "0.115.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.9",
|
||||
"rolldown": "1.0.0-rc.11",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3669,7 +3651,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.0.0-alpha.31",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -4212,7 +4194,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -4268,19 +4249,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
|
||||
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.0",
|
||||
"@vitest/mocker": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/runner": "4.1.0",
|
||||
"@vitest/snapshot": "4.1.0",
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/expect": "4.1.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/runner": "4.1.1",
|
||||
"@vitest/snapshot": "4.1.1",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -4292,7 +4273,7 @@
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4308,13 +4289,13 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.0",
|
||||
"@vitest/browser-preview": "4.1.0",
|
||||
"@vitest/browser-webdriverio": "4.1.0",
|
||||
"@vitest/ui": "4.1.0",
|
||||
"@vitest/browser-playwright": "4.1.1",
|
||||
"@vitest/browser-preview": "4.1.1",
|
||||
"@vitest/browser-webdriverio": "4.1.1",
|
||||
"@vitest/ui": "4.1.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
@@ -4355,7 +4336,6 @@
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
@@ -4390,9 +4370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -30,6 +30,7 @@
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run --project unit",
|
||||
"test:adapter": "vitest run --project adapter",
|
||||
"test:all": "vitest run",
|
||||
"test:e2e": "vitest run --project e2e",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
@@ -49,7 +50,6 @@
|
||||
"url": "git+https://github.com/jackwener/opencli.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/turndown": "^5.0.6",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
@@ -60,9 +60,10 @@
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ total=0
|
||||
|
||||
for adapter_dir in "$SRC_DIR"/*/; do
|
||||
adapter_name="$(basename "$adapter_dir")"
|
||||
# Skip internal directories (e.g., _shared)
|
||||
[[ "$adapter_name" == _* ]] && continue
|
||||
total=$((total + 1))
|
||||
|
||||
# Check if doc exists in browser/ or desktop/ subdirectories
|
||||
|
||||
+51
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, __test__ } from './browser/index.js';
|
||||
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
|
||||
describe('browser helpers', () => {
|
||||
@@ -133,3 +133,53 @@ describe('BrowserBridge state', () => {
|
||||
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stealth anti-detection', () => {
|
||||
it('generates non-empty JS string', () => {
|
||||
const js = generateStealthJs();
|
||||
expect(typeof js).toBe('string');
|
||||
expect(js.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('contains all 7 anti-detection patches', () => {
|
||||
const js = generateStealthJs();
|
||||
// 1. webdriver
|
||||
expect(js).toContain('navigator');
|
||||
expect(js).toContain('webdriver');
|
||||
// 2. chrome stub
|
||||
expect(js).toContain('window.chrome');
|
||||
// 3. plugins
|
||||
expect(js).toContain('plugins');
|
||||
expect(js).toContain('PDF Viewer');
|
||||
// 4. languages
|
||||
expect(js).toContain('languages');
|
||||
// 5. permissions
|
||||
expect(js).toContain('Permissions');
|
||||
expect(js).toContain('notifications');
|
||||
// 6. automation artifacts (dynamic cdc_ scan)
|
||||
expect(js).toContain('__playwright');
|
||||
expect(js).toContain('__puppeteer');
|
||||
expect(js).toContain('getOwnPropertyNames');
|
||||
expect(js).toContain('cdc_');
|
||||
// 7. CDP stack trace cleanup
|
||||
expect(js).toContain('Error.prototype');
|
||||
expect(js).toContain('puppeteer_evaluation_script');
|
||||
expect(js).toContain('getOwnPropertyDescriptor');
|
||||
});
|
||||
|
||||
it('includes guard flag to prevent double-injection', () => {
|
||||
const js = generateStealthJs();
|
||||
// Guard uses a non-enumerable property on a built-in prototype
|
||||
expect(js).toContain("EventTarget.prototype");
|
||||
// Guard should check early and return 'skipped'
|
||||
expect(js).toContain("return 'skipped'");
|
||||
// Normal path returns 'applied'
|
||||
expect(js).toContain("return 'applied'");
|
||||
});
|
||||
|
||||
it('generates syntactically valid JS', () => {
|
||||
const js = generateStealthJs();
|
||||
// Should not throw when parsed
|
||||
expect(() => new Function(js)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+19
-11
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -50,6 +51,8 @@ export class CDPBridge {
|
||||
private _eventListeners = new Map<string, Set<(params: unknown) => void>>();
|
||||
|
||||
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
|
||||
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');
|
||||
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
|
||||
|
||||
@@ -71,9 +74,16 @@ export class CDPBridge {
|
||||
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
|
||||
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.on('open', async () => {
|
||||
clearTimeout(timeout);
|
||||
this._ws = ws;
|
||||
// Register stealth script to run before any page JS on every navigation.
|
||||
try {
|
||||
await this.send('Page.enable');
|
||||
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
resolve(new CDPPage(this));
|
||||
});
|
||||
|
||||
@@ -169,13 +179,17 @@ export class CDPBridge {
|
||||
}
|
||||
|
||||
class CDPPage implements IPage {
|
||||
private _pageEnabled = false;
|
||||
constructor(private bridge: CDPBridge) {}
|
||||
|
||||
/** Navigate with proper load event waiting (P1 fix #3) */
|
||||
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
|
||||
await this.bridge.send('Page.enable');
|
||||
if (!this._pageEnabled) {
|
||||
await this.bridge.send('Page.enable');
|
||||
this._pageEnabled = true;
|
||||
}
|
||||
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
|
||||
.catch(() => {}); // Don't fail if event times out
|
||||
.catch(() => {}); // Don't fail if load event times out — page may be an SPA
|
||||
await this.bridge.send('Page.navigate', { url });
|
||||
await loadPromise;
|
||||
// Smart settle: use DOM stability detection instead of fixed sleep.
|
||||
@@ -278,11 +292,7 @@ class CDPPage implements IPage {
|
||||
});
|
||||
const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : '';
|
||||
if (options.path) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.dirname(options.path);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
|
||||
await saveBase64ToFile(base64, options.path);
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
@@ -327,9 +337,7 @@ class CDPPage implements IPage {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
import { isRecord, saveBase64ToFile } from '../utils.js';
|
||||
|
||||
function isCookie(value: unknown): value is BrowserCookie {
|
||||
return isRecord(value)
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* Provides a typed send() function that posts a Command and returns a Result.
|
||||
*/
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
||||
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import type { BrowserSessionInfo } from '../types.js';
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* scanning for @playwright/mcp locations.
|
||||
*/
|
||||
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import { isDaemonRunning } from './daemon-client.js';
|
||||
|
||||
export { isDaemonRunning };
|
||||
@@ -17,7 +18,7 @@ export async function checkDaemonStatus(): Promise<{
|
||||
extensionConnected: boolean;
|
||||
}> {
|
||||
try {
|
||||
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
||||
|
||||
@@ -17,7 +18,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'The daemon should start automatically. If it doesn\'t, try:\n' +
|
||||
' node dist/daemon.js\n' +
|
||||
'Make sure port 19825 is available.',
|
||||
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
);
|
||||
case 'extension-not-connected':
|
||||
return new BrowserConnectError(
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
export { Page } from './page.js';
|
||||
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
||||
export { BrowserBridge } 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 { SnapshotOptions } from './dom-snapshot.js';
|
||||
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
|
||||
+2
-4
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
|
||||
import type { IPage } from '../types.js';
|
||||
import { Page } from './page.js';
|
||||
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
||||
|
||||
@@ -112,10 +113,7 @@ export class BrowserBridge {
|
||||
throw new Error(
|
||||
'Failed to start opencli daemon. Try running manually:\n' +
|
||||
` node ${daemonPath}\n` +
|
||||
'Make sure port 19825 is available.',
|
||||
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use BrowserBridge instead */
|
||||
export const PlaywrightMCP = BrowserBridge;
|
||||
|
||||
+43
-35
@@ -14,7 +14,9 @@ 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,
|
||||
@@ -35,33 +37,44 @@ export class Page implements IPage {
|
||||
/** Active tab ID, set after navigate and used in all subsequent commands */
|
||||
private _tabId: number | undefined;
|
||||
|
||||
/** Helper: spread tabId into command params if we have one */
|
||||
private _tabOpt(): { tabId: number } | Record<string, never> {
|
||||
return this._tabId !== undefined ? { tabId: this._tabId } : {};
|
||||
/** Helper: spread workspace into command params */
|
||||
private _wsOpt(): { workspace: string } {
|
||||
return { workspace: this.workspace };
|
||||
}
|
||||
|
||||
private _workspaceOpt(): { workspace: string } {
|
||||
return { workspace: this.workspace };
|
||||
/** Helper: spread workspace + tabId into command params */
|
||||
private _cmdOpts(): Record<string, unknown> {
|
||||
return {
|
||||
workspace: this.workspace,
|
||||
...(this._tabId !== undefined && { tabId: this._tabId }),
|
||||
};
|
||||
}
|
||||
|
||||
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
|
||||
const result = await sendCommand('navigate', {
|
||||
url,
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
...this._cmdOpts(),
|
||||
}) as { tabId?: number };
|
||||
// Remember the tabId for subsequent exec calls
|
||||
if (result?.tabId) {
|
||||
this._tabId = result.tabId;
|
||||
}
|
||||
// Inject stealth anti-detection patches (guard flag prevents double-injection).
|
||||
try {
|
||||
await sendCommand('exec', {
|
||||
code: generateStealthJs(),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
// Smart settle: use DOM stability detection instead of fixed sleep.
|
||||
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const maxMs = options?.settleMs ?? 1000;
|
||||
await sendCommand('exec', {
|
||||
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,7 +82,7 @@ export class Page implements IPage {
|
||||
/** Close the automation window in the extension */
|
||||
async closeWindow(): Promise<void> {
|
||||
try {
|
||||
await sendCommand('close-window', { ...this._workspaceOpt() });
|
||||
await sendCommand('close-window', { ...this._wsOpt() });
|
||||
} catch {
|
||||
// Window may already be closed or daemon may be down
|
||||
}
|
||||
@@ -77,11 +90,11 @@ export class Page implements IPage {
|
||||
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
const code = wrapForEval(js);
|
||||
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
return sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
|
||||
const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts });
|
||||
const result = await sendCommand('cookies', { ...this._wsOpt(), ...opts });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
@@ -97,7 +110,7 @@ export class Page implements IPage {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await sendCommand('exec', { code: snapshotJs, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
|
||||
// The advanced engine already produces a clean, pruned, LLM-friendly output.
|
||||
// Do NOT pass through formatSnapshot — its format is incompatible.
|
||||
return result;
|
||||
@@ -137,7 +150,7 @@ export class Page implements IPage {
|
||||
return buildTree(document.body, 0);
|
||||
})()
|
||||
`;
|
||||
const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
if (opts.raw) return raw;
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
@@ -145,27 +158,27 @@ export class Page implements IPage {
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
const code = clickJs(ref);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
const code = typeTextJs(ref, text);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
const code = pressKeyJs(key);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async scrollTo(ref: string): Promise<unknown> {
|
||||
const code = scrollToRefJs(ref);
|
||||
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
return sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async getFormState(): Promise<Record<string, unknown>> {
|
||||
const code = getFormStateJs();
|
||||
return (await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() })) as Record<string, unknown>;
|
||||
return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async wait(options: number | WaitOptions): Promise<void> {
|
||||
@@ -173,42 +186,42 @@ export class Page implements IPage {
|
||||
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.time) {
|
||||
if (typeof options.time === 'number') {
|
||||
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
const code = waitForTextJs(options.text, timeout);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<unknown[]> {
|
||||
const result = await sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
|
||||
const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
|
||||
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
|
||||
// Invalidate cached tabId — the closed tab might have been our active one.
|
||||
// We can't know for sure (close-by-index doesn't return tabId), so reset.
|
||||
this._tabId = undefined;
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number };
|
||||
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
|
||||
if (result?.tabId) this._tabId = result.tabId;
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) as { selected?: number };
|
||||
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
|
||||
if (result?.selected) this._tabId = result.selected;
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
|
||||
const code = networkRequestsJs(includeStatic);
|
||||
const result = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
const result = await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
@@ -230,19 +243,14 @@ export class Page implements IPage {
|
||||
*/
|
||||
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
|
||||
const base64 = await sendCommand('screenshot', {
|
||||
...this._workspaceOpt(),
|
||||
...this._cmdOpts(),
|
||||
format: options.format,
|
||||
quality: options.quality,
|
||||
fullPage: options.fullPage,
|
||||
...this._tabOpt(),
|
||||
}) as string;
|
||||
|
||||
if (options.path) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.dirname(options.path);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
|
||||
await saveBase64ToFile(base64, options.path);
|
||||
}
|
||||
|
||||
return base64;
|
||||
@@ -250,14 +258,14 @@ export class Page implements IPage {
|
||||
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
const code = scrollJs(direction, amount);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
|
||||
const times = options.times ?? 3;
|
||||
const delayMs = options.delayMs ?? 2000;
|
||||
const code = autoScrollJs(times, delayMs);
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Stealth anti-detection module.
|
||||
*
|
||||
* Generates JS code that patches browser globals to hide automation
|
||||
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
|
||||
* plugin list). Injected before page scripts run so that websites cannot
|
||||
* detect CDP / extension-based control.
|
||||
*
|
||||
* Inspired by puppeteer-extra-plugin-stealth.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return a self-contained JS string that, when evaluated in a page context,
|
||||
* applies all stealth patches. Safe to call multiple times — the guard flag
|
||||
* ensures patches are applied only once.
|
||||
*/
|
||||
export function generateStealthJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
// Guard: prevent double-injection across separate CDP evaluations.
|
||||
// We cannot use a closure variable (each eval is a fresh scope), and
|
||||
// window properties / Symbols are discoverable by anti-bot scripts.
|
||||
// Instead, stash the flag in a non-enumerable getter on a built-in
|
||||
// prototype that fingerprinters are unlikely to scan.
|
||||
const _gProto = EventTarget.prototype;
|
||||
const _gKey = '__lsn'; // looks like an internal listener cache
|
||||
if (_gProto[_gKey]) return 'skipped';
|
||||
try {
|
||||
Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true });
|
||||
} catch {}
|
||||
|
||||
// 1. navigator.webdriver → false
|
||||
// Most common check; Playwright/Puppeteer/CDP set this to true.
|
||||
// Real Chrome returns false (not undefined) — returning undefined is
|
||||
// itself a detection signal for advanced fingerprinters.
|
||||
try {
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// 2. window.chrome stub
|
||||
// Real Chrome exposes window.chrome with runtime, loadTimes, csi.
|
||||
// Headless/automated Chrome may not have it.
|
||||
try {
|
||||
if (!window.chrome) {
|
||||
window.chrome = {
|
||||
runtime: {
|
||||
onConnect: { addListener: () => {}, removeListener: () => {} },
|
||||
onMessage: { addListener: () => {}, removeListener: () => {} },
|
||||
},
|
||||
loadTimes: () => ({}),
|
||||
csi: () => ({}),
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 3. navigator.plugins — fake population only if empty
|
||||
// Real user browser already has plugins; only patch in automated/headless
|
||||
// contexts where the list is empty (overwriting real plugins with fakes
|
||||
// would be counterproductive and detectable).
|
||||
try {
|
||||
if (!navigator.plugins || navigator.plugins.length === 0) {
|
||||
const fakePlugins = [
|
||||
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
|
||||
];
|
||||
fakePlugins.item = (i) => fakePlugins[i] || null;
|
||||
fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null;
|
||||
fakePlugins.refresh = () => {};
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => fakePlugins,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 4. navigator.languages — guarantee non-empty
|
||||
// Some automated contexts return undefined or empty array.
|
||||
try {
|
||||
if (!navigator.languages || navigator.languages.length === 0) {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en'],
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 5. Permissions.query — normalize notification permission
|
||||
// Headless Chrome throws on Permissions.query({ name: 'notifications' }).
|
||||
try {
|
||||
const origQuery = window.Permissions?.prototype?.query;
|
||||
if (origQuery) {
|
||||
window.Permissions.prototype.query = function (parameters) {
|
||||
if (parameters?.name === 'notifications') {
|
||||
return Promise.resolve({ state: Notification.permission, onchange: null });
|
||||
}
|
||||
return origQuery.call(this, parameters);
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 6. Clean automation artifacts
|
||||
// Remove properties left by Playwright, Puppeteer, or CDP injection.
|
||||
try {
|
||||
delete window.__playwright;
|
||||
delete window.__puppeteer;
|
||||
// ChromeDriver injects cdc_ prefixed globals; the suffix varies by version,
|
||||
// so scan window for any matching property rather than hardcoding names.
|
||||
for (const prop of Object.getOwnPropertyNames(window)) {
|
||||
if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) {
|
||||
try { delete window[prop]; } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 7. CDP stack trace cleanup
|
||||
// Runtime.evaluate injects scripts whose source URLs appear in Error
|
||||
// stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://).
|
||||
// Websites detect automation by doing: new Error().stack and inspecting it.
|
||||
// We override the stack property getter on Error.prototype to filter them.
|
||||
// Note: Error.prepareStackTrace is V8/Node-only and not available in
|
||||
// browser page context, so we use a property descriptor approach instead.
|
||||
// We use generic protocol patterns instead of product-specific names to
|
||||
// also catch our own injected code frames without leaking identifiers.
|
||||
try {
|
||||
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
|
||||
const _cdpPatterns = [
|
||||
'puppeteer_evaluation_script',
|
||||
'pptr:',
|
||||
'debugger://',
|
||||
'__playwright',
|
||||
'__puppeteer',
|
||||
];
|
||||
if (_origDescriptor && _origDescriptor.get) {
|
||||
Object.defineProperty(Error.prototype, 'stack', {
|
||||
get: function () {
|
||||
const raw = _origDescriptor.get.call(this);
|
||||
if (typeof raw !== 'string') return raw;
|
||||
return raw.split('\\n').filter(line =>
|
||||
!_cdpPatterns.some(p => line.includes(p))
|
||||
).join('\\n');
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return 'applied';
|
||||
})()
|
||||
`;
|
||||
}
|
||||
+5
-29
@@ -46,34 +46,9 @@ export interface ManifestEntry {
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
interface YamlArgDefinition {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
description?: string;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
import type { YamlCliDefinition } from './yaml-schema.js';
|
||||
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
|
||||
function extractBalancedBlock(
|
||||
@@ -180,7 +155,8 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
|
||||
choices: parseInlineChoices(body),
|
||||
});
|
||||
|
||||
cursor = objectStart + body.length + 2;
|
||||
cursor = objectStart + body.length;
|
||||
if (cursor <= objectStart) break; // safety: prevent infinite loop
|
||||
}
|
||||
|
||||
return args;
|
||||
@@ -301,7 +277,7 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
|
||||
* prefer the TS version (it self-registers and typically has richer logic).
|
||||
*/
|
||||
export function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean {
|
||||
if (current.type === next.type) return true;
|
||||
if (current.type === next.type) return false;
|
||||
return current.type === 'yaml' && next.type === 'ts';
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -145,9 +145,10 @@ export async function cascadeProbe(
|
||||
url: string,
|
||||
opts: { maxStrategy?: Strategy; timeout?: number } = {},
|
||||
): Promise<CascadeResult> {
|
||||
const maxIdx = opts.maxStrategy
|
||||
const rawIdx = opts.maxStrategy
|
||||
? CASCADE_ORDER.indexOf(opts.maxStrategy)
|
||||
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
|
||||
const maxIdx = rawIdx === -1 ? CASCADE_ORDER.indexOf(Strategy.HEADER) : rawIdx;
|
||||
|
||||
const probes: ProbeResult[] = [];
|
||||
|
||||
|
||||
+7
-21
@@ -173,8 +173,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const r = await generateCliFromUrl({
|
||||
url,
|
||||
BrowserFactory: getBrowserFactory(),
|
||||
builtinClis: BUILTIN_CLIS,
|
||||
userClis: USER_CLIS,
|
||||
goal: opts.goal,
|
||||
site: opts.site,
|
||||
workspace,
|
||||
@@ -196,7 +194,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
.action(async (url, opts) => {
|
||||
const { recordSession, renderRecordSummary } = await import('./record.js');
|
||||
const result = await recordSession({
|
||||
BrowserFactory: getBrowserFactory() as any,
|
||||
BrowserFactory: getBrowserFactory(),
|
||||
url,
|
||||
site: opts.site,
|
||||
outDir: opts.out,
|
||||
@@ -408,29 +406,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
registerAllCommands(program, siteGroups);
|
||||
|
||||
// ── Unknown command fallback ──────────────────────────────────────────────
|
||||
|
||||
const DENY_LIST = new Set([
|
||||
'rm', 'sudo', 'dd', 'mkfs', 'fdisk', 'shutdown', 'reboot',
|
||||
'kill', 'killall', 'chmod', 'chown', 'passwd', 'su', 'mount',
|
||||
'umount', 'format', 'diskutil',
|
||||
]);
|
||||
// Security: do NOT auto-discover and register arbitrary system binaries.
|
||||
// Only explicitly registered external CLIs (via `opencli register`) are allowed.
|
||||
|
||||
program.on('command:*', (operands: string[]) => {
|
||||
const binary = operands[0];
|
||||
if (DENY_LIST.has(binary)) {
|
||||
console.error(chalk.red(`Refusing to register system command '${binary}'.`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`error: unknown command '${binary}'`));
|
||||
if (isBinaryInstalled(binary)) {
|
||||
console.log(chalk.cyan(`🔹 Auto-discovered local CLI '${binary}'. Registering...`));
|
||||
registerExternalCli(binary);
|
||||
passthroughExternal(binary);
|
||||
} else {
|
||||
console.error(chalk.red(`error: unknown command '${binary}'`));
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
|
||||
}
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Shared command factories for Electron/desktop app adapters.
|
||||
* Eliminates duplicate screenshot/status/new/dump implementations
|
||||
* across cursor, codex, chatwise, etc.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Factory: capture DOM HTML + accessibility snapshot.
|
||||
*/
|
||||
export function makeScreenshotCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'screenshot',
|
||||
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
|
||||
],
|
||||
columns: ['Status', 'File'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
|
||||
|
||||
const snap = await page.snapshot({ compact: true });
|
||||
const html = await page.evaluate('document.documentElement.outerHTML');
|
||||
|
||||
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
|
||||
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
|
||||
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{ Status: 'Success', File: htmlPath },
|
||||
{ Status: 'Success', File: snapPath },
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: check CDP connection status.
|
||||
*/
|
||||
export function makeStatusCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'status',
|
||||
description: `Check active CDP connection to ${label}`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
return [{ Status: 'Connected', Url: url, Title: title }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: start a new session via Cmd/Ctrl+N.
|
||||
*/
|
||||
export function makeNewCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'new',
|
||||
description: `Start a new ${label} session`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
return [{ Status: 'Success' }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: dump DOM + snapshot for reverse-engineering.
|
||||
*/
|
||||
export function makeDumpCommand(site: string) {
|
||||
return cli({
|
||||
site,
|
||||
name: 'dump',
|
||||
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['action', 'files'],
|
||||
func: async (page: IPage) => {
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
|
||||
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'Dom extraction finished',
|
||||
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ cli({
|
||||
columns: ['id', 'title', 'authors', 'published'],
|
||||
func: async (_page, args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
const query = encodeURIComponent(`all:${args.keyword}`);
|
||||
const query = encodeURIComponent(`all:${args.query}`);
|
||||
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
|
||||
const entries = parseEntries(xml);
|
||||
if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './dynamic.js';
|
||||
|
||||
describe('bilibili dynamic adapter', () => {
|
||||
const command = getRegistry().get('bilibili/dynamic');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
});
|
||||
|
||||
it('maps desc text rows from the dynamic feed payload', async () => {
|
||||
mockApiGet.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id_str: '123',
|
||||
modules: {
|
||||
module_author: { name: 'Alice' },
|
||||
module_dynamic: { desc: { text: 'hello world' } },
|
||||
module_stat: { like: { count: 9 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await command!.func!({} as any, { limit: 5 });
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith({}, '/x/polymer/web-dynamic/v1/feed/all', { params: {}, signed: false });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: '123',
|
||||
author: 'Alice',
|
||||
text: 'hello world',
|
||||
likes: 9,
|
||||
url: 'https://t.bilibili.com/123',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to archive title when desc text is absent', async () => {
|
||||
mockApiGet.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id_str: '456',
|
||||
modules: {
|
||||
module_author: { name: 'Bob' },
|
||||
module_dynamic: { major: { archive: { title: 'Video title' } } },
|
||||
module_stat: { like: { count: 3 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await command!.func!({} as any, { limit: 5 });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: '456',
|
||||
author: 'Bob',
|
||||
text: 'Video title',
|
||||
likes: 3,
|
||||
url: 'https://t.bilibili.com/456',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { apiGet, payloadData } from './utils.js';
|
||||
import { apiGet, payloadData, getSelfUid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -15,9 +15,12 @@ cli({
|
||||
func: async (page, kwargs) => {
|
||||
const { limit = 20, page: pageNum = 1 } = kwargs;
|
||||
|
||||
// Get current user's UID
|
||||
const uid = await getSelfUid(page);
|
||||
|
||||
// Get default favorite folder ID
|
||||
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
|
||||
params: { up_mid: 0 },
|
||||
params: { up_mid: uid },
|
||||
signed: true,
|
||||
});
|
||||
const folders = payloadData(foldersPayload)?.list ?? [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
|
||||
|
||||
@@ -14,7 +15,7 @@ cli({
|
||||
],
|
||||
columns: ['mid', 'name', 'sign', 'following', 'fans'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
|
||||
|
||||
// 1. Resolve UID (default to self)
|
||||
const uid = kwargs.uid
|
||||
@@ -30,7 +31,7 @@ cli({
|
||||
);
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
|
||||
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const list = payload.data?.list || [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { apiGet } from './utils.js';
|
||||
|
||||
@@ -13,7 +14,7 @@ cli({
|
||||
],
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
|
||||
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
|
||||
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
|
||||
|
||||
@@ -24,7 +25,7 @@ cli({
|
||||
})()`);
|
||||
|
||||
if (!cid) {
|
||||
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
}
|
||||
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
@@ -35,12 +36,12 @@ cli({
|
||||
});
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const subtitles = payload.data?.subtitle?.subtitles || [];
|
||||
if (subtitles.length === 0) {
|
||||
throw new Error('此视频没有发现外挂或智能字幕。');
|
||||
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
|
||||
}
|
||||
|
||||
// 4. 选择目标字幕语言
|
||||
@@ -50,7 +51,7 @@ cli({
|
||||
|
||||
const targetSubUrl = target.subtitle_url;
|
||||
if (!targetSubUrl || targetSubUrl === '') {
|
||||
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
}
|
||||
|
||||
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
|
||||
@@ -81,12 +82,12 @@ cli({
|
||||
const items = await page.evaluate(fetchJs);
|
||||
|
||||
if (items?.error) {
|
||||
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
}
|
||||
|
||||
const finalItems = items?.data || [];
|
||||
if (!Array.isArray(finalItems)) {
|
||||
throw new Error('解析到的字幕列表对象不符合数组格式');
|
||||
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
|
||||
}
|
||||
|
||||
// 6. 数据映射
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError } from '../../errors.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,
|
||||
@@ -112,5 +112,5 @@ export async function resolveUid(page: IPage, input: string): Promise<string> {
|
||||
});
|
||||
const results = payload?.data?.result ?? [];
|
||||
if (results.length > 0) return String(results[0].mid);
|
||||
throw new Error(`Cannot resolve UID for: ${input}`);
|
||||
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
import { ArgumentError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
const LABEL_MAP: Record<string, number> = {
|
||||
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
|
||||
@@ -44,7 +45,7 @@ cli({
|
||||
if (entry) {
|
||||
labelId = entry[1];
|
||||
} else {
|
||||
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
|
||||
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ cli({
|
||||
await navigateToChat(page);
|
||||
|
||||
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
|
||||
if (!friend) throw new Error('未找到该候选人');
|
||||
if (!friend) throw new EmptyResultError('boss candidate search');
|
||||
|
||||
const friendName = friend.name || '候选人';
|
||||
const action = remove ? 'deleteMark' : 'addMark';
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
requirePage, navigateToChat, findFriendByUid,
|
||||
clickCandidateInList, typeAndSendMessage,
|
||||
} from './common.js';
|
||||
import { EmptyResultError, SelectorError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
@@ -29,21 +30,21 @@ cli({
|
||||
await navigateToChat(page, 3);
|
||||
|
||||
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
|
||||
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
|
||||
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
|
||||
|
||||
const numericUid = friend.uid;
|
||||
const friendName = friend.name || '候选人';
|
||||
|
||||
const clicked = await clickCandidateInList(page, numericUid);
|
||||
if (!clicked) {
|
||||
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
|
||||
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
}
|
||||
|
||||
await page.wait({ time: 2 });
|
||||
|
||||
const sent = await typeAndSendMessage(page, kwargs.text);
|
||||
if (!sent) {
|
||||
throw new Error('找不到消息输入框');
|
||||
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
}
|
||||
|
||||
await page.wait({ time: 1 });
|
||||
|
||||
@@ -16,6 +16,10 @@ export const askCommand = cli({
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('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;
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ export const newCommand = cli({
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage | null) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('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'");
|
||||
|
||||
@@ -13,6 +13,10 @@ export const readCommand = cli({
|
||||
args: [],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage | null) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('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'");
|
||||
|
||||
@@ -12,6 +12,10 @@ export const statusCommand = cli({
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage | null) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('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' }];
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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' }];
|
||||
},
|
||||
});
|
||||
export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation');
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise');
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');
|
||||
|
||||
+2
-27
@@ -1,28 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
import { makeDumpCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
export const dumpCommand = makeDumpCommand('codex');
|
||||
|
||||
+2
-28
@@ -1,29 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
export const newCommand = makeNewCommand('codex', 'Codex conversation');
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
export const screenshotCommand = makeScreenshotCommand('codex', 'Codex');
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
export const statusCommand = makeStatusCommand('codex', 'OpenAI Codex App');
|
||||
|
||||
+2
-27
@@ -1,28 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
import { makeDumpCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
export const dumpCommand = makeDumpCommand('cursor');
|
||||
|
||||
+2
-20
@@ -1,21 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
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' }];
|
||||
},
|
||||
});
|
||||
export const newCommand = makeNewCommand('cursor', 'Cursor chat or Composer');
|
||||
|
||||
@@ -1,38 +1,3 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
function makeScreenshotCommand(site: string) {
|
||||
return cli({
|
||||
site,
|
||||
name: 'screenshot',
|
||||
description: `Capture a snapshot of the current ${site} 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`;
|
||||
|
||||
// Get both the accessibility snapshot and the raw DOM HTML
|
||||
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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
export const screenshotCursor = makeScreenshotCommand('cursor');
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'status',
|
||||
description: 'Check active CDP connection to Cursor AI Editor',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI, // Interactive UI manipulation
|
||||
browser: true,
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Connected',
|
||||
Url: url,
|
||||
Title: title,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
export const statusCommand = makeStatusCommand('cursor', 'Cursor AI Editor');
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
site: dictionary
|
||||
name: examples
|
||||
description: Read real-world example sentences utilizing the word
|
||||
domain: api.dictionaryapi.dev
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
word:
|
||||
type: string
|
||||
required: true
|
||||
positional: true
|
||||
description: Word to get example sentences for
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
|
||||
|
||||
- map:
|
||||
word: "${{ item.word }}"
|
||||
example: "${{ (() => { if (item.meanings) { for (const m of item.meanings) { if (m.definitions) { for (const d of m.definitions) { if (d.example) return d.example; } } } } return 'No example found in API.'; })() }}"
|
||||
|
||||
- limit: 1
|
||||
|
||||
columns: [word, example]
|
||||
@@ -0,0 +1,27 @@
|
||||
site: dictionary
|
||||
name: search
|
||||
description: Search the Free Dictionary API for definitions, parts of speech, and pronunciations.
|
||||
domain: api.dictionaryapi.dev
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
word:
|
||||
type: string
|
||||
required: true
|
||||
positional: true
|
||||
description: Word to define (e.g., serendipity)
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
|
||||
|
||||
- map:
|
||||
word: "${{ item.word }}"
|
||||
phonetic: "${{ (() => { if (item.phonetic) return item.phonetic; if (item.phonetics) { for (const p of item.phonetics) { if (p.text) return p.text; } } return ''; })() }}"
|
||||
type: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].partOfSpeech) return item.meanings[0].partOfSpeech; return 'N/A'; })() }}"
|
||||
definition: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].definitions && item.meanings[0].definitions[0] && item.meanings[0].definitions[0].definition) return item.meanings[0].definitions[0].definition; return 'No definition found in API.'; })() }}"
|
||||
|
||||
- limit: 1
|
||||
|
||||
columns: [word, phonetic, type, definition]
|
||||
@@ -0,0 +1,25 @@
|
||||
site: dictionary
|
||||
name: synonyms
|
||||
description: Find synonyms for a specific word
|
||||
domain: api.dictionaryapi.dev
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
word:
|
||||
type: string
|
||||
required: true
|
||||
positional: true
|
||||
description: Word to find synonyms for (e.g., serendipity)
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
|
||||
|
||||
- map:
|
||||
word: "${{ item.word }}"
|
||||
synonyms: "${{ (() => { const s = new Set(); if (item.meanings) { for (const m of item.meanings) { if (m.synonyms) { for (const syn of m.synonyms) s.add(syn); } if (m.definitions) { for (const d of m.definitions) { if (d.synonyms) { for (const syn of d.synonyms) s.add(syn); } } } } } const arr = Array.from(s); return arr.length > 0 ? arr.slice(0, 5).join(', ') : 'No synonyms found in API.'; })() }}"
|
||||
|
||||
- limit: 1
|
||||
|
||||
columns: [word, synonyms]
|
||||
@@ -1,7 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { __test__ } from './ask.js';
|
||||
|
||||
describe('grok ask helpers', () => {
|
||||
describe('isOnGrok', () => {
|
||||
const fakePage = (url: string | Error): IPage =>
|
||||
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
|
||||
|
||||
it('returns true for grok.com URLs', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage('https://grok.com/'))).toBe(true);
|
||||
expect(await __test__.isOnGrok(fakePage('https://grok.com/chat/abc123'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for grok.com subdomains', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage('https://api.grok.com/v1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-grok domains', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage('https://fakegrok.com/'))).toBe(false);
|
||||
expect(await __test__.isOnGrok(fakePage('https://example.com/?next=grok.com'))).toBe(false);
|
||||
expect(await __test__.isOnGrok(fakePage('about:blank'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when evaluate throws (detached tab)', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage(new Error('detached')))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes boolean flags for explicit web routing', () => {
|
||||
expect(__test__.normalizeBooleanFlag(true)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('true')).toBe(true);
|
||||
|
||||
+25
-12
@@ -53,6 +53,19 @@ function updateStableState(previousText: string, stableCount: number, nextText:
|
||||
return { previousText: nextText, stableCount: 0 };
|
||||
}
|
||||
|
||||
/** Check whether the tab is already on grok.com (any path). */
|
||||
async function isOnGrok(page: IPage): Promise<boolean> {
|
||||
// catch handles blank tabs (about:blank) or detached pages
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDefaultAsk(
|
||||
page: IPage,
|
||||
prompt: string,
|
||||
@@ -60,21 +73,17 @@ async function runDefaultAsk(
|
||||
newChat: boolean,
|
||||
) {
|
||||
if (newChat) {
|
||||
// Explicitly start a fresh conversation via the homepage
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(2);
|
||||
await page.evaluate(`(() => {
|
||||
const btn = [...document.querySelectorAll('a, button')].find(b => {
|
||||
const t = (b.textContent || '').trim().toLowerCase();
|
||||
return t.includes('new') || b.getAttribute('href') === '/';
|
||||
});
|
||||
if (btn) btn.click();
|
||||
})()`);
|
||||
await tryStartFreshChat(page);
|
||||
await page.wait(2);
|
||||
} else if (!(await isOnGrok(page))) {
|
||||
// First invocation or tab was recycled — navigate to Grok
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(3);
|
||||
}
|
||||
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(3);
|
||||
|
||||
const promptJson = JSON.stringify(prompt);
|
||||
const sendResult = await page.evaluate(`(async () => {
|
||||
try {
|
||||
@@ -249,11 +258,14 @@ async function runExplicitWebAsk(
|
||||
timeoutMs: number,
|
||||
newChat: boolean,
|
||||
) {
|
||||
await page.goto(GROK_URL, { settleMs: 2000 });
|
||||
|
||||
if (newChat) {
|
||||
// Navigate to homepage and start a fresh conversation
|
||||
await page.goto(GROK_URL, { settleMs: 2000 });
|
||||
await tryStartFreshChat(page);
|
||||
await page.wait(2);
|
||||
} else if (!(await isOnGrok(page))) {
|
||||
// First invocation or tab was recycled — navigate to Grok
|
||||
await page.goto(GROK_URL, { settleMs: 2000 });
|
||||
}
|
||||
|
||||
const baselineBubbles = await getBubbleTexts(page);
|
||||
@@ -318,4 +330,5 @@ export const __test__ = {
|
||||
updateStableState,
|
||||
normalizeBooleanFlag,
|
||||
normalizeBubbleText,
|
||||
isOnGrok,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './item.js';
|
||||
|
||||
describe('jd item adapter', () => {
|
||||
const command = getRegistry().get('jd/item');
|
||||
|
||||
it('registers the command with correct shape', () => {
|
||||
expect(command).toBeDefined();
|
||||
expect(command!.site).toBe('jd');
|
||||
expect(command!.name).toBe('item');
|
||||
expect(command!.domain).toBe('item.jd.com');
|
||||
expect(command!.strategy).toBe('cookie');
|
||||
expect(typeof command!.func).toBe('function');
|
||||
});
|
||||
|
||||
it('has sku as a required positional arg', () => {
|
||||
const skuArg = command!.args.find((a) => a.name === 'sku');
|
||||
expect(skuArg).toBeDefined();
|
||||
expect(skuArg!.required).toBe(true);
|
||||
expect(skuArg!.positional).toBe(true);
|
||||
});
|
||||
|
||||
it('has images arg with default 10', () => {
|
||||
const imagesArg = command!.args.find((a) => a.name === 'images');
|
||||
expect(imagesArg).toBeDefined();
|
||||
expect(imagesArg!.default).toBe(10);
|
||||
});
|
||||
|
||||
it('includes expected columns', () => {
|
||||
expect(command!.columns).toEqual(
|
||||
expect.arrayContaining(['title', 'price', 'shop', 'specs', 'mainImages', 'detailImages']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 京东商品详情 — browser cookie, DOM scraping + evaluate.
|
||||
*
|
||||
* 依赖: 需要在 Chrome 已登录京东
|
||||
* 用法: opencli jd item 100291143898
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'jd',
|
||||
name: 'item',
|
||||
description: '京东商品详情(价格、主图、详情图、规格参数)',
|
||||
domain: 'item.jd.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{
|
||||
name: 'sku',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: '商品 SKU ID(如 100291143898)',
|
||||
},
|
||||
{
|
||||
name: 'images',
|
||||
type: 'int',
|
||||
default: 10,
|
||||
help: '详情图数量(默认10)',
|
||||
},
|
||||
],
|
||||
columns: ['title', 'price', 'shop', 'specs', 'mainImages', 'detailImages'],
|
||||
func: async (page, kwargs) => {
|
||||
const sku = kwargs.sku;
|
||||
const maxImages = kwargs.images as number;
|
||||
const url = `https://item.jd.com/${sku}.html`;
|
||||
|
||||
await page.goto(url, { waitUntil: 'load' });
|
||||
await page.wait(2);
|
||||
|
||||
// 滚动加载详情图
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await page.evaluate(`window.scrollTo(0, ${i * 2500})`);
|
||||
await page.wait(1);
|
||||
}
|
||||
await page.evaluate(`window.scrollTo(0, document.body.scrollHeight)`);
|
||||
await page.wait(2);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
const maxImg = ${maxImages};
|
||||
// 尝试多种价格选择器
|
||||
const skuMatch = location.pathname.match(/(\\d+)\\.html/);
|
||||
const sku = skuMatch ? skuMatch[1] : '';
|
||||
const priceEl = document.querySelector('.J-p-' + sku) ||
|
||||
document.querySelector('[class*="price"] [class*="num"]') ||
|
||||
document.querySelector('.p-price strong') ||
|
||||
document.querySelector('.price.jd-price');
|
||||
const price = priceEl?.textContent?.trim() || 'not found';
|
||||
|
||||
// 标题
|
||||
const title = document.querySelector('.product-title')?.textContent?.trim() ||
|
||||
document.title.split('-')[0].trim();
|
||||
|
||||
// 店铺
|
||||
const shop = document.querySelector('.J-shop-name')?.textContent?.trim() || '京东自营';
|
||||
|
||||
// 所有图片
|
||||
const allImgs = Array.from(document.querySelectorAll('img[src*="360buyimg.com"]'));
|
||||
const srcs = allImgs.map(img => img.src).filter(Boolean);
|
||||
const unique = [...new Set(srcs)];
|
||||
|
||||
// 主图
|
||||
const mainImgs = unique
|
||||
.filter(u => u.includes('/n1/') || u.includes('/n3/') || u.includes('/n4/') || u.includes('/img/'))
|
||||
.slice(0, maxImg);
|
||||
|
||||
// 详情图
|
||||
const detailImgs = unique
|
||||
.filter(u => u.includes('/babel/') || u.includes('/popshop/'))
|
||||
.slice(0, maxImg);
|
||||
|
||||
// 规格参数:从页面文本提取
|
||||
const text = document.body.innerText;
|
||||
const specMatch = text.match(/商品编号[\\s\\S]*?(?=包装清单|\\n\\n|$)/);
|
||||
let specs = {};
|
||||
if (specMatch) {
|
||||
const lines = specMatch[0].split('\\n').filter(l => l.trim());
|
||||
for (let i = 0; i < lines.length - 1; i += 2) {
|
||||
const key = lines[i].trim();
|
||||
const val = lines[i + 1]?.trim() || '';
|
||||
if (key && val && key !== '商品编号') {
|
||||
specs[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { title, price, shop, specs, mainImages: mainImgs, detailImages: detailImgs, totalImages: unique.length };
|
||||
})()
|
||||
`);
|
||||
|
||||
return [data];
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
|
||||
// ── Filter value mappings ──────────────────────────────────────────────
|
||||
|
||||
@@ -64,7 +65,7 @@ function mapFilterValues(input: unknown, mapping: Record<string, string>, label:
|
||||
const resolved = values.map(value => {
|
||||
const key = value.toLowerCase();
|
||||
const mapped = mapping[key];
|
||||
if (!mapped) throw new Error(`Unsupported ${label}: ${value}`);
|
||||
if (!mapped) throw new ArgumentError(`Unsupported ${label}: ${value}`);
|
||||
return mapped;
|
||||
});
|
||||
return [...new Set(resolved)];
|
||||
@@ -214,7 +215,7 @@ async function resolveCompanyIds(page: IPage, input: unknown): Promise<string[]>
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
throw new Error(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
|
||||
throw new ArgumentError(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
@@ -252,7 +253,7 @@ async function fetchJobCards(
|
||||
})()`);
|
||||
|
||||
if (!batch || batch.error) {
|
||||
throw new Error(batch?.error || 'LinkedIn search returned an unexpected response');
|
||||
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
|
||||
}
|
||||
|
||||
const elements: any[] = Array.isArray(batch?.elements) ? batch.elements : [];
|
||||
@@ -387,7 +388,7 @@ cli({
|
||||
const location = (kwargs.location ?? '').trim();
|
||||
const keywords = String(kwargs.query ?? '').trim();
|
||||
|
||||
if (!keywords) throw new Error('query is required');
|
||||
if (!keywords) throw new ArgumentError('query is required');
|
||||
|
||||
const searchParams = new URLSearchParams({ keywords });
|
||||
if (location) searchParams.set('location', location);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './timeline.js';
|
||||
|
||||
const { parseMetric, buildPostId, mergeTimelinePosts } = await import('./timeline.js').then(
|
||||
(m) => (m as any).__test__,
|
||||
);
|
||||
|
||||
describe('linkedin timeline adapter', () => {
|
||||
const command = getRegistry().get('linkedin/timeline');
|
||||
|
||||
it('registers the command with correct shape', () => {
|
||||
expect(command).toBeDefined();
|
||||
expect(command!.site).toBe('linkedin');
|
||||
expect(command!.name).toBe('timeline');
|
||||
expect(command!.domain).toBe('www.linkedin.com');
|
||||
expect(command!.strategy).toBe('cookie');
|
||||
expect(command!.browser).toBe(true);
|
||||
expect(typeof command!.func).toBe('function');
|
||||
});
|
||||
|
||||
it('has limit arg with default 20', () => {
|
||||
const limitArg = command!.args.find((a) => a.name === 'limit');
|
||||
expect(limitArg).toBeDefined();
|
||||
expect(limitArg!.default).toBe(20);
|
||||
});
|
||||
|
||||
it('includes expected columns', () => {
|
||||
expect(command!.columns).toEqual(
|
||||
expect.arrayContaining(['author', 'text', 'reactions', 'comments', 'url']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMetric', () => {
|
||||
it('parses plain numbers', () => {
|
||||
expect(parseMetric('42')).toBe(42);
|
||||
expect(parseMetric('1,234')).toBe(1234);
|
||||
});
|
||||
|
||||
it('handles k/m suffixes', () => {
|
||||
expect(parseMetric('2.5k')).toBe(2500);
|
||||
expect(parseMetric('1.2M')).toBe(1200000);
|
||||
});
|
||||
|
||||
it('returns 0 for empty/undefined', () => {
|
||||
expect(parseMetric('')).toBe(0);
|
||||
expect(parseMetric(undefined)).toBe(0);
|
||||
expect(parseMetric(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPostId', () => {
|
||||
it('uses url when present', () => {
|
||||
expect(buildPostId({ url: 'https://linkedin.com/post/123' })).toBe(
|
||||
'https://linkedin.com/post/123',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to composite key', () => {
|
||||
const id = buildPostId({ author: 'Alice', posted_at: '2h', text: 'Hello world' });
|
||||
expect(id).toBe('Alice::2h::Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeTimelinePosts', () => {
|
||||
it('deduplicates by url', () => {
|
||||
const url = 'https://linkedin.com/post/1';
|
||||
const a = {
|
||||
id: url,
|
||||
author: 'Alice',
|
||||
author_url: '',
|
||||
headline: '',
|
||||
text: 'Hello',
|
||||
posted_at: '1h',
|
||||
reactions: 5,
|
||||
comments: 1,
|
||||
url,
|
||||
};
|
||||
const result = mergeTimelinePosts([a], [a]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips posts without author or text', () => {
|
||||
const empty = {
|
||||
id: '2',
|
||||
author: '',
|
||||
author_url: '',
|
||||
headline: '',
|
||||
text: 'some text',
|
||||
posted_at: '',
|
||||
reactions: 0,
|
||||
comments: 0,
|
||||
url: '',
|
||||
};
|
||||
const result = mergeTimelinePosts([], [empty]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
interface TimelinePost {
|
||||
rank?: number;
|
||||
id: string;
|
||||
author: string;
|
||||
author_url: string;
|
||||
headline: string;
|
||||
text: string;
|
||||
posted_at: string;
|
||||
reactions: number;
|
||||
comments: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ExtractedBatch {
|
||||
loginRequired?: boolean;
|
||||
posts?: TimelinePost[];
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: unknown): string {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function parseMetric(value: unknown): number {
|
||||
const raw = normalizeWhitespace(value).toLowerCase();
|
||||
if (!raw) return 0;
|
||||
|
||||
const compact = raw.replace(/,/g, '');
|
||||
const match = compact.match(/(\d+(?:\.\d+)?)(k|m)?/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const base = Number(match[1]);
|
||||
const suffix = (match[2] || '').toLowerCase();
|
||||
if (suffix === 'k') return Math.round(base * 1000);
|
||||
if (suffix === 'm') return Math.round(base * 1000000);
|
||||
return Math.round(base);
|
||||
}
|
||||
|
||||
function buildPostId(post: Partial<TimelinePost>): string {
|
||||
const url = normalizeWhitespace(post.url);
|
||||
if (url) return url;
|
||||
|
||||
const author = normalizeWhitespace(post.author);
|
||||
const text = normalizeWhitespace(post.text);
|
||||
const postedAt = normalizeWhitespace(post.posted_at);
|
||||
return `${author}::${postedAt}::${text.slice(0, 120)}`;
|
||||
}
|
||||
|
||||
function mergeTimelinePosts(existing: TimelinePost[], batch: TimelinePost[]): TimelinePost[] {
|
||||
const seen = new Set(existing.map(post => post.id));
|
||||
const merged = [...existing];
|
||||
|
||||
for (const rawPost of batch) {
|
||||
const post: TimelinePost = {
|
||||
id: buildPostId(rawPost),
|
||||
author: normalizeWhitespace(rawPost.author),
|
||||
author_url: normalizeWhitespace(rawPost.author_url),
|
||||
headline: normalizeWhitespace(rawPost.headline),
|
||||
text: normalizeWhitespace(rawPost.text),
|
||||
posted_at: normalizeWhitespace(rawPost.posted_at),
|
||||
reactions: Number(rawPost.reactions) || 0,
|
||||
comments: Number(rawPost.comments) || 0,
|
||||
url: normalizeWhitespace(rawPost.url),
|
||||
};
|
||||
|
||||
if (!post.author || !post.text) continue;
|
||||
if (seen.has(post.id)) continue;
|
||||
seen.add(post.id);
|
||||
merged.push(post);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function extractVisiblePosts(page: IPage): Promise<ExtractedBatch> {
|
||||
return page.evaluate(`(function () {
|
||||
function normalize(value) {
|
||||
return String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
}
|
||||
function textOf(root, selector) {
|
||||
var el = root.querySelector(selector);
|
||||
return el ? el.textContent : '';
|
||||
}
|
||||
function hrefOf(root, selector) {
|
||||
var el = root.querySelector(selector);
|
||||
return el && el.href ? el.href : '';
|
||||
}
|
||||
function attrOf(root, selector, attr) {
|
||||
var el = root.querySelector(selector);
|
||||
return el ? el.getAttribute(attr) : '';
|
||||
}
|
||||
function cleanTimestamp(value) {
|
||||
return normalize(String(value || '').replace(/[•.]/g, ' '));
|
||||
}
|
||||
function parseMetric(value) {
|
||||
var raw = normalize(value).toLowerCase();
|
||||
var match;
|
||||
var base;
|
||||
var suffix;
|
||||
if (!raw) return 0;
|
||||
match = raw.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)(k|m)?/i);
|
||||
if (!match) return 0;
|
||||
base = Number(match[1]);
|
||||
suffix = (match[2] || '').toLowerCase();
|
||||
if (suffix === 'k') return Math.round(base * 1000);
|
||||
if (suffix === 'm') return Math.round(base * 1000000);
|
||||
return Math.round(base);
|
||||
}
|
||||
function splitBlocks(text) {
|
||||
var lines = String(text || '').split('\\n');
|
||||
var blocks = [];
|
||||
var current = [];
|
||||
var i;
|
||||
var line;
|
||||
for (i = 0; i < lines.length; i += 1) {
|
||||
line = normalize(lines[i]);
|
||||
if (!line) {
|
||||
if (current.length) {
|
||||
blocks.push(normalize(current.join(' ')));
|
||||
current = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
if (current.length) blocks.push(normalize(current.join(' ')));
|
||||
return blocks;
|
||||
}
|
||||
function looksLikeTimestamp(value) {
|
||||
var lower = String(value || '').toLowerCase();
|
||||
return /^\\d+\\s*(s|m|h|d|w|mo|yr|min)(\\s*[•.])?$/i.test(lower);
|
||||
}
|
||||
function looksLikeBadge(value) {
|
||||
var lower = String(value || '').toLowerCase();
|
||||
return String(value || '').indexOf('•') === 0
|
||||
|| lower === '1st'
|
||||
|| lower === '2nd'
|
||||
|| lower === '3rd'
|
||||
|| lower === 'degree connection';
|
||||
}
|
||||
function looksLikeAction(value) {
|
||||
return /^(follow|send message|connect|visit my website|view my newsletter|subscribe)$/i.test((value || '').toLowerCase());
|
||||
}
|
||||
function looksLikeCta(value) {
|
||||
return /^(book an appointment|view my services|visit my website|view my newsletter|subscribe|learn more|contact us)$/i.test((value || '').toLowerCase());
|
||||
}
|
||||
function looksLikeEngagement(value) {
|
||||
return /(reactions?|comments?|reposts?)/i.test(String(value || ''));
|
||||
}
|
||||
function looksLikeFooterAction(value) {
|
||||
return /^(like|comment|repost|send|reply|load more comments)$/i.test((value || '').toLowerCase());
|
||||
}
|
||||
function findActivityUrn(root) {
|
||||
var elements = [root].concat(Array.from(root.querySelectorAll('*')));
|
||||
var i;
|
||||
var j;
|
||||
var attrs;
|
||||
var value;
|
||||
var match;
|
||||
for (i = 0; i < elements.length; i += 1) {
|
||||
attrs = Array.from(elements[i].attributes || []);
|
||||
for (j = 0; j < attrs.length; j += 1) {
|
||||
value = String(attrs[j].value || '');
|
||||
match = value.match(/urn:li:activity:\\d+/);
|
||||
if (match) return match[0];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function parseReactionCount(root, blocks) {
|
||||
var direct = textOf(root, '.social-details-social-counts__reactions-count');
|
||||
var rootText = String(root.innerText || '');
|
||||
var i;
|
||||
var value;
|
||||
value = rootText.match(/and\\s+(\\d[\\d,]*)\\s+others\\s+reacted/i);
|
||||
if (value) return parseMetric(value[1]) + 1;
|
||||
value = rootText.match(/and\\s+(\\d[\\d,]*)\\s+others(?!\\s+comments?)(?!\\s+reposts?)/i);
|
||||
if (value) return parseMetric(value[1]) + 1;
|
||||
value = rootText.match(/(\\d[\\d,]*)\\s+reactions?/i);
|
||||
if (value) return parseMetric(value[0]);
|
||||
if (direct) return parseMetric(direct);
|
||||
for (i = 0; i < blocks.length; i += 1) {
|
||||
value = blocks[i];
|
||||
if (/and\\s+\\d[\\d,]*\\s+others(?!\\s+comments?)(?!\\s+reposts?)/i.test(value)) {
|
||||
return parseMetric(value) + 1;
|
||||
}
|
||||
if (/reactions?/i.test(value)) return parseMetric(value);
|
||||
if (/and\\s+\\d+[\\d,]*\\s+others\\s+reacted/i.test(value)) return parseMetric(value) + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function parseCommentCount(blocks) {
|
||||
var i;
|
||||
var text = blocks.join(' ');
|
||||
var match = text.match(/(\\d[\\d,]*)\\s+comments?/i);
|
||||
if (match) return parseMetric(match[0]);
|
||||
for (i = 0; i < blocks.length; i += 1) {
|
||||
if (/comments?/i.test(blocks[i])) return parseMetric(blocks[i]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function selectProfileLink(root, author) {
|
||||
var links = Array.from(root.querySelectorAll('a[href*="/in/"], a[href*="/company/"]'));
|
||||
var normalizedAuthor = normalize(author).toLowerCase();
|
||||
var i;
|
||||
var label;
|
||||
for (i = 0; i < links.length; i += 1) {
|
||||
label = normalize(links[i].textContent || links[i].getAttribute('aria-label')).toLowerCase();
|
||||
if (!links[i].href) continue;
|
||||
if (normalizedAuthor && label.indexOf(normalizedAuthor) >= 0) return links[i];
|
||||
}
|
||||
return links[0] || null;
|
||||
}
|
||||
function selectProfileUrl(root, author) {
|
||||
var link = selectProfileLink(root, author);
|
||||
return link && link.href ? link.href : '';
|
||||
}
|
||||
function parseActorLinkMeta(root, author) {
|
||||
var link = selectProfileLink(root, author);
|
||||
var text = normalize(link ? link.textContent : '');
|
||||
var normalizedAuthor = normalize(author);
|
||||
var match;
|
||||
var rest;
|
||||
var headline = '';
|
||||
var postedAt = '';
|
||||
if (!text || !normalizedAuthor) return { headline: '', postedAt: '' };
|
||||
if (text.indexOf(normalizedAuthor) === 0) {
|
||||
rest = normalize(text.slice(normalizedAuthor.length));
|
||||
} else {
|
||||
rest = text;
|
||||
}
|
||||
rest = normalize(rest.replace(/^[•·]\\s*(1st|2nd|3rd\\+?|3rd|degree connection)/i, ''));
|
||||
match = rest.match(/(\\d+\\s*(?:s|m|h|d|w|mo|yr|min))\\s*[•·]?$/i);
|
||||
if (match) {
|
||||
postedAt = cleanTimestamp(match[1]);
|
||||
headline = normalize(rest.slice(0, rest.length - match[0].length));
|
||||
} else {
|
||||
headline = rest;
|
||||
}
|
||||
headline = normalize(headline.replace(/^(book an appointment|view my services|visit my website|view my newsletter)\\s*/i, ''));
|
||||
return { headline: headline, postedAt: postedAt };
|
||||
}
|
||||
function stripBodyTail(value) {
|
||||
return normalize(String(value || '')
|
||||
.replace(/\\s+\\d[\\d,]*\\s+reactions?[\\s\\S]*$/i, '')
|
||||
.replace(/\\s+\\d[\\d,]*\\s+comments?[\\s\\S]*$/i, '')
|
||||
.replace(/\\s+[A-Z][^\\n]+\\s+and\\s+\\d[\\d,]*\\s+others\\s+reacted[\\s\\S]*$/i, '')
|
||||
.replace(/\\s+Like\\s+Comment\\s+Repost\\s+Send[\\s\\S]*$/i, '')
|
||||
.replace(/\\s+Reaction button state:[\\s\\S]*$/i, '')
|
||||
.replace(/^\\d+\\s*(?:s|m|h|d|w|mo|yr|min)\\s*[•.]?\\s*Follow\\s+/i, '')
|
||||
);
|
||||
}
|
||||
function parseActorMeta(root) {
|
||||
var actorLink = root.querySelector('a[href*="/in/"], a[href*="/company/"]');
|
||||
var actorText = normalize(actorLink ? actorLink.textContent : '');
|
||||
var author = '';
|
||||
var headline = '';
|
||||
var postedAt = '';
|
||||
var match;
|
||||
if (actorText) {
|
||||
match = actorText.match(/^(.+?)\\s+[•·]\\s+(1st|2nd|3rd\\+?|3rd|degree connection)(.*)$/i);
|
||||
if (match) {
|
||||
author = normalize(match[1]);
|
||||
actorText = normalize(match[3]);
|
||||
}
|
||||
}
|
||||
match = actorText.match(/(.+?)\\s+(\\d+\\s*(?:s|m|h|d|w|mo|yr|min))\\s*[•·]?$/i);
|
||||
if (match) {
|
||||
headline = normalize(match[1]);
|
||||
postedAt = cleanTimestamp(match[2]);
|
||||
} else if (actorText) {
|
||||
headline = actorText;
|
||||
}
|
||||
return {
|
||||
author: author,
|
||||
headline: headline,
|
||||
postedAt: postedAt,
|
||||
authorUrl: actorLink && actorLink.href ? actorLink.href : '',
|
||||
};
|
||||
}
|
||||
function extractFromListItem(root) {
|
||||
var blocks = splitBlocks(root.innerText || '');
|
||||
var filtered = [];
|
||||
var i;
|
||||
var value;
|
||||
var author = '';
|
||||
var authorUrl = '';
|
||||
var headline = '';
|
||||
var postedAt = '';
|
||||
var text = '';
|
||||
var bodyStart = -1;
|
||||
var permalink;
|
||||
var url;
|
||||
var reactions;
|
||||
var comments;
|
||||
var endIndex = -1;
|
||||
var urn;
|
||||
|
||||
if (blocks.length < 5) return null;
|
||||
if (blocks[0] !== 'Feed post') return null;
|
||||
|
||||
for (i = 1; i < blocks.length; i += 1) {
|
||||
value = blocks[i];
|
||||
if (!value) continue;
|
||||
if (/commented on this|reposted this|liked this|suggested/i.test(value)) continue;
|
||||
filtered.push(value);
|
||||
}
|
||||
if (filtered.length < 4) return null;
|
||||
|
||||
for (i = 0; i < filtered.length; i += 1) {
|
||||
value = filtered[i];
|
||||
if (!author && !looksLikeBadge(value) && !looksLikeAction(value) && !looksLikeTimestamp(value)) {
|
||||
author = value;
|
||||
continue;
|
||||
}
|
||||
if (author && !headline && !looksLikeBadge(value) && !looksLikeAction(value) && !looksLikeTimestamp(value) && !looksLikeCta(value)) {
|
||||
headline = value;
|
||||
continue;
|
||||
}
|
||||
if (!postedAt && looksLikeTimestamp(value)) {
|
||||
postedAt = cleanTimestamp(value);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!author) return null;
|
||||
authorUrl = selectProfileUrl(root, author);
|
||||
if (!headline || !postedAt) {
|
||||
var actorMeta = parseActorLinkMeta(root, author);
|
||||
if (!headline && actorMeta.headline) headline = actorMeta.headline;
|
||||
if (!postedAt && actorMeta.postedAt) postedAt = actorMeta.postedAt;
|
||||
}
|
||||
|
||||
for (i = 0; i < filtered.length; i += 1) {
|
||||
value = filtered[i];
|
||||
if (looksLikeAction(value)) {
|
||||
bodyStart = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bodyStart < 0 && postedAt) {
|
||||
bodyStart = filtered.indexOf(postedAt) + 1;
|
||||
}
|
||||
if (bodyStart < 0) bodyStart = Math.min(filtered.length, headline ? 2 : 1);
|
||||
|
||||
for (i = bodyStart; i < filtered.length; i += 1) {
|
||||
value = filtered[i];
|
||||
if (looksLikeEngagement(value) || looksLikeFooterAction(value)) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endIndex < 0) endIndex = filtered.length;
|
||||
|
||||
text = stripBodyTail(filtered.slice(bodyStart, endIndex).join('\\n\\n'));
|
||||
if (!text) return null;
|
||||
|
||||
permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
|
||||
url = permalink ? permalink.href : '';
|
||||
urn = findActivityUrn(root);
|
||||
if (!url && urn) url = 'https://www.linkedin.com/feed/update/' + urn + '/';
|
||||
reactions = parseReactionCount(root, filtered);
|
||||
comments = parseCommentCount(filtered);
|
||||
|
||||
return {
|
||||
id: url || (author + '::' + postedAt + '::' + text.slice(0, 120)),
|
||||
author: author,
|
||||
author_url: authorUrl,
|
||||
headline: headline,
|
||||
text: text,
|
||||
posted_at: postedAt,
|
||||
reactions: reactions,
|
||||
comments: comments,
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
function commentMetric(root) {
|
||||
var links = Array.from(root.querySelectorAll('button, a'));
|
||||
var i;
|
||||
var label;
|
||||
for (i = 0; i < links.length; i += 1) {
|
||||
label = normalize(links[i].textContent || links[i].getAttribute('aria-label'));
|
||||
if (/comment/i.test(label)) return parseMetric(label);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
var currentUrl = window.location.href;
|
||||
var path = String(window.location.pathname || '');
|
||||
var loginRequired = path.indexOf('/login') >= 0
|
||||
|| path.indexOf('/checkpoint/') >= 0
|
||||
|| Boolean(document.querySelector('input[name="session_key"], form.login__form'));
|
||||
var moreButtons = Array.from(document.querySelectorAll('button, a[role="button"]'))
|
||||
.filter(function (el) {
|
||||
return /see more|more/i.test(normalize(el.textContent))
|
||||
|| /see more|more/i.test(normalize(el.getAttribute('aria-label')));
|
||||
})
|
||||
.slice(0, 8);
|
||||
var cards = Array.from(document.querySelectorAll('article, .feed-shared-update-v2, .occludable-update, [role="listitem"]'));
|
||||
var seen = new Set();
|
||||
var posts = [];
|
||||
var i;
|
||||
var card;
|
||||
var root;
|
||||
var author;
|
||||
var headline;
|
||||
var text;
|
||||
var postedAt;
|
||||
var permalink;
|
||||
var url;
|
||||
var reactions;
|
||||
var comments;
|
||||
|
||||
for (i = 0; i < moreButtons.length; i += 1) {
|
||||
try { moreButtons[i].click(); } catch (err) {}
|
||||
}
|
||||
|
||||
for (i = 0; i < cards.length; i += 1) {
|
||||
card = cards[i];
|
||||
root = card.closest('article, .feed-shared-update-v2, .occludable-update, [role="listitem"]') || card;
|
||||
if (!root || seen.has(root)) continue;
|
||||
seen.add(root);
|
||||
|
||||
if (String(root.getAttribute('role') || '') === 'listitem') {
|
||||
var extracted = extractFromListItem(root);
|
||||
if (extracted) posts.push(extracted);
|
||||
continue;
|
||||
}
|
||||
|
||||
author = normalize(
|
||||
textOf(root, '.update-components-actor__title span[dir="ltr"]')
|
||||
|| textOf(root, '.update-components-actor__title')
|
||||
|| textOf(root, '[data-control-name="actor"] span[dir="ltr"]')
|
||||
|| textOf(root, '[data-control-name="actor"]')
|
||||
);
|
||||
headline = normalize(
|
||||
textOf(root, '.update-components-actor__description')
|
||||
|| textOf(root, '.update-components-actor__sub-description')
|
||||
);
|
||||
text = normalize(
|
||||
textOf(root, '.update-components-text span[dir="ltr"]')
|
||||
|| textOf(root, '.update-components-text')
|
||||
|| textOf(root, '.feed-shared-inline-show-more-text span[dir="ltr"]')
|
||||
|| textOf(root, '.feed-shared-inline-show-more-text')
|
||||
|| textOf(root, '[data-test-id="main-feed-activity-card"] .break-words')
|
||||
);
|
||||
postedAt = normalize(
|
||||
textOf(root, '.update-components-actor__sub-description a')
|
||||
|| textOf(root, '.update-components-actor__sub-description span[aria-hidden="true"]')
|
||||
|| textOf(root, 'time')
|
||||
);
|
||||
permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
|
||||
url = permalink ? permalink.href : '';
|
||||
if (url && url.indexOf('/') === 0) url = new URL(url, currentUrl).toString();
|
||||
reactions = parseMetric(
|
||||
textOf(root, '.social-details-social-counts__reactions-count')
|
||||
|| attrOf(root, '[aria-label*="reaction"]', 'aria-label')
|
||||
|| attrOf(root, '[aria-label*="like"]', 'aria-label')
|
||||
);
|
||||
comments = commentMetric(root);
|
||||
|
||||
if (!author || !text) continue;
|
||||
|
||||
posts.push({
|
||||
id: url || (author + '::' + postedAt + '::' + text.slice(0, 120)),
|
||||
author: author,
|
||||
author_url: hrefOf(root, 'a[href*="/in/"], a[href*="/company/"]'),
|
||||
headline: headline,
|
||||
text: text,
|
||||
posted_at: postedAt,
|
||||
reactions: reactions,
|
||||
comments: comments,
|
||||
url: url,
|
||||
});
|
||||
}
|
||||
|
||||
return { loginRequired: loginRequired, posts: posts };
|
||||
})()`);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'linkedin',
|
||||
name: 'timeline',
|
||||
description: 'Read LinkedIn home timeline posts',
|
||||
domain: 'www.linkedin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts to return (max 100)' },
|
||||
],
|
||||
columns: ['rank', 'author', 'author_url', 'headline', 'text', 'posted_at', 'reactions', 'comments', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = Math.max(1, Math.min(kwargs.limit ?? 20, 100));
|
||||
|
||||
await page.goto('https://www.linkedin.com/feed/');
|
||||
await page.wait(4);
|
||||
|
||||
let posts: TimelinePost[] = [];
|
||||
let sawLoginWall = false;
|
||||
|
||||
for (let i = 0; i < 6 && posts.length < limit; i++) {
|
||||
const batch = await extractVisiblePosts(page);
|
||||
if (batch?.loginRequired) sawLoginWall = true;
|
||||
posts = mergeTimelinePosts(posts, Array.isArray(batch?.posts) ? batch.posts : []);
|
||||
if (posts.length >= limit) break;
|
||||
await page.autoScroll({ times: 1, delayMs: 1200 });
|
||||
await page.wait(1);
|
||||
}
|
||||
|
||||
if (sawLoginWall && posts.length === 0) {
|
||||
throw new AuthRequiredError('linkedin.com', 'LinkedIn timeline requires an active signed-in browser session');
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
throw new EmptyResultError('linkedin timeline', 'Make sure your LinkedIn home feed is visible in the browser.');
|
||||
}
|
||||
|
||||
return posts.slice(0, limit).map((post, index) => ({
|
||||
rank: index + 1,
|
||||
...post,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
parseMetric,
|
||||
buildPostId,
|
||||
mergeTimelinePosts,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export function buildMediumTagUrl(topic?: string): string {
|
||||
@@ -13,7 +14,7 @@ export function buildMediumUserUrl(username: string): string {
|
||||
}
|
||||
|
||||
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
|
||||
if (!page) throw new Error('Requires browser session');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
|
||||
await page.goto(url);
|
||||
await page.wait(5);
|
||||
const data = await page.evaluate(`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './read.js';
|
||||
|
||||
describe('reddit read adapter', () => {
|
||||
const command = getRegistry().get('reddit/read');
|
||||
|
||||
it('returns threaded rows from the browser-evaluated payload', async () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue([
|
||||
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
|
||||
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
|
||||
]),
|
||||
} as any;
|
||||
|
||||
const result = await command!.func!(page, { 'post-id': 'abc123', limit: 5 });
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
|
||||
expect(result).toEqual([
|
||||
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
|
||||
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces adapter-level API errors clearly', async () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ error: 'Reddit API returned HTTP 403' }),
|
||||
} as any;
|
||||
|
||||
await expect(command!.func!(page, { 'post-id': 'abc123' })).rejects.toThrow('Reddit API returned HTTP 403');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
* - Indented output showing conversation threads
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
@@ -176,9 +177,9 @@ cli({
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || typeof data !== 'object') throw new Error('Failed to fetch post data');
|
||||
if (!Array.isArray(data) && data.error) throw new Error(data.error);
|
||||
if (!Array.isArray(data)) throw new Error('Unexpected response');
|
||||
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch post data');
|
||||
if (!Array.isArray(data) && data.error) throw new CommandExecutionError(data.error);
|
||||
if (!Array.isArray(data)) throw new CommandExecutionError('Unexpected response');
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
||||
|
||||
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
|
||||
@@ -137,7 +138,7 @@ cli({
|
||||
const ct0 = await page.evaluate(`() => {
|
||||
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
}`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
const queryId = await page.evaluate(`async () => {
|
||||
try {
|
||||
@@ -185,7 +186,7 @@ cli({
|
||||
}`);
|
||||
|
||||
if (data?.error) {
|
||||
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
|
||||
if (allTweets.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
@@ -13,7 +14,7 @@ cli({
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for tweet to load completely
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './search.js';
|
||||
|
||||
describe('twitter search command', () => {
|
||||
it('retries transient SPA navigation failures before giving up', async () => {
|
||||
const command = getRegistry().get('twitter/search');
|
||||
expect(command?.func).toBeTypeOf('function');
|
||||
|
||||
const evaluate = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce('/explore')
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce('/search');
|
||||
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([
|
||||
{
|
||||
data: {
|
||||
search_by_raw_query: {
|
||||
search_timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
type: 'TimelineAddEntries',
|
||||
entries: [
|
||||
{
|
||||
entryId: 'tweet-1',
|
||||
content: {
|
||||
itemContent: {
|
||||
tweet_results: {
|
||||
result: {
|
||||
rest_id: '1',
|
||||
legacy: {
|
||||
full_text: 'hello world',
|
||||
favorite_count: 7,
|
||||
},
|
||||
core: {
|
||||
user_results: {
|
||||
result: {
|
||||
core: {
|
||||
screen_name: 'alice',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
count: '12',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const result = await command!.func!(page as any, { query: 'from:alice', limit: 5 });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
author: 'alice',
|
||||
text: 'hello world',
|
||||
likes: 7,
|
||||
views: '12',
|
||||
url: 'https://x.com/i/status/1',
|
||||
},
|
||||
]);
|
||||
expect(page.installInterceptor).toHaveBeenCalledWith('SearchTimeline');
|
||||
expect(evaluate).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('throws with the final path after both attempts fail', async () => {
|
||||
const command = getRegistry().get('twitter/search');
|
||||
expect(command?.func).toBeTypeOf('function');
|
||||
|
||||
const evaluate = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce('/explore')
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce('/login');
|
||||
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(command!.func!(page as any, { query: 'from:alice', limit: 5 }))
|
||||
.rejects
|
||||
.toThrow('Final path: /login');
|
||||
expect(page.autoScroll).not.toHaveBeenCalled();
|
||||
expect(page.getInterceptedRequests).not.toHaveBeenCalled();
|
||||
expect(evaluate).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
+37
-14
@@ -1,4 +1,40 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Trigger Twitter search SPA navigation and retry once on transient failures.
|
||||
*
|
||||
* Twitter/X sometimes keeps the page on /explore for a short period even after
|
||||
* pushState + popstate. A second attempt is enough for the intermittent cases
|
||||
* reported in issue #353 while keeping the flow narrowly scoped.
|
||||
*/
|
||||
async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: string): Promise<void> {
|
||||
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=top`);
|
||||
let lastPath = '';
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
await page.evaluate(`
|
||||
(() => {
|
||||
window.history.pushState({}, '', ${searchUrl});
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
||||
})()
|
||||
`);
|
||||
await page.wait(5);
|
||||
|
||||
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
|
||||
if (lastPath.startsWith('/search')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`,
|
||||
);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
@@ -29,20 +65,7 @@ cli({
|
||||
// a full page reload, so the interceptor stays alive.
|
||||
// Note: the previous approach (nativeSetter + Enter keydown on the
|
||||
// search input) does not reliably trigger Twitter's form submission.
|
||||
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=top`);
|
||||
await page.evaluate(`
|
||||
(() => {
|
||||
window.history.pushState({}, '', ${searchUrl});
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
||||
})()
|
||||
`);
|
||||
await page.wait(5);
|
||||
|
||||
// Verify SPA navigation succeeded
|
||||
const currentPath = await page.evaluate('() => window.location.pathname');
|
||||
if (!currentPath?.startsWith('/search')) {
|
||||
throw new Error('SPA navigation to /search failed. Twitter may have changed its routing.');
|
||||
}
|
||||
await navigateToSearch(page, query);
|
||||
|
||||
// 4. Scroll to trigger additional pagination
|
||||
await page.autoScroll({ times: 3, delayMs: 2000 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
||||
|
||||
@@ -37,7 +38,7 @@ cli({
|
||||
const ct0 = await page.evaluate(`(() => {
|
||||
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
})()`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
// Try legacy guide.json API first (faster than DOM scraping)
|
||||
let trends: TrendItem[] = [];
|
||||
@@ -105,7 +106,7 @@ cli({
|
||||
}
|
||||
|
||||
if (trends.length === 0) {
|
||||
throw new Error('No trending data found. API may have changed or login may be required.');
|
||||
throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
|
||||
}
|
||||
|
||||
return trends.slice(0, limit);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
@@ -13,7 +14,7 @@ cli({
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter unfollow');
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Generic web page reader — fetch any URL and export as Markdown.
|
||||
*
|
||||
* Uses browser-side DOM heuristics to extract the main content:
|
||||
* 1. <article> element
|
||||
* 2. [role="main"] element
|
||||
* 3. <main> element
|
||||
* 4. Largest text-dense block as fallback
|
||||
*
|
||||
* Pipes through the shared article-download pipeline (Turndown + image download).
|
||||
*
|
||||
* Usage:
|
||||
* opencli web read --url "https://www.anthropic.com/research/..." --output ./articles
|
||||
* opencli web read --url "https://..." --download-images false
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { downloadArticle } from '../../download/article-download.js';
|
||||
|
||||
cli({
|
||||
site: 'web',
|
||||
name: 'read',
|
||||
description: 'Fetch any web page and export as Markdown',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false, // we handle navigation ourselves
|
||||
args: [
|
||||
{ name: 'url', required: true, help: 'Any web page URL' },
|
||||
{ name: 'output', default: './web-articles', help: 'Output directory' },
|
||||
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
|
||||
{ name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' },
|
||||
],
|
||||
columns: ['title', 'author', 'publish_time', 'status', 'size'],
|
||||
func: async (page, kwargs) => {
|
||||
const url = kwargs.url;
|
||||
const waitSeconds = kwargs.wait ?? 3;
|
||||
|
||||
// Navigate to the target URL
|
||||
await page.goto(url);
|
||||
await page.wait(waitSeconds);
|
||||
|
||||
// Extract article content using browser-side heuristics
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
const result = {
|
||||
title: '',
|
||||
author: '',
|
||||
publishTime: '',
|
||||
contentHtml: '',
|
||||
imageUrls: []
|
||||
};
|
||||
|
||||
// --- Title extraction ---
|
||||
// Priority: og:title > <title> > first <h1>
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
if (ogTitle) {
|
||||
result.title = ogTitle.getAttribute('content')?.trim() || '';
|
||||
}
|
||||
if (!result.title) {
|
||||
result.title = document.title?.trim() || '';
|
||||
}
|
||||
if (!result.title) {
|
||||
const h1 = document.querySelector('h1');
|
||||
result.title = h1?.textContent?.trim() || 'untitled';
|
||||
}
|
||||
// Strip site suffix (e.g. " | Anthropic", " - Blog")
|
||||
result.title = result.title.replace(/\\s*[|\\-–—]\\s*[^|\\-–—]{1,30}$/, '').trim();
|
||||
|
||||
// --- Author extraction ---
|
||||
const authorMeta = document.querySelector(
|
||||
'meta[name="author"], meta[property="article:author"], meta[name="twitter:creator"]'
|
||||
);
|
||||
result.author = authorMeta?.getAttribute('content')?.trim() || '';
|
||||
|
||||
// --- Publish time extraction ---
|
||||
const timeMeta = document.querySelector(
|
||||
'meta[property="article:published_time"], meta[name="date"], meta[name="publishdate"], time[datetime]'
|
||||
);
|
||||
if (timeMeta) {
|
||||
result.publishTime = timeMeta.getAttribute('content')
|
||||
|| timeMeta.getAttribute('datetime')
|
||||
|| timeMeta.textContent?.trim()
|
||||
|| '';
|
||||
}
|
||||
|
||||
// --- Content extraction ---
|
||||
// Strategy: try semantic elements first, then fall back to largest text block
|
||||
let contentEl = null;
|
||||
|
||||
// 1. <article>
|
||||
const articles = document.querySelectorAll('article');
|
||||
if (articles.length === 1) {
|
||||
contentEl = articles[0];
|
||||
} else if (articles.length > 1) {
|
||||
// Pick the largest article by text length
|
||||
let maxLen = 0;
|
||||
articles.forEach(a => {
|
||||
const len = a.textContent?.length || 0;
|
||||
if (len > maxLen) { maxLen = len; contentEl = a; }
|
||||
});
|
||||
}
|
||||
|
||||
// 2. [role="main"]
|
||||
if (!contentEl) {
|
||||
contentEl = document.querySelector('[role="main"]');
|
||||
}
|
||||
|
||||
// 3. <main>
|
||||
if (!contentEl) {
|
||||
contentEl = document.querySelector('main');
|
||||
}
|
||||
|
||||
// 4. Largest text-dense block fallback
|
||||
if (!contentEl) {
|
||||
const candidates = document.querySelectorAll(
|
||||
'div[class*="content"], div[class*="article"], div[class*="post"], ' +
|
||||
'div[class*="entry"], div[class*="body"], div[id*="content"], ' +
|
||||
'div[id*="article"], div[id*="post"], section'
|
||||
);
|
||||
let maxLen = 0;
|
||||
candidates.forEach(c => {
|
||||
const len = c.textContent?.length || 0;
|
||||
if (len > maxLen) { maxLen = len; contentEl = c; }
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Last resort: document.body
|
||||
if (!contentEl || (contentEl.textContent?.length || 0) < 200) {
|
||||
contentEl = document.body;
|
||||
}
|
||||
|
||||
// Clean up noise elements before extraction
|
||||
const clone = contentEl.cloneNode(true);
|
||||
const noise = 'nav, header, footer, aside, .sidebar, .nav, .menu, .footer, ' +
|
||||
'.header, .comments, .comment, .ad, .ads, .advertisement, .social-share, ' +
|
||||
'.related-posts, .newsletter, .cookie-banner, script, style, noscript, iframe';
|
||||
clone.querySelectorAll(noise).forEach(el => el.remove());
|
||||
|
||||
// Deduplicate: some sites (e.g. Anthropic) render each paragraph twice
|
||||
// (a visible version + a line-broken animation version with missing spaces).
|
||||
// Compare by stripping ALL whitespace so "Hello world" matches "Helloworld".
|
||||
const stripWS = (s) => (s || '').replace(/\\s+/g, '');
|
||||
const dedup = (parent) => {
|
||||
const children = Array.from(parent.children || []);
|
||||
for (let i = children.length - 1; i >= 1; i--) {
|
||||
const curRaw = children[i].textContent || '';
|
||||
const prevRaw = children[i - 1].textContent || '';
|
||||
const cur = stripWS(curRaw);
|
||||
const prev = stripWS(prevRaw);
|
||||
if (cur.length < 20 || prev.length < 20) continue;
|
||||
// Exact match after whitespace strip, or >90% overlap
|
||||
if (cur === prev) {
|
||||
// Keep the one with more proper spacing (more spaces = better formatted)
|
||||
const curSpaces = (curRaw.match(/ /g) || []).length;
|
||||
const prevSpaces = (prevRaw.match(/ /g) || []).length;
|
||||
if (curSpaces >= prevSpaces) children[i - 1].remove();
|
||||
else children[i].remove();
|
||||
} else if (prev.includes(cur) && cur.length / prev.length > 0.8) {
|
||||
children[i].remove();
|
||||
} else if (cur.includes(prev) && prev.length / cur.length > 0.8) {
|
||||
children[i - 1].remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
dedup(clone);
|
||||
clone.querySelectorAll('section, div').forEach(el => {
|
||||
if (el.children && el.children.length > 2) dedup(el);
|
||||
});
|
||||
|
||||
result.contentHtml = clone.innerHTML;
|
||||
|
||||
// --- Image extraction ---
|
||||
const seen = new Set();
|
||||
clone.querySelectorAll('img').forEach(img => {
|
||||
const src = img.getAttribute('data-src')
|
||||
|| img.getAttribute('data-original')
|
||||
|| img.getAttribute('src');
|
||||
if (src && !src.startsWith('data:') && !seen.has(src)) {
|
||||
seen.add(src);
|
||||
result.imageUrls.push(src);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
})()
|
||||
`);
|
||||
|
||||
// Determine Referer from URL for image downloads
|
||||
let referer = '';
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
referer = parsed.origin + '/';
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return downloadArticle(
|
||||
{
|
||||
title: data?.title || 'untitled',
|
||||
author: data?.author,
|
||||
publishTime: data?.publishTime,
|
||||
sourceUrl: url,
|
||||
contentHtml: data?.contentHtml || '',
|
||||
imageUrls: data?.imageUrls,
|
||||
},
|
||||
{
|
||||
output: kwargs.output,
|
||||
downloadImages: kwargs['download-images'],
|
||||
imageHeaders: referer ? { Referer: referer } : undefined,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type RawSegment,
|
||||
type Chapter,
|
||||
} from './transcript-group.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'youtube',
|
||||
@@ -91,10 +92,10 @@ cli({
|
||||
`);
|
||||
|
||||
if (!captionData || typeof captionData === 'string') {
|
||||
throw new Error(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
|
||||
throw new CommandExecutionError(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
|
||||
}
|
||||
if (captionData.error) {
|
||||
throw new Error(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
|
||||
throw new CommandExecutionError(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
|
||||
}
|
||||
|
||||
// Warn if --lang was specified but not matched
|
||||
@@ -176,10 +177,10 @@ cli({
|
||||
`);
|
||||
|
||||
if (!Array.isArray(segments)) {
|
||||
throw new Error((segments as any)?.error || 'Failed to parse caption segments');
|
||||
throw new CommandExecutionError((segments as any)?.error || 'Failed to parse caption segments');
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
throw new Error('No caption segments found');
|
||||
throw new EmptyResultError('youtube transcript');
|
||||
}
|
||||
|
||||
// Step 3: Fetch chapters (for grouped mode)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { parseVideoId } from './utils.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'youtube',
|
||||
@@ -104,8 +105,8 @@ cli({
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to extract video metadata from page');
|
||||
if (data.error) throw new CommandExecutionError(data.error);
|
||||
|
||||
// Return as field/value pairs for table display
|
||||
return Object.entries(data).map(([field, value]) => ({
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* Shared constants used across explore, synthesize, and pipeline modules.
|
||||
*/
|
||||
|
||||
/** Default daemon port for HTTP/WebSocket communication with browser extension */
|
||||
export const DEFAULT_DAEMON_PORT = 19825;
|
||||
|
||||
/** URL query params that are volatile/ephemeral and should be stripped from patterns */
|
||||
export const VOLATILE_PARAMS = new Set([
|
||||
'w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign',
|
||||
|
||||
+12
-7
@@ -21,8 +21,9 @@
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
||||
|
||||
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
@@ -63,13 +64,14 @@ function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let aborted = false;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return; }
|
||||
if (size > MAX_BODY) { aborted = true; req.destroy(); reject(new Error('Body too large')); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
req.on('end', () => { if (!aborted) resolve(Buffer.concat(chunks).toString('utf-8')); });
|
||||
req.on('error', (err) => { if (!aborted) reject(err); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,11 +152,14 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
|
||||
? body.timeout * 1000
|
||||
: 120000;
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(body.id);
|
||||
reject(new Error('Command timeout (120s)'));
|
||||
}, 120000);
|
||||
reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
|
||||
}, timeoutMs);
|
||||
pending.set(body.id, { resolve, reject, timer });
|
||||
extensionWs!.send(JSON.stringify(body));
|
||||
});
|
||||
@@ -267,7 +272,7 @@ httpServer.listen(PORT, '127.0.0.1', () => {
|
||||
httpServer.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('[daemon] Server error:', err.message);
|
||||
process.exit(1);
|
||||
|
||||
+35
-57
@@ -14,39 +14,15 @@ import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
||||
import { log } from './logger.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { log } from './logger.js';
|
||||
import type { ManifestEntry } from './build-manifest.js';
|
||||
|
||||
/** Plugins directory: ~/.opencli/plugins/ */
|
||||
export const PLUGINS_DIR = path.join(os.homedir(), '.opencli', 'plugins');
|
||||
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
|
||||
|
||||
interface YamlArgDefinition {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
description?: string;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
interface YamlCliDefinition {
|
||||
site?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
domain?: string;
|
||||
strategy?: string;
|
||||
browser?: boolean;
|
||||
args?: Record<string, YamlArgDefinition>;
|
||||
columns?: string[];
|
||||
pipeline?: Record<string, unknown>[];
|
||||
timeout?: number;
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
|
||||
import type { YamlCliDefinition } from './yaml-schema.js';
|
||||
|
||||
function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy {
|
||||
if (!rawStrategy) return fallback;
|
||||
@@ -54,9 +30,7 @@ function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Str
|
||||
return Strategy[key] ?? fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
/**
|
||||
* Discover and register CLI commands.
|
||||
@@ -68,12 +42,12 @@ export async function discoverClis(...dirs: string[]): Promise<void> {
|
||||
const manifestPath = path.resolve(dir, '..', 'cli-manifest.json');
|
||||
try {
|
||||
await fs.promises.access(manifestPath);
|
||||
await loadFromManifest(manifestPath, dir);
|
||||
continue; // Skip filesystem scan for this directory
|
||||
const loaded = await loadFromManifest(manifestPath, dir);
|
||||
if (loaded) continue; // Skip filesystem scan only when manifest is usable
|
||||
} catch {
|
||||
// Fallback: runtime filesystem scan (development)
|
||||
await discoverClisFromFs(dir);
|
||||
// Fall through to filesystem scan
|
||||
}
|
||||
await discoverClisFromFs(dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +56,7 @@ export async function discoverClis(...dirs: string[]): Promise<void> {
|
||||
* YAML pipelines are inlined — zero YAML parsing at runtime.
|
||||
* TS modules are deferred — loaded lazily on first execution.
|
||||
*/
|
||||
async function loadFromManifest(manifestPath: string, clisDir: string): Promise<void> {
|
||||
async function loadFromManifest(manifestPath: string, clisDir: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(raw) as ManifestEntry[];
|
||||
@@ -128,8 +102,10 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
|
||||
registerCommand(cmd);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to load manifest ${manifestPath}: ${getErrorMessage(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,32 +114,34 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
|
||||
*/
|
||||
async function discoverClisFromFs(dir: string): Promise<void> {
|
||||
try { await fs.promises.access(dir); } catch { return; }
|
||||
const promises: Promise<unknown>[] = [];
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const site = entry.name;
|
||||
const siteDir = path.join(dir, site);
|
||||
const files = await fs.promises.readdir(siteDir);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(siteDir, file);
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
||||
promises.push(registerYamlCli(filePath, site));
|
||||
} else if (
|
||||
(file.endsWith('.js') && !file.endsWith('.d.js')) ||
|
||||
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
|
||||
) {
|
||||
if (!(await isCliModule(filePath))) continue;
|
||||
promises.push(
|
||||
import(pathToFileURL(filePath).href).catch((err) => {
|
||||
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
|
||||
})
|
||||
);
|
||||
const sitePromises = entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(async (entry) => {
|
||||
const site = entry.name;
|
||||
const siteDir = path.join(dir, site);
|
||||
const files = await fs.promises.readdir(siteDir);
|
||||
const filePromises: Promise<unknown>[] = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(siteDir, file);
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
||||
filePromises.push(registerYamlCli(filePath, site));
|
||||
} else if (
|
||||
(file.endsWith('.js') && !file.endsWith('.d.js')) ||
|
||||
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
|
||||
) {
|
||||
if (!(await isCliModule(filePath))) continue;
|
||||
filePromises.push(
|
||||
import(pathToFileURL(filePath).href).catch((err) => {
|
||||
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await Promise.all(filePromises);
|
||||
});
|
||||
await Promise.all(sitePromises);
|
||||
}
|
||||
|
||||
async function registerYamlCli(filePath: string, defaultSite: string): Promise<void> {
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
||||
import { checkDaemonStatus } from './browser/discover.js';
|
||||
import { BrowserBridge } from './browser/index.js';
|
||||
import { listSessions } from './browser/daemon-client.js';
|
||||
@@ -107,7 +108,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
|
||||
// Daemon status
|
||||
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
|
||||
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
|
||||
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running'}`);
|
||||
|
||||
// Extension status
|
||||
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCookieHeader, resolveRedirectUrl } from './index.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
})));
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
async function startServer(handler: http.RequestListener): Promise<string> {
|
||||
const server = http.createServer(handler);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to start test server');
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
describe('download helpers', () => {
|
||||
it('resolves relative redirects against the original URL', () => {
|
||||
@@ -13,4 +37,23 @@ describe('download helpers', () => {
|
||||
{ name: 'ct0', value: 'def', domain: 'example.com' },
|
||||
])).toBe('sid=abc; ct0=def');
|
||||
});
|
||||
|
||||
it('fails after exceeding the redirect limit', async () => {
|
||||
const baseUrl = await startServer((_req, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('Location', '/loop');
|
||||
res.end();
|
||||
});
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-download-'));
|
||||
const destPath = path.join(tempDir, 'file.txt');
|
||||
const result = await httpDownload(`${baseUrl}/loop`, destPath, { maxRedirects: 2 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
size: 0,
|
||||
error: 'Too many redirects (> 2)',
|
||||
});
|
||||
expect(fs.existsSync(destPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+27
-21
@@ -11,12 +11,16 @@ import * as os from 'node:os';
|
||||
import { URL } from 'node:url';
|
||||
import type { ProgressBar } from './progress.js';
|
||||
import { isBinaryInstalled } from '../external.js';
|
||||
import type { BrowserCookie } from '../types.js';
|
||||
|
||||
export type { BrowserCookie } from '../types.js';
|
||||
|
||||
export interface DownloadOptions {
|
||||
cookies?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
onProgress?: (received: number, total: number) => void;
|
||||
maxRedirects?: number;
|
||||
}
|
||||
|
||||
export interface YtdlpOptions {
|
||||
@@ -27,26 +31,11 @@ export interface YtdlpOptions {
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
export interface BrowserCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
expirationDate?: number;
|
||||
}
|
||||
|
||||
/** Check if yt-dlp is available in PATH. */
|
||||
export function checkYtdlp(): boolean {
|
||||
return isBinaryInstalled('yt-dlp');
|
||||
}
|
||||
|
||||
/** Check if ffmpeg is available in PATH. */
|
||||
export function checkFfmpeg(): boolean {
|
||||
return isBinaryInstalled('ffmpeg');
|
||||
}
|
||||
|
||||
/** Domains that host video content and can be downloaded via yt-dlp. */
|
||||
const VIDEO_PLATFORM_DOMAINS = [
|
||||
'youtube.com', 'youtu.be', 'bilibili.com', 'twitter.com',
|
||||
@@ -92,15 +81,16 @@ export async function httpDownload(
|
||||
url: string,
|
||||
destPath: string,
|
||||
options: DownloadOptions = {},
|
||||
redirectCount = 0,
|
||||
): Promise<{ success: boolean; size: number; error?: string }> {
|
||||
const { cookies, headers = {}, timeout = 30000, onProgress } = options;
|
||||
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||
...headers,
|
||||
};
|
||||
|
||||
@@ -120,7 +110,16 @@ export async function httpDownload(
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
file.close();
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
httpDownload(resolveRedirectUrl(url, response.headers.location), destPath, options).then(resolve);
|
||||
if (redirectCount >= maxRedirects) {
|
||||
resolve({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
|
||||
return;
|
||||
}
|
||||
httpDownload(
|
||||
resolveRedirectUrl(url, response.headers.location),
|
||||
destPath,
|
||||
options,
|
||||
redirectCount + 1,
|
||||
).then(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,7 +187,9 @@ export function exportCookiesToNetscape(
|
||||
const cookiePath = cookie.path || '/';
|
||||
const secure = cookie.secure ? 'TRUE' : 'FALSE';
|
||||
const expiry = Math.floor(Date.now() / 1000) + 86400 * 365; // 1 year from now
|
||||
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${cookie.name}\t${cookie.value}`);
|
||||
const safeName = cookie.name.replace(/[\t\n\r]/g, '');
|
||||
const safeValue = cookie.value.replace(/[\t\n\r]/g, '');
|
||||
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${safeName}\t${safeValue}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
@@ -226,8 +227,13 @@ export async function ytdlpDownload(
|
||||
'--progress',
|
||||
];
|
||||
|
||||
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
||||
args.push('--cookies', cookiesFile);
|
||||
if (cookiesFile) {
|
||||
if (fs.existsSync(cookiesFile)) {
|
||||
args.push('--cookies', cookiesFile);
|
||||
} else {
|
||||
console.error(`[download] Cookies file not found: ${cookiesFile}, falling back to browser cookies`);
|
||||
args.push('--cookies-from-browser', 'chrome');
|
||||
}
|
||||
} else {
|
||||
// Try to use browser cookies
|
||||
args.push('--cookies-from-browser', 'chrome');
|
||||
|
||||
@@ -45,6 +45,36 @@ cli({
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to filesystem discovery when the manifest is invalid', async () => {
|
||||
const tempBuildRoot = await fs.promises.mkdtemp(path.join('/tmp', 'opencli-manifest-fallback-'));
|
||||
const distDir = path.join(tempBuildRoot, 'dist');
|
||||
const siteDir = path.join(distDir, 'fallback-site');
|
||||
const commandPath = path.join(siteDir, 'hello.ts');
|
||||
const manifestPath = path.join(tempBuildRoot, 'cli-manifest.json');
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(siteDir, { recursive: true });
|
||||
await fs.promises.writeFile(manifestPath, '{ invalid json');
|
||||
await fs.promises.writeFile(commandPath, `
|
||||
import { cli, Strategy } from '${path.join(process.cwd(), 'src', 'registry.ts')}';
|
||||
cli({
|
||||
site: 'fallback-site',
|
||||
name: 'hello',
|
||||
description: 'hello command',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
func: async () => [{ ok: true }],
|
||||
});
|
||||
`);
|
||||
|
||||
await discoverClis(distDir);
|
||||
|
||||
expect(getRegistry().get('fallback-site/hello')).toBeDefined();
|
||||
} finally {
|
||||
await fs.promises.rm(tempBuildRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverPlugins', () => {
|
||||
|
||||
+10
-4
@@ -22,7 +22,6 @@ const _loadedModules = new Set<string>();
|
||||
type CommandArgs = Record<string, unknown>;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Validates and coerces arguments based on the command's Arg definitions.
|
||||
*/
|
||||
@@ -99,11 +98,16 @@ async function runCommand(
|
||||
}
|
||||
// After loading, the module's cli() call will have updated the registry.
|
||||
const updated = getRegistry().get(fullName(cmd));
|
||||
if (updated?.func) return updated.func(page!, kwargs, debug);
|
||||
if (updated?.func) {
|
||||
if (!page && updated.browser !== false) {
|
||||
throw new CommandExecutionError(`Command ${fullName(cmd)} requires a browser session but none was provided`);
|
||||
}
|
||||
return updated.func(page as IPage, kwargs, debug);
|
||||
}
|
||||
if (updated?.pipeline) return executePipeline(page, updated.pipeline, { args: kwargs, debug });
|
||||
}
|
||||
|
||||
if (cmd.func) return cmd.func(page!, kwargs, debug);
|
||||
if (cmd.func) return cmd.func(page as IPage, kwargs, debug);
|
||||
if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
|
||||
throw new CommandExecutionError(
|
||||
`Command ${fullName(cmd)} has no func or pipeline`,
|
||||
@@ -155,7 +159,9 @@ export async function executeCommand(
|
||||
// Each adapter controls this via `navigateBefore` (see CliCommand docs).
|
||||
const preNavUrl = resolvePreNav(cmd);
|
||||
if (preNavUrl) {
|
||||
try { await page.goto(preNavUrl); await page.wait(2); } catch {}
|
||||
try { await page.goto(preNavUrl); await page.wait(2); } catch (err) {
|
||||
if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
|
||||
timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT,
|
||||
|
||||
+4
-2
@@ -15,6 +15,7 @@ import { detectFramework } from './scripts/framework.js';
|
||||
import { discoverStores } from './scripts/store.js';
|
||||
import { interactFuzz } from './scripts/interact.js';
|
||||
import type { IPage } from './types.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
// ── Site name detection ────────────────────────────────────────────────────
|
||||
|
||||
@@ -224,7 +225,8 @@ function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[]
|
||||
}
|
||||
|
||||
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
&& Object.values(value as Record<string, unknown>).every(v => typeof v === 'boolean');
|
||||
}
|
||||
|
||||
function scoreEndpoint(ep: { contentType: string; responseAnalysis: AnalyzedEndpoint['responseAnalysis']; pattern: string; status: number | null; hasSearchParam: boolean; hasPaginationParam: boolean; hasLimitParam: boolean }): number {
|
||||
@@ -453,7 +455,7 @@ export async function exploreUrl(
|
||||
const clicks = await page.evaluate(INTERACT_FUZZ_JS);
|
||||
await page.wait(2); // wait for XHRs to settle
|
||||
} catch (e) {
|
||||
// fuzzing is best-effort, don't fail the whole explore
|
||||
log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,14 +22,6 @@
|
||||
install:
|
||||
default: "npm install -g @readwiseio/readwise-cli"
|
||||
|
||||
- name: kubectl
|
||||
binary: kubectl
|
||||
description: "Kubernetes command-line tool"
|
||||
homepage: "https://kubernetes.io/docs/reference/kubectl/"
|
||||
tags: [kubernetes, k8s, devops]
|
||||
install:
|
||||
mac: "brew install kubectl"
|
||||
|
||||
- name: docker
|
||||
binary: docker
|
||||
description: "Docker command-line interface"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
|
||||
mockExecFileSync: vi.fn(),
|
||||
mockPlatform: vi.fn(() => 'darwin'),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawnSync: vi.fn(),
|
||||
execFileSync: mockExecFileSync,
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:os')>('node:os');
|
||||
return {
|
||||
...actual,
|
||||
platform: mockPlatform,
|
||||
};
|
||||
});
|
||||
|
||||
import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('splits binaries and quoted arguments without invoking a shell', () => {
|
||||
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
|
||||
binary: 'npm',
|
||||
args: ['install', '-g', '@scope/tool name'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects shell operators', () => {
|
||||
expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
|
||||
'Install command contains unsafe shell operators',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects command substitution and multiline input', () => {
|
||||
expect(() => parseCommand('brew install $(whoami)')).toThrow(
|
||||
'Install command contains unsafe shell operators',
|
||||
);
|
||||
expect(() => parseCommand('brew install gh\nrm -rf /')).toThrow(
|
||||
'Install command contains unsafe shell operators',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installExternalCli', () => {
|
||||
const cli: ExternalCliConfig = {
|
||||
name: 'readwise',
|
||||
binary: 'readwise',
|
||||
install: {
|
||||
default: 'npm install -g @readwiseio/readwise-cli',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecFileSync.mockReset();
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
});
|
||||
|
||||
it('retries with .cmd on Windows when the bare binary is unavailable', () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
mockExecFileSync
|
||||
.mockImplementationOnce(() => {
|
||||
const err = new Error('not found') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
})
|
||||
.mockReturnValueOnce(Buffer.from(''));
|
||||
|
||||
expect(installExternalCli(cli)).toBe(true);
|
||||
expect(mockExecFileSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '-g', '@readwiseio/readwise-cli'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
expect(mockExecFileSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'npm.cmd',
|
||||
['install', '-g', '@readwiseio/readwise-cli'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mask non-ENOENT failures', () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
mockExecFileSync.mockImplementationOnce(() => {
|
||||
const err = new Error('permission denied') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(installExternalCli(cli)).toBe(false);
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+56
-2
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync, execSync, execFileSync } from 'node:child_process';
|
||||
import { spawnSync, execFileSync } from 'node:child_process';
|
||||
import yaml from 'js-yaml';
|
||||
import chalk from 'chalk';
|
||||
import { log } from './logger.js';
|
||||
@@ -82,6 +82,60 @@ export function getInstallCmd(installConfig?: ExternalCliInstall): string | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a command string into a binary and argument list.
|
||||
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
||||
* cannot be safely expressed as execFileSync arguments.
|
||||
*
|
||||
* Args:
|
||||
* cmd: Raw command string from YAML config (e.g. "brew install gh")
|
||||
*
|
||||
* Returns:
|
||||
* Object with `binary` and `args` fields, or throws on unsafe input.
|
||||
*/
|
||||
export function parseCommand(cmd: string): { binary: string; args: string[] } {
|
||||
const shellOperators = /&&|\|\|?|;|[><`$#\n\r]|\$\(/;
|
||||
if (shellOperators.test(cmd)) {
|
||||
throw new Error(
|
||||
`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
|
||||
`Please install the tool manually.`
|
||||
);
|
||||
}
|
||||
|
||||
// Tokenise respecting single- and double-quoted segments (no variable expansion).
|
||||
const tokens: string[] = [];
|
||||
const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(cmd)) !== null) {
|
||||
tokens.push(match[1] ?? match[2] ?? match[3]);
|
||||
}
|
||||
|
||||
if (tokens.length === 0) {
|
||||
throw new Error(`Install command is empty.`);
|
||||
}
|
||||
|
||||
const [binary, ...args] = tokens;
|
||||
return { binary, args };
|
||||
}
|
||||
|
||||
function shouldRetryWithCmdShim(binary: string, err: NodeJS.ErrnoException): boolean {
|
||||
return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
|
||||
}
|
||||
|
||||
function runInstallCommand(cmd: string): void {
|
||||
const { binary, args } = parseCommand(cmd);
|
||||
|
||||
try {
|
||||
execFileSync(binary, args, { stdio: 'inherit' });
|
||||
} catch (err: any) {
|
||||
if (shouldRetryWithCmdShim(binary, err)) {
|
||||
execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function installExternalCli(cli: ExternalCliConfig): boolean {
|
||||
if (!cli.install) {
|
||||
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
|
||||
@@ -99,7 +153,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
|
||||
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
|
||||
console.log(chalk.dim(`$ ${cmd}`));
|
||||
try {
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
runInstallCommand(cmd);
|
||||
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
|
||||
+4
-40
@@ -12,29 +12,13 @@ import { exploreUrl } from './explore.js';
|
||||
import type { IBrowserFactory } from './runtime.js';
|
||||
import { synthesizeFromExplore, type SynthesizeCandidateSummary, type SynthesizeResult } from './synthesize.js';
|
||||
|
||||
// TODO: implement real CLI registration (copy candidate YAML to user clis dir)
|
||||
interface RegisterCandidatesOptions {
|
||||
target: string;
|
||||
builtinClis?: string;
|
||||
userClis?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface RegisterCandidatesResult {
|
||||
ok: boolean;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GenerateCliOptions {
|
||||
url: string;
|
||||
BrowserFactory: new () => IBrowserFactory;
|
||||
builtinClis?: string;
|
||||
userClis?: string;
|
||||
goal?: string | null;
|
||||
site?: string;
|
||||
waitSeconds?: number;
|
||||
top?: number;
|
||||
register?: boolean;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
@@ -56,11 +40,6 @@ export interface GenerateCliResult {
|
||||
candidate_count: number;
|
||||
candidates: Array<Pick<SynthesizeCandidateSummary, 'name' | 'strategy' | 'confidence'>>;
|
||||
};
|
||||
register: RegisterCandidatesResult | null;
|
||||
}
|
||||
|
||||
function registerCandidates(_opts: RegisterCandidatesOptions): RegisterCandidatesResult {
|
||||
return { ok: true, count: 0 };
|
||||
}
|
||||
|
||||
const CAPABILITY_ALIASES: Record<string, string[]> = {
|
||||
@@ -101,9 +80,10 @@ function selectCandidate(candidates: SynthesizeResult['candidates'], goal?: stri
|
||||
}
|
||||
|
||||
const lower = (goal ?? '').trim().toLowerCase();
|
||||
const partial = candidates.find(c =>
|
||||
c.name?.toLowerCase().includes(lower) || lower.includes(c.name?.toLowerCase())
|
||||
);
|
||||
const partial = candidates.find(c => {
|
||||
const cName = c.name?.toLowerCase() ?? '';
|
||||
return cName.includes(lower) || lower.includes(cName);
|
||||
});
|
||||
return partial ?? candidates[0];
|
||||
}
|
||||
|
||||
@@ -126,19 +106,6 @@ export async function generateCliFromUrl(opts: GenerateCliOptions): Promise<Gene
|
||||
const selected = selectCandidate(synthesizeResult.candidates ?? [], opts.goal);
|
||||
const selectedSite = synthesizeResult.site ?? exploreResult.site;
|
||||
|
||||
// Step 4: Register (if requested)
|
||||
let registerResult: RegisterCandidatesResult | null = null;
|
||||
if (opts.register !== false && synthesizeResult.candidate_count > 0) {
|
||||
try {
|
||||
registerResult = registerCandidates({
|
||||
target: synthesizeResult.out_dir,
|
||||
builtinClis: opts.builtinClis,
|
||||
userClis: opts.userClis,
|
||||
name: selected?.name,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const ok = exploreResult.endpoint_count > 0 && synthesizeResult.candidate_count > 0;
|
||||
|
||||
return {
|
||||
@@ -163,7 +130,6 @@ export async function generateCliFromUrl(opts: GenerateCliOptions): Promise<Gene
|
||||
confidence: c.confidence,
|
||||
})),
|
||||
},
|
||||
register: registerResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,8 +153,6 @@ export function renderGenerateSummary(r: GenerateCliResult): string {
|
||||
lines.push(` • ${c.name} (${c.strategy}, ${((c.confidence ?? 0) * 100).toFixed(0)}%)`);
|
||||
}
|
||||
|
||||
if (r.register) lines.push(`\nRegistered: ${r.register.count ?? 0}`);
|
||||
|
||||
const fw = r.explore?.framework ?? {};
|
||||
const fwNames = Object.entries(fw).filter(([, v]) => v).map(([k]) => k);
|
||||
if (fwNames.length) lines.push(`Framework: ${fwNames.join(', ')}`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user