Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8bc3e73f | |||
| c1a4bd3b7e | |||
| 5a839701ab | |||
| 68485cc54e | |||
| aa8d4b72f7 | |||
| e848594519 | |||
| 977105b0f6 | |||
| 413bbbe819 | |||
| 11ceca4fc2 | |||
| be5234ced1 | |||
| 1da105edea | |||
| ca25f65bf7 | |||
| 0f29790795 | |||
| 98062a21c9 | |||
| a353db5fbe | |||
| 1d407ab62f | |||
| 1b19b3ebe9 | |||
| d60e1cf43d | |||
| 1cd1253d46 | |||
| 7869bdb2ca | |||
| 2e93ac6e63 | |||
| de0d74bf62 | |||
| a9e0ca648f | |||
| bebc7aa35e | |||
| 061fba100d | |||
| e364ec6b9c | |||
| 765eb56c99 | |||
| 5f72770eff | |||
| 3017ca78aa | |||
| 7e68e19f0d | |||
| 4ceb3314fe | |||
| 5f0cce7b22 | |||
| 284c96133b | |||
| eac17b361e | |||
| aa33262ef7 | |||
| a0b2df1448 | |||
| fc7245f9f6 | |||
| 88bcd814ee | |||
| 2fd7272559 | |||
| c131b4b435 | |||
| f202d65f87 | |||
| 5c871dd7a3 | |||
| b41ee2b671 | |||
| 6c077237a8 | |||
| 8dd9d578d4 | |||
| d65cccd7d8 | |||
| c0aea6c1ae | |||
| 349b4bab49 | |||
| 094ff0da80 | |||
| 25e86532a3 | |||
| 8dee08bc4c | |||
| c86b6826a4 | |||
| c264531586 | |||
| 10baf02060 | |||
| dff3fd8950 | |||
| ff571fc965 | |||
| 08a2428306 | |||
| 02b3033954 | |||
| 141ec95c01 | |||
| bc9ae39cfc | |||
| 02dbbb1c18 | |||
| 07760d00ba | |||
| ac80c4962b | |||
| e2b595272b | |||
| 23beb9508c | |||
| 9cd247d74d | |||
| f099e4cb3a | |||
| a999dcec83 | |||
| ca8459c400 | |||
| 54ffc88283 | |||
| d9c96f7e3b | |||
| 0e9e1ce953 | |||
| 766677422d | |||
| c0a49e4b44 | |||
| 6827de4ab2 | |||
| 43873326c8 | |||
| c8eedee760 | |||
| 9870258075 | |||
| a3d0185afa | |||
| 9c2eb07037 | |||
| 60114f99ba | |||
| f88b965dc5 | |||
| 648390eacd | |||
| 733ac0747d | |||
| e83148a2c1 | |||
| 3ec98b9405 | |||
| 00d54135ad | |||
| 69d3a73390 | |||
| 5460a18d71 | |||
| dc724262f2 | |||
| 5935191e04 | |||
| 0710678986 | |||
| a6d1eca204 | |||
| 92efa38aba | |||
| 666a955fac | |||
| 2ad417949a | |||
| 9675f6262e | |||
| dba333d1f9 | |||
| 7c35935861 | |||
| d36bee04bb | |||
| 2f66d48a47 | |||
| 04a5a171d5 | |||
| f7fd805ef8 | |||
| b92755597c | |||
| b9bb3020a4 | |||
| b3db955da3 | |||
| 8a8f048a89 | |||
| acb08a4050 | |||
| 37020c4348 | |||
| 6cf5cb2f25 | |||
| 7fd8bd6fdc | |||
| 295c5237cb | |||
| 93395653f4 | |||
| 51e3ac4708 | |||
| 4d25b2b99e | |||
| a0b2155510 | |||
| afa5e6046c | |||
| 0f903f544b | |||
| 163974652e | |||
| be2c1cd452 | |||
| 1ecbf7a17c | |||
| 3bbea014e5 | |||
| 6b2f4cdc31 | |||
| fbdb1b24dc | |||
| fe59ee990c | |||
| bb21e7e831 | |||
| b65df6b1e2 | |||
| 0cd63562f2 | |||
| 02d637f3d2 | |||
| 44d87879d8 | |||
| cb9521d52d | |||
| 025df31ce5 | |||
| 8a8f4a1778 | |||
| ab44d9f542 | |||
| 4ebada6b99 | |||
| bc06d99c83 | |||
| 3738cd2595 | |||
| 240dccd754 | |||
| 44b4107f36 | |||
| 3076c12d6c | |||
| 44147e54c1 | |||
| 677e37b7a4 | |||
| d48c71b993 | |||
| 6fbeda951e | |||
| 9bcdaaa0be | |||
| db70a3aaf3 | |||
| 0040081f2b | |||
| 16d597cfce | |||
| ba3a674d7b | |||
| 0e38fd8c37 | |||
| cd48917a39 | |||
| 45d6f5b09f | |||
| 3ebc46f978 | |||
| 6e29845dc3 | |||
| 88bce1becf | |||
| ca68f3999b | |||
| 847c8317b6 | |||
| 741bcf9b6e | |||
| 44388d21fc | |||
| 745ce459d1 | |||
| a5cd0dc307 | |||
| beabed4bad | |||
| fa208ec761 | |||
| 56a727cc04 | |||
| feedaf93b4 | |||
| 9ebb921c89 | |||
| 9ac2e1d8ef | |||
| 2aee4caa10 | |||
| 323fe8857c | |||
| ff6563d12a | |||
| c42b040af4 | |||
| 79a15e8353 |
@@ -46,25 +46,20 @@ jobs:
|
||||
run: |
|
||||
EXT_VERSION=$(node -p "require('./extension/package.json').version")
|
||||
cd extension-package
|
||||
zip -r ../opencli-extension.zip .
|
||||
cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip
|
||||
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
|
||||
|
||||
- name: Upload Artifacts (Action Run)
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opencli-extension-build
|
||||
path: |
|
||||
opencli-extension.zip
|
||||
opencli-extension-v*.zip
|
||||
path: opencli-extension-v*.zip
|
||||
retention-days: 7
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v3.0.0
|
||||
with:
|
||||
files: |
|
||||
opencli-extension.zip
|
||||
opencli-extension-v*.zip
|
||||
files: opencli-extension-v*.zip
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
|
||||
@@ -38,6 +38,18 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# Guard: committed cli-manifest.json must match the one build regenerates.
|
||||
# Prevents silent drift where unrelated adapter entries vanish or change
|
||||
# across PRs (agent hits unexpected manifest diff → surgical-merge churn).
|
||||
- name: Check cli-manifest.json is up-to-date
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
if ! git diff --exit-code -- cli-manifest.json; then
|
||||
echo "::error::cli-manifest.json is out of sync with the source. Run 'npm run build' and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
|
||||
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
|
||||
@@ -136,12 +148,8 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -64,11 +64,7 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -26,6 +26,17 @@ jobs:
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Build before the manifest drift gate: adapter modules import
|
||||
# @jackwener/opencli/* through package exports, which resolve to dist/.
|
||||
# A fresh release checkout has no dist/ until the full build runs.
|
||||
- name: Build package and verify cli-manifest.json is up-to-date
|
||||
run: |
|
||||
npm run build
|
||||
if ! git diff --exit-code -- cli-manifest.json; then
|
||||
echo "::error::cli-manifest.json drift detected at release time. Run 'npm run build' locally and commit the result before tagging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install extension dependencies
|
||||
run: npm ci
|
||||
working-directory: extension
|
||||
@@ -40,17 +51,15 @@ jobs:
|
||||
|
||||
- name: Create extension ZIP
|
||||
run: |
|
||||
EXT_VERSION=$(node -p "require('./extension/package.json').version")
|
||||
EXT_VERSION=$(jq -r .version extension/package.json)
|
||||
cd extension-package
|
||||
zip -r ../opencli-extension.zip .
|
||||
cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip
|
||||
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v3.0.0
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
opencli-extension.zip
|
||||
opencli-extension-v*.zip
|
||||
|
||||
- name: Publish to npm
|
||||
|
||||
@@ -3,6 +3,7 @@ dist/
|
||||
!extension/dist/
|
||||
*.tsbuildinfo
|
||||
.opencli/
|
||||
.worktrees/
|
||||
.mcp.json
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
+129
-1
@@ -1,5 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
* **observation** — add trace artifact primitives, `browser console`, `browser network --since/--follow/--failed`, and adapter `--trace=retain-on-failure` for failure-retained browser evidence.
|
||||
* **autofix** — retire `OPENCLI_DIAGNOSTIC`; adapter repair now uses `--trace retain-on-failure`, trace `summary.md`, and error-envelope trace metadata.
|
||||
* **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929))
|
||||
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
|
||||
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
|
||||
|
||||
## [1.7.8](https://github.com/jackwener/opencli/compare/v1.7.7...v1.7.8) (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
* **powerchina** — procurement search adapter. ([#1155](https://github.com/jackwener/opencli/issues/1155))
|
||||
* **toutiao** — `articles` adapter for 头条号 creator dashboard. ([#1148](https://github.com/jackwener/opencli/issues/1148))
|
||||
* **weixin** — `create-draft` and `drafts` commands for Official Account. ([#1095](https://github.com/jackwener/opencli/issues/1095))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **chatgpt-app** — use AX send flow and support zh-CN generating state. ([#1135](https://github.com/jackwener/opencli/issues/1135))
|
||||
* **deepseek** — fix history titles and resume conversation on `ask`. ([#1153](https://github.com/jackwener/opencli/issues/1153))
|
||||
* **amazon** — fall back discussion to product page. ([#1154](https://github.com/jackwener/opencli/issues/1154))
|
||||
* **sinafinance** — match stock symbol in addition to name. ([#1158](https://github.com/jackwener/opencli/issues/1158))
|
||||
|
||||
### Chores
|
||||
|
||||
* **extension** — restore pre-1.6.8 neon terminal icons. ([#1177](https://github.com/jackwener/opencli/issues/1177))
|
||||
|
||||
## [1.7.7](https://github.com/jackwener/opencli/compare/v1.7.6...v1.7.7) (2026-04-23)
|
||||
|
||||
### Features
|
||||
|
||||
* **51job** — comprehensive adapter: `search`, `hot`, `detail`, `company`. ([#1132](https://github.com/jackwener/opencli/issues/1132))
|
||||
* **weread** — `ai-outline` command for AI-generated book outlines. ([#1141](https://github.com/jackwener/opencli/issues/1141))
|
||||
* **web/download** — video/audio/iframe download + `--stdout` streaming. ([#1146](https://github.com/jackwener/opencli/issues/1146))
|
||||
* **download** — hardened HTML→Markdown pipeline with better element handling. ([#1143](https://github.com/jackwener/opencli/issues/1143))
|
||||
* **verify** — fixture-based value validation + skill docs for COOKIE pitfalls. ([#1131](https://github.com/jackwener/opencli/issues/1131))
|
||||
* **agent-native retrospective** — analyze / verify guards / fixture content checks. ([#1133](https://github.com/jackwener/opencli/issues/1133))
|
||||
* **twitter** — expose `has_media` and `media_urls` columns. ([#1115](https://github.com/jackwener/opencli/issues/1115))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core** — quality audit fixes: elapsed=0 display, daemon error handler state reset, cause chain truncation guard, download cookie expiry, launcher async kill, verbose error logging. ([#1151](https://github.com/jackwener/opencli/issues/1151))
|
||||
* **daemon** — allow extension ping CORS for reachability probing. ([#1150](https://github.com/jackwener/opencli/issues/1150))
|
||||
* **deepseek** — separate thinking process from response in `--think` mode. ([#1142](https://github.com/jackwener/opencli/issues/1142))
|
||||
* **deepseek** — use position-based model selection instead of text matching. ([#1123](https://github.com/jackwener/opencli/issues/1123))
|
||||
* **weread/book** — add fallback selectors for reader page without cover. ([#1138](https://github.com/jackwener/opencli/issues/1138))
|
||||
* **xiaoyuzhou** — correct podcast-episodes API endpoint. ([#1129](https://github.com/jackwener/opencli/issues/1129))
|
||||
* **bilibili** — resolve full video URLs and preserve full description. ([#1118](https://github.com/jackwener/opencli/issues/1118))
|
||||
|
||||
### Docs
|
||||
|
||||
* Fix stale references in READMEs and autofix skill doc. ([#1130](https://github.com/jackwener/opencli/issues/1130))
|
||||
* Restore and rewrite `opencli-usage` as orientation skill. ([#1128](https://github.com/jackwener/opencli/issues/1128))
|
||||
|
||||
## [1.7.6](https://github.com/jackwener/opencli/compare/v1.7.5...v1.7.6) (2026-04-21)
|
||||
|
||||
Extension bumped to 1.0.2 (body-truncation signal unified across raw / detail / fallback paths).
|
||||
|
||||
### Features
|
||||
|
||||
* **Window lifecycle flags** — `--live` (or `OPENCLI_LIVE=1`) keeps the automation window open after a command finishes; `--focus` (or `OPENCLI_WINDOW_FOCUSED=1`) brings the window to the foreground. Works on any subcommand. ([#1122](https://github.com/jackwener/opencli/issues/1122))
|
||||
* **Selector-first browser interactions** — `find` / `get` / `click` / `type` / `select` accept CSS selectors in addition to numeric refs; `--nth` disambiguates multiple matches. ([#1112](https://github.com/jackwener/opencli/issues/1112))
|
||||
* **Agent-native browser payload** — structured `network` bodies with truncation signal, `get html --as json` with `--depth` / `--children-max` / `--text-max` budgets, new `browser extract` command for long-form content with resume cursor. ([#1104](https://github.com/jackwener/opencli/issues/1104))
|
||||
* **`network --filter <fields>`** — filter captured requests by body-shape path segments for quick API discovery. ([#1103](https://github.com/jackwener/opencli/issues/1103))
|
||||
* **`get html --as json`** — structured HTML tree output; no more silent truncation on raw `--as html`. ([#1102](https://github.com/jackwener/opencli/issues/1102))
|
||||
* **`browser network` rewrite** — agent-native discovery with cache keys and shape preview. ([#1100](https://github.com/jackwener/opencli/issues/1100))
|
||||
* **Compound form fields** — date / select / file controls surface a `compound` envelope with format, options, `accept`. Cascading stale-ref recovery + bbox 0.99 dedup for tagged elements. ([#1116](https://github.com/jackwener/opencli/issues/1116))
|
||||
* **twitter `tweets`** — fetch a user's recent posts. ([#1098](https://github.com/jackwener/opencli/issues/1098))
|
||||
* **bilibili `video`** — new video command. ([#1110](https://github.com/jackwener/opencli/issues/1110))
|
||||
* **deepseek `--file`** — file upload support on `ask`. ([#1093](https://github.com/jackwener/opencli/issues/1093))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **twitter** — 5s timeout on `resolveTwitterQueryId` to prevent hang. ([#1106](https://github.com/jackwener/opencli/issues/1106))
|
||||
* **youtube** — fall back to Videos tab when Home has no videos. ([#1109](https://github.com/jackwener/opencli/issues/1109))
|
||||
* **jianyu** — keep accessible detail urls in search. ([#1099](https://github.com/jackwener/opencli/issues/1099))
|
||||
* **jianyu** — block inaccessible detail links and verification pages. ([#918](https://github.com/jackwener/opencli/issues/918))
|
||||
|
||||
### Docs
|
||||
|
||||
* **opencli-browser skill** — restored and upgraded for selector-first workflow. ([#1119](https://github.com/jackwener/opencli/issues/1119))
|
||||
* **Window lifecycle** — sync README + skill docs with `--live` / `--focus` behavior. ([#1125](https://github.com/jackwener/opencli/issues/1125))
|
||||
|
||||
### Extension (1.0.2)
|
||||
|
||||
* Unify body-truncation contract across raw / detail / fallback network paths; surface `body_truncated` / `body_full_size` / `body_truncation_reason`. ([#1104](https://github.com/jackwener/opencli/issues/1104))
|
||||
|
||||
## [1.7.5](https://github.com/jackwener/opencli/compare/v1.7.4...v1.7.5) (2026-04-20)
|
||||
|
||||
Extension bumped to 1.0.1 (multi-tab routing + cross-origin iframe).
|
||||
|
||||
### Features
|
||||
|
||||
* **DeepSeek adapter** — browser-based `ask` / `history` / `new` / `read` / `status` ([#1088](https://github.com/jackwener/opencli/issues/1088))
|
||||
* **Eastmoney adapters** — 13 finance adapters as Phase A oracle: `quote`, `rank`, `kline`, `sectors`, `etf`, `holders`, `money-flow`, `northbound`, `longhu`, `kuaixun`, `convertible`, `index-board`, `announcement` ([#1091](https://github.com/jackwener/opencli/issues/1091))
|
||||
* **Twitter GraphQL lists** — `list-tweets`, `list-add`, `list-remove` ([#1076](https://github.com/jackwener/opencli/issues/1076))
|
||||
* **nowcoder adapter** — 牛客网 with 16 commands ([#1036](https://github.com/jackwener/opencli/issues/1036))
|
||||
* **Chinese academic & policy adapters** — `baidu-scholar`, `google-scholar`, `wanfang`, `gov-law`, `gov-policy` ([#243](https://github.com/jackwener/opencli/issues/243))
|
||||
* **Download saved path** — `web read` and `weixin download` now show saved file location ([#1042](https://github.com/jackwener/opencli/issues/1042))
|
||||
* **Cross-origin iframe support** — CDP execution context for iframed content ([#1084](https://github.com/jackwener/opencli/issues/1084))
|
||||
|
||||
### Improvements
|
||||
|
||||
* **Multi-tab routing** — hardened target isolation and tab routing ([#1072](https://github.com/jackwener/opencli/issues/1072))
|
||||
* **Skill consolidation** — 6 skills merged into 3 (`opencli-adapter-author`, `opencli-autofix`, `smart-search`); removed mechanical commands `explore` / `synthesize` / `generate` / `cascade` / `record` ([#1094](https://github.com/jackwener/opencli/issues/1094))
|
||||
* **Browser docs rewrite** — docs reoriented for AI Agent use case ([#1080](https://github.com/jackwener/opencli/issues/1080))
|
||||
* **antigravity serve** — configurable timeout + auto-reconnect ([#859](https://github.com/jackwener/opencli/issues/859), [#1063](https://github.com/jackwener/opencli/issues/1063))
|
||||
* **Design debt cleanup** — deprecated APIs, arg validation, dead plugin code ([#1065](https://github.com/jackwener/opencli/issues/1065))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **xiaoyuzhou** — migrate from broken SSR to authenticated API ([#1059](https://github.com/jackwener/opencli/issues/1059)); accept `CONFIG_ERROR` in E2E guard ([#1066](https://github.com/jackwener/opencli/issues/1066))
|
||||
* **xiaohongshu** — detect draft save success ([#1060](https://github.com/jackwener/opencli/issues/1060)); verify title input sticks on publish ([#1050](https://github.com/jackwener/opencli/issues/1050))
|
||||
* **twitter** — repair lists scraping from detail pages ([#1053](https://github.com/jackwener/opencli/issues/1053))
|
||||
* **zsxq** — separate content from title, remove title truncation ([#1079](https://github.com/jackwener/opencli/issues/1079))
|
||||
* **extension** — per-workspace idle timeout for browser sessions ([#1064](https://github.com/jackwener/opencli/issues/1064))
|
||||
|
||||
### Revert
|
||||
|
||||
* Undo output renderer table-formatting patch ([#1085](https://github.com/jackwener/opencli/issues/1085), reverts [#1081](https://github.com/jackwener/opencli/issues/1081))
|
||||
|
||||
### Extension (1.0.1)
|
||||
|
||||
* Multi-tab routing support ([#1072](https://github.com/jackwener/opencli/issues/1072))
|
||||
* Cross-origin iframe CDP contexts ([#1084](https://github.com/jackwener/opencli/issues/1084))
|
||||
|
||||
## [1.7.0](https://github.com/jackwener/opencli/compare/v1.6.1...v1.7.0) (2026-04-11)
|
||||
|
||||
This is a major release with significant internal architecture changes.
|
||||
@@ -84,7 +212,7 @@ Adapter code, validation, and error handling have been modernized.
|
||||
1. **Update Node.js** to v21 or later (v22 LTS recommended).
|
||||
2. **Run `npm install -g @jackwener/opencli@latest`** — the preuninstall hook gracefully stops the old daemon; the first browser command after upgrade auto-restarts it.
|
||||
3. **If you have custom `.ts` adapters** in `~/.opencli/clis/`, rename or compile them to `.js`. A warning will be printed on startup if stale `.ts` files are detected.
|
||||
4. **If you have custom `.yaml` adapters**, convert them to JS using the `cli()` API (see `skills/opencli-explorer/references/adapter-templates.md`).
|
||||
4. **If you have custom `.yaml` adapters**, convert them to JS using the `cli()` API (see `skills/opencli-adapter-author/references/adapter-template.md`).
|
||||
5. **If you parse error output from stdout**, switch to stderr. Errors are now structured YAML envelopes with typed exit codes.
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ cli({
|
||||
});
|
||||
```
|
||||
|
||||
Use `opencli explore <url>` to discover APIs and see [opencli-explorer skill](./skills/opencli-explorer/SKILL.md) if you need the full adapter workflow.
|
||||
Install the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md) if you need the full adapter workflow — recon → API discovery → field decoding → `opencli browser verify`.
|
||||
|
||||
### Validate Your Adapter
|
||||
|
||||
|
||||
@@ -11,29 +11,22 @@
|
||||
OpenCLI gives you one surface for three different kinds of automation:
|
||||
|
||||
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
|
||||
- **Drive a live browser directly** with `opencli browser` when an AI agent needs to click, type, extract, or inspect a page in real time.
|
||||
- **Generate new adapters** from real browser behavior with `explore`, `synthesize`, `generate`, and `cascade`.
|
||||
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
|
||||
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
|
||||
|
||||
It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion.
|
||||
|
||||
## Why OpenCLI
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **Browser Automation** — `browser` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 87+ pre-built adapters, or crystallize your own with `opencli record`.
|
||||
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
|
||||
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type, extract, screenshot — all through your logged-in Chrome session.
|
||||
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
|
||||
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
|
||||
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `browser` controls the browser directly.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.js` adapters into the `clis/` folder for auto-registration.
|
||||
- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification.
|
||||
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, etc).
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
|
||||
- **Broad coverage** — 87+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +34,10 @@ It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other
|
||||
|
||||
### 1. Install OpenCLI
|
||||
|
||||
OpenCLI requires **Node.js >= 21**.
|
||||
|
||||
```bash
|
||||
node --version
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
@@ -49,7 +45,11 @@ npm install -g @jackwener/opencli
|
||||
|
||||
OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extension plus a small local daemon. The daemon auto-starts when needed.
|
||||
|
||||
1. Download the latest `opencli-extension.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
|
||||
**Option A — Chrome Web Store (recommended):**
|
||||
Install **OpenCLI** from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk).
|
||||
|
||||
**Option B — Manual install:**
|
||||
1. Download the latest `opencli-extension-v{version}.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
|
||||
2. Unzip it, open `chrome://extensions`, and enable **Developer mode**.
|
||||
3. Click **Load unpacked** and select the unzipped folder.
|
||||
|
||||
@@ -59,7 +59,20 @@ OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extensi
|
||||
opencli doctor
|
||||
```
|
||||
|
||||
### 4. Run your first commands
|
||||
### 4. Optional: name your Chrome profile
|
||||
|
||||
Each Chrome profile runs its own OpenCLI extension instance. If you use multiple Chrome profiles, list the connected profiles and assign local aliases:
|
||||
|
||||
```bash
|
||||
opencli profile list
|
||||
opencli profile rename <contextId> work
|
||||
opencli profile use work
|
||||
opencli --profile work browser state
|
||||
```
|
||||
|
||||
With only one connected profile, OpenCLI uses it automatically. With multiple connected profiles and no default, OpenCLI asks you to choose instead of guessing.
|
||||
|
||||
### 5. Run your first commands
|
||||
|
||||
```bash
|
||||
opencli list
|
||||
@@ -73,17 +86,26 @@ Use OpenCLI directly when you want a reliable command instead of a live browser
|
||||
|
||||
- `opencli list` shows every registered command.
|
||||
- `opencli <site> <command>` runs a built-in or generated adapter.
|
||||
- `opencli register mycli` exposes a local CLI through the same discovery surface.
|
||||
- `opencli external register mycli` exposes a local CLI through the same discovery surface.
|
||||
- `opencli doctor` helps diagnose browser connectivity.
|
||||
|
||||
## Extending OpenCLI
|
||||
|
||||
If you want to add your own commands, start with the [Extending OpenCLI guide](./docs/guide/extending-opencli.md). README keeps this short; the guide covers the directory layout, source-control model, and install commands.
|
||||
|
||||
| Need | Recommended path |
|
||||
|------|------------------|
|
||||
| Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
||||
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
|
||||
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
||||
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
|
||||
| Wrap an existing local binary | `opencli external register <name>` |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
Use two different entry points depending on the task:
|
||||
OpenCLI's browser commands are designed to be used by AI Agents — not run manually. Install skills into your AI agent (Claude Code, Cursor, etc.), and the agent operates websites on your behalf using your logged-in Chrome session.
|
||||
|
||||
- [`skills/opencli-explorer/SKILL.md`](./skills/opencli-explorer/SKILL.md): the entry point for creating new adapters — supports both fully automated generation (`opencli generate <url>`) and manual exploration workflows.
|
||||
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md): the low-level control surface for live browsing, debugging, and manual intervention.
|
||||
|
||||
Install the packaged skills with:
|
||||
### Install skills
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli
|
||||
@@ -92,40 +114,68 @@ npx skills add jackwener/opencli
|
||||
Or install only what you need:
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill opencli-adapter-author
|
||||
npx skills add jackwener/opencli --skill opencli-autofix
|
||||
npx skills add jackwener/opencli --skill opencli-browser
|
||||
npx skills add jackwener/opencli --skill opencli-explorer
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
In practice:
|
||||
### Which skill to use
|
||||
|
||||
- start with `opencli-explorer` when the agent needs a reusable command for a site (it covers both automated and manual flows)
|
||||
- use `opencli-browser` when the agent needs to inspect or steer the page directly
|
||||
| Skill | When to use | Example prompt to your AI agent |
|
||||
|-------|------------|-------------------------------|
|
||||
| **opencli-adapter-author** | Operate a site in real time, or write a reusable adapter for a new site | "Help me check my Xiaohongshu notifications" / "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
|
||||
| **opencli-autofix** | Repair a broken adapter when a built-in command fails | "`opencli zhihu hot` is returning empty — fix it" |
|
||||
| **opencli-browser** | Browser automation reference for AI agents | "Use browser commands to scrape this page" |
|
||||
| **opencli-usage** | Quick reference for all OpenCLI commands and sites | "What commands does OpenCLI have for Twitter?" |
|
||||
| **smart-search** | Search across existing OpenCLI capabilities | "Find me a Bilibili trending adapter" |
|
||||
|
||||
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, and `close`.
|
||||
### How it works
|
||||
|
||||
Once `opencli-adapter-author` is installed, your AI agent can:
|
||||
|
||||
1. **Navigate** to any URL using your logged-in browser
|
||||
2. **Read** page content via structured DOM snapshots (not screenshots)
|
||||
3. **Interact** — click buttons, fill forms, select options, press keys
|
||||
4. **Extract** data from the page or intercept network API responses
|
||||
5. **Wait** for elements, text, or page transitions
|
||||
|
||||
The agent handles all the `opencli browser` commands internally — you just describe what you want done in natural language.
|
||||
|
||||
**Skill references:**
|
||||
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — browser operation + adapter authoring, end-to-end
|
||||
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — repair broken adapters
|
||||
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — browser automation reference
|
||||
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
|
||||
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — capability search
|
||||
|
||||
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
|
||||
|
||||
`opencli browser open <url>` and `opencli browser tab new [url]` both return a target ID. Use `opencli browser tab list` to inspect the target IDs of tabs that already exist, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted `opencli browser ...` commands.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### `browser`: live control
|
||||
### `browser`: AI Agent browser control
|
||||
|
||||
Use `opencli browser` when the task is inherently interactive and the agent needs to operate the page directly.
|
||||
`opencli browser` commands are the low-level primitives that AI Agents use to operate websites. You don't run these manually — instead, install the `opencli-adapter-author` skill into your AI agent, describe what you want in natural language, and the agent handles the browser operations.
|
||||
|
||||
For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser open`, `state`, `click`, etc. under the hood.
|
||||
|
||||
### Built-in adapters: stable commands
|
||||
|
||||
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists and you want deterministic output.
|
||||
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists. These are deterministic and work without browser — ideal for both humans and AI agents.
|
||||
|
||||
### `explore` / `synthesize` / `generate`: create new CLIs
|
||||
### Writing a new adapter
|
||||
|
||||
Use these commands when the site you need is not covered yet:
|
||||
When the site you need is not yet covered, use the `opencli-adapter-author` skill. It takes the agent end-to-end:
|
||||
|
||||
- `explore` inspects the page, network activity, and capability surface.
|
||||
- `synthesize` turns exploration artifacts into evaluate-based JS adapters.
|
||||
- `generate` runs the verified generation path and returns either a usable command or a structured explanation of why completion was blocked or needs human review.
|
||||
|
||||
### `cascade`: auth strategy discovery
|
||||
|
||||
Use `cascade` to probe fallback auth paths such as public endpoints, cookies, and custom headers before you commit to an adapter design.
|
||||
1. Recon the site and classify its pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
|
||||
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
|
||||
4. Decode response fields and design output columns.
|
||||
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
||||
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
|
||||
|
||||
### CLI Hub and desktop adapters
|
||||
|
||||
@@ -136,7 +186,8 @@ OpenCLI is not only for websites. It can also:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 21.0.0 (or **Bun** >= 1.0)
|
||||
- **Node.js**: >= 21.0.0 (required for the standard npm install path)
|
||||
- **Bun**: >= 1.0 (optional alternative runtime)
|
||||
- **Chrome or Chromium** running and logged into the target site for browser-backed commands
|
||||
|
||||
> **Important**: Browser-backed commands reuse your Chrome/Chromium login session. If you get empty data or permission-like failures, first confirm the site is already open and authenticated in Chrome/Chromium.
|
||||
@@ -146,18 +197,18 @@ OpenCLI is not only for websites. It can also:
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge |
|
||||
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open automation windows in the foreground (useful for debugging) |
|
||||
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
|
||||
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open the automation container in the foreground (useful for debugging). The `--focus` flag sets this. |
|
||||
| `OPENCLI_LIVE` | `false` | Set to `1` to keep the automation lease open after an adapter command finishes (useful for inspection). The `--live` flag sets this. |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
|
||||
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | `120` | Seconds to wait for explore/record operations |
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
|
||||
| `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) |
|
||||
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
|
||||
| `OPENCLI_DIAGNOSTIC` | `false` | Set to `1` to capture structured diagnostic context on failures |
|
||||
| `OUTPUT` | — | Override output format: `json`, `yaml`, or `table` |
|
||||
| `DEBUG` | — | Set to `opencli` for internal debug logging |
|
||||
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
|
||||
|
||||
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation lease open until you run `opencli browser close` or the idle timeout expires.
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
@@ -170,10 +221,11 @@ npx skills add jackwener/opencli
|
||||
Or refresh only the skills you actually use:
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill opencli-adapter-author
|
||||
npx skills add jackwener/opencli --skill opencli-autofix
|
||||
npx skills add jackwener/opencli --skill opencli-browser
|
||||
npx skills add jackwener/opencli --skill opencli-explorer
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
## For Developers
|
||||
@@ -198,24 +250,36 @@ To load the source Browser Bridge extension:
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
|
||||
| **tieba** | `hot` `posts` `search` `read` |
|
||||
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
|
||||
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
||||
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
|
||||
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
|
||||
| **1688** | `search` `item` `assets` `download` `store` |
|
||||
| **gitee** | `trending` `search` `user` |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
|
||||
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
|
||||
| **yuanbao** | `new` `ask` |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
|
||||
| **xianyu** | `search` `item` `chat` |
|
||||
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
|
||||
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
|
||||
| **uiverse** | `code` `preview` |
|
||||
| **baidu-scholar** | `search` |
|
||||
| **google-scholar** | `search` `cite` `profile` |
|
||||
| **gov-law** | `search` `recent` |
|
||||
| **gov-policy** | `search` `recent` |
|
||||
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` |
|
||||
| **wanfang** | `search` |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
|
||||
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
|
||||
|
||||
87+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
100+ site surfaces in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
`*` `opencli xiaoyuzhou podcast`, `podcast-episodes`, `episode`, `download`, and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
|
||||
|
||||
## CLI Hub
|
||||
|
||||
@@ -227,14 +291,14 @@ OpenCLI acts as a universal hub for your existing command-line tools — unified
|
||||
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker | `opencli docker ps` |
|
||||
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
|
||||
| **dingtalk** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dingtalk msg send --to user "hello"` |
|
||||
| **wecom** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom msg send --to user "hello"` |
|
||||
| **dws** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dws msg send --to user "hello"` |
|
||||
| **wecom-cli** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom-cli msg send --to user "hello"` |
|
||||
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
|
||||
|
||||
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
|
||||
|
||||
```bash
|
||||
opencli register mycli
|
||||
opencli external register mycli
|
||||
```
|
||||
|
||||
### Desktop App Adapters
|
||||
@@ -246,7 +310,7 @@ Control Electron desktop apps directly from the terminal. Each adapter has its o
|
||||
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
|
||||
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
|
||||
| **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
|
||||
| **ChatGPT** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt.md) |
|
||||
| **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
|
||||
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
|
||||
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
|
||||
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
|
||||
@@ -266,18 +330,24 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
|
||||
| **douban** | Images | Poster / still image lists |
|
||||
| **pixiv** | Images | Original-quality illustrations, multi-page |
|
||||
| **1688** | Images, Videos | Downloads page-visible product media from item pages |
|
||||
| **xiaoyuzhou** | Audio, Transcript | Downloads episode audio and transcript JSON/text with local credentials |
|
||||
| **zhihu** | Articles (Markdown) | Exports with optional image download |
|
||||
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
|
||||
|
||||
For video downloads, install `yt-dlp` first: `brew install yt-dlp`
|
||||
|
||||
```bash
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
|
||||
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
opencli 1688 download 841141931191 --output ./1688-downloads
|
||||
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
|
||||
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
|
||||
```
|
||||
|
||||
`opencli xiaoyuzhou download` and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
|
||||
|
||||
## Output Formats
|
||||
|
||||
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
|
||||
@@ -306,8 +376,8 @@ opencli follows Unix `sysexits.h` conventions so it integrates naturally with sh
|
||||
|
||||
```bash
|
||||
opencli spotify status || echo "exit $?" # 69 if browser not running
|
||||
opencli github issues 2>/dev/null
|
||||
[ $? -eq 77 ] && opencli github auth # auto-auth if not logged in
|
||||
opencli gh issue list 2>/dev/null
|
||||
[ $? -eq 77 ] && opencli gh auth login # auto-auth if not logged in
|
||||
```
|
||||
|
||||
## Plugins
|
||||
@@ -332,16 +402,15 @@ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
|
||||
|
||||
## For AI Agents (Developer Guide)
|
||||
|
||||
> **Quick mode**: To generate a single command for a specific page URL, see [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — just a URL + one-line goal, 4 steps done.
|
||||
Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end:
|
||||
|
||||
> **Full mode**: Before writing any adapter code, read [opencli-explorer skill](./skills/opencli-explorer/SKILL.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
|
||||
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
|
||||
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
|
||||
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
|
||||
- Verify with `opencli browser verify <site>/<name>` before shipping.
|
||||
|
||||
```bash
|
||||
opencli explore https://example.com --site mysite # Discover APIs + capabilities
|
||||
opencli synthesize mysite # Generate JS adapters
|
||||
opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
|
||||
opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
|
||||
```
|
||||
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -349,10 +418,10 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions` in Chrome or Chromium.
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) and **enabled** in `chrome://extensions`.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
|
||||
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
|
||||
- **Node API errors** — Ensure Node.js >= 21. Some features require `node:util` styleText (stable in Node 21+).
|
||||
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 21**. Run `node --version`, upgrade Node if needed, then retry.
|
||||
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
|
||||
|
||||
## Star History
|
||||
|
||||
+136
-81
@@ -10,26 +10,31 @@
|
||||
|
||||
OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
|
||||
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [87+ 站点](#内置命令) 开箱即用。
|
||||
- **直接驱动浏览器**:用 `opencli browser` 让 AI Agent 实时点击、输入、提取、截图、检查页面状态。
|
||||
- **把新网站生成成 CLI**:通过 `explore`、`synthesize`、`generate`、`cascade` 从真实页面行为推导出新的适配器。
|
||||
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
|
||||
- **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入、提取任意网页内容。
|
||||
- **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。
|
||||
|
||||
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
|
||||
|
||||
## 为什么是 OpenCLI
|
||||
## 亮点
|
||||
|
||||
- **同一个心智模型**:网站、浏览器自动化、Electron 应用、本地 CLI 都走同一个入口。
|
||||
- **复用真实会话**:浏览器命令直接使用你已经登录的 Chrome/Chromium,而不是重新造一套认证。
|
||||
- **输出稳定**:适配器命令返回固定结构,适合 shell、脚本、CI 和 AI Agent 工具调用。
|
||||
- **面向 AI Agent**:`browser` 负责实时操作,`explore` 负责探索接口,`synthesize` 负责生成适配器,`cascade` 负责探测认证路径。
|
||||
- **运行成本低**:已有命令运行时不消耗模型 token。
|
||||
- **天然可扩展**:既能用内置能力,也能注册本地 CLI,或直接往 `clis/` 丢 `.js` 适配器。
|
||||
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)。
|
||||
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入、提取、截图——全部通过你的已登录 Chrome 会话完成。
|
||||
- **网站 → CLI** — 把任何网站变成确定性 CLI:100+ 站点能力已注册,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
|
||||
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
|
||||
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
|
||||
- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian 等)。
|
||||
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
|
||||
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 OpenCLI
|
||||
|
||||
OpenCLI 要求 **Node.js >= 21**。
|
||||
|
||||
```bash
|
||||
node --version
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
@@ -37,7 +42,11 @@ npm install -g @jackwener/opencli
|
||||
|
||||
OpenCLI 通过轻量 Browser Bridge 扩展和本地微型 daemon 与 Chrome/Chromium 通信。daemon 会按需自动启动。
|
||||
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`。
|
||||
**方式 A — Chrome Web Store(推荐):**
|
||||
在 [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) 安装 **OpenCLI** 扩展。
|
||||
|
||||
**方式 B — 手动安装:**
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension-v{version}.zip`。
|
||||
2. 解压后打开 `chrome://extensions`,启用 **开发者模式**。
|
||||
3. 点击 **加载已解压的扩展程序**,选择解压后的目录。
|
||||
|
||||
@@ -61,17 +70,26 @@ opencli bilibili hot --limit 5
|
||||
|
||||
- `opencli list` 查看当前所有命令
|
||||
- `opencli <site> <command>` 调用内置或生成好的适配器
|
||||
- `opencli register mycli` 把本地 CLI 接入同一发现入口
|
||||
- `opencli external register mycli` 把本地 CLI 接入同一发现入口
|
||||
- `opencli doctor` 处理浏览器连通性问题
|
||||
|
||||
## 扩展 OpenCLI
|
||||
|
||||
如果你想新增自己的命令,先看 [扩展 OpenCLI](./docs/zh/guide/extending-opencli.md)。README 只保留入口;目录结构、源码管理方式和安装命令放在文档里。
|
||||
|
||||
| 需求 | 推荐路径 |
|
||||
|------|----------|
|
||||
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
||||
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
|
||||
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
||||
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
|
||||
| 包装已有本机 binary | `opencli external register <name>` |
|
||||
|
||||
## 给 AI Agent
|
||||
|
||||
按任务类型,AI Agent 有两个不同入口:
|
||||
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI Agent(Claude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
|
||||
|
||||
- [`skills/opencli-explorer/SKILL.md`](./skills/opencli-explorer/SKILL.md):适配器创建入口,支持全自动生成(`opencli generate <url>`)和手动探索两种流程。
|
||||
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md):底层控制入口,适合实时操作页面、debug 和人工介入。
|
||||
|
||||
安装全部 OpenCLI skills:
|
||||
### 安装 skill
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli
|
||||
@@ -80,40 +98,68 @@ npx skills add jackwener/opencli
|
||||
或只装需要的 skill:
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill opencli-adapter-author
|
||||
npx skills add jackwener/opencli --skill opencli-autofix
|
||||
npx skills add jackwener/opencli --skill opencli-browser
|
||||
npx skills add jackwener/opencli --skill opencli-explorer
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
实际使用上:
|
||||
### 选择哪个 skill
|
||||
|
||||
- 需要把某个站点收成可复用命令时,优先走 `opencli-explorer`(涵盖自动和手动两种路径)
|
||||
- 需要直接检查页面、操作页面时,再走 `opencli-browser`
|
||||
| Skill | 适用场景 | 你对 AI Agent 说的话 |
|
||||
|-------|---------|-------------------|
|
||||
| **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
|
||||
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
|
||||
| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" |
|
||||
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
|
||||
| **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" |
|
||||
|
||||
`browser` 可用命令包括:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`screenshot`、`scroll`、`back`、`eval`、`network`、`init`、`verify`、`close`。
|
||||
### 工作原理
|
||||
|
||||
安装 `opencli-adapter-author` skill 后,你的 AI Agent 可以:
|
||||
|
||||
1. **导航**到任意 URL,使用你的已登录浏览器
|
||||
2. **读取**页面内容——通过结构化 DOM 快照(不是截图)
|
||||
3. **交互**——点击按钮、填写表单、选择选项、按键
|
||||
4. **提取**页面数据或拦截网络 API 响应
|
||||
5. **等待**元素、文本或页面跳转
|
||||
|
||||
Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自然语言描述想做的事。
|
||||
|
||||
**Skill 参考文档:**
|
||||
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 浏览器操作 + 适配器编写,全流程
|
||||
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — 修复已有适配器
|
||||
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 浏览器自动化参考
|
||||
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
|
||||
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — 能力搜索
|
||||
|
||||
`browser` 可用命令包括:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`find`、`extract`、`frames`、`screenshot`、`scroll`、`back`、`eval`、`network`、`tab list`、`tab new`、`tab select`、`tab close`、`init`、`verify`、`close`。
|
||||
|
||||
`opencli browser open <url>` 和 `opencli browser tab new [url]` 都会返回 target ID。`opencli browser tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为后续未指定 target 的 `opencli browser ...` 命令的默认目标。
|
||||
|
||||
## 核心概念
|
||||
|
||||
### `browser`:实时操作
|
||||
### `browser`:AI Agent 的浏览器控制层
|
||||
|
||||
当任务本身就是交互式页面操作时,使用 `opencli browser` 直接驱动浏览器。
|
||||
`opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作。
|
||||
|
||||
比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser open`、`state`、`click` 等命令。
|
||||
|
||||
### 内置适配器:稳定命令
|
||||
|
||||
当某个站点能力已经存在时,优先使用 `opencli hackernews top`、`opencli reddit hot` 这类稳定命令,而不是重新走一遍浏览器操作。
|
||||
当某个站点能力已经存在时,优先使用 `opencli hackernews top`、`opencli reddit hot` 这类稳定命令。这些命令是确定性的,无需浏览器——人类和 AI Agent 都可以直接使用。
|
||||
|
||||
### `explore` / `synthesize` / `generate`:生成新的 CLI
|
||||
### 为新站点写适配器
|
||||
|
||||
当你需要的网站还没覆盖时:
|
||||
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,它会把 Agent 带到闭环:
|
||||
|
||||
- `explore` 负责观察页面、网络请求和能力边界
|
||||
- `synthesize` 负责把探索结果转成 evaluate-based YAML 适配器
|
||||
- `generate` 负责跑通 verified generation 主链路,最后要么给出可直接使用的命令,要么返回结构化的阻塞原因 / 人工介入结果
|
||||
|
||||
### `cascade`:认证策略探测
|
||||
|
||||
用 `cascade` 去判断某个能力应该优先走公开接口、Cookie 还是自定义 Header,而不是一开始就把适配器写死。
|
||||
1. 侦察站点,分类 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
||||
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
|
||||
4. 字段解码 + 设计输出列
|
||||
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
||||
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
||||
|
||||
### CLI 枢纽与桌面端适配器
|
||||
|
||||
@@ -124,7 +170,8 @@ OpenCLI 不只是网站 CLI,还可以:
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 21.0.0
|
||||
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
|
||||
- **Bun**: >= 1.0(可选替代运行时)
|
||||
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
|
||||
|
||||
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
|
||||
@@ -134,18 +181,17 @@ OpenCLI 不只是网站 CLI,还可以:
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
|
||||
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试) |
|
||||
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试)。`--focus` 标志会设置此变量 |
|
||||
| `OPENCLI_LIVE` | `false` | 设为 `1` 时 adapter 命令执行完后保留 automation 窗口不关闭(适合检查页面)。`--live` 标志会设置此变量 |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
|
||||
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | `120` | explore/record 操作超时(秒) |
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
|
||||
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com`) |
|
||||
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
|
||||
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
|
||||
| `OUTPUT` | — | 覆盖输出格式:`json`、`yaml` 或 `table` |
|
||||
| `DEBUG` | — | 设为 `opencli` 开启内部调试日志 |
|
||||
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
|
||||
|
||||
`--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时。
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
@@ -158,10 +204,9 @@ npx skills add jackwener/opencli
|
||||
如果你只装了部分 skill,也可以只刷新自己在用的:
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill opencli-browser
|
||||
npx skills add jackwener/opencli --skill opencli-explorer
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot
|
||||
npx skills add jackwener/opencli --skill opencli-adapter-author
|
||||
npx skills add jackwener/opencli --skill opencli-autofix
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
## 面向开发者
|
||||
@@ -187,12 +232,12 @@ npm link
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
|
||||
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
|
||||
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
|
||||
@@ -201,16 +246,23 @@ npm link
|
||||
| **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` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
|
||||
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
||||
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
|
||||
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
|
||||
| **uiverse** | `code` `preview` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
| **baidu-scholar** | `search` | 公开 |
|
||||
| **google-scholar** | `search` `cite` `profile` | 公开 |
|
||||
| **gov-law** | `search` `recent` | 公开 |
|
||||
| **gov-policy** | `search` `recent` | 公开 |
|
||||
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` | 公开 / 浏览器 |
|
||||
| **wanfang** | `search` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` `auth` | 本地凭证 |
|
||||
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
|
||||
| **weixin** | `download` | 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` | 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 |
|
||||
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 浏览器 |
|
||||
| **bbc** | `news` | 公共 API |
|
||||
@@ -232,7 +284,7 @@ npm link
|
||||
| **sinafinance** | `news` | 🌐 公开 |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
|
||||
| **chaoxing** | `assignments` `exams` | 浏览器 |
|
||||
| **grok** | `ask` | 浏览器 |
|
||||
| **grok** | `ask` `image` | 浏览器 |
|
||||
| **hf** | `top` | 公开 |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
@@ -244,17 +296,18 @@ npm link
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
|
||||
| **google** | `news` `search` `suggest` `trends` | 公开 |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | 浏览器 |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` | 浏览器 |
|
||||
| **1688** | `search` `item` `assets` `download` `store` | 浏览器 |
|
||||
| **gitee** | `trending` `search` `user` | 公开 / 浏览器 |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
|
||||
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` | 浏览器 |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | 浏览器 |
|
||||
| **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` | 公开 |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` `read` | 公开 |
|
||||
| **medium** | `feed` `search` `user` | 浏览器 |
|
||||
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
|
||||
| **substack** | `feed` `search` `publication` | 浏览器 |
|
||||
@@ -265,7 +318,9 @@ npm link
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
|
||||
87+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
100+ 站点能力 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
`*` `opencli xiaoyuzhou podcast`、`podcast-episodes`、`episode`、`download`、`transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`。
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
@@ -277,8 +332,8 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
|
||||
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker 命令行工具 | `opencli docker ps` |
|
||||
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
|
||||
| **dingtalk** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dingtalk msg send --to user "hello"` |
|
||||
| **wecom** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom msg send --to user "hello"` |
|
||||
| **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
|
||||
| **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` |
|
||||
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
|
||||
|
||||
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
|
||||
@@ -300,7 +355,7 @@ opencli register mycli
|
||||
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
|
||||
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
|
||||
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
|
||||
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt.md) |
|
||||
| **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
|
||||
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
|
||||
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
|
||||
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
|
||||
@@ -319,6 +374,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
|
||||
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
|
||||
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
|
||||
| **1688** | 图片、视频 | 下载商品页中可见的商品素材 |
|
||||
| **小宇宙** | 音频、转录 | 使用本地凭证下载单集音频和转录 JSON / 文本 |
|
||||
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
|
||||
| **微信公众号** | 文章(Markdown) | 导出微信公众号文章为 Markdown |
|
||||
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
|
||||
@@ -338,7 +394,8 @@ brew install yt-dlp
|
||||
|
||||
```bash
|
||||
# 下载小红书笔记中的图片/视频
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
|
||||
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
|
||||
|
||||
# 下载B站视频(需要 yt-dlp)
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
@@ -356,6 +413,12 @@ opencli douban download 30382501 --output ./douban
|
||||
# 下载 1688 商品页中的图片 / 视频素材
|
||||
opencli 1688 download 841141931191 --output ./1688-downloads
|
||||
|
||||
# 下载小宇宙单集音频
|
||||
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
|
||||
|
||||
# 下载小宇宙单集转录
|
||||
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
|
||||
|
||||
# 导出知乎文章为 Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
@@ -366,6 +429,8 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
```
|
||||
|
||||
`opencli xiaoyuzhou download` 和 `transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`。
|
||||
|
||||
|
||||
|
||||
## 输出格式
|
||||
@@ -435,36 +500,26 @@ opencli plugin uninstall my-tool # 卸载
|
||||
|
||||
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
|
||||
|
||||
> **快速模式**:只想为某个页面快速生成一个命令?看 [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — 给一个 URL + 一句话描述,4 步搞定。
|
||||
在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来:
|
||||
|
||||
> **完整模式**:在编写任何新代码前,先阅读 [opencli-explorer skill](./skills/opencli-explorer/SKILL.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
|
||||
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
|
||||
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
|
||||
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
|
||||
- 交付前用 `opencli browser verify <site>/<name>` 验证
|
||||
|
||||
```bash
|
||||
# 1. Deep Explore — 网络拦截 → 响应分析 → 能力推理 → 框架检测
|
||||
opencli explore https://example.com --site mysite
|
||||
|
||||
# 2. Synthesize — 从探索成果物生成 evaluate-based TS 适配器
|
||||
opencli synthesize mysite
|
||||
|
||||
# 3. Generate — 一键完成:探索 → 合成 → 注册
|
||||
opencli generate https://example.com --goal "hot"
|
||||
|
||||
# 4. Strategy Cascade — 自动降级探测:PUBLIC → COOKIE → HEADER
|
||||
opencli cascade https://api.example.com/data
|
||||
```
|
||||
|
||||
探索结果输出到 `.opencli/explore/<site>/`。
|
||||
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
- **"Extension not connected" 报错**
|
||||
- 确保你当前的 Chrome 或 Chromium 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- 确保你已从 [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) 安装 OpenCLI 扩展,且在 `chrome://extensions` 中**已启用**。
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
|
||||
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
|
||||
- **返回空数据,或者报错 "Unauthorized"**
|
||||
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 (如 parseArgs, fs 等)**
|
||||
- 确保 Node.js 版本 `>= 21`(`node:util` 的 `styleText` 需要 Node 21+)。
|
||||
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
|
||||
- OpenCLI 要求 **Node.js >= 21**。先执行 `node --version`,如果版本过低先升级,再重试命令。
|
||||
- **Daemon 问题**
|
||||
- 检查 daemon 状态:`curl localhost:19825/status`
|
||||
- 查看扩展日志:`curl localhost:19825/logs`
|
||||
|
||||
+2
-7
@@ -208,7 +208,7 @@ it('producthunt me fails gracefully without login', async () => {
|
||||
|---|---|---|
|
||||
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
|
||||
|
||||
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径。
|
||||
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome。
|
||||
|
||||
### Sharding
|
||||
|
||||
@@ -233,12 +233,7 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
|
||||
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
```
|
||||
CI 通过 `./.github/actions/setup-chrome` 准备真实 Chrome,再直接执行测试。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
|
||||
*
|
||||
* Spawns Claude Code with the opencli-browser skill. Claude Code
|
||||
* Spawns Claude Code with the opencli-adapter-author skill. Claude Code
|
||||
* completes the task using browse commands AND judges its own result.
|
||||
*
|
||||
* Task format: YAML with judge_context (multi-criteria, like Browser Use)
|
||||
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-browser', 'SKILL.md');
|
||||
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-adapter-author', 'SKILL.md');
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export const saveReliability: AutoResearchConfig = {
|
||||
'src/cli.ts',
|
||||
'src/discovery.ts',
|
||||
'src/registry.ts',
|
||||
'skills/opencli-browser/SKILL.md',
|
||||
'skills/opencli-adapter-author/SKILL.md',
|
||||
'autoresearch/save-tasks.json',
|
||||
'autoresearch/save-adapters/*.ts',
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Preset: Skill E2E Quality
|
||||
*
|
||||
* Optimizes the opencli-browser SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Optimizes the opencli-adapter-author SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Metric: number of passing skill-tasks (out of 35).
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { AutoResearchConfig } from '../config.js';
|
||||
export const skillQuality: AutoResearchConfig = {
|
||||
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
|
||||
scope: [
|
||||
'skills/opencli-browser/SKILL.md',
|
||||
'skills/opencli-adapter-author/SKILL.md',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
|
||||
+5021
-385
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -12,7 +12,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'summary', 'date', 'url'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (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)' },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 51job company jobs + basic info by encCoId.
|
||||
*
|
||||
* Navigates to `jobs.51job.com/all/co<encCoId>.html`. Each job card is an
|
||||
* `<a sensorsdata="…">` whose attribute is a JSON blob with jobId, title,
|
||||
* salary, area, year, degree — so parsing is just JSON, not DOM-text fragile.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { JOBS_ORIGIN, requirePage, navigateTo, parseCompanyJobCard } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'company',
|
||||
description: '51job 公司简介 + 在招职位(按 encCoId)',
|
||||
domain: 'jobs.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'encCoId', type: 'string', required: true, positional: true, help: '加密公司 ID(search 返回的 encCoId)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回职位数(1-50)' },
|
||||
],
|
||||
columns: [
|
||||
'rank', 'jobId', 'title', 'salary', 'city', 'workYear', 'degree',
|
||||
'funcType', 'issueDate', 'url',
|
||||
'companyName', 'companyType', 'companySize', 'companyIndustry',
|
||||
'companyIntro', 'companyUrl',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const encCoId = String(kwargs.encCoId ?? '').trim();
|
||||
if (!encCoId) throw new CliError('INVALID_ARGUMENT', 'encCoId is required');
|
||||
if (!/^[A-Za-z0-9_]+$/.test(encCoId)) {
|
||||
throw new CliError('INVALID_ARGUMENT', `encCoId must be alphanumeric/underscore, got "${encCoId}"`);
|
||||
}
|
||||
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
|
||||
|
||||
const url = `${JOBS_ORIGIN}/all/co${encCoId}.html`;
|
||||
await navigateTo(page, url, 2);
|
||||
|
||||
const script = `(() => {
|
||||
const sel = s => document.querySelector(s)?.innerText?.trim() || '';
|
||||
const bodyText = (document.body.innerText || '').slice(0, 400);
|
||||
if (/公司不存在|页面不存在|账号状态异常/.test(bodyText)) {
|
||||
return { error: 'NOT_FOUND', bodyText };
|
||||
}
|
||||
const companyName = sel('h1') || sel('.cname');
|
||||
// Company introduction block
|
||||
const introEl = document.querySelector('#companyIntroRef, .c-intro');
|
||||
const companyIntro = introEl ? (introEl.innerText || '').trim() : '';
|
||||
// Info sidebar (type / size / industry) — labels sit in .com-info dl or .coinfo
|
||||
const sidebarText = sel('.ci-content, .company-info, .coinfo, .com-info');
|
||||
const links = [...document.querySelectorAll('a[sensorsdata]')]
|
||||
.filter(a => /\\/\\d{6,}\\.html/.test(a.href || ''))
|
||||
.slice(0, 60)
|
||||
.map(a => {
|
||||
return {
|
||||
href: a.href,
|
||||
sensorsdata: a.getAttribute('sensorsdata') || '',
|
||||
text: (a.innerText || '').trim(),
|
||||
};
|
||||
});
|
||||
// Company meta is three inline spans under .c-info.ellipsis
|
||||
// (title/size/industry) — extract them by position.
|
||||
const cInfo = document.querySelector('.c-info.ellipsis');
|
||||
const cInfoParts = cInfo
|
||||
? [...cInfo.querySelectorAll('span')].map(s => (s.innerText || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
companyName,
|
||||
companyIntro,
|
||||
links,
|
||||
cInfoParts,
|
||||
sidebarText: sidebarText.slice(0, 400),
|
||||
};
|
||||
})()`;
|
||||
const data = await page.evaluate(script);
|
||||
if (data.error === 'NOT_FOUND') {
|
||||
throw new CliError('NO_DATA', `Company ${encCoId} not found`);
|
||||
}
|
||||
if (!data.companyName) {
|
||||
throw new CliError('NO_DATA', `Could not parse company page ${encCoId}; layout may have changed`);
|
||||
}
|
||||
|
||||
const companyUrl = url;
|
||||
const [companyType = '', companySize = '', companyIndustry = ''] = data.cInfoParts || [];
|
||||
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const link of data.links || []) {
|
||||
const job = parseCompanyJobCard(link);
|
||||
if (!job) continue;
|
||||
if (seen.has(job.jobId)) continue;
|
||||
seen.add(job.jobId);
|
||||
rows.push({
|
||||
rank: rows.length + 1,
|
||||
...job,
|
||||
companyName: data.companyName,
|
||||
companyType,
|
||||
companySize,
|
||||
companyIndustry,
|
||||
companyIntro: data.companyIntro || '',
|
||||
companyUrl,
|
||||
});
|
||||
if (rows.length >= limit) break;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
// Still return a sentinel row with the company info so caller isn't left with [].
|
||||
return [{
|
||||
rank: 0,
|
||||
jobId: '',
|
||||
title: '(no active jobs)',
|
||||
salary: '', city: '', workYear: '', degree: '',
|
||||
funcType: '', issueDate: '', url: '',
|
||||
companyName: data.companyName,
|
||||
companyType, companySize, companyIndustry,
|
||||
companyIntro: data.companyIntro || '',
|
||||
companyUrl,
|
||||
}];
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 51job job detail by jobId.
|
||||
*
|
||||
* Navigates to `jobs.51job.com/x/<jobId>.html` (SSR page — the generic `/x/`
|
||||
* area slug always resolves) and scrapes the structured blocks. No API
|
||||
* surface returns the full detail page, so DOM scraping is the only path.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { JOBS_ORIGIN, requirePage, navigateTo } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'detail',
|
||||
description: '51job 职位详情(按 jobId)',
|
||||
domain: 'jobs.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'jobId', type: 'string', required: true, positional: true, help: '职位 ID(search 返回的 jobId)' },
|
||||
],
|
||||
columns: [
|
||||
'jobId', 'title', 'salary', 'location', 'workYear', 'degree',
|
||||
'category', 'address', 'ageRequirement',
|
||||
'description', 'welfare',
|
||||
'company', 'companyType', 'companySize', 'companyIndustry',
|
||||
'companyUrl', 'url',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const jobId = String(kwargs.jobId ?? '').trim();
|
||||
if (!jobId) throw new CliError('INVALID_ARGUMENT', 'jobId is required');
|
||||
if (!/^\d{6,12}$/.test(jobId)) throw new CliError('INVALID_ARGUMENT', `jobId must be a 6-12 digit number, got "${jobId}"`);
|
||||
|
||||
const url = `${JOBS_ORIGIN}/x/${jobId}.html`;
|
||||
await navigateTo(page, url, 2);
|
||||
|
||||
const script = `(() => {
|
||||
const sel = s => document.querySelector(s)?.innerText?.trim() || '';
|
||||
const all = s => [...document.querySelectorAll(s)].map(e => e.innerText.trim()).filter(Boolean);
|
||||
const finalUrl = window.location.href;
|
||||
const bodyText = (document.body.innerText || '').slice(0, 400);
|
||||
if (/职位已下线|该职位已删除|页面不存在/.test(bodyText)) {
|
||||
return { error: 'EXPIRED', bodyText };
|
||||
}
|
||||
const companyA = document.querySelector('.cname a, .tCompany_sidebar .com_msg a');
|
||||
const funcs = all('.bmsg .fp');
|
||||
const pick = (prefix) => {
|
||||
const row = funcs.find(f => f.startsWith(prefix));
|
||||
return row ? row.slice(prefix.length).replace(/^[::\\s\\n]+/, '').trim() : '';
|
||||
};
|
||||
return {
|
||||
finalUrl,
|
||||
title: sel('h1') || sel('.cn .name'),
|
||||
salary: sel('.cn strong') || sel('strong'),
|
||||
meta: sel('.cn .msg.ltype') || sel('.msg.ltype'),
|
||||
description: (() => {
|
||||
const box = document.querySelector('.bmsg.job_msg') || document.querySelector('.job_msg');
|
||||
if (!box) return '';
|
||||
const clone = box.cloneNode(true);
|
||||
clone.querySelectorAll('.fp, .mt10, script, style').forEach(n => n.remove());
|
||||
return (clone.innerText || '').trim();
|
||||
})(),
|
||||
welfare: all('.t1 span, .jtag .t1 span'),
|
||||
category: pick('职能类别'),
|
||||
address: pick('上班地址'),
|
||||
ageRequirement: pick('年龄要求'),
|
||||
company: companyA?.innerText?.trim() || '',
|
||||
companyUrl: companyA?.href || '',
|
||||
companyTag: sel('.com_tag'),
|
||||
};
|
||||
})()`;
|
||||
const data = await page.evaluate(script);
|
||||
if (data.error === 'EXPIRED') {
|
||||
throw new CliError('NO_DATA', `Job ${jobId} is offline or removed`);
|
||||
}
|
||||
if (!data.title) {
|
||||
throw new CliError('NO_DATA', `Could not parse job detail for ${jobId}; page may have changed layout`);
|
||||
}
|
||||
|
||||
// meta looks like "北京-丰台区 | 3年及以上 | 本科"
|
||||
const [locRaw, workYear, degree] = (data.meta || '').split('|').map(s => s.trim());
|
||||
// companyTag looks like "国企\n\n150-500人\n\n电子技术/半导体/集成电路"
|
||||
const tagParts = (data.companyTag || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
return [{
|
||||
jobId,
|
||||
title: data.title,
|
||||
salary: data.salary || '',
|
||||
location: locRaw || '',
|
||||
workYear: workYear || '',
|
||||
degree: degree || '',
|
||||
category: data.category || '',
|
||||
address: data.address || '',
|
||||
ageRequirement: data.ageRequirement || '',
|
||||
description: data.description || '',
|
||||
welfare: (data.welfare || []).join(','),
|
||||
company: data.company || '',
|
||||
companyType: tagParts[0] || '',
|
||||
companySize: tagParts[1] || '',
|
||||
companyIndustry: tagParts.slice(2).join(' / '),
|
||||
companyUrl: data.companyUrl || '',
|
||||
url: data.finalUrl || url,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 51job hot / recommended feed.
|
||||
*
|
||||
* Same endpoint as `search`, but with empty keyword — 51job returns its
|
||||
* own ranked recommendation list (up to ~999 for most regions).
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
WE_ORIGIN, SEARCH_COLUMNS, SORT_CODES,
|
||||
requirePage, navigateTo, pageFetchJson,
|
||||
buildSearchUrl, mapJobItem, resolveCity, resolveCode,
|
||||
} from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'hot',
|
||||
description: '51job 推荐职位(按城市/行业/排序浏览)',
|
||||
domain: 'we.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'area', type: 'string', default: '全国', help: '城市名或 6 位城市码(默认 "全国")' },
|
||||
{ name: 'sort', type: 'string', default: '综合', help: '排序:综合 / 最新 / 薪资 / 距离' },
|
||||
{ name: 'page', type: 'int', default: 1, help: '页码(1-based)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50)' },
|
||||
],
|
||||
columns: SEARCH_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
|
||||
const pageNum = Math.max(1, Number(kwargs.page) || 1);
|
||||
const jobArea = resolveCity(kwargs.area);
|
||||
const sortType = resolveCode(kwargs.sort, SORT_CODES, '0');
|
||||
|
||||
const currentUrl = await page.evaluate(`(() => window.location.href)()`);
|
||||
if (!String(currentUrl).startsWith(WE_ORIGIN)) {
|
||||
await navigateTo(page, `${WE_ORIGIN}/pc/search?searchType=2`, 2);
|
||||
}
|
||||
|
||||
const url = buildSearchUrl({
|
||||
keyword: '', jobArea, sortType,
|
||||
pageNum, pageSize: Math.min(limit, 50),
|
||||
});
|
||||
const data = await pageFetchJson(page, url);
|
||||
if (data.status !== '1' && data.status !== 1) {
|
||||
throw new CliError('API_ERROR', `51job hot failed: ${data.message ?? 'unknown'}`);
|
||||
}
|
||||
const items = data?.resultbody?.job?.items ?? [];
|
||||
if (items.length === 0) throw new CliError('NO_DATA', 'No recommended jobs returned');
|
||||
return items.slice(0, limit).map((it, i) => mapJobItem(it, (pageNum - 1) * limit + i + 1));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 51job keyword search.
|
||||
*
|
||||
* Backed by `we.51job.com/api/job/search-pc`, which returns a job list with
|
||||
* the full `jobDescribe` embedded. Needs the browser session because the
|
||||
* Aliyun WAF in front of `we.51job.com` challenges bare fetches; the
|
||||
* `pageFetchJson` helper runs inside the page so the WAF sees a real browser.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
WE_ORIGIN, SEARCH_COLUMNS,
|
||||
SALARY_CODES, WORKYEAR_CODES, DEGREE_CODES,
|
||||
COMPANY_TYPE_CODES, COMPANY_SIZE_CODES, SORT_CODES,
|
||||
requirePage, navigateTo, pageFetchJson,
|
||||
buildSearchUrl, mapJobItem, resolveCity, resolveCode,
|
||||
} from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'search',
|
||||
description: '51job 前程无忧关键词职位搜索',
|
||||
domain: 'we.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'keyword', type: 'string', required: true, positional: true, help: '搜索关键词(岗位名 / 技能 / 公司)' },
|
||||
{ name: 'area', type: 'string', default: '全国', help: '城市名或 6 位城市码(如 "杭州" / "080200" / "全国")' },
|
||||
{ name: 'salary', type: 'string', default: '', help: '薪资区间(如 "10-15k" / "1-1.5万" / "20-30k")' },
|
||||
{ name: 'experience', type: 'string', default: '', help: '工作年限(如 "应届" / "1-3年" / "3-5年" / "5-7年")' },
|
||||
{ name: 'degree', type: 'string', default: '', help: '学历要求(如 "本科" / "大专" / "硕士")' },
|
||||
{ name: 'companyType', type: 'string', default: '', help: '公司性质(如 "外资" / "国企" / "民营")' },
|
||||
{ name: 'companySize', type: 'string', default: '', help: '公司规模(如 "50-150" / "1000-5000")' },
|
||||
{ name: 'sort', type: 'string', default: '综合', help: '排序:综合 / 最新 / 薪资 / 距离' },
|
||||
{ name: 'page', type: 'int', default: 1, help: '页码(1-based)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50)' },
|
||||
],
|
||||
columns: SEARCH_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const keyword = String(kwargs.keyword ?? '').trim();
|
||||
if (!keyword) throw new CliError('INVALID_ARGUMENT', 'keyword is required');
|
||||
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
|
||||
const pageNum = Math.max(1, Number(kwargs.page) || 1);
|
||||
|
||||
const jobArea = resolveCity(kwargs.area);
|
||||
const salary = resolveCode(kwargs.salary, SALARY_CODES);
|
||||
const workYear = resolveCode(kwargs.experience, WORKYEAR_CODES);
|
||||
const degree = resolveCode(kwargs.degree, DEGREE_CODES);
|
||||
const companyType = resolveCode(kwargs.companyType, COMPANY_TYPE_CODES);
|
||||
const companySize = resolveCode(kwargs.companySize, COMPANY_SIZE_CODES);
|
||||
const sortType = resolveCode(kwargs.sort, SORT_CODES, '0');
|
||||
|
||||
// Establish WAF-clean origin. Reusing the same tab avoids the slider
|
||||
// challenge fire every call.
|
||||
const currentUrl = await page.evaluate(`(() => window.location.href)()`);
|
||||
if (!String(currentUrl).startsWith(WE_ORIGIN)) {
|
||||
await navigateTo(page, `${WE_ORIGIN}/pc/search?keyword=${encodeURIComponent(keyword)}&searchType=2`, 2);
|
||||
}
|
||||
|
||||
const url = buildSearchUrl({
|
||||
keyword, jobArea, salary, workYear, degree,
|
||||
companyType, companySize, sortType,
|
||||
pageNum, pageSize: Math.min(limit, 50),
|
||||
});
|
||||
|
||||
const data = await pageFetchJson(page, url);
|
||||
if (data.status !== '1' && data.status !== 1) {
|
||||
throw new CliError('API_ERROR', `51job search failed: ${data.message ?? 'unknown'}`);
|
||||
}
|
||||
const items = data?.resultbody?.job?.items ?? [];
|
||||
if (items.length === 0) {
|
||||
throw new CliError('NO_DATA', `No jobs matched "${keyword}"`);
|
||||
}
|
||||
return items.slice(0, limit).map((it, i) => mapJobItem(it, (pageNum - 1) * limit + i + 1));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 51job shared utilities.
|
||||
*
|
||||
* Key design points:
|
||||
* - we.51job.com is protected by Aliyun WAF — bare `curl` / Node-side fetch
|
||||
* gets a slider CAPTCHA HTML page. Only browser-context fetch (page.evaluate)
|
||||
* with the session's cookies survives the challenge.
|
||||
* - `document.cookie` exposes the anti-bot cookies (`acw_sc__v2`, `ssxmod_itna`
|
||||
* etc.) — no HttpOnly/login needed for public pages.
|
||||
* - API (`we.51job.com/api/job/search-pc`) is same-origin when we've navigated
|
||||
* to `https://we.51job.com/...`, so fetch inside page.evaluate works.
|
||||
* - Detail / company pages live on `jobs.51job.com` and render data into the
|
||||
* DOM (SSR), so adapters for those navigate and scrape.
|
||||
*/
|
||||
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const WE_ORIGIN = 'https://we.51job.com';
|
||||
export const JOBS_ORIGIN = 'https://jobs.51job.com';
|
||||
|
||||
/**
|
||||
* City name / alias → 6-digit jobArea code. `000000` is the national bucket.
|
||||
* Covers the 40 largest cities the search UI surfaces. Unknown input passed
|
||||
* as-is if it's already 6 digits; otherwise fall back to `000000` (all).
|
||||
*/
|
||||
export const CITY_CODES = {
|
||||
'全国': '000000', 'all': '000000',
|
||||
'北京': '010000', 'beijing': '010000',
|
||||
'上海': '020000', 'shanghai': '020000',
|
||||
'广州': '030200', 'guangzhou': '030200',
|
||||
'深圳': '040000', 'shenzhen': '040000',
|
||||
'武汉': '180200', 'wuhan': '180200',
|
||||
'西安': '200200', "xi'an": '200200', 'xian': '200200',
|
||||
'杭州': '080200', 'hangzhou': '080200',
|
||||
'南京': '070200', 'nanjing': '070200',
|
||||
'成都': '090200', 'chengdu': '090200',
|
||||
'苏州': '070300', 'suzhou': '070300',
|
||||
'重庆': '060000', 'chongqing': '060000',
|
||||
'天津': '050000', 'tianjin': '050000',
|
||||
'长沙': '190200', 'changsha': '190200',
|
||||
'郑州': '170200', 'zhengzhou': '170200',
|
||||
'青岛': '120300', 'qingdao': '120300',
|
||||
'合肥': '150200', 'hefei': '150200',
|
||||
'厦门': '110300', 'xiamen': '110300',
|
||||
'无锡': '070400', 'wuxi': '070400',
|
||||
'济南': '120200', 'jinan': '120200',
|
||||
'佛山': '030700', 'foshan': '030700',
|
||||
'东莞': '030800', 'dongguan': '030800',
|
||||
'宁波': '080300', 'ningbo': '080300',
|
||||
'福州': '110200', 'fuzhou': '110200',
|
||||
'昆明': '250200', 'kunming': '250200',
|
||||
'大连': '230300', 'dalian': '230300',
|
||||
'沈阳': '230200', 'shenyang': '230200',
|
||||
'哈尔滨': '220200', 'haerbin': '220200', 'harbin': '220200',
|
||||
'石家庄': '160200', 'shijiazhuang': '160200',
|
||||
'贵阳': '260200', 'guiyang': '260200',
|
||||
'南宁': '100200', 'nanning': '100200',
|
||||
'南昌': '130200', 'nanchang': '130200',
|
||||
'长春': '240200', 'changchun': '240200',
|
||||
'太原': '210200', 'taiyuan': '210200',
|
||||
'兰州': '280200', 'lanzhou': '280200',
|
||||
'乌鲁木齐': '310200', 'urumqi': '310200',
|
||||
'海口': '270200', 'haikou': '270200',
|
||||
'香港': '330000', 'hongkong': '330000', 'hk': '330000',
|
||||
};
|
||||
|
||||
/** Salary bucket code (matches 51job's `salary` filter). */
|
||||
export const SALARY_CODES = {
|
||||
'不限': '',
|
||||
'2千以下': '01', '2-3千': '02', '3-4.5千': '03',
|
||||
'4.5-6千': '04', '6-8千': '05', '8k-1万': '06', '8-10k': '06',
|
||||
'1-1.5万': '07', '10-15k': '07',
|
||||
'1.5-2万': '08', '15-20k': '08',
|
||||
'2-3万': '09', '20-30k': '09',
|
||||
'3-5万': '10', '30-50k': '10',
|
||||
'5万以上': '11', '50k以上': '11',
|
||||
};
|
||||
|
||||
/** Work experience bucket. */
|
||||
export const WORKYEAR_CODES = {
|
||||
'不限': '',
|
||||
'在校生': '01', '应届': '02', '1年以下': '03',
|
||||
'1-3年': '04', '3-5年': '05', '5-7年': '06',
|
||||
'7-10年': '07', '10年以上': '08',
|
||||
};
|
||||
|
||||
/** Degree bucket. */
|
||||
export const DEGREE_CODES = {
|
||||
'不限': '',
|
||||
'初中及以下': '01', '高中/中技/中专': '02', '高中': '02',
|
||||
'大专': '03', '本科': '04', '硕士': '05', '博士': '06',
|
||||
};
|
||||
|
||||
/** Company ownership type. */
|
||||
export const COMPANY_TYPE_CODES = {
|
||||
'不限': '',
|
||||
'外资': '01', '欧美': '0101', '日韩': '0102',
|
||||
'合资': '02', '国企': '03', '民营': '04',
|
||||
'上市公司': '05', '创业公司': '06', '事业单位': '07',
|
||||
'非营利': '08', '政府': '09',
|
||||
};
|
||||
|
||||
/** Company headcount bucket. */
|
||||
export const COMPANY_SIZE_CODES = {
|
||||
'不限': '',
|
||||
'少于50': '01', '50以下': '01',
|
||||
'50-150': '02', '150-500': '03',
|
||||
'500-1000': '04', '1000-5000': '05',
|
||||
'5000-10000': '06', '10000以上': '07',
|
||||
};
|
||||
|
||||
/** Sort strategy. */
|
||||
export const SORT_CODES = {
|
||||
'综合': '0', 'relevance': '0', 'default': '0',
|
||||
'最新': '1', 'new': '1', 'newest': '1',
|
||||
'薪资': '2', 'salary': '2', 'pay': '2',
|
||||
'距离': '9', 'distance': '9',
|
||||
};
|
||||
|
||||
export function resolveCity(input) {
|
||||
if (!input) return '000000';
|
||||
const s = String(input).trim();
|
||||
if (!s || s === '全国' || s.toLowerCase() === 'all') return '000000';
|
||||
if (/^\d{6}$/.test(s)) return s;
|
||||
const key = s.toLowerCase();
|
||||
if (CITY_CODES[s] !== undefined) return CITY_CODES[s];
|
||||
if (CITY_CODES[key] !== undefined) return CITY_CODES[key];
|
||||
for (const [name, code] of Object.entries(CITY_CODES)) {
|
||||
if (typeof name === 'string' && name.includes(s)) return code;
|
||||
}
|
||||
throw new CliError('INVALID_ARGUMENT', `Unknown city/area "${s}"`, 'Use a supported city name like "杭州" or a 6-digit city code');
|
||||
}
|
||||
|
||||
export function resolveCode(input, table, fallback = '') {
|
||||
if (input === undefined || input === null || input === '') return fallback;
|
||||
const s = String(input).trim();
|
||||
if (table[s] !== undefined) return table[s];
|
||||
const key = s.toLowerCase();
|
||||
if (table[key] !== undefined) return table[key];
|
||||
if (Object.values(table).includes(s)) return s;
|
||||
for (const [k, v] of Object.entries(table)) {
|
||||
if (typeof k === 'string' && k.includes(s)) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function requirePage(page) {
|
||||
if (!page) throw new CliError('INTERNAL_ERROR', 'Browser page required (adapter must set browser: true)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the page to a URL and give the SPA a moment to settle. Reuses
|
||||
* existing session cookies — first call on a fresh browser may trigger the
|
||||
* Aliyun WAF interstitial, which the headless Chromium solves automatically
|
||||
* because the JS that sets `acw_sc__v2` runs in the page.
|
||||
*/
|
||||
export async function navigateTo(page, url, waitSeconds = 2) {
|
||||
await page.goto(url);
|
||||
await page.wait({ time: waitSeconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-context fetch: execute `fetch(url, { credentials: 'include' })`
|
||||
* inside the page so cookies apply and WAF sees a real browser. Returns
|
||||
* parsed JSON; throws on network / parse / status failure.
|
||||
*/
|
||||
export async function pageFetchJson(page, url, opts = {}) {
|
||||
const method = opts.method ?? 'GET';
|
||||
const body = opts.body ?? null;
|
||||
const timeout = opts.timeout ?? 15000;
|
||||
const headers = opts.headers ?? {};
|
||||
const script = `
|
||||
async () => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), ${timeout});
|
||||
try {
|
||||
const resp = await fetch(${JSON.stringify(url)}, {
|
||||
method: ${JSON.stringify(method)},
|
||||
credentials: 'include',
|
||||
headers: ${JSON.stringify({ Accept: 'application/json', ...headers })},
|
||||
${body !== null ? `body: ${JSON.stringify(body)},` : ''}
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
return { ok: resp.ok, status: resp.status, text };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, text: '', error: String(e && e.message || e) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
`;
|
||||
const res = await page.evaluate(script);
|
||||
if (res.error) throw new CliError('HTTP_ERROR', `51job fetch failed: ${res.error}`);
|
||||
if (!res.ok) throw new CliError('HTTP_ERROR', `51job HTTP ${res.status}`);
|
||||
if (res.text.trim().startsWith('<')) {
|
||||
throw new CliError('ANTI_BOT', '51job returned HTML (likely Aliyun WAF slider). Refresh browser session.');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(res.text);
|
||||
} catch (e) {
|
||||
throw new CliError('API_ERROR', `51job invalid JSON: ${res.text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical search-pc URL. All optional filters default to empty
|
||||
* (no constraint). `scene=7` + `source=1` match what the real SPA sends.
|
||||
*/
|
||||
export function buildSearchUrl(params) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('api_key', '51job');
|
||||
qs.set('timestamp', String(Date.now()));
|
||||
qs.set('keyword', params.keyword ?? '');
|
||||
qs.set('searchType', '2');
|
||||
qs.set('function', params.function ?? '');
|
||||
qs.set('industry', params.industry ?? '');
|
||||
qs.set('jobArea', params.jobArea ?? '000000');
|
||||
qs.set('jobArea2', params.jobArea2 ?? '');
|
||||
qs.set('landmark', params.landmark ?? '');
|
||||
qs.set('metro', params.metro ?? '');
|
||||
qs.set('salary', params.salary ?? '');
|
||||
qs.set('workYear', params.workYear ?? '');
|
||||
qs.set('degree', params.degree ?? '');
|
||||
qs.set('companyType', params.companyType ?? '');
|
||||
qs.set('companySize', params.companySize ?? '');
|
||||
qs.set('jobType', params.jobType ?? '');
|
||||
qs.set('issueDate', params.issueDate ?? '');
|
||||
qs.set('sortType', params.sortType ?? '0');
|
||||
qs.set('pageNum', String(params.pageNum ?? 1));
|
||||
qs.set('pageSize', String(params.pageSize ?? 20));
|
||||
qs.set('source', '1');
|
||||
qs.set('scene', '7');
|
||||
return `${WE_ORIGIN}/api/job/search-pc?${qs.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw search-pc `resultbody.job.items[i]` into the canonical row shape
|
||||
* we expose to the user. Kept here so `search` and `hot` stay aligned.
|
||||
*/
|
||||
export function mapJobItem(it, rank) {
|
||||
const area = it.jobAreaLevelDetail || {};
|
||||
return {
|
||||
rank,
|
||||
jobId: String(it.jobId ?? ''),
|
||||
title: it.jobName ?? '',
|
||||
salary: it.provideSalaryString ?? '',
|
||||
salaryMin: Number(it.jobSalaryMin ?? 0) || 0,
|
||||
salaryMax: Number(it.jobSalaryMax ?? 0) || 0,
|
||||
city: area.cityString ?? it.jobAreaString ?? '',
|
||||
district: area.districtString ?? '',
|
||||
workYear: it.workYearString ?? '',
|
||||
degree: it.degreeString ?? '',
|
||||
tags: Array.isArray(it.jobTags) ? it.jobTags.join(',') : '',
|
||||
company: it.companyName ?? '',
|
||||
companyFull: it.fullCompanyName ?? '',
|
||||
companyType: it.companyTypeString ?? '',
|
||||
companySize: it.companySizeString ?? '',
|
||||
industry: it.industryType1Str ?? '',
|
||||
hr: it.hrName ? `${it.hrName}·${it.hrPosition ?? ''}` : '',
|
||||
issueDate: it.issueDateString ?? '',
|
||||
url: it.jobHref ?? '',
|
||||
companyUrl: it.companyHref ?? '',
|
||||
encCoId: it.encCoId ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const SEARCH_COLUMNS = [
|
||||
'rank', 'jobId', 'title', 'salary', 'salaryMin', 'salaryMax',
|
||||
'city', 'district', 'workYear', 'degree', 'tags',
|
||||
'company', 'companyFull', 'companyType', 'companySize', 'industry',
|
||||
'hr', 'issueDate', 'url', 'companyUrl', 'encCoId',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a 51job company-page `<a sensorsdata="...">` payload into a stable
|
||||
* row fragment. Returns null when the attribute is absent or malformed.
|
||||
*/
|
||||
export function parseCompanyJobCard(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const href = typeof raw.href === 'string' ? raw.href : '';
|
||||
const sensorsdata = typeof raw.sensorsdata === 'string' ? raw.sensorsdata : '';
|
||||
if (!href || !sensorsdata) return null;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(sensorsdata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || !data.jobId) return null;
|
||||
return {
|
||||
jobId: String(data.jobId),
|
||||
title: data.jobTitle || '',
|
||||
salary: data.jobSalary || '',
|
||||
city: data.jobArea || '',
|
||||
workYear: data.jobYear || '',
|
||||
degree: data.jobDegree || '',
|
||||
funcType: data.funcType || '',
|
||||
issueDate: data.jobTime || '',
|
||||
url: href,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { parseCompanyJobCard, pageFetchJson, resolveCity } from './utils.js';
|
||||
|
||||
describe('51job resolveCity', () => {
|
||||
it('maps known city names and explicit national scope', () => {
|
||||
expect(resolveCity('杭州')).toBe('080200');
|
||||
expect(resolveCity('all')).toBe('000000');
|
||||
expect(resolveCity('000000')).toBe('000000');
|
||||
});
|
||||
|
||||
it('rejects unknown non-empty inputs instead of silently widening to 全国', () => {
|
||||
expect(() => resolveCity('杭州z')).toThrowError(CliError);
|
||||
expect(() => resolveCity('杭州z')).toThrow(/Unknown city\/area/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('51job pageFetchJson', () => {
|
||||
it('detects WAF challenge HTML and throws ANTI_BOT', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: '<html><title>slider</title></html>',
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(pageFetchJson(page, 'https://we.51job.com/api/job/search-pc')).rejects.toMatchObject({
|
||||
code: 'ANTI_BOT',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('51job parseCompanyJobCard', () => {
|
||||
it('parses sensorsdata JSON into a stable row fragment', () => {
|
||||
const row = parseCompanyJobCard({
|
||||
href: 'https://jobs.51job.com/shanghai/123456789.html',
|
||||
sensorsdata: JSON.stringify({
|
||||
jobId: '123456789',
|
||||
jobTitle: 'Senior Engineer',
|
||||
jobSalary: '20-30K',
|
||||
jobArea: '上海',
|
||||
jobYear: '3-5年',
|
||||
jobDegree: '本科',
|
||||
funcType: '后端开发',
|
||||
jobTime: '04-22',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(row).toEqual({
|
||||
jobId: '123456789',
|
||||
title: 'Senior Engineer',
|
||||
salary: '20-30K',
|
||||
city: '上海',
|
||||
workYear: '3-5年',
|
||||
degree: '本科',
|
||||
funcType: '后端开发',
|
||||
issueDate: '04-22',
|
||||
url: 'https://jobs.51job.com/shanghai/123456789.html',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null on malformed sensorsdata', () => {
|
||||
expect(parseCompanyJobCard({
|
||||
href: 'https://jobs.51job.com/shanghai/123456789.html',
|
||||
sensorsdata: '{bad json}',
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
|
||||
import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
|
||||
function normalizeDiscussionPayload(payload) {
|
||||
const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
|
||||
const asin = extractAsin(payload.href ?? '') ?? null;
|
||||
@@ -28,10 +28,16 @@ function normalizeDiscussionPayload(payload) {
|
||||
})),
|
||||
};
|
||||
}
|
||||
async function readDiscussionPayload(page, input, limit) {
|
||||
const url = buildDiscussionUrl(input);
|
||||
const state = await gotoAndReadState(page, url, 2500, 'discussion');
|
||||
assertUsableState(state, 'discussion');
|
||||
function hasDiscussionSummary(payload) {
|
||||
return Boolean(cleanText(payload.average_rating_text) || cleanText(payload.total_review_count_text));
|
||||
}
|
||||
function isSignInState(state) {
|
||||
const href = cleanText(state.href).toLowerCase();
|
||||
const title = cleanText(state.title).toLowerCase();
|
||||
return href.includes('/ap/signin')
|
||||
|| title.includes('amazon sign-in');
|
||||
}
|
||||
async function readCurrentDiscussionPayload(page, limit) {
|
||||
return await page.evaluate(`
|
||||
(() => ({
|
||||
href: window.location.href,
|
||||
@@ -53,6 +59,29 @@ async function readDiscussionPayload(page, input, limit) {
|
||||
}))()
|
||||
`);
|
||||
}
|
||||
async function readDiscussionPayload(page, input, limit) {
|
||||
const reviewUrl = buildDiscussionUrl(input);
|
||||
const reviewState = await gotoAndReadState(page, reviewUrl, 2500, 'discussion');
|
||||
assertUsableState(reviewState, 'discussion');
|
||||
const reviewPayload = await readCurrentDiscussionPayload(page, limit);
|
||||
if (hasDiscussionSummary(reviewPayload)) {
|
||||
return reviewPayload;
|
||||
}
|
||||
const productUrl = buildProductUrl(input);
|
||||
const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
|
||||
assertUsableState(productState, 'discussion');
|
||||
if (isSignInState(reviewState) && isSignInState(productState)) {
|
||||
throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
|
||||
}
|
||||
const productPayload = await readCurrentDiscussionPayload(page, limit);
|
||||
if (hasDiscussionSummary(productPayload)) {
|
||||
return productPayload;
|
||||
}
|
||||
if (isSignInState(reviewState)) {
|
||||
throw new CommandExecutionError('amazon review page redirected to sign-in and product page fallback did not expose review summary', 'Open the product page in Chrome, verify reviews are visible, and retry.');
|
||||
}
|
||||
return reviewPayload;
|
||||
}
|
||||
cli({
|
||||
site: 'amazon',
|
||||
name: 'discussion',
|
||||
@@ -88,4 +117,6 @@ cli({
|
||||
});
|
||||
export const __test__ = {
|
||||
normalizeDiscussionPayload,
|
||||
hasDiscussionSummary,
|
||||
isSignInState,
|
||||
};
|
||||
|
||||
+147
-32
@@ -1,36 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthRequiredError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { __test__ } from './discussion.js';
|
||||
import './discussion.js';
|
||||
|
||||
function createPageMock(evaluateResults) {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('amazon discussion normalization', () => {
|
||||
it('normalizes review summary and sample reviews', () => {
|
||||
const result = __test__.normalizeDiscussionPayload({
|
||||
href: 'https://www.amazon.com/product-reviews/B0FJS72893',
|
||||
average_rating_text: '3.9 out of 5',
|
||||
total_review_count_text: '27 global ratings',
|
||||
qa_links: [],
|
||||
review_samples: [
|
||||
{
|
||||
title: '5.0 out of 5 stars Great value and quality',
|
||||
rating_text: '5.0 out of 5 stars',
|
||||
author: 'GTreader2',
|
||||
date_text: 'Reviewed in the United States on February 21, 2026',
|
||||
body: 'Small but mighty.',
|
||||
verified: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.asin).toBe('B0FJS72893');
|
||||
expect(result.average_rating_value).toBe(3.9);
|
||||
expect(result.total_review_count).toBe(27);
|
||||
expect(result.review_samples).toEqual([
|
||||
{
|
||||
title: 'Great value and quality',
|
||||
rating_text: '5.0 out of 5 stars',
|
||||
rating_value: 5,
|
||||
author: 'GTreader2',
|
||||
date_text: 'Reviewed in the United States on February 21, 2026',
|
||||
body: 'Small but mighty.',
|
||||
verified_purchase: true,
|
||||
},
|
||||
]);
|
||||
it('normalizes review summary and sample reviews', () => {
|
||||
const result = __test__.normalizeDiscussionPayload({
|
||||
href: 'https://www.amazon.com/product-reviews/B0FJS72893',
|
||||
average_rating_text: '3.9 out of 5',
|
||||
total_review_count_text: '27 global ratings',
|
||||
qa_links: [],
|
||||
review_samples: [
|
||||
{
|
||||
title: '5.0 out of 5 stars Great value and quality',
|
||||
rating_text: '5.0 out of 5 stars',
|
||||
author: 'GTreader2',
|
||||
date_text: 'Reviewed in the United States on February 21, 2026',
|
||||
body: 'Small but mighty.',
|
||||
verified: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.asin).toBe('B0FJS72893');
|
||||
expect(result.average_rating_value).toBe(3.9);
|
||||
expect(result.total_review_count).toBe(27);
|
||||
expect(result.review_samples).toEqual([
|
||||
{
|
||||
title: 'Great value and quality',
|
||||
rating_text: '5.0 out of 5 stars',
|
||||
rating_value: 5,
|
||||
author: 'GTreader2',
|
||||
date_text: 'Reviewed in the United States on February 21, 2026',
|
||||
body: 'Small but mighty.',
|
||||
verified_purchase: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the product page when the review page redirects to sign-in', async () => {
|
||||
const command = getRegistry().get('amazon/discussion');
|
||||
const page = createPageMock([
|
||||
{
|
||||
href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
|
||||
title: 'Amazon Sign-In',
|
||||
body_text: 'Sign in Create account',
|
||||
},
|
||||
{
|
||||
href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
|
||||
average_rating_text: '',
|
||||
total_review_count_text: '',
|
||||
review_samples: [],
|
||||
},
|
||||
{
|
||||
href: 'https://www.amazon.com/dp/B09HKN2ZRT',
|
||||
title: 'Amazon.com: Example product',
|
||||
body_text: 'Hello, zejia-wu Reviews',
|
||||
},
|
||||
{
|
||||
href: 'https://www.amazon.com/dp/B09HKN2ZRT',
|
||||
average_rating_text: '4.4 out of 5',
|
||||
total_review_count_text: '349 global ratings',
|
||||
review_samples: [
|
||||
{
|
||||
title: '5.0 out of 5 stars Perfect for the office',
|
||||
rating_text: '5.0 out of 5 stars',
|
||||
author: 'Ken',
|
||||
date_text: 'Reviewed in the United States on March 19, 2026',
|
||||
body: 'Good for the office, no complaints.',
|
||||
verified: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await command.func(page, { input: 'B09HKN2ZRT', limit: 1 });
|
||||
|
||||
expect(page.goto.mock.calls.map((call) => call[0])).toEqual([
|
||||
'https://www.amazon.com/product-reviews/B09HKN2ZRT',
|
||||
'https://www.amazon.com/dp/B09HKN2ZRT',
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
asin: 'B09HKN2ZRT',
|
||||
discussion_url: 'https://www.amazon.com/dp/B09HKN2ZRT',
|
||||
average_rating_value: 4.4,
|
||||
total_review_count: 349,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws AuthRequiredError when both review and product pages are gated', async () => {
|
||||
const command = getRegistry().get('amazon/discussion');
|
||||
const authState = {
|
||||
href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
|
||||
title: 'Amazon Sign-In',
|
||||
body_text: 'Sign in Create account',
|
||||
};
|
||||
const page = createPageMock([
|
||||
authState,
|
||||
{
|
||||
href: authState.href,
|
||||
average_rating_text: '',
|
||||
total_review_count_text: '',
|
||||
review_samples: [],
|
||||
},
|
||||
authState,
|
||||
]);
|
||||
|
||||
await expect(command.func(page, { input: 'B09HKN2ZRT', limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('does not treat a public product page with sign-in copy as a gated page', () => {
|
||||
expect(__test__.isSignInState({
|
||||
href: 'https://www.amazon.com/dp/B09HKN2ZRT',
|
||||
title: 'Amazon.com: Example product',
|
||||
body_text: 'Hello, sign in Account & Lists Create account',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+71
-25
@@ -54,6 +54,20 @@ function jsonResponse(res, status, data) {
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
function parseTimeoutValue(val, label, fallback) {
|
||||
if (val === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`);
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
function parseEnvTimeout(envVar, fallback) {
|
||||
return parseTimeoutValue(process.env[envVar], envVar, fallback);
|
||||
}
|
||||
// ─── DOM helpers ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Click the 'New Conversation' button to reset context.
|
||||
@@ -267,41 +281,65 @@ async function waitForReply(page, beforeText, opts = {}) {
|
||||
let lastText = beforeText;
|
||||
let stableCount = 0;
|
||||
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
|
||||
let reconnectCount = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const generating = await isGenerating(page);
|
||||
const currentText = await getConversationText(page);
|
||||
const textChanged = currentText !== beforeText && currentText.length > 0;
|
||||
if (generating) {
|
||||
hasStartedGenerating = true;
|
||||
stableCount = 0; // Reset stability while generating
|
||||
}
|
||||
else {
|
||||
if (hasStartedGenerating) {
|
||||
// It actively generated and now it stopped -> DONE
|
||||
// Provide a small buffer to let React render the final message fully
|
||||
await sleep(500);
|
||||
return;
|
||||
try {
|
||||
const generating = await isGenerating(page);
|
||||
const currentText = await getConversationText(page);
|
||||
const textChanged = currentText !== beforeText && currentText.length > 0;
|
||||
if (generating) {
|
||||
hasStartedGenerating = true;
|
||||
stableCount = 0; // Reset stability while generating
|
||||
}
|
||||
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
|
||||
if (textChanged) {
|
||||
if (currentText === lastText) {
|
||||
stableCount++;
|
||||
if (stableCount >= stableThreshold) {
|
||||
return; // Text has been stable for 2 seconds -> DONE
|
||||
else {
|
||||
if (hasStartedGenerating) {
|
||||
// It actively generated and now it stopped -> DONE
|
||||
// Provide a small buffer to let React render the final message fully
|
||||
await sleep(500);
|
||||
return page;
|
||||
}
|
||||
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
|
||||
if (textChanged) {
|
||||
if (currentText === lastText) {
|
||||
stableCount++;
|
||||
if (stableCount >= stableThreshold) {
|
||||
return page; // Text has been stable for 2 seconds -> DONE
|
||||
}
|
||||
}
|
||||
else {
|
||||
stableCount = 0;
|
||||
lastText = currentText;
|
||||
}
|
||||
}
|
||||
else {
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err.message || String(err);
|
||||
const isSessionLoss = /closed|lost|not open|websocket/i.test(msg);
|
||||
if (opts.reconnect && isSessionLoss && reconnectCount < 2) {
|
||||
reconnectCount++;
|
||||
console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`);
|
||||
try {
|
||||
page = await opts.reconnect();
|
||||
// Reset stability tracking after reconnect
|
||||
stableCount = 0;
|
||||
lastText = currentText;
|
||||
lastText = beforeText;
|
||||
continue;
|
||||
}
|
||||
catch (reconnectErr) {
|
||||
console.error(`[serve] Reconnection failed: ${reconnectErr.message}`);
|
||||
throw err; // Throw original error if reconnection itself fails
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
throw new Error('Timeout waiting for Antigravity reply');
|
||||
throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
|
||||
}
|
||||
// ─── Request Handlers ────────────────────────────────────────────────
|
||||
async function handleMessages(body, page, bridge) {
|
||||
async function handleMessages(body, page, opts = {}) {
|
||||
const { bridge, timeout, reconnect } = opts;
|
||||
// Extract the last user message
|
||||
const userMessages = body.messages.filter(m => m.role === 'user');
|
||||
if (userMessages.length === 0) {
|
||||
@@ -328,7 +366,7 @@ async function handleMessages(body, page, bridge) {
|
||||
await sendMessage(page, userText, bridge);
|
||||
// Poll for reply (change detection)
|
||||
console.error('[serve] Waiting for reply...');
|
||||
await waitForReply(page, beforeText);
|
||||
page = await waitForReply(page, beforeText, { timeout, reconnect });
|
||||
// Extract the actual reply text precisely from the DOM
|
||||
const replyText = await getLastAssistantReply(page, userText);
|
||||
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
|
||||
@@ -349,6 +387,10 @@ async function handleMessages(body, page, bridge) {
|
||||
// ─── Server ──────────────────────────────────────────────────────────
|
||||
export async function startServe(opts = {}) {
|
||||
const port = opts.port ?? 8082;
|
||||
const envTimeoutSeconds = parseEnvTimeout('OPENCLI_ANTIGRAVITY_TIMEOUT', 120);
|
||||
const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds);
|
||||
const effectiveTimeout = effectiveTimeoutSeconds * 1000;
|
||||
console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`);
|
||||
// Lazy CDP connection — connect when first request comes in
|
||||
let cdp = null;
|
||||
let page = null;
|
||||
@@ -462,7 +504,11 @@ export async function startServe(opts = {}) {
|
||||
}
|
||||
// Lazy connect on first request
|
||||
const activePage = await ensureConnected();
|
||||
const response = await handleMessages(body, activePage, cdp ?? undefined);
|
||||
const response = await handleMessages(body, activePage, {
|
||||
bridge: cdp,
|
||||
timeout: effectiveTimeout,
|
||||
reconnect: ensureConnected,
|
||||
});
|
||||
jsonResponse(res, 200, response);
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('apple-podcasts search command', () => {
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await cmd.func(null, {
|
||||
const result = await cmd.func({
|
||||
query: 'machine learning',
|
||||
keyword: 'sports',
|
||||
limit: 5,
|
||||
@@ -60,7 +60,7 @@ describe('apple-podcasts top command', () => {
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await cmd.func(null, { country: 'US', limit: 1 });
|
||||
await cmd.func({ country: 'US', limit: 1 });
|
||||
const [, options] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(options).toBeDefined();
|
||||
expect(options.signal).toBeDefined();
|
||||
@@ -81,7 +81,7 @@ describe('apple-podcasts top command', () => {
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await cmd.func(null, { country: 'US', limit: 2 });
|
||||
const result = await cmd.func({ country: 'US', limit: 2 });
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
|
||||
signal: expect.any(Object),
|
||||
}));
|
||||
@@ -94,6 +94,6 @@ describe('apple-podcasts top command', () => {
|
||||
const cmd = getRegistry().get('apple-podcasts/top');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
||||
await expect(cmd.func(null, { country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
|
||||
await expect(cmd.func({ country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
|
||||
],
|
||||
columns: ['title', 'duration', 'date'],
|
||||
func: async (_page, args) => {
|
||||
func: async (args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 200));
|
||||
// results[0] is the podcast itself; the rest are episodes
|
||||
const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
|
||||
|
||||
@@ -12,7 +12,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
|
||||
func: async (_page, args) => {
|
||||
func: async (args) => {
|
||||
const term = encodeURIComponent(args.query);
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
|
||||
|
||||
@@ -14,7 +14,7 @@ cli({
|
||||
{ name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'id'],
|
||||
func: async (_page, args) => {
|
||||
func: async (args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 100));
|
||||
const country = String(args.country || 'us').trim().toLowerCase();
|
||||
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { normalizeArxivCategory, normalizeArxivLimit, parseEntries } from './utils.js';
|
||||
import './paper.js';
|
||||
import './search.js';
|
||||
import './recent.js';
|
||||
|
||||
const SAMPLE_ENTRY_XML = `<?xml version='1.0' encoding='UTF-8'?>
|
||||
<feed xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"
|
||||
xmlns:arxiv="http://arxiv.org/schemas/atom"
|
||||
xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/1706.03762v7</id>
|
||||
<title>Attention Is All You Need & Friends</title>
|
||||
<updated>2023-08-02T00:41:18Z</updated>
|
||||
<link href="https://arxiv.org/abs/1706.03762v7" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/1706.03762v7" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>The dominant sequence transduction models are based on complex recurrent or convolutional neural networks. We propose a new simple network architecture, the Transformer, based solely on attention.</summary>
|
||||
<category term="cs.CL" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2017-06-12T17:57:34Z</published>
|
||||
<arxiv:comment>15 pages, 5 figures</arxiv:comment>
|
||||
<arxiv:primary_category term="cs.CL"/>
|
||||
<author><name>Ashish Vaswani</name></author>
|
||||
<author><name>Noam Shazeer</name></author>
|
||||
<author><name>Niki Parmar</name></author>
|
||||
<author><name>Jakob Uszkoreit</name></author>
|
||||
<author><name>Llion Jones</name></author>
|
||||
<author><name>Aidan N. Gomez</name></author>
|
||||
<author><name>Lukasz Kaiser</name></author>
|
||||
<author><name>Illia Polosukhin</name></author>
|
||||
</entry>
|
||||
</feed>`;
|
||||
|
||||
describe('arxiv adapter', () => {
|
||||
it('registers paper, search and recent commands with the expected columns', () => {
|
||||
const paper = getRegistry().get('arxiv/paper');
|
||||
const search = getRegistry().get('arxiv/search');
|
||||
const recent = getRegistry().get('arxiv/recent');
|
||||
|
||||
expect(paper).toBeDefined();
|
||||
expect(search).toBeDefined();
|
||||
expect(recent).toBeDefined();
|
||||
|
||||
expect(paper.columns).toEqual([
|
||||
'id', 'title', 'authors', 'published', 'updated',
|
||||
'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url',
|
||||
]);
|
||||
expect(search.columns).toEqual([
|
||||
'id', 'title', 'authors', 'published', 'primary_category', 'url',
|
||||
]);
|
||||
expect(recent.columns).toEqual([
|
||||
'id', 'title', 'authors', 'published', 'primary_category', 'url',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parseEntries returns full abstract, all authors, pdf, primary category and comment', () => {
|
||||
const [entry] = parseEntries(SAMPLE_ENTRY_XML);
|
||||
|
||||
expect(entry.id).toBe('1706.03762');
|
||||
expect(entry.title).toBe('Attention Is All You Need & Friends');
|
||||
// All 8 authors must be present — earlier impl truncated to 3.
|
||||
expect(entry.authors.split(', ')).toHaveLength(8);
|
||||
expect(entry.authors).toContain('Ashish Vaswani');
|
||||
expect(entry.authors).toContain('Illia Polosukhin');
|
||||
// Full abstract — earlier impl truncated at 200 chars.
|
||||
expect(entry.abstract.length).toBeGreaterThan(140);
|
||||
expect(entry.abstract.endsWith('...')).toBe(false);
|
||||
expect(entry.abstract).toContain('attention');
|
||||
expect(entry.published).toBe('2017-06-12');
|
||||
expect(entry.updated).toBe('2023-08-02');
|
||||
expect(entry.primary_category).toBe('cs.CL');
|
||||
expect(entry.categories).toBe('cs.CL, cs.LG');
|
||||
expect(entry.comment).toBe('15 pages, 5 figures');
|
||||
expect(entry.pdf).toBe('https://arxiv.org/pdf/1706.03762v7');
|
||||
expect(entry.url).toBe('https://arxiv.org/abs/1706.03762');
|
||||
});
|
||||
|
||||
it('parseEntries returns an empty list for feeds with no entries', () => {
|
||||
expect(parseEntries('<feed></feed>')).toEqual([]);
|
||||
});
|
||||
|
||||
it('recent rejects malformed category strings', async () => {
|
||||
const recent = getRegistry().get('arxiv/recent');
|
||||
await expect(recent.func({ category: 'not a category', limit: 5 })).rejects.toMatchObject({
|
||||
code: 'ARGUMENT',
|
||||
});
|
||||
await expect(recent.func({ category: '', limit: 5 })).rejects.toMatchObject({
|
||||
code: 'ARGUMENT',
|
||||
});
|
||||
});
|
||||
|
||||
it('category validation accepts real arXiv archive and subcategory forms', () => {
|
||||
expect(normalizeArxivCategory('cs.CL')).toBe('cs.CL');
|
||||
expect(normalizeArxivCategory('math')).toBe('math');
|
||||
expect(normalizeArxivCategory('physics.comp-ph')).toBe('physics.comp-ph');
|
||||
expect(normalizeArxivCategory('physics.data-an')).toBe('physics.data-an');
|
||||
expect(normalizeArxivCategory('cond-mat.soft')).toBe('cond-mat.soft');
|
||||
expect(normalizeArxivCategory('q-bio.NC')).toBe('q-bio.NC');
|
||||
expect(() => normalizeArxivCategory('not a category')).toThrow('Invalid arXiv category');
|
||||
expect(() => normalizeArxivCategory('cs/CL')).toThrow('Invalid arXiv category');
|
||||
expect(() => normalizeArxivCategory('')).toThrow('Invalid arXiv category');
|
||||
});
|
||||
|
||||
it('limit validation rejects non-positive, non-integer and over-cap values', () => {
|
||||
expect(normalizeArxivLimit(10, 5, 25)).toBe(10);
|
||||
expect(normalizeArxivLimit(undefined, 5, 25)).toBe(5);
|
||||
expect(() => normalizeArxivLimit(0, 5, 25)).toThrow('positive integer');
|
||||
expect(() => normalizeArxivLimit(1.5, 5, 25)).toThrow('positive integer');
|
||||
expect(() => normalizeArxivLimit(26, 5, 25)).toThrow('<= 25');
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, parseEntries } from './utils.js';
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
@@ -10,12 +10,12 @@ cli({
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
|
||||
],
|
||||
columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
|
||||
func: async (_page, args) => {
|
||||
columns: ['id', 'title', 'authors', 'published', 'updated', 'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url'],
|
||||
func: async (args) => {
|
||||
const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
|
||||
const entries = parseEntries(xml);
|
||||
if (!entries.length)
|
||||
throw new CliError('NOT_FOUND', `Paper ${args.id} not found`, 'Check the arXiv ID format, e.g. 1706.03762');
|
||||
throw new EmptyResultError('arxiv paper', `Paper ${args.id} was not found. Check the arXiv ID format, e.g. 1706.03762`);
|
||||
return entries;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, normalizeArxivCategory, normalizeArxivLimit, parseEntries } from './utils.js';
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
name: 'recent',
|
||||
description: 'List recent arXiv submissions in a category',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'category', positional: true, required: true, help: 'arXiv category (e.g. cs.CL, cs.LG, math.PR, q-bio.NC)' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 50)' },
|
||||
],
|
||||
columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
|
||||
func: async (args) => {
|
||||
const category = normalizeArxivCategory(args.category);
|
||||
const limit = normalizeArxivLimit(args.limit, 10, 50);
|
||||
const query = encodeURIComponent(`cat:${category}`);
|
||||
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=submittedDate&sortOrder=descending`);
|
||||
const entries = parseEntries(xml);
|
||||
if (!entries.length)
|
||||
throw new EmptyResultError('arxiv', `No recent papers in ${category}. Check the category name.`);
|
||||
return entries.map(e => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
authors: e.authors,
|
||||
published: e.published,
|
||||
primary_category: e.primary_category,
|
||||
url: e.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
+19
-8
@@ -1,6 +1,6 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, parseEntries } from './utils.js';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, normalizeArxivLimit, parseEntries } from './utils.js';
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
name: 'search',
|
||||
@@ -11,14 +11,25 @@ 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'],
|
||||
func: async (_page, args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
const query = encodeURIComponent(`all:${args.query}`);
|
||||
columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
|
||||
func: async (args) => {
|
||||
const queryText = String(args.query || '').trim();
|
||||
if (!queryText) {
|
||||
throw new ArgumentError('arxiv search query cannot be empty');
|
||||
}
|
||||
const limit = normalizeArxivLimit(args.limit, 10, 25);
|
||||
const query = encodeURIComponent(`all:${queryText}`);
|
||||
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 }));
|
||||
throw new EmptyResultError('arxiv', 'No papers found. Try a different keyword.');
|
||||
return entries.map(e => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
authors: e.authors,
|
||||
published: e.published,
|
||||
primary_category: e.primary_category,
|
||||
url: e.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+68
-5
@@ -4,15 +4,44 @@
|
||||
* arXiv exposes a public Atom/XML API — no key required.
|
||||
* https://info.arxiv.org/help/api/index.html
|
||||
*/
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
|
||||
const ARXIV_CATEGORY_PATTERN = /^[a-z]+(?:-[a-z]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)?$/;
|
||||
export async function arxivFetch(params) {
|
||||
const resp = await fetch(`${ARXIV_BASE}?${params}`);
|
||||
if (!resp.ok) {
|
||||
throw new CliError('FETCH_ERROR', `arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
|
||||
throw new CommandExecutionError(`arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
export function normalizeArxivLimit(value, defaultValue, maxValue, label = 'limit') {
|
||||
const raw = value ?? defaultValue;
|
||||
const limit = Number(raw);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new ArgumentError(`arxiv ${label} must be a positive integer`);
|
||||
}
|
||||
if (limit > maxValue) {
|
||||
throw new ArgumentError(`arxiv ${label} must be <= ${maxValue}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
export function normalizeArxivCategory(value) {
|
||||
const category = String(value || '').trim();
|
||||
if (!ARXIV_CATEGORY_PATTERN.test(category)) {
|
||||
throw new ArgumentError(`Invalid arXiv category "${value}". Examples: cs.CL, cs.LG, math.PR, q-bio.NC, physics.comp-ph`);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
/** Decode the small set of XML entities arXiv emits in text fields. */
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
/** Extract the text content of the first matching XML tag. */
|
||||
function extract(xml, tag) {
|
||||
const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
|
||||
@@ -27,6 +56,34 @@ function extractAll(xml, tag) {
|
||||
results.push(m[1].trim());
|
||||
return results;
|
||||
}
|
||||
/** Extract the value of a named attribute from the first matching tag (open or self-closing). */
|
||||
function extractAttr(xml, tag, attr) {
|
||||
const m = xml.match(new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
/** Extract all values of a named attribute across repeated tags. */
|
||||
function extractAllAttr(xml, tag, attr) {
|
||||
const re = new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`, 'g');
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null)
|
||||
out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
/** Find the href of the first <link> tag matching a given rel. */
|
||||
function findLinkHref(xml, rel) {
|
||||
const re = /<link\b([^>]*)\/?>/g;
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
const attrs = m[1];
|
||||
if (new RegExp(`\\brel="${rel}"`).test(attrs)) {
|
||||
const h = attrs.match(/\bhref="([^"]*)"/);
|
||||
if (h)
|
||||
return h[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
/** Parse Atom XML feed into structured entries. */
|
||||
export function parseEntries(xml) {
|
||||
const entryRe = /<entry>([\s\S]*?)<\/entry>/g;
|
||||
@@ -36,12 +93,18 @@ export function parseEntries(xml) {
|
||||
const e = m[1];
|
||||
const rawId = extract(e, 'id');
|
||||
const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, '');
|
||||
const pdf = findLinkHref(e, 'related') || `https://arxiv.org/pdf/${arxivId}`;
|
||||
entries.push({
|
||||
id: arxivId,
|
||||
title: extract(e, 'title').replace(/\s+/g, ' '),
|
||||
authors: extractAll(e, 'name').slice(0, 3).join(', '),
|
||||
abstract: (() => { const s = extract(e, 'summary').replace(/\s+/g, ' '); return s.length > 200 ? s.slice(0, 200) + '...' : s; })(),
|
||||
title: decodeEntities(extract(e, 'title').replace(/\s+/g, ' ')),
|
||||
authors: decodeEntities(extractAll(e, 'name').join(', ')),
|
||||
abstract: decodeEntities(extract(e, 'summary').replace(/\s+/g, ' ')),
|
||||
published: extract(e, 'published').slice(0, 10),
|
||||
updated: extract(e, 'updated').slice(0, 10),
|
||||
primary_category: extractAttr(e, 'arxiv:primary_category', 'term'),
|
||||
categories: extractAllAttr(e, 'category', 'term').join(', '),
|
||||
comment: decodeEntities(extract(e, 'arxiv:comment').replace(/\s+/g, ' ')),
|
||||
pdf,
|
||||
url: `https://arxiv.org/abs/${arxivId}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
||||
|
||||
cli({
|
||||
site: 'baidu-scholar',
|
||||
name: 'search',
|
||||
description: '百度学术搜索',
|
||||
domain: 'xueshu.baidu.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'authors', 'journal', 'year', 'cited', 'url'],
|
||||
navigateBefore: false,
|
||||
func: async (page, kwargs) => {
|
||||
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
||||
const query = requireNonEmptyQuery(kwargs.query);
|
||||
await page.goto(`https://xueshu.baidu.com/s?wd=${encodeURIComponent(query)}&pn=0&tn=SE_baiduxueshu_c1gjeupa`);
|
||||
await page.wait(5);
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (document.querySelectorAll('.result').length > 0) break;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
const results = [];
|
||||
for (const el of document.querySelectorAll('.result')) {
|
||||
const titleEl = el.querySelector('h3 a, .paper-title a, .t a');
|
||||
const title = normalize(titleEl?.textContent);
|
||||
if (!title) continue;
|
||||
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (url && !url.startsWith('http')) url = 'https://xueshu.baidu.com' + url;
|
||||
|
||||
const infoEl = el.querySelector('.paper-info');
|
||||
const infoText = normalize(infoEl?.textContent);
|
||||
const spans = infoEl ? Array.from(infoEl.querySelectorAll('span')) : [];
|
||||
|
||||
let journal = '';
|
||||
let year = '';
|
||||
let cited = '0';
|
||||
const authorParts = [];
|
||||
|
||||
for (const span of spans) {
|
||||
const text = normalize(span.textContent);
|
||||
if (!text || text === ',' || text === ',') continue;
|
||||
if (text.startsWith('《') || text.startsWith('〈')) {
|
||||
journal = text.replace(/[《》〈〉]/g, '');
|
||||
continue;
|
||||
}
|
||||
if (/^被引量[::]/.test(text)) {
|
||||
cited = text.match(/(\\d+)/)?.[1] || '0';
|
||||
continue;
|
||||
}
|
||||
if (/^-\\s*(\\d{4})/.test(text) || /^\\d{4}年?$/.test(text)) {
|
||||
year = text.match(/(\\d{4})/)?.[1] || '';
|
||||
continue;
|
||||
}
|
||||
if (!journal && !/^被引/.test(text) && !text.startsWith('-')) {
|
||||
authorParts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (!year) year = infoText.match(/(19|20)\\d{2}/)?.[0] || '';
|
||||
if (!cited || cited === '0') cited = infoText.match(/被引量[::]\\s*(\\d+)/)?.[1] || '0';
|
||||
|
||||
results.push({
|
||||
rank: results.length + 1,
|
||||
title,
|
||||
authors: authorParts.join(', ').slice(0, 80),
|
||||
journal,
|
||||
year,
|
||||
cited,
|
||||
url,
|
||||
});
|
||||
|
||||
if (results.length >= ${limit}) break;
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './search.js';
|
||||
|
||||
describe('baidu-scholar search command', () => {
|
||||
const command = getRegistry().get('baidu-scholar/search');
|
||||
|
||||
it('registers as a public browser command', () => {
|
||||
expect(command).toBeDefined();
|
||||
expect(command.site).toBe('baidu-scholar');
|
||||
expect(command.strategy).toBe('public');
|
||||
expect(command.browser).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty queries before browser navigation', async () => {
|
||||
const page = { goto: vi.fn() };
|
||||
await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
|
||||
name: 'ArgumentError',
|
||||
code: 'ARGUMENT',
|
||||
});
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
/**
|
||||
* band mentions — Show Band notifications where you were @mentioned.
|
||||
@@ -52,7 +52,7 @@ cli({
|
||||
await page.wait(0.5);
|
||||
}
|
||||
if (!bellReady) {
|
||||
throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
||||
throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
||||
}
|
||||
// Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
|
||||
// getInterceptedRequests() clears the array on each call, so captures are accumulated
|
||||
@@ -80,7 +80,7 @@ cli({
|
||||
return true;
|
||||
}`);
|
||||
if (!bellClicked) {
|
||||
throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
||||
throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
||||
}
|
||||
const requests = await waitForOneCapture();
|
||||
// Find the get_news response (has result_data.news); get_news_count responses do not.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'description', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
const count = Math.min(kwargs.limit || 20, 50);
|
||||
const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml');
|
||||
if (!resp.ok)
|
||||
|
||||
+18
-13
@@ -3,27 +3,32 @@ import { apiGet, payloadData, getSelfUid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'favorite',
|
||||
description: '我的默认收藏夹',
|
||||
description: '我的收藏夹',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'fid', type: 'int', required: false, help: 'Favorite folder ID (defaults to first folder)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
||||
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'plays', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const { limit = 20, page: pageNum = 1 } = kwargs;
|
||||
// Get current user's UID
|
||||
const uid = await getSelfUid(page);
|
||||
// Get default favorite folder ID
|
||||
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
|
||||
params: { up_mid: uid },
|
||||
signed: true,
|
||||
});
|
||||
const folders = payloadData(foldersPayload)?.list ?? [];
|
||||
if (!folders.length)
|
||||
return [];
|
||||
const fid = folders[0].id;
|
||||
const { fid: favoriteId, limit = 20, page: pageNum = 1 } = kwargs;
|
||||
let fid;
|
||||
if (favoriteId) {
|
||||
fid = Number(favoriteId);
|
||||
} else {
|
||||
// Fall back to the default (first) favorite folder
|
||||
const uid = await getSelfUid(page);
|
||||
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
|
||||
params: { up_mid: uid },
|
||||
signed: true,
|
||||
});
|
||||
const folders = payloadData(foldersPayload)?.list ?? [];
|
||||
if (!folders.length)
|
||||
return [];
|
||||
fid = folders[0].id;
|
||||
}
|
||||
// Fetch favorite items
|
||||
const payload = await apiGet(page, '/x/v3/fav/resource/list', {
|
||||
params: { media_id: fid, pn: pageNum, ps: Math.min(Number(limit), 40) },
|
||||
|
||||
@@ -7,7 +7,7 @@ cli({
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'play', 'danmaku'],
|
||||
columns: ['rank', 'title', 'author', 'play', 'danmaku', 'bvid', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://www.bilibili.com' },
|
||||
{ evaluate: `(async () => {
|
||||
@@ -20,6 +20,8 @@ cli({
|
||||
author: item.owner?.name,
|
||||
play: item.stat?.view,
|
||||
danmaku: item.stat?.danmaku,
|
||||
bvid: item.bvid,
|
||||
url: item.bvid ? 'https://www.bilibili.com/video/' + item.bvid : '',
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
@@ -29,6 +31,8 @@ cli({
|
||||
author: '${{ item.author }}',
|
||||
play: '${{ item.play }}',
|
||||
danmaku: '${{ item.danmaku }}',
|
||||
bvid: '${{ item.bvid }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './hot.js';
|
||||
|
||||
describe('bilibili hot adapter', () => {
|
||||
const command = getRegistry().get('bilibili/hot');
|
||||
|
||||
it('registers bvid and url columns in the public hot-list shape', () => {
|
||||
expect(command?.columns).toEqual(['rank', 'title', 'author', 'play', 'danmaku', 'bvid', 'url']);
|
||||
expect(command?.pipeline?.[1]?.evaluate).toContain('bvid: item.bvid');
|
||||
expect(command?.pipeline?.[1]?.evaluate).toContain("'https://www.bilibili.com/video/' + item.bvid");
|
||||
expect(command?.pipeline?.[2]?.map).toMatchObject({
|
||||
bvid: '${{ item.bvid }}',
|
||||
url: '${{ item.url }}',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -23,7 +23,7 @@ cli({
|
||||
return state?.videoData?.cid;
|
||||
})()`);
|
||||
if (!cid) {
|
||||
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
}
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'video',
|
||||
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
|
||||
],
|
||||
columns: ['field', 'value'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) {
|
||||
throw new CommandExecutionError('Browser session required for bilibili video');
|
||||
}
|
||||
|
||||
// Resolve BV ID from three advertised input forms:
|
||||
// 1. Bare "BV..." id
|
||||
// 2. Full bilibili.com/video/<BV>... URL (with or without query string / www / m.)
|
||||
// 3. b23.tv short link (delegated to resolveBvid)
|
||||
// resolveBvid() alone handles (1) and (3) but not (2), so we pre-extract
|
||||
// from bilibili URLs before falling through.
|
||||
const input = String(kwargs.bvid ?? '').trim();
|
||||
const bilibiliUrlMatch = input.match(/bilibili\.com\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
|
||||
const bvid = bilibiliUrlMatch ? bilibiliUrlMatch[1] : await resolveBvid(input);
|
||||
|
||||
// Navigate to video page first so subsequent api call shares a primed session.
|
||||
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
|
||||
|
||||
const payload = await apiGet(page, '/x/web-interface/view', {
|
||||
params: { bvid },
|
||||
});
|
||||
if (payload.code !== 0) {
|
||||
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const d = payload.data || {};
|
||||
const stat = d.stat || {};
|
||||
const owner = d.owner || {};
|
||||
|
||||
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
|
||||
const dur = d.duration || 0;
|
||||
const mm = Math.floor(dur / 60);
|
||||
const ss = dur % 60;
|
||||
|
||||
return [
|
||||
{ field: 'bvid', value: d.bvid ?? '' },
|
||||
{ field: 'aid', value: String(d.aid ?? '') },
|
||||
{ field: 'title', value: d.title ?? '' },
|
||||
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
|
||||
{ field: 'category', value: d.tname_v2 || d.tname || '' },
|
||||
{ field: 'publish_time', value: pubDate },
|
||||
{ field: 'duration', value: dur ? `${mm}m${ss}s (${dur}s)` : '' },
|
||||
{ field: 'view', value: String(stat.view ?? '') },
|
||||
{ field: 'danmaku', value: String(stat.danmaku ?? '') },
|
||||
{ field: 'reply', value: String(stat.reply ?? '') },
|
||||
{ field: 'like', value: String(stat.like ?? '') },
|
||||
{ field: 'coin', value: String(stat.coin ?? '') },
|
||||
{ field: 'favorite', value: String(stat.favorite ?? '') },
|
||||
{ field: 'share', value: String(stat.share ?? '') },
|
||||
{ field: 'parts', value: String(d.videos ?? 1) },
|
||||
{ field: 'thumbnail', value: d.pic ?? '' },
|
||||
{ field: 'description', value: d.desc ?? '' },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './video.js';
|
||||
|
||||
describe('bilibili video', () => {
|
||||
const command = getRegistry().get('bilibili/video');
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
page.goto.mockClear();
|
||||
page.evaluate.mockReset();
|
||||
});
|
||||
|
||||
it('returns a field/value table of video metadata on success', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1xx411c7mD',
|
||||
aid: 12345678,
|
||||
title: '三层结构笔记法',
|
||||
tname: '教程',
|
||||
pubdate: 1775053078, // 2026-04-01 14:17:58 UTC
|
||||
duration: 434,
|
||||
videos: 1,
|
||||
pic: 'https://i1.hdslb.com/some.jpg',
|
||||
desc: 'Obsidian 教程',
|
||||
owner: { mid: 507578555, name: 'IOI科技' },
|
||||
stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
|
||||
// Every row has { field, value }
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
for (const row of rows) {
|
||||
expect(row).toHaveProperty('field');
|
||||
expect(row).toHaveProperty('value');
|
||||
}
|
||||
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.bvid).toBe('BV1xx411c7mD');
|
||||
expect(byField.title).toBe('三层结构笔记法');
|
||||
expect(byField.author).toBe('IOI科技 (mid: 507578555)');
|
||||
expect(byField.duration).toBe('7m14s (434s)');
|
||||
expect(byField.view).toBe('6128');
|
||||
expect(byField.like).toBe('162');
|
||||
|
||||
// Navigation primes the session
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
|
||||
// API called without signing
|
||||
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when bilibili view API returns non-zero code', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: -404,
|
||||
message: '啥都木有',
|
||||
data: null,
|
||||
});
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /啥都木有|-404/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts BV ID from full bilibili.com URL input', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
|
||||
});
|
||||
|
||||
await command.func(page, { bvid: 'https://www.bilibili.com/video/BV1xx411c7mD/' });
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
|
||||
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
|
||||
});
|
||||
|
||||
it('extracts BV ID from bilibili URL with trailing query string', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '' },
|
||||
});
|
||||
|
||||
await command.func(page, {
|
||||
bvid: 'https://www.bilibili.com/video/BV1Je9EBnEha/?spm_id_from=333.1007&vd_source=abc',
|
||||
});
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1Je9EBnEha' } });
|
||||
});
|
||||
|
||||
it('extracts BV ID from m.bilibili.com mobile URL', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
|
||||
});
|
||||
|
||||
await command.func(page, { bvid: 'https://m.bilibili.com/video/BV1xx411c7mD' });
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
|
||||
});
|
||||
|
||||
it('returns full description without truncation or whitespace collapse', async () => {
|
||||
const longDesc = '第一行描述\n\n第二段,有多个空格 和换行\n\n' + 'x'.repeat(500);
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc },
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
// JSON/YAML consumers must receive the complete description verbatim,
|
||||
// including original whitespace and length > 200 chars.
|
||||
expect(byField.description).toBe(longDesc);
|
||||
expect(byField.description.length).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'depth',
|
||||
description: 'Order book bid prices for a trading pair',
|
||||
description: 'Order book bid and ask prices for a trading pair',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
@@ -11,11 +11,10 @@ cli({
|
||||
{ name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' },
|
||||
],
|
||||
columns: ['rank', 'bid_price', 'bid_qty'],
|
||||
columns: ['rank', 'bid_price', 'bid_qty', 'ask_price', 'ask_qty'],
|
||||
pipeline: [
|
||||
{ fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
|
||||
{ select: 'bids' },
|
||||
{ map: { rank: '${{ index + 1 }}', bid_price: '${{ item.0 }}', bid_qty: '${{ item.1 }}' } },
|
||||
{ map: { select: 'bids', rank: '${{ index + 1 }}', bid_price: '${{ item[0] }}', bid_qty: '${{ item[1] }}', ask_price: '${{ root.asks[index]?.[0] ?? "" }}', ask_qty: '${{ root.asks[index]?.[1] ?? "" }}' } },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('economics', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('industries', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('main', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('markets', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('opinions', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('politics', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('tech', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
|
||||
+49
-8
@@ -2,6 +2,7 @@
|
||||
* BOSS直聘 job search — browser cookie API.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
|
||||
/** City name → BOSS Zhipin city code mapping */
|
||||
const CITY_CODES = {
|
||||
@@ -22,8 +23,16 @@ const CITY_CODES = {
|
||||
'香港': '101320100',
|
||||
};
|
||||
const EXP_MAP = {
|
||||
'不限': '0', '在校/应届': '108', '应届': '108', '1年以内': '101',
|
||||
'1-3年': '102', '3-5年': '103', '5-10年': '104', '10年以上': '105',
|
||||
'不限': '0',
|
||||
'在校/应届': '108',
|
||||
'在校生': '108', '在校': '108',
|
||||
'应届生': '102', '应届': '102',
|
||||
'经验不限': '101',
|
||||
'1年以内': '103',
|
||||
'1-3年': '104',
|
||||
'3-5年': '105',
|
||||
'5-10年': '106',
|
||||
'10年以上': '107',
|
||||
};
|
||||
const DEGREE_MAP = {
|
||||
'不限': '0', '初中及以下': '209', '中专/中技': '208', '高中': '206',
|
||||
@@ -38,6 +47,10 @@ const INDUSTRY_MAP = {
|
||||
'人工智能': '100901', '大数据': '100902', '金融': '100101',
|
||||
'教育培训': '100200', '医疗健康': '100300',
|
||||
};
|
||||
const JOB_TYPE_MAP = {
|
||||
'不限': '0', '全职': '1901', '实习': '1902', '兼职': '1903',
|
||||
};
|
||||
const JOB_TYPE_CODES = new Set(Object.values(JOB_TYPE_MAP));
|
||||
function resolveCity(input) {
|
||||
if (!input)
|
||||
return '101010100';
|
||||
@@ -62,35 +75,54 @@ function resolveMap(input, map) {
|
||||
}
|
||||
return input;
|
||||
}
|
||||
function resolveJobType(input) {
|
||||
if (!input)
|
||||
return '';
|
||||
if (JOB_TYPE_MAP[input] !== undefined)
|
||||
return JOB_TYPE_MAP[input];
|
||||
if (JOB_TYPE_CODES.has(input))
|
||||
return input;
|
||||
throw new ArgumentError(`Invalid jobType: ${input}`, 'Use one of: 全职, 兼职, 实习, 不限');
|
||||
}
|
||||
function formatBossOnline(value) {
|
||||
if (value === true)
|
||||
return 'Y';
|
||||
if (value === false)
|
||||
return 'N';
|
||||
return '';
|
||||
}
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'search',
|
||||
description: 'BOSS直聘搜索职位',
|
||||
description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
|
||||
domain: 'www.zhipin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: 'Search keyword (e.g. AI agent, 前端)' },
|
||||
{ name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
|
||||
{ name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
|
||||
{ name: 'experience', default: '', help: 'Experience: 应届/1年以内/1-3年/3-5年/5-10年/10年以上' },
|
||||
{ name: 'experience', default: '', help: 'Experience: 在校生(实习)/应届生(校招)/经验不限/1年以内/1-3年/3-5年/5-10年/10年以上' },
|
||||
{ name: 'degree', default: '', help: 'Degree: 大专/本科/硕士/博士' },
|
||||
{ name: 'salary', default: '', help: 'Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上' },
|
||||
{ name: 'industry', default: '', help: 'Industry code or name (e.g. 100020, 互联网)' },
|
||||
{ name: 'jobType', default: '', help: 'Job type: 全职/兼职/实习(不传=不限,混合校招与实习)' },
|
||||
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
||||
],
|
||||
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
|
||||
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'bossOnline', 'security_id', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const query = String(kwargs.query ?? '').trim();
|
||||
const cityCode = resolveCity(kwargs.city);
|
||||
verbose('Navigating to set referrer context...');
|
||||
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(kwargs.query)}&city=${cityCode}`);
|
||||
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const expVal = resolveMap(kwargs.experience, EXP_MAP);
|
||||
const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
|
||||
const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
|
||||
const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
|
||||
const jobTypeVal = resolveJobType(kwargs.jobType);
|
||||
const limit = kwargs.limit || 15;
|
||||
let currentPage = kwargs.page || 1;
|
||||
let allJobs = [];
|
||||
@@ -101,7 +133,7 @@ cli({
|
||||
}
|
||||
const qs = new URLSearchParams({
|
||||
scene: '1',
|
||||
query: kwargs.query,
|
||||
query,
|
||||
city: cityCode,
|
||||
page: String(currentPage),
|
||||
pageSize: '15',
|
||||
@@ -114,6 +146,8 @@ cli({
|
||||
qs.set('salary', salaryVal);
|
||||
if (industryVal)
|
||||
qs.set('industry', industryVal);
|
||||
if (jobTypeVal)
|
||||
qs.set('jobType', jobTypeVal);
|
||||
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
|
||||
verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
|
||||
const data = await bossFetch(page, targetUrl);
|
||||
@@ -135,6 +169,7 @@ cli({
|
||||
degree: j.jobDegree,
|
||||
skills: (j.skills || []).join(','),
|
||||
boss: j.bossName + ' · ' + j.bossTitle,
|
||||
bossOnline: formatBossOnline(j.bossOnline),
|
||||
security_id: j.securityId || '',
|
||||
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
||||
});
|
||||
@@ -153,3 +188,9 @@ cli({
|
||||
return allJobs;
|
||||
},
|
||||
});
|
||||
export const __test__ = {
|
||||
EXP_MAP,
|
||||
resolveMap,
|
||||
resolveJobType,
|
||||
formatBossOnline,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { __test__ } from './search.js';
|
||||
import './search.js';
|
||||
|
||||
function createPageMock(response) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(response),
|
||||
};
|
||||
}
|
||||
|
||||
describe('boss search', () => {
|
||||
const command = getRegistry().get('boss/search');
|
||||
|
||||
it('keeps legacy 在校/应届 experience input compatible', () => {
|
||||
expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
|
||||
expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
|
||||
});
|
||||
|
||||
it('fails fast on invalid jobType values', async () => {
|
||||
expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('accepts supported jobType labels and raw codes', () => {
|
||||
expect(__test__.resolveJobType('全职')).toBe('1901');
|
||||
expect(__test__.resolveJobType('实习')).toBe('1902');
|
||||
expect(__test__.resolveJobType('兼职')).toBe('1903');
|
||||
expect(__test__.resolveJobType('1902')).toBe('1902');
|
||||
});
|
||||
|
||||
it('keeps empty query empty and sends jobType filter to the API', async () => {
|
||||
const page = createPageMock({
|
||||
code: 0,
|
||||
zpData: {
|
||||
hasMore: false,
|
||||
jobList: [
|
||||
{
|
||||
encryptJobId: 'abc',
|
||||
securityId: 'sec',
|
||||
jobName: '前端开发实习生',
|
||||
salaryDesc: '150-200/天',
|
||||
brandName: 'OpenCLI',
|
||||
cityName: '北京',
|
||||
areaDistrict: '海淀区',
|
||||
businessDistrict: '',
|
||||
jobExperience: '在校/应届',
|
||||
jobDegree: '本科',
|
||||
skills: ['JavaScript'],
|
||||
bossName: '张三',
|
||||
bossTitle: '技术负责人',
|
||||
bossOnline: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, {
|
||||
query: undefined,
|
||||
city: '北京',
|
||||
jobType: '实习',
|
||||
limit: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
|
||||
const fetchScript = page.evaluate.mock.calls.at(-1)[0];
|
||||
expect(fetchScript).toContain('query=');
|
||||
expect(fetchScript).not.toContain('query=undefined');
|
||||
expect(fetchScript).toContain('jobType=1902');
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: '前端开发实习生',
|
||||
bossOnline: 'N',
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, } from './utils.js';
|
||||
import { EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'send',
|
||||
@@ -30,12 +30,12 @@ cli({
|
||||
const friendName = friend.name || '候选人';
|
||||
const clicked = await clickCandidateInList(page, numericUid);
|
||||
if (!clicked) {
|
||||
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
throw selectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
}
|
||||
await page.wait({ time: 2 });
|
||||
const sent = await typeAndSendMessage(page, kwargs.text);
|
||||
if (!sent) {
|
||||
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
}
|
||||
await page.wait({ time: 1 });
|
||||
return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
|
||||
|
||||
+2
-2
@@ -214,10 +214,10 @@ export async function typeAndSendMessage(page, text) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Verbose log helper — prints when OPENCLI_VERBOSE or DEBUG=opencli is set.
|
||||
* Verbose log helper — prints when OPENCLI_VERBOSE is set.
|
||||
*/
|
||||
export function verbose(msg) {
|
||||
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli:boss] ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ConfigError } from '@jackwener/opencli/errors';
|
||||
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
|
||||
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating, sendPrompt } from './ax.js';
|
||||
export const askCommand = cli({
|
||||
site: 'chatgpt',
|
||||
site: 'chatgpt-app',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt and wait for the AI response (send + wait + read)',
|
||||
domain: 'localhost',
|
||||
@@ -15,7 +15,7 @@ export const askCommand = cli({
|
||||
{ name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
@@ -27,26 +27,10 @@ export const askCommand = cli({
|
||||
activateChatGPT();
|
||||
selectModel(model);
|
||||
}
|
||||
// Backup clipboard
|
||||
let clipBackup = '';
|
||||
try {
|
||||
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
|
||||
}
|
||||
catch { }
|
||||
const messagesBefore = getVisibleChatMessages();
|
||||
// Send the message
|
||||
spawnSync('pbcopy', { input: text });
|
||||
activateChatGPT();
|
||||
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 clipboard after the prompt is sent.
|
||||
if (clipBackup)
|
||||
spawnSync('pbcopy', { input: clipBackup });
|
||||
sendPrompt(text);
|
||||
// Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears),
|
||||
// then read the final response text.
|
||||
const pollInterval = 2;
|
||||
@@ -60,6 +60,125 @@ for list in lists {
|
||||
let data = try! JSONSerialization.data(withJSONObject: best, options: [])
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
`;
|
||||
const AX_SEND_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 { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEnabled(_ el: AXUIElement) -> Bool {
|
||||
(attr(el, kAXEnabledAttribute as String) as? Bool) ?? true
|
||||
}
|
||||
|
||||
func children(_ el: AXUIElement) -> [AXUIElement] {
|
||||
(attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
|
||||
}
|
||||
|
||||
func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) {
|
||||
guard depth < 25 else { return }
|
||||
let role = s(el, kAXRoleAttribute as String) ?? ""
|
||||
if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) {
|
||||
out.append(el)
|
||||
}
|
||||
for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) }
|
||||
}
|
||||
|
||||
func isInput(_ el: AXUIElement) -> Bool {
|
||||
let role = s(el, kAXRoleAttribute as String) ?? ""
|
||||
return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String
|
||||
}
|
||||
|
||||
func focusedInput(_ axApp: AXUIElement) -> AXUIElement? {
|
||||
guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else {
|
||||
return nil
|
||||
}
|
||||
return isInput(focused) && isEnabled(focused) ? focused : nil
|
||||
}
|
||||
|
||||
func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? {
|
||||
guard depth < 25 else { return nil }
|
||||
let role = s(el, kAXRoleAttribute as String) ?? ""
|
||||
let desc = s(el, kAXDescriptionAttribute as String) ?? ""
|
||||
if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el }
|
||||
for c in children(el) {
|
||||
if let found = findByDescriptions(c, targets, depth: depth + 1) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func press(_ el: AXUIElement) {
|
||||
AXUIElementPerformAction(el, kAXPressAction as CFString)
|
||||
}
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count > 1 else {
|
||||
fputs("Missing prompt text\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let text = args[1]
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var inputs: [AXUIElement] = []
|
||||
collectEditableInputs(win, into: &inputs)
|
||||
guard let input = focusedInput(axApp) ?? inputs.last else {
|
||||
fputs("Could not find editable input area\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else {
|
||||
fputs("Failed to set input value\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
|
||||
guard s(input, kAXValueAttribute as String) == text else {
|
||||
fputs("Failed to verify input value after AX set\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
guard let sendButton = findByDescriptions(win, ["发送", "傳送", "Send"]) else {
|
||||
fputs("Could not find send button\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
press(sendButton)
|
||||
|
||||
var submitted = false
|
||||
for _ in 0..<15 {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
if s(input, kAXValueAttribute as String) != text {
|
||||
submitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard submitted else {
|
||||
fputs("Prompt did not leave input after pressing send\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
print("Sent")
|
||||
`;
|
||||
const AX_MODEL_SCRIPT = `
|
||||
import Cocoa
|
||||
import ApplicationServices
|
||||
@@ -121,11 +240,15 @@ 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 {
|
||||
// Step 1: Click the "Options" button to open the popover (support English, Simplified and Traditional Chinese UI)
|
||||
var optionsBtn: AXUIElement? = nil
|
||||
if let btn = findByDesc(win, "Options") { optionsBtn = btn }
|
||||
else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
|
||||
else if let btn = findByDesc(win, "選項") { optionsBtn = btn }
|
||||
guard let options = optionsBtn else {
|
||||
fputs("Could not find Options button\\n", stderr); exit(1)
|
||||
}
|
||||
press(optionsBtn)
|
||||
press(options)
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
|
||||
// Step 2: Find the popover that appeared, search ONLY within it
|
||||
@@ -189,7 +312,8 @@ 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")
|
||||
let targets = ["Stop generating", "停止生成"]
|
||||
print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
|
||||
`;
|
||||
const MODEL_MAP = {
|
||||
'auto': { desc: 'Auto' },
|
||||
@@ -218,6 +342,13 @@ export function selectModel(model) {
|
||||
}).trim();
|
||||
return output;
|
||||
}
|
||||
export function sendPrompt(text) {
|
||||
return execFileSync('swift', ['-', text], {
|
||||
input: AX_SEND_SCRIPT,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).trim();
|
||||
}
|
||||
export function isGenerating() {
|
||||
try {
|
||||
const output = execFileSync('swift', ['-'], {
|
||||
@@ -247,3 +378,8 @@ export function getVisibleChatMessages() {
|
||||
.map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
export const __test__ = {
|
||||
AX_SEND_SCRIPT,
|
||||
AX_MODEL_SCRIPT,
|
||||
AX_GENERATING_SCRIPT,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './ax.js';
|
||||
|
||||
describe('chatgpt-app AX send script', () => {
|
||||
it('prefers the focused composer before falling back to the last editable input', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('kAXFocusedUIElementAttribute');
|
||||
});
|
||||
|
||||
it('fails fast when the AX set does not round-trip into the composer value', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('Failed to verify input value after AX set');
|
||||
});
|
||||
|
||||
it('does not report success until the prompt leaves the composer after send', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('Prompt did not leave input after pressing send');
|
||||
});
|
||||
|
||||
it('supports english, zh-CN, and zh-TW send button labels', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('["发送", "傳送", "Send"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt-app AX model script', () => {
|
||||
it('supports english, zh-CN, and zh-TW options button labels', () => {
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "Options")');
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "选项")');
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "選項")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt-app generating detection', () => {
|
||||
it('supports both english and zh-CN stop-generating labels', () => {
|
||||
expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating');
|
||||
expect(__test__.AX_GENERATING_SCRIPT).toContain('停止生成');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ConfigError } from '@jackwener/opencli/errors';
|
||||
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
|
||||
export const modelCommand = cli({
|
||||
site: 'chatgpt',
|
||||
site: 'chatgpt-app',
|
||||
name: 'model',
|
||||
description: 'Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)',
|
||||
domain: 'localhost',
|
||||
@@ -12,7 +12,7 @@ export const modelCommand = cli({
|
||||
{ name: 'model', required: true, positional: true, help: 'Model to switch to', choices: MODEL_CHOICES },
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS');
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
export const newCommand = cli({
|
||||
site: 'chatgpt',
|
||||
site: 'chatgpt-app',
|
||||
name: 'new',
|
||||
description: 'Open a new chat in ChatGPT Desktop App',
|
||||
domain: 'localhost',
|
||||
@@ -10,7 +10,7 @@ export const newCommand = cli({
|
||||
browser: false,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
func: async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { getVisibleChatMessages } from './ax.js';
|
||||
export const readCommand = cli({
|
||||
site: 'chatgpt',
|
||||
site: 'chatgpt-app',
|
||||
name: 'read',
|
||||
description: 'Read the last visible message from the focused ChatGPT Desktop window',
|
||||
domain: 'localhost',
|
||||
@@ -11,7 +11,7 @@ export const readCommand = cli({
|
||||
browser: false,
|
||||
args: [],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page) => {
|
||||
func: async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
|
||||
import { activateChatGPT, selectModel, MODEL_CHOICES, sendPrompt } from './ax.js';
|
||||
export const sendCommand = cli({
|
||||
site: 'chatgpt',
|
||||
site: 'chatgpt-app',
|
||||
name: 'send',
|
||||
description: 'Send a message to the active ChatGPT Desktop App window',
|
||||
domain: 'localhost',
|
||||
@@ -14,7 +13,7 @@ export const sendCommand = cli({
|
||||
{ name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES },
|
||||
],
|
||||
columns: ['Status'],
|
||||
func: async (page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
const text = kwargs.text;
|
||||
const model = kwargs.model;
|
||||
try {
|
||||
@@ -23,26 +22,8 @@ export const sendCommand = cli({
|
||||
activateChatGPT();
|
||||
selectModel(model);
|
||||
}
|
||||
// Backup current clipboard content
|
||||
let clipBackup = '';
|
||||
try {
|
||||
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
|
||||
}
|
||||
catch { /* clipboard may be empty */ }
|
||||
// Copy text to clipboard
|
||||
spawnSync('pbcopy', { input: text });
|
||||
activateChatGPT();
|
||||
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
|
||||
if (clipBackup) {
|
||||
spawnSync('pbcopy', { input: clipBackup });
|
||||
}
|
||||
sendPrompt(text);
|
||||
return [{ Status: 'Success' }];
|
||||
}
|
||||
catch (err) {
|
||||
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError, ConfigError } from '@jackwener/opencli/errors';
|
||||
export const statusCommand = cli({
|
||||
site: 'chatgpt',
|
||||
site: 'chatgpt-app',
|
||||
name: 'status',
|
||||
description: 'Check if ChatGPT Desktop App is running natively on macOS',
|
||||
domain: 'localhost',
|
||||
@@ -10,7 +10,7 @@ export const statusCommand = cli({
|
||||
browser: false,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
func: async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { saveBase64ToFile } from '@jackwener/opencli/utils';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
|
||||
|
||||
const CHATGPT_DOMAIN = 'chatgpt.com';
|
||||
@@ -24,13 +26,29 @@ function displayPath(filePath) {
|
||||
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
|
||||
}
|
||||
|
||||
export function resolveOutputDir(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return path.join(os.homedir(), 'Pictures', 'chatgpt');
|
||||
if (raw === '~') return os.homedir();
|
||||
if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2));
|
||||
return path.resolve(raw);
|
||||
}
|
||||
|
||||
export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync) {
|
||||
let candidate = path.join(dir, `${baseName}${ext}`);
|
||||
for (let index = 1; existsSync(candidate); index += 1) {
|
||||
candidate = path.join(dir, `${baseName}_${index}${ext}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function currentChatGPTLink(page) {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
return typeof url === 'string' && url ? url : 'https://chatgpt.com';
|
||||
}
|
||||
|
||||
export const imageCommand = cli({
|
||||
site: 'chatgptweb',
|
||||
site: 'chatgpt',
|
||||
name: 'image',
|
||||
description: 'Generate images with ChatGPT web and save them locally',
|
||||
domain: CHATGPT_DOMAIN,
|
||||
@@ -41,13 +59,13 @@ export const imageCommand = cli({
|
||||
timeoutSeconds: 240,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
|
||||
{ name: 'op', default: path.join(os.homedir(), 'Pictures', 'chatgpt'), help: 'Output directory' },
|
||||
{ name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
|
||||
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
|
||||
],
|
||||
columns: ['status', 'file', 'link'],
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = kwargs.prompt;
|
||||
const outputDir = kwargs.op || path.join(os.homedir(), 'Pictures', 'chatgpt');
|
||||
const outputDir = resolveOutputDir(kwargs.op);
|
||||
const skipDownloadRaw = kwargs.sd;
|
||||
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
|
||||
const timeout = 120;
|
||||
@@ -63,12 +81,23 @@ export const imageCommand = cli({
|
||||
return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }];
|
||||
}
|
||||
|
||||
// Wait for response and images
|
||||
const urls = await waitForChatGPTImages(page, beforeUrls, timeout);
|
||||
const link = await currentChatGPTLink(page);
|
||||
// ChatGPT briefly navigates to /c/{id} after sending, then may
|
||||
// redirect back to the home page. Poll until we capture the /c/ URL.
|
||||
let convUrl = '';
|
||||
for (let ci = 0; ci < 10; ci++) {
|
||||
const url = await currentChatGPTLink(page);
|
||||
if (url.includes('/c/')) { convUrl = url; break; }
|
||||
await page.wait(2);
|
||||
}
|
||||
if (!convUrl) {
|
||||
convUrl = await currentChatGPTLink(page);
|
||||
}
|
||||
|
||||
const urls = await waitForChatGPTImages(page, beforeUrls, timeout, convUrl);
|
||||
const link = convUrl;
|
||||
|
||||
if (!urls.length) {
|
||||
return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
|
||||
throw new EmptyResultError('chatgpt image', `No generated images were detected before timeout. Open ${link} and verify whether ChatGPT finished generating the image.`);
|
||||
}
|
||||
|
||||
if (skipDownload) {
|
||||
@@ -78,7 +107,7 @@ export const imageCommand = cli({
|
||||
// Export and save images
|
||||
const assets = await getChatGPTImageAssets(page, urls);
|
||||
if (!assets.length) {
|
||||
return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
|
||||
throw new CommandExecutionError('Failed to export generated ChatGPT image assets', `Open ${link} and verify the generated images are visible, then retry.`);
|
||||
}
|
||||
|
||||
const stamp = Date.now();
|
||||
@@ -88,7 +117,7 @@ export const imageCommand = cli({
|
||||
const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
|
||||
const suffix = assets.length > 1 ? `_${index + 1}` : '';
|
||||
const ext = extFromMime(asset.mimeType);
|
||||
const filePath = path.join(outputDir, `chatgpt_${stamp}${suffix}${ext}`);
|
||||
const filePath = nextAvailablePath(outputDir, `chatgpt_${stamp}${suffix}`, ext);
|
||||
await saveBase64ToFile(base64, filePath);
|
||||
results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getChatGPTVisibleImageUrls: vi.fn(),
|
||||
sendChatGPTMessage: vi.fn(),
|
||||
waitForChatGPTImages: vi.fn(),
|
||||
getChatGPTImageAssets: vi.fn(),
|
||||
saveBase64ToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
|
||||
sendChatGPTMessage: mocks.sendChatGPTMessage,
|
||||
waitForChatGPTImages: mocks.waitForChatGPTImages,
|
||||
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
|
||||
}));
|
||||
|
||||
vi.mock('@jackwener/opencli/utils', () => ({
|
||||
saveBase64ToFile: mocks.saveBase64ToFile,
|
||||
}));
|
||||
|
||||
const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
|
||||
|
||||
function createPage() {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
|
||||
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
|
||||
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
|
||||
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
|
||||
url: 'https://images.example/generated.png',
|
||||
dataUrl: 'data:image/png;base64,aGVsbG8=',
|
||||
mimeType: 'image/png',
|
||||
}]);
|
||||
mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('chatgpt image output paths', () => {
|
||||
it('expands the default and explicit home-relative output directories', () => {
|
||||
expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt'));
|
||||
expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images'));
|
||||
expect(resolveOutputDir('~')).toBe(os.homedir());
|
||||
});
|
||||
|
||||
it('generates a non-overwriting file path when a timestamp collision exists', () => {
|
||||
const dir = '/tmp/chatgpt';
|
||||
const taken = new Set([
|
||||
path.join(dir, 'chatgpt_123.png'),
|
||||
path.join(dir, 'chatgpt_123_1.png'),
|
||||
]);
|
||||
|
||||
expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt image failure contracts', () => {
|
||||
it('fails fast when image generation detection finds no new images', async () => {
|
||||
mocks.waitForChatGPTImages.mockResolvedValue([]);
|
||||
|
||||
await expect(imageCommand.func(createPage(), {
|
||||
prompt: 'cat',
|
||||
op: '',
|
||||
sd: false,
|
||||
})).rejects.toMatchObject({
|
||||
code: 'EMPTY_RESULT',
|
||||
message: expect.stringContaining('chatgpt image returned no data'),
|
||||
hint: expect.stringContaining('No generated images were detected'),
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast when generated image assets cannot be exported', async () => {
|
||||
mocks.getChatGPTImageAssets.mockResolvedValue([]);
|
||||
|
||||
await expect(imageCommand.func(createPage(), {
|
||||
prompt: 'cat',
|
||||
op: '',
|
||||
sd: false,
|
||||
})).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,21 @@ export const CHATGPT_DOMAIN = 'chatgpt.com';
|
||||
export const CHATGPT_URL = 'https://chatgpt.com';
|
||||
|
||||
// Selectors
|
||||
const COMPOSER_SELECTOR = '[aria-label="Chat with ChatGPT"]';
|
||||
const COMPOSER_SELECTORS = [
|
||||
'[aria-label="Chat with ChatGPT"]',
|
||||
'[placeholder="Ask anything"]',
|
||||
'#prompt-textarea',
|
||||
];
|
||||
const SEND_BTN_SELECTOR = 'button[aria-label="Send prompt"]';
|
||||
|
||||
function isSameChatGPTConversation(currentUrl, expectedUrl) {
|
||||
if (!currentUrl || !expectedUrl) return false;
|
||||
return currentUrl === expectedUrl
|
||||
|| currentUrl.startsWith(`${expectedUrl}?`)
|
||||
|| currentUrl.startsWith(`${expectedUrl}#`);
|
||||
}
|
||||
|
||||
function buildComposerLocatorScript() {
|
||||
const selectorsJson = JSON.stringify([COMPOSER_SELECTOR]);
|
||||
const markerAttr = 'data-opencli-chatgpt-composer';
|
||||
return `
|
||||
const isVisible = (el) => {
|
||||
@@ -33,7 +43,7 @@ function buildComposerLocatorScript() {
|
||||
const marked = document.querySelector('[' + markerAttr + '="1"]');
|
||||
if (marked instanceof HTMLElement && isVisible(marked)) return marked;
|
||||
|
||||
for (const selector of ${JSON.stringify([COMPOSER_SELECTOR])}) {
|
||||
for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) {
|
||||
const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c));
|
||||
if (node instanceof HTMLElement) {
|
||||
node.setAttribute(markerAttr, '1');
|
||||
@@ -89,7 +99,9 @@ export async function sendChatGPTMessage(page, text) {
|
||||
// Fallback: use execCommand
|
||||
await page.evaluate(`
|
||||
(() => {
|
||||
const composer = document.querySelector('[aria-label="Chat with ChatGPT"]');
|
||||
var composer = null;
|
||||
var sels = ${JSON.stringify(COMPOSER_SELECTORS)};
|
||||
for (var si = 0; si < sels.length; si++) { composer = document.querySelector(sels[si]); if (composer) break; }
|
||||
if (!composer) return;
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
||||
@@ -181,7 +193,7 @@ export async function getChatGPTVisibleImageUrls(page) {
|
||||
/**
|
||||
* Wait for new images to appear after sending a prompt.
|
||||
*/
|
||||
export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
|
||||
export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, convUrl) {
|
||||
const beforeSet = new Set(beforeUrls);
|
||||
const pollIntervalSeconds = 3;
|
||||
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
|
||||
@@ -191,10 +203,26 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
|
||||
for (let i = 0; i < maxPolls; i++) {
|
||||
await page.wait(i === 0 ? 3 : pollIntervalSeconds);
|
||||
|
||||
// Check if still generating
|
||||
let currentUrl = '';
|
||||
if (convUrl && convUrl.includes('/c/')) {
|
||||
currentUrl = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
|
||||
await page.goto(convUrl);
|
||||
await page.wait(3);
|
||||
}
|
||||
}
|
||||
|
||||
const generating = await isGenerating(page);
|
||||
if (generating) continue;
|
||||
|
||||
if (convUrl && convUrl.includes('/c/') && i > 0 && i % 5 === 0) {
|
||||
const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl);
|
||||
if (onConversation) {
|
||||
await page.goto(convUrl);
|
||||
await page.wait(3);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url));
|
||||
if (urls.length === 0) continue;
|
||||
|
||||
@@ -214,6 +242,11 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
|
||||
return lastUrls;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
COMPOSER_SELECTORS,
|
||||
isSameChatGPTConversation,
|
||||
};
|
||||
|
||||
/**
|
||||
* Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs.
|
||||
*/
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { __test__, waitForChatGPTImages } from './utils.js';
|
||||
|
||||
function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
|
||||
let generatingIndex = 0;
|
||||
let imageIndex = 0;
|
||||
return {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
if (script === 'window.location.href') return Promise.resolve(location);
|
||||
if (script.includes('Stop generating') || script.includes('Thinking')) {
|
||||
const value = generating[Math.min(generatingIndex, generating.length - 1)] ?? false;
|
||||
generatingIndex += 1;
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
if (script.includes("document.querySelectorAll('img')")) {
|
||||
const value = imageUrls[Math.min(imageIndex, imageUrls.length - 1)] ?? [];
|
||||
imageIndex += 1;
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('chatgpt image wait contract', () => {
|
||||
it('does not periodically reload the conversation while generation is still active', async () => {
|
||||
const convUrl = 'https://chatgpt.com/c/demo';
|
||||
const page = createPageMock({
|
||||
location: convUrl,
|
||||
generating: [true, true, true, true, true, true],
|
||||
});
|
||||
|
||||
await expect(waitForChatGPTImages(page, [], 18, convUrl)).resolves.toEqual([]);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jumps back to the captured conversation when the page drifts away', async () => {
|
||||
const convUrl = 'https://chatgpt.com/c/demo';
|
||||
const page = createPageMock({
|
||||
location: 'https://chatgpt.com/',
|
||||
generating: [false],
|
||||
imageUrls: [['https://cdn.openai.com/generated/demo.png']],
|
||||
});
|
||||
|
||||
await expect(waitForChatGPTImages(page, [], 3, convUrl)).resolves.toEqual([
|
||||
'https://cdn.openai.com/generated/demo.png',
|
||||
]);
|
||||
expect(page.goto).toHaveBeenCalledWith(convUrl);
|
||||
});
|
||||
|
||||
it('treats query and hash variants as the same conversation', () => {
|
||||
expect(__test__.isSameChatGPTConversation(
|
||||
'https://chatgpt.com/c/demo?model=gpt-image-1',
|
||||
'https://chatgpt.com/c/demo',
|
||||
)).toBe(true);
|
||||
expect(__test__.isSameChatGPTConversation(
|
||||
'https://chatgpt.com/c/other',
|
||||
'https://chatgpt.com/c/demo',
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const askCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'ask',
|
||||
@@ -43,7 +43,7 @@ export const askCommand = cli({
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('ChatWise input element');
|
||||
throw selectorError('ChatWise input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll for response
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const modelCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'model',
|
||||
@@ -58,7 +58,7 @@ export const modelCommand = cli({
|
||||
})(${JSON.stringify(desiredModel)})
|
||||
`);
|
||||
if (!opened)
|
||||
throw new SelectorError('ChatWise model selector');
|
||||
throw selectorError('ChatWise model selector');
|
||||
await page.wait(0.5);
|
||||
// Find and click the target model in the dropdown
|
||||
const found = await page.evaluate(`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const sendCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'send',
|
||||
@@ -36,7 +36,7 @@ export const sendCommand = cli({
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('ChatWise input element');
|
||||
throw selectorError('ChatWise input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, selectModel, setAdaptiveThinking,
|
||||
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
||||
ensureClaudeComposer, requireNonEmptyPrompt, requirePositiveInt,
|
||||
} from './utils.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt to Claude and get the response',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
timeoutSeconds: 180,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
|
||||
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
||||
{ name: 'model', default: 'sonnet', choices: ['sonnet', 'opus', 'haiku'], help: 'Model to use: sonnet, opus, or haiku' },
|
||||
{ name: 'think', type: 'boolean', default: false, help: 'Enable Adaptive thinking' },
|
||||
{ name: 'file', help: 'Attach a file (image, PDF, text) with the prompt' },
|
||||
],
|
||||
columns: ['response'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude ask');
|
||||
const timeoutSeconds = requirePositiveInt(
|
||||
Number(kwargs.timeout ?? 120),
|
||||
'claude ask --timeout',
|
||||
'Example: opencli claude ask "hello" --timeout 120',
|
||||
);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
const wantThink = parseBoolFlag(kwargs.think);
|
||||
|
||||
if (parseBoolFlag(kwargs.new)) {
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(3);
|
||||
} else {
|
||||
const navigated = await ensureOnClaude(page);
|
||||
if (navigated) {
|
||||
// Workspace was recycled; try to resume the most recent
|
||||
// conversation instead of starting a new one.
|
||||
await page.evaluate(`(() => {
|
||||
var link = document.querySelector('a[href*="/chat/"]');
|
||||
if (link) link.click();
|
||||
})()`);
|
||||
await page.wait(2);
|
||||
}
|
||||
}
|
||||
|
||||
await page.wait(2);
|
||||
await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.'));
|
||||
|
||||
// Model selector is only available on the new-chat page, not inside
|
||||
// an existing conversation. Skip it when we resumed a prior thread.
|
||||
const currentUrl = await page.evaluate('window.location.href') || '';
|
||||
const inConversation = currentUrl.includes('/chat/');
|
||||
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
|
||||
|
||||
const wantModel = kwargs.model || 'sonnet';
|
||||
if (inConversation && modelExplicit) {
|
||||
throw new ArgumentError(
|
||||
`Cannot switch to ${wantModel} model inside an existing conversation.`,
|
||||
'Re-run with --new to start a fresh chat before selecting a model.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!inConversation) {
|
||||
const modelResult = await withRetry(() => selectModel(page, wantModel));
|
||||
if (!modelResult?.ok) {
|
||||
if (modelResult?.upgrade) {
|
||||
throw new ArgumentError(
|
||||
`${wantModel} model requires a paid Claude plan.`,
|
||||
'Pick --model sonnet or --model haiku, or upgrade your account.',
|
||||
);
|
||||
}
|
||||
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
||||
}
|
||||
if (modelResult?.toggled) await page.wait(0.5);
|
||||
}
|
||||
|
||||
const thinkResult = await withRetry(() => setAdaptiveThinking(page, wantThink));
|
||||
if (!thinkResult?.ok && wantThink) {
|
||||
throw new CommandExecutionError('Could not enable Adaptive thinking');
|
||||
}
|
||||
if (thinkResult?.toggled) await page.wait(0.5);
|
||||
|
||||
if (kwargs.file) {
|
||||
const baseline = await withRetry(() => getBubbleCount(page));
|
||||
try {
|
||||
const fileResult = await sendWithFile(page, kwargs.file, prompt);
|
||||
if (fileResult && !fileResult.ok) {
|
||||
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
|
||||
}
|
||||
} catch (err) {
|
||||
// SPA navigates after send; "Promise was collected" means send succeeded
|
||||
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
||||
}
|
||||
await page.wait(3);
|
||||
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
|
||||
if (!result) {
|
||||
throw new EmptyResultError(
|
||||
'claude ask',
|
||||
`No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`,
|
||||
);
|
||||
}
|
||||
return [{ response: result }];
|
||||
}
|
||||
|
||||
const baseline = await withRetry(() => getBubbleCount(page));
|
||||
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
||||
if (!sendResult?.ok) {
|
||||
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
||||
}
|
||||
|
||||
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
|
||||
if (!result) {
|
||||
throw new EmptyResultError(
|
||||
'claude ask',
|
||||
`No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`,
|
||||
);
|
||||
}
|
||||
return [{ response: result }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const {
|
||||
mockEnsureOnClaude,
|
||||
mockEnsureClaudeComposer,
|
||||
mockSelectModel,
|
||||
mockSetAdaptiveThinking,
|
||||
mockSendMessage,
|
||||
mockSendWithFile,
|
||||
mockGetBubbleCount,
|
||||
mockWaitForResponse,
|
||||
mockParseBoolFlag,
|
||||
mockRequireNonEmptyPrompt,
|
||||
mockRequirePositiveInt,
|
||||
mockWithRetry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureOnClaude: vi.fn(),
|
||||
mockEnsureClaudeComposer: vi.fn(),
|
||||
mockSelectModel: vi.fn(),
|
||||
mockSetAdaptiveThinking: vi.fn(),
|
||||
mockSendMessage: vi.fn(),
|
||||
mockSendWithFile: vi.fn(),
|
||||
mockGetBubbleCount: vi.fn(),
|
||||
mockWaitForResponse: vi.fn(),
|
||||
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
||||
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
|
||||
mockRequirePositiveInt: vi.fn((v) => Number(v)),
|
||||
mockWithRetry: vi.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
CLAUDE_DOMAIN: 'claude.ai',
|
||||
CLAUDE_URL: 'https://claude.ai/new',
|
||||
ensureOnClaude: mockEnsureOnClaude,
|
||||
ensureClaudeComposer: mockEnsureClaudeComposer,
|
||||
selectModel: mockSelectModel,
|
||||
setAdaptiveThinking: mockSetAdaptiveThinking,
|
||||
sendMessage: mockSendMessage,
|
||||
sendWithFile: mockSendWithFile,
|
||||
getBubbleCount: mockGetBubbleCount,
|
||||
waitForResponse: mockWaitForResponse,
|
||||
parseBoolFlag: mockParseBoolFlag,
|
||||
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
|
||||
requirePositiveInt: mockRequirePositiveInt,
|
||||
withRetry: mockWithRetry,
|
||||
}));
|
||||
|
||||
import { askCommand } from './ask.js';
|
||||
|
||||
describe('claude ask basic flow', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/new');
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockSendWithFile.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('hello there');
|
||||
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
||||
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
||||
});
|
||||
|
||||
it('returns the assistant response on a fresh chat', async () => {
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'hello there' }]);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(page, 'hi');
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 0, 'hi', 120000);
|
||||
});
|
||||
|
||||
it('navigates to /new when --new is set', async () => {
|
||||
await askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: true,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
});
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://claude.ai/new');
|
||||
expect(mockEnsureOnClaude).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when waitForResponse yields nothing', async () => {
|
||||
mockWaitForResponse.mockResolvedValue(null);
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 60,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when send fails', async () => {
|
||||
mockSendMessage.mockResolvedValue({ ok: false, reason: 'composer not found' });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(/composer not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude ask --model handling', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('reply');
|
||||
});
|
||||
|
||||
it('rejects --model opus on free tier with usage-error guidance', async () => {
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/new');
|
||||
mockSelectModel.mockResolvedValue({ ok: false, upgrade: true });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'opus',
|
||||
think: false,
|
||||
})).rejects.toMatchObject(new ArgumentError(
|
||||
'opus model requires a paid Claude plan.',
|
||||
'Pick --model sonnet or --model haiku, or upgrade your account.',
|
||||
));
|
||||
});
|
||||
|
||||
it('skips model selection inside an existing conversation', async () => {
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'continue',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'reply' }]);
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast when --model is explicit inside an existing conversation', async () => {
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'continue',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'opus',
|
||||
think: false,
|
||||
__opencliOptionSources: { model: 'cli' },
|
||||
})).rejects.toMatchObject(new ArgumentError(
|
||||
'Cannot switch to opus model inside an existing conversation.',
|
||||
'Re-run with --new to start a fresh chat before selecting a model.',
|
||||
));
|
||||
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude ask --think', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('reply');
|
||||
});
|
||||
|
||||
it('toggles Adaptive thinking when --think is set', async () => {
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: true });
|
||||
|
||||
await askCommand.func(page, {
|
||||
prompt: 'reason carefully',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: true,
|
||||
});
|
||||
|
||||
expect(mockSetAdaptiveThinking).toHaveBeenCalledWith(page, true);
|
||||
});
|
||||
|
||||
it('throws when --think requested but toggle fails', async () => {
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'reason carefully',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: true,
|
||||
})).rejects.toThrow(/Adaptive thinking/);
|
||||
});
|
||||
|
||||
it('does not throw when --think is false and toggle returns ok=false', async () => {
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).resolves.toEqual([{ response: 'reply' }]);
|
||||
});
|
||||
|
||||
it('fails fast when prompt validation rejects an empty prompt', async () => {
|
||||
mockRequireNonEmptyPrompt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude ask prompt cannot be empty');
|
||||
});
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: '',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('fails fast when timeout validation rejects a non-positive value', async () => {
|
||||
mockRequirePositiveInt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude ask --timeout must be a positive integer');
|
||||
});
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 0,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude ask --file', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendWithFile.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(3);
|
||||
mockWaitForResponse.mockResolvedValue('the image shows a cat');
|
||||
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
||||
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
||||
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
||||
});
|
||||
|
||||
it('routes through sendWithFile and captures baseline before sending', async () => {
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'describe this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
file: '/tmp/cat.png',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
|
||||
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendWithFile).toHaveBeenCalledWith(page, '/tmp/cat.png', 'describe this');
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 3, 'describe this', 120000);
|
||||
});
|
||||
|
||||
it('surfaces file upload failure as CommandExecutionError', async () => {
|
||||
mockSendWithFile.mockResolvedValue({ ok: false, reason: 'file preview did not appear' });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'describe this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
file: '/tmp/cat.png',
|
||||
})).rejects.toThrow(/file preview did not appear/);
|
||||
});
|
||||
|
||||
it('absorbs "Promise was collected" SPA navigation error after send', async () => {
|
||||
mockSendWithFile.mockRejectedValue(new Error('Promise was collected'));
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'describe this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
file: '/tmp/cat.png',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const {
|
||||
mockEnsureOnClaude,
|
||||
mockEnsureClaudeComposer,
|
||||
mockEnsureClaudeLogin,
|
||||
mockSendMessage,
|
||||
mockParseBoolFlag,
|
||||
mockRequireNonEmptyPrompt,
|
||||
mockGetVisibleMessages,
|
||||
mockGetConversationList,
|
||||
mockRequirePositiveInt,
|
||||
mockRequireConversationId,
|
||||
mockWithRetry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureOnClaude: vi.fn(),
|
||||
mockEnsureClaudeComposer: vi.fn(),
|
||||
mockEnsureClaudeLogin: vi.fn(),
|
||||
mockSendMessage: vi.fn(),
|
||||
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
||||
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
|
||||
mockGetVisibleMessages: vi.fn(),
|
||||
mockGetConversationList: vi.fn(),
|
||||
mockRequirePositiveInt: vi.fn((v) => Number(v)),
|
||||
mockRequireConversationId: vi.fn((v) => String(v ?? '').trim()),
|
||||
mockWithRetry: vi.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
CLAUDE_DOMAIN: 'claude.ai',
|
||||
CLAUDE_URL: 'https://claude.ai/new',
|
||||
ensureOnClaude: mockEnsureOnClaude,
|
||||
ensureClaudeComposer: mockEnsureClaudeComposer,
|
||||
ensureClaudeLogin: mockEnsureClaudeLogin,
|
||||
sendMessage: mockSendMessage,
|
||||
parseBoolFlag: mockParseBoolFlag,
|
||||
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
|
||||
getVisibleMessages: mockGetVisibleMessages,
|
||||
getConversationList: mockGetConversationList,
|
||||
requirePositiveInt: mockRequirePositiveInt,
|
||||
requireConversationId: mockRequireConversationId,
|
||||
withRetry: mockWithRetry,
|
||||
}));
|
||||
|
||||
import { sendCommand } from './send.js';
|
||||
import { newCommand } from './new.js';
|
||||
import { readCommand } from './read.js';
|
||||
import { historyCommand } from './history.js';
|
||||
import { detailCommand } from './detail.js';
|
||||
|
||||
describe('claude command-level fail-fast contracts', () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
||||
mockEnsureClaudeLogin.mockResolvedValue({ isLoggedIn: true });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
||||
mockGetVisibleMessages.mockResolvedValue([{ Index: 0, Role: 'assistant', Text: 'hi' }]);
|
||||
mockGetConversationList.mockResolvedValue([{ Index: 1, Id: 'abc', Title: 'Hi', Url: 'https://claude.ai/chat/abc' }]);
|
||||
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
||||
mockRequireConversationId.mockImplementation((v) => String(v ?? '').trim());
|
||||
});
|
||||
|
||||
it('send rejects empty prompt via ArgumentError', async () => {
|
||||
mockRequireNonEmptyPrompt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude send prompt cannot be empty');
|
||||
});
|
||||
|
||||
await expect(sendCommand.func(page, { prompt: '', new: false })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('send surfaces auth failure from composer readiness', async () => {
|
||||
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude send requires a logged-in Claude session.'));
|
||||
|
||||
await expect(sendCommand.func(page, { prompt: 'hi', new: false })).rejects.toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('new no longer false-succeeds on login wall', async () => {
|
||||
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude new requires a logged-in Claude session with a visible composer.'));
|
||||
|
||||
await expect(newCommand.func(page)).rejects.toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('read throws EmptyResultError instead of a placeholder row', async () => {
|
||||
mockGetVisibleMessages.mockResolvedValue([]);
|
||||
|
||||
await expect(readCommand.func(page)).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('history rejects invalid --limit values instead of silently coercing them', async () => {
|
||||
mockRequirePositiveInt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude history --limit must be a positive integer');
|
||||
});
|
||||
|
||||
await expect(historyCommand.func(page, { limit: 0 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('history throws EmptyResultError on an empty /recents page', async () => {
|
||||
mockGetConversationList.mockResolvedValue([]);
|
||||
|
||||
await expect(historyCommand.func(page, { limit: 20 })).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('detail rejects a missing conversation id', async () => {
|
||||
mockRequireConversationId.mockImplementation(() => {
|
||||
throw new ArgumentError('claude detail requires a conversation id');
|
||||
});
|
||||
|
||||
await expect(detailCommand.func(page, { id: '' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
|
||||
|
||||
export const detailCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'detail',
|
||||
description: 'Open a Claude conversation by ID and read its messages',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/<id>)' },
|
||||
],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const id = requireConversationId(kwargs.id);
|
||||
|
||||
await page.goto(`https://claude.ai/chat/${id}`);
|
||||
await page.wait(4);
|
||||
await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.');
|
||||
|
||||
const messages = await getVisibleMessages(page);
|
||||
if (messages.length > 0) return messages;
|
||||
throw new EmptyResultError('claude detail', `No visible Claude messages were found for conversation ${id}.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, getConversationList, ensureClaudeLogin, requirePositiveInt } from './utils.js';
|
||||
|
||||
export const historyCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'history',
|
||||
description: 'List conversation history from Claude /recents',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
|
||||
],
|
||||
columns: ['Index', 'Id', 'Title', 'Url'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const limit = requirePositiveInt(
|
||||
Number(kwargs.limit ?? 20),
|
||||
'claude history --limit',
|
||||
'Example: opencli claude history --limit 20',
|
||||
);
|
||||
const conversations = await getConversationList(page);
|
||||
await ensureClaudeLogin(page, 'Claude history requires a logged-in Claude session.');
|
||||
if (conversations.length === 0) {
|
||||
throw new EmptyResultError('claude history', 'No Claude conversation history was visible on /recents.');
|
||||
}
|
||||
return conversations.slice(0, limit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureClaudeComposer } from './utils.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in Claude',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
|
||||
func: async (page) => {
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(2);
|
||||
await ensureClaudeComposer(page, 'Claude new requires a logged-in Claude session with a visible composer.');
|
||||
return [{ Status: 'New chat started' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, ensureOnClaude, getVisibleMessages, ensureClaudeLogin } from './utils.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'read',
|
||||
description: 'Read the current Claude conversation',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
|
||||
func: async (page) => {
|
||||
await ensureOnClaude(page);
|
||||
await page.wait(3);
|
||||
await ensureClaudeLogin(page, 'Claude read requires a logged-in Claude session.');
|
||||
const messages = await getVisibleMessages(page);
|
||||
if (messages.length > 0) return messages;
|
||||
throw new EmptyResultError('claude read', 'No visible Claude messages were found in the current conversation.');
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'send',
|
||||
description: 'Send a prompt to Claude without waiting for the response',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
||||
],
|
||||
columns: ['Status', 'SubmittedBy', 'InjectedText'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude send');
|
||||
|
||||
if (parseBoolFlag(kwargs.new)) {
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(3);
|
||||
} else {
|
||||
await ensureOnClaude(page);
|
||||
await page.wait(2);
|
||||
}
|
||||
await withRetry(() => ensureClaudeComposer(page, 'Claude send requires a visible composer on the current page.'));
|
||||
|
||||
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
||||
if (!sendResult?.ok) {
|
||||
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
||||
}
|
||||
return [{
|
||||
Status: 'Success',
|
||||
SubmittedBy: sendResult.method || 'send-button',
|
||||
InjectedText: prompt,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CLAUDE_DOMAIN, ensureOnClaude, getPageState } from './utils.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'status',
|
||||
description: 'Check Claude page availability and login state',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Login', 'Url'],
|
||||
|
||||
func: async (page) => {
|
||||
await ensureOnClaude(page);
|
||||
const state = await getPageState(page);
|
||||
return [{
|
||||
Status: state.hasComposer ? 'Connected' : 'Page not ready',
|
||||
Login: state.isLoggedIn ? 'Yes' : 'No',
|
||||
Url: state.url,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const CLAUDE_DOMAIN = 'claude.ai';
|
||||
export const CLAUDE_URL = 'https://claude.ai/new';
|
||||
export const COMPOSER_SELECTOR = '[data-testid="chat-input"]';
|
||||
export const MESSAGE_SELECTOR = '.font-claude-response';
|
||||
export const MODEL_DROPDOWN_SELECTOR = '[data-testid="model-selector-dropdown"]';
|
||||
|
||||
const MODEL_DISPLAY_NAMES = {
|
||||
sonnet: 'Sonnet 4.6',
|
||||
opus: 'Opus 4.7',
|
||||
haiku: 'Haiku 4.5',
|
||||
};
|
||||
|
||||
export async function isOnClaude(page) {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
try {
|
||||
const h = new URL(url).hostname;
|
||||
return h === CLAUDE_DOMAIN || h.endsWith(`.${CLAUDE_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOnClaude(page) {
|
||||
if (await isOnClaude(page)) return false;
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(3);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPageState(page) {
|
||||
return page.evaluate(`(() => {
|
||||
var composer = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
var userMenu = document.querySelector('[data-testid="user-menu-button"]');
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
hasComposer: !!composer,
|
||||
isLoggedIn: !!userMenu,
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function ensureClaudeLogin(page, message = 'Claude requires a logged-in browser session.') {
|
||||
const state = await getPageState(page);
|
||||
if (!state.isLoggedIn) {
|
||||
throw new AuthRequiredError(CLAUDE_DOMAIN, message);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function ensureClaudeComposer(page, message = 'Claude composer is not available on the current page.') {
|
||||
const state = await ensureClaudeLogin(page, message);
|
||||
if (!state.hasComposer) {
|
||||
throw new CommandExecutionError(message);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function requireNonEmptyPrompt(prompt, commandName) {
|
||||
const text = String(prompt ?? '').trim();
|
||||
if (!text) {
|
||||
throw new ArgumentError(
|
||||
`${commandName} prompt cannot be empty`,
|
||||
`Example: opencli ${commandName} "hello"`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, flagLabel, hint) {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new ArgumentError(`${flagLabel} must be a positive integer`, hint);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireConversationId(value) {
|
||||
const id = String(value ?? '').trim();
|
||||
if (!id) {
|
||||
throw new ArgumentError(
|
||||
'claude detail requires a conversation id',
|
||||
'Example: opencli claude detail 123e4567-e89b-12d3-a456-426614174000',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getVisibleMessages(page) {
|
||||
const result = await page.evaluate(`(() => {
|
||||
var nodes = document.querySelectorAll('[data-testid="user-message"], ${MESSAGE_SELECTOR}');
|
||||
var rows = [];
|
||||
Array.from(nodes).forEach(function(el) {
|
||||
var isUser = el.getAttribute('data-testid') === 'user-message';
|
||||
var raw = (el.innerText || '').trim();
|
||||
if (!isUser) {
|
||||
var parts = raw.split(/\\n\\n+/);
|
||||
while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
|
||||
raw = parts.join('\\n\\n').trim();
|
||||
}
|
||||
if (raw) rows.push({ role: isUser ? 'user' : 'assistant', text: raw });
|
||||
});
|
||||
return rows;
|
||||
})()`);
|
||||
if (!Array.isArray(result)) return [];
|
||||
return result.map(function(r, i) { return { Index: i, Role: r.role, Text: r.text }; });
|
||||
}
|
||||
|
||||
export async function getConversationList(page) {
|
||||
if (!(await isOnClaude(page)) || !(await page.evaluate('window.location.href') || '').includes('/recents')) {
|
||||
await page.goto('https://claude.ai/recents');
|
||||
await page.wait(3);
|
||||
}
|
||||
const items = await page.evaluate(`(() => {
|
||||
var links = Array.from(document.querySelectorAll('a[href*="/chat/"]'));
|
||||
return links.map(function(link, i) {
|
||||
var href = link.getAttribute('href') || '';
|
||||
var idMatch = href.match(/\\/chat\\/([a-f0-9-]+)/);
|
||||
return {
|
||||
Index: i + 1,
|
||||
Id: idMatch ? idMatch[1] : href,
|
||||
Title: (link.innerText || '').trim().split('\\n')[0].trim() || '(untitled)',
|
||||
Url: href.startsWith('http') ? href : ('https://claude.ai' + href),
|
||||
};
|
||||
});
|
||||
})()`);
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
export async function selectModel(page, modelName) {
|
||||
const display = MODEL_DISPLAY_NAMES[String(modelName).toLowerCase()];
|
||||
if (!display) return { ok: false };
|
||||
|
||||
const opened = await page.evaluate(`(() => {
|
||||
var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
|
||||
if (!trigger) return { ok: false };
|
||||
var label = trigger.getAttribute('aria-label') || '';
|
||||
if (label.indexOf(${JSON.stringify(display)}) >= 0) {
|
||||
return { ok: true, toggled: false };
|
||||
}
|
||||
trigger.click();
|
||||
return { ok: true, opened: true };
|
||||
})()`);
|
||||
|
||||
if (!opened?.ok) return opened;
|
||||
if (!opened.opened) return opened;
|
||||
|
||||
await page.wait(0.6);
|
||||
|
||||
return page.evaluate(`(() => {
|
||||
var items = Array.from(document.querySelectorAll('div[role="menuitemradio"]'));
|
||||
var target = items.find(function(el) { return (el.innerText || '').indexOf(${JSON.stringify(display)}) >= 0; });
|
||||
if (!target) return { ok: false };
|
||||
// Free-tier locked options carry an inline "Upgrade" button next to the label.
|
||||
var upgrade = target.querySelector('button');
|
||||
if (upgrade && (upgrade.innerText || '').toLowerCase().indexOf('upgrade') >= 0) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: false, upgrade: true };
|
||||
}
|
||||
var alreadySelected = target.getAttribute('aria-checked') === 'true';
|
||||
if (!alreadySelected) target.click();
|
||||
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: true, toggled: !alreadySelected };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function setAdaptiveThinking(page, enabled) {
|
||||
const opened = await page.evaluate(`(() => {
|
||||
var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
|
||||
if (!trigger) return { ok: false };
|
||||
trigger.click();
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!opened?.ok) return { ok: false };
|
||||
|
||||
await page.wait(0.6);
|
||||
|
||||
return page.evaluate(`(() => {
|
||||
var items = Array.from(document.querySelectorAll('div[role="menuitem"]'));
|
||||
var target = items.find(function(el) { return (el.innerText || '').indexOf('Adaptive thinking') >= 0; });
|
||||
if (!target) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: false };
|
||||
}
|
||||
var isActive = target.getAttribute('aria-checked') === 'true';
|
||||
if (${enabled} !== isActive) target.click();
|
||||
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: true, toggled: ${enabled} !== isActive };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function sendMessage(page, prompt) {
|
||||
const promptJson = JSON.stringify(prompt);
|
||||
const composerReady = await page.evaluate(`(() => {
|
||||
var box = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (!box) return false;
|
||||
box.focus();
|
||||
// ProseMirror editors hold content in nested <p>; clear via Range/delete
|
||||
// rather than .value or textContent, which the editor won't notice.
|
||||
var sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(box);
|
||||
sel.addRange(range);
|
||||
document.execCommand('delete', false);
|
||||
return true;
|
||||
})()`);
|
||||
if (!composerReady) return { ok: false, reason: 'composer not found' };
|
||||
|
||||
let typedNatively = false;
|
||||
if (page.nativeType) {
|
||||
try {
|
||||
await page.nativeType(prompt);
|
||||
typedNatively = true;
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (!msg.includes('Unknown action') && !msg.includes('not supported')) throw err;
|
||||
}
|
||||
}
|
||||
if (!typedNatively) {
|
||||
await page.evaluate(`(() => {
|
||||
var box = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (!box) return;
|
||||
box.focus();
|
||||
document.execCommand('insertText', false, ${promptJson});
|
||||
})()`);
|
||||
}
|
||||
|
||||
await page.wait(1.2);
|
||||
|
||||
return page.evaluate(`(() => {
|
||||
var ariaCandidates = [
|
||||
'button[aria-label="Send Message"]',
|
||||
'button[aria-label="Send message"]',
|
||||
'button[aria-label="Send"]',
|
||||
'button[aria-label*="Send"]',
|
||||
];
|
||||
for (var i = 0; i < ariaCandidates.length; i++) {
|
||||
var btn = document.querySelector(ariaCandidates[i]);
|
||||
if (btn && !btn.disabled) { btn.click(); return { ok: true }; }
|
||||
}
|
||||
// Fallback: rightmost enabled button with an svg in the composer container.
|
||||
var box = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (box) {
|
||||
var c = box.parentElement;
|
||||
for (var hop = 0; hop < 6 && c; hop++) {
|
||||
var btns = Array.from(c.querySelectorAll('button')).filter(function(b) { return !b.disabled && b.querySelector('svg'); });
|
||||
if (btns.length) { btns[btns.length - 1].click(); return { ok: true, method: 'fallback' }; }
|
||||
c = c.parentElement;
|
||||
}
|
||||
}
|
||||
var box2 = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (box2) {
|
||||
box2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
return { ok: true, method: 'enter' };
|
||||
}
|
||||
return { ok: false, reason: 'send button not found' };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function getBubbleCount(page) {
|
||||
const count = await page.evaluate(`(() => {
|
||||
return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
|
||||
})()`);
|
||||
return count || 0;
|
||||
}
|
||||
|
||||
export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
||||
const startTime = Date.now();
|
||||
let lastText = '';
|
||||
let stableCount = 0;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await page.wait(3);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await page.evaluate(`(() => {
|
||||
var bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
||||
// Adaptive thinking renders "Thought process" labels at the top
|
||||
// of the response (often duplicated for the expand/collapse widget).
|
||||
// Strip them so the row value is the actual answer text.
|
||||
var texts = Array.from(bubbles).map(function(b) {
|
||||
var raw = (b.innerText || '').trim();
|
||||
// Drop leading paragraphs that are widget labels:
|
||||
// "Thought process" / "Thought for Xs" — Adaptive thinking expand widget
|
||||
// "View uploaded image" / "View attachment" — file thumbnail label
|
||||
// These render twice (collapsed + expanded) and are followed by a blank line.
|
||||
var parts = raw.split(/\\n\\n+/);
|
||||
while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
|
||||
return parts.join('\\n\\n').trim();
|
||||
}).filter(Boolean);
|
||||
return {
|
||||
count: texts.length,
|
||||
last: texts[texts.length - 1] || '',
|
||||
streaming: !!document.querySelector('[data-is-streaming="true"]'),
|
||||
};
|
||||
})()`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result) continue;
|
||||
|
||||
const candidate = result.last;
|
||||
if (!candidate || candidate === prompt.trim()) continue;
|
||||
if (result.count <= baselineCount) continue;
|
||||
if (result.streaming) {
|
||||
lastText = candidate;
|
||||
stableCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate === lastText) {
|
||||
stableCount++;
|
||||
if (stableCount >= 3) return candidate;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastText = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return lastText || null;
|
||||
}
|
||||
|
||||
async function waitForFilePreview(page, fileName) {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await page.wait(1);
|
||||
const ready = await page.evaluate(`(() => {
|
||||
// Claude renders attachments as data-testid="file-thumbnail" cards with
|
||||
// a sibling Remove button. Either signal indicates the file took.
|
||||
if (document.querySelector('[data-testid="file-thumbnail"]')) return true;
|
||||
var removeBtn = Array.from(document.querySelectorAll('button'))
|
||||
.find(function(b) { return (b.getAttribute('aria-label') || '') === 'Remove'; });
|
||||
return !!removeBtn;
|
||||
})()`);
|
||||
if (ready) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function sendWithFile(page, filePath, prompt) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const absPath = path.default.resolve(filePath);
|
||||
|
||||
if (!fs.default.existsSync(absPath)) {
|
||||
return { ok: false, reason: `File not found: ${absPath}` };
|
||||
}
|
||||
|
||||
const stats = fs.default.statSync(absPath);
|
||||
if (stats.size > 30 * 1024 * 1024) {
|
||||
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 30 MB` };
|
||||
}
|
||||
|
||||
const fileName = path.default.basename(absPath);
|
||||
|
||||
let uploaded = false;
|
||||
if (page.setFileInput) {
|
||||
try {
|
||||
// Upload via CDP so the file content does not cross the daemon body
|
||||
// limit, then trigger React's controlled onChange manually because
|
||||
// CDP assigns .files without firing the synthetic event React listens for.
|
||||
await page.setFileInput([absPath], 'input[data-testid="file-upload"]');
|
||||
const fired = await page.evaluate(`(() => {
|
||||
var inp = document.querySelector('input[data-testid="file-upload"]');
|
||||
if (!inp) return { ok: false, reason: 'file input not found' };
|
||||
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
||||
if (propsKey && typeof inp[propsKey].onChange === 'function') {
|
||||
inp[propsKey].onChange({ target: { files: inp.files } });
|
||||
return { ok: true, via: 'react' };
|
||||
}
|
||||
inp.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true, via: 'native' };
|
||||
})()`);
|
||||
if (!fired?.ok) return fired;
|
||||
uploaded = true;
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uploaded) {
|
||||
const content = fs.default.readFileSync(absPath);
|
||||
const base64 = content.toString('base64');
|
||||
const fallbackResult = await page.evaluate(`(async () => {
|
||||
var binary = atob('${base64}');
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
|
||||
var file = new File([bytes], ${JSON.stringify(fileName)});
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
|
||||
var inp = document.querySelector('input[data-testid="file-upload"]');
|
||||
if (!inp) return { ok: false, reason: 'file input not found' };
|
||||
|
||||
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
||||
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
|
||||
return { ok: false, reason: 'React onChange not found' };
|
||||
}
|
||||
|
||||
inp.files = dt.files;
|
||||
inp[propsKey].onChange({ target: { files: inp.files } });
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
||||
}
|
||||
|
||||
const ready = await waitForFilePreview(page, fileName);
|
||||
if (!ready) return { ok: false, reason: 'file preview did not appear' };
|
||||
|
||||
return sendMessage(page, prompt);
|
||||
}
|
||||
|
||||
// Retries on CDP "Promise was collected" errors caused by Claude SPA route changes.
|
||||
export async function withRetry(fn, retries = 2) {
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (i < retries && msg.includes('Promise was collected')) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoolFlag(value) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return String(value ?? '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { parseBoolFlag, sendWithFile, selectModel, requireConversationId, requireNonEmptyPrompt, requirePositiveInt } from './utils.js';
|
||||
|
||||
describe('claude parseBoolFlag', () => {
|
||||
it('returns booleans unchanged', () => {
|
||||
expect(parseBoolFlag(true)).toBe(true);
|
||||
expect(parseBoolFlag(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats only "true" string (case-insensitive) as true', () => {
|
||||
expect(parseBoolFlag('true')).toBe(true);
|
||||
expect(parseBoolFlag('TRUE')).toBe(true);
|
||||
expect(parseBoolFlag('1')).toBe(false);
|
||||
expect(parseBoolFlag('yes')).toBe(false);
|
||||
expect(parseBoolFlag('')).toBe(false);
|
||||
expect(parseBoolFlag(null)).toBe(false);
|
||||
expect(parseBoolFlag(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude argument helpers', () => {
|
||||
it('rejects blank prompts', () => {
|
||||
expect(() => requireNonEmptyPrompt(' ', 'claude ask')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects non-positive integers for numeric flags', () => {
|
||||
expect(() => requirePositiveInt(0, 'claude ask --timeout')).toThrow(ArgumentError);
|
||||
expect(() => requirePositiveInt(-1, 'claude history --limit')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects missing conversation ids', () => {
|
||||
expect(() => requireConversationId(' ')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude sendWithFile', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (tempDirs.length) {
|
||||
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers page.setFileInput, then sends after preview appears', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'cat.png');
|
||||
fs.writeFileSync(filePath, 'fake');
|
||||
|
||||
const page = {
|
||||
nativeType: vi.fn().mockResolvedValue(undefined),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, via: 'react' }) // React onChange fired after setFileInput
|
||||
.mockResolvedValueOnce(true) // waitForFilePreview hit
|
||||
.mockResolvedValueOnce(true) // composer ready
|
||||
.mockResolvedValueOnce({ ok: true }), // send button click
|
||||
};
|
||||
|
||||
const result = await sendWithFile(page, filePath, 'describe this');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[data-testid="file-upload"]');
|
||||
expect(page.nativeType).toHaveBeenCalledWith('describe this');
|
||||
});
|
||||
|
||||
it('returns file-not-found when path does not exist', async () => {
|
||||
const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
|
||||
const result = await sendWithFile(page, '/no/such/file.png', 'hi');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('File not found');
|
||||
});
|
||||
|
||||
it('rejects oversized files before any upload attempt', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'big.bin');
|
||||
fs.writeFileSync(filePath, Buffer.alloc(31 * 1024 * 1024));
|
||||
|
||||
const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
|
||||
const result = await sendWithFile(page, filePath, 'hi');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/too large/);
|
||||
expect(page.setFileInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude selectModel', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects unknown model keys without touching the page', async () => {
|
||||
const page = { evaluate: vi.fn() };
|
||||
|
||||
const result = await selectModel(page, 'gpt5');
|
||||
|
||||
expect(result).toEqual({ ok: false });
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns toggled=false when the dropdown already shows the requested model', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn().mockResolvedValueOnce({ ok: true, toggled: false }),
|
||||
wait: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'sonnet');
|
||||
|
||||
expect(result).toEqual({ ok: true, toggled: false });
|
||||
expect(page.wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the dropdown and clicks the matching radio', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, opened: true })
|
||||
.mockResolvedValueOnce({ ok: true, toggled: true }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'haiku');
|
||||
|
||||
expect(result).toEqual({ ok: true, toggled: true });
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('flags upgrade-required when picking a paid model on free tier', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, opened: true })
|
||||
.mockResolvedValueOnce({ ok: false, upgrade: true }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'opus');
|
||||
|
||||
expect(result).toEqual({ ok: false, upgrade: true });
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const askCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'ask',
|
||||
@@ -34,7 +34,7 @@ export const askCommand = cli({
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('Codex input element');
|
||||
throw selectorError('Codex input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll for new content
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const sendCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'send',
|
||||
@@ -28,7 +28,7 @@ export const sendCommand = cli({
|
||||
})(${JSON.stringify(textToInsert)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('Codex Composer input element');
|
||||
throw selectorError('Codex Composer input element');
|
||||
// Wait for the UI to register the input
|
||||
await page.wait(0.5);
|
||||
// Simulate Enter key to submit
|
||||
|
||||
@@ -34,7 +34,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
||||
],
|
||||
columns: ['rank', 'name', 'type', 'score', 'price', 'url'],
|
||||
func: async (_page, kwargs) => {
|
||||
func: async (kwargs) => {
|
||||
const query = String(kwargs.query || '').trim();
|
||||
if (!query) {
|
||||
throw new ArgumentError('Search keyword cannot be empty');
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('ctrip search', () => {
|
||||
],
|
||||
},
|
||||
}), { status: 200 })));
|
||||
const result = await command.func(null, { query: '苏州', limit: 3 });
|
||||
const result = await command.func({ query: '苏州', limit: 3 });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
@@ -46,11 +46,11 @@ describe('ctrip search', () => {
|
||||
]);
|
||||
});
|
||||
it('rejects empty queries', async () => {
|
||||
await expect(command.func(null, { query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
|
||||
await expect(command.func({ query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
|
||||
});
|
||||
it('surfaces fetch failures as CliError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 503 })));
|
||||
await expect(command.func(null, { query: '苏州', limit: 3 })).rejects.toMatchObject({
|
||||
await expect(command.func({ query: '苏州', limit: 3 })).rejects.toMatchObject({
|
||||
code: 'FETCH_ERROR',
|
||||
message: 'ctrip search failed with status 503',
|
||||
});
|
||||
@@ -59,6 +59,6 @@ describe('ctrip search', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
Response: { searchResults: [] },
|
||||
}), { status: 200 })));
|
||||
await expect(command.func(null, { query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
|
||||
await expect(command.func({ query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const askCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'ask',
|
||||
@@ -28,7 +28,7 @@ export const askCommand = cli({
|
||||
return true;
|
||||
})(${JSON.stringify(text)})`);
|
||||
if (!injected)
|
||||
throw new SelectorError('Cursor input element');
|
||||
throw selectorError('Cursor input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll until a new assistant message appears or timeout
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const composerCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'composer',
|
||||
@@ -27,7 +27,7 @@ export const composerCommand = cli({
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})`);
|
||||
if (!typed) {
|
||||
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
throw selectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
}
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const sendCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'send',
|
||||
@@ -24,7 +24,7 @@ export const sendCommand = cli({
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})`);
|
||||
if (!injected) {
|
||||
throw new SelectorError('Cursor Composer input element');
|
||||
throw selectorError('Cursor Composer input element');
|
||||
}
|
||||
// Submit the command. In Cursor, Enter usually submits the chat.
|
||||
await page.wait(0.5);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
|
||||
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
||||
} from './utils.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'deepseek',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt to DeepSeek and get the response',
|
||||
domain: DEEPSEEK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
timeoutSeconds: 180,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
|
||||
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
||||
{ name: 'model', default: 'instant', choices: ['instant', 'expert', 'vision'], help: 'Model to use: instant, expert, or vision' },
|
||||
{ name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' },
|
||||
{ name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
|
||||
{ name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
|
||||
],
|
||||
// columns omitted: derived from row keys so non-think output shows only 'response'
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = kwargs.prompt;
|
||||
const timeoutMs = (kwargs.timeout || 120) * 1000;
|
||||
const wantThink = parseBoolFlag(kwargs.think);
|
||||
const wantSearch = parseBoolFlag(kwargs.search);
|
||||
|
||||
if (parseBoolFlag(kwargs.new)) {
|
||||
await page.goto(DEEPSEEK_URL);
|
||||
await page.wait(3);
|
||||
} else {
|
||||
const navigated = await ensureOnDeepSeek(page);
|
||||
if (navigated) {
|
||||
// Workspace was recycled; try to resume the most recent
|
||||
// conversation instead of starting a new one.
|
||||
await page.evaluate(`(() => {
|
||||
var link = document.querySelector('a[href*="/a/chat/s/"]');
|
||||
if (link) link.click();
|
||||
})()`);
|
||||
await page.wait(2);
|
||||
}
|
||||
}
|
||||
|
||||
await page.wait(2);
|
||||
|
||||
// Model selector is only available on the new-chat page, not inside
|
||||
// an existing conversation. Skip it when we resumed a prior thread.
|
||||
const currentUrl = await page.evaluate('window.location.href') || '';
|
||||
const inConversation = currentUrl.includes('/a/chat/s/');
|
||||
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
|
||||
|
||||
const wantModel = kwargs.model || 'instant';
|
||||
if (inConversation && modelExplicit) {
|
||||
throw new CliError(
|
||||
'ARGUMENT',
|
||||
`Cannot switch to ${wantModel} model inside an existing conversation.`,
|
||||
'Re-run with --new to start a fresh chat before selecting a model.',
|
||||
EXIT_CODES.USAGE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
if (!inConversation) {
|
||||
const modelResult = await withRetry(() => selectModel(page, wantModel));
|
||||
if (!modelResult?.ok) {
|
||||
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
||||
}
|
||||
if (modelResult?.toggled) await page.wait(0.5);
|
||||
}
|
||||
|
||||
const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
|
||||
if (!thinkResult?.ok && wantThink) {
|
||||
throw new CommandExecutionError('Could not enable DeepThink');
|
||||
}
|
||||
|
||||
if (wantModel === 'vision' && wantSearch) {
|
||||
throw new CliError(
|
||||
'ARGUMENT',
|
||||
'DeepSeek vision mode does not support --search.',
|
||||
'Run without --search, or use --model instant/expert for web search.',
|
||||
EXIT_CODES.USAGE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
// Vision mode does not have the search toggle.
|
||||
let searchResult;
|
||||
if (wantModel !== 'vision') {
|
||||
searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
|
||||
if (!searchResult?.ok && wantSearch) {
|
||||
throw new CommandExecutionError('Could not enable Search');
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkResult?.toggled || searchResult?.toggled) await page.wait(0.5);
|
||||
|
||||
if (kwargs.file) {
|
||||
const baseline = await withRetry(() => getBubbleCount(page));
|
||||
try {
|
||||
const fileResult = await sendWithFile(page, kwargs.file, prompt);
|
||||
if (fileResult && !fileResult.ok) {
|
||||
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
|
||||
}
|
||||
} catch (err) {
|
||||
// SPA navigates after send; "Promise was collected" means send succeeded
|
||||
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
||||
}
|
||||
await page.wait(3);
|
||||
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
|
||||
if (!result) {
|
||||
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
||||
}
|
||||
if (wantThink && typeof result === 'object' && result.response !== undefined) {
|
||||
return [result];
|
||||
}
|
||||
return [{ response: result }];
|
||||
}
|
||||
|
||||
const baseline = await withRetry(() => getBubbleCount(page));
|
||||
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
||||
if (!sendResult?.ok) {
|
||||
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
||||
}
|
||||
|
||||
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
|
||||
if (!result) {
|
||||
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
||||
}
|
||||
|
||||
if (wantThink && typeof result === 'object' && result.response !== undefined) {
|
||||
return [result];
|
||||
}
|
||||
return [{ response: result }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
||||
|
||||
const {
|
||||
mockEnsureOnDeepSeek,
|
||||
mockSelectModel,
|
||||
mockSetFeature,
|
||||
mockSendMessage,
|
||||
mockSendWithFile,
|
||||
mockGetBubbleCount,
|
||||
mockWaitForResponse,
|
||||
mockParseBoolFlag,
|
||||
mockWithRetry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureOnDeepSeek: vi.fn(),
|
||||
mockSelectModel: vi.fn(),
|
||||
mockSetFeature: vi.fn(),
|
||||
mockSendMessage: vi.fn(),
|
||||
mockSendWithFile: vi.fn(),
|
||||
mockGetBubbleCount: vi.fn(),
|
||||
mockWaitForResponse: vi.fn(),
|
||||
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
||||
mockWithRetry: vi.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
DEEPSEEK_DOMAIN: 'chat.deepseek.com',
|
||||
DEEPSEEK_URL: 'https://chat.deepseek.com/',
|
||||
ensureOnDeepSeek: mockEnsureOnDeepSeek,
|
||||
selectModel: mockSelectModel,
|
||||
setFeature: mockSetFeature,
|
||||
sendMessage: mockSendMessage,
|
||||
sendWithFile: mockSendWithFile,
|
||||
getBubbleCount: mockGetBubbleCount,
|
||||
waitForResponse: mockWaitForResponse,
|
||||
parseBoolFlag: mockParseBoolFlag,
|
||||
withRetry: mockWithRetry,
|
||||
}));
|
||||
|
||||
import { askCommand } from './ask.js';
|
||||
|
||||
describe('deepseek ask --file', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendWithFile.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(7);
|
||||
mockWaitForResponse.mockResolvedValue('new reply');
|
||||
});
|
||||
|
||||
it('captures the existing baseline before sending a file prompt', async () => {
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'summarize this',
|
||||
timeout: 120,
|
||||
file: './report.pdf',
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'new reply' }]);
|
||||
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000, false);
|
||||
});
|
||||
|
||||
it('still fails when explicit instant model selection cannot be verified', async () => {
|
||||
mockSelectModel.mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'summarize this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: false,
|
||||
search: false,
|
||||
})).rejects.toThrow(new CommandExecutionError('Could not switch to instant model'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepseek ask --think', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(5);
|
||||
});
|
||||
|
||||
it('returns separate thinking and response fields when --think is enabled', async () => {
|
||||
mockWaitForResponse.mockResolvedValue({
|
||||
response: 'The answer is 42.',
|
||||
thinking: 'Let me analyze this...',
|
||||
thinking_time: '2.5',
|
||||
});
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'what is the answer?',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: true,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{
|
||||
response: 'The answer is 42.',
|
||||
thinking: 'Let me analyze this...',
|
||||
thinking_time: '2.5',
|
||||
}]);
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, true);
|
||||
});
|
||||
|
||||
it('returns plain response when --think is disabled', async () => {
|
||||
mockWaitForResponse.mockResolvedValue('The answer is 42.');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'what is the answer?',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'The answer is 42.' }]);
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, false);
|
||||
});
|
||||
|
||||
it('does not declare static columns (derived from row keys)', () => {
|
||||
// columns should be undefined so the renderer infers from row keys,
|
||||
// avoiding empty trailing columns on non-think output.
|
||||
expect(askCommand.columns).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-think rows only contain response key', async () => {
|
||||
mockWaitForResponse.mockResolvedValue('Plain answer.');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'hello',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
// Row keys drive rendered columns; no thinking/thinking_time present.
|
||||
expect(Object.keys(rows[0])).toEqual(['response']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepseek ask conversation resume', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(2);
|
||||
mockWaitForResponse.mockResolvedValue('follow-up reply');
|
||||
});
|
||||
|
||||
it('resumes the most recent conversation and skips model selection', async () => {
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(true);
|
||||
// first evaluate: sidebar resume click (returns undefined)
|
||||
page.evaluate.mockResolvedValueOnce(undefined);
|
||||
// second evaluate: URL check (now inside a conversation)
|
||||
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'follow up',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
expect(mockSendMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips model selection when already inside an existing conversation', async () => {
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
||||
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'continue',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'expert',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast when --model is explicitly requested inside an existing conversation', async () => {
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
||||
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'continue',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'expert',
|
||||
think: false,
|
||||
search: false,
|
||||
__opencliOptionSources: { model: 'cli' },
|
||||
})).rejects.toMatchObject(new CliError(
|
||||
'ARGUMENT',
|
||||
'Cannot switch to expert model inside an existing conversation.',
|
||||
'Re-run with --new to start a fresh chat before selecting a model.',
|
||||
EXIT_CODES.USAGE_ERROR,
|
||||
));
|
||||
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still selects model when no conversation to resume', async () => {
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(true);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
// first evaluate: sidebar resume click (no link found)
|
||||
page.evaluate.mockResolvedValueOnce(undefined);
|
||||
// second evaluate: URL check (still on root page)
|
||||
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'hello',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'instant',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
||||
expect(mockSelectModel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips search toggle in vision mode when search is not requested', async () => {
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('vision reply');
|
||||
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'describe',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'vision',
|
||||
think: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'vision reply' }]);
|
||||
expect(mockSetFeature).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetFeature).toHaveBeenCalledWith(expect.anything(), 'DeepThink', false);
|
||||
});
|
||||
|
||||
it('fails fast instead of silently ignoring --search in vision mode', async () => {
|
||||
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'describe',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'vision',
|
||||
think: false,
|
||||
search: true,
|
||||
})).rejects.toMatchObject(new CliError(
|
||||
'ARGUMENT',
|
||||
'DeepSeek vision mode does not support --search.',
|
||||
'Run without --search, or use --model instant/expert for web search.',
|
||||
EXIT_CODES.USAGE_ERROR,
|
||||
));
|
||||
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
expect(mockSendWithFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { DEEPSEEK_DOMAIN, getConversationList } from './utils.js';
|
||||
|
||||
export const historyCommand = cli({
|
||||
site: 'deepseek',
|
||||
name: 'history',
|
||||
description: 'List conversation history from DeepSeek sidebar',
|
||||
domain: DEEPSEEK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
|
||||
],
|
||||
columns: ['Index', 'Title', 'Url'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const limit = Math.max(1, kwargs.limit || 20);
|
||||
const conversations = await getConversationList(page);
|
||||
if (conversations.length === 0) {
|
||||
return [{ Index: 0, Title: 'No conversation history found.', Url: '' }];
|
||||
}
|
||||
return conversations.slice(0, limit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { DEEPSEEK_DOMAIN, DEEPSEEK_URL } from './utils.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'deepseek',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in DeepSeek',
|
||||
domain: DEEPSEEK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
|
||||
func: async (page) => {
|
||||
await page.goto(DEEPSEEK_URL);
|
||||
await page.wait(2);
|
||||
return [{ Status: 'New chat started' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getVisibleMessages } from './utils.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'deepseek',
|
||||
name: 'read',
|
||||
description: 'Read the current DeepSeek conversation',
|
||||
domain: DEEPSEEK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Role', 'Text'],
|
||||
|
||||
func: async (page) => {
|
||||
await ensureOnDeepSeek(page);
|
||||
await page.wait(5);
|
||||
const messages = await getVisibleMessages(page);
|
||||
if (messages.length > 0) return messages;
|
||||
return [{ Role: 'system', Text: 'No visible messages found.' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getPageState } from './utils.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'deepseek',
|
||||
name: 'status',
|
||||
description: 'Check DeepSeek page availability and login state',
|
||||
domain: DEEPSEEK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Login', 'Url'],
|
||||
|
||||
func: async (page) => {
|
||||
await ensureOnDeepSeek(page);
|
||||
const state = await getPageState(page);
|
||||
return [{
|
||||
Status: state.hasTextarea ? 'Connected' : 'Page not ready',
|
||||
Login: state.isLoggedIn ? 'Yes' : 'No',
|
||||
Url: state.url,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
export const DEEPSEEK_DOMAIN = 'chat.deepseek.com';
|
||||
export const DEEPSEEK_URL = 'https://chat.deepseek.com/';
|
||||
export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]';
|
||||
export const MESSAGE_SELECTOR = '.ds-message';
|
||||
|
||||
export async function isOnDeepSeek(page) {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
try {
|
||||
const h = new URL(url).hostname;
|
||||
return h === 'deepseek.com' || h.endsWith('.deepseek.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOnDeepSeek(page) {
|
||||
if (await isOnDeepSeek(page)) return false;
|
||||
await page.goto(DEEPSEEK_URL);
|
||||
await page.wait(3);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPageState(page) {
|
||||
return page.evaluate(`(() => {
|
||||
const url = window.location.href;
|
||||
const title = document.title;
|
||||
const textarea = document.querySelector('${TEXTAREA_SELECTOR}');
|
||||
const avatar = document.querySelector('img[src*="user-avatar"]');
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
hasTextarea: !!textarea,
|
||||
isLoggedIn: !!avatar,
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function selectModel(page, modelName) {
|
||||
return page.evaluate(`(() => {
|
||||
var radios = document.querySelectorAll('div[role="radio"]');
|
||||
if (radios.length === 0) return { ok: false };
|
||||
var name = '${modelName}'.toLowerCase();
|
||||
var index = name === 'instant' ? 0 : name === 'expert' ? 1 : name === 'vision' ? 2 : -1;
|
||||
if (index < 0 || index >= radios.length) return { ok: false };
|
||||
var target = radios[index];
|
||||
var alreadySelected = target.getAttribute('aria-checked') === 'true';
|
||||
if (!alreadySelected) target.click();
|
||||
return { ok: true, toggled: !alreadySelected };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function setFeature(page, featureName, enabled) {
|
||||
// Match by position: DeepThink is the first toggle, Search is the second
|
||||
var index = featureName === 'DeepThink' ? 0 : 1;
|
||||
return page.evaluate(`(() => {
|
||||
var toggles = Array.from(document.querySelectorAll('.ds-toggle-button'));
|
||||
var btn = toggles[${index}];
|
||||
if (!btn) return { ok: false };
|
||||
var isActive = btn.classList.contains('ds-toggle-button--selected');
|
||||
if (${enabled} !== isActive) btn.click();
|
||||
return { ok: true, toggled: ${enabled} !== isActive };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function sendMessage(page, prompt) {
|
||||
const promptJson = JSON.stringify(prompt);
|
||||
return page.evaluate(`(async () => {
|
||||
const box = document.querySelector('${TEXTAREA_SELECTOR}');
|
||||
if (!box) return { ok: false, reason: 'textarea not found' };
|
||||
|
||||
box.focus();
|
||||
box.value = '';
|
||||
document.execCommand('selectAll');
|
||||
document.execCommand('insertText', false, ${promptJson});
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
|
||||
// Find the send button: last non-toggle button in the textarea's container
|
||||
var container = box.parentElement;
|
||||
while (container && !container.querySelector('div[role="button"]')) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
if (container) {
|
||||
var btns = container.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
|
||||
var sendBtn = btns[btns.length - 1];
|
||||
if (sendBtn && sendBtn.getAttribute('aria-disabled') === 'false'
|
||||
&& sendBtn.querySelectorAll('svg').length > 0) {
|
||||
sendBtn.click();
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
return { ok: true, method: 'enter' };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function getBubbleCount(page) {
|
||||
const count = await page.evaluate(`(() => {
|
||||
return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
|
||||
})()`);
|
||||
return count || 0;
|
||||
}
|
||||
|
||||
// Parse thinking response using text as a fallback when DOM-level extraction
|
||||
// is not available. Does NOT split on \n\n — that heuristic silently corrupts
|
||||
// multi-paragraph thinking or multi-paragraph answers. Instead, everything
|
||||
// after the header is treated as thinking content, and `response` stays empty
|
||||
// until the caller provides a DOM-separated answer.
|
||||
export function parseThinkingResponse(rawText) {
|
||||
if (!rawText) return null;
|
||||
|
||||
// Match thinking header patterns: "Thought for X seconds" or "已思考(用时 X 秒)"
|
||||
const thinkHeaderMatch = rawText.match(/^(Thought for ([\d.]+) seconds?|已思考(用时 ([\d.]+) 秒))\s*/);
|
||||
|
||||
if (!thinkHeaderMatch) {
|
||||
// No thinking section found, return plain response
|
||||
return { response: rawText, thinking: null, thinking_time: null };
|
||||
}
|
||||
|
||||
const thinkingTime = thinkHeaderMatch[2] || thinkHeaderMatch[3];
|
||||
const afterHeader = rawText.slice(thinkHeaderMatch[0].length);
|
||||
|
||||
// Treat everything after the header as thinking. The response will be
|
||||
// populated by the DOM-level extraction in waitForResponse().
|
||||
return {
|
||||
response: '',
|
||||
thinking: afterHeader.trim(),
|
||||
thinking_time: thinkingTime,
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForResponse(page, baselineCount, prompt, timeoutMs, parseThinking = false) {
|
||||
const startTime = Date.now();
|
||||
let lastText = '';
|
||||
let stableCount = 0;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await page.wait(3);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await page.evaluate(`(() => {
|
||||
const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
||||
const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
|
||||
var last = texts[texts.length - 1] || '';
|
||||
|
||||
// DOM-level thinking/response separation.
|
||||
// DeepSeek renders thinking in a collapsible container with a
|
||||
// distinct class (e.g. .ds-markdown--think or similar) and the
|
||||
// final answer in the main .ds-markdown region. By querying
|
||||
// these separately we avoid any text-heuristic split.
|
||||
var thinkEl = null, answerEl = null, thinkTime = null;
|
||||
if (${parseThinking} && bubbles.length > 0) {
|
||||
var lastBubble = bubbles[bubbles.length - 1];
|
||||
// Thinking container — DeepSeek uses various class names;
|
||||
// try common selectors.
|
||||
thinkEl = lastBubble.querySelector('.ds-markdown--think')
|
||||
|| lastBubble.querySelector('[class*="think"]');
|
||||
// Final answer container — the main markdown block that is
|
||||
// NOT the thinking section.
|
||||
var markdownEls = lastBubble.querySelectorAll('.ds-markdown');
|
||||
for (var i = 0; i < markdownEls.length; i++) {
|
||||
if (markdownEls[i] !== thinkEl
|
||||
&& !(thinkEl && thinkEl.contains(markdownEls[i]))
|
||||
&& !markdownEls[i].classList.contains('ds-markdown--think')) {
|
||||
answerEl = markdownEls[i];
|
||||
}
|
||||
}
|
||||
// Thinking time from the toggle/header element
|
||||
var timeEl = lastBubble.querySelector('[class*="think"] ~ *')
|
||||
|| lastBubble.querySelector('.ds-thinking-header');
|
||||
if (!timeEl) {
|
||||
// Fallback: parse from raw text header
|
||||
var m = last.match(/^(?:Thought for ([\\d.]+) seconds?|已思考(用时 ([\\d.]+) 秒))/);
|
||||
if (m) thinkTime = m[1] || m[2];
|
||||
} else {
|
||||
var tm = (timeEl.textContent || '').match(/([\\d.]+)/);
|
||||
if (tm) thinkTime = tm[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: texts.length,
|
||||
last: last,
|
||||
// DOM-separated fields (null when not available)
|
||||
thinkText: thinkEl ? (thinkEl.innerText || '').trim() : null,
|
||||
answerText: answerEl ? (answerEl.innerText || '').trim() : null,
|
||||
thinkTime: thinkTime,
|
||||
};
|
||||
})()`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result) continue;
|
||||
|
||||
const candidate = result.last;
|
||||
if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
|
||||
if (candidate === lastText) {
|
||||
stableCount++;
|
||||
if (stableCount >= 3) {
|
||||
if (parseThinking) {
|
||||
// Prefer DOM-level separation
|
||||
if (result.thinkText != null || result.answerText != null) {
|
||||
return {
|
||||
thinking: result.thinkText || '',
|
||||
response: result.answerText || '',
|
||||
thinking_time: result.thinkTime || null,
|
||||
};
|
||||
}
|
||||
// Fallback to text-header parsing (no \n\n split)
|
||||
return parseThinkingResponse(candidate);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
} else {
|
||||
stableCount = 0;
|
||||
}
|
||||
lastText = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (parseThinking && lastText) {
|
||||
return parseThinkingResponse(lastText);
|
||||
}
|
||||
return lastText || null;
|
||||
}
|
||||
|
||||
export async function getVisibleMessages(page) {
|
||||
const result = await page.evaluate(`(() => {
|
||||
const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
||||
return Array.from(msgs).map(m => {
|
||||
// User messages carry an extra hash-class alongside ds-message
|
||||
const isUser = m.className.split(/\\s+/).length > 2;
|
||||
return {
|
||||
Role: isUser ? 'user' : 'assistant',
|
||||
Text: (m.innerText || '').trim(),
|
||||
};
|
||||
}).filter(m => m.Text);
|
||||
})()`);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function getConversationList(page) {
|
||||
await ensureOnDeepSeek(page);
|
||||
// Expand sidebar if collapsed
|
||||
await page.evaluate(`(() => {
|
||||
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) {
|
||||
const btn = document.querySelector('div[tabindex="0"][role="button"]');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
})()`);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await page.wait(2);
|
||||
const items = await page.evaluate(`(() => {
|
||||
const items = [];
|
||||
const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
|
||||
links.forEach((link, i) => {
|
||||
const title = (link.innerText || '').trim().split('\\n')[0].trim();
|
||||
const href = link.getAttribute('href') || '';
|
||||
const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
|
||||
items.push({
|
||||
Index: i + 1,
|
||||
Id: idMatch ? idMatch[1] : href,
|
||||
Title: title || '(untitled)',
|
||||
Url: 'https://chat.deepseek.com' + href,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
})()`);
|
||||
if (Array.isArray(items) && items.length > 0) return items;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function waitForFilePreview(page, fileName) {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
await page.wait(2);
|
||||
const ready = await page.evaluate(`(() => {
|
||||
var name = ${JSON.stringify(fileName)};
|
||||
var hasFileName = Array.from(document.querySelectorAll('div'))
|
||||
.some(function(el) { return el.children.length === 0 && (el.textContent || '').trim() === name; });
|
||||
if (hasFileName) return true;
|
||||
// Vision mode shows an image thumbnail, not filename text. Require
|
||||
// a preview-like node here; send-button readiness is checked later.
|
||||
var box = document.querySelector('${TEXTAREA_SELECTOR}');
|
||||
if (!box) return false;
|
||||
var c = box.parentElement;
|
||||
while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
|
||||
if (!c) return false;
|
||||
return !!c.querySelector('img[src], canvas, video, [style*="background-image"], [class*="preview"], [class*="upload"]');
|
||||
})()`);
|
||||
if (ready) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function sendWithFile(page, filePath, prompt) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const absPath = path.default.resolve(filePath);
|
||||
|
||||
if (!fs.default.existsSync(absPath)) {
|
||||
return { ok: false, reason: `File not found: ${absPath}` };
|
||||
}
|
||||
|
||||
const stats = fs.default.statSync(absPath);
|
||||
if (stats.size > 100 * 1024 * 1024) {
|
||||
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 100 MB` };
|
||||
}
|
||||
|
||||
const fileName = path.default.basename(absPath);
|
||||
|
||||
// Collapse sidebar to keep DOM simple for send button matching
|
||||
await page.evaluate(`(() => {
|
||||
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length > 0) {
|
||||
const btn = document.querySelector('div[tabindex="0"][role="button"]');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
})()`);
|
||||
await page.wait(0.5);
|
||||
|
||||
let uploaded = false;
|
||||
if (page.setFileInput) {
|
||||
try {
|
||||
await page.setFileInput([absPath], 'input[type="file"]');
|
||||
uploaded = true;
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uploaded) {
|
||||
const content = fs.default.readFileSync(absPath);
|
||||
const base64 = content.toString('base64');
|
||||
const fallbackResult = await page.evaluate(`(async () => {
|
||||
var binary = atob('${base64}');
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
|
||||
var file = new File([bytes], ${JSON.stringify(fileName)});
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
|
||||
var inp = document.querySelector('input[type="file"]');
|
||||
if (!inp) return { ok: false, reason: 'file input not found' };
|
||||
|
||||
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
||||
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
|
||||
return { ok: false, reason: 'React onChange not found' };
|
||||
}
|
||||
|
||||
inp.files = dt.files;
|
||||
// Use inp.files, not dt.files; assignment transfers ownership
|
||||
inp[propsKey].onChange({ target: { files: inp.files } });
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
||||
}
|
||||
|
||||
const ready = await waitForFilePreview(page, fileName);
|
||||
if (!ready) return { ok: false, reason: 'file preview did not appear' };
|
||||
|
||||
// File preview appears immediately but send button stays disabled until
|
||||
// the server upload finishes. Wait for it.
|
||||
let sendEnabled = false;
|
||||
for (let tick = 0; tick < 15; tick++) {
|
||||
const enabled = await page.evaluate(`(() => {
|
||||
var box = document.querySelector('${TEXTAREA_SELECTOR}');
|
||||
if (!box) return false;
|
||||
var c = box.parentElement;
|
||||
while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
|
||||
if (!c) return false;
|
||||
var btns = c.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
|
||||
var last = btns[btns.length - 1];
|
||||
return !!(last && last.getAttribute('aria-disabled') === 'false');
|
||||
})()`);
|
||||
if (enabled) {
|
||||
sendEnabled = true;
|
||||
break;
|
||||
}
|
||||
await page.wait(1);
|
||||
}
|
||||
if (!sendEnabled) {
|
||||
return { ok: false, reason: 'send button did not enable after upload' };
|
||||
}
|
||||
|
||||
return sendMessage(page, prompt);
|
||||
}
|
||||
|
||||
// Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
|
||||
export async function withRetry(fn, retries = 2) {
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (i < retries && msg.includes('Promise was collected')) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoolFlag(value) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return String(value ?? '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { selectModel, sendWithFile, parseThinkingResponse } from './utils.js';
|
||||
|
||||
describe('deepseek parseThinkingResponse', () => {
|
||||
it('returns plain response when no thinking header is present', () => {
|
||||
const rawText = 'This is a regular response without thinking.';
|
||||
const result = parseThinkingResponse(rawText);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: rawText,
|
||||
thinking: null,
|
||||
thinking_time: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses English thinking header — all content after header is thinking', () => {
|
||||
const rawText = 'Thought for 3.5 seconds\n\nLet me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.';
|
||||
const result = parseThinkingResponse(rawText);
|
||||
|
||||
// Text-level parser no longer splits on \n\n; everything after header is thinking.
|
||||
// DOM-level extraction in waitForResponse() handles the actual separation.
|
||||
expect(result).toEqual({
|
||||
response: '',
|
||||
thinking: 'Let me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.',
|
||||
thinking_time: '3.5',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Chinese thinking header — all content after header is thinking', () => {
|
||||
const rawText = '已思考(用时 2.3 秒)\n\n让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。';
|
||||
const result = parseThinkingResponse(rawText);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: '',
|
||||
thinking: '让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。',
|
||||
thinking_time: '2.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('multi-paragraph thinking without final answer is not corrupted', () => {
|
||||
const rawText = 'Thought for 1.2 seconds\n\nFirst paragraph.\n\nSecond paragraph.';
|
||||
const result = parseThinkingResponse(rawText);
|
||||
|
||||
// Both paragraphs must stay in thinking; response is empty.
|
||||
expect(result).toEqual({
|
||||
response: '',
|
||||
thinking: 'First paragraph.\n\nSecond paragraph.',
|
||||
thinking_time: '1.2',
|
||||
});
|
||||
});
|
||||
|
||||
it('multi-paragraph final answer is not split by text parser', () => {
|
||||
const rawText = 'Thought for 3 seconds\n\nreasoning\n\nAnswer para 1.\n\nAnswer para 2.';
|
||||
const result = parseThinkingResponse(rawText);
|
||||
|
||||
// Text parser treats everything as thinking; DOM handles separation.
|
||||
expect(result).toEqual({
|
||||
response: '',
|
||||
thinking: 'reasoning\n\nAnswer para 1.\n\nAnswer para 2.',
|
||||
thinking_time: '3',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles thinking without final response', () => {
|
||||
const rawText = 'Thought for 1.2 seconds\n\nThinking process here...';
|
||||
const result = parseThinkingResponse(rawText);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: '',
|
||||
thinking: 'Thinking process here...',
|
||||
thinking_time: '1.2',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
const result = parseThinkingResponse('');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null input', () => {
|
||||
const result = parseThinkingResponse(null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('deepseek sendWithFile', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (tempDirs.length) {
|
||||
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers page.setFileInput over base64-in-evaluate when supported', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'report.txt');
|
||||
fs.writeFileSync(filePath, 'hello');
|
||||
|
||||
const page = {
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce(undefined) // sidebar collapse
|
||||
.mockResolvedValueOnce(true) // waitForFilePreview
|
||||
.mockResolvedValueOnce(true) // send button enabled check
|
||||
.mockResolvedValueOnce({ ok: true }), // sendMessage
|
||||
};
|
||||
|
||||
const result = await sendWithFile(page, filePath, 'summarize this');
|
||||
|
||||
expect(result).toEqual({ ok: true }); expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
|
||||
});
|
||||
|
||||
it('fails closed when upload preview appears but send button never enables', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'report.txt');
|
||||
fs.writeFileSync(filePath, 'hello');
|
||||
|
||||
const page = {
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce(undefined) // sidebar collapse
|
||||
.mockResolvedValueOnce(true) // waitForFilePreview
|
||||
.mockResolvedValue(false), // send button never enables
|
||||
};
|
||||
|
||||
const result = await sendWithFile(page, filePath, 'summarize this');
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'send button did not enable after upload' });
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(17);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepseek selectModel', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
it('fails expert selection when only one radio is present', async () => {
|
||||
const instantRadio = {
|
||||
getAttribute: vi.fn(() => 'true'),
|
||||
click: vi.fn(),
|
||||
};
|
||||
global.document = {
|
||||
querySelectorAll: vi.fn(() => [instantRadio]),
|
||||
};
|
||||
const page = {
|
||||
evaluate: vi.fn(async (script) => eval(script)),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'expert');
|
||||
|
||||
expect(result).toEqual({ ok: false });
|
||||
expect(instantRadio.click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects the correct radio for each model', async () => {
|
||||
const radios = [0, 1, 2].map(() => ({
|
||||
getAttribute: vi.fn(() => 'false'),
|
||||
click: vi.fn(),
|
||||
}));
|
||||
global.document = {
|
||||
querySelectorAll: vi.fn(() => radios),
|
||||
};
|
||||
const page = {
|
||||
evaluate: vi.fn(async (script) => eval(script)),
|
||||
};
|
||||
|
||||
await selectModel(page, 'instant');
|
||||
expect(radios[0].click).toHaveBeenCalled();
|
||||
expect(radios[1].click).not.toHaveBeenCalled();
|
||||
expect(radios[2].click).not.toHaveBeenCalled();
|
||||
|
||||
radios.forEach(r => r.click.mockClear());
|
||||
await selectModel(page, 'expert');
|
||||
expect(radios[1].click).toHaveBeenCalled();
|
||||
|
||||
radios.forEach(r => r.click.mockClear());
|
||||
await selectModel(page, 'vision');
|
||||
expect(radios[2].click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unknown model names', async () => {
|
||||
const radios = [0, 1, 2].map(() => ({
|
||||
getAttribute: vi.fn(() => 'false'),
|
||||
click: vi.fn(),
|
||||
}));
|
||||
global.document = {
|
||||
querySelectorAll: vi.fn(() => radios),
|
||||
};
|
||||
const page = {
|
||||
evaluate: vi.fn(async (script) => eval(script)),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'turbo');
|
||||
expect(result).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepseek sendWithFile Not allowed fallback', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (tempDirs.length) {
|
||||
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to DataTransfer when setFileInput throws Not allowed', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'image.png');
|
||||
fs.writeFileSync(filePath, 'fake-png');
|
||||
|
||||
const page = {
|
||||
setFileInput: vi.fn().mockRejectedValue(new Error('Not allowed')),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce(undefined) // sidebar collapse
|
||||
.mockResolvedValueOnce({ ok: true }) // DataTransfer fallback
|
||||
.mockResolvedValueOnce(true) // waitForFilePreview
|
||||
.mockResolvedValueOnce(true) // send button enabled
|
||||
.mockResolvedValueOnce({ ok: true }),// sendMessage
|
||||
};
|
||||
|
||||
const result = await sendWithFile(page, filePath, 'describe');
|
||||
|
||||
expect(page.setFileInput).toHaveBeenCalled();
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(5);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not treat send-button enablement alone as image upload proof', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'image.png');
|
||||
fs.writeFileSync(filePath, 'fake-png');
|
||||
|
||||
const page = {
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce(undefined) // sidebar collapse
|
||||
.mockResolvedValue(false), // no filename / thumbnail preview
|
||||
};
|
||||
|
||||
const result = await sendWithFile(page, filePath, 'describe');
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'file preview did not appear' });
|
||||
expect(page.evaluate.mock.calls[1][0]).toContain('img[src], canvas, video');
|
||||
expect(page.evaluate.mock.calls[1][0]).not.toContain("aria-disabled') === 'false'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './top.js';
|
||||
import './tag.js';
|
||||
import './user.js';
|
||||
import './read.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('devto listing adapters surface id + reading_time + published_at', () => {
|
||||
it('devto/top has the agent-native column shape and pipeline mapping', () => {
|
||||
const cmd = getRegistry().get('devto/top');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'author', 'reactions', 'comments',
|
||||
'reading_time', 'published_at', 'tags', 'url',
|
||||
]);
|
||||
const mapStep = cmd?.pipeline?.find((step) => step.map);
|
||||
expect(mapStep?.map).toMatchObject({
|
||||
id: '${{ item.id }}',
|
||||
reading_time: '${{ item.reading_time_minutes }}',
|
||||
published_at: '${{ item.published_at }}',
|
||||
url: '${{ item.url }}',
|
||||
});
|
||||
});
|
||||
|
||||
it('devto/tag has the agent-native column shape and pipeline mapping', () => {
|
||||
const cmd = getRegistry().get('devto/tag');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'author', 'reactions', 'comments',
|
||||
'reading_time', 'published_at', 'tags', 'url',
|
||||
]);
|
||||
const mapStep = cmd?.pipeline?.find((step) => step.map);
|
||||
expect(mapStep?.map).toMatchObject({
|
||||
id: '${{ item.id }}',
|
||||
reading_time: '${{ item.reading_time_minutes }}',
|
||||
published_at: '${{ item.published_at }}',
|
||||
});
|
||||
});
|
||||
|
||||
it('devto/user has the agent-native column shape (no author column, since user-specific)', () => {
|
||||
const cmd = getRegistry().get('devto/user');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'reactions', 'comments',
|
||||
'reading_time', 'published_at', 'tags', 'url',
|
||||
]);
|
||||
const mapStep = cmd?.pipeline?.find((step) => step.map);
|
||||
expect(mapStep?.map).toMatchObject({
|
||||
id: '${{ item.id }}',
|
||||
reading_time: '${{ item.reading_time_minutes }}',
|
||||
published_at: '${{ item.published_at }}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('devto/read adapter', () => {
|
||||
const cmd = getRegistry().get('devto/read');
|
||||
|
||||
it('registers the article-detail row shape', () => {
|
||||
expect(cmd?.columns).toEqual([
|
||||
'id', 'title', 'author', 'reactions', 'reading_time',
|
||||
'tags', 'published_at', 'body', 'url',
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes a positional id plus a tunable max-length', () => {
|
||||
const argNames = (cmd?.args || []).map((a) => a.name);
|
||||
expect(argNames).toEqual(['id', 'max-length']);
|
||||
const idArg = cmd?.args?.find((a) => a.name === 'id');
|
||||
expect(idArg?.required).toBe(true);
|
||||
expect(idArg?.positional).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the public dev.to JSON endpoint (no browser, public strategy)', () => {
|
||||
expect(cmd?.browser).toBe(false);
|
||||
expect(cmd?.strategy).toBe('public');
|
||||
});
|
||||
|
||||
it('fails fast with ArgumentError for non-numeric id before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: 'not-a-number', 'max-length': 20000 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast with ArgumentError for max-length below 100 before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'max-length': 50 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts numeric max-length strings on the direct func path', async () => {
|
||||
const article = {
|
||||
id: 1,
|
||||
title: 't',
|
||||
user: { username: 'u' },
|
||||
public_reactions_count: 0,
|
||||
reading_time_minutes: 1,
|
||||
tag_list: [],
|
||||
published_at: '',
|
||||
body_markdown: 'x'.repeat(150),
|
||||
url: 'https://dev.to/u/t-1',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'max-length': '100' });
|
||||
expect(rows[0].body).toBe('x'.repeat(100) + '\n\n... [truncated]');
|
||||
});
|
||||
|
||||
it('fails fast with ArgumentError for invalid max-length strings before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'max-length': 'abc' }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast with EmptyResultError on 404', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Not found', { status: 404 })));
|
||||
|
||||
await expect(cmd.func({ id: '99999999', 'max-length': 20000 }))
|
||||
.rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('fails fast with CommandExecutionError on non-404 HTTP failures', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Server error', { status: 500 })));
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'max-length': 20000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('fails fast with CommandExecutionError on invalid JSON responses', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 })));
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'max-length': 20000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('fails fast when the full article body is missing instead of returning a summary', async () => {
|
||||
const article = {
|
||||
id: 1,
|
||||
title: 't',
|
||||
user: { username: 'u' },
|
||||
public_reactions_count: 0,
|
||||
reading_time_minutes: 1,
|
||||
tag_list: [],
|
||||
published_at: '',
|
||||
description: 'summary only',
|
||||
url: 'https://dev.to/u/t-1',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
|
||||
|
||||
await expect(cmd.func({ id: '1', 'max-length': 20000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('returns a single article row with body_markdown extracted', async () => {
|
||||
// Real /api/articles/<id> returns tag_list as a comma string and tags as an array.
|
||||
const article = {
|
||||
id: 3605688,
|
||||
title: 'How to do thing X in Rust',
|
||||
user: { username: 'jdoe' },
|
||||
public_reactions_count: 42,
|
||||
reading_time_minutes: 7,
|
||||
tag_list: 'rust, webdev',
|
||||
tags: ['rust', 'webdev'],
|
||||
published_at: '2026-05-01T00:00:00Z',
|
||||
body_markdown: '# Hello\n\nThis is the article body.',
|
||||
url: 'https://dev.to/jdoe/how-to-do-thing-x-in-rust-1234',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
|
||||
|
||||
const rows = await cmd.func({ id: '3605688', 'max-length': 20000 });
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 3605688,
|
||||
title: 'How to do thing X in Rust',
|
||||
author: 'jdoe',
|
||||
reactions: 42,
|
||||
reading_time: 7,
|
||||
tags: 'rust, webdev',
|
||||
published_at: '2026-05-01T00:00:00Z',
|
||||
body: '# Hello\n\nThis is the article body.',
|
||||
url: 'https://dev.to/jdoe/how-to-do-thing-x-in-rust-1234',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles the alternate shape where tag_list is an array (defensive)', async () => {
|
||||
const article = {
|
||||
id: 1,
|
||||
title: 't',
|
||||
user: { username: 'u' },
|
||||
public_reactions_count: 0,
|
||||
reading_time_minutes: 1,
|
||||
tag_list: ['javascript', 'webdev'],
|
||||
published_at: '',
|
||||
body_markdown: 'body',
|
||||
url: 'https://dev.to/u/t-1',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'max-length': 20000 });
|
||||
expect(rows[0].tags).toBe('javascript, webdev');
|
||||
});
|
||||
|
||||
it('truncates body when over max-length and appends a marker', async () => {
|
||||
const longBody = 'x'.repeat(500);
|
||||
const article = {
|
||||
id: 1,
|
||||
title: 't',
|
||||
user: { username: 'u' },
|
||||
public_reactions_count: 0,
|
||||
reading_time_minutes: 1,
|
||||
tag_list: [],
|
||||
published_at: '',
|
||||
body_markdown: longBody,
|
||||
url: 'https://dev.to/u/t-1',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'max-length': 100 });
|
||||
|
||||
expect(rows[0].body).toBe('x'.repeat(100) + '\n\n... [truncated]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* DEV.to article reader.
|
||||
*
|
||||
* Public API: https://dev.to/api/articles/<id>
|
||||
* Returns the full article including `body_markdown` (and `body_html`).
|
||||
*
|
||||
* The DEV.to API does not currently expose article comments — this reader
|
||||
* therefore emits one row with the article body. If/when comments become
|
||||
* available we can extend to a POST + L0/L1 shape like `hackernews read`.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const DEVTO_ARTICLE_BASE = 'https://dev.to/api/articles';
|
||||
|
||||
async function fetchArticle(id) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${DEVTO_ARTICLE_BASE}/${id}`);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CommandExecutionError(`DEV.to API request failed for article ${id}`, detail);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new EmptyResultError(`devto/${id}`, 'Article not found');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new CommandExecutionError(`DEV.to API HTTP ${res.status} for article ${id}`, 'Check the article id');
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
throw new CommandExecutionError(`DEV.to API returned invalid JSON for article ${id}`, 'Retry later or open the article URL directly');
|
||||
}
|
||||
}
|
||||
|
||||
function requireMinInt(value, min, label) {
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isInteger(number) || number < min) {
|
||||
throw new ArgumentError(`${label} must be an integer >= ${min}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function requireArticleBody(article, id) {
|
||||
if (typeof article.body_markdown === 'string' && article.body_markdown.trim()) {
|
||||
return article.body_markdown;
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`DEV.to article ${id} did not include body_markdown`,
|
||||
'DEV.to API response shape may have changed. Open the article URL directly or retry later.',
|
||||
);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'devto',
|
||||
name: 'read',
|
||||
description: 'Read a DEV.to article body by id',
|
||||
domain: 'dev.to',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'id', required: true, positional: true, help: 'DEV.to article id (numeric, e.g. 3605688)' },
|
||||
{ name: 'max-length', type: 'int', default: 20000, help: 'Max characters of body to return (min 100)' },
|
||||
],
|
||||
columns: ['id', 'title', 'author', 'reactions', 'reading_time', 'tags', 'published_at', 'body', 'url'],
|
||||
func: async (args) => {
|
||||
const id = String(args.id || '').trim();
|
||||
if (!/^\d+$/.test(id)) {
|
||||
throw new ArgumentError(`Invalid DEV.to article id: ${args.id}`, 'Pass a numeric id like 3605688');
|
||||
}
|
||||
const maxLength = requireMinInt(args['max-length'] ?? 20000, 100, 'devto read --max-length');
|
||||
|
||||
const article = await fetchArticle(id);
|
||||
if (!article || !article.id) {
|
||||
throw new EmptyResultError(`devto/${id}`, 'Article not found');
|
||||
}
|
||||
|
||||
const body = requireArticleBody(article, id);
|
||||
const truncated = body.length > maxLength
|
||||
? body.slice(0, maxLength) + '\n\n... [truncated]'
|
||||
: body;
|
||||
|
||||
// The single-article endpoint returns `tag_list` as a comma-separated
|
||||
// string and `tags` as an array — the opposite of the listing endpoints.
|
||||
// Normalize either shape into a single comma-separated string.
|
||||
const tagsRaw = article.tag_list ?? article.tags ?? '';
|
||||
const tags = Array.isArray(tagsRaw) ? tagsRaw.join(', ') : String(tagsRaw);
|
||||
|
||||
return [{
|
||||
id: article.id,
|
||||
title: article.title || '',
|
||||
author: article.user?.username || '[deleted]',
|
||||
reactions: article.public_reactions_count ?? 0,
|
||||
reading_time: article.reading_time_minutes ?? 0,
|
||||
tags,
|
||||
published_at: article.published_at || '',
|
||||
body: truncated,
|
||||
url: article.url || '',
|
||||
}];
|
||||
},
|
||||
});
|
||||
+4
-1
@@ -15,15 +15,18 @@ cli({
|
||||
},
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'reactions', 'comments', 'tags'],
|
||||
columns: ['rank', 'id', 'title', 'author', 'reactions', 'comments', 'reading_time', 'published_at', 'tags', 'url'],
|
||||
pipeline: [
|
||||
{ fetch: { url: 'https://dev.to/api/articles?tag=${{ args.tag }}&per_page=${{ args.limit }}' } },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
id: '${{ item.id }}',
|
||||
title: '${{ item.title }}',
|
||||
author: '${{ item.user.username }}',
|
||||
reactions: '${{ item.public_reactions_count }}',
|
||||
comments: '${{ item.comments_count }}',
|
||||
reading_time: '${{ item.reading_time_minutes }}',
|
||||
published_at: '${{ item.published_at }}',
|
||||
tags: `\${{ item.tag_list | join(', ') }}`,
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user