Compare commits
279 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2dd8c2701 | |||
| 2929dfa2c0 | |||
| 2beaf83d62 | |||
| 9057441221 | |||
| 31d80af07d | |||
| c0c8e60b25 | |||
| adddc5733a | |||
| 7145d8b5d0 | |||
| 240930fc8c | |||
| bd4c1e39e1 | |||
| ae86f7f5ff | |||
| 1d3c97e477 | |||
| 80d5d3d6c9 | |||
| 07aee7cd9f | |||
| 14d4665f5d | |||
| a34705a67f | |||
| 70890c7e26 | |||
| 451cd0276c | |||
| ff1d59ffec | |||
| 9846c59f1c | |||
| c45105d6d1 | |||
| f909f1e27f | |||
| 078984204c | |||
| 60a94cab27 | |||
| aa0e6b0743 | |||
| 083f78acf5 | |||
| da333c99a7 | |||
| b173b176aa | |||
| ca25f148d6 | |||
| d981c8b1f3 | |||
| 7323deb885 | |||
| fea093fe05 | |||
| 69454ae9e4 | |||
| 5d6594f12e | |||
| 5c0aa36a60 | |||
| db2718fc13 | |||
| 42755b9e93 | |||
| 3c337d7385 | |||
| 124602d437 | |||
| 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 |
@@ -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)"
|
||||
@@ -34,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
|
||||
@@ -49,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
|
||||
|
||||
@@ -59,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'
|
||||
@@ -83,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
|
||||
|
||||
+149
-4
@@ -1,13 +1,154 @@
|
||||
# Changelog
|
||||
|
||||
## [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.
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **skills** — remove the `smart-search` skill. Use `opencli-usage` for command/site reference, `opencli-browser` for ad-hoc browser operation, and `opencli-adapter-author` for writing new adapters.
|
||||
|
||||
### 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.
|
||||
@@ -270,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,
|
||||
|
||||
@@ -15,13 +15,23 @@ OpenCLI gives you one surface for three different kinds of automation:
|
||||
- **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`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT.
|
||||
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
|
||||
@@ -54,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.
|
||||
@@ -104,6 +114,8 @@ 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
|
||||
```
|
||||
|
||||
@@ -114,6 +126,8 @@ npx skills add jackwener/opencli --skill opencli-usage
|
||||
| **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** | 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?" |
|
||||
|
||||
### How it works
|
||||
@@ -130,6 +144,8 @@ The agent handles all the `opencli browser` commands internally — you just des
|
||||
|
||||
**Skill references:**
|
||||
- [`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-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
|
||||
@@ -149,22 +165,13 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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_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`) |
|
||||
@@ -173,50 +180,39 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
||||
|
||||
`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.
|
||||
|
||||
## 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` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `summary` `video` `user-videos` |
|
||||
| **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` |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
|
||||
| **linkedin** | `connect` `inbox` `safe-send` `search` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
|
||||
| **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-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` |
|
||||
| **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` |
|
||||
|
||||
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 / and more).
|
||||
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
|
||||
|
||||
Unified passthrough for your existing command-line tools. Run `opencli <tool> ...` for any of:
|
||||
|
||||
`gh` · `docker` · `vercel` · `wrangler` · `ntn` · `obsidian` · `longbridge` · `lark-cli` · `dws` · `wecom-cli` · `tg` · `discord` · `wx`
|
||||
`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 with `opencli external register <name>`; list everything with `opencli external list`.
|
||||
|
||||
**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — see [`docs/adapters/desktop/`](./docs/adapters/desktop/).
|
||||
**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
|
||||
|
||||
@@ -262,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
|
||||
|
||||
@@ -299,6 +277,7 @@ 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.
|
||||
|
||||
@@ -311,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
|
||||
|
||||
+36
-59
@@ -15,13 +15,22 @@ OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
- **让 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`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT 等 Electron 应用。
|
||||
除了网站能力,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
|
||||
@@ -91,6 +100,8 @@ 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
|
||||
```
|
||||
|
||||
@@ -101,6 +112,8 @@ npx skills add jackwener/opencli --skill opencli-usage
|
||||
| **opencli-adapter-author** | 为新站点写可复用适配器,或给已有站点添加命令 | "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
|
||||
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
|
||||
| **opencli-browser** | 实时驱动 Chrome 页面——导航、填表单、点击、抓取 | "帮我看看小红书的通知" / "帮我填一下这个表单" / "用浏览器命令抓取这个页面" |
|
||||
| **opencli-browser-sitemap** | 使用站点 sitemap 上下文来操作浏览器任务 | "用 sitemap 帮我少走弯路地操作这个网站" |
|
||||
| **opencli-sitemap-author** | 创建或更新面向浏览器 Agent 的站点 sitemap | "把刚发现的稳定流程记录到这个站点的 sitemap" |
|
||||
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
|
||||
|
||||
### 工作原理
|
||||
@@ -117,6 +130,8 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
|
||||
**Skill 参考文档:**
|
||||
- [`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-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
|
||||
@@ -136,75 +151,57 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
5. `opencli browser recon analyze <url>` → `opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
|
||||
6. 站点知识沉到 `~/.opencli/sites/<site>/`,下次同站点直接吃缓存
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
|
||||
- **Bun**: >= 1.0(可选替代运行时)
|
||||
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
|
||||
|
||||
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
|
||||
|
||||
## 配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
|
||||
| `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground` 或 `background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window <foreground\|background>` |
|
||||
| `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
|
||||
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` 查看完整注册表。
|
||||
|
||||
| 站点 | 命令 |
|
||||
|------|------|
|
||||
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `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` `user-videos` `download` |
|
||||
| **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` |
|
||||
| **linkedin** | `connect` `inbox` `safe-send` `search` `people-search` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
|
||||
| **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-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` |
|
||||
| **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)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Bilibili / 等)。
|
||||
精选清单 — **[→ 查看全部 100+ 站点和命令](./docs/adapters/index.md)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Chess.com / Bilibili / 等)。
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
把现有命令行工具统一接入 `opencli <tool> ...`:
|
||||
|
||||
`gh` · `docker` · `vercel` · `wrangler` · `ntn` · `obsidian` · `longbridge` · `lark-cli` · `dws` · `wecom-cli` · `tg` · `discord` · `wx`
|
||||
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
|
||||
|
||||
注册自定义本地 CLI:`opencli external register <name>`;查看所有:`opencli external list`。
|
||||
|
||||
**桌面应用适配器**(Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
|
||||
**桌面应用适配器**(Electron,通过 CDP):Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
|
||||
|
||||
## 下载支持
|
||||
|
||||
@@ -296,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)。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -339,6 +315,7 @@ 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) 了解如何创建自己的插件。
|
||||
|
||||
@@ -351,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();
|
||||
}
|
||||
|
||||
+17131
-86
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);
|
||||
},
|
||||
});
|
||||
@@ -7,20 +7,12 @@
|
||||
* `--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';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { isAuthLikePayload, maskChineseName, normalizeLimit, 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',
|
||||
@@ -86,5 +78,3 @@ cli({
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { normalizeLimit };
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwen
|
||||
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-Z]{8,18}$/;
|
||||
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
|
||||
const SEAT_TYPES_RE = /^[A-Z0-9]{1,32}$/;
|
||||
|
||||
const SEAT_LETTERS = {
|
||||
@@ -163,4 +163,4 @@ cli({
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS };
|
||||
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS, TRAIN_NO_RE };
|
||||
|
||||
+1
-15
@@ -6,22 +6,10 @@
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchStationBundle } from './utils.js';
|
||||
import { fetchStationBundle, normalizeLimit } 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',
|
||||
@@ -62,5 +50,3 @@ cli({
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { normalizeLimit };
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwen
|
||||
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-Z]{8,18}$/;
|
||||
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}`;
|
||||
|
||||
+51
-16
@@ -11,22 +11,45 @@
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchStationBundle, mintSession, resolveStation, validateDate, parseTrainRecord } from './utils.js';
|
||||
import { fetchStationBundle, mintSession, normalizeLimit, 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})`);
|
||||
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] : '';
|
||||
}
|
||||
if (n > max) {
|
||||
throw new ArgumentError(`limit must be <= ${max}`);
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -37,11 +60,23 @@ async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
|
||||
};
|
||||
const queryParams = `leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromCode}&leftTicketDTO.to_station=${toCode}&purpose_codes=ADULT`;
|
||||
let lastResponseText = '';
|
||||
for (const endpoint of QUERY_ENDPOINTS) {
|
||||
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 });
|
||||
const resp = await fetch(url, { headers, redirect: 'manual' });
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 302) continue;
|
||||
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();
|
||||
@@ -51,9 +86,9 @@ async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
|
||||
throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
|
||||
}
|
||||
if (json?.c_url && typeof json.c_url === 'string') {
|
||||
const rotated = json.c_url.replace('leftTicket/', '').trim();
|
||||
if (rotated && !QUERY_ENDPOINTS.includes(rotated)) {
|
||||
QUERY_ENDPOINTS.unshift(rotated);
|
||||
const rotated = await parseRotationEndpoint(resp, endpoint, text);
|
||||
if (rotated && !tried.has(rotated)) {
|
||||
queue.unshift(rotated);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -116,4 +151,4 @@ cli({
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { normalizeLimit, queryLeftTickets };
|
||||
export const __test__ = { extractQueryEndpoint, queryLeftTickets };
|
||||
|
||||
@@ -101,6 +101,18 @@ export function validateDate(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
|
||||
export function buildCookieHeader(setCookieHeaders) {
|
||||
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
|
||||
|
||||
+125
-4
@@ -1,14 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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__, normalizeLimit } 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 } = priceTest;
|
||||
const { queryStops } = trainTest;
|
||||
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', () => {
|
||||
@@ -86,6 +92,34 @@ describe('12306 utils - validateDate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - normalizeLimit', () => {
|
||||
it('uses the default for omitted, null, and empty values', () => {
|
||||
expect(normalizeLimit(undefined, 20, 50)).toBe(20);
|
||||
expect(normalizeLimit(null, 30, 80)).toBe(30);
|
||||
expect(normalizeLimit('', 50, 100)).toBe(50);
|
||||
});
|
||||
|
||||
it('accepts numeric values and numeric strings at legal boundaries', () => {
|
||||
expect(normalizeLimit(1, 20, 50)).toBe(1);
|
||||
expect(normalizeLimit('25', 20, 50)).toBe(25);
|
||||
expect(normalizeLimit(50, 20, 50)).toBe(50);
|
||||
});
|
||||
|
||||
it('rejects non-integer and non-positive values with the positive integer message', () => {
|
||||
expect(() => normalizeLimit('abc', 20, 50)).toThrow(ArgumentError);
|
||||
expect(() => normalizeLimit('abc', 20, 50)).toThrow('limit must be a positive integer (1-50)');
|
||||
expect(() => normalizeLimit(1.5, 20, 50)).toThrow('limit must be a positive integer (1-50)');
|
||||
expect(() => normalizeLimit(0, 20, 50)).toThrow('limit must be a positive integer (1-50)');
|
||||
expect(() => normalizeLimit(-1, 20, 50)).toThrow('limit must be a positive integer (1-50)');
|
||||
});
|
||||
|
||||
it('rejects values over the command max with the max message', () => {
|
||||
expect(() => normalizeLimit(51, 20, 50)).toThrow(ArgumentError);
|
||||
expect(() => normalizeLimit(51, 20, 50)).toThrow('limit must be <= 50');
|
||||
expect(() => normalizeLimit('101', 50, 100)).toThrow('limit must be <= 100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12306 utils - buildCookieHeader', () => {
|
||||
it('joins set-cookie lines into a single Cookie header', () => {
|
||||
const headers = [
|
||||
@@ -229,6 +263,30 @@ describe('12306 price - parsePriceData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -250,6 +308,69 @@ describe('12306 public API typed boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
+85
-3
@@ -1,5 +1,28 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { assertAuthenticatedState, buildDetailUrl, buildProvenance, cleanText, extractOfferId, gotoAndReadState, uniqueMediaSources, } from './shared.js';
|
||||
// 1688 商品详情区位于自定义元素 v-detail-e 的 shadow DOM 内(懒渲染),
|
||||
// 普通 CSS selector 无法穿透 shadowRoot,需沿 shadow host 链判断归属。
|
||||
export const DETAIL_CONTAINER_SELECTOR = '.de-description-detail, #detailContentContainer, .html-description, .desc-lazyload-container';
|
||||
export function inDetailContainer(el, selector = DETAIL_CONTAINER_SELECTOR) {
|
||||
let node = el;
|
||||
while (node) {
|
||||
// Check ancestors within the current root first: a detail container can
|
||||
// be a plain element inside a shadow root, not only the host itself.
|
||||
if (node.closest && node.closest(selector))
|
||||
return true;
|
||||
const rootNode = node.getRootNode ? node.getRootNode() : null;
|
||||
if (rootNode && rootNode.host) {
|
||||
const host = rootNode.host;
|
||||
if (host && host.matches && host.matches(selector))
|
||||
return true;
|
||||
node = host;
|
||||
}
|
||||
else {
|
||||
node = null;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function scriptToReadAssets() {
|
||||
return `
|
||||
(() => {
|
||||
@@ -11,8 +34,12 @@ function scriptToReadAssets() {
|
||||
{ key: 'main', type: 'image', selectors: ['#dt-tab img', '.detail-gallery-turn img.detail-gallery-img', '.img-list-wrapper img.od-gallery-img', '.od-scroller-item span'] },
|
||||
{ key: 'video', type: 'video', selectors: ['.lib-video video', 'video[src]', 'video source[src]'] },
|
||||
{ key: 'sku', type: 'image', selectors: ['.pc-sku-wrapper .prop-item-inner-wrapper', '.sku-item-wrapper', '.specification-cell', '.sku-filter-button', '.expand-view-item', '.feature-item img'], srcProps: ['backgroundImage'] },
|
||||
{ key: 'detail', type: 'image', selectors: ['.de-description-detail img', '#detailContentContainer img', '.html-description img', '.html-description source', '.desc-lazyload-container img'] },
|
||||
];
|
||||
const detailContainerSelector = ${JSON.stringify(DETAIL_CONTAINER_SELECTOR)};
|
||||
// Inject the module-level implementation rather than hand-copying it, so
|
||||
// the unit tests exercise the same code that runs in the page.
|
||||
const inDetailContainerImpl = ${inDetailContainer.toString()};
|
||||
const inDetailContainer = (el) => inDetailContainerImpl(el, detailContainerSelector);
|
||||
const assets = [];
|
||||
const seen = new Set();
|
||||
|
||||
@@ -108,6 +135,14 @@ function scriptToReadAssets() {
|
||||
}
|
||||
}
|
||||
|
||||
// 详情区素材:全量收集 img/source(穿透 shadowRoot)+ host 链归属判断
|
||||
for (const element of [...queryAllDeep('img'), ...queryAllDeep('source')]) {
|
||||
if (!inDetailContainer(element)) continue;
|
||||
for (const value of valuesFromElement(element)) {
|
||||
push('image', 'detail', value, 'shadow:html-description');
|
||||
}
|
||||
}
|
||||
|
||||
const scriptTexts = Array.from(document.scripts).map((script) => script.textContent || '');
|
||||
const videoRegex = /https?:\\/\\/[^"'\\s]+\\.(?:mp4|m3u8)(?:\\?[^"'\\s]*)?/gi;
|
||||
for (const scriptText of scriptTexts) {
|
||||
@@ -171,10 +206,55 @@ function normalizeAssets(payload) {
|
||||
async function readAssetsPayload(page, itemUrl) {
|
||||
const state = await gotoAndReadState(page, itemUrl, 2500, 'assets');
|
||||
assertAuthenticatedState(state, 'assets');
|
||||
await page.autoScroll({ times: 3, delayMs: 400 });
|
||||
await page.wait(1);
|
||||
// The detail section renders lazily inside a shadow root. Scroll once to the
|
||||
// bottom to trigger it, bring the container into view, then poll until the
|
||||
// deep detail-image count stops growing. autoScroll keeps no state across
|
||||
// calls, so calling it twice was identical to one longer call, and a fixed
|
||||
// page.wait(3) paid the full cost on every invocation even when the content
|
||||
// was already there.
|
||||
await page.autoScroll({ times: 6, delayMs: 500 });
|
||||
await page.evaluate(`(() => {
|
||||
const el = document.querySelector('.html-description, v-detail-e, .de-description-detail, #detailContentContainer');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
})()`);
|
||||
await waitForDetailImages(page);
|
||||
return await page.evaluate(scriptToReadAssets());
|
||||
}
|
||||
/**
|
||||
* Poll until the detail-image count is stable across two reads (or the cap is
|
||||
* reached). Returns as soon as the content settles instead of always sleeping.
|
||||
*/
|
||||
async function waitForDetailImages(page, { attempts = 10, intervalSeconds = 0.5 } = {}) {
|
||||
const countJs = `(() => {
|
||||
const sel = ${JSON.stringify(DETAIL_CONTAINER_SELECTOR)};
|
||||
let total = 0;
|
||||
const walk = (root) => {
|
||||
for (const node of root.querySelectorAll('*')) {
|
||||
if (node.shadowRoot) walk(node.shadowRoot);
|
||||
}
|
||||
for (const host of root.querySelectorAll(sel)) {
|
||||
total += host.querySelectorAll('img, source').length;
|
||||
if (host.shadowRoot) total += host.shadowRoot.querySelectorAll('img, source').length;
|
||||
}
|
||||
};
|
||||
walk(document);
|
||||
return total;
|
||||
})()`;
|
||||
let previous = -1;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
let current = 0;
|
||||
try {
|
||||
current = Number(await page.evaluate(countJs)) || 0;
|
||||
}
|
||||
catch {
|
||||
return; // Reading the count is best-effort; fall through to extraction.
|
||||
}
|
||||
if (current > 0 && current === previous)
|
||||
return;
|
||||
previous = current;
|
||||
await page.wait(intervalSeconds);
|
||||
}
|
||||
}
|
||||
export async function extractAssetsForInput(page, input) {
|
||||
const itemUrl = buildDetailUrl(String(input ?? ''));
|
||||
const payload = await readAssetsPayload(page, itemUrl);
|
||||
@@ -202,4 +282,6 @@ cli({
|
||||
});
|
||||
export const __test__ = {
|
||||
normalizeAssets,
|
||||
inDetailContainer,
|
||||
DETAIL_CONTAINER_SELECTOR,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { __test__ } from './assets.js';
|
||||
import { __test__ as sharedTest } from './shared.js';
|
||||
|
||||
function makeDetailHostDom() {
|
||||
// 模拟 1688 详情页:详情图片位于 v-detail-e(class=html-description)
|
||||
// 的 shadow DOM 内,普通 CSS selector 无法穿透 shadowRoot。
|
||||
const dom = new JSDOM(
|
||||
`<html><body>
|
||||
<div class="detail-gallery-turn"><img src="https://img.example.com/main-1.jpg"></div>
|
||||
<v-detail-e class="html-description"></v-detail-e>
|
||||
<div class="de-description-detail"><img src="https://img.example.com/light-1.jpg"></div>
|
||||
</body></html>`,
|
||||
{ url: 'https://detail.1688.com/offer/887904326744.html' },
|
||||
);
|
||||
const { window } = dom;
|
||||
const host = window.document.querySelector('v-detail-e');
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = `
|
||||
<img src="https://img.example.com/detail-1.jpg">
|
||||
<img data-lazyload-src="https://img.example.com/detail-2.jpg">
|
||||
<img src="https://img.example.com/detail-3.jpg">
|
||||
`;
|
||||
return { window, host, shadow };
|
||||
}
|
||||
|
||||
describe('1688 assets shadow-DOM detail container detection', () => {
|
||||
it('detects images inside the v-detail-e shadow root as detail assets', () => {
|
||||
const { window, shadow } = makeDetailHostDom();
|
||||
const shadowImgs = [...shadow.querySelectorAll('img, source')];
|
||||
expect(shadowImgs.length).toBe(3);
|
||||
for (const el of shadowImgs) {
|
||||
expect(__test__.inDetailContainer(el)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match light-DOM main gallery images', () => {
|
||||
const { window } = makeDetailHostDom();
|
||||
const mainImg = window.document.querySelector('.detail-gallery-turn img');
|
||||
expect(__test__.inDetailContainer(mainImg)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches light-DOM detail containers that use plain classes', () => {
|
||||
const { window } = makeDetailHostDom();
|
||||
const lightDetail = window.document.querySelector('.de-description-detail img');
|
||||
expect(__test__.inDetailContainer(lightDetail)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Restored from main: this PR originally replaced these two rather than adding
|
||||
// alongside them, which silently dropped all coverage of normalizeAssets and
|
||||
// normalizeMediaUrl.
|
||||
describe('1688 assets normalization', () => {
|
||||
it('normalizes gallery and scanned assets into grouped media lists', () => {
|
||||
const result = __test__.normalizeAssets({
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,88 @@
|
||||
/**
|
||||
* 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 { parseSpaceMidUrl, fetchRelationAttribute, waitForRelation } from './relation.js';
|
||||
import { apiPost, getSelfUid, requireOkPayload, resolveUid } from './utils.js';
|
||||
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
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';
|
||||
|
||||
async function expectRejectsWithMessage(promise, type, message) {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(type);
|
||||
expect(error.message).toBe(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Expected rejection with message: ${message}`);
|
||||
}
|
||||
|
||||
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 expectRejectsWithMessage(
|
||||
command.func({}, { target: ' ' }),
|
||||
ArgumentError,
|
||||
'bilibili follow target cannot be empty',
|
||||
);
|
||||
expect(mockGetSelfUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed Bilibili profile URLs instead of searching the whole URL', async () => {
|
||||
await expectRejectsWithMessage(
|
||||
command.func({}, { target: 'https://space.bilibili.com/not-a-uid' }),
|
||||
ArgumentError,
|
||||
'bilibili follow target must be a valid space.bilibili.com/<uid> URL',
|
||||
);
|
||||
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/);
|
||||
});
|
||||
|
||||
it('rejects an empty target with unfollow-specific text before touching the API', async () => {
|
||||
await expectRejectsWithMessage(
|
||||
command.func({}, { target: ' ' }),
|
||||
ArgumentError,
|
||||
'bilibili unfollow target cannot be empty',
|
||||
);
|
||||
expect(mockGetSelfUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed profile URLs with unfollow-specific text', async () => {
|
||||
await expectRejectsWithMessage(
|
||||
command.func({}, { target: 'https://space.bilibili.com/not-a-uid' }),
|
||||
ArgumentError,
|
||||
'bilibili unfollow target must be a valid space.bilibili.com/<uid> URL',
|
||||
);
|
||||
expect(mockResolveUid).not.toHaveBeenCalled();
|
||||
expect(mockFetchJson).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { fetchJson, requireOkPayload } from './utils.js';
|
||||
|
||||
const RELATION_VERIFY_TIMEOUT_MS = 5000;
|
||||
const RELATION_VERIFY_POLL_MS = 500;
|
||||
|
||||
export 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] : '';
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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}`,
|
||||
);
|
||||
}
|
||||
+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,74 @@
|
||||
/**
|
||||
* 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 { parseSpaceMidUrl, fetchRelationAttribute, waitForRelation } from './relation.js';
|
||||
import { apiPost, getSelfUid, requireOkPayload, resolveUid } from './utils.js';
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 }];
|
||||
},
|
||||
});
|
||||
+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,30 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title>供应链实习生_OpenCLI招聘-BOSS直聘</title></head>
|
||||
<body>
|
||||
<section class="job-primary">
|
||||
<div class="name"><h1 title="供应链实习生">供应链实习生</h1><span class="salary">180-230元/天</span></div>
|
||||
<p><a class="text-desc text-city" href="/shanghai/">上海</a><span class="text-desc text-experiece">5天/周 6个月</span><span class="text-desc text-degree">本科</span></p>
|
||||
<div class="tag-all job-tags"><span>五险一金</span><span>餐补</span></div>
|
||||
</section>
|
||||
<main class="job-detail">
|
||||
<section class="job-detail-section">
|
||||
<ul class="job-keyword-list"><li>供应链/物流类专业</li><li>采购/供应商管理经验</li></ul>
|
||||
<div class="job-sec-text">负责订单、仓储与采购协同,使用 Excel 制作供应链报表。</div>
|
||||
</section>
|
||||
<div class="job-boss-info">
|
||||
<h2 class="name">张女士<span class="boss-active-time">本周活跃</span></h2>
|
||||
<div class="boss-info-attr">OpenCLI<em class="vdot">·</em>供应链负责人</div>
|
||||
</div>
|
||||
<div class="detail-section-item company-address"><h3>工作地址</h3><div class="location-address">上海示例路 8 号</div><p>点击查看地图</p></div>
|
||||
</main>
|
||||
<aside class="job-sider">
|
||||
<p class="title">公司基本信息</p>
|
||||
<a href="/gongsi/example.html" title="OpenCLI">OpenCLI</a>
|
||||
<p>D轮及以上</p>
|
||||
<p>1000-9999人</p>
|
||||
<p><a href="/i101001/">互联网</a></p>
|
||||
<a class="look-all">查看全部职位</a>
|
||||
</aside>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
// Keep the helper within the adapter so `opencli adapter eject boss` remains runnable.
|
||||
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');
|
||||
});
|
||||
});
|
||||
+164
-41
@@ -1,63 +1,186 @@
|
||||
/**
|
||||
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
|
||||
* BOSS直聘 job detail — extract the fully rendered job page. Read-only.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { readRequiredString, requirePage, navigateTo, verbose } from './utils.js';
|
||||
|
||||
const BOSS_DOMAIN = 'www.zhipin.com';
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value || '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function domSnapshotToRow(raw, jobId) {
|
||||
if (!raw || typeof raw !== 'object' || !cleanText(raw.jobName)) return null;
|
||||
const unique = values => [...new Set(values.map(cleanText).filter(Boolean))];
|
||||
const nullable = value => cleanText(value) || null;
|
||||
return {
|
||||
name: cleanText(raw.jobName),
|
||||
salary: cleanText(raw.salaryText),
|
||||
experience: cleanText(raw.experienceText),
|
||||
degree: cleanText(raw.degreeText),
|
||||
city: nullable(raw.cityText),
|
||||
address: nullable(raw.addressText),
|
||||
description: cleanText(raw.descriptionText),
|
||||
skills: unique(Array.isArray(raw.skillTexts) ? raw.skillTexts : []).join(', '),
|
||||
welfare: unique(Array.isArray(raw.welfareTexts) ? raw.welfareTexts : []).join(', '),
|
||||
boss_name: nullable(raw.recruiterName),
|
||||
boss_title: nullable(raw.recruiterTitle),
|
||||
active_time: nullable(raw.recruiterActiveTime),
|
||||
company: cleanText(raw.companyName),
|
||||
industry: nullable(raw.industryText),
|
||||
scale: nullable(raw.scaleText),
|
||||
stage: nullable(raw.stageText),
|
||||
url: `https://www.zhipin.com/job_detail/${jobId}.html`,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractRenderedJob() {
|
||||
const text = (selector, root = document) => {
|
||||
const element = root.querySelector(selector);
|
||||
return element ? (element.innerText || element.textContent || '').trim() : '';
|
||||
};
|
||||
const texts = (selector, root = document) => Array.from(root.querySelectorAll(selector))
|
||||
.map(element => (element.innerText || element.textContent || '').trim())
|
||||
.filter(Boolean);
|
||||
const first = selectors => {
|
||||
for (const selector of selectors) {
|
||||
const value = text(selector);
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const title = first(['.job-primary .name h1', '.job-primary h1', 'h1[title]']);
|
||||
const limits = texts('.job-primary .job-limit a, .job-primary .job-limit span, .job-primary p a, .job-primary p span');
|
||||
const companyCandidates = Array.from(document.querySelectorAll('.job-sider a[href^="/gongsi/"], .detail-op a[href*="/gongsi/"]'))
|
||||
.map(element => (element.getAttribute('title') || element.innerText || element.textContent || '').trim())
|
||||
.filter(value => value && !/查看.*职位/.test(value));
|
||||
const companyFromTitle = document.title.match(/_([^_]+)招聘-BOSS直聘$/)?.[1] || '';
|
||||
const company = companyCandidates[0] || companyFromTitle;
|
||||
const sideFacts = texts('.job-sider p, .job-sider a[href^="/i"]')
|
||||
.filter(value => value !== '公司基本信息' && value !== '查看全部职位');
|
||||
const bossBlock = document.querySelector('.job-boss-info');
|
||||
const bossHeading = bossBlock?.querySelector('h2');
|
||||
const bossName = bossHeading
|
||||
? Array.from(bossHeading.childNodes)
|
||||
.filter(node => node.nodeType === 3)
|
||||
.map(node => node.textContent.trim()).filter(Boolean).join(' ')
|
||||
: '';
|
||||
const bossLines = bossBlock
|
||||
? (bossBlock.innerText || bossBlock.textContent || '').split(/\n|·/).map(value => value.trim()).filter(Boolean)
|
||||
: [];
|
||||
const activeTime = bossHeading ? text('span', bossHeading) : bossLines.find(value => /活跃|在线/.test(value)) || '';
|
||||
const resolvedBossName = bossName || bossLines[0] || '';
|
||||
const bossAttributes = text('.boss-info-attr', bossBlock).split('·').map(value => value.trim()).filter(Boolean);
|
||||
const bossTitle = bossAttributes.findLast(value => value !== company) ||
|
||||
bossLines.findLast(value => value !== resolvedBossName && value !== activeTime && value !== company) || '';
|
||||
const addressBlock = document.querySelector('.company-address');
|
||||
const address = text('.location-address', addressBlock) || (addressBlock
|
||||
? (addressBlock.innerText || addressBlock.textContent || '').split('\n').map(value => value.trim())
|
||||
.filter(value => value && value !== '工作地址' && value !== '点击查看地图')[0] || ''
|
||||
: '');
|
||||
return {
|
||||
jobName: title,
|
||||
salaryText: first(['.job-primary .salary', '.job-banner .salary', '.job-primary .name + span']),
|
||||
// Prefer BOSS's semantic classes; fall back to positional order only if
|
||||
// they are absent. Positional indexing alone shifts every field when the
|
||||
// header gains or loses a node.
|
||||
cityText: text('.job-primary .text-city') || limits[0] || '',
|
||||
experienceText: text('.job-primary .text-experiece') || limits[1] || '',
|
||||
degreeText: text('.job-primary .text-degree') || limits[2] || '',
|
||||
descriptionText: first(['.job-detail .job-detail-section .job-sec-text:not(.fold-text)', '.job-detail .job-sec-text:not(.fold-text)', '.job-sec-text:not(.fold-text)']),
|
||||
skillTexts: texts('.job-detail .job-keyword-list li, .job-keyword-list li'),
|
||||
welfareTexts: texts('.job-banner .job-tags span, .job-primary .job-tags span'),
|
||||
recruiterName: resolvedBossName,
|
||||
recruiterTitle: bossTitle,
|
||||
recruiterActiveTime: activeTime,
|
||||
companyName: company,
|
||||
industryText: sideFacts.find(value => !/人|融资|上市|轮/.test(value)) || '',
|
||||
scaleText: sideFacts.find(value => /\d+.*人/.test(value)) || '',
|
||||
// `D轮及以上` / `天使轮` are the common shapes; the industry filter above
|
||||
// already excludes 轮, so omitting it here left stage permanently empty.
|
||||
stageText: sideFacts.find(value => /轮|融资|上市/.test(value)) || '',
|
||||
addressText: address,
|
||||
};
|
||||
}
|
||||
|
||||
async function readRenderedPage(page, jobId) {
|
||||
const raw = await page.evaluate(extractRenderedJob);
|
||||
return domSnapshotToRow(raw, jobId);
|
||||
}
|
||||
|
||||
async function captureJobDetail(page, jobId) {
|
||||
const url = `https://www.zhipin.com/job_detail/${encodeURIComponent(jobId)}.html`;
|
||||
await navigateTo(page, 'https://www.zhipin.com/web/geek/jobs', 2);
|
||||
for (let navigationAttempt = 0; navigationAttempt < 2; navigationAttempt++) {
|
||||
await navigateTo(page, url, 5);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
const domRow = await readRenderedPage(page, jobId);
|
||||
if (domRow?.name && domRow?.description && domRow?.company) return domRow;
|
||||
} catch { /* wait for a complete render */ }
|
||||
if (attempt < 4) await page.wait(1);
|
||||
}
|
||||
}
|
||||
// The retry loop above swallows every read error, so without this check a
|
||||
// session that got bounced to the login wall reports "incomplete posting".
|
||||
// The API path this command replaced classified that as AuthRequiredError
|
||||
// via assertOk; keep that signal rather than losing it to the UI rewrite.
|
||||
if (await isLoginWall(page)) {
|
||||
throw new AuthRequiredError(BOSS_DOMAIN, 'BOSS redirected the job detail page to the login flow');
|
||||
}
|
||||
throw new CommandExecutionError('BOSS detail page did not expose a complete job posting');
|
||||
}
|
||||
|
||||
async function isLoginWall(page) {
|
||||
try {
|
||||
return await page.evaluate(`
|
||||
(() => {
|
||||
const href = window.location.href || '';
|
||||
if (/\\/login|\\/user\\/login|passport/.test(href)) return true;
|
||||
return !!document.querySelector('.sign-form, .login-card, [class*="login-register"]');
|
||||
})()
|
||||
`) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'detail',
|
||||
access: 'read',
|
||||
description: 'BOSS直聘查看职位详情',
|
||||
domain: 'www.zhipin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
strategy: Strategy.UI,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
defaultWindowMode: 'background',
|
||||
siteSession: 'persistent',
|
||||
args: [
|
||||
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (securityId field)' },
|
||||
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (security_id field)' },
|
||||
],
|
||||
columns: [
|
||||
'name', 'salary', 'experience', 'degree', 'city', 'district',
|
||||
'name', 'salary', 'experience', 'degree',
|
||||
'city', 'address',
|
||||
'description', 'skills', 'welfare',
|
||||
'boss_name', 'boss_title', 'active_time',
|
||||
'company', 'industry', 'scale', 'stage',
|
||||
'address', 'url',
|
||||
'company', 'industry', 'scale', 'stage', 'url',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
requirePage(page);
|
||||
const securityId = kwargs['security-id'];
|
||||
verbose('Fetching job detail...');
|
||||
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
|
||||
await navigateTo(page, 'https://www.zhipin.com/web/geek/job');
|
||||
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
|
||||
const data = await bossFetch(page, targetUrl);
|
||||
const zpData = data.zpData || {};
|
||||
const jobInfo = zpData.jobInfo || {};
|
||||
const bossInfo = zpData.bossInfo || {};
|
||||
const brandComInfo = zpData.brandComInfo || {};
|
||||
if (!jobInfo.jobName) {
|
||||
throw new Error('该职位信息不存在或已下架');
|
||||
const jobId = readRequiredString(kwargs['security-id'], 'security-id');
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(jobId)) {
|
||||
throw new ArgumentError('boss security-id contains unsupported characters', 'Pass the security_id returned by `opencli boss search`');
|
||||
}
|
||||
return [{
|
||||
name: jobInfo.jobName || '',
|
||||
salary: jobInfo.salaryDesc || '',
|
||||
experience: jobInfo.experienceName || '',
|
||||
degree: jobInfo.degreeName || '',
|
||||
city: jobInfo.locationName || '',
|
||||
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
|
||||
description: jobInfo.postDescription || '',
|
||||
skills: (jobInfo.showSkills || []).join(', '),
|
||||
welfare: (brandComInfo.labels || []).join(', '),
|
||||
boss_name: bossInfo.name || '',
|
||||
boss_title: bossInfo.title || '',
|
||||
active_time: bossInfo.activeTimeDesc || '',
|
||||
company: brandComInfo.brandName || bossInfo.brandName || '',
|
||||
industry: brandComInfo.industryName || '',
|
||||
scale: brandComInfo.scaleName || '',
|
||||
stage: brandComInfo.stageName || '',
|
||||
address: jobInfo.address || '',
|
||||
url: jobInfo.encryptId
|
||||
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
|
||||
: '',
|
||||
}];
|
||||
verbose('Fetching job detail from the rendered BOSS page...');
|
||||
return [await captureJobDetail(page, jobId)];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { cleanText, domSnapshotToRow };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { __test__, extractRenderedJob } from './detail.js';
|
||||
import './detail.js';
|
||||
|
||||
const fixture = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '__fixtures__/detail.html'), 'utf8');
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
describe('boss detail', () => {
|
||||
const command = getRegistry().get('boss/detail');
|
||||
|
||||
it('is registered as a read-only rendered-page command', () => {
|
||||
expect(command).toMatchObject({ access: 'read', strategy: Strategy.UI });
|
||||
});
|
||||
|
||||
it('normalizes the rendered detail snapshot into the documented row', () => {
|
||||
const row = __test__.domSnapshotToRow({
|
||||
jobName: ' 数据分析实习生 ', salaryText: '150-200/天',
|
||||
cityText: '上海', experienceText: '在校/应届', degreeText: '本科',
|
||||
descriptionText: '负责数据分析', skillTexts: ['SQL', 'SQL'], welfareTexts: ['餐补', '餐补'],
|
||||
recruiterName: '张三', recruiterTitle: '技术负责人', recruiterActiveTime: '刚刚活跃', companyName: 'OpenCLI',
|
||||
}, 'job-id');
|
||||
// Flat scalar columns only: the table/plain/csv/markdown renderers coerce
|
||||
// each cell with String(v) (src/output.ts), so a nested object column would
|
||||
// print as [object Object] in the default output.
|
||||
expect(Object.keys(row)).toHaveLength(17);
|
||||
expect(row).toMatchObject({
|
||||
name: '数据分析实习生', city: '上海', experience: '在校/应届', degree: '本科',
|
||||
skills: 'SQL', welfare: '餐补',
|
||||
boss_name: '张三', boss_title: '技术负责人', active_time: '刚刚活跃',
|
||||
company: 'OpenCLI', url: 'https://www.zhipin.com/job_detail/job-id.html',
|
||||
});
|
||||
for (const value of Object.values(row)) {
|
||||
expect(typeof value === 'object' && value !== null).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts current BOSS detail selectors from a sanitized live-page fixture', () => {
|
||||
const dom = new JSDOM(fixture, { url: 'https://www.zhipin.com/job_detail/job-id.html' });
|
||||
globalThis.document = dom.window.document;
|
||||
|
||||
expect(extractRenderedJob()).toMatchObject({
|
||||
jobName: '供应链实习生', salaryText: '180-230元/天', cityText: '上海',
|
||||
experienceText: '5天/周 6个月', degreeText: '本科', companyName: 'OpenCLI',
|
||||
recruiterName: '张女士', recruiterActiveTime: '本周活跃', addressText: '上海示例路 8 号',
|
||||
skillTexts: ['供应链/物流类专业', '采购/供应商管理经验'],
|
||||
welfareTexts: ['五险一金', '餐补'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the rendered current job page instead of the retired detail API', async () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({
|
||||
jobName: '数据分析实习生', salaryText: '150-200/天', cityText: '上海', experienceText: '在校/应届', degreeText: '本科',
|
||||
descriptionText: '负责数据分析', skillTexts: ['SQL'], welfareTexts: ['餐补'], companyName: 'OpenCLI',
|
||||
}),
|
||||
};
|
||||
const rows = await command.func(page, { 'security-id': 'job-id' });
|
||||
|
||||
expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.zhipin.com/web/geek/jobs');
|
||||
expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.zhipin.com/job_detail/job-id.html');
|
||||
expect(rows[0]).toMatchObject({ name: '数据分析实习生', company: 'OpenCLI' });
|
||||
});
|
||||
|
||||
it('rejects malformed detail ids before navigation', async () => {
|
||||
await expect(command.func({ goto: vi.fn(), wait: vi.fn(), evaluate: vi.fn() }, {
|
||||
'security-id': '../write-action',
|
||||
})).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
+50
-16
@@ -2,8 +2,8 @@
|
||||
* BOSS直聘 job search — browser cookie API.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { assertOk, readPositiveInteger, requirePage, navigateTo, verbose } from './utils.js';
|
||||
/** City name → BOSS Zhipin city code mapping */
|
||||
const CITY_CODES = {
|
||||
'全国': '100010000', '北京': '101010100', '上海': '101020100',
|
||||
@@ -25,8 +25,8 @@ const CITY_CODES = {
|
||||
const EXP_MAP = {
|
||||
'不限': '0',
|
||||
'在校/应届': '108',
|
||||
'在校生': '108', '在校': '108',
|
||||
'应届生': '102', '应届': '102',
|
||||
'在校生(实习)': '108', '在校生': '108', '在校': '108',
|
||||
'应届生(校招)': '102', '应届生': '102', '应届': '102',
|
||||
'经验不限': '101',
|
||||
'1年以内': '103',
|
||||
'1-3年': '104',
|
||||
@@ -62,7 +62,7 @@ function resolveCity(input) {
|
||||
if (name.includes(input))
|
||||
return code;
|
||||
}
|
||||
return '101010100';
|
||||
throw new ArgumentError(`Invalid BOSS city: ${input}`, 'Use a supported city name or a numeric BOSS city code');
|
||||
}
|
||||
function resolveMap(input, map) {
|
||||
if (!input)
|
||||
@@ -91,15 +91,49 @@ function formatBossOnline(value) {
|
||||
return 'N';
|
||||
return '';
|
||||
}
|
||||
async function captureJobList(page, url) {
|
||||
if (typeof page.startNetworkCapture !== 'function' ||
|
||||
typeof page.readNetworkCapture !== 'function' ||
|
||||
!await page.startNetworkCapture('joblist.json')) {
|
||||
throw new CommandExecutionError('BOSS search requires CDP network capture');
|
||||
}
|
||||
await page.readNetworkCapture();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
await navigateTo(page, `${url}${separator}_opencli=${Date.now()}`, 5);
|
||||
await page.wait(1);
|
||||
const captures = await page.readNetworkCapture();
|
||||
for (const entry of Array.isArray(captures) ? captures : []) {
|
||||
if (!String(entry?.url || '').includes('joblist.json') ||
|
||||
Number(entry?.responseStatus || 0) !== 200 ||
|
||||
typeof entry?.responsePreview !== 'string') {
|
||||
continue;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(entry.responsePreview);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (payload && typeof payload === 'object' && 'code' in payload && payload.code !== 0) {
|
||||
assertOk(payload, 'BOSS search failed');
|
||||
}
|
||||
if (Array.isArray(payload?.zpData?.jobList)) return payload;
|
||||
}
|
||||
}
|
||||
throw new CommandExecutionError('BOSS search page did not expose its job-list response');
|
||||
}
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
|
||||
domain: 'www.zhipin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
strategy: Strategy.INTERCEPT,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
defaultWindowMode: 'background',
|
||||
siteSession: 'persistent',
|
||||
args: [
|
||||
{ name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
|
||||
{ name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
|
||||
@@ -117,15 +151,13 @@ cli({
|
||||
const query = String(kwargs.query ?? '').trim();
|
||||
const cityCode = resolveCity(kwargs.city);
|
||||
verbose('Navigating to set referrer context...');
|
||||
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const expVal = resolveMap(kwargs.experience, EXP_MAP);
|
||||
const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
|
||||
const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
|
||||
const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
|
||||
const jobTypeVal = resolveJobType(kwargs.jobType);
|
||||
const limit = kwargs.limit || 15;
|
||||
let currentPage = kwargs.page || 1;
|
||||
const limit = readPositiveInteger(kwargs.limit, 'limit', 15, 100);
|
||||
let currentPage = readPositiveInteger(kwargs.page, 'page', 1);
|
||||
let allJobs = [];
|
||||
const seenIds = new Set();
|
||||
while (allJobs.length < limit) {
|
||||
@@ -133,11 +165,9 @@ cli({
|
||||
await new Promise(r => setTimeout(r, 1000 + Math.random() * 2000));
|
||||
}
|
||||
const qs = new URLSearchParams({
|
||||
scene: '1',
|
||||
query,
|
||||
city: cityCode,
|
||||
page: String(currentPage),
|
||||
pageSize: '15',
|
||||
});
|
||||
if (expVal)
|
||||
qs.set('experience', expVal);
|
||||
@@ -149,9 +179,9 @@ cli({
|
||||
qs.set('industry', industryVal);
|
||||
if (jobTypeVal)
|
||||
qs.set('jobType', jobTypeVal);
|
||||
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
|
||||
verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
|
||||
const data = await bossFetch(page, targetUrl);
|
||||
const targetUrl = `https://www.zhipin.com/web/geek/jobs?${qs.toString()}`;
|
||||
verbose(`Capturing page ${currentPage}... (current jobs: ${allJobs.length})`);
|
||||
const data = await captureJobList(page, targetUrl);
|
||||
const zpData = data.zpData || {};
|
||||
const batch = zpData.jobList || [];
|
||||
if (batch.length === 0)
|
||||
@@ -171,7 +201,7 @@ cli({
|
||||
skills: (j.skills || []).join(','),
|
||||
boss: j.bossName + ' · ' + j.bossTitle,
|
||||
bossOnline: formatBossOnline(j.bossOnline),
|
||||
security_id: j.securityId || '',
|
||||
security_id: j.encryptJobId || '',
|
||||
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
||||
});
|
||||
addedInBatch++;
|
||||
@@ -186,11 +216,15 @@ cli({
|
||||
break;
|
||||
currentPage++;
|
||||
}
|
||||
if (allJobs.length === 0) {
|
||||
throw new EmptyResultError('boss search', query ? `No BOSS jobs found for "${query}"` : 'BOSS returned no recommended jobs');
|
||||
}
|
||||
return allJobs;
|
||||
},
|
||||
});
|
||||
export const __test__ = {
|
||||
EXP_MAP,
|
||||
resolveCity,
|
||||
resolveMap,
|
||||
resolveJobType,
|
||||
formatBossOnline,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { __test__ } from './search.js';
|
||||
import './search.js';
|
||||
|
||||
@@ -8,13 +8,24 @@ function createPageMock(response) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(response),
|
||||
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
||||
readNetworkCapture: vi.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
url: 'https://www.zhipin.com/wapi/zpgeek/search/joblist.json',
|
||||
responseStatus: 200,
|
||||
responsePreview: JSON.stringify(response),
|
||||
}]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('boss search', () => {
|
||||
const command = getRegistry().get('boss/search');
|
||||
|
||||
it('is registered as a read-only intercepted listing command', () => {
|
||||
expect(command).toMatchObject({ access: 'read', strategy: Strategy.INTERCEPT });
|
||||
});
|
||||
|
||||
it('keeps legacy 在校/应届 experience input compatible', () => {
|
||||
expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
|
||||
expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
|
||||
@@ -24,6 +35,10 @@ describe('boss search', () => {
|
||||
expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('fails fast on unknown city names instead of silently searching Beijing', () => {
|
||||
expect(() => __test__.resolveCity('不存在的城市')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('accepts supported jobType labels and raw codes', () => {
|
||||
expect(__test__.resolveJobType('全职')).toBe('1901');
|
||||
expect(__test__.resolveJobType('实习')).toBe('1902');
|
||||
@@ -31,7 +46,7 @@ describe('boss search', () => {
|
||||
expect(__test__.resolveJobType('1902')).toBe('1902');
|
||||
});
|
||||
|
||||
it('keeps empty query empty and sends jobType filter to the API', async () => {
|
||||
it('captures the current jobs page response instead of calling the retired API directly', async () => {
|
||||
const page = createPageMock({
|
||||
code: 0,
|
||||
zpData: {
|
||||
@@ -65,14 +80,31 @@ describe('boss search', () => {
|
||||
page: 1,
|
||||
});
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
|
||||
const fetchScript = page.evaluate.mock.calls.at(-1)[0];
|
||||
expect(fetchScript).toContain('query=');
|
||||
expect(fetchScript).not.toContain('query=undefined');
|
||||
expect(fetchScript).toContain('jobType=1902');
|
||||
expect(page.startNetworkCapture).toHaveBeenCalledWith('joblist.json');
|
||||
expect(page.goto.mock.calls[0][0]).toContain('https://www.zhipin.com/web/geek/jobs?query=&city=101010100');
|
||||
expect(page.goto.mock.calls[0][0]).toContain('jobType=1902');
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: '前端开发实习生',
|
||||
bossOnline: 'N',
|
||||
security_id: 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates page and limit instead of silently replacing invalid values', async () => {
|
||||
const page = createPageMock({ code: 0, zpData: { hasMore: false, jobList: [] } });
|
||||
await expect(command.func(page, { city: '北京', page: 0, limit: 1 })).rejects.toThrow(ArgumentError);
|
||||
await expect(command.func(page, { city: '北京', page: 1, limit: 101 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('returns the stable empty-result exit category when BOSS has no matching jobs', async () => {
|
||||
const page = createPageMock({ code: 0, zpData: { hasMore: false, jobList: [] } });
|
||||
await expect(command.func(page, { query: '不存在', city: '北京', page: 1, limit: 1 }))
|
||||
.rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('preserves typed auth failures from the captured BOSS response', async () => {
|
||||
const page = createPageMock({ code: 7, message: '请登录' });
|
||||
await expect(command.func(page, { query: '供应链', city: '北京', page: 1, limit: 1 }))
|
||||
.rejects.toThrow(AuthRequiredError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 ────────────────────────────────────────────────────────────
|
||||
@@ -56,6 +58,13 @@ export function checkAuth(data) {
|
||||
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,
|
||||
@@ -80,6 +89,7 @@ export function assertOk(data, errorPrefix) {
|
||||
}
|
||||
if (data.code === 0)
|
||||
return;
|
||||
checkEnvironment(data);
|
||||
checkAuth(data);
|
||||
checkRecruiterSide(data);
|
||||
const prefix = errorPrefix ? `${errorPrefix}: ` : '';
|
||||
|
||||
@@ -12,6 +12,30 @@ describe('assertOk', () => {
|
||||
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: '请切换身份后再试' });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -17,19 +17,79 @@ describe('chatgpt-app AX send script', () => {
|
||||
it('supports english, zh-CN, and zh-TW send button labels', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('["发送", "傳送", "Send"]');
|
||||
});
|
||||
|
||||
it('supports loading an optional image and writing it to the general pasteboard', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('NSImage(contentsOfFile: imagePath)');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('NSPasteboard.general');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('pasteboard.clearContents()');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('pasteboard.writeObjects([image])');
|
||||
});
|
||||
|
||||
it('simulates Cmd + V paste via CGEvent targeted directly to the ChatGPT process', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('CGEventSource(stateID: .hidSystemState)');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('let cmdDown = CGEvent');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('.maskCommand');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('virtualKey: 0x09'); // 'V'
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('virtualKey: 0x37'); // 'Cmd'
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('postToPid(app.processIdentifier)');
|
||||
});
|
||||
|
||||
it('uses a dynamic submission check with valueBeforeSend to handle rich content correctly', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('let valueBeforeSend = s(input, kAXValueAttribute as String)');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('(s(input, kAXValueAttribute as String) ?? "") != valueBeforeSend');
|
||||
});
|
||||
|
||||
it('safeguards user clipboard by backing up and restoring pasteboard contents', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('pasteboard.pasteboardItems');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('NSPasteboardItem()');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('savedItems.append');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('func restorePasteboard()');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('restorePasteboard()');
|
||||
});
|
||||
|
||||
it('requires visible attachment evidence before pressing send with an image', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('attachmentEvidenceCount');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('let attachmentCountBefore = attachmentEvidenceCount(win, fileName: fileName)');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('attachmentEvidenceCount(win, fileName: fileName) > attachmentCountBefore');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('Image attachment did not appear in ChatGPT before send');
|
||||
expect(__test__.AX_SEND_SCRIPT.indexOf('Image attachment did not appear in ChatGPT before send'))
|
||||
.toBeLessThan(__test__.AX_SEND_SCRIPT.indexOf('guard let sendButton'));
|
||||
});
|
||||
|
||||
it('uses safe casting and fallback window search to prevent runtime crashes', () => {
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('as! AXUIElement');
|
||||
expect(__test__.AX_SEND_SCRIPT).toContain('kAXWindowsAttribute');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt-app AX model script', () => {
|
||||
it('supports english, zh-CN, and zh-TW options button labels', () => {
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "Options")');
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "选项")');
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "選項")');
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('["Options", "选项", "選項"]');
|
||||
});
|
||||
|
||||
it('utilizes dynamic element polling helper to prevent rigid sleep delays', () => {
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('waitForElement');
|
||||
});
|
||||
|
||||
it('supports localized legacy model menus for Chinese systems', () => {
|
||||
expect(__test__.AX_MODEL_SCRIPT).toContain('["Legacy models", "经典模型", "經典模型"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt-app generating detection', () => {
|
||||
it('supports both english and zh-CN stop-generating labels', () => {
|
||||
it('supports english, zh-CN, and zh-TW stop-generating labels', () => {
|
||||
expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating');
|
||||
expect(__test__.AX_GENERATING_SCRIPT).toContain('停止生成');
|
||||
expect(__test__.AX_GENERATING_SCRIPT).toContain('停止產生');
|
||||
expect(__test__.AX_GENERATING_SCRIPT).toContain('停止傳送');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt-app temporary chat detection', () => {
|
||||
it('looks for localized temporary-chat state text in the active window', () => {
|
||||
expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('Temporary Chat');
|
||||
expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('临时聊天');
|
||||
expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('臨時聊天');
|
||||
expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('hasTemporaryChatText');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './ask.js';
|
||||
import './new.js';
|
||||
import './send.js';
|
||||
import './read.js';
|
||||
import './status.js';
|
||||
import './model.js';
|
||||
|
||||
describe('chatgpt-app desktop command registration', () => {
|
||||
it('registers the baseline desktop chat commands with localhost scope', () => {
|
||||
const expectedAccess = {
|
||||
ask: 'write',
|
||||
send: 'write',
|
||||
read: 'read',
|
||||
new: 'write',
|
||||
status: 'read',
|
||||
model: 'read',
|
||||
};
|
||||
|
||||
for (const [name, access] of Object.entries(expectedAccess)) {
|
||||
const cmd = getRegistry().get(`chatgpt-app/${name}`);
|
||||
expect(cmd, `chatgpt-app/${name}`).toBeDefined();
|
||||
expect(cmd.site).toBe('chatgpt-app');
|
||||
expect(cmd.domain).toBe('localhost');
|
||||
expect(cmd.strategy).toBe('public');
|
||||
expect(cmd.browser).toBe(false);
|
||||
expect(cmd.access).toBe(access);
|
||||
}
|
||||
});
|
||||
|
||||
it('defines the --temp boolean argument in the new command', () => {
|
||||
const newCmd = getRegistry().get('chatgpt-app/new');
|
||||
expect(newCmd.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'temp', type: 'boolean', default: false }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('defines the --image argument in the ask command', () => {
|
||||
const askCmd = getRegistry().get('chatgpt-app/ask');
|
||||
expect(askCmd.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'image', required: false }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
+39
-6
@@ -1,28 +1,61 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { CommandExecutionError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { isTemporaryChatVisible } from './ax.js';
|
||||
export const newCommand = cli({
|
||||
site: 'chatgpt-app',
|
||||
name: 'new',
|
||||
access: 'read',
|
||||
access: 'write',
|
||||
description: 'Open a new chat in ChatGPT Desktop App',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [],
|
||||
args: [
|
||||
{ name: 'temp', type: 'boolean', default: false, help: 'Open a temporary chat with privacy protection' }
|
||||
],
|
||||
columns: ['Status'],
|
||||
func: async () => {
|
||||
func: async (kwargs) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
try {
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync("osascript -e 'delay 0.5'");
|
||||
execSync("osascript -e 'tell application \"System Events\" to keystroke \"n\" using command down'");
|
||||
if (kwargs.temp) {
|
||||
const appleScript = [
|
||||
'tell application "System Events"',
|
||||
' tell process "ChatGPT"',
|
||||
' try',
|
||||
' click menu item "新的临时聊天" of menu "文件" of menu bar 1',
|
||||
' on error',
|
||||
' try',
|
||||
' click menu item "新的臨時聊天" of menu "檔案" of menu bar 1',
|
||||
' on error',
|
||||
' try',
|
||||
' click menu item "New Temporary Chat" of menu "File" of menu bar 1',
|
||||
' on error',
|
||||
' error "Unable to locate Temporary Chat menu item. Ensure Accessibility permissions are granted and the language is supported."' ,
|
||||
' end try',
|
||||
' end try',
|
||||
' end try',
|
||||
' end tell',
|
||||
'end tell'
|
||||
].map(line => `-e '${line.replace(/'/g, "'\\''")}'`).join(' ');
|
||||
execSync(`osascript ${appleScript}`);
|
||||
execSync("osascript -e 'delay 0.8'");
|
||||
if (!isTemporaryChatVisible()) {
|
||||
throw new CommandExecutionError('Temporary chat did not become visible after selecting the menu item');
|
||||
}
|
||||
} else {
|
||||
execSync("osascript -e 'tell application \"System Events\" to keystroke \"n\" using command down'");
|
||||
}
|
||||
return [{ Status: 'Success' }];
|
||||
}
|
||||
catch (err) {
|
||||
return [{ Status: "Error: " + getErrorMessage(err) }];
|
||||
if (err instanceof CommandExecutionError) {
|
||||
throw err;
|
||||
}
|
||||
throw new CommandExecutionError("Failed to open ChatGPT chat: " + getErrorMessage(err));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { CommandExecutionError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
import { activateChatGPT, selectModel, MODEL_CHOICES, sendPrompt } from './ax.js';
|
||||
export const sendCommand = cli({
|
||||
site: 'chatgpt-app',
|
||||
@@ -15,6 +15,9 @@ export const sendCommand = cli({
|
||||
],
|
||||
columns: ['Status'],
|
||||
func: async (kwargs) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
const text = kwargs.text;
|
||||
const model = kwargs.model;
|
||||
try {
|
||||
@@ -28,7 +31,7 @@ export const sendCommand = cli({
|
||||
return [{ Status: 'Success' }];
|
||||
}
|
||||
catch (err) {
|
||||
return [{ Status: "Error: " + getErrorMessage(err) }];
|
||||
throw new CommandExecutionError("Failed to send ChatGPT message: " + getErrorMessage(err));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+77
-6
@@ -1,19 +1,40 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
CHATGPT_DOMAIN,
|
||||
CHATGPT_URL,
|
||||
currentChatGPTUrl,
|
||||
ensureChatGPTComposer,
|
||||
ensureOnChatGPT,
|
||||
getBubbleCount,
|
||||
getChatGPTResponsePairCounts,
|
||||
getVisibleMessages,
|
||||
normalizeBooleanFlag,
|
||||
openChatGPTConversation,
|
||||
requireNonEmptyPrompt,
|
||||
requirePositiveInt,
|
||||
parseChatGPTConversationId,
|
||||
sendChatGPTMessage,
|
||||
selectChatGPTTool,
|
||||
isGenerating,
|
||||
startNewChat,
|
||||
navigateToProject,
|
||||
waitForChatGPTResponse,
|
||||
} from './utils.js';
|
||||
|
||||
async function waitForConversationUrl(page, timeoutSeconds = 30) {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutSeconds * 1000) {
|
||||
const conversationUrl = await currentChatGPTUrl(page);
|
||||
try {
|
||||
const conversationId = parseChatGPTConversationId(conversationUrl);
|
||||
return { conversationId, conversationUrl };
|
||||
} catch {
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
throw new CommandExecutionError('ChatGPT did not create a conversation URL after sending the message.');
|
||||
}
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'chatgpt',
|
||||
name: 'ask',
|
||||
@@ -28,8 +49,13 @@ export const askCommand = cli({
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
|
||||
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
||||
{ name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/<id> URL' },
|
||||
{ name: 'project', valueRequired: true, help: 'Start a new chat inside a ChatGPT project ID or /g/g-p-<id> URL' },
|
||||
{ name: 'wait', type: 'boolean', default: true, help: 'Wait for the assistant response after sending' },
|
||||
{ name: 'deep-research', type: 'boolean', default: false, help: 'Enable ChatGPT 深度研究 (Deep Research)' },
|
||||
{ name: 'web-search', type: 'boolean', default: false, help: 'Enable ChatGPT 网页搜索 (Web Search)' },
|
||||
],
|
||||
columns: ['response'],
|
||||
columns: ['conversationId', 'conversationUrl', 'tool', 'response'],
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt ask');
|
||||
const timeout = requirePositiveInt(
|
||||
@@ -37,8 +63,34 @@ export const askCommand = cli({
|
||||
'chatgpt ask --timeout',
|
||||
'Example: opencli chatgpt ask "hello" --timeout 120',
|
||||
);
|
||||
const useDeepResearch = normalizeBooleanFlag(kwargs['deep-research'], false);
|
||||
const useWebSearch = normalizeBooleanFlag(kwargs['web-search'], false);
|
||||
const shouldWait = normalizeBooleanFlag(kwargs.wait, true);
|
||||
if (useDeepResearch && useWebSearch) {
|
||||
throw new ArgumentError(
|
||||
'chatgpt ask cannot enable both --deep-research and --web-search',
|
||||
'Choose one ChatGPT composer tool for this message.',
|
||||
);
|
||||
}
|
||||
if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) {
|
||||
throw new ArgumentError(
|
||||
'chatgpt ask cannot use --new and --conversation together',
|
||||
'Choose either a new chat or an existing conversation.',
|
||||
);
|
||||
}
|
||||
if (kwargs.project && kwargs.conversation) {
|
||||
throw new ArgumentError(
|
||||
'chatgpt ask cannot use --project and --conversation together',
|
||||
'Choose either a project new chat or an existing conversation.',
|
||||
);
|
||||
}
|
||||
const tool = useDeepResearch ? 'deep-research' : (useWebSearch ? 'web-search' : null);
|
||||
|
||||
if (normalizeBooleanFlag(kwargs.new)) {
|
||||
if (kwargs.conversation) {
|
||||
await openChatGPTConversation(page, kwargs.conversation);
|
||||
} else if (kwargs.project) {
|
||||
await navigateToProject(page, kwargs.project);
|
||||
} else if (normalizeBooleanFlag(kwargs.new)) {
|
||||
await startNewChat(page);
|
||||
} else {
|
||||
await ensureOnChatGPT(page);
|
||||
@@ -46,13 +98,32 @@ export const askCommand = cli({
|
||||
// startNewChat / ensureOnChatGPT now wait for the composer selector
|
||||
// after navigating, so the previous standalone 2 s settle is redundant.
|
||||
await ensureChatGPTComposer(page, 'ChatGPT ask requires a logged-in ChatGPT session with a visible composer.');
|
||||
const selectedTool = tool ? await selectChatGPTTool(page, tool) : null;
|
||||
|
||||
const baseline = await getBubbleCount(page);
|
||||
const settleStart = Date.now();
|
||||
while (await isGenerating(page)) {
|
||||
if (Date.now() - settleStart > timeout * 1000) {
|
||||
throw new CommandExecutionError('ChatGPT conversation is still generating; wait for it to finish before sending another message.');
|
||||
}
|
||||
await page.sleep(3);
|
||||
}
|
||||
|
||||
const baselineMessages = await getVisibleMessages(page);
|
||||
const baseline = baselineMessages.length;
|
||||
const baselinePairCounts = getChatGPTResponsePairCounts(baselineMessages, prompt);
|
||||
const sent = await sendChatGPTMessage(page, prompt);
|
||||
if (!sent) {
|
||||
throw new CommandExecutionError('Failed to send message to ChatGPT', `Open ${CHATGPT_URL} and verify the composer is ready.`);
|
||||
}
|
||||
|
||||
return [{ response: await waitForChatGPTResponse(page, baseline, prompt, timeout) }];
|
||||
const { conversationId, conversationUrl } = await waitForConversationUrl(page);
|
||||
if (!shouldWait) {
|
||||
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response: '' }];
|
||||
}
|
||||
const response = await waitForChatGPTResponse(page, baseline, prompt, timeout, {
|
||||
baselinePairCounts,
|
||||
conversationUrl,
|
||||
});
|
||||
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response }];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasChatgptSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://chatgpt.com' });
|
||||
// Prefix match: NextAuth chunks large session tokens into
|
||||
// `__Secure-next-auth.session-token.0`, `.1`, … so an exact-name check
|
||||
// false-negatives on chunked sessions (the `auth status`/`refresh`/login
|
||||
// fast paths that consume this). See issue #2087.
|
||||
return cookies.some(c => c.name.startsWith('__Secure-next-auth.session-token') && c.value);
|
||||
}
|
||||
|
||||
async function verifyChatgptIdentity(page) {
|
||||
// The `/api/auth/session` probe below is authoritative — do NOT pre-gate on the
|
||||
// legacy `__Secure-next-auth.session-token` cookie. Current ChatGPT web
|
||||
// sessions authenticate without that cookie, so gating on it produced false
|
||||
// AUTH_REQUIRED for logged-in users. See issue #2087.
|
||||
await page.goto('https://chatgpt.com/');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/session', { credentials: 'include' });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'ChatGPT /api/auth/session HTTP ' + res.status };
|
||||
}
|
||||
if (!res.ok) return { kind: 'http', httpStatus: res.status };
|
||||
const d = await res.json();
|
||||
const user = d && d.user;
|
||||
if (!user || !user.id) {
|
||||
return { kind: 'auth', detail: 'ChatGPT /api/auth/session has no user — anonymous' };
|
||||
}
|
||||
return { ok: true, user_id: String(user.id), name: String(user.name || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('chatgpt.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/auth/session`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`ChatGPT whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected ChatGPT probe: ${JSON.stringify(result)}`);
|
||||
return { user_id: result.user_id, name: result.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'chatgpt',
|
||||
domain: 'chatgpt.com',
|
||||
loginUrl: 'https://auth.openai.com/log-in',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: hasChatgptSessionCookie,
|
||||
verify: verifyChatgptIdentity,
|
||||
// Poll keeps the cheap, non-navigating cookie gate: during `login` the browser
|
||||
// sits on the OAuth page, and verify (which navigates to chatgpt.com) must not
|
||||
// run every ~2s or it would yank the user off the login form. #2087 is about
|
||||
// `whoami` (the verify path above); login-completion detection is unchanged.
|
||||
poll: async (page) => {
|
||||
if (!await hasChatgptSessionCookie(page)) {
|
||||
throw new AuthRequiredError('chatgpt.com', 'Waiting for ChatGPT session cookie');
|
||||
}
|
||||
return verifyChatgptIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -1,13 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './ask.js';
|
||||
import './send.js';
|
||||
import './read.js';
|
||||
import './history.js';
|
||||
import './detail.js';
|
||||
import './deep-research-result.js';
|
||||
import './new.js';
|
||||
import './status.js';
|
||||
import './image.js';
|
||||
import './model.js';
|
||||
import './project-list.js';
|
||||
import './project-file-add.js';
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (tempDirs.length) {
|
||||
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProjectUploadPageMock() {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
const s = String(script);
|
||||
if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) {
|
||||
return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } });
|
||||
}
|
||||
if (s.includes('expectedFileNames')) return Promise.resolve({ ok: true });
|
||||
if (s.includes('Add files')) return Promise.resolve(true);
|
||||
if (s.includes('role="dialog"')) return Promise.resolve(true);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('chatgpt browser command registration', () => {
|
||||
it('registers the baseline web chat commands with persistent site sessions', () => {
|
||||
@@ -17,9 +53,13 @@ describe('chatgpt browser command registration', () => {
|
||||
read: 'read',
|
||||
history: 'read',
|
||||
detail: 'read',
|
||||
'deep-research-result': 'read',
|
||||
new: 'read',
|
||||
status: 'read',
|
||||
image: 'write',
|
||||
model: 'write',
|
||||
'project-list': 'read',
|
||||
'project-file-add': 'write',
|
||||
};
|
||||
|
||||
for (const [name, access] of Object.entries(expectedAccess)) {
|
||||
@@ -40,6 +80,363 @@ describe('chatgpt browser command registration', () => {
|
||||
expect(ask.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
|
||||
expect.objectContaining({ name: 'new', type: 'boolean', default: false }),
|
||||
expect.objectContaining({ name: 'conversation', valueRequired: true }),
|
||||
expect.objectContaining({ name: 'project', valueRequired: true }),
|
||||
expect.objectContaining({ name: 'wait', type: 'boolean', default: true }),
|
||||
expect.objectContaining({ name: 'deep-research', type: 'boolean', default: false }),
|
||||
expect.objectContaining({ name: 'web-search', type: 'boolean', default: false }),
|
||||
]));
|
||||
expect(ask.columns).toEqual(['conversationId', 'conversationUrl', 'tool', 'response']);
|
||||
});
|
||||
|
||||
it('registers send conversation and project routing options', () => {
|
||||
const send = getRegistry().get('chatgpt/send');
|
||||
expect(send.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'new', type: 'boolean', default: false }),
|
||||
expect.objectContaining({ name: 'conversation', valueRequired: true }),
|
||||
expect.objectContaining({ name: 'project', valueRequired: true }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('rejects using project and conversation routing together', async () => {
|
||||
const ask = getRegistry().get('chatgpt/ask');
|
||||
const send = getRegistry().get('chatgpt/send');
|
||||
const page = {
|
||||
goto: () => {
|
||||
throw new Error('should not navigate');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(ask.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' }))
|
||||
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
await expect(send.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' }))
|
||||
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
});
|
||||
|
||||
it('registers detail wait options and generation state columns', () => {
|
||||
const detail = getRegistry().get('chatgpt/detail');
|
||||
expect(detail.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'wait', type: 'boolean', default: false }),
|
||||
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
|
||||
expect.objectContaining({ name: 'stable', type: 'int', default: 6 }),
|
||||
]));
|
||||
expect(detail.columns).toEqual(['Index', 'Role', 'Text', 'Generating', 'StableSeconds']);
|
||||
});
|
||||
|
||||
it('registers deep research result command with wait options', () => {
|
||||
const command = getRegistry().get('chatgpt/deep-research-result');
|
||||
expect(command.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'id', positional: true, required: true }),
|
||||
expect.objectContaining({ name: 'wait', type: 'boolean', default: false }),
|
||||
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
|
||||
expect.objectContaining({ name: 'stable', type: 'int', default: 6 }),
|
||||
]));
|
||||
expect(command.columns).toEqual([
|
||||
'conversationId',
|
||||
'status',
|
||||
'report',
|
||||
'sources',
|
||||
'progress',
|
||||
'asyncTaskConversationId',
|
||||
'widgetSessionId',
|
||||
'asyncStatus',
|
||||
'venusMessageType',
|
||||
'venusStatus',
|
||||
'waitingForUserUntil',
|
||||
'planTitle',
|
||||
'planId',
|
||||
'url',
|
||||
'method',
|
||||
'diagnostics',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not return a success row when no completed deep research report exists', async () => {
|
||||
const command = getRegistry().get('chatgpt/deep-research-result');
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
||||
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
evaluate: vi.fn((script) => {
|
||||
const s = String(script);
|
||||
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
|
||||
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
text: JSON.stringify({ mapping: {} }),
|
||||
});
|
||||
}
|
||||
if (s.includes("document.querySelectorAll('iframe')")) {
|
||||
return Promise.resolve({
|
||||
url: 'https://chatgpt.com/c/requested123',
|
||||
title: 'ChatGPT',
|
||||
iframes: [],
|
||||
deepResearchIframe: null,
|
||||
});
|
||||
}
|
||||
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
|
||||
return Promise.resolve({
|
||||
url: 'https://chatgpt.com/c/requested123',
|
||||
title: 'ChatGPT',
|
||||
hasComposer: true,
|
||||
isLoggedIn: true,
|
||||
hasLoginGate: false,
|
||||
});
|
||||
}
|
||||
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(command.func(page, { id: 'requested123' }))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('returns structured deep research progress rows without a completed report', async () => {
|
||||
const command = getRegistry().get('chatgpt/deep-research-result');
|
||||
const payload = {
|
||||
conversation_id: 'requested123',
|
||||
mapping: {
|
||||
progress_node: {
|
||||
message: {
|
||||
metadata: {
|
||||
chatgpt_sdk: {
|
||||
widget_state: JSON.stringify({
|
||||
status: 'waiting_for_user_response_on_plan',
|
||||
waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z',
|
||||
plan: {
|
||||
plan_id: 'plan-demo',
|
||||
title: 'Research plan',
|
||||
},
|
||||
}),
|
||||
response_metadata: {
|
||||
async_task_conversation_id: 'async-conversation-123',
|
||||
'openai/widgetSessionId': 'widget-session-123',
|
||||
'openai/asyncStatus': 7,
|
||||
venus_message_type: 'initial_loading_message',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
||||
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
evaluate: vi.fn((script) => {
|
||||
const s = String(script);
|
||||
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
|
||||
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
text: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
if (s.includes("document.querySelectorAll('iframe')")) {
|
||||
return Promise.resolve({
|
||||
url: 'https://chatgpt.com/c/requested123',
|
||||
title: 'ChatGPT',
|
||||
iframes: [],
|
||||
deepResearchIframe: null,
|
||||
});
|
||||
}
|
||||
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
|
||||
return Promise.resolve({
|
||||
url: 'https://chatgpt.com/c/requested123',
|
||||
title: 'ChatGPT',
|
||||
hasComposer: true,
|
||||
isLoggedIn: true,
|
||||
hasLoginGate: false,
|
||||
});
|
||||
}
|
||||
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(command.func(page, { id: 'requested123' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
conversationId: 'requested123',
|
||||
status: 'waiting_for_user',
|
||||
report: '',
|
||||
sources: [],
|
||||
asyncTaskConversationId: 'async-conversation-123',
|
||||
widgetSessionId: 'widget-session-123',
|
||||
asyncStatus: 7,
|
||||
venusMessageType: 'initial_loading_message',
|
||||
venusStatus: 'waiting_for_user_response_on_plan',
|
||||
waitingForUserUntil: '2026-07-02T02:29:48.298274Z',
|
||||
planTitle: 'Research plan',
|
||||
planId: 'plan-demo',
|
||||
method: 'conversation-widget-progress',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('typed-fails malformed deep research source rows instead of falling back to empty success', async () => {
|
||||
const command = getRegistry().get('chatgpt/deep-research-result');
|
||||
const report = `# Executive Summary\n\n${'Completed Deep Research report paragraph with enough detail to pass extraction heuristics. '.repeat(12)}\n\n## Sources`;
|
||||
const payload = {
|
||||
conversation_id: 'requested123',
|
||||
mapping: {
|
||||
report_node: {
|
||||
message: {
|
||||
metadata: {
|
||||
chatgpt_sdk: {
|
||||
widget_state: JSON.stringify({
|
||||
status: 'completed',
|
||||
report_message: {
|
||||
id: 'report-msg',
|
||||
content: { parts: [report] },
|
||||
metadata: {
|
||||
search_result_groups: [
|
||||
{ entries: [{ title: 'Source without URL' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
||||
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
evaluate: vi.fn((script) => {
|
||||
const s = String(script);
|
||||
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
|
||||
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
text: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
if (s.includes("document.querySelectorAll('iframe')")) {
|
||||
return Promise.resolve({
|
||||
url: 'https://chatgpt.com/c/requested123',
|
||||
title: 'ChatGPT',
|
||||
iframes: [],
|
||||
deepResearchIframe: null,
|
||||
});
|
||||
}
|
||||
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
|
||||
return Promise.resolve({
|
||||
url: 'https://chatgpt.com/c/requested123',
|
||||
title: 'ChatGPT',
|
||||
hasComposer: true,
|
||||
isLoggedIn: true,
|
||||
hasLoginGate: false,
|
||||
});
|
||||
}
|
||||
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(command.func(page, { id: 'requested123' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('registers project routing on chat-starting commands', () => {
|
||||
for (const name of ['new', 'image', 'model']) {
|
||||
const cmd = getRegistry().get(`chatgpt/${name}`);
|
||||
expect(cmd.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'project', valueRequired: true }),
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
it('starts a new chat inside a project when new receives project routing', async () => {
|
||||
const cmd = getRegistry().get('chatgpt/new');
|
||||
const page = createProjectUploadPageMock();
|
||||
|
||||
await expect(cmd.func(page, { project: '12345678' }))
|
||||
.resolves.toEqual([{ Status: 'New chat started' }]);
|
||||
expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/g/g-p-12345678', { settleMs: 2000 });
|
||||
});
|
||||
|
||||
it('registers chatgpt model with web model choices', () => {
|
||||
const model = getRegistry().get('chatgpt/model');
|
||||
expect(model.args).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'model',
|
||||
positional: true,
|
||||
required: true,
|
||||
choices: expect.arrayContaining(['fast', 'speed', 'instant', 'balanced', 'balance', 'advanced', 'high', 'thinking', 'very-high', 'ultra', 'xhigh', 'x-high', 'pro', 'professional']),
|
||||
}),
|
||||
expect.objectContaining({ name: 'project', valueRequired: true }),
|
||||
]));
|
||||
expect(model.columns).toEqual(['Status', 'Model']);
|
||||
});
|
||||
|
||||
it('rejects off-domain conversation URLs before ask/send can navigate', async () => {
|
||||
const ask = getRegistry().get('chatgpt/ask');
|
||||
const send = getRegistry().get('chatgpt/send');
|
||||
const page = {
|
||||
goto: () => {
|
||||
throw new Error('should not navigate');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(ask.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' }))
|
||||
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
await expect(send.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' }))
|
||||
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
});
|
||||
|
||||
it('does not expose command-level system proxy mutation for project-file-add', () => {
|
||||
const cmd = getRegistry().get('chatgpt/project-file-add');
|
||||
expect(cmd.args.map(arg => arg.name)).toEqual(['file', 'id']);
|
||||
});
|
||||
|
||||
it('rejects empty project-file-add file input', async () => {
|
||||
const cmd = getRegistry().get('chatgpt/project-file-add');
|
||||
await expect(cmd.func(createProjectUploadPageMock(), { file: ' , ', id: '12345678' }))
|
||||
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
});
|
||||
|
||||
it('maps successful project-file-add uploads to table rows', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'report.pdf');
|
||||
fs.writeFileSync(filePath, 'fake-pdf');
|
||||
|
||||
const cmd = getRegistry().get('chatgpt/project-file-add');
|
||||
await expect(cmd.func(createProjectUploadPageMock(), { file: filePath, id: '12345678' }))
|
||||
.resolves.toEqual([
|
||||
{
|
||||
Status: '📄 uploaded to project knowledge',
|
||||
File: filePath,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps project-file-add local file validation failures to argument errors', async () => {
|
||||
const cmd = getRegistry().get('chatgpt/project-file-add');
|
||||
await expect(cmd.func(createProjectUploadPageMock(), { file: '/no/such/report.pdf', id: '12345678' }))
|
||||
.rejects.toMatchObject({
|
||||
code: 'ARGUMENT',
|
||||
message: expect.stringContaining('File not found'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
CHATGPT_DOMAIN,
|
||||
CHATGPT_URL,
|
||||
currentChatGPTUrl,
|
||||
ensureChatGPTLogin,
|
||||
getChatGPTDeepResearchResult,
|
||||
normalizeBooleanFlag,
|
||||
parseChatGPTConversationId,
|
||||
requireNonNegativeInt,
|
||||
requirePositiveInt,
|
||||
waitForChatGPTDeepResearchResult,
|
||||
} from './utils.js';
|
||||
|
||||
function hasDeepResearchProgress(result) {
|
||||
return !!result
|
||||
&& result.status !== 'completed'
|
||||
&& result.progress
|
||||
&& typeof result.progress === 'object'
|
||||
&& !Array.isArray(result.progress)
|
||||
&& Object.keys(result.progress).length > 0;
|
||||
}
|
||||
|
||||
export const deepResearchResultCommand = cli({
|
||||
site: 'chatgpt',
|
||||
name: 'deep-research-result',
|
||||
access: 'read',
|
||||
description: 'Read a completed ChatGPT Deep Research report from the conversation payload',
|
||||
domain: CHATGPT_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/<id> URL' },
|
||||
{ name: 'wait', type: 'boolean', default: false, help: 'Wait until Deep Research completes or becomes extractable' },
|
||||
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' },
|
||||
{ name: 'stable', type: 'int', default: 6, help: 'Seconds the report text must remain unchanged when --wait is true' },
|
||||
],
|
||||
columns: [
|
||||
'conversationId',
|
||||
'status',
|
||||
'report',
|
||||
'sources',
|
||||
'progress',
|
||||
'asyncTaskConversationId',
|
||||
'widgetSessionId',
|
||||
'asyncStatus',
|
||||
'venusMessageType',
|
||||
'venusStatus',
|
||||
'waitingForUserUntil',
|
||||
'planTitle',
|
||||
'planId',
|
||||
'url',
|
||||
'method',
|
||||
'diagnostics',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const id = parseChatGPTConversationId(kwargs.id);
|
||||
const shouldWait = normalizeBooleanFlag(kwargs.wait, false);
|
||||
const timeout = requirePositiveInt(
|
||||
Number(kwargs.timeout ?? 120),
|
||||
'chatgpt deep-research-result --timeout',
|
||||
'Example: opencli chatgpt deep-research-result <id> --wait true --timeout 600',
|
||||
);
|
||||
const stableSeconds = requireNonNegativeInt(
|
||||
Number(kwargs.stable ?? 6),
|
||||
'chatgpt deep-research-result --stable',
|
||||
'Example: opencli chatgpt deep-research-result <id> --wait true --stable 6',
|
||||
);
|
||||
const targetUrl = `${CHATGPT_URL}/c/${id}`;
|
||||
await page.readNetworkCapture?.().catch(() => []);
|
||||
const currentUrl = await currentChatGPTUrl(page).catch(() => '');
|
||||
if (currentUrl.startsWith(targetUrl)) {
|
||||
await page.goto(`${CHATGPT_URL}/?opencli_dr_result=${Date.now()}`, { waitUntil: 'none' });
|
||||
await page.wait(1);
|
||||
}
|
||||
await page.startNetworkCapture?.('/backend-api/conversation/').catch(() => false);
|
||||
await page.goto(targetUrl, { waitUntil: 'none' });
|
||||
await page.sleep(3);
|
||||
await ensureChatGPTLogin(page, 'ChatGPT deep-research-result requires a logged-in ChatGPT session.');
|
||||
|
||||
const result = shouldWait
|
||||
? await waitForChatGPTDeepResearchResult(page, { conversationId: id, timeoutSeconds: timeout, stableSeconds })
|
||||
: await getChatGPTDeepResearchResult(page, { conversationId: id, useBridgeProbes: true });
|
||||
|
||||
if (result.status !== 'completed' && !hasDeepResearchProgress(result)) {
|
||||
throw new EmptyResultError(
|
||||
'chatgpt deep-research-result',
|
||||
`No completed Deep Research report was found for conversation ${id}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === 'completed' && !result.report) {
|
||||
throw new EmptyResultError(
|
||||
'chatgpt deep-research-result',
|
||||
`No completed Deep Research report was found for conversation ${id}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [{
|
||||
conversationId: id,
|
||||
status: result.status,
|
||||
report: result.report || '',
|
||||
sources: result.sources || [],
|
||||
progress: result.progress || {},
|
||||
asyncTaskConversationId: result.asyncTaskConversationId || '',
|
||||
widgetSessionId: result.widgetSessionId || '',
|
||||
asyncStatus: result.asyncStatus ?? '',
|
||||
venusMessageType: result.venusMessageType || '',
|
||||
venusStatus: result.venusStatus || '',
|
||||
waitingForUserUntil: result.waitingForUserUntil || '',
|
||||
planTitle: result.planTitle || '',
|
||||
planId: result.planId || '',
|
||||
url: result.url || targetUrl,
|
||||
method: result.method || '',
|
||||
diagnostics: result.diagnostics || {},
|
||||
}];
|
||||
},
|
||||
});
|
||||
+23
-11
@@ -5,10 +5,12 @@ import {
|
||||
CHATGPT_URL,
|
||||
CONVERSATION_MESSAGE_SELECTOR,
|
||||
ensureChatGPTLogin,
|
||||
getVisibleMessages,
|
||||
messageHtmlToMarkdown,
|
||||
getChatGPTDetailRows,
|
||||
normalizeBooleanFlag,
|
||||
parseChatGPTConversationId,
|
||||
requireNonNegativeInt,
|
||||
requirePositiveInt,
|
||||
waitForChatGPTDetailRows,
|
||||
} from './utils.js';
|
||||
|
||||
export const detailCommand = cli({
|
||||
@@ -24,11 +26,25 @@ export const detailCommand = cli({
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/<id> URL' },
|
||||
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
|
||||
{ name: 'wait', type: 'boolean', default: false, help: 'Wait until the conversation stops generating and stabilizes' },
|
||||
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' },
|
||||
{ name: 'stable', type: 'int', default: 6, help: 'Seconds the final messages must remain unchanged when --wait is true' },
|
||||
],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
columns: ['Index', 'Role', 'Text', 'Generating', 'StableSeconds'],
|
||||
func: async (page, kwargs) => {
|
||||
const id = parseChatGPTConversationId(kwargs.id);
|
||||
const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false);
|
||||
const shouldWait = normalizeBooleanFlag(kwargs.wait, false);
|
||||
const timeout = requirePositiveInt(
|
||||
Number(kwargs.timeout ?? 120),
|
||||
'chatgpt detail --timeout',
|
||||
'Example: opencli chatgpt detail <id> --wait true --timeout 600',
|
||||
);
|
||||
const stableSeconds = requireNonNegativeInt(
|
||||
Number(kwargs.stable ?? 6),
|
||||
'chatgpt detail --stable',
|
||||
'Example: opencli chatgpt detail <id> --wait true --stable 6',
|
||||
);
|
||||
await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 });
|
||||
try {
|
||||
await page.wait({ selector: CONVERSATION_MESSAGE_SELECTOR, timeout: 10 });
|
||||
@@ -36,16 +52,12 @@ export const detailCommand = cli({
|
||||
// Empty conversation, missing access, or login redirect — handled by ensureChatGPTLogin / EmptyResultError below.
|
||||
}
|
||||
await ensureChatGPTLogin(page, 'ChatGPT detail requires a logged-in ChatGPT session.');
|
||||
const messages = await getVisibleMessages(page);
|
||||
const { messages, rows } = shouldWait
|
||||
? await waitForChatGPTDetailRows(page, { wantMarkdown, timeoutSeconds: timeout, stableSeconds })
|
||||
: await getChatGPTDetailRows(page, { wantMarkdown });
|
||||
if (!messages.length) {
|
||||
throw new EmptyResultError('chatgpt detail', `No visible ChatGPT messages were found for conversation ${id}.`);
|
||||
}
|
||||
return messages.map((message) => ({
|
||||
Index: message.Index,
|
||||
Role: message.Role,
|
||||
Text: wantMarkdown && message.Role === 'Assistant' && message.Html
|
||||
? (messageHtmlToMarkdown(message.Html) || message.Text)
|
||||
: message.Text,
|
||||
}));
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { saveBase64ToFile } from '@jackwener/opencli/utils';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
|
||||
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, navigateToProject, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
|
||||
|
||||
const CHATGPT_DOMAIN = 'chatgpt.com';
|
||||
|
||||
@@ -72,6 +72,7 @@ export const imageCommand = cli({
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
|
||||
{ name: 'image', help: 'Local image path to attach before prompting; comma-separated paths are supported' },
|
||||
{ name: 'project', valueRequired: true, help: 'Start image generation inside a ChatGPT project ID or /g/g-p-<id> URL' },
|
||||
{ name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
|
||||
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
|
||||
{ name: 'timeout', type: 'int', required: false, default: 240, help: 'Max seconds for the overall command (default: 240)' },
|
||||
@@ -92,8 +93,12 @@ export const imageCommand = cli({
|
||||
throw new ArgumentError(preparedImages.reason);
|
||||
}
|
||||
|
||||
// Navigate to chatgpt.com/new with full reload to clear React sidebar state
|
||||
await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
|
||||
// Navigate with full reload to clear React sidebar state before editing the draft.
|
||||
if (kwargs.project) {
|
||||
await navigateToProject(page, kwargs.project);
|
||||
} else {
|
||||
await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
|
||||
}
|
||||
await clearChatGPTDraft(page);
|
||||
|
||||
if (imagePaths.length) {
|
||||
@@ -123,7 +128,7 @@ export const imageCommand = cli({
|
||||
for (let ci = 0; ci < 10; ci++) {
|
||||
const url = await currentChatGPTLink(page);
|
||||
if (url.includes('/c/')) { convUrl = url; break; }
|
||||
await page.wait(2);
|
||||
await page.sleep(2);
|
||||
}
|
||||
if (!convUrl) {
|
||||
convUrl = await currentChatGPTLink(page);
|
||||
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
prepareChatGPTImagePaths: vi.fn(),
|
||||
sendChatGPTMessage: vi.fn(),
|
||||
uploadChatGPTImages: vi.fn(),
|
||||
navigateToProject: vi.fn(),
|
||||
waitForChatGPTImages: vi.fn(),
|
||||
getChatGPTImageAssets: vi.fn(),
|
||||
saveBase64ToFile: vi.fn(),
|
||||
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock('./utils.js', () => ({
|
||||
clearChatGPTDraft: mocks.clearChatGPTDraft,
|
||||
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
|
||||
navigateToProject: mocks.navigateToProject,
|
||||
normalizeBooleanFlag: (value, fallback = false) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value == null || value === '') return fallback;
|
||||
@@ -56,6 +58,7 @@ beforeEach(() => {
|
||||
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
|
||||
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
|
||||
mocks.uploadChatGPTImages.mockReset().mockResolvedValue({ ok: true });
|
||||
mocks.navigateToProject.mockReset().mockResolvedValue(undefined);
|
||||
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
|
||||
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
|
||||
url: 'https://images.example/generated.png',
|
||||
@@ -89,6 +92,22 @@ describe('chatgpt image output paths', () => {
|
||||
});
|
||||
|
||||
describe('chatgpt image upload flow', () => {
|
||||
it('starts image generation inside a specified project', async () => {
|
||||
const page = createPage();
|
||||
await imageCommand.func(page, {
|
||||
prompt: 'cat in a lab',
|
||||
project: '12345678',
|
||||
op: '',
|
||||
sd: true,
|
||||
timeout: 240,
|
||||
});
|
||||
|
||||
expect(mocks.navigateToProject).toHaveBeenCalledWith(page, '12345678');
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
expect(mocks.clearChatGPTDraft).toHaveBeenCalled();
|
||||
expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(page, 'Generate an image of: cat in a lab');
|
||||
});
|
||||
|
||||
it('uploads local images before sending an edit prompt', async () => {
|
||||
mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: true, paths: ['/abs/cat.png', '/abs/dog.jpg'] });
|
||||
await imageCommand.func(createPage(), {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
CHATGPT_DOMAIN,
|
||||
CHATGPT_MODEL_CHOICES,
|
||||
navigateToProject,
|
||||
selectChatGPTModel,
|
||||
} from './utils.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'chatgpt',
|
||||
name: 'model',
|
||||
access: 'write',
|
||||
description: 'Switch ChatGPT web model or intelligence level (GPT-5.6 Pro, fast, balanced, advanced, very-high, pro)',
|
||||
domain: CHATGPT_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'model', required: true, positional: true, help: 'ChatGPT model or intelligence level to switch to', choices: CHATGPT_MODEL_CHOICES },
|
||||
{ name: 'project', valueRequired: true, help: 'Open a ChatGPT project ID or /g/g-p-<id> URL before switching intelligence level' },
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page, kwargs) => {
|
||||
if (kwargs.project) {
|
||||
await navigateToProject(page, kwargs.project);
|
||||
}
|
||||
const result = await selectChatGPTModel(page, kwargs.model);
|
||||
return [{ Status: result.Status, Model: result.Model }];
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user