Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90971d4c1a | |||
| 4945f10ac8 | |||
| 454964010d |
@@ -1,5 +1,5 @@
|
||||
name: Setup Chrome
|
||||
description: Install real Chrome for browser testing (with xvfb on Linux)
|
||||
name: Setup Chrome + xvfb
|
||||
description: Install real Chrome and xvfb virtual display for headed browser testing
|
||||
|
||||
outputs:
|
||||
chrome-path:
|
||||
@@ -19,9 +19,8 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}"
|
||||
"${{ steps.setup-chrome.outputs.chrome-path }}" --version
|
||||
${{ steps.setup-chrome.outputs.chrome-path }} --version
|
||||
|
||||
- name: Install xvfb (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
- name: Install xvfb for headed mode
|
||||
shell: bash
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
@@ -24,10 +24,8 @@ Related issue:
|
||||
- [ ] Added doc page under `docs/adapters/` (if new adapter)
|
||||
- [ ] Updated `docs/adapters/index.md` table (if new adapter)
|
||||
- [ ] Updated sidebar in `docs/.vitepress/config.mts` (if new adapter)
|
||||
- [ ] Updated `README.md` / `README.zh-CN.md` when command discoverability changed
|
||||
- [ ] Used positional args for the command's primary subject unless a named flag is clearly better
|
||||
- [ ] Normalized expected adapter failures to `CliError` subclasses instead of raw `Error`
|
||||
|
||||
## Screenshots / Output
|
||||
|
||||
<!-- If applicable, paste CLI output or screenshots here. -->
|
||||
|
||||
|
||||
@@ -4,14 +4,8 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*.*.*" ]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.github/workflows/build-extension.yml'
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.github/workflows/build-extension.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -39,8 +33,12 @@ jobs:
|
||||
working-directory: extension
|
||||
|
||||
- name: Prepare extension package
|
||||
run: npm run package:release -- --out ../extension-package
|
||||
working-directory: extension
|
||||
run: |
|
||||
rm -rf extension-package
|
||||
mkdir -p extension-package
|
||||
cp extension/manifest.json extension-package/
|
||||
cp -R extension/dist extension-package/
|
||||
cp -R extension/icons extension-package/
|
||||
|
||||
- name: Create Extension ZIP
|
||||
run: |
|
||||
|
||||
@@ -16,11 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Fast gate: typecheck + build ──
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -40,11 +36,10 @@ jobs:
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
unit-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
@@ -61,33 +56,8 @@ jobs:
|
||||
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
|
||||
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
# ── Bun compatibility check ──
|
||||
bun-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests under Bun
|
||||
run: bun vitest run --project unit --reporter=verbose
|
||||
|
||||
adapter-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -107,13 +77,7 @@ jobs:
|
||||
smoke-test:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
needs: build
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
|
||||
# Chrome MSI installation on Windows runners (known issue).
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -125,24 +89,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run smoke tests (Linux, via xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/smoke/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
- name: Run smoke tests (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
run: npx vitest run tests/smoke/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -3,28 +3,8 @@ name: E2E Headed Chrome
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- 'src/browser/**'
|
||||
- 'src/daemon.ts'
|
||||
- 'src/execution.ts'
|
||||
- 'src/interceptor.ts'
|
||||
- 'tests/e2e/**'
|
||||
- 'tests/smoke/**'
|
||||
- '.github/actions/setup-chrome/**'
|
||||
- '.github/workflows/e2e-headed.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- 'src/browser/**'
|
||||
- 'src/daemon.ts'
|
||||
- 'src/execution.ts'
|
||||
- 'src/interceptor.ts'
|
||||
- 'tests/e2e/**'
|
||||
- 'tests/smoke/**'
|
||||
- '.github/actions/setup-chrome/**'
|
||||
- '.github/workflows/e2e-headed.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -33,13 +13,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
e2e-headed:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
|
||||
# Chrome MSI installation on Windows runners (known issue).
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -52,23 +26,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests (Linux, via xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
- name: Run E2E tests (headed Chrome + xvfb)
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/e2e/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
- name: Run E2E tests (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
run: npx vitest run tests/e2e/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish Any Commit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish
|
||||
@@ -31,3 +31,6 @@ jobs:
|
||||
|
||||
- name: npm audit (production)
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx --yes audit-ci@^7 --high --skip-dev
|
||||
|
||||
@@ -1,83 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.1](https://github.com/jackwener/opencli/compare/v1.4.0...v1.4.1) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **douyin:** add Douyin creator center adapter — 14 commands, 8-phase publish pipeline ([#416](https://github.com/jackwener/opencli/issues/416))
|
||||
* **weibo,youtube:** add Weibo commands and YouTube channel/comments ([#418](https://github.com/jackwener/opencli/issues/418))
|
||||
* **twitter:** add filter option for search ([#410](https://github.com/jackwener/opencli/issues/410))
|
||||
* **extension:** add popup UI, privacy policy, and CSP for Chrome Web Store ([#415](https://github.com/jackwener/opencli/issues/415))
|
||||
* add url field to 9 search adapters (67% -> 97% coverage) ([#414](https://github.com/jackwener/opencli/issues/414))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension:** improve UX when daemon is not running — show hint in popup, reduce reconnect noise ([#424](https://github.com/jackwener/opencli/issues/424))
|
||||
* remove incorrect gws and readwise external CLI entries ([#419](https://github.com/jackwener/opencli/issues/419), [#420](https://github.com/jackwener/opencli/issues/420))
|
||||
|
||||
|
||||
### CI
|
||||
|
||||
* limit default e2e to bilibili/zhihu/v2ex, gate extended browser tests ([#421](https://github.com/jackwener/opencli/issues/421), [#423](https://github.com/jackwener/opencli/issues/423))
|
||||
|
||||
|
||||
## [1.4.0](https://github.com/jackwener/opencli/compare/v1.3.3...v1.4.0) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **pixiv:** add Pixiv adapter — ranking, search, user illusts, detail, download ([#403](https://github.com/jackwener/opencli/issues/403))
|
||||
* **plugin:** add lifecycle hooks API — onStartup, onBeforeExecute, onAfterExecute ([#376](https://github.com/jackwener/opencli/issues/376))
|
||||
* **plugin:** validate plugin structure on install and update ([#364](https://github.com/jackwener/opencli/issues/364))
|
||||
* **xueqiu:** add Danjuan fund account commands — fund-holdings, fund-snapshot ([#391](https://github.com/jackwener/opencli/issues/391))
|
||||
* **tiktok:** add video URL to search results ([#404](https://github.com/jackwener/opencli/issues/404))
|
||||
* **linkedin:** add timeline feed command ([#342](https://github.com/jackwener/opencli/issues/342))
|
||||
* **jd:** add JD.com product details adapter ([#344](https://github.com/jackwener/opencli/issues/344))
|
||||
* **web:** add generic `web read` command for any URL → Markdown ([#343](https://github.com/jackwener/opencli/issues/343))
|
||||
* **dictionary:** add dictionary search, synonyms, and examples adapters ([#241](https://github.com/jackwener/opencli/issues/241))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analysis:** fix hasLimit using wrong Set (SEARCH_PARAMS → LIMIT_PARAMS) ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* **pipeline:** remove phantom scroll step — declared but never registered ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* **validate:** add missing download step to KNOWN_STEP_NAMES ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* **extension:** security hardening — tab isolation, URL validation, cookie scope ([#409](https://github.com/jackwener/opencli/issues/409))
|
||||
* **sort:** use localeCompare with natural numeric sort by default ([#306](https://github.com/jackwener/opencli/issues/306))
|
||||
* **pipeline:** evaluate chained || in template engine ([#305](https://github.com/jackwener/opencli/issues/305))
|
||||
* **pipeline:** check HTTP status in fetch step ([#384](https://github.com/jackwener/opencli/issues/384))
|
||||
* **plugin:** resolve Windows path and symlink issues ([#400](https://github.com/jackwener/opencli/issues/400))
|
||||
* **download:** scope cookies to target domain ([#385](https://github.com/jackwener/opencli/issues/385))
|
||||
* **extension:** fix same-url navigation timeout ([#380](https://github.com/jackwener/opencli/issues/380))
|
||||
* fix ChatWise Windows connect ([#405](https://github.com/jackwener/opencli/issues/405))
|
||||
* resolve 6 critical + 11 important bugs from deep code review ([#337](https://github.com/jackwener/opencli/issues/337), [#340](https://github.com/jackwener/opencli/issues/340))
|
||||
* harden security-sensitive execution paths ([#335](https://github.com/jackwener/opencli/issues/335))
|
||||
* **stealth:** harden anti-detection against advanced fingerprinting ([#357](https://github.com/jackwener/opencli/issues/357))
|
||||
|
||||
|
||||
### Code Quality
|
||||
|
||||
* replace all `catch (err: any)` with typed `getErrorMessage()` across 13 files ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* adopt CliError subclasses in social and desktop adapters ([#367](https://github.com/jackwener/opencli/issues/367), [#372](https://github.com/jackwener/opencli/issues/372), [#375](https://github.com/jackwener/opencli/issues/375))
|
||||
* simplify codebase with type dedup, shared analysis module, and consistent naming ([#373](https://github.com/jackwener/opencli/issues/373))
|
||||
* **ci:** add cross-platform CI matrix (Linux/macOS/Windows) ([#402](https://github.com/jackwener/opencli/issues/402))
|
||||
|
||||
|
||||
## [1.3.3](https://github.com/jackwener/opencli/compare/v1.3.2...v1.3.3) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **browser:** add stealth anti-detection for CDP and daemon modes ([#319](https://github.com/jackwener/opencli/issues/319))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **stealth:** review fixes — guard plugins, rewrite stack trace cleanup ([#320](https://github.com/jackwener/opencli/issues/320))
|
||||
|
||||
|
||||
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
# Privacy Policy — OpenCLI Browser Extension
|
||||
|
||||
**Last updated**: 2026-03-25
|
||||
|
||||
## What the extension does
|
||||
|
||||
The OpenCLI Browser Extension is a bridge between the [OpenCLI](https://github.com/jackwener/opencli) command-line tool and your Chrome browser. It receives commands from a **locally running daemon** process via WebSocket (`localhost` only) and executes them in **isolated Chrome windows** that are separate from your normal browsing session.
|
||||
|
||||
## Data collection
|
||||
|
||||
The extension does **NOT** collect, store, transmit, or sell any personal data. Specifically:
|
||||
|
||||
- **No analytics or telemetry** — no data is sent to any remote server.
|
||||
- **No user tracking** — no cookies, identifiers, or fingerprints are created.
|
||||
- **No external network requests** — all communication is strictly `localhost` (WebSocket to `ws://localhost:19825`).
|
||||
|
||||
## Permissions explained
|
||||
|
||||
| Permission | Why it's needed |
|
||||
|------------|----------------|
|
||||
| `debugger` | Required to use Chrome DevTools Protocol (CDP) for browser automation — executing JavaScript, capturing page content, and taking screenshots in isolated windows. |
|
||||
| `tabs` | Required to create and manage isolated automation windows and tabs, separate from the user's browsing session. |
|
||||
| `cookies` | Required to read site-specific cookies (scoped by domain) so CLI commands can authenticate with websites the user is already logged into. Cookies are **never written, modified, or transmitted externally**. |
|
||||
| `activeTab` | Required to identify the currently active tab for context-aware commands. |
|
||||
| `alarms` | Required to maintain the WebSocket connection to the local daemon via periodic keepalive checks. |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
User's terminal (opencli CLI)
|
||||
↓ (spawns)
|
||||
Local daemon process (localhost:19825)
|
||||
↓ (WebSocket, localhost only)
|
||||
Chrome Extension (this extension)
|
||||
↓ (Chrome APIs)
|
||||
Isolated Chrome automation window
|
||||
```
|
||||
|
||||
All data stays on the user's machine. No data leaves `localhost`.
|
||||
|
||||
## Cookie access
|
||||
|
||||
The extension reads cookies **only** when explicitly requested by a CLI command, and **only** for the specific domain the command targets. It cannot and does not dump all cookies. Cookie data is returned to the local daemon process and is never sent to any external server.
|
||||
|
||||
## Third-party services
|
||||
|
||||
This extension does not integrate with, send data to, or receive data from any third-party service.
|
||||
|
||||
## Open source
|
||||
|
||||
This extension is fully open source. You can audit the complete source code at:
|
||||
https://github.com/jackwener/opencli/tree/main/extension
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy questions or concerns, please open an issue at:
|
||||
https://github.com/jackwener/opencli/issues
|
||||
@@ -3,7 +3,8 @@
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
|
||||
|
||||
[](./README.zh-CN.md)
|
||||
[中文文档](./README.zh-CN.md)
|
||||
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
@@ -49,31 +50,11 @@ There are many great browser automation tools. Here's when opencli is the right
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0 — see [Runtime Support](#runtime-support) below)
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
### Runtime Support
|
||||
|
||||
OpenCLI works with both **Node.js** (≥ 20) and **Bun** (≥ 1.0). All commands and adapters are runtime-agnostic.
|
||||
|
||||
```bash
|
||||
# Development with Bun (faster startup)
|
||||
npm run dev:bun
|
||||
|
||||
# Run the built CLI with Bun
|
||||
npm run start:bun
|
||||
|
||||
# Run unit tests under Bun
|
||||
npm run test:bun
|
||||
|
||||
# Run E2E tests with Bun as the runtime
|
||||
OPENCLI_TEST_RUNTIME=bun npm run test:e2e
|
||||
```
|
||||
|
||||
Use `opencli doctor` to check your current runtime — it displays the active engine (e.g. `node v22.13.0` or `bun 1.1.42`).
|
||||
|
||||
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
### Browser Bridge Extension Setup
|
||||
@@ -149,9 +130,9 @@ Run `opencli list` for the live registry.
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 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 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
|
||||
@@ -166,14 +147,11 @@ Run `opencli list` for the live registry.
|
||||
| **devto** | `top` `tag` `user` | Public |
|
||||
| **dictionary** | `search` `synonyms` `examples` | Public |
|
||||
| **arxiv** | `search` `paper` | Public |
|
||||
| **paperreview** | `submit` `review` `feedback` | Public |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
||||
| **jd** | `item` | Browser |
|
||||
| **linkedin** | `search` `timeline` | Browser |
|
||||
| **linkedin** | `search` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
| **smzdm** | `search` | Browser |
|
||||
| **web** | `read` | Browser |
|
||||
| **weibo** | `hot` `search` | Browser |
|
||||
| **yahoo-finance** | `quote` | Browser |
|
||||
| **sinafinance** | `news` | 🌐 Public |
|
||||
@@ -184,22 +162,18 @@ Run `opencli list` for the live registry.
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | Browser |
|
||||
| **jimeng** | `generate` `history` | Browser |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | Browser |
|
||||
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | Browser |
|
||||
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | Public |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
|
||||
| **steam** | `top-sellers` | Public |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 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 |
|
||||
| **36kr** | `news` `hot` `search` `article` | Public / Browser |
|
||||
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | Public |
|
||||
| **producthunt** | `posts` `today` `hot` `browse` | Public / Browser |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | Public |
|
||||
| **medium** | `feed` `search` `user` | Browser |
|
||||
| **sinablog** | `hot` `search` `article` `user` | Browser |
|
||||
| **substack** | `feed` `search` `publication` | Browser |
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | Browser |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
|
||||
|
||||
|
||||
@@ -229,8 +203,6 @@ opencli register mycli
|
||||
|
||||
Each desktop adapter has its own detailed documentation with commands reference, setup guide, and examples:
|
||||
|
||||
If you want to add support for a new Electron desktop app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md) and the deeper [Electron guide](./docs/advanced/electron.md).
|
||||
|
||||
| App | Description | Doc |
|
||||
|-----|-------------|-----|
|
||||
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
|
||||
@@ -253,8 +225,6 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **douban** | Images | Downloads poster / still image lists from movie subjects |
|
||||
| **pixiv** | Images | Downloads original-quality illustrations, supports multi-page works |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
|
||||
@@ -285,9 +255,6 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Download Douban posters / stills
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
@@ -323,12 +290,9 @@ Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # Install
|
||||
opencli plugin list # List installed
|
||||
opencli plugin update my-tool # Update to latest
|
||||
opencli plugin update --all # Update all installed plugins
|
||||
opencli plugin uninstall my-tool # Remove
|
||||
```
|
||||
|
||||
`opencli plugin list` also shows the tracked short commit hash when a plugin version is recorded in `~/.opencli/plugins.lock.json`.
|
||||
|
||||
| Plugin | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
|
||||
|
||||
+7
-21
@@ -3,7 +3,8 @@
|
||||
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
|
||||
[](./README.md)
|
||||
[English](./README.md)
|
||||
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
@@ -131,9 +132,9 @@ npm install -g @jackwener/opencli@latest
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
|
||||
| **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` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
@@ -148,14 +149,11 @@ npm install -g @jackwener/opencli@latest
|
||||
| **devto** | `top` `tag` `user` | 公开 |
|
||||
| **dictionary** | `search` `synonyms` `examples` | 公开 |
|
||||
| **arxiv** | `search` `paper` | 公开 |
|
||||
| **paperreview** | `submit` `review` `feedback` | 公开 |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
| **jd** | `item` | 浏览器 |
|
||||
| **linkedin** | `search` `timeline` | 浏览器 |
|
||||
| **linkedin** | `search` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **web** | `read` | 浏览器 |
|
||||
| **weibo** | `hot` `search` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
| **sinafinance** | `news` | 🌐 公开 |
|
||||
@@ -166,22 +164,18 @@ npm install -g @jackwener/opencli@latest
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
|
||||
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 浏览器 |
|
||||
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | 公开 |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
||||
| **steam** | `top-sellers` | 公开 |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
||||
| **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` | 公开 |
|
||||
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
|
||||
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
|
||||
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
|
||||
| **medium** | `feed` `search` `user` | 浏览器 |
|
||||
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
|
||||
| **substack** | `feed` `search` `publication` | 浏览器 |
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
|
||||
|
||||
@@ -233,10 +227,8 @@ OpenCLI 支持从各平台下载图片、视频和文章。
|
||||
| **小红书** | 图片、视频 | 下载笔记中的所有媒体文件 |
|
||||
| **B站** | 视频 | 需要安装 `yt-dlp` |
|
||||
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
|
||||
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
|
||||
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
|
||||
| **微信公众号** | 文章(Markdown) | 导出微信公众号文章为 Markdown |
|
||||
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
|
||||
|
||||
### 前置依赖
|
||||
|
||||
@@ -265,9 +257,6 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
# 下载单条推文的媒体
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# 下载豆瓣电影海报 / 剧照
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# 导出知乎文章为 Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
@@ -303,12 +292,9 @@ opencli bilibili hot -v # 详细模式:展示管线执行步骤调试
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # 安装
|
||||
opencli plugin list # 查看已安装
|
||||
opencli plugin update my-tool # 更新到最新
|
||||
opencli plugin update --all # 更新全部已安装插件
|
||||
opencli plugin uninstall my-tool # 卸载
|
||||
```
|
||||
|
||||
当 plugin 的版本被记录到 `~/.opencli/plugins.lock.json` 后,`opencli plugin list` 也会显示对应的短 commit hash。
|
||||
|
||||
| 插件 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 1.4.1
|
||||
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]
|
||||
---
|
||||
@@ -15,12 +15,6 @@ tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, githu
|
||||
> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
|
||||
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 创建或修改 adapter 时,再额外遵守 3 条收口规则:
|
||||
> 1. 主参数优先用 positional arg,不要把 `query` / `id` / `url` 默认做成 `--query` / `--id` / `--url`
|
||||
> 2. 预期中的 adapter 失败优先抛 `CliError` 子类,不要直接 throw 原始 `Error`
|
||||
> 3. 新增 adapter 或新增用户可发现命令时,同步更新 adapter docs、`docs/adapters/index.md`、sidebar,以及 README/README.zh-CN 中受影响的入口
|
||||
|
||||
## Install & Run
|
||||
|
||||
```bash
|
||||
@@ -88,9 +82,6 @@ opencli xueqiu watchlist # 获取自选股/持仓列表
|
||||
opencli xueqiu feed # 我的关注 timeline
|
||||
opencli xueqiu hot --limit 10 # 雪球热榜
|
||||
opencli xueqiu search "特斯拉" # 搜索 (query positional)
|
||||
opencli xueqiu earnings-date SH600519 # 股票财报发布日期 (symbol positional)
|
||||
opencli xueqiu fund-holdings # 蛋卷基金持仓明细 (支持 --account 过滤)
|
||||
opencli xueqiu fund-snapshot # 蛋卷基金快照(总资产、子账户、持仓)
|
||||
|
||||
# GitHub (via gh External CLI)
|
||||
opencli gh repo list # 列出仓库 (passthrough to gh)
|
||||
@@ -109,19 +100,6 @@ opencli twitter follow elonmusk # 关注用户
|
||||
opencli twitter unfollow elonmusk # 取消关注
|
||||
opencli twitter bookmark https://x.com/... # 收藏推文
|
||||
opencli twitter unbookmark https://x.com/... # 取消收藏
|
||||
opencli twitter post "Hello world" # 发布推文 (text positional)
|
||||
opencli twitter like https://x.com/... # 点赞推文 (url positional)
|
||||
opencli twitter reply https://x.com/... "Nice!" # 回复推文 (url + text positional)
|
||||
opencli twitter delete https://x.com/... # 删除推文 (url positional)
|
||||
opencli twitter block elonmusk # 屏蔽用户 (username positional)
|
||||
opencli twitter unblock elonmusk # 取消屏蔽 (username positional)
|
||||
opencli twitter followers elonmusk # 用户的粉丝列表 (user positional)
|
||||
opencli twitter following elonmusk # 用户的关注列表 (user positional)
|
||||
opencli twitter notifications --limit 20 # 通知列表
|
||||
opencli twitter hide-reply https://x.com/... # 隐藏回复 (url positional)
|
||||
opencli twitter download elonmusk # 下载用户媒体 (username positional, 支持 --tweet-url)
|
||||
opencli twitter accept "群,微信" # 自动接受含关键词的 DM 请求 (query positional)
|
||||
opencli twitter reply-dm "消息内容" # 批量回复 DM (text positional)
|
||||
|
||||
# Reddit (browser)
|
||||
opencli reddit hot --limit 10 # 热门帖子
|
||||
@@ -148,21 +126,9 @@ opencli v2ex topic 1024 # 主题详情 (id positional)
|
||||
opencli v2ex daily # 每日签到 (browser)
|
||||
opencli v2ex me # 我的信息 (browser)
|
||||
opencli v2ex notifications --limit 10 # 通知 (browser)
|
||||
opencli v2ex node python # 节点话题列表 (name positional)
|
||||
opencli v2ex nodes --limit 30 # 所有节点列表
|
||||
opencli v2ex member username # 用户资料 (username positional)
|
||||
opencli v2ex user username # 用户发帖列表 (username positional)
|
||||
opencli v2ex replies 1024 # 主题回复列表 (id positional)
|
||||
|
||||
# Hacker News (public)
|
||||
opencli hackernews top --limit 10 # Top stories
|
||||
opencli hackernews new --limit 10 # Newest stories
|
||||
opencli hackernews best --limit 10 # Best stories
|
||||
opencli hackernews ask --limit 10 # Ask HN posts
|
||||
opencli hackernews show --limit 10 # Show HN posts
|
||||
opencli hackernews jobs --limit 10 # Job postings
|
||||
opencli hackernews search "rust" # 搜索 (query positional)
|
||||
opencli hackernews user dang # 用户资料 (username positional)
|
||||
|
||||
# BBC (public)
|
||||
opencli bbc news --limit 10 # BBC News RSS headlines
|
||||
@@ -232,13 +198,11 @@ opencli jike comment xxx "评论" # 评论 (id + text positional)
|
||||
opencli jike repost xxx # 转发 (id positional)
|
||||
opencli jike notifications # 通知
|
||||
|
||||
# Linux.do (public + browser)
|
||||
# Linux.do (public)
|
||||
opencli linux-do hot --limit 10 # 热门话题
|
||||
opencli linux-do latest --limit 10 # 最新话题
|
||||
opencli linux-do search "rust" # 搜索 (query positional)
|
||||
opencli linux-do topic 1024 # 主题详情 (id positional)
|
||||
opencli linux-do categories --limit 20 # 分类列表 (browser)
|
||||
opencli linux-do category dev 7 # 分类内话题 (slug + id positional, browser)
|
||||
|
||||
# StackOverflow (public)
|
||||
opencli stackoverflow hot --limit 10 # 热门问题
|
||||
@@ -264,12 +228,6 @@ opencli yollomi video "提示词" --model kling-2-1 # 视频
|
||||
opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用
|
||||
opencli yollomi remove-bg <image-url> # 去背景(免费)
|
||||
opencli yollomi edit <image-url> "改成油画风格" # Qwen 图像编辑
|
||||
opencli yollomi background <image-url> # AI 背景生成 (5 credits)
|
||||
opencli yollomi face-swap --source <url> --target <url> # 换脸 (3 credits)
|
||||
opencli yollomi object-remover <image-url> <mask-url> # AI 去除物体 (3 credits)
|
||||
opencli yollomi restore <image-url> # AI 修复老照片 (4 credits)
|
||||
opencli yollomi try-on --person <url> --cloth <url> # 虚拟试衣 (3 credits)
|
||||
opencli yollomi upscale <image-url> # AI 超分辨率 (1 credit, 支持 --scale 2/4)
|
||||
|
||||
# Grok (default + explicit web)
|
||||
opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径)
|
||||
@@ -286,8 +244,6 @@ opencli chaoxing exams # 考试列表
|
||||
opencli douban search "三体" # 搜索 (query positional)
|
||||
opencli douban top250 # 豆瓣 Top 250
|
||||
opencli douban subject 1234567 # 条目详情 (id positional)
|
||||
opencli douban photos 30382501 # 图片列表 / 直链(默认海报)
|
||||
opencli douban download 30382501 # 下载海报 / 剧照
|
||||
opencli douban marks --limit 10 # 我的标记
|
||||
opencli douban reviews --limit 10 # 短评
|
||||
|
||||
@@ -372,69 +328,6 @@ opencli devto user username # 用户文章 (username positional)
|
||||
# Steam (public)
|
||||
opencli steam top-sellers --limit 10 # 热销游戏
|
||||
|
||||
# Apple Podcasts (public)
|
||||
opencli apple-podcasts top --limit 10 # 热门播客排行榜 (支持 --country us/cn/gb/jp)
|
||||
opencli apple-podcasts search "科技" # 搜索播客 (query positional)
|
||||
opencli apple-podcasts episodes 12345 # 播客剧集列表 (id positional, 用 search 获取 ID)
|
||||
|
||||
# arXiv (public)
|
||||
opencli arxiv search "attention" # 搜索论文 (query positional)
|
||||
opencli arxiv paper 1706.03762 # 论文详情 (id positional)
|
||||
|
||||
# Bloomberg (public RSS + browser)
|
||||
opencli bloomberg main --limit 10 # Bloomberg 首页头条 (RSS)
|
||||
opencli bloomberg markets --limit 10 # 市场新闻 (RSS)
|
||||
opencli bloomberg tech --limit 10 # 科技新闻 (RSS)
|
||||
opencli bloomberg politics --limit 10 # 政治新闻 (RSS)
|
||||
opencli bloomberg economics --limit 10 # 经济新闻 (RSS)
|
||||
opencli bloomberg opinions --limit 10 # 观点 (RSS)
|
||||
opencli bloomberg industries --limit 10 # 行业新闻 (RSS)
|
||||
opencli bloomberg businessweek --limit 10 # Businessweek (RSS)
|
||||
opencli bloomberg feeds # 列出所有 RSS feed 别名
|
||||
opencli bloomberg news "https://..." # 阅读 Bloomberg 文章全文 (link positional, browser)
|
||||
|
||||
# Coupang 쿠팡 (browser)
|
||||
opencli coupang search "耳机" # 搜索商品 (query positional, 支持 --filter rocket)
|
||||
opencli coupang add-to-cart 12345 # 加入购物车 (product-id positional, 或 --url)
|
||||
|
||||
# Dictionary (public)
|
||||
opencli dictionary search "serendipity" # 单词释义 (word positional)
|
||||
opencli dictionary synonyms "happy" # 近义词 (word positional)
|
||||
opencli dictionary examples "ubiquitous" # 例句 (word positional)
|
||||
|
||||
# 豆包 Doubao Web (browser)
|
||||
opencli doubao status # 检查豆包页面状态
|
||||
opencli doubao new # 新建对话
|
||||
opencli doubao send "你好" # 发送消息 (text positional)
|
||||
opencli doubao read # 读取对话记录
|
||||
opencli doubao ask "问题" # 一键提问并等回复 (text positional)
|
||||
|
||||
# 京东 JD (browser)
|
||||
opencli jd item 100291143898 # 商品详情 (sku positional, 含价格/主图/规格)
|
||||
|
||||
# LinkedIn (browser)
|
||||
opencli linkedin search "AI engineer" # 搜索职位 (query positional, 支持 --location/--company/--remote)
|
||||
opencli linkedin timeline --limit 20 # 首页动态流
|
||||
|
||||
# Pixiv (browser)
|
||||
opencli pixiv ranking --limit 20 # 插画排行榜 (支持 --mode daily/weekly/monthly)
|
||||
opencli pixiv search "風景" # 搜索插画 (query positional)
|
||||
opencli pixiv user 12345 # 画师资料 (uid positional)
|
||||
opencli pixiv illusts 12345 # 画师作品列表 (user-id positional)
|
||||
opencli pixiv detail 12345 # 插画详情 (id positional)
|
||||
opencli pixiv download 12345 # 下载插画 (illust-id positional)
|
||||
|
||||
# Web (browser)
|
||||
opencli web read --url "https://..." # 抓取任意网页并导出为 Markdown
|
||||
|
||||
# 微信公众号 Weixin (browser)
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" # 下载公众号文章为 Markdown
|
||||
|
||||
# 小宇宙 Xiaoyuzhou (public)
|
||||
opencli xiaoyuzhou podcast 12345 # 播客资料 (id positional)
|
||||
opencli xiaoyuzhou podcast-episodes 12345 # 播客剧集列表 (id positional)
|
||||
opencli xiaoyuzhou episode 12345 # 单集详情 (id positional)
|
||||
|
||||
# Wikipedia (public)
|
||||
opencli wikipedia search "AI" # 搜索 (query positional)
|
||||
opencli wikipedia summary "Python" # 摘要 (title positional)
|
||||
|
||||
@@ -68,7 +68,6 @@ src/
|
||||
| `tests/e2e/browser-auth.test.ts` | `bilibili`、`twitter`、`v2ex`、`xueqiu`、`linux-do`、`xiaohongshu` 的需登录命令 graceful failure |
|
||||
| `tests/e2e/management.test.ts` | `list`、`validate`、`verify`、`--version`、`--help`、unknown command |
|
||||
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
|
||||
| `tests/e2e/plugin-management.test.ts` | `plugin install` / `list` / `update` / `uninstall` 全生命周期 |
|
||||
|
||||
### 烟雾测试(1 个文件)
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$OpenCliArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$chatwiseExe = 'C:\Program Files\ChatWise\ChatWise.exe'
|
||||
if (-not (Test-Path $chatwiseExe)) {
|
||||
throw "ChatWise executable not found at $chatwiseExe"
|
||||
}
|
||||
|
||||
$opencli = Get-Command opencli -ErrorAction SilentlyContinue
|
||||
if (-not $opencli) {
|
||||
throw 'opencli was not found in PATH'
|
||||
}
|
||||
|
||||
function Clear-LocalProxyEnv {
|
||||
$vars = 'http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY'
|
||||
foreach ($name in $vars) {
|
||||
Set-Item -Path "Env:$name" -Value ''
|
||||
}
|
||||
$noProxy = '127.0.0.1,localhost'
|
||||
Set-Item -Path 'Env:NO_PROXY' -Value $noProxy
|
||||
Set-Item -Path 'Env:no_proxy' -Value $noProxy
|
||||
}
|
||||
|
||||
function Stop-ChatWiseTree {
|
||||
$candidates = Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -match '^ChatWise\.exe$|^chatwise\.exe$' }
|
||||
|
||||
foreach ($proc in $candidates) {
|
||||
try {
|
||||
Stop-Process -Id $proc.ProcessId -Force -ErrorAction Stop
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
function Wait-ChatWiseDebugPort {
|
||||
param(
|
||||
[int]$Port = 9228,
|
||||
[int]$TimeoutSeconds = 20
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri "http://127.0.0.1:$Port/json/version"
|
||||
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
throw "ChatWise debugging endpoint did not come up on 127.0.0.1:$Port"
|
||||
}
|
||||
|
||||
Clear-LocalProxyEnv
|
||||
Stop-ChatWiseTree
|
||||
|
||||
$proc = Start-Process -FilePath $chatwiseExe -ArgumentList '--remote-debugging-port=9228' -PassThru
|
||||
Start-Sleep -Seconds 4
|
||||
|
||||
if ($proc.HasExited) {
|
||||
throw "ChatWise exited early with code $($proc.ExitCode)"
|
||||
}
|
||||
|
||||
Wait-ChatWiseDebugPort
|
||||
|
||||
$env:OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9228'
|
||||
|
||||
if (-not $OpenCliArgs -or $OpenCliArgs.Count -eq 0) {
|
||||
& $opencli.Source 'chatwise' 'status'
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
& $opencli.Source @OpenCliArgs
|
||||
exit $LASTEXITCODE
|
||||
@@ -32,7 +32,6 @@ export default defineConfig({
|
||||
{ text: 'Comparison', link: '/comparison' },
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
{ text: 'Add an Electron App CLI', link: '/guide/electron-app-cli' },
|
||||
{ text: 'Plugins', link: '/guide/plugins' },
|
||||
],
|
||||
},
|
||||
@@ -74,18 +73,6 @@ export default defineConfig({
|
||||
{ text: 'Douban', link: '/adapters/browser/douban' },
|
||||
{ text: 'Sina Blog', link: '/adapters/browser/sinablog' },
|
||||
{ text: 'Substack', link: '/adapters/browser/substack' },
|
||||
{ text: 'Pixiv', link: '/adapters/browser/pixiv' },
|
||||
{ text: 'Douban', link: '/adapters/browser/douban' },
|
||||
{ text: 'Doubao', link: '/adapters/browser/doubao' },
|
||||
{ text: 'Facebook', link: '/adapters/browser/facebook' },
|
||||
{ text: 'Google', link: '/adapters/browser/google' },
|
||||
{ text: 'IMDb', link: '/adapters/browser/imdb' },
|
||||
{ text: 'Instagram', link: '/adapters/browser/instagram' },
|
||||
{ text: 'JD.com', link: '/adapters/browser/jd' },
|
||||
{ text: 'Medium', link: '/adapters/browser/medium' },
|
||||
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
|
||||
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
|
||||
{ text: 'Weixin', link: '/adapters/browser/weixin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -100,14 +87,11 @@ export default defineConfig({
|
||||
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
|
||||
{ text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' },
|
||||
{ text: 'arXiv', link: '/adapters/browser/arxiv' },
|
||||
{ text: 'paperreview.ai', link: '/adapters/browser/paperreview' },
|
||||
{ text: 'Barchart', link: '/adapters/browser/barchart' },
|
||||
{ text: 'Hugging Face', link: '/adapters/browser/hf' },
|
||||
{ text: 'Sina Finance', link: '/adapters/browser/sinafinance' },
|
||||
{ text: 'Stack Overflow', link: '/adapters/browser/stackoverflow' },
|
||||
{ text: 'Wikipedia', link: '/adapters/browser/wikipedia' },
|
||||
{ text: 'Lobsters', link: '/adapters/browser/lobsters' },
|
||||
{ text: 'Steam', link: '/adapters/browser/steam' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -121,7 +105,6 @@ export default defineConfig({
|
||||
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
|
||||
{ text: 'Notion', link: '/adapters/desktop/notion' },
|
||||
{ text: 'Discord', link: '/adapters/desktop/discord' },
|
||||
{ text: 'Doubao App', link: '/adapters/desktop/doubao-app' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -171,7 +154,6 @@ export default defineConfig({
|
||||
{ text: '快速开始', link: '/zh/guide/getting-started' },
|
||||
{ text: '安装', link: '/zh/guide/installation' },
|
||||
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
|
||||
{ text: '给新 Electron 应用生成 CLI', link: '/zh/guide/electron-app-cli' },
|
||||
{ text: '插件', link: '/zh/guide/plugins' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 36kr (36氪)
|
||||
|
||||
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `36kr.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli 36kr hot` | 36氪热榜 — trending articles |
|
||||
| `opencli 36kr news` | Latest tech/startup news from 36kr |
|
||||
| `opencli 36kr search <query>` | Search 36kr articles |
|
||||
| `opencli 36kr article <id-or-url>` | Read full article content |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Trending articles
|
||||
opencli 36kr hot --limit 10
|
||||
|
||||
# Hot by type
|
||||
opencli 36kr hot --type renqi --limit 10
|
||||
opencli 36kr hot --type zonghe --limit 10
|
||||
|
||||
# Latest news
|
||||
opencli 36kr news --limit 20
|
||||
|
||||
# Search articles
|
||||
opencli 36kr search "AI" --limit 10
|
||||
opencli 36kr search "OpenAI" --limit 5
|
||||
|
||||
# Read full article (by ID or URL)
|
||||
opencli 36kr article 3000000123456
|
||||
opencli 36kr article https://36kr.com/p/3000000123456
|
||||
|
||||
# JSON output
|
||||
opencli 36kr hot -f json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `news` uses the public RSS feed and works without Browser Bridge.
|
||||
- `hot`, `search`, and `article` use Browser Bridge and are best run with Chrome open.
|
||||
- `hot --type` accepts `catalog`, `renqi`, `zonghe`, and `shoucang`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -9,8 +9,6 @@
|
||||
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
||||
| `opencli douban top250` | 豆瓣电影 Top 250 |
|
||||
| `opencli douban subject` | 条目详情 |
|
||||
| `opencli douban photos` | 获取电影海报/剧照图片列表 |
|
||||
| `opencli douban download` | 下载电影海报/剧照图片 |
|
||||
| `opencli douban marks` | 我的标记 |
|
||||
| `opencli douban reviews` | 我的短评 |
|
||||
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
|
||||
@@ -34,18 +32,6 @@ opencli douban top250 --limit 10
|
||||
# 条目详情
|
||||
opencli douban subject 1292052
|
||||
|
||||
# 获取海报直链(默认 type=Rb)
|
||||
opencli douban photos 30382501 --limit 20
|
||||
|
||||
# 下载海报到本地目录
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# 只下载指定 photo_id 的一张图
|
||||
opencli douban download 30382501 --photo-id 2913621075 --output ./douban
|
||||
|
||||
# 返回 JSON,便于上层界面直接渲染图片并右键取图
|
||||
opencli douban photos 30382501 -f json
|
||||
|
||||
# 电影热门
|
||||
opencli douban movie-hot --limit 10
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Douyin (抖音创作者中心)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `creator.douyin.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli douyin profile` | 获取账号信息 |
|
||||
| `opencli douyin videos` | 获取作品列表 |
|
||||
| `opencli douyin drafts` | 获取草稿列表 |
|
||||
| `opencli douyin draft` | 上传视频并保存为草稿 |
|
||||
| `opencli douyin publish` | 定时发布视频到抖音 |
|
||||
| `opencli douyin update` | 更新视频信息 |
|
||||
| `opencli douyin delete` | 删除作品 |
|
||||
| `opencli douyin stats` | 查询作品数据分析 |
|
||||
| `opencli douyin collections` | 获取合集列表 |
|
||||
| `opencli douyin activities` | 获取官方活动列表 |
|
||||
| `opencli douyin location` | 搜索发布可用的地理位置 |
|
||||
| `opencli douyin hashtag search` | 按关键词搜索话题 |
|
||||
| `opencli douyin hashtag suggest` | 基于封面 URI 推荐话题 |
|
||||
| `opencli douyin hashtag hot` | 获取热点词 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# 账号与作品
|
||||
opencli douyin profile
|
||||
opencli douyin videos --limit 10
|
||||
opencli douyin videos --status scheduled
|
||||
opencli douyin drafts
|
||||
|
||||
# 发布前辅助信息
|
||||
opencli douyin collections
|
||||
opencli douyin activities
|
||||
opencli douyin location "东京塔"
|
||||
opencli douyin hashtag search "春游"
|
||||
opencli douyin hashtag hot --limit 10
|
||||
|
||||
# 保存草稿
|
||||
opencli douyin draft ./video.mp4 \
|
||||
--title "春游 vlog" \
|
||||
--caption "#春游 先存草稿"
|
||||
|
||||
# 定时发布
|
||||
opencli douyin publish ./video.mp4 \
|
||||
--title "春游 vlog" \
|
||||
--caption "#春游 今天去看樱花" \
|
||||
--schedule "2026-04-08T12:00:00+09:00"
|
||||
|
||||
# 也支持 Unix 秒字符串
|
||||
opencli douyin publish ./video.mp4 \
|
||||
--title "春游 vlog" \
|
||||
--schedule 1775617200
|
||||
|
||||
# 更新与删除
|
||||
opencli douyin update 1234567890 --caption "更新后的文案"
|
||||
opencli douyin update 1234567890 --reschedule "2026-04-09T20:00:00+09:00"
|
||||
opencli douyin delete 1234567890
|
||||
|
||||
# JSON 输出
|
||||
opencli douyin profile -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** `creator.douyin.com`
|
||||
- The logged-in account must have access to Douyin Creator Center publishing features
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `publish` requires `--schedule` to be at least 2 hours later and no more than 14 days later
|
||||
- `draft` and `publish` upload the video through Douyin/ByteDance browser-authenticated APIs, so cookies in the active browser session must be valid
|
||||
- `hashtag suggest` expects a valid `cover`/`cover_uri` value produced during the publish pipeline; for normal manual use, `hashtag search` and `hashtag hot` are usually more convenient
|
||||
@@ -1,47 +0,0 @@
|
||||
# IMDb
|
||||
|
||||
**Mode**: 🌐 Public (Browser) · **Domain**: `www.imdb.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli imdb search` | Search movies, TV shows, and people |
|
||||
| `opencli imdb title` | Get movie or TV show details |
|
||||
| `opencli imdb top` | IMDb Top 250 Movies |
|
||||
| `opencli imdb trending` | IMDb Most Popular Movies |
|
||||
| `opencli imdb person` | Get actor or director info |
|
||||
| `opencli imdb reviews` | Get user reviews for a title |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Search for a movie
|
||||
opencli imdb search "inception" --limit 10
|
||||
|
||||
# Get movie details
|
||||
opencli imdb title tt1375666
|
||||
|
||||
# Get TV series details (also accepts full URL)
|
||||
opencli imdb title "https://www.imdb.com/title/tt0903747/"
|
||||
|
||||
# Top 250 movies
|
||||
opencli imdb top --limit 20
|
||||
|
||||
# Currently trending movies
|
||||
opencli imdb trending --limit 10
|
||||
|
||||
# Actor/director info with filmography
|
||||
opencli imdb person nm0634240 --limit 5
|
||||
|
||||
# User reviews
|
||||
opencli imdb reviews tt1375666 --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli imdb top --limit 5 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome with Browser Bridge extension installed
|
||||
- No login required (all data is public)
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli jd item <sku>` | Fetch product details (price, shop, specs, AVIF images) |
|
||||
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# Get product details by SKU
|
||||
opencli jd item 100291143898
|
||||
|
||||
# Limit returned AVIF images
|
||||
# Limit detail images
|
||||
opencli jd item 100291143898 --images 5
|
||||
|
||||
# JSON output
|
||||
|
||||
@@ -6,198 +6,37 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli linux-do feed` | Browse topics (site-wide, by tag, or by category) |
|
||||
| `opencli linux-do categories` | List all categories |
|
||||
| `opencli linux-do tags` | List popular tags |
|
||||
| `opencli linux-do search <query>` | Search topics |
|
||||
| `opencli linux-do topic <id>` | View topic posts |
|
||||
| `opencli linux-do user-topics <username>` | Topics created by a user |
|
||||
| `opencli linux-do user-posts <username>` | Replies posted by a user |
|
||||
| `opencli linux-do hot` | 热门话题 |
|
||||
| `opencli linux-do latest` | 最新话题 |
|
||||
| `opencli linux-do categories` | 板块列表 |
|
||||
| `opencli linux-do category` | 板块话题 |
|
||||
| `opencli linux-do search` | 搜索话题 |
|
||||
| `opencli linux-do topic` | 话题详情 |
|
||||
|
||||
## feed
|
||||
|
||||
Browse topic listings. Defaults to latest topics when called with no arguments.
|
||||
|
||||
- Supports filtering by `--tag`, `--category`, or both
|
||||
- `--tag` accepts tag name, slug, or ID
|
||||
- `--category` accepts category name, slug, ID, or `Parent / Child` path for sub-categories
|
||||
- Use `--view` to switch between latest / hot / top
|
||||
|
||||
### Basic
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Latest topics (default)
|
||||
opencli linux-do feed
|
||||
# Hot topics this week
|
||||
opencli linux-do hot --limit 20
|
||||
|
||||
# Hot topics
|
||||
opencli linux-do feed --view hot
|
||||
# Hot topics by period
|
||||
opencli linux-do hot --period daily
|
||||
opencli linux-do hot --period monthly
|
||||
|
||||
# Top topics — default period is weekly
|
||||
opencli linux-do feed --view top
|
||||
opencli linux-do feed --view top --period daily
|
||||
opencli linux-do feed --view top --period monthly
|
||||
# Latest topics
|
||||
opencli linux-do latest --limit 10
|
||||
|
||||
# Sort by views descending
|
||||
opencli linux-do feed --order views
|
||||
# List all categories
|
||||
opencli linux-do categories
|
||||
|
||||
# Sort by created time ascending
|
||||
opencli linux-do feed --order created --ascending
|
||||
# Search topics
|
||||
opencli linux-do search "NixOS"
|
||||
|
||||
# Limit results
|
||||
opencli linux-do feed --limit 10
|
||||
# View topic details
|
||||
opencli linux-do topic 12345
|
||||
|
||||
# JSON output
|
||||
opencli linux-do feed -f json
|
||||
```
|
||||
|
||||
### Filter by tag
|
||||
|
||||
```bash
|
||||
# By tag name, slug, or ID — all equivalent
|
||||
opencli linux-do feed --tag "ChatGPT"
|
||||
opencli linux-do feed --tag chatgpt
|
||||
opencli linux-do feed --tag 3
|
||||
|
||||
# Tag + hot view
|
||||
opencli linux-do feed --tag "ChatGPT" --view hot
|
||||
|
||||
# Tag + top view with period
|
||||
opencli linux-do feed --tag "OpenAI" --view top --period monthly
|
||||
```
|
||||
|
||||
### Filter by category
|
||||
|
||||
Supports both top-level and sub-categories. Sub-categories auto-resolve their parent path.
|
||||
|
||||
```bash
|
||||
# Top-level category — name, slug, or ID
|
||||
opencli linux-do feed --category "开发调优"
|
||||
opencli linux-do feed --category develop
|
||||
opencli linux-do feed --category 4
|
||||
|
||||
# Sub-category
|
||||
opencli linux-do feed --category "开发调优 / Lv1"
|
||||
opencli linux-do feed --category "网盘资源"
|
||||
|
||||
# Category + hot / top view
|
||||
opencli linux-do feed --category "开发调优" --view hot
|
||||
opencli linux-do feed --category "开发调优" --view top --period weekly
|
||||
```
|
||||
|
||||
### Category + tag
|
||||
|
||||
Combine `--category` and `--tag` to narrow results within a category.
|
||||
|
||||
```bash
|
||||
opencli linux-do feed --category "开发调优" --tag "ChatGPT"
|
||||
opencli linux-do feed --category "网盘资源" --tag "OpenAI"
|
||||
opencli linux-do feed --category 94 --tag 4 --view top --period monthly
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `--view V` | `latest`, `hot`, `top` | `latest` |
|
||||
| `--tag VALUE` | Tag name, slug, or ID | — |
|
||||
| `--category VALUE` | Category name, slug, or ID | — |
|
||||
| `--limit N` | Number of results | `20` |
|
||||
| `--order O` | `default`, `created`, `activity`, `views`, `posts`, `category`, `likes`, `op_likes`, `posters` | `default` |
|
||||
| `--ascending` | Sort ascending instead of descending | off |
|
||||
| `--period P` | `all`, `daily`, `weekly`, `monthly`, `quarterly`, `yearly` (only with `--view top`) | `weekly` |
|
||||
|
||||
Output columns: `title`, `replies`, `created`, `likes`, `views`, `url`
|
||||
|
||||
## categories
|
||||
|
||||
List forum categories with optional sub-category expansion.
|
||||
|
||||
```bash
|
||||
opencli linux-do categories
|
||||
opencli linux-do categories --subcategories
|
||||
opencli linux-do categories --limit 50
|
||||
```
|
||||
|
||||
When `--subcategories` is enabled, sub-categories are rendered as `Parent / Child` so the `name` value can be copied directly into `opencli linux-do feed --category ...`.
|
||||
|
||||
Output columns: `name`, `slug`, `id`, `topics`, `description`
|
||||
|
||||
## tags
|
||||
|
||||
List tags sorted by usage count.
|
||||
|
||||
```bash
|
||||
opencli linux-do tags
|
||||
opencli linux-do tags --limit 50
|
||||
```
|
||||
|
||||
Output columns: `rank`, `name`, `count`, `url`
|
||||
|
||||
## search
|
||||
|
||||
Search topics by keyword.
|
||||
|
||||
```bash
|
||||
opencli linux-do search "NixOS"
|
||||
opencli linux-do search "Docker" --limit 10
|
||||
opencli linux-do search "Claude" -f json
|
||||
```
|
||||
|
||||
Output columns: `rank`, `title`, `views`, `likes`, `replies`, `url`
|
||||
|
||||
## topic
|
||||
|
||||
View posts within a topic (first page).
|
||||
|
||||
```bash
|
||||
opencli linux-do topic 1234
|
||||
opencli linux-do topic 1234 --limit 50
|
||||
opencli linux-do topic 1234 --main_only -f json | jq -r '.[0].content'
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--main_only` returns only the main post row and keeps the body untruncated
|
||||
|
||||
Output columns: `author`, `content`, `likes`, `created_at`
|
||||
|
||||
## user-topics
|
||||
|
||||
List topics created by a user.
|
||||
|
||||
```bash
|
||||
opencli linux-do user-topics neo
|
||||
opencli linux-do user-topics neo --limit 10
|
||||
```
|
||||
|
||||
Output columns: `rank`, `title`, `replies`, `created_at`, `likes`, `views`, `url`
|
||||
|
||||
## user-posts
|
||||
|
||||
List replies posted by a user.
|
||||
|
||||
```bash
|
||||
opencli linux-do user-posts neo
|
||||
opencli linux-do user-posts neo --limit 10
|
||||
```
|
||||
|
||||
Output columns: `index`, `topic_user`, `topic`, `reply`, `time`, `url`
|
||||
|
||||
## Compatibility
|
||||
|
||||
The legacy commands below are still available as compatibility wrappers while `feed` becomes the canonical entrypoint:
|
||||
|
||||
```bash
|
||||
opencli linux-do latest
|
||||
opencli linux-do hot --period weekly
|
||||
opencli linux-do category develop 4
|
||||
```
|
||||
|
||||
Preferred modern forms:
|
||||
|
||||
```bash
|
||||
opencli linux-do feed --view latest
|
||||
opencli linux-do feed --view top --period weekly
|
||||
opencli linux-do feed --category 4
|
||||
opencli linux-do hot -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# paperreview.ai
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `paperreview.ai`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli paperreview submit` | Submit a PDF to paperreview.ai for review |
|
||||
| `opencli paperreview review` | Fetch a review by token |
|
||||
| `opencli paperreview feedback` | Send feedback on a completed review |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Validate a local PDF without uploading it
|
||||
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --dry-run true
|
||||
|
||||
# Request an upload slot but stop before the actual upload
|
||||
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --prepare-only true
|
||||
|
||||
# Submit a paper for review
|
||||
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL -f json
|
||||
|
||||
# Check the review status or fetch the final review
|
||||
opencli paperreview review tok_123 -f json
|
||||
|
||||
# Submit feedback on the review quality
|
||||
opencli paperreview feedback tok_123 --helpfulness 4 --critical-error no --actionable-suggestions yes
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public paperreview.ai endpoints
|
||||
- The input file must be a local `.pdf`
|
||||
- paperreview.ai currently rejects files larger than `10MB`
|
||||
- `submit` requires `--email`; `--venue` is optional
|
||||
|
||||
## Notes
|
||||
|
||||
- `submit` returns both the review token and the review URL when submission succeeds
|
||||
- `review` returns `processing` until the paperreview.ai result is ready
|
||||
- `feedback` expects `yes` / `no` values for `--critical-error` and `--actionable-suggestions`
|
||||
@@ -1,92 +0,0 @@
|
||||
# Pixiv
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `www.pixiv.net`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli pixiv ranking` | Daily/weekly/monthly illustration rankings |
|
||||
| `opencli pixiv search <query>` | Search illustrations by keyword or tag |
|
||||
| `opencli pixiv user <uid>` | View artist profile info |
|
||||
| `opencli pixiv illusts <user-id>` | List illustrations by artist |
|
||||
| `opencli pixiv detail <id>` | View illustration details |
|
||||
| `opencli pixiv download <illust-id>` | Download original-quality images |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Ranking
|
||||
|
||||
```bash
|
||||
# Daily rankings (default)
|
||||
opencli pixiv ranking --limit 10
|
||||
|
||||
# Weekly / monthly rankings
|
||||
opencli pixiv ranking --mode weekly
|
||||
opencli pixiv ranking --mode monthly
|
||||
|
||||
# R18 rankings
|
||||
opencli pixiv ranking --mode daily_r18
|
||||
opencli pixiv ranking --mode weekly_r18
|
||||
|
||||
# Other modes: rookie, original, male, female
|
||||
opencli pixiv ranking --mode rookie
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
# Search by keyword or tag
|
||||
opencli pixiv search "初音ミク" --limit 20
|
||||
|
||||
# Filter by content rating
|
||||
opencli pixiv search "風景" --mode safe # Safe-for-work only
|
||||
opencli pixiv search "風景" --mode r18 # R18 only
|
||||
opencli pixiv search "風景" --mode all # All (default)
|
||||
|
||||
# Sort by popularity
|
||||
opencli pixiv search "VOCALOID" --order popular_d
|
||||
|
||||
# All sort options: date_d (newest), date (oldest), popular_d, popular_male_d, popular_female_d
|
||||
|
||||
# Pagination
|
||||
opencli pixiv search "オリジナル" --page 2 --limit 30
|
||||
```
|
||||
|
||||
### User & Illustrations
|
||||
|
||||
```bash
|
||||
# View artist profile
|
||||
opencli pixiv user 11
|
||||
|
||||
# List artist's illustrations (newest first)
|
||||
opencli pixiv illusts 11 --limit 10
|
||||
|
||||
# View illustration details (tags, stats, type)
|
||||
opencli pixiv detail 12345678
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
```bash
|
||||
# Download all images from an illustration
|
||||
opencli pixiv download 12345678
|
||||
|
||||
# Download to a custom directory
|
||||
opencli pixiv download 12345678 --output ./my-images
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
```bash
|
||||
# JSON output
|
||||
opencli pixiv ranking -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli pixiv search "test" -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** pixiv.net
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -1,49 +0,0 @@
|
||||
# Product Hunt
|
||||
|
||||
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `www.producthunt.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli producthunt posts` | Latest Product Hunt launches (optional category filter) |
|
||||
| `opencli producthunt today` | Today's Product Hunt launches (most recent day in feed) |
|
||||
| `opencli producthunt hot` | Today's top Product Hunt launches with vote counts |
|
||||
| `opencli producthunt browse <category>` | Best products in a Product Hunt category |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Today's top launches with vote counts
|
||||
opencli producthunt hot --limit 10
|
||||
|
||||
# Latest posts (RSS feed)
|
||||
opencli producthunt posts --limit 20
|
||||
|
||||
# Filter by category
|
||||
opencli producthunt posts --category developer-tools --limit 10
|
||||
|
||||
# Today's launches only
|
||||
opencli producthunt today --limit 10
|
||||
|
||||
# Browse best products in a category
|
||||
opencli producthunt browse vibe-coding --limit 10
|
||||
opencli producthunt browse ai-agents --limit 10
|
||||
opencli producthunt browse developer-tools --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli producthunt hot -f json
|
||||
```
|
||||
|
||||
## Category Slugs
|
||||
|
||||
Common categories for `browse` and `posts --category`:
|
||||
|
||||
`ai-agents`, `ai-coding-agents`, `ai-code-editors`, `ai-chatbots`, `ai-workflow-automation`,
|
||||
`vibe-coding`, `developer-tools`, `productivity`, `design-creative`, `marketing-sales`,
|
||||
`no-code-platforms`, `llms`, `finance`, `social-community`, `engineering-development`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `posts` and `today` — no browser required (public RSS feed)
|
||||
- `hot` and `browse` — Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -37,12 +37,6 @@
|
||||
# Quick start
|
||||
opencli twitter trending --limit 5
|
||||
|
||||
# Search top tweets (default)
|
||||
opencli twitter search "react 19"
|
||||
|
||||
# Search latest/live tweets
|
||||
opencli twitter search "react 19" --filter live
|
||||
|
||||
# JSON output
|
||||
opencli twitter trending -f json
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
# Xueqiu (雪球)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com` / `danjuanfunds.com`
|
||||
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xueqiu feed` | 获取雪球首页时间线 |
|
||||
| `opencli xueqiu earnings-date` | 获取股票预计财报发布日期 |
|
||||
| `opencli xueqiu hot-stock` | 获取雪球热门股票榜 |
|
||||
| `opencli xueqiu hot` | 获取雪球热门动态 |
|
||||
| `opencli xueqiu search` | 搜索雪球股票(代码或名称) |
|
||||
| `opencli xueqiu stock` | 获取雪球股票实时行情 |
|
||||
| `opencli xueqiu watchlist` | 获取雪球自选股列表 |
|
||||
| `opencli xueqiu fund-holdings` | 获取蛋卷基金持仓明细(可用 `--account` 按子账户过滤) |
|
||||
| `opencli xueqiu fund-snapshot` | 获取蛋卷基金快照(总资产、子账户、持仓,推荐 `-f json`) |
|
||||
| `opencli xueqiu feed` | |
|
||||
| `opencli xueqiu earnings-date` | |
|
||||
| `opencli xueqiu hot-stock` | |
|
||||
| `opencli xueqiu hot` | |
|
||||
| `opencli xueqiu search` | |
|
||||
| `opencli xueqiu stock` | |
|
||||
| `opencli xueqiu watchlist` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -31,15 +29,6 @@ opencli xueqiu stock SH600519
|
||||
# Upcoming earnings dates
|
||||
opencli xueqiu earnings-date SH600519 --next
|
||||
|
||||
# Danjuan all holdings
|
||||
opencli xueqiu fund-holdings
|
||||
|
||||
# Filter one Danjuan sub-account
|
||||
opencli xueqiu fund-holdings --account 默认账户
|
||||
|
||||
# Full Danjuan snapshot as JSON
|
||||
opencli xueqiu fund-snapshot -f json
|
||||
|
||||
# JSON output
|
||||
opencli xueqiu feed -f json
|
||||
|
||||
@@ -49,12 +38,5 @@ opencli xueqiu feed -v
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** `xueqiu.com`
|
||||
- For fund commands, Chrome must also be logged into `danjuanfunds.com` and able to open `https://danjuanfunds.com/my-money`
|
||||
- Chrome running and **logged into** xueqiu.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `fund-holdings` exposes both market value and share fields (`volume`, `usableRemainShare`)
|
||||
- `fund-snapshot -f json` is the easiest way to persist a full account snapshot for later analysis or diffing
|
||||
- If the commands return empty data, first confirm the logged-in browser can directly see the Danjuan asset page
|
||||
|
||||
@@ -14,13 +14,8 @@ The current built-in commands use native AppleScript automation — no extra lau
|
||||
- `opencli chatgpt status`: Check if the ChatGPT app is currently running.
|
||||
- `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
|
||||
- `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
|
||||
- `opencli chatgpt send "message" --model thinking`: Switch model/mode first, then send the message.
|
||||
- `opencli chatgpt read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree.
|
||||
- `opencli chatgpt ask "message"`: Send a prompt and wait for the visible reply in one shot.
|
||||
- `opencli chatgpt ask "message" --model instant`: Run a one-shot prompt using a specific model/mode.
|
||||
- `opencli chatgpt model thinking`: Switch the active ChatGPT model/mode without sending a message.
|
||||
|
||||
Supported model choices: `auto`, `instant`, `thinking`, `5.2-instant`, `5.2-thinking`.
|
||||
|
||||
## Approach 2: CDP (Advanced, Electron Debug Mode)
|
||||
|
||||
|
||||
+3
-13
@@ -11,7 +11,7 @@ Run `opencli list` for the live registry.
|
||||
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 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` | 🌐 / 🔐 |
|
||||
@@ -25,26 +25,18 @@ Run `opencli list` for the live registry.
|
||||
| **[jike](/adapters/browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
|
||||
| **[jimeng](/adapters/browser/jimeng)** | `generate` `history` | 🔐 Browser |
|
||||
| **[yollomi](/adapters/browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
|
||||
| **[linux-do](/adapters/browser/linux-do)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 Browser |
|
||||
| **[linux-do](/adapters/browser/linux-do)** | `hot` `latest` `categories` `category` `search` `topic` | 🔐 Browser |
|
||||
| **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
|
||||
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
|
||||
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
|
||||
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
|
||||
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 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 |
|
||||
| **[imdb](/adapters/browser/imdb)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 |
|
||||
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
|
||||
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
|
||||
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
|
||||
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
|
||||
| **[pixiv](/adapters/browser/pixiv)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser |
|
||||
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
|
||||
| **[google](/adapters/browser/google)** | `news` `search` `suggest` `trends` | 🌐 / 🔐 |
|
||||
| **[jd](/adapters/browser/jd)** | `item` | 🔐 Browser |
|
||||
| **[web](/adapters/browser/web)** | `read` | 🔐 Browser |
|
||||
| **[weixin](/adapters/browser/weixin)** | `download` | 🔐 Browser |
|
||||
| **[36kr](/adapters/browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 |
|
||||
| **[producthunt](/adapters/browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 |
|
||||
|
||||
## Public API Adapters
|
||||
|
||||
@@ -58,14 +50,12 @@ Run `opencli list` for the live registry.
|
||||
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
| **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public |
|
||||
| **[paperreview](/adapters/browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
|
||||
| **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
|
||||
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
|
||||
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
|
||||
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
|
||||
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
|
||||
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
|
||||
| **[steam](/adapters/browser/steam)** | `top-sellers` | 🌐 Public |
|
||||
|
||||
## Desktop Adapters
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **douban** | Images | Downloads poster / still image lists from movie subjects |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
|
||||
@@ -40,9 +39,6 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Download Douban posters / stills
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Rate Limiter Plugin
|
||||
|
||||
An optional plugin that adds a random sleep between browser-based commands to reduce the risk of platform rate-limiting or bot detection.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
opencli plugin install github:jackwener/opencli-plugin-rate-limiter
|
||||
```
|
||||
|
||||
Or copy the example below into `~/.opencli/plugins/rate-limiter/` to use it locally without installing from GitHub.
|
||||
|
||||
## What it does
|
||||
|
||||
After every command targeting a browser platform (xiaohongshu, weibo, bilibili, douyin, tiktok, …), the plugin sleeps for a random duration — 5–30 seconds by default — before returning control to the caller.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OPENCLI_RATE_MIN` | `5` | Minimum sleep in seconds |
|
||||
| `OPENCLI_RATE_MAX` | `30` | Maximum sleep in seconds |
|
||||
| `OPENCLI_NO_RATE` | — | Set to `1` to disable entirely (local dev) |
|
||||
|
||||
```bash
|
||||
# Shorter delays for light scraping
|
||||
OPENCLI_RATE_MIN=3 OPENCLI_RATE_MAX=10 opencli xiaohongshu search "AI眼镜"
|
||||
|
||||
# Skip delays when iterating locally
|
||||
OPENCLI_NO_RATE=1 opencli bilibili comments BV1WtAGzYEBm
|
||||
```
|
||||
|
||||
## Local installation (without GitHub)
|
||||
|
||||
1. Create the plugin directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.opencli/plugins/rate-limiter
|
||||
```
|
||||
|
||||
2. Create `~/.opencli/plugins/rate-limiter/package.json`:
|
||||
|
||||
```json
|
||||
{ "type": "module" }
|
||||
```
|
||||
|
||||
3. Create `~/.opencli/plugins/rate-limiter/index.js`:
|
||||
|
||||
```js
|
||||
import { onAfterExecute } from '@jackwener/opencli/hooks'
|
||||
|
||||
const BROWSER_DOMAINS = [
|
||||
'xiaohongshu', 'weibo', 'bilibili', 'douyin', 'tiktok',
|
||||
'instagram', 'twitter', 'youtube', 'zhihu', 'douban',
|
||||
'jike', 'weixin', 'xiaoyuzhou',
|
||||
]
|
||||
|
||||
onAfterExecute(async (ctx) => {
|
||||
if (process.env.OPENCLI_NO_RATE === '1') return
|
||||
|
||||
const site = ctx.command?.split('/')?.[0] ?? ''
|
||||
if (!BROWSER_DOMAINS.includes(site)) return
|
||||
|
||||
const min = Number(process.env.OPENCLI_RATE_MIN ?? 5)
|
||||
const max = Number(process.env.OPENCLI_RATE_MAX ?? 30)
|
||||
const ms = Math.floor(Math.random() * (max - min + 1) + min) * 1000
|
||||
|
||||
process.stderr.write(`[rate-limiter] ${site}: sleeping ${(ms / 1000).toFixed(0)}s\n`)
|
||||
await new Promise(r => setTimeout(r, ms))
|
||||
})
|
||||
```
|
||||
|
||||
4. Verify it loaded:
|
||||
|
||||
```bash
|
||||
OPENCLI_NO_RATE=1 opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
|
||||
# → (no output — plugin loaded but rate limit skipped)
|
||||
|
||||
opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
|
||||
# → [rate-limiter] xiaohongshu: sleeping 12s
|
||||
```
|
||||
|
||||
## Writing your own plugin
|
||||
|
||||
Plugins are plain JS/TS files in `~/.opencli/plugins/<name>/`. A plugin file must export a hook registration call that matches the pattern `onStartup(`, `onBeforeExecute(`, or `onAfterExecute(` — opencli's discovery engine uses this pattern to identify hook files vs. command files.
|
||||
|
||||
```js
|
||||
// ~/.opencli/plugins/my-plugin/index.js
|
||||
import { onAfterExecute } from '@jackwener/opencli/hooks'
|
||||
|
||||
onAfterExecute(async (ctx) => {
|
||||
// ctx.command — e.g. "bilibili/comments"
|
||||
// ctx.args — coerced command arguments
|
||||
// ctx.error — set if the command threw
|
||||
console.error(`[my-plugin] finished: ${ctx.command}`)
|
||||
})
|
||||
```
|
||||
|
||||
See [hooks.ts](../../src/hooks.ts) for the full `HookContext` type.
|
||||
@@ -28,12 +28,6 @@ npm link
|
||||
|
||||
This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows.
|
||||
|
||||
Before you start:
|
||||
|
||||
- Prefer positional args for the command's primary subject (`search <query>`, `topic <id>`, `download <url>`). Reserve named flags for optional modifiers such as `--limit`, `--sort`, `--lang`, and `--output`.
|
||||
- Normalize expected adapter failures to `CliError` subclasses instead of raw `Error` whenever possible. Prefer `AuthRequiredError`, `EmptyResultError`, `CommandExecutionError`, `TimeoutError`, and `ArgumentError` so the top-level CLI can render better messages and hints.
|
||||
- If you add a new adapter or make a command newly discoverable, update the matching doc page and the user-facing indexes that expose it.
|
||||
|
||||
### YAML Adapter (Recommended for data-fetching commands)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.yaml`:
|
||||
@@ -77,7 +71,6 @@ Create a file like `src/clis/<site>/<command>.ts`:
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
@@ -94,8 +87,6 @@ cli({
|
||||
func: async (page, kwargs) => {
|
||||
const { query, limit = 10 } = kwargs;
|
||||
// ... browser automation logic
|
||||
if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
|
||||
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
@@ -119,7 +110,6 @@ opencli <site> <command> -v # Verbose mode for debugging
|
||||
- **ES Modules** — use `.js` extensions in imports (TypeScript output).
|
||||
- **Naming**: `kebab-case` for files, `camelCase` for variables/functions, `PascalCase` for types/classes.
|
||||
- **No default exports** — use named exports.
|
||||
- **Errors** — throw `CliError` subclasses for expected adapter failures; avoid raw `Error` for normal adapter control flow.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -146,10 +136,3 @@ chore: bump vitest to v4
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
5. Push and open a PR
|
||||
|
||||
If your PR adds a new adapter or changes user-facing commands, also verify:
|
||||
|
||||
- Adapter docs exist under `docs/adapters/`
|
||||
- `docs/adapters/index.md` is updated for new adapters
|
||||
- VitePress sidebar includes the new doc page
|
||||
- `README.md` / `README.zh-CN.md` stay aligned when command discoverability changes
|
||||
|
||||
@@ -6,7 +6,6 @@ Use TypeScript adapters when you need browser-side logic, multi-step flows, DOM
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
@@ -35,9 +34,6 @@ cli({
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
|
||||
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
|
||||
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
@@ -73,20 +69,6 @@ Contains parsed CLI arguments as key-value pairs. Always destructure with defaul
|
||||
const { query, limit = 10, format = 'json' } = kwargs;
|
||||
```
|
||||
|
||||
For most search/read/detail commands, the main subject should be positional (`opencli mysite search "rust"`, `opencli mysite article 123`) instead of a named flag such as `--query` or `--id`. Keep named flags for optional modifiers.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Prefer throwing `CliError` subclasses from `src/errors.ts` for expected adapter failures:
|
||||
|
||||
- `AuthRequiredError` for missing login / cookies
|
||||
- `EmptyResultError` for empty but valid responses
|
||||
- `CommandExecutionError` for unexpected API or browser failures
|
||||
- `TimeoutError` for site timeouts
|
||||
- `ArgumentError` for invalid user input
|
||||
|
||||
Avoid raw `Error` for normal adapter control flow. This keeps top-level CLI output consistent and preserves hints for users.
|
||||
|
||||
## AI-Assisted Development
|
||||
|
||||
Use the AI workflow tools to accelerate adapter creation:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
YAML adapters are the recommended way to add new commands when the site offers a straightforward API. They use a declarative pipeline approach — no TypeScript required.
|
||||
|
||||
Use YAML only when the command stays mostly declarative. If you find yourself embedding long JavaScript expressions, many fallbacks, or multi-step browser logic, move the command to a TypeScript adapter instead of growing an opaque template blob.
|
||||
|
||||
## Basic Structure
|
||||
|
||||
::: v-pre
|
||||
@@ -35,14 +33,6 @@ columns: [rank, title, score, url]
|
||||
```
|
||||
:::
|
||||
|
||||
For most commands, keep the primary subject positional. Good examples:
|
||||
|
||||
- `opencli mysite search "rust"`
|
||||
- `opencli mysite topic 123`
|
||||
- `opencli mysite download "https://example.com/post/1"`
|
||||
|
||||
Prefer named flags only for optional modifiers such as `--limit`, `--sort`, `--lang`, or `--output`.
|
||||
|
||||
## Pipeline Steps
|
||||
|
||||
### `fetch`
|
||||
@@ -116,9 +106,3 @@ Use `${{ ... }}` for dynamic values:
|
||||
## Real Example
|
||||
|
||||
See [`src/clis/hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/src/clis/hackernews/top.yaml).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Add fallbacks for optional fields in `map` expressions when upstream payloads may be sparse.
|
||||
- Keep template expressions short and readable. If the expression starts looking like a mini program, switch to TypeScript.
|
||||
- If you add a new adapter, also add the matching doc page plus index/sidebar entries so `doc-coverage` stays green.
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
---
|
||||
description: How to turn a new Electron desktop app into an OpenCLI adapter
|
||||
---
|
||||
|
||||
# Add a New Electron App CLI
|
||||
|
||||
This guide is the **fast entry point** for turning a new Electron desktop application into an OpenCLI adapter.
|
||||
|
||||
If you want the full background and deeper SOP, read:
|
||||
- [CLI-ifying Electron Applications](/advanced/electron)
|
||||
- [Chrome DevTools Protocol](/advanced/cdp)
|
||||
- [TypeScript Adapter Guide](/developer/ts-adapter)
|
||||
|
||||
## When to use this guide
|
||||
|
||||
Use this workflow when the target app:
|
||||
- is built with **Electron**, or at least exposes a working **Chrome DevTools Protocol (CDP)** endpoint
|
||||
- can be launched with `--remote-debugging-port=<port>`
|
||||
- should be automated through its real UI instead of a public HTTP API
|
||||
|
||||
If the app is **not** Electron and does **not** expose CDP, use the native desktop automation pattern instead. See [CLI-ifying Electron Applications](/advanced/electron#non-electron-pattern-applescript).
|
||||
|
||||
## The shortest path
|
||||
|
||||
### 1. Confirm the app is Electron
|
||||
|
||||
Typical macOS check:
|
||||
|
||||
```bash
|
||||
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
|
||||
```
|
||||
|
||||
If Electron is present, the next step is usually to launch the app with a debugging port.
|
||||
|
||||
### 2. Launch it with CDP enabled
|
||||
|
||||
```bash
|
||||
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
Then point OpenCLI at that CDP endpoint:
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
### 3. Start with the 5-command pattern
|
||||
|
||||
For a new Electron adapter, implement these commands first in `src/clis/<app>/`:
|
||||
|
||||
- `status.ts` — verify the app is reachable through CDP
|
||||
- `dump.ts` — inspect DOM and snapshot structure before guessing selectors
|
||||
- `read.ts` — extract the visible context you actually need
|
||||
- `send.ts` — inject text and submit through the real editor
|
||||
- `new.ts` — create a new session, tab, thread, or document
|
||||
|
||||
This is the standard baseline because it gives you:
|
||||
- a connection check
|
||||
- a reverse-engineering tool
|
||||
- one read path
|
||||
- one write path
|
||||
- one session reset path
|
||||
|
||||
The full rationale and examples are in [CLI-ifying Electron Applications](/advanced/electron).
|
||||
|
||||
## Recommended implementation workflow
|
||||
|
||||
### Step 1: Build `status`
|
||||
|
||||
Goal: prove CDP connectivity before touching app-specific logic.
|
||||
|
||||
Typical checks:
|
||||
- current URL
|
||||
- document title
|
||||
- app shell presence
|
||||
|
||||
If `status` is unstable, stop there and fix connectivity first.
|
||||
|
||||
### Step 2: Build `dump`
|
||||
|
||||
Do **not** guess selectors from the rendered UI.
|
||||
|
||||
Dump:
|
||||
- `document.body.innerHTML`
|
||||
- accessibility snapshot
|
||||
- any stable attributes such as `data-testid`, `role`, `aria-*`, framework-specific markers
|
||||
|
||||
Use the dump to identify real containers, buttons, composers, and conversation regions.
|
||||
|
||||
### Step 3: Build `read`
|
||||
|
||||
Target only the app region that matters.
|
||||
|
||||
Good targets:
|
||||
- message list
|
||||
- editor history
|
||||
- visible thread content
|
||||
- selected document panel
|
||||
|
||||
Avoid dumping the entire page text into the final command output.
|
||||
|
||||
### Step 4: Build `send`
|
||||
|
||||
Most Electron apps use React-style controlled editors, so direct `.value = ...` assignments are often ignored.
|
||||
|
||||
Prefer editor-aware input patterns such as:
|
||||
- focus the editable region
|
||||
- use `document.execCommand('insertText', false, text)` when applicable
|
||||
- use real key presses like `Enter`, `Meta+Enter`, or app-specific shortcuts
|
||||
|
||||
### Step 5: Build `new`
|
||||
|
||||
Many desktop apps rely on keyboard shortcuts for “new chat”, “new tab”, or “new note”.
|
||||
|
||||
Typical pattern:
|
||||
|
||||
```ts
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
```
|
||||
|
||||
## Where to put files
|
||||
|
||||
For a TypeScript desktop adapter, the usual layout is:
|
||||
|
||||
```text
|
||||
src/clis/<app>/status.ts
|
||||
src/clis/<app>/dump.ts
|
||||
src/clis/<app>/read.ts
|
||||
src/clis/<app>/send.ts
|
||||
src/clis/<app>/new.ts
|
||||
src/clis/<app>/utils.ts
|
||||
```
|
||||
|
||||
If the app grows beyond the baseline, add higher-level commands such as:
|
||||
- `ask`
|
||||
- `history`
|
||||
- `model`
|
||||
- `screenshot`
|
||||
- `export`
|
||||
|
||||
## What to document when you add a new app
|
||||
|
||||
When the adapter is ready, also add:
|
||||
|
||||
- an adapter doc under `docs/adapters/desktop/`
|
||||
- command list and examples
|
||||
- launch instructions with `--remote-debugging-port`
|
||||
- any required environment variables
|
||||
- platform-specific caveats
|
||||
|
||||
Examples to study:
|
||||
- `docs/adapters/desktop/codex.md`
|
||||
- `docs/adapters/desktop/chatwise.md`
|
||||
- `docs/adapters/desktop/notion.md`
|
||||
- `docs/adapters/desktop/discord.md`
|
||||
|
||||
## Common failure modes
|
||||
|
||||
### CDP endpoint exists, but commands are flaky
|
||||
|
||||
Usually one of these:
|
||||
- the wrong window/tab is selected
|
||||
- the app has not finished rendering
|
||||
- selectors were guessed instead of discovered from `dump`
|
||||
- the editor is controlled and ignores direct value assignment
|
||||
|
||||
### The app is Chromium-based but not truly controllable
|
||||
|
||||
Some desktop apps embed Chromium but do not expose a usable CDP surface.
|
||||
In that case, switch to the non-Electron desktop automation approach instead of forcing the Electron pattern.
|
||||
|
||||
### You already have a browser workflow and wonder whether to reuse it
|
||||
|
||||
If the app exposes a normal web URL and the browser flow is enough, a browser adapter is usually simpler.
|
||||
Use an Electron adapter only when the desktop app is the real integration surface.
|
||||
|
||||
## Recommended reading order
|
||||
|
||||
If you are starting from zero:
|
||||
|
||||
1. This page
|
||||
2. [CLI-ifying Electron Applications](/advanced/electron)
|
||||
3. [Chrome DevTools Protocol](/advanced/cdp)
|
||||
4. [TypeScript Adapter Guide](/developer/ts-adapter)
|
||||
5. One concrete desktop adapter doc under `docs/adapters/desktop/`
|
||||
|
||||
## Practical rule
|
||||
|
||||
Do not start with a large feature surface.
|
||||
|
||||
Start with:
|
||||
- `status`
|
||||
- `dump`
|
||||
- `read`
|
||||
- `send`
|
||||
- `new`
|
||||
|
||||
Once those are stable, extend outward.
|
||||
@@ -55,4 +55,3 @@ opencli bilibili hot -v # Verbose: show pipeline debug
|
||||
- [Plugins — extend with community adapters](/guide/plugins)
|
||||
- [All available adapters](/adapters/)
|
||||
- [For developers / AI agents](/developer/contributing)
|
||||
- [Add a new Electron app CLI](/guide/electron-app-cli)
|
||||
|
||||
@@ -11,12 +11,6 @@ opencli plugin install github:ByteYue/opencli-plugin-github-trending
|
||||
# List installed plugins
|
||||
opencli plugin list
|
||||
|
||||
# Update one plugin
|
||||
opencli plugin update github-trending
|
||||
|
||||
# Update all installed plugins
|
||||
opencli plugin update --all
|
||||
|
||||
# Use the plugin (it's just a regular command)
|
||||
opencli github-trending repos --limit 10
|
||||
|
||||
@@ -32,102 +26,11 @@ Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at st
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/repo
|
||||
opencli plugin install github:user/repo/subplugin # install specific sub-plugin from monorepo
|
||||
opencli plugin install https://github.com/user/repo
|
||||
```
|
||||
|
||||
The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
|
||||
|
||||
## Plugin Manifest (`opencli-plugin.json`)
|
||||
|
||||
Plugins can include an `opencli-plugin.json` manifest file at the repo root to declare metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"opencli": ">=1.0.0",
|
||||
"description": "My awesome plugin"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Plugin name (overrides repo-derived name) |
|
||||
| `version` | Semantic version |
|
||||
| `opencli` | Required opencli version range (e.g. `>=1.0.0`, `^1.2.0`) |
|
||||
| `description` | Human-readable description |
|
||||
| `plugins` | Monorepo sub-plugin declarations (see below) |
|
||||
|
||||
The manifest is optional — plugins without one continue to work exactly as before.
|
||||
|
||||
## Monorepo Plugins
|
||||
|
||||
A single repository can contain multiple plugins by declaring a `plugins` field in `opencli-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"opencli": ">=1.0.0",
|
||||
"description": "My plugin collection",
|
||||
"plugins": {
|
||||
"polymarket": {
|
||||
"path": "packages/polymarket",
|
||||
"description": "Prediction market analysis",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"defi": {
|
||||
"path": "packages/defi",
|
||||
"description": "DeFi protocol data",
|
||||
"version": "0.8.0",
|
||||
"opencli": ">=1.2.0"
|
||||
},
|
||||
"experimental": {
|
||||
"path": "packages/experimental",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Installing
|
||||
|
||||
```bash
|
||||
# Install ALL enabled sub-plugins from a monorepo
|
||||
opencli plugin install github:user/opencli-plugins
|
||||
|
||||
# Install a SPECIFIC sub-plugin
|
||||
opencli plugin install github:user/opencli-plugins/polymarket
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
- The monorepo is cloned once to `~/.opencli/monorepos/<repo>/`
|
||||
- Each sub-plugin gets a symlink in `~/.opencli/plugins/<name>/` pointing to its subdirectory
|
||||
- Command discovery works transparently — symlinks are scanned just like regular directories
|
||||
- Disabled sub-plugins (with `"disabled": true`) are skipped during install
|
||||
- Sub-plugins can specify their own `opencli` compatibility range
|
||||
|
||||
### Updating
|
||||
|
||||
Updating any sub-plugin from a monorepo pulls the entire repo and refreshes all sub-plugins:
|
||||
|
||||
```bash
|
||||
opencli plugin update polymarket # updates the monorepo, refreshes all
|
||||
```
|
||||
|
||||
### Uninstalling
|
||||
|
||||
```bash
|
||||
opencli plugin uninstall polymarket # removes just this sub-plugin's symlink
|
||||
```
|
||||
|
||||
When the last sub-plugin from a monorepo is uninstalled, the monorepo clone is automatically cleaned up.
|
||||
|
||||
## Version Tracking
|
||||
|
||||
OpenCLI records installed plugin versions in `~/.opencli/plugins.lock.json`. Each entry stores the plugin source, current git commit hash, install time, and last update time. `opencli plugin list` shows the short commit hash when version metadata is available.
|
||||
|
||||
## Creating a Plugin
|
||||
|
||||
### Option 1: YAML Plugin (Simplest)
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# 给新 Electron 应用生成 CLI
|
||||
|
||||
这篇文档是把一个新的 Electron 桌面应用接入 OpenCLI 的**中文入口指南**。
|
||||
|
||||
如果你需要更完整的背景和标准流程,继续看:
|
||||
- [Chrome DevTools Protocol(中文)](/zh/advanced/cdp)
|
||||
- [CLI-ifying Electron Applications(英文深度版)](/advanced/electron)
|
||||
- [TypeScript 适配器开发指南(英文)](/developer/ts-adapter)
|
||||
|
||||
## 这篇文档适合什么场景
|
||||
|
||||
当目标应用满足下面条件时,用这套流程:
|
||||
- 应用是 **Electron**,或者至少能暴露可用的 **CDP(Chrome DevTools Protocol)** 端口
|
||||
- 可以通过 `--remote-debugging-port=<port>` 启动
|
||||
- 你希望控制的是桌面应用本身,而不是它背后的公开 HTTP API
|
||||
|
||||
如果应用**不是** Electron,或者不暴露 CDP,就不要硬套这套方案。那种情况应改用原生桌面自动化方案。可参考 [英文版说明](/advanced/electron#non-electron-pattern-applescript)。
|
||||
|
||||
## 最短落地路径
|
||||
|
||||
### 1. 先确认它是不是 Electron
|
||||
|
||||
macOS 下常见检查方式:
|
||||
|
||||
```bash
|
||||
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
|
||||
```
|
||||
|
||||
如果存在,通常就可以继续尝试 CDP。
|
||||
|
||||
### 2. 带 CDP 端口启动应用
|
||||
|
||||
```bash
|
||||
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
然后把 OpenCLI 指到这个端口:
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
### 3. 先做 5 个基础命令
|
||||
|
||||
建议一个新 Electron 适配器先实现这 5 个命令:
|
||||
|
||||
- `status.ts` —— 确认 CDP 连通
|
||||
- `dump.ts` —— 导出 DOM / snapshot,先做逆向再写逻辑
|
||||
- `read.ts` —— 读取当前上下文
|
||||
- `send.ts` —— 往真实编辑器里输入并发送
|
||||
- `new.ts` —— 新建会话 / 标签页 / 文档
|
||||
|
||||
这是最稳妥的基线,因为它先把“能连上、能看见、能读、能写、能重置状态”这 5 件核心事情打通了。
|
||||
|
||||
## 推荐开发顺序
|
||||
|
||||
### 第一步:先做 `status`
|
||||
|
||||
目标不是功能,而是先证明:
|
||||
- CDP 真的连上了
|
||||
- 你连到的是对的窗口/标签页
|
||||
- 应用当前页面确实可读
|
||||
|
||||
如果 `status` 都不稳定,先不要继续往下做。
|
||||
|
||||
### 第二步:做 `dump`
|
||||
|
||||
**不要猜 selector。**
|
||||
|
||||
先把这些导出来:
|
||||
- `document.body.innerHTML`
|
||||
- accessibility snapshot
|
||||
- 稳定属性:`data-testid`、`role`、`aria-*` 等
|
||||
|
||||
然后再决定:
|
||||
- 消息列表在哪
|
||||
- 输入框在哪
|
||||
- 按钮在哪
|
||||
- 当前会话容器在哪
|
||||
|
||||
### 第三步:做 `read`
|
||||
|
||||
只读真正需要的区域,不要把整个页面文本都塞出来。
|
||||
|
||||
常见目标:
|
||||
- 对话消息区
|
||||
- 当前线程内容
|
||||
- 当前编辑器历史
|
||||
- 当前文档主区域
|
||||
|
||||
### 第四步:做 `send`
|
||||
|
||||
很多 Electron 应用的输入框是 React 控制组件,直接改 `.value` 往往没用。
|
||||
|
||||
更稳妥的方式通常是:
|
||||
- 先 focus 到可编辑区域
|
||||
- 能用时优先 `document.execCommand('insertText', false, text)`
|
||||
- 最后用真实按键提交,比如 `Enter`、`Meta+Enter`
|
||||
|
||||
### 第五步:做 `new`
|
||||
|
||||
很多桌面应用的新建动作其实更适合走快捷键,而不是点按钮。
|
||||
|
||||
典型模式:
|
||||
|
||||
```ts
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
```
|
||||
|
||||
## 文件一般怎么放
|
||||
|
||||
一个 TypeScript 桌面适配器,通常结构是:
|
||||
|
||||
```text
|
||||
src/clis/<app>/status.ts
|
||||
src/clis/<app>/dump.ts
|
||||
src/clis/<app>/read.ts
|
||||
src/clis/<app>/send.ts
|
||||
src/clis/<app>/new.ts
|
||||
src/clis/<app>/utils.ts
|
||||
```
|
||||
|
||||
当基础能力稳定后,再继续加:
|
||||
- `ask`
|
||||
- `history`
|
||||
- `model`
|
||||
- `screenshot`
|
||||
- `export`
|
||||
|
||||
## 加完适配器后,还应该补什么文档
|
||||
|
||||
至少补这几项:
|
||||
- `docs/adapters/desktop/` 下的适配器说明页
|
||||
- 命令列表和示例
|
||||
- 如何带 `--remote-debugging-port` 启动
|
||||
- 需要哪些环境变量
|
||||
- 平台限制和注意事项
|
||||
|
||||
可以参考这些现成文档:
|
||||
- `docs/adapters/desktop/codex.md`
|
||||
- `docs/adapters/desktop/chatwise.md`
|
||||
- `docs/adapters/desktop/notion.md`
|
||||
- `docs/adapters/desktop/discord.md`
|
||||
|
||||
## 常见问题
|
||||
|
||||
### CDP 能连,但命令不稳定
|
||||
|
||||
常见原因:
|
||||
- 连错窗口或标签页
|
||||
- 页面还没渲染完
|
||||
- selector 是猜的,不是从 `dump` 里找出来的
|
||||
- 输入框是受控组件,直接赋值不生效
|
||||
|
||||
### 应用看起来像 Chromium,但就是不好控
|
||||
|
||||
有些桌面应用虽然嵌了 Chromium,但并不真正暴露可用的 CDP 接口。
|
||||
这种情况不要强行走 Electron 方案,应该换到非 Electron 的桌面自动化方案。
|
||||
|
||||
### 这个应用其实也有网页版本,还要不要做 Electron 适配器
|
||||
|
||||
如果网页版本已经足够稳定,浏览器适配器通常更简单。
|
||||
只有当**桌面应用才是真正的集成面**时,再优先做 Electron 适配器。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
如果你从零开始:
|
||||
|
||||
1. 先看这篇
|
||||
2. 再看 [CLI-ifying Electron Applications(英文深度版)](/advanced/electron)
|
||||
3. 再看 [Chrome DevTools Protocol(中文)](/zh/advanced/cdp)
|
||||
4. 再看 [TypeScript Adapter Guide(英文)](/developer/ts-adapter)
|
||||
5. 最后找一个现成桌面适配器文档照着做
|
||||
|
||||
## 最后一个实践建议
|
||||
|
||||
不要一上来就做很大的命令面。
|
||||
|
||||
先把下面 5 个做稳:
|
||||
- `status`
|
||||
- `dump`
|
||||
- `read`
|
||||
- `send`
|
||||
- `new`
|
||||
|
||||
这 5 个稳定了,再往外扩,成本最低,返工也最少。
|
||||
@@ -38,4 +38,3 @@ opencli bilibili hot -f csv # CSV
|
||||
- [Browser Bridge 设置](/zh/guide/browser-bridge)
|
||||
- [所有适配器](/zh/adapters/)
|
||||
- [开发者指南](/zh/developer/contributing)
|
||||
- [给新 Electron 应用生成 CLI](/zh/guide/electron-app-cli)
|
||||
|
||||
@@ -11,12 +11,6 @@ opencli plugin install github:ByteYue/opencli-plugin-github-trending
|
||||
# 列出已安装插件
|
||||
opencli plugin list
|
||||
|
||||
# 更新单个插件
|
||||
opencli plugin update github-trending
|
||||
|
||||
# 更新全部已安装插件
|
||||
opencli plugin update --all
|
||||
|
||||
# 使用插件(本质上就是普通 command)
|
||||
opencli github-trending today
|
||||
|
||||
@@ -32,80 +26,11 @@ Plugins 存放在 `~/.opencli/plugins/<name>/`。每个子目录都会在启动
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/repo
|
||||
opencli plugin install github:user/repo/subplugin # 安装 monorepo 中的指定子插件
|
||||
opencli plugin install https://github.com/user/repo
|
||||
```
|
||||
|
||||
如果仓库名带 `opencli-plugin-` 前缀,本地目录会自动去掉这个前缀。例如 `opencli-plugin-hot-digest` 会变成 `hot-digest`。
|
||||
|
||||
## 插件清单 (`opencli-plugin.json`)
|
||||
|
||||
插件可以在仓库根目录放置 `opencli-plugin.json` 来声明元数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"opencli": ">=1.0.0",
|
||||
"description": "我的插件"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 插件名称(覆盖从仓库名推导的名称) |
|
||||
| `version` | 语义化版本 |
|
||||
| `opencli` | 所需的 opencli 版本范围(如 `>=1.0.0`、`^1.2.0`) |
|
||||
| `description` | 描述 |
|
||||
| `plugins` | Monorepo 子插件声明(见下文) |
|
||||
|
||||
清单文件是可选的——没有它的插件依然可以正常工作。
|
||||
|
||||
## Monorepo 插件
|
||||
|
||||
一个仓库可以通过在 `opencli-plugin.json` 中声明 `plugins` 字段来包含多个插件:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"opencli": ">=1.0.0",
|
||||
"description": "我的插件合集",
|
||||
"plugins": {
|
||||
"polymarket": {
|
||||
"path": "packages/polymarket",
|
||||
"description": "预测市场分析",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"defi": {
|
||||
"path": "packages/defi",
|
||||
"description": "DeFi 协议数据",
|
||||
"version": "0.8.0"
|
||||
},
|
||||
"experimental": {
|
||||
"path": "packages/experimental",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 安装 monorepo 中的全部子插件
|
||||
opencli plugin install github:user/opencli-plugins
|
||||
|
||||
# 安装指定子插件
|
||||
opencli plugin install github:user/opencli-plugins/polymarket
|
||||
```
|
||||
|
||||
- Monorepo 只 clone 一次到 `~/.opencli/monorepos/<repo>/`
|
||||
- 每个子插件通过 symlink 出现在 `~/.opencli/plugins/<name>/`
|
||||
- 更新任何子插件会拉取整个 monorepo 并刷新所有子插件
|
||||
- 卸载最后一个子插件时,monorepo 目录会被自动清理
|
||||
|
||||
## 版本追踪
|
||||
|
||||
OpenCLI 会把已安装 plugin 的版本记录到 `~/.opencli/plugins.lock.json`。每条记录会保存 plugin source、当前 git commit hash、安装时间,以及最近一次更新时间。只要有这份元数据,`opencli plugin list` 就会显示对应的短 commit hash。
|
||||
|
||||
## YAML plugin 示例
|
||||
|
||||
```text
|
||||
|
||||
Vendored
+518
-508
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenCLI",
|
||||
"version": "1.4.1",
|
||||
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
|
||||
"version": "1.2.6",
|
||||
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"tabs",
|
||||
@@ -22,14 +22,10 @@
|
||||
},
|
||||
"action": {
|
||||
"default_title": "OpenCLI",
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"homepage_url": "https://github.com/jackwener/opencli"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "1.4.1",
|
||||
"version": "1.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"package:release": "node scripts/package-release.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 280px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.header img { width: 24px; height: 24px; }
|
||||
.header h1 { font-size: 15px; font-weight: 600; }
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.connected { background: #34c759; }
|
||||
.dot.disconnected { background: #ff3b30; }
|
||||
.dot.connecting { background: #ff9500; }
|
||||
.status-text { font-size: 13px; color: #555; }
|
||||
.status-text strong { color: #333; }
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f0f4ff;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
.hint code {
|
||||
background: #e8ecf1;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 14px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
.footer a { color: #007aff; text-decoration: none; }
|
||||
.footer a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<img src="icons/icon-48.png" alt="OpenCLI">
|
||||
<h1>OpenCLI</h1>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="dot disconnected" id="dot"></span>
|
||||
<span class="status-text" id="status">Checking...</span>
|
||||
</div>
|
||||
<div class="hint" id="hint">
|
||||
This is normal. The extension connects automatically when you run any <code>opencli</code> command.
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="https://github.com/jackwener/opencli" target="_blank">Documentation</a>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
// Query connection status from background service worker
|
||||
chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
|
||||
const dot = document.getElementById('dot');
|
||||
const status = document.getElementById('status');
|
||||
const hint = document.getElementById('hint');
|
||||
if (chrome.runtime.lastError || !resp) {
|
||||
dot.className = 'dot disconnected';
|
||||
status.innerHTML = '<strong>No daemon connected</strong>';
|
||||
hint.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (resp.connected) {
|
||||
dot.className = 'dot connected';
|
||||
status.innerHTML = '<strong>Connected to daemon</strong>';
|
||||
hint.style.display = 'none';
|
||||
} else if (resp.reconnecting) {
|
||||
dot.className = 'dot connecting';
|
||||
status.innerHTML = '<strong>Reconnecting...</strong>';
|
||||
hint.style.display = 'none';
|
||||
} else {
|
||||
dot.className = 'dot disconnected';
|
||||
status.innerHTML = '<strong>No daemon connected</strong>';
|
||||
hint.style.display = 'block';
|
||||
}
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const extensionDir = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(extensionDir, '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { outDir: path.join(repoRoot, 'extension-package') };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--out' && argv[i + 1]) {
|
||||
const outDir = argv[++i];
|
||||
args.outDir = path.isAbsolute(outDir)
|
||||
? outDir
|
||||
: path.resolve(process.cwd(), outDir);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function exists(targetPath) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalAsset(ref) {
|
||||
return typeof ref === 'string'
|
||||
&& ref.length > 0
|
||||
&& !ref.startsWith('http://')
|
||||
&& !ref.startsWith('https://')
|
||||
&& !ref.startsWith('//')
|
||||
&& !ref.startsWith('chrome://')
|
||||
&& !ref.startsWith('chrome-extension://')
|
||||
&& !ref.startsWith('data:')
|
||||
&& !ref.startsWith('#');
|
||||
}
|
||||
|
||||
function addLocalAsset(files, ref) {
|
||||
if (isLocalAsset(ref)) files.add(ref);
|
||||
}
|
||||
|
||||
function collectManifestEntrypoints(manifest) {
|
||||
const files = new Set(['manifest.json']);
|
||||
|
||||
addLocalAsset(files, manifest.background?.service_worker);
|
||||
addLocalAsset(files, manifest.action?.default_popup);
|
||||
addLocalAsset(files, manifest.options_page);
|
||||
addLocalAsset(files, manifest.devtools_page);
|
||||
addLocalAsset(files, manifest.side_panel?.default_path);
|
||||
|
||||
for (const ref of Object.values(manifest.icons ?? {})) addLocalAsset(files, ref);
|
||||
for (const ref of Object.values(manifest.action?.default_icon ?? {})) addLocalAsset(files, ref);
|
||||
for (const contentScript of manifest.content_scripts ?? []) {
|
||||
for (const jsFile of contentScript.js ?? []) addLocalAsset(files, jsFile);
|
||||
for (const cssFile of contentScript.css ?? []) addLocalAsset(files, cssFile);
|
||||
}
|
||||
for (const page of manifest.sandbox?.pages ?? []) addLocalAsset(files, page);
|
||||
for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) addLocalAsset(files, overridePage);
|
||||
for (const entry of manifest.web_accessible_resources ?? []) {
|
||||
for (const resource of entry.resources ?? []) addLocalAsset(files, resource);
|
||||
}
|
||||
if (manifest.default_locale) files.add('_locales');
|
||||
|
||||
return [...files];
|
||||
}
|
||||
|
||||
async function collectHtmlDependencies(relativeHtmlPath, files, visited) {
|
||||
if (visited.has(relativeHtmlPath)) return;
|
||||
visited.add(relativeHtmlPath);
|
||||
|
||||
const htmlPath = path.join(extensionDir, relativeHtmlPath);
|
||||
const html = await fs.readFile(htmlPath, 'utf8');
|
||||
const attrRe = /\b(?:src|href)=["']([^"'#?]+(?:\?[^"']*)?)["']/gi;
|
||||
|
||||
for (const match of html.matchAll(attrRe)) {
|
||||
const rawRef = match[1];
|
||||
const cleanRef = rawRef.split('?')[0];
|
||||
if (!isLocalAsset(cleanRef)) continue;
|
||||
|
||||
const resolvedRelativePath = cleanRef.startsWith('/')
|
||||
? cleanRef.slice(1)
|
||||
: path.posix.normalize(path.posix.join(path.posix.dirname(relativeHtmlPath), cleanRef));
|
||||
|
||||
addLocalAsset(files, resolvedRelativePath);
|
||||
if (resolvedRelativePath.endsWith('.html')) {
|
||||
await collectHtmlDependencies(resolvedRelativePath, files, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectManifestAssets(manifest) {
|
||||
const files = new Set(collectManifestEntrypoints(manifest));
|
||||
const htmlPages = [];
|
||||
|
||||
if (manifest.action?.default_popup) {
|
||||
htmlPages.push(manifest.action.default_popup);
|
||||
}
|
||||
if (manifest.options_page) htmlPages.push(manifest.options_page);
|
||||
if (manifest.devtools_page) htmlPages.push(manifest.devtools_page);
|
||||
if (manifest.side_panel?.default_path) htmlPages.push(manifest.side_panel.default_path);
|
||||
for (const page of manifest.sandbox?.pages ?? []) htmlPages.push(page);
|
||||
for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) htmlPages.push(overridePage);
|
||||
|
||||
const visited = new Set();
|
||||
for (const htmlPage of htmlPages) {
|
||||
if (isLocalAsset(htmlPage)) {
|
||||
await collectHtmlDependencies(htmlPage, files, visited);
|
||||
}
|
||||
}
|
||||
|
||||
return [...files];
|
||||
}
|
||||
|
||||
async function copyEntry(relativePath, outDir) {
|
||||
const fromPath = path.join(extensionDir, relativePath);
|
||||
const toPath = path.join(outDir, relativePath);
|
||||
const stats = await fs.stat(fromPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await fs.cp(fromPath, toPath, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(toPath), { recursive: true });
|
||||
await fs.copyFile(fromPath, toPath);
|
||||
}
|
||||
|
||||
async function findMissingEntries(baseDir, entries) {
|
||||
const missingEntries = [];
|
||||
for (const relativePath of entries) {
|
||||
const absolutePath = path.join(baseDir, relativePath);
|
||||
if (!(await exists(absolutePath))) {
|
||||
missingEntries.push(relativePath);
|
||||
}
|
||||
}
|
||||
return missingEntries;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { outDir } = parseArgs(process.argv.slice(2));
|
||||
const manifestPath = path.join(extensionDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||
|
||||
const requiredEntries = await collectManifestAssets(manifest);
|
||||
const missingEntries = await findMissingEntries(extensionDir, requiredEntries);
|
||||
|
||||
if (missingEntries.length > 0) {
|
||||
console.error('Missing files referenced by the extension package:');
|
||||
for (const missingEntry of missingEntries) console.error(`- ${missingEntry}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await fs.rm(outDir, { recursive: true, force: true });
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const relativePath of requiredEntries) {
|
||||
await copyEntry(relativePath, outDir);
|
||||
}
|
||||
|
||||
// Guard against regressions where manifest entry files (e.g. action.default_popup)
|
||||
// are accidentally omitted from the packaged directory.
|
||||
const packagedEntrypoints = collectManifestEntrypoints(manifest);
|
||||
const missingPackagedEntrypoints = await findMissingEntries(outDir, packagedEntrypoints);
|
||||
if (missingPackagedEntrypoints.length > 0) {
|
||||
console.error('Packaged extension is missing files referenced by manifest.json:');
|
||||
for (const missingEntry of missingPackagedEntrypoints) console.error(`- ${missingEntry}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Extension package prepared at ${path.relative(repoRoot, outDir) || outDir}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Listener<T extends (...args: any[]) => void> = { addListener: (fn: T) => void };
|
||||
|
||||
@@ -96,15 +96,9 @@ function createChromeMock() {
|
||||
describe('background tab isolation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useRealTimers();
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('lists only automation-window web tabs', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
@@ -139,45 +133,6 @@ describe('background tab isolation', () => {
|
||||
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true });
|
||||
});
|
||||
|
||||
it('treats normalized same-url navigate as already complete', async () => {
|
||||
const { chrome, tabs, update } = createChromeMock();
|
||||
tabs[0].url = 'https://www.bilibili.com/';
|
||||
tabs[0].title = 'bilibili';
|
||||
tabs[0].status = 'complete';
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:bilibili', 1);
|
||||
|
||||
const result = await mod.__test__.handleNavigate(
|
||||
{ id: 'same-url', action: 'navigate', url: 'https://www.bilibili.com', workspace: 'site:bilibili' },
|
||||
'site:bilibili',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'same-url',
|
||||
ok: true,
|
||||
data: {
|
||||
title: 'bilibili',
|
||||
url: 'https://www.bilibili.com/',
|
||||
tabId: 1,
|
||||
timedOut: false,
|
||||
},
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps hash routes distinct when comparing target URLs', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
|
||||
expect(mod.__test__.isTargetUrl('https://example.com/', 'https://example.com')).toBe(true);
|
||||
expect(mod.__test__.isTargetUrl('https://example.com/#feed', 'https://example.com/#settings')).toBe(false);
|
||||
expect(mod.__test__.isTargetUrl('https://example.com/app/', 'https://example.com/app')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports sessions per workspace', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
+32
-118
@@ -74,17 +74,10 @@ function connect(): void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects.
|
||||
* The keepalive alarm (~24s) will still call connect() periodically, but at a
|
||||
* much lower frequency — reducing console noise when the daemon is not running.
|
||||
*/
|
||||
const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return;
|
||||
reconnectAttempts++;
|
||||
if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it
|
||||
// Exponential backoff: 2s, 4s, 8s, 16s, ..., capped at 60s
|
||||
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
@@ -145,7 +138,7 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
|
||||
// Using about:blank would be hijacked by extensions like "New Tab Override".
|
||||
const win = await chrome.windows.create({
|
||||
url: BLANK_PAGE,
|
||||
url: 'data:text/html,<html></html>',
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
@@ -200,18 +193,6 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'keepalive') connect();
|
||||
});
|
||||
|
||||
// ─── Popup status API ───────────────────────────────────────────────
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === 'getStatus') {
|
||||
sendResponse({
|
||||
connected: ws?.readyState === WebSocket.OPEN,
|
||||
reconnecting: reconnectTimer !== null,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// ─── Command dispatcher ─────────────────────────────────────────────
|
||||
|
||||
async function handleCommand(cmd: Command): Promise<Result> {
|
||||
@@ -248,37 +229,10 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
||||
|
||||
// ─── Action handlers ─────────────────────────────────────────────────
|
||||
|
||||
/** Internal blank page used when no user URL is provided. */
|
||||
const BLANK_PAGE = 'data:text/html,<html></html>';
|
||||
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
||||
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
|
||||
}
|
||||
|
||||
/** Check if a URL is safe for user-facing navigation (http/https only). */
|
||||
function isSafeNavigationUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
/** Minimal URL normalization for same-page comparison: root slash + default port only. */
|
||||
function normalizeUrlForComparison(url?: string): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if ((parsed.protocol === 'https:' && parsed.port === '443') || (parsed.protocol === 'http:' && parsed.port === '80')) {
|
||||
parsed.port = '';
|
||||
}
|
||||
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean {
|
||||
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
||||
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,14 +247,9 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
||||
if (tabId !== undefined) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const session = automationSessions.get(workspace);
|
||||
if (isDebuggableUrl(tab.url) && session && tab.windowId === session.windowId) return tabId;
|
||||
if (session && tab.windowId !== session.windowId) {
|
||||
console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`);
|
||||
} else if (!isDebuggableUrl(tab.url)) {
|
||||
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
||||
}
|
||||
if (isDebuggableUrl(tab.url)) return tabId;
|
||||
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
||||
} catch {
|
||||
// Tab was closed — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
||||
@@ -319,7 +268,7 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
||||
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
|
||||
const reuseTab = tabs.find(t => t.id);
|
||||
if (reuseTab?.id) {
|
||||
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
|
||||
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
try {
|
||||
const updated = await chrome.tabs.get(reuseTab.id);
|
||||
@@ -331,7 +280,7 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
||||
}
|
||||
|
||||
// Fallback: create a new tab
|
||||
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
|
||||
const newTab = await chrome.tabs.create({ windowId, url: 'data:text/html,<html></html>', active: true });
|
||||
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
||||
return newTab.id;
|
||||
}
|
||||
@@ -365,24 +314,13 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
||||
|
||||
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
||||
if (!isSafeNavigationUrl(cmd.url)) {
|
||||
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
|
||||
// Capture the current URL before navigation to detect actual URL change
|
||||
const beforeTab = await chrome.tabs.get(tabId);
|
||||
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
|
||||
const beforeUrl = beforeTab.url ?? '';
|
||||
const targetUrl = cmd.url;
|
||||
|
||||
// Fast-path: tab is already at the target URL and fully loaded.
|
||||
if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false },
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -393,51 +331,45 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
|
||||
|
||||
await chrome.tabs.update(tabId, { url: targetUrl });
|
||||
|
||||
// Wait until navigation completes. Resolve when status is 'complete' AND either:
|
||||
// - the URL matches the target (handles same-URL / canonicalized navigations), OR
|
||||
// - the URL differs from the pre-navigation URL (handles redirects).
|
||||
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
|
||||
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
|
||||
let timedOut = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
if (checkTimer) clearTimeout(checkTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const isNavigationDone = (url: string | undefined): boolean => {
|
||||
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
|
||||
};
|
||||
let urlChanged = false;
|
||||
|
||||
const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
|
||||
if (id !== tabId) return;
|
||||
if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) {
|
||||
finish();
|
||||
|
||||
// Track URL change (new URL differs from the one before navigation)
|
||||
if (info.url && info.url !== beforeUrl) {
|
||||
urlChanged = true;
|
||||
}
|
||||
|
||||
// Only resolve when both URL has changed AND status is complete
|
||||
if (urlChanged && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
|
||||
// Also check if the tab already navigated (e.g. instant cache hit)
|
||||
checkTimer = setTimeout(async () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const currentTab = await chrome.tabs.get(tabId);
|
||||
if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) {
|
||||
finish();
|
||||
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
} catch { /* tab gone */ }
|
||||
}, 100);
|
||||
|
||||
// Timeout fallback with warning
|
||||
timeoutTimer = setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
timedOut = true;
|
||||
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
||||
finish();
|
||||
resolve();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@@ -464,11 +396,8 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case 'new': {
|
||||
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
|
||||
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
|
||||
}
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'data:text/html,<html></html>', active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case 'close': {
|
||||
@@ -489,16 +418,6 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (cmd.index === undefined && cmd.tabId === undefined)
|
||||
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
|
||||
if (cmd.tabId !== undefined) {
|
||||
const session = automationSessions.get(workspace);
|
||||
let tab: chrome.tabs.Tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(cmd.tabId);
|
||||
} catch {
|
||||
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` };
|
||||
}
|
||||
if (!session || tab.windowId !== session.windowId) {
|
||||
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` };
|
||||
}
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
@@ -514,9 +433,6 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
}
|
||||
|
||||
async function handleCookies(cmd: Command): Promise<Result> {
|
||||
if (!cmd.domain && !cmd.url) {
|
||||
return { id: cmd.id, ok: false, error: 'Cookie scope required: provide domain or url to avoid dumping all cookies' };
|
||||
}
|
||||
const details: chrome.cookies.GetAllDetails = {};
|
||||
if (cmd.domain) details.domain = cmd.domain;
|
||||
if (cmd.url) details.url = cmd.url;
|
||||
@@ -573,8 +489,6 @@ async function handleSessions(cmd: Command): Promise<Result> {
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
handleNavigate,
|
||||
isTargetUrl,
|
||||
handleTabs,
|
||||
handleSessions,
|
||||
getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
|
||||
|
||||
@@ -8,13 +8,10 @@
|
||||
|
||||
const attached = new Set<number>();
|
||||
|
||||
/** Internal blank page used when no user URL is provided. */
|
||||
const BLANK_PAGE = 'data:text/html,<html></html>';
|
||||
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
||||
/** Check if a URL can be attached via CDP */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
|
||||
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
||||
}
|
||||
|
||||
async function ensureAttached(tabId: number): Promise<void> {
|
||||
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.4.1",
|
||||
"version": "1.3.3",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -19,20 +19,17 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/main.ts",
|
||||
"dev:bun": "bun src/main.ts",
|
||||
"build": "npm run clean-dist && tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
|
||||
"build-manifest": "node dist/build-manifest.js",
|
||||
"clean-dist": "node scripts/clean-dist.cjs",
|
||||
"clean-yaml": "node scripts/clean-yaml.cjs",
|
||||
"copy-yaml": "node scripts/copy-yaml.cjs",
|
||||
"start": "node dist/main.js",
|
||||
"start:bun": "bun dist/main.js",
|
||||
"postinstall": "node scripts/postinstall.js || true",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run --project unit",
|
||||
"test:bun": "bun vitest run --project unit",
|
||||
"test:adapter": "vitest run --project adapter",
|
||||
"test:all": "vitest run",
|
||||
"test:e2e": "vitest run --project e2e",
|
||||
|
||||
+1
-2
@@ -14,7 +14,6 @@ import {
|
||||
VOLATILE_PARAMS,
|
||||
SEARCH_PARAMS,
|
||||
PAGINATION_PARAMS,
|
||||
LIMIT_PARAMS,
|
||||
FIELD_ROLES,
|
||||
} from './constants.js';
|
||||
|
||||
@@ -165,6 +164,6 @@ export function classifyQueryParams(url: string): {
|
||||
params,
|
||||
hasSearch: params.some(p => SEARCH_PARAMS.has(p)),
|
||||
hasPagination: params.some(p => PAGINATION_PARAMS.has(p)),
|
||||
hasLimit: params.some(p => LIMIT_PARAMS.has(p)),
|
||||
hasLimit: params.some(p => SEARCH_PARAMS.has(p)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { MockWebSocket } = vi.hoisted(() => {
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
private handlers = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
constructor(_url: string) {
|
||||
queueMicrotask(() => this.emit('open'));
|
||||
}
|
||||
|
||||
on(event: string, handler: (...args: any[]) => void): void {
|
||||
const handlers = this.handlers.get(event) ?? [];
|
||||
handlers.push(handler);
|
||||
this.handlers.set(event, handlers);
|
||||
}
|
||||
|
||||
send(_message: string): void {}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: any[]): void {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocket };
|
||||
});
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
WebSocket: MockWebSocket,
|
||||
}));
|
||||
|
||||
import { CDPBridge } from './cdp.js';
|
||||
|
||||
describe('CDPBridge cookies', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('filters cookies by actual domain match instead of substring match', async () => {
|
||||
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
|
||||
|
||||
const bridge = new CDPBridge();
|
||||
vi.spyOn(bridge, 'send').mockResolvedValue({
|
||||
cookies: [
|
||||
{ name: 'good', value: '1', domain: '.example.com' },
|
||||
{ name: 'exact', value: '2', domain: 'example.com' },
|
||||
{ name: 'bad', value: '3', domain: 'notexample.com' },
|
||||
],
|
||||
});
|
||||
|
||||
const page = await bridge.connect();
|
||||
const cookies = await page.getCookies({ domain: 'example.com' });
|
||||
|
||||
expect(cookies).toEqual([
|
||||
{ name: 'good', value: '1', domain: '.example.com' },
|
||||
{ name: 'exact', value: '2', domain: 'example.com' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+37
-53
@@ -9,10 +9,7 @@
|
||||
*/
|
||||
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
||||
import type { IBrowserFactory } from '../runtime.js';
|
||||
import { wrapForEval } from './utils.js';
|
||||
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
import { generateStealthJs } from './stealth.js';
|
||||
@@ -26,7 +23,6 @@ import {
|
||||
networkRequestsJs,
|
||||
waitForDomStableJs,
|
||||
} from './dom-helpers.js';
|
||||
import { isRecord, saveBase64ToFile } from '../utils.js';
|
||||
|
||||
export interface CDPTarget {
|
||||
type?: string;
|
||||
@@ -46,9 +42,9 @@ interface RuntimeEvaluateResult {
|
||||
};
|
||||
}
|
||||
|
||||
const CDP_SEND_TIMEOUT = 30_000;
|
||||
const CDP_SEND_TIMEOUT = 30_000; // 30s per command
|
||||
|
||||
export class CDPBridge implements IBrowserFactory {
|
||||
export class CDPBridge {
|
||||
private _ws: WebSocket | null = null;
|
||||
private _idCounter = 0;
|
||||
private _pending = new Map<number, { resolve: (val: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
@@ -60,9 +56,12 @@ export class CDPBridge implements IBrowserFactory {
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
|
||||
|
||||
// If it's a direct ws:// URL, use it. Otherwise, fetch the /json endpoint to find a page.
|
||||
let wsUrl = endpoint;
|
||||
if (endpoint.startsWith('http')) {
|
||||
const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[];
|
||||
const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch CDP targets: ${res.statusText}`);
|
||||
const targets = await res.json() as CDPTarget[];
|
||||
const target = selectCDPTarget(targets);
|
||||
if (!target || !target.webSocketDebuggerUrl) {
|
||||
throw new Error('No inspectable targets found at CDP endpoint');
|
||||
@@ -72,16 +71,19 @@ export class CDPBridge implements IBrowserFactory {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const timeoutMs = (opts?.timeout ?? 10) * 1000;
|
||||
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
|
||||
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
|
||||
|
||||
ws.on('open', async () => {
|
||||
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 {}
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
resolve(new CDPPage(this));
|
||||
});
|
||||
|
||||
@@ -93,6 +95,7 @@ export class CDPBridge implements IBrowserFactory {
|
||||
ws.on('message', (data: RawData) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
// Handle command responses
|
||||
if (msg.id && this._pending.has(msg.id)) {
|
||||
const entry = this._pending.get(msg.id)!;
|
||||
clearTimeout(entry.timer);
|
||||
@@ -103,13 +106,16 @@ export class CDPBridge implements IBrowserFactory {
|
||||
entry.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
// Handle CDP events
|
||||
if (msg.method) {
|
||||
const listeners = this._eventListeners.get(msg.method);
|
||||
if (listeners) {
|
||||
for (const fn of listeners) fn(msg.params);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// ignore parsing errors
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -127,6 +133,7 @@ export class CDPBridge implements IBrowserFactory {
|
||||
this._eventListeners.clear();
|
||||
}
|
||||
|
||||
/** Send a CDP command with timeout guard (P0 fix #4) */
|
||||
async send(method: string, params: Record<string, unknown> = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<unknown> {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('CDP connection is not open');
|
||||
@@ -142,19 +149,19 @@ export class CDPBridge implements IBrowserFactory {
|
||||
});
|
||||
}
|
||||
|
||||
/** Listen for a CDP event */
|
||||
on(event: string, handler: (params: unknown) => void): void {
|
||||
let set = this._eventListeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this._eventListeners.set(event, set);
|
||||
}
|
||||
if (!set) { set = new Set(); this._eventListeners.set(event, set); }
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
/** Remove a CDP event listener */
|
||||
off(event: string, handler: (params: unknown) => void): void {
|
||||
this._eventListeners.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
/** Wait for a CDP event to fire (one-shot) */
|
||||
waitForEvent(event: string, timeoutMs: number = 15_000): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -175,14 +182,18 @@ class CDPPage implements IPage {
|
||||
private _pageEnabled = false;
|
||||
constructor(private bridge: CDPBridge) {}
|
||||
|
||||
/** Navigate with proper load event waiting (P1 fix #3) */
|
||||
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
|
||||
if (!this._pageEnabled) {
|
||||
await this.bridge.send('Page.enable');
|
||||
this._pageEnabled = true;
|
||||
}
|
||||
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {});
|
||||
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
|
||||
.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.
|
||||
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const maxMs = options?.settleMs ?? 1000;
|
||||
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
|
||||
@@ -194,7 +205,7 @@ class CDPPage implements IPage {
|
||||
const result = await this.bridge.send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
awaitPromise: true
|
||||
}) as RuntimeEvaluateResult;
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception'));
|
||||
@@ -207,7 +218,7 @@ class CDPPage implements IPage {
|
||||
const cookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : [];
|
||||
const domain = opts.domain;
|
||||
return domain
|
||||
? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && matchesCookieDomain(cookie.domain, domain))
|
||||
? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && cookie.domain.includes(domain))
|
||||
: cookies;
|
||||
}
|
||||
|
||||
@@ -223,6 +234,8 @@ class CDPPage implements IPage {
|
||||
return this.evaluate(snapshotJs);
|
||||
}
|
||||
|
||||
// ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.evaluate(clickJs(ref));
|
||||
}
|
||||
@@ -245,12 +258,12 @@ class CDPPage implements IPage {
|
||||
|
||||
async wait(options: number | WaitOptions): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
await new Promise((resolve) => setTimeout(resolve, options * 1000));
|
||||
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
||||
return;
|
||||
}
|
||||
if (typeof options.time === 'number') {
|
||||
const waitTime = options.time;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
@@ -259,6 +272,8 @@ class CDPPage implements IPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Implemented methods (P1 fix #2) ──
|
||||
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
await this.evaluate(scrollJs(direction, amount));
|
||||
}
|
||||
@@ -322,6 +337,8 @@ class CDPPage implements IPage {
|
||||
}
|
||||
}
|
||||
|
||||
import { isRecord, saveBase64ToFile } from '../utils.js';
|
||||
|
||||
function isCookie(value: unknown): value is BrowserCookie {
|
||||
return isRecord(value)
|
||||
&& typeof value.name === 'string'
|
||||
@@ -329,12 +346,7 @@ function isCookie(value: unknown): value is BrowserCookie {
|
||||
&& typeof value.domain === 'string';
|
||||
}
|
||||
|
||||
function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolean {
|
||||
const normalizedCookieDomain = cookieDomain.replace(/^\./, '').toLowerCase();
|
||||
const normalizedTargetDomain = targetDomain.replace(/^\./, '').toLowerCase();
|
||||
return normalizedTargetDomain === normalizedCookieDomain
|
||||
|| normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`);
|
||||
}
|
||||
// ── CDP target selection (unchanged) ──
|
||||
|
||||
function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
|
||||
const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET);
|
||||
@@ -408,31 +420,3 @@ export const __test__ = {
|
||||
selectCDPTarget,
|
||||
scoreCDPTarget,
|
||||
};
|
||||
|
||||
function fetchJsonDirect(url: string): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const request = (parsed.protocol === 'https:' ? httpsRequest : httpRequest)(parsed, (res) => {
|
||||
const statusCode = res.statusCode ?? 0;
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
res.resume();
|
||||
reject(new Error(`Failed to fetch CDP targets: HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.setTimeout(10_000, () => request.destroy(new Error('Timed out fetching CDP targets')));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,45 +247,3 @@ describe('getFormStateJs', () => {
|
||||
expect(js).toContain('data-opencli-ref');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Element Detection', () => {
|
||||
it('includes SEARCH_INDICATORS set', () => {
|
||||
const js = generateSnapshotJs();
|
||||
expect(js).toContain('SEARCH_INDICATORS');
|
||||
expect(js).toContain('search');
|
||||
expect(js).toContain('magnify');
|
||||
expect(js).toContain('glass');
|
||||
});
|
||||
|
||||
it('includes hasFormControlDescendant function', () => {
|
||||
const js = generateSnapshotJs();
|
||||
expect(js).toContain('hasFormControlDescendant');
|
||||
expect(js).toContain('input');
|
||||
expect(js).toContain('select');
|
||||
expect(js).toContain('textarea');
|
||||
});
|
||||
|
||||
it('includes isSearchElement function', () => {
|
||||
const js = generateSnapshotJs();
|
||||
expect(js).toContain('isSearchElement');
|
||||
expect(js).toContain('className');
|
||||
expect(js).toContain('data-');
|
||||
});
|
||||
|
||||
it('checks label wrapper detection in isInteractive', () => {
|
||||
const js = generateSnapshotJs();
|
||||
// Label elements without "for" attribute should check for form control descendants
|
||||
expect(js).toContain('hasFormControlDescendant(el, 2)');
|
||||
});
|
||||
|
||||
it('checks span wrapper detection in isInteractive', () => {
|
||||
const js = generateSnapshotJs();
|
||||
// Span elements should check for form control descendants
|
||||
expect(js).toContain("tag === 'span'");
|
||||
});
|
||||
|
||||
it('integrates search element detection into isInteractive', () => {
|
||||
const js = generateSnapshotJs();
|
||||
expect(js).toContain('isSearchElement(el)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,13 +271,6 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
|
||||
const AD_SELECTOR_RE = /\\b(ad[_-]?(?:banner|container|wrapper|slot|unit|block|frame|leaderboard|sidebar)|google[_-]?ad|sponsored|adsbygoogle|banner[_-]?ad)\\b/i;
|
||||
|
||||
// Search element indicators for heuristic detection
|
||||
const SEARCH_INDICATORS = new Set([
|
||||
'search', 'magnify', 'glass', 'lookup', 'find', 'query',
|
||||
'search-icon', 'search-btn', 'search-button', 'searchbox',
|
||||
'fa-search', 'icon-search', 'btn-search',
|
||||
]);
|
||||
|
||||
// ── Viewport & Layout Helpers ──────────────────────────────────────
|
||||
|
||||
const vw = window.innerWidth;
|
||||
@@ -346,65 +339,19 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
|
||||
// ── Interactivity Detection ────────────────────────────────────────
|
||||
|
||||
// Check if element contains a form control within limited depth (handles label/span wrappers)
|
||||
function hasFormControlDescendant(el, maxDepth = 2) {
|
||||
if (maxDepth <= 0) return false;
|
||||
for (const child of el.children || []) {
|
||||
const tag = child.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'select' || tag === 'textarea') return true;
|
||||
if (hasFormControlDescendant(child, maxDepth - 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInteractive(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (INTERACTIVE_TAGS.has(tag)) {
|
||||
// Skip labels that proxy via "for" to avoid double-activating external inputs
|
||||
if (tag === 'label') {
|
||||
if (el.hasAttribute('for')) return false;
|
||||
// Detect labels that wrap form controls up to two levels deep (label > span > input)
|
||||
if (hasFormControlDescendant(el, 2)) return true;
|
||||
}
|
||||
if (tag === 'label' && el.hasAttribute('for')) return false;
|
||||
if (el.disabled && (tag === 'button' || tag === 'input')) return false;
|
||||
return true;
|
||||
}
|
||||
// Span wrappers for UI components - check if they contain form controls
|
||||
if (tag === 'span') {
|
||||
if (hasFormControlDescendant(el, 2)) return true;
|
||||
}
|
||||
const role = el.getAttribute('role');
|
||||
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
||||
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true;
|
||||
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true;
|
||||
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {}
|
||||
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true;
|
||||
// Search element heuristic detection
|
||||
if (isSearchElement(el)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSearchElement(el) {
|
||||
// Check class names for search indicators
|
||||
const className = el.className?.toLowerCase() || '';
|
||||
const classes = className.split(/\\s+/).filter(Boolean);
|
||||
for (const cls of classes) {
|
||||
const cleaned = cls.replace(/[^a-z0-9-]/g, '');
|
||||
if (SEARCH_INDICATORS.has(cleaned)) return true;
|
||||
}
|
||||
// Check id for search indicators
|
||||
const id = el.id?.toLowerCase() || '';
|
||||
const cleanedId = id.replace(/[^a-z0-9-]/g, '');
|
||||
if (SEARCH_INDICATORS.has(cleanedId)) return true;
|
||||
// Check data-* attributes for search functionality
|
||||
for (const attr of el.attributes || []) {
|
||||
if (attr.name.startsWith('data-')) {
|
||||
const value = attr.value.toLowerCase();
|
||||
for (const kw of SEARCH_INDICATORS) {
|
||||
if (value.includes(kw)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+14
-13
@@ -5,37 +5,38 @@
|
||||
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
|
||||
*/
|
||||
|
||||
import { BrowserConnectError, type BrowserConnectKind } from '../errors.js';
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
// Re-export so callers don't need to import from two places
|
||||
export type ConnectFailureKind = BrowserConnectKind;
|
||||
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
||||
|
||||
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
|
||||
switch (kind) {
|
||||
case 'daemon-not-running':
|
||||
return new BrowserConnectError(
|
||||
'Cannot connect to opencli daemon.' + (detail ? `\n\n${detail}` : ''),
|
||||
`The daemon should auto-start. If it keeps failing, make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
kind,
|
||||
'Cannot connect to opencli daemon.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'The daemon should start automatically. If it doesn\'t, try:\n' +
|
||||
' node dist/daemon.js\n' +
|
||||
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
);
|
||||
case 'extension-not-connected':
|
||||
return new BrowserConnectError(
|
||||
'Browser Bridge extension is not connected.' + (detail ? `\n\n${detail}` : ''),
|
||||
'Install the extension from GitHub Releases, then reload.',
|
||||
kind,
|
||||
'opencli Browser Bridge extension is not connected.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'Please install the extension:\n' +
|
||||
' 1. Download from GitHub Releases\n' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
' 3. Click "Load unpacked" → select the extension folder\n' +
|
||||
' 4. Make sure Chrome is running',
|
||||
);
|
||||
case 'command-failed':
|
||||
return new BrowserConnectError(
|
||||
`Browser command failed: ${detail ?? 'unknown error'}`,
|
||||
undefined,
|
||||
kind,
|
||||
);
|
||||
default:
|
||||
return new BrowserConnectError(
|
||||
detail ?? 'Failed to connect to browser',
|
||||
undefined,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ import { fileURLToPath } from 'node:url';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import type { IPage } from '../types.js';
|
||||
import type { IBrowserFactory } from '../runtime.js';
|
||||
import { Page } from './page.js';
|
||||
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
@@ -19,7 +18,7 @@ export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing'
|
||||
/**
|
||||
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
||||
*/
|
||||
export class BrowserBridge implements IBrowserFactory {
|
||||
export class BrowserBridge {
|
||||
private _state: BrowserBridgeState = 'idle';
|
||||
private _page: Page | null = null;
|
||||
private _daemonProc: ChildProcess | null = null;
|
||||
|
||||
@@ -129,41 +129,4 @@ describe('manifest helper rules', () => {
|
||||
|
||||
expect(scanTs(file, 'demo')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps literal domain and navigateBefore for TS adapters', () => {
|
||||
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
|
||||
const entry = scanTs(file, 'xueqiu');
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
site: 'xueqiu',
|
||||
name: 'fund-holdings',
|
||||
domain: 'danjuanfunds.com',
|
||||
navigateBefore: 'https://danjuanfunds.com/my-money',
|
||||
type: 'ts',
|
||||
modulePath: 'xueqiu/fund-holdings.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('captures deprecated metadata for TS adapters', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, 'legacy.ts');
|
||||
fs.writeFileSync(file, `
|
||||
import { cli } from '../../registry.js';
|
||||
cli({
|
||||
site: 'demo',
|
||||
name: 'legacy',
|
||||
description: 'legacy command',
|
||||
deprecated: 'legacy is deprecated',
|
||||
replacedBy: 'opencli demo new',
|
||||
});
|
||||
`);
|
||||
|
||||
expect(scanTs(file, 'demo')).toMatchObject({
|
||||
site: 'demo',
|
||||
name: 'legacy',
|
||||
deprecated: 'legacy is deprecated',
|
||||
replacedBy: 'opencli demo new',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+18
-48
@@ -38,8 +38,6 @@ export interface ManifestEntry {
|
||||
columns?: string[];
|
||||
pipeline?: Record<string, unknown>[];
|
||||
timeout?: number;
|
||||
deprecated?: boolean | string;
|
||||
replacedBy?: string;
|
||||
/** 'yaml' or 'ts' — determines how executeCommand loads the handler */
|
||||
type: 'yaml' | 'ts';
|
||||
/** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
|
||||
@@ -48,7 +46,7 @@ export interface ManifestEntry {
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
|
||||
import type { YamlCliDefinition } from './yaml-schema.js';
|
||||
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
@@ -175,7 +173,20 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
||||
const strategy = strategyStr.toUpperCase();
|
||||
const browser = cliDef.browser ?? (strategy !== 'PUBLIC');
|
||||
|
||||
const args = parseYamlArgs(cliDef.args);
|
||||
const args: ManifestEntry['args'] = [];
|
||||
if (cliDef.args && typeof cliDef.args === 'object') {
|
||||
for (const [argName, argDef] of Object.entries(cliDef.args)) {
|
||||
args.push({
|
||||
name: argName,
|
||||
type: argDef?.type ?? 'str',
|
||||
default: argDef?.default,
|
||||
required: argDef?.required ?? false,
|
||||
positional: argDef?.positional === true || undefined,
|
||||
help: argDef?.description ?? argDef?.help ?? '',
|
||||
choices: argDef?.choices,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
site: cliDef.site ?? site,
|
||||
@@ -188,8 +199,6 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
||||
columns: cliDef.columns,
|
||||
pipeline: cliDef.pipeline,
|
||||
timeout: cliDef.timeout,
|
||||
deprecated: (cliDef as Record<string, unknown>).deprecated as boolean | string | undefined,
|
||||
replacedBy: (cliDef as Record<string, unknown>).replacedBy as string | undefined,
|
||||
type: 'yaml',
|
||||
navigateBefore: cliDef.navigateBefore,
|
||||
};
|
||||
@@ -251,25 +260,9 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
|
||||
entry.args = parseTsArgsBlock(argsBlock);
|
||||
}
|
||||
|
||||
// Extract navigateBefore: false / true / 'https://...'
|
||||
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
|
||||
if (navBoolMatch) {
|
||||
entry.navigateBefore = navBoolMatch[1] === 'true';
|
||||
} else {
|
||||
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
|
||||
}
|
||||
|
||||
const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
|
||||
if (deprecatedBoolMatch) {
|
||||
entry.deprecated = deprecatedBoolMatch[1] === 'true';
|
||||
} else {
|
||||
const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
|
||||
}
|
||||
|
||||
const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
|
||||
// Extract navigateBefore: false
|
||||
const navMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
|
||||
if (navMatch) entry.navigateBefore = navMatch[1] === 'true' ? true : false;
|
||||
|
||||
return entry;
|
||||
} catch (err) {
|
||||
@@ -340,29 +333,6 @@ function main(): void {
|
||||
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
|
||||
const tsCount = manifest.filter(e => e.type === 'ts').length;
|
||||
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
|
||||
|
||||
// Restore executable permissions on bin entries.
|
||||
// tsc does not preserve the +x bit, so after a clean rebuild the CLI
|
||||
// entry-point loses its executable permission, causing "Permission denied".
|
||||
// See: https://github.com/jackwener/opencli/issues/446
|
||||
if (process.platform !== 'win32') {
|
||||
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
const bins: Record<string, string> = typeof pkg.bin === 'string'
|
||||
? { [pkg.name ?? 'cli']: pkg.bin }
|
||||
: pkg.bin ?? {};
|
||||
for (const binPath of Object.values(bins)) {
|
||||
const abs = path.resolve(__dirname, '..', binPath);
|
||||
if (fs.existsSync(abs)) {
|
||||
fs.chmodSync(abs, 0o755);
|
||||
console.log(`✅ Restored executable permission: ${binPath}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; never break the build for a permission fix.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Strategy, type CliCommand } from './registry.js';
|
||||
|
||||
/** Pipeline steps that require a live browser session. */
|
||||
export const BROWSER_ONLY_STEPS = new Set([
|
||||
const BROWSER_ONLY_STEPS = new Set([
|
||||
'navigate',
|
||||
'click',
|
||||
'type',
|
||||
|
||||
+2
-3
@@ -12,7 +12,6 @@
|
||||
|
||||
import { Strategy } from './registry.js';
|
||||
import type { IPage } from './types.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
|
||||
/** Strategy cascade order (simplest → most complex) */
|
||||
const CASCADE_ORDER: Strategy[] = [
|
||||
@@ -129,9 +128,9 @@ export async function probeEndpoint(
|
||||
result.error = `Strategy ${strategy} requires site-specific implementation`;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
result.success = false;
|
||||
result.error = getErrorMessage(err);
|
||||
result.error = err.message ?? String(err);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
+16
-94
@@ -15,7 +15,6 @@ import { PKG_VERSION } from './version.js';
|
||||
import { printCompletionScript } from './completion.js';
|
||||
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
||||
import { registerAllCommands } from './commanderAdapter.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
|
||||
export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const program = new Command();
|
||||
@@ -258,19 +257,11 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const { installPlugin } = await import('./plugin.js');
|
||||
const { discoverPlugins } = await import('./discovery.js');
|
||||
try {
|
||||
const result = installPlugin(source);
|
||||
const name = installPlugin(source);
|
||||
await discoverPlugins();
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
console.log(chalk.yellow('No plugins were installed (all skipped or incompatible).'));
|
||||
} else {
|
||||
console.log(chalk.green(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.green(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
@@ -284,69 +275,25 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
try {
|
||||
uninstallPlugin(name);
|
||||
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
pluginCmd
|
||||
.command('update')
|
||||
.description('Update a plugin (or all plugins) to the latest version')
|
||||
.argument('[name]', 'Plugin name (required unless --all is passed)')
|
||||
.option('--all', 'Update all installed plugins')
|
||||
.action(async (name: string | undefined, opts: { all?: boolean }) => {
|
||||
if (!name && !opts.all) {
|
||||
console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (name && opts.all) {
|
||||
console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { updatePlugin, updateAllPlugins } = await import('./plugin.js');
|
||||
.description('Update a plugin to the latest version')
|
||||
.argument('<name>', 'Plugin name')
|
||||
.action(async (name: string) => {
|
||||
const { updatePlugin } = await import('./plugin.js');
|
||||
const { discoverPlugins } = await import('./discovery.js');
|
||||
if (opts.all) {
|
||||
const results = updateAllPlugins();
|
||||
if (results.length > 0) {
|
||||
await discoverPlugins();
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
console.log(chalk.bold(' Update Results:'));
|
||||
for (const result of results) {
|
||||
if (result.success) {
|
||||
console.log(` ${chalk.green('✓')} ${result.name}`);
|
||||
continue;
|
||||
}
|
||||
hasErrors = true;
|
||||
console.log(` ${chalk.red('✗')} ${result.name} — ${chalk.dim(result.error)}`);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log(chalk.dim(' No plugins installed.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (hasErrors) {
|
||||
console.error(chalk.red('Completed with some errors.'));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(chalk.green('✅ All plugins updated successfully.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updatePlugin(name!);
|
||||
updatePlugin(name);
|
||||
await discoverPlugins();
|
||||
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
@@ -376,36 +323,11 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log();
|
||||
console.log(chalk.bold(' Installed plugins'));
|
||||
console.log();
|
||||
|
||||
// Group by monorepo
|
||||
const standalone = plugins.filter((p) => !p.monorepoName);
|
||||
const monoGroups = new Map<string, typeof plugins>();
|
||||
for (const p of plugins) {
|
||||
if (!p.monorepoName) continue;
|
||||
const g = monoGroups.get(p.monorepoName) ?? [];
|
||||
g.push(p);
|
||||
monoGroups.set(p.monorepoName, g);
|
||||
}
|
||||
|
||||
for (const p of standalone) {
|
||||
const version = p.version ? chalk.green(` @${p.version}`) : '';
|
||||
const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
|
||||
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
|
||||
const src = p.source ? chalk.dim(` ← ${p.source}`) : '';
|
||||
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}${src}`);
|
||||
console.log(` ${chalk.cyan(p.name)}${cmds}${src}`);
|
||||
}
|
||||
|
||||
for (const [mono, group] of monoGroups) {
|
||||
console.log();
|
||||
console.log(chalk.bold.magenta(` 📦 ${mono}`) + chalk.dim(' (monorepo)'));
|
||||
for (const p of group) {
|
||||
const version = p.version ? chalk.green(` @${p.version}`) : '';
|
||||
const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
|
||||
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
|
||||
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
|
||||
console.log();
|
||||
@@ -447,8 +369,8 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
})();
|
||||
try {
|
||||
executeExternalCli(name, args, externalClis);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 36kr article detail — INTERCEPT strategy.
|
||||
*
|
||||
* Fetches the full content of a 36kr article given its ID or URL.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
/** Extract article ID from a full URL or a bare numeric ID string */
|
||||
function parseArticleId(input: string): string {
|
||||
const m = input.match(/\/p\/(\d+)/);
|
||||
return m ? m[1] : input.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'article',
|
||||
description: '获取36氪文章正文内容',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Article ID or full 36kr article URL' },
|
||||
],
|
||||
columns: ['field', 'value'],
|
||||
func: async (page: IPage, args) => {
|
||||
const articleId = parseArticleId(String(args.id ?? ''));
|
||||
if (!articleId) {
|
||||
throw new CliError('INVALID_ARGUMENT', 'Invalid article ID or URL');
|
||||
}
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(`https://www.36kr.com/p/${articleId}`);
|
||||
await page.wait(5);
|
||||
|
||||
const data: any = await page.evaluate(`
|
||||
(() => {
|
||||
// Title: 36kr uses class "article-title" on h1
|
||||
const title = document.querySelector('.article-title, h1')?.textContent?.trim() || '';
|
||||
// Author: second .author-name (first is empty nav link, second has real name)
|
||||
const authorEls = document.querySelectorAll('.author-name');
|
||||
const author = Array.from(authorEls).map(el => el.textContent?.trim()).filter(Boolean)[0] || '';
|
||||
// Date: 36kr uses class "title-icon-item item-time" for the publish date
|
||||
const dateRaw = document.querySelector('.item-time')?.textContent?.trim() || '';
|
||||
const date = dateRaw.replace(/^[·\s]+/, '').trim();
|
||||
// Article body paragraphs
|
||||
const bodyEls = document.querySelectorAll('[class*="article-content"] p, [class*="rich-text"] p, .article p');
|
||||
const body = Array.from(bodyEls)
|
||||
.map(el => el.textContent?.trim())
|
||||
.filter(t => t && t.length > 10)
|
||||
.join(' ')
|
||||
.slice(0, 800);
|
||||
return { title, author, date, body };
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data?.title) {
|
||||
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
|
||||
}
|
||||
|
||||
return [
|
||||
{ field: 'title', value: data.title },
|
||||
{ field: 'author', value: data.author || '-' },
|
||||
{ field: 'date', value: data.date || '-' },
|
||||
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
|
||||
{ field: 'body', value: data.body || '-' },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHotListUrl, getShanghaiDate } from './hot.js';
|
||||
|
||||
describe('36kr/hot date routing', () => {
|
||||
it('formats dates in Asia/Shanghai instead of UTC', () => {
|
||||
const date = new Date('2026-03-25T18:30:00.000Z');
|
||||
expect(getShanghaiDate(date)).toBe('2026-03-26');
|
||||
});
|
||||
|
||||
it('builds dated hot-list routes with Shanghai-local date', () => {
|
||||
const date = new Date('2026-03-25T18:30:00.000Z');
|
||||
expect(buildHotListUrl('renqi', date)).toBe('https://www.36kr.com/hot-list/renqi/2026-03-26/1');
|
||||
});
|
||||
|
||||
it('keeps catalog on the static route', () => {
|
||||
expect(buildHotListUrl('catalog')).toBe('https://www.36kr.com/hot-list/catalog');
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 36kr hot-list — INTERCEPT strategy.
|
||||
*
|
||||
* Navigates to the 36kr hot-list page and scrapes rendered article links.
|
||||
* Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
renqi: '人气榜',
|
||||
zonghe: '综合榜',
|
||||
shoucang: '收藏榜',
|
||||
catalog: '热门资讯',
|
||||
};
|
||||
|
||||
function getShanghaiDate(date = new Date()): string {
|
||||
// Shanghai stays on UTC+8 year-round, so a fixed offset is sufficient here
|
||||
// and avoids the slow Intl timezone path that timed out on Windows CI.
|
||||
return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildHotListUrl(listType: string, date = new Date()): string {
|
||||
if (listType === 'catalog') {
|
||||
return 'https://www.36kr.com/hot-list/catalog';
|
||||
}
|
||||
|
||||
return `https://www.36kr.com/hot-list/${listType}/${getShanghaiDate(date)}/1`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'hot',
|
||||
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
|
||||
{
|
||||
name: 'type',
|
||||
type: 'string',
|
||||
default: 'catalog',
|
||||
help: 'List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)',
|
||||
},
|
||||
],
|
||||
columns: ['rank', 'title', 'url'],
|
||||
func: async (page: IPage, args) => {
|
||||
const count = Math.min(Number(args.limit) || 20, 50);
|
||||
const listType = String(args.type ?? 'catalog');
|
||||
|
||||
if (!TYPE_MAP[listType]) {
|
||||
throw new CliError(
|
||||
'INVALID_ARGUMENT',
|
||||
`Unknown type "${listType}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const url = buildHotListUrl(listType);
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(url);
|
||||
await page.wait(6);
|
||||
|
||||
// Scrape rendered article links from DOM (deduplicated)
|
||||
const domItems: any = await page.evaluate(`
|
||||
(() => {
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
const links = document.querySelectorAll('a[href*="/p/"]');
|
||||
for (const el of links) {
|
||||
const href = el.getAttribute('href') || '';
|
||||
const title = el.textContent?.trim() || '';
|
||||
if (!title || title.length < 5 || seen.has(href) || seen.has(title)) continue;
|
||||
seen.add(href);
|
||||
seen.add(title);
|
||||
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href });
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
|
||||
if (items.length === 0) {
|
||||
throw new CliError(
|
||||
'NO_DATA',
|
||||
'Could not retrieve 36kr hot list',
|
||||
'36kr may have changed its DOM structure',
|
||||
);
|
||||
}
|
||||
|
||||
return items.slice(0, count).map((item: any, i: number) => ({
|
||||
rank: i + 1,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export { buildHotListUrl, getShanghaiDate };
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
const SAMPLE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel><title>36氪</title>
|
||||
<item>
|
||||
<title>红杉中国领投AI公司「示例」,金额近2亿元</title>
|
||||
<link><![CDATA[https://36kr.com/p/1111111111111111?f=rss]]></link>
|
||||
<pubDate>2026-03-26 10:00:00 +0800</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>马斯克旗下xAI估值突破1000亿美元</title>
|
||||
<link><![CDATA[https://36kr.com/p/2222222222222222?f=rss]]></link>
|
||||
<pubDate>2026-03-26 09:00:00 +0800</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>OpenAI发布GPT-5,多模态能力大幅提升</title>
|
||||
<link><![CDATA[https://36kr.com/p/3333333333333333?f=rss]]></link>
|
||||
<pubDate>2026-03-25 20:00:00 +0800</pubDate>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('36kr/news RSS parsing', () => {
|
||||
it('parses RSS feed into ranked news items', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => SAMPLE_RSS,
|
||||
} as Response);
|
||||
|
||||
// Direct RSS parse test using the same regex logic as news.ts
|
||||
const xml = SAMPLE_RSS;
|
||||
const items: { rank: number; title: string; date: string; url: string }[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(xml)) && items.length < 10) {
|
||||
const block = match[1];
|
||||
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
||||
const url =
|
||||
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
|
||||
block.match(/<link>(.*?)<\/link>/)?.[1] ??
|
||||
'';
|
||||
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
|
||||
const date = pubDate.slice(0, 10);
|
||||
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].rank).toBe(1);
|
||||
expect(items[0].title).toBe('红杉中国领投AI公司「示例」,金额近2亿元');
|
||||
expect(items[0].date).toBe('2026-03-26');
|
||||
expect(items[0].url).toBe('https://36kr.com/p/1111111111111111?f=rss');
|
||||
});
|
||||
|
||||
it('respects limit — returns at most N items', async () => {
|
||||
const xml = SAMPLE_RSS;
|
||||
const limit = 2;
|
||||
const items: { rank: number; title: string; date: string; url: string }[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(xml)) && items.length < limit) {
|
||||
const block = match[1];
|
||||
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
||||
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ?? '';
|
||||
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
|
||||
const date = pubDate.slice(0, 10);
|
||||
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
|
||||
}
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips items with empty title', async () => {
|
||||
const xml = `<rss><channel>
|
||||
<item><title></title><link>https://36kr.com/p/0</link><pubDate>2026-01-01</pubDate></item>
|
||||
<item><title>有标题的文章</title><link>https://36kr.com/p/1</link><pubDate>2026-01-01</pubDate></item>
|
||||
</channel></rss>`;
|
||||
const items: any[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(xml))) {
|
||||
const block = match[1];
|
||||
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
||||
if (title) items.push({ title });
|
||||
}
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('有标题的文章');
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 36kr latest news — public RSS feed, no browser needed.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'news',
|
||||
description: 'Latest tech/startup news from 36kr (36氪)',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'summary', 'date', 'url'],
|
||||
func: async (_page, kwargs) => {
|
||||
const count = Math.min(kwargs.limit || 20, 50);
|
||||
const resp = await fetch('https://www.36kr.com/feed', {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const xml = await resp.text();
|
||||
|
||||
const items: { rank: number; title: string; summary: string; date: string; url: string }[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(xml)) && items.length < count) {
|
||||
const block = match[1];
|
||||
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
||||
const url =
|
||||
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
|
||||
block.match(/<link>(.*?)<\/link>/)?.[1] ??
|
||||
'';
|
||||
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
|
||||
const date = pubDate.slice(0, 10);
|
||||
// Extract plain-text summary from HTML description (first ~120 chars)
|
||||
const rawDesc = block.match(/<description><!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? '';
|
||||
const summary = rawDesc
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
|
||||
if (title) {
|
||||
items.push({ rank: items.length + 1, title, summary, date, url: url.trim() });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
},
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 36kr article search — INTERCEPT strategy.
|
||||
*
|
||||
* Navigates to the 36kr search results page and scrapes rendered articles.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'search',
|
||||
description: '搜索36氪文章',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'date', 'url'],
|
||||
func: async (page: IPage, args) => {
|
||||
const count = Math.min(Number(args.limit) || 20, 50);
|
||||
const query = encodeURIComponent(String(args.query ?? ''));
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(`https://www.36kr.com/search/articles/${query}`);
|
||||
await page.wait(6);
|
||||
|
||||
const domItems: any = await page.evaluate(`
|
||||
(() => {
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
// article-item-title contains the clickable title link
|
||||
const titleEls = document.querySelectorAll('.article-item-title a[href*="/p/"], .article-item-title[href*="/p/"]');
|
||||
for (const el of titleEls) {
|
||||
const href = el.getAttribute('href') || '';
|
||||
const title = el.textContent?.trim() || '';
|
||||
if (!title || seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
// Look for date near the article item
|
||||
const item = el.closest('[class*="article-item"]') || el.parentElement;
|
||||
const dateEl = item?.querySelector('[class*="time"], [class*="date"], time');
|
||||
const date = dateEl?.textContent?.trim() || '';
|
||||
results.push({
|
||||
title,
|
||||
url: href.startsWith('http') ? href : 'https://36kr.com' + href,
|
||||
date,
|
||||
});
|
||||
}
|
||||
// Fallback: generic /p/ links with meaningful text
|
||||
if (results.length === 0) {
|
||||
const links = document.querySelectorAll('a[href*="/p/"]');
|
||||
for (const el of links) {
|
||||
const href = el.getAttribute('href') || '';
|
||||
const title = el.textContent?.trim() || '';
|
||||
if (!title || title.length < 8 || seen.has(href) || seen.has(title)) continue;
|
||||
seen.add(href);
|
||||
seen.add(title);
|
||||
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href, date: '' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
|
||||
if (items.length === 0) {
|
||||
throw new CliError('NO_DATA', 'No results found', `Try a different query or check your keyword`);
|
||||
}
|
||||
|
||||
return items.slice(0, count).map((item: any, i: number) => ({
|
||||
rank: i + 1,
|
||||
title: item.title,
|
||||
date: item.date,
|
||||
url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -7,15 +7,13 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import type { CliOptions } from '../../registry.js';
|
||||
|
||||
/**
|
||||
* Factory: capture DOM HTML + accessibility snapshot.
|
||||
*/
|
||||
export function makeScreenshotCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
|
||||
export function makeScreenshotCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
...extra,
|
||||
site,
|
||||
name: 'screenshot',
|
||||
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
|
||||
@@ -49,10 +47,9 @@ export function makeScreenshotCommand(site: string, displayName?: string, extra:
|
||||
/**
|
||||
* Factory: check CDP connection status.
|
||||
*/
|
||||
export function makeStatusCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
|
||||
export function makeStatusCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
...extra,
|
||||
site,
|
||||
name: 'status',
|
||||
description: `Check active CDP connection to ${label}`,
|
||||
@@ -71,10 +68,9 @@ export function makeStatusCommand(site: string, displayName?: string, extra: Par
|
||||
/**
|
||||
* Factory: start a new session via Cmd/Ctrl+N.
|
||||
*/
|
||||
export function makeNewCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
|
||||
export function makeNewCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
...extra,
|
||||
site,
|
||||
name: 'new',
|
||||
description: `Start a new ${label} session`,
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { CDPBridge } from '../../browser/cdp.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getErrorMessage } from '../../errors.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -462,17 +461,15 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
|
||||
cdp = new CDPBridge();
|
||||
try {
|
||||
page = await cdp.connect({ timeout: 15_000 });
|
||||
} catch (err: unknown) {
|
||||
} catch (err: any) {
|
||||
cdp = null;
|
||||
const errMsg = getErrorMessage(err);
|
||||
const cause = err instanceof Error ? (err.cause as Record<string, unknown> | undefined) : undefined;
|
||||
const isRefused = cause?.code === 'ECONNREFUSED' || errMsg.includes('ECONNREFUSED');
|
||||
const isRefused = err?.cause?.code === 'ECONNREFUSED' || err?.message?.includes('ECONNREFUSED');
|
||||
throw new Error(
|
||||
isRefused
|
||||
? `Cannot connect to Antigravity at ${endpoint}.\n` +
|
||||
' 1. Make sure Antigravity is running\n' +
|
||||
' 2. Launch with: --remote-debugging-port=9224'
|
||||
: `CDP connection failed: ${errMsg}`
|
||||
: `CDP connection failed: ${err.message}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ cli({
|
||||
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
|
||||
columns: ['id', 'title', 'author', 'episodes', 'genre'],
|
||||
func: async (_page, args) => {
|
||||
const term = encodeURIComponent(args.query);
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
@@ -24,7 +24,6 @@ cli({
|
||||
author: p.artistName,
|
||||
episodes: p.trackCount ?? '-',
|
||||
genre: p.primaryGenreName ?? '-',
|
||||
url: p.collectionViewUrl || '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,13 +12,13 @@ cli({
|
||||
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
|
||||
],
|
||||
columns: ['id', 'title', 'authors', 'published', 'url'],
|
||||
columns: ['id', 'title', 'authors', 'published'],
|
||||
func: async (_page, args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
const query = encodeURIComponent(`all:${args.query}`);
|
||||
const 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');
|
||||
return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published, url: e.url }));
|
||||
return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* BBC News headlines — public RSS feed, no browser needed.
|
||||
* Source: bb-sites/bbc/news.js
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import './comments.js';
|
||||
|
||||
describe('bilibili comments', () => {
|
||||
const command = getRegistry().get('bilibili/comments');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
});
|
||||
|
||||
it('resolves bvid to aid and fetches replies', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
replies: [
|
||||
{
|
||||
member: { uname: 'Alice' },
|
||||
content: { message: 'Great video!' },
|
||||
like: 42,
|
||||
rcount: 3,
|
||||
ctime: 1700000000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await command!.func!({} as any, { bvid: 'BV1WtAGzYEBm', limit: 5 });
|
||||
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
|
||||
params: { oid: 12345, type: 1, mode: 3, ps: 5 },
|
||||
signed: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
author: 'Alice',
|
||||
text: 'Great video!',
|
||||
likes: 42,
|
||||
replies: 3,
|
||||
time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when aid cannot be resolved', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
|
||||
|
||||
await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
|
||||
'Cannot resolve aid for bvid: BV_invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array when replies is missing', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 99 } })
|
||||
.mockResolvedValueOnce({ data: {} }); // no replies key
|
||||
|
||||
const result = await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('caps limit at 50', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({ data: { replies: [] } });
|
||||
|
||||
await command!.func!({} as any, { bvid: 'BV1xxx', limit: 999 });
|
||||
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
|
||||
params: { oid: 1, type: 1, mode: 3, ps: 50 },
|
||||
signed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses newlines in comment text', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
replies: [
|
||||
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 })) as any[];
|
||||
expect(result[0].text).toBe('line1 line2 line3');
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
|
||||
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { apiGet } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'comments',
|
||||
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
|
||||
],
|
||||
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
|
||||
func: async (page, kwargs) => {
|
||||
const bvid = String(kwargs.bvid).trim();
|
||||
const limit = Math.min(Number(kwargs.limit) || 20, 50);
|
||||
|
||||
// Resolve bvid → aid (required by reply API)
|
||||
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
const aid = view?.data?.aid;
|
||||
if (!aid) throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
|
||||
|
||||
const payload = await apiGet(page, '/x/v2/reply/main', {
|
||||
params: { oid: aid, type: 1, mode: 3, ps: limit },
|
||||
signed: true,
|
||||
});
|
||||
|
||||
const replies: any[] = payload?.data?.replies ?? [];
|
||||
return replies.slice(0, limit).map((r: any, i: number) => ({
|
||||
rank: i + 1,
|
||||
author: r.member?.uname ?? '',
|
||||
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
|
||||
likes: r.like ?? 0,
|
||||
replies: r.rcount ?? 0,
|
||||
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* 6=已交换微信, 7=不合适, 8=牛人发起, 11=收藏
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
import { ArgumentError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
const LABEL_MAP: Record<string, number> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
requirePage, navigateToChat, findFriendByUid,
|
||||
clickCandidateInList, typeAndSendMessage,
|
||||
} from './utils.js';
|
||||
} from './common.js';
|
||||
import { EmptyResultError, SelectorError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
|
||||
+14
-28
@@ -2,7 +2,7 @@ import { execSync, spawnSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
|
||||
import { getVisibleChatMessages } from './ax.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'chatgpt',
|
||||
@@ -13,7 +13,6 @@ export const askCommand = cli({
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
|
||||
{ name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES },
|
||||
{ name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
@@ -23,15 +22,8 @@ export const askCommand = cli({
|
||||
}
|
||||
|
||||
const text = kwargs.text as string;
|
||||
const model = kwargs.model as string | undefined;
|
||||
const timeout = parseInt(kwargs.timeout as string, 10) || 30;
|
||||
|
||||
// Switch model before sending if requested
|
||||
if (model) {
|
||||
activateChatGPT();
|
||||
selectModel(model);
|
||||
}
|
||||
|
||||
// Backup clipboard
|
||||
let clipBackup = '';
|
||||
try { clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); } catch {}
|
||||
@@ -39,7 +31,8 @@ export const askCommand = cli({
|
||||
|
||||
// Send the message
|
||||
spawnSync('pbcopy', { input: text });
|
||||
activateChatGPT();
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync("osascript -e 'delay 0.5'");
|
||||
|
||||
const cmd = "osascript " +
|
||||
"-e 'tell application \"System Events\"' " +
|
||||
@@ -52,32 +45,25 @@ export const askCommand = cli({
|
||||
// Restore clipboard after the prompt is sent.
|
||||
if (clipBackup) spawnSync('pbcopy', { input: clipBackup });
|
||||
|
||||
// Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears),
|
||||
// then read the final response text.
|
||||
const pollInterval = 2;
|
||||
// Wait for response, then read the latest visible assistant message from the AX tree.
|
||||
const pollInterval = 1;
|
||||
const maxPolls = Math.ceil(timeout / pollInterval);
|
||||
let response = '';
|
||||
let generationStarted = false;
|
||||
|
||||
for (let i = 0; i < maxPolls; i++) {
|
||||
execSync(`sleep ${pollInterval}`);
|
||||
const generating = isGenerating();
|
||||
if (generating) {
|
||||
generationStarted = true;
|
||||
continue;
|
||||
}
|
||||
// Generation finished (or never started yet)
|
||||
if (!generationStarted && i < 3) continue; // give it a moment to start
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync("osascript -e 'delay 0.2'");
|
||||
|
||||
// Read final response
|
||||
activateChatGPT(0.3);
|
||||
const messagesNow = getVisibleChatMessages();
|
||||
if (messagesNow.length > messagesBefore.length) {
|
||||
const newMessages = messagesNow.slice(messagesBefore.length);
|
||||
const candidate = [...newMessages].reverse().find((message) => message !== text);
|
||||
if (candidate) response = candidate;
|
||||
if (messagesNow.length <= messagesBefore.length) continue;
|
||||
|
||||
const newMessages = messagesNow.slice(messagesBefore.length);
|
||||
const candidate = [...newMessages].reverse().find((message) => message !== text);
|
||||
if (candidate) {
|
||||
response = candidate;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
|
||||
+1
-180
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const AX_READ_SCRIPT = `
|
||||
import Cocoa
|
||||
@@ -62,185 +62,6 @@ let data = try! JSONSerialization.data(withJSONObject: best, options: [])
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
`;
|
||||
|
||||
const AX_MODEL_SCRIPT = `
|
||||
import Cocoa
|
||||
import ApplicationServices
|
||||
|
||||
func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
|
||||
return value as AnyObject?
|
||||
}
|
||||
|
||||
func s(_ el: AXUIElement, _ name: String) -> String? {
|
||||
if let v = attr(el, name) as? String, !v.isEmpty { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
func children(_ el: AXUIElement) -> [AXUIElement] {
|
||||
(attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
|
||||
}
|
||||
|
||||
func press(_ el: AXUIElement) {
|
||||
AXUIElementPerformAction(el, kAXPressAction as CFString)
|
||||
}
|
||||
|
||||
func findByDesc(_ el: AXUIElement, _ target: String, prefix: Bool = false, depth: Int = 0) -> AXUIElement? {
|
||||
guard depth < 20 else { return nil }
|
||||
let desc = s(el, kAXDescriptionAttribute as String) ?? ""
|
||||
if prefix ? desc.hasPrefix(target) : (desc == target) { return el }
|
||||
for c in children(el) {
|
||||
if let found = findByDesc(c, target, prefix: prefix, depth: depth + 1) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findPopover(_ el: AXUIElement, depth: Int = 0) -> AXUIElement? {
|
||||
guard depth < 20 else { return nil }
|
||||
let role = s(el, kAXRoleAttribute as String) ?? ""
|
||||
if role == "AXPopover" { return el }
|
||||
for c in children(el) {
|
||||
if let found = findPopover(c, depth: depth + 1) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pressEscape() {
|
||||
let src = CGEventSource(stateID: .combinedSessionState)
|
||||
if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: true) { esc.post(tap: .cghidEventTap) }
|
||||
if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: false) { esc.post(tap: .cghidEventTap) }
|
||||
}
|
||||
|
||||
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
|
||||
fputs("ChatGPT not running\\n", stderr); exit(1)
|
||||
}
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
|
||||
fputs("No focused ChatGPT window\\n", stderr); exit(1)
|
||||
}
|
||||
|
||||
let args = CommandLine.arguments
|
||||
let target = args.count > 1 ? args[1] : ""
|
||||
let needsLegacy = args.count > 2 && args[2] == "legacy"
|
||||
|
||||
// Step 1: Click the "Options" button to open the popover
|
||||
guard let optionsBtn = findByDesc(win, "Options") else {
|
||||
fputs("Could not find Options button\\n", stderr); exit(1)
|
||||
}
|
||||
press(optionsBtn)
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
|
||||
// Step 2: Find the popover that appeared, search ONLY within it
|
||||
guard let popover = findPopover(win) else {
|
||||
pressEscape()
|
||||
fputs("Popover did not appear\\n", stderr); exit(1)
|
||||
}
|
||||
|
||||
// Step 3: If legacy, click "Legacy models" to expand submenu
|
||||
if needsLegacy {
|
||||
guard let legacyBtn = findByDesc(popover, "Legacy models") else {
|
||||
pressEscape()
|
||||
fputs("Could not find Legacy models button\\n", stderr); exit(1)
|
||||
}
|
||||
press(legacyBtn)
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
}
|
||||
|
||||
// Step 4: Click the target model button within the popover (prefix match)
|
||||
guard let modelBtn = findByDesc(popover, target, prefix: true) else {
|
||||
pressEscape()
|
||||
fputs("Could not find button starting with '\\(target)' in popover\\n", stderr); exit(1)
|
||||
}
|
||||
press(modelBtn)
|
||||
print("Selected: \\(target)")
|
||||
`;
|
||||
|
||||
const AX_GENERATING_SCRIPT = `
|
||||
import Cocoa
|
||||
import ApplicationServices
|
||||
|
||||
func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
|
||||
return value as AnyObject?
|
||||
}
|
||||
|
||||
func s(_ el: AXUIElement, _ name: String) -> String? {
|
||||
if let v = attr(el, name) as? String, !v.isEmpty { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
func children(_ el: AXUIElement) -> [AXUIElement] {
|
||||
(attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
|
||||
}
|
||||
|
||||
func hasButton(_ el: AXUIElement, desc target: String, depth: Int = 0) -> Bool {
|
||||
guard depth < 15 else { return false }
|
||||
let role = s(el, kAXRoleAttribute as String) ?? ""
|
||||
let desc = s(el, kAXDescriptionAttribute as String) ?? ""
|
||||
if role == "AXButton" && desc == target { return true }
|
||||
for c in children(el) {
|
||||
if hasButton(c, desc: target, depth: depth + 1) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
|
||||
print("false"); exit(0)
|
||||
}
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
|
||||
print("false"); exit(0)
|
||||
}
|
||||
print(hasButton(win, desc: "Stop generating") ? "true" : "false")
|
||||
`;
|
||||
|
||||
type ModelChoice = 'auto' | 'instant' | 'thinking' | '5.2-instant' | '5.2-thinking';
|
||||
|
||||
const MODEL_MAP: Record<ModelChoice, { desc: string; legacy?: boolean }> = {
|
||||
'auto': { desc: 'Auto' },
|
||||
'instant': { desc: 'Instant' },
|
||||
'thinking': { desc: 'Thinking' },
|
||||
'5.2-instant': { desc: 'GPT-5.2 Instant', legacy: true },
|
||||
'5.2-thinking': { desc: 'GPT-5.2 Thinking', legacy: true },
|
||||
};
|
||||
|
||||
export const MODEL_CHOICES = Object.keys(MODEL_MAP) as ModelChoice[];
|
||||
|
||||
export function activateChatGPT(delaySeconds: number = 0.5): void {
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync(`osascript -e 'delay ${delaySeconds}'`);
|
||||
}
|
||||
|
||||
export function selectModel(model: string): string {
|
||||
const entry = MODEL_MAP[model as ModelChoice];
|
||||
if (!entry) {
|
||||
throw new Error(`Unknown model "${model}". Choose from: ${MODEL_CHOICES.join(', ')}`);
|
||||
}
|
||||
const swiftArgs = ['-', entry.desc];
|
||||
if (entry.legacy) swiftArgs.push('legacy');
|
||||
|
||||
const output = execFileSync('swift', swiftArgs, {
|
||||
input: AX_MODEL_SCRIPT,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).trim();
|
||||
return output;
|
||||
}
|
||||
|
||||
export function isGenerating(): boolean {
|
||||
try {
|
||||
const output = execFileSync('swift', ['-'], {
|
||||
input: AX_GENERATING_SCRIPT,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).trim();
|
||||
return output === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVisibleChatMessages(): string[] {
|
||||
const output = execFileSync('swift', ['-'], {
|
||||
input: AX_READ_SCRIPT,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'chatgpt',
|
||||
name: 'model',
|
||||
description: 'Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'model', required: true, positional: true, help: 'Model to switch to', choices: MODEL_CHOICES },
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS');
|
||||
}
|
||||
|
||||
const model = kwargs.model as string;
|
||||
activateChatGPT();
|
||||
const result = selectModel(model);
|
||||
return [{ Status: 'Success', Model: result }];
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ConfigError, getErrorMessage } from '../../errors.js';
|
||||
import { ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
@@ -22,8 +22,8 @@ export const newCommand = cli({
|
||||
execSync("osascript -e 'delay 0.5'");
|
||||
execSync("osascript -e 'tell application \"System Events\" to keystroke \"n\" using command down'");
|
||||
return [{ Status: 'Success' }];
|
||||
} catch (err) {
|
||||
return [{ Status: "Error: " + getErrorMessage(err) }];
|
||||
} catch (err: any) {
|
||||
return [{ Status: "Error: " + err.message }];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, ConfigError, getErrorMessage } from '../../errors.js';
|
||||
import { CommandExecutionError, ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getVisibleChatMessages } from './ax.js';
|
||||
|
||||
@@ -28,8 +28,8 @@ export const readCommand = cli({
|
||||
}
|
||||
|
||||
return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError("Failed to read from ChatGPT: " + getErrorMessage(err));
|
||||
} catch (err: any) {
|
||||
throw new CommandExecutionError("Failed to read from ChatGPT: " + err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getErrorMessage } from '../../errors.js';
|
||||
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'chatgpt',
|
||||
@@ -11,21 +9,11 @@ export const sendCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'text', required: true, positional: true, help: 'Message to send' },
|
||||
{ name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES },
|
||||
],
|
||||
args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
const text = kwargs.text as string;
|
||||
const model = kwargs.model as string | undefined;
|
||||
try {
|
||||
// Switch model before sending if requested
|
||||
if (model) {
|
||||
activateChatGPT();
|
||||
selectModel(model);
|
||||
}
|
||||
|
||||
// Backup current clipboard content
|
||||
let clipBackup = '';
|
||||
try {
|
||||
@@ -34,16 +22,17 @@ export const sendCommand = cli({
|
||||
|
||||
// Copy text to clipboard
|
||||
spawnSync('pbcopy', { input: text });
|
||||
|
||||
activateChatGPT();
|
||||
|
||||
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync("osascript -e 'delay 0.5'");
|
||||
|
||||
const cmd = "osascript " +
|
||||
"-e 'tell application \"System Events\"' " +
|
||||
"-e 'keystroke \"v\" using command down' " +
|
||||
"-e 'delay 0.2' " +
|
||||
"-e 'keystroke return' " +
|
||||
"-e 'end tell'";
|
||||
|
||||
|
||||
execSync(cmd);
|
||||
|
||||
// Restore original clipboard content
|
||||
@@ -52,8 +41,8 @@ export const sendCommand = cli({
|
||||
}
|
||||
|
||||
return [{ Status: 'Success' }];
|
||||
} catch (err) {
|
||||
return [{ Status: "Error: " + getErrorMessage(err) }];
|
||||
} catch (err: any) {
|
||||
return [{ Status: "Error: " + err.message }];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'chatwise',
|
||||
@@ -10,7 +9,6 @@ export const askCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
requiredEnv: chatwiseRequiredEnv,
|
||||
args: [
|
||||
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', required: false, help: 'Max seconds to wait (default: 30)', default: '30' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const exportCommand = cli({
|
||||
site: 'chatwise',
|
||||
@@ -10,7 +9,6 @@ export const exportCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
requiredEnv: chatwiseRequiredEnv,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: 'Output file (default: /tmp/chatwise-export.md)' },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const historyCommand = cli({
|
||||
site: 'chatwise',
|
||||
@@ -9,7 +8,6 @@ export const historyCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
requiredEnv: chatwiseRequiredEnv,
|
||||
args: [],
|
||||
columns: ['Index', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'chatwise',
|
||||
@@ -10,7 +9,6 @@ export const modelCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
requiredEnv: chatwiseRequiredEnv,
|
||||
args: [
|
||||
{ name: 'model-name', required: false, positional: true, help: 'Model to switch to (e.g. gpt-4, claude-3)' },
|
||||
],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation', { requiredEnv: chatwiseRequiredEnv });
|
||||
export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'chatwise',
|
||||
@@ -9,7 +8,6 @@ export const readCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
requiredEnv: chatwiseRequiredEnv,
|
||||
args: [],
|
||||
columns: ['Content'],
|
||||
func: async (page: IPage) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise', { requiredEnv: chatwiseRequiredEnv });
|
||||
export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'chatwise',
|
||||
@@ -10,7 +9,6 @@ export const sendCommand = cli({
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
requiredEnv: chatwiseRequiredEnv,
|
||||
args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }],
|
||||
columns: ['Status', 'InjectedText'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { RequiredEnv } from '../../registry.js';
|
||||
|
||||
export const chatwiseRequiredEnv: RequiredEnv[] = [
|
||||
{
|
||||
name: 'OPENCLI_CDP_ENDPOINT',
|
||||
help: 'Launch ChatWise with --remote-debugging-port=9228, then run OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9228 opencli chatwise status. If you use a local proxy, also set NO_PROXY=127.0.0.1,localhost.',
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,3 @@
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
import { chatwiseRequiredEnv } from './shared.js';
|
||||
|
||||
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop', { requiredEnv: chatwiseRequiredEnv });
|
||||
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 携程旅行搜索 — browser cookie, multi-strategy.
|
||||
* Source: bb-sites/ctrip/search.js (simplified to suggestion API)
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import type { CliCommand } from '../../registry.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
const { mockHttpDownload, mockLoadDoubanSubjectPhotos, mockMkdirSync } = vi.hoisted(() => ({
|
||||
mockHttpDownload: vi.fn(),
|
||||
mockLoadDoubanSubjectPhotos: vi.fn(),
|
||||
mockMkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../download/index.js', () => ({
|
||||
httpDownload: mockHttpDownload,
|
||||
sanitizeFilename: vi.fn((value: string) => value.replace(/\s+/g, '_')),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
|
||||
return {
|
||||
...actual,
|
||||
loadDoubanSubjectPhotos: mockLoadDoubanSubjectPhotos,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../download/progress.js', () => ({
|
||||
formatBytes: vi.fn((size: number) => `${size} B`),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
mkdirSync: mockMkdirSync,
|
||||
}));
|
||||
|
||||
await import('./download.js');
|
||||
|
||||
let cmd: CliCommand;
|
||||
|
||||
beforeAll(() => {
|
||||
cmd = getRegistry().get('douban/download')!;
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.replaceAll(path.sep, '/');
|
||||
}
|
||||
|
||||
describe('douban download', () => {
|
||||
beforeEach(() => {
|
||||
mockHttpDownload.mockReset();
|
||||
mockLoadDoubanSubjectPhotos.mockReset();
|
||||
mockMkdirSync.mockReset();
|
||||
});
|
||||
|
||||
it('downloads douban poster images and merges metadata into the result', async () => {
|
||||
const page = {} as IPage;
|
||||
mockLoadDoubanSubjectPhotos.mockResolvedValue({
|
||||
subjectId: '30382501',
|
||||
subjectTitle: 'The Wandering Earth 2',
|
||||
type: 'Rb',
|
||||
photos: [
|
||||
{
|
||||
index: 1,
|
||||
photoId: '2913450214',
|
||||
title: 'Main poster',
|
||||
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
|
||||
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450214.webp',
|
||||
detailUrl: 'https://movie.douban.com/photos/photo/2913450214/',
|
||||
page: 1,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
photoId: '2913450215',
|
||||
title: 'Character poster',
|
||||
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
|
||||
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
|
||||
detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
|
||||
page: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockHttpDownload
|
||||
.mockResolvedValueOnce({ success: true, size: 1200 })
|
||||
.mockResolvedValueOnce({ success: true, size: 980 });
|
||||
|
||||
const result = await cmd.func!(page, {
|
||||
id: '30382501',
|
||||
type: 'Rb',
|
||||
limit: 20,
|
||||
output: '/tmp/douban-test',
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(mockLoadDoubanSubjectPhotos).toHaveBeenCalledWith(page, '30382501', {
|
||||
type: 'Rb',
|
||||
limit: 20,
|
||||
});
|
||||
expect(mockMkdirSync).toHaveBeenCalledTimes(1);
|
||||
expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/douban-test/30382501');
|
||||
expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
|
||||
expect(mockHttpDownload).toHaveBeenCalledTimes(2);
|
||||
expect(mockHttpDownload.mock.calls[0]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
|
||||
expect(toPosixPath(mockHttpDownload.mock.calls[0]?.[1])).toBe('/tmp/douban-test/30382501/30382501_001_2913450214_Main_poster.webp');
|
||||
expect(mockHttpDownload.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
|
||||
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450214/' },
|
||||
timeout: 60000,
|
||||
}));
|
||||
expect(mockHttpDownload.mock.calls[1]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg');
|
||||
expect(toPosixPath(mockHttpDownload.mock.calls[1]?.[1])).toBe('/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg');
|
||||
expect(mockHttpDownload.mock.calls[1]?.[2]).toEqual(expect.objectContaining({
|
||||
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
|
||||
timeout: 60000,
|
||||
}));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
index: 1,
|
||||
title: 'Main poster',
|
||||
photo_id: '2913450214',
|
||||
image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
|
||||
detail_url: 'https://movie.douban.com/photos/photo/2913450214/',
|
||||
status: 'success',
|
||||
size: '1200 B',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: 'Character poster',
|
||||
photo_id: '2913450215',
|
||||
image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
|
||||
detail_url: 'https://movie.douban.com/photos/photo/2913450215/',
|
||||
status: 'success',
|
||||
size: '980 B',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('downloads only the requested photo when photo-id is provided', async () => {
|
||||
const page = {} as IPage;
|
||||
mockLoadDoubanSubjectPhotos.mockResolvedValue({
|
||||
subjectId: '30382501',
|
||||
subjectTitle: 'The Wandering Earth 2',
|
||||
type: 'Rb',
|
||||
photos: [
|
||||
{
|
||||
index: 2,
|
||||
photoId: '2913450215',
|
||||
title: 'Character poster',
|
||||
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
|
||||
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
|
||||
detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
|
||||
page: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockHttpDownload.mockResolvedValueOnce({ success: true, size: 980 });
|
||||
|
||||
const result = await cmd.func!(page, {
|
||||
id: '30382501',
|
||||
type: 'Rb',
|
||||
'photo-id': '2913450215',
|
||||
output: '/tmp/douban-test',
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(mockLoadDoubanSubjectPhotos).toHaveBeenCalledWith(page, '30382501', {
|
||||
type: 'Rb',
|
||||
targetPhotoId: '2913450215',
|
||||
});
|
||||
expect(mockHttpDownload).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpDownload.mock.calls[0]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg');
|
||||
expect(toPosixPath(mockHttpDownload.mock.calls[0]?.[1])).toBe('/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg');
|
||||
expect(mockHttpDownload.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
|
||||
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
|
||||
timeout: 60000,
|
||||
}));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
index: 2,
|
||||
title: 'Character poster',
|
||||
photo_id: '2913450215',
|
||||
image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
|
||||
detail_url: 'https://movie.douban.com/photos/photo/2913450215/',
|
||||
status: 'success',
|
||||
size: '980 B',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid subject ids before attempting browser work', async () => {
|
||||
await expect(
|
||||
cmd.func!({} as IPage, { id: 'movie-30382501' }),
|
||||
).rejects.toThrow('Invalid Douban subject ID');
|
||||
|
||||
expect(mockHttpDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { formatBytes } from '../../download/progress.js';
|
||||
import { httpDownload, sanitizeFilename } from '../../download/index.js';
|
||||
import { EmptyResultError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { DoubanSubjectPhoto, LoadDoubanSubjectPhotosOptions } from './utils.js';
|
||||
import { getDoubanPhotoExtension, loadDoubanSubjectPhotos, normalizeDoubanSubjectId } from './utils.js';
|
||||
|
||||
function buildDoubanPhotoFilename(subjectId: string, photo: DoubanSubjectPhoto): string {
|
||||
const index = String(photo.index).padStart(3, '0');
|
||||
const suffix = sanitizeFilename(photo.title || photo.photoId || 'photo', 80) || 'photo';
|
||||
return `${subjectId}_${index}_${photo.photoId || 'photo'}_${suffix}${getDoubanPhotoExtension(photo.imageUrl)}`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
name: 'download',
|
||||
description: '下载电影海报/剧照图片',
|
||||
domain: 'movie.douban.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: '电影 subject ID' },
|
||||
{ name: 'type', default: 'Rb', help: '豆瓣 photos 的 type 参数,默认 Rb(海报)' },
|
||||
{ name: 'limit', type: 'int', default: 120, help: '最多下载多少张图片' },
|
||||
{ name: 'photo-id', help: '只下载指定 photo_id 的图片' },
|
||||
{ name: 'output', default: './douban-downloads', help: '输出目录' },
|
||||
],
|
||||
columns: ['index', 'title', 'status', 'size'],
|
||||
func: async (page, kwargs) => {
|
||||
const subjectId = normalizeDoubanSubjectId(String(kwargs.id || ''));
|
||||
const output = String(kwargs.output || './douban-downloads');
|
||||
const requestedPhotoId = String(kwargs['photo-id'] || '').trim();
|
||||
const loadOptions: LoadDoubanSubjectPhotosOptions = {
|
||||
type: String(kwargs.type || 'Rb'),
|
||||
};
|
||||
if (requestedPhotoId) loadOptions.targetPhotoId = requestedPhotoId;
|
||||
else loadOptions.limit = Number(kwargs.limit) || 120;
|
||||
|
||||
const data = await loadDoubanSubjectPhotos(page, subjectId, loadOptions);
|
||||
|
||||
const photos = requestedPhotoId
|
||||
? data.photos.filter((photo) => photo.photoId === requestedPhotoId)
|
||||
: data.photos;
|
||||
|
||||
if (requestedPhotoId && !photos.length) {
|
||||
throw new EmptyResultError(
|
||||
'douban download',
|
||||
`Photo ID ${requestedPhotoId} was not found under subject ${subjectId}. Try "douban photos ${subjectId} -f json" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const outputDir = path.join(output, subjectId);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
for (const photo of photos) {
|
||||
const filename = buildDoubanPhotoFilename(subjectId, photo);
|
||||
const destPath = path.join(outputDir, filename);
|
||||
const result = await httpDownload(photo.imageUrl, destPath, {
|
||||
headers: { Referer: photo.detailUrl || `https://movie.douban.com/subject/${subjectId}/photos?type=${encodeURIComponent(String(kwargs.type || 'Rb'))}` },
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
results.push({
|
||||
index: photo.index,
|
||||
title: photo.title,
|
||||
photo_id: photo.photoId,
|
||||
image_url: photo.imageUrl,
|
||||
detail_url: photo.detailUrl,
|
||||
status: result.success ? 'success' : 'failed',
|
||||
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadDoubanSubjectPhotos, normalizeDoubanSubjectId } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
name: 'photos',
|
||||
description: '获取电影海报/剧照图片列表',
|
||||
domain: 'movie.douban.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: '电影 subject ID' },
|
||||
{ name: 'type', default: 'Rb', help: '豆瓣 photos 的 type 参数,默认 Rb(海报)' },
|
||||
{ name: 'limit', type: 'int', default: 120, help: '最多返回多少张图片' },
|
||||
],
|
||||
columns: ['index', 'title', 'image_url', 'detail_url'],
|
||||
func: async (page, kwargs) => {
|
||||
const subjectId = normalizeDoubanSubjectId(String(kwargs.id || ''));
|
||||
const data = await loadDoubanSubjectPhotos(page, subjectId, {
|
||||
type: String(kwargs.type || 'Rb'),
|
||||
limit: Number(kwargs.limit) || 120,
|
||||
});
|
||||
|
||||
return data.photos.map((photo) => ({
|
||||
subject_id: data.subjectId,
|
||||
subject_title: data.subjectTitle,
|
||||
type: data.type,
|
||||
index: photo.index,
|
||||
photo_id: photo.photoId,
|
||||
title: photo.title,
|
||||
image_url: photo.imageUrl,
|
||||
thumb_url: photo.thumbUrl,
|
||||
detail_url: photo.detailUrl,
|
||||
page: photo.page,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import {
|
||||
getDoubanPhotoExtension,
|
||||
loadDoubanSubjectPhotos,
|
||||
normalizeDoubanSubjectId,
|
||||
promoteDoubanPhotoUrl,
|
||||
resolveDoubanPhotoAssetUrl,
|
||||
} from './utils.js';
|
||||
|
||||
describe('douban utils', () => {
|
||||
it('normalizes valid subject ids', () => {
|
||||
expect(normalizeDoubanSubjectId(' 30382501 ')).toBe('30382501');
|
||||
});
|
||||
|
||||
it('rejects invalid subject ids', () => {
|
||||
expect(() => normalizeDoubanSubjectId('tt30382501')).toThrow('Invalid Douban subject ID');
|
||||
});
|
||||
|
||||
it('promotes thumbnail urls to large photo urls', () => {
|
||||
expect(
|
||||
promoteDoubanPhotoUrl('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp'),
|
||||
).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
|
||||
|
||||
expect(
|
||||
promoteDoubanPhotoUrl('https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2578474613.jpg'),
|
||||
).toBe('https://img9.doubanio.com/view/photo/l/public/p2578474613.jpg');
|
||||
});
|
||||
|
||||
it('rejects non-http photo urls during promotion', () => {
|
||||
expect(promoteDoubanPhotoUrl('data:image/gif;base64,abc')).toBe('');
|
||||
});
|
||||
|
||||
it('prefers lazy-loaded photo urls over data placeholders', () => {
|
||||
expect(
|
||||
resolveDoubanPhotoAssetUrl([
|
||||
'',
|
||||
'https://img1.doubanio.com/view/photo/m/public/p2913450214.webp',
|
||||
'data:image/gif;base64,abc',
|
||||
], 'https://movie.douban.com/subject/30382501/photos?type=Rb'),
|
||||
).toBe('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp');
|
||||
});
|
||||
|
||||
it('drops unsupported non-http photo urls when no real image url exists', () => {
|
||||
expect(
|
||||
resolveDoubanPhotoAssetUrl(
|
||||
['data:image/gif;base64,abc', 'blob:https://movie.douban.com/example'],
|
||||
'https://movie.douban.com/subject/30382501/photos?type=Rb',
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('removes the default photo cap when scanning for an exact photo id', async () => {
|
||||
const evaluate = vi.fn()
|
||||
.mockResolvedValueOnce({ blocked: false, title: 'Some Movie', href: 'https://movie.douban.com/subject/30382501/photos?type=Rb' })
|
||||
.mockResolvedValueOnce({
|
||||
subjectId: '30382501',
|
||||
subjectTitle: 'The Wandering Earth 2',
|
||||
type: 'Rb',
|
||||
photos: [
|
||||
{
|
||||
index: 731,
|
||||
photoId: '2913450215',
|
||||
title: 'Character poster',
|
||||
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
|
||||
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
|
||||
detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
|
||||
page: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
} as unknown as IPage;
|
||||
|
||||
await loadDoubanSubjectPhotos(page, '30382501', {
|
||||
type: 'Rb',
|
||||
targetPhotoId: '2913450215',
|
||||
});
|
||||
|
||||
const scanScript = evaluate.mock.calls[1]?.[0];
|
||||
expect(scanScript).toContain('const targetPhotoId = "2913450215";');
|
||||
expect(scanScript).toContain(`const limit = ${Number.MAX_SAFE_INTEGER};`);
|
||||
expect(scanScript).toContain('for (let pageIndex = 0; photos.length < limit; pageIndex += 1)');
|
||||
});
|
||||
|
||||
it('keeps image extensions when download urls contain query params', () => {
|
||||
expect(
|
||||
getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp?foo=1'),
|
||||
).toBe('.webp');
|
||||
expect(
|
||||
getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.jpeg'),
|
||||
).toBe('.jpeg');
|
||||
});
|
||||
});
|
||||
+1
-232
@@ -2,20 +2,13 @@
|
||||
* Douban adapter utilities.
|
||||
*/
|
||||
|
||||
import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
const DOUBAN_PHOTO_PAGE_SIZE = 30;
|
||||
const MAX_DOUBAN_PHOTOS = 500;
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 20, 50));
|
||||
}
|
||||
|
||||
function clampPhotoLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
|
||||
}
|
||||
|
||||
async function ensureDoubanReady(page: IPage): Promise<void> {
|
||||
const state = await page.evaluate(`
|
||||
(() => {
|
||||
@@ -34,230 +27,6 @@ async function ensureDoubanReady(page: IPage): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface DoubanSubjectPhoto {
|
||||
index: number;
|
||||
photoId: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
thumbUrl: string;
|
||||
detailUrl: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface DoubanSubjectPhotosResult {
|
||||
subjectId: string;
|
||||
subjectTitle: string;
|
||||
type: string;
|
||||
photos: DoubanSubjectPhoto[];
|
||||
}
|
||||
|
||||
export interface LoadDoubanSubjectPhotosOptions {
|
||||
type?: string;
|
||||
limit?: number;
|
||||
targetPhotoId?: string;
|
||||
}
|
||||
|
||||
export function normalizeDoubanSubjectId(subjectId: string): string {
|
||||
const normalized = String(subjectId || '').trim();
|
||||
if (!/^\d+$/.test(normalized)) {
|
||||
throw new ArgumentError(`Invalid Douban subject ID: ${subjectId}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function promoteDoubanPhotoUrl(url: string, size: 's' | 'm' | 'l' = 'l'): string {
|
||||
const normalized = String(url || '').trim();
|
||||
if (!normalized) return '';
|
||||
if (/^[a-z]+:/i.test(normalized) && !/^https?:/i.test(normalized)) return '';
|
||||
return normalized.replace(/\/view\/photo\/[^/]+\/public\//, `/view/photo/${size}/public/`);
|
||||
}
|
||||
|
||||
export function resolveDoubanPhotoAssetUrl(
|
||||
candidates: Array<string | null | undefined>,
|
||||
baseUrl = '',
|
||||
): string {
|
||||
for (const candidate of candidates) {
|
||||
const normalized = String(candidate || '').trim();
|
||||
if (!normalized) continue;
|
||||
|
||||
let resolved = normalized;
|
||||
try {
|
||||
resolved = baseUrl
|
||||
? new URL(normalized, baseUrl).toString()
|
||||
: new URL(normalized).toString();
|
||||
} catch {
|
||||
resolved = normalized;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getDoubanPhotoExtension(url: string): string {
|
||||
const normalized = String(url || '').trim();
|
||||
if (!normalized) return '.jpg';
|
||||
|
||||
try {
|
||||
const ext = new URL(normalized).pathname.match(/\.(jpe?g|png|gif|webp|avif|bmp)$/i)?.[0];
|
||||
return ext || '.jpg';
|
||||
} catch {
|
||||
const ext = normalized.match(/\.(jpe?g|png|gif|webp|avif|bmp)(?:$|[?#])/i)?.[0];
|
||||
return ext ? ext.replace(/[?#].*$/, '') : '.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDoubanSubjectPhotos(
|
||||
page: IPage,
|
||||
subjectId: string,
|
||||
options: LoadDoubanSubjectPhotosOptions = {},
|
||||
): Promise<DoubanSubjectPhotosResult> {
|
||||
const normalizedId = normalizeDoubanSubjectId(subjectId);
|
||||
const type = String(options.type || 'Rb').trim() || 'Rb';
|
||||
const targetPhotoId = String(options.targetPhotoId || '').trim();
|
||||
const safeLimit = targetPhotoId ? Number.MAX_SAFE_INTEGER : clampPhotoLimit(Number(options.limit) || 120);
|
||||
const resolvePhotoAssetUrlSource = resolveDoubanPhotoAssetUrl.toString();
|
||||
|
||||
const galleryUrl = `https://movie.douban.com/subject/${normalizedId}/photos?type=${encodeURIComponent(type)}`;
|
||||
await page.goto(galleryUrl);
|
||||
await page.wait(2);
|
||||
await ensureDoubanReady(page);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const subjectId = ${JSON.stringify(normalizedId)};
|
||||
const type = ${JSON.stringify(type)};
|
||||
const limit = ${safeLimit};
|
||||
const targetPhotoId = ${JSON.stringify(targetPhotoId)};
|
||||
const pageSize = ${DOUBAN_PHOTO_PAGE_SIZE};
|
||||
const resolveDoubanPhotoAssetUrl = ${resolvePhotoAssetUrlSource};
|
||||
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const toAbsoluteUrl = (value) => {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return new URL(value, location.origin).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const promotePhotoUrl = (value) => {
|
||||
const absolute = toAbsoluteUrl(value);
|
||||
if (!absolute) return '';
|
||||
if (/^[a-z]+:/i.test(absolute) && !/^https?:/i.test(absolute)) return '';
|
||||
return absolute.replace(/\\/view\\/photo\\/[^/]+\\/public\\//, '/view/photo/l/public/');
|
||||
};
|
||||
const buildPageUrl = (start) => {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('type', type);
|
||||
if (start > 0) url.searchParams.set('start', String(start));
|
||||
else url.searchParams.delete('start');
|
||||
return url.toString();
|
||||
};
|
||||
const getTitle = (doc) => {
|
||||
const raw = normalize(doc.querySelector('#content h1')?.textContent)
|
||||
|| normalize(doc.querySelector('title')?.textContent);
|
||||
return raw.replace(/\\s*\\(豆瓣\\)\\s*$/, '');
|
||||
};
|
||||
const extractPhotos = (doc, pageNumber) => {
|
||||
const nodes = Array.from(doc.querySelectorAll('.poster-col3 li, .poster-col3l li, .article li'));
|
||||
const rows = [];
|
||||
for (const node of nodes) {
|
||||
const link = node.querySelector('a[href*="/photos/photo/"]');
|
||||
const img = node.querySelector('img');
|
||||
if (!link || !img) continue;
|
||||
|
||||
const detailUrl = toAbsoluteUrl(link.getAttribute('href') || '');
|
||||
const photoId = detailUrl.match(/\\/photo\\/(\\d+)/)?.[1] || '';
|
||||
const thumbUrl = resolveDoubanPhotoAssetUrl([
|
||||
img.getAttribute('data-origin'),
|
||||
img.getAttribute('data-src'),
|
||||
img.getAttribute('src'),
|
||||
], location.href);
|
||||
const imageUrl = promotePhotoUrl(thumbUrl);
|
||||
const title = normalize(link.getAttribute('title'))
|
||||
|| normalize(img.getAttribute('alt'))
|
||||
|| (photoId ? 'photo_' + photoId : 'photo_' + String(rows.length + 1));
|
||||
|
||||
if (!detailUrl || !thumbUrl || !imageUrl) continue;
|
||||
|
||||
rows.push({
|
||||
photoId,
|
||||
title,
|
||||
imageUrl,
|
||||
thumbUrl,
|
||||
detailUrl,
|
||||
page: pageNumber,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const subjectTitle = getTitle(document);
|
||||
const seen = new Set();
|
||||
const photos = [];
|
||||
|
||||
for (let pageIndex = 0; photos.length < limit; pageIndex += 1) {
|
||||
let doc = document;
|
||||
if (pageIndex > 0) {
|
||||
const response = await fetch(buildPageUrl(pageIndex * pageSize), { credentials: 'include' });
|
||||
if (!response.ok) break;
|
||||
const html = await response.text();
|
||||
doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
}
|
||||
|
||||
const pagePhotos = extractPhotos(doc, pageIndex + 1);
|
||||
if (!pagePhotos.length) break;
|
||||
|
||||
let appended = 0;
|
||||
let foundTarget = false;
|
||||
for (const photo of pagePhotos) {
|
||||
const key = photo.photoId || photo.detailUrl || photo.imageUrl;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
photos.push({
|
||||
index: photos.length + 1,
|
||||
...photo,
|
||||
});
|
||||
appended += 1;
|
||||
if (targetPhotoId && photo.photoId === targetPhotoId) {
|
||||
foundTarget = true;
|
||||
break;
|
||||
}
|
||||
if (photos.length >= limit) break;
|
||||
}
|
||||
|
||||
if (foundTarget || pagePhotos.length < pageSize || appended === 0) break;
|
||||
}
|
||||
|
||||
return {
|
||||
subjectId,
|
||||
subjectTitle,
|
||||
type,
|
||||
photos,
|
||||
};
|
||||
})()
|
||||
`);
|
||||
|
||||
const photos = Array.isArray(data?.photos) ? data.photos : [];
|
||||
if (!photos.length) {
|
||||
throw new EmptyResultError(
|
||||
'douban photos',
|
||||
'No photos found. Try a different subject ID or a different --type value such as Rb.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
subjectId: normalizedId,
|
||||
subjectTitle: String(data?.subjectTitle || '').trim(),
|
||||
type,
|
||||
photos,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto('https://book.douban.com/chart');
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../../types.js';
|
||||
import { browserFetch } from './browser-fetch.js';
|
||||
|
||||
function makePage(result: unknown): IPage {
|
||||
return {
|
||||
goto: vi.fn(), evaluate: vi.fn().mockResolvedValue(result),
|
||||
getCookies: vi.fn(), snapshot: vi.fn(), click: vi.fn(),
|
||||
typeText: vi.fn(), pressKey: vi.fn(), scrollTo: vi.fn(),
|
||||
getFormState: vi.fn(), wait: vi.fn(), tabs: vi.fn(),
|
||||
closeTab: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(),
|
||||
networkRequests: vi.fn(), consoleMessages: vi.fn(),
|
||||
scroll: vi.fn(), autoScroll: vi.fn(),
|
||||
installInterceptor: vi.fn(), getInterceptedRequests: vi.fn(),
|
||||
screenshot: vi.fn(),
|
||||
} as unknown as IPage;
|
||||
}
|
||||
|
||||
describe('browserFetch', () => {
|
||||
it('returns parsed JSON on success', async () => {
|
||||
const page = makePage({ status_code: 0, data: { ak: 'KEY' } });
|
||||
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
|
||||
expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
|
||||
});
|
||||
|
||||
it('throws when status_code is non-zero', async () => {
|
||||
const page = makePage({ status_code: 8, message: 'fail' });
|
||||
await expect(
|
||||
browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')
|
||||
).rejects.toThrow('Douyin API error 8');
|
||||
});
|
||||
|
||||
it('returns result even when no status_code field', async () => {
|
||||
const page = makePage({ some_field: 'value' });
|
||||
const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
|
||||
expect(result).toEqual({ some_field: 'value' });
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { IPage } from '../../../types.js';
|
||||
import { CommandExecutionError } from '../../../errors.js';
|
||||
|
||||
export interface FetchOptions {
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a fetch() call inside the Chrome browser context via page.evaluate.
|
||||
* This ensures a_bogus signing and cookies are handled automatically by the browser.
|
||||
*/
|
||||
export async function browserFetch(
|
||||
page: IPage,
|
||||
method: 'GET' | 'POST',
|
||||
url: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<unknown> {
|
||||
const js = `
|
||||
(async () => {
|
||||
const res = await fetch(${JSON.stringify(url)}, {
|
||||
method: ${JSON.stringify(method)},
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...${JSON.stringify(options.headers ?? {})}
|
||||
},
|
||||
${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
|
||||
});
|
||||
return res.json();
|
||||
})()
|
||||
`;
|
||||
|
||||
const result = await page.evaluate(js);
|
||||
|
||||
if (result && typeof result === 'object' && 'status_code' in result) {
|
||||
const code = (result as { status_code: number }).status_code;
|
||||
if (code !== 0) {
|
||||
const msg = (result as { status_msg?: string }).status_msg ?? 'unknown error';
|
||||
throw new CommandExecutionError(`Douyin API error ${code}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user