Compare commits
347 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50565efdde | |||
| a86d64705c | |||
| a93f6e71bb | |||
| 27ba33fcb8 | |||
| 18f1ceba32 | |||
| 46dd226875 | |||
| 12831fc793 | |||
| 2065a4f90e | |||
| da3eb951eb | |||
| 865f5aa021 | |||
| ce5f3762a1 | |||
| e169cc19b3 | |||
| 073c214507 | |||
| 05cfbee66e | |||
| 9a689c98ef | |||
| 0850d8f4d4 | |||
| 8b9faef054 | |||
| 399c0de2a7 | |||
| dc9dde41f3 | |||
| 7702d8d534 | |||
| 0ae77bdedc | |||
| a80e5a3d58 | |||
| 0de1d56796 | |||
| 03ba558386 | |||
| 77812f0e3d | |||
| b4f5df9d19 | |||
| b58f26006d | |||
| ab28a1f2eb | |||
| da8d32fc59 | |||
| 13635649e4 | |||
| 89fe2f2288 | |||
| b866dbeebe | |||
| 03c0157614 | |||
| da3c26e610 | |||
| a8dddcb291 | |||
| 254c54a99b | |||
| 4d859546d3 | |||
| 5256711a25 | |||
| 0124c4eb7d | |||
| c1ee31d063 | |||
| 0e73c3c2c2 | |||
| 1cb353d57a | |||
| 5add09f078 | |||
| 9a53369bd6 | |||
| f0c07e781c | |||
| 95420d27ae | |||
| f3586293d0 | |||
| cde6198f3d | |||
| b0f84c99c9 | |||
| 654019eeba | |||
| c1ad69676f | |||
| ce9151f363 | |||
| 63ae0d81fc | |||
| c4e6aab925 | |||
| 183c5e6fed | |||
| 189462c0dd | |||
| 00a1d8b1e9 | |||
| dc8c75f7f7 | |||
| f1f28cb2aa | |||
| 229b3b00d4 | |||
| e2e9a1261e | |||
| 6129bb3953 | |||
| 67344d5e36 | |||
| cad35e7a6a | |||
| 8de1184da6 | |||
| 1db7b5f1e8 | |||
| f7ad36ba12 | |||
| 63db56d07b | |||
| 338dc794a7 | |||
| 9387cd9262 | |||
| 18dce783da | |||
| 08d50d9b24 | |||
| 928b1e548d | |||
| 96cbeb4f65 | |||
| bab040985a | |||
| 23cf6e5239 | |||
| d6a7011454 | |||
| e7bdad4783 | |||
| 0a90179322 | |||
| 215a73dc56 | |||
| 1f8b09bd1c | |||
| 9a4e11d3fa | |||
| d174f724ec | |||
| 244ec45278 | |||
| 5d2e87ad16 | |||
| b3695a2468 | |||
| 01022d9c09 | |||
| 52396b2da2 | |||
| 046712ab42 | |||
| 9161d99d96 | |||
| 616cc88603 | |||
| cbb9e23394 | |||
| fc4c7c151f | |||
| 6b99276a8d | |||
| b056e420b1 | |||
| 70629fb9b0 | |||
| ba2dcc0dcc | |||
| df8ca8d440 | |||
| 1dd712d609 | |||
| ee4820ef59 | |||
| 55b053a6f6 | |||
| 80548c7de9 | |||
| 7af50abd04 | |||
| e5bbce4fe2 | |||
| 5848754943 | |||
| 19e64da565 | |||
| acfab26a52 | |||
| 22bedf21eb | |||
| 6557622156 | |||
| de2ef4ca56 | |||
| 001abdf481 | |||
| 1d87cde513 | |||
| bcd9c124c4 | |||
| 7879b774e9 | |||
| 84dde77359 | |||
| d2abdcc4b7 | |||
| e416f5f071 | |||
| b0de4e6cc8 | |||
| efd3c03d49 | |||
| 8b765236fb | |||
| ac1684a691 | |||
| 3b14f2f080 | |||
| a28390d36f | |||
| 0a239af512 | |||
| 09a0af7a23 | |||
| 1ff4de3119 | |||
| 98a2e4f91d | |||
| 237741afd5 | |||
| b16a9d6cd6 | |||
| b4fb1509da | |||
| b85ab89938 | |||
| 8ed8ca26dd | |||
| 1c88aff23f | |||
| c027944380 | |||
| ac0a8ba725 | |||
| 7382a3541f | |||
| 08dc81d715 | |||
| 1e40165107 | |||
| 2be23cf534 | |||
| a2bd694bdb | |||
| 531ef27436 | |||
| aa468589c5 | |||
| c50386dcf9 | |||
| 54cb1f76b3 | |||
| 221366d7b6 | |||
| f68ea38f31 | |||
| 678d0086d8 | |||
| 9139baaef8 | |||
| f9abec1455 | |||
| 77b29b3d09 | |||
| a25a2836e9 | |||
| 5a82ecfd3b | |||
| 4a0d26835f | |||
| 72d20c9113 | |||
| d8fc0a9e2b | |||
| be14222a9f | |||
| 37b1289264 | |||
| 82dda11a2a | |||
| 3f1a723b5c | |||
| 880c7d37c4 | |||
| 46c8fe8e2e | |||
| 04fd4f86f8 | |||
| 944ca3a105 | |||
| c08d0e28b2 | |||
| 19228d721e | |||
| ec3eddec2a | |||
| 62bd1175e2 | |||
| f192f69761 | |||
| 53d62b0cc2 | |||
| 323f5318eb | |||
| c45dd409c0 | |||
| 3dcddb6293 | |||
| dc67023be8 | |||
| 65cab71b07 | |||
| cc760810a2 | |||
| 7731e36388 | |||
| 55d91906d3 | |||
| 24e6be9165 | |||
| 2615d331e8 | |||
| a73301f7bf | |||
| 6d99979c4d | |||
| 1aec5c59dc | |||
| 707cebd042 | |||
| 5e938a3245 | |||
| 98b978eaba | |||
| 594c9a628f | |||
| 0a4a2cff2f | |||
| 10acaa9541 | |||
| 8d3e7d459a | |||
| cbf1ac1558 | |||
| 54270cdc4d | |||
| d14930201a | |||
| 6cde68689b | |||
| 203ff56e2d | |||
| a1555e8f19 | |||
| 76fcc28c99 | |||
| 6126413d60 | |||
| 5b11251692 | |||
| 26ccbaa4c5 | |||
| 221f02f364 | |||
| 3307640a05 | |||
| c3d2fc1f9f | |||
| 06daf6f8b9 | |||
| c2f0d33293 | |||
| add386b699 | |||
| 68ebb0a277 | |||
| c32fb02a74 | |||
| 7143e52093 | |||
| 4e8bad41fb | |||
| 29e8fe9a16 | |||
| cc13dd0c0c | |||
| 56ac98cb3f | |||
| 8aa48b1094 | |||
| 7ed42a67b8 | |||
| c730a02640 | |||
| 3329a23b20 | |||
| 7362ced82d | |||
| 6b8d30b12d | |||
| e318522fbd | |||
| b6965a5973 | |||
| 52a6ce0264 | |||
| 40f270bacb | |||
| c90b355ca0 | |||
| d1076c0deb | |||
| c40a8547c6 | |||
| 6804324066 | |||
| 6ed93fdbe5 | |||
| d4640b2418 | |||
| a79a977a58 | |||
| 90e4cb9878 | |||
| e3e2a97925 | |||
| cd2c3ebf81 | |||
| 4d1da75baa | |||
| da84782969 | |||
| 299c020eb3 | |||
| 9379556078 | |||
| 377bc06367 | |||
| 0311ff3c62 | |||
| 7d0f46d009 | |||
| 5cb075d102 | |||
| ce432c2428 | |||
| 7ee16aa087 | |||
| 7a2ab47bf8 | |||
| 2c8b50c4fd | |||
| 51a9456305 | |||
| 62592547a4 | |||
| 4a3a55634a | |||
| da497f0b02 | |||
| 2590278f43 | |||
| 488e407a65 | |||
| e682c1c30a | |||
| 86f57c0846 | |||
| 4a6cfe8060 | |||
| bcb0fb362f | |||
| 85b1c07ba9 | |||
| 6fbaf0d5b8 | |||
| 34f351e59f | |||
| acc18be999 | |||
| 67ed9e9c81 | |||
| 8577d88ee7 | |||
| cd731bd2aa | |||
| d1c714ecd3 | |||
| 8182ffbe89 | |||
| 5a4984789d | |||
| e1185da882 | |||
| 9446bddb60 | |||
| 0c4bcdbb86 | |||
| ec3b7dadf3 | |||
| 4de04c43ad | |||
| 40592ea5fd | |||
| 942539a695 | |||
| dc645a5bcf | |||
| f0d9aa187c | |||
| e82e32abc6 | |||
| 254d51835f | |||
| 87dfb68e74 | |||
| 000c867f3a | |||
| 24f643af16 | |||
| 1f30a9027b | |||
| 72f2b020de | |||
| 261b8bfbb5 | |||
| 1e7ebe7f27 | |||
| 7e44e71150 | |||
| 76a9c78261 | |||
| 030a0ad885 | |||
| e29150bab5 | |||
| a50074d684 | |||
| 368581ea4d | |||
| 0c488bbf51 | |||
| 86792d2954 | |||
| ee54eb8e62 | |||
| 663b3387ee | |||
| 716461581a | |||
| 854cf01aad | |||
| baf1522420 | |||
| e3995df25c | |||
| 4682ffc3de | |||
| e3140af5ee | |||
| 43f5c6e1cf | |||
| 68ef95659f | |||
| c922a39a7d | |||
| aae6e823b4 | |||
| 3f62cc45bf | |||
| b6f352b318 | |||
| dadf01b56f | |||
| 1239798d04 | |||
| 9ccc896585 | |||
| 1a69f40a80 | |||
| 300607f692 | |||
| 42b5a4e68d | |||
| bccd275d66 | |||
| edfa5f0da3 | |||
| 16b02bcc58 | |||
| 5af2ff1d6c | |||
| 9c25bc7009 | |||
| 8c88a3cbf3 | |||
| 9c4f4a3d30 | |||
| 29c135b656 | |||
| 7edf53783f | |||
| af7b94152f | |||
| 6b26aedd56 | |||
| 68b18cdbcd | |||
| cddc84776c | |||
| 4f5fcd9acb | |||
| 40b2f75098 | |||
| feab24f76c | |||
| 8ef7e903b8 | |||
| 7c5bafd49b | |||
| f66996a148 | |||
| 4fac911425 | |||
| b52da639a3 | |||
| b1dca04ddd | |||
| f481585ba1 | |||
| 723f2b9147 | |||
| 2babed84e9 | |||
| a6ca53c7cf | |||
| c3912d8e5c | |||
| 67599ea67c | |||
| f321a6096d | |||
| 59ebf551f0 | |||
| 04a57029b3 | |||
| fd438c2109 | |||
| 1eac8e0776 | |||
| 6af4db2ab5 | |||
| 5127211ec1 | |||
| 587750cad3 | |||
| a77e05847f |
@@ -13,7 +13,9 @@ runs:
|
||||
uses: browser-actions/setup-chrome@v2
|
||||
id: setup-chrome
|
||||
with:
|
||||
chrome-version: latest
|
||||
# Stable Chrome for Testing keeps headed E2E on a released browser.
|
||||
# `latest` pulls Chromium snapshots, which can break extension startup.
|
||||
chrome-version: stable
|
||||
|
||||
- name: Verify Chrome installation
|
||||
shell: bash
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
commit-message:
|
||||
prefix: "chore(ci)"
|
||||
@@ -110,8 +110,12 @@ jobs:
|
||||
- name: Run unit tests under Bun
|
||||
run: bun vitest run --project unit --reporter=verbose
|
||||
|
||||
# Adapter tests are pure unit tests — OS doesn't affect results.
|
||||
# Adapter tests are pure unit tests — OS doesn't affect results. Gated off
|
||||
# `pull_request` to keep PR CI under ~2 minutes; adapter authors run focused
|
||||
# tests locally before pushing, and `push` to main / nightly cron / manual
|
||||
# dispatch still guard the merged state.
|
||||
adapter-test:
|
||||
if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: E2E Headed Chrome
|
||||
|
||||
on:
|
||||
# E2E removed from `pull_request` to keep PR feedback under ~2 minutes; PR-time
|
||||
# protection is the CI workflow (typecheck / unit / lint / adapter / build).
|
||||
# E2E still guards `main` directly, runs nightly, and on release tag push so
|
||||
# protocol/CDP/extension contract regressions are caught before they ship.
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
@@ -13,18 +17,11 @@ on:
|
||||
- 'tests/smoke/**'
|
||||
- '.github/actions/setup-chrome/**'
|
||||
- '.github/workflows/e2e-headed.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- 'src/browser/**'
|
||||
- 'src/daemon.ts'
|
||||
- 'src/execution.ts'
|
||||
- 'src/interceptor.ts'
|
||||
- 'tests/e2e/**'
|
||||
- 'tests/smoke/**'
|
||||
- '.github/actions/setup-chrome/**'
|
||||
- '.github/workflows/e2e-headed.yml'
|
||||
tags: ['v*']
|
||||
schedule:
|
||||
# Daily 08:00 UTC — catch flake / Chrome-version drift even when no commits
|
||||
# touched the watched paths recently.
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -37,9 +34,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
|
||||
# Chrome MSI installation on Windows runners (known issue).
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
# Gate placement by what each runner can run deterministically:
|
||||
# - the real-browser extension smoke needs a Chrome that reliably runs
|
||||
# an MV3 extension, which only Linux+xvfb provides on hosted runners
|
||||
# (headed macOS crashes on Mach port rendezvous outside an Aqua
|
||||
# session; headless does not connect the extension SW there);
|
||||
# - the daemon transport contracts need no browser and run blocking on
|
||||
# every OS, so macOS/Windows get a real gate, not a skipped one.
|
||||
# macOS pinned to 15 while the macOS 26 image stabilizes.
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -52,7 +55,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Linux runs the extension smoke and macOS runs the full real-site e2e
|
||||
# suite; both need a real Chrome. Windows runs only the browser-free
|
||||
# transport gate, and the setup-chrome action hangs on Windows anyway.
|
||||
- name: Setup Chrome
|
||||
if: runner.os != 'Windows'
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
@@ -62,21 +69,28 @@ jobs:
|
||||
- name: Build extension
|
||||
run: npm run build --prefix extension
|
||||
|
||||
- name: Run AX Chrome smoke (Linux, via xvfb)
|
||||
# Real-browser extension smoke: Linux under xvfb is the one hosted
|
||||
# environment where a real Chrome reliably starts an MV3 extension, so
|
||||
# this is the release-blocking browser gate. Headed (not headless):
|
||||
# headless does not connect the extension service worker on hosted
|
||||
# runners. See the matrix comment for why macOS/Windows don't run it.
|
||||
- name: Run AX Chrome smoke (Linux, real extension via xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
OPENCLI_AX_E2E: '1'
|
||||
OPENCLI_E2E_HEADED: '1'
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
|
||||
|
||||
- name: Run AX Chrome smoke (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
env:
|
||||
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
OPENCLI_AX_E2E: '1'
|
||||
run: npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
|
||||
# Transport contract E2E: real daemon process + scripted fake extension.
|
||||
# Pins the cross-layer contracts (waiter attach, deadline 408, dispatched
|
||||
# disconnect, profile fallback, graceful shutdown) end to end with the
|
||||
# actual daemon binary — no browser required, so this is the blocking
|
||||
# gate on EVERY OS, including macOS and Windows.
|
||||
- name: Run daemon transport contract E2E
|
||||
run: npx vitest run --project e2e-fixed-port tests/e2e/daemon-transport.test.ts --reporter=verbose
|
||||
|
||||
- name: Run E2E tests (Linux, via xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -86,8 +100,10 @@ jobs:
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/e2e/ --reporter=verbose
|
||||
|
||||
- name: Run E2E tests (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
# Real-site adapter e2e stays on Linux/macOS; Windows runs the two
|
||||
# deterministic gates above (unit coverage in ci.yml already spans it).
|
||||
- name: Run E2E tests (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
OPENCLI_AX_E2E: '0'
|
||||
run: npx vitest run tests/e2e/ --reporter=verbose
|
||||
|
||||
+298
-2
@@ -1,10 +1,302 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## [1.8.4](https://github.com/jackwener/opencli/compare/v1.8.3...v1.8.4) (2026-06-15)
|
||||
|
||||
Patch release surfacing the bundled skills directory, expanding the auth subsystem across 50+ adapters, refactoring the extension's tab-group model, and adding ten or so new adapter capabilities.
|
||||
|
||||
### Features
|
||||
|
||||
* **skills** — new `opencli skills list` and `opencli skills read <skill> [path]` commands expose the bundled `skills/opencli-*` directories as a canonical, version-bound source of agent-facing guidance. Skills are now published as part of the npm package (`skills/opencli-*/**`), so the Browser Bridge App's bundled OpenCLI carries the same skills the CLI version itself documents. Non-opencli skills, `../` path traversal, and unknown skill names are rejected with friendly error messages. ([#1948](https://github.com/jackwener/opencli/pull/1948))
|
||||
* **auth** — `opencli auth status` aggregate command lists per-adapter session health; `quickCheck` wired into 50 adapters so the aggregate is fast; `auth refresh` maintenance command extends the daily auth-refresh model; first auth coverage for `nowcoder`, `jike`, `maimai`, `jimeng` and another batch of sites. ([#1878](https://github.com/jackwener/opencli/pull/1878), [#1879](https://github.com/jackwener/opencli/pull/1879), [#1880](https://github.com/jackwener/opencli/pull/1880), [#1881](https://github.com/jackwener/opencli/pull/1881))
|
||||
* **extension 1.0.20** — `refactor(extension): remove visible adapter tab group` drops the visible Adapter tab-group surface; OpenCLI no longer creates a user-visible group for adapter tabs. ([#1925](https://github.com/jackwener/opencli/pull/1925))
|
||||
* **xiaohongshu** — `ask` adapter with citations; `follow` / `unfollow` commands; commenter user-identity columns on `read`.
|
||||
* **bilibili** — `follow` / `unfollow` commands.
|
||||
* **twitter** — expose media poster URLs in tweet output; harden SearchTimeline metadata and API error paths.
|
||||
* **reddit** — media columns surfaced in `read` output.
|
||||
* **discord-app** — targeted `read` navigation.
|
||||
* **huodongxing** — new `events` adapter.
|
||||
* **slock** — new collaboration adapter.
|
||||
* **manus** / **gemini** — Patch release backports (carried in from 1.8.3 timeline coverage gap).
|
||||
* **llms.txt** — generated for AI visibility / GEO. ([#1889](https://github.com/jackwener/opencli/pull/1889))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **douban** — `title` splitting is now self-contained for the `page.evaluate` call (was depending on outer scope under chunked extraction).
|
||||
* **bloomberg** — Businessweek reads now traverse from the section page instead of the legacy article landing.
|
||||
* **deepseek** — reject search with incompatible models pre-navigation (saves a wasted page load).
|
||||
* **chatgpt** — response extraction stabilized under virtual scrolling.
|
||||
|
||||
## [1.8.3](https://github.com/jackwener/opencli/compare/v1.8.2...v1.8.3) (2026-06-06)
|
||||
|
||||
Patch release focused on two architectural fixes around extension and daemon lifecycle, plus the first wave of the new site auth subsystem.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.19** — close the MV3 Service Worker race that spawned duplicate `OpenCLI Adapter` tab groups (and, in the worst case, duplicate Adapter windows). The extension now persists the owned `windowId` immediately after `chrome.windows.create` returns and persists the owned `groupId` immediately after `chrome.tabs.group` returns, so a worker death between those API calls and the subsequent `chrome.tabGroups.update` no longer leaves a titleless orphan group and no longer drops the window pointer. Title-update failure no longer ungroups (it lets `ensureCanonicalGroupTitle` self-heal on the next ensure cycle), and `collectOwnedGroupCandidates` gains a fourth recovery layer: a global scan for empty-title groups containing a known owned `preferredTabId` for the role, with explicit hijack defense for user-built untitled groups. Closes the duplicate-tab-group bug report users had reported across the 1.8.2 window. ([#1862](https://github.com/jackwener/opencli/pull/1862))
|
||||
* **daemon** — SIGKILL fallback when the stale daemon refuses graceful shutdown. After `npm install -g @jackwener/opencli@latest`, the CLI detects a version-mismatched daemon (`daemonVersion !== PKG_VERSION`), asks it to exit via `/shutdown`, and now — if the port is still held after 3 s — reads the stale daemon's pid from its own `/status` response and `process.kill(pid, 'SIGKILL')` (cross-platform: maps to `TerminateProcess` on Windows). The previous flow surfaced `Stale daemon could not be replaced` and asked users to run `opencli daemon stop && opencli doctor`; this is now automatic. ([#1861](https://github.com/jackwener/opencli/pull/1861))
|
||||
* **xiaohongshu/publish** — prioritize the visible title input when the editor renders both a hidden draft input and a visible publish input.
|
||||
* **xiaohongshu/publish** — accept inline topic suggestions with Enter when the dropdown lives inside a Shadow DOM surface, while still verifying the topic marker appears in the editor.
|
||||
* **instagram/following** — paginate beyond the first endpoint page so high `--limit` values return more than the initial batch.
|
||||
|
||||
### Features
|
||||
|
||||
* **site auth subsystem** — new `opencli <site> login` and `opencli <site> whoami` commands, registered through a shared `clis/_shared/site-auth.js` helper. `login` opens the site's auth page in a foreground persistent session and polls the configured `verify` probe (cookie, JSON API, DOM scrape) until the browser session reports logged-in; `whoami` runs the same probe without opening the page. First five sites: twitter, github, bilibili, douyin, xiaohongshu. `whoami` outputs are PII-scrubbed (no email / phone / token in row columns). ([#1852](https://github.com/jackwener/opencli/pull/1852))
|
||||
* **gemini** — add read-only conversation commands (list / read / search).
|
||||
* **manus** — add a read-only `manus.im` adapter.
|
||||
|
||||
### Docs / Sitemap
|
||||
|
||||
* **sitemaps/xiaohongshu** — Phase 2 sitemap content seeded with login schema dogfood, the first non-PoC consumer of the v1.1 sitemap schema. ([#1853](https://github.com/jackwener/opencli/pull/1853))
|
||||
|
||||
### Internal
|
||||
|
||||
* **test(e2e)** — raise `runCli` `maxBuffer` so manifest-output snapshots no longer truncate on macOS / Windows CI.
|
||||
|
||||
## [1.8.2](https://github.com/jackwener/opencli/compare/v1.8.1...v1.8.2) (2026-06-03)
|
||||
|
||||
Mid-cycle release: introduces the **Site Maps Hub** subsystem (agent-facing per-site navigation knowledge), restores the **smart-search** skill, and ships a wide batch of new adapters / commands plus a long tail of read-path fixes. Extension bumped to 1.0.18 for an owned-group reusable-tab scope fix.
|
||||
|
||||
### Site Maps Hub (new subsystem)
|
||||
|
||||
* **`sitemaps/<site>/` top-level seed directory** — sitemap content lives alongside `clis/` and `skills/`, parallel first-class repo citizens. Twitter and HackerNews seeded as v1 baselines.
|
||||
* **`opencli browser open` / `analyze` surface sitemap availability** — when the requested site has a sitemap (global seed or local overlay `~/.opencli/sites/<site>/sitemap/`), the JSON envelope gains an optional `sitemap` field with `{ available, source, hint }`. `open` emits the hint once per session per site (deduped via `~/.opencli/cache/browser-sitemap-hints/`); `analyze` emits every call since it is a planning command. Adds no new browser-action behavior and no `~/.opencli/sites/` writes unless an agent explicitly invokes a sitemap skill.
|
||||
* **Two new skills**:
|
||||
* `opencli-sitemap-author` — create / maintain per-site sitemaps. Two-layer storage (global repo seed + local overlay), Form B compact YAML action schema with `pre / do / post / fail / recover / evidence`, `adapter_health_update` directives, `selector_pattern` as first-class anchor type, partial pages (`_<name>.md`) for cross-page UI, and a size-guidance table with hard 800-token / 1500-3000 cohesion / >3000 split tiers.
|
||||
* `opencli-browser-sitemap` — consume site sitemaps while executing browser tasks. Lazy load, Trust-Reality rule (`browser state` is truth, sitemap is hint), stale-on-conflict writeback, `adapter_health` write-back closure so subsequent agents skip a known-suspect adapter.
|
||||
* **`references/sitemap-schema.md`** — full field-level spec for `SITE.md / pages/<id>.md / workflows/<id>.md / apis.md / pitfalls.md`, action `state_signature` for re-entry, `adapter_health` enum, stable-id matching across overlay layers, draft placement rule, Phase 2 validation hooks.
|
||||
* **Twitter + HackerNews v1.1 seeds** under `sitemaps/{twitter,hackernews}/` validating the schema on dense React UI and simple SSR HTML respectively.
|
||||
|
||||
### Features
|
||||
|
||||
* **smart-search** — restored as a skill (`skills/smart-search/`) with per-category source guides (AI / info / media / shopping / social / tech / travel / other).
|
||||
* **twitter** — batch follow + list lifecycle (`list-create` / `list-delete` / `list-add` / `list-remove` batch forms).
|
||||
* **xiaohongshu** — draft management commands (`drafts` / `draft-open` / `draft-delete` / `draft-clear`).
|
||||
* **chatgpt-app** — temporary chat + multi-modal image attachment support.
|
||||
* **antigravity** — history mgmt (`history` / `delete` / `mark-read`) and model read/switch commands.
|
||||
* **codex** — conversation management (`pin` / `unpin` / `archive` / `rename`) plus model selector fix.
|
||||
* **grok** — conversation management (`delete` / `pin` / `unpin`) with locale-independent selectors.
|
||||
* **kimi** — new adapter for `kimi.com` (21 commands).
|
||||
* **qoder** — new adapter for Qoder IDE (19 commands).
|
||||
* **trae-cn** — new desktop adapter (Trae CN Electron app).
|
||||
* **trae-solo** — new desktop adapter (Trae SOLO Electron app).
|
||||
* **chatgpt** — add web model switch command.
|
||||
* **douyin** — add `search` command for keyword video search.
|
||||
* **wechat-channels** — add WeChat Video Channels (视频号) publish adapter.
|
||||
* **pubmed** — add workflow presets and richer article metadata.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.18** — scope reusable-tab selection to owned-group members (follow-up to the v1.0.17 owned-container convergence model; ensures `findReusableOwnedContainerTab` does not pick up user tabs that were dragged into the owned window).
|
||||
* **chatgpt** — ignore image placeholders and upload previews when extracting the latest assistant message.
|
||||
* **xiaohongshu** — attach real topics via inline dropdown; feed returns signed note URLs for drill-down; carousel order preserved on download.
|
||||
* **twitter** — drop global tweetPhoto selector from the post-submit poll to avoid matching the wrong button.
|
||||
* **grok** — fall back to `Enter` key dispatch when send button is hidden behind layout shifts.
|
||||
* **daemon** — differentiate multi-profile status output so multiple Chrome profiles do not collapse into a single status row.
|
||||
* **youtube** — Videos tab fallback now supports `lockupViewModel` format alongside the legacy `gridVideoRenderer`.
|
||||
* **12306** — accept lowercase letters in `train_no` regex.
|
||||
* **weixin** — strip typographic quotes from pasted URLs.
|
||||
* **launcher** — Chromium 142+ CDP websocket origin check needs `--remote-allow-origins=*`.
|
||||
* **douyin/publish** — handle illegal-title errors with a typed error rather than a silent retry.
|
||||
|
||||
### Docs
|
||||
|
||||
* **opencli-adapter-author** — add `references/strategy-selection.md` codifying the empirical contract ladder (PUBLIC_API / COOKIE_API / UI_SELECTOR / DOM_STATE as contracted vs PAGE_FETCH / INTERCEPT as internal-unstable, with fixes/adapter-year data from a 837-adapter / 30-day window) and update SKILL.md to require a `strategy` evidence block at the top of every new adapter.
|
||||
* **opencli-adapter-author** — `browser analyze` upgrade: each candidate API gets `real_data_score` and a `likely_data` / `maybe_data` / `noise` verdict so Pattern A is no longer fired by analytics XHRs.
|
||||
* **readme** — prefix "Let AI Agents operate any website" bullet with "Browser User &" in both EN and zh-CN.
|
||||
|
||||
## [1.8.1](https://github.com/jackwener/opencli/compare/v1.8.0...v1.8.1) (2026-05-31)
|
||||
|
||||
Patch release focused on the extension tab-group convergence fix, plus 10 new adapters/commands and a wave of read-path / security hardening across browser, download, and adapters.
|
||||
|
||||
### Features
|
||||
|
||||
* **chess** — add Chess.com browser adapter.
|
||||
* **geogebra** — add GeoGebra browser adapter suite.
|
||||
* **jira / confluence** — add Atlassian Jira and Confluence adapter support. ([#1690](https://github.com/jackwener/opencli/pull/1690))
|
||||
* **upwork** — add `search`, `feed`, and `detail` commands.
|
||||
* **notebooklm** — add guarded write commands.
|
||||
* **bilibili** — add comment commands.
|
||||
* **weread** — add book search inside an open WeRead book.
|
||||
* **linkedin** — consolidate read commands and add `profile-experience`.
|
||||
* **xiaohongshu** — paginate `creator-notes` past the analyze list cap.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.16** — ship the `OpenCLI Browser` / `OpenCLI Adapter` tab-group race fix from [#1693](https://github.com/jackwener/opencli/pull/1693). The extension now serializes owned tab-group creation per role so concurrent adapter/browser leases reuse the same group instead of creating duplicate same-title groups.
|
||||
* **extension 1.0.17** — replace owned tab-group management with a Chrome-state-as-truth convergence model. The extension now keeps one canonical `OpenCLI Browser` / `OpenCLI Adapter` group per profile role, recovers renamed groups from stored hints or owned lease tabs, merges same-window and cross-window duplicates into the canonical group, and normalizes legacy or user-renamed container titles back to the canonical owned-container title. ⚠️ User-renamed `OpenCLI Browser` / `OpenCLI Adapter` groups are now force-renamed back; treat these as extension-managed automation containers, not user free-form bins. ([#1794](https://github.com/jackwener/opencli/pull/1794))
|
||||
* **browser** — write the network response cache file with `0o600` owner-only permissions to keep captured response bodies out of other local users' reach.
|
||||
* **download** — write the yt-dlp cookie file with `0o600` owner-only permissions.
|
||||
* **pixiv** — migrate `user/detail` to the shared `pixivFetch` helper.
|
||||
* **twitter** — drop unknown silent sentinels; read profile `name` / `created_at` from `result.core`; handle `NotAllowed` image-upload fallback; detect private `likes` / `following` empty-timeline shape. ([#1702](https://github.com/jackwener/opencli/pull/1702))
|
||||
* **weread** — decode HTML entities in search results.
|
||||
* **zhihu** — decode numeric HTML entities in text output. ([#1695](https://github.com/jackwener/opencli/pull/1695))
|
||||
* **xiaohongshu** — hook dashboard fetch to capture signed `datacenter/note/*` responses ([#1732](https://github.com/jackwener/opencli/pull/1732)); preserve carousel order via `__INITIAL_STATE__.imageList` on download ([#1687](https://github.com/jackwener/opencli/pull/1687)).
|
||||
* **bilibili** — subtitle support for bangumi / PGC bvid (番剧 / 纪录片 / 电影 / 综艺). ([#1669](https://github.com/jackwener/opencli/pull/1669))
|
||||
* **suno** — derive current plan from subscription metadata.
|
||||
* **douyin/hashtag** — validate action args before navigation.
|
||||
* **byte-formatting** — stabilize byte formatting output.
|
||||
|
||||
### Docs
|
||||
|
||||
* **readme** — correct Node floor (>=20, not 21) and drop the Prerequisites section ([#1705](https://github.com/jackwener/opencli/pull/1705)); add CLI Hub brand aliases and split Exit Codes into the dedicated docs page ([#1685](https://github.com/jackwener/opencli/pull/1685)); drop the For Developers section ([#1684](https://github.com/jackwener/opencli/pull/1684)).
|
||||
|
||||
### Internal
|
||||
|
||||
* **ci** — disable Dependabot automated updates.
|
||||
* **test(download)** — retry media-download Windows tests to absorb runner cold-start variance. ([#1708](https://github.com/jackwener/opencli/pull/1708))
|
||||
|
||||
## [1.8.0](https://github.com/jackwener/opencli/compare/v1.7.22...v1.8.0) (2026-05-20)
|
||||
|
||||
Substantial release: a new official-API adapter (`weread-official`), wider LinkedIn / Twitter / Reddit / Zhihu coverage, the 12306 / Suno / Xianyu inbox additions, security and reliability fixes for the Browser Bridge and media downloads, plus a 20% README shrink. Node 20 compatibility is restored after an automated `undici` bump regression.
|
||||
|
||||
### Features
|
||||
|
||||
* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter.
|
||||
* **12306** — add full read adapter (`stations` / `trains` / `train` / `price` / `me` / `passengers` / `orders`). ([#1637](https://github.com/jackwener/opencli/issues/1637))
|
||||
* **xianyu** — add `inbox`, `messages`, and `reply` commands. ([#1639](https://github.com/jackwener/opencli/issues/1639))
|
||||
* **suno** — add Suno.com music-generation adapter. ([#1638](https://github.com/jackwener/opencli/issues/1638))
|
||||
* **linkedin** — consolidate messaging and Sales Navigator commands (`connect`, `inbox`, `safe-send`, `salesnav-search`, `salesnav-inbox`, `salesnav-message`, `salesnav-thread`, `sent-invitations`, `thread-snapshot`, `timeline`). ([#1647](https://github.com/jackwener/opencli/issues/1647))
|
||||
* **linkedin/people-search** — add a dedicated people-search command. ([#1649](https://github.com/jackwener/opencli/issues/1649))
|
||||
* **linkedin-learning** — add `search` / `trending` / `course` read commands. ([#1657](https://github.com/jackwener/opencli/issues/1657))
|
||||
* **twitter** — rewrite the download-profile path on GraphQL UserMedia with cursor pagination. ([#1636](https://github.com/jackwener/opencli/issues/1636))
|
||||
* **twitter** — add `list-create` (GraphQL CreateList mutation). ([#1656](https://github.com/jackwener/opencli/issues/1656))
|
||||
* **twitter** — add `device-follow` notification-stream command.
|
||||
* **twitter** — expose `card.binding_values` on read commands for inline link-preview metadata. ([#1660](https://github.com/jackwener/opencli/issues/1660))
|
||||
* **twitter** — expose `quoted_tweet` on read commands. ([#1667](https://github.com/jackwener/opencli/issues/1667))
|
||||
* **twitter** — expose `bio` on read commands.
|
||||
* **reddit/subscribed** — new `subscribed` command + listing-level `id` / `created_utc` / `selftext` exposure. ([#1651](https://github.com/jackwener/opencli/issues/1651))
|
||||
* **reddit** — expose `post_hint` / `url` / `preview` / `gallery` media routes on listing commands. ([#1676](https://github.com/jackwener/opencli/issues/1676))
|
||||
* **zhihu** — add answer-comments reader; include answer links in question results.
|
||||
* **chatgpt** — detect generated image surfaces (CSS background and canvas, not just `<img>`) so image generation works after UI drift. ([#1677](https://github.com/jackwener/opencli/issues/1677))
|
||||
* **external** — add Cloudflare Wrangler as a built-in external CLI passthrough. ([#1679](https://github.com/jackwener/opencli/pull/1679))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps** — restore Node 20 runtime compatibility by pinning runtime `undici` back to the 6.x line (an automated dependabot bump to 8.x had moved the engines floor to Node ≥22.19, silently breaking the published Node 20 promise), and clear the docs build audit chain by overriding VitePress' Vite/PostCSS transitive dependencies to patched versions. ([#1673](https://github.com/jackwener/opencli/issues/1673))
|
||||
* **download** — keep custom media filenames inside the requested output directory by stripping POSIX/Windows path components and sanitizing the generated fallback prefix. Prevents remote-controlled fields (e.g. video titles used as filename) from escaping the output directory via `../`. ([#1642](https://github.com/jackwener/opencli/pull/1642))
|
||||
* **browser** — recover `Page.goto()` from stale page identities by clearing the cached targetId and retrying navigation once through the session lease; classify CDP `-32000 Cannot find default execution context` as retryable target navigation. ([#1645](https://github.com/jackwener/opencli/issues/1645))
|
||||
* **cli** — escape leading-dash positional values via the argv preprocessor so users can pass tokens starting with `-` without commander mis-classifying them as flags. ([#1658](https://github.com/jackwener/opencli/issues/1658))
|
||||
* **chatgpt/image** — fix ChatGPT web image generation after UI drift by letting the composer locator continue into the caller's readiness check and detecting generated images rendered as CSS backgrounds or canvases, not just plain `<img>` elements.
|
||||
* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors (Douyin user video comments, Jike SSR JSON parse, WeRead search-page fetch). True empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`.
|
||||
* **adapters** — drop silent-sentinel row fallbacks across Apple Podcasts / Reddit / Gitee. ([#1634](https://github.com/jackwener/opencli/issues/1634))
|
||||
* **adapters** — migrate legal empty-data branches to `EmptyResultError` for `xhs` / YouTube and 5 follow-up commands. ([#1674](https://github.com/jackwener/opencli/issues/1674), [#1678](https://github.com/jackwener/opencli/issues/1678))
|
||||
* **lesswrong** — drop the `"Unknown"` silent sentinel in the author column; missing authors now propagate as `null`. ([#1611](https://github.com/jackwener/opencli/issues/1611))
|
||||
* **youtube/transcript** — scope timedtext URL matching to the current `videoId` across the in-page resource-buffer scan, the in-page fetch/XHR hook, and the Node-side CDP capture. SPA-style watch→watch navigation no longer returns a predecessor video's captions. ([#1655](https://github.com/jackwener/opencli/issues/1655))
|
||||
* **twitter/lists** — skip the "Discover new Lists" recommendation block so it is no longer treated as one of the user's lists. ([#1652](https://github.com/jackwener/opencli/issues/1652))
|
||||
* **zhihu** — harden search pagination. ([#1615](https://github.com/jackwener/opencli/issues/1615))
|
||||
* **zhihu** — decode numeric HTML entities in `answer-detail`. ([#1629](https://github.com/jackwener/opencli/issues/1629))
|
||||
|
||||
### Docs
|
||||
|
||||
* **readme** — major shrink and reframing: tagline rephrased around "Browser Use", Highlights and Update sections folded into adjacent content, Built-in Commands curated to 11 popular sites, CLI Hub table reduced to a name enumeration, Desktop App Adapters collapsed to a one-liner, skill-attribution references audited against `SKILL.md` frontmatter, "For AI Agents (Developer Guide)" merged into "Writing a new adapter". Net: EN 410 → 326 (-20%), ZH 455 → 371 (-18%). ([#1654](https://github.com/jackwener/opencli/pull/1654), [#1666](https://github.com/jackwener/opencli/pull/1666), [#1679](https://github.com/jackwener/opencli/pull/1679), [#1681](https://github.com/jackwener/opencli/pull/1681))
|
||||
|
||||
### Internal
|
||||
|
||||
* **audit** — stop flagging sentinel fallback strings inside thrown error messages as `silent-sentinel` violations. These are typed failure diagnostics rather than fake row data, reducing the typed-error baseline to actual adapter output fallbacks.
|
||||
|
||||
## [1.7.22](https://github.com/jackwener/opencli/compare/v1.7.21...v1.7.22) (2026-05-15)
|
||||
|
||||
External CLI ergonomics + two adapter envelope/auth fixes. New `longbridge` external CLI entry; `opencli list` / root help now render human-readable brand labels for executables whose bare name is ambiguous.
|
||||
|
||||
### Features
|
||||
|
||||
* **external** — add the Longbridge CLI as a built-in external CLI passthrough (`opencli longbridge ...`) for Longbridge OpenAPI market data, account, and trading commands. ([#1584](https://github.com/jackwener/opencli/issues/1584))
|
||||
* **external-cli** — render brand alias `name(package)` in `opencli list` and root help when the bare executable name is ambiguous. Built-in entries `ntn` → `ntn(notion)`, `dws` → `dws(DingTalk Workspace)`, `wecom-cli` → `wecom-cli(企业微信)` now self-explain in help output. `package` field is repurposed to cover both upstream distribution names (e.g. `tg-cli`) and human-readable brand labels (e.g. `notion`, `企业微信`). ([#1585](https://github.com/jackwener/opencli/issues/1585))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **boss** — map `code=24` (identity mismatch) to `AuthRequiredError` so re-login is signaled instead of surfacing as a generic API error. ([#1573](https://github.com/jackwener/opencli/issues/1573))
|
||||
* **weibo** — unwrap Browser Bridge `page.evaluate` envelopes in read adapters. ([#1568](https://github.com/jackwener/opencli/issues/1568))
|
||||
|
||||
## [1.7.21](https://github.com/jackwener/opencli/compare/v1.7.20...v1.7.21) (2026-05-14)
|
||||
|
||||
Adapter polish release: new web search adapters, better Browser Bridge tab group reuse, and social adapters returning to one-shot tab leases. Extension package version is bumped to 1.0.15 for the Browser Bridge fix.
|
||||
|
||||
### Features
|
||||
|
||||
* **search** — add DuckDuckGo, Brave, and Yahoo web search adapters. ([#1546](https://github.com/jackwener/opencli/issues/1546))
|
||||
* **boss** — support job-seeker `chatlist` and `chatmsg` adapters. ([#1539](https://github.com/jackwener/opencli/issues/1539))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension** — reuse existing `OpenCLI Adapter` tab groups before creating new ones, including cross-window discovery, legacy `OpenCLI` title fallback, and deterministic candidate selection. ([#1541](https://github.com/jackwener/opencli/issues/1541))
|
||||
* **twitter, reddit** — default browser-backed social adapters back to ephemeral tab leases. Twitter/X and Reddit commands now release their site tab after each run while keeping the shared Adapter window available for reuse; persistent sessions remain reserved for AI/chat-style adapters that need long-lived conversation state. ([#1569](https://github.com/jackwener/opencli/issues/1569))
|
||||
* **xiaohongshu, rednote** — unwrap Browser Bridge `page.evaluate` envelopes in search adapters. ([#1561](https://github.com/jackwener/opencli/issues/1561))
|
||||
* **facebook/feed** — add fallback extraction for empty article nodes. ([#1538](https://github.com/jackwener/opencli/issues/1538))
|
||||
|
||||
### Internal
|
||||
|
||||
* **ci** — add Windows native binding lockfile entries for Rolldown/Rollup optional packages. ([#1563](https://github.com/jackwener/opencli/issues/1563))
|
||||
* **extension** — add regression coverage for the adapter tab group `groupId` tiebreaker. ([#1566](https://github.com/jackwener/opencli/issues/1566))
|
||||
|
||||
## [1.7.20](https://github.com/jackwener/opencli/compare/v1.7.19...v1.7.20) (2026-05-14)
|
||||
|
||||
External CLI surface cleanup + Browser Bridge WebSocket lifecycle hardening. Two BREAKING changes around external CLIs: built-in `tg`/`discord`/`wx` (was `tg-cli`/`discord-cli`/`wx-cli`) now match their real binary names, and Notion's in-tree CDP adapter is replaced by the official `ntn` external CLI.
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **browser** — replace the `--session <name>` flag with a `<session>` positional argument that immediately follows `browser`. `opencli browser work click 12` instead of `opencli browser --session work click 12`; `opencli browser work bind` instead of `opencli browser bind --session work`. Required-flag semantics are now encoded structurally as a positional, matching the Docker/git convention for required operation-target identifiers. The internal `--session` flag is preserved for the daemon protocol and for direct `program.parseAsync` callers but is no longer part of the user-facing surface.
|
||||
* **notion** — remove the in-tree `clis/notion/` CDP-on-Desktop adapter (8 commands: `status` / `search` / `read` / `new` / `write` / `sidebar` / `favorites` / `export`). Notion has shipped an official CLI at <https://ntn.dev>, registered as a first-class external CLI in `external-clis.yaml`. Migration: install `ntn` from <https://ntn.dev> (`curl -fsSL https://ntn.dev | bash`), then use `opencli ntn <command>`. Auto-install is intentionally not configured because the official installer is a shell script while OpenCLI external installs run shell-free command strings. The official CLI uses the public Notion API rather than reverse-engineering the Desktop UI, so it survives Notion app updates and exposes a wider command surface (blocks / databases / properties / comments) than the reverse-engineered adapter could. ([#1559](https://github.com/jackwener/opencli/issues/1559))
|
||||
* **external** — drop the `-cli` suffix from built-in external CLI subcommand names. `opencli tg-cli`, `opencli discord-cli`, `opencli wx-cli` are now `opencli tg`, `opencli discord`, `opencli wx`, matching the real binary names that those tools install as. Root help still shows the package lineage as `tg(tg-cli)` / `discord(discord-cli)` / `wx(wx-cli)`. ([#1544](https://github.com/jackwener/opencli/issues/1544))
|
||||
|
||||
### Features
|
||||
|
||||
* **twitter** — `bookmarks` and `bookmark-folder` now include media via `extractMedia`, reaching parity with `timeline` / `search`. ([#1555](https://github.com/jackwener/opencli/issues/1555))
|
||||
* **twitter/list-tweets** — include media via `extractMedia` (parity with `timeline` / `search`). ([#1464](https://github.com/jackwener/opencli/issues/1464))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **daemon** — report ambiguous browser command outcomes with a distinct `command_result_unknown` errorCode and `503` when the extension WebSocket drops between command dispatch and result delivery. `sendCommandRaw()` treats this code as hard non-retryable, so write-side commands (`navigate` / `click` / `type` / `eval`) won't be silently re-issued and double-executed. Daemon exposes a `commandResultUnknown` counter on `/status` for future observability. ([#1558](https://github.com/jackwener/opencli/issues/1558))
|
||||
* **extension** — keep active daemon WebSocket; stale sockets no longer clobber active connection (`onopen` / `onclose` / `onmessage` are all gated by `ws !== thisWs` short-circuit), and `safeSend` only fires when `readyState === OPEN`. ([#1540](https://github.com/jackwener/opencli/issues/1540))
|
||||
* **extension** — coalesce concurrent daemon WebSocket connects via an in-flight promise. Startup / keepalive / reconnect triggering `connect()` during the daemon-probe or context-lookup async gap no longer creates duplicate real WebSocket connections. ([#1554](https://github.com/jackwener/opencli/issues/1554))
|
||||
* **external** — distinguish external CLI executable names from distribution/project names in root help. Built-in aliases such as `tg`, `discord`, `wx` remain the callable `opencli <name> ...` entrypoints while help renders `tg(tg-cli)`, `discord(discord-cli)`, `wx(wx-cli)` to show their package lineage. ([#1560](https://github.com/jackwener/opencli/issues/1560))
|
||||
|
||||
### Docs
|
||||
|
||||
* **browser** — clarify named session lifecycle in the Browser Bridge guide. ([#1542](https://github.com/jackwener/opencli/issues/1542))
|
||||
|
||||
## [1.7.19](https://github.com/jackwener/opencli/compare/v1.7.18...v1.7.19) (2026-05-14)
|
||||
|
||||
Major hotfix + simplification batch. Extension bumped to 1.0.14. Node floor lowered to v20 so the long tail of Node v20–v21.6 users no longer crashes at module load. `opencli browser` user surface replaces required-flag `--session <name>` with a `<session>` positional. `page.evaluate(fn, ...args)` adds a type-safe alternative to the implicit auto-IIFE string form. Twitter cursor pagination no longer silently caps at ~500 items.
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **browser** — replace the `--session <name>` flag with a `<session>` positional argument that immediately follows `browser`. `opencli browser work click 12` instead of `opencli browser --session work click 12`; `opencli browser work bind` instead of `opencli browser bind --session work`. Required-flag semantics are now encoded structurally as a positional, matching the Docker/git convention for required operation-target identifiers. The internal `--session` flag is preserved for the daemon protocol and for direct `program.parseAsync` callers but is no longer part of the user-facing surface. ([#1505](https://github.com/jackwener/opencli/issues/1505))
|
||||
* **env** — remove `OPENCLI_KEEP_TAB`. The flag was a debugging shortcut, not a config dimension: `--keep-tab true|false` on the command line is the single source of truth, and adapter `siteSession: 'persistent'` already pins persistent site tabs as a hard constraint. Removing the env eliminates a globally-leaking process state that overrode every browser command in the shell. ([#1509](https://github.com/jackwener/opencli/issues/1509))
|
||||
* **extension** — remove the internal `surface\\0session` command-session backdoor. Browser Bridge commands now route only through structured `session` + `surface` fields; lease-key strings remain an extension-internal registry detail. ([#1510](https://github.com/jackwener/opencli/issues/1510))
|
||||
|
||||
### Features
|
||||
|
||||
* **browser** — add `page.evaluate(fn, ...args)` for type-safe browser-context evaluation with JSON-serialized arguments. String evaluation remains supported, but new adapter code should use function form to avoid implicit `wrapForEval` auto-IIFE magic. ([#1508](https://github.com/jackwener/opencli/issues/1508))
|
||||
* **twitter** — default `tweets` command to the logged-in user when `user` is omitted, and fix the sibling envelope-unwrap silent bug. ([#1531](https://github.com/jackwener/opencli/issues/1531))
|
||||
* **zhihu** — add `answer-detail` to fetch a single answer's full content. ([#1528](https://github.com/jackwener/opencli/issues/1528))
|
||||
* **zhihu** — paginate question answers and recommendations. ([#1517](https://github.com/jackwener/opencli/issues/1517))
|
||||
* **reddit/read** — `--expand-more` via `/api/morechildren` + 7-kind typed errors. ([#1492](https://github.com/jackwener/opencli/issues/1492))
|
||||
* **reddit** — add `whoami`, `home`, `subreddit-info` read commands. ([#1491](https://github.com/jackwener/opencli/issues/1491))
|
||||
* **ctrip** — add `hotel-search` + flight browser-mode commands. ([#1489](https://github.com/jackwener/opencli/issues/1489))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **browser** — `page.evaluate()` / `evaluateInFrame()` now return the user JavaScript value directly. Browser Bridge `exec` previously routed through a shared `pageScopedResult` helper that spread / wrapped the lease's `session` into the result `data`, contaminating arbitrary user returns: array / primitive returns came back as `{ session, data }` envelopes, and plain-object returns had an extra `session` key injected (overwriting any user `session` field). `google search` and `xiaohongshu search` were the visible repro — Chrome rendered results correctly but adapters extracted an empty array. Fixed in extension 1.0.14 by reverting `pageScopedResult` to its pre-1461 form (`{ id, ok, data, page }`); no client-side unwrap is needed. ([#1518](https://github.com/jackwener/opencli/issues/1518))
|
||||
* **twitter** — raise fixed cursor-pagination caps in `bookmarks` / `likes` / `tweets` / `timeline` / `bookmark-folder` / `list-tweets` / `search` / `following`. The old `i < 5` / `i < 10` literals and following's `Math.ceil(limit / 50) + 2` formula imposed hidden result ceilings below `--limit`; the loop now treats the page count as a high runaway guard while `--limit` and cursor exhaustion control normal pagination. ([#1532](https://github.com/jackwener/opencli/issues/1532))
|
||||
* **twitter** — repair `list-add` / `list-tweets` / `lists` / `following` after 2026-05 site changes. ([#1503](https://github.com/jackwener/opencli/issues/1503))
|
||||
* **twitter** — repair `search` and `tweets` readback. ([#1512](https://github.com/jackwener/opencli/issues/1512))
|
||||
* **twitter** — make reply submission robust. ([#1511](https://github.com/jackwener/opencli/issues/1511))
|
||||
* **google/search** — wait for `#rso a h3` before extracting, falling back to the existing fixed wait. On Chrome 148 + Linux Wayland the DOM can settle before SERP anchors are populated, making extraction return empty even with the envelope bug fixed. ([#1518](https://github.com/jackwener/opencli/issues/1518))
|
||||
* **google/search** — wrap evaluate return value in object to fix serialization. ([#1523](https://github.com/jackwener/opencli/issues/1523))
|
||||
* **google-scholar/search** — wrap evaluate return to fix serialization. ([#1525](https://github.com/jackwener/opencli/issues/1525))
|
||||
* **xiaohongshu/search** — extract initially visible cards before scrolling, then merge post-scroll rows by URL. Xiaohongshu's virtualized masonry layout can evict the initial cards from the DOM after scroll, so the previous always-scroll-then-extract flow could lose the top results. ([#1518](https://github.com/jackwener/opencli/issues/1518))
|
||||
* **xiaohongshu** — `parseLikes` handles `2.1w` / `1.5万` / `1.2k` shortforms. ([#1504](https://github.com/jackwener/opencli/issues/1504))
|
||||
* **xiaohongshu+rednote/search** — fall back to href-based note cards when `section.note-item` class is dropped. ([#1507](https://github.com/jackwener/opencli/issues/1507))
|
||||
* **xueqiu** — `kline` / `earnings-date` format dates in Asia/Shanghai instead of UTC. ([#1498](https://github.com/jackwener/opencli/issues/1498))
|
||||
* **download** — clamp progress percentages. ([#1520](https://github.com/jackwener/opencli/issues/1520))
|
||||
|
||||
### Internal
|
||||
|
||||
* **runtime** — lower the Node floor to `>=20.0.0`. Three coupled changes: drop all `util.styleText()` usage (added in Node v21.7.0 / v20.12.0; previously crashed v21.0–v21.6 at module load), downgrade `undici` from `^8.0.2` (engines `>=22.19.0`) to `^6.25.0` (engines `>=18.17`, retains `Agent` / `EnvHttpProxyAgent` / `fetch`), and lower `MIN_SUPPORTED_NODE_MAJOR` from 21 to 20 so the startup guard matches the declared `engines.node`. Smoke-tested on v20.0.0 / v21.2.0 / v22.22.2. The semantic markers (`[OK]` / `[WARN]` / `[FAIL]` / `ℹ` / `⚠` / `✖`) keep their meaning; ANSI colors were redundant for the primarily agent-facing CLI. ([#1524](https://github.com/jackwener/opencli/issues/1524))
|
||||
* **extension 1.0.14** — `pageScopedResult` no longer injects `session` into `data`. The field had no consumers and contaminated `exec` results with arbitrary user-JS shapes; routing-relevant identity is already exposed via `Result.page`. ([#1518](https://github.com/jackwener/opencli/issues/1518))
|
||||
* **extension 1.0.13** — remove the internal command-session lease-key backdoor. ([#1510](https://github.com/jackwener/opencli/issues/1510))
|
||||
* **ci** — drop `e2e-headed` and `adapter-test` from `pull_request` triggers (kept on `push` to main / nightly / `workflow_dispatch`). PR-time CI now targets ~2 min wall-time. ([#1521](https://github.com/jackwener/opencli/issues/1521), [#1522](https://github.com/jackwener/opencli/issues/1522))
|
||||
* **scripts** — auto-refresh `dist/` before `build-manifest`. ([#1490](https://github.com/jackwener/opencli/issues/1490))
|
||||
|
||||
## [1.7.18](https://github.com/jackwener/opencli/compare/v1.7.17...v1.7.18) (2026-05-12)
|
||||
|
||||
@@ -119,6 +411,10 @@ Extension bumped to 1.0.9 (Accessibility.enable allowlist + downloads permission
|
||||
|
||||
Extension bumped to 1.0.6 (screenshot `--width` / `--height` / `--full-page` flags, automation tab group color marker, automation container reuse fix).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **xiaohongshu** — fix `publish --topics` leaving bare `#` characters with no linked topics. The adapter now types `#keyword` into the body editor to trigger the inline suggestion dropdown and selects the matching topic, matching the current creator-center UI.
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **linux-do** — remove deprecated compatibility shims `linux-do hot`, `linux-do category`, `linux-do latest`. Use `linux-do feed --view top --period <period>`, `linux-do feed --category <id-or-name>`, and `linux-do feed --view latest` instead.
|
||||
|
||||
+7
-8
@@ -40,6 +40,7 @@ cli({
|
||||
description: 'Trending posts on MySite',
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
access: 'read',
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
|
||||
@@ -84,14 +85,12 @@ cli({
|
||||
const { query, limit = 10 } = kwargs;
|
||||
await page.goto('https://www.mysite.com');
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const res = await fetch('/api/search?q=${encodeURIComponent(query)}', {
|
||||
credentials: 'include'
|
||||
});
|
||||
return (await res.json()).results;
|
||||
})()
|
||||
`);
|
||||
const data = await page.evaluate(async (q: string) => {
|
||||
const res = await fetch('/api/search?q=' + encodeURIComponent(q), {
|
||||
credentials: 'include'
|
||||
});
|
||||
return (await res.json()).results;
|
||||
}, query);
|
||||
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.**
|
||||
> Reuse your logged-in browser, automate live workflows, and crystallize repeated actions into reusable CLI commands.
|
||||
> **Convert any website into a CLI & run Browser Use on your logged-in Chrome.**
|
||||
> Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.
|
||||
> Or run Browser Use against any page — navigate, fill forms, click, extract, automate.
|
||||
|
||||
[](./README.zh-CN.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
@@ -11,30 +12,26 @@
|
||||
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).
|
||||
- **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/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
|
||||
- **Let AI Agents operate any website** — install the `opencli-browser` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, 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`, `tg-cli`, `discord-cli`, `wx-cli`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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/fill, 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.
|
||||
- **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, tg-cli, discord-cli, wx-cli, 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.
|
||||
|
||||
---
|
||||
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Trae CN, Codex, Antigravity, ChatGPT, and Trae SOLO.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install OpenCLI
|
||||
|
||||
OpenCLI requires **Node.js >= 21**.
|
||||
For desktop use, start with **OpenCLIApp**. It bundles the OpenCLI runtime,
|
||||
keeps the managed `opencli` command installed, and gives you a system tray UI
|
||||
for setup, diagnostics, updates, browser-login keepalive, and Web → Markdown.
|
||||
|
||||
**Option A — OpenCLIApp (recommended for macOS / Windows):**
|
||||
Download the latest app from <https://opencli.info/download>, install it, then
|
||||
open the app once and use the System page to install or repair the `opencli`
|
||||
command.
|
||||
|
||||
**Option B — npm global install (CLI-only / CI / servers):**
|
||||
OpenCLI requires **Node.js >= 20** when installed through npm.
|
||||
|
||||
```bash
|
||||
node --version
|
||||
@@ -67,7 +64,7 @@ Each Chrome profile runs its own OpenCLI extension instance. If you use multiple
|
||||
opencli profile list
|
||||
opencli profile rename <contextId> work
|
||||
opencli profile use work
|
||||
opencli --profile work browser state
|
||||
opencli --profile work browser main 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.
|
||||
@@ -105,7 +102,7 @@ If you want to add your own commands, start with the [Extending OpenCLI guide](.
|
||||
|
||||
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.
|
||||
|
||||
### Install skills
|
||||
### Install skills (also refreshes existing installs)
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli
|
||||
@@ -117,23 +114,25 @@ Or install only what you need:
|
||||
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-browser-sitemap
|
||||
npx skills add jackwener/opencli --skill opencli-sitemap-author
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
### Which skill to use
|
||||
|
||||
| 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-adapter-author** | Write a reusable adapter for a new site or add a command to an existing site | "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-browser** | Drive a real Chrome page ad-hoc — navigate, fill forms, click, extract | "Help me check my Xiaohongshu notifications" / "Help me fill out this form" / "Use browser commands to scrape this page" |
|
||||
| **opencli-browser-sitemap** | Consume site sitemap context while driving a browser task | "Use the sitemap to navigate this website without blind clicking" |
|
||||
| **opencli-sitemap-author** | Create or update site sitemap knowledge for browser agents | "Record the stable workflow you just discovered for this site" |
|
||||
| **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" |
|
||||
|
||||
### How it works
|
||||
|
||||
Once `opencli-adapter-author` is installed, your AI agent can:
|
||||
Once `opencli-browser` 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)
|
||||
@@ -144,63 +143,35 @@ Once `opencli-adapter-author` is installed, your AI agent can:
|
||||
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-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — drive Chrome ad-hoc (navigate, fill forms, click, extract)
|
||||
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — use sitemap context while driving a browser task
|
||||
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — create or update site sitemap knowledge
|
||||
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — write a new adapter 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`, `fill`, `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` commands require a `<session>` positional immediately after `browser`. `opencli browser work open <url>` and `opencli browser work tab new [url]` both return a target ID. Use `opencli browser work tab list` to inspect target IDs, 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 commands in the same session.
|
||||
|
||||
## Core Concepts
|
||||
## Writing a new adapter
|
||||
|
||||
### `browser`: AI Agent browser control
|
||||
When the site you need is not yet covered, use the `opencli-adapter-author` skill end-to-end:
|
||||
|
||||
`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 <session> 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. These are deterministic and work without browser — ideal for both humans and AI agents.
|
||||
|
||||
### Writing a new adapter
|
||||
|
||||
When the site you need is not yet covered, use the `opencli-adapter-author` skill. It takes the agent end-to-end:
|
||||
|
||||
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` / `INTERCEPT` / `UI` / `LOCAL`.
|
||||
4. Decode response fields and design output columns.
|
||||
5. `opencli browser recon analyze <url>` for one-shot recon, then `opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon 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
|
||||
|
||||
OpenCLI is not only for websites. It can also:
|
||||
|
||||
- expose local binaries like `gh`, `docker`, `obsidian`, `tg-cli`, `discord-cli`, `wx-cli`, or custom tools through `opencli <tool> ...`
|
||||
- control Electron desktop apps through dedicated adapters and CDP-backed integrations
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **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.
|
||||
1. **Recon** the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
2. **Discover** the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
|
||||
3. **Pick auth** — `PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`.
|
||||
4. **Decode** response fields and design output columns.
|
||||
5. `opencli browser recon analyze <url>` → `opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon verify <site>/<name>`.
|
||||
6. Site knowledge persists to `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge |
|
||||
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
|
||||
| `OPENCLI_WINDOW` | command default | Set to `foreground` or `background` to override Browser Bridge window placement. Browser-backed commands also accept `--window <foreground\|background>`. |
|
||||
| `OPENCLI_KEEP_TAB` | command default | Set to `true` or `false` to keep or release the browser tab lease after a browser-backed adapter command. Browser-backed adapter commands also accept `--keep-tab <true\|false>`. |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `45` | Seconds to wait for browser connection |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
|
||||
| `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`) |
|
||||
@@ -209,118 +180,39 @@ OpenCLI is not only for websites. It can also:
|
||||
|
||||
`opencli browser *` requires an explicit `<session>` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser <session> close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab.
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli@latest
|
||||
|
||||
# If you use the packaged OpenCLI skills, refresh them too
|
||||
npx skills add jackwener/opencli
|
||||
```
|
||||
|
||||
Or refresh only the skills you actually use:
|
||||
|
||||
```bash
|
||||
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-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
## For Developers
|
||||
|
||||
Install from source:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:jackwener/opencli.git
|
||||
cd opencli
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
To load the source Browser Bridge extension:
|
||||
|
||||
1. Open `chrome://extensions` and enable **Developer mode**.
|
||||
2. Click **Load unpacked** and select this repository's `extension/` directory.
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
||||
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` |
|
||||
| **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` `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` |
|
||||
| **xiaohongshu** | `search` `ask` `note` `comments` `feed` `user` `download` `publish` `follow` `unfollow` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `follow` `unfollow` `me` `subtitle` `summary` `video` `user-videos` |
|
||||
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
|
||||
| **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` `publish` |
|
||||
| **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*` |
|
||||
| **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` |
|
||||
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
|
||||
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
|
||||
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
||||
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
|
||||
| **upwork** | `search` `feed` `detail` |
|
||||
| **slock** | `message-send` `message-read` `message-search` `channel-list` `channel-info` `channel-create` `channel-members` `channel-join` `task-list` `task-create` `task-claim` `task-status` `task-convert` `task-delete` `thread-list` `thread-follow` `attachment-upload` `attachment-download` `bookmark-add` `inbox` `dm-list` `server-list` `server-use` `whoami` |
|
||||
| **huodongxing** | `events` |
|
||||
| **midjourney** | `login` `whoami` `settings` `quota` `generate` `describe` `history` `status` `action` `download` |
|
||||
|
||||
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`.
|
||||
Curated highlights — **[→ see all 100+ supported sites & commands](./docs/adapters/index.md)** (douyin / weibo / spotify / 1688 / quark / nowcoder / google-scholar / hupu / xianyu / weread / weread-official / xiaoyuzhou / Chess.com / and more).
|
||||
|
||||
## CLI Hub
|
||||
|
||||
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
|
||||
Unified passthrough for your existing command-line tools. Run `opencli <tool> ...` for any of:
|
||||
|
||||
| External CLI | Description | Example |
|
||||
|--------------|-------------|---------|
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **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` |
|
||||
| **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"` |
|
||||
| **tg-cli** | Telegram — local-first sync, search, and export via MTProto for AI agents | `opencli tg search "AI news" -f json` |
|
||||
| **discord-cli** | Discord — local-first sync, search, and export via SQLite for AI agents | `opencli discord recent --channel general` |
|
||||
| **wx-cli** | WeChat — query local WeChat data: sessions, messages, search, contacts, export | `opencli wx search "OpenCLI"` |
|
||||
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
|
||||
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
|
||||
|
||||
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
|
||||
Register your own with `opencli external register <name>`; list everything with `opencli external list`.
|
||||
|
||||
```bash
|
||||
opencli external register mycli
|
||||
```
|
||||
|
||||
### Desktop App Adapters
|
||||
|
||||
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
|
||||
|
||||
| App | Description | Doc |
|
||||
|-----|-------------|-----|
|
||||
| **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 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) |
|
||||
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
|
||||
|
||||
To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
|
||||
**Desktop app adapters** (Electron, via CDP): Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/).
|
||||
|
||||
## Download Support
|
||||
|
||||
@@ -366,25 +258,7 @@ opencli bilibili hot -v # Verbose: show pipeline debug steps
|
||||
|
||||
## Exit Codes
|
||||
|
||||
opencli follows Unix `sysexits.h` conventions so it integrates naturally with shell pipelines and CI scripts:
|
||||
|
||||
| Code | Meaning | When |
|
||||
|------|---------|------|
|
||||
| `0` | Success | Command completed normally |
|
||||
| `1` | Generic error | Unexpected / unclassified failure |
|
||||
| `2` | Usage error | Bad arguments or unknown command |
|
||||
| `66` | Empty result | No data returned (`EX_NOINPUT`) |
|
||||
| `69` | Service unavailable | Browser Bridge not connected (`EX_UNAVAILABLE`) |
|
||||
| `75` | Temporary failure | Command timed out — retry (`EX_TEMPFAIL`) |
|
||||
| `77` | Auth required | Not logged in to target site (`EX_NOPERM`) |
|
||||
| `78` | Config error | Missing credentials or bad config (`EX_CONFIG`) |
|
||||
| `130` | Interrupted | Ctrl-C / SIGINT |
|
||||
|
||||
```bash
|
||||
opencli spotify status || echo "exit $?" # 69 if browser not running
|
||||
opencli gh issue list 2>/dev/null
|
||||
[ $? -eq 77 ] && opencli gh auth login # auto-auth if not logged in
|
||||
```
|
||||
opencli follows Unix `sysexits.h` so CI / scripts can branch on failure mode: `0` success, `66` empty result, `69` Browser Bridge down, `75` timeout, `77` auth required, `78` config error, `130` Ctrl-C. Full reference: [docs/guide/exit-codes.md](./docs/guide/exit-codes.md).
|
||||
|
||||
## Plugins
|
||||
|
||||
@@ -403,21 +277,10 @@ opencli plugin uninstall my-tool
|
||||
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | JS | Multi-platform trending aggregator |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | JS | 稀土掘金 (Juejin) hot articles |
|
||||
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | JS | VK (VKontakte) wall, feed, and search |
|
||||
| [opencli-plugin-x-article-publisher](https://github.com/genoooool/opencli-plugin-x-article-publisher) | JS | Publish Markdown with local images as X long-form Articles via OpenCLI and xPoster |
|
||||
|
||||
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
|
||||
|
||||
## For AI Agents (Developer Guide)
|
||||
|
||||
Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end:
|
||||
|
||||
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
- Discover the right endpoint via `opencli browser <session> network`, `eval`, or the interceptor fallback.
|
||||
- Decide auth strategy (`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`).
|
||||
- Run `opencli browser recon analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser recon init`.
|
||||
- Verify with `opencli browser recon verify <site>/<name>` before shipping.
|
||||
|
||||
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
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
@@ -427,7 +290,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
- **"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 / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 21**. Run `node --version`, upgrade Node if needed, then retry.
|
||||
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 20**. 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
|
||||
|
||||
+65
-263
@@ -1,7 +1,8 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。**
|
||||
> 复用浏览器登录态,先自动化真实操作,再把高频流程沉淀成可复用的 CLI 命令。
|
||||
> **把任意网站变成 CLI & 在你的登录态浏览器上跑 Browser Use。**
|
||||
> 把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。
|
||||
> 或者在任意页面上跑 Browser Use —— 导航、填表单、点击、抓取、自动化。
|
||||
|
||||
[](./README.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
@@ -11,27 +12,25 @@
|
||||
OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
|
||||
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
|
||||
- **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。
|
||||
- **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-browser` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。
|
||||
- **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。
|
||||
|
||||
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`tg-cli`、`discord-cli`、`wx-cli` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
|
||||
|
||||
## 亮点
|
||||
|
||||
- **桌面应用控制** — 通过 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、tg-cli、discord-cli、wx-cli 等)。
|
||||
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
|
||||
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
|
||||
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Trae CN、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 OpenCLI
|
||||
|
||||
OpenCLI 要求 **Node.js >= 21**。
|
||||
如果你是在自己的电脑上使用,优先安装 **OpenCLIApp**。它会内置
|
||||
OpenCLI runtime,帮你安装 / 修复受管理的 `opencli` 命令,并提供系统托盘
|
||||
UI 来做环境诊断、更新、浏览器登录态保活和网页转 Markdown。
|
||||
|
||||
**方式 A — OpenCLIApp(macOS / Windows 推荐):**
|
||||
从 <https://opencli.info/download> 下载最新版 App,安装后打开一次,在
|
||||
System 页面安装或修复 `opencli` 命令。
|
||||
|
||||
**方式 B — npm 全局安装(纯 CLI / CI / 服务器):**
|
||||
通过 npm 安装时,OpenCLI 要求 **Node.js >= 20**。
|
||||
|
||||
```bash
|
||||
node --version
|
||||
@@ -89,7 +88,7 @@ opencli bilibili hot --limit 5
|
||||
|
||||
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI Agent(Claude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
|
||||
|
||||
### 安装 skill
|
||||
### 安装 skill(同时也用于更新)
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli
|
||||
@@ -101,23 +100,25 @@ npx skills add jackwener/opencli
|
||||
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-browser-sitemap
|
||||
npx skills add jackwener/opencli --skill opencli-sitemap-author
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
### 选择哪个 skill
|
||||
|
||||
| Skill | 适用场景 | 你对 AI Agent 说的话 |
|
||||
|-------|---------|-------------------|
|
||||
| **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
|
||||
| **opencli-adapter-author** | 为新站点写可复用适配器,或给已有站点添加命令 | "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
|
||||
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
|
||||
| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" |
|
||||
| **opencli-browser** | 实时驱动 Chrome 页面——导航、填表单、点击、抓取 | "帮我看看小红书的通知" / "帮我填一下这个表单" / "用浏览器命令抓取这个页面" |
|
||||
| **opencli-browser-sitemap** | 使用站点 sitemap 上下文来操作浏览器任务 | "用 sitemap 帮我少走弯路地操作这个网站" |
|
||||
| **opencli-sitemap-author** | 创建或更新面向浏览器 Agent 的站点 sitemap | "把刚发现的稳定流程记录到这个站点的 sitemap" |
|
||||
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
|
||||
| **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" |
|
||||
|
||||
### 工作原理
|
||||
|
||||
安装 `opencli-adapter-author` skill 后,你的 AI Agent 可以:
|
||||
安装 `opencli-browser` skill 后,你的 AI Agent 可以:
|
||||
|
||||
1. **导航**到任意 URL,使用你的已登录浏览器
|
||||
2. **读取**页面内容——通过结构化 DOM 快照(不是截图)
|
||||
@@ -128,244 +129,79 @@ npx skills add jackwener/opencli --skill smart-search
|
||||
Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自然语言描述想做的事。
|
||||
|
||||
**Skill 参考文档:**
|
||||
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 浏览器操作 + 适配器编写,全流程
|
||||
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 实时驱动 Chrome(导航、填表单、点击、抓取)
|
||||
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — 操作浏览器任务时消费 sitemap 上下文
|
||||
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — 创建或更新站点 sitemap 知识
|
||||
- [`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`、`fill`、`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` 命令必须紧跟一个 `<session>` 位置参数。`opencli browser work open <url>` 和 `opencli browser work tab new [url]` 都会返回 target ID。`opencli browser work tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为同一 session 后续未指定 target 的默认目标。
|
||||
|
||||
## 核心概念
|
||||
## 为新站点写适配器
|
||||
|
||||
### `browser`:AI Agent 的浏览器控制层
|
||||
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,全流程:
|
||||
|
||||
`opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作。
|
||||
|
||||
比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser <session> open`、`state`、`click` 等命令。
|
||||
|
||||
### 内置适配器:稳定命令
|
||||
|
||||
当某个站点能力已经存在时,优先使用 `opencli hackernews top`、`opencli reddit hot` 这类稳定命令。这些命令是确定性的,无需浏览器——人类和 AI Agent 都可以直接使用。
|
||||
|
||||
### 为新站点写适配器
|
||||
|
||||
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,它会把 Agent 带到闭环:
|
||||
|
||||
1. 侦察站点,分类 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
||||
3. 定认证策略——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
|
||||
4. 字段解码 + 设计输出列
|
||||
5. `opencli browser recon analyze <url>` 一步侦察,再 `opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
|
||||
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
||||
|
||||
### CLI 枢纽与桌面端适配器
|
||||
|
||||
OpenCLI 不只是网站 CLI,还可以:
|
||||
|
||||
- 统一代理本地二进制工具,例如 `gh`、`docker`、`obsidian`、`tg-cli`、`discord-cli`、`wx-cli`
|
||||
- 通过专门适配器和 CDP 集成控制 Electron 桌面应用
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
|
||||
- **Bun**: >= 1.0(可选替代运行时)
|
||||
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
|
||||
|
||||
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
|
||||
1. **侦察**站点,分类 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
2. **发现** endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
||||
3. **定认证**——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
|
||||
4. **字段解码** + 设计输出列
|
||||
5. `opencli browser recon analyze <url>` → `opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
|
||||
6. 站点知识沉到 `~/.opencli/sites/<site>/`,下次同站点直接吃缓存
|
||||
|
||||
## 配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
|
||||
| `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground` 或 `background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window <foreground\|background>` |
|
||||
| `OPENCLI_KEEP_TAB` | 命令默认值 | 设为 `true` 或 `false` 来控制浏览器型 adapter 命令结束后是否保留 tab lease。浏览器型 adapter 命令也支持 `--keep-tab <true\|false>` |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `45` | 浏览器连接超时(秒) |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
|
||||
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com`) |
|
||||
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
|
||||
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
|
||||
|
||||
Browser Bridge daemon 与扩展的通信端口固定为 `localhost:19825`,不再支持通过 `OPENCLI_DAEMON_PORT` 配置自定义端口。
|
||||
|
||||
`opencli browser *` 必须紧跟一个 `<session>` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser <session> close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`。
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli@latest
|
||||
|
||||
# 如果你在用打包发布的 OpenCLI skills,也一起刷新
|
||||
npx skills add jackwener/opencli
|
||||
```
|
||||
|
||||
如果你只装了部分 skill,也可以只刷新自己在用的:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## 面向开发者
|
||||
|
||||
从源码安装:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:jackwener/opencli.git
|
||||
cd opencli
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
加载源码版 Browser Bridge 扩展:
|
||||
|
||||
1. 打开 `chrome://extensions` 并启用 **开发者模式**
|
||||
2. 点击 **加载已解压的扩展程序**,选择本仓库里的 `extension/` 目录
|
||||
|
||||
## 内置命令
|
||||
|
||||
运行 `opencli list` 查看完整注册表。
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **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` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `history` `export` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **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` `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` | 浏览器 |
|
||||
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` | 浏览器 |
|
||||
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
|
||||
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
|
||||
| **uiverse** | `code` `preview` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **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` `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 |
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
|
||||
| **ctrip** | `search` | 浏览器 |
|
||||
| **devto** | `top` `tag` `user` | 公开 |
|
||||
| **dictionary** | `search` `synonyms` `examples` | 公开 |
|
||||
| **arxiv** | `search` `paper` | 公开 |
|
||||
| **pubmed** | `search` `article` `author` `citations` `related` | 公开 |
|
||||
| **openreview** | `search` `venue` `paper` `reviews` | 公开 |
|
||||
| **paperreview** | `submit` `review` `feedback` | 公开 |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
| **jd** | `item` | 浏览器 |
|
||||
| **linkedin** | `search` `timeline` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **web** | `read` | 浏览器 |
|
||||
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
| **sinafinance** | `news` | 🌐 公开 |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
|
||||
| **chaoxing** | `assignments` `exams` | 浏览器 |
|
||||
| **grok** | `ask` `image` | 浏览器 |
|
||||
| **hf** | `top` | 公开 |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
|
||||
| **linux-do** | `feed` `search` `categories` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
||||
| **steam** | `top-sellers` | 公开 |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
||||
| **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` `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` `read` | 公开 |
|
||||
| **medium** | `feed` `search` `user` | 浏览器 |
|
||||
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
|
||||
| **substack** | `feed` `search` `publication` | 浏览器 |
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
|
||||
| **xianyu** | `search` `item` `chat` `publish` | 浏览器 |
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
| 站点 | 命令 |
|
||||
|------|------|
|
||||
| **xiaohongshu** | `search` `ask` `note` `comments` `notifications` `feed` `user` `saved` `liked` `download` `publish` `follow` `unfollow` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` |
|
||||
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
|
||||
| **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` |
|
||||
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
|
||||
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `people-search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` |
|
||||
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` |
|
||||
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
|
||||
| **upwork** | `search` `feed` `detail` |
|
||||
| **slock** | `message-send` `message-read` `message-search` `channel-list` `channel-info` `channel-create` `channel-members` `channel-join` `task-list` `task-create` `task-claim` `task-status` `task-convert` `task-delete` `thread-list` `thread-follow` `attachment-upload` `attachment-download` `bookmark-add` `inbox` `dm-list` `server-list` `server-use` `whoami` |
|
||||
| **huodongxing** | `events` |
|
||||
| **midjourney** | `login` `whoami` `settings` `quota` `generate` `describe` `history` `status` `action` `download` |
|
||||
|
||||
100+ 站点能力 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
`*` `opencli xiaoyuzhou podcast`、`podcast-episodes`、`episode`、`download`、`transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`。
|
||||
精选清单 — **[→ 查看全部 100+ 站点和命令](./docs/adapters/index.md)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Chess.com / Bilibili / 等)。
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。
|
||||
把现有命令行工具统一接入 `opencli <tool> ...`:
|
||||
|
||||
| 外部 CLI | 描述 | 示例 |
|
||||
|----------|------|------|
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker 命令行工具 | `opencli docker ps` |
|
||||
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
|
||||
| **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
|
||||
| **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` |
|
||||
| **tg-cli** | Telegram CLI — 基于 MTProto 的本地优先同步、搜索、导出,面向 AI Agent | `opencli tg search "AI news" -f json` |
|
||||
| **discord-cli** | Discord CLI — 基于 SQLite 的本地优先同步、搜索、导出,面向 AI Agent | `opencli discord recent --channel general` |
|
||||
| **wx-cli** | 微信本地数据 CLI — 会话、聊天记录、搜索、联系人、导出 | `opencli wx search "OpenCLI"` |
|
||||
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
|
||||
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
|
||||
|
||||
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
|
||||
注册自定义本地 CLI:`opencli external register <name>`;查看所有:`opencli external list`。
|
||||
|
||||
**自动安装**:如果你运行 `opencli gh ...` 时系统中还没有 `gh`,OpenCLI 会优先尝试通过系统包管理器安装,然后自动重试命令。
|
||||
|
||||
**注册自定义本地 CLI**:
|
||||
|
||||
```bash
|
||||
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 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) |
|
||||
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
|
||||
**桌面应用适配器**(Electron,通过 CDP):Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
|
||||
|
||||
## 下载支持
|
||||
|
||||
@@ -457,28 +293,7 @@ opencli bilibili hot -v # 详细模式:展示管线执行步骤调试
|
||||
|
||||
## 退出码
|
||||
|
||||
opencli 遵循 Unix `sysexits.h` 惯例,可无缝接入 shell 管道和 CI 脚本:
|
||||
|
||||
| 退出码 | 含义 | 触发场景 |
|
||||
|--------|------|----------|
|
||||
| `0` | 成功 | 命令正常完成 |
|
||||
| `1` | 通用错误 | 未分类的意外错误 |
|
||||
| `2` | 用法错误 | 参数错误或未知命令 |
|
||||
| `66` | 无数据 | 命令返回空结果(`EX_NOINPUT`) |
|
||||
| `69` | 服务不可用 | Browser Bridge 未连接(`EX_UNAVAILABLE`) |
|
||||
| `75` | 临时失败 | 命令超时,可重试(`EX_TEMPFAIL`) |
|
||||
| `77` | 需要认证 | 未登录目标网站(`EX_NOPERM`) |
|
||||
| `78` | 配置错误 | 凭证缺失或配置有误(`EX_CONFIG`) |
|
||||
| `130` | 中断 | Ctrl-C / SIGINT |
|
||||
|
||||
```bash
|
||||
opencli bilibili hot 2>/dev/null
|
||||
case $? in
|
||||
0) echo "ok" ;;
|
||||
69) echo "请先启动 Browser Bridge" ;;
|
||||
77) echo "请先登录 bilibili.com" ;;
|
||||
esac
|
||||
```
|
||||
opencli 遵循 Unix `sysexits.h`,CI / 脚本可按失败模式分支:`0` 成功、`66` 无数据、`69` Browser Bridge 未连接、`75` 超时、`77` 需要认证、`78` 配置错误、`130` Ctrl-C。完整参考:[docs/zh/guide/exit-codes.md](./docs/zh/guide/exit-codes.md)。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -500,23 +315,10 @@ opencli plugin uninstall my-tool # 卸载
|
||||
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | JS | 多平台热榜聚合 |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | JS | 稀土掘金热门文章 |
|
||||
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | JS | VK (VKontakte) 动态、信息流和搜索 |
|
||||
| [opencli-plugin-x-article-publisher](https://github.com/genoooool/opencli-plugin-x-article-publisher) | JS | 通过 OpenCLI 与 xPoster 将带本地图片的 Markdown 发布为 X 长文 |
|
||||
|
||||
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
|
||||
|
||||
## 致 AI Agent(开发者指南)
|
||||
|
||||
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
|
||||
|
||||
在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来:
|
||||
|
||||
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
- 用 `opencli browser <name> network`、`eval`、interceptor 等找到目标 endpoint
|
||||
- 定认证策略(`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`)
|
||||
- 先用 `opencli browser recon analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser recon init` 生成骨架
|
||||
- 交付前用 `opencli browser recon verify <site>/<name>` 验证
|
||||
|
||||
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
- **"Extension not connected" 报错**
|
||||
@@ -526,7 +328,7 @@ opencli plugin uninstall my-tool # 卸载
|
||||
- **返回空数据,或者报错 "Unauthorized"**
|
||||
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
|
||||
- OpenCLI 要求 **Node.js >= 21**。先执行 `node --version`,如果版本过低先升级,再重试命令。
|
||||
- OpenCLI 要求 **Node.js >= 20**。先执行 `node --version`,如果版本过低先升级,再重试命令。
|
||||
- **Daemon 问题**
|
||||
- 检查 daemon 状态:`curl localhost:19825/status`
|
||||
- 查看扩展日志:`curl localhost:19825/logs`
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Engine handles commit, verify, guard, keep/discard, and logging.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, type AutoResearchConfig } from '../config.js';
|
||||
@@ -20,6 +20,31 @@ import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
const CLAUDE_ALLOWED_TOOLS = 'Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep';
|
||||
|
||||
export function buildClaudeModifyInvocation(prompt: string) {
|
||||
const options: ExecFileSyncOptionsWithStringEncoding = {
|
||||
cwd: ROOT,
|
||||
timeout: 300_000,
|
||||
encoding: 'utf-8',
|
||||
input: prompt,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
};
|
||||
return {
|
||||
command: 'claude',
|
||||
args: [
|
||||
'-p',
|
||||
'--dangerously-skip-permissions',
|
||||
'--allowedTools',
|
||||
CLAUDE_ALLOWED_TOOLS,
|
||||
'--output-format',
|
||||
'text',
|
||||
'--no-session-persistence',
|
||||
],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
|
||||
const recent = ctx.recentLog.slice(-10).map(r =>
|
||||
@@ -60,15 +85,13 @@ async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<s
|
||||
|
||||
console.log(' Claude Code making a change...');
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{
|
||||
cwd: ROOT,
|
||||
timeout: 300_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}
|
||||
// Keep command structure and the repository-derived prompt out of a shell.
|
||||
// Claude reads the prompt from stdin when -p has no positional prompt.
|
||||
const invocation = buildClaudeModifyInvocation(prompt);
|
||||
const result = execFileSync(
|
||||
invocation.command,
|
||||
invocation.args,
|
||||
invocation.options
|
||||
).trim();
|
||||
|
||||
// Extract description from Claude's response (last non-empty line or summary)
|
||||
@@ -135,4 +158,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main();
|
||||
}
|
||||
|
||||
+19597
-328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function has12306SessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://kyfw.12306.cn' });
|
||||
return cookies.some(c => c.name === 'tk' && c.value);
|
||||
}
|
||||
|
||||
async function verify12306Identity(page) {
|
||||
if (!await has12306SessionCookie(page)) {
|
||||
throw new AuthRequiredError('12306.cn', '12306 tk auth cookie missing');
|
||||
}
|
||||
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const r = await fetch('/otn/index/initMy12306Api', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (/login\\.html/.test(r.url)) {
|
||||
return { kind: 'auth', detail: '12306 initMy12306Api redirected to login' };
|
||||
}
|
||||
const t = await r.text();
|
||||
let d = null;
|
||||
try { d = JSON.parse(t); } catch {}
|
||||
if (!d || d.status === false || /未登录|登录超时|NotLogin/i.test(t)) {
|
||||
return { kind: 'auth', detail: '12306 initMy12306Api returned NotLogin' };
|
||||
}
|
||||
const userName = d.data?.user_name || d.data?.userName || d.user_name || '';
|
||||
if (!userName) {
|
||||
return { kind: 'auth', detail: '12306 initMy12306Api 200 but no user_name surface' };
|
||||
}
|
||||
return { ok: true, user_name: String(userName) };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('12306.cn', probe.detail);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`12306 whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected 12306 probe: ${JSON.stringify(probe)}`);
|
||||
return { user_name: probe.user_name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: '12306',
|
||||
domain: '12306.cn',
|
||||
loginUrl: 'https://kyfw.12306.cn/otn/resources/login.html',
|
||||
columns: ['user_name'],
|
||||
quickCheck: has12306SessionCookie,
|
||||
verify: verify12306Identity,
|
||||
poll: async (page) => {
|
||||
if (!await has12306SessionCookie(page)) {
|
||||
throw new AuthRequiredError('12306.cn', 'Waiting for 12306 tk auth cookie');
|
||||
}
|
||||
return verify12306Identity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 12306 account summary for the logged-in user.
|
||||
*
|
||||
* Returns non-sensitive identity fields plus masked email / mobile.
|
||||
* Use `--include-sensitive` to surface unmasked values from 12306's
|
||||
* own response (12306 already masks the ID number server-side; this
|
||||
* adapter never decodes that mask).
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { isAuthLikePayload, maskEmail, maskMobile, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
|
||||
|
||||
const ACCOUNT_INFO_URL = 'https://kyfw.12306.cn/otn/modifyUser/initQueryUserInfoApi';
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'me',
|
||||
access: 'read',
|
||||
description: 'Show the logged-in 12306 account summary. Sensitive fields (real name, email, mobile, birth date) are masked by default; pass --include-sensitive to opt in.',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real name / email / mobile / birth date. The 12306 ID-number mask is server-side and never decoded.' },
|
||||
],
|
||||
columns: ['username', 'real_name', 'email', 'mobile', 'birth_date', 'sex', 'country', 'user_type', 'member', 'active'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for 12306 me');
|
||||
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
||||
await require12306Login(page, AuthRequiredError);
|
||||
const json = requireEvaluateObject(await page.evaluate(`async () => {
|
||||
const r = await fetch(${JSON.stringify(ACCOUNT_INFO_URL)}, { credentials: 'include' });
|
||||
if (!r.ok) return { __http: r.status };
|
||||
try {
|
||||
return await r.json();
|
||||
} catch (err) {
|
||||
return { __parse: String(err && err.message || err) };
|
||||
}
|
||||
}`), 'account info');
|
||||
if (json?.__http) {
|
||||
if ([401, 403].includes(Number(json.__http))) {
|
||||
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
|
||||
}
|
||||
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for account info`);
|
||||
}
|
||||
if (json?.__parse) {
|
||||
throw new CommandExecutionError(`12306 account info returned non-JSON body: ${json.__parse}`);
|
||||
}
|
||||
if (isAuthLikePayload(json)) {
|
||||
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
|
||||
}
|
||||
if (json?.status !== true || !json?.data?.userDTO) {
|
||||
throw new CommandExecutionError('12306 account info payload missing userDTO');
|
||||
}
|
||||
const dto = json.data.userDTO;
|
||||
const loginDto = dto.loginUserDTO || {};
|
||||
const username = loginDto.user_name || loginDto.name || '';
|
||||
const realName = loginDto.real_name || loginDto.realname || '';
|
||||
const include = kwargs['include-sensitive'] === true;
|
||||
return [{
|
||||
username,
|
||||
real_name: include ? realName : maskChineseName(realName),
|
||||
email: include ? (dto.email || '') : maskEmail(dto.email || ''),
|
||||
mobile: include ? (dto.mobile_no || '') : maskMobile(dto.mobile_no || ''),
|
||||
birth_date: include ? (dto.born_date || '') : (dto.born_date || '').slice(0, 4),
|
||||
sex: dto.sex_code === 'M' ? '男' : (dto.sex_code === 'F' ? '女' : ''),
|
||||
country: dto.country_code || '',
|
||||
user_type: json.data.userTypeName || '',
|
||||
member: dto.flag_member === '1',
|
||||
active: dto.is_active === '1',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 12306 in-progress orders for the logged-in user.
|
||||
*
|
||||
* Returns orders that have not yet been ridden / refunded / completed
|
||||
* (the `noComplete` slice). Order history covering completed and
|
||||
* refunded tickets uses a separate endpoint that requires extra
|
||||
* referer / page-state handshakes and is left for a follow-up so this
|
||||
* command can ship reliably.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
|
||||
|
||||
const NO_COMPLETE_URL = 'https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete';
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'orders',
|
||||
access: 'read',
|
||||
description: 'List in-progress 12306 orders (not yet ridden, refunded, or completed) for the logged-in user',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked passenger names in order rows. Masked by default.' },
|
||||
],
|
||||
columns: ['order_id', 'order_date', 'train_code', 'from_station', 'to_station', 'departure', 'passengers', 'status', 'amount'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for 12306 orders');
|
||||
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
||||
await require12306Login(page, AuthRequiredError);
|
||||
const include = kwargs['include-sensitive'] === true;
|
||||
const json = requireEvaluateObject(await page.evaluate(`async () => {
|
||||
const r = await fetch(${JSON.stringify(NO_COMPLETE_URL)}, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: '_json_att=', credentials: 'include',
|
||||
});
|
||||
if (!r.ok) return { __http: r.status };
|
||||
try {
|
||||
return await r.json();
|
||||
} catch (err) {
|
||||
return { __parse: String(err && err.message || err) };
|
||||
}
|
||||
}`), 'orders');
|
||||
if (json?.__http) {
|
||||
if ([401, 403].includes(Number(json.__http))) {
|
||||
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
|
||||
}
|
||||
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for queryMyOrderNoComplete`);
|
||||
}
|
||||
if (json?.__parse) {
|
||||
throw new CommandExecutionError(`12306 orders returned non-JSON body: ${json.__parse}`);
|
||||
}
|
||||
if (isAuthLikePayload(json)) {
|
||||
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
|
||||
}
|
||||
if (json?.status !== true) {
|
||||
throw new CommandExecutionError('12306 queryMyOrderNoComplete returned a failure status');
|
||||
}
|
||||
let orders;
|
||||
if (Array.isArray(json?.data?.orderDBList)) {
|
||||
orders = json.data.orderDBList;
|
||||
} else if (Array.isArray(json?.data?.orderDTODataList)) {
|
||||
orders = json.data.orderDTODataList;
|
||||
} else if (Array.isArray(json?.data?.orders)) {
|
||||
orders = json.data.orders;
|
||||
} else if (Array.isArray(json?.data)) {
|
||||
orders = json.data;
|
||||
} else {
|
||||
throw new CommandExecutionError('12306 queryMyOrderNoComplete payload missing order list array');
|
||||
}
|
||||
if (orders.length === 0) {
|
||||
throw new EmptyResultError('No in-progress 12306 orders on this account');
|
||||
}
|
||||
return orders.map((o) => {
|
||||
const tickets = Array.isArray(o.tickets) ? o.tickets : [];
|
||||
const passengerNames = tickets
|
||||
.map((t) => t.passenger_name || '')
|
||||
.filter(Boolean)
|
||||
.map((name) => include ? name : maskChineseName(name))
|
||||
.join(', ');
|
||||
return {
|
||||
order_id: o.sequence_no || o.order_id || o.sequenceNo || '',
|
||||
order_date: o.order_date || '',
|
||||
train_code: o.train_code_page || o.station_train_code || o.train_code || '',
|
||||
from_station: o.from_station_name_page || o.from_station_name || '',
|
||||
to_station: o.to_station_name_page || o.to_station_name || '',
|
||||
departure: o.start_train_date_page || o.start_train_date || '',
|
||||
passengers: passengerNames,
|
||||
status: o.ticket_status_name || o.order_status_name || o.statusName || '',
|
||||
amount: o.ticket_total_price_page || o.ticket_total_price || '',
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 12306 saved passenger list for the logged-in user.
|
||||
*
|
||||
* 12306 already masks ID numbers (`xxxx***********xxx`) and mobile
|
||||
* numbers (`138****xxxx`) server-side. This adapter further masks the
|
||||
* passenger's Chinese real name and birth date by default; pass
|
||||
* `--include-sensitive` to surface the unmasked-by-12306 fields.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
|
||||
|
||||
const PASSENGER_QUERY_URL = 'https://kyfw.12306.cn/otn/passengers/query';
|
||||
const MAX_PAGE_SIZE = 50;
|
||||
|
||||
function normalizeLimit(value, defaultValue, max) {
|
||||
if (value === undefined || value === null || value === '') return defaultValue;
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n < 1) throw new ArgumentError(`limit must be a positive integer (1-${max})`);
|
||||
if (n > max) throw new ArgumentError(`limit must be <= ${max}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'passengers',
|
||||
access: 'read',
|
||||
description: 'List the logged-in user\'s saved 12306 passengers. Sensitive fields are masked by default; pass --include-sensitive to opt in.',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: `Max passengers to return (1-${MAX_PAGE_SIZE})` },
|
||||
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real names and birth dates. The 12306 ID-number / mobile masks are server-side and never decoded.' },
|
||||
],
|
||||
columns: ['name', 'sex', 'born_year', 'id_type', 'id_no', 'mobile', 'passenger_type', 'country'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for 12306 passengers');
|
||||
const limit = normalizeLimit(kwargs.limit, 20, MAX_PAGE_SIZE);
|
||||
const include = kwargs['include-sensitive'] === true;
|
||||
|
||||
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
||||
await require12306Login(page, AuthRequiredError);
|
||||
const json = requireEvaluateObject(await page.evaluate(`async () => {
|
||||
const body = "pageIndex=1&pageSize=${MAX_PAGE_SIZE}";
|
||||
const r = await fetch(${JSON.stringify(PASSENGER_QUERY_URL)}, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body, credentials: 'include',
|
||||
});
|
||||
if (!r.ok) return { __http: r.status };
|
||||
try {
|
||||
return await r.json();
|
||||
} catch (err) {
|
||||
return { __parse: String(err && err.message || err) };
|
||||
}
|
||||
}`), 'passengers');
|
||||
if (json?.__http) {
|
||||
if ([401, 403].includes(Number(json.__http))) {
|
||||
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
|
||||
}
|
||||
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for passengers/query`);
|
||||
}
|
||||
if (json?.__parse) {
|
||||
throw new CommandExecutionError(`12306 passengers returned non-JSON body: ${json.__parse}`);
|
||||
}
|
||||
if (isAuthLikePayload(json)) {
|
||||
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
|
||||
}
|
||||
if (json?.status !== true || !Array.isArray(json?.data?.datas)) {
|
||||
throw new CommandExecutionError('12306 passengers payload missing data.datas array');
|
||||
}
|
||||
const datas = json.data.datas;
|
||||
if (datas.length === 0) {
|
||||
throw new EmptyResultError('No saved passengers on this 12306 account');
|
||||
}
|
||||
return datas.slice(0, limit).map((p) => ({
|
||||
name: include ? (p.passenger_name || '') : maskChineseName(p.passenger_name || ''),
|
||||
sex: p.sex_name || '',
|
||||
born_year: (p.born_date || '').slice(0, 4),
|
||||
id_type: p.passenger_id_type_name || '',
|
||||
id_no: p.passenger_id_no || '',
|
||||
mobile: p.mobile_no || '',
|
||||
passenger_type: p.passenger_type_name || '',
|
||||
country: p.country_code || '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { normalizeLimit };
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 12306 ticket price lookup for a single train + segment.
|
||||
*
|
||||
* Cascades three anonymous API calls:
|
||||
* 1. /otn/leftTicket/init: mint session cookies
|
||||
* 2. /otn/czxx/queryByTrainNo: resolve from/to station_no within the
|
||||
* train route (price endpoint addresses stops by station_no, not
|
||||
* telecode)
|
||||
* 3. /otn/leftTicket/queryTicketPrice: ticket prices keyed by seat
|
||||
* letter (M=一等座, O=二等座, A9=商务座, A1=硬座, A3=硬卧,
|
||||
* A4=软卧, F=动卧, P=特等座, WZ=无座, etc.)
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
|
||||
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
|
||||
const SEAT_TYPES_RE = /^[A-Z0-9]{1,32}$/;
|
||||
|
||||
const SEAT_LETTERS = {
|
||||
'A9': '商务座',
|
||||
'P': '特等座',
|
||||
'M': '一等座',
|
||||
'O': '二等座',
|
||||
'A1': '硬座',
|
||||
'A3': '硬卧',
|
||||
'A4': '软卧',
|
||||
'F': '动卧',
|
||||
'WZ': '无座',
|
||||
};
|
||||
|
||||
async function queryStopsForPrice(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
|
||||
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
|
||||
const resp = await fetchImpl(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
||||
'Cookie': cookieHeader,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
|
||||
let json;
|
||||
try {
|
||||
json = await resp.json();
|
||||
} catch {
|
||||
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
|
||||
}
|
||||
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
|
||||
throw new CommandExecutionError('12306 queryByTrainNo returned an unexpected payload shape');
|
||||
}
|
||||
return json.data.data;
|
||||
}
|
||||
|
||||
function pickStationNos(stops, fromCode, toCode, fromName, toName) {
|
||||
const matches = (s, code, name) => (s.station_name && name && s.station_name === name);
|
||||
const fromStop = stops.find((s) => matches(s, fromCode, fromName));
|
||||
const toStop = stops.find((s) => matches(s, toCode, toName));
|
||||
if (!fromStop) throw new CommandExecutionError(`Train does not stop at ${fromName}`);
|
||||
if (!toStop) throw new CommandExecutionError(`Train does not stop at ${toName}`);
|
||||
return { fromNo: fromStop.station_no, toNo: toStop.station_no };
|
||||
}
|
||||
|
||||
async function queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date, fetchImpl = fetch) {
|
||||
const url = `https://kyfw.12306.cn/otn/leftTicket/queryTicketPrice?train_no=${trainNo}&from_station_no=${fromNo}&to_station_no=${toNo}&seat_types=${seatTypes}&train_date=${date}`;
|
||||
const resp = await fetchImpl(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
||||
'Cookie': cookieHeader,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new CommandExecutionError(`12306 queryTicketPrice returned HTTP ${resp.status}`);
|
||||
let json;
|
||||
try {
|
||||
json = await resp.json();
|
||||
} catch {
|
||||
throw new CommandExecutionError('12306 queryTicketPrice returned non-JSON body');
|
||||
}
|
||||
if (json?.status !== true || !json?.data) {
|
||||
throw new CommandExecutionError('12306 queryTicketPrice returned an unexpected payload shape');
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
function parsePriceData(priceData) {
|
||||
const rows = [];
|
||||
for (const [letter, value] of Object.entries(priceData)) {
|
||||
if (letter === 'train_no' || letter === 'OT') continue;
|
||||
if (typeof value !== 'string' || !value) continue;
|
||||
// 12306 doubles up some prices as bare numerics ("9": "21580"), which
|
||||
// mirror their letter sibling ("A9": "¥2158.0") in cents/no-decimal
|
||||
// form. Skip the bare numeric letter codes to avoid duplicates.
|
||||
if (/^\d+$/.test(letter)) continue;
|
||||
if (!/^[A-Z]/.test(letter)) continue;
|
||||
const numeric = value.replace(/^¥/, '');
|
||||
if (!/^[\d.]+$/.test(numeric)) continue;
|
||||
rows.push({
|
||||
seat_code: letter,
|
||||
seat_name: SEAT_LETTERS[letter] || letter,
|
||||
price: numeric,
|
||||
currency: 'CNY',
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => Number(b.price) - Number(a.price));
|
||||
return rows;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'price',
|
||||
access: 'read',
|
||||
description: 'Look up 12306 ticket prices by seat class for one train on a given date and segment (anonymous, no login required)',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L)' },
|
||||
{ name: 'from', required: true, help: 'Origin station (Chinese name, telecode, or pinyin) - must be a stop of this train' },
|
||||
{ name: 'to', required: true, help: 'Destination station - must be a stop of this train' },
|
||||
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
||||
{ name: 'seat-types', default: 'OM9PA1A3A4FWZ', help: 'Seat-type letters to query (default covers the common classes). Examples: OM9 (二等/一等/商务), A1A3A4 (硬座/硬卧/软卧).' },
|
||||
],
|
||||
columns: ['seat_code', 'seat_name', 'price', 'currency'],
|
||||
func: async (kwargs) => {
|
||||
const trainNo = String(kwargs['train-no'] ?? '').trim();
|
||||
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
|
||||
if (!TRAIN_NO_RE.test(trainNo)) {
|
||||
throw new ArgumentError(
|
||||
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
|
||||
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
|
||||
);
|
||||
}
|
||||
const fromArg = String(kwargs.from ?? '').trim();
|
||||
const toArg = String(kwargs.to ?? '').trim();
|
||||
if (!fromArg) throw new ArgumentError('--from station must not be empty');
|
||||
if (!toArg) throw new ArgumentError('--to station must not be empty');
|
||||
const date = validateDate(kwargs.date);
|
||||
const seatTypes = String(kwargs['seat-types'] ?? '').trim() || 'OM9PA1A3A4FWZ';
|
||||
if (!SEAT_TYPES_RE.test(seatTypes)) {
|
||||
throw new ArgumentError('--seat-types must contain only 12306 seat letters/digits (A-Z, 0-9)');
|
||||
}
|
||||
|
||||
const stations = await fetchStationBundle();
|
||||
const fromStation = resolveStation(stations, fromArg);
|
||||
const toStation = resolveStation(stations, toArg);
|
||||
if (fromStation.code === toStation.code) {
|
||||
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
||||
}
|
||||
|
||||
const cookieHeader = await mintSession();
|
||||
const stops = await queryStopsForPrice(cookieHeader, trainNo, fromStation.code, toStation.code, date);
|
||||
const { fromNo, toNo } = pickStationNos(stops, fromStation.code, toStation.code, fromStation.name, toStation.name);
|
||||
const priceData = await queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date);
|
||||
const rows = parsePriceData(priceData);
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError(
|
||||
`No prices returned for train_no=${trainNo} ${fromStation.name} -> ${toStation.name} on ${date}`,
|
||||
'Try a different seat-types letter set, or check that this train operates on the date.',
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS, TRAIN_NO_RE };
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 12306 station search.
|
||||
*
|
||||
* Queries the public `station_name.js` bundle and filters by the user's
|
||||
* keyword. Anonymous, no session needed.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchStationBundle } from './utils.js';
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
function normalizeLimit(value, defaultValue, max) {
|
||||
if (value === undefined || value === null || value === '') return defaultValue;
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
|
||||
}
|
||||
if (n > max) {
|
||||
throw new ArgumentError(`limit must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'stations',
|
||||
access: 'read',
|
||||
description: 'Search 12306 (China Railway) stations by Chinese name, telecode, or pinyin keyword',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'keyword', positional: true, required: true, help: 'Chinese substring (上海), telecode (AOH), or pinyin (shanghai)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: `Maximum results (1-${MAX_LIMIT})` },
|
||||
],
|
||||
columns: ['name', 'code', 'pinyin', 'abbr', 'city'],
|
||||
func: async (kwargs) => {
|
||||
const keyword = String(kwargs.keyword ?? '').trim();
|
||||
if (!keyword) throw new ArgumentError('keyword must not be empty');
|
||||
const limit = normalizeLimit(kwargs.limit, 20, MAX_LIMIT);
|
||||
|
||||
const stations = await fetchStationBundle();
|
||||
const lower = keyword.toLowerCase();
|
||||
const matches = stations.filter((s) =>
|
||||
s.name.includes(keyword)
|
||||
|| s.code === keyword.toUpperCase()
|
||||
|| s.pinyin.includes(lower)
|
||||
|| s.abbr.includes(lower)
|
||||
|| s.short.includes(lower)
|
||||
|| s.city.includes(keyword),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
throw new EmptyResultError(`No 12306 stations match "${keyword}"`);
|
||||
}
|
||||
return matches.slice(0, limit).map((s) => ({
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
pinyin: s.pinyin,
|
||||
abbr: s.abbr,
|
||||
city: s.city,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { normalizeLimit };
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 12306 train stop details - list every station a train calls at,
|
||||
* with arrival / departure / stopover time.
|
||||
*
|
||||
* Requires the internal `train_no` returned by `12306 trains`
|
||||
* (`24000000G10L`), not the public train code (`G1`).
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
|
||||
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
|
||||
|
||||
async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
|
||||
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
|
||||
const resp = await fetchImpl(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
||||
'Cookie': cookieHeader,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = await resp.json();
|
||||
} catch {
|
||||
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
|
||||
}
|
||||
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
|
||||
throw new CommandExecutionError(`12306 queryByTrainNo returned an unexpected payload shape`);
|
||||
}
|
||||
return json.data.data;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'train',
|
||||
access: 'read',
|
||||
description: 'List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)' },
|
||||
{ name: 'from', required: true, help: 'Origin station for the segment: Chinese name, telecode, or pinyin' },
|
||||
{ name: 'to', required: true, help: 'Destination station for the segment' },
|
||||
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
||||
],
|
||||
columns: ['station_no', 'station_name', 'arrive_time', 'start_time', 'stopover_time'],
|
||||
func: async (kwargs) => {
|
||||
const trainNo = String(kwargs['train-no'] ?? '').trim();
|
||||
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
|
||||
if (!TRAIN_NO_RE.test(trainNo)) {
|
||||
throw new ArgumentError(
|
||||
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
|
||||
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
|
||||
);
|
||||
}
|
||||
const fromArg = String(kwargs.from ?? '').trim();
|
||||
const toArg = String(kwargs.to ?? '').trim();
|
||||
if (!fromArg) throw new ArgumentError('--from station must not be empty');
|
||||
if (!toArg) throw new ArgumentError('--to station must not be empty');
|
||||
const date = validateDate(kwargs.date);
|
||||
|
||||
const stations = await fetchStationBundle();
|
||||
const fromStation = resolveStation(stations, fromArg);
|
||||
const toStation = resolveStation(stations, toArg);
|
||||
if (fromStation.code === toStation.code) {
|
||||
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
||||
}
|
||||
|
||||
const cookieHeader = await mintSession();
|
||||
const stops = await queryStops(cookieHeader, trainNo, fromStation.code, toStation.code, date);
|
||||
if (stops.length === 0) {
|
||||
throw new EmptyResultError(`No stops returned for train_no=${trainNo} on ${date}`);
|
||||
}
|
||||
return stops.map((s) => ({
|
||||
station_no: s.station_no || '',
|
||||
station_name: s.station_name || '',
|
||||
arrive_time: s.arrive_time === '----' ? '' : (s.arrive_time || ''),
|
||||
start_time: s.start_time === '----' ? '' : (s.start_time || ''),
|
||||
stopover_time: s.stopover_time === '----' ? '' : (s.stopover_time || ''),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { queryStops, TRAIN_NO_RE };
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 12306 train availability between two stations on a given date.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Fetch the station bundle (cached implicitly via per-process module state).
|
||||
* 2. Mint anonymous session cookies via /otn/leftTicket/init.
|
||||
* 3. Query /otn/leftTicket/queryG; if 12306 returns
|
||||
* `{c_url: "leftTicket/queryX"}` (endpoint rotation), retry once
|
||||
* against the suggested name.
|
||||
* 4. Parse the `|`-separated train records.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchStationBundle, mintSession, resolveStation, validateDate, parseTrainRecord } from './utils.js';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
|
||||
const QUERY_ENDPOINTS = ['queryG', 'queryO', 'queryZ', 'queryA'];
|
||||
const MAX_LIMIT = 100;
|
||||
const QUERY_ENDPOINT_RE = /^query[A-Z]$/;
|
||||
|
||||
function normalizeLimit(value, defaultValue, max) {
|
||||
if (value === undefined || value === null || value === '') return defaultValue;
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
|
||||
}
|
||||
if (n > max) {
|
||||
throw new ArgumentError(`limit must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function extractQueryEndpoint(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const direct = raw.replace(/^leftTicket\//, '').trim();
|
||||
if (QUERY_ENDPOINT_RE.test(direct)) return direct;
|
||||
try {
|
||||
const url = new URL(raw, 'https://kyfw.12306.cn');
|
||||
if (url.hostname !== 'kyfw.12306.cn') return '';
|
||||
const match = url.pathname.match(/\/leftTicket\/(query[A-Z])$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function parseRotationEndpoint(resp, endpoint, bodyText) {
|
||||
let json;
|
||||
if (bodyText) {
|
||||
try { json = JSON.parse(bodyText); } catch { /* body may be HTML on non-rotation redirects */ }
|
||||
}
|
||||
const bodyEndpoint = extractQueryEndpoint(json?.c_url);
|
||||
if (bodyEndpoint) return bodyEndpoint;
|
||||
const locationEndpoint = extractQueryEndpoint(resp.headers?.get?.('location'));
|
||||
if (locationEndpoint) return locationEndpoint;
|
||||
if (resp.status === 302) {
|
||||
throw new CommandExecutionError(`12306 ${endpoint} redirected without a leftTicket query endpoint`);
|
||||
}
|
||||
if (json?.c_url) {
|
||||
throw new CommandExecutionError(`12306 ${endpoint} returned an invalid rotation endpoint`);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
|
||||
const headers = {
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
||||
'Cookie': cookieHeader,
|
||||
};
|
||||
const queryParams = `leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromCode}&leftTicketDTO.to_station=${toCode}&purpose_codes=ADULT`;
|
||||
let lastResponseText = '';
|
||||
const queue = [...QUERY_ENDPOINTS];
|
||||
const tried = new Set();
|
||||
while (queue.length > 0) {
|
||||
const endpoint = queue.shift();
|
||||
if (tried.has(endpoint)) continue;
|
||||
tried.add(endpoint);
|
||||
const url = `https://kyfw.12306.cn/otn/leftTicket/${endpoint}?${queryParams}`;
|
||||
const resp = await fetch(url, { headers, redirect: 'manual' });
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 302) {
|
||||
const body = await resp.text();
|
||||
const rotated = await parseRotationEndpoint(resp, endpoint, body);
|
||||
if (rotated && !tried.has(rotated)) {
|
||||
queue.unshift(rotated);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new CommandExecutionError(`12306 ${endpoint} returned HTTP ${resp.status}`);
|
||||
}
|
||||
const text = await resp.text();
|
||||
lastResponseText = text;
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch {
|
||||
throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
|
||||
}
|
||||
if (json?.c_url && typeof json.c_url === 'string') {
|
||||
const rotated = await parseRotationEndpoint(resp, endpoint, text);
|
||||
if (rotated && !tried.has(rotated)) {
|
||||
queue.unshift(rotated);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(json?.data?.result)) {
|
||||
return json.data.result;
|
||||
}
|
||||
throw new CommandExecutionError(`12306 ${endpoint} returned an unexpected payload shape`);
|
||||
}
|
||||
throw new CommandExecutionError(`12306 rejected every known query endpoint name (${QUERY_ENDPOINTS.join(', ')}); the wire protocol may have changed. Last body: ${lastResponseText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '12306',
|
||||
name: 'trains',
|
||||
access: 'read',
|
||||
description: 'List trains between two 12306 stations on a given date (anonymous, no login required)',
|
||||
domain: 'kyfw.12306.cn',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'from', positional: true, required: true, help: 'Origin station: Chinese name (北京), telecode (BJP), or pinyin (beijing)' },
|
||||
{ name: 'to', positional: true, required: true, help: 'Destination station: same forms as <from>' },
|
||||
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
||||
{ name: 'limit', type: 'int', default: 50, help: `Maximum rows (1-${MAX_LIMIT})` },
|
||||
],
|
||||
columns: [
|
||||
'code', 'from_station', 'to_station', 'start_time', 'arrive_time',
|
||||
'duration', 'available', 'business_seat', 'first_seat', 'second_seat',
|
||||
'soft_sleeper', 'hard_sleeper', 'hard_seat', 'no_seat', 'train_no',
|
||||
],
|
||||
func: async (kwargs) => {
|
||||
const fromArg = String(kwargs.from ?? '').trim();
|
||||
const toArg = String(kwargs.to ?? '').trim();
|
||||
if (!fromArg) throw new ArgumentError('<from> station must not be empty');
|
||||
if (!toArg) throw new ArgumentError('<to> station must not be empty');
|
||||
const date = validateDate(kwargs.date);
|
||||
const limit = normalizeLimit(kwargs.limit, 50, MAX_LIMIT);
|
||||
|
||||
const stations = await fetchStationBundle();
|
||||
const fromStation = resolveStation(stations, fromArg);
|
||||
const toStation = resolveStation(stations, toArg);
|
||||
if (fromStation.code === toStation.code) {
|
||||
throw new ArgumentError(`<from> and <to> must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
||||
}
|
||||
const stationByCode = new Map(stations.map((s) => [s.code, s]));
|
||||
|
||||
const cookieHeader = await mintSession();
|
||||
const rawRows = await queryLeftTickets(cookieHeader, fromStation.code, toStation.code, date);
|
||||
const decoded = rawRows
|
||||
.map((line) => parseTrainRecord(decodeURIComponent(line.replace(/%0A/g, '')), stationByCode))
|
||||
.filter(Boolean);
|
||||
|
||||
if (decoded.length === 0) {
|
||||
throw new EmptyResultError(
|
||||
`No trains found from ${fromStation.name} to ${toStation.name} on ${date}`,
|
||||
'Try a different date or check whether the route is operated by 12306.',
|
||||
);
|
||||
}
|
||||
return decoded.slice(0, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { normalizeLimit, extractQueryEndpoint, queryLeftTickets };
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 12306 (中国铁路) shared helpers.
|
||||
*
|
||||
* - Station lookup: parses the public `station_name.js` bundle into
|
||||
* structured records.
|
||||
* - Cookie session: 12306's query endpoints reject anonymous requests
|
||||
* with `HTTP 302 -> error.html`, so callers must hit `/otn/leftTicket/init`
|
||||
* first to mint the JSESSIONID / route / BIGipServerotn cookies.
|
||||
* - Query endpoint rotation: 12306 rotates the train-query endpoint
|
||||
* name (queryO / queryZ / queryA / queryG / ...) every few weeks.
|
||||
* When the wrong name is hit, the server returns
|
||||
* `{"c_url":"leftTicket/queryG","c_name":"CLeftTicketUrl","status":false}`
|
||||
* pointing to the current correct name; retry once with that name.
|
||||
*/
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
const STATION_BUNDLE_URL = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
|
||||
const INIT_URL = 'https://kyfw.12306.cn/otn/leftTicket/init';
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const STATION_CODE_RE = /^[A-Z]{2,4}$/;
|
||||
|
||||
/**
|
||||
* Parse the `station_name.js` bundle into a station record array.
|
||||
*
|
||||
* Bundle format (single line, `@`-delimited records, each `|`-delimited):
|
||||
* `var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||...';`
|
||||
*
|
||||
* Per-record fields (positional):
|
||||
* [0] short pinyin alias (e.g. `bjb`)
|
||||
* [1] Chinese station name (e.g. `北京北`)
|
||||
* [2] telecode (3-4 uppercase letters, e.g. `VAP`) - this is the
|
||||
* wire format 12306 uses for `from_station` / `to_station`.
|
||||
* [3] full pinyin (e.g. `beijingbei`)
|
||||
* [4] short alias (duplicate of [0] usually)
|
||||
* [5] index/rank
|
||||
* [6] city code
|
||||
* [7] city name (e.g. `北京`)
|
||||
*/
|
||||
export function parseStationBundle(text) {
|
||||
const match = text.match(/'([^']+)'/);
|
||||
if (!match) {
|
||||
throw new CommandExecutionError('Failed to parse 12306 station_name.js: source string not found');
|
||||
}
|
||||
const raw = match[1];
|
||||
const records = raw.split('@').filter(Boolean);
|
||||
const stations = [];
|
||||
for (const r of records) {
|
||||
const parts = r.split('|');
|
||||
if (parts.length < 8 || !parts[2]) continue;
|
||||
stations.push({
|
||||
short: parts[0] || '',
|
||||
name: parts[1] || '',
|
||||
code: parts[2] || '',
|
||||
pinyin: parts[3] || '',
|
||||
abbr: parts[4] || '',
|
||||
city: parts[7] || '',
|
||||
});
|
||||
}
|
||||
if (stations.length === 0) {
|
||||
throw new CommandExecutionError('Failed to parse 12306 station_name.js: no station records found');
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied station identifier to a telecode.
|
||||
*
|
||||
* Accepts Chinese name (`上海虹桥`), telecode (`AOH`), pinyin
|
||||
* (`shanghaihongqiao`), short alias (`shh`), or city name with a
|
||||
* preference for the city's main station.
|
||||
*/
|
||||
export function resolveStation(stations, input) {
|
||||
const trimmed = String(input ?? '').trim();
|
||||
if (!trimmed) throw new ArgumentError('station must not be empty');
|
||||
if (STATION_CODE_RE.test(trimmed)) {
|
||||
const exact = stations.find((s) => s.code === trimmed);
|
||||
if (exact) return exact;
|
||||
throw new ArgumentError(`Unknown 12306 station telecode "${trimmed}"`);
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
const exactName = stations.find((s) => s.name === trimmed);
|
||||
if (exactName) return exactName;
|
||||
const exactPinyin = stations.find((s) => s.pinyin === lower);
|
||||
if (exactPinyin) return exactPinyin;
|
||||
const exactAbbr = stations.find((s) => s.abbr === lower || s.short === lower);
|
||||
if (exactAbbr) return exactAbbr;
|
||||
throw new ArgumentError(`Unknown 12306 station "${trimmed}"`, 'Try the Chinese name (上海虹桥), the 3-4 letter telecode (AOH), or full pinyin (shanghaihongqiao).');
|
||||
}
|
||||
|
||||
export function validateDate(value) {
|
||||
if (!DATE_RE.test(String(value ?? ''))) {
|
||||
throw new ArgumentError(`date must be YYYY-MM-DD, got "${value}"`);
|
||||
}
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
|
||||
throw new ArgumentError(`date "${value}" is not a real calendar date`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
|
||||
export function buildCookieHeader(setCookieHeaders) {
|
||||
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
|
||||
return setCookieHeaders
|
||||
.map((line) => line.split(';')[0])
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export async function fetchStationBundle(fetchImpl = fetch) {
|
||||
const resp = await fetchImpl(STATION_BUNDLE_URL, {
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`Failed to fetch 12306 station bundle: HTTP ${resp.status}`);
|
||||
}
|
||||
return parseStationBundle(await resp.text());
|
||||
}
|
||||
|
||||
/** Mint a 12306 anonymous session by hitting /otn/leftTicket/init. */
|
||||
export async function mintSession(fetchImpl = fetch) {
|
||||
const resp = await fetchImpl(INIT_URL, {
|
||||
headers: { 'User-Agent': UA },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`Failed to mint 12306 session: HTTP ${resp.status}`);
|
||||
}
|
||||
const setCookies = typeof resp.headers.getSetCookie === 'function'
|
||||
? resp.headers.getSetCookie()
|
||||
: resp.headers.raw?.()['set-cookie'] || [];
|
||||
const cookieHeader = buildCookieHeader(setCookies);
|
||||
if (!cookieHeader) {
|
||||
throw new CommandExecutionError('12306 init returned no session cookies');
|
||||
}
|
||||
return cookieHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twelve-row train query record (LEFT_TICKET_DTO).
|
||||
*
|
||||
* 12306 returns each train as a `|`-separated string with ~36 fields.
|
||||
* Positions used here come from the public web client; unused
|
||||
* positions are documented inline so future maintainers can extend
|
||||
* the row shape without re-reverse-engineering.
|
||||
*/
|
||||
export function parseTrainRecord(line, stationByCode) {
|
||||
const f = line.split('|');
|
||||
if (f.length < 33) return null;
|
||||
return {
|
||||
train_no: f[2] || '',
|
||||
code: f[3] || '',
|
||||
from_station: stationByCode.get(f[6])?.name || f[6] || '',
|
||||
to_station: stationByCode.get(f[7])?.name || f[7] || '',
|
||||
from_code: f[6] || '',
|
||||
to_code: f[7] || '',
|
||||
start_time: f[8] || '',
|
||||
arrive_time: f[9] || '',
|
||||
duration: f[10] || '',
|
||||
available: (f[1] || '').trim() === '预订' || (f[11] || '').trim() === 'Y',
|
||||
business_seat: f[32] || '',
|
||||
first_seat: f[31] || '',
|
||||
second_seat: f[30] || '',
|
||||
soft_sleeper: f[23] || '',
|
||||
hard_sleeper: f[28] || '',
|
||||
hard_seat: f[29] || '',
|
||||
no_seat: f[26] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask helpers for sensitive identity fields rendered by 12306.
|
||||
*
|
||||
* 12306 already masks ID numbers and mobile numbers server-side
|
||||
* (`xxxx***********xxx` / `138****xxxx`); these helpers handle the
|
||||
* remaining fields (email, real Chinese name) so the adapter never
|
||||
* leaks unmasked PII without an explicit `--include-sensitive` opt-in.
|
||||
*/
|
||||
export function maskEmail(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
const at = v.indexOf('@');
|
||||
if (at <= 0) return v;
|
||||
const local = v.slice(0, at);
|
||||
const domain = v.slice(at);
|
||||
if (local.length <= 2) return local[0] + '*' + domain;
|
||||
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local.slice(-1) + domain;
|
||||
}
|
||||
|
||||
export function maskMobile(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
if (/\*/.test(v)) return v;
|
||||
if (v.length < 7) return v.replace(/.(?=.)/g, '*');
|
||||
return v.slice(0, 3) + '*'.repeat(v.length - 7) + v.slice(-4);
|
||||
}
|
||||
|
||||
export function maskChineseName(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
if (v.length === 1) return v;
|
||||
if (v.length === 2) return v[0] + '*';
|
||||
return v[0] + '*'.repeat(v.length - 2) + v.slice(-1);
|
||||
}
|
||||
|
||||
export function unwrapEvaluateResult(value) {
|
||||
if (
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& !Array.isArray(value)
|
||||
&& Object.prototype.hasOwnProperty.call(value, 'session')
|
||||
&& Object.prototype.hasOwnProperty.call(value, 'data')
|
||||
) {
|
||||
return value.data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireEvaluateObject(value, label) {
|
||||
const payload = unwrapEvaluateResult(value);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new CommandExecutionError(`12306 ${label} returned a malformed browser payload`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function isAuthLikePayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const parts = [];
|
||||
if (Array.isArray(payload.messages)) parts.push(...payload.messages);
|
||||
if (payload.message) parts.push(payload.message);
|
||||
if (payload.msg) parts.push(payload.msg);
|
||||
if (payload.validateMessages && typeof payload.validateMessages === 'object') {
|
||||
parts.push(...Object.values(payload.validateMessages).flat());
|
||||
}
|
||||
const text = parts.map((item) => String(item ?? '')).join(' ');
|
||||
return /未登录|登录|请登录|身份|认证|session|Session|login/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the 12306 login marker by reading `document.cookie` from the
|
||||
* current adapter page. Cannot use `page.getCookies({url})` here:
|
||||
* 12306 sets the auth cookie `tk` and `JSESSIONID` with `Path=/otn`,
|
||||
* and CDP `Network.getCookies` with a bare URL filter excludes
|
||||
* cookies whose path does not match the URL path. `document.cookie`
|
||||
* returns all non-httponly cookies visible to the current page
|
||||
* regardless of path, which is what we need to confirm login.
|
||||
*/
|
||||
export async function require12306Login(page, AuthRequiredErrorClass) {
|
||||
const docCookie = unwrapEvaluateResult(await page.evaluate(`document.cookie || ''`));
|
||||
const cookieStr = typeof docCookie === 'string' ? docCookie : '';
|
||||
if (!/\btk=/.test(cookieStr) || !/JSESSIONID=/.test(cookieStr)) {
|
||||
throw new AuthRequiredErrorClass('kyfw.12306.cn', 'Not logged into 12306. Sign in at https://kyfw.12306.cn first.');
|
||||
}
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
parseStationBundle,
|
||||
resolveStation,
|
||||
validateDate,
|
||||
buildCookieHeader,
|
||||
parseTrainRecord,
|
||||
maskEmail,
|
||||
maskMobile,
|
||||
maskChineseName,
|
||||
unwrapEvaluateResult,
|
||||
requireEvaluateObject,
|
||||
isAuthLikePayload,
|
||||
};
|
||||
@@ -0,0 +1,424 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { __test__ } from './utils.js';
|
||||
import { __test__ as priceTest } from './price.js';
|
||||
import { __test__ as trainTest } from './train.js';
|
||||
import { __test__ as trainsTest } from './trains.js';
|
||||
import './orders.js';
|
||||
|
||||
const { parseStationBundle, resolveStation, validateDate, buildCookieHeader, parseTrainRecord, maskEmail, maskMobile, maskChineseName, unwrapEvaluateResult, requireEvaluateObject, isAuthLikePayload } = __test__;
|
||||
const { parsePriceData, queryStopsForPrice, queryPrice, TRAIN_NO_RE: PRICE_TRAIN_NO_RE } = priceTest;
|
||||
const { queryStops, TRAIN_NO_RE: TRAIN_TRAIN_NO_RE } = trainTest;
|
||||
const { queryLeftTickets, extractQueryEndpoint } = trainsTest;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('12306 utils - parseStationBundle', () => {
|
||||
it('parses the `@`-delimited station bundle into structured records', () => {
|
||||
const bundle = "var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||@bji|北京|BJP|beijing|bj|2|0357|北京|||@aoh|上海虹桥|AOH|shanghaihongqiao|shhq|10|7600|上海|||';";
|
||||
const stations = parseStationBundle(bundle);
|
||||
expect(stations).toHaveLength(3);
|
||||
expect(stations[1]).toEqual({
|
||||
short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips records that lack a telecode', () => {
|
||||
const bundle = "var station_names ='@xxx|||||||||@bji|北京|BJP|beijing|bj|2|0357|北京|||';";
|
||||
const stations = parseStationBundle(bundle);
|
||||
expect(stations).toHaveLength(1);
|
||||
expect(stations[0].code).toBe('BJP');
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when the bundle has no parseable station rows', () => {
|
||||
expect(() => parseStationBundle("var station_names ='@xxx|||||||||';")).toThrow(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - resolveStation', () => {
|
||||
const stations = [
|
||||
{ short: 'bjb', name: '北京北', code: 'VAP', pinyin: 'beijingbei', abbr: 'bjb', city: '北京' },
|
||||
{ short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京' },
|
||||
{ short: 'aoh', name: '上海虹桥', code: 'AOH', pinyin: 'shanghaihongqiao', abbr: 'shhq', city: '上海' },
|
||||
];
|
||||
|
||||
it('matches by exact Chinese name', () => {
|
||||
expect(resolveStation(stations, '上海虹桥').code).toBe('AOH');
|
||||
});
|
||||
|
||||
it('matches by uppercase telecode', () => {
|
||||
expect(resolveStation(stations, 'BJP').code).toBe('BJP');
|
||||
});
|
||||
|
||||
it('matches by full pinyin (case-insensitive)', () => {
|
||||
expect(resolveStation(stations, 'Beijing').code).toBe('BJP');
|
||||
});
|
||||
|
||||
it('matches by short alias / abbr', () => {
|
||||
expect(resolveStation(stations, 'shhq').code).toBe('AOH');
|
||||
});
|
||||
|
||||
it('throws ArgumentError for empty input', () => {
|
||||
expect(() => resolveStation(stations, ' ')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('throws ArgumentError for unknown station', () => {
|
||||
expect(() => resolveStation(stations, '某不存在站')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('throws ArgumentError for telecode-shaped but unknown input', () => {
|
||||
expect(() => resolveStation(stations, 'XYZ')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - validateDate', () => {
|
||||
it('accepts valid YYYY-MM-DD', () => {
|
||||
expect(validateDate('2026-05-22')).toBe('2026-05-22');
|
||||
});
|
||||
|
||||
it('throws ArgumentError on wrong format', () => {
|
||||
expect(() => validateDate('2026/05/22')).toThrow(ArgumentError);
|
||||
expect(() => validateDate('26-05-22')).toThrow(ArgumentError);
|
||||
expect(() => validateDate('today')).toThrow(ArgumentError);
|
||||
expect(() => validateDate('')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('throws ArgumentError on impossible calendar dates', () => {
|
||||
expect(() => validateDate('2026-02-30')).toThrow(ArgumentError);
|
||||
expect(() => validateDate('2026-13-01')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - buildCookieHeader', () => {
|
||||
it('joins set-cookie lines into a single Cookie header', () => {
|
||||
const headers = [
|
||||
'JSESSIONID=ABC123; Path=/otn',
|
||||
'BIGipServerotn=xxx.yyy; Path=/',
|
||||
'route=zzz; Expires=Sat, 01 Jan 2027 00:00:00 GMT',
|
||||
];
|
||||
expect(buildCookieHeader(headers)).toBe('JSESSIONID=ABC123; BIGipServerotn=xxx.yyy; route=zzz');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(buildCookieHeader([])).toBe('');
|
||||
expect(buildCookieHeader(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - parseTrainRecord', () => {
|
||||
const stationByCode = new Map([
|
||||
['VNP', { name: '北京南', code: 'VNP' }],
|
||||
['AOH', { name: '上海虹桥', code: 'AOH' }],
|
||||
]);
|
||||
|
||||
it('extracts the canonical train fields from a wire record', () => {
|
||||
// 33 `|`-separated fields, with positions used by parseTrainRecord populated.
|
||||
const fields = new Array(36).fill('');
|
||||
fields[0] = 'SECRET_TOKEN';
|
||||
fields[1] = '预订';
|
||||
fields[2] = '240000G54700';
|
||||
fields[3] = 'G547';
|
||||
fields[6] = 'VNP';
|
||||
fields[7] = 'AOH';
|
||||
fields[8] = '06:18';
|
||||
fields[9] = '12:11';
|
||||
fields[10] = '05:53';
|
||||
fields[11] = 'Y';
|
||||
fields[23] = ''; // soft sleeper
|
||||
fields[26] = '无'; // no seat
|
||||
fields[28] = ''; // hard sleeper
|
||||
fields[29] = ''; // hard seat
|
||||
fields[30] = '有'; // second seat
|
||||
fields[31] = '有'; // first seat
|
||||
fields[32] = '无'; // business seat
|
||||
const row = parseTrainRecord(fields.join('|'), stationByCode);
|
||||
expect(row).toEqual({
|
||||
train_no: '240000G54700',
|
||||
code: 'G547',
|
||||
from_station: '北京南',
|
||||
to_station: '上海虹桥',
|
||||
from_code: 'VNP',
|
||||
to_code: 'AOH',
|
||||
start_time: '06:18',
|
||||
arrive_time: '12:11',
|
||||
duration: '05:53',
|
||||
available: true,
|
||||
business_seat: '无',
|
||||
first_seat: '有',
|
||||
second_seat: '有',
|
||||
soft_sleeper: '',
|
||||
hard_sleeper: '',
|
||||
hard_seat: '',
|
||||
no_seat: '无',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose the booking-handshake secret token', () => {
|
||||
const fields = new Array(36).fill('');
|
||||
fields[0] = 'SECRET_TOKEN_DO_NOT_LEAK';
|
||||
fields[2] = 't_no'; fields[3] = 'X1'; fields[6] = 'VNP'; fields[7] = 'AOH';
|
||||
const row = parseTrainRecord(fields.join('|'), stationByCode);
|
||||
expect(Object.values(row)).not.toContain('SECRET_TOKEN_DO_NOT_LEAK');
|
||||
expect('secret' in row).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the telecode when the station bundle has no name', () => {
|
||||
const fields = new Array(36).fill('');
|
||||
fields[2] = 'X'; fields[3] = 'X'; fields[6] = 'ZZZ'; fields[7] = 'YYY';
|
||||
const row = parseTrainRecord(fields.join('|'), stationByCode);
|
||||
expect(row.from_station).toBe('ZZZ');
|
||||
expect(row.to_station).toBe('YYY');
|
||||
});
|
||||
|
||||
it('returns null for short records', () => {
|
||||
expect(parseTrainRecord('a|b|c', stationByCode)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - mask helpers', () => {
|
||||
it('masks the local-part of an email', () => {
|
||||
expect(maskEmail('hello@example.com')).toBe('h***o@example.com');
|
||||
expect(maskEmail('ab@x.cn')).toBe('a*@x.cn');
|
||||
expect(maskEmail('a@x.cn')).toBe('a*@x.cn');
|
||||
expect(maskEmail('')).toBe('');
|
||||
expect(maskEmail('not-an-email')).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('masks Chinese mobile numbers while preserving 12306-side masks', () => {
|
||||
expect(maskMobile('13800001234')).toBe('138****1234');
|
||||
expect(maskMobile('138****1234')).toBe('138****1234');
|
||||
expect(maskMobile('')).toBe('');
|
||||
expect(maskMobile('123')).toBe('**3');
|
||||
});
|
||||
|
||||
it('masks Chinese real names', () => {
|
||||
expect(maskChineseName('张三')).toBe('张*');
|
||||
expect(maskChineseName('李四明')).toBe('李*明');
|
||||
expect(maskChineseName('欧阳锋')).toBe('欧*锋');
|
||||
expect(maskChineseName('张')).toBe('张');
|
||||
expect(maskChineseName('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 price - parsePriceData', () => {
|
||||
it('returns seat rows sorted by descending price and drops dup numeric codes', () => {
|
||||
const data = {
|
||||
train_no: '24000000G10L',
|
||||
'OT': [],
|
||||
'A9': '¥2158.0',
|
||||
'9': '21580',
|
||||
'P': '¥1163.0',
|
||||
'M': '¥1035.0',
|
||||
'O': '¥626.0',
|
||||
'WZ': '¥626.0',
|
||||
'INVALID': 'not-a-price',
|
||||
};
|
||||
const rows = parsePriceData(data);
|
||||
const codes = rows.map((r) => r.seat_code);
|
||||
expect(codes).not.toContain('9');
|
||||
expect(codes).not.toContain('OT');
|
||||
expect(codes).not.toContain('train_no');
|
||||
expect(codes).not.toContain('INVALID');
|
||||
expect(codes).toEqual(['A9', 'P', 'M', 'O', 'WZ']);
|
||||
expect(rows[0]).toEqual({ seat_code: 'A9', seat_name: '商务座', price: '2158.0', currency: 'CNY' });
|
||||
expect(rows[4]).toEqual({ seat_code: 'WZ', seat_name: '无座', price: '626.0', currency: 'CNY' });
|
||||
});
|
||||
|
||||
it('keeps unknown letter codes with the letter as the name', () => {
|
||||
const data = { 'A9': '¥100.0', 'ZZ': '¥50.0' };
|
||||
const rows = parsePriceData(data);
|
||||
const zz = rows.find((r) => r.seat_code === 'ZZ');
|
||||
expect(zz?.seat_name).toBe('ZZ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 train_no validation regex', () => {
|
||||
// 12306 train_no values returned by /otn/leftTicket/query sometimes contain
|
||||
// lowercase letters (e.g. "5l000G1970A3" for G1970 上海虹桥 -> 宝鸡南).
|
||||
// Both `12306 price` and `12306 train` must accept the raw value emitted
|
||||
// by `12306 trains`, otherwise the two adapters drift apart and downstream
|
||||
// calls fail with ARGUMENT before ever hitting 12306.
|
||||
for (const [label, re] of [['price', PRICE_TRAIN_NO_RE], ['train', TRAIN_TRAIN_NO_RE]]) {
|
||||
describe(label, () => {
|
||||
it('accepts an all-uppercase train_no', () => {
|
||||
expect(re.test('24000000G10L')).toBe(true);
|
||||
});
|
||||
it('accepts a train_no with lowercase letters (real 12306 payload)', () => {
|
||||
expect(re.test('5l000G1970A3')).toBe(true);
|
||||
});
|
||||
it('rejects public codes like G1970', () => {
|
||||
expect(re.test('G1970')).toBe(false);
|
||||
});
|
||||
it('rejects values with disallowed characters', () => {
|
||||
expect(re.test('5l000-G1970A3')).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('12306 public API typed boundaries', () => {
|
||||
const nonJsonFetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <');
|
||||
},
|
||||
});
|
||||
|
||||
it('wraps non-JSON train stop bodies as CommandExecutionError', async () => {
|
||||
await expect(queryStops('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('wraps non-JSON price helper bodies as CommandExecutionError', async () => {
|
||||
await expect(queryStopsForPrice('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(queryPrice('cookie=1', '24000000G10L', '01', '02', 'OM9', '2026-05-22', nonJsonFetch))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 trains endpoint rotation', () => {
|
||||
const successBody = { data: { result: ['row|payload'] } };
|
||||
|
||||
it('extracts only leftTicket query endpoints from rotation hints', () => {
|
||||
expect(extractQueryEndpoint('leftTicket/queryB')).toBe('queryB');
|
||||
expect(extractQueryEndpoint('/otn/leftTicket/queryC')).toBe('queryC');
|
||||
expect(extractQueryEndpoint('https://kyfw.12306.cn/otn/leftTicket/queryD')).toBe('queryD');
|
||||
expect(extractQueryEndpoint('/otn/error.html')).toBe('');
|
||||
expect(extractQueryEndpoint('https://example.com/leftTicket/queryB')).toBe('');
|
||||
expect(extractQueryEndpoint('leftTicket/querybad')).toBe('');
|
||||
});
|
||||
|
||||
it('follows a 302 JSON c_url rotation signal before trying fallback endpoints', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ c_url: 'leftTicket/queryB' }), { status: 302 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('/leftTicket/queryG?');
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryB?');
|
||||
});
|
||||
|
||||
it('follows a 302 Location header rotation signal when the body is not JSON', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('<html>redirect</html>', {
|
||||
status: 302,
|
||||
headers: { location: '/otn/leftTicket/queryB' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryB?');
|
||||
});
|
||||
|
||||
it('typed-fails a 302 that does not identify a leftTicket query endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('<html>error</html>', {
|
||||
status: 302,
|
||||
headers: { location: '/otn/error.html' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22'))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates rotation endpoints request-locally and keeps fallback bounded', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ c_url: 'leftTicket/queryG' }), { status: 302 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('/leftTicket/queryG?');
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryO?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 browser evaluate boundaries', () => {
|
||||
it('unwraps Browser Bridge {session,data} evaluate envelopes only at the boundary', () => {
|
||||
expect(unwrapEvaluateResult({ session: 's1', data: 'JSESSIONID=1; tk=2' })).toBe('JSESSIONID=1; tk=2');
|
||||
expect(unwrapEvaluateResult({ status: true, data: { value: 1 } })).toEqual({ status: true, data: { value: 1 } });
|
||||
expect(requireEvaluateObject({ session: 's1', data: { status: true } }, 'test')).toEqual({ status: true });
|
||||
expect(() => requireEvaluateObject({ session: 's1', data: null }, 'test')).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('classifies 12306 login-like API envelopes as auth failures', () => {
|
||||
expect(isAuthLikePayload({ status: false, messages: ['用户未登录'] })).toBe(true);
|
||||
expect(isAuthLikePayload({ status: false, validateMessages: { global: ['请登录后再试'] } })).toBe(true);
|
||||
expect(isAuthLikePayload({ status: false, messages: ['系统繁忙'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('masks passenger names in orders by default and supports explicit sensitive opt-in', async () => {
|
||||
const command = getRegistry().get('12306/orders');
|
||||
const makePage = () => ({
|
||||
goto: async () => {},
|
||||
evaluate: async (script) => {
|
||||
if (script === "document.cookie || ''") return { session: 'browser', data: 'JSESSIONID=abc; tk=def' };
|
||||
return {
|
||||
session: 'browser',
|
||||
data: {
|
||||
status: true,
|
||||
data: {
|
||||
orderDBList: [{
|
||||
sequence_no: 'E123',
|
||||
order_date: '2026-05-18 10:00',
|
||||
train_code_page: 'G1',
|
||||
from_station_name_page: '北京南',
|
||||
to_station_name_page: '上海虹桥',
|
||||
start_train_date_page: '2026-05-22 07:00',
|
||||
ticket_status_name: '未出行',
|
||||
ticket_total_price_page: '626.0',
|
||||
tickets: [{ passenger_name: '张三' }, { passenger_name: '李四明' }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(command.func(makePage(), {})).resolves.toMatchObject([
|
||||
{ order_id: 'E123', passengers: '张*, 李*明' },
|
||||
]);
|
||||
await expect(command.func(makePage(), { 'include-sensitive': true })).resolves.toMatchObject([
|
||||
{ order_id: 'E123', passengers: '张三, 李四明' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps login-like order payloads to AuthRequiredError instead of parser drift', async () => {
|
||||
const command = getRegistry().get('12306/orders');
|
||||
const page = {
|
||||
goto: async () => {},
|
||||
evaluate: async (script) => {
|
||||
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
|
||||
return { status: false, messages: ['用户未登录'] };
|
||||
},
|
||||
};
|
||||
|
||||
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('treats missing order list shape as parser drift but known empty arrays as empty result', async () => {
|
||||
const command = getRegistry().get('12306/orders');
|
||||
const makePage = (payload) => ({
|
||||
goto: async () => {},
|
||||
evaluate: async (script) => {
|
||||
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(command.func(makePage({ status: true, data: {} }), {}))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func(makePage({ status: true, data: { orderDBList: [] } }), {}))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function has1688LogonCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.1688.com' });
|
||||
return cookies.some(c => c.name === '__cn_logon__' && c.value === 'true');
|
||||
}
|
||||
|
||||
async function verify1688Identity(page) {
|
||||
if (!await has1688LogonCookie(page)) {
|
||||
throw new AuthRequiredError('1688.com', '1688 __cn_logon__=true cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://www.1688.com/');
|
||||
await page.wait(2);
|
||||
const cookies = await page.getCookies({ url: 'https://www.1688.com' });
|
||||
const cookieMap = Object.fromEntries(cookies.map(c => [c.name, c.value]));
|
||||
if (cookieMap['__cn_logon__'] !== 'true') {
|
||||
throw new AuthRequiredError('1688.com', '1688 __cn_logon__ cookie absent after navigation');
|
||||
}
|
||||
const unb = cookieMap['unb'] || '';
|
||||
if (!unb) {
|
||||
throw new AuthRequiredError('1688.com', '1688 unb cookie missing — partial logged-in state');
|
||||
}
|
||||
let name = '';
|
||||
try {
|
||||
name = cookieMap['lid'] ? decodeURIComponent(cookieMap['lid']) : '';
|
||||
} catch {
|
||||
name = cookieMap['lid'] || '';
|
||||
}
|
||||
return { user_id: String(unb), name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: '1688',
|
||||
domain: '1688.com',
|
||||
loginUrl: 'https://login.1688.com/member/signin.htm',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: has1688LogonCookie,
|
||||
verify: verify1688Identity,
|
||||
poll: async (page) => {
|
||||
if (!await has1688LogonCookie(page)) {
|
||||
throw new AuthRequiredError('1688.com', 'Waiting for 1688 __cn_logon__=true cookie');
|
||||
}
|
||||
return verify1688Identity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
export const IDENTITY_PROBE_JS = `
|
||||
(() => {
|
||||
if (/auth\\.1point3acres\\.com\\/login/.test(location.href)) {
|
||||
return { kind: 'auth', detail: '1point3acres bbs redirected to auth login' };
|
||||
}
|
||||
const loginLink = document.querySelector('a[href*="auth.1point3acres.com/login"], a[href*="member.php?mod=logging&action=login"]');
|
||||
if (loginLink && /登录/.test(loginLink.innerText || '')) {
|
||||
return { kind: 'auth', detail: '1point3acres bbs shows 登录 link — anonymous' };
|
||||
}
|
||||
const nameEl = document.querySelector('a[title="访问我的空间"], #um .vwmy h4 a, a.username, .vwmy a');
|
||||
const username = (nameEl?.innerText || nameEl?.textContent || '').trim();
|
||||
const uid = (nameEl?.getAttribute('href') || '').match(/uid[=-](\\d+)/)?.[1] || '';
|
||||
if (!uid && !username) {
|
||||
const hasLoggedInMenu = !!document.querySelector('#g_upmine, #extcreditmenu');
|
||||
return {
|
||||
kind: hasLoggedInMenu ? 'shape' : 'auth',
|
||||
detail: hasLoggedInMenu
|
||||
? '1point3acres bbs rendered logged-in menus but no identity link'
|
||||
: '1point3acres bbs rendered but no logged-in identity',
|
||||
};
|
||||
}
|
||||
return { ok: true, user_id: uid, username };
|
||||
})()
|
||||
`;
|
||||
|
||||
async function has1Point3AcresAuthCookie(page) {
|
||||
const host = await page.getCookies({ url: 'https://www.1point3acres.com' });
|
||||
const root = await page.getCookies({ url: 'https://.1point3acres.com' });
|
||||
return [...host, ...root].some(c => /_auth$/.test(c.name) && c.value);
|
||||
}
|
||||
|
||||
async function verify1Point3AcresIdentity(page) {
|
||||
if (!await has1Point3AcresAuthCookie(page)) {
|
||||
throw new AuthRequiredError('1point3acres.com', '1point3acres Discuz *_auth cookie missing');
|
||||
}
|
||||
await page.goto('https://www.1point3acres.com/bbs/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(IDENTITY_PROBE_JS);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('1point3acres.com', probe.detail);
|
||||
if (probe?.kind === 'shape') throw new CommandExecutionError(probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected 1point3acres probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, username: probe.username };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: '1point3acres',
|
||||
domain: '1point3acres.com',
|
||||
loginUrl: 'https://auth.1point3acres.com/login',
|
||||
columns: ['user_id', 'username'],
|
||||
quickCheck: has1Point3AcresAuthCookie,
|
||||
verify: verify1Point3AcresIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await has1Point3AcresAuthCookie(page)) {
|
||||
throw new AuthRequiredError('1point3acres.com', 'Waiting for 1point3acres Discuz *_auth cookie');
|
||||
}
|
||||
return verify1Point3AcresIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IDENTITY_PROBE_JS } from './auth.js';
|
||||
|
||||
function runIdentityProbe(html, url = 'https://www.1point3acres.com/bbs/') {
|
||||
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
|
||||
return dom.window.eval(IDENTITY_PROBE_JS);
|
||||
}
|
||||
|
||||
describe('1point3acres auth identity probe', () => {
|
||||
it('detects the current Discuz user-panel identity link', () => {
|
||||
const result = runIdentityProbe(`
|
||||
<div id="um">
|
||||
<a href="space-uid-123456.html" title="访问我的空间">test_user</a>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(result).toEqual({ ok: true, user_id: '123456', username: 'test_user' });
|
||||
});
|
||||
|
||||
it('keeps legacy identity selectors as fallbacks', () => {
|
||||
const result = runIdentityProbe(`
|
||||
<div id="um">
|
||||
<div class="vwmy"><h4><a href="home.php?mod=space&uid=42">legacy_user</a></h4></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(result).toEqual({ ok: true, user_id: '42', username: 'legacy_user' });
|
||||
});
|
||||
|
||||
it('does not report a successful blank identity when only logged-in menu ids render', () => {
|
||||
const result = runIdentityProbe('<div id="g_upmine"></div><div id="extcreditmenu"></div>');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'shape',
|
||||
detail: '1point3acres bbs rendered logged-in menus but no identity link',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an anonymous login link as auth required', () => {
|
||||
const result = runIdentityProbe('<a href="https://auth.1point3acres.com/login">登录</a>');
|
||||
|
||||
expect(result).toMatchObject({ kind: 'auth' });
|
||||
});
|
||||
});
|
||||
@@ -52,12 +52,15 @@ cli({
|
||||
if (!data?.title) {
|
||||
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
|
||||
}
|
||||
if (!data.body) {
|
||||
throw new CliError('PARSE_ERROR', 'Article body not found', '36kr page loaded but no article body paragraphs were extracted');
|
||||
}
|
||||
return [
|
||||
{ field: 'title', value: data.title },
|
||||
{ field: 'author', value: data.author || '-' },
|
||||
{ field: 'date', value: data.date || '-' },
|
||||
{ field: 'author', value: data.author || '' },
|
||||
{ field: 'date', value: data.date || '' },
|
||||
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
|
||||
{ field: 'body', value: data.body || '-' },
|
||||
{ field: 'body', value: data.body || '' },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import './article.js';
|
||||
|
||||
function makePage(evaluateResult) {
|
||||
return {
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
};
|
||||
}
|
||||
|
||||
describe('36kr article', () => {
|
||||
it('emits empty-string for missing optional author / date instead of a sentinel', async () => {
|
||||
const command = getRegistry().get('36kr/article');
|
||||
expect(command?.func).toBeDefined();
|
||||
const page = makePage({ title: 'Real Title', author: '', date: '', body: 'Real article body' });
|
||||
const rows = await command.func(page, { id: '1234567' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.title).toBe('Real Title');
|
||||
expect(byField.author).toBe('');
|
||||
expect(byField.date).toBe('');
|
||||
expect(byField.body).toBe('Real article body');
|
||||
expect(byField.url).toBe('https://36kr.com/p/1234567');
|
||||
});
|
||||
|
||||
it('throws CliError NOT_FOUND when the page exposes no title', async () => {
|
||||
const command = getRegistry().get('36kr/article');
|
||||
const page = makePage({ title: '', author: 'x', date: 'y', body: 'z' });
|
||||
await expect(command.func(page, { id: '1234567' })).rejects.toBeInstanceOf(CliError);
|
||||
});
|
||||
|
||||
it('throws CliError PARSE_ERROR when the page exposes title but no body', async () => {
|
||||
const command = getRegistry().get('36kr/article');
|
||||
const page = makePage({ title: 'Real Title', author: 'x', date: 'y', body: '' });
|
||||
await expect(command.func(page, { id: '1234567' })).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
||||
});
|
||||
|
||||
it('throws CliError INVALID_ARGUMENT when no numeric id can be parsed', async () => {
|
||||
const command = getRegistry().get('36kr/article');
|
||||
const page = makePage({});
|
||||
await expect(command.func(page, { id: 'not-a-url' })).rejects.toBeInstanceOf(CliError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { htmlToMarkdown as coreHtmlToMarkdown } from '@jackwener/opencli/utils';
|
||||
import {
|
||||
ArgumentError,
|
||||
AuthRequiredError,
|
||||
CommandExecutionError,
|
||||
ConfigError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
const USER_AGENT = 'opencli-atlassian-adapter (+https://github.com/jackwener/opencli)';
|
||||
const DEPLOYMENTS = new Set(['cloud', 'datacenter', 'auto']);
|
||||
|
||||
function firstEnv(names) {
|
||||
for (const name of names) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value, label) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
throw new ConfigError(`Missing ${label}`, `Set ${label}, for example https://example.atlassian.net`);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new ConfigError(`Invalid ${label}: ${raw}`, 'Use an absolute http(s) URL.');
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new ConfigError(`Invalid ${label}: ${raw}`, 'Use an http(s) URL.');
|
||||
}
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function parseDeployment(raw, baseUrl) {
|
||||
const value = String(raw || 'auto').trim().toLowerCase();
|
||||
if (!DEPLOYMENTS.has(value)) {
|
||||
throw new ConfigError('Invalid ATLASSIAN_DEPLOYMENT', 'Expected one of: cloud, datacenter, auto.');
|
||||
}
|
||||
if (value !== 'auto') return value;
|
||||
const host = new URL(baseUrl).hostname;
|
||||
return host === 'atlassian.net' || host.endsWith('.atlassian.net') ? 'cloud' : 'datacenter';
|
||||
}
|
||||
|
||||
function appendPath(baseUrl, suffix) {
|
||||
const base = new URL(baseUrl);
|
||||
const path = base.pathname.replace(/\/+$/, '');
|
||||
base.pathname = `${path}${suffix}`;
|
||||
return base.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizeConfluenceBaseUrl(baseUrl, deployment) {
|
||||
if (deployment !== 'cloud') return baseUrl;
|
||||
const parsed = new URL(baseUrl);
|
||||
const normalized = parsed.pathname.replace(/\/+$/, '');
|
||||
if (normalized === '/wiki' || normalized.endsWith('/wiki')) return baseUrl;
|
||||
return appendPath(baseUrl, '/wiki');
|
||||
}
|
||||
|
||||
function basicAuth(user, token) {
|
||||
return `Basic ${Buffer.from(`${user}:${token}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function resolveAuthHeaders(deployment, productLabel) {
|
||||
const bearer = firstEnv(['ATLASSIAN_BEARER_TOKEN', 'ATLASSIAN_OAUTH_TOKEN']);
|
||||
if (bearer) return { Authorization: `Bearer ${bearer}` };
|
||||
|
||||
const pat = firstEnv(['ATLASSIAN_PAT', `${productLabel.toUpperCase()}_PAT`]);
|
||||
if (deployment === 'datacenter' && pat) return { Authorization: `Bearer ${pat}` };
|
||||
|
||||
const prefix = productLabel.toUpperCase();
|
||||
const email = firstEnv(['ATLASSIAN_EMAIL', 'ATLASSIAN_USERNAME', `${prefix}_EMAIL`, `${prefix}_USERNAME`]);
|
||||
const token = firstEnv(['ATLASSIAN_API_TOKEN', 'ATLASSIAN_PASSWORD', `${prefix}_API_TOKEN`, `${prefix}_PASSWORD`]);
|
||||
if (email && token) return { Authorization: basicAuth(email, token) };
|
||||
|
||||
if (deployment === 'cloud') {
|
||||
throw new ConfigError(
|
||||
'Missing Atlassian Cloud credentials',
|
||||
'Set ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN, or set ATLASSIAN_BEARER_TOKEN for OAuth.',
|
||||
);
|
||||
}
|
||||
throw new ConfigError(
|
||||
'Missing Atlassian Data Center credentials',
|
||||
'Set ATLASSIAN_PAT, ATLASSIAN_BEARER_TOKEN, or ATLASSIAN_USERNAME plus ATLASSIAN_PASSWORD.',
|
||||
);
|
||||
}
|
||||
|
||||
export function getJiraConfig() {
|
||||
const baseUrl = normalizeBaseUrl(firstEnv(['ATLASSIAN_JIRA_BASE_URL', 'JIRA_BASE_URL']), 'ATLASSIAN_JIRA_BASE_URL');
|
||||
const deployment = parseDeployment(process.env.ATLASSIAN_DEPLOYMENT, baseUrl);
|
||||
return {
|
||||
product: 'jira',
|
||||
baseUrl,
|
||||
deployment,
|
||||
authHeaders: resolveAuthHeaders(deployment, 'jira'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfluenceConfig() {
|
||||
const initialBaseUrl = normalizeBaseUrl(
|
||||
firstEnv(['ATLASSIAN_CONFLUENCE_BASE_URL', 'CONFLUENCE_BASE_URL']),
|
||||
'ATLASSIAN_CONFLUENCE_BASE_URL',
|
||||
);
|
||||
const deployment = parseDeployment(process.env.ATLASSIAN_DEPLOYMENT, initialBaseUrl);
|
||||
return {
|
||||
product: 'confluence',
|
||||
baseUrl: normalizeConfluenceBaseUrl(initialBaseUrl, deployment),
|
||||
deployment,
|
||||
authHeaders: resolveAuthHeaders(deployment, 'confluence'),
|
||||
};
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl, apiPath) {
|
||||
if (/^https?:\/\//i.test(apiPath)) return apiPath;
|
||||
const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
function summarizeApiError(parsed, fallback) {
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const messages = [];
|
||||
if (Array.isArray(parsed.errorMessages)) messages.push(...parsed.errorMessages.filter(Boolean));
|
||||
if (typeof parsed.message === 'string') messages.push(parsed.message);
|
||||
if (typeof parsed.error === 'string') messages.push(parsed.error);
|
||||
if (typeof parsed.reason === 'string') messages.push(parsed.reason);
|
||||
if (parsed.errors && typeof parsed.errors === 'object') {
|
||||
for (const [key, value] of Object.entries(parsed.errors)) {
|
||||
messages.push(`${key}: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
if (messages.length) return messages.join(' · ');
|
||||
}
|
||||
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim().slice(0, 300);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function parseResponseBody(resp, label) {
|
||||
let text;
|
||||
try {
|
||||
text = await resp.text();
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(
|
||||
`${label} response body could not be read: ${err?.message ?? err}`,
|
||||
'Check whether the Atlassian instance, proxy, or network interrupted the response.',
|
||||
);
|
||||
}
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export async function atlassianRequest(config, apiPath, options = {}) {
|
||||
const method = (options.method ?? 'GET').toUpperCase();
|
||||
const label = options.label ?? `${config.product} ${method} ${apiPath}`;
|
||||
const headers = {
|
||||
'user-agent': USER_AGENT,
|
||||
accept: 'application/json',
|
||||
...config.authHeaders,
|
||||
...(options.headers ?? {}),
|
||||
};
|
||||
let body;
|
||||
if (options.body !== undefined) {
|
||||
headers['content-type'] = headers['content-type'] ?? 'application/json';
|
||||
body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let resp;
|
||||
const url = joinUrl(config.baseUrl, apiPath);
|
||||
try {
|
||||
resp = await fetch(url, { method, headers, body });
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(
|
||||
`${label} request failed: ${err?.message ?? err}`,
|
||||
'Check the Atlassian base URL, VPN/network access, and proxy settings.',
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = await parseResponseBody(resp, label);
|
||||
if (resp.status === 401) {
|
||||
throw new AuthRequiredError(
|
||||
config.baseUrl,
|
||||
`${label} returned HTTP 401`,
|
||||
'Check Atlassian credentials and whether this instance accepts the configured auth method.',
|
||||
);
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
throw new AuthRequiredError(
|
||||
config.baseUrl,
|
||||
`${label} returned HTTP 403: ${summarizeApiError(parsed, 'forbidden')}`,
|
||||
'The authenticated user lacks permission for this Jira issue, Confluence page, or space.',
|
||||
);
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
throw new EmptyResultError(label, `Atlassian returned 404 for ${url}.`);
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
throw new CommandExecutionError(
|
||||
`${label} returned HTTP 409: ${summarizeApiError(parsed, 'version conflict')}`,
|
||||
'Reload the current Confluence page version and retry the update.',
|
||||
);
|
||||
}
|
||||
if (resp.status === 429) {
|
||||
throw new CommandExecutionError(`${label} returned HTTP 429 (rate limited)`, 'Wait and retry with a smaller limit.');
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}: ${summarizeApiError(parsed, resp.statusText)}`);
|
||||
}
|
||||
if (typeof parsed === 'string') {
|
||||
throw new CommandExecutionError(
|
||||
`${label} returned a non-JSON response`,
|
||||
'Expected Atlassian REST API JSON. Check the base URL and whether an HTML login, SSO, or proxy page was returned.',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function queryString(params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) qs.append(key, String(item));
|
||||
} else {
|
||||
qs.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function requireString(value, label) {
|
||||
const s = String(value ?? '').trim();
|
||||
if (!s) throw new ArgumentError(`${label} is required`);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function requirePayloadObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requirePayloadArray(value, label) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requirePayloadString(value, field, label) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
throw new CommandExecutionError(`${label} did not include a stable ${field}.`);
|
||||
}
|
||||
const s = String(value).trim();
|
||||
if (!s) throw new CommandExecutionError(`${label} did not include a stable ${field}.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function requireNonEmptyRows(rows, label, hint) {
|
||||
if (!rows.length) throw new EmptyResultError(label, hint);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseLimit(value, defaultValue = 20, maxValue = 100, label = 'limit') {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
if (n > maxValue) {
|
||||
throw new ArgumentError(`${label} must be <= ${maxValue}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function requireExecute(args, commandName) {
|
||||
if (args.execute !== true) {
|
||||
throw new ArgumentError(`${commandName} requires --execute to perform a remote write`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readUtf8File(filePath) {
|
||||
const path = requireString(filePath, '--file');
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await stat(path);
|
||||
} catch {
|
||||
throw new ArgumentError(`File not found: ${path}`);
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
throw new ArgumentError(`File must be a readable text file: ${path}`);
|
||||
}
|
||||
let raw;
|
||||
try {
|
||||
raw = await readFile(path);
|
||||
} catch {
|
||||
throw new ArgumentError(`File could not be read: ${path}`);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(raw);
|
||||
} catch {
|
||||
throw new ArgumentError(`File could not be decoded as UTF-8 text: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function htmlEscape(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(html) {
|
||||
return coreHtmlToMarkdown(String(html ?? ''));
|
||||
}
|
||||
|
||||
function applyAdfMarks(text, marks = []) {
|
||||
let out = text;
|
||||
for (const mark of marks) {
|
||||
const type = mark?.type;
|
||||
if (type === 'link' && mark.attrs?.href) out = `[${out}](${mark.attrs.href})`;
|
||||
else if (type === 'strong') out = `**${out}**`;
|
||||
else if (type === 'em') out = `_${out}_`;
|
||||
else if (type === 'code') out = `\`${out}\``;
|
||||
else if (type === 'strike') out = `~~${out}~~`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderAdfNode(node, depth = 0) {
|
||||
if (!node || typeof node !== 'object') return '';
|
||||
const content = Array.isArray(node.content) ? node.content : [];
|
||||
const renderChildren = (sep = '') => content.map((child) => renderAdfNode(child, depth)).filter(Boolean).join(sep);
|
||||
switch (node.type) {
|
||||
case 'doc':
|
||||
return content.map((child) => renderAdfNode(child, depth)).filter(Boolean).join('\n\n').trim();
|
||||
case 'paragraph':
|
||||
return renderChildren('');
|
||||
case 'text':
|
||||
return applyAdfMarks(String(node.text ?? ''), Array.isArray(node.marks) ? node.marks : []);
|
||||
case 'hardBreak':
|
||||
return '\n';
|
||||
case 'heading':
|
||||
return `${'#'.repeat(Math.max(1, Math.min(6, Number(node.attrs?.level ?? 2))))} ${renderChildren('')}`;
|
||||
case 'bulletList':
|
||||
return content.map((child) => renderAdfListItem(child, depth, '-')).join('\n');
|
||||
case 'orderedList':
|
||||
return content.map((child, i) => renderAdfListItem(child, depth, `${i + 1}.`)).join('\n');
|
||||
case 'listItem':
|
||||
return renderChildren('\n');
|
||||
case 'codeBlock':
|
||||
return `\`\`\`\n${renderChildren('')}\n\`\`\``;
|
||||
case 'blockquote':
|
||||
return renderChildren('\n').split('\n').map((line) => `> ${line}`).join('\n');
|
||||
case 'rule':
|
||||
return '---';
|
||||
case 'table':
|
||||
return renderAdfTable(content);
|
||||
case 'tableRow':
|
||||
return content.map((cell) => escapeMarkdownTableCell(renderAdfNode(cell, depth))).join(' | ');
|
||||
case 'tableHeader':
|
||||
case 'tableCell':
|
||||
return renderChildren(' ').replace(/\s+/g, ' ').trim();
|
||||
case 'mention':
|
||||
return node.attrs?.text ? String(node.attrs.text) : '';
|
||||
case 'emoji':
|
||||
return String(node.attrs?.shortName ?? node.attrs?.text ?? '');
|
||||
case 'inlineCard':
|
||||
return node.attrs?.url ? String(node.attrs.url) : '';
|
||||
default:
|
||||
return renderChildren('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdfListItem(node, depth, marker) {
|
||||
const indent = ' '.repeat(depth);
|
||||
const body = renderAdfNode(node, depth + 1).trim();
|
||||
const lines = body.split('\n');
|
||||
const [first, ...rest] = lines;
|
||||
return `${indent}${marker} ${first ?? ''}${rest.length ? `\n${rest.map((line) => `${indent} ${line}`).join('\n')}` : ''}`;
|
||||
}
|
||||
|
||||
function escapeMarkdownTableCell(value) {
|
||||
return String(value ?? '').replace(/\|/g, '\\|').replace(/\n+/g, '<br>').trim();
|
||||
}
|
||||
|
||||
function renderAdfTable(rows) {
|
||||
const matrix = rows
|
||||
.map((row) => {
|
||||
const cells = Array.isArray(row?.content) ? row.content : [];
|
||||
return cells.map((cell) => escapeMarkdownTableCell(renderAdfNode(cell)));
|
||||
})
|
||||
.filter((row) => row.length > 0);
|
||||
if (!matrix.length) return '';
|
||||
const colCount = Math.max(...matrix.map((row) => row.length));
|
||||
const normalize = (row) => Array.from({ length: colCount }, (_value, index) => row[index] ?? '').join(' | ');
|
||||
return [
|
||||
normalize(matrix[0]),
|
||||
Array.from({ length: colCount }, () => '---').join(' | '),
|
||||
...matrix.slice(1).map(normalize),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function adfToMarkdown(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
return renderAdfNode(value).trim();
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(value) {
|
||||
const src = String(value ?? '');
|
||||
const linkRe = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
||||
let out = '';
|
||||
let last = 0;
|
||||
for (const match of src.matchAll(linkRe)) {
|
||||
out += htmlEscape(src.slice(last, match.index));
|
||||
out += `<a href="${htmlEscape(match[2])}">${htmlEscape(match[1])}</a>`;
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
out += htmlEscape(src.slice(last));
|
||||
return out
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
}
|
||||
|
||||
function isMarkdownTable(lines, index) {
|
||||
return lines[index]?.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1] ?? '');
|
||||
}
|
||||
|
||||
function parseTableRow(line) {
|
||||
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function renderMarkdownTable(lines, start) {
|
||||
const rows = [];
|
||||
let index = start;
|
||||
rows.push(parseTableRow(lines[index]));
|
||||
index += 2;
|
||||
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
|
||||
rows.push(parseTableRow(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
const htmlRows = rows.map((row, rowIndex) => {
|
||||
const tag = rowIndex === 0 ? 'th' : 'td';
|
||||
return `<tr>${row.map((cell) => `<${tag}>${renderInlineMarkdown(cell)}</${tag}>`).join('')}</tr>`;
|
||||
}).join('');
|
||||
return { html: `<table><tbody>${htmlRows}</tbody></table>`, next: index };
|
||||
}
|
||||
|
||||
export function markdownToConfluenceStorage(markdown) {
|
||||
const lines = String(markdown ?? '').replace(/\r\n/g, '\n').split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let inCode = false;
|
||||
let codeLines = [];
|
||||
const listStack = [];
|
||||
|
||||
const closeOneList = () => {
|
||||
const current = listStack.pop();
|
||||
if (!current) return;
|
||||
if (current.liOpen) out.push('</li>');
|
||||
out.push(`</${current.tag}>`);
|
||||
};
|
||||
|
||||
const closeListsTo = (indent) => {
|
||||
while (listStack.length && listStack[listStack.length - 1].indent > indent) closeOneList();
|
||||
};
|
||||
|
||||
const closeAllLists = () => {
|
||||
while (listStack.length) closeOneList();
|
||||
};
|
||||
|
||||
const openList = (tag, indent) => {
|
||||
out.push(`<${tag}>`);
|
||||
listStack.push({ tag, indent, liOpen: false });
|
||||
};
|
||||
|
||||
const renderListItem = (tag, indent, text) => {
|
||||
closeListsTo(indent);
|
||||
let current = listStack[listStack.length - 1];
|
||||
if (current && current.indent === indent && current.tag !== tag) {
|
||||
closeOneList();
|
||||
current = listStack[listStack.length - 1];
|
||||
}
|
||||
if (!current || current.indent < indent) {
|
||||
openList(tag, indent);
|
||||
current = listStack[listStack.length - 1];
|
||||
}
|
||||
if (current.indent === indent && current.liOpen) {
|
||||
out.push('</li>');
|
||||
current.liOpen = false;
|
||||
}
|
||||
out.push(`<li>${renderInlineMarkdown(text)}`);
|
||||
current.liOpen = true;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const fence = line.match(/^```/);
|
||||
if (fence) {
|
||||
if (inCode) {
|
||||
out.push(`<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[${codeLines.join('\n')}]]></ac:plain-text-body></ac:structured-macro>`);
|
||||
codeLines = [];
|
||||
inCode = false;
|
||||
} else {
|
||||
closeAllLists();
|
||||
inCode = true;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
codeLines.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (!line.trim()) {
|
||||
closeAllLists();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (isMarkdownTable(lines, i)) {
|
||||
closeAllLists();
|
||||
const table = renderMarkdownTable(lines, i);
|
||||
out.push(table.html);
|
||||
i = table.next;
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
closeAllLists();
|
||||
out.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const unordered = line.match(/^(\s*)[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^(\s*)\d+\.\s+(.+)$/);
|
||||
if (unordered || ordered) {
|
||||
const match = unordered || ordered;
|
||||
const indent = match[1].replace(/\t/g, ' ').length;
|
||||
renderListItem(unordered ? 'ul' : 'ol', indent, match[2]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
closeAllLists();
|
||||
out.push(`<p>${renderInlineMarkdown(line)}</p>`);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
closeAllLists();
|
||||
if (inCode) {
|
||||
out.push(`<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[${codeLines.join('\n')}]]></ac:plain-text-body></ac:structured-macro>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
adfToMarkdown,
|
||||
atlassianRequest,
|
||||
getConfluenceConfig,
|
||||
getJiraConfig,
|
||||
htmlToMarkdown,
|
||||
markdownToConfluenceStorage,
|
||||
parseLimit,
|
||||
queryString,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, afterEach, vi } from 'vitest';
|
||||
import { __test__ } from './shared.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ATLASSIAN_CONFLUENCE_BASE_URL',
|
||||
'ATLASSIAN_DEPLOYMENT',
|
||||
'ATLASSIAN_EMAIL',
|
||||
'ATLASSIAN_API_TOKEN',
|
||||
'ATLASSIAN_PAT',
|
||||
'ATLASSIAN_JIRA_BASE_URL',
|
||||
];
|
||||
|
||||
function clearEnv() {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('atlassian shared helpers', () => {
|
||||
it('infers Confluence Cloud and appends /wiki', () => {
|
||||
clearEnv();
|
||||
process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://example.atlassian.net';
|
||||
process.env.ATLASSIAN_EMAIL = 'bot@example.com';
|
||||
process.env.ATLASSIAN_API_TOKEN = 'secret';
|
||||
const config = __test__.getConfluenceConfig();
|
||||
expect(config.deployment).toBe('cloud');
|
||||
expect(config.baseUrl).toBe('https://example.atlassian.net/wiki');
|
||||
expect(config.authHeaders.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it('uses Data Center PAT as bearer auth', () => {
|
||||
clearEnv();
|
||||
process.env.ATLASSIAN_JIRA_BASE_URL = 'https://jira.example.com';
|
||||
process.env.ATLASSIAN_DEPLOYMENT = 'datacenter';
|
||||
process.env.ATLASSIAN_PAT = 'pat-123';
|
||||
const config = __test__.getJiraConfig();
|
||||
expect(config.deployment).toBe('datacenter');
|
||||
expect(config.authHeaders.Authorization).toBe('Bearer pat-123');
|
||||
});
|
||||
|
||||
it('converts Jira ADF to Markdown', () => {
|
||||
const markdown = __test__.adfToMarkdown({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Broken ', marks: [{ type: 'strong' }] },
|
||||
{ type: 'text', text: 'checkout', marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'retry payment' }] }] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(markdown).toContain('**Broken **');
|
||||
expect(markdown).toContain('[checkout](https://example.com)');
|
||||
expect(markdown).toContain('- retry payment');
|
||||
});
|
||||
|
||||
it('escapes pipe characters inside ADF table cells', () => {
|
||||
const markdown = __test__.adfToMarkdown({
|
||||
type: 'doc',
|
||||
content: [{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Service' }] }] },
|
||||
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Notes' }] }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'payments' }] }] },
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a | b' }] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
expect(markdown).toContain('Service | Notes');
|
||||
expect(markdown).toContain('--- | ---');
|
||||
expect(markdown).toContain('payments | a \\| b');
|
||||
});
|
||||
|
||||
it('converts nested HTML to Markdown through the shared Turndown converter', () => {
|
||||
const markdown = __test__.htmlToMarkdown('<ul><li><strong>Root</strong><ul><li>Child</li></ul></li></ul><table><tr><th>A</th></tr><tr><td>B</td></tr></table>');
|
||||
expect(markdown).toContain('**Root**');
|
||||
expect(markdown).toContain('Child');
|
||||
expect(markdown).toContain('A');
|
||||
expect(markdown).toContain('B');
|
||||
});
|
||||
|
||||
it('converts Markdown to conservative Confluence storage XHTML', () => {
|
||||
const storage = __test__.markdownToConfluenceStorage([
|
||||
'# RCA',
|
||||
'',
|
||||
'- Impacted checkout',
|
||||
'',
|
||||
'| Service | Status |',
|
||||
'| --- | --- |',
|
||||
'| payments | fixed |',
|
||||
].join('\n'));
|
||||
expect(storage).toContain('<h1>RCA</h1>');
|
||||
expect(storage).toContain('<ul>');
|
||||
expect(storage).toContain('<table>');
|
||||
expect(storage).toContain('<td>fixed</td>');
|
||||
});
|
||||
|
||||
it('preserves nested Markdown lists in Confluence storage XHTML', () => {
|
||||
const storage = __test__.markdownToConfluenceStorage([
|
||||
'- Parent',
|
||||
' - Child',
|
||||
'- Next',
|
||||
].join('\n'));
|
||||
const compact = storage.replace(/\s*\n\s*/g, '');
|
||||
expect(compact).toContain('<ul><li>Parent<ul><li>Child</li></ul></li><li>Next</li></ul>');
|
||||
});
|
||||
|
||||
it('sends JSON requests with configured auth headers', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const data = await __test__.atlassianRequest({
|
||||
product: 'jira',
|
||||
baseUrl: 'https://jira.example.com',
|
||||
deployment: 'datacenter',
|
||||
authHeaders: { Authorization: 'Bearer token' },
|
||||
}, '/rest/api/2/myself', { label: 'jira myself' });
|
||||
expect(data).toEqual({ ok: true });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://jira.example.com/rest/api/2/myself');
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('maps auth and rate-limit responses to typed errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ message: 'bad token' }), { status: 401 })));
|
||||
await expect(__test__.atlassianRequest({
|
||||
product: 'jira',
|
||||
baseUrl: 'https://jira.example.com',
|
||||
deployment: 'datacenter',
|
||||
authHeaders: { Authorization: 'Bearer token' },
|
||||
}, '/rest/api/2/myself', { label: 'jira myself' })).rejects.toMatchObject({ code: 'AUTH_REQUIRED' });
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ message: 'slow down' }), { status: 429 })));
|
||||
await expect(__test__.atlassianRequest({
|
||||
product: 'jira',
|
||||
baseUrl: 'https://jira.example.com',
|
||||
deployment: 'datacenter',
|
||||
authHeaders: { Authorization: 'Bearer token' },
|
||||
}, '/rest/api/2/myself', { label: 'jira myself' })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
||||
});
|
||||
|
||||
it('fails typed when a successful Atlassian REST response is not JSON', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>login</html>', { status: 200, headers: { 'content-type': 'text/html' } })));
|
||||
await expect(__test__.atlassianRequest({
|
||||
product: 'jira',
|
||||
baseUrl: 'https://jira.example.com',
|
||||
deployment: 'datacenter',
|
||||
authHeaders: { Authorization: 'Bearer token' },
|
||||
}, '/rest/api/2/myself', { label: 'jira myself' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
export function requireSearchQuery(value, label = 'keyword') {
|
||||
const query = String(value ?? '').trim();
|
||||
if (!query) {
|
||||
throw new ArgumentError(`${label} cannot be empty`);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export function requireBoundedInteger(value, defaultValue, min, max, label) {
|
||||
const raw = value ?? defaultValue;
|
||||
const parsed = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
if (parsed < min || parsed > max) {
|
||||
throw new ArgumentError(`${label} must be between ${min} and ${max}, got ${parsed}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function requireNonNegativeInteger(value, defaultValue, label) {
|
||||
const raw = value ?? defaultValue;
|
||||
const parsed = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new ArgumentError(`${label} must be a non-negative integer, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function unwrapBrowserResult(value) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
|
||||
return value.data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireRows(value, label) {
|
||||
const rows = unwrapBrowserResult(value);
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array of result rows.`);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function toHttpsUrl(value, baseUrl) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const url = new URL(raw, baseUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
|
||||
return url.href;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function emptySearchResults(site, query) {
|
||||
return new EmptyResultError(`${site} search`, `No ${site} results matched "${query}".`);
|
||||
}
|
||||
|
||||
export async function runBrowserStep(label, fn) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error?.code || error?.name === 'ArgumentError') throw error;
|
||||
throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { AuthRequiredError, TimeoutError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
const DEFAULT_TIMEOUT_SECONDS = 300;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
function normalizeIdentity(site, identity) {
|
||||
const row = identity && typeof identity === 'object' && !Array.isArray(identity)
|
||||
? identity
|
||||
: {};
|
||||
return { logged_in: true, site, ...row };
|
||||
}
|
||||
|
||||
function isAuthRequired(error) {
|
||||
return error instanceof AuthRequiredError;
|
||||
}
|
||||
|
||||
async function tryProbe(config, page, phase) {
|
||||
const probe = phase === 'poll' && config.poll ? config.poll : config.verify;
|
||||
return normalizeIdentity(config.site, await probe(page, { phase }));
|
||||
}
|
||||
|
||||
function authHint(config) {
|
||||
return `Run \`opencli ${config.site} login\` to open the login page, then retry.`;
|
||||
}
|
||||
|
||||
function commandColumns(config) {
|
||||
const identityColumns = config.columns ?? ['id', 'username', 'name'];
|
||||
return ['logged_in', 'site', ...identityColumns];
|
||||
}
|
||||
|
||||
function normalizeQuickCheck(result) {
|
||||
if (typeof result === 'boolean') return { logged_in: result };
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
return { logged_in: !!result.logged_in, ...result };
|
||||
}
|
||||
return { logged_in: false };
|
||||
}
|
||||
|
||||
function normalizeRefreshResult(result) {
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) return result;
|
||||
return { touched: true };
|
||||
}
|
||||
|
||||
export function registerSiteAuthCommands(config) {
|
||||
if (!config?.site || !config?.domain || !config?.loginUrl || typeof config.verify !== 'function') {
|
||||
throw new Error('registerSiteAuthCommands requires site, domain, loginUrl, and verify(page)');
|
||||
}
|
||||
|
||||
cli({
|
||||
site: config.site,
|
||||
name: 'whoami',
|
||||
access: 'read',
|
||||
description: config.whoamiDescription ?? `Show the current logged-in ${config.site} account`,
|
||||
domain: config.domain,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
siteSession: 'persistent',
|
||||
args: [],
|
||||
columns: commandColumns(config),
|
||||
authStatus: {
|
||||
...(typeof config.quickCheck === 'function'
|
||||
? { quickCheck: async (page) => normalizeQuickCheck(await config.quickCheck(page)) }
|
||||
: {}),
|
||||
...(typeof config.refresh === 'function'
|
||||
? { refresh: async (page, kwargs) => normalizeRefreshResult(await config.refresh(page, kwargs)) }
|
||||
: {}),
|
||||
},
|
||||
func: async (page) => tryProbe(config, page, 'identity'),
|
||||
});
|
||||
|
||||
cli({
|
||||
site: config.site,
|
||||
name: 'login',
|
||||
access: 'write',
|
||||
description: config.loginDescription ?? `Open ${config.site} login and wait until the browser session is authenticated`,
|
||||
domain: config.domain,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
defaultWindowMode: 'foreground',
|
||||
siteSession: 'persistent',
|
||||
args: [
|
||||
{ name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
|
||||
],
|
||||
columns: ['status', ...commandColumns(config)],
|
||||
func: async (page, kwargs) => {
|
||||
try {
|
||||
return { status: 'already_logged_in', ...await tryProbe(config, page, 'identity') };
|
||||
} catch (error) {
|
||||
if (!isAuthRequired(error)) throw error;
|
||||
}
|
||||
|
||||
await page.goto(config.loginUrl);
|
||||
const timeoutSeconds = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
let lastAuthMessage = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await page.wait(Math.min(POLL_INTERVAL_MS / 1000, Math.max(0.2, (deadline - Date.now()) / 1000)));
|
||||
try {
|
||||
const identity = await tryProbe(config, page, 'poll');
|
||||
return { status: 'login_complete', ...identity };
|
||||
} catch (error) {
|
||||
if (!isAuthRequired(error)) throw error;
|
||||
lastAuthMessage = getErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutError(
|
||||
`${config.site} login`,
|
||||
timeoutSeconds,
|
||||
lastAuthMessage ? `${authHint(config)} Last auth check: ${lastAuthMessage}` : authHint(config),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthRequiredError, TimeoutError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { registerSiteAuthCommands } from './site-auth.js';
|
||||
|
||||
function pageMock() {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('site auth command helper', () => {
|
||||
it('registers whoami and foreground login commands', () => {
|
||||
registerSiteAuthCommands({
|
||||
site: 'auth-helper-registration',
|
||||
domain: 'example.com',
|
||||
loginUrl: 'https://example.com/login',
|
||||
columns: ['username'],
|
||||
verify: async () => ({ username: 'alice' }),
|
||||
});
|
||||
|
||||
expect(getRegistry().get('auth-helper-registration/whoami')).toMatchObject({
|
||||
access: 'read',
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
columns: ['logged_in', 'site', 'username'],
|
||||
});
|
||||
expect(getRegistry().get('auth-helper-registration/login')).toMatchObject({
|
||||
access: 'write',
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
defaultWindowMode: 'foreground',
|
||||
siteSession: 'persistent',
|
||||
columns: ['status', 'logged_in', 'site', 'username'],
|
||||
});
|
||||
});
|
||||
|
||||
it('whoami returns normalized identity without opening login', async () => {
|
||||
registerSiteAuthCommands({
|
||||
site: 'auth-helper-whoami',
|
||||
domain: 'example.com',
|
||||
loginUrl: 'https://example.com/login',
|
||||
columns: ['username'],
|
||||
verify: async () => ({ username: 'alice' }),
|
||||
});
|
||||
const cmd = getRegistry().get('auth-helper-whoami/whoami');
|
||||
const page = pageMock();
|
||||
|
||||
await expect(cmd.func(page, {})).resolves.toEqual({
|
||||
logged_in: true,
|
||||
site: 'auth-helper-whoami',
|
||||
username: 'alice',
|
||||
});
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('login opens the login URL and polls until authenticated', async () => {
|
||||
const poll = vi.fn()
|
||||
.mockRejectedValueOnce(new AuthRequiredError('example.com', 'not yet'))
|
||||
.mockResolvedValueOnce({ username: 'alice' });
|
||||
registerSiteAuthCommands({
|
||||
site: 'auth-helper-login',
|
||||
domain: 'example.com',
|
||||
loginUrl: 'https://example.com/login',
|
||||
columns: ['username'],
|
||||
verify: async () => { throw new AuthRequiredError('example.com', 'missing'); },
|
||||
poll,
|
||||
});
|
||||
const cmd = getRegistry().get('auth-helper-login/login');
|
||||
const page = pageMock();
|
||||
|
||||
await expect(cmd.func(page, { timeout: 1 })).resolves.toEqual({
|
||||
status: 'login_complete',
|
||||
logged_in: true,
|
||||
site: 'auth-helper-login',
|
||||
username: 'alice',
|
||||
});
|
||||
expect(page.goto).toHaveBeenCalledWith('https://example.com/login');
|
||||
expect(page.wait).toHaveBeenCalled();
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('login times out when auth never completes', async () => {
|
||||
registerSiteAuthCommands({
|
||||
site: 'auth-helper-timeout',
|
||||
domain: 'example.com',
|
||||
loginUrl: 'https://example.com/login',
|
||||
verify: async () => { throw new AuthRequiredError('example.com', 'missing'); },
|
||||
poll: async () => { throw new AuthRequiredError('example.com', 'still missing'); },
|
||||
});
|
||||
const cmd = getRegistry().get('auth-helper-timeout/login');
|
||||
const page = pageMock();
|
||||
|
||||
await expect(cmd.func(page, { timeout: 0 })).rejects.toBeInstanceOf(TimeoutError);
|
||||
expect(page.goto).toHaveBeenCalledWith('https://example.com/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasAmazonSessionCookies(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.amazon.com' });
|
||||
const names = new Set(cookies.map(c => c.name));
|
||||
return names.has('at-main') || names.has('x-main');
|
||||
}
|
||||
|
||||
async function verifyAmazonIdentity(page) {
|
||||
if (!await hasAmazonSessionCookies(page)) {
|
||||
throw new AuthRequiredError('amazon.com', 'Amazon auth cookies (at-main / x-main) are missing');
|
||||
}
|
||||
await page.goto('https://www.amazon.com/', { waitUntil: 'load' });
|
||||
await page.wait(3);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
const navLink = document.querySelector('#nav-link-accountList');
|
||||
if (!navLink) {
|
||||
return { kind: 'auth', detail: 'Amazon header missing nav-link-accountList — layout changed or robot challenge' };
|
||||
}
|
||||
const greeting = (navLink.querySelector('.nav-line-1, #nav-link-accountList-nav-line-1') || {}).textContent || '';
|
||||
const trimmed = greeting.trim();
|
||||
if (/sign\\s*in/i.test(trimmed)) {
|
||||
return { kind: 'auth', detail: 'Amazon header shows "Hello, sign in" — anonymous' };
|
||||
}
|
||||
const m = trimmed.match(/^Hello,?\\s+(.+)$/i);
|
||||
const name = m ? m[1].trim() : '';
|
||||
if (!name) {
|
||||
return { kind: 'auth', detail: 'Amazon greeting unparseable: ' + trimmed };
|
||||
}
|
||||
return { ok: true, user_name: name };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('amazon.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Amazon probe: ${JSON.stringify(probe)}`);
|
||||
return { user_name: probe.user_name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'amazon',
|
||||
domain: 'amazon.com',
|
||||
loginUrl: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2F&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.assoc_handle=usflex&openid.mode=checkid_setup&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0',
|
||||
columns: ['user_name'],
|
||||
quickCheck: hasAmazonSessionCookies,
|
||||
verify: verifyAmazonIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasAmazonSessionCookies(page)) {
|
||||
throw new AuthRequiredError('amazon.com', 'Waiting for Amazon at-main / x-main cookie');
|
||||
}
|
||||
return verifyAmazonIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
|
||||
import { DOMAIN, amazonHostFromInput, 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;
|
||||
@@ -9,7 +9,7 @@ function normalizeDiscussionPayload(payload) {
|
||||
const provenance = buildProvenance(sourceUrl);
|
||||
return {
|
||||
asin,
|
||||
product_url: asin ? normalizeProductUrl(asin) : null,
|
||||
product_url: asin ? normalizeProductUrl(sourceUrl) : null,
|
||||
discussion_url: sourceUrl,
|
||||
...provenance,
|
||||
average_rating_text: averageRatingText,
|
||||
@@ -71,7 +71,7 @@ async function readDiscussionPayload(page, input, limit) {
|
||||
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.');
|
||||
throw new AuthRequiredError(amazonHostFromInput(input) ?? DOMAIN, 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
|
||||
}
|
||||
const productPayload = await readCurrentDiscussionPayload(page, limit);
|
||||
if (hasDiscussionSummary(productPayload)) {
|
||||
@@ -111,7 +111,8 @@ cli({
|
||||
const payload = await readDiscussionPayload(page, input, limit);
|
||||
const normalized = normalizeDiscussionPayload(payload);
|
||||
if (!normalized.average_rating_text && !normalized.total_review_count_text) {
|
||||
throw new CommandExecutionError('amazon discussion page did not expose review summary', 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
|
||||
const landedUrl = cleanText(payload.href) || buildDiscussionUrl(input);
|
||||
throw new CommandExecutionError(`amazon discussion page did not expose review summary (landed on ${landedUrl})`, 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
|
||||
}
|
||||
return [normalized];
|
||||
},
|
||||
|
||||
@@ -41,6 +41,68 @@ describe('amazon discussion normalization', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the review marketplace in every emitted url', () => {
|
||||
const result = __test__.normalizeDiscussionPayload({
|
||||
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
|
||||
average_rating_text: '4.4 out of 5',
|
||||
total_review_count_text: '40 global ratings',
|
||||
qa_links: [],
|
||||
review_samples: [],
|
||||
});
|
||||
|
||||
expect(result.asin).toBe('B0FGCPFY9L');
|
||||
expect(result.discussion_url).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
|
||||
expect(result.product_url).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
|
||||
});
|
||||
|
||||
it('requests the review page on the marketplace the input names', async () => {
|
||||
const command = getRegistry().get('amazon/discussion');
|
||||
const page = createPageMock([
|
||||
{
|
||||
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
|
||||
title: 'Amazon.co.uk: Example product',
|
||||
body_text: 'Customer reviews',
|
||||
},
|
||||
{
|
||||
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
|
||||
average_rating_text: '4.4 out of 5',
|
||||
total_review_count_text: '40 global ratings',
|
||||
review_samples: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await command.func(page, { input: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', limit: 1 });
|
||||
|
||||
expect(page.goto.mock.calls[0][0]).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
|
||||
});
|
||||
|
||||
it('names the loaded url when neither page exposes a review summary', async () => {
|
||||
const command = getRegistry().get('amazon/discussion');
|
||||
const emptyPayload = { href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', average_rating_text: '', total_review_count_text: '', review_samples: [] };
|
||||
const page = createPageMock([
|
||||
{ href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Customer reviews' },
|
||||
emptyPayload,
|
||||
{ href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Product' },
|
||||
emptyPayload,
|
||||
]);
|
||||
|
||||
await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 }))
|
||||
.rejects.toThrow('landed on https://www.amazon.co.uk/dp/B0FGCPFY9L');
|
||||
});
|
||||
|
||||
it('points a gated non-US review page at that marketplace, not the US store', async () => {
|
||||
const command = getRegistry().get('amazon/discussion');
|
||||
const signIn = { href: 'https://www.amazon.co.uk/ap/signin', title: 'Amazon Sign-In', body_text: 'Sign in Create account' };
|
||||
const page = createPageMock([
|
||||
signIn,
|
||||
{ href: signIn.href, average_rating_text: '', total_review_count_text: '', review_samples: [] },
|
||||
signIn,
|
||||
]);
|
||||
|
||||
await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 }))
|
||||
.rejects.toMatchObject({ domain: 'www.amazon.co.uk' });
|
||||
});
|
||||
|
||||
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([
|
||||
|
||||
@@ -83,7 +83,8 @@ cli({
|
||||
const input = String(kwargs.input ?? '');
|
||||
const payload = await readProductPayload(page, input);
|
||||
if (!cleanText(payload.product_title)) {
|
||||
throw new CommandExecutionError('amazon product page did not expose product content', 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
|
||||
const landedUrl = cleanText(payload.href) || buildProductUrl(input);
|
||||
throw new CommandExecutionError(`amazon product page did not expose product content (landed on ${landedUrl})`, 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
|
||||
}
|
||||
return [normalizeProductPayload(payload)];
|
||||
},
|
||||
|
||||
+57
-4
@@ -20,6 +20,42 @@ export const PRIMARY_PRICE_SELECTORS = [
|
||||
'#priceblock_dealprice',
|
||||
'#tp_price_block_total_price_ww',
|
||||
];
|
||||
// Keep this explicit because these hosts are navigation targets in the user's
|
||||
// signed-in browser. A shape-only `amazon.<tld>` pattern also accepts unrelated
|
||||
// registrable domains such as amazon.shop or amazon.zip.
|
||||
const MARKETPLACE_DOMAINS = new Set([
|
||||
'amazon.com',
|
||||
'amazon.ca',
|
||||
'amazon.com.mx',
|
||||
'amazon.com.br',
|
||||
'amazon.co.uk',
|
||||
'amazon.de',
|
||||
'amazon.fr',
|
||||
'amazon.it',
|
||||
'amazon.es',
|
||||
'amazon.nl',
|
||||
'amazon.pl',
|
||||
'amazon.se',
|
||||
'amazon.com.be',
|
||||
'amazon.ie',
|
||||
'amazon.com.tr',
|
||||
'amazon.ae',
|
||||
'amazon.sa',
|
||||
'amazon.eg',
|
||||
'amazon.co.za',
|
||||
'amazon.in',
|
||||
'amazon.co.jp',
|
||||
'amazon.com.au',
|
||||
'amazon.sg',
|
||||
]);
|
||||
function isAmazonMarketplaceHost(hostname) {
|
||||
const normalized = cleanText(hostname).toLowerCase().replace(/\.$/, '');
|
||||
for (const domain of MARKETPLACE_DOMAINS) {
|
||||
if (normalized === domain || normalized.endsWith(`.${domain}`))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const ROBOT_TEXT_PATTERNS = [
|
||||
'Sorry, we just need to make sure you\'re not a robot',
|
||||
'Enter the characters you see below',
|
||||
@@ -91,19 +127,33 @@ export function extractAsin(input) {
|
||||
const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i);
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
export function amazonHostFromInput(input) {
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized)
|
||||
return null;
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
return isAmazonMarketplaceHost(url.hostname) ? url.hostname : null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function buildProductUrl(input) {
|
||||
const asin = extractAsin(input);
|
||||
if (!asin) {
|
||||
throw new ArgumentError('amazon product expects an ASIN or product URL', 'Example: opencli amazon product B0FJS72893');
|
||||
}
|
||||
return `${PRODUCT_URL_PREFIX}${asin}`;
|
||||
const host = amazonHostFromInput(input);
|
||||
return host ? `https://${host}/dp/${asin}` : `${PRODUCT_URL_PREFIX}${asin}`;
|
||||
}
|
||||
export function buildDiscussionUrl(input) {
|
||||
const asin = extractAsin(input);
|
||||
if (!asin) {
|
||||
throw new ArgumentError('amazon discussion expects an ASIN or product URL', 'Example: opencli amazon discussion B0FJS72893');
|
||||
}
|
||||
return `${DISCUSSION_URL_PREFIX}${asin}`;
|
||||
const host = amazonHostFromInput(input);
|
||||
return host ? `https://${host}/product-reviews/${asin}` : `${DISCUSSION_URL_PREFIX}${asin}`;
|
||||
}
|
||||
function getRankingSpec(listType) {
|
||||
return AMAZON_RANKING_SPECS[listType];
|
||||
@@ -206,7 +256,7 @@ export function resolveBestsellersUrl(input) {
|
||||
export function canonicalizeAmazonUrl(input) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
if (!url.hostname.endsWith(DOMAIN)) {
|
||||
if (!isAmazonMarketplaceHost(url.hostname)) {
|
||||
throw new Error('not-amazon');
|
||||
}
|
||||
return url.toString();
|
||||
@@ -230,7 +280,7 @@ export function normalizeProductUrl(value) {
|
||||
const normalized = cleanText(value);
|
||||
const asin = extractAsin(normalized);
|
||||
if (asin)
|
||||
return buildProductUrl(asin);
|
||||
return buildProductUrl(normalized);
|
||||
return toAbsoluteAmazonUrl(normalized);
|
||||
}
|
||||
export function parsePriceText(text) {
|
||||
@@ -347,8 +397,11 @@ export function assertUsableState(state, action) {
|
||||
export const __test__ = {
|
||||
buildSearchUrl,
|
||||
extractAsin,
|
||||
amazonHostFromInput,
|
||||
buildProductUrl,
|
||||
buildDiscussionUrl,
|
||||
normalizeProductUrl,
|
||||
canonicalizeAmazonUrl,
|
||||
resolveBestsellersUrl,
|
||||
resolveRankingUrl,
|
||||
isSupportedRankingPath,
|
||||
|
||||
@@ -6,6 +6,35 @@ describe('amazon shared helpers', () => {
|
||||
expect(__test__.buildProductUrl('https://www.amazon.com/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.com/dp/B0FJS72893');
|
||||
expect(__test__.buildDiscussionUrl('https://www.amazon.com/dp/B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
|
||||
});
|
||||
it('keeps the input marketplace instead of rewriting it to the US store', () => {
|
||||
expect(__test__.buildProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
|
||||
expect(__test__.buildProductUrl('https://www.amazon.de/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.de/dp/B0FJS72893');
|
||||
expect(__test__.buildProductUrl('https://www.amazon.com.au/dp/B0FJS72893')).toBe('https://www.amazon.com.au/dp/B0FJS72893');
|
||||
expect(__test__.buildDiscussionUrl('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L?pageNumber=1')).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
|
||||
expect(__test__.normalizeProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
|
||||
});
|
||||
it('defaults to the US store for bare ASINs and non-marketplace hosts', () => {
|
||||
expect(__test__.amazonHostFromInput('B0FJS72893')).toBeNull();
|
||||
expect(__test__.buildProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
|
||||
expect(__test__.buildDiscussionUrl('B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
|
||||
expect(__test__.normalizeProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
|
||||
});
|
||||
it('accepts sibling marketplaces but rejects look-alike hosts', () => {
|
||||
expect(__test__.amazonHostFromInput('https://www.amazon.co.uk/dp/B0FJS72893')).toBe('www.amazon.co.uk');
|
||||
expect(__test__.amazonHostFromInput('https://amazon.de/dp/B0FJS72893')).toBe('amazon.de');
|
||||
expect(__test__.amazonHostFromInput('https://amazon.com.au/dp/B0FJS72893')).toBe('amazon.com.au');
|
||||
expect(__test__.amazonHostFromInput('https://smile.amazon.com.be/dp/B0FJS72893')).toBe('smile.amazon.com.be');
|
||||
expect(__test__.amazonHostFromInput('https://evilamazon.com/dp/B0FJS72893')).toBeNull();
|
||||
expect(__test__.amazonHostFromInput('https://amazon.com.evil.com/dp/B0FJS72893')).toBeNull();
|
||||
expect(__test__.amazonHostFromInput('https://amazon.evil.com/dp/B0FJS72893')).toBeNull();
|
||||
expect(__test__.amazonHostFromInput('https://x.amazon.evil.com/dp/B0FJS72893')).toBeNull();
|
||||
expect(__test__.amazonHostFromInput('https://amazon.attacker.io/dp/B0FJS72893')).toBeNull();
|
||||
expect(__test__.amazonHostFromInput('https://amazon.shop/dp/B0FJS72893')).toBeNull();
|
||||
expect(__test__.amazonHostFromInput('https://amazon.zip/dp/B0FJS72893')).toBeNull();
|
||||
expect(() => __test__.canonicalizeAmazonUrl('https://amazon.evil.com/gp/bestsellers')).toThrow('Invalid Amazon URL');
|
||||
expect(__test__.canonicalizeAmazonUrl('https://www.amazon.co.uk/gp/bestsellers/books')).toBe('https://www.amazon.co.uk/gp/bestsellers/books');
|
||||
expect(() => __test__.canonicalizeAmazonUrl('https://evilamazon.com/gp/bestsellers')).toThrow('Invalid Amazon URL');
|
||||
});
|
||||
it('parses price, rating, and review-count text', () => {
|
||||
expect(__test__.parsePriceText('1 offer from $34.11')).toEqual({
|
||||
price_text: '$34.11',
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Shared helpers for Antigravity sidebar conversation management.
|
||||
//
|
||||
// Each conversation in the sidebar is rendered as a row whose visible
|
||||
// title element has stable testid `convo-pill-<uuid>`. The row container
|
||||
// is the 3rd ancestor — it carries `role="button"` and acts as the
|
||||
// clickable row.
|
||||
//
|
||||
// On hover the row shows 3 icon-only buttons. The FIRST (button[0]) is a
|
||||
// "more options" 3-dot trigger that opens a 3-item dropdown:
|
||||
//
|
||||
// Mark as Read
|
||||
// Rename
|
||||
// Delete Conversation
|
||||
//
|
||||
// We use that dropdown for all management operations. Antigravity does
|
||||
// not currently expose Pin/Unpin as menu items (different model than
|
||||
// Codex / Grok).
|
||||
//
|
||||
// All clicks go through the full pointer-event chain because the menu is
|
||||
// likely radix-based and ignores bare .click().
|
||||
|
||||
import { CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
|
||||
|
||||
const PILL_SELECTOR_PREFIX = 'convo-pill-';
|
||||
|
||||
export function unwrapEvaluateResult(payload) {
|
||||
if (
|
||||
payload
|
||||
&& typeof payload === 'object'
|
||||
&& Object.prototype.hasOwnProperty.call(payload, 'data')
|
||||
&& Object.prototype.hasOwnProperty.call(payload, 'session')
|
||||
) {
|
||||
return payload.data;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildPillTestId(conversationId) {
|
||||
return `${PILL_SELECTOR_PREFIX}${String(conversationId).toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all visible conversation pills with their {id, title} for
|
||||
* history-style listings or for fuzzy match.
|
||||
*/
|
||||
export async function listConversations(page) {
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
return Array.from(document.querySelectorAll('[data-testid^="${PILL_SELECTOR_PREFIX}"]'))
|
||||
.filter((el) => el.offsetParent)
|
||||
.map((el, idx) => ({
|
||||
index: idx + 1,
|
||||
id: el.getAttribute('data-testid').slice(${PILL_SELECTOR_PREFIX.length}),
|
||||
title: (el.textContent || '').trim().slice(0, 200),
|
||||
}));
|
||||
})()`));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function conversationVisible(page, conversationId) {
|
||||
const testId = buildPillTestId(conversationId);
|
||||
return !!unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(`[data-testid="${testId}"]`)});
|
||||
return !!(el && el.offsetParent);
|
||||
})()`));
|
||||
}
|
||||
|
||||
export async function getConversationMenuLabels(page, conversationId) {
|
||||
const testId = buildPillTestId(conversationId);
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const pill = document.querySelector(${JSON.stringify(`[data-testid="${testId}"]`)});
|
||||
if (!pill) return { ok: false, reason: 'Conversation pill not found.', detail: 'testid=${testId}' };
|
||||
let row = pill;
|
||||
for (let i = 0; i < 3; i++) row = row.parentElement || row;
|
||||
row.scrollIntoView({ block: 'center' });
|
||||
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
let dotBtn = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await wait(80);
|
||||
const btns = Array.from(row.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
if (btns.length >= 1) { dotBtn = btns[0]; break; }
|
||||
}
|
||||
if (!dotBtn) return { ok: false, reason: 'Per-row 3-dot trigger never mounted after hover.' };
|
||||
const r = dotBtn.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(r.left + r.width / 2),
|
||||
clientY: Math.round(r.top + r.height / 2),
|
||||
};
|
||||
dotBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
dotBtn.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
dotBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
dotBtn.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
dotBtn.dispatchEvent(new MouseEvent('click', init));
|
||||
let menuItems = [];
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await wait(80);
|
||||
menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (menuItems.length) break;
|
||||
}
|
||||
const labels = menuItems.map((it) => {
|
||||
const clone = it.cloneNode(true);
|
||||
clone.querySelectorAll('kbd').forEach((k) => k.remove());
|
||||
return (clone.textContent || '').trim();
|
||||
}).filter(Boolean);
|
||||
document.body.click();
|
||||
return { ok: true, labels };
|
||||
})()`));
|
||||
return result || { ok: false, reason: 'Empty result from page.evaluate.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the per-row 3-dot menu for the given conversation, click the
|
||||
* menu item whose visible text matches `labelOptions`, return status.
|
||||
* Single page.evaluate so the menu stays mounted while we click.
|
||||
*
|
||||
* Returns { ok, clicked? , reason?, detail? }.
|
||||
*/
|
||||
export async function clickConversationMenuItem(page, conversationId, labelOptions) {
|
||||
const testId = buildPillTestId(conversationId);
|
||||
const testIdJson = JSON.stringify(testId);
|
||||
const labelsJson = JSON.stringify(labelOptions);
|
||||
|
||||
// Wrap in try/catch — Antigravity menu clicks often trigger a
|
||||
// sidebar re-render that destroys the eval reply mid-stream, surfacing
|
||||
// as "Promise was collected" or 30s Runtime.evaluate timeout. The
|
||||
// click DID happen (we verified live by toggling Mark as Read /
|
||||
// Unread). Treat these specific failures as success-with-no-confirmation
|
||||
// and let the caller re-query history to verify.
|
||||
let result;
|
||||
try {
|
||||
result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const testId = ${testIdJson};
|
||||
const labels = ${labelsJson};
|
||||
|
||||
const pill = document.querySelector(\`[data-testid="\${testId}"]\`);
|
||||
if (!pill) {
|
||||
return { ok: false, reason: 'Conversation pill not found.', detail: 'testid=' + testId };
|
||||
}
|
||||
|
||||
// Walk up to the row container — depth 3 holds the role="button" row
|
||||
// with the per-row action buttons.
|
||||
let row = pill;
|
||||
for (let i = 0; i < 3; i++) row = row.parentElement || row;
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'Could not locate the row container above the pill.' };
|
||||
}
|
||||
|
||||
row.scrollIntoView({ block: 'center' });
|
||||
|
||||
// React synthetic hover mounts the per-row buttons. Visibility-state
|
||||
// doesn't appear to gate Antigravity's overlay (unlike Codex), but
|
||||
// we still dispatch the full set for safety.
|
||||
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
|
||||
// Wait for the row's 3-dot trigger to mount.
|
||||
let dotBtn = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await wait(80);
|
||||
const btns = Array.from(row.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
if (btns.length >= 1) { dotBtn = btns[0]; break; } // First button == more-options
|
||||
}
|
||||
if (!dotBtn) {
|
||||
return { ok: false, reason: 'Per-row 3-dot trigger never mounted after hover.' };
|
||||
}
|
||||
|
||||
// Open the menu via full pointer chain.
|
||||
const r = dotBtn.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(r.left + r.width / 2),
|
||||
clientY: Math.round(r.top + r.height / 2),
|
||||
};
|
||||
dotBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
dotBtn.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
dotBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
dotBtn.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
dotBtn.dispatchEvent(new MouseEvent('click', init));
|
||||
|
||||
// Wait for menu items to mount.
|
||||
let menuItems = [];
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await wait(80);
|
||||
menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (menuItems.length) break;
|
||||
}
|
||||
if (!menuItems.length) {
|
||||
return { ok: false, reason: 'Conversation 3-dot menu did not open after click.' };
|
||||
}
|
||||
|
||||
function leadingText(el) {
|
||||
const clone = el.cloneNode(true);
|
||||
clone.querySelectorAll('kbd').forEach((k) => k.remove());
|
||||
return (clone.textContent || '').trim();
|
||||
}
|
||||
|
||||
let target = null;
|
||||
for (const item of menuItems) {
|
||||
const text = leadingText(item);
|
||||
for (const label of labels) {
|
||||
if (text === label || text.startsWith(label)) {
|
||||
target = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target) break;
|
||||
}
|
||||
if (!target) {
|
||||
const visible = menuItems.map(leadingText);
|
||||
document.body.click(); // close menu
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'No menu item matched the requested label.',
|
||||
detail: 'wanted=' + JSON.stringify(labels) + ' visible=' + JSON.stringify(visible),
|
||||
};
|
||||
}
|
||||
|
||||
// Click via pointer chain too — radix is picky.
|
||||
const tr = target.getBoundingClientRect();
|
||||
const tinit = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(tr.left + tr.width / 2),
|
||||
clientY: Math.round(tr.top + tr.height / 2),
|
||||
};
|
||||
const matchedLabel = leadingText(target);
|
||||
// Defer to next microtask so the eval reply returns before any re-render.
|
||||
Promise.resolve().then(() => {
|
||||
try {
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { ...tinit, pointerType: 'mouse' }));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', tinit));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', { ...tinit, pointerType: 'mouse' }));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', tinit));
|
||||
target.dispatchEvent(new MouseEvent('click', tinit));
|
||||
} catch {}
|
||||
});
|
||||
return { ok: true, clicked: matchedLabel };
|
||||
})()`));
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (/Promise was collected|timed out after \d+s|Runtime\.evaluate/i.test(msg)) {
|
||||
// Click was scheduled inside a microtask before destruction, so
|
||||
// the action almost certainly fired. Report ambiguous-but-likely-ok.
|
||||
return {
|
||||
ok: true,
|
||||
clicked: labelOptions[0],
|
||||
note: 'eval reply destroyed by post-click re-render; click likely fired',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result || { ok: false, reason: 'Empty result from page.evaluate.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* After Delete Conversation menu item is clicked, Antigravity shows a
|
||||
* confirm dialog. Locate it and click the confirm button.
|
||||
*/
|
||||
export async function confirmDeleteDialog(page, confirmLabels) {
|
||||
const labelsJson = JSON.stringify(confirmLabels);
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let dialog = null;
|
||||
for (let attempt = 0; attempt < 15; attempt += 1) {
|
||||
await wait(120);
|
||||
dialog = document.querySelector('[role="alertdialog"], [role="dialog"]');
|
||||
if (dialog && dialog.offsetParent) break;
|
||||
}
|
||||
if (!dialog) {
|
||||
return { ok: false, reason: 'Delete confirm dialog did not appear.' };
|
||||
}
|
||||
const buttons = Array.from(dialog.querySelectorAll('button'));
|
||||
const labels = ${labelsJson};
|
||||
const confirmBtn = buttons.find((b) => {
|
||||
const t = (b.textContent || '').trim();
|
||||
return labels.some((l) => t === l || t.toLowerCase() === l.toLowerCase());
|
||||
});
|
||||
if (!confirmBtn) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Confirm button not found in dialog.',
|
||||
detail: 'present=' + JSON.stringify(buttons.map((b) => (b.textContent || '').trim())),
|
||||
};
|
||||
}
|
||||
const r = confirmBtn.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, button: 0, buttons: 1, cancelable: true,
|
||||
clientX: Math.round(r.left + r.width / 2),
|
||||
clientY: Math.round(r.top + r.height / 2),
|
||||
};
|
||||
Promise.resolve().then(() => {
|
||||
try {
|
||||
confirmBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
confirmBtn.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
confirmBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
confirmBtn.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
confirmBtn.dispatchEvent(new MouseEvent('click', init));
|
||||
} catch {}
|
||||
});
|
||||
return { ok: true, confirmed: (confirmBtn.textContent || '').trim() };
|
||||
})()`));
|
||||
return result || { ok: false, reason: 'Empty result.' };
|
||||
}
|
||||
|
||||
export const conversationTargetArgs = [
|
||||
{
|
||||
name: 'id',
|
||||
positional: true,
|
||||
type: 'string',
|
||||
required: true,
|
||||
help: 'Conversation UUID (the part after "convo-pill-" in the sidebar testid)',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { listConversations } from './_actions.js';
|
||||
import './audit-extras.js';
|
||||
import './delete.js';
|
||||
import './history.js';
|
||||
import './mark-read.js';
|
||||
import './model.js';
|
||||
import './rename.js';
|
||||
import './storage.js';
|
||||
|
||||
function makePage(evaluateResults = []) {
|
||||
const queue = [...evaluateResults];
|
||||
return {
|
||||
evaluate: vi.fn(async () => (queue.length ? queue.shift() : null)),
|
||||
wait: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('antigravity command registration', () => {
|
||||
it('classifies commands by maximum side effect', () => {
|
||||
const expected = {
|
||||
history: 'read',
|
||||
delete: 'write',
|
||||
'mark-read': 'write',
|
||||
model: 'write',
|
||||
rename: 'write',
|
||||
'copy-message': 'write',
|
||||
'copy-code': 'read',
|
||||
'state-keys': 'read',
|
||||
'state-get': 'read',
|
||||
'recent-paths': 'read',
|
||||
'workspaces-list': 'read',
|
||||
'settings-read': 'read',
|
||||
};
|
||||
for (const [name, access] of Object.entries(expected)) {
|
||||
const command = getRegistry().get(`antigravity/${name}`);
|
||||
expect(command, `antigravity/${name}`).toBeDefined();
|
||||
expect(command.access).toBe(access);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('antigravity Browser Bridge envelopes', () => {
|
||||
it('unwraps conversation listings returned as { session, data }', async () => {
|
||||
const page = makePage([
|
||||
{ session: { id: 's1' }, data: [{ index: 1, id: 'abc', title: 'Demo' }] },
|
||||
]);
|
||||
|
||||
await expect(listConversations(page)).resolves.toEqual([
|
||||
{ index: 1, id: 'abc', title: 'Demo' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('antigravity write postconditions', () => {
|
||||
let deleteCommand;
|
||||
let markReadCommand;
|
||||
let modelCommand;
|
||||
let storageKeysCommand;
|
||||
|
||||
beforeAll(() => {
|
||||
deleteCommand = getRegistry().get('antigravity/delete');
|
||||
markReadCommand = getRegistry().get('antigravity/mark-read');
|
||||
modelCommand = getRegistry().get('antigravity/model');
|
||||
storageKeysCommand = getRegistry().get('antigravity/storage-keys');
|
||||
});
|
||||
|
||||
it('delete fails closed when the conversation remains visible after confirmation', async () => {
|
||||
const page = makePage([
|
||||
{ ok: true, clicked: 'Delete Conversation' },
|
||||
{ ok: true, confirmed: 'Delete' },
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
|
||||
await expect(deleteCommand.func(page, { id: 'abc', yes: true }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('mark-read refuses to toggle already-read rows back to unread', async () => {
|
||||
const page = makePage([
|
||||
{ ok: true, labels: ['Mark as Unread', 'Rename', 'Delete Conversation'] },
|
||||
]);
|
||||
|
||||
await expect(markReadCommand.func(page, { id: 'abc' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('model rejects ambiguous partial matches before clicking', async () => {
|
||||
const page = makePage([
|
||||
'Gemini 3.5 Flash',
|
||||
{ ok: false, reason: 'Ambiguous model match.', detail: 'wanted=gemini matches=["Gemini Pro","Gemini Flash"]' },
|
||||
]);
|
||||
|
||||
await expect(modelCommand.func(page, { name: 'gemini' }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
|
||||
it('model list mode never switches even when a name filter is supplied', async () => {
|
||||
const page = makePage([
|
||||
'Gemini 3.5 Flash',
|
||||
{ ok: true, labels: ['Gemini 3.5 Flash', 'Claude Sonnet'] },
|
||||
]);
|
||||
|
||||
await expect(modelCommand.func(page, { list: true, name: 'claude' })).resolves.toEqual([
|
||||
{ Status: 'Active', Model: 'Gemini 3.5 Flash' },
|
||||
{ Status: 'Available', Model: 'Claude Sonnet' },
|
||||
]);
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('model accepts an exact match before falling back to ambiguous partial matching', async () => {
|
||||
const page = makePage([
|
||||
'Gemini 3.5 Flash',
|
||||
{ ok: true, switched: true, chosen: 'Gemini Pro', labels: ['Gemini Pro', 'Gemini Pro Extended'] },
|
||||
'Gemini Pro',
|
||||
]);
|
||||
|
||||
await expect(modelCommand.func(page, { name: 'gemini pro' })).resolves.toEqual([
|
||||
{ Status: 'switched', Model: 'Gemini Pro' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('model fails closed when read-back does not prove the target is active', async () => {
|
||||
const page = makePage([
|
||||
'Gemini 3.5 Flash',
|
||||
{ ok: true, switched: true, chosen: 'Claude Sonnet', labels: ['Claude Sonnet'] },
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
'Gemini 3.5 Flash',
|
||||
]);
|
||||
|
||||
await expect(modelCommand.func(page, { name: 'claude' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('storage-keys unwraps Browser Bridge envelopes before shaping rows', async () => {
|
||||
const page = makePage([
|
||||
{ session: { id: 's1' }, data: [{ k: 'alpha', bytes: 12 }] },
|
||||
]);
|
||||
|
||||
await expect(storageKeysCommand.func(page, { storage: 'local' })).resolves.toEqual([
|
||||
{ Index: 1, Key: 'alpha', Bytes: 12 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copy-message click-button fails closed when the in-UI copy click fails', async () => {
|
||||
const copyMessageCommand = getRegistry().get('antigravity/copy-message');
|
||||
const page = makePage([
|
||||
{ text: 'assistant response' },
|
||||
{ ok: false, reason: 'No matching visible element.' },
|
||||
]);
|
||||
|
||||
await expect(copyMessageCommand.func(page, { 'click-button': true }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
// Deep-audit gap closers for Antigravity (port 9234).
|
||||
//
|
||||
// Live snapshot of CodexBar agent project (chat view) showed 49 visible
|
||||
// interactive elements / 28 unique labels. Beyond the 12 existing
|
||||
// commands, these 10 wrap the rest:
|
||||
//
|
||||
// react <good|bad> — Good response / Bad response
|
||||
// copy-message — text of last assistant turn (clicks last visible Copy)
|
||||
// copy-code [--index N] — copy a specific code block (uses Copy code button)
|
||||
// settings — click the settings-button data-testid
|
||||
// sidebar-toggle — click Toggle Sidebar
|
||||
// nav <back|forward> — Go Back / Go Forward
|
||||
// toggle-aux — Toggle Auxiliary Pane
|
||||
// display-options — open Display Options menu + list items
|
||||
// add-context — click Add context (opens file/url picker)
|
||||
// revert — click revert-button (per-message revert)
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import { unwrapEvaluateResult } from './_actions.js';
|
||||
|
||||
function clickFirstScript(sels) {
|
||||
return `(() => {
|
||||
const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
||||
for (const sel of ${JSON.stringify(sels)}) {
|
||||
const t = Array.from(document.querySelectorAll(sel)).filter(isVis)[0];
|
||||
if (t) {
|
||||
const r = t.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
t.dispatchEvent(new PointerEvent('pointerdown', opts));
|
||||
t.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
t.dispatchEvent(new PointerEvent('pointerup', opts));
|
||||
t.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
t.click();
|
||||
return { ok: true, sel };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: 'No matching visible element.' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
function clickLastScript(sels) {
|
||||
return `(() => {
|
||||
const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
||||
for (const sel of ${JSON.stringify(sels)}) {
|
||||
const found = Array.from(document.querySelectorAll(sel)).filter(isVis);
|
||||
if (found.length) {
|
||||
const t = found[found.length - 1];
|
||||
const r = t.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
t.dispatchEvent(new PointerEvent('pointerdown', opts));
|
||||
t.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
t.dispatchEvent(new PointerEvent('pointerup', opts));
|
||||
t.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
t.click();
|
||||
return { ok: true, sel };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: 'No matching visible element.' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
// -------- react --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'react',
|
||||
access: 'write',
|
||||
description: 'Click "Good response" or "Bad response" on the LAST assistant message.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'kind', positional: true, required: true, help: 'good or bad' },
|
||||
],
|
||||
columns: ['Status', 'Reaction'],
|
||||
func: async (page, kwargs) => {
|
||||
const kind = String(kwargs?.kind || '').trim().toLowerCase();
|
||||
if (kind !== 'good' && kind !== 'bad') throw new ArgumentError('kind', 'must be "good" or "bad"');
|
||||
const label = kind === 'good' ? 'Good response' : 'Bad response';
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickLastScript([`button[aria-label="${label}"]`])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || `${label} click failed`, '');
|
||||
return [{ Status: 'clicked', Reaction: kind }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- copy-message --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'copy-message',
|
||||
access: 'write',
|
||||
description: 'Return the text of the last assistant message (best-effort: walks up from the last visible Copy button).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'click-button', type: 'boolean', default: false, help: 'Also click the in-UI Copy button' },
|
||||
],
|
||||
columns: ['Field', 'Value'],
|
||||
func: async (page, kwargs) => {
|
||||
const data = unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
||||
// Antigravity has both "Copy" (message) and "Copy code" (code block) buttons.
|
||||
// We want the bottom-of-message Copy, not the code-block Copy.
|
||||
const copies = Array.from(document.querySelectorAll('button[aria-label="Copy"]')).filter(isVis);
|
||||
if (!copies.length) return null;
|
||||
const lastCopy = copies[copies.length - 1];
|
||||
let container = lastCopy;
|
||||
let best = '';
|
||||
for (let i = 0; i < 8 && container.parentElement; i++) {
|
||||
container = container.parentElement;
|
||||
const txt = (container.innerText || '').trim();
|
||||
if (txt.length > best.length) best = txt;
|
||||
if (best.length > 200) break;
|
||||
}
|
||||
return { text: best };
|
||||
})()`));
|
||||
if (!data) throw new EmptyResultError('antigravity copy-message', 'No Copy buttons visible — make sure an assistant reply is on screen.');
|
||||
if (kwargs?.['click-button'] === true || kwargs?.['click-button'] === 'true') {
|
||||
const clickResult = unwrapEvaluateResult(await page.evaluate(clickLastScript(['button[aria-label="Copy"]'])));
|
||||
if (!clickResult?.ok) {
|
||||
throw new CommandExecutionError(clickResult?.reason || 'Copy button click failed', '');
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ Field: 'Length', Value: String((data.text || '').length) + ' chars' },
|
||||
{ Field: 'ClipboardClicked', Value: (kwargs?.['click-button'] === true || kwargs?.['click-button'] === 'true') ? 'yes' : 'no' },
|
||||
{ Field: 'Text', Value: data.text || '' },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- copy-code --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'copy-code',
|
||||
access: 'read',
|
||||
description: 'Return the text of a code block in the current conversation. Default: last code block; pass --index N (1-based from top) to pick a specific one.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'index', type: 'int', required: false, help: '1-based index of code block (default: last)' },
|
||||
],
|
||||
columns: ['Field', 'Value'],
|
||||
func: async (page, kwargs) => {
|
||||
const idx = Number.isInteger(kwargs?.index) ? kwargs.index : null;
|
||||
const data = unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
||||
const btns = Array.from(document.querySelectorAll('button[aria-label="Copy code"]')).filter(isVis);
|
||||
if (!btns.length) return null;
|
||||
const idx = ${idx === null ? 'btns.length - 1' : (idx - 1)};
|
||||
const btn = btns[idx];
|
||||
if (!btn) return { err: 'index ' + (${idx} ?? 'last') + ' out of range. Have ' + btns.length + ' code blocks.' };
|
||||
// Find the <code> or <pre> element inside the parent block.
|
||||
let container = btn;
|
||||
for (let i = 0; i < 6 && container.parentElement; i++) container = container.parentElement;
|
||||
const code = container.querySelector('pre, code');
|
||||
return { text: code ? (code.innerText || '').trim() : (container.innerText || '').trim(), total: btns.length };
|
||||
})()`));
|
||||
if (!data) throw new EmptyResultError('antigravity copy-code', 'No code blocks visible.');
|
||||
if (data.err) throw new CommandExecutionError(data.err, '');
|
||||
return [
|
||||
{ Field: 'TotalCodeBlocks', Value: String(data.total) },
|
||||
{ Field: 'PickedIndex', Value: String(idx === null ? data.total : idx) },
|
||||
{ Field: 'Length', Value: String((data.text || '').length) + ' chars' },
|
||||
{ Field: 'Code', Value: data.text || '' },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- settings --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'settings',
|
||||
access: 'write',
|
||||
description: 'Click the Antigravity settings button (matched by data-testid="settings-button").',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript([
|
||||
'[data-testid="settings-button"]',
|
||||
'button[aria-label="Settings"]',
|
||||
])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'settings click failed', '');
|
||||
await page.wait(0.6);
|
||||
return [{ Status: `clicked via ${res.sel}` }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- sidebar-toggle --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'sidebar-toggle',
|
||||
access: 'write',
|
||||
description: 'Click Toggle Sidebar (collapses/expands the Antigravity sidebar).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Toggle Sidebar"]'])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'sidebar-toggle failed', '');
|
||||
return [{ Status: 'toggled' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- nav --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'nav',
|
||||
access: 'write',
|
||||
description: 'Click Go Back or Go Forward (Antigravity in-app history).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'direction', positional: true, required: true, help: 'back or forward' },
|
||||
],
|
||||
columns: ['Status'],
|
||||
func: async (page, kwargs) => {
|
||||
const dir = String(kwargs?.direction || '').trim().toLowerCase();
|
||||
if (dir !== 'back' && dir !== 'forward') throw new ArgumentError('direction', 'must be "back" or "forward"');
|
||||
const label = dir === 'back' ? 'Go Back' : 'Go Forward';
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript([`button[aria-label="${label}"]`])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || `${label} click failed`, '');
|
||||
return [{ Status: `${dir} clicked` }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- toggle-aux --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'toggle-aux',
|
||||
access: 'write',
|
||||
description: 'Toggle the Auxiliary Pane (Antigravity\'s secondary panel for code/preview).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Toggle Auxiliary Pane"]'])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'toggle-aux failed', '');
|
||||
return [{ Status: 'toggled' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- display-options --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'display-options',
|
||||
access: 'read',
|
||||
description: 'Open the Display Options menu and list its items.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Index', 'Item'],
|
||||
func: async (page) => {
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Display Options"]'])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'display-options click failed', '');
|
||||
await page.wait(0.4);
|
||||
// Antigravity renders Display Options as a [role="dialog"] popover,
|
||||
// NOT a [role="menu"]. Search both. Among visible candidates, prefer
|
||||
// the most-recently-mounted small popover (not a full-page dialog).
|
||||
const items = unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
||||
const candidates = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], [class*="popover"i]'))
|
||||
.filter(isVis)
|
||||
// Filter out app-shell dialogs (huge ones); prefer small popovers (<600px wide).
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width < 600 && r.height < 600;
|
||||
});
|
||||
if (!candidates.length) return [];
|
||||
// The popover is usually the LAST one mounted (highest in DOM order).
|
||||
const menu = candidates[candidates.length - 1];
|
||||
return Array.from(menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button'))
|
||||
.filter(isVis)
|
||||
.map((it) => (it.innerText || '').trim().replace(/\\s+/g, ' '))
|
||||
.filter(Boolean);
|
||||
})()`));
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
if (!items.length) {
|
||||
throw new EmptyResultError('antigravity display-options', 'Menu opened but no items detected.');
|
||||
}
|
||||
return items.map((it, i) => ({ Index: i + 1, Item: it }));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- add-context --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'add-context',
|
||||
access: 'write',
|
||||
description: 'Click the Add context button in the composer (opens file/URL picker for context attachment).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Add context"]'])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'add-context click failed', '');
|
||||
await page.wait(0.4);
|
||||
return [{ Status: 'clicked — picker should be open' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- revert --------
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'revert',
|
||||
access: 'write',
|
||||
description: 'Click the revert button (per-message revert for agent changes). Requires --yes (this modifies your workspace).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually revert (default: dry-run)' },
|
||||
],
|
||||
columns: ['Status'],
|
||||
func: async (page, kwargs) => {
|
||||
const yes = kwargs?.yes === true || kwargs?.yes === 'true' || kwargs?.yes === '1';
|
||||
if (!yes) {
|
||||
return [{ Status: 'dry-run — pass --yes to revert (modifies workspace)' }];
|
||||
}
|
||||
const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['[data-testid="revert-button"]', 'button[aria-label="Revert"]'])));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'revert click failed', '');
|
||||
await page.wait(1);
|
||||
return [{ Status: 'reverted' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
clickConversationMenuItem,
|
||||
confirmDeleteDialog,
|
||||
conversationVisible,
|
||||
conversationTargetArgs,
|
||||
} from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'delete',
|
||||
access: 'write',
|
||||
description: 'Delete an Antigravity conversation by ID. Antigravity asks for confirmation; we click through it. Require --yes to actually delete.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
...conversationTargetArgs,
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually delete (default: dry-run preview)' },
|
||||
],
|
||||
columns: ['status', 'id'],
|
||||
func: async (page, kwargs) => {
|
||||
const id = String(kwargs.id);
|
||||
const yes = kwargs.yes === true || kwargs.yes === 'true' || kwargs.yes === '1';
|
||||
if (!yes) {
|
||||
return [{ status: 'dry-run (pass --yes to actually delete)', id }];
|
||||
}
|
||||
|
||||
// 1. Open the per-row 3-dot menu and click "Delete Conversation".
|
||||
const menuRes = await clickConversationMenuItem(page, id, ['Delete Conversation', 'Delete']);
|
||||
if (!menuRes.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`${menuRes.reason}${menuRes.detail ? ' ' + menuRes.detail : ''}`,
|
||||
'Make sure Antigravity is in the foreground and the sidebar is open.',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Click the Delete button in the confirm dialog.
|
||||
const confirmRes = await confirmDeleteDialog(page, ['Delete', 'Delete Conversation', 'Confirm', 'OK']);
|
||||
if (!confirmRes.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`${confirmRes.reason}${confirmRes.detail ? ' ' + confirmRes.detail : ''}`,
|
||||
'Delete menu fired but the confirm dialog did not show / its button was not found.',
|
||||
);
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (!(await conversationVisible(page, id))) {
|
||||
return [{ status: 'deleted', id }];
|
||||
}
|
||||
await page.wait(0.5);
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`Delete did not remove conversation ${id} from the visible sidebar.`,
|
||||
'The delete click/confirmation may have failed or the selector contract drifted.',
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { listConversations } from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'history',
|
||||
access: 'read',
|
||||
description: 'List visible Antigravity conversations from the sidebar',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', required: false, default: 50, help: 'Max conversations to return' },
|
||||
],
|
||||
columns: ['Index', 'Id', 'Title'],
|
||||
func: async (page, kwargs) => {
|
||||
const all = await listConversations(page);
|
||||
const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 50;
|
||||
const sliced = all.slice(0, limit);
|
||||
if (!sliced.length) {
|
||||
throw new EmptyResultError('antigravity history', 'No conversations are visible in the sidebar. Open the sidebar and retry.');
|
||||
}
|
||||
return sliced.map((c) => ({ Index: c.index, Id: c.id, Title: c.title }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { clickConversationMenuItem, conversationTargetArgs, getConversationMenuLabels } from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'mark-read',
|
||||
access: 'write',
|
||||
description: 'Mark an unread Antigravity conversation as read. Fails if the row is already read or the postcondition cannot be verified.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [...conversationTargetArgs],
|
||||
columns: ['status', 'id', 'clicked'],
|
||||
func: async (page, kwargs) => {
|
||||
const id = String(kwargs.id);
|
||||
const before = await getConversationMenuLabels(page, id);
|
||||
if (!before.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`${before.reason}${before.detail ? ' ' + before.detail : ''}`,
|
||||
'Make sure Antigravity is in the foreground and the sidebar is open.',
|
||||
);
|
||||
}
|
||||
if (!before.labels?.includes('Mark as Read')) {
|
||||
throw new CommandExecutionError(
|
||||
`Conversation ${id} is not currently markable as read.`,
|
||||
`Visible menu labels: ${JSON.stringify(before.labels || [])}`,
|
||||
);
|
||||
}
|
||||
|
||||
const res = await clickConversationMenuItem(page, id, ['Mark as Read']);
|
||||
if (!res.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`${res.reason}${res.detail ? ' ' + res.detail : ''}`,
|
||||
'Make sure Antigravity is in the foreground and the sidebar is open.',
|
||||
);
|
||||
}
|
||||
await page.wait(0.6);
|
||||
const after = await getConversationMenuLabels(page, id);
|
||||
if (!after.ok || !after.labels?.includes('Mark as Unread')) {
|
||||
throw new CommandExecutionError(
|
||||
`Could not verify conversation ${id} was marked read.`,
|
||||
`Visible menu labels after click: ${JSON.stringify(after.labels || [])}`,
|
||||
);
|
||||
}
|
||||
return [{
|
||||
status: 'marked-read',
|
||||
id,
|
||||
clicked: res.clicked,
|
||||
}];
|
||||
},
|
||||
});
|
||||
+149
-33
@@ -1,45 +1,161 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const modelCommand = cli({
|
||||
import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { unwrapEvaluateResult } from './_actions.js';
|
||||
|
||||
// Antigravity exposes the active model via the composer button whose
|
||||
// aria-label looks like:
|
||||
// "Select model, current: Gemini 3.5 Flash (Medium)"
|
||||
// We parse the current model from that aria-label, and switch by clicking
|
||||
// the button to open the model picker dialog, then matching by visible
|
||||
// text inside the dialog.
|
||||
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'model',
|
||||
access: 'read',
|
||||
description: 'Switch the active LLM model in Antigravity',
|
||||
domain: 'localhost',
|
||||
access: 'write',
|
||||
description: 'Read or switch the active model in Antigravity. Without arguments, reports the current model. With <name> (substring, case-insensitive), switches.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'name', help: 'Target model name (e.g. claude, gemini, o1)', required: true, positional: true }
|
||||
{ name: 'name', required: false, positional: true, help: 'Substring (case-insensitive) of target model name. Omit to read current.' },
|
||||
{ name: 'list', type: 'boolean', default: false, help: 'List models in the picker (does not switch)' },
|
||||
],
|
||||
columns: ['Status'],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page, kwargs) => {
|
||||
const targetName = kwargs.name.toLowerCase();
|
||||
await page.evaluate(`
|
||||
async () => {
|
||||
const targetModelName = ${JSON.stringify(targetName)};
|
||||
|
||||
// 1. Locate the model selector dropdown trigger
|
||||
const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
|
||||
if (!trigger) throw new Error('Could not find the model selector trigger in the UI');
|
||||
trigger.click();
|
||||
|
||||
// 2. Wait a brief moment for React to mount the Portal/Dialog
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
// 3. Find the option spanning target text
|
||||
const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
|
||||
const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
|
||||
if (!target) {
|
||||
// If not found, click the trigger again to close it safely
|
||||
trigger.click();
|
||||
throw new Error('Model matching "' + targetModelName + '" was not found in the dropdown list.');
|
||||
const name = String(kwargs.name || '').trim().toLowerCase();
|
||||
const listOnly = kwargs.list === true || kwargs.list === 'true';
|
||||
const normalize = (value) => String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
|
||||
// Read current model from button's aria-label.
|
||||
const current = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
const btn = document.querySelector('button[aria-label^="Select model, current:"]');
|
||||
if (!btn) return '';
|
||||
const aria = btn.getAttribute('aria-label') || '';
|
||||
const m = aria.match(/current:\\s*(.*)$/i);
|
||||
return m ? m[1].trim() : (btn.textContent || '').trim();
|
||||
})()`));
|
||||
if (!current) {
|
||||
throw selectorError('Antigravity model button (button[aria-label^="Select model, current:"]). Make sure a chat is open in the foreground.');
|
||||
}
|
||||
|
||||
// 4. Click the closest parent that handles the row action
|
||||
const optionNode = target.closest('.cursor-pointer') || target;
|
||||
optionNode.click();
|
||||
|
||||
if (!name && !listOnly) {
|
||||
return [{ Status: 'Active', Model: current }];
|
||||
}
|
||||
|
||||
const namejson = JSON.stringify(name);
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const trigger = document.querySelector('button[aria-label^="Select model, current:"]');
|
||||
if (!trigger) return { ok: false, reason: 'trigger missing' };
|
||||
|
||||
// Open the picker dialog (full pointer chain — radix uses pointer events).
|
||||
const r = trigger.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(r.left + r.width / 2),
|
||||
clientY: Math.round(r.top + r.height / 2),
|
||||
};
|
||||
trigger.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
trigger.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
trigger.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
trigger.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
trigger.dispatchEvent(new MouseEvent('click', init));
|
||||
|
||||
// Wait for the picker dialog to open. Antigravity renders it as a
|
||||
// [role="dialog"] or a div with selectable rows (cursor-pointer).
|
||||
let rows = [];
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
await wait(80);
|
||||
rows = Array.from(document.querySelectorAll('[role="dialog"] .cursor-pointer, [role="dialog"] [role="option"], [role="dialog"] li, .cursor-pointer'))
|
||||
.filter((el) => el instanceof HTMLElement && el.offsetParent);
|
||||
// Filter out rows clearly outside the dialog (e.g. global cursor-pointer in sidebar)
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (dialog) {
|
||||
rows = rows.filter((r) => dialog.contains(r));
|
||||
}
|
||||
if (rows.length) break;
|
||||
}
|
||||
`);
|
||||
await page.wait(0.5);
|
||||
return [{ Status: `Model switched to: ${kwargs.name}` }];
|
||||
if (!rows.length) {
|
||||
return { ok: false, reason: 'Model picker dialog did not surface any rows.' };
|
||||
}
|
||||
|
||||
const labels = rows.map((r) => (r.innerText || r.textContent || '').trim().slice(0, 80));
|
||||
const target = ${namejson};
|
||||
const listOnly = ${listOnly ? 'true' : 'false'};
|
||||
if (!target || listOnly) {
|
||||
// Close picker (Esc) and return list.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||||
return { ok: true, labels };
|
||||
}
|
||||
const exactMatches = labels
|
||||
.map((label, index) => ({ label, index }))
|
||||
.filter((entry) => entry.label.toLowerCase() === target);
|
||||
const matches = exactMatches.length ? exactMatches : labels
|
||||
.map((label, index) => ({ label, index }))
|
||||
.filter((entry) => entry.label.toLowerCase().includes(target));
|
||||
if (!matches.length) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||||
return { ok: false, reason: 'No model matched.', detail: 'wanted=' + target + ' visible=' + JSON.stringify(labels) };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||||
return { ok: false, reason: 'Ambiguous model match.', detail: 'wanted=' + target + ' matches=' + JSON.stringify(matches.map((m) => m.label)) };
|
||||
}
|
||||
const chosen = rows[matches[0].index];
|
||||
const chosenLabel = matches[0].label;
|
||||
|
||||
const cr = chosen.getBoundingClientRect();
|
||||
const cinit = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(cr.left + cr.width / 2),
|
||||
clientY: Math.round(cr.top + cr.height / 2),
|
||||
};
|
||||
Promise.resolve().then(() => {
|
||||
try {
|
||||
chosen.dispatchEvent(new PointerEvent('pointerdown', { ...cinit, pointerType: 'mouse' }));
|
||||
chosen.dispatchEvent(new MouseEvent('mousedown', cinit));
|
||||
chosen.dispatchEvent(new PointerEvent('pointerup', { ...cinit, pointerType: 'mouse' }));
|
||||
chosen.dispatchEvent(new MouseEvent('mouseup', cinit));
|
||||
chosen.dispatchEvent(new MouseEvent('click', cinit));
|
||||
} catch {}
|
||||
});
|
||||
return { ok: true, switched: true, chosen: chosenLabel, labels };
|
||||
})()`));
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'Ambiguous model match.') {
|
||||
throw new ArgumentError(result.detail || 'Ambiguous model match.');
|
||||
}
|
||||
throw new CommandExecutionError(result.reason, result.detail || '');
|
||||
}
|
||||
if (listOnly) {
|
||||
return result.labels.map((m) => ({ Status: m.startsWith(current.slice(0, 20)) ? 'Active' : 'Available', Model: m }));
|
||||
}
|
||||
await page.wait(0.8);
|
||||
let verified = '';
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
verified = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
const btn = document.querySelector('button[aria-label^="Select model, current:"]');
|
||||
if (!btn) return '';
|
||||
const aria = btn.getAttribute('aria-label') || '';
|
||||
const m = aria.match(/current:\\s*(.*)$/i);
|
||||
return m ? m[1].trim() : (btn.textContent || '').trim();
|
||||
})()`));
|
||||
if (
|
||||
normalize(verified)
|
||||
&& (normalize(result.chosen).includes(normalize(verified)) || normalize(verified).includes(normalize(result.chosen)))
|
||||
) {
|
||||
return [{ Status: 'switched', Model: verified }];
|
||||
}
|
||||
if (normalize(verified) === normalize(result.chosen)) {
|
||||
return [{ Status: 'switched', Model: verified }];
|
||||
}
|
||||
await page.wait(0.4);
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`Could not verify Antigravity model switched to ${result.chosen}.`,
|
||||
`Read back current model: ${verified || '(empty)'}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { conversationTargetArgs } from './_actions.js';
|
||||
|
||||
// Known followup: a first attempt at rename triggered a destructive side
|
||||
// effect that removed the conversation from the sidebar (the convo titled
|
||||
// "1" disappeared after attempting `rename b79d8b28-... "..."` with the
|
||||
// Promise eval being collected mid-way). The 3-dot menu's Rename option
|
||||
// may interact with Antigravity's React state in a way that an
|
||||
// incomplete eval treats as "discard" — needs more investigation before
|
||||
// it's safe to ship.
|
||||
//
|
||||
// For now this command refuses to run; pin/delete/mark-read are wired up.
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'rename',
|
||||
access: 'write',
|
||||
description: 'Rename an Antigravity conversation by ID (NOT YET IMPLEMENTED — see source comment).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
...conversationTargetArgs,
|
||||
{ name: 'title', positional: true, type: 'string', required: true, help: 'New title' },
|
||||
],
|
||||
columns: ['status'],
|
||||
func: async () => {
|
||||
throw new CommandExecutionError(
|
||||
'antigravity rename is not yet implemented — first attempt caused the conversation to be removed from the sidebar instead of renamed. Use the Antigravity UI to rename until this is fixed.',
|
||||
'',
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// Storage commands for Antigravity:
|
||||
// Renderer-side (4): storage-keys / storage-get / cookies / idb-list
|
||||
// VSCode FS-side (4): state-keys / state-get / recent-paths / workspaces-list
|
||||
// Settings (1): settings-read
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import { unwrapEvaluateResult } from './_actions.js';
|
||||
|
||||
const STORAGE_COLUMNS = [
|
||||
'Index',
|
||||
'Key',
|
||||
'Bytes',
|
||||
'Name',
|
||||
'Preview',
|
||||
'Database',
|
||||
'Version',
|
||||
'Kind',
|
||||
'Path',
|
||||
'Workspace Id',
|
||||
'Folder',
|
||||
'Modified',
|
||||
'Field',
|
||||
'Value',
|
||||
];
|
||||
|
||||
// ====== Path helpers ======
|
||||
const AG_APP_SUPPORT = path.join(os.homedir(), 'Library/Application Support/Antigravity');
|
||||
const AG_USER_DIR = path.join(AG_APP_SUPPORT, 'User');
|
||||
const AG_GLOBAL_STATE_DB = path.join(AG_USER_DIR, 'globalStorage/state.vscdb');
|
||||
const AG_WORKSPACE_STORAGE = path.join(AG_USER_DIR, 'workspaceStorage');
|
||||
const AG_SETTINGS_JSON = path.join(AG_USER_DIR, 'settings.json');
|
||||
|
||||
function sqliteQuery(db, sql) {
|
||||
if (!fs.existsSync(db)) {
|
||||
throw new CommandExecutionError(`state.vscdb not found: ${db}`, 'Has Antigravity been run at least once?');
|
||||
}
|
||||
try {
|
||||
return execFileSync('/usr/bin/sqlite3', [db, sql], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
|
||||
} catch (e) {
|
||||
throw new CommandExecutionError(
|
||||
`sqlite3 failed on ${path.basename(db)}: ${e.message}`,
|
||||
'The DB may be locked by a running Antigravity instance. Try closing it or wait a few seconds.',
|
||||
);
|
||||
}
|
||||
}
|
||||
function listKeys(db) {
|
||||
const out = sqliteQuery(db, 'SELECT key FROM ItemTable ORDER BY key;');
|
||||
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
function getValue(db, key) {
|
||||
const esc = key.replace(/'/g, "''");
|
||||
const raw = sqliteQuery(db, `SELECT value FROM ItemTable WHERE key = '${esc}';`).trim();
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
}
|
||||
function resolveStateDb(args) {
|
||||
const ws = args?.workspace ? String(args.workspace).trim() : '';
|
||||
if (!ws) return AG_GLOBAL_STATE_DB;
|
||||
const db = path.join(AG_WORKSPACE_STORAGE, ws, 'state.vscdb');
|
||||
if (!fs.existsSync(db)) {
|
||||
throw new CommandExecutionError(`Workspace state.vscdb not found: ${db}`, 'List workspace ids with `opencli antigravity workspaces-list`.');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// ====== Renderer-side: storage-keys ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'storage-keys',
|
||||
access: 'read',
|
||||
description: 'List localStorage / sessionStorage keys on the Antigravity renderer (CDP).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'storage', required: false, default: 'local', help: '"local" or "session"' },
|
||||
{ name: 'filter', required: false, help: 'Case-insensitive substring filter' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 100, help: 'Max rows to return' },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const s = String(kwargs?.storage || 'local').trim().toLowerCase();
|
||||
if (s !== 'local' && s !== 'session') throw new ArgumentError('storage', 'must be "local" or "session"');
|
||||
const store = s === 'session' ? 'sessionStorage' : 'localStorage';
|
||||
const raw = unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const s = ${store};
|
||||
const out = [];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const k = s.key(i); const v = s.getItem(k) || '';
|
||||
out.push({ k, bytes: v.length });
|
||||
}
|
||||
return out;
|
||||
})()`));
|
||||
const flt = kwargs?.filter ? String(kwargs.filter).toLowerCase() : null;
|
||||
const filtered = flt ? raw.filter((r) => r.k.toLowerCase().includes(flt)) : raw;
|
||||
if (!filtered.length) throw new EmptyResultError('antigravity storage-keys', flt ? `No keys match "${flt}".` : `${store} is empty.`);
|
||||
filtered.sort((a, b) => a.k.localeCompare(b.k));
|
||||
const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 100;
|
||||
return filtered.slice(0, limit).map((r, i) => ({ Index: i + 1, Key: r.k, Bytes: r.bytes }));
|
||||
},
|
||||
});
|
||||
|
||||
// ====== Renderer-side: storage-get ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'storage-get',
|
||||
access: 'read',
|
||||
description: 'Read a single localStorage / sessionStorage value on the Antigravity renderer.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'key', positional: true, required: true, help: 'Storage key name' },
|
||||
{ name: 'storage', required: false, default: 'local', help: '"local" or "session"' },
|
||||
{ name: 'max-bytes', type: 'int', required: false, default: 4000, help: 'Truncate value to this many chars' },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const key = String(kwargs?.key || '').trim();
|
||||
if (!key) throw new ArgumentError('key', 'is required');
|
||||
const s = String(kwargs?.storage || 'local').trim().toLowerCase();
|
||||
const store = s === 'session' ? 'sessionStorage' : 'localStorage';
|
||||
const raw = unwrapEvaluateResult(await page.evaluate(`${store}.getItem(${JSON.stringify(key)})`));
|
||||
if (raw === null) throw new CommandExecutionError(`Key not found in ${store}: ${key}`, '');
|
||||
const max = Number.isInteger(kwargs['max-bytes']) && kwargs['max-bytes'] > 0 ? kwargs['max-bytes'] : 4000;
|
||||
let parsed = raw, kind = 'string';
|
||||
try { parsed = JSON.parse(raw); kind = Array.isArray(parsed) ? 'array' : typeof parsed; } catch {}
|
||||
const text = kind === 'string' ? parsed : JSON.stringify(parsed, null, 2);
|
||||
const truncated = text.length > max;
|
||||
return [
|
||||
{ Field: 'Key', Value: key },
|
||||
{ Field: 'Store', Value: store },
|
||||
{ Field: 'Type', Value: kind },
|
||||
{ Field: 'Size', Value: `${text.length} chars${truncated ? ' (truncated)' : ''}` },
|
||||
{ Field: 'Value', Value: truncated ? text.slice(0, max) + '\n...(truncated)' : text },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// ====== Renderer-side: cookies ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'cookies',
|
||||
access: 'read',
|
||||
description: 'List cookies on the Antigravity renderer (JS-visible via document.cookie).',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page) => {
|
||||
const raw = unwrapEvaluateResult(await page.evaluate('document.cookie'));
|
||||
if (!raw) throw new EmptyResultError('antigravity cookies', 'document.cookie is empty.');
|
||||
const cookies = raw.split('; ').map((pair) => {
|
||||
const idx = pair.indexOf('=');
|
||||
if (idx < 0) return { name: pair, value: '' };
|
||||
return { name: pair.slice(0, idx), value: pair.slice(idx + 1) };
|
||||
});
|
||||
return cookies.map((c, i) => ({
|
||||
Index: i + 1, Name: c.name, Bytes: c.value.length,
|
||||
Preview: c.value.slice(0, 40) + (c.value.length > 40 ? '…' : ''),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// ====== Renderer-side: idb-list ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'idb-list',
|
||||
access: 'read',
|
||||
description: 'List IndexedDB databases on the Antigravity renderer.',
|
||||
domain: '127.0.0.1',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page) => {
|
||||
const dbs = unwrapEvaluateResult(await page.evaluate(`(async () => indexedDB.databases ? await indexedDB.databases() : [])()`));
|
||||
if (!Array.isArray(dbs) || !dbs.length) throw new EmptyResultError('antigravity idb-list', 'No IndexedDB databases.');
|
||||
return dbs.map((d, i) => ({ Index: i + 1, Database: d.name || '(unnamed)', Version: String(d.version || '') }));
|
||||
},
|
||||
});
|
||||
|
||||
// ====== FS-side: state-keys ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'state-keys',
|
||||
access: 'read',
|
||||
description: 'List keys in Antigravity\'s globalStorage state.vscdb (VSCode-style). Pass --workspace <id> to query a per-workspace DB. Works while Antigravity is closed.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.LOCAL,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'filter', required: false, help: 'Case-insensitive substring filter over keys' },
|
||||
{ name: 'workspace', required: false, help: 'Workspace id (from workspaces-list) to query per-workspace DB' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 200, help: 'Max rows to return' },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (args) => {
|
||||
const db = resolveStateDb(args);
|
||||
const keys = listKeys(db);
|
||||
const flt = args?.filter ? String(args.filter).toLowerCase() : null;
|
||||
const filtered = flt ? keys.filter((k) => k.toLowerCase().includes(flt)) : keys;
|
||||
if (!filtered.length) throw new EmptyResultError('antigravity state-keys', flt ? `No keys match "${flt}".` : 'No keys.');
|
||||
const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : 200;
|
||||
return filtered.slice(0, limit).map((k, i) => ({ Index: i + 1, Key: k }));
|
||||
},
|
||||
});
|
||||
|
||||
// ====== FS-side: state-get ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'state-get',
|
||||
access: 'read',
|
||||
description: 'Read one value from Antigravity\'s state.vscdb. Pass --workspace <id> for per-workspace.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.LOCAL,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'key', positional: true, required: true, help: 'Storage key name' },
|
||||
{ name: 'workspace', required: false, help: 'Workspace id (from workspaces-list) to query per-workspace DB' },
|
||||
{ name: 'max-bytes', type: 'int', required: false, default: 8000, help: 'Truncate value to this many chars' },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (args) => {
|
||||
const key = String(args?.key || '').trim();
|
||||
if (!key) throw new ArgumentError('key', 'is required');
|
||||
const db = resolveStateDb(args);
|
||||
const val = getValue(db, key);
|
||||
if (val === null) throw new CommandExecutionError(`Key not found: ${key}`, '');
|
||||
const max = Number.isInteger(args['max-bytes']) && args['max-bytes'] > 0 ? args['max-bytes'] : 8000;
|
||||
const valStr = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
|
||||
const truncated = valStr.length > max;
|
||||
return [
|
||||
{ Field: 'Key', Value: key },
|
||||
{ Field: 'Type', Value: typeof val === 'string' ? 'string' : (Array.isArray(val) ? 'array' : typeof val) },
|
||||
{ Field: 'Size', Value: `${valStr.length} chars${truncated ? ' (truncated)' : ''}` },
|
||||
{ Field: 'Value', Value: truncated ? valStr.slice(0, max) + '\n...(truncated)' : valStr },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// ====== FS-side: recent-paths ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'recent-paths',
|
||||
access: 'read',
|
||||
description: 'Show Antigravity\'s recently-opened folders/files (history.recentlyOpenedPathsList).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.LOCAL,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', required: false, default: 20, help: 'Max rows to return' },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (args) => {
|
||||
const val = getValue(AG_GLOBAL_STATE_DB, 'history.recentlyOpenedPathsList');
|
||||
if (!val) throw new EmptyResultError('antigravity recent-paths', 'No recent paths recorded.');
|
||||
const entries = val.entries || [];
|
||||
if (!entries.length) throw new EmptyResultError('antigravity recent-paths', 'Recent paths list is empty.');
|
||||
const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : 20;
|
||||
return entries.slice(0, limit).map((e, i) => {
|
||||
let kind = 'other', target = JSON.stringify(e).slice(0, 200);
|
||||
if (e.folderUri) {
|
||||
kind = 'folder';
|
||||
target = decodeURI(String(e.folderUri).replace(/^file:\/\//, ''));
|
||||
} else if (e.fileUri) {
|
||||
kind = 'file';
|
||||
target = decodeURI(String(e.fileUri).replace(/^file:\/\//, ''));
|
||||
} else if (e.workspace?.configPath) {
|
||||
kind = 'workspace';
|
||||
target = decodeURI(String(e.workspace.configPath).replace(/^file:\/\//, ''));
|
||||
}
|
||||
return { Index: i + 1, Kind: kind, Path: target };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ====== FS-side: workspaces-list ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'workspaces-list',
|
||||
access: 'read',
|
||||
description: 'List Antigravity workspaceStorage entries (each represents a previously-opened folder).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.LOCAL,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', required: false, default: 50, help: 'Max rows to return' },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (args) => {
|
||||
if (!fs.existsSync(AG_WORKSPACE_STORAGE)) {
|
||||
throw new CommandExecutionError(`workspaceStorage not found: ${AG_WORKSPACE_STORAGE}`, '');
|
||||
}
|
||||
const dirs = fs.readdirSync(AG_WORKSPACE_STORAGE).filter((n) => {
|
||||
const full = path.join(AG_WORKSPACE_STORAGE, n);
|
||||
return fs.statSync(full).isDirectory();
|
||||
});
|
||||
if (!dirs.length) throw new EmptyResultError('antigravity workspaces-list', 'No workspace storage.');
|
||||
const rows = dirs.map((id) => {
|
||||
const dir = path.join(AG_WORKSPACE_STORAGE, id);
|
||||
const wj = path.join(dir, 'workspace.json');
|
||||
let folder = '(no workspace.json)';
|
||||
if (fs.existsSync(wj)) {
|
||||
try {
|
||||
const outer = JSON.parse(fs.readFileSync(wj, 'utf-8'));
|
||||
if (outer.folder) folder = decodeURI(outer.folder.replace(/^file:\/\//, ''));
|
||||
else if (outer.workspace) folder = '(multi-folder) ' + decodeURI(outer.workspace.replace(/^file:\/\//, ''));
|
||||
} catch { folder = '(invalid workspace.json)'; }
|
||||
}
|
||||
return { id, folder, mtime: fs.statSync(dir).mtimeMs };
|
||||
}).sort((a, b) => b.mtime - a.mtime);
|
||||
const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : 50;
|
||||
return rows.slice(0, limit).map((r, i) => ({
|
||||
Index: i + 1,
|
||||
'Workspace Id': r.id,
|
||||
Folder: r.folder.slice(0, 120),
|
||||
Modified: new Date(r.mtime).toISOString().replace('T', ' ').slice(0, 19),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// ====== Settings ======
|
||||
cli({
|
||||
site: 'antigravity',
|
||||
name: 'settings-read',
|
||||
access: 'read',
|
||||
description: 'Read Antigravity\'s user settings.json (theme, proxy, agCockpit, tfa.system.autoAccept, etc.).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.LOCAL,
|
||||
browser: false,
|
||||
args: [],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async () => {
|
||||
if (!fs.existsSync(AG_SETTINGS_JSON)) {
|
||||
throw new CommandExecutionError(`settings.json not found: ${AG_SETTINGS_JSON}`, '');
|
||||
}
|
||||
const raw = fs.readFileSync(AG_SETTINGS_JSON, 'utf-8');
|
||||
// VSCode allows JSONC (line + block comments + trailing commas).
|
||||
// Strip comments and trailing commas before parsing.
|
||||
const stripped = raw
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/^\s*\/\/.*$/gm, '') // line comments (full line)
|
||||
.replace(/([^:"])\/\/.*$/gm, '$1') // line comments (after code)
|
||||
.replace(/,(\s*[}\]])/g, '$1'); // trailing commas
|
||||
let obj;
|
||||
try { obj = JSON.parse(stripped); } catch (e) {
|
||||
throw new CommandExecutionError(`Failed to parse settings.json: ${e.message}`, '');
|
||||
}
|
||||
const rows = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
rows.push({ Field: k, Value: typeof v === 'object' ? JSON.stringify(v) : String(v) });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -41,6 +41,26 @@ describe('apple-podcasts search command', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
it('emits empty-string for missing trackCount and primaryGenreName instead of a sentinel', async () => {
|
||||
const cmd = getRegistry().get('apple-podcasts/search');
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
collectionId: 99,
|
||||
collectionName: 'No-Meta Show',
|
||||
artistName: 'Anon Host',
|
||||
collectionViewUrl: 'https://example.com/p/99',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await cmd.func({ query: 'no-meta', limit: 1 });
|
||||
expect(result[0].episodes).toBe('');
|
||||
expect(result[0].genre).toBe('');
|
||||
});
|
||||
});
|
||||
describe('apple-podcasts top command', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -23,8 +23,8 @@ cli({
|
||||
id: p.collectionId,
|
||||
title: p.collectionName,
|
||||
author: p.artistName,
|
||||
episodes: p.trackCount ?? '-',
|
||||
genre: p.primaryGenreName ?? '-',
|
||||
episodes: p.trackCount ?? '',
|
||||
genre: p.primaryGenreName ?? '',
|
||||
url: p.collectionViewUrl || '',
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './search.js';
|
||||
import './item.js';
|
||||
import './wayback.js';
|
||||
import './snapshots.js';
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('archive adapter registry contracts', () => {
|
||||
it('declares archive search columns so identifier round-trips into archive item', () => {
|
||||
const search = getRegistry().get('archive/search');
|
||||
const item = getRegistry().get('archive/item');
|
||||
|
||||
expect(search).toBeDefined();
|
||||
expect(item).toBeDefined();
|
||||
expect(search.columns).toEqual(['rank', 'identifier', 'title', 'creator', 'date', 'mediatype', 'downloads', 'url']);
|
||||
expect(item.columns).toContain('identifier');
|
||||
});
|
||||
|
||||
it('declares wayback and snapshots columns so URL round-trips between them', () => {
|
||||
const wayback = getRegistry().get('archive/wayback');
|
||||
const snapshots = getRegistry().get('archive/snapshots');
|
||||
|
||||
expect(wayback).toBeDefined();
|
||||
expect(snapshots).toBeDefined();
|
||||
expect(wayback.columns).toContain('snapshot_url');
|
||||
expect(snapshots.columns).toContain('snapshot_url');
|
||||
expect(wayback.columns).toContain('original_url');
|
||||
expect(snapshots.columns).toContain('original_url');
|
||||
});
|
||||
|
||||
it('marks every archive command as read access on the archive.org domain', () => {
|
||||
for (const name of ['search', 'item', 'wayback', 'snapshots']) {
|
||||
const cmd = getRegistry().get(`archive/${name}`);
|
||||
expect(cmd, name).toBeDefined();
|
||||
expect(cmd.access, name).toBe('read');
|
||||
expect(cmd.domain, name).toBe('archive.org');
|
||||
expect(cmd.browser, name).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive search command', () => {
|
||||
const command = getRegistry().get('archive/search');
|
||||
|
||||
it('returns stable identifier rows that round-trip to archive item', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
response: {
|
||||
docs: [{
|
||||
identifier: 'sample_item-1',
|
||||
title: 'Sample Item',
|
||||
creator: ['Alice', 'Bob'],
|
||||
date: '2020-01-02T00:00:00Z',
|
||||
mediatype: 'texts',
|
||||
downloads: '42',
|
||||
}],
|
||||
},
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ query: 'sample', limit: 1 })).resolves.toEqual([{
|
||||
rank: 1,
|
||||
identifier: 'sample_item-1',
|
||||
title: 'Sample Item',
|
||||
creator: 'Alice, Bob',
|
||||
date: '2020-01-02',
|
||||
mediatype: 'texts',
|
||||
downloads: 42,
|
||||
url: 'https://archive.org/details/sample_item-1',
|
||||
}]);
|
||||
const url = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(url.searchParams.get('q')).toBe('sample');
|
||||
expect(url.searchParams.getAll('fl[]')).toContain('identifier');
|
||||
});
|
||||
|
||||
it('rejects invalid arguments before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ query: ' ', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ query: 'x', mediatype: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ query: 'x', sort: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ query: 'x', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps true empty search results to EmptyResultError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ response: { docs: [] } })));
|
||||
|
||||
await expect(command.func({ query: 'zz-no-hit', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('typed-fails malformed search payloads instead of emitting empty identifiers', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ response: { docs: [{ title: 'No id' }] } })));
|
||||
|
||||
await expect(command.func({ query: 'bad', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive item command', () => {
|
||||
const command = getRegistry().get('archive/item');
|
||||
|
||||
it('returns metadata for the requested stable identifier', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
|
||||
metadata: {
|
||||
identifier: 'sample_item-1',
|
||||
title: 'Sample Item',
|
||||
creator: 'Alice',
|
||||
date: '2020',
|
||||
mediatype: 'texts',
|
||||
collection: ['opensource'],
|
||||
description: ['Line one.', 'Line two.'],
|
||||
},
|
||||
files: [{ name: 'a.txt' }, { name: 'b.txt' }],
|
||||
})));
|
||||
|
||||
await expect(command.func({ identifier: 'sample_item-1' })).resolves.toEqual([{
|
||||
identifier: 'sample_item-1',
|
||||
title: 'Sample Item',
|
||||
creator: 'Alice',
|
||||
date: '2020',
|
||||
mediatype: 'texts',
|
||||
collection: 'opensource',
|
||||
description: 'Line one. Line two.',
|
||||
file_count: 2,
|
||||
url: 'https://archive.org/details/sample_item-1',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('rejects invalid identifiers before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ identifier: '' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ identifier: '../secret' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps missing public metadata to EmptyResultError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
|
||||
|
||||
await expect(command.func({ identifier: 'missing_item' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('typed-fails mismatched identity and malformed files payload', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ metadata: { identifier: 'other_item' }, files: [] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ metadata: { identifier: 'sample_item' }, files: {} })));
|
||||
|
||||
await expect(command.func({ identifier: 'sample_item' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func({ identifier: 'sample_item' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive wayback command', () => {
|
||||
const command = getRegistry().get('archive/wayback');
|
||||
|
||||
it('returns the closest snapshot with normalized timestamp input', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
url: 'example.com',
|
||||
archived_snapshots: {
|
||||
closest: {
|
||||
available: true,
|
||||
timestamp: '20200102030405',
|
||||
url: 'https://web.archive.org/web/20200102030405/https://example.com/',
|
||||
status: '200',
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ url: 'example.com', timestamp: '2020-01-02T03:04:05' })).resolves.toEqual([{
|
||||
original_url: 'example.com',
|
||||
requested_timestamp: '20200102030405',
|
||||
snapshot_timestamp: '20200102030405',
|
||||
snapshot_url: 'https://web.archive.org/web/20200102030405/https://example.com/',
|
||||
status: '200',
|
||||
}]);
|
||||
expect(new URL(fetchMock.mock.calls[0][0]).searchParams.get('timestamp')).toBe('20200102030405');
|
||||
});
|
||||
|
||||
it('rejects invalid URL/timestamp arguments before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ url: '' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ url: 'example.com', timestamp: '202' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('distinguishes no snapshot from malformed closest snapshot', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ archived_snapshots: {} }))
|
||||
.mockResolvedValueOnce(jsonResponse({ archived_snapshots: { closest: { available: true, url: 'x' } } })));
|
||||
|
||||
await expect(command.func({ url: 'example.com' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
await expect(command.func({ url: 'example.com' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive snapshots command', () => {
|
||||
const command = getRegistry().get('archive/snapshots');
|
||||
|
||||
it('returns CDX snapshots with stable Wayback permalinks', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([
|
||||
['urlkey', 'timestamp', 'original', 'mimetype', 'statuscode'],
|
||||
['com,example)/', '20200102030405', 'https://example.com/', 'text/html', '200'],
|
||||
]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ url: 'example.com', from: '2020', limit: 1 })).resolves.toEqual([{
|
||||
timestamp: '20200102030405',
|
||||
snapshot_url: 'https://web.archive.org/web/20200102030405/https://example.com/',
|
||||
status: '200',
|
||||
mimetype: 'text/html',
|
||||
original_url: 'https://example.com/',
|
||||
}]);
|
||||
const url = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(url.protocol).toBe('http:');
|
||||
expect(url.searchParams.get('from')).toBe('2020');
|
||||
});
|
||||
|
||||
it('rejects invalid arguments before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(command.func({ url: '', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ url: 'example.com', limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func({ url: 'example.com', from: '2020-01' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps no CDX rows to EmptyResultError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype']])));
|
||||
|
||||
await expect(command.func({ url: 'missing.example', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('typed-fails malformed CDX headers and rows', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([['timestamp', 'original'], ['20200102030405', 'https://example.com/']]))
|
||||
.mockResolvedValueOnce(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype'], ['', 'https://example.com/', '200', 'text/html']]))
|
||||
.mockResolvedValueOnce(jsonResponse({ timestamp: '20200102030405' }))
|
||||
.mockResolvedValueOnce(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype'], ['20200102030405', 'https://example.com/']])));
|
||||
|
||||
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// archive item: Internet Archive item metadata (one row per identifier).
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
const IDENTIFIER_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
cli({
|
||||
site: 'archive',
|
||||
name: 'item',
|
||||
access: 'read',
|
||||
description: 'Fetch metadata for a single Internet Archive item by identifier.',
|
||||
domain: 'archive.org',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'identifier', positional: true, required: true, help: 'Archive item identifier (e.g. "open-syllabus", "FinalFantasy2_356").' },
|
||||
],
|
||||
columns: ['identifier', 'title', 'creator', 'date', 'mediatype', 'collection', 'description', 'file_count', 'url'],
|
||||
func: async (args) => {
|
||||
const identifier = String(args.identifier ?? '').trim();
|
||||
if (!identifier) {
|
||||
throw new ArgumentError(
|
||||
'archive item identifier cannot be empty',
|
||||
'Example: opencli archive item open-syllabus',
|
||||
);
|
||||
}
|
||||
if (!IDENTIFIER_RE.test(identifier)) {
|
||||
throw new ArgumentError(
|
||||
`archive item identifier "${args.identifier}" is not valid`,
|
||||
'Archive item identifiers may only contain letters, digits, ".", "_", "-".',
|
||||
);
|
||||
}
|
||||
|
||||
const url = `https://archive.org/metadata/${encodeURIComponent(identifier)}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive item request failed: ${error?.message || error}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`archive item failed: HTTP ${resp.status}`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive item returned malformed JSON: ${error?.message || error}`);
|
||||
}
|
||||
|
||||
const meta = data?.metadata;
|
||||
// The metadata endpoint returns {} for missing or dark items.
|
||||
if (!meta || typeof meta !== 'object' || !meta.identifier) {
|
||||
throw new EmptyResultError('archive item', `No public metadata for "${identifier}" on archive.org.`);
|
||||
}
|
||||
const responseIdentifier = String(meta.identifier);
|
||||
if (!IDENTIFIER_RE.test(responseIdentifier)) {
|
||||
throw new CommandExecutionError('archive item returned malformed payload: metadata.identifier is not stable');
|
||||
}
|
||||
if (responseIdentifier !== identifier) {
|
||||
throw new CommandExecutionError(`archive item returned metadata for "${responseIdentifier}" instead of "${identifier}"`);
|
||||
}
|
||||
|
||||
const creator = Array.isArray(meta.creator) ? meta.creator.join(', ') : String(meta.creator ?? '');
|
||||
const collection = Array.isArray(meta.collection) ? meta.collection.join(', ') : String(meta.collection ?? '');
|
||||
const description = Array.isArray(meta.description) ? meta.description.join(' ') : String(meta.description ?? '');
|
||||
if (!Array.isArray(data.files)) {
|
||||
throw new CommandExecutionError('archive item returned malformed payload: files must be an array');
|
||||
}
|
||||
|
||||
return [{
|
||||
identifier: responseIdentifier,
|
||||
title: String(meta.title ?? ''),
|
||||
creator,
|
||||
date: meta.date ? String(meta.date).slice(0, 10) : '',
|
||||
mediatype: String(meta.mediatype ?? ''),
|
||||
collection,
|
||||
description,
|
||||
file_count: data.files.length,
|
||||
url: `https://archive.org/details/${responseIdentifier}`,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// archive search: Internet Archive Advanced Search across all mediatypes.
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
const SORT_OPTIONS = ['downloads', 'date', 'addeddate', 'week', 'title'];
|
||||
const SORT_ALIAS = { added: 'addeddate', published: 'date' };
|
||||
const MEDIATYPES = ['texts', 'movies', 'audio', 'software', 'image', 'web', 'data', 'collection'];
|
||||
const IDENTIFIER_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
cli({
|
||||
site: 'archive',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Search Internet Archive items across books, movies, audio, software, and web.',
|
||||
domain: 'archive.org',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Full-text query (matches title, description, creator, subject).' },
|
||||
{ name: 'mediatype', type: 'string', required: false, help: `Restrict to mediatype: ${MEDIATYPES.join(', ')}` },
|
||||
{ name: 'sort', type: 'string', default: 'downloads', help: `Sort key: ${SORT_OPTIONS.join(', ')}` },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max items (max 100; one API page).' },
|
||||
],
|
||||
columns: ['rank', 'identifier', 'title', 'creator', 'date', 'mediatype', 'downloads', 'url'],
|
||||
func: async (args) => {
|
||||
const sortRaw = String(args.sort ?? 'downloads').toLowerCase();
|
||||
const sort = SORT_ALIAS[sortRaw] ?? sortRaw;
|
||||
if (!SORT_OPTIONS.includes(sort)) {
|
||||
throw new ArgumentError(`archive search sort must be one of ${SORT_OPTIONS.join(', ')}`);
|
||||
}
|
||||
if (args.mediatype && !MEDIATYPES.includes(String(args.mediatype))) {
|
||||
throw new ArgumentError(`archive search mediatype must be one of ${MEDIATYPES.join(', ')}`);
|
||||
}
|
||||
const limit = Number(args.limit ?? 20);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new ArgumentError('archive search limit must be a positive integer');
|
||||
}
|
||||
if (limit > 100) {
|
||||
throw new ArgumentError('archive search limit must be <= 100');
|
||||
}
|
||||
|
||||
const query = String(args.query ?? '').trim();
|
||||
if (!query) {
|
||||
throw new ArgumentError('archive search query must not be empty');
|
||||
}
|
||||
|
||||
const fullQuery = args.mediatype
|
||||
? `(${query}) AND mediatype:${args.mediatype}`
|
||||
: query;
|
||||
|
||||
const url = new URL('https://archive.org/advancedsearch.php');
|
||||
url.searchParams.set('q', fullQuery);
|
||||
url.searchParams.set('output', 'json');
|
||||
url.searchParams.set('rows', String(limit));
|
||||
url.searchParams.set('sort[]', `${sort} desc`);
|
||||
for (const fl of ['identifier', 'title', 'creator', 'date', 'mediatype', 'downloads']) {
|
||||
url.searchParams.append('fl[]', fl);
|
||||
}
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive search request failed: ${error?.message || error}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`archive search failed: HTTP ${resp.status}`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive search returned malformed JSON: ${error?.message || error}`);
|
||||
}
|
||||
|
||||
const docs = data?.response?.docs;
|
||||
if (!Array.isArray(docs)) {
|
||||
throw new CommandExecutionError('archive search returned malformed payload: response.docs must be an array');
|
||||
}
|
||||
if (docs.length === 0) {
|
||||
throw new EmptyResultError('archive search', `No items match "${query}" on archive.org.`);
|
||||
}
|
||||
|
||||
return docs.slice(0, limit).map((d, i) => {
|
||||
const id = String(d.identifier ?? '');
|
||||
if (!IDENTIFIER_RE.test(id)) {
|
||||
throw new CommandExecutionError('archive search returned malformed payload: result row is missing a stable identifier');
|
||||
}
|
||||
const downloads = Number(d.downloads ?? 0);
|
||||
if (!Number.isFinite(downloads)) {
|
||||
throw new CommandExecutionError(`archive search returned malformed payload for "${id}": downloads must be numeric`);
|
||||
}
|
||||
const creator = Array.isArray(d.creator) ? d.creator.join(', ') : String(d.creator ?? '');
|
||||
return {
|
||||
rank: i + 1,
|
||||
identifier: id,
|
||||
title: String(d.title ?? ''),
|
||||
creator,
|
||||
date: d.date ? String(d.date).slice(0, 10) : '',
|
||||
mediatype: String(d.mediatype ?? ''),
|
||||
downloads,
|
||||
url: id ? `https://archive.org/details/${id}` : '',
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// archive snapshots: Wayback Machine CDX history for a URL.
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
function buildWaybackUrl(timestamp, original) {
|
||||
if (!timestamp || !original) return '';
|
||||
return `https://web.archive.org/web/${timestamp}/${original}`;
|
||||
}
|
||||
|
||||
function requireCdxColumn(cols, name) {
|
||||
const index = cols[name];
|
||||
if (!Number.isInteger(index)) {
|
||||
throw new CommandExecutionError(`archive snapshots returned malformed CDX payload: missing "${name}" column`);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'archive',
|
||||
name: 'snapshots',
|
||||
access: 'read',
|
||||
description: 'List Wayback Machine snapshots over time for a URL via the CDX API.',
|
||||
domain: 'archive.org',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'url', positional: true, required: true, help: 'URL to look up (with or without scheme).' },
|
||||
{ name: 'from', type: 'string', required: false, help: 'Earliest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])' },
|
||||
{ name: 'to', type: 'string', required: false, help: 'Latest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max snapshots to return (max 1000).' },
|
||||
],
|
||||
columns: ['timestamp', 'snapshot_url', 'status', 'mimetype', 'original_url'],
|
||||
func: async (args) => {
|
||||
const target = String(args.url ?? '').trim();
|
||||
if (!target) {
|
||||
throw new ArgumentError(
|
||||
'archive snapshots url cannot be empty',
|
||||
'Example: opencli archive snapshots wikipedia.org',
|
||||
);
|
||||
}
|
||||
const limit = Number(args.limit ?? 20);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new ArgumentError('archive snapshots limit must be a positive integer');
|
||||
}
|
||||
if (limit > 1000) {
|
||||
throw new ArgumentError('archive snapshots limit must be <= 1000');
|
||||
}
|
||||
for (const key of ['from', 'to']) {
|
||||
const v = args[key];
|
||||
if (v != null && !/^\d{4,14}$/.test(String(v))) {
|
||||
throw new ArgumentError(`archive snapshots ${key} must be a digit-only timestamp (YYYY[MM[DD[hh[mm[ss]]]]])`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wayback CDX is served on HTTP only; the HTTPS endpoint returns 503.
|
||||
const apiUrl = new URL('http://web.archive.org/cdx/search/cdx');
|
||||
apiUrl.searchParams.set('url', target);
|
||||
apiUrl.searchParams.set('output', 'json');
|
||||
apiUrl.searchParams.set('limit', String(limit));
|
||||
if (args.from) apiUrl.searchParams.set('from', String(args.from));
|
||||
if (args.to) apiUrl.searchParams.set('to', String(args.to));
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive snapshots request failed: ${error?.message || error}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`archive snapshots failed: HTTP ${resp.status}`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive snapshots returned malformed JSON: ${error?.message || error}`);
|
||||
}
|
||||
|
||||
// CDX returns an array of arrays; the first row is the header.
|
||||
if (!Array.isArray(data)) {
|
||||
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: top-level payload must be an array');
|
||||
}
|
||||
if (data.length < 2) {
|
||||
throw new EmptyResultError('archive snapshots', `No Wayback snapshots for "${target}".`);
|
||||
}
|
||||
const [header, ...rows] = data;
|
||||
if (!Array.isArray(header)) {
|
||||
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: header row must be an array');
|
||||
}
|
||||
const cols = {};
|
||||
header.forEach((name, i) => { cols[name] = i; });
|
||||
const timestampCol = requireCdxColumn(cols, 'timestamp');
|
||||
const originalCol = requireCdxColumn(cols, 'original');
|
||||
const statusCol = requireCdxColumn(cols, 'statuscode');
|
||||
const mimetypeCol = requireCdxColumn(cols, 'mimetype');
|
||||
|
||||
return rows.slice(0, limit).map(row => {
|
||||
if (!Array.isArray(row)) {
|
||||
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row must be an array');
|
||||
}
|
||||
const timestamp = String(row[timestampCol] ?? '');
|
||||
const original = String(row[originalCol] ?? '');
|
||||
const status = row[statusCol];
|
||||
const mimetype = row[mimetypeCol];
|
||||
if (!/^\d{14}$/.test(timestamp) || !original) {
|
||||
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row is missing timestamp/original URL');
|
||||
}
|
||||
if (status == null || mimetype == null || String(status) === '' || String(mimetype) === '') {
|
||||
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row is missing statuscode/mimetype');
|
||||
}
|
||||
return {
|
||||
timestamp,
|
||||
snapshot_url: buildWaybackUrl(timestamp, original),
|
||||
status: String(status),
|
||||
mimetype: String(mimetype),
|
||||
original_url: original,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// archive wayback: Wayback Machine closest-snapshot lookup for a URL.
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
function normalizeTimestamp(raw) {
|
||||
// Accept YYYY, YYYYMM, YYYYMMDD, YYYYMMDDhh, YYYYMMDDhhmm, YYYYMMDDhhmmss,
|
||||
// YYYY-MM-DD, or YYYY-MM-DDThh:mm:ss. Strip non-digits and validate length.
|
||||
const digits = String(raw).replace(/[^0-9]/g, '');
|
||||
if (!/^\d{4,14}$/.test(digits) || digits.length % 2 !== 0 && digits.length !== 4) {
|
||||
throw new ArgumentError('archive wayback timestamp must be YYYY[MM[DD[hh[mm[ss]]]]] or an ISO date');
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'archive',
|
||||
name: 'wayback',
|
||||
access: 'read',
|
||||
description: 'Look up the closest Wayback Machine snapshot for a URL.',
|
||||
domain: 'archive.org',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'url', positional: true, required: true, help: 'URL to look up (with or without scheme).' },
|
||||
{ name: 'timestamp', type: 'string', required: false, help: 'Target timestamp (YYYY[MM[DD[hh[mm[ss]]]]] or ISO date). Defaults to most recent snapshot.' },
|
||||
],
|
||||
columns: ['original_url', 'requested_timestamp', 'snapshot_timestamp', 'snapshot_url', 'status'],
|
||||
func: async (args) => {
|
||||
const target = String(args.url ?? '').trim();
|
||||
if (!target) {
|
||||
throw new ArgumentError(
|
||||
'archive wayback url cannot be empty',
|
||||
'Example: opencli archive wayback wikipedia.org',
|
||||
);
|
||||
}
|
||||
const timestamp = args.timestamp ? normalizeTimestamp(args.timestamp) : '';
|
||||
|
||||
const apiUrl = new URL('https://archive.org/wayback/available');
|
||||
apiUrl.searchParams.set('url', target);
|
||||
if (timestamp) apiUrl.searchParams.set('timestamp', timestamp);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive wayback request failed: ${error?.message || error}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`archive wayback failed: HTTP ${resp.status}`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`archive wayback returned malformed JSON: ${error?.message || error}`);
|
||||
}
|
||||
|
||||
const snap = data?.archived_snapshots?.closest;
|
||||
if (!snap || !snap.available) {
|
||||
throw new EmptyResultError('archive wayback', `No Wayback snapshot for "${target}".`);
|
||||
}
|
||||
if (typeof snap.url !== 'string' || !snap.url || !/^\d{14}$/.test(String(snap.timestamp ?? ''))) {
|
||||
throw new CommandExecutionError('archive wayback returned malformed payload: closest snapshot is missing url/timestamp');
|
||||
}
|
||||
|
||||
return [{
|
||||
original_url: String(data.url ?? target),
|
||||
requested_timestamp: timestamp,
|
||||
snapshot_timestamp: String(snap.timestamp ?? ''),
|
||||
snapshot_url: String(snap.url),
|
||||
status: String(snap.status ?? ''),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html><html><body><dl id="15" olr="5"> <dt><a href="//car.autohome.com.cn/price/brand-15.html#pvareaid=2042362"><img width="50" height="50" src="//car2.autoimg.cn/cardfs/series/g28/M08/10/45/autohomecar__CjIFVGUNeJWAOukrAADdG-QkWXI004.png"></a><div><a href="//car.autohome.com.cn/price/brand-15.html#pvareaid=2042362">宝马</a></div></dt> <dd> <li id="s7344">
|
||||
<h4><a href='//www.autohome.com.cn/7344/#levelsource=000000000_0&pvareaid=101594'>宝马i5</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/7344/price.html#pvareaid=101446'>43.99-53.99万</a></div><div><a href='//car.autohome.com.cn/price/series-7344.html#pvareaid=103446'>报价</a> <a id='atk_7344' href='//car.autohome.com.cn/pic/series/7344.html#pvareaid=103448'>图库</a> <a data-value='7344' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-7344-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/7344/#pvareaid=103459'>口碑</a></div>
|
||||
</li> <li id="s5758">
|
||||
<h4><a href='//www.autohome.com.cn/5758/#levelsource=000000000_0&pvareaid=101594'>宝马iX3</a><i class='icon icon-jseason' title='将上市'></i></h4>指导价:暂无<div><span class='text-through'>报价</span> <a id='atk_5758' href='//car.autohome.com.cn/pic/series/5758.html#pvareaid=103448'>图库</a> <a data-value='5758' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-5758-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/5758/#pvareaid=103459'>口碑</a></div>
|
||||
</li> <li id="s7827">
|
||||
<h4><a href='//www.autohome.com.cn/7827/#levelsource=000000000_0&pvareaid=101594'>宝马2系</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/7827/price.html#pvareaid=101446'>20.80-22.80万</a></div><div><a href='//car.autohome.com.cn/price/series-7827.html#pvareaid=103446'>报价</a> <a id='atk_7827' href='//car.autohome.com.cn/pic/series/7827.html#pvareaid=103448'>图库</a> <a data-value='7827' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-7827-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/7827/#pvareaid=103459'>口碑</a></div>
|
||||
</li> <li id="s66">
|
||||
<h4><a href='//www.autohome.com.cn/66/#levelsource=000000000_0&pvareaid=101594'>宝马3系</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/66/price.html#pvareaid=101446'>25.80-33.80万</a></div><div><a href='//car.autohome.com.cn/price/series-66.html#pvareaid=103446'>报价</a> <a id='atk_66' href='//car.autohome.com.cn/pic/series/66.html#pvareaid=103448'>图库</a> <a data-value='66' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-66-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/66/#pvareaid=103459'>口碑</a></div>
|
||||
</li> <li id="s6544">
|
||||
<h4><a href='//www.autohome.com.cn/6544/#levelsource=000000000_0&pvareaid=101594'>宝马i3</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/6544/price.html#pvareaid=101446'>27.80-33.80万</a></div><div><a href='//car.autohome.com.cn/price/series-6544.html#pvareaid=103446'>报价</a> <a id='atk_6544' href='//car.autohome.com.cn/pic/series/6544.html#pvareaid=103448'>图库</a> <a data-value='6544' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-6544-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/6544/#pvareaid=103459'>口碑</a></div>
|
||||
</li> </dd> </dl></body></html>
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"baseData": {
|
||||
"seriesname": "宝马X5",
|
||||
"brandName": "宝马",
|
||||
"levelname": "中大型SUV",
|
||||
"pricerange": "59.80-74.80",
|
||||
"average": "4.41",
|
||||
"seriesAverage": "4.41",
|
||||
"seriesScoreList": [
|
||||
{
|
||||
"typeName": "空间",
|
||||
"typeKey": 3,
|
||||
"score": 4.91,
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"typeName": "驾驶感受",
|
||||
"typeKey": 4,
|
||||
"score": 4.75,
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"typeName": "油耗",
|
||||
"typeKey": 6,
|
||||
"score": 4.02,
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"typeName": "外观",
|
||||
"typeKey": 8,
|
||||
"score": 4.75,
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"typeName": "内饰",
|
||||
"typeKey": 9,
|
||||
"score": 4.17,
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"typeName": "性价比",
|
||||
"typeKey": 15,
|
||||
"score": 4.22,
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"typeName": "配置",
|
||||
"typeKey": 40,
|
||||
"score": 4.02,
|
||||
"rank": 0
|
||||
}
|
||||
],
|
||||
"cmpSeriesScore": [
|
||||
{
|
||||
"seriesId": 8449,
|
||||
"newCarPPH": 0,
|
||||
"newCarPPHUserCount": 0,
|
||||
"seriesName": "奥迪E7X",
|
||||
"score": "4.59",
|
||||
"maxItemScore": "4.90",
|
||||
"maxItemName": "动力",
|
||||
"reliabilityPPH": 0,
|
||||
"reliabilityPPHUserCount": 0
|
||||
},
|
||||
{
|
||||
"seriesId": 8529,
|
||||
"newCarPPH": 0,
|
||||
"newCarPPHUserCount": 0,
|
||||
"seriesName": "问界M6",
|
||||
"score": "4.58",
|
||||
"maxItemScore": "4.80",
|
||||
"maxItemName": "空间",
|
||||
"reliabilityPPH": 0,
|
||||
"reliabilityPPHUserCount": 0
|
||||
},
|
||||
{
|
||||
"seriesId": 8183,
|
||||
"newCarPPH": 42,
|
||||
"newCarPPHUserCount": 33,
|
||||
"seriesName": "理想i6",
|
||||
"score": "4.57",
|
||||
"maxItemScore": "4.82",
|
||||
"maxItemName": "空间",
|
||||
"reliabilityPPH": 0,
|
||||
"reliabilityPPHUserCount": 0
|
||||
},
|
||||
{
|
||||
"seriesId": 8171,
|
||||
"newCarPPH": 123,
|
||||
"newCarPPHUserCount": 84,
|
||||
"seriesName": "钛7",
|
||||
"score": "4.52",
|
||||
"maxItemScore": "4.70",
|
||||
"maxItemName": "空间",
|
||||
"reliabilityPPH": 0,
|
||||
"reliabilityPPHUserCount": 0
|
||||
},
|
||||
{
|
||||
"seriesId": 6643,
|
||||
"newCarPPH": 47,
|
||||
"newCarPPHUserCount": 53,
|
||||
"seriesName": "问界M7",
|
||||
"score": "4.51",
|
||||
"maxItemScore": "4.68",
|
||||
"maxItemName": "空间",
|
||||
"reliabilityPPH": 0,
|
||||
"reliabilityPPHUserCount": 0
|
||||
}
|
||||
],
|
||||
"seriesid": 6548
|
||||
},
|
||||
"qualityData": {
|
||||
"pph": 136,
|
||||
"userCount": 53
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Unit tests for the 汽车之家 (Autohome) adapter.
|
||||
*
|
||||
* `brand` parses the catalog HTML; `score` parses koubei __NEXT_DATA__.
|
||||
* Both pure parsers run against frozen real-data fixtures (宝马 / series 6548).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
import {
|
||||
BRAND_COLUMNS,
|
||||
SCORE_COLUMNS,
|
||||
resolveBrandInitial,
|
||||
normalizeSeriesId,
|
||||
extractPageProps,
|
||||
requireLimit,
|
||||
} from './utils.js';
|
||||
import { parseBrandSeries } from './brand.js';
|
||||
import { parseScore } from './score.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG = readFileSync(join(__dirname, '__fixtures__/catalog.html'), 'utf8');
|
||||
const KOUBEI = JSON.parse(readFileSync(join(__dirname, '__fixtures__/koubei.json'), 'utf8'));
|
||||
|
||||
describe('autohome adapter — registration', () => {
|
||||
it('registers brand + score as PUBLIC (no browser)', () => {
|
||||
for (const n of ['brand', 'score']) {
|
||||
const cmd = getRegistry().get(`autohome/${n}`);
|
||||
expect(cmd, n).toBeTruthy();
|
||||
expect(cmd.strategy, n).toBe(Strategy.PUBLIC);
|
||||
expect(cmd.browser, n).toBe(false);
|
||||
expect(cmd.access, n).toBe('read');
|
||||
}
|
||||
expect(getRegistry().get('autohome/brand').columns).toEqual(BRAND_COLUMNS);
|
||||
expect(getRegistry().get('autohome/score').columns).toEqual(SCORE_COLUMNS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autohome adapter — utils', () => {
|
||||
it('resolveBrandInitial maps brands and letters', () => {
|
||||
expect(resolveBrandInitial('宝马')).toBe('B');
|
||||
expect(resolveBrandInitial('比亚迪')).toBe('B');
|
||||
expect(resolveBrandInitial('理想')).toBe('L');
|
||||
expect(resolveBrandInitial('丰田')).toBe('F');
|
||||
expect(resolveBrandInitial('b')).toBe('B');
|
||||
expect(() => resolveBrandInitial('不存在的牌子')).toThrow();
|
||||
expect(() => resolveBrandInitial('')).toThrow();
|
||||
});
|
||||
it('normalizeSeriesId accepts numbers and URLs', () => {
|
||||
expect(normalizeSeriesId('6548')).toBe('6548');
|
||||
expect(normalizeSeriesId('https://k.autohome.com.cn/6548')).toBe('6548');
|
||||
expect(normalizeSeriesId('s6548')).toBe('6548');
|
||||
expect(() => normalizeSeriesId('宝马')).toThrow();
|
||||
});
|
||||
it('requireLimit rejects invalid limits instead of silently falling back', () => {
|
||||
expect(requireLimit(undefined, 60, 120)).toBe(60);
|
||||
expect(requireLimit('5', 60, 120)).toBe(5);
|
||||
expect(() => requireLimit('abc', 60, 120)).toThrow(/integer/);
|
||||
expect(() => requireLimit(121, 60, 120)).toThrow(/integer/);
|
||||
});
|
||||
it('extractPageProps returns null on missing blob', () => {
|
||||
expect(extractPageProps('<html>no</html>')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autohome adapter — parsers against frozen fixtures', () => {
|
||||
it('parseBrandSeries lists a brand\'s series with id + guide price', () => {
|
||||
const rows = parseBrandSeries(CATALOG, '宝马', 60);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const r of rows) {
|
||||
expect(Object.keys(r).sort()).toEqual([...BRAND_COLUMNS].sort());
|
||||
expect(r.series_id).toMatch(/^\d+$/);
|
||||
expect(r.name).toContain('宝马');
|
||||
expect(r.url).toContain(`/${r.series_id}/`);
|
||||
}
|
||||
expect(rows.some((r) => /万/.test(r.price))).toBe(true);
|
||||
});
|
||||
|
||||
it('parseBrandSeries returns [] for a brand not on the page', () => {
|
||||
expect(parseBrandSeries(CATALOG, '丰田', 60)).toEqual([]);
|
||||
});
|
||||
|
||||
it('parseBrandSeries rejects catalog pages without brand blocks', () => {
|
||||
expect(() => parseBrandSeries('<html></html>', '宝马', 60)).toThrow(/unexpected HTML shape/);
|
||||
});
|
||||
|
||||
it('parseBrandSeries rejects malformed series cards', () => {
|
||||
expect(() => parseBrandSeries('<dl><dt><div><a>宝马</a></div></dt><li id="s6548"></li></dl>', '宝马', 60))
|
||||
.toThrow(/stable text value/);
|
||||
});
|
||||
|
||||
it('parseScore builds a rating sheet with overall + axes + pph', () => {
|
||||
const rows = parseScore(KOUBEI, '6548');
|
||||
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
|
||||
expect(map.name).toBe('宝马X5');
|
||||
expect(map.brand).toBe('宝马');
|
||||
expect(map.guide_price).toMatch(/万$/);
|
||||
expect(typeof map.overall).toBe('number');
|
||||
expect(map.overall).toBeGreaterThan(0);
|
||||
expect(map.overall).toBeLessThanOrEqual(5);
|
||||
// a known axis from the fixture
|
||||
expect(typeof map['空间']).toBe('number');
|
||||
expect(typeof map.pph_每百车故障).toBe('number');
|
||||
expect(map.url).toContain('/6548');
|
||||
});
|
||||
|
||||
it('parseScore rejects malformed koubei payloads', () => {
|
||||
expect(() => parseScore({}, '6548')).toThrow(/unexpected payload shape/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* autohome brand — list a brand's car series with guide prices.
|
||||
*
|
||||
* Fetches the brand catalog page `grade/carhtml/<INITIAL>.html` (UTF-8, fully
|
||||
* server-rendered), isolates the `<dl>` block whose `<dt>` names the brand,
|
||||
* and reads each `<li id="s<seriesId>">` series + its 指导价. Pure HTML→rows
|
||||
* so it is unit-tested against a frozen catalog slice.
|
||||
*
|
||||
* This is Autohome's login-free "search": you search by brand (the catalog is
|
||||
* brand-organized). Free-text model search is signature-gated and not offered;
|
||||
* for that, use `dongchedi search`.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
AH_BASE,
|
||||
BRAND_COLUMNS,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
ahFetch,
|
||||
clean,
|
||||
requireLimit,
|
||||
requireStableId,
|
||||
requireText,
|
||||
resolveBrandInitial,
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* Pure parser: catalog HTML + brand name → series rows. Exported for tests.
|
||||
*/
|
||||
export function parseBrandSeries(html, brandName, limit) {
|
||||
const source = String(html || '');
|
||||
const blocks = source.match(/<dl[^>]*>[\s\S]*?<\/dl>/g);
|
||||
if (!blocks) {
|
||||
throw new CommandExecutionError('autohome brand catalog returned an unexpected HTML shape; expected brand <dl> blocks.');
|
||||
}
|
||||
const want = String(brandName || '').replace(/[·\s]/g, '');
|
||||
|
||||
// No brand name (single-letter catalog mode): scan the whole page.
|
||||
// Otherwise isolate the <dl> block whose <dt> names the brand.
|
||||
let block = html;
|
||||
if (want) {
|
||||
block = null;
|
||||
for (const b of blocks) {
|
||||
const nameM = b.match(/<dt>[\s\S]*?<div>\s*<a[^>]*>([^<]+)<\/a>/);
|
||||
const name = nameM ? clean(nameM[1]).replace(/[·\s]/g, '') : '';
|
||||
if (name && (name === want || name.startsWith(want) || want.startsWith(name))) {
|
||||
block = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!block) return [];
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
const liRe = /<li id="s(\d+)">([\s\S]*?)<\/li>/g;
|
||||
let m;
|
||||
while ((m = liRe.exec(block)) !== null) {
|
||||
const seriesId = requireStableId(m[1], `autohome brand row ${rows.length + 1}`);
|
||||
const li = m[2];
|
||||
const nameM = li.match(/<h4>\s*<a[^>]*>([^<]+)<\/a>/) || li.match(/<a[^>]*>([^<]+)<\/a>/);
|
||||
const name = requireText(nameM && nameM[1], `autohome brand row ${rows.length + 1} name`);
|
||||
const priceM = li.match(/指导价[::]\s*<[^>]*>([^<]+)</) || li.match(/指导价[::]\s*([^<]+)</);
|
||||
let price = clean(priceM && priceM[1]);
|
||||
if (/暂无|未上市|停售/.test(price)) price = '';
|
||||
rows.push({
|
||||
series_id: seriesId,
|
||||
name,
|
||||
price,
|
||||
url: `${AH_BASE}/${seriesId}/`,
|
||||
});
|
||||
if (rows.length >= limit) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'autohome',
|
||||
name: 'brand',
|
||||
access: 'read',
|
||||
aliases: ['series'],
|
||||
description: '汽车之家按品牌列出全部车系 + 厂商指导价(免登录)',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'brand', required: true, positional: true, help: '品牌名(宝马 / 比亚迪 / 理想 / 丰田 …)或车系目录首字母 A-Z' },
|
||||
{ name: 'limit', type: 'int', default: 60, help: '返回的车系数量(最多 120)' },
|
||||
],
|
||||
columns: BRAND_COLUMNS,
|
||||
func: async (args) => {
|
||||
const brand = String(args.brand || '').trim();
|
||||
const initial = resolveBrandInitial(brand);
|
||||
const limit = requireLimit(args.limit, 60, 120);
|
||||
|
||||
const html = await ahFetch(
|
||||
`${AH_BASE}/grade/carhtml/${initial}.html`,
|
||||
`brand ${brand}`,
|
||||
);
|
||||
const rows = parseBrandSeries(html, /^[A-Za-z]$/.test(brand) ? '' : brand, limit);
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError(
|
||||
`autohome brand ${brand}`,
|
||||
`No series found for '${brand}'. Check the brand name spelling (simplified Chinese), or try a single A-Z catalog letter.`,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* autohome score — 口碑 (owner-rating) summary for a car series.
|
||||
*
|
||||
* Reads `__NEXT_DATA__.props.pageProps.baseData` (+ `qualityData`) from the
|
||||
* koubei page `k.autohome.com.cn/<seriesId>`: overall rating, per-dimension
|
||||
* scores, level, guide price, the reliability PPH (每百辆车故障数), and the
|
||||
* competitor comparison. All unsigned, login-free. Returns a key/value sheet.
|
||||
*
|
||||
* Note: Autohome's per-review TEXT list loads from a separate signed XHR and
|
||||
* is intentionally not scraped — this command surfaces the aggregate only.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
AH_KOUBEI_BASE,
|
||||
SCORE_COLUMNS,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
assertPlainObject,
|
||||
ahFetch,
|
||||
clean,
|
||||
extractPageProps,
|
||||
normalizeSeriesId,
|
||||
} from './utils.js';
|
||||
|
||||
/** Number or null. */
|
||||
function num(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure parser: koubei pageProps → field/value rows. Exported for unit tests.
|
||||
*/
|
||||
export function parseScore(pp, seriesId) {
|
||||
const bd = assertPlainObject(pp?.baseData, 'autohome baseData');
|
||||
const qd = (pp && pp.qualityData) || {};
|
||||
|
||||
const competitors = (Array.isArray(bd.cmpSeriesScore) ? bd.cmpSeriesScore : [])
|
||||
.map((c) => {
|
||||
const name = clean(c.seriesname || c.seriesName);
|
||||
const s = c.average || c.score;
|
||||
return name ? `${name}(${s})` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(', ');
|
||||
|
||||
const fields = [
|
||||
['series_id', String(seriesId)],
|
||||
['name', clean(bd.seriesname)],
|
||||
['brand', clean(bd.brandName)],
|
||||
['level', clean(bd.levelname)],
|
||||
['guide_price', bd.pricerange ? `${clean(bd.pricerange)}万` : ''],
|
||||
['overall', num(bd.average ?? bd.seriesAverage)],
|
||||
];
|
||||
|
||||
for (const axis of (Array.isArray(bd.seriesScoreList) ? bd.seriesScoreList : [])) {
|
||||
const label = clean(axis.typeName);
|
||||
if (label) fields.push([label, num(axis.score)]);
|
||||
}
|
||||
|
||||
fields.push(['pph_每百车故障', num(qd.pph)]);
|
||||
fields.push(['review_users', num(qd.userCount)]);
|
||||
fields.push(['competitors', competitors]);
|
||||
fields.push(['url', `${AH_KOUBEI_BASE}/${seriesId}`]);
|
||||
|
||||
return fields.map(([field, value]) => ({ field, value }));
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'autohome',
|
||||
name: 'score',
|
||||
access: 'read',
|
||||
aliases: ['koubei', 'rating'],
|
||||
description: '汽车之家车系口碑评分(总分 + 各维度 + 故障率PPH + 竞品对比,免登录)',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'series_id', required: true, positional: true, help: '车系 ID(来自 brand 的 series_id,或 k.autohome.com.cn/<id> URL)' },
|
||||
],
|
||||
columns: SCORE_COLUMNS,
|
||||
func: async (args) => {
|
||||
const seriesId = normalizeSeriesId(args.series_id);
|
||||
const html = await ahFetch(`${AH_KOUBEI_BASE}/${seriesId}`, `score ${seriesId}`);
|
||||
const pp = extractPageProps(html);
|
||||
if (!pp) {
|
||||
throw new CommandExecutionError(
|
||||
`autohome score ${seriesId}`,
|
||||
'No koubei data found — the series id may be wrong, or Autohome changed its page.',
|
||||
);
|
||||
}
|
||||
const rows = parseScore(pp, seriesId);
|
||||
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
if (!map.name && map.overall == null) {
|
||||
throw new EmptyResultError(
|
||||
`autohome score ${seriesId}`,
|
||||
'This series has no koubei rating yet.',
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Shared helpers for the 汽车之家 (Autohome) adapter.
|
||||
*
|
||||
* Autohome's keyword-search and per-trim-config JSON APIs are app-signature
|
||||
* gated (and the config page additionally uses CSS font-glyph obfuscation),
|
||||
* so those are deliberately NOT used — they cannot be read reliably without a
|
||||
* browser running Autohome's signing code, and faking partial data would be
|
||||
* worse than omitting it. Two sources ARE clean, no-login, plain-HTTP:
|
||||
*
|
||||
* 1. The brand catalog `grade/carhtml/<INITIAL>.html` — every series of a
|
||||
* brand with its 指导价 (guide price), keyed by the brand's pinyin
|
||||
* initial letter (hence the BRAND_INITIAL map below).
|
||||
* 2. The 口碑 page `k.autohome.com.cn/<seriesId>` — a Next.js page whose
|
||||
* `__NEXT_DATA__.props.pageProps.baseData` carries the aggregate owner
|
||||
* rating (overall + per-dimension), level, price, competitors, and the
|
||||
* reliability PPH (每百辆车故障数).
|
||||
*
|
||||
* So the adapter searches by BRAND (you almost always know the brand) and
|
||||
* reads ratings by seriesId — both unsigned, both login-free.
|
||||
*/
|
||||
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
export const AH_BASE = 'https://www.autohome.com.cn';
|
||||
export const AH_KOUBEI_BASE = 'https://k.autohome.com.cn';
|
||||
|
||||
const UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
|
||||
+ '(KHTML, like Gecko) Chrome/126.0 Safari/537.36';
|
||||
|
||||
export const BRAND_COLUMNS = ['series_id', 'name', 'price', 'url'];
|
||||
export const SCORE_COLUMNS = ['field', 'value'];
|
||||
|
||||
/**
|
||||
* 中文品牌名 → 车系目录页的拼音首字母 (grade/carhtml/<X>.html).
|
||||
* Covers the brands people actually search; unknown brands raise a clear
|
||||
* error rather than guessing the wrong page.
|
||||
*/
|
||||
export const BRAND_INITIAL = {
|
||||
奥迪: 'A', 阿斯顿马丁: 'A', 阿尔法罗密欧: 'A', 阿维塔: 'A', 埃安: 'A', 极狐: 'A',
|
||||
宝马: 'B', 奔驰: 'B', 比亚迪: 'B', 别克: 'B', 本田: 'B', 标致: 'B', 保时捷: 'B', 宝骏: 'B', 北京: 'B', 北汽: 'B', 宾利: 'B', 北京现代: 'B',
|
||||
长安: 'C', 长城: 'C', 长安启源: 'C', 长安欧尚: 'C', 传祺: 'C',
|
||||
大众: 'D', 东风: 'D', 道奇: 'D', 东风风行: 'D', 东风小康: 'D',
|
||||
法拉利: 'F', 福特: 'F', 丰田: 'F', 菲亚特: 'F', 福田: 'F', 方程豹: 'F', 飞凡: 'F',
|
||||
广汽: 'G', 广汽丰田: 'G', 广汽本田: 'G', 高合: 'G',
|
||||
哈弗: 'H', 红旗: 'H', 海马: 'H', 悍马: 'H', 哈飞: 'H', 华晨: 'H',
|
||||
吉利: 'J', 捷豹: 'J', 极氪: 'J', 江淮: 'J', 几何: 'J', 捷途: 'J', 金杯: 'J', 江铃: 'J', 吉普: 'J', 极石: 'J',
|
||||
凯迪拉克: 'K', 克莱斯勒: 'K', 开瑞: 'K', 凯翼: 'K',
|
||||
兰博基尼: 'L', 路虎: 'L', 雷克萨斯: 'L', 林肯: 'L', 铃木: 'L', 劳斯莱斯: 'L', 雷诺: 'L', 理想: 'L', 领克: 'L', 零跑: 'L', 路特斯: 'L', 岚图: 'L', 猎豹: 'L',
|
||||
马自达: 'M', 迈巴赫: 'M', 名爵: 'M', 玛莎拉蒂: 'M', 迈凯伦: 'M',
|
||||
哪吒: 'N',
|
||||
欧拉: 'O',
|
||||
奇瑞: 'Q', 起亚: 'Q',
|
||||
日产: 'R', 荣威: 'R',
|
||||
斯巴鲁: 'S', 斯柯达: 'S', 三菱: 'S', 上汽大通: 'S', 思皓: 'S', 赛力斯: 'S', smart: 'S',
|
||||
特斯拉: 'T', 腾势: 'T', 坦克: 'T',
|
||||
沃尔沃: 'W', 五菱: 'W', 蔚来: 'W', 威马: 'W', 魏牌: 'W', 问界: 'W',
|
||||
现代: 'X', 雪佛兰: 'X', 雪铁龙: 'X', 小鹏: 'X', 星途: 'X', 小米: 'X',
|
||||
英菲尼迪: 'Y', 一汽: 'Y', 野马: 'Y', 仰望: 'Y',
|
||||
智己: 'Z', 中华: 'Z', 众泰: 'Z',
|
||||
};
|
||||
|
||||
/** Resolve a brand name to its catalog initial letter. */
|
||||
export function resolveBrandInitial(brandArg) {
|
||||
const raw = String(brandArg || '').trim();
|
||||
if (!raw) throw new ArgumentError('brand must be a non-empty value');
|
||||
// single A-Z letter passes through (advanced: fetch a whole letter page)
|
||||
if (/^[A-Za-z]$/.test(raw)) return raw.toUpperCase();
|
||||
const key = raw.replace(/[·\s]/g, '');
|
||||
if (BRAND_INITIAL[key]) return BRAND_INITIAL[key];
|
||||
if (BRAND_INITIAL[raw]) return BRAND_INITIAL[raw];
|
||||
throw new ArgumentError(
|
||||
'brand',
|
||||
`unknown brand '${brandArg}'. Pass a known Chinese brand name (e.g. 宝马 / 比亚迪 / 理想) or a single A-Z catalog letter.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalize a series id: a bare number or an autohome URL containing it. */
|
||||
export function normalizeSeriesId(rawInput) {
|
||||
const raw = String(rawInput || '').trim();
|
||||
if (!raw) throw new ArgumentError('series_id must be a non-empty value');
|
||||
const m = raw.match(/\/(?:s)?(\d+)(?:\/|$|\.)/) || raw.match(/^s?(\d+)$/);
|
||||
if (!m) {
|
||||
throw new ArgumentError(`'${rawInput}' does not look like an autohome series id (a number, or a k.autohome.com.cn/<id> URL)`);
|
||||
}
|
||||
return m[1];
|
||||
}
|
||||
|
||||
export function clean(s) {
|
||||
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function requireLimit(value, def, max) {
|
||||
const raw = value == null || value === '' ? def : value;
|
||||
const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
||||
if (!Number.isInteger(n) || n < 1 || n > max) {
|
||||
throw new ArgumentError(`limit must be an integer between 1 and ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function requireStableId(value, label) {
|
||||
const id = String(value ?? '').trim();
|
||||
if (!/^\d+$/.test(id)) throw new CommandExecutionError(`${label} did not include a stable numeric id.`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function requireText(value, label) {
|
||||
const text = clean(value);
|
||||
if (!text) throw new CommandExecutionError(`${label} did not include a stable text value.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function assertPlainObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Fetch an Autohome page as text. The grade + koubei pages are UTF-8. */
|
||||
export async function ahFetch(url, contextHint) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: `${AH_BASE}/`,
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(`autohome ${contextHint} network error: ${err?.message || err}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`autohome ${contextHint} HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
/** Extract __NEXT_DATA__ pageProps from a koubei page (pure, testable). */
|
||||
export function extractPageProps(html) {
|
||||
const m = String(html || '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
const data = JSON.parse(m[1]);
|
||||
return (data && data.props && data.props.pageProps) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { ArgumentError, CommandExecutionError, EmptyResultError };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasBandSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.band.us' });
|
||||
return cookies.some(c => c.name === 'band_session' && c.value);
|
||||
}
|
||||
|
||||
async function verifyBandIdentity(page) {
|
||||
if (!await hasBandSessionCookie(page)) {
|
||||
throw new AuthRequiredError('band.us', 'Band band_session cookie missing');
|
||||
}
|
||||
await page.goto('https://www.band.us/feed');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
if (/auth\\.band\\.us\\/login/.test(location.href)) {
|
||||
return { kind: 'auth', detail: 'Band /feed redirected to auth login' };
|
||||
}
|
||||
let userId = '';
|
||||
try {
|
||||
const stack = [window.__INITIAL_STATE__, window.__BAND_STORE__].filter(Boolean);
|
||||
const seen = new Set();
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node || typeof node !== 'object' || seen.has(node)) continue;
|
||||
seen.add(node);
|
||||
if (Array.isArray(node)) { stack.push(...node); continue; }
|
||||
const u = node.user || node.me || node.currentUser;
|
||||
if (u && (u.user_no || u.user_id || u.userId || u.id)) {
|
||||
userId = String(u.user_no || u.user_id || u.userId || u.id);
|
||||
break;
|
||||
}
|
||||
for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
|
||||
}
|
||||
} catch {}
|
||||
if (!userId) {
|
||||
const el = document.querySelector('[data-user-no], [data-user_no]');
|
||||
userId = el?.getAttribute('data-user-no') || el?.getAttribute('data-user_no') || '';
|
||||
}
|
||||
return { ok: true, user_id: userId };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('band.us', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Band probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'band',
|
||||
domain: 'band.us',
|
||||
loginUrl: 'https://auth.band.us/login',
|
||||
columns: ['user_id'],
|
||||
quickCheck: hasBandSessionCookie,
|
||||
verify: verifyBandIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasBandSessionCookie(page)) {
|
||||
throw new AuthRequiredError('band.us', 'Waiting for Band band_session cookie');
|
||||
}
|
||||
return verifyBandIdentity(page);
|
||||
},
|
||||
});
|
||||
+144
-56
@@ -4,6 +4,47 @@
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
function normalizeSymbol(value) {
|
||||
const symbol = String(value ?? '').trim().toUpperCase();
|
||||
if (!symbol) throw new ArgumentError('symbol is required');
|
||||
return symbol;
|
||||
}
|
||||
|
||||
function normalizeExpiration(value) {
|
||||
const expiration = String(value ?? '').trim();
|
||||
if (!expiration) return '';
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(expiration)) {
|
||||
throw new ArgumentError('--expiration must use YYYY-MM-DD format');
|
||||
}
|
||||
const parsed = new Date(`${expiration}T00:00:00Z`);
|
||||
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== expiration) {
|
||||
throw new ArgumentError('--expiration must be a valid calendar date');
|
||||
}
|
||||
return expiration;
|
||||
}
|
||||
|
||||
function parseLimit(value) {
|
||||
if (value === undefined || value === null || value === '') return DEFAULT_LIMIT;
|
||||
const limit = Number(value);
|
||||
if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
|
||||
throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function unwrapBrowserResult(value) {
|
||||
if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
|
||||
return value.data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'greeks',
|
||||
@@ -14,19 +55,19 @@ cli({
|
||||
args: [
|
||||
{ name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
|
||||
{ name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of near-the-money strikes per type' },
|
||||
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Number of near-the-money strikes per type (1-100)' },
|
||||
],
|
||||
columns: [
|
||||
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
|
||||
'volume', 'openInterest', 'expiration',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const symbol = kwargs.symbol.toUpperCase().trim();
|
||||
const expiration = kwargs.expiration ?? '';
|
||||
const limit = kwargs.limit ?? 10;
|
||||
const symbol = normalizeSymbol(kwargs.symbol);
|
||||
const expiration = normalizeExpiration(kwargs.expiration);
|
||||
const limit = parseLimit(kwargs.limit);
|
||||
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
|
||||
await page.wait(4);
|
||||
const data = await page.evaluate(`
|
||||
const data = unwrapBrowserResult(await page.evaluate(`
|
||||
(async () => {
|
||||
const sym = ${JSON.stringify(symbol)};
|
||||
const expDate = ${JSON.stringify(expiration)};
|
||||
@@ -45,39 +86,53 @@ cli({
|
||||
+ '&fields=' + fields + '&raw=1';
|
||||
if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate);
|
||||
const resp = await fetch(url, { credentials: 'include', headers });
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
let items = d?.data || [];
|
||||
if (!resp.ok) {
|
||||
return { ok: false, reason: 'http', status: resp.status, statusText: resp.statusText || '' };
|
||||
}
|
||||
|
||||
if (!expDate) {
|
||||
const expirations = items
|
||||
.map(i => (i.raw || i).expirationDate || null)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const aTime = Date.parse(a);
|
||||
const bTime = Date.parse(b);
|
||||
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
|
||||
if (Number.isNaN(aTime)) return 1;
|
||||
if (Number.isNaN(bTime)) return -1;
|
||||
return aTime - bTime;
|
||||
});
|
||||
const nearestExpiration = expirations[0];
|
||||
if (nearestExpiration) {
|
||||
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
|
||||
}
|
||||
const d = await resp.json();
|
||||
const allItems = d?.data;
|
||||
if (!Array.isArray(allItems)) {
|
||||
return { ok: false, reason: 'malformed' };
|
||||
}
|
||||
let items = allItems;
|
||||
|
||||
if (!expDate) {
|
||||
const expirations = items
|
||||
.map(i => (i.raw || i).expirationDate || null)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const aTime = Date.parse(a);
|
||||
const bTime = Date.parse(b);
|
||||
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
|
||||
if (Number.isNaN(aTime)) return 1;
|
||||
if (Number.isNaN(bTime)) return -1;
|
||||
return aTime - bTime;
|
||||
});
|
||||
const nearestExpiration = expirations[0];
|
||||
if (nearestExpiration) {
|
||||
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate calls and puts, sort by distance from current price
|
||||
const calls = items
|
||||
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
|
||||
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
||||
.slice(0, limit);
|
||||
const puts = items
|
||||
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
|
||||
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
||||
.slice(0, limit);
|
||||
// Separate calls and puts, sort by distance from current price.
|
||||
const calls = items
|
||||
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
|
||||
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
||||
.slice(0, limit);
|
||||
const puts = items
|
||||
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
|
||||
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
||||
.slice(0, limit);
|
||||
const selected = [...calls, ...puts];
|
||||
|
||||
return [...calls, ...puts].map(i => {
|
||||
if (items.length > 0 && selected.length === 0) {
|
||||
return { ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rows: selected.map(i => {
|
||||
const r = i.raw || i;
|
||||
return {
|
||||
type: r.optionType,
|
||||
@@ -93,28 +148,61 @@ cli({
|
||||
openInterest: r.openInterest,
|
||||
expiration: r.expirationDate,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
return [];
|
||||
})
|
||||
};
|
||||
} catch(e) {
|
||||
return { ok: false, reason: 'exception', message: e?.message || String(e) };
|
||||
}
|
||||
})()
|
||||
`);
|
||||
if (!data || !Array.isArray(data))
|
||||
return [];
|
||||
return data.map(r => ({
|
||||
type: r.type || '',
|
||||
strike: r.strike,
|
||||
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
||||
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
||||
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
|
||||
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
|
||||
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
|
||||
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
|
||||
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
expiration: r.expiration ?? null,
|
||||
}));
|
||||
`));
|
||||
if (!data || data.ok !== true) {
|
||||
if (data?.reason === 'http') {
|
||||
throw new CommandExecutionError(`Barchart greeks request failed: HTTP ${data.status}${data.statusText ? ` ${data.statusText}` : ''}`);
|
||||
}
|
||||
if (data?.reason === 'malformed') {
|
||||
throw new CommandExecutionError(`Barchart greeks returned an unreadable options payload${data.message ? `: ${data.message}` : ''}`);
|
||||
}
|
||||
if (data?.reason === 'exception') {
|
||||
throw new CommandExecutionError(`Barchart greeks request failed: ${data.message || 'unknown error'}`);
|
||||
}
|
||||
throw new CommandExecutionError(`Failed to fetch Barchart greeks for ${symbol}`);
|
||||
}
|
||||
if (!Array.isArray(data.rows)) {
|
||||
throw new CommandExecutionError('Barchart greeks returned an unreadable options payload');
|
||||
}
|
||||
if (data.rows.length === 0) {
|
||||
throw new EmptyResultError('barchart greeks', `No option greeks were returned for ${symbol}. Confirm the symbol, expiration, and Barchart login state.`);
|
||||
}
|
||||
return data.rows.map(r => {
|
||||
if (!r || typeof r !== 'object' || Array.isArray(r)) {
|
||||
throw new CommandExecutionError('Barchart greeks returned a malformed option row');
|
||||
}
|
||||
const type = String(r.type || '').trim();
|
||||
const expirationValue = String(r.expiration || '').trim();
|
||||
if (!/^(call|put)$/i.test(type) || r.strike === undefined || r.strike === null || r.strike === '' || !expirationValue) {
|
||||
throw new CommandExecutionError('Barchart greeks returned a malformed option row identity');
|
||||
}
|
||||
return {
|
||||
type,
|
||||
strike: r.strike,
|
||||
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
||||
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
||||
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
|
||||
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
|
||||
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
|
||||
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
|
||||
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
expiration: expirationValue,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeSymbol,
|
||||
normalizeExpiration,
|
||||
parseLimit,
|
||||
unwrapBrowserResult,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './greeks.js';
|
||||
|
||||
const { normalizeExpiration, normalizeSymbol, parseLimit, unwrapBrowserResult } = await import('./greeks.js').then((m) => m.__test__);
|
||||
|
||||
function makePage(evaluateResult) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
};
|
||||
}
|
||||
|
||||
describe('barchart greeks command', () => {
|
||||
const command = getRegistry().get('barchart/greeks');
|
||||
|
||||
it('registers with the expected shape', () => {
|
||||
expect(command).toBeDefined();
|
||||
expect(command.access).toBe('read');
|
||||
expect(command.browser).toBe(true);
|
||||
expect(command.columns).toEqual([
|
||||
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
|
||||
'volume', 'openInterest', 'expiration',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps returned option rows without changing the declared output shape', async () => {
|
||||
const page = makePage({
|
||||
session: 'site:barchart',
|
||||
data: {
|
||||
ok: true,
|
||||
rows: [
|
||||
{
|
||||
type: 'Call',
|
||||
strike: 190,
|
||||
last: 3.456,
|
||||
iv: 21.234,
|
||||
delta: 0.56789,
|
||||
gamma: 0.01234,
|
||||
theta: -0.12345,
|
||||
vega: 0.23456,
|
||||
rho: 0.03456,
|
||||
volume: 123,
|
||||
openInterest: 456,
|
||||
expiration: '2026-06-19',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { symbol: 'aapl', limit: 1 });
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.barchart.com/stocks/quotes/AAPL/options');
|
||||
expect(page.wait).toHaveBeenCalledWith(4);
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
type: 'Call',
|
||||
strike: 190,
|
||||
last: 3.46,
|
||||
iv: '21.23%',
|
||||
delta: 0.5679,
|
||||
gamma: 0.0123,
|
||||
theta: -0.1235,
|
||||
vega: 0.2346,
|
||||
rho: 0.0346,
|
||||
volume: 123,
|
||||
openInterest: 456,
|
||||
expiration: '2026-06-19',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates args before browser navigation and unwraps bridge envelopes', async () => {
|
||||
expect(normalizeSymbol(' aapl ')).toBe('AAPL');
|
||||
expect(normalizeExpiration('2026-06-19')).toBe('2026-06-19');
|
||||
expect(parseLimit(undefined)).toBe(10);
|
||||
expect(parseLimit(100)).toBe(100);
|
||||
expect(unwrapBrowserResult({ session: 'site:barchart', data: { ok: true } })).toEqual({ ok: true });
|
||||
|
||||
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: '', limit: 1 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', expiration: '2026-02-30', limit: 1 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', limit: 101 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
|
||||
it('embeds expiration and limit in the browser-side request script', async () => {
|
||||
const page = makePage({
|
||||
ok: true,
|
||||
rows: [{
|
||||
type: 'Put',
|
||||
strike: 185,
|
||||
last: null,
|
||||
iv: null,
|
||||
delta: null,
|
||||
gamma: null,
|
||||
theta: null,
|
||||
vega: null,
|
||||
rho: null,
|
||||
volume: 0,
|
||||
openInterest: 0,
|
||||
expiration: '2026-07-17',
|
||||
}],
|
||||
});
|
||||
|
||||
await command.func(page, { symbol: 'MSFT', expiration: '2026-07-17', limit: 7 });
|
||||
const script = page.evaluate.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain('const expDate = "2026-07-17"');
|
||||
expect(script).toContain('const limit = 7');
|
||||
expect(script).toContain("url += '&expirationDate=' + encodeURIComponent(expDate)");
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError for HTTP, malformed, exception, and missing payload states', async () => {
|
||||
await expect(command.func(makePage({ ok: false, reason: 'http', status: 403, statusText: 'Forbidden' }), { symbol: 'AAPL' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func(makePage({ ok: false, reason: 'malformed' }), { symbol: 'AAPL' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func(makePage({ ok: false, reason: 'exception', message: 'network down' }), { symbol: 'AAPL' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func(makePage({ ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' }), { symbol: 'AAPL' }))
|
||||
.rejects.toThrow('call or put identities');
|
||||
await expect(command.func(makePage(null), { symbol: 'AAPL' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func(makePage({ ok: true, rows: 'bad' }), { symbol: 'AAPL' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(command.func(makePage({ ok: true, rows: [{ type: 'Call', strike: null, expiration: '' }] }), { symbol: 'AAPL' }))
|
||||
.rejects.toThrow('malformed option row identity');
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when Barchart returns no greeks rows', async () => {
|
||||
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL' }))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AuthRequiredError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
import { apiGet, getSelfUid } from './utils.js';
|
||||
|
||||
async function hasBilibiliSessionCookies(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.bilibili.com' });
|
||||
const names = new Set(cookies.map(cookie => cookie.name));
|
||||
return names.has('SESSDATA') && names.has('DedeUserID');
|
||||
}
|
||||
|
||||
async function verifyBilibiliIdentity(page) {
|
||||
await page.goto('https://www.bilibili.com');
|
||||
const uid = await getSelfUid(page);
|
||||
const payload = await apiGet(page, '/x/space/wbi/acc/info', { params: { mid: uid }, signed: true });
|
||||
const data = payload?.data ?? {};
|
||||
return {
|
||||
id: String(data.mid ?? uid),
|
||||
username: data.name ?? '',
|
||||
level: data.level ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'bilibili',
|
||||
domain: 'www.bilibili.com',
|
||||
loginUrl: 'https://passport.bilibili.com/login',
|
||||
columns: ['id', 'username', 'level'],
|
||||
quickCheck: hasBilibiliSessionCookies,
|
||||
verify: verifyBilibiliIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasBilibiliSessionCookies(page)) {
|
||||
throw new AuthRequiredError('bilibili.com', 'Waiting for Bilibili session cookies');
|
||||
}
|
||||
return verifyBilibiliIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Bilibili comment — posts a top-level comment or a reply on a video via the official API.
|
||||
* Uses /x/v2/reply/add, authenticated by the logged-in cookie + bili_jct CSRF token.
|
||||
* @username mentions in the message are resolved to real mentions (at_name_to_mid).
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, apiPost, requireOkPayload, resolveBvid, resolveUid } from './utils.js';
|
||||
|
||||
function readPositiveInteger(value, label) {
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new ArgumentError(`bilibili comment ${label} must be a positive integer`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'comment',
|
||||
access: 'write',
|
||||
description: '在 B站视频下发表评论或回复(官方 API,需登录;消息里的 @用户 会被解析为真实提及)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
|
||||
{ name: 'message', required: true, positional: true, help: 'Comment text. Any @username in it is resolved to a real mention' },
|
||||
{ name: 'parent', type: 'int', help: 'top-level/root rpid to reply under (omit for a top-level comment)' },
|
||||
{ name: 'execute', type: 'boolean', help: 'Actually post the comment. Without it the command refuses to write.' },
|
||||
],
|
||||
columns: ['rpid', 'bvid', 'oid', 'message', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) {
|
||||
throw new CommandExecutionError('Browser session required for bilibili comment');
|
||||
}
|
||||
const message = String(kwargs.message ?? '').trim();
|
||||
if (!message)
|
||||
throw new ArgumentError('bilibili comment message cannot be empty');
|
||||
// Write guard: posting is public and irreversible-ish, so require an explicit opt-in.
|
||||
if (!kwargs.execute)
|
||||
throw new ArgumentError('Refusing to post: pass --execute to actually publish this comment');
|
||||
const parent = kwargs.parent != null ? readPositiveInteger(kwargs.parent, 'parent') : null;
|
||||
let bvid;
|
||||
try {
|
||||
bvid = await resolveBvid(kwargs.bvid);
|
||||
}
|
||||
catch (error) {
|
||||
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
// Resolve bvid → aid (the reply API addresses videos by aid, as `oid`)
|
||||
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
const viewData = requireOkPayload(view, 'view');
|
||||
const oid = viewData?.aid;
|
||||
if (!oid)
|
||||
throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
|
||||
// Resolve @username mentions to uids. Bilibili only turns "@name" into a real
|
||||
// mention — one that notifies the mentioned user — when the request carries
|
||||
// at_name_to_mid; a plain-text "@name" is otherwise inert and notifies nobody.
|
||||
/** @type {Record<string, number>} */
|
||||
const atNameToMid = {};
|
||||
for (const match of message.matchAll(/@([^\s@]+)/g)) {
|
||||
const name = match[1];
|
||||
if (name in atNameToMid)
|
||||
continue;
|
||||
try {
|
||||
const mid = Number(await resolveUid(page, name));
|
||||
if (!Number.isInteger(mid) || mid <= 0) {
|
||||
throw new CommandExecutionError(`Bilibili user search returned malformed mid for @${name}`);
|
||||
}
|
||||
atNameToMid[name] = mid;
|
||||
}
|
||||
catch (error) {
|
||||
if (!(error instanceof EmptyResultError)) {
|
||||
throw error;
|
||||
}
|
||||
// Unresolvable @name (typo, or not a user) — leave it as plain text.
|
||||
}
|
||||
}
|
||||
// For a reply, Bilibili needs both `root` (top-level comment) and `parent`.
|
||||
// Replying to a top-level comment means root === parent.
|
||||
const params = {
|
||||
oid,
|
||||
type: 1,
|
||||
message,
|
||||
plat: 1,
|
||||
...(parent != null
|
||||
? { root: parent, parent }
|
||||
: {}),
|
||||
...(Object.keys(atNameToMid).length > 0
|
||||
? { at_name_to_mid: JSON.stringify(atNameToMid) }
|
||||
: {}),
|
||||
};
|
||||
const payload = await apiPost(page, '/x/v2/reply/add', { params });
|
||||
const postData = requireOkPayload(payload, 'reply add');
|
||||
const rpid = postData?.rpid;
|
||||
if (!rpid) {
|
||||
throw new CommandExecutionError('Bilibili reply add API did not return rpid for the posted comment');
|
||||
}
|
||||
return [{
|
||||
rpid: String(rpid),
|
||||
bvid,
|
||||
oid: String(oid),
|
||||
message,
|
||||
url: `https://www.bilibili.com/video/${bvid}#reply${rpid}`,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const { mockApiGet, mockApiPost, mockResolveUid } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
mockApiPost: vi.fn(),
|
||||
mockResolveUid: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
apiGet: mockApiGet,
|
||||
apiPost: mockApiPost,
|
||||
resolveUid: mockResolveUid,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './comment.js';
|
||||
|
||||
describe('bilibili comment', () => {
|
||||
const command = getRegistry().get('bilibili/comment');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockResolveUid.mockReset();
|
||||
});
|
||||
|
||||
it('refuses to post without --execute', async () => {
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1WtAGzYEBm', message: 'hi' }),
|
||||
).rejects.toThrow(/--execute/);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty message before calling the API', async () => {
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: ' ', execute: true }),
|
||||
).rejects.toThrow(/empty/i);
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a top-level comment, resolving @mentions to at_name_to_mid', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } });
|
||||
mockResolveUid.mockResolvedValueOnce('1141159409'); // @AI视频小助理 → mid
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 99887766 } });
|
||||
|
||||
const result = await command.func({}, {
|
||||
bvid: 'BV1WtAGzYEBm', message: '@AI视频小助理 总结一下', execute: true,
|
||||
});
|
||||
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
|
||||
expect(mockResolveUid).toHaveBeenCalledWith({}, 'AI视频小助理');
|
||||
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
|
||||
params: {
|
||||
oid: 12345,
|
||||
type: 1,
|
||||
message: '@AI视频小助理 总结一下',
|
||||
plat: 1,
|
||||
at_name_to_mid: '{"AI视频小助理":1141159409}',
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([{
|
||||
rpid: '99887766',
|
||||
bvid: 'BV1WtAGzYEBm',
|
||||
oid: '12345',
|
||||
message: '@AI视频小助理 总结一下',
|
||||
url: 'https://www.bilibili.com/video/BV1WtAGzYEBm#reply99887766',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('still posts when an @mention cannot be resolved, leaving it as plain text', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
|
||||
mockResolveUid.mockRejectedValueOnce(new EmptyResultError('bilibili user search'));
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 5 } });
|
||||
|
||||
await command.func({}, { bvid: 'BV1xxx', message: '@幽灵用户zzz hi', execute: true });
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
|
||||
params: { oid: 7, type: 1, message: '@幽灵用户zzz hi', plat: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when mention resolution has parser or transport errors', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
|
||||
mockResolveUid.mockRejectedValueOnce(new CommandExecutionError('search API drift'));
|
||||
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: '@用户 hi', execute: true }),
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when mention resolution returns a malformed mid', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
|
||||
mockResolveUid.mockResolvedValueOnce('not-a-mid');
|
||||
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: '@用户 hi', execute: true }),
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a reply under an existing comment when --parent is given', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 1 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 2 } });
|
||||
|
||||
await command.func({}, { bvid: 'BV1xxx', message: 'thanks', parent: 555, execute: true });
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
|
||||
params: { oid: 1, type: 1, message: 'thanks', plat: 1, root: 555, parent: 555 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the bvid cannot be resolved to an aid', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BVbroken', message: 'hi', execute: true }),
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws with the API code and message when Bilibili rejects the comment', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 12025, message: '评论字数过多' });
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('maps login/csrf failures from the write API to AuthRequiredError', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: -111, message: 'csrf 校验失败' });
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
|
||||
).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('rejects invalid parent ids before posting', async () => {
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: 'x', parent: 0, execute: true }),
|
||||
).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when the write API omits rpid', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
await expect(
|
||||
command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
+116
-21
@@ -1,41 +1,136 @@
|
||||
/**
|
||||
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
|
||||
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
|
||||
* Bilibili comments — fetches comments via the official API.
|
||||
* Top-level comments come from /x/v2/reply/main (WBI-signed); with --parent,
|
||||
* the replies nested under a given comment come from /x/v2/reply/reply.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
function isAuthLikeBilibiliError(code, message) {
|
||||
return code === -101 || code === -403 || /登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
|
||||
}
|
||||
|
||||
function parseLimit(value) {
|
||||
const raw = value == null ? 20 : value;
|
||||
const limit = Number(raw);
|
||||
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
||||
throw new ArgumentError(`bilibili comments limit must be an integer between 1 and ${MAX_LIMIT}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function parseParent(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const parent = Number(value);
|
||||
if (!Number.isInteger(parent) || parent <= 0) {
|
||||
throw new ArgumentError('bilibili comments parent must be a positive integer rpid');
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function requireOkPayload(payload, label) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
|
||||
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
|
||||
}
|
||||
if (payload.code !== 0) {
|
||||
const message = payload.message ?? 'unknown error';
|
||||
if (isAuthLikeBilibiliError(payload.code, message)) {
|
||||
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
|
||||
}
|
||||
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function requireReplies(data, label) {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new CommandExecutionError(`Bilibili ${label} API returned malformed data`);
|
||||
}
|
||||
if (!Object.hasOwn(data, 'replies')) {
|
||||
throw new CommandExecutionError(`Bilibili ${label} API did not return replies`);
|
||||
}
|
||||
if (data.replies === null) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(data.replies)) {
|
||||
throw new CommandExecutionError(`Bilibili ${label} API returned malformed replies`);
|
||||
}
|
||||
return data.replies;
|
||||
}
|
||||
|
||||
function formatReplyRow(reply, index) {
|
||||
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) {
|
||||
throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was malformed`);
|
||||
}
|
||||
const rpid = String(reply.rpid ?? '').trim();
|
||||
if (!rpid) {
|
||||
throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was missing rpid`);
|
||||
}
|
||||
const ctime = Number(reply.ctime);
|
||||
if (!Number.isFinite(ctime)) {
|
||||
throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was missing ctime`);
|
||||
}
|
||||
return {
|
||||
rank: index + 1,
|
||||
rpid,
|
||||
author: String(reply.member?.uname ?? ''),
|
||||
text: String(reply.content?.message ?? '').replace(/\n/g, ' ').trim(),
|
||||
likes: reply.like ?? 0,
|
||||
replies: reply.rcount ?? 0,
|
||||
time: new Date(ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
|
||||
};
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'comments',
|
||||
access: 'read',
|
||||
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
|
||||
description: '获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
|
||||
{ name: 'parent', type: 'int', help: 'rpid of a comment — fetch the replies under it instead of top-level comments' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
|
||||
],
|
||||
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
|
||||
columns: ['rank', 'rpid', 'author', 'text', 'likes', 'replies', 'time'],
|
||||
func: async (page, kwargs) => {
|
||||
const bvid = await resolveBvid(kwargs.bvid);
|
||||
const limit = Math.min(Number(kwargs.limit) || 20, 50);
|
||||
if (!page) {
|
||||
throw new CommandExecutionError('Browser session required for bilibili comments');
|
||||
}
|
||||
let bvid;
|
||||
try {
|
||||
bvid = await resolveBvid(kwargs.bvid);
|
||||
}
|
||||
catch (error) {
|
||||
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const limit = parseLimit(kwargs.limit);
|
||||
const parent = parseParent(kwargs.parent);
|
||||
// Resolve bvid → aid (required by reply API)
|
||||
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
const aid = view?.data?.aid;
|
||||
const viewData = requireOkPayload(view, 'view');
|
||||
const aid = viewData?.aid;
|
||||
if (!aid)
|
||||
throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
|
||||
const payload = await apiGet(page, '/x/v2/reply/main', {
|
||||
params: { oid: aid, type: 1, mode: 3, ps: limit },
|
||||
signed: true,
|
||||
});
|
||||
const replies = payload?.data?.replies ?? [];
|
||||
return replies.slice(0, limit).map((r, i) => ({
|
||||
rank: i + 1,
|
||||
author: r.member?.uname ?? '',
|
||||
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
|
||||
likes: r.like ?? 0,
|
||||
replies: r.rcount ?? 0,
|
||||
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
|
||||
}));
|
||||
throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
|
||||
const payload = parent != null
|
||||
? await apiGet(page, '/x/v2/reply/reply', {
|
||||
params: { oid: aid, type: 1, root: parent, pn: 1, ps: limit },
|
||||
})
|
||||
: await apiGet(page, '/x/v2/reply/main', {
|
||||
params: { oid: aid, type: 1, mode: 3, ps: limit },
|
||||
signed: true,
|
||||
});
|
||||
const label = parent != null ? 'reply thread' : 'reply main';
|
||||
const replies = requireReplies(requireOkPayload(payload, label), label);
|
||||
if (replies.length === 0) {
|
||||
throw new EmptyResultError(parent != null ? `bilibili comment replies: ${parent}` : `bilibili comments: ${bvid}`);
|
||||
}
|
||||
return replies.slice(0, limit).map(formatReplyRow);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
@@ -15,11 +16,13 @@ describe('bilibili comments', () => {
|
||||
});
|
||||
it('resolves bvid to aid and fetches replies', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
|
||||
.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
replies: [
|
||||
{
|
||||
rpid: 777,
|
||||
member: { uname: 'Alice' },
|
||||
content: { message: 'Great video!' },
|
||||
like: 42,
|
||||
@@ -38,6 +41,7 @@ describe('bilibili comments', () => {
|
||||
expect(result).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
rpid: '777',
|
||||
author: 'Alice',
|
||||
text: 'Great video!',
|
||||
likes: 42,
|
||||
@@ -46,38 +50,93 @@ describe('bilibili comments', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('throws when aid cannot be resolved', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
|
||||
await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toThrow('Cannot resolve aid for bvid: BVinvalid123');
|
||||
});
|
||||
it('returns empty array when replies is missing', async () => {
|
||||
it('fetches replies under a comment via /x/v2/reply/reply when --parent is given', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 99 } })
|
||||
.mockResolvedValueOnce({ data: {} }); // no replies key
|
||||
const result = await command.func({}, { bvid: 'BV1xxx', limit: 5 });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
it('caps limit at 50', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({ data: { replies: [] } });
|
||||
await command.func({}, { bvid: 'BV1xxx', limit: 999 });
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
|
||||
params: { oid: 1, type: 1, mode: 3, ps: 50 },
|
||||
signed: true,
|
||||
.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
replies: [
|
||||
{
|
||||
rpid: 888,
|
||||
member: { uname: 'AI视频小助理' },
|
||||
content: { message: '视频总结:作者开了一家咖啡馆' },
|
||||
like: 8,
|
||||
rcount: 0,
|
||||
ctime: 1700000000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = await command.func({}, { bvid: 'BV1WtAGzYEBm', parent: 777, limit: 5 });
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/reply', {
|
||||
params: { oid: 12345, type: 1, root: 777, pn: 1, ps: 5 },
|
||||
});
|
||||
expect(result[0].author).toBe('AI视频小助理');
|
||||
expect(result[0].rpid).toBe('888');
|
||||
expect(result[0].text).toBe('视频总结:作者开了一家咖啡馆');
|
||||
});
|
||||
it('throws when aid cannot be resolved', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} }); // no aid
|
||||
await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
it('throws CommandExecutionError when replies is missing', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ code: 0, data: { aid: 99 } })
|
||||
.mockResolvedValueOnce({ code: 0, data: {} }); // no replies key
|
||||
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
it('rejects out-of-range limits instead of silently clamping', async () => {
|
||||
await expect(command.func({}, { bvid: 'BV1xxx', limit: 999 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
it('rejects invalid parent ids before fetching comments', async () => {
|
||||
await expect(command.func({}, { bvid: 'BV1xxx', parent: 0, limit: 5 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
it('maps auth-like API errors to AuthRequiredError', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ code: -101, message: '账号未登录', data: null });
|
||||
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
|
||||
.rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
it('throws EmptyResultError for explicit empty comments', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({ code: 0, data: { replies: [] } });
|
||||
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
it('collapses newlines in comment text', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
replies: [
|
||||
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
|
||||
{ rpid: 123, member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = (await command.func({}, { bvid: 'BV1xxx', limit: 5 }));
|
||||
expect(result[0].text).toBe('line1 line2 line3');
|
||||
});
|
||||
it('throws CommandExecutionError when a comment row lacks rpid', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
replies: [
|
||||
{ member: { uname: 'Bob' }, content: { message: 'hi' }, like: 0, rcount: 0, ctime: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
+103
-7
@@ -8,9 +8,91 @@
|
||||
* - yt-dlp must be installed: pip install yt-dlp
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
||||
import { checkYtdlp, sanitizeFilename } from '@jackwener/opencli/download';
|
||||
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
||||
import { resolveBvid } from './utils.js';
|
||||
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
|
||||
|
||||
const PAYMENT_LABELS = {
|
||||
vip: '大会员专享/付费 OGV',
|
||||
ugc_pay: 'UGC 单点付费',
|
||||
upower: '充电专属',
|
||||
};
|
||||
|
||||
function isObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载前付费预检:付费/会员视频 yt-dlp 只能拿到试看流或直接失败,
|
||||
* 与其跑一半吐一坨 yt-dlp stderr,不如提前抛结构化 PAID_CONTENT(exit 77)。
|
||||
*
|
||||
* 大会员专享(vip)会再查一次 nav API:当前账号大会员有效就放行(cookie 喂给
|
||||
* yt-dlp 能下完整流)。ugc_pay / upower 的购买/充电状态没有廉价查询端点,保守
|
||||
* 拦截,已购用户用 --force 跳过。预检自身的 API 失败不阻塞下载(保持旧行为)。
|
||||
*/
|
||||
async function assertNotPaidContent(page, bvid) {
|
||||
let d;
|
||||
try {
|
||||
const payload = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
if (!isObject(payload) || !Object.hasOwn(payload, 'code')) {
|
||||
throw new CommandExecutionError('Bilibili view API returned a malformed payload during paid-content pre-check');
|
||||
}
|
||||
if (payload.code !== 0)
|
||||
return;
|
||||
if (!isObject(payload.data) || !isObject(payload.data.rights)) {
|
||||
throw new CommandExecutionError('Bilibili view API returned malformed paid-content metadata');
|
||||
}
|
||||
d = payload.data;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof CommandExecutionError) {
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rights = d.rights;
|
||||
const paymentType = rights.pay
|
||||
? 'vip'
|
||||
: (rights.ugc_pay || rights.arc_pay)
|
||||
? 'ugc_pay'
|
||||
: d.is_upower_exclusive
|
||||
? 'upower'
|
||||
: '';
|
||||
if (!paymentType)
|
||||
return;
|
||||
if (paymentType === 'vip') {
|
||||
try {
|
||||
const nav = await apiGet(page, '/x/web-interface/nav');
|
||||
if (nav.code === 0 && Number(nav.data?.vipStatus) === 1)
|
||||
return;
|
||||
}
|
||||
catch {
|
||||
// nav 查询失败按"无会员"保守处理,走下面的拦截
|
||||
}
|
||||
}
|
||||
throw new CliError(
|
||||
'PAID_CONTENT',
|
||||
`该视频为付费内容(${PAYMENT_LABELS[paymentType]}),当前账号无观看权益,无法获取完整视频流`,
|
||||
'若已购买/已充电/已开通会员,加 --force 跳过本检查直接下载',
|
||||
EXIT_CODES.NOPERM,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSelectedPart(page, bvid, pageNum) {
|
||||
let payload;
|
||||
try {
|
||||
payload = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
}
|
||||
catch (error) {
|
||||
throw new CommandExecutionError(`获取视频分P信息失败: ${error?.message || error}`);
|
||||
}
|
||||
if (!isObject(payload) || payload.code !== 0) {
|
||||
throw new CommandExecutionError(`获取视频分P信息失败: ${payload?.message ?? 'unknown'} (${payload?.code ?? 'malformed'})`);
|
||||
}
|
||||
return selectVideoPart(payload.data, pageNum);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'download',
|
||||
@@ -22,12 +104,20 @@ cli({
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g., BV1xxx)' },
|
||||
{ name: 'output', default: './bilibili-downloads', help: 'Output directory' },
|
||||
{ name: 'quality', default: 'best', help: 'Video quality (best, 1080p, 720p, 480p)' },
|
||||
{ name: 'force', type: 'boolean', default: false, help: '跳过付费内容预检直接下载(已购买/已充电/已开通会员时用)' },
|
||||
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频下载该集;缺省下载默认 P1' },
|
||||
],
|
||||
columns: ['bvid', 'title', 'status', 'size'],
|
||||
func: async (page, kwargs) => {
|
||||
const bvid = await resolveBvid(kwargs.bvid);
|
||||
const output = kwargs.output;
|
||||
const quality = kwargs.quality;
|
||||
const selectedPage = parsePageArg(kwargs.page);
|
||||
const selectedPart = selectedPage != null ? await loadSelectedPart(page, bvid, selectedPage) : null;
|
||||
// yt-dlp 原生支持分P URL(?p=N),直接拼到 watch URL 即可定位到该集。
|
||||
const watchUrl = selectedPage != null
|
||||
? `https://www.bilibili.com/video/${bvid}?p=${selectedPage}`
|
||||
: `https://www.bilibili.com/video/${bvid}`;
|
||||
// Check yt-dlp availability
|
||||
if (!checkYtdlp()) {
|
||||
return [{
|
||||
@@ -37,9 +127,13 @@ cli({
|
||||
size: 'yt-dlp not installed. Run: pip install yt-dlp',
|
||||
}];
|
||||
}
|
||||
// Navigate to video page to get title and cookies
|
||||
await page.goto(`https://www.bilibili.com/video/${bvid}`);
|
||||
// Navigate to video page to get title and cookies(分P 时定位到该集)
|
||||
await page.goto(watchUrl);
|
||||
await page.wait(3);
|
||||
// 付费内容预检(--force 跳过)
|
||||
if (!kwargs.force) {
|
||||
await assertNotPaidContent(page, bvid);
|
||||
}
|
||||
// Extract video info
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
@@ -48,7 +142,9 @@ cli({
|
||||
return { title, author };
|
||||
})()
|
||||
`);
|
||||
const title = sanitizeFilename(data?.title || 'video');
|
||||
const partTitle = typeof selectedPart?.part === 'string' ? selectedPart.part.trim() : '';
|
||||
const displayTitle = partTitle || data?.title || 'video';
|
||||
const title = sanitizeFilename(displayTitle);
|
||||
// Extract cookies for yt-dlp
|
||||
const browserCookies = await page.getCookies({ domain: 'bilibili.com' });
|
||||
// Build yt-dlp format string based on quality
|
||||
@@ -62,8 +158,8 @@ cli({
|
||||
else if (quality === '480p') {
|
||||
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
|
||||
}
|
||||
const videoUrl = `https://www.bilibili.com/video/${bvid}`;
|
||||
const filename = `${bvid}_${title}.mp4`;
|
||||
const videoUrl = watchUrl;
|
||||
const filename = selectedPage != null ? `${bvid}_p${selectedPage}_${title}.mp4` : `${bvid}_${title}.mp4`;
|
||||
const results = await downloadMedia([{ type: 'video-ytdlp', url: videoUrl, filename }], {
|
||||
output,
|
||||
browserCookies,
|
||||
@@ -74,7 +170,7 @@ cli({
|
||||
const r = results[0] || { status: 'failed', size: '-' };
|
||||
return [{
|
||||
bvid,
|
||||
title: data?.title || 'video',
|
||||
title: displayTitle,
|
||||
status: r.status,
|
||||
size: r.size,
|
||||
}];
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
const { mockApiGet, mockDownloadMedia, mockCheckYtdlp } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
mockDownloadMedia: vi.fn(),
|
||||
mockCheckYtdlp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
vi.mock('@jackwener/opencli/download', () => ({
|
||||
checkYtdlp: mockCheckYtdlp,
|
||||
sanitizeFilename: (s) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@jackwener/opencli/download/media-download', () => ({
|
||||
downloadMedia: mockDownloadMedia,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './download.js';
|
||||
|
||||
/** view API 成功响应的最小骨架 */
|
||||
function viewPayload(extra = {}) {
|
||||
return { code: 0, data: { bvid: 'BV1xx411c7mD', rights: {}, ...extra } };
|
||||
}
|
||||
|
||||
describe('bilibili download paid-content pre-check', () => {
|
||||
const command = getRegistry().get('bilibili/download');
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ title: '标题', author: 'UP主' }),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockDownloadMedia.mockReset();
|
||||
mockCheckYtdlp.mockReset();
|
||||
mockCheckYtdlp.mockReturnValue(true);
|
||||
mockDownloadMedia.mockResolvedValue([{ status: 'success', size: '10MB' }]);
|
||||
page.goto.mockClear();
|
||||
page.evaluate.mockClear();
|
||||
});
|
||||
|
||||
it('downloads normal (free) video without interference', async () => {
|
||||
mockApiGet.mockResolvedValueOnce(viewPayload());
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
|
||||
|
||||
expect(rows[0].status).toBe('success');
|
||||
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws PAID_CONTENT for member-only bangumi when account has no vip', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } })) // view
|
||||
.mockResolvedValueOnce({ code: 0, data: { vipStatus: 0 } }); // nav
|
||||
|
||||
await expect(
|
||||
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
|
||||
).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
|
||||
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows member-only content when account has active vip', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } }))
|
||||
.mockResolvedValueOnce({ code: 0, data: { vipStatus: 1 } });
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
|
||||
|
||||
expect(rows[0].status).toBe('success');
|
||||
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws PAID_CONTENT for upower-exclusive video (no entitlement endpoint, conservative block)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce(viewPayload({ is_upower_exclusive: true }));
|
||||
|
||||
await expect(
|
||||
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
|
||||
).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
|
||||
// upower 没有权益查询端点,不应再打 nav API
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1);
|
||||
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when successful view payload lacks paid-content metadata', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1xx411c7mD' } });
|
||||
|
||||
await expect(
|
||||
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
|
||||
).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /paid-content metadata/.test(err.message),
|
||||
);
|
||||
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips pre-check entirely with --force', async () => {
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: true });
|
||||
|
||||
expect(rows[0].status).toBe('success');
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not block download when the pre-check API itself fails', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
|
||||
|
||||
expect(rows[0].status).toBe('success');
|
||||
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('targets the selected 分P part URL (?p=N) when --page is given', async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce(viewPayload({
|
||||
pages: [
|
||||
{ cid: 1001, page: 1, part: 'P1' },
|
||||
{ cid: 1003, page: 3, part: 'P3 标题' },
|
||||
],
|
||||
}))
|
||||
.mockResolvedValueOnce(viewPayload());
|
||||
|
||||
await command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '3' });
|
||||
|
||||
// goto 与 yt-dlp 下载 URL 都应带 ?p=3
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1h6V16SEpg?p=3');
|
||||
const job = mockDownloadMedia.mock.calls[0][0][0];
|
||||
expect(job.url).toBe('https://www.bilibili.com/video/BV1h6V16SEpg?p=3');
|
||||
expect(job.filename).toContain('_p3_P3 标题');
|
||||
});
|
||||
|
||||
it('rejects malformed --page before download side effects', async () => {
|
||||
await expect(
|
||||
command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '1e2' }),
|
||||
).rejects.toBeInstanceOf(ArgumentError);
|
||||
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before yt-dlp when selected --page is absent from view API pages', async () => {
|
||||
mockApiGet.mockResolvedValueOnce(viewPayload({
|
||||
pages: [{ cid: 1001, page: 1, part: 'P1' }],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '9' }),
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads default P1 (no ?p=) when --page is omitted', async () => {
|
||||
mockApiGet.mockResolvedValueOnce(viewPayload());
|
||||
|
||||
await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD');
|
||||
const job = mockDownloadMedia.mock.calls[0][0][0];
|
||||
expect(job.url).toBe('https://www.bilibili.com/video/BV1xx411c7mD');
|
||||
expect(job.filename).not.toContain('_p');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bilibili follow — establishes a follow relation via the official write API.
|
||||
* Authenticated by logged-in cookie + bili_jct CSRF token (handled by apiPost).
|
||||
*
|
||||
* Accepts target as: numeric uid, username, or a space.bilibili.com profile URL.
|
||||
* Pre-checks the current relation so the result row reports `already-following`
|
||||
* accurately instead of relying on the modify API's idempotent silent success.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiPost, fetchJson, getSelfUid, requireOkPayload, resolveUid } from './utils.js';
|
||||
|
||||
const RELATION_VERIFY_TIMEOUT_MS = 5000;
|
||||
const RELATION_VERIFY_POLL_MS = 500;
|
||||
|
||||
function parseSpaceMidUrl(raw) {
|
||||
const trimmed = String(raw ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (parsed.hostname.toLowerCase() !== 'space.bilibili.com') return '';
|
||||
const match = parsed.pathname.match(/^\/(\d+)\/?$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a uid out of a `space.bilibili.com/<uid>` URL before falling back to the
|
||||
* generic resolver. `resolveUid` only handles bare digits or usernames; without
|
||||
* this short-circuit a profile URL would get sent to the user-search endpoint
|
||||
* and likely return nothing.
|
||||
*/
|
||||
async function resolveTargetMid(page, raw) {
|
||||
const trimmed = String(raw ?? '').trim();
|
||||
if (!trimmed) {
|
||||
throw new ArgumentError('bilibili follow target cannot be empty');
|
||||
}
|
||||
if (/^(?:https?:\/\/)?space\.bilibili\.com\//i.test(trimmed)) {
|
||||
const mid = parseSpaceMidUrl(trimmed);
|
||||
if (!mid) {
|
||||
throw new ArgumentError('bilibili follow target must be a valid space.bilibili.com/<uid> URL');
|
||||
}
|
||||
return mid;
|
||||
}
|
||||
try {
|
||||
return await resolveUid(page, trimmed);
|
||||
} catch (error) {
|
||||
if (error instanceof EmptyResultError) throw error;
|
||||
throw new ArgumentError(
|
||||
`Cannot resolve Bilibili target from input: ${trimmed}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `attribute` from /x/relation encodes the viewer→target relation:
|
||||
* 0 = no relation, 2 = following, 6 = mutual follow, 128 = blocked.
|
||||
* `2` and `6` both count as "already following" from the follow command's POV.
|
||||
*/
|
||||
async function fetchRelationAttribute(page, mid) {
|
||||
const payload = await fetchJson(page, `https://api.bilibili.com/x/relation?fid=${mid}`);
|
||||
requireOkPayload(payload, 'relation query');
|
||||
const attribute = payload?.data?.attribute;
|
||||
if (typeof attribute !== 'number') {
|
||||
throw new CommandExecutionError('Bilibili relation query returned a malformed attribute');
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async function waitForRelation(page, mid, predicate, expectedLabel) {
|
||||
const deadline = Date.now() + RELATION_VERIFY_TIMEOUT_MS;
|
||||
let lastAttribute;
|
||||
while (Date.now() <= deadline) {
|
||||
lastAttribute = await fetchRelationAttribute(page, mid);
|
||||
if (predicate(lastAttribute)) return lastAttribute;
|
||||
if (typeof page.wait !== 'function') break;
|
||||
await page.wait({ time: RELATION_VERIFY_POLL_MS / 1000 });
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`Bilibili relation modify did not verify ${expectedLabel}; last attribute=${lastAttribute}`,
|
||||
);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'follow',
|
||||
access: 'write',
|
||||
description: '关注 B站用户(官方 API,需登录)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{
|
||||
name: 'target',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: '目标 UID / 用户名 / space.bilibili.com 链接',
|
||||
},
|
||||
],
|
||||
columns: ['mid', 'name', 'status', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) {
|
||||
throw new CommandExecutionError('Browser session required for bilibili follow');
|
||||
}
|
||||
const mid = await resolveTargetMid(page, kwargs.target);
|
||||
const self = await getSelfUid(page);
|
||||
if (mid === self) {
|
||||
throw new ArgumentError('Cannot follow yourself');
|
||||
}
|
||||
const attribute = await fetchRelationAttribute(page, mid);
|
||||
const url = `https://space.bilibili.com/${mid}`;
|
||||
if (attribute === 2 || attribute === 6) {
|
||||
return [{ mid, name: '', status: 'already-following', url }];
|
||||
}
|
||||
if (attribute === 128) {
|
||||
throw new CommandExecutionError(
|
||||
`Bilibili user ${mid} is blocked; unblock first before following.`,
|
||||
);
|
||||
}
|
||||
// act=1 follow, act=2 unfollow. re_src=11 is the community-standard
|
||||
// "web" source value used by third-party libs (bilibili-api-python etc.);
|
||||
// omitting it makes the modify API reject with a vague code.
|
||||
const payload = await apiPost(page, '/x/relation/modify', {
|
||||
params: { fid: mid, act: 1, re_src: 11 },
|
||||
});
|
||||
requireOkPayload(payload, 'relation modify');
|
||||
await waitForRelation(page, mid, (nextAttribute) => nextAttribute === 2 || nextAttribute === 6, 'following');
|
||||
return [{ mid, name: '', status: 'followed', url }];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
resolveTargetMid,
|
||||
fetchRelationAttribute,
|
||||
waitForRelation,
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const { mockApiPost, mockFetchJson, mockGetSelfUid, mockResolveUid } = vi.hoisted(() => ({
|
||||
mockApiPost: vi.fn(),
|
||||
mockFetchJson: vi.fn(),
|
||||
mockGetSelfUid: vi.fn(),
|
||||
mockResolveUid: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
apiPost: mockApiPost,
|
||||
fetchJson: mockFetchJson,
|
||||
getSelfUid: mockGetSelfUid,
|
||||
resolveUid: mockResolveUid,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './follow.js';
|
||||
import './unfollow.js';
|
||||
|
||||
describe('bilibili follow', () => {
|
||||
const command = getRegistry().get('bilibili/follow');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiPost.mockReset();
|
||||
mockFetchJson.mockReset();
|
||||
mockGetSelfUid.mockReset();
|
||||
mockResolveUid.mockReset();
|
||||
mockResolveUid.mockImplementation(async (_page, input) => String(input));
|
||||
mockGetSelfUid.mockResolvedValue('11111111');
|
||||
});
|
||||
|
||||
it('follows a user by numeric uid', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
|
||||
|
||||
const result = await command.func({}, { target: '9617619' });
|
||||
|
||||
expect(mockFetchJson).toHaveBeenCalledWith({}, 'https://api.bilibili.com/x/relation?fid=9617619');
|
||||
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/relation/modify', {
|
||||
params: { fid: '9617619', act: 1, re_src: 11 },
|
||||
});
|
||||
expect(result).toEqual([{
|
||||
mid: '9617619', name: '', status: 'followed',
|
||||
url: 'https://space.bilibili.com/9617619',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('extracts uid from a space.bilibili.com URL without calling resolveUid', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
|
||||
|
||||
await command.func({}, { target: 'https://space.bilibili.com/9617619' });
|
||||
|
||||
expect(mockResolveUid).not.toHaveBeenCalled();
|
||||
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/relation/modify', {
|
||||
params: { fid: '9617619', act: 1, re_src: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a username via resolveUid', async () => {
|
||||
mockResolveUid.mockResolvedValueOnce('555');
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
|
||||
|
||||
const result = await command.func({}, { target: '某up主' });
|
||||
|
||||
expect(mockResolveUid).toHaveBeenCalledWith({}, '某up主');
|
||||
expect(result[0].mid).toBe('555');
|
||||
});
|
||||
|
||||
it('reports already-following without calling modify when attribute is 2', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
|
||||
|
||||
const result = await command.func({}, { target: '9617619' });
|
||||
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(result[0].status).toBe('already-following');
|
||||
});
|
||||
|
||||
it('reports already-following for mutual-follow (attribute=6)', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
|
||||
|
||||
const result = await command.func({}, { target: '9617619' });
|
||||
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(result[0].status).toBe('already-following');
|
||||
});
|
||||
|
||||
it('refuses to follow yourself', async () => {
|
||||
mockGetSelfUid.mockResolvedValueOnce('9617619');
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockFetchJson).not.toHaveBeenCalled();
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty target before touching the API', async () => {
|
||||
await expect(command.func({}, { target: ' ' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockGetSelfUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed Bilibili profile URLs instead of searching the whole URL', async () => {
|
||||
await expect(command.func({}, { target: 'https://space.bilibili.com/not-a-uid' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockResolveUid).not.toHaveBeenCalled();
|
||||
expect(mockFetchJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates EmptyResultError when resolveUid finds no user', async () => {
|
||||
mockResolveUid.mockRejectedValueOnce(new EmptyResultError('bilibili user search'));
|
||||
|
||||
await expect(command.func({}, { target: '幽灵用户zzz' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to follow when the target is blocked (attribute=128)', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 128 } });
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps login/csrf failures from modify to AuthRequiredError', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: -101, message: '账号未登录' });
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('requires the relation to verify as following after modify succeeds', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toThrow(/did not verify following/);
|
||||
});
|
||||
|
||||
it('throws when relation query returns malformed attribute', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toThrow(/malformed attribute/);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError with the upstream code on non-auth modify failure', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 22002, message: 'follow too fast' });
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws when no browser session is provided', async () => {
|
||||
await expect(command.func(null, { target: '9617619' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bilibili unfollow', () => {
|
||||
const command = getRegistry().get('bilibili/unfollow');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiPost.mockReset();
|
||||
mockFetchJson.mockReset();
|
||||
mockGetSelfUid.mockReset();
|
||||
mockResolveUid.mockReset();
|
||||
mockResolveUid.mockImplementation(async (_page, input) => String(input));
|
||||
mockGetSelfUid.mockResolvedValue('11111111');
|
||||
});
|
||||
|
||||
it('unfollows a followed user and verifies the relation flipped', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
|
||||
const result = await command.func({}, { target: '9617619' });
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/relation/modify', {
|
||||
params: { fid: '9617619', act: 2, re_src: 11 },
|
||||
});
|
||||
expect(result[0].status).toBe('unfollowed');
|
||||
});
|
||||
|
||||
it('returns not-following without calling modify when already not following', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
|
||||
|
||||
const result = await command.func({}, { target: '9617619' });
|
||||
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(result[0].status).toBe('not-following');
|
||||
});
|
||||
|
||||
it('requires the relation to verify as not-following after modify succeeds', async () => {
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
|
||||
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
|
||||
|
||||
await expect(command.func({}, { target: '9617619' })).rejects.toThrow(/did not verify not following/);
|
||||
});
|
||||
});
|
||||
+81
-33
@@ -1,68 +1,97 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'subtitle',
|
||||
access: 'read',
|
||||
description: '获取 Bilibili 视频的字幕',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Bilibili 视频 BV ID(如 BV1xx411c7mD),或视频 URL / b23.tv 短链' },
|
||||
{ name: 'lang', required: false, help: '字幕语言代码 (如 zh-CN, en-US, ai-zh),默认取第一个' },
|
||||
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频取该集字幕;缺省取默认 P1' },
|
||||
],
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page)
|
||||
throw new CommandExecutionError('Browser session required for bilibili subtitle');
|
||||
const bvid = await resolveBvid(kwargs.bvid);
|
||||
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
|
||||
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
|
||||
// 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
|
||||
const cid = await page.evaluate(`(async () => {
|
||||
const state = window.__INITIAL_STATE__ || {};
|
||||
return state?.videoData?.cid;
|
||||
})()`);
|
||||
if (!cid) {
|
||||
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
const selectedPage = parsePageArg(kwargs.page);
|
||||
// 1. 通过 view API 拿 cid。
|
||||
// 以前的实现走 page.goto(/video/<bvid>) + window.__INITIAL_STATE__.videoData.cid,
|
||||
// bangumi 绑定的 bvid(番剧/纪录片/电影/综艺)页面 state 不在 videoData 而在 epList,
|
||||
// 导致 SELECTOR 错。view API 接受任何 bvid(UGC + PGC 都通),且不依赖 DOM 结构。
|
||||
let view;
|
||||
try {
|
||||
view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
}
|
||||
catch (err) {
|
||||
throw new CommandExecutionError(`获取视频信息失败: ${err?.message || err}`);
|
||||
}
|
||||
if (view?.code !== 0) {
|
||||
throw new CommandExecutionError(`获取视频信息失败: ${view?.message ?? 'unknown'} (${view?.code})`);
|
||||
}
|
||||
// --page 给定时用该集 cid(selectVideoPart 越界抛错);缺省取整集默认 cid(P1,旧行为)。
|
||||
const cid = selectedPage != null ? selectVideoPart(view?.data, selectedPage).cid : view?.data?.cid;
|
||||
if (!cid) {
|
||||
throw new CommandExecutionError(`无法从 view API 拿到 cid (bvid=${bvid})`);
|
||||
}
|
||||
// 2. 用带 Wbi 签名的 player/v2 拿字幕列表(之前 evaluate 里 fetch 因为没签名会 403)
|
||||
let payload;
|
||||
try {
|
||||
payload = await apiGet(page, '/x/player/wbi/v2', {
|
||||
params: { bvid, cid },
|
||||
signed: true,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
throw new CommandExecutionError(`获取视频播放信息失败: ${err?.message || err}`);
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new CommandExecutionError('获取到的视频播放信息对象不符合预期格式');
|
||||
}
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
|
||||
const payload = await apiGet(page, '/x/player/wbi/v2', {
|
||||
params: { bvid, cid },
|
||||
signed: true, // 开启 wbi_sign 自动签名
|
||||
});
|
||||
if (payload.code !== 0) {
|
||||
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
const needLoginSubtitle = payload.data?.need_login_subtitle === true;
|
||||
const subtitles = payload.data?.subtitle?.subtitles || [];
|
||||
const subtitles = payload.data?.subtitle?.subtitles;
|
||||
if (!Array.isArray(subtitles)) {
|
||||
throw new CommandExecutionError('获取到的字幕列表对象不符合数组格式');
|
||||
}
|
||||
if (subtitles.length === 0) {
|
||||
if (needLoginSubtitle) {
|
||||
throw new AuthRequiredError('bilibili.com', 'Bilibili subtitles are hidden behind login for this video. Please log in to bilibili.com in Chrome and retry.');
|
||||
}
|
||||
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
|
||||
}
|
||||
// 4. 选择目标字幕语言
|
||||
// 3. 选择目标字幕语言
|
||||
const target = kwargs.lang
|
||||
? subtitles.find((s) => s.lan === kwargs.lang) || subtitles[0]
|
||||
: subtitles[0];
|
||||
const targetSubUrl = target.subtitle_url;
|
||||
if (!targetSubUrl || targetSubUrl === '') {
|
||||
if (!target || typeof target !== 'object' || !Object.hasOwn(target, 'subtitle_url')) {
|
||||
throw new CommandExecutionError('字幕条目缺少 subtitle_url 字段');
|
||||
}
|
||||
const targetSubUrl = typeof target.subtitle_url === 'string' ? target.subtitle_url.trim() : '';
|
||||
if (!targetSubUrl) {
|
||||
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
}
|
||||
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
|
||||
// 5. 解析并拉取 CDN 的 JSON 文件
|
||||
if (!/^https?:\/\//i.test(finalUrl)) {
|
||||
throw new CommandExecutionError(`字幕 URL 非法: ${finalUrl}`);
|
||||
}
|
||||
// 4. 解析并拉取 CDN 的 JSON 文件
|
||||
const fetchJs = `
|
||||
(async () => {
|
||||
const url = ${JSON.stringify(finalUrl)};
|
||||
const res = await fetch(url);
|
||||
const text = await res.text();
|
||||
|
||||
|
||||
if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
|
||||
return { error: 'HTML', text: text.substring(0, 100), url };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const subJson = JSON.parse(text);
|
||||
// B站真实返回格式是 { font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", type: "json" , body: [{from: 0, to: 0, content: ""}] }
|
||||
@@ -74,20 +103,39 @@ cli({
|
||||
}
|
||||
})()
|
||||
`;
|
||||
const items = await page.evaluate(fetchJs);
|
||||
let items;
|
||||
try {
|
||||
items = await page.evaluate(fetchJs);
|
||||
}
|
||||
catch (err) {
|
||||
throw new CommandExecutionError(`字幕获取失败: ${err?.message || err}`);
|
||||
}
|
||||
if (items?.error) {
|
||||
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
}
|
||||
const finalItems = items?.data || [];
|
||||
if (!items || typeof items !== 'object' || items.success !== true) {
|
||||
throw new CommandExecutionError('字幕获取结果对象不符合预期格式');
|
||||
}
|
||||
const finalItems = items.data;
|
||||
if (!Array.isArray(finalItems)) {
|
||||
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
|
||||
}
|
||||
// 6. 数据映射
|
||||
return finalItems.map((item, idx) => ({
|
||||
index: idx + 1,
|
||||
from: Number(item.from || 0).toFixed(2) + 's',
|
||||
to: Number(item.to || 0).toFixed(2) + 's',
|
||||
content: item.content
|
||||
}));
|
||||
if (finalItems.length === 0) {
|
||||
throw new EmptyResultError('bilibili subtitle', '字幕文件中没有字幕片段。');
|
||||
}
|
||||
// 5. 数据映射
|
||||
return finalItems.map((item, idx) => {
|
||||
const from = Number(item?.from);
|
||||
const to = Number(item?.to);
|
||||
if (!item || typeof item !== 'object' || !Number.isFinite(from) || !Number.isFinite(to)) {
|
||||
throw new CommandExecutionError('字幕片段缺少有效 from/to 时间戳');
|
||||
}
|
||||
return {
|
||||
index: idx + 1,
|
||||
from: from.toFixed(2) + 's',
|
||||
to: to.toFixed(2) + 's',
|
||||
content: String(item.content ?? '')
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
@@ -20,30 +20,221 @@ describe('bilibili subtitle', () => {
|
||||
page.goto.mockClear();
|
||||
page.evaluate.mockReset();
|
||||
});
|
||||
|
||||
// 帮助函数:第一发 apiGet(view)固定返 cid=123456 的 OK 响应
|
||||
const mockViewOk = (cid = 123456) =>
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm', cid } });
|
||||
|
||||
it('throws AuthRequiredError when bilibili hides subtitles behind login', async () => {
|
||||
page.evaluate.mockResolvedValueOnce(123456);
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: true,
|
||||
subtitle: {
|
||||
subtitles: [],
|
||||
},
|
||||
subtitle: { subtitles: [] },
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy((err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message));
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy(
|
||||
(err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when a video truly has no subtitles', async () => {
|
||||
page.evaluate.mockResolvedValueOnce(123456);
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [] },
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when view API returns non-zero code', async () => {
|
||||
// 番剧/地区限制等场景下 view API 也会返非零;之前路径走 SELECTOR 错,现在统一走 view 错
|
||||
mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('wraps view API fetch/json exceptions as CommandExecutionError', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new SyntaxError('Unexpected token <'));
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when view API succeeds but lacks cid', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm' /* no cid */ } });
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(/cid/);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when player subtitle payload is malformed', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: { lan: 'zh-CN' } },
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when player API returns a non-object payload', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce(null);
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
|
||||
mockApiGet.mockReset();
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce([]);
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws AuthRequiredError only for explicit empty subtitle_url entries', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '' }] },
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when subtitle entry lacks subtitle_url field', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [{ lan: 'zh-CN' }] },
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('wraps subtitle file fetch exceptions as CommandExecutionError', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
|
||||
},
|
||||
});
|
||||
page.evaluate.mockRejectedValueOnce(new Error('Failed to fetch'));
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when subtitle file has no cue rows', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
|
||||
},
|
||||
});
|
||||
page.evaluate.mockResolvedValueOnce({ success: true, data: [] });
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when subtitle cue rows have malformed time ranges', async () => {
|
||||
mockViewOk();
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
|
||||
},
|
||||
});
|
||||
page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 'bad', to: 1.5, content: 'hello' }] });
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('uses the selected 分P part cid when --page is given', async () => {
|
||||
// view 返回 pages 数组;--page 3 应改用 pages[2].cid,而非 data.cid(默认 P1)
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1h6V16SEpg',
|
||||
cid: 1001, // P1 默认 cid
|
||||
pages: [
|
||||
{ cid: 1001, page: 1, part: '01' },
|
||||
{ cid: 1002, page: 2, part: '02' },
|
||||
{ cid: 1003, page: 3, part: '03 人生的价值' },
|
||||
],
|
||||
},
|
||||
});
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
|
||||
},
|
||||
});
|
||||
page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 0, to: 1, content: 'a' }] });
|
||||
|
||||
await command.func(page, { bvid: 'BV1h6V16SEpg', page: '3' });
|
||||
|
||||
// 第二发 apiGet(player/wbi/v2)的 cid 必须是第 3 集的 1003
|
||||
const playerCall = mockApiGet.mock.calls[1];
|
||||
expect(playerCall[1]).toBe('/x/player/wbi/v2');
|
||||
expect(playerCall[2]?.params?.cid).toBe(1003);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when --page is out of range', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1h6V16SEpg', cid: 1001, pages: [{ cid: 1001, page: 1, part: '01' }] },
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '9' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('rejects malformed --page before querying subtitle APIs', async () => {
|
||||
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('works for bangumi-bound bvid (PGC content) — same code path, view API returns cid + redirect_url', async () => {
|
||||
// 回归保护:以前 page.goto(/video/<bvid>) 对 bangumi 走重定向,
|
||||
// window.__INITIAL_STATE__.videoData 不存在 → SELECTOR 错。view API 不依赖页面结构,bangumi 同样能拿 cid。
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1Py4y1D781',
|
||||
cid: 267270412,
|
||||
redirect_url: 'https://www.bilibili.com/bangumi/play/ep371508',
|
||||
title: '【纪录片】灭绝的真相',
|
||||
},
|
||||
});
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
need_login_subtitle: false,
|
||||
subtitle: {
|
||||
subtitles: [],
|
||||
subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
|
||||
page.evaluate.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{ from: 0, to: 1.5, content: 'hello' },
|
||||
{ from: 1.5, to: 3.2, content: 'world' },
|
||||
],
|
||||
});
|
||||
const out = await command.func(page, { bvid: 'BV1Py4y1D781' });
|
||||
expect(out).toEqual([
|
||||
{ index: 1, from: '0.00s', to: '1.50s', content: 'hello' },
|
||||
{ index: 2, from: '1.50s', to: '3.20s', content: 'world' },
|
||||
]);
|
||||
// 关键:不再依赖 page.goto,所有 cid 解析走 apiGet
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
// 第一发 apiGet 一定是 view 端点
|
||||
const firstCall = mockApiGet.mock.calls[0];
|
||||
expect(firstCall[1]).toBe('/x/web-interface/view');
|
||||
expect(firstCall[2]?.params?.bvid).toBe('BV1Py4y1D781');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Bilibili summary — fetches the official AI-generated video summary (the "AI总结"
|
||||
* shown on the video page) via /x/web-interface/view/conclusion/get.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
|
||||
const BILIBILI_HOST_RE = /(^|\.)bilibili\.com$/i;
|
||||
const B23_HOST_RE = /(^|\.)b23\.tv$/i;
|
||||
const BVID_RE = /^BV[A-Za-z0-9]+$/;
|
||||
|
||||
function formatTime(seconds) {
|
||||
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
async function readBvid(raw) {
|
||||
const input = String(raw ?? '').trim();
|
||||
if (!input) {
|
||||
throw new ArgumentError('bilibili summary bvid cannot be empty', 'Pass a BV ID, Bilibili video URL, or b23.tv short link.');
|
||||
}
|
||||
if (BVID_RE.test(input)) {
|
||||
return input;
|
||||
}
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = new URL(input);
|
||||
} catch {
|
||||
// Bare b23.tv short codes are accepted by the shared resolver.
|
||||
}
|
||||
if (parsed) {
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new ArgumentError('Bilibili summary URL must use http or https');
|
||||
}
|
||||
if (BILIBILI_HOST_RE.test(parsed.hostname)) {
|
||||
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
|
||||
if (!match) {
|
||||
throw new ArgumentError('Bilibili summary URL must contain a BV video id');
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
if (!B23_HOST_RE.test(parsed.hostname)) {
|
||||
throw new ArgumentError('Bilibili summary URL must be a bilibili.com or b23.tv URL');
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await resolveBvid(input);
|
||||
} catch (error) {
|
||||
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${input}`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function requireOkPayload(payload, label) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
|
||||
}
|
||||
if (payload.code !== 0) {
|
||||
const message = payload.message ?? 'unknown error';
|
||||
if (payload.code === -101 || payload.code === -403 || /登录|权限|forbidden|permission|login/i.test(String(message))) {
|
||||
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
|
||||
}
|
||||
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function readModelResult(data, bvid) {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed data');
|
||||
}
|
||||
if (data.code !== 0) {
|
||||
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
|
||||
}
|
||||
let modelResult = data.model_result;
|
||||
if (typeof modelResult === 'string') {
|
||||
try {
|
||||
modelResult = JSON.parse(modelResult);
|
||||
} catch {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result JSON');
|
||||
}
|
||||
}
|
||||
if (!modelResult || typeof modelResult !== 'object' || Array.isArray(modelResult)) {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result');
|
||||
}
|
||||
const summary = String(modelResult.summary ?? '').trim();
|
||||
if (!summary) {
|
||||
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
|
||||
}
|
||||
const outline = modelResult.outline ?? [];
|
||||
if (!Array.isArray(outline)) {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline');
|
||||
}
|
||||
return { summary, outline };
|
||||
}
|
||||
|
||||
function rowsFromModel(model) {
|
||||
const rows = [{ time: '', content: model.summary }];
|
||||
for (const section of model.outline) {
|
||||
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline section');
|
||||
}
|
||||
const sectionTitle = String(section.title ?? '').trim();
|
||||
const sectionTime = formatTime(section.timestamp);
|
||||
if (sectionTitle) {
|
||||
rows.push({ time: sectionTime, content: `# ${sectionTitle}` });
|
||||
}
|
||||
const points = section.part_outline ?? [];
|
||||
if (!Array.isArray(points)) {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed part outline');
|
||||
}
|
||||
for (const point of points) {
|
||||
if (!point || typeof point !== 'object' || Array.isArray(point)) {
|
||||
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline point');
|
||||
}
|
||||
const content = String(point.content ?? '').trim();
|
||||
if (content) {
|
||||
rows.push({ time: formatTime(point.timestamp), content });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
var command = cli({
|
||||
site: 'bilibili',
|
||||
name: 'summary',
|
||||
access: 'read',
|
||||
description: '获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
|
||||
],
|
||||
columns: ['time', 'content'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) {
|
||||
throw new CommandExecutionError('Browser session required for bilibili summary');
|
||||
}
|
||||
const bvid = await readBvid(kwargs.bvid);
|
||||
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||||
const viewData = requireOkPayload(view, 'view');
|
||||
const cid = viewData?.cid;
|
||||
const upMid = viewData?.owner?.mid;
|
||||
if (!cid || !upMid) {
|
||||
throw new CommandExecutionError(`Bilibili view API did not return cid/up_mid for ${bvid}`);
|
||||
}
|
||||
const conclusion = await apiGet(page, '/x/web-interface/view/conclusion/get', {
|
||||
params: { bvid, cid, up_mid: upMid },
|
||||
signed: true,
|
||||
});
|
||||
const conclusionData = requireOkPayload(conclusion, 'conclusion');
|
||||
return rowsFromModel(readModelResult(conclusionData, bvid));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
command,
|
||||
formatTime,
|
||||
readBvid,
|
||||
readModelResult,
|
||||
rowsFromModel,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const { mockApiGet, mockResolveBvid } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
mockResolveBvid: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
apiGet: mockApiGet,
|
||||
resolveBvid: mockResolveBvid,
|
||||
}));
|
||||
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './summary.js';
|
||||
|
||||
describe('bilibili summary', () => {
|
||||
const command = getRegistry().get('bilibili/summary');
|
||||
const page = {};
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockResolveBvid.mockReset();
|
||||
mockResolveBvid.mockRejectedValue(new Error('short link not found'));
|
||||
});
|
||||
|
||||
function mockView(data = { aid: 114, cid: 222, owner: { mid: 333 } }) {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data });
|
||||
}
|
||||
|
||||
function mockConclusion(modelResult) {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
code: 0,
|
||||
model_result: modelResult,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('returns the summary plus timestamped outline rows', async () => {
|
||||
mockView();
|
||||
mockConclusion({
|
||||
summary: '整体总结',
|
||||
outline: [
|
||||
{
|
||||
title: '第一节',
|
||||
timestamp: 0,
|
||||
part_outline: [
|
||||
{ timestamp: 12, content: '要点A' },
|
||||
{ timestamp: 3725, content: '要点B' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await command.func(page, { bvid: 'BV1xxx' });
|
||||
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1xxx' } });
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(2, page, '/x/web-interface/view/conclusion/get', {
|
||||
params: { bvid: 'BV1xxx', cid: 222, up_mid: 333 },
|
||||
signed: true,
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ time: '', content: '整体总结' },
|
||||
{ time: '00:00', content: '# 第一节' },
|
||||
{ time: '00:12', content: '要点A' },
|
||||
{ time: '1:02:05', content: '要点B' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns just the summary when the video has no outline', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion({ summary: '只有总结', outline: [] });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([
|
||||
{ time: '', content: '只有总结' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses model_result when Bilibili returns it as a JSON string', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion(JSON.stringify({ summary: '字符串总结', outline: [] }));
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([
|
||||
{ time: '', content: '字符串总结' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes Bilibili video URLs before calling the APIs', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion({ summary: 'URL 总结', outline: [] });
|
||||
|
||||
await command.func(page, {
|
||||
bvid: 'https://www.bilibili.com/video/BV1abc12345/?spm_id_from=333.1007',
|
||||
});
|
||||
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1abc12345' } });
|
||||
});
|
||||
|
||||
it('resolves b23.tv short links through the shared resolver', async () => {
|
||||
mockResolveBvid.mockResolvedValueOnce('BVshort12345');
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion({ summary: '短链总结', outline: [] });
|
||||
|
||||
await command.func(page, { bvid: 'https://b23.tv/abc' });
|
||||
|
||||
expect(mockResolveBvid).toHaveBeenCalledWith('https://b23.tv/abc');
|
||||
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BVshort12345' } });
|
||||
});
|
||||
|
||||
it('rejects invalid inputs before calling Bilibili APIs', async () => {
|
||||
const cases = [
|
||||
'',
|
||||
'javascript:alert(1)',
|
||||
'https://example.com/video/BV1abc12345',
|
||||
'https://share.note.youdao.com/video/BV1abc12345',
|
||||
'https://www.bilibili.com/read/cv12345',
|
||||
];
|
||||
|
||||
for (const bvid of cases) {
|
||||
await expect(command.func(page, { bvid })).rejects.toBeInstanceOf(ArgumentError);
|
||||
}
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps unresolved short-code inputs to ArgumentError without calling APIs', async () => {
|
||||
await expect(command.func(page, { bvid: 'not-a-bv' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
|
||||
expect(mockResolveBvid).toHaveBeenCalledWith('not-a-bv');
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when Bilibili has not generated an AI summary for the video', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: { code: 1, model_result: {} } });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when the view payload is malformed', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /cid\/up_mid/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when the view API returns a non-auth error', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /啥都木有.*-404/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps conclusion auth or permission errors to AuthRequiredError', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockApiGet.mockResolvedValueOnce({ code: -403, message: '访问权限不足' });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('maps conclusion non-auth API errors to CommandExecutionError', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockApiGet.mockResolvedValueOnce({ code: -500, message: 'server error' });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /server error.*-500/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError for malformed conclusion API payloads', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockApiGet.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError for malformed model_result JSON', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion('{bad json');
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /model_result JSON/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError for malformed outline shapes', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion({ summary: '坏 outline', outline: {} });
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /outline/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError for malformed part outline shapes', async () => {
|
||||
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
|
||||
mockConclusion({
|
||||
summary: '坏 part_outline',
|
||||
outline: [{ title: '段落', timestamp: 0, part_outline: {} }],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
|
||||
(err) => err instanceof CommandExecutionError && /part outline/.test(err.message),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Bilibili unfollow — removes a follow relation via the official write API.
|
||||
* Mirror of follow.js with act=2. If the viewer is not currently following the
|
||||
* target, the API call is skipped and `not-following` is returned without
|
||||
* touching state.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiPost, fetchJson, getSelfUid, requireOkPayload, resolveUid } from './utils.js';
|
||||
|
||||
const RELATION_VERIFY_TIMEOUT_MS = 5000;
|
||||
const RELATION_VERIFY_POLL_MS = 500;
|
||||
|
||||
function parseSpaceMidUrl(raw) {
|
||||
const trimmed = String(raw ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (parsed.hostname.toLowerCase() !== 'space.bilibili.com') return '';
|
||||
const match = parsed.pathname.match(/^\/(\d+)\/?$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
async function resolveTargetMid(page, raw) {
|
||||
const trimmed = String(raw ?? '').trim();
|
||||
if (!trimmed) {
|
||||
throw new ArgumentError('bilibili unfollow target cannot be empty');
|
||||
}
|
||||
if (/^(?:https?:\/\/)?space\.bilibili\.com\//i.test(trimmed)) {
|
||||
const mid = parseSpaceMidUrl(trimmed);
|
||||
if (!mid) {
|
||||
throw new ArgumentError('bilibili unfollow target must be a valid space.bilibili.com/<uid> URL');
|
||||
}
|
||||
return mid;
|
||||
}
|
||||
try {
|
||||
return await resolveUid(page, trimmed);
|
||||
} catch (error) {
|
||||
if (error instanceof EmptyResultError) throw error;
|
||||
throw new ArgumentError(
|
||||
`Cannot resolve Bilibili target from input: ${trimmed}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRelationAttribute(page, mid) {
|
||||
const payload = await fetchJson(page, `https://api.bilibili.com/x/relation?fid=${mid}`);
|
||||
requireOkPayload(payload, 'relation query');
|
||||
const attribute = payload?.data?.attribute;
|
||||
if (typeof attribute !== 'number') {
|
||||
throw new CommandExecutionError('Bilibili relation query returned a malformed attribute');
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async function waitForRelation(page, mid, predicate, expectedLabel) {
|
||||
const deadline = Date.now() + RELATION_VERIFY_TIMEOUT_MS;
|
||||
let lastAttribute;
|
||||
while (Date.now() <= deadline) {
|
||||
lastAttribute = await fetchRelationAttribute(page, mid);
|
||||
if (predicate(lastAttribute)) return lastAttribute;
|
||||
if (typeof page.wait !== 'function') break;
|
||||
await page.wait({ time: RELATION_VERIFY_POLL_MS / 1000 });
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`Bilibili relation modify did not verify ${expectedLabel}; last attribute=${lastAttribute}`,
|
||||
);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'unfollow',
|
||||
access: 'write',
|
||||
description: '取消关注 B站用户(官方 API,需登录)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{
|
||||
name: 'target',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: '目标 UID / 用户名 / space.bilibili.com 链接',
|
||||
},
|
||||
],
|
||||
columns: ['mid', 'name', 'status', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) {
|
||||
throw new CommandExecutionError('Browser session required for bilibili unfollow');
|
||||
}
|
||||
const mid = await resolveTargetMid(page, kwargs.target);
|
||||
const self = await getSelfUid(page);
|
||||
if (mid === self) {
|
||||
throw new ArgumentError('Cannot unfollow yourself');
|
||||
}
|
||||
const attribute = await fetchRelationAttribute(page, mid);
|
||||
const url = `https://space.bilibili.com/${mid}`;
|
||||
// attribute 2=following, 6=mutual. Anything else means the viewer isn't
|
||||
// currently following — skip the POST and return idempotent status.
|
||||
if (attribute !== 2 && attribute !== 6) {
|
||||
return [{ mid, name: '', status: 'not-following', url }];
|
||||
}
|
||||
const payload = await apiPost(page, '/x/relation/modify', {
|
||||
params: { fid: mid, act: 2, re_src: 11 },
|
||||
});
|
||||
requireOkPayload(payload, 'relation modify');
|
||||
await waitForRelation(page, mid, (nextAttribute) => nextAttribute !== 2 && nextAttribute !== 6, 'not following');
|
||||
return [{ mid, name: '', status: 'unfollowed', url }];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
resolveTargetMid,
|
||||
fetchRelationAttribute,
|
||||
waitForRelation,
|
||||
};
|
||||
+152
-5
@@ -2,7 +2,7 @@
|
||||
* Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution.
|
||||
*/
|
||||
import https from 'node:https';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
/**
|
||||
* Resolve Bilibili short URL / short code to BV ID.
|
||||
* Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa
|
||||
@@ -12,7 +12,22 @@ export function resolveBvid(input) {
|
||||
if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) {
|
||||
return Promise.resolve(trimmed);
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (/(\.|^)bilibili\.com$/i.test(parsed.hostname)) {
|
||||
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
|
||||
if (match) {
|
||||
return Promise.resolve(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Non-URL inputs fall through to b23.tv short-code resolution.
|
||||
}
|
||||
const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, '');
|
||||
if (!/^[A-Za-z0-9]+$/.test(shortCode)) {
|
||||
return Promise.reject(new Error(`Cannot resolve BV ID from invalid b23.tv short code: ${trimmed}`));
|
||||
}
|
||||
const url = 'https://b23.tv/' + shortCode;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (res) => {
|
||||
@@ -29,9 +44,73 @@ export function resolveBvid(input) {
|
||||
reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
|
||||
req.setTimeout(4000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 解析 --page 选集序号(分P / 视频选集)。
|
||||
* 缺省/空串 → null(不下钻,保持整集默认 P1 旧行为)。
|
||||
* 非正十进制整数 → 抛 ArgumentError(参数错误,不静默吞)。
|
||||
*/
|
||||
export function parsePageArg(value) {
|
||||
if (value == null || value === '') return null;
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isSafeInteger(value) && value >= 1) return value;
|
||||
throw new ArgumentError(`--page must be a positive decimal integer, got: ${value}`);
|
||||
}
|
||||
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
|
||||
throw new ArgumentError(`--page must be a positive decimal integer, got: ${String(value)}`);
|
||||
}
|
||||
const n = Number(value);
|
||||
if (!Number.isSafeInteger(n)) {
|
||||
throw new ArgumentError(`--page is too large: ${value}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function readApiPositiveInteger(value, label) {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) {
|
||||
const n = Number(value);
|
||||
if (Number.isSafeInteger(n)) return n;
|
||||
}
|
||||
throw new CommandExecutionError(`Bilibili view API returned a malformed ${label}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 view API 的 data.pages 数组取第 N 集(1-based)。
|
||||
* page/cid 都以 view API 的 pages[] 为 source-of-truth;缺失、重复或畸形都 fail closed。
|
||||
* 返回该集 raw 对象(含 cid / part(分集标题) / page / duration)。
|
||||
*/
|
||||
export function selectVideoPart(viewData, pageNum) {
|
||||
const pages = Array.isArray(viewData?.pages) ? viewData.pages : null;
|
||||
if (!pages || pages.length === 0) {
|
||||
throw new CommandExecutionError('Bilibili view API did not return pages[] for --page selection');
|
||||
}
|
||||
const matches = [];
|
||||
for (const entry of pages) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
throw new CommandExecutionError('Bilibili view API returned a malformed pages[] entry');
|
||||
}
|
||||
const apiPage = readApiPositiveInteger(entry.page, 'page number');
|
||||
if (apiPage === pageNum) {
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new CommandExecutionError(`Bilibili view API returned duplicate page entries for p=${pageNum}`);
|
||||
}
|
||||
const part = matches[0];
|
||||
if (!part) {
|
||||
const total = pages.length || viewData?.videos || 1;
|
||||
throw new CommandExecutionError(`分P 序号超出范围:p=${pageNum}(该视频共 ${total} 集)`);
|
||||
}
|
||||
readApiPositiveInteger(part.cid, `cid for p=${pageNum}`);
|
||||
return part;
|
||||
}
|
||||
|
||||
const MIXIN_KEY_ENC_TAB = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
||||
@@ -104,6 +183,63 @@ export async function fetchJson(page, url) {
|
||||
}
|
||||
`);
|
||||
}
|
||||
/**
|
||||
* Bilibili write APIs return a JSON envelope `{ code, message, data }`. A non-zero
|
||||
* `code` carries either an auth/permission failure (login expired, CSRF rejected,
|
||||
* forbidden) or an application-level error (rate limit, validation, etc.). These
|
||||
* two helpers route the envelope to the right typed error so every write adapter
|
||||
* surfaces login problems as `AuthRequiredError`, not a generic execution error.
|
||||
*/
|
||||
export function isAuthLikeBilibiliError(code, message) {
|
||||
return code === -101 || code === -111 || code === -403 || /csrf|登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
|
||||
}
|
||||
|
||||
export function requireOkPayload(payload, label) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
|
||||
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
|
||||
}
|
||||
if (payload.code !== 0) {
|
||||
const message = payload.message ?? 'unknown error';
|
||||
if (isAuthLikeBilibiliError(payload.code, message)) {
|
||||
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
|
||||
}
|
||||
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST form-encoded params to a Bilibili API endpoint.
|
||||
* Runs inside the logged-in browser context and auto-attaches the bili_jct CSRF token,
|
||||
* which Bilibili requires on every authenticated write request.
|
||||
*/
|
||||
export async function apiPost(page, path, opts = {}) {
|
||||
const params = opts.params ?? {};
|
||||
const stringified = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]));
|
||||
const paramsJs = JSON.stringify(stringified);
|
||||
const urlJs = JSON.stringify(`https://api.bilibili.com${path}`);
|
||||
return page.evaluate(`
|
||||
async () => {
|
||||
const csrf = (document.cookie.match(/bili_jct=([^;]+)/) || [])[1] || "";
|
||||
const body = new URLSearchParams(${paramsJs});
|
||||
body.set("csrf", csrf);
|
||||
const res = await fetch(${urlJs}, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
// Bilibili write endpoints can return an HTML risk-control page (e.g. HTTP 412)
|
||||
// instead of JSON. Surface that as a structured error rather than a parse crash.
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { code: -1, message: "Non-JSON response (HTTP " + res.status + "): " + text.slice(0, 200) };
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
export async function getSelfUid(page) {
|
||||
const nav = await getNavData(page);
|
||||
const mid = nav?.data?.mid;
|
||||
@@ -119,8 +255,19 @@ export async function resolveUid(page, input) {
|
||||
params: { search_type: 'bili_user', keyword: input },
|
||||
signed: true,
|
||||
});
|
||||
const results = payload?.data?.result ?? [];
|
||||
if (results.length > 0)
|
||||
return String(results[0].mid);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data) || !Object.hasOwn(payload.data, 'result')) {
|
||||
throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
|
||||
}
|
||||
const results = payload.data.result;
|
||||
if (!Array.isArray(results)) {
|
||||
throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
|
||||
}
|
||||
if (results.length > 0) {
|
||||
const mid = String(results[0]?.mid ?? '').trim();
|
||||
if (!mid) {
|
||||
throw new CommandExecutionError(`Bilibili user search returned malformed mid for ${input}`);
|
||||
}
|
||||
return mid;
|
||||
}
|
||||
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveBvid } from './utils.js';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { parsePageArg, resolveBvid, resolveUid, selectVideoPart } from './utils.js';
|
||||
describe('resolveBvid', () => {
|
||||
it('passes through a valid BV ID', async () => {
|
||||
expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
|
||||
@@ -10,8 +11,87 @@ describe('resolveBvid', () => {
|
||||
it('handles non-string input via String() coercion', async () => {
|
||||
expect(await resolveBvid('BV123abc')).toBe('BV123abc');
|
||||
});
|
||||
it('extracts BV IDs from bilibili video URLs', async () => {
|
||||
expect(await resolveBvid('https://www.bilibili.com/video/BV1xx411c7mD/?spm_id_from=333.1007')).toBe('BV1xx411c7mD');
|
||||
expect(await resolveBvid('https://m.bilibili.com/video/BV1Je9EBnEha')).toBe('BV1Je9EBnEha');
|
||||
});
|
||||
it('rejects invalid input that cannot be resolved', async () => {
|
||||
// A random string that b23.tv won't resolve — should timeout or fail
|
||||
await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUid', () => {
|
||||
function pageWithUserSearchResult(result) {
|
||||
return {
|
||||
evaluate: async (script) => {
|
||||
if (String(script).includes('/x/web-interface/nav')) {
|
||||
return {
|
||||
data: {
|
||||
wbi_img: {
|
||||
img_url: 'https://i0.hdslb.com/bfs/wbi/abcdefghijklmnopqrstuvwxyz123456.png',
|
||||
sub_url: 'https://i0.hdslb.com/bfs/wbi/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456.png',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('returns numeric uid input without searching', async () => {
|
||||
expect(await resolveUid({}, '12345')).toBe('12345');
|
||||
});
|
||||
|
||||
it('fails closed when user search payload lacks result', async () => {
|
||||
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: {} }), 'missing'))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('fails closed when user search result row lacks mid', async () => {
|
||||
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [{}] } }), 'missing-mid'))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('keeps explicit no-user result as EmptyResultError', async () => {
|
||||
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [] } }), 'nobody'))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePageArg', () => {
|
||||
it('accepts omitted page and strict positive decimal integers', () => {
|
||||
expect(parsePageArg(undefined)).toBeNull();
|
||||
expect(parsePageArg(null)).toBeNull();
|
||||
expect(parsePageArg('')).toBeNull();
|
||||
expect(parsePageArg('1')).toBe(1);
|
||||
expect(parsePageArg('12')).toBe(12);
|
||||
expect(parsePageArg(3)).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects malformed or coerced page values as argument errors', () => {
|
||||
for (const value of ['0', '-1', '1.5', '1e2', '0x10', ' 1 ', '01', 'abc', Number.NaN, 1.2]) {
|
||||
expect(() => parsePageArg(value)).toThrow(ArgumentError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectVideoPart', () => {
|
||||
it('selects by unique API page number and preserves cid', () => {
|
||||
const part = selectVideoPart({
|
||||
pages: [
|
||||
{ page: 1, cid: 1001, part: 'P1' },
|
||||
{ page: 3, cid: '1003', part: 'P3' },
|
||||
],
|
||||
}, 3);
|
||||
expect(part).toMatchObject({ page: 3, cid: '1003', part: 'P3' });
|
||||
});
|
||||
|
||||
it('fails closed for missing, duplicate, or malformed page identity', () => {
|
||||
expect(() => selectVideoPart({ pages: [] }, 1)).toThrow(CommandExecutionError);
|
||||
expect(() => selectVideoPart({ pages: [{ page: 1, cid: 1 }, { page: 1, cid: 2 }] }, 1)).toThrow(CommandExecutionError);
|
||||
expect(() => selectVideoPart({ pages: [{ page: '1e0', cid: 1 }] }, 1)).toThrow(CommandExecutionError);
|
||||
expect(() => selectVideoPart({ pages: [{ page: 1, cid: 0 }] }, 1)).toThrow(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
|
||||
+93
-7
@@ -1,6 +1,33 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
|
||||
|
||||
function requireObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new CommandExecutionError(`${label} returned a malformed payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function unwrapBrowserResult(value) {
|
||||
if (value && typeof value === 'object' && typeof value.session === 'string' && Object.hasOwn(value, 'data')) {
|
||||
return value.data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readOptionalFlag(value, label) {
|
||||
if (value == null) return false;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
throw new CommandExecutionError(`${label} returned a malformed flag`);
|
||||
}
|
||||
|
||||
function readOptionalString(value, label) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
throw new CommandExecutionError(`${label} returned a malformed string`);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -10,6 +37,7 @@ cli({
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
|
||||
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频指定某一集,title/cid 返回该集;缺省取整集默认(P1)' },
|
||||
],
|
||||
columns: ['field', 'value'],
|
||||
func: async (page, kwargs) => {
|
||||
@@ -17,6 +45,9 @@ cli({
|
||||
throw new CommandExecutionError('Browser session required for bilibili video');
|
||||
}
|
||||
|
||||
// 选集序号(--page):缺省 null = 不下钻分P,保持整集(P1)旧行为。
|
||||
const selectedPage = parsePageArg(kwargs.page);
|
||||
|
||||
// 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.)
|
||||
@@ -30,26 +61,66 @@ cli({
|
||||
// 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', {
|
||||
const payload = unwrapBrowserResult(await apiGet(page, '/x/web-interface/view', {
|
||||
params: { bvid },
|
||||
});
|
||||
}));
|
||||
requireObject(payload, 'Bilibili view API');
|
||||
if (payload.code !== 0) {
|
||||
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const d = payload.data || {};
|
||||
const d = requireObject(payload.data, 'Bilibili view API data');
|
||||
const stat = d.stat || {};
|
||||
const owner = d.owner || {};
|
||||
|
||||
// 付费/会员标记:view API 的 rights 位 + 充电专属字段本来就在响应里,
|
||||
// 透出给下游在下载/截屏前判断"拿不到视频流"。
|
||||
// rights.pay=1 → 付费 OGV(大会员专享/单点付费番剧、影视;实测会员番剧单集 pay=1)
|
||||
// rights.ugc_pay=1 / arc_pay=1 → UGC 单点付费 / 付费合集
|
||||
// is_upower_exclusive=true → 充电专属视频
|
||||
// redirect_url 非空(指向 /bangumi/play/ep<id>)= OGV 内容,细分可再查 pgc season API。
|
||||
const rights = requireObject(d.rights, 'Bilibili view API data.rights');
|
||||
const rightsPay = readOptionalFlag(rights.pay, 'Bilibili rights.pay');
|
||||
const rightsUgcPay = readOptionalFlag(rights.ugc_pay, 'Bilibili rights.ugc_pay');
|
||||
const rightsArcPay = readOptionalFlag(rights.arc_pay, 'Bilibili rights.arc_pay');
|
||||
const upowerExclusive = readOptionalFlag(d.is_upower_exclusive, 'Bilibili is_upower_exclusive');
|
||||
const paymentType = rightsPay
|
||||
? 'vip'
|
||||
: (rightsUgcPay || rightsArcPay)
|
||||
? 'ugc_pay'
|
||||
: upowerExclusive
|
||||
? 'upower'
|
||||
: '';
|
||||
const payPreview = readOptionalFlag(rights.ugc_pay_preview, 'Bilibili rights.ugc_pay_preview')
|
||||
|| readOptionalFlag(d.is_upower_preview, 'Bilibili is_upower_preview');
|
||||
const redirectUrl = readOptionalString(d.redirect_url, 'Bilibili redirect_url');
|
||||
|
||||
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
|
||||
const dur = d.duration || 0;
|
||||
|
||||
// 选集下钻:--page 给定时从 data.pages 取该集,title 用分集标题(part),
|
||||
// 越界由 selectVideoPart 抛结构化错。缺省保持整集 title = d.title(旧行为不变)。
|
||||
let title = d.title ?? '';
|
||||
let partCid = '';
|
||||
let partDur = d.duration || 0;
|
||||
if (selectedPage != null) {
|
||||
const part = selectVideoPart(d, selectedPage);
|
||||
partCid = String(part.cid ?? '');
|
||||
const partTitle = typeof part.part === 'string' ? part.part.trim() : '';
|
||||
title = partTitle || `${d.title ?? ''} P${selectedPage}`;
|
||||
// 分集时长(pages[].duration)比整集 d.duration 更贴合该集;缺则回退整集。
|
||||
if (Number.isFinite(Number(part.duration)) && Number(part.duration) > 0) {
|
||||
partDur = Number(part.duration);
|
||||
}
|
||||
}
|
||||
|
||||
const dur = partDur || 0;
|
||||
const mm = Math.floor(dur / 60);
|
||||
const ss = dur % 60;
|
||||
|
||||
return [
|
||||
const rows = [
|
||||
{ field: 'bvid', value: d.bvid ?? '' },
|
||||
{ field: 'aid', value: String(d.aid ?? '') },
|
||||
{ field: 'title', value: d.title ?? '' },
|
||||
{ field: 'title', value: title },
|
||||
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
|
||||
{ field: 'category', value: d.tname_v2 || d.tname || '' },
|
||||
{ field: 'publish_time', value: pubDate },
|
||||
@@ -64,6 +135,21 @@ cli({
|
||||
{ field: 'parts', value: String(d.videos ?? 1) },
|
||||
{ field: 'thumbnail', value: d.pic ?? '' },
|
||||
{ field: 'description', value: d.desc ?? '' },
|
||||
{ field: 'requires_payment', value: String(!!paymentType) },
|
||||
{ field: 'payment_type', value: paymentType },
|
||||
// 可试看(ugc_pay_preview / 充电预览):有预览流但拿不到完整正片
|
||||
{ field: 'pay_preview', value: String(payPreview) },
|
||||
{ field: 'redirect_url', value: redirectUrl },
|
||||
];
|
||||
|
||||
// --page 时透出分集专属字段:page(选集序号)、cid(该集弹幕/字幕轴 id)、
|
||||
// series_title(整集标题,给下游做"分集标题为空"兜底)。缺省不加,保持旧输出。
|
||||
if (selectedPage != null) {
|
||||
rows.push({ field: 'page', value: String(selectedPage) });
|
||||
rows.push({ field: 'cid', value: partCid });
|
||||
rows.push({ field: 'series_title', value: d.title ?? '' });
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
+181
-5
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
@@ -39,6 +39,7 @@ describe('bilibili video', () => {
|
||||
videos: 1,
|
||||
pic: 'https://i1.hdslb.com/some.jpg',
|
||||
desc: 'Obsidian 教程',
|
||||
rights: {},
|
||||
owner: { mid: 507578555, name: 'IOI科技' },
|
||||
stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
|
||||
},
|
||||
@@ -60,6 +61,11 @@ describe('bilibili video', () => {
|
||||
expect(byField.duration).toBe('7m14s (434s)');
|
||||
expect(byField.view).toBe('6128');
|
||||
expect(byField.like).toBe('162');
|
||||
// 普通视频:无任何付费标记
|
||||
expect(byField.requires_payment).toBe('false');
|
||||
expect(byField.payment_type).toBe('');
|
||||
expect(byField.pay_preview).toBe('false');
|
||||
expect(byField.redirect_url).toBe('');
|
||||
|
||||
// Navigation primes the session
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
|
||||
@@ -79,10 +85,26 @@ describe('bilibili video', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('unwraps Browser Bridge envelopes before reading view API data', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
session: 'browser:default',
|
||||
data: {
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: { pay: 1 } },
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
|
||||
expect(byField.requires_payment).toBe('true');
|
||||
expect(byField.payment_type).toBe('vip');
|
||||
});
|
||||
|
||||
it('extracts BV ID from full bilibili.com URL input', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: {} },
|
||||
});
|
||||
|
||||
await command.func(page, { bvid: 'https://www.bilibili.com/video/BV1xx411c7mD/' });
|
||||
@@ -94,7 +116,7 @@ describe('bilibili video', () => {
|
||||
it('extracts BV ID from bilibili URL with trailing query string', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '' },
|
||||
data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '', rights: {} },
|
||||
});
|
||||
|
||||
await command.func(page, {
|
||||
@@ -107,7 +129,7 @@ describe('bilibili video', () => {
|
||||
it('extracts BV ID from m.bilibili.com mobile URL', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: {} },
|
||||
});
|
||||
|
||||
await command.func(page, { bvid: 'https://m.bilibili.com/video/BV1xx411c7mD' });
|
||||
@@ -115,11 +137,165 @@ describe('bilibili video', () => {
|
||||
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
|
||||
});
|
||||
|
||||
it('flags member-only bangumi episode as vip paid content', async () => {
|
||||
// 实测数据形状:会员番剧单集(如 国王排名 02)view API 返回 rights.pay=1
|
||||
// + redirect_url 指向 bangumi ep 页
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1HR4y1J7Sp',
|
||||
title: '【10月】国王排名 02【独家正版】',
|
||||
stat: {},
|
||||
owner: {},
|
||||
desc: '',
|
||||
rights: { pay: 1, hd5: 1 },
|
||||
redirect_url: 'https://www.bilibili.com/bangumi/play/ep424606',
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1HR4y1J7Sp' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.requires_payment).toBe('true');
|
||||
expect(byField.payment_type).toBe('vip');
|
||||
expect(byField.redirect_url).toBe('https://www.bilibili.com/bangumi/play/ep424606');
|
||||
});
|
||||
|
||||
it('flags upower-exclusive video and ugc_pay preview', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1xx411c7mD',
|
||||
stat: {},
|
||||
owner: {},
|
||||
desc: '',
|
||||
rights: { ugc_pay_preview: 1 },
|
||||
is_upower_exclusive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.requires_payment).toBe('true');
|
||||
expect(byField.payment_type).toBe('upower');
|
||||
expect(byField.pay_preview).toBe('true');
|
||||
});
|
||||
|
||||
it('flags ugc_pay video', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: { ugc_pay: 1 } },
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.requires_payment).toBe('true');
|
||||
expect(byField.payment_type).toBe('ugc_pay');
|
||||
});
|
||||
|
||||
it('typed-fails when paid marker source fields are missing', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
|
||||
});
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('typed-fails malformed paid marker flags instead of defaulting to free', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: { pay: '0' } },
|
||||
});
|
||||
|
||||
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('selects a specific 分P part via --page: title=part, plus cid/page/series_title fields', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1h6V16SEpg',
|
||||
title: '《道德经的奥秘》',
|
||||
stat: {},
|
||||
owner: { mid: 1, name: 'UP' },
|
||||
desc: '',
|
||||
rights: {},
|
||||
videos: 21,
|
||||
pages: [
|
||||
{ cid: 1001, page: 1, part: '01 上士闻道', duration: 1391 },
|
||||
{ cid: 1002, page: 2, part: '02 上士闻道', duration: 1391 },
|
||||
{ cid: 1003, page: 3, part: '03 人生的价值', duration: 1391 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1h6V16SEpg', page: '3' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
|
||||
expect(byField.title).toBe('03 人生的价值');
|
||||
expect(byField.cid).toBe('1003');
|
||||
expect(byField.page).toBe('3');
|
||||
expect(byField.series_title).toBe('《道德经的奥秘》');
|
||||
expect(byField.parts).toBe('21');
|
||||
expect(byField.duration).toBe('23m11s (1391s)');
|
||||
});
|
||||
|
||||
it('falls back to "<series> P<n>" when the part has no title', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1h6V16SEpg', title: '合集标题', stat: {}, owner: {}, desc: '', rights: {},
|
||||
videos: 2,
|
||||
pages: [
|
||||
{ cid: 1, page: 1, part: '', duration: 60 },
|
||||
{ cid: 2, page: 2, part: ' ', duration: 60 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1h6V16SEpg', page: '2' });
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.title).toBe('合集标题 P2');
|
||||
expect(byField.cid).toBe('2');
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when --page is out of range', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
bvid: 'BV1h6V16SEpg', title: 't', stat: {}, owner: {}, desc: '', rights: {},
|
||||
videos: 2, pages: [{ cid: 1, page: 1, part: 'a' }, { cid: 2, page: 2, part: 'b' }],
|
||||
},
|
||||
});
|
||||
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '99' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws ArgumentError when --page is not a strict positive decimal integer', async () => {
|
||||
for (const pageArg of ['0', 'abc', '1e2', '0x10', ' 1 ']) {
|
||||
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: pageArg })).rejects.toBeInstanceOf(ArgumentError);
|
||||
}
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits cid/page/series_title fields when --page is not given (backward compat)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { bvid: 'BV1xx411c7mD', title: 't', stat: {}, owner: {}, desc: '', rights: {}, videos: 3, pages: [{ cid: 1, page: 1, part: 'a' }] },
|
||||
});
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
const fields = rows.map((r) => r.field);
|
||||
expect(fields).not.toContain('cid');
|
||||
expect(fields).not.toContain('page');
|
||||
expect(fields).not.toContain('series_title');
|
||||
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
||||
expect(byField.title).toBe('t'); // 整集标题,不下钻
|
||||
});
|
||||
|
||||
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 },
|
||||
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc, rights: {} },
|
||||
});
|
||||
|
||||
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
||||
|
||||
@@ -1,18 +1,125 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
import { ArgumentError, CliError } from '@jackwener/opencli/errors';
|
||||
|
||||
const SECTION_URL = 'https://www.bloomberg.com/businessweek';
|
||||
|
||||
export function parseBusinessweekLimit(value) {
|
||||
const limit = value == null || value === '' ? 1 : Number(value);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
|
||||
throw new ArgumentError('bloomberg businessweek --limit must be an integer between 1 and 20', 'Example: opencli bloomberg businessweek --limit 5');
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function normalizeBusinessweekStoryPath(path) {
|
||||
const raw = typeof path === 'string' ? path.trim() : '';
|
||||
if (!raw)
|
||||
return '';
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw, 'https://www.bloomberg.com');
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'www.bloomberg.com')
|
||||
return '';
|
||||
if (!/^\/(?:news|features)\//.test(url.pathname))
|
||||
return '';
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function extractBusinessweekStoriesFromNextData(data) {
|
||||
const modules = data && data.props && data.props.pageProps
|
||||
&& data.props.pageProps.initialState && data.props.pageProps.initialState.modulesById;
|
||||
if (!modules || typeof modules !== 'object')
|
||||
return null;
|
||||
const seen = new Set();
|
||||
const stories = [];
|
||||
for (const mod of Object.values(modules)) {
|
||||
const items = mod && Array.isArray(mod.items) ? mod.items : [];
|
||||
for (const it of items) {
|
||||
const headline = it && typeof it.headline === 'string' ? it.headline.trim() : '';
|
||||
const storyPath = normalizeBusinessweekStoryPath(it && typeof it.url === 'string' ? it.url : '');
|
||||
if (!headline || !storyPath)
|
||||
continue;
|
||||
const key = storyPath.split('?')[0];
|
||||
if (seen.has(key))
|
||||
continue;
|
||||
seen.add(key);
|
||||
const summary = (it.summary && String(it.summary).trim())
|
||||
|| (it.eyebrow && it.eyebrow.text ? String(it.eyebrow.text).trim() : '');
|
||||
const img = (it.image && (it.image.baseUrl || it.image.url))
|
||||
|| (it.lede && (it.lede.baseUrl || it.lede.url)) || '';
|
||||
stories.push({
|
||||
title: headline,
|
||||
summary,
|
||||
link: `https://www.bloomberg.com${storyPath}`,
|
||||
mediaLinks: img ? [img] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
return stories;
|
||||
}
|
||||
|
||||
// Bloomberg now serves the Businessweek RSS feed empty (feeds.bloomberg.com/businessweek/news.rss
|
||||
// returns a maintained-but-item-less channel), while the Businessweek section page keeps
|
||||
// publishing. Like `bloomberg news`, the page ships its data as Next.js __NEXT_DATA__; the
|
||||
// section's stories live under props.pageProps.initialState.modulesById[*].items[]. So we read
|
||||
// the section page in the browser and pull the story list out of the embedded SSR state.
|
||||
export const command = cli({
|
||||
site: 'bloomberg',
|
||||
name: 'businessweek',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Businessweek top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
description: 'Bloomberg Businessweek top stories',
|
||||
domain: 'www.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of stories to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
|
||||
func: async (page, kwargs) => {
|
||||
const count = parseBusinessweekLimit(kwargs.limit);
|
||||
await page.goto(SECTION_URL);
|
||||
await page.wait({ selector: '#__NEXT_DATA__', timeout: 8 });
|
||||
const normalizeStoryPathSource = normalizeBusinessweekStoryPath.toString();
|
||||
const extractStoriesSource = extractBusinessweekStoriesFromNextData.toString();
|
||||
const loadStories = async () => page.evaluate(`(() => {
|
||||
${normalizeStoryPathSource}
|
||||
${extractStoriesSource}
|
||||
const el = document.getElementById('__NEXT_DATA__');
|
||||
if (!el) return { ok: false, error: 'NO_NEXT_DATA', title: document.title };
|
||||
let data;
|
||||
try { data = JSON.parse(el.textContent); }
|
||||
catch (err) { return { ok: false, error: 'BAD_NEXT_DATA', message: String(err) }; }
|
||||
const stories = extractBusinessweekStoriesFromNextData(data);
|
||||
if (!stories) return { ok: false, error: 'NO_MODULES' };
|
||||
return { ok: true, stories };
|
||||
})()`);
|
||||
let result = await loadStories();
|
||||
// Next.js sometimes hydrates slowly — retry once before giving up.
|
||||
if (result && result.ok === false && (result.error === 'NO_NEXT_DATA' || result.error === 'NO_MODULES')) {
|
||||
await page.wait(4);
|
||||
result = await loadStories();
|
||||
}
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new CliError('PARSE_ERROR', 'Bloomberg Businessweek page returned malformed story data', 'Bloomberg may have changed the page structure.');
|
||||
}
|
||||
if (result.ok === false) {
|
||||
throw new CliError('PARSE_ERROR', `Bloomberg Businessweek page did not expose story data (${result.error})`, 'Bloomberg may have changed the page structure.');
|
||||
}
|
||||
const stories = Array.isArray(result.stories) ? result.stories : [];
|
||||
if (!stories.length) {
|
||||
throw new CliError('NOT_FOUND', 'No Bloomberg Businessweek stories found', 'Bloomberg may have changed the page structure.');
|
||||
}
|
||||
return stories.slice(0, count);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
command,
|
||||
parseBusinessweekLimit,
|
||||
normalizeBusinessweekStoryPath,
|
||||
extractBusinessweekStoriesFromNextData,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CliError } from '@jackwener/opencli/errors';
|
||||
import { __test__ } from './businessweek.js';
|
||||
|
||||
const {
|
||||
command,
|
||||
extractBusinessweekStoriesFromNextData,
|
||||
normalizeBusinessweekStoryPath,
|
||||
parseBusinessweekLimit,
|
||||
} = __test__;
|
||||
|
||||
function makePage(evaluateResults) {
|
||||
const results = Array.isArray(evaluateResults) ? evaluateResults : [evaluateResults];
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockImplementation(() => Promise.resolve(results.shift())),
|
||||
};
|
||||
}
|
||||
|
||||
function nextDataWithItems(items) {
|
||||
return {
|
||||
props: {
|
||||
pageProps: {
|
||||
initialState: {
|
||||
modulesById: {
|
||||
lede_story_large: { items },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Bloomberg Businessweek section feed', () => {
|
||||
it('registers as a public browser read command with stable columns', () => {
|
||||
expect(command.site).toBe('bloomberg');
|
||||
expect(command.name).toBe('businessweek');
|
||||
expect(command.access).toBe('read');
|
||||
expect(command.browser).toBe(true);
|
||||
expect(command.strategy).toBe('public');
|
||||
expect(command.domain).toBe('www.bloomberg.com');
|
||||
expect(command.columns).toEqual(['title', 'summary', 'link', 'mediaLinks']);
|
||||
});
|
||||
|
||||
it('validates --limit instead of silently clamping invalid values', () => {
|
||||
expect(parseBusinessweekLimit(undefined)).toBe(1);
|
||||
expect(parseBusinessweekLimit('')).toBe(1);
|
||||
expect(parseBusinessweekLimit('20')).toBe(20);
|
||||
expect(() => parseBusinessweekLimit(0)).toThrow(ArgumentError);
|
||||
expect(() => parseBusinessweekLimit(21)).toThrow(ArgumentError);
|
||||
expect(() => parseBusinessweekLimit(1.5)).toThrow(ArgumentError);
|
||||
expect(() => parseBusinessweekLimit('abc')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('accepts Bloomberg news and feature story paths from the section page only', () => {
|
||||
expect(normalizeBusinessweekStoryPath('/news/features/2026-06-08/story?srnd=phx-businessweek'))
|
||||
.toBe('/news/features/2026-06-08/story?srnd=phx-businessweek');
|
||||
expect(normalizeBusinessweekStoryPath('/features/2026-ice-detention-center/?srnd=phx-businessweek'))
|
||||
.toBe('/features/2026-ice-detention-center/?srnd=phx-businessweek');
|
||||
expect(normalizeBusinessweekStoryPath('https://www.bloomberg.com/features/2026-story/'))
|
||||
.toBe('/features/2026-story/');
|
||||
expect(normalizeBusinessweekStoryPath('https://example.com/features/2026-story/')).toBe('');
|
||||
expect(normalizeBusinessweekStoryPath('/markets')).toBe('');
|
||||
expect(normalizeBusinessweekStoryPath('javascript:alert(1)')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts current section-page stories, including /features paths, and dedupes by canonical path', () => {
|
||||
const rows = extractBusinessweekStoriesFromNextData(nextDataWithItems([
|
||||
{
|
||||
headline: 'SpaceX IPO Demands Trust',
|
||||
summary: 'A feature summary',
|
||||
url: '/news/features/2026-06-08/spacex-ipo?srnd=phx-businessweek',
|
||||
image: { baseUrl: 'https://assets.bwbx.io/spacex.jpg' },
|
||||
},
|
||||
{
|
||||
headline: 'ICE Warehouse Jails',
|
||||
eyebrow: { text: 'Feature' },
|
||||
url: '/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/?srnd=phx-businessweek',
|
||||
lede: { url: 'https://assets.bwbx.io/ice.jpg' },
|
||||
},
|
||||
{
|
||||
headline: 'Duplicate without query',
|
||||
url: '/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/',
|
||||
},
|
||||
{
|
||||
headline: 'Non-story module link',
|
||||
url: '/markets',
|
||||
},
|
||||
]));
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
title: 'SpaceX IPO Demands Trust',
|
||||
summary: 'A feature summary',
|
||||
link: 'https://www.bloomberg.com/news/features/2026-06-08/spacex-ipo?srnd=phx-businessweek',
|
||||
mediaLinks: ['https://assets.bwbx.io/spacex.jpg'],
|
||||
},
|
||||
{
|
||||
title: 'ICE Warehouse Jails',
|
||||
summary: 'Feature',
|
||||
link: 'https://www.bloomberg.com/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/?srnd=phx-businessweek',
|
||||
mediaLinks: ['https://assets.bwbx.io/ice.jpg'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns rows from the browser section payload and respects validated limit', async () => {
|
||||
const page = makePage({
|
||||
ok: true,
|
||||
stories: [
|
||||
{ title: 'One', summary: 'A', link: 'https://www.bloomberg.com/news/a', mediaLinks: [] },
|
||||
{ title: 'Two', summary: 'B', link: 'https://www.bloomberg.com/features/b', mediaLinks: [] },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { limit: 1 })).resolves.toEqual([
|
||||
{ title: 'One', summary: 'A', link: 'https://www.bloomberg.com/news/a', mediaLinks: [] },
|
||||
]);
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.bloomberg.com/businessweek');
|
||||
expect(page.wait).toHaveBeenCalledWith({ selector: '#__NEXT_DATA__', timeout: 8 });
|
||||
});
|
||||
|
||||
it('retries slow hydration diagnostics before failing', async () => {
|
||||
const page = makePage([
|
||||
{ ok: false, error: 'NO_NEXT_DATA', title: 'Businessweek' },
|
||||
{
|
||||
ok: true,
|
||||
stories: [
|
||||
{ title: 'Hydrated', summary: '', link: 'https://www.bloomberg.com/news/hydrated', mediaLinks: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(command.func(page, { limit: 5 })).resolves.toEqual([
|
||||
{ title: 'Hydrated', summary: '', link: 'https://www.bloomberg.com/news/hydrated', mediaLinks: [] },
|
||||
]);
|
||||
expect(page.wait).toHaveBeenCalledWith(4);
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails typed for malformed section payloads', async () => {
|
||||
const page = makePage({ ok: false, error: 'NO_MODULES' });
|
||||
|
||||
await expect(command.func(page, { limit: 5 })).rejects.toBeInstanceOf(CliError);
|
||||
await expect(command.func(makePage({ ok: true, stories: [] }), { limit: 5 })).rejects.toBeInstanceOf(CliError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'crypto',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Crypto top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('crypto', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'green',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Green (climate & energy) top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('green', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'pursuits',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Pursuits (lifestyle) top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
||||
],
|
||||
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
||||
func: async (kwargs) => {
|
||||
return fetchBloombergFeed('pursuits', kwargs.limit ?? 1);
|
||||
},
|
||||
});
|
||||
+29
-13
@@ -6,28 +6,44 @@ export const BLOOMBERG_FEEDS = {
|
||||
industries: 'https://feeds.bloomberg.com/industries/news.rss',
|
||||
tech: 'https://feeds.bloomberg.com/technology/news.rss',
|
||||
politics: 'https://feeds.bloomberg.com/politics/news.rss',
|
||||
businessweek: 'https://feeds.bloomberg.com/businessweek/news.rss',
|
||||
opinions: 'https://feeds.bloomberg.com/bview/news.rss',
|
||||
green: 'https://feeds.bloomberg.com/green/news.rss',
|
||||
crypto: 'https://feeds.bloomberg.com/crypto/news.rss',
|
||||
pursuits: 'https://feeds.bloomberg.com/pursuits/news.rss',
|
||||
};
|
||||
// Note: the Businessweek RSS feed (feeds.bloomberg.com/businessweek/news.rss) is now served
|
||||
// empty by Bloomberg, so the `businessweek` command reads the section page instead (see
|
||||
// businessweek.js). Other sections still publish working RSS feeds.
|
||||
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; opencli)';
|
||||
// Bloomberg's edge occasionally serves a transient empty/non-OK RSS response under load; a
|
||||
// couple of quick retries turn those intermittent misses into a successful fetch instead of a
|
||||
// hard NOT_FOUND. A feed that is genuinely empty still surfaces NOT_FOUND after the retries.
|
||||
export async function fetchBloombergFeed(name, limit = 1) {
|
||||
const feedUrl = BLOOMBERG_FEEDS[name];
|
||||
if (!feedUrl) {
|
||||
throw new CliError('ARGUMENT', `Unknown Bloomberg feed: ${name}`);
|
||||
}
|
||||
const resp = await fetch(feedUrl, {
|
||||
headers: { 'User-Agent': DEFAULT_USER_AGENT },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new CliError('FETCH_ERROR', `Bloomberg RSS HTTP ${resp.status}`, 'Bloomberg may be temporarily unavailable; try again later.');
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt));
|
||||
}
|
||||
const resp = await fetch(feedUrl, {
|
||||
headers: { 'User-Agent': DEFAULT_USER_AGENT },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
lastError = new CliError('FETCH_ERROR', `Bloomberg RSS HTTP ${resp.status}`, 'Bloomberg may be temporarily unavailable; try again later.');
|
||||
continue;
|
||||
}
|
||||
const xml = await resp.text();
|
||||
const items = parseBloombergRss(xml);
|
||||
if (items.length) {
|
||||
const count = Math.max(1, Math.min(Number(limit) || 1, 20));
|
||||
return items.slice(0, count);
|
||||
}
|
||||
lastError = new CliError('NOT_FOUND', 'Bloomberg RSS feed returned no items', 'Bloomberg may have changed the feed format.');
|
||||
}
|
||||
const xml = await resp.text();
|
||||
const items = parseBloombergRss(xml);
|
||||
if (!items.length) {
|
||||
throw new CliError('NOT_FOUND', 'Bloomberg RSS feed returned no items', 'Bloomberg may have changed the feed format.');
|
||||
}
|
||||
const count = Math.max(1, Math.min(Number(limit) || 1, 20));
|
||||
return items.slice(0, count);
|
||||
throw lastError;
|
||||
}
|
||||
export function parseBloombergRss(xml) {
|
||||
const items = [];
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './search.js';
|
||||
import { __test__ } from './search.js';
|
||||
|
||||
const {
|
||||
normalizePositiveInt,
|
||||
normalizeNonNegativeInt,
|
||||
normalizeDate,
|
||||
normalizeCurrency,
|
||||
normalizeLang,
|
||||
hasPositiveResultCount,
|
||||
buildSearchUrl,
|
||||
} = __test__;
|
||||
|
||||
describe('booking helpers — normalizePositiveInt (no silent clamp)', () => {
|
||||
it('returns default when value is undefined/null/empty', () => {
|
||||
expect(normalizePositiveInt(undefined, 2, 'adults', 30)).toBe(2);
|
||||
expect(normalizePositiveInt(null, 2, 'adults', 30)).toBe(2);
|
||||
});
|
||||
|
||||
it('accepts integers in range', () => {
|
||||
expect(normalizePositiveInt(1, 2, 'adults', 30)).toBe(1);
|
||||
expect(normalizePositiveInt(30, 2, 'adults', 30)).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects zero / negative / out-of-range / non-integer (no silent clamp)', () => {
|
||||
expect(() => normalizePositiveInt(0, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt(-1, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt(31, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt(1.5, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt('abc', 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeNonNegativeInt', () => {
|
||||
it('accepts zero', () => {
|
||||
expect(normalizeNonNegativeInt(0, 0, 'children', 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects negative / out-of-range (no silent clamp)', () => {
|
||||
expect(() => normalizeNonNegativeInt(-1, 0, 'children', 10)).toThrow(ArgumentError);
|
||||
expect(() => normalizeNonNegativeInt(11, 0, 'children', 10)).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeDate', () => {
|
||||
it('accepts YYYY-MM-DD', () => {
|
||||
expect(normalizeDate('2026-06-15', 'checkin')).toBe('2026-06-15');
|
||||
});
|
||||
|
||||
it('rejects bad format / nonsense dates with ArgumentError', () => {
|
||||
expect(() => normalizeDate('', 'checkin')).toThrow(ArgumentError);
|
||||
expect(() => normalizeDate('06/15/2026', 'checkin')).toThrow(ArgumentError);
|
||||
expect(() => normalizeDate('2026-13-40', 'checkin')).toThrow(ArgumentError);
|
||||
expect(() => normalizeDate('2026-02-31', 'checkin')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeCurrency', () => {
|
||||
it('passes 3-letter codes uppercased', () => {
|
||||
expect(normalizeCurrency('usd')).toBe('USD');
|
||||
expect(normalizeCurrency('JPY')).toBe('JPY');
|
||||
});
|
||||
|
||||
it('returns empty for unset', () => {
|
||||
expect(normalizeCurrency(undefined)).toBe('');
|
||||
expect(normalizeCurrency('')).toBe('');
|
||||
});
|
||||
|
||||
it('rejects non-3-letter codes', () => {
|
||||
expect(() => normalizeCurrency('US')).toThrow(ArgumentError);
|
||||
expect(() => normalizeCurrency('US$')).toThrow(ArgumentError);
|
||||
expect(() => normalizeCurrency('USDX')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeLang whitelist', () => {
|
||||
it('lowercases supported langs', () => {
|
||||
expect(normalizeLang('EN-US')).toBe('en-us');
|
||||
expect(normalizeLang('zh-cn')).toBe('zh-cn');
|
||||
});
|
||||
|
||||
it('rejects unknown langs', () => {
|
||||
expect(() => normalizeLang('xx-yy')).toThrow(ArgumentError);
|
||||
expect(() => normalizeLang('en')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — buildSearchUrl', () => {
|
||||
it('constructs canonical search URL with required params', () => {
|
||||
const url = buildSearchUrl({
|
||||
destination: 'Tokyo',
|
||||
checkin: '2026-06-15',
|
||||
checkout: '2026-06-17',
|
||||
adults: 2,
|
||||
rooms: 1,
|
||||
children: 0,
|
||||
offset: 0,
|
||||
currency: 'USD',
|
||||
lang: 'en-us',
|
||||
});
|
||||
expect(url).toContain('https://www.booking.com/searchresults.en-us.html');
|
||||
expect(url).toContain('ss=Tokyo');
|
||||
expect(url).toContain('checkin=2026-06-15');
|
||||
expect(url).toContain('checkout=2026-06-17');
|
||||
expect(url).toContain('group_adults=2');
|
||||
expect(url).toContain('no_rooms=1');
|
||||
expect(url).toContain('group_children=0');
|
||||
expect(url).toContain('selected_currency=USD');
|
||||
expect(url).not.toContain('offset=');
|
||||
});
|
||||
|
||||
it('omits lang file segment when lang is empty', () => {
|
||||
const url = buildSearchUrl({
|
||||
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
|
||||
adults: 2, rooms: 1, children: 0, offset: 0, currency: '', lang: '',
|
||||
});
|
||||
expect(url).toMatch(/booking\.com\/searchresults\.html\?/);
|
||||
});
|
||||
|
||||
it('emits offset only when > 0', () => {
|
||||
const url = buildSearchUrl({
|
||||
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
|
||||
adults: 2, rooms: 1, children: 0, offset: 25, currency: '', lang: '',
|
||||
});
|
||||
expect(url).toContain('offset=25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — hasPositiveResultCount', () => {
|
||||
it('detects positive Booking result-count evidence', () => {
|
||||
expect(hasPositiveResultCount('Tokyo: 1,234 properties found')).toBe(true);
|
||||
expect(hasPositiveResultCount('1 stay found')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat no-results text as positive evidence', () => {
|
||||
expect(hasPositiveResultCount('No properties found')).toBe(false);
|
||||
expect(hasPositiveResultCount('0 properties found')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking adapter registry shape', () => {
|
||||
it('search is registered as read with id-shaped column for round-trip', () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
expect(search).toBeDefined();
|
||||
expect(search.access).toBe('read');
|
||||
expect(search.browser).toBe(true);
|
||||
// slug + country together form the round-trip identity (URL: /hotel/<country>/<slug>.html)
|
||||
expect(search.columns).toContain('slug');
|
||||
expect(search.columns).toContain('country');
|
||||
expect(search.columns).toContain('url');
|
||||
});
|
||||
|
||||
it('search columns stay <= 12 to honor agent-native row shape', () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
expect(search.columns.length).toBeLessThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking search — typed errors (no silent fallback)', () => {
|
||||
const fakePage = { goto: () => { throw new Error('should not navigate'); } };
|
||||
|
||||
it('rejects empty destination with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: ' ', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects missing checkin/checkout with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo' })).rejects.toThrow(ArgumentError);
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects checkout <= checkin with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-17', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects out-of-range --limit with ArgumentError (no silent clamp to 100)', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', limit: 999 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects negative --offset with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: -1 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects unsupported --lang with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', lang: 'xx-yy' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects malformed --currency with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'US$' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('wraps browser navigation failures as CommandExecutionError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const downPage = { goto: () => Promise.reject(new Error('browser down')) };
|
||||
await expect(search.func(downPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when extractor returns no cards', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const emptyPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'No properties found' }),
|
||||
};
|
||||
await expect(search.func(emptyPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when result-count evidence exists but no cards were parsed', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const driftPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'Tokyo: 1,234 properties found' }),
|
||||
};
|
||||
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when captcha is detected', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const blockedPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, items: [], blocked: true, totalText: 'Verify you are human' }),
|
||||
};
|
||||
await expect(search.func(blockedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when extractor payload is malformed instead of treating it as empty', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const malformedPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, blocked: false, totalText: 'Tokyo hotels' }),
|
||||
};
|
||||
await expect(search.func(malformedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when rendered cards lack stable hotel URL identity', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const driftPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: 'Tokyo hotels',
|
||||
items: [{
|
||||
name: 'Unlinked Hotel',
|
||||
country: '',
|
||||
slug: '',
|
||||
url: '',
|
||||
distance: '',
|
||||
review_score: null,
|
||||
review_count: null,
|
||||
star_rating: null,
|
||||
price_currency: '',
|
||||
price_amount: null,
|
||||
recommended_room: '',
|
||||
}],
|
||||
}),
|
||||
};
|
||||
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('unwraps {session, data} envelope from CDP bridge before validating', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const envelopePage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
session: 1,
|
||||
data: {
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: '',
|
||||
items: [{
|
||||
name: 'Test Hotel',
|
||||
country: 'jp',
|
||||
slug: 'test-hotel',
|
||||
url: 'https://www.booking.com/hotel/jp/test-hotel.html',
|
||||
distance: '1 km from centre',
|
||||
review_score: 8.6,
|
||||
review_count: 100,
|
||||
star_rating: 4,
|
||||
price_currency: 'USD',
|
||||
price_amount: 120,
|
||||
recommended_room: 'Standard double',
|
||||
}],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const rows = await search.func(envelopePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].rank).toBe(1);
|
||||
expect(rows[0].slug).toBe('test-hotel');
|
||||
expect(rows[0].url).toBe('https://www.booking.com/hotel/jp/test-hotel.html');
|
||||
});
|
||||
|
||||
it('uses requested selected_currency as the output source when price is present', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const currencyPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: '',
|
||||
items: [{
|
||||
name: 'Currency Hotel',
|
||||
country: 'cn',
|
||||
slug: 'currency-hotel',
|
||||
url: 'https://www.booking.com/hotel/cn/currency-hotel.html',
|
||||
distance: '',
|
||||
review_score: null,
|
||||
review_count: null,
|
||||
star_rating: null,
|
||||
price_currency: 'JPY',
|
||||
price_amount: 880,
|
||||
recommended_room: '',
|
||||
}],
|
||||
}),
|
||||
};
|
||||
const rows = await search.func(currencyPage, { destination: 'Shanghai', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'CNY' });
|
||||
expect(rows[0].price_currency).toBe('CNY');
|
||||
});
|
||||
|
||||
it('respects offset for rank numbering when paginating', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const pagedPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: '',
|
||||
items: [
|
||||
{ name: 'A', country: 'jp', slug: 'a', url: 'https://www.booking.com/hotel/jp/a.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
|
||||
{ name: 'B', country: 'jp', slug: 'b', url: 'https://www.booking.com/hotel/jp/b.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const rows = await search.func(pagedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: 50 });
|
||||
expect(rows[0].rank).toBe(51);
|
||||
expect(rows[1].rank).toBe(52);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function normalizePositiveInt(value, defaultValue, label, max) {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
if (typeof max === 'number' && n > max) {
|
||||
throw new ArgumentError(`${label} must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value, defaultValue, label, max) {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new ArgumentError(`${label} must be a non-negative integer`);
|
||||
}
|
||||
if (typeof max === 'number' && n > max) {
|
||||
throw new ArgumentError(`${label} must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeDate(value, label) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) {
|
||||
throw new ArgumentError(`${label} is required (YYYY-MM-DD)`);
|
||||
}
|
||||
if (!DATE_RE.test(v)) {
|
||||
throw new ArgumentError(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
const [year, month, day] = v.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
Number.isNaN(d.getTime()) ||
|
||||
d.getUTCFullYear() !== year ||
|
||||
d.getUTCMonth() !== month - 1 ||
|
||||
d.getUTCDate() !== day
|
||||
) {
|
||||
throw new ArgumentError(`${label} is not a valid calendar date: ${v}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function normalizeCurrency(value) {
|
||||
if (value == null || value === '') return '';
|
||||
const v = String(value).trim().toUpperCase();
|
||||
if (!/^[A-Z]{3}$/.test(v)) {
|
||||
throw new ArgumentError(`currency must be a 3-letter ISO code (e.g. USD, JPY, CNY), got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const ALLOWED_LANGS = new Set([
|
||||
'en-us', 'en-gb', 'zh-cn', 'zh-tw', 'ja', 'ko', 'de', 'fr', 'es', 'it',
|
||||
'pt-br', 'pt-pt', 'ru', 'th', 'vi', 'tr', 'pl', 'nl', 'ar',
|
||||
]);
|
||||
|
||||
function normalizeLang(value) {
|
||||
if (value == null || value === '') return '';
|
||||
const v = String(value).trim().toLowerCase();
|
||||
if (!ALLOWED_LANGS.has(v)) {
|
||||
throw new ArgumentError(`lang must be one of: ${[...ALLOWED_LANGS].join(', ')}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function hasPositiveResultCount(text) {
|
||||
const value = String(text || '').replace(/\u00a0/g, ' ');
|
||||
const resultCount = value.match(/\b([1-9][0-9,.\s]*)\s+(?:properties|property|stays|stay|hotels|hotel)\b/i);
|
||||
if (!resultCount) return false;
|
||||
const digits = resultCount[1].replace(/\D/g, '');
|
||||
return Boolean(digits) && Number(digits) > 0;
|
||||
}
|
||||
|
||||
function buildSearchUrl({
|
||||
destination,
|
||||
checkin,
|
||||
checkout,
|
||||
adults,
|
||||
rooms,
|
||||
children,
|
||||
offset,
|
||||
currency,
|
||||
lang,
|
||||
}) {
|
||||
const file = lang ? `searchresults.${lang}.html` : 'searchresults.html';
|
||||
const params = new URLSearchParams();
|
||||
params.set('ss', destination);
|
||||
params.set('checkin', checkin);
|
||||
params.set('checkout', checkout);
|
||||
params.set('group_adults', String(adults));
|
||||
params.set('no_rooms', String(rooms));
|
||||
params.set('group_children', String(children));
|
||||
if (offset > 0) params.set('offset', String(offset));
|
||||
if (currency) params.set('selected_currency', currency);
|
||||
return `https://www.booking.com/${file}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const EXTRACTOR = `
|
||||
(() => {
|
||||
const trim = (v) => (v == null ? '' : String(v).replace(/\\s+/g, ' ').trim());
|
||||
const cards = Array.from(document.querySelectorAll('[data-testid=property-card]'));
|
||||
|
||||
// Detect blocking / captcha pages: no cards but body shows a verification prompt.
|
||||
if (cards.length === 0) {
|
||||
const text = [
|
||||
(document.title || ''),
|
||||
(document.body && document.body.innerText) || '',
|
||||
(location && location.pathname) || '',
|
||||
].join(' ');
|
||||
const blocked = /captcha|challenge|verify\\s*you\\s*are|access\\s*denied|forbidden|robot|unusual\\s*traffic/i.test(text);
|
||||
const totalEl = document.querySelector('h1');
|
||||
const totalText = trim(totalEl && totalEl.textContent);
|
||||
return { ok: true, items: [], blocked, totalText };
|
||||
}
|
||||
|
||||
const items = cards.map((card) => {
|
||||
const titleEl = card.querySelector('[data-testid=title]');
|
||||
const link = card.querySelector('a[data-testid=title-link]');
|
||||
const href = (link && link.href) || '';
|
||||
let country = '';
|
||||
let slug = '';
|
||||
let canonicalUrl = '';
|
||||
try {
|
||||
const u = new URL(href, 'https://www.booking.com');
|
||||
const m = u.pathname.match(/^\\/hotel\\/([a-z]{2})\\/([^./]+)/);
|
||||
if (m) {
|
||||
country = m[1];
|
||||
slug = m[2];
|
||||
canonicalUrl = 'https://www.booking.com/hotel/' + country + '/' + slug + '.html';
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const reviewTextRaw = trim(card.querySelector('[data-testid=review-score]')?.textContent);
|
||||
// Booking renders the score twice (a11y + visual), text reads like "Scored 8.6 8.6 Very Good 6,151 reviews"
|
||||
// or "评分8.68.6很棒 6,151条住客点评". Take only the first numeric occurrence.
|
||||
const scoreMatch = reviewTextRaw.match(/(\\d{1,2})\\.(\\d)/);
|
||||
const reviewScore = scoreMatch ? Number(scoreMatch[1] + '.' + scoreMatch[2]) : null;
|
||||
|
||||
const countMatch = reviewTextRaw.match(/([0-9][0-9,]*)\\s*(?:reviews|reseñas|avis|recensioni|条住客点评|条评论|レビュー|리뷰)/i);
|
||||
const reviewCount = countMatch ? Number(countMatch[1].replace(/,/g, '')) : null;
|
||||
|
||||
// Star rating: aria-label often "5 out of 5" / "4 星 (满分 5 星)" / "Hôtel 4 étoiles"
|
||||
let starRating = null;
|
||||
const starEl = card.querySelector('[data-testid=rating-stars], [data-testid=quality-rating]');
|
||||
if (starEl) {
|
||||
const aria = starEl.getAttribute('aria-label') || starEl.textContent || '';
|
||||
const m = aria.match(/(\\d)(?:\\s*(?:out of|\\/|星|颗星|stars?|étoiles?)|\\s*$)/i);
|
||||
if (m) starRating = Number(m[1]);
|
||||
if (starRating == null) {
|
||||
const count = starEl.querySelectorAll('svg, [aria-hidden=true]').length;
|
||||
if (count >= 1 && count <= 5) starRating = count;
|
||||
}
|
||||
}
|
||||
|
||||
const priceEl = card.querySelector('[data-testid=price-and-discounted-price]');
|
||||
const priceText = trim(priceEl && priceEl.textContent);
|
||||
|
||||
// currency symbol → ISO best-effort
|
||||
const currencySymbolMap = {
|
||||
'$': 'USD', 'US$': 'USD', 'A$': 'AUD', 'C$': 'CAD', 'HK$': 'HKD',
|
||||
'€': 'EUR', '£': 'GBP', '¥': 'JPY', '¥': 'CNY', '₹': 'INR', '₩': 'KRW',
|
||||
'CN¥': 'CNY', 'CN¥': 'CNY', 'NT$': 'TWD', 'S$': 'SGD',
|
||||
};
|
||||
let priceCurrency = '';
|
||||
let priceAmount = null;
|
||||
const sym = priceText.match(/(US\\$|A\\$|C\\$|HK\\$|NT\\$|S\\$|CN¥|CN¥|[$€£¥¥₹₩])/);
|
||||
if (sym) priceCurrency = currencySymbolMap[sym[1]] || '';
|
||||
const num = priceText.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)/);
|
||||
if (num) priceAmount = Number(num[1]);
|
||||
|
||||
return {
|
||||
name: trim(titleEl?.textContent),
|
||||
country,
|
||||
slug,
|
||||
url: canonicalUrl,
|
||||
distance: trim(card.querySelector('[data-testid=distance]')?.textContent),
|
||||
review_score: reviewScore,
|
||||
review_count: reviewCount,
|
||||
star_rating: starRating,
|
||||
price_currency: priceCurrency,
|
||||
price_amount: priceAmount,
|
||||
recommended_room: trim(card.querySelector('[data-testid=recommended-units]')?.textContent),
|
||||
};
|
||||
});
|
||||
|
||||
const totalEl = document.querySelector('h1');
|
||||
const totalText = trim(totalEl && totalEl.textContent);
|
||||
return { ok: true, items, blocked: false, totalText };
|
||||
})()
|
||||
`;
|
||||
|
||||
cli({
|
||||
site: 'booking',
|
||||
name: 'search',
|
||||
description: 'Search Booking.com hotels by destination and dates (server-rendered card scrape).',
|
||||
access: 'read',
|
||||
example: 'opencli booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml',
|
||||
domain: 'www.booking.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'destination', required: true, positional: true, help: 'Destination keyword (city, district, or hotel name)' },
|
||||
{ name: 'checkin', required: true, help: 'Check-in date YYYY-MM-DD' },
|
||||
{ name: 'checkout', required: true, help: 'Check-out date YYYY-MM-DD' },
|
||||
{ name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-30)' },
|
||||
{ name: 'rooms', type: 'int', default: 1, help: 'Number of rooms (1-30)' },
|
||||
{ name: 'children', type: 'int', default: 0, help: 'Number of children (0-10)' },
|
||||
{ name: 'currency', required: false, help: 'Force result currency (e.g. USD, JPY, CNY)' },
|
||||
{ name: 'lang', required: false, help: 'Force result language (e.g. en-us, zh-cn, ja)' },
|
||||
{ name: 'limit', type: 'int', default: 25, help: 'Max rows to return (1-100; Booking pages 25 per request)' },
|
||||
{ name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (multiple of 25)' },
|
||||
],
|
||||
columns: [
|
||||
'rank',
|
||||
'name',
|
||||
'country',
|
||||
'slug',
|
||||
'star_rating',
|
||||
'review_score',
|
||||
'review_count',
|
||||
'price_amount',
|
||||
'price_currency',
|
||||
'distance',
|
||||
'recommended_room',
|
||||
'url',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const destination = String(kwargs.destination || '').trim();
|
||||
if (!destination) throw new ArgumentError('destination is required');
|
||||
const checkin = normalizeDate(kwargs.checkin, 'checkin');
|
||||
const checkout = normalizeDate(kwargs.checkout, 'checkout');
|
||||
if (checkin >= checkout) {
|
||||
throw new ArgumentError(`checkout (${checkout}) must be after checkin (${checkin})`);
|
||||
}
|
||||
const adults = normalizePositiveInt(kwargs.adults, 2, 'adults', 30);
|
||||
const rooms = normalizePositiveInt(kwargs.rooms, 1, 'rooms', 30);
|
||||
const children = normalizeNonNegativeInt(kwargs.children, 0, 'children', 10);
|
||||
const currency = normalizeCurrency(kwargs.currency);
|
||||
const lang = normalizeLang(kwargs.lang);
|
||||
const limit = normalizePositiveInt(kwargs.limit, 25, 'limit', 100);
|
||||
const offset = normalizeNonNegativeInt(kwargs.offset, 0, 'offset', 1000);
|
||||
|
||||
const url = buildSearchUrl({ destination, checkin, checkout, adults, rooms, children, offset, currency, lang });
|
||||
|
||||
try {
|
||||
await page.goto(url);
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(`Failed to load Booking.com search page: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
// Booking lazy-loads price cells; wait for at least the first card price to settle.
|
||||
try {
|
||||
await page.wait('selector', '[data-testid=property-card]', { timeoutMs: 20000 });
|
||||
} catch (_) {
|
||||
// selector wait is best-effort — extractor handles empty case explicitly
|
||||
}
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = await page.evaluate(EXTRACTOR);
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(`Failed to extract Booking.com cards: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object' && raw.data && raw.session) {
|
||||
raw = raw.data;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new CommandExecutionError('Booking.com page returned no extractable data');
|
||||
}
|
||||
if (raw.blocked) {
|
||||
throw new CommandExecutionError('Booking.com served a verification / captcha page; retry later or change profile');
|
||||
}
|
||||
|
||||
if (raw.ok !== true) {
|
||||
throw new CommandExecutionError('Booking.com extractor returned an invalid status');
|
||||
}
|
||||
if (!Array.isArray(raw.items)) {
|
||||
throw new CommandExecutionError('Booking.com extractor returned malformed items');
|
||||
}
|
||||
|
||||
const items = raw.items;
|
||||
if (items.length === 0) {
|
||||
const totalText = String(raw.totalText || '').trim();
|
||||
if (hasPositiveResultCount(totalText)) {
|
||||
throw new CommandExecutionError(
|
||||
`Booking.com page declared results but no property cards were parsed: ${totalText}`,
|
||||
);
|
||||
}
|
||||
throw new EmptyResultError(
|
||||
`booking search ${JSON.stringify(destination)}`,
|
||||
totalText
|
||||
? `No hotels rendered (${totalText}). Try a broader destination, different dates, or check the URL in a browser.`
|
||||
: 'No hotels rendered. Try a broader destination, different dates, or check the URL in a browser.',
|
||||
);
|
||||
}
|
||||
|
||||
return items.slice(0, limit).map((it, i) => {
|
||||
if (!it || typeof it !== 'object') {
|
||||
throw new CommandExecutionError('Booking.com extractor returned malformed hotel row');
|
||||
}
|
||||
const name = String(it.name || '').trim();
|
||||
const country = String(it.country || '').trim();
|
||||
const slug = String(it.slug || '').trim();
|
||||
const urlValue = String(it.url || '').trim();
|
||||
const expectedUrl = country && slug
|
||||
? `https://www.booking.com/hotel/${country}/${slug}.html`
|
||||
: '';
|
||||
if (!name || !/^[a-z]{2}$/.test(country) || !slug || urlValue !== expectedUrl) {
|
||||
throw new CommandExecutionError('Booking.com hotel row is missing stable name/url identity');
|
||||
}
|
||||
return {
|
||||
rank: offset + i + 1,
|
||||
name,
|
||||
country,
|
||||
slug,
|
||||
star_rating: it.star_rating,
|
||||
review_score: it.review_score,
|
||||
review_count: it.review_count,
|
||||
price_amount: it.price_amount,
|
||||
price_currency: it.price_amount == null ? '' : (currency || it.price_currency || ''),
|
||||
distance: it.distance,
|
||||
recommended_room: it.recommended_room,
|
||||
url: urlValue,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizePositiveInt,
|
||||
normalizeNonNegativeInt,
|
||||
normalizeDate,
|
||||
normalizeCurrency,
|
||||
normalizeLang,
|
||||
hasPositiveResultCount,
|
||||
buildSearchUrl,
|
||||
EXTRACTOR,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
const BOSS_GEEK_JOBS_URL = 'https://www.zhipin.com/web/geek/jobs';
|
||||
|
||||
async function hasBossSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.zhipin.com' });
|
||||
const names = new Set(cookies.map(c => c.name));
|
||||
return names.has('wt2') || names.has('t');
|
||||
}
|
||||
|
||||
async function verifyBossIdentity(page) {
|
||||
if (!await hasBossSessionCookie(page)) {
|
||||
throw new AuthRequiredError('zhipin.com', 'Boss wt2 / t cookies missing');
|
||||
}
|
||||
await page.goto(BOSS_GEEK_JOBS_URL);
|
||||
await page.wait(3);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
const path = location.pathname || '';
|
||||
if (/\\/web\\/user\\/login|\\/login\\.html/.test(location.href)) {
|
||||
return { kind: 'auth', detail: 'Boss redirected to login page' };
|
||||
}
|
||||
const userType = /\\/web\\/geek\\//.test(path) ? 'geek' : /\\/web\\/(boss|recruit|chat\\/boss)/.test(path) ? 'recruiter' : '';
|
||||
if (!userType) {
|
||||
return { kind: 'auth', detail: 'Boss path does not look like authenticated geek/recruiter page: ' + path };
|
||||
}
|
||||
return { ok: true, user_type: userType };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('zhipin.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Boss probe: ${JSON.stringify(probe)}`);
|
||||
return { user_type: probe.user_type };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'boss',
|
||||
domain: 'zhipin.com',
|
||||
loginUrl: 'https://login.zhipin.com/',
|
||||
columns: ['user_type'],
|
||||
quickCheck: hasBossSessionCookie,
|
||||
verify: verifyBossIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasBossSessionCookie(page)) {
|
||||
throw new AuthRequiredError('zhipin.com', 'Waiting for Boss wt2 / t cookies');
|
||||
}
|
||||
return verifyBossIdentity(page);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
BOSS_GEEK_JOBS_URL,
|
||||
verifyBossIdentity,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { __test__ } from './auth.js';
|
||||
|
||||
describe('boss auth identity probe', () => {
|
||||
it('navigates to the current geek jobs route instead of the retired reload-loop route', async () => {
|
||||
const page = {
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'wt2' }]),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ ok: true, user_type: 'geek' }),
|
||||
};
|
||||
|
||||
await expect(__test__.verifyBossIdentity(page)).resolves.toEqual({ user_type: 'geek' });
|
||||
expect(page.goto).toHaveBeenCalledOnce();
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/jobs');
|
||||
expect(__test__.BOSS_GEEK_JOBS_URL).not.toContain('job-recommend');
|
||||
});
|
||||
});
|
||||
+96
-14
@@ -1,10 +1,60 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
requirePage, navigateToChat, navigateToGeekChat,
|
||||
fetchFriendList, fetchGeekFriendLabelList, fetchGeekFriendInfoList,
|
||||
readEncryptSystemId, assertOk, IDENTITY_MISMATCH_CODE,
|
||||
readPositiveInteger,
|
||||
} from './utils.js';
|
||||
|
||||
function formatMsgTime(ms) {
|
||||
if (!ms) return '';
|
||||
return new Date(ms).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function mapBossRow(f) {
|
||||
return {
|
||||
name: f.name || '',
|
||||
company: '',
|
||||
job: f.jobName || '',
|
||||
title: '',
|
||||
last_msg: f.lastMessageInfo?.text || '',
|
||||
last_time: f.lastTime || '',
|
||||
uid: f.encryptUid || '',
|
||||
security_id: f.securityId || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function buildGeekRows(page, limit) {
|
||||
const encryptSystemId = await readEncryptSystemId(page);
|
||||
const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId });
|
||||
if (labelList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const slicedLabels = labelList.slice(0, limit);
|
||||
const friendIds = slicedLabels.map((f) => f.friendId).filter(Boolean);
|
||||
const enriched = await fetchGeekFriendInfoList(page, friendIds);
|
||||
const enrichMap = new Map(enriched.map((f) => [String(f.friendId ?? f.uid), f]));
|
||||
return slicedLabels.map((f) => {
|
||||
const e = enrichMap.get(String(f.friendId)) || {};
|
||||
return {
|
||||
name: e.name || f.name || '',
|
||||
company: e.brandName || f.brandName || '',
|
||||
job: e.jobName || f.jobName || '',
|
||||
title: e.bossTitle || f.bossTitle || '',
|
||||
last_msg: e.lastMessageInfo?.showText || e.lastMsg || f.lastMsg || '',
|
||||
last_time: e.lastTime || formatMsgTime(e.lastMessageInfo?.msgTime) || formatMsgTime(f.updateTime) || '',
|
||||
uid: e.encryptUid || f.encryptFriendId || String(e.uid ?? e.friendId ?? f.friendId ?? ''),
|
||||
security_id: e.securityId || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'chatlist',
|
||||
access: 'read',
|
||||
description: 'BOSS直聘查看聊天列表(招聘端)',
|
||||
description: 'BOSS直聘查看聊天列表(招聘端/求职端)',
|
||||
domain: 'www.zhipin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
@@ -12,23 +62,55 @@ cli({
|
||||
args: [
|
||||
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
||||
{ name: 'job-id', default: '0', help: 'Filter by job ID (0=all)' },
|
||||
{ name: 'job-id', default: '0', help: 'Filter by job ID (0=all, boss side only)' },
|
||||
{ name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' },
|
||||
],
|
||||
columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'],
|
||||
columns: ['name', 'company', 'job', 'title', 'last_msg', 'last_time', 'uid', 'security_id'],
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const limit = readPositiveInteger(kwargs.limit, 'chatlist --limit', 20, 100);
|
||||
const pageNum = readPositiveInteger(kwargs.page, 'chatlist --page', 1);
|
||||
const side = kwargs.side || 'auto';
|
||||
|
||||
if (side === 'boss') {
|
||||
await navigateToChat(page);
|
||||
const friends = await fetchFriendList(page, {
|
||||
pageNum,
|
||||
jobId: kwargs['job-id'] || '0',
|
||||
});
|
||||
if (friends.length === 0)
|
||||
throw new EmptyResultError('boss chatlist', 'No recruiter-side chat sessions were returned.');
|
||||
return friends.slice(0, limit).map(mapBossRow);
|
||||
}
|
||||
|
||||
if (side === 'geek') {
|
||||
await navigateToGeekChat(page);
|
||||
const rows = await buildGeekRows(page, limit);
|
||||
if (rows.length === 0)
|
||||
throw new EmptyResultError('boss chatlist', 'No job-seeker-side chat sessions were returned.');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// auto: try recruiter first, fall back to geek on identity mismatch
|
||||
await navigateToChat(page);
|
||||
const friends = await fetchFriendList(page, {
|
||||
pageNum: kwargs.page || 1,
|
||||
const bossResult = await fetchFriendList(page, {
|
||||
pageNum,
|
||||
jobId: kwargs['job-id'] || '0',
|
||||
allowNonZero: true,
|
||||
});
|
||||
return friends.slice(0, kwargs.limit || 20).map((f) => ({
|
||||
name: f.name || '',
|
||||
job: f.jobName || '',
|
||||
last_msg: f.lastMessageInfo?.text || '',
|
||||
last_time: f.lastTime || '',
|
||||
uid: f.encryptUid || '',
|
||||
security_id: f.securityId || '',
|
||||
}));
|
||||
if (Array.isArray(bossResult)) {
|
||||
if (bossResult.length === 0)
|
||||
throw new EmptyResultError('boss chatlist', 'No recruiter-side chat sessions were returned.');
|
||||
return bossResult.slice(0, limit).map(mapBossRow);
|
||||
}
|
||||
if (bossResult.code === IDENTITY_MISMATCH_CODE) {
|
||||
await navigateToGeekChat(page);
|
||||
const rows = await buildGeekRows(page, limit);
|
||||
if (rows.length === 0)
|
||||
throw new EmptyResultError('boss chatlist', 'No job-seeker-side chat sessions were returned.');
|
||||
return rows;
|
||||
}
|
||||
assertOk(bossResult);
|
||||
throw new CommandExecutionError('Boss chatlist returned an unexpected response');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './chatlist.js';
|
||||
|
||||
const BOSS_FRIEND = {
|
||||
name: '张三',
|
||||
jobName: '后端工程师',
|
||||
lastMessageInfo: { text: '你好' },
|
||||
lastTime: '2024-01-01 10:00',
|
||||
encryptUid: 'enc-boss-uid',
|
||||
securityId: 'boss-sec-id',
|
||||
};
|
||||
|
||||
const GEEK_LABEL_FRIEND = {
|
||||
friendId: 12345,
|
||||
name: '李四',
|
||||
brandName: '字节跳动',
|
||||
jobName: '产品经理',
|
||||
bossTitle: 'HR',
|
||||
lastMsg: '感谢投递',
|
||||
updateTime: 1704067200000,
|
||||
encryptFriendId: 'enc-geek-uid',
|
||||
};
|
||||
|
||||
const GEEK_ENRICHED = {
|
||||
friendId: 12345,
|
||||
uid: 99999,
|
||||
name: '李四',
|
||||
brandName: '字节跳动',
|
||||
jobName: '产品经理',
|
||||
bossTitle: 'HR总监',
|
||||
encryptUid: 'enc-geek-uid',
|
||||
securityId: 'geek-sec-id',
|
||||
lastMessageInfo: { showText: '感谢投递', msgTime: 1704067200000 },
|
||||
lastTime: '2024-01-01',
|
||||
};
|
||||
|
||||
function createPageMock(evaluateImpl) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockImplementation(evaluateImpl),
|
||||
};
|
||||
}
|
||||
|
||||
describe('boss chatlist', () => {
|
||||
const command = getRegistry().get('boss/chatlist');
|
||||
|
||||
it('--side boss preserves existing behavior with 8-column output', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' });
|
||||
expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: '张三',
|
||||
company: '',
|
||||
job: '后端工程师',
|
||||
title: '',
|
||||
last_msg: '你好',
|
||||
uid: 'enc-boss-uid',
|
||||
security_id: 'boss-sec-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('--side geek maps enriched getGeekFriendList data into 8 columns', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_ENRICHED] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' });
|
||||
expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: '李四',
|
||||
company: '字节跳动',
|
||||
job: '产品经理',
|
||||
title: 'HR总监',
|
||||
uid: 'enc-geek-uid',
|
||||
security_id: 'geek-sec-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('--side geek falls back to label fields when enrichment has no match', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('李四');
|
||||
expect(rows[0].company).toBe('字节跳动');
|
||||
expect(rows[0].security_id).toBe('');
|
||||
});
|
||||
|
||||
it('rejects invalid --limit before navigating', async () => {
|
||||
const page = createPageMock(async () => ({}));
|
||||
await expect(
|
||||
command.func(page, { page: 1, limit: 0, 'job-id': '0', side: 'geek' })
|
||||
).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('--side geek reports a true empty chat list as EmptyResultError', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' })
|
||||
).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('treats malformed geek enrichment payload as CommandExecutionError', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: {} };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' })
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('treats null Boss API payload as CommandExecutionError', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) return null;
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' })
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('maps expired Boss cookies to AuthRequiredError', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 7, message: 'Cookie 已过期' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' })
|
||||
).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('--side auto falls back to geek when recruiter returns code 24', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 24, message: '请切换身份后再试' };
|
||||
}
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_ENRICHED] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].company).toBe('字节跳动');
|
||||
expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat'));
|
||||
});
|
||||
|
||||
it('--side auto uses recruiter results when code 0 and does not call geek API', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('张三');
|
||||
const evaluateCalls = page.evaluate.mock.calls.map((c) => c[0]);
|
||||
expect(evaluateCalls.some((s) => s.includes('geekFilterByLabel'))).toBe(false);
|
||||
});
|
||||
|
||||
it('registers --side as a choices-constrained arg defaulting to auto', () => {
|
||||
const sideArg = command.args.find((a) => a.name === 'side');
|
||||
expect(sideArg?.choices).toEqual(['auto', 'boss', 'geek']);
|
||||
expect(sideArg?.default).toBe('auto');
|
||||
});
|
||||
});
|
||||
+98
-24
@@ -1,10 +1,72 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
requirePage, navigateToChat, navigateToGeekChat,
|
||||
bossFetch, findFriendByUid, findGeekFriendByUid,
|
||||
fetchGeekHistoryMsg, readEncryptSystemId,
|
||||
assertOk, IDENTITY_MISMATCH_CODE,
|
||||
readPositiveInteger, readRequiredString,
|
||||
} from './utils.js';
|
||||
|
||||
const TYPE_MAP = {
|
||||
1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统',
|
||||
6: '名片', 7: '语音', 8: '视频', 9: '表情',
|
||||
};
|
||||
|
||||
function mapBossMsg(m, friend) {
|
||||
const fromObj = m.from || {};
|
||||
const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
|
||||
return {
|
||||
from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
|
||||
type: TYPE_MAP[m.type] || `其他(${m.type})`,
|
||||
text: m.text || m.body?.text || '',
|
||||
time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
|
||||
};
|
||||
}
|
||||
|
||||
function mapGeekMsg(m, friend) {
|
||||
const fromUid = m.from && m.from.uid;
|
||||
const isFromBoss = fromUid != null && String(fromUid) === String(friend.uid);
|
||||
return {
|
||||
from: isFromBoss ? '对方' : '我',
|
||||
type: TYPE_MAP[m.type] || `其他(${m.type})`,
|
||||
text: m.text || m.body?.text || m.body?.content || m.body?.showText ||
|
||||
JSON.stringify(m.body || {}).slice(0, 120),
|
||||
time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function bossChatMsg(page, kwargs, existingFriend) {
|
||||
const friend = existingFriend ?? await findFriendByUid(page, kwargs.uid);
|
||||
if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该候选人');
|
||||
if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
|
||||
const gid = friend.uid;
|
||||
const securityId = encodeURIComponent(friend.securityId);
|
||||
const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`;
|
||||
const msgData = await bossFetch(page, msgUrl);
|
||||
const messages = msgData.zpData?.messages ?? msgData.zpData?.historyMsgList;
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new CommandExecutionError('Boss recruiter history response did not include a message list');
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.');
|
||||
}
|
||||
return messages.map((m) => mapBossMsg(m, friend));
|
||||
}
|
||||
|
||||
async function geekChatMsg(page, kwargs, encryptSystemId) {
|
||||
const friend = await findGeekFriendByUid(page, kwargs.uid, { encryptSystemId });
|
||||
if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该聊天(geek 侧)');
|
||||
if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
|
||||
const messages = await fetchGeekHistoryMsg(page, friend, { page: kwargs.page });
|
||||
return messages.map((m) => mapGeekMsg(m, friend));
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'chatmsg',
|
||||
access: 'read',
|
||||
description: 'BOSS直聘查看与候选人的聊天消息',
|
||||
description: 'BOSS直聘查看聊天消息历史(招聘端/求职端)',
|
||||
domain: 'www.zhipin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
@@ -12,32 +74,44 @@ cli({
|
||||
args: [
|
||||
{ name: 'uid', required: true, positional: true, help: 'Encrypted UID (from chatlist)' },
|
||||
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
||||
{ name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' },
|
||||
],
|
||||
columns: ['from', 'type', 'text', 'time'],
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const uid = readRequiredString(kwargs.uid, 'chatmsg uid');
|
||||
const pageNum = readPositiveInteger(kwargs.page, 'chatmsg --page', 1);
|
||||
const normalizedKwargs = { ...kwargs, uid, page: pageNum };
|
||||
const side = kwargs.side || 'auto';
|
||||
|
||||
if (side === 'boss') {
|
||||
await navigateToChat(page);
|
||||
return await bossChatMsg(page, normalizedKwargs);
|
||||
}
|
||||
|
||||
if (side === 'geek') {
|
||||
await navigateToGeekChat(page);
|
||||
const encryptSystemId = await readEncryptSystemId(page);
|
||||
return await geekChatMsg(page, normalizedKwargs, encryptSystemId);
|
||||
}
|
||||
|
||||
// auto: try recruiter first, fall back to geek when not found or identity mismatch
|
||||
await navigateToChat(page);
|
||||
const friend = await findFriendByUid(page, kwargs.uid);
|
||||
if (!friend)
|
||||
throw new Error('未找到该候选人');
|
||||
const gid = friend.uid;
|
||||
const securityId = encodeURIComponent(friend.securityId);
|
||||
const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`;
|
||||
const msgData = await bossFetch(page, msgUrl);
|
||||
const TYPE_MAP = {
|
||||
1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统',
|
||||
6: '名片', 7: '语音', 8: '视频', 9: '表情',
|
||||
};
|
||||
const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || [];
|
||||
return messages.map((m) => {
|
||||
const fromObj = m.from || {};
|
||||
const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
|
||||
return {
|
||||
from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
|
||||
type: TYPE_MAP[m.type] || '其他(' + m.type + ')',
|
||||
text: m.text || m.body?.text || '',
|
||||
time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
|
||||
};
|
||||
});
|
||||
const bossResult = await findFriendByUid(page, uid, { allowNonZero: true });
|
||||
if (bossResult?.friend) {
|
||||
return await bossChatMsg(page, normalizedKwargs, bossResult.friend);
|
||||
}
|
||||
// Not found or identity mismatch — check for hard errors before falling back
|
||||
if (bossResult?.code && bossResult.code !== 0 && bossResult.code !== IDENTITY_MISMATCH_CODE) {
|
||||
assertOk(bossResult);
|
||||
}
|
||||
// Fall back to geek side
|
||||
await navigateToGeekChat(page);
|
||||
const encryptSystemId = await readEncryptSystemId(page);
|
||||
const geekFriend = await findGeekFriendByUid(page, uid, { encryptSystemId });
|
||||
if (!geekFriend) throw new EmptyResultError('boss chatmsg', 'uid 在招聘端与求职端聊天列表中均未找到');
|
||||
if (!geekFriend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
|
||||
const messages = await fetchGeekHistoryMsg(page, geekFriend, { page: pageNum });
|
||||
return messages.map((m) => mapGeekMsg(m, geekFriend));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './chatmsg.js';
|
||||
|
||||
const BOSS_FRIEND = {
|
||||
uid: 12345,
|
||||
encryptUid: 'enc-boss-uid',
|
||||
securityId: 'boss-sec-id',
|
||||
name: '候选人甲',
|
||||
};
|
||||
const BOSS_MSGS = [
|
||||
{ type: 1, text: 'Hello', from: { uid: 99999, name: 'HR' }, time: 1704067200000 },
|
||||
{ type: 1, text: '感谢', from: { uid: 12345, name: '候选人甲' }, time: 1704067201000 },
|
||||
];
|
||||
|
||||
const GEEK_FRIEND_LABEL = {
|
||||
friendId: 11111,
|
||||
encryptFriendId: 'enc-geek-uid',
|
||||
name: 'Boss张',
|
||||
brandName: '公司A',
|
||||
};
|
||||
const GEEK_FRIEND_ENRICHED = {
|
||||
friendId: 11111,
|
||||
uid: 67890,
|
||||
encryptUid: 'enc-geek-uid',
|
||||
securityId: 'geek-sec-id',
|
||||
name: 'Boss张',
|
||||
};
|
||||
const GEEK_MSGS = [
|
||||
{ type: 1, text: '欢迎投递', received: true, time: 1704067200000, from: { uid: 67890, name: 'Boss张' } },
|
||||
{ type: 1, text: '谢谢', received: true, time: 1704067201000, from: { uid: 99999, name: '我' } },
|
||||
];
|
||||
|
||||
function createPageMock(evaluateImpl) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockImplementation(evaluateImpl),
|
||||
};
|
||||
}
|
||||
|
||||
describe('boss chatmsg', () => {
|
||||
const command = getRegistry().get('boss/chatmsg');
|
||||
|
||||
it('rejects empty uid before navigating', async () => {
|
||||
const page = createPageMock(async () => ({}));
|
||||
await expect(
|
||||
command.func(page, { uid: ' ', page: 1, side: 'geek' })
|
||||
).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid --page before navigating', async () => {
|
||||
const page = createPageMock(async () => ({}));
|
||||
await expect(
|
||||
command.func(page, { uid: 'enc-geek-uid', page: 0, side: 'geek' })
|
||||
).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('--side boss preserves existing behavior', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
|
||||
}
|
||||
if (script.includes('boss/historyMsg')) {
|
||||
return { code: 0, zpData: { messages: BOSS_MSGS } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' });
|
||||
expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index'));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].from).toBe('我');
|
||||
expect(rows[1].from).toBe('候选人甲');
|
||||
});
|
||||
|
||||
it('--side geek calls historyMsg with bossId, securityId, page, c=20, src=0', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
|
||||
}
|
||||
if (script.includes('geek/historyMsg')) {
|
||||
return { code: 0, zpData: { messages: GEEK_MSGS } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
|
||||
const historyScript = page.evaluate.mock.calls.find((c) => c[0].includes('geek/historyMsg'))?.[0];
|
||||
expect(historyScript).toBeDefined();
|
||||
expect(historyScript).toContain('bossId=67890');
|
||||
expect(historyScript).toContain('securityId=');
|
||||
expect(historyScript).toContain('page=1');
|
||||
expect(historyScript).toContain('c=20');
|
||||
expect(historyScript).toContain('src=0');
|
||||
});
|
||||
|
||||
it('--side geek uses from.uid to determine direction, not received flag', async () => {
|
||||
// Both messages have received:true (mirrors real geek historyMsg API behaviour)
|
||||
// Direction is determined by whether m.from.uid matches the boss's uid (67890)
|
||||
const msgsAllReceived = [
|
||||
{ type: 1, text: '欢迎投递', received: true, time: 1704067200000, from: { uid: 67890, name: 'Boss张' } },
|
||||
{ type: 1, text: '谢谢', received: true, time: 1704067201000, from: { uid: 99999, name: '我' } },
|
||||
];
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
|
||||
}
|
||||
if (script.includes('geek/historyMsg')) {
|
||||
return { code: 0, zpData: { messages: msgsAllReceived } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
|
||||
// from.uid=67890 matches friend.uid=67890 → boss sent it → '对方'
|
||||
expect(rows[0].from).toBe('对方');
|
||||
// from.uid=99999 does not match → geek sent it → '我'
|
||||
expect(rows[1].from).toBe('我');
|
||||
});
|
||||
|
||||
it('non-text message body does not crash and produces truncated JSON', async () => {
|
||||
const nonTextMsg = { type: 99, received: true, time: 1704067200000, body: { action: 'resume_request', detail: 'X' } };
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
|
||||
}
|
||||
if (script.includes('geek/historyMsg')) {
|
||||
return { code: 0, zpData: { messages: [nonTextMsg] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].text).toContain('resume_request');
|
||||
});
|
||||
|
||||
it('--side auto falls back to geek when recruiter returns code 24', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 24, message: '请切换身份后再试' };
|
||||
}
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
|
||||
}
|
||||
if (script.includes('geek/historyMsg')) {
|
||||
return { code: 0, zpData: { messages: GEEK_MSGS } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'auto' });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].from).toBe('对方');
|
||||
});
|
||||
|
||||
it('--side geek throws when uid is not found in geek chat list', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { uid: 'unknown-uid', page: 1, side: 'geek' })
|
||||
).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('--side boss maps expired cookies to AuthRequiredError', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 7, message: 'Cookie 已过期' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' })
|
||||
).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('--side boss treats missing history list as parser drift', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('getBossFriendListV2')) {
|
||||
return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
|
||||
}
|
||||
if (script.includes('boss/historyMsg')) {
|
||||
return { code: 0, zpData: {} };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' })
|
||||
).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('--side geek reports an empty history as EmptyResultError', async () => {
|
||||
const page = createPageMock(async (script) => {
|
||||
if (script.includes('document.cookie')) return 'test-enc-sys-id';
|
||||
if (script.includes('geekFilterByLabel')) {
|
||||
return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
|
||||
}
|
||||
if (script.includes('getGeekFriendList.json')) {
|
||||
return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
|
||||
}
|
||||
if (script.includes('geek/historyMsg')) {
|
||||
return { code: 0, zpData: { messages: [] } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await expect(
|
||||
command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' })
|
||||
).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
+267
-12
@@ -1,8 +1,13 @@
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
const BOSS_DOMAIN = 'www.zhipin.com';
|
||||
const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`;
|
||||
const COOKIE_EXPIRED_CODES = new Set([7, 37]);
|
||||
const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。';
|
||||
const AMBIGUOUS_AUTH_CODE = 37;
|
||||
const ENVIRONMENT_REJECTED_MARKERS = ['环境存在异常', '环境异常', 'abnormal environment'];
|
||||
const RECRUITER_ONLY_MSG = '该命令仅支持招聘端(BOSS 端)账号,请使用招聘者账号登录后重试。';
|
||||
const DEFAULT_TIMEOUT = 15_000;
|
||||
// ── Core helpers ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
@@ -10,7 +15,24 @@ const DEFAULT_TIMEOUT = 15_000;
|
||||
*/
|
||||
export function requirePage(page) {
|
||||
if (!page)
|
||||
throw new Error('Browser page required');
|
||||
throw new CommandExecutionError('Browser page required');
|
||||
}
|
||||
export function readPositiveInteger(raw, name, fallback, max) {
|
||||
const value = raw === undefined || raw === null || raw === '' ? fallback : Number(raw);
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new ArgumentError(`boss ${name} must be a positive integer`);
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
throw new ArgumentError(`boss ${name} must be <= ${max}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export function readRequiredString(raw, name) {
|
||||
const value = String(raw ?? '').trim();
|
||||
if (!value) {
|
||||
throw new ArgumentError(`boss ${name} cannot be empty`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* Navigate to BOSS chat page and wait for it to settle.
|
||||
@@ -33,19 +55,45 @@ export async function navigateTo(page, url, waitSeconds = 1) {
|
||||
*/
|
||||
export function checkAuth(data) {
|
||||
if (COOKIE_EXPIRED_CODES.has(data.code)) {
|
||||
throw new Error(COOKIE_EXPIRED_MSG);
|
||||
throw new AuthRequiredError(BOSS_DOMAIN, COOKIE_EXPIRED_MSG);
|
||||
}
|
||||
}
|
||||
function checkEnvironment(data) {
|
||||
const message = String(data.message || '').toLowerCase();
|
||||
if (data.code === AMBIGUOUS_AUTH_CODE &&
|
||||
ENVIRONMENT_REJECTED_MARKERS.some((marker) => message.includes(marker.toLowerCase()))) {
|
||||
throw new CommandExecutionError(`Boss rejected the current browser environment: ${data.message || 'Unknown error'} (code=${data.code})`, '重新登录通常无法解决此问题。请保留当前页面,稍后重试,并在问题持续时上报完整错误信息。');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Map BOSS code=24 ("请切换身份后再试") to a typed AuthRequiredError.
|
||||
* Recruiter-only commands (recommend, joblist, stats, resume, mark,
|
||||
* exchange, invite, greet, batchgreet) have no geek-side equivalent;
|
||||
* surfacing this as a generic COMMAND_EXEC hides what the user must do.
|
||||
* chatlist / chatmsg avoid this path by using `allowNonZero: true` and
|
||||
* branching to the geek-side fetch when they see code 24.
|
||||
*/
|
||||
function checkRecruiterSide(data) {
|
||||
if (data.code === IDENTITY_MISMATCH_CODE) {
|
||||
throw new AuthRequiredError(BOSS_DOMAIN, RECRUITER_ONLY_MSG);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Throw if the API response is not code 0.
|
||||
* Checks for cookie expiry first, then throws with the provided message.
|
||||
* Checks for cookie expiry first, then identity mismatch, then throws
|
||||
* with the provided message.
|
||||
*/
|
||||
export function assertOk(data, errorPrefix) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new CommandExecutionError(`${errorPrefix ? `${errorPrefix}: ` : ''}Boss API returned malformed response`);
|
||||
}
|
||||
if (data.code === 0)
|
||||
return;
|
||||
checkEnvironment(data);
|
||||
checkAuth(data);
|
||||
checkRecruiterSide(data);
|
||||
const prefix = errorPrefix ? `${errorPrefix}: ` : '';
|
||||
throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
|
||||
throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
|
||||
}
|
||||
/**
|
||||
* Make a credentialed XHR request via page.evaluate().
|
||||
@@ -80,7 +128,19 @@ export async function bossFetch(page, url, opts = {}) {
|
||||
});
|
||||
}
|
||||
`;
|
||||
const data = await page.evaluate(script);
|
||||
let data;
|
||||
try {
|
||||
data = await page.evaluate(script);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new CommandExecutionError(`Boss API request failed: ${message}`);
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new CommandExecutionError('Boss API returned malformed response');
|
||||
}
|
||||
// Auto-check auth unless caller opts out
|
||||
if (!opts.allowNonZero && data.code !== 0) {
|
||||
assertOk(data);
|
||||
@@ -95,8 +155,13 @@ export async function fetchFriendList(page, opts = {}) {
|
||||
const pageNum = opts.pageNum ?? 1;
|
||||
const jobId = opts.jobId ?? '0';
|
||||
const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`;
|
||||
const data = await bossFetch(page, url);
|
||||
return data.zpData?.friendList || [];
|
||||
const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero });
|
||||
if (opts.allowNonZero && data.code !== 0) return data;
|
||||
const list = data.zpData?.friendList;
|
||||
if (!Array.isArray(list)) {
|
||||
throw new CommandExecutionError('Boss friend list response did not include zpData.friendList');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
/**
|
||||
* Fetch the recommended candidates (greetRecSortList).
|
||||
@@ -104,7 +169,11 @@ export async function fetchFriendList(page, opts = {}) {
|
||||
export async function fetchRecommendList(page) {
|
||||
const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/greetRecSortList`;
|
||||
const data = await bossFetch(page, url);
|
||||
return data.zpData?.friendList || [];
|
||||
const list = data.zpData?.friendList;
|
||||
if (!Array.isArray(list)) {
|
||||
throw new CommandExecutionError('Boss recommend response did not include zpData.friendList');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
/**
|
||||
* Find a friend by encryptUid, searching through friend list and optionally greet list.
|
||||
@@ -115,10 +184,14 @@ export async function findFriendByUid(page, encryptUid, opts = {}) {
|
||||
const checkGreetList = opts.checkGreetList ?? false;
|
||||
// Search friend list pages
|
||||
for (let p = 1; p <= maxPages; p++) {
|
||||
const friends = await fetchFriendList(page, { pageNum: p });
|
||||
const result = await fetchFriendList(page, { pageNum: p, allowNonZero: opts.allowNonZero });
|
||||
if (opts.allowNonZero && !Array.isArray(result)) {
|
||||
return { friend: null, code: result.code };
|
||||
}
|
||||
const friends = Array.isArray(result) ? result : [];
|
||||
const found = friends.find((f) => f.encryptUid === encryptUid);
|
||||
if (found)
|
||||
return found;
|
||||
return opts.allowNonZero ? { friend: found, code: 0 } : found;
|
||||
if (friends.length === 0)
|
||||
break;
|
||||
}
|
||||
@@ -127,9 +200,9 @@ export async function findFriendByUid(page, encryptUid, opts = {}) {
|
||||
const greetList = await fetchRecommendList(page);
|
||||
const found = greetList.find((f) => f.encryptUid === encryptUid);
|
||||
if (found)
|
||||
return found;
|
||||
return opts.allowNonZero ? { friend: found, code: 0 } : found;
|
||||
}
|
||||
return null;
|
||||
return opts.allowNonZero ? { friend: null, code: 0 } : null;
|
||||
}
|
||||
// ── UI automation helpers ───────────────────────────────────────────────────
|
||||
/**
|
||||
@@ -221,3 +294,185 @@ export function verbose(msg) {
|
||||
console.error(`[opencli:boss] ${msg}`);
|
||||
}
|
||||
}
|
||||
// ── Geek-side helpers ────────────────────────────────────────────────────────
|
||||
export const IDENTITY_MISMATCH_CODE = 24;
|
||||
const GEEK_CHAT_URL = `https://${BOSS_DOMAIN}/web/geek/chat`;
|
||||
/**
|
||||
* Navigate to the job-seeker chat page.
|
||||
* Establishes the cookie + JS-global context needed for geek-side API calls.
|
||||
*/
|
||||
export async function navigateToGeekChat(page, waitSeconds = 2) {
|
||||
await page.goto(GEEK_CHAT_URL);
|
||||
await page.wait({ time: waitSeconds });
|
||||
}
|
||||
/**
|
||||
* Read the encryptSystemId value required by the geek-side list API.
|
||||
* Strategy (in order):
|
||||
* 1. Vue app state / Pinia stores / $route.query (Option 1 — runtime source)
|
||||
* 2. performance.getEntriesByType('resource') — parse from geekFilterByLabel URL
|
||||
* that the page itself already issued (Option 2 — most deterministic)
|
||||
* 3. cookie, inline <script> SSR state, known window globals, localStorage (fallbacks)
|
||||
* Returns empty string if nothing is found; the API may still succeed without it.
|
||||
* Caller must have navigated to the geek chat page first.
|
||||
*/
|
||||
export async function readEncryptSystemId(page) {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
// 1. Vue app state / Pinia / $route.query
|
||||
// The chat component reads encryptSystemId from the app runtime to build
|
||||
// its own geekFilterByLabel request, so the value lives in the Vue tree.
|
||||
try {
|
||||
const appEl = document.querySelector('#app') || document.querySelector('[data-v-app]');
|
||||
const vueApp = appEl && (appEl.__vue_app__ || appEl._vei);
|
||||
if (vueApp) {
|
||||
// 1a. Pinia stores (Vue 3 standard state management on BOSS直聘)
|
||||
const pinia = vueApp.config && vueApp.config.globalProperties.$pinia;
|
||||
if (pinia && pinia.state && pinia.state.value) {
|
||||
for (const store of Object.values(pinia.state.value)) {
|
||||
try {
|
||||
const flat = JSON.stringify(store);
|
||||
if (flat.includes('encryptSystemId')) {
|
||||
const m = flat.match(/"encryptSystemId":"([^"]+)"/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
// 1b. Vue Router current route query
|
||||
const router = vueApp.config && vueApp.config.globalProperties.$router;
|
||||
const query = router && router.currentRoute && router.currentRoute.value && router.currentRoute.value.query;
|
||||
if (query && query.encryptSystemId) return query.encryptSystemId;
|
||||
}
|
||||
} catch (_) {}
|
||||
// 2. Performance resource entries — the page already issued geekFilterByLabel
|
||||
// with encryptSystemId in the URL; read it back from the resource timing API.
|
||||
try {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.includes('geekFilterByLabel')) continue;
|
||||
const u = new URL(entry.name);
|
||||
const v = u.searchParams.get('encryptSystemId');
|
||||
if (v) return v;
|
||||
}
|
||||
} catch (_) {}
|
||||
// 3. cookie
|
||||
try {
|
||||
const m = document.cookie.match(/encryptSystemId=([^;]+)/i);
|
||||
if (m) return decodeURIComponent(m[1]);
|
||||
} catch (_) {}
|
||||
// 4. inline <script> SSR state (Nuxt embeds server state here)
|
||||
try {
|
||||
for (const s of document.querySelectorAll('script:not([src])')) {
|
||||
const t = s.textContent || '';
|
||||
if (!t.includes('encryptSystemId')) continue;
|
||||
const m = t.match(/"encryptSystemId":"([^"]+)"/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
} catch (_) {}
|
||||
// 5. known BOSS / Nuxt window globals
|
||||
const KNOWN = [
|
||||
'__NUXT__', '__INITIAL_STATE__', '__ZP_INFO__', '__BOSS_ZP__',
|
||||
'pageGlobalVar', 'ZP_DATA', '__ZP_DATA__', '__PAGE_DATA__',
|
||||
];
|
||||
for (const k of KNOWN) {
|
||||
const obj = window[k];
|
||||
if (!obj || typeof obj !== 'object') continue;
|
||||
try {
|
||||
const flat = JSON.stringify(obj);
|
||||
if (!flat.includes('encryptSystemId')) continue;
|
||||
const m = flat.match(/"encryptSystemId":"([^"]+)"/);
|
||||
if (m) return m[1];
|
||||
} catch (_) {}
|
||||
}
|
||||
// 6. localStorage
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (!k) continue;
|
||||
if (k.toLowerCase().includes('encryptsystemid')) {
|
||||
const v = localStorage.getItem(k);
|
||||
if (v) return v;
|
||||
}
|
||||
const v = localStorage.getItem(k) || '';
|
||||
if (v.includes('encryptSystemId')) {
|
||||
const m = v.match(/"encryptSystemId":"([^"]+)"/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
})()
|
||||
`);
|
||||
return result || '';
|
||||
}
|
||||
/**
|
||||
* Fetch the job-seeker chat list (brief info, no securityId).
|
||||
* Use fetchGeekFriendInfoList to enrich with securityId before calling chatmsg.
|
||||
*/
|
||||
export async function fetchGeekFriendLabelList(page, opts = {}) {
|
||||
const labelId = opts.labelId ?? 0;
|
||||
const encryptSystemId = opts.encryptSystemId ?? '';
|
||||
const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/geekFilterByLabel?labelId=${labelId}&encryptSystemId=${encodeURIComponent(encryptSystemId)}`;
|
||||
const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero });
|
||||
if (opts.allowNonZero && data.code !== 0) return data;
|
||||
const list = data.zpData?.friendList;
|
||||
if (!Array.isArray(list)) {
|
||||
throw new CommandExecutionError('Boss geek chat list response did not include zpData.friendList');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
/**
|
||||
* Enrich a batch of geek friends with full fields including securityId.
|
||||
* Processes in batches of 50 to avoid oversized request bodies.
|
||||
*/
|
||||
export async function fetchGeekFriendInfoList(page, friendIds = []) {
|
||||
if (!friendIds.length) return [];
|
||||
const BATCH_SIZE = 50;
|
||||
const results = [];
|
||||
for (let i = 0; i < friendIds.length; i += BATCH_SIZE) {
|
||||
const batch = friendIds.slice(i, i + BATCH_SIZE).map(String);
|
||||
const body = `friendIds=${batch.join(',')}`;
|
||||
const data = await bossFetch(page, `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getGeekFriendList.json`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
const batchResult = data.zpData?.result;
|
||||
if (!Array.isArray(batchResult)) {
|
||||
throw new CommandExecutionError('Boss geek friend enrichment response did not include zpData.result');
|
||||
}
|
||||
results.push(...batchResult);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/**
|
||||
* Find a geek-side friend by encrypted uid.
|
||||
* Merges label-list and enriched data; returns null if not found.
|
||||
*/
|
||||
export async function findGeekFriendByUid(page, encryptUid, opts = {}) {
|
||||
const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId: opts.encryptSystemId });
|
||||
const candidate = labelList.find((f) => f.encryptFriendId === encryptUid ||
|
||||
String(f.uid) === String(encryptUid) ||
|
||||
String(f.friendId) === String(encryptUid));
|
||||
if (!candidate) return null;
|
||||
const enriched = await fetchGeekFriendInfoList(page, [candidate.friendId]);
|
||||
return { ...candidate, ...(enriched[0] || {}) };
|
||||
}
|
||||
/**
|
||||
* Fetch message history for a geek-side chat.
|
||||
* friend must have .uid (boss's numeric id) and .securityId.
|
||||
*/
|
||||
export async function fetchGeekHistoryMsg(page, friend, opts = {}) {
|
||||
const pageNum = opts.page ?? 1;
|
||||
const bossId = friend.uid;
|
||||
const securityId = encodeURIComponent(friend.securityId || '');
|
||||
const url = `https://${BOSS_DOMAIN}/wapi/zpchat/geek/historyMsg?bossId=${bossId}&securityId=${securityId}&page=${pageNum}&c=20&src=0`;
|
||||
const data = await bossFetch(page, url);
|
||||
const messages = data.zpData?.messages ?? data.zpData?.historyMsgList;
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new CommandExecutionError('Boss geek history response did not include a message list');
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.');
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { assertOk } from './utils.js';
|
||||
|
||||
describe('assertOk', () => {
|
||||
it('returns silently on code 0', () => {
|
||||
expect(() => assertOk({ code: 0 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('maps expired cookie codes (7, 37) to AuthRequiredError', () => {
|
||||
expect(() => assertOk({ code: 7, message: 'expired' })).toThrow(AuthRequiredError);
|
||||
expect(() => assertOk({ code: 37, message: 'expired' })).toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('does not misclassify code 37 environment rejection as expired login', () => {
|
||||
let error;
|
||||
try {
|
||||
assertOk({ code: 37, message: '您的环境存在异常.' }, 'Boss search failed');
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(CommandExecutionError);
|
||||
expect(error).not.toBeInstanceOf(AuthRequiredError);
|
||||
expect(error.code).toBe('COMMAND_EXEC');
|
||||
expect(error.message).toContain('环境存在异常');
|
||||
expect(error.message).toContain('code=37');
|
||||
expect(error.hint).toContain('重新登录通常无法解决');
|
||||
});
|
||||
|
||||
it('keeps code 37 login-expiry responses as auth required', () => {
|
||||
expect(() => assertOk({ code: 37, message: '登录状态已失效' })).toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('keeps code 7 responses as auth required', () => {
|
||||
expect(() => assertOk({ code: 7, message: '请重新登录' })).toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('maps code 24 (identity mismatch) to AuthRequiredError with recruiter-only hint', () => {
|
||||
try {
|
||||
assertOk({ code: 24, message: '请切换身份后再试' });
|
||||
throw new Error('assertOk should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AuthRequiredError);
|
||||
expect(String(err.message)).toContain('招聘端');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls through to CommandExecutionError for other non-zero codes', () => {
|
||||
expect(() => assertOk({ code: 99, message: 'something else' }))
|
||||
.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError on malformed (non-object) response', () => {
|
||||
expect(() => assertOk(null)).toThrow(CommandExecutionError);
|
||||
expect(() => assertOk('not-an-object')).toThrow(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
emptySearchResults,
|
||||
requireBoundedInteger,
|
||||
requireNonNegativeInteger,
|
||||
requireRows,
|
||||
requireSearchQuery,
|
||||
runBrowserStep,
|
||||
toHttpsUrl,
|
||||
} from '../_shared/search-adapter.js';
|
||||
|
||||
function buildExtractorJs(limit) {
|
||||
return `
|
||||
(function() {
|
||||
var results = [];
|
||||
var seen = {};
|
||||
var items = document.querySelectorAll('.snippet');
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (results.length >= ${limit}) break;
|
||||
var el = items[i];
|
||||
if (el.classList.contains('standalone') || el.classList.contains('ad')) continue;
|
||||
var titleEl = el.querySelector('.search-snippet-title');
|
||||
var snippetEl = el.querySelector('.generic-snippet .content');
|
||||
var linkEl = el.querySelector('.result-content a');
|
||||
if (!titleEl) continue;
|
||||
var title = titleEl.textContent.trim();
|
||||
var href = linkEl ? linkEl.getAttribute('href') || '' : '';
|
||||
var snippet = snippetEl ? snippetEl.textContent.trim() : '';
|
||||
if (!title || !href || seen[href]) continue;
|
||||
if (href.indexOf('/') === 0) continue;
|
||||
seen[href] = true;
|
||||
results.push([title, href, snippet]);
|
||||
}
|
||||
return results;
|
||||
})()`;
|
||||
}
|
||||
|
||||
const command = cli({
|
||||
site: 'brave',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Search Brave Search',
|
||||
domain: 'search.brave.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'keyword', positional: true, required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of results per page (max 18)' },
|
||||
{ name: 'offset', type: 'int', default: 0, help: 'Page offset (0, 1, 2...). Brave returns ~18 results per page' },
|
||||
],
|
||||
columns: ['rank', 'title', 'url', 'snippet'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = requireBoundedInteger(kwargs.limit, 10, 1, 18, '--limit');
|
||||
const query = requireSearchQuery(kwargs.keyword);
|
||||
const keyword = encodeURIComponent(query);
|
||||
const offset = requireNonNegativeInteger(kwargs.offset, 0, '--offset');
|
||||
let url = `https://search.brave.com/search?q=${keyword}`;
|
||||
if (offset > 0) url += `&offset=${offset}`;
|
||||
await runBrowserStep('brave search navigation', () => page.goto(url));
|
||||
try {
|
||||
await page.wait({ selector: '.snippet', timeout: 10 });
|
||||
} catch {
|
||||
await page.wait(3).catch(function() {});
|
||||
}
|
||||
const raw = await runBrowserStep('brave search extraction', () => page.evaluate(buildExtractorJs(limit)));
|
||||
const results = requireRows(raw, 'brave search');
|
||||
if (results.length === 0) {
|
||||
throw emptySearchResults('Brave', query);
|
||||
}
|
||||
const rows = results
|
||||
.map(function(r, index) {
|
||||
return { rank: index + 1 + offset * 18, title: r[0], url: toHttpsUrl(r[1], 'https://search.brave.com'), snippet: r[2] };
|
||||
})
|
||||
.filter((row) => row.url);
|
||||
if (rows.length === 0) throw emptySearchResults('Brave', query);
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { command };
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const { __test__ } = await import('./search.js');
|
||||
const command = __test__.command;
|
||||
|
||||
function createPageMock(evaluateResult = []) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
};
|
||||
}
|
||||
|
||||
describe('brave search', () => {
|
||||
it('should register as a valid command', () => {
|
||||
expect(command).toBeDefined();
|
||||
expect(command.site).toBe('brave');
|
||||
expect(command.name).toBe('search');
|
||||
expect(command.access).toBe('read');
|
||||
expect(command.browser).toBe(true);
|
||||
expect(command.strategy).toBe('public');
|
||||
expect(command.domain).toBe('search.brave.com');
|
||||
});
|
||||
|
||||
it('should define keyword positional arg', () => {
|
||||
const kwArg = command.args.find(a => a.name === 'keyword');
|
||||
expect(kwArg).toBeDefined();
|
||||
expect(kwArg.positional).toBe(true);
|
||||
expect(kwArg.required).toBe(true);
|
||||
});
|
||||
|
||||
it('should define limit arg with default 10', () => {
|
||||
const limitArg = command.args.find(a => a.name === 'limit');
|
||||
expect(limitArg).toBeDefined();
|
||||
expect(limitArg.type).toBe('int');
|
||||
expect(limitArg.default).toBe(10);
|
||||
});
|
||||
|
||||
it('should define output columns', () => {
|
||||
expect(command.columns).toContain('rank');
|
||||
expect(command.columns).toContain('title');
|
||||
expect(command.columns).toContain('url');
|
||||
expect(command.columns).toContain('snippet');
|
||||
});
|
||||
|
||||
it('rejects empty query, invalid limit, and invalid offset before navigation', async () => {
|
||||
const page = createPageMock();
|
||||
await expect(command.func(page, { keyword: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
await expect(command.func(page, { keyword: 'opencli', limit: 19 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
await expect(command.func(page, { keyword: 'opencli', limit: 5, offset: -1 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unwraps browser envelopes and returns ranked HTTPS rows', async () => {
|
||||
const page = createPageMock({
|
||||
session: 'site:brave',
|
||||
data: [['OpenCLI', 'https://github.com/jackwener/OpenCLI', 'CLI browser tooling']],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { keyword: 'opencli', limit: 1, offset: 1 })).resolves.toEqual([{
|
||||
rank: 19,
|
||||
title: 'OpenCLI',
|
||||
url: 'https://github.com/jackwener/OpenCLI',
|
||||
snippet: 'CLI browser tooling',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('fails typed instead of silently returning [] for malformed extraction payloads', async () => {
|
||||
const page = createPageMock({ rows: [] });
|
||||
|
||||
await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
message: expect.stringContaining('payload shape'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasChaoxingSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://i.chaoxing.com' });
|
||||
return cookies.some(c => /^(UID|_uid|chaoxinguser|cx_p_token)$/i.test(c.name) && c.value);
|
||||
}
|
||||
|
||||
async function verifyChaoxingIdentity(page) {
|
||||
if (!await hasChaoxingSessionCookie(page)) {
|
||||
throw new AuthRequiredError('chaoxing.com', 'Chaoxing session cookies missing');
|
||||
}
|
||||
await page.goto('https://i.chaoxing.com/');
|
||||
await page.wait(3);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
if (/passport2\\.chaoxing\\.com\\/login/.test(location.href)) {
|
||||
return { kind: 'auth', detail: 'Chaoxing i.chaoxing.com redirected to passport2 login' };
|
||||
}
|
||||
const userIdCookie = (document.cookie.split('; ').find(c => /^(_uid|UID)=/.test(c)) || '').split('=')[1] || '';
|
||||
let userName = '';
|
||||
const unameCookie = (document.cookie.split('; ').find(c => /^uname=/.test(c)) || '').split('=')[1] || '';
|
||||
if (unameCookie) {
|
||||
try { userName = decodeURIComponent(unameCookie); } catch { userName = unameCookie; }
|
||||
}
|
||||
if (!userName) {
|
||||
const el = document.querySelector('.userTitle, .myInfo, .user-name, [class*=userName]');
|
||||
userName = (el?.innerText || '').trim();
|
||||
}
|
||||
if (!userIdCookie && !userName) {
|
||||
return { kind: 'auth', detail: 'Chaoxing i.chaoxing.com no user identity surface — anonymous' };
|
||||
}
|
||||
return { ok: true, user_id: userIdCookie, name: userName };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('chaoxing.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Chaoxing probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'chaoxing',
|
||||
domain: 'chaoxing.com',
|
||||
loginUrl: 'https://passport2.chaoxing.com/login?fid=&newversion=true&refer=https%3A%2F%2Fi.chaoxing.com',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: hasChaoxingSessionCookie,
|
||||
verify: verifyChaoxingIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasChaoxingSessionCookie(page)) {
|
||||
throw new AuthRequiredError('chaoxing.com', 'Waiting for Chaoxing session cookies');
|
||||
}
|
||||
return verifyChaoxingIdentity(page);
|
||||
},
|
||||
});
|
||||
+19
-8
@@ -1,6 +1,6 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { statSync } from 'node:fs';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, ConfigError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, ConfigError, TimeoutError } from '@jackwener/opencli/errors';
|
||||
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating, sendPrompt } from './ax.js';
|
||||
export const askCommand = cli({
|
||||
site: 'chatgpt-app',
|
||||
@@ -14,6 +14,7 @@ export const askCommand = cli({
|
||||
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
|
||||
{ name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES },
|
||||
{ name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for response (default: 30)', default: 30 },
|
||||
{ name: 'image', required: false, help: 'Path to local image to attach (optional)' },
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (kwargs) => {
|
||||
@@ -23,6 +24,19 @@ export const askCommand = cli({
|
||||
const text = kwargs.text;
|
||||
const model = kwargs.model;
|
||||
const timeout = kwargs.timeout;
|
||||
const image = kwargs.image;
|
||||
if (image) {
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(image);
|
||||
}
|
||||
catch {
|
||||
throw new ArgumentError(`The specified image path does not exist: ${image}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new ArgumentError(`The specified image path is not a file: ${image}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(timeout) || timeout < 1) {
|
||||
throw new ArgumentError('--timeout must be a positive integer (seconds)');
|
||||
}
|
||||
@@ -34,7 +48,7 @@ export const askCommand = cli({
|
||||
const messagesBefore = getVisibleChatMessages();
|
||||
// Send the message
|
||||
activateChatGPT();
|
||||
sendPrompt(text);
|
||||
sendPrompt(text, image);
|
||||
// Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears),
|
||||
// then read the final response text.
|
||||
const pollInterval = 2;
|
||||
@@ -42,7 +56,7 @@ export const askCommand = cli({
|
||||
let response = '';
|
||||
let generationStarted = false;
|
||||
for (let i = 0; i < maxPolls; i++) {
|
||||
execSync(`sleep ${pollInterval}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));
|
||||
const generating = isGenerating();
|
||||
if (generating) {
|
||||
generationStarted = true;
|
||||
@@ -63,10 +77,7 @@ export const askCommand = cli({
|
||||
break;
|
||||
}
|
||||
if (!response) {
|
||||
return [
|
||||
{ Role: 'User', Text: text },
|
||||
{ Role: 'System', Text: `No response within ${timeout}s. ChatGPT may still be generating.` },
|
||||
];
|
||||
throw new TimeoutError('chatgpt-app/ask', timeout, 'ChatGPT may still be generating; rerun read or increase --timeout');
|
||||
}
|
||||
return [
|
||||
{ Role: 'User', Text: text },
|
||||
|
||||
+245
-27
@@ -40,8 +40,17 @@ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "
|
||||
}
|
||||
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
|
||||
fputs("No focused ChatGPT window\\n", stderr)
|
||||
var targetWin: AXUIElement? = nil
|
||||
if let focused = attr(axApp, kAXFocusedWindowAttribute as String) {
|
||||
targetWin = (focused as! AXUIElement)
|
||||
}
|
||||
if targetWin == nil {
|
||||
if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty {
|
||||
targetWin = windows.first
|
||||
}
|
||||
}
|
||||
guard let win = targetWin else {
|
||||
fputs("Could not find or focus any ChatGPT window\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@@ -98,10 +107,11 @@ func isInput(_ el: AXUIElement) -> Bool {
|
||||
}
|
||||
|
||||
func focusedInput(_ axApp: AXUIElement) -> AXUIElement? {
|
||||
guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else {
|
||||
guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) else {
|
||||
return nil
|
||||
}
|
||||
return isInput(focused) && isEnabled(focused) ? focused : nil
|
||||
let focusedEl = focused as! AXUIElement
|
||||
return isInput(focusedEl) && isEnabled(focusedEl) ? focusedEl : nil
|
||||
}
|
||||
|
||||
func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? {
|
||||
@@ -115,6 +125,24 @@ func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func attachmentEvidenceCount(_ el: AXUIElement, fileName: String, depth: Int = 0) -> Int {
|
||||
guard depth < 25 else { return 0 }
|
||||
let role = s(el, kAXRoleAttribute as String) ?? ""
|
||||
let desc = s(el, kAXDescriptionAttribute as String) ?? ""
|
||||
let title = s(el, kAXTitleAttribute as String) ?? ""
|
||||
let value = s(el, kAXValueAttribute as String) ?? ""
|
||||
let help = s(el, kAXHelpAttribute as String) ?? ""
|
||||
let haystack = [desc, title, value, help].joined(separator: " ")
|
||||
var count = role == kAXImageRole as String ? 1 : 0
|
||||
if !fileName.isEmpty && haystack.localizedCaseInsensitiveContains(fileName) {
|
||||
count += 1
|
||||
}
|
||||
for c in children(el) {
|
||||
count += attachmentEvidenceCount(c, fileName: fileName, depth: depth + 1)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func press(_ el: AXUIElement) {
|
||||
AXUIElementPerformAction(el, kAXPressAction as CFString)
|
||||
}
|
||||
@@ -125,6 +153,7 @@ guard args.count > 1 else {
|
||||
exit(1)
|
||||
}
|
||||
let text = args[1]
|
||||
let imagePath = args.count > 2 ? args[2] : ""
|
||||
|
||||
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
|
||||
fputs("ChatGPT not running\\n", stderr)
|
||||
@@ -132,8 +161,17 @@ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "
|
||||
}
|
||||
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
|
||||
fputs("No focused ChatGPT window\\n", stderr)
|
||||
var targetWin: AXUIElement? = nil
|
||||
if let focused = attr(axApp, kAXFocusedWindowAttribute as String) {
|
||||
targetWin = (focused as! AXUIElement)
|
||||
}
|
||||
if targetWin == nil {
|
||||
if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty {
|
||||
targetWin = windows.first
|
||||
}
|
||||
}
|
||||
guard let win = targetWin else {
|
||||
fputs("Could not find or focus any ChatGPT window\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@@ -156,6 +194,78 @@ guard s(input, kAXValueAttribute as String) == text else {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if !imagePath.isEmpty {
|
||||
guard let image = NSImage(contentsOfFile: imagePath) else {
|
||||
fputs("Failed to load image from path: \(imagePath)\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let fileName = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let attachmentCountBefore = attachmentEvidenceCount(win, fileName: fileName)
|
||||
|
||||
// Safeguard Clipboard: Backup existing clipboard items
|
||||
let pasteboard = NSPasteboard.general
|
||||
var savedItems: [NSPasteboardItem] = []
|
||||
if let items = pasteboard.pasteboardItems {
|
||||
for item in items {
|
||||
let savedItem = NSPasteboardItem()
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
savedItem.setData(data, forType: type)
|
||||
}
|
||||
}
|
||||
savedItems.append(savedItem)
|
||||
}
|
||||
}
|
||||
func restorePasteboard() {
|
||||
pasteboard.clearContents()
|
||||
if !savedItems.isEmpty {
|
||||
pasteboard.writeObjects(savedItems)
|
||||
}
|
||||
}
|
||||
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([image])
|
||||
|
||||
AXUIElementSetAttributeValue(input, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
|
||||
// Simulate paste command targeted directly to ChatGPT's PID to prevent global interference
|
||||
let src = CGEventSource(stateID: .hidSystemState)
|
||||
let cmdDown = CGEvent(keyboardEventSource: src, virtualKey: 0x37, keyDown: true)
|
||||
cmdDown?.flags = .maskCommand
|
||||
cmdDown?.postToPid(app.processIdentifier)
|
||||
|
||||
let vDown = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true)
|
||||
vDown?.flags = .maskCommand
|
||||
vDown?.postToPid(app.processIdentifier)
|
||||
|
||||
let vUp = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false)
|
||||
vUp?.flags = .maskCommand
|
||||
vUp?.postToPid(app.processIdentifier)
|
||||
|
||||
let cmdUp = CGEvent(keyboardEventSource: src, virtualKey: 0x37, keyDown: false)
|
||||
cmdUp?.postToPid(app.processIdentifier)
|
||||
|
||||
var attachmentReady = false
|
||||
for _ in 0..<80 {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
if attachmentEvidenceCount(win, fileName: fileName) > attachmentCountBefore {
|
||||
attachmentReady = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Safeguard Clipboard: Restore user clipboard content after the paste flow.
|
||||
restorePasteboard()
|
||||
|
||||
guard attachmentReady else {
|
||||
fputs("Image attachment did not appear in ChatGPT before send\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let valueBeforeSend = s(input, kAXValueAttribute as String) ?? ""
|
||||
|
||||
guard let sendButton = findByDescriptions(win, ["发送", "傳送", "Send"]) else {
|
||||
fputs("Could not find send button\\n", stderr)
|
||||
exit(1)
|
||||
@@ -166,7 +276,7 @@ press(sendButton)
|
||||
var submitted = false
|
||||
for _ in 0..<15 {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
if s(input, kAXValueAttribute as String) != text {
|
||||
if (s(input, kAXValueAttribute as String) ?? "") != valueBeforeSend {
|
||||
submitted = true
|
||||
break
|
||||
}
|
||||
@@ -228,12 +338,30 @@ func pressEscape() {
|
||||
if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: false) { esc.post(tap: .cghidEventTap) }
|
||||
}
|
||||
|
||||
func waitForElement(timeout: TimeInterval = 1.2, check: () -> AXUIElement?) -> AXUIElement? {
|
||||
let start = Date()
|
||||
while Date().timeIntervalSince(start) < timeout {
|
||||
if let el = check() { return el }
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 targetWin: AXUIElement? = nil
|
||||
if let focused = attr(axApp, kAXFocusedWindowAttribute as String) {
|
||||
targetWin = (focused as! AXUIElement)
|
||||
}
|
||||
if targetWin == nil {
|
||||
if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty {
|
||||
targetWin = windows.first
|
||||
}
|
||||
}
|
||||
guard let win = targetWin else {
|
||||
fputs("Could not find or focus any ChatGPT window\\n", stderr); exit(1)
|
||||
}
|
||||
|
||||
let args = CommandLine.arguments
|
||||
@@ -242,38 +370,46 @@ let needsLegacy = args.count > 2 && args[2] == "legacy"
|
||||
|
||||
// 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 }
|
||||
for label in ["Options", "选项", "選項"] {
|
||||
if let btn = findByDesc(win, label) {
|
||||
optionsBtn = btn
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let options = optionsBtn else {
|
||||
fputs("Could not find Options button\\n", stderr); exit(1)
|
||||
}
|
||||
press(options)
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
|
||||
// Step 2: Find the popover that appeared, search ONLY within it
|
||||
guard let popover = findPopover(win) else {
|
||||
// Step 2: Find the popover that appeared, search ONLY within it (utilizing dynamic polling helper)
|
||||
guard let popover = waitForElement(check: { findPopover(win) }) else {
|
||||
pressEscape()
|
||||
fputs("Popover did not appear\\n", stderr); exit(1)
|
||||
}
|
||||
|
||||
// Step 3: If legacy, click "Legacy models" to expand submenu
|
||||
// Step 3: If legacy, click "Legacy models" to expand submenu (supports EN/CN/TW localizations)
|
||||
if needsLegacy {
|
||||
guard let legacyBtn = findByDesc(popover, "Legacy models") else {
|
||||
var legacyBtn: AXUIElement? = nil
|
||||
for label in ["Legacy models", "经典模型", "經典模型"] {
|
||||
if let btn = findByDesc(popover, label) {
|
||||
legacyBtn = btn
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let btn = legacyBtn else {
|
||||
pressEscape()
|
||||
fputs("Could not find Legacy models button\\n", stderr); exit(1)
|
||||
}
|
||||
press(legacyBtn)
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
press(btn)
|
||||
}
|
||||
|
||||
// Step 4: Click the target model button within the popover (prefix match)
|
||||
guard let modelBtn = findByDesc(popover, target, prefix: true) else {
|
||||
// Step 4: Click the target model button within the popover (prefix match via dynamic polling helper)
|
||||
guard let modelBtn = waitForElement(check: { findByDesc(popover, target, prefix: true) }) else {
|
||||
pressEscape()
|
||||
fputs("Could not find button starting with '\\(target)' in popover\\n", stderr); exit(1)
|
||||
fputs("Could not find button starting with '\(target)'\\n", stderr); exit(1)
|
||||
}
|
||||
press(modelBtn)
|
||||
print("Selected: \\(target)")
|
||||
print("Selected: \(target)")
|
||||
`;
|
||||
const AX_GENERATING_SCRIPT = `
|
||||
import Cocoa
|
||||
@@ -309,12 +445,76 @@ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "
|
||||
print("false"); exit(0)
|
||||
}
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
|
||||
var targetWin: AXUIElement? = nil
|
||||
if let focused = attr(axApp, kAXFocusedWindowAttribute as String) {
|
||||
targetWin = (focused as! AXUIElement)
|
||||
}
|
||||
if targetWin == nil {
|
||||
if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty {
|
||||
targetWin = windows.first
|
||||
}
|
||||
}
|
||||
guard let win = targetWin else {
|
||||
print("false"); exit(0)
|
||||
}
|
||||
let targets = ["Stop generating", "停止生成"]
|
||||
let targets = ["Stop generating", "停止生成", "停止產生", "停止傳送"]
|
||||
print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
|
||||
`;
|
||||
const AX_TEMPORARY_CHAT_SCRIPT = `
|
||||
import Cocoa
|
||||
import ApplicationServices
|
||||
|
||||
func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
|
||||
return value as AnyObject?
|
||||
}
|
||||
|
||||
func s(_ el: AXUIElement, _ name: String) -> String? {
|
||||
if let v = attr(el, name) as? String, !v.isEmpty { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
func children(_ el: AXUIElement) -> [AXUIElement] {
|
||||
(attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
|
||||
}
|
||||
|
||||
func hasTemporaryChatText(_ el: AXUIElement, depth: Int = 0) -> Bool {
|
||||
guard depth < 25 else { return false }
|
||||
let haystack = [
|
||||
s(el, kAXDescriptionAttribute as String) ?? "",
|
||||
s(el, kAXTitleAttribute as String) ?? "",
|
||||
s(el, kAXValueAttribute as String) ?? "",
|
||||
s(el, kAXHelpAttribute as String) ?? "",
|
||||
].joined(separator: " ")
|
||||
let labels = ["Temporary Chat", "临时聊天", "臨時聊天", "临时对话", "臨時對話"]
|
||||
if labels.contains(where: { haystack.localizedCaseInsensitiveContains($0) }) {
|
||||
return true
|
||||
}
|
||||
for c in children(el) {
|
||||
if hasTemporaryChatText(c, depth: depth + 1) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
|
||||
print("false"); exit(0)
|
||||
}
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
var targetWin: AXUIElement? = nil
|
||||
if let focused = attr(axApp, kAXFocusedWindowAttribute as String) {
|
||||
targetWin = (focused as! AXUIElement)
|
||||
}
|
||||
if targetWin == nil {
|
||||
if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty {
|
||||
targetWin = windows.first
|
||||
}
|
||||
}
|
||||
guard let win = targetWin else {
|
||||
print("false"); exit(0)
|
||||
}
|
||||
print(hasTemporaryChatText(win) ? "true" : "false")
|
||||
`;
|
||||
const MODEL_MAP = {
|
||||
'auto': { desc: 'Auto' },
|
||||
'instant': { desc: 'Instant' },
|
||||
@@ -342,8 +542,12 @@ export function selectModel(model) {
|
||||
}).trim();
|
||||
return output;
|
||||
}
|
||||
export function sendPrompt(text) {
|
||||
return execFileSync('swift', ['-', text], {
|
||||
export function sendPrompt(text, imagePath = '') {
|
||||
const args = ['-', text];
|
||||
if (imagePath) {
|
||||
args.push(imagePath);
|
||||
}
|
||||
return execFileSync('swift', args, {
|
||||
input: AX_SEND_SCRIPT,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
@@ -362,6 +566,19 @@ export function isGenerating() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function isTemporaryChatVisible() {
|
||||
try {
|
||||
const output = execFileSync('swift', ['-'], {
|
||||
input: AX_TEMPORARY_CHAT_SCRIPT,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).trim();
|
||||
return output === 'true';
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function getVisibleChatMessages() {
|
||||
const output = execFileSync('swift', ['-'], {
|
||||
input: AX_READ_SCRIPT,
|
||||
@@ -382,4 +599,5 @@ export const __test__ = {
|
||||
AX_SEND_SCRIPT,
|
||||
AX_MODEL_SCRIPT,
|
||||
AX_GENERATING_SCRIPT,
|
||||
AX_TEMPORARY_CHAT_SCRIPT,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user