Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8b1d454d1 | |||
| 678d0086d8 | |||
| 9139baaef8 | |||
| f9abec1455 | |||
| 77b29b3d09 | |||
| a25a2836e9 | |||
| 5a82ecfd3b | |||
| 4a0d26835f | |||
| 72d20c9113 | |||
| d8fc0a9e2b | |||
| be14222a9f | |||
| 37b1289264 | |||
| 82dda11a2a | |||
| 3f1a723b5c | |||
| 880c7d37c4 | |||
| 46c8fe8e2e | |||
| 04fd4f86f8 | |||
| 944ca3a105 | |||
| c08d0e28b2 | |||
| 19228d721e | |||
| ec3eddec2a | |||
| 62bd1175e2 | |||
| f192f69761 | |||
| 53d62b0cc2 | |||
| 323f5318eb | |||
| c45dd409c0 | |||
| 3dcddb6293 | |||
| dc67023be8 | |||
| 65cab71b07 | |||
| cc760810a2 | |||
| 7731e36388 | |||
| 55d91906d3 | |||
| 24e6be9165 | |||
| 2615d331e8 | |||
| a73301f7bf | |||
| 6d99979c4d | |||
| 1aec5c59dc | |||
| 707cebd042 | |||
| 5e938a3245 | |||
| 98b978eaba | |||
| 594c9a628f | |||
| 0a4a2cff2f | |||
| 10acaa9541 | |||
| 8d3e7d459a | |||
| cbf1ac1558 | |||
| 54270cdc4d | |||
| d14930201a | |||
| 6cde68689b | |||
| 203ff56e2d | |||
| a1555e8f19 | |||
| 76fcc28c99 | |||
| 6126413d60 | |||
| 5b11251692 | |||
| 26ccbaa4c5 | |||
| 221f02f364 | |||
| 3307640a05 | |||
| c3d2fc1f9f | |||
| 06daf6f8b9 | |||
| c2f0d33293 | |||
| add386b699 | |||
| 68ebb0a277 | |||
| c32fb02a74 | |||
| 7143e52093 | |||
| 4e8bad41fb | |||
| 29e8fe9a16 | |||
| cc13dd0c0c | |||
| 56ac98cb3f | |||
| 8aa48b1094 | |||
| 7ed42a67b8 | |||
| c730a02640 | |||
| 3329a23b20 | |||
| 7362ced82d | |||
| 6b8d30b12d | |||
| e318522fbd | |||
| b6965a5973 | |||
| 52a6ce0264 | |||
| 40f270bacb | |||
| c90b355ca0 | |||
| d1076c0deb | |||
| c40a8547c6 | |||
| 6804324066 | |||
| 6ed93fdbe5 | |||
| d4640b2418 | |||
| a79a977a58 | |||
| 90e4cb9878 | |||
| e3e2a97925 | |||
| cd2c3ebf81 | |||
| 4d1da75baa | |||
| da84782969 | |||
| 299c020eb3 | |||
| 9379556078 | |||
| 377bc06367 | |||
| 0311ff3c62 | |||
| 7d0f46d009 | |||
| 5cb075d102 | |||
| ce432c2428 | |||
| 7ee16aa087 |
@@ -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)"
|
||||
+157
-8
@@ -1,23 +1,168 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## [1.8.3](https://github.com/jackwener/opencli/compare/v1.8.2...v1.8.3) (2026-06-06)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
Patch release focused on two architectural fixes around extension and daemon lifecycle, plus the first wave of the new site auth subsystem.
|
||||
|
||||
* **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.
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.19** — close the MV3 Service Worker race that spawned duplicate `OpenCLI Adapter` tab groups (and, in the worst case, duplicate Adapter windows). The extension now persists the owned `windowId` immediately after `chrome.windows.create` returns and persists the owned `groupId` immediately after `chrome.tabs.group` returns, so a worker death between those API calls and the subsequent `chrome.tabGroups.update` no longer leaves a titleless orphan group and no longer drops the window pointer. Title-update failure no longer ungroups (it lets `ensureCanonicalGroupTitle` self-heal on the next ensure cycle), and `collectOwnedGroupCandidates` gains a fourth recovery layer: a global scan for empty-title groups containing a known owned `preferredTabId` for the role, with explicit hijack defense for user-built untitled groups. Closes the duplicate-tab-group bug report users had reported across the 1.8.2 window. ([#1862](https://github.com/jackwener/opencli/pull/1862))
|
||||
* **daemon** — SIGKILL fallback when the stale daemon refuses graceful shutdown. After `npm install -g @jackwener/opencli@latest`, the CLI detects a version-mismatched daemon (`daemonVersion !== PKG_VERSION`), asks it to exit via `/shutdown`, and now — if the port is still held after 3 s — reads the stale daemon's pid from its own `/status` response and `process.kill(pid, 'SIGKILL')` (cross-platform: maps to `TerminateProcess` on Windows). The previous flow surfaced `Stale daemon could not be replaced` and asked users to run `opencli daemon stop && opencli doctor`; this is now automatic. ([#1861](https://github.com/jackwener/opencli/pull/1861))
|
||||
* **xiaohongshu/publish** — prioritize the visible title input when the editor renders both a hidden draft input and a visible publish input.
|
||||
* **xiaohongshu/publish** — accept inline topic suggestions with Enter when the dropdown lives inside a Shadow DOM surface, while still verifying the topic marker appears in the editor.
|
||||
* **instagram/following** — paginate beyond the first endpoint page so high `--limit` values return more than the initial batch.
|
||||
|
||||
### Features
|
||||
|
||||
* **site auth subsystem** — new `opencli <site> login` and `opencli <site> whoami` commands, registered through a shared `clis/_shared/site-auth.js` helper. `login` opens the site's auth page in a foreground persistent session and polls the configured `verify` probe (cookie, JSON API, DOM scrape) until the browser session reports logged-in; `whoami` runs the same probe without opening the page. First five sites: twitter, github, bilibili, douyin, xiaohongshu. `whoami` outputs are PII-scrubbed (no email / phone / token in row columns). ([#1852](https://github.com/jackwener/opencli/pull/1852))
|
||||
* **gemini** — add read-only conversation commands (list / read / search).
|
||||
* **manus** — add a read-only `manus.im` adapter.
|
||||
|
||||
### Docs / Sitemap
|
||||
|
||||
* **sitemaps/xiaohongshu** — Phase 2 sitemap content seeded with login schema dogfood, the first non-PoC consumer of the v1.1 sitemap schema. ([#1853](https://github.com/jackwener/opencli/pull/1853))
|
||||
|
||||
### Internal
|
||||
|
||||
* **test(e2e)** — raise `runCli` `maxBuffer` so manifest-output snapshots no longer truncate on macOS / Windows CI.
|
||||
|
||||
## [1.8.2](https://github.com/jackwener/opencli/compare/v1.8.1...v1.8.2) (2026-06-03)
|
||||
|
||||
Mid-cycle release: introduces the **Site Maps Hub** subsystem (agent-facing per-site navigation knowledge), restores the **smart-search** skill, and ships a wide batch of new adapters / commands plus a long tail of read-path fixes. Extension bumped to 1.0.18 for an owned-group reusable-tab scope fix.
|
||||
|
||||
### Site Maps Hub (new subsystem)
|
||||
|
||||
* **`sitemaps/<site>/` top-level seed directory** — sitemap content lives alongside `clis/` and `skills/`, parallel first-class repo citizens. Twitter and HackerNews seeded as v1 baselines.
|
||||
* **`opencli browser open` / `analyze` surface sitemap availability** — when the requested site has a sitemap (global seed or local overlay `~/.opencli/sites/<site>/sitemap/`), the JSON envelope gains an optional `sitemap` field with `{ available, source, hint }`. `open` emits the hint once per session per site (deduped via `~/.opencli/cache/browser-sitemap-hints/`); `analyze` emits every call since it is a planning command. Adds no new browser-action behavior and no `~/.opencli/sites/` writes unless an agent explicitly invokes a sitemap skill.
|
||||
* **Two new skills**:
|
||||
* `opencli-sitemap-author` — create / maintain per-site sitemaps. Two-layer storage (global repo seed + local overlay), Form B compact YAML action schema with `pre / do / post / fail / recover / evidence`, `adapter_health_update` directives, `selector_pattern` as first-class anchor type, partial pages (`_<name>.md`) for cross-page UI, and a size-guidance table with hard 800-token / 1500-3000 cohesion / >3000 split tiers.
|
||||
* `opencli-browser-sitemap` — consume site sitemaps while executing browser tasks. Lazy load, Trust-Reality rule (`browser state` is truth, sitemap is hint), stale-on-conflict writeback, `adapter_health` write-back closure so subsequent agents skip a known-suspect adapter.
|
||||
* **`references/sitemap-schema.md`** — full field-level spec for `SITE.md / pages/<id>.md / workflows/<id>.md / apis.md / pitfalls.md`, action `state_signature` for re-entry, `adapter_health` enum, stable-id matching across overlay layers, draft placement rule, Phase 2 validation hooks.
|
||||
* **Twitter + HackerNews v1.1 seeds** under `sitemaps/{twitter,hackernews}/` validating the schema on dense React UI and simple SSR HTML respectively.
|
||||
|
||||
### Features
|
||||
|
||||
* **smart-search** — restored as a skill (`skills/smart-search/`) with per-category source guides (AI / info / media / shopping / social / tech / travel / other).
|
||||
* **twitter** — batch follow + list lifecycle (`list-create` / `list-delete` / `list-add` / `list-remove` batch forms).
|
||||
* **xiaohongshu** — draft management commands (`drafts` / `draft-open` / `draft-delete` / `draft-clear`).
|
||||
* **chatgpt-app** — temporary chat + multi-modal image attachment support.
|
||||
* **antigravity** — history mgmt (`history` / `delete` / `mark-read`) and model read/switch commands.
|
||||
* **codex** — conversation management (`pin` / `unpin` / `archive` / `rename`) plus model selector fix.
|
||||
* **grok** — conversation management (`delete` / `pin` / `unpin`) with locale-independent selectors.
|
||||
* **kimi** — new adapter for `kimi.com` (21 commands).
|
||||
* **qoder** — new adapter for Qoder IDE (19 commands).
|
||||
* **trae-cn** — new desktop adapter (Trae CN Electron app).
|
||||
* **trae-solo** — new desktop adapter (Trae SOLO Electron app).
|
||||
* **chatgpt** — add web model switch command.
|
||||
* **douyin** — add `search` command for keyword video search.
|
||||
* **wechat-channels** — add WeChat Video Channels (视频号) publish adapter.
|
||||
* **pubmed** — add workflow presets and richer article metadata.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.18** — scope reusable-tab selection to owned-group members (follow-up to the v1.0.17 owned-container convergence model; ensures `findReusableOwnedContainerTab` does not pick up user tabs that were dragged into the owned window).
|
||||
* **chatgpt** — ignore image placeholders and upload previews when extracting the latest assistant message.
|
||||
* **xiaohongshu** — attach real topics via inline dropdown; feed returns signed note URLs for drill-down; carousel order preserved on download.
|
||||
* **twitter** — drop global tweetPhoto selector from the post-submit poll to avoid matching the wrong button.
|
||||
* **grok** — fall back to `Enter` key dispatch when send button is hidden behind layout shifts.
|
||||
* **daemon** — differentiate multi-profile status output so multiple Chrome profiles do not collapse into a single status row.
|
||||
* **youtube** — Videos tab fallback now supports `lockupViewModel` format alongside the legacy `gridVideoRenderer`.
|
||||
* **12306** — accept lowercase letters in `train_no` regex.
|
||||
* **weixin** — strip typographic quotes from pasted URLs.
|
||||
* **launcher** — Chromium 142+ CDP websocket origin check needs `--remote-allow-origins=*`.
|
||||
* **douyin/publish** — handle illegal-title errors with a typed error rather than a silent retry.
|
||||
|
||||
### Docs
|
||||
|
||||
* **opencli-adapter-author** — add `references/strategy-selection.md` codifying the empirical contract ladder (PUBLIC_API / COOKIE_API / UI_SELECTOR / DOM_STATE as contracted vs PAGE_FETCH / INTERCEPT as internal-unstable, with fixes/adapter-year data from a 837-adapter / 30-day window) and update SKILL.md to require a `strategy` evidence block at the top of every new adapter.
|
||||
* **opencli-adapter-author** — `browser analyze` upgrade: each candidate API gets `real_data_score` and a `likely_data` / `maybe_data` / `noise` verdict so Pattern A is no longer fired by analytics XHRs.
|
||||
* **readme** — prefix "Let AI Agents operate any website" bullet with "Browser User &" in both EN and zh-CN.
|
||||
|
||||
## [1.8.1](https://github.com/jackwener/opencli/compare/v1.8.0...v1.8.1) (2026-05-31)
|
||||
|
||||
Patch release focused on the extension tab-group convergence fix, plus 10 new adapters/commands and a wave of read-path / security hardening across browser, download, and adapters.
|
||||
|
||||
### Features
|
||||
|
||||
* **chess** — add Chess.com browser adapter.
|
||||
* **geogebra** — add GeoGebra browser adapter suite.
|
||||
* **jira / confluence** — add Atlassian Jira and Confluence adapter support. ([#1690](https://github.com/jackwener/opencli/pull/1690))
|
||||
* **upwork** — add `search`, `feed`, and `detail` commands.
|
||||
* **notebooklm** — add guarded write commands.
|
||||
* **bilibili** — add comment commands.
|
||||
* **weread** — add book search inside an open WeRead book.
|
||||
* **linkedin** — consolidate read commands and add `profile-experience`.
|
||||
* **xiaohongshu** — paginate `creator-notes` past the analyze list cap.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.16** — ship the `OpenCLI Browser` / `OpenCLI Adapter` tab-group race fix from [#1693](https://github.com/jackwener/opencli/pull/1693). The extension now serializes owned tab-group creation per role so concurrent adapter/browser leases reuse the same group instead of creating duplicate same-title groups.
|
||||
* **extension 1.0.17** — replace owned tab-group management with a Chrome-state-as-truth convergence model. The extension now keeps one canonical `OpenCLI Browser` / `OpenCLI Adapter` group per profile role, recovers renamed groups from stored hints or owned lease tabs, merges same-window and cross-window duplicates into the canonical group, and normalizes legacy or user-renamed container titles back to the canonical owned-container title. ⚠️ User-renamed `OpenCLI Browser` / `OpenCLI Adapter` groups are now force-renamed back; treat these as extension-managed automation containers, not user free-form bins. ([#1794](https://github.com/jackwener/opencli/pull/1794))
|
||||
* **browser** — write the network response cache file with `0o600` owner-only permissions to keep captured response bodies out of other local users' reach.
|
||||
* **download** — write the yt-dlp cookie file with `0o600` owner-only permissions.
|
||||
* **pixiv** — migrate `user/detail` to the shared `pixivFetch` helper.
|
||||
* **twitter** — drop unknown silent sentinels; read profile `name` / `created_at` from `result.core`; handle `NotAllowed` image-upload fallback; detect private `likes` / `following` empty-timeline shape. ([#1702](https://github.com/jackwener/opencli/pull/1702))
|
||||
* **weread** — decode HTML entities in search results.
|
||||
* **zhihu** — decode numeric HTML entities in text output. ([#1695](https://github.com/jackwener/opencli/pull/1695))
|
||||
* **xiaohongshu** — hook dashboard fetch to capture signed `datacenter/note/*` responses ([#1732](https://github.com/jackwener/opencli/pull/1732)); preserve carousel order via `__INITIAL_STATE__.imageList` on download ([#1687](https://github.com/jackwener/opencli/pull/1687)).
|
||||
* **bilibili** — subtitle support for bangumi / PGC bvid (番剧 / 纪录片 / 电影 / 综艺). ([#1669](https://github.com/jackwener/opencli/pull/1669))
|
||||
* **suno** — derive current plan from subscription metadata.
|
||||
* **douyin/hashtag** — validate action args before navigation.
|
||||
* **byte-formatting** — stabilize byte formatting output.
|
||||
|
||||
### Docs
|
||||
|
||||
* **readme** — correct Node floor (>=20, not 21) and drop the Prerequisites section ([#1705](https://github.com/jackwener/opencli/pull/1705)); add CLI Hub brand aliases and split Exit Codes into the dedicated docs page ([#1685](https://github.com/jackwener/opencli/pull/1685)); drop the For Developers section ([#1684](https://github.com/jackwener/opencli/pull/1684)).
|
||||
|
||||
### Internal
|
||||
|
||||
* **ci** — disable Dependabot automated updates.
|
||||
* **test(download)** — retry media-download Windows tests to absorb runner cold-start variance. ([#1708](https://github.com/jackwener/opencli/pull/1708))
|
||||
|
||||
## [1.8.0](https://github.com/jackwener/opencli/compare/v1.7.22...v1.8.0) (2026-05-20)
|
||||
|
||||
Substantial release: a new official-API adapter (`weread-official`), wider LinkedIn / Twitter / Reddit / Zhihu coverage, the 12306 / Suno / Xianyu inbox additions, security and reliability fixes for the Browser Bridge and media downloads, plus a 20% README shrink. Node 20 compatibility is restored after an automated `undici` bump regression.
|
||||
|
||||
### Features
|
||||
|
||||
* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter.
|
||||
* **12306** — add full read adapter (`stations` / `trains` / `train` / `price` / `me` / `passengers` / `orders`). ([#1637](https://github.com/jackwener/opencli/issues/1637))
|
||||
* **xianyu** — add `inbox`, `messages`, and `reply` commands. ([#1639](https://github.com/jackwener/opencli/issues/1639))
|
||||
* **suno** — add Suno.com music-generation adapter. ([#1638](https://github.com/jackwener/opencli/issues/1638))
|
||||
* **linkedin** — consolidate messaging and Sales Navigator commands (`connect`, `inbox`, `safe-send`, `salesnav-search`, `salesnav-inbox`, `salesnav-message`, `salesnav-thread`, `sent-invitations`, `thread-snapshot`, `timeline`). ([#1647](https://github.com/jackwener/opencli/issues/1647))
|
||||
* **linkedin/people-search** — add a dedicated people-search command. ([#1649](https://github.com/jackwener/opencli/issues/1649))
|
||||
* **linkedin-learning** — add `search` / `trending` / `course` read commands. ([#1657](https://github.com/jackwener/opencli/issues/1657))
|
||||
* **twitter** — rewrite the download-profile path on GraphQL UserMedia with cursor pagination. ([#1636](https://github.com/jackwener/opencli/issues/1636))
|
||||
* **twitter** — add `list-create` (GraphQL CreateList mutation). ([#1656](https://github.com/jackwener/opencli/issues/1656))
|
||||
* **twitter** — add `device-follow` notification-stream command.
|
||||
* **twitter** — expose `card.binding_values` on read commands for inline link-preview metadata. ([#1660](https://github.com/jackwener/opencli/issues/1660))
|
||||
* **twitter** — expose `quoted_tweet` on read commands. ([#1667](https://github.com/jackwener/opencli/issues/1667))
|
||||
* **twitter** — expose `bio` on read commands.
|
||||
* **reddit/subscribed** — new `subscribed` command + listing-level `id` / `created_utc` / `selftext` exposure. ([#1651](https://github.com/jackwener/opencli/issues/1651))
|
||||
* **reddit** — expose `post_hint` / `url` / `preview` / `gallery` media routes on listing commands. ([#1676](https://github.com/jackwener/opencli/issues/1676))
|
||||
* **zhihu** — add answer-comments reader; include answer links in question results.
|
||||
* **chatgpt** — detect generated image surfaces (CSS background and canvas, not just `<img>`) so image generation works after UI drift. ([#1677](https://github.com/jackwener/opencli/issues/1677))
|
||||
* **external** — add Cloudflare Wrangler as a built-in external CLI passthrough. ([#1679](https://github.com/jackwener/opencli/pull/1679))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **youtube/transcript** — scope timedtext URL matching to the current `videoId` across the in-page resource-buffer scan (`findTimedtextUrl`), the in-page fetch/XHR hook (`isJson3TimedtextUrl`), and the Node-side CDP capture (`extractSegmentsFromNetworkCapture`). Previously, SPA-style watch→watch navigation kept stale timedtext URLs from prior videos in `performance.getEntriesByType('resource')`, letting the polling loop fetch a same-language predecessor's captions and return them as the current video's transcript. Most likely to hit callers that reuse a single daemon tab to fetch many transcripts back-to-back (e.g. ml-scout).
|
||||
* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors. Douyin user video comment fetch failures, Jike SSR JSON parse failures, and WeRead search-page fetch failures now throw `CommandExecutionError`; true empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`.
|
||||
* **deps** — restore Node 20 runtime compatibility by pinning runtime `undici` back to the 6.x line (an automated dependabot bump to 8.x had moved the engines floor to Node ≥22.19, silently breaking the published Node 20 promise), and clear the docs build audit chain by overriding VitePress' Vite/PostCSS transitive dependencies to patched versions. ([#1673](https://github.com/jackwener/opencli/issues/1673))
|
||||
* **download** — keep custom media filenames inside the requested output directory by stripping POSIX/Windows path components and sanitizing the generated fallback prefix. Prevents remote-controlled fields (e.g. video titles used as filename) from escaping the output directory via `../`. ([#1642](https://github.com/jackwener/opencli/pull/1642))
|
||||
* **browser** — recover `Page.goto()` from stale page identities by clearing the cached targetId and retrying navigation once through the session lease; classify CDP `-32000 Cannot find default execution context` as retryable target navigation. ([#1645](https://github.com/jackwener/opencli/issues/1645))
|
||||
* **cli** — escape leading-dash positional values via the argv preprocessor so users can pass tokens starting with `-` without commander mis-classifying them as flags. ([#1658](https://github.com/jackwener/opencli/issues/1658))
|
||||
* **chatgpt/image** — fix ChatGPT web image generation after UI drift by letting the composer locator continue into the caller's readiness check and detecting generated images rendered as CSS backgrounds or canvases, not just plain `<img>` elements.
|
||||
* **browser** — recover `Page.goto()` from stale page identities by clearing the cached targetId and retrying navigation once through the session lease; classify CDP `-32000 Cannot find default execution context` as retryable target navigation.
|
||||
* **deps** — restore Node 20 runtime compatibility by pinning runtime `undici` back to the 6.x line, and clear the docs build audit chain by overriding VitePress' Vite/PostCSS transitive dependencies to patched versions.
|
||||
* **download** — keep custom media filenames inside the requested output directory by stripping path components and sanitizing generated fallback names.
|
||||
* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors (Douyin user video comments, Jike SSR JSON parse, WeRead search-page fetch). True empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`.
|
||||
* **adapters** — drop silent-sentinel row fallbacks across Apple Podcasts / Reddit / Gitee. ([#1634](https://github.com/jackwener/opencli/issues/1634))
|
||||
* **adapters** — migrate legal empty-data branches to `EmptyResultError` for `xhs` / YouTube and 5 follow-up commands. ([#1674](https://github.com/jackwener/opencli/issues/1674), [#1678](https://github.com/jackwener/opencli/issues/1678))
|
||||
* **lesswrong** — drop the `"Unknown"` silent sentinel in the author column; missing authors now propagate as `null`. ([#1611](https://github.com/jackwener/opencli/issues/1611))
|
||||
* **youtube/transcript** — scope timedtext URL matching to the current `videoId` across the in-page resource-buffer scan, the in-page fetch/XHR hook, and the Node-side CDP capture. SPA-style watch→watch navigation no longer returns a predecessor video's captions. ([#1655](https://github.com/jackwener/opencli/issues/1655))
|
||||
* **twitter/lists** — skip the "Discover new Lists" recommendation block so it is no longer treated as one of the user's lists. ([#1652](https://github.com/jackwener/opencli/issues/1652))
|
||||
* **zhihu** — harden search pagination. ([#1615](https://github.com/jackwener/opencli/issues/1615))
|
||||
* **zhihu** — decode numeric HTML entities in `answer-detail`. ([#1629](https://github.com/jackwener/opencli/issues/1629))
|
||||
|
||||
### Docs
|
||||
|
||||
* **readme** — major shrink and reframing: tagline rephrased around "Browser Use", Highlights and Update sections folded into adjacent content, Built-in Commands curated to 11 popular sites, CLI Hub table reduced to a name enumeration, Desktop App Adapters collapsed to a one-liner, skill-attribution references audited against `SKILL.md` frontmatter, "For AI Agents (Developer Guide)" merged into "Writing a new adapter". Net: EN 410 → 326 (-20%), ZH 455 → 371 (-18%). ([#1654](https://github.com/jackwener/opencli/pull/1654), [#1666](https://github.com/jackwener/opencli/pull/1666), [#1679](https://github.com/jackwener/opencli/pull/1679), [#1681](https://github.com/jackwener/opencli/pull/1681))
|
||||
|
||||
### Internal
|
||||
|
||||
@@ -240,6 +385,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.
|
||||
|
||||
@@ -15,13 +15,13 @@ 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**.
|
||||
OpenCLI requires **Node.js >= 20**.
|
||||
|
||||
```bash
|
||||
node --version
|
||||
@@ -104,6 +104,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 +116,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 +134,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,14 +155,6 @@ 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 |
|
||||
@@ -173,23 +171,6 @@ 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 |
|
||||
@@ -198,25 +179,27 @@ To load the source Browser Bridge extension:
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `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` |
|
||||
| **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` |
|
||||
|
||||
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 +245,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
|
||||
|
||||
@@ -311,7 +276,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
|
||||
|
||||
+17
-55
@@ -15,13 +15,13 @@ 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**。
|
||||
OpenCLI 要求 **Node.js >= 20**。
|
||||
|
||||
```bash
|
||||
node --version
|
||||
@@ -91,6 +91,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 +103,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 +121,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,14 +142,6 @@ 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 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
|
||||
|
||||
## 配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
@@ -159,23 +157,6 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
|
||||
`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` 查看完整注册表。
|
||||
@@ -186,25 +167,27 @@ npm link
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `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` |
|
||||
| **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` |
|
||||
|
||||
精选清单 — **[→ 查看全部 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 +279,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)。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -351,7 +313,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`
|
||||
|
||||
+9587
-23
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);
|
||||
},
|
||||
});
|
||||
+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
-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}`;
|
||||
|
||||
@@ -7,8 +7,8 @@ import { __test__ as trainTest } from './train.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;
|
||||
|
||||
describe('12306 utils - parseStationBundle', () => {
|
||||
it('parses the `@`-delimited station bundle into structured records', () => {
|
||||
@@ -229,6 +229,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,
|
||||
|
||||
@@ -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,52 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
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(`
|
||||
(() => {
|
||||
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('#um .vwmy h4 a, a.username, .vwmy a');
|
||||
const username = (nameEl?.innerText || '').trim();
|
||||
const uid = (nameEl?.getAttribute('href') || '').match(/uid[=-](\\d+)/)?.[1] || '';
|
||||
if (!uid && !username) {
|
||||
return { kind: 'auth', detail: '1point3acres bbs rendered but no #um identity — anonymous or shape drifted' };
|
||||
}
|
||||
return { ok: true, user_id: uid, username };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('1point3acres.com', 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,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,117 @@
|
||||
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,
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -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,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,125 @@
|
||||
/**
|
||||
* 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, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, apiPost, resolveBvid, resolveUid } from './utils.js';
|
||||
|
||||
function isAuthLikeBilibiliError(code, message) {
|
||||
return code === -101 || code === -111 || code === -403 || /csrf|登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
|
||||
}
|
||||
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
|
||||
+77
-32
@@ -1,11 +1,12 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } 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 短链' },
|
||||
@@ -16,53 +17,78 @@ cli({
|
||||
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,请检查页面是否正常加载。');
|
||||
// 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})`);
|
||||
}
|
||||
const 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 +100,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 { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
@@ -20,30 +20,177 @@ 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('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');
|
||||
});
|
||||
});
|
||||
|
||||
+63
-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 { 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,7 +44,7 @@ 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}`)); });
|
||||
});
|
||||
}
|
||||
const MIXIN_KEY_ENC_TAB = [
|
||||
@@ -104,6 +119,38 @@ export async function fetchJson(page, url) {
|
||||
}
|
||||
`);
|
||||
}
|
||||
/**
|
||||
* 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 +166,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 { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { resolveBvid, resolveUid } from './utils.js';
|
||||
describe('resolveBvid', () => {
|
||||
it('passes through a valid BV ID', async () => {
|
||||
expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
|
||||
@@ -10,8 +11,51 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './search.js';
|
||||
import { __test__ } from './search.js';
|
||||
|
||||
const {
|
||||
normalizePositiveInt,
|
||||
normalizeNonNegativeInt,
|
||||
normalizeDate,
|
||||
normalizeCurrency,
|
||||
normalizeLang,
|
||||
hasPositiveResultCount,
|
||||
buildSearchUrl,
|
||||
} = __test__;
|
||||
|
||||
describe('booking helpers — normalizePositiveInt (no silent clamp)', () => {
|
||||
it('returns default when value is undefined/null/empty', () => {
|
||||
expect(normalizePositiveInt(undefined, 2, 'adults', 30)).toBe(2);
|
||||
expect(normalizePositiveInt(null, 2, 'adults', 30)).toBe(2);
|
||||
});
|
||||
|
||||
it('accepts integers in range', () => {
|
||||
expect(normalizePositiveInt(1, 2, 'adults', 30)).toBe(1);
|
||||
expect(normalizePositiveInt(30, 2, 'adults', 30)).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects zero / negative / out-of-range / non-integer (no silent clamp)', () => {
|
||||
expect(() => normalizePositiveInt(0, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt(-1, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt(31, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt(1.5, 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
expect(() => normalizePositiveInt('abc', 2, 'adults', 30)).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeNonNegativeInt', () => {
|
||||
it('accepts zero', () => {
|
||||
expect(normalizeNonNegativeInt(0, 0, 'children', 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects negative / out-of-range (no silent clamp)', () => {
|
||||
expect(() => normalizeNonNegativeInt(-1, 0, 'children', 10)).toThrow(ArgumentError);
|
||||
expect(() => normalizeNonNegativeInt(11, 0, 'children', 10)).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeDate', () => {
|
||||
it('accepts YYYY-MM-DD', () => {
|
||||
expect(normalizeDate('2026-06-15', 'checkin')).toBe('2026-06-15');
|
||||
});
|
||||
|
||||
it('rejects bad format / nonsense dates with ArgumentError', () => {
|
||||
expect(() => normalizeDate('', 'checkin')).toThrow(ArgumentError);
|
||||
expect(() => normalizeDate('06/15/2026', 'checkin')).toThrow(ArgumentError);
|
||||
expect(() => normalizeDate('2026-13-40', 'checkin')).toThrow(ArgumentError);
|
||||
expect(() => normalizeDate('2026-02-31', 'checkin')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeCurrency', () => {
|
||||
it('passes 3-letter codes uppercased', () => {
|
||||
expect(normalizeCurrency('usd')).toBe('USD');
|
||||
expect(normalizeCurrency('JPY')).toBe('JPY');
|
||||
});
|
||||
|
||||
it('returns empty for unset', () => {
|
||||
expect(normalizeCurrency(undefined)).toBe('');
|
||||
expect(normalizeCurrency('')).toBe('');
|
||||
});
|
||||
|
||||
it('rejects non-3-letter codes', () => {
|
||||
expect(() => normalizeCurrency('US')).toThrow(ArgumentError);
|
||||
expect(() => normalizeCurrency('US$')).toThrow(ArgumentError);
|
||||
expect(() => normalizeCurrency('USDX')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — normalizeLang whitelist', () => {
|
||||
it('lowercases supported langs', () => {
|
||||
expect(normalizeLang('EN-US')).toBe('en-us');
|
||||
expect(normalizeLang('zh-cn')).toBe('zh-cn');
|
||||
});
|
||||
|
||||
it('rejects unknown langs', () => {
|
||||
expect(() => normalizeLang('xx-yy')).toThrow(ArgumentError);
|
||||
expect(() => normalizeLang('en')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — buildSearchUrl', () => {
|
||||
it('constructs canonical search URL with required params', () => {
|
||||
const url = buildSearchUrl({
|
||||
destination: 'Tokyo',
|
||||
checkin: '2026-06-15',
|
||||
checkout: '2026-06-17',
|
||||
adults: 2,
|
||||
rooms: 1,
|
||||
children: 0,
|
||||
offset: 0,
|
||||
currency: 'USD',
|
||||
lang: 'en-us',
|
||||
});
|
||||
expect(url).toContain('https://www.booking.com/searchresults.en-us.html');
|
||||
expect(url).toContain('ss=Tokyo');
|
||||
expect(url).toContain('checkin=2026-06-15');
|
||||
expect(url).toContain('checkout=2026-06-17');
|
||||
expect(url).toContain('group_adults=2');
|
||||
expect(url).toContain('no_rooms=1');
|
||||
expect(url).toContain('group_children=0');
|
||||
expect(url).toContain('selected_currency=USD');
|
||||
expect(url).not.toContain('offset=');
|
||||
});
|
||||
|
||||
it('omits lang file segment when lang is empty', () => {
|
||||
const url = buildSearchUrl({
|
||||
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
|
||||
adults: 2, rooms: 1, children: 0, offset: 0, currency: '', lang: '',
|
||||
});
|
||||
expect(url).toMatch(/booking\.com\/searchresults\.html\?/);
|
||||
});
|
||||
|
||||
it('emits offset only when > 0', () => {
|
||||
const url = buildSearchUrl({
|
||||
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
|
||||
adults: 2, rooms: 1, children: 0, offset: 25, currency: '', lang: '',
|
||||
});
|
||||
expect(url).toContain('offset=25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking helpers — hasPositiveResultCount', () => {
|
||||
it('detects positive Booking result-count evidence', () => {
|
||||
expect(hasPositiveResultCount('Tokyo: 1,234 properties found')).toBe(true);
|
||||
expect(hasPositiveResultCount('1 stay found')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat no-results text as positive evidence', () => {
|
||||
expect(hasPositiveResultCount('No properties found')).toBe(false);
|
||||
expect(hasPositiveResultCount('0 properties found')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking adapter registry shape', () => {
|
||||
it('search is registered as read with id-shaped column for round-trip', () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
expect(search).toBeDefined();
|
||||
expect(search.access).toBe('read');
|
||||
expect(search.browser).toBe(true);
|
||||
// slug + country together form the round-trip identity (URL: /hotel/<country>/<slug>.html)
|
||||
expect(search.columns).toContain('slug');
|
||||
expect(search.columns).toContain('country');
|
||||
expect(search.columns).toContain('url');
|
||||
});
|
||||
|
||||
it('search columns stay <= 12 to honor agent-native row shape', () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
expect(search.columns.length).toBeLessThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking search — typed errors (no silent fallback)', () => {
|
||||
const fakePage = { goto: () => { throw new Error('should not navigate'); } };
|
||||
|
||||
it('rejects empty destination with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: ' ', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects missing checkin/checkout with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo' })).rejects.toThrow(ArgumentError);
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects checkout <= checkin with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-17', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects out-of-range --limit with ArgumentError (no silent clamp to 100)', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', limit: 999 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects negative --offset with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: -1 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects unsupported --lang with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', lang: 'xx-yy' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects malformed --currency with ArgumentError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'US$' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('wraps browser navigation failures as CommandExecutionError', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const downPage = { goto: () => Promise.reject(new Error('browser down')) };
|
||||
await expect(search.func(downPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when extractor returns no cards', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const emptyPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'No properties found' }),
|
||||
};
|
||||
await expect(search.func(emptyPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when result-count evidence exists but no cards were parsed', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const driftPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'Tokyo: 1,234 properties found' }),
|
||||
};
|
||||
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when captcha is detected', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const blockedPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, items: [], blocked: true, totalText: 'Verify you are human' }),
|
||||
};
|
||||
await expect(search.func(blockedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when extractor payload is malformed instead of treating it as empty', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const malformedPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({ ok: true, blocked: false, totalText: 'Tokyo hotels' }),
|
||||
};
|
||||
await expect(search.func(malformedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when rendered cards lack stable hotel URL identity', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const driftPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: 'Tokyo hotels',
|
||||
items: [{
|
||||
name: 'Unlinked Hotel',
|
||||
country: '',
|
||||
slug: '',
|
||||
url: '',
|
||||
distance: '',
|
||||
review_score: null,
|
||||
review_count: null,
|
||||
star_rating: null,
|
||||
price_currency: '',
|
||||
price_amount: null,
|
||||
recommended_room: '',
|
||||
}],
|
||||
}),
|
||||
};
|
||||
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('unwraps {session, data} envelope from CDP bridge before validating', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const envelopePage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
session: 1,
|
||||
data: {
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: '',
|
||||
items: [{
|
||||
name: 'Test Hotel',
|
||||
country: 'jp',
|
||||
slug: 'test-hotel',
|
||||
url: 'https://www.booking.com/hotel/jp/test-hotel.html',
|
||||
distance: '1 km from centre',
|
||||
review_score: 8.6,
|
||||
review_count: 100,
|
||||
star_rating: 4,
|
||||
price_currency: 'USD',
|
||||
price_amount: 120,
|
||||
recommended_room: 'Standard double',
|
||||
}],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const rows = await search.func(envelopePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].rank).toBe(1);
|
||||
expect(rows[0].slug).toBe('test-hotel');
|
||||
expect(rows[0].url).toBe('https://www.booking.com/hotel/jp/test-hotel.html');
|
||||
});
|
||||
|
||||
it('uses requested selected_currency as the output source when price is present', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const currencyPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: '',
|
||||
items: [{
|
||||
name: 'Currency Hotel',
|
||||
country: 'cn',
|
||||
slug: 'currency-hotel',
|
||||
url: 'https://www.booking.com/hotel/cn/currency-hotel.html',
|
||||
distance: '',
|
||||
review_score: null,
|
||||
review_count: null,
|
||||
star_rating: null,
|
||||
price_currency: 'JPY',
|
||||
price_amount: 880,
|
||||
recommended_room: '',
|
||||
}],
|
||||
}),
|
||||
};
|
||||
const rows = await search.func(currencyPage, { destination: 'Shanghai', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'CNY' });
|
||||
expect(rows[0].price_currency).toBe('CNY');
|
||||
});
|
||||
|
||||
it('respects offset for rank numbering when paginating', async () => {
|
||||
const search = getRegistry().get('booking/search');
|
||||
const pagedPage = {
|
||||
goto: async () => {},
|
||||
wait: async () => {},
|
||||
evaluate: async () => ({
|
||||
ok: true,
|
||||
blocked: false,
|
||||
totalText: '',
|
||||
items: [
|
||||
{ name: 'A', country: 'jp', slug: 'a', url: 'https://www.booking.com/hotel/jp/a.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
|
||||
{ name: 'B', country: 'jp', slug: 'b', url: 'https://www.booking.com/hotel/jp/b.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const rows = await search.func(pagedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: 50 });
|
||||
expect(rows[0].rank).toBe(51);
|
||||
expect(rows[1].rank).toBe(52);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function normalizePositiveInt(value, defaultValue, label, max) {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
if (typeof max === 'number' && n > max) {
|
||||
throw new ArgumentError(`${label} must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value, defaultValue, label, max) {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new ArgumentError(`${label} must be a non-negative integer`);
|
||||
}
|
||||
if (typeof max === 'number' && n > max) {
|
||||
throw new ArgumentError(`${label} must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeDate(value, label) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) {
|
||||
throw new ArgumentError(`${label} is required (YYYY-MM-DD)`);
|
||||
}
|
||||
if (!DATE_RE.test(v)) {
|
||||
throw new ArgumentError(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
const [year, month, day] = v.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
Number.isNaN(d.getTime()) ||
|
||||
d.getUTCFullYear() !== year ||
|
||||
d.getUTCMonth() !== month - 1 ||
|
||||
d.getUTCDate() !== day
|
||||
) {
|
||||
throw new ArgumentError(`${label} is not a valid calendar date: ${v}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function normalizeCurrency(value) {
|
||||
if (value == null || value === '') return '';
|
||||
const v = String(value).trim().toUpperCase();
|
||||
if (!/^[A-Z]{3}$/.test(v)) {
|
||||
throw new ArgumentError(`currency must be a 3-letter ISO code (e.g. USD, JPY, CNY), got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const ALLOWED_LANGS = new Set([
|
||||
'en-us', 'en-gb', 'zh-cn', 'zh-tw', 'ja', 'ko', 'de', 'fr', 'es', 'it',
|
||||
'pt-br', 'pt-pt', 'ru', 'th', 'vi', 'tr', 'pl', 'nl', 'ar',
|
||||
]);
|
||||
|
||||
function normalizeLang(value) {
|
||||
if (value == null || value === '') return '';
|
||||
const v = String(value).trim().toLowerCase();
|
||||
if (!ALLOWED_LANGS.has(v)) {
|
||||
throw new ArgumentError(`lang must be one of: ${[...ALLOWED_LANGS].join(', ')}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function hasPositiveResultCount(text) {
|
||||
const value = String(text || '').replace(/\u00a0/g, ' ');
|
||||
const resultCount = value.match(/\b([1-9][0-9,.\s]*)\s+(?:properties|property|stays|stay|hotels|hotel)\b/i);
|
||||
if (!resultCount) return false;
|
||||
const digits = resultCount[1].replace(/\D/g, '');
|
||||
return Boolean(digits) && Number(digits) > 0;
|
||||
}
|
||||
|
||||
function buildSearchUrl({
|
||||
destination,
|
||||
checkin,
|
||||
checkout,
|
||||
adults,
|
||||
rooms,
|
||||
children,
|
||||
offset,
|
||||
currency,
|
||||
lang,
|
||||
}) {
|
||||
const file = lang ? `searchresults.${lang}.html` : 'searchresults.html';
|
||||
const params = new URLSearchParams();
|
||||
params.set('ss', destination);
|
||||
params.set('checkin', checkin);
|
||||
params.set('checkout', checkout);
|
||||
params.set('group_adults', String(adults));
|
||||
params.set('no_rooms', String(rooms));
|
||||
params.set('group_children', String(children));
|
||||
if (offset > 0) params.set('offset', String(offset));
|
||||
if (currency) params.set('selected_currency', currency);
|
||||
return `https://www.booking.com/${file}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const EXTRACTOR = `
|
||||
(() => {
|
||||
const trim = (v) => (v == null ? '' : String(v).replace(/\\s+/g, ' ').trim());
|
||||
const cards = Array.from(document.querySelectorAll('[data-testid=property-card]'));
|
||||
|
||||
// Detect blocking / captcha pages: no cards but body shows a verification prompt.
|
||||
if (cards.length === 0) {
|
||||
const text = [
|
||||
(document.title || ''),
|
||||
(document.body && document.body.innerText) || '',
|
||||
(location && location.pathname) || '',
|
||||
].join(' ');
|
||||
const blocked = /captcha|challenge|verify\\s*you\\s*are|access\\s*denied|forbidden|robot|unusual\\s*traffic/i.test(text);
|
||||
const totalEl = document.querySelector('h1');
|
||||
const totalText = trim(totalEl && totalEl.textContent);
|
||||
return { ok: true, items: [], blocked, totalText };
|
||||
}
|
||||
|
||||
const items = cards.map((card) => {
|
||||
const titleEl = card.querySelector('[data-testid=title]');
|
||||
const link = card.querySelector('a[data-testid=title-link]');
|
||||
const href = (link && link.href) || '';
|
||||
let country = '';
|
||||
let slug = '';
|
||||
let canonicalUrl = '';
|
||||
try {
|
||||
const u = new URL(href, 'https://www.booking.com');
|
||||
const m = u.pathname.match(/^\\/hotel\\/([a-z]{2})\\/([^./]+)/);
|
||||
if (m) {
|
||||
country = m[1];
|
||||
slug = m[2];
|
||||
canonicalUrl = 'https://www.booking.com/hotel/' + country + '/' + slug + '.html';
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const reviewTextRaw = trim(card.querySelector('[data-testid=review-score]')?.textContent);
|
||||
// Booking renders the score twice (a11y + visual), text reads like "Scored 8.6 8.6 Very Good 6,151 reviews"
|
||||
// or "评分8.68.6很棒 6,151条住客点评". Take only the first numeric occurrence.
|
||||
const scoreMatch = reviewTextRaw.match(/(\\d{1,2})\\.(\\d)/);
|
||||
const reviewScore = scoreMatch ? Number(scoreMatch[1] + '.' + scoreMatch[2]) : null;
|
||||
|
||||
const countMatch = reviewTextRaw.match(/([0-9][0-9,]*)\\s*(?:reviews|reseñas|avis|recensioni|条住客点评|条评论|レビュー|리뷰)/i);
|
||||
const reviewCount = countMatch ? Number(countMatch[1].replace(/,/g, '')) : null;
|
||||
|
||||
// Star rating: aria-label often "5 out of 5" / "4 星 (满分 5 星)" / "Hôtel 4 étoiles"
|
||||
let starRating = null;
|
||||
const starEl = card.querySelector('[data-testid=rating-stars], [data-testid=quality-rating]');
|
||||
if (starEl) {
|
||||
const aria = starEl.getAttribute('aria-label') || starEl.textContent || '';
|
||||
const m = aria.match(/(\\d)(?:\\s*(?:out of|\\/|星|颗星|stars?|étoiles?)|\\s*$)/i);
|
||||
if (m) starRating = Number(m[1]);
|
||||
if (starRating == null) {
|
||||
const count = starEl.querySelectorAll('svg, [aria-hidden=true]').length;
|
||||
if (count >= 1 && count <= 5) starRating = count;
|
||||
}
|
||||
}
|
||||
|
||||
const priceEl = card.querySelector('[data-testid=price-and-discounted-price]');
|
||||
const priceText = trim(priceEl && priceEl.textContent);
|
||||
|
||||
// currency symbol → ISO best-effort
|
||||
const currencySymbolMap = {
|
||||
'$': 'USD', 'US$': 'USD', 'A$': 'AUD', 'C$': 'CAD', 'HK$': 'HKD',
|
||||
'€': 'EUR', '£': 'GBP', '¥': 'JPY', '¥': 'CNY', '₹': 'INR', '₩': 'KRW',
|
||||
'CN¥': 'CNY', 'CN¥': 'CNY', 'NT$': 'TWD', 'S$': 'SGD',
|
||||
};
|
||||
let priceCurrency = '';
|
||||
let priceAmount = null;
|
||||
const sym = priceText.match(/(US\\$|A\\$|C\\$|HK\\$|NT\\$|S\\$|CN¥|CN¥|[$€£¥¥₹₩])/);
|
||||
if (sym) priceCurrency = currencySymbolMap[sym[1]] || '';
|
||||
const num = priceText.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)/);
|
||||
if (num) priceAmount = Number(num[1]);
|
||||
|
||||
return {
|
||||
name: trim(titleEl?.textContent),
|
||||
country,
|
||||
slug,
|
||||
url: canonicalUrl,
|
||||
distance: trim(card.querySelector('[data-testid=distance]')?.textContent),
|
||||
review_score: reviewScore,
|
||||
review_count: reviewCount,
|
||||
star_rating: starRating,
|
||||
price_currency: priceCurrency,
|
||||
price_amount: priceAmount,
|
||||
recommended_room: trim(card.querySelector('[data-testid=recommended-units]')?.textContent),
|
||||
};
|
||||
});
|
||||
|
||||
const totalEl = document.querySelector('h1');
|
||||
const totalText = trim(totalEl && totalEl.textContent);
|
||||
return { ok: true, items, blocked: false, totalText };
|
||||
})()
|
||||
`;
|
||||
|
||||
cli({
|
||||
site: 'booking',
|
||||
name: 'search',
|
||||
description: 'Search Booking.com hotels by destination and dates (server-rendered card scrape).',
|
||||
access: 'read',
|
||||
example: 'opencli booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml',
|
||||
domain: 'www.booking.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'destination', required: true, positional: true, help: 'Destination keyword (city, district, or hotel name)' },
|
||||
{ name: 'checkin', required: true, help: 'Check-in date YYYY-MM-DD' },
|
||||
{ name: 'checkout', required: true, help: 'Check-out date YYYY-MM-DD' },
|
||||
{ name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-30)' },
|
||||
{ name: 'rooms', type: 'int', default: 1, help: 'Number of rooms (1-30)' },
|
||||
{ name: 'children', type: 'int', default: 0, help: 'Number of children (0-10)' },
|
||||
{ name: 'currency', required: false, help: 'Force result currency (e.g. USD, JPY, CNY)' },
|
||||
{ name: 'lang', required: false, help: 'Force result language (e.g. en-us, zh-cn, ja)' },
|
||||
{ name: 'limit', type: 'int', default: 25, help: 'Max rows to return (1-100; Booking pages 25 per request)' },
|
||||
{ name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (multiple of 25)' },
|
||||
],
|
||||
columns: [
|
||||
'rank',
|
||||
'name',
|
||||
'country',
|
||||
'slug',
|
||||
'star_rating',
|
||||
'review_score',
|
||||
'review_count',
|
||||
'price_amount',
|
||||
'price_currency',
|
||||
'distance',
|
||||
'recommended_room',
|
||||
'url',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const destination = String(kwargs.destination || '').trim();
|
||||
if (!destination) throw new ArgumentError('destination is required');
|
||||
const checkin = normalizeDate(kwargs.checkin, 'checkin');
|
||||
const checkout = normalizeDate(kwargs.checkout, 'checkout');
|
||||
if (checkin >= checkout) {
|
||||
throw new ArgumentError(`checkout (${checkout}) must be after checkin (${checkin})`);
|
||||
}
|
||||
const adults = normalizePositiveInt(kwargs.adults, 2, 'adults', 30);
|
||||
const rooms = normalizePositiveInt(kwargs.rooms, 1, 'rooms', 30);
|
||||
const children = normalizeNonNegativeInt(kwargs.children, 0, 'children', 10);
|
||||
const currency = normalizeCurrency(kwargs.currency);
|
||||
const lang = normalizeLang(kwargs.lang);
|
||||
const limit = normalizePositiveInt(kwargs.limit, 25, 'limit', 100);
|
||||
const offset = normalizeNonNegativeInt(kwargs.offset, 0, 'offset', 1000);
|
||||
|
||||
const url = buildSearchUrl({ destination, checkin, checkout, adults, rooms, children, offset, currency, lang });
|
||||
|
||||
try {
|
||||
await page.goto(url);
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(`Failed to load Booking.com search page: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
// Booking lazy-loads price cells; wait for at least the first card price to settle.
|
||||
try {
|
||||
await page.wait('selector', '[data-testid=property-card]', { timeoutMs: 20000 });
|
||||
} catch (_) {
|
||||
// selector wait is best-effort — extractor handles empty case explicitly
|
||||
}
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = await page.evaluate(EXTRACTOR);
|
||||
} catch (err) {
|
||||
throw new CommandExecutionError(`Failed to extract Booking.com cards: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object' && raw.data && raw.session) {
|
||||
raw = raw.data;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new CommandExecutionError('Booking.com page returned no extractable data');
|
||||
}
|
||||
if (raw.blocked) {
|
||||
throw new CommandExecutionError('Booking.com served a verification / captcha page; retry later or change profile');
|
||||
}
|
||||
|
||||
if (raw.ok !== true) {
|
||||
throw new CommandExecutionError('Booking.com extractor returned an invalid status');
|
||||
}
|
||||
if (!Array.isArray(raw.items)) {
|
||||
throw new CommandExecutionError('Booking.com extractor returned malformed items');
|
||||
}
|
||||
|
||||
const items = raw.items;
|
||||
if (items.length === 0) {
|
||||
const totalText = String(raw.totalText || '').trim();
|
||||
if (hasPositiveResultCount(totalText)) {
|
||||
throw new CommandExecutionError(
|
||||
`Booking.com page declared results but no property cards were parsed: ${totalText}`,
|
||||
);
|
||||
}
|
||||
throw new EmptyResultError(
|
||||
`booking search ${JSON.stringify(destination)}`,
|
||||
totalText
|
||||
? `No hotels rendered (${totalText}). Try a broader destination, different dates, or check the URL in a browser.`
|
||||
: 'No hotels rendered. Try a broader destination, different dates, or check the URL in a browser.',
|
||||
);
|
||||
}
|
||||
|
||||
return items.slice(0, limit).map((it, i) => {
|
||||
if (!it || typeof it !== 'object') {
|
||||
throw new CommandExecutionError('Booking.com extractor returned malformed hotel row');
|
||||
}
|
||||
const name = String(it.name || '').trim();
|
||||
const country = String(it.country || '').trim();
|
||||
const slug = String(it.slug || '').trim();
|
||||
const urlValue = String(it.url || '').trim();
|
||||
const expectedUrl = country && slug
|
||||
? `https://www.booking.com/hotel/${country}/${slug}.html`
|
||||
: '';
|
||||
if (!name || !/^[a-z]{2}$/.test(country) || !slug || urlValue !== expectedUrl) {
|
||||
throw new CommandExecutionError('Booking.com hotel row is missing stable name/url identity');
|
||||
}
|
||||
return {
|
||||
rank: offset + i + 1,
|
||||
name,
|
||||
country,
|
||||
slug,
|
||||
star_rating: it.star_rating,
|
||||
review_score: it.review_score,
|
||||
review_count: it.review_count,
|
||||
price_amount: it.price_amount,
|
||||
price_currency: it.price_amount == null ? '' : (currency || it.price_currency || ''),
|
||||
distance: it.distance,
|
||||
recommended_room: it.recommended_room,
|
||||
url: urlValue,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizePositiveInt,
|
||||
normalizeNonNegativeInt,
|
||||
normalizeDate,
|
||||
normalizeCurrency,
|
||||
normalizeLang,
|
||||
hasPositiveResultCount,
|
||||
buildSearchUrl,
|
||||
EXTRACTOR,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
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('https://www.zhipin.com/web/geek/job-recommend');
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+59
-4
@@ -1,19 +1,38 @@
|
||||
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,
|
||||
normalizeBooleanFlag,
|
||||
openChatGPTConversation,
|
||||
requireNonEmptyPrompt,
|
||||
requirePositiveInt,
|
||||
parseChatGPTConversationId,
|
||||
sendChatGPTMessage,
|
||||
selectChatGPTTool,
|
||||
isGenerating,
|
||||
startNewChat,
|
||||
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 +47,12 @@ 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: '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 +60,26 @@ 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.',
|
||||
);
|
||||
}
|
||||
const tool = useDeepResearch ? 'deep-research' : (useWebSearch ? 'web-search' : null);
|
||||
|
||||
if (normalizeBooleanFlag(kwargs.new)) {
|
||||
if (kwargs.conversation) {
|
||||
await openChatGPTConversation(page, kwargs.conversation);
|
||||
} else if (normalizeBooleanFlag(kwargs.new)) {
|
||||
await startNewChat(page);
|
||||
} else {
|
||||
await ensureOnChatGPT(page);
|
||||
@@ -46,6 +87,15 @@ 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 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.wait(3);
|
||||
}
|
||||
|
||||
const baseline = await getBubbleCount(page);
|
||||
const sent = await sendChatGPTMessage(page, prompt);
|
||||
@@ -53,6 +103,11 @@ export const askCommand = cli({
|
||||
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);
|
||||
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response }];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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' });
|
||||
return cookies.some(c => c.name === '__Secure-next-auth.session-token' && c.value);
|
||||
}
|
||||
|
||||
async function verifyChatgptIdentity(page) {
|
||||
if (!await hasChatgptSessionCookie(page)) {
|
||||
throw new AuthRequiredError('chatgpt.com', 'ChatGPT __Secure-next-auth.session-token cookie missing');
|
||||
}
|
||||
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: async (page) => {
|
||||
if (!await hasChatgptSessionCookie(page)) {
|
||||
throw new AuthRequiredError('chatgpt.com', 'Waiting for ChatGPT session cookie');
|
||||
}
|
||||
return verifyChatgptIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import './detail.js';
|
||||
import './new.js';
|
||||
import './status.js';
|
||||
import './image.js';
|
||||
import './model.js';
|
||||
|
||||
describe('chatgpt browser command registration', () => {
|
||||
it('registers the baseline web chat commands with persistent site sessions', () => {
|
||||
@@ -20,6 +21,7 @@ describe('chatgpt browser command registration', () => {
|
||||
new: 'read',
|
||||
status: 'read',
|
||||
image: 'write',
|
||||
model: 'write',
|
||||
};
|
||||
|
||||
for (const [name, access] of Object.entries(expectedAccess)) {
|
||||
@@ -40,6 +42,57 @@ 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: '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 routing option', () => {
|
||||
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 }),
|
||||
]));
|
||||
});
|
||||
|
||||
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 chatgpt model with web model choices', () => {
|
||||
const model = getRegistry().get('chatgpt/model');
|
||||
expect(model.args).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'model',
|
||||
positional: true,
|
||||
required: true,
|
||||
choices: ['instant', 'thinking', 'pro'],
|
||||
}),
|
||||
]);
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
+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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
CHATGPT_DOMAIN,
|
||||
CHATGPT_MODEL_CHOICES,
|
||||
selectChatGPTModel,
|
||||
} from './utils.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'chatgpt',
|
||||
name: 'model',
|
||||
access: 'write',
|
||||
description: 'Switch ChatGPT web model/mode (instant, thinking, pro)',
|
||||
domain: CHATGPT_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'model', required: true, positional: true, help: 'Model/mode to switch to', choices: CHATGPT_MODEL_CHOICES },
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page, kwargs) => {
|
||||
const result = await selectChatGPTModel(page, kwargs.model);
|
||||
return [{ Status: result.Status, Model: result.Model }];
|
||||
},
|
||||
});
|
||||
+13
-2
@@ -1,11 +1,12 @@
|
||||
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,
|
||||
ensureChatGPTComposer,
|
||||
ensureOnChatGPT,
|
||||
normalizeBooleanFlag,
|
||||
openChatGPTConversation,
|
||||
requireNonEmptyPrompt,
|
||||
sendChatGPTMessage,
|
||||
startNewChat,
|
||||
@@ -24,12 +25,22 @@ export const sendCommand = cli({
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ 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' },
|
||||
],
|
||||
columns: ['Status', 'InjectedText'],
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt send');
|
||||
|
||||
if (normalizeBooleanFlag(kwargs.new)) {
|
||||
if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) {
|
||||
throw new ArgumentError(
|
||||
'chatgpt send cannot use --new and --conversation together',
|
||||
'Choose either a new chat or an existing conversation.',
|
||||
);
|
||||
}
|
||||
|
||||
if (kwargs.conversation) {
|
||||
await openChatGPTConversation(page, kwargs.conversation);
|
||||
} else if (normalizeBooleanFlag(kwargs.new)) {
|
||||
await startNewChat(page);
|
||||
} else {
|
||||
await ensureOnChatGPT(page);
|
||||
|
||||
+470
-25
@@ -9,15 +9,29 @@ import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError }
|
||||
export const CHATGPT_DOMAIN = 'chatgpt.com';
|
||||
export const CHATGPT_URL = 'https://chatgpt.com';
|
||||
|
||||
const CHATGPT_MODEL_OPTIONS = {
|
||||
instant: { label: 'Instant', labels: ['Instant', '即时'], testId: 'model-switcher-gpt-5-5' },
|
||||
thinking: { label: 'Thinking', labels: ['Thinking', '思考'], testId: 'model-switcher-gpt-5-5-thinking' },
|
||||
pro: { label: 'Pro', labels: ['Pro', '进阶专业'], testId: 'model-switcher-gpt-5-5-pro' },
|
||||
};
|
||||
export const CHATGPT_MODEL_CHOICES = Object.keys(CHATGPT_MODEL_OPTIONS);
|
||||
|
||||
const CHATGPT_TOOL_OPTIONS = {
|
||||
'deep-research': { label: 'Deep Research', labels: ['深度研究', 'Deep Research'] },
|
||||
'web-search': { label: 'Web Search', labels: ['网页搜索', '搜索', 'Web Search', 'Search'] },
|
||||
};
|
||||
export const CHATGPT_TOOL_CHOICES = Object.keys(CHATGPT_TOOL_OPTIONS);
|
||||
|
||||
// Selectors
|
||||
const COMPOSER_SELECTORS = [
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'#prompt-textarea[contenteditable="true"]',
|
||||
'[aria-label="Chat with ChatGPT"]',
|
||||
'[aria-label="与 ChatGPT 聊天"]',
|
||||
'[placeholder="Ask anything"]',
|
||||
'[placeholder="有问题,尽管问"]',
|
||||
'#prompt-textarea',
|
||||
'[data-testid="prompt-textarea"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
];
|
||||
const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
|
||||
const SEND_BUTTON_FALLBACK_SELECTORS = [
|
||||
@@ -27,6 +41,8 @@ const SEND_BUTTON_LABELS = [
|
||||
'Send prompt',
|
||||
'Send message',
|
||||
'Send',
|
||||
'发送',
|
||||
'发送消息',
|
||||
'发送提示',
|
||||
];
|
||||
const CLOSE_SIDEBAR_LABELS = [
|
||||
@@ -60,12 +76,11 @@ function buildComposerLocatorScript() {
|
||||
};
|
||||
|
||||
const findComposer = () => {
|
||||
const marked = document.querySelector('[' + markerAttr + '="1"]');
|
||||
if (marked instanceof HTMLElement && isVisible(marked)) return marked;
|
||||
|
||||
for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) {
|
||||
const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c));
|
||||
const candidates = Array.from(document.querySelectorAll(selector)).filter(c => c instanceof HTMLElement && isVisible(c));
|
||||
const node = candidates.find(c => c.isContentEditable) || candidates[0];
|
||||
if (node instanceof HTMLElement) {
|
||||
clearMarkers(node);
|
||||
node.setAttribute(markerAttr, '1');
|
||||
return node;
|
||||
}
|
||||
@@ -102,6 +117,13 @@ export function requirePositiveInt(value, flagLabel, hint) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireNonNegativeInt(value, flagLabel, hint) {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new ArgumentError(`${flagLabel} must be a non-negative integer`, hint);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// page.evaluate envelope helpers.
|
||||
//
|
||||
@@ -148,11 +170,27 @@ export function requireBooleanEvaluateResult(payload, label) {
|
||||
|
||||
export function parseChatGPTConversationId(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
const match = raw.match(/(?:^|\/c\/)([A-Za-z0-9_-]{8,})(?:[/?#]|$)/);
|
||||
if (match) return match[1];
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== 'https:' || (parsed.hostname !== CHATGPT_DOMAIN && !parsed.hostname.endsWith(`.${CHATGPT_DOMAIN}`))) {
|
||||
throw new Error('off-domain');
|
||||
}
|
||||
const match = parsed.pathname.match(/^\/c\/([A-Za-z0-9_-]{8,})$/);
|
||||
if (match) return match[1];
|
||||
} catch {
|
||||
// Fall through to the shared typed ArgumentError below.
|
||||
}
|
||||
throw new ArgumentError(
|
||||
'chatgpt detail requires a conversation id or chatgpt.com /c/<id> URL',
|
||||
'Example: opencli chatgpt detail https://chatgpt.com/c/123e4567-e89b-12d3-a456-426614174000',
|
||||
);
|
||||
}
|
||||
const pathMatch = raw.match(/^\/c\/([A-Za-z0-9_-]{8,})(?:[?#].*)?$/);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
if (/^[A-Za-z0-9_-]{8,}$/.test(raw)) return raw;
|
||||
throw new ArgumentError(
|
||||
'chatgpt detail requires a conversation id or /c/<id> URL',
|
||||
'chatgpt detail requires a conversation id or chatgpt.com /c/<id> URL',
|
||||
'Example: opencli chatgpt detail 123e4567-e89b-12d3-a456-426614174000',
|
||||
);
|
||||
}
|
||||
@@ -203,6 +241,17 @@ export async function startNewChat(page) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openChatGPTConversation(page, value) {
|
||||
const id = parseChatGPTConversationId(value);
|
||||
await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 });
|
||||
try {
|
||||
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
|
||||
} catch {
|
||||
// Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getPageState(page) {
|
||||
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
@@ -249,6 +298,240 @@ export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is
|
||||
return state;
|
||||
}
|
||||
|
||||
function requireKnownChatGPTModel(model) {
|
||||
const key = String(model ?? '').trim().toLowerCase();
|
||||
const option = CHATGPT_MODEL_OPTIONS[key];
|
||||
if (!option) {
|
||||
throw new ArgumentError(
|
||||
`Unknown ChatGPT model "${model}"`,
|
||||
`Choose one of: ${CHATGPT_MODEL_CHOICES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return { key, ...option };
|
||||
}
|
||||
|
||||
function requireKnownChatGPTTool(tool) {
|
||||
const key = String(tool ?? '').trim().toLowerCase();
|
||||
const option = CHATGPT_TOOL_OPTIONS[key];
|
||||
if (!option) {
|
||||
throw new ArgumentError(
|
||||
`Unknown ChatGPT tool "${tool}"`,
|
||||
`Choose one of: ${CHATGPT_TOOL_CHOICES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return { key, ...option };
|
||||
}
|
||||
|
||||
export async function getCurrentChatGPTModel(page) {
|
||||
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const labels = ${JSON.stringify(CHATGPT_MODEL_OPTIONS)};
|
||||
const button = Array.from(document.querySelectorAll('form button')).find((node) => {
|
||||
if (!isVisible(node)) return false;
|
||||
const text = normalize(node.textContent);
|
||||
return Object.values(labels).some((entry) => entry.labels.includes(text));
|
||||
});
|
||||
const label = normalize(button?.textContent || '');
|
||||
const entry = Object.entries(labels).find(([, value]) => value.labels.includes(label));
|
||||
return {
|
||||
model: entry?.[0] ?? null,
|
||||
label: entry?.[1]?.label ?? null,
|
||||
};
|
||||
})()`)), 'chatgpt current model');
|
||||
}
|
||||
|
||||
export async function selectChatGPTModel(page, model) {
|
||||
const target = requireKnownChatGPTModel(model);
|
||||
if (typeof page.nativeClick !== 'function') {
|
||||
throw new CommandExecutionError('ChatGPT model selection requires native browser click support.');
|
||||
}
|
||||
await ensureOnChatGPT(page);
|
||||
await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.');
|
||||
|
||||
const before = await getCurrentChatGPTModel(page);
|
||||
if (before.model === target.key) {
|
||||
return { Status: 'Already selected', Model: target.label };
|
||||
}
|
||||
|
||||
const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const labels = ${JSON.stringify(Object.values(CHATGPT_MODEL_OPTIONS).flatMap((entry) => entry.labels))};
|
||||
const button = Array.from(document.querySelectorAll('form button')).find((node) =>
|
||||
isVisible(node) && labels.includes(normalize(node.textContent))
|
||||
);
|
||||
if (!button) return { found: false };
|
||||
button.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
})()`)), 'chatgpt model menu button');
|
||||
if (!menuButton.found) {
|
||||
throw new CommandExecutionError('Could not find the ChatGPT model selector in the composer.');
|
||||
}
|
||||
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
|
||||
await page.wait(0.5);
|
||||
|
||||
let optionCenter = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const option = document.querySelector(${JSON.stringify(`[data-testid="${target.testId}"]`)});
|
||||
if (!(option instanceof HTMLElement) || !isVisible(option)) return { found: false };
|
||||
option.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = option.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
})()`)), 'chatgpt model option click');
|
||||
if (optionCenter.found) break;
|
||||
await page.wait(0.5);
|
||||
}
|
||||
if (!optionCenter?.found) {
|
||||
throw new CommandExecutionError(`Could not click the ChatGPT ${target.label} model option.`);
|
||||
}
|
||||
await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y));
|
||||
|
||||
await page.wait(0.5);
|
||||
const after = await getCurrentChatGPTModel(page);
|
||||
if (after.model !== target.key) {
|
||||
throw new CommandExecutionError(`ChatGPT model did not switch to ${target.label}.`);
|
||||
}
|
||||
return { Status: 'Success', Model: target.label };
|
||||
}
|
||||
|
||||
export async function getCurrentChatGPTTool(page) {
|
||||
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const labels = ${JSON.stringify(CHATGPT_TOOL_OPTIONS)};
|
||||
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
|
||||
const root = form || document.body;
|
||||
const nodes = Array.from(root.querySelectorAll('button, [role="button"], [role="menuitemradio"], span, div'));
|
||||
const node = nodes.find((candidate) => {
|
||||
if (!isVisible(candidate)) return false;
|
||||
const text = normalize(candidate.textContent);
|
||||
return Object.values(labels).some((entry) => entry.labels.includes(text));
|
||||
});
|
||||
const label = normalize(node?.textContent || '');
|
||||
const entry = Object.entries(labels).find(([, value]) => value.labels.includes(label));
|
||||
return {
|
||||
tool: entry?.[0] ?? null,
|
||||
label: entry?.[1]?.label ?? null,
|
||||
};
|
||||
})()`)), 'chatgpt current tool');
|
||||
}
|
||||
|
||||
export async function selectChatGPTTool(page, tool) {
|
||||
const target = requireKnownChatGPTTool(tool);
|
||||
if (typeof page.nativeClick !== 'function') {
|
||||
throw new CommandExecutionError('ChatGPT tool selection requires native browser click support.');
|
||||
}
|
||||
await ensureOnChatGPT(page);
|
||||
await ensureChatGPTComposer(page, 'ChatGPT tool selection requires a logged-in ChatGPT session with a visible composer.');
|
||||
|
||||
const before = await getCurrentChatGPTTool(page);
|
||||
if (before.tool === target.key) {
|
||||
return { Status: 'Already selected', Tool: target.label };
|
||||
}
|
||||
|
||||
const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const button = document.querySelector('button[data-testid="composer-plus-btn"]');
|
||||
if (!(button instanceof HTMLElement) || !isVisible(button)) return { found: false };
|
||||
button.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
})()`)), 'chatgpt tools menu button');
|
||||
if (!menuButton.found) {
|
||||
throw new CommandExecutionError('Could not find the ChatGPT tools menu button in the composer.');
|
||||
}
|
||||
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
|
||||
await page.wait(0.5);
|
||||
|
||||
let optionCenter = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const labels = ${JSON.stringify(target.labels)};
|
||||
const options = Array.from(document.querySelectorAll('[role="menuitemradio"]'));
|
||||
const option = options.find((node) => node instanceof HTMLElement && isVisible(node) && labels.includes(normalize(node.textContent)));
|
||||
if (!(option instanceof HTMLElement)) return { found: false };
|
||||
const checked = option.getAttribute('aria-checked') === 'true';
|
||||
option.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = option.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
checked,
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
})()`)), 'chatgpt tool option click');
|
||||
if (optionCenter.found) break;
|
||||
await page.wait(0.5);
|
||||
}
|
||||
if (!optionCenter?.found) {
|
||||
throw new CommandExecutionError(`Could not find the ChatGPT ${target.label} tool option.`);
|
||||
}
|
||||
if (!optionCenter.checked) {
|
||||
await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y));
|
||||
}
|
||||
|
||||
await page.wait(0.5);
|
||||
const after = await getCurrentChatGPTTool(page);
|
||||
if (after.tool !== target.key) {
|
||||
throw new CommandExecutionError(`ChatGPT tool did not switch to ${target.label}.`);
|
||||
}
|
||||
return { Status: optionCenter.checked ? 'Already selected' : 'Success', Tool: target.label };
|
||||
}
|
||||
|
||||
export async function clearChatGPTDraft(page) {
|
||||
await page.evaluate(`
|
||||
(() => {
|
||||
@@ -301,11 +584,11 @@ export async function sendChatGPTMessage(page, text) {
|
||||
// findComposer() retries inside a single CDP call, so no fixed sleep is
|
||||
// needed before reading the composer.
|
||||
|
||||
const typeResult = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
||||
const typeResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
||||
(() => {
|
||||
${buildComposerLocatorScript()}
|
||||
const composer = findComposer();
|
||||
if (!composer) return false;
|
||||
if (!composer) return { ready: false };
|
||||
composer.focus();
|
||||
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
|
||||
composer.value = '';
|
||||
@@ -317,15 +600,25 @@ export async function sendChatGPTMessage(page, text) {
|
||||
}
|
||||
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
||||
composer.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
composer.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = composer.getBoundingClientRect();
|
||||
return {
|
||||
ready: true,
|
||||
x: Math.round(rect.left + Math.max(8, Math.min(rect.width / 2, rect.width - 8))),
|
||||
y: Math.round(rect.top + Math.max(8, Math.min(rect.height / 2, rect.height - 8))),
|
||||
};
|
||||
})()
|
||||
`)), 'chatgpt composer readiness');
|
||||
|
||||
if (!typeResult) return false;
|
||||
if (!typeResult.ready) return false;
|
||||
|
||||
// Use page.type() which is Playwright's native method
|
||||
try {
|
||||
if (page.nativeType) {
|
||||
if (typeof page.nativeClick === 'function') {
|
||||
await page.nativeClick(Number(typeResult.x), Number(typeResult.y));
|
||||
await page.wait(0.2);
|
||||
}
|
||||
await page.nativeType(text);
|
||||
} else {
|
||||
throw new Error('nativeType unavailable');
|
||||
@@ -349,16 +642,31 @@ export async function sendChatGPTMessage(page, text) {
|
||||
await page.wait(0.5);
|
||||
sent = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const isUsable = (button) => button
|
||||
&& isVisible(button)
|
||||
&& !button.disabled
|
||||
&& button.getAttribute('aria-disabled') !== 'true';
|
||||
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
||||
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
|
||||
const root = form || document.body;
|
||||
const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
||||
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean);
|
||||
const btns = Array.from(root.querySelectorAll('button'));
|
||||
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
||||
const looksLikeSend = (button) => {
|
||||
const label = button.getAttribute('aria-label') || '';
|
||||
const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
return labels.includes(label) || labels.includes(text) || /send|发送/i.test(label) || /send|发送/i.test(text);
|
||||
};
|
||||
const sendBtn = isUsable(primary)
|
||||
? primary
|
||||
: btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b));
|
||||
: btns.find(b => looksLikeSend(b) && isUsable(b));
|
||||
return { sendBtnFound: !!sendBtn };
|
||||
})()
|
||||
`)), 'chatgpt send button readiness');
|
||||
@@ -371,10 +679,30 @@ export async function sendChatGPTMessage(page, text) {
|
||||
|
||||
await page.evaluate(`
|
||||
(() => {
|
||||
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
||||
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const isUsable = (button) => button
|
||||
&& isVisible(button)
|
||||
&& !button.disabled
|
||||
&& button.getAttribute('aria-disabled') !== 'true';
|
||||
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
|
||||
const root = form || document.body;
|
||||
const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
||||
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean);
|
||||
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
||||
const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
||||
const looksLikeSend = (button) => {
|
||||
const label = button.getAttribute('aria-label') || '';
|
||||
const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
return labels.includes(label) || labels.includes(text) || /send|发送/i.test(label) || /send|发送/i.test(text);
|
||||
};
|
||||
const sendBtn = isUsable(primary)
|
||||
? primary
|
||||
: Array.from(root.querySelectorAll('button')).find(b => looksLikeSend(b) && isUsable(b));
|
||||
if (sendBtn) sendBtn.click();
|
||||
})()
|
||||
`);
|
||||
@@ -437,6 +765,70 @@ export async function getVisibleMessages(page) {
|
||||
})).filter((item) => item.Text);
|
||||
}
|
||||
|
||||
function formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }) {
|
||||
return messages.map((message) => ({
|
||||
Index: message.Index,
|
||||
Role: message.Role,
|
||||
Text: wantMarkdown && message.Role === 'Assistant' && message.Html
|
||||
? (messageHtmlToMarkdown(message.Html) || message.Text)
|
||||
: message.Text,
|
||||
Generating: generating,
|
||||
StableSeconds: stableSeconds,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getChatGPTDetailRows(page, { wantMarkdown = false, stableSeconds = 0 } = {}) {
|
||||
const generating = await isGenerating(page);
|
||||
const messages = await getVisibleMessages(page);
|
||||
return {
|
||||
messages,
|
||||
rows: formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }),
|
||||
generating,
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForChatGPTDetailRows(page, { wantMarkdown = false, timeoutSeconds = 120, stableSeconds = 6 } = {}) {
|
||||
const startTime = Date.now();
|
||||
let lastKey = '';
|
||||
let stableStartedAt = 0;
|
||||
|
||||
while (Date.now() - startTime < timeoutSeconds * 1000) {
|
||||
const generating = await isGenerating(page);
|
||||
const messages = await getVisibleMessages(page);
|
||||
const key = JSON.stringify(messages.map((message) => [message.Role, message.Text]));
|
||||
if (!generating && messages.length && messages[messages.length - 1]?.Role === 'Assistant') {
|
||||
if (key === lastKey) {
|
||||
if (!stableStartedAt) stableStartedAt = Date.now();
|
||||
const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000);
|
||||
if (elapsedSeconds >= stableSeconds) {
|
||||
return {
|
||||
messages,
|
||||
rows: formatChatGPTDetailMessages(messages, {
|
||||
wantMarkdown,
|
||||
generating: false,
|
||||
stableSeconds: elapsedSeconds,
|
||||
}),
|
||||
generating: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
lastKey = key;
|
||||
stableStartedAt = Date.now();
|
||||
}
|
||||
} else {
|
||||
lastKey = key;
|
||||
stableStartedAt = 0;
|
||||
}
|
||||
await page.wait(3);
|
||||
}
|
||||
|
||||
throw new TimeoutError(
|
||||
'chatgpt detail',
|
||||
timeoutSeconds,
|
||||
'Conversation did not finish or stabilize before timeout. Re-run with a higher --timeout if it is still generating.',
|
||||
);
|
||||
}
|
||||
|
||||
export function messageHtmlToMarkdown(html) {
|
||||
try {
|
||||
return htmlToMarkdown(html).trim();
|
||||
@@ -609,7 +1001,18 @@ async function waitForChatGPTUploadPreview(page, fileNames) {
|
||||
const scope = root || document.body;
|
||||
if (!scope) return false;
|
||||
|
||||
const previewNodes = scope.querySelectorAll('img[src], canvas, video, [style*="background-image"], [data-testid*="attachment"], [data-testid*="upload"], [class*="attachment"], [class*="upload"]');
|
||||
const isVisibleMedia = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const width = node.naturalWidth || node.videoWidth || rect.width || 0;
|
||||
const height = node.naturalHeight || node.videoHeight || rect.height || 0;
|
||||
if (width > 32 && height > 32) return true;
|
||||
const backgroundImage = style.backgroundImage || '';
|
||||
return /url\\(/.test(backgroundImage) && rect.width > 32 && rect.height > 32;
|
||||
};
|
||||
const previewNodes = Array.from(scope.querySelectorAll('img[src], canvas, video, [style*="background-image"]')).filter(isVisibleMedia);
|
||||
return previewNodes.length >= names.length;
|
||||
})()
|
||||
`)), 'chatgpt upload preview detection');
|
||||
@@ -698,9 +1101,14 @@ export async function uploadChatGPTImages(page, imagePaths) {
|
||||
export async function isGenerating(page) {
|
||||
return requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
|
||||
(() => {
|
||||
const text = (document.body?.innerText || '').replace(/\\s+/g, ' ');
|
||||
if (/正在思考|停止生成|Thinking/.test(text)) return true;
|
||||
return Array.from(document.querySelectorAll('button')).some(b => {
|
||||
const label = b.getAttribute('aria-label') || '';
|
||||
return label === 'Stop generating' || label.includes('Thinking');
|
||||
return label === 'Stop generating'
|
||||
|| label.includes('Thinking')
|
||||
|| label.includes('停止生成')
|
||||
|| label.includes('正在思考');
|
||||
});
|
||||
})()
|
||||
`)), 'chatgpt generation state');
|
||||
@@ -746,6 +1154,17 @@ export async function getChatGPTVisibleImageUrls(page) {
|
||||
const text = [alt, cls, testId, label, src.toLowerCase()].join(' ');
|
||||
return /avatar|profile|logo|icon/.test(text);
|
||||
};
|
||||
const isUserUploadPreview = (img) => {
|
||||
const alt = (img.getAttribute('alt') || '').toLowerCase();
|
||||
const turn = img.closest('section[data-testid^="conversation-turn"]');
|
||||
const heading = (turn?.querySelector('h4')?.innerText || '').toLowerCase();
|
||||
if (/you said|你说/.test(heading)) return true;
|
||||
if (/chatgpt|assistant|助手/.test(heading)) return false;
|
||||
const openButtonLabel = (img.closest('button[aria-label^="Open image:"]')?.getAttribute('aria-label') || '').toLowerCase();
|
||||
const previewText = [alt, openButtonLabel].join(' ');
|
||||
return /\.(png|jpe?g|webp|gif|heic|heif)(?:\b|$)/i.test(previewText)
|
||||
|| /ref-|reference|参考|upload|uploaded|attachment/.test(previewText);
|
||||
};
|
||||
|
||||
const imgs = Array.from(document.querySelectorAll('img')).filter(img =>
|
||||
img instanceof HTMLImageElement && isVisible(img)
|
||||
@@ -758,6 +1177,7 @@ export async function getChatGPTVisibleImageUrls(page) {
|
||||
|
||||
if (!src) continue;
|
||||
if (isDecorative(img, src)) continue;
|
||||
if (isUserUploadPreview(img)) continue;
|
||||
if (width < 128 && height < 128) continue;
|
||||
addUrl(src);
|
||||
}
|
||||
@@ -777,16 +1197,41 @@ export async function getChatGPTVisibleImageUrls(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// Some image experiences render to a canvas. Returning the data URL
|
||||
// lets the downstream asset exporter save it without needing a DOM
|
||||
// selector to rediscover the canvas.
|
||||
// Some ChatGPT image surfaces mount large transparent canvases as
|
||||
// placeholders/overlays before the real backend image is ready. If
|
||||
// those data URLs are accepted as generated assets, the adapter can
|
||||
// save a blank transparent PNG while reporting success. Prefer real
|
||||
// <img>/background URLs; only keep a canvas if it contains at least
|
||||
// one non-transparent/non-white sampled pixel.
|
||||
for (const canvas of Array.from(document.querySelectorAll('canvas'))) {
|
||||
if (!(canvas instanceof HTMLCanvasElement) || !isVisible(canvas) || isDecorative(canvas)) continue;
|
||||
const width = canvas.width || canvas.getBoundingClientRect().width || 0;
|
||||
const height = canvas.height || canvas.getBoundingClientRect().height || 0;
|
||||
if (width < 128 && height < 128) continue;
|
||||
try {
|
||||
addUrl(canvas.toDataURL('image/png'));
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) continue;
|
||||
const sourceWidth = Math.max(1, Math.floor(canvas.width || width));
|
||||
const sourceHeight = Math.max(1, Math.floor(canvas.height || height));
|
||||
const xCount = Math.min(sourceWidth, 16);
|
||||
const yCount = Math.min(sourceHeight, 16);
|
||||
let hasContent = false;
|
||||
for (let yi = 0; yi < yCount && !hasContent; yi += 1) {
|
||||
const y = Math.min(sourceHeight - 1, Math.floor((yi + 0.5) * sourceHeight / yCount));
|
||||
for (let xi = 0; xi < xCount && !hasContent; xi += 1) {
|
||||
const x = Math.min(sourceWidth - 1, Math.floor((xi + 0.5) * sourceWidth / xCount));
|
||||
const pixel = ctx.getImageData(x, y, 1, 1).data;
|
||||
const r = pixel[0];
|
||||
const g = pixel[1];
|
||||
const b = pixel[2];
|
||||
const a = pixel[3];
|
||||
if (a > 0 && !(r > 248 && g > 248 && b > 248)) {
|
||||
hasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasContent) addUrl(canvas.toDataURL('image/png'));
|
||||
} catch { }
|
||||
}
|
||||
return urls;
|
||||
|
||||
+390
-5
@@ -3,7 +3,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __test__, getChatGPTImageAssets, getChatGPTVisibleImageUrls, prepareChatGPTImagePaths, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTImages } from './utils.js';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, isGenerating, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDetailRows, waitForChatGPTImages } from './utils.js';
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
@@ -37,6 +38,19 @@ function createPageMock({ location = '', generating = [], imageUrls = [] } = {})
|
||||
};
|
||||
}
|
||||
|
||||
function createDomEvaluatePage(html) {
|
||||
const dom = new JSDOM(html, {
|
||||
url: 'https://chatgpt.com/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
for (const node of dom.window.document.querySelectorAll('button')) {
|
||||
node.getBoundingClientRect = () => ({ width: 120, height: 36 });
|
||||
}
|
||||
return {
|
||||
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(script))),
|
||||
};
|
||||
}
|
||||
|
||||
describe('chatgpt image wait contract', () => {
|
||||
it('does not periodically reload the conversation while generation is still active', async () => {
|
||||
const convUrl = 'https://chatgpt.com/c/demo';
|
||||
@@ -79,6 +93,7 @@ describe('chatgpt conversation id parsing', () => {
|
||||
it('accepts ids and chatgpt conversation URLs', () => {
|
||||
expect(__test__.parseChatGPTConversationId('abc_123-def')).toBe('abc_123-def');
|
||||
expect(__test__.parseChatGPTConversationId('https://chatgpt.com/c/abc_123-def?model=gpt-5')).toBe('abc_123-def');
|
||||
expect(__test__.parseChatGPTConversationId('https://chat.openai.chatgpt.com/c/abc_123-def')).toBe('abc_123-def');
|
||||
expect(__test__.parseChatGPTConversationId('/c/abc_123-def')).toBe('abc_123-def');
|
||||
});
|
||||
|
||||
@@ -86,6 +101,221 @@ describe('chatgpt conversation id parsing', () => {
|
||||
expect(() => __test__.parseChatGPTConversationId('')).toThrow(/conversation id/);
|
||||
expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com/')).toThrow(/conversation id/);
|
||||
});
|
||||
|
||||
it('rejects off-domain or ambiguous conversation URLs before routing writes', () => {
|
||||
expect(() => __test__.parseChatGPTConversationId('https://evil.test/c/abc_123-def')).toThrow(/chatgpt\.com/);
|
||||
expect(() => __test__.parseChatGPTConversationId('http://chatgpt.com/c/abc_123-def')).toThrow(/chatgpt\.com/);
|
||||
expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com.evil.test/c/abc_123-def')).toThrow(/chatgpt\.com/);
|
||||
expect(() => __test__.parseChatGPTConversationId('/c/abc_123-def/extra')).toThrow(/conversation id/);
|
||||
expect(() => __test__.parseChatGPTConversationId('prefix https://chatgpt.com/c/abc_123-def')).toThrow(/conversation id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt conversation navigation', () => {
|
||||
it('opens conversation URLs by parsed id', async () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await expect(openChatGPTConversation(page, 'https://chatgpt.com/c/abc_123-def?model=gpt-5'))
|
||||
.resolves.toBe('abc_123-def');
|
||||
expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/c/abc_123-def', { settleMs: 2000 });
|
||||
expect(page.wait).toHaveBeenCalledWith({ selector: '#prompt-textarea, [data-testid="prompt-textarea"]', timeout: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt model selection validation', () => {
|
||||
it('rejects unknown model names', async () => {
|
||||
await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown'))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown'))
|
||||
.rejects.toThrow('Unknown ChatGPT model "unknown"');
|
||||
});
|
||||
|
||||
it('requires native browser click support', async () => {
|
||||
await expect(selectChatGPTModel({}, 'pro'))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(selectChatGPTModel({}, 'pro'))
|
||||
.rejects.toThrow('ChatGPT model selection requires native browser click support.');
|
||||
});
|
||||
|
||||
it('clicks the model selector and verifies the selected postcondition', async () => {
|
||||
let objectCall = 0;
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
nativeClick: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo');
|
||||
objectCall += 1;
|
||||
if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true });
|
||||
if (objectCall === 2) return Promise.resolve({ model: 'instant', label: 'Instant' });
|
||||
if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 });
|
||||
if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 });
|
||||
if (objectCall === 5) return Promise.resolve({ model: 'pro', label: 'Pro' });
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(selectChatGPTModel(page, 'pro')).resolves.toEqual({ Status: 'Success', Model: 'Pro' });
|
||||
expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20);
|
||||
expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40);
|
||||
});
|
||||
|
||||
it('fails closed when the postcondition does not prove the requested model', async () => {
|
||||
let objectCall = 0;
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
nativeClick: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo');
|
||||
objectCall += 1;
|
||||
if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true });
|
||||
if (objectCall === 2) return Promise.resolve({ model: 'instant', label: 'Instant' });
|
||||
if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 });
|
||||
if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 });
|
||||
if (objectCall === 5) return Promise.resolve({ model: 'instant', label: 'Instant' });
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(selectChatGPTModel(page, 'pro')).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
message: expect.stringContaining('did not switch to Pro'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt tool selection validation', () => {
|
||||
it('rejects unknown tool names', async () => {
|
||||
await expect(selectChatGPTTool({ nativeClick: vi.fn() }, 'unknown'))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(selectChatGPTTool({ nativeClick: vi.fn() }, 'unknown'))
|
||||
.rejects.toThrow('Unknown ChatGPT tool "unknown"');
|
||||
});
|
||||
|
||||
it('requires native browser click support', async () => {
|
||||
await expect(selectChatGPTTool({}, 'deep-research'))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(selectChatGPTTool({}, 'deep-research'))
|
||||
.rejects.toThrow('ChatGPT tool selection requires native browser click support.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt detail completion state', () => {
|
||||
function createDetailPageMock({ generating = false, messages = [] } = {}) {
|
||||
return {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
if (script.includes('Stop generating') || script.includes('Thinking')) {
|
||||
return Promise.resolve(generating);
|
||||
}
|
||||
if (script.includes('data-message-author-role')) {
|
||||
return Promise.resolve(messages.map((message) => ({
|
||||
role: message.Role,
|
||||
text: message.Text,
|
||||
html: message.Html ?? message.Text,
|
||||
})));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('adds generation state to detail rows', async () => {
|
||||
const page = createDetailPageMock({
|
||||
generating: true,
|
||||
messages: [
|
||||
{ Role: 'User', Text: 'question' },
|
||||
{ Role: 'Assistant', Text: 'working' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(getChatGPTDetailRows(page)).resolves.toMatchObject({
|
||||
generating: true,
|
||||
rows: [
|
||||
{ Index: 1, Role: 'User', Text: 'question', Generating: true, StableSeconds: 0 },
|
||||
{ Index: 2, Role: 'Assistant', Text: 'working', Generating: true, StableSeconds: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('waits until assistant output is stable', async () => {
|
||||
const page = createDetailPageMock({
|
||||
generating: false,
|
||||
messages: [
|
||||
{ Role: 'User', Text: 'question' },
|
||||
{ Role: 'Assistant', Text: 'done' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await waitForChatGPTDetailRows(page, { timeoutSeconds: 5, stableSeconds: 0 });
|
||||
|
||||
expect(result.rows.at(-1)).toMatchObject({
|
||||
Role: 'Assistant',
|
||||
Text: 'done',
|
||||
Generating: false,
|
||||
StableSeconds: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt generation state', () => {
|
||||
it('detects zh-CN thinking status text', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn((script) => {
|
||||
expect(script).toContain('正在思考');
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(isGenerating(page)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt current model detection', () => {
|
||||
it.each([
|
||||
['Instant', { model: 'instant', label: 'Instant' }],
|
||||
['Thinking', { model: 'thinking', label: 'Thinking' }],
|
||||
['Pro', { model: 'pro', label: 'Pro' }],
|
||||
['进阶专业', { model: 'pro', label: 'Pro' }],
|
||||
])('detects the visible %s model label', async (label, expected) => {
|
||||
const page = createDomEvaluatePage(`<form><button>${label}</button></form>`);
|
||||
|
||||
await expect(getCurrentChatGPTModel(page)).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns null fields when the model selector is missing', async () => {
|
||||
const page = createDomEvaluatePage('<form><button>Send</button></form>');
|
||||
|
||||
await expect(getCurrentChatGPTModel(page)).resolves.toEqual({
|
||||
model: null,
|
||||
label: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt current tool detection', () => {
|
||||
it.each([
|
||||
['深度研究', { tool: 'deep-research', label: 'Deep Research' }],
|
||||
['Deep Research', { tool: 'deep-research', label: 'Deep Research' }],
|
||||
['网页搜索', { tool: 'web-search', label: 'Web Search' }],
|
||||
['搜索', { tool: 'web-search', label: 'Web Search' }],
|
||||
['Web Search', { tool: 'web-search', label: 'Web Search' }],
|
||||
])('detects the visible %s tool label', async (label, expected) => {
|
||||
const page = createDomEvaluatePage(`<form><button>${label}</button></form>`);
|
||||
|
||||
await expect(getCurrentChatGPTTool(page)).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns null fields when no supported tool is selected', async () => {
|
||||
const page = createDomEvaluatePage('<form><button>添加文件</button></form>');
|
||||
|
||||
await expect(getCurrentChatGPTTool(page)).resolves.toEqual({
|
||||
tool: null,
|
||||
label: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatgpt send selectors', () => {
|
||||
@@ -111,9 +341,10 @@ describe('chatgpt send selectors', () => {
|
||||
it('keeps locale-independent send-button selector before aria-label fallbacks', async () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
nativeClick: vi.fn().mockResolvedValue(undefined),
|
||||
nativeType: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
if (script.includes('findComposer')) return Promise.resolve(true);
|
||||
if (script.includes('findComposer')) return Promise.resolve({ ready: true, x: 12, y: 34 });
|
||||
if (script.includes('sendBtnFound')) {
|
||||
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
||||
return Promise.resolve({ sendBtnFound: true });
|
||||
@@ -126,6 +357,7 @@ describe('chatgpt send selectors', () => {
|
||||
};
|
||||
|
||||
await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
|
||||
expect(page.nativeClick).toHaveBeenCalledWith(12, 34);
|
||||
});
|
||||
|
||||
it('uses the composer submit fallback consistently for readiness and click', async () => {
|
||||
@@ -133,7 +365,7 @@ describe('chatgpt send selectors', () => {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
nativeType: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => {
|
||||
if (script.includes('findComposer')) return Promise.resolve(true);
|
||||
if (script.includes('findComposer')) return Promise.resolve({ ready: true, x: 12, y: 34 });
|
||||
if (script.includes('sendBtnFound')) {
|
||||
expect(script).toContain('#composer-submit-button:not([disabled])');
|
||||
return Promise.resolve({ sendBtnFound: true });
|
||||
@@ -158,7 +390,7 @@ describe('chatgpt send selectors', () => {
|
||||
]));
|
||||
expect(__test__.SEND_BUTTON_SELECTOR).toBe('button[data-testid="send-button"]:not([disabled])');
|
||||
expect(__test__.SEND_BUTTON_FALLBACK_SELECTORS).toContain('#composer-submit-button:not([disabled])');
|
||||
expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送提示']));
|
||||
expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送', '发送消息', '发送提示']));
|
||||
expect(__test__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', '关闭边栏']));
|
||||
});
|
||||
});
|
||||
@@ -193,10 +425,13 @@ describe('chatgpt generated image detection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects visible generated canvases as data URLs', async () => {
|
||||
it('detects visible generated canvases as data URLs when they contain pixels', async () => {
|
||||
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
||||
const canvas = window.document.querySelector('canvas');
|
||||
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
canvas.getContext = () => ({
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([255, 0, 0, 255]) }),
|
||||
});
|
||||
canvas.toDataURL = () => 'data:image/png;base64,ZmFrZQ==';
|
||||
});
|
||||
|
||||
@@ -205,6 +440,103 @@ describe('chatgpt generated image detection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('samples generated canvas content outside the top-left corner', async () => {
|
||||
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
||||
const canvas = window.document.querySelector('canvas');
|
||||
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
canvas.getContext = () => ({
|
||||
getImageData: (x, y) => ({
|
||||
data: x > 480 && y > 480
|
||||
? new Uint8ClampedArray([255, 0, 0, 255])
|
||||
: new Uint8ClampedArray([0, 0, 0, 0]),
|
||||
}),
|
||||
});
|
||||
canvas.toDataURL = () => 'data:image/png;base64,lower-right';
|
||||
});
|
||||
|
||||
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
||||
'data:image/png;base64,lower-right',
|
||||
]);
|
||||
});
|
||||
|
||||
it('samples generated canvas content near the center', async () => {
|
||||
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
||||
const canvas = window.document.querySelector('canvas');
|
||||
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
canvas.getContext = () => ({
|
||||
getImageData: (x, y) => {
|
||||
const inCenter = x >= 240 && x <= 272 && y >= 240 && y <= 272;
|
||||
return { data: new Uint8ClampedArray(inCenter ? [0, 80, 200, 255] : [255, 255, 255, 255]) };
|
||||
},
|
||||
});
|
||||
canvas.toDataURL = () => 'data:image/png;base64,center';
|
||||
});
|
||||
|
||||
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
||||
'data:image/png;base64,center',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores transparent placeholder canvases', async () => {
|
||||
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
||||
const canvas = window.document.querySelector('canvas');
|
||||
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
canvas.getContext = () => ({
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([0, 0, 0, 0]) }),
|
||||
});
|
||||
canvas.toDataURL = () => 'data:image/png;base64,blank';
|
||||
});
|
||||
|
||||
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores user-uploaded reference image previews', async () => {
|
||||
const page = createDomPage(`
|
||||
<!doctype html>
|
||||
<section data-testid="conversation-turn-1">
|
||||
<h4>You said:</h4>
|
||||
<button aria-label="Open image: reference.png">
|
||||
<img alt="reference.png" src="https://chatgpt.com/backend-api/uploaded/reference.png">
|
||||
</button>
|
||||
</section>
|
||||
<section data-testid="conversation-turn-2">
|
||||
<h4>ChatGPT said:</h4>
|
||||
<img alt="generated image" src="https://chatgpt.com/backend-api/generated/foo.webp">
|
||||
</section>
|
||||
`, (window) => {
|
||||
for (const img of window.document.querySelectorAll('img')) {
|
||||
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 });
|
||||
Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 });
|
||||
img.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
}
|
||||
});
|
||||
|
||||
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
||||
'https://chatgpt.com/backend-api/generated/foo.webp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps assistant generated images even when they are inside an open-image button', async () => {
|
||||
const page = createDomPage(`
|
||||
<!doctype html>
|
||||
<section data-testid="conversation-turn-2">
|
||||
<h4>ChatGPT said:</h4>
|
||||
<button aria-label="Open image: generated image">
|
||||
<img alt="generated image" src="https://chatgpt.com/backend-api/generated/foo.webp">
|
||||
</button>
|
||||
</section>
|
||||
`, (window) => {
|
||||
const img = window.document.querySelector('img');
|
||||
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 });
|
||||
Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 });
|
||||
img.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
});
|
||||
|
||||
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
||||
'https://chatgpt.com/backend-api/generated/foo.webp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exports assets for generated CSS background images', async () => {
|
||||
const imageUrl = 'https://chatgpt.com/backend-api/generated/foo.webp';
|
||||
const page = createDomPage(`
|
||||
@@ -322,6 +654,59 @@ describe('chatgpt image upload helper', () => {
|
||||
expect(fallbackScript).toContain('stopPropagation()');
|
||||
});
|
||||
|
||||
it('does not treat generic upload controls as uploaded image previews', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'cat.png');
|
||||
fs.writeFileSync(filePath, 'fake-png');
|
||||
|
||||
const dom = new JSDOM(`
|
||||
<!doctype html>
|
||||
<main>
|
||||
<div aria-label="Chat with ChatGPT">
|
||||
<button class="upload-button" data-testid="upload-button">Attach</button>
|
||||
</div>
|
||||
</main>
|
||||
`, { url: 'https://chatgpt.com/new', runScripts: 'outside-only' });
|
||||
const page = {
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
||||
};
|
||||
|
||||
const result = await uploadChatGPTImages(page, [filePath]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('image upload preview did not appear');
|
||||
});
|
||||
|
||||
it('accepts a real uploaded media preview even when the filename text is absent', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'cat.png');
|
||||
fs.writeFileSync(filePath, 'fake-png');
|
||||
|
||||
const dom = new JSDOM(`
|
||||
<!doctype html>
|
||||
<main>
|
||||
<div aria-label="Chat with ChatGPT">
|
||||
<img src="blob:https://chatgpt.com/upload-preview">
|
||||
</div>
|
||||
</main>
|
||||
`, { url: 'https://chatgpt.com/new', runScripts: 'outside-only' });
|
||||
const img = dom.window.document.querySelector('img');
|
||||
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 });
|
||||
Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 });
|
||||
img.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
||||
const page = {
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
||||
};
|
||||
|
||||
await expect(uploadChatGPTImages(page, [filePath])).resolves.toEqual({ ok: true, files: [filePath] });
|
||||
});
|
||||
|
||||
it('exposes image MIME inference for fallback upload', () => {
|
||||
expect(__test__.imageMimeFromPath('/tmp/a.png')).toBe('image/png');
|
||||
expect(__test__.imageMimeFromPath('/tmp/a.webp')).toBe('image/webp');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Open a Chess.com game in the browser's analysis view. Thin wrapper:
|
||||
* navigates the bound session to the `/analysis` form of the game URL
|
||||
* and reports the resolved page URL.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { parseGameUrl } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'chess',
|
||||
name: 'analyze',
|
||||
access: 'read',
|
||||
description: 'Open a Chess.com game in the browser analysis board',
|
||||
domain: 'www.chess.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' },
|
||||
],
|
||||
columns: ['kind', 'game_id', 'analysis_url'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for chess analyze');
|
||||
const { kind, id } = parseGameUrl(kwargs['game-url']);
|
||||
const analysisUrl = `https://www.chess.com/analysis/game/${kind}/${id}`;
|
||||
try {
|
||||
await page.goto(analysisUrl);
|
||||
await page.wait(2);
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`Failed to open Chess.com analysis board: ${error?.message || error}`);
|
||||
}
|
||||
return [{ kind, game_id: id, analysis_url: analysisUrl }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import './analyze.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const manifestPath = resolve(__dirname, '../../cli-manifest.json');
|
||||
|
||||
function loadManifestCommand(name) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
return manifest.find(cmd => cmd.site === 'chess' && cmd.name === name);
|
||||
}
|
||||
|
||||
function makePage() {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('chess analyze command', () => {
|
||||
it('navigates to /analysis/game/<kind>/<id> and reports the URL', async () => {
|
||||
const cmd = getRegistry().get('chess/analyze');
|
||||
const page = makePage();
|
||||
const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' });
|
||||
expect(rows).toEqual([{ kind: 'live', game_id: '42', analysis_url: 'https://www.chess.com/analysis/game/live/42' }]);
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.chess.com/analysis/game/live/42');
|
||||
});
|
||||
|
||||
it('preserves daily kind in the analysis URL', async () => {
|
||||
const cmd = getRegistry().get('chess/analyze');
|
||||
const page = makePage();
|
||||
const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/daily/123' });
|
||||
expect(rows[0].analysis_url).toBe('https://www.chess.com/analysis/game/daily/123');
|
||||
});
|
||||
|
||||
it('rejects invalid URL with ArgumentError before navigation', async () => {
|
||||
const cmd = getRegistry().get('chess/analyze');
|
||||
const page = makePage();
|
||||
await expect(cmd.func(page, { 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError without a browser page', async () => {
|
||||
const cmd = getRegistry().get('chess/analyze');
|
||||
await expect(cmd.func(null, { 'game-url': 'https://www.chess.com/game/live/42' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when browser navigation fails', async () => {
|
||||
const cmd = getRegistry().get('chess/analyze');
|
||||
const page = makePage();
|
||||
page.goto.mockRejectedValue(new Error('navigation failed'));
|
||||
await expect(cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('registers with the expected columns + browser flag', () => {
|
||||
const cmd = getRegistry().get('chess/analyze');
|
||||
expect(cmd?.columns).toEqual(['kind', 'game_id', 'analysis_url']);
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.navigateBefore).toBe(false);
|
||||
});
|
||||
|
||||
it('build manifest keeps analyze pre-navigation disabled and game source attribution stable', () => {
|
||||
expect(loadManifestCommand('analyze')).toMatchObject({
|
||||
navigateBefore: false,
|
||||
modulePath: 'chess/analyze.js',
|
||||
sourceFile: 'chess/analyze.js',
|
||||
});
|
||||
expect(loadManifestCommand('game')).toMatchObject({
|
||||
modulePath: 'chess/game.js',
|
||||
sourceFile: 'chess/game.js',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Chess.com single-game detail by URL, via the internal callback
|
||||
* endpoint `/callback/{live|daily}/game/{id}`. Returns the canonical
|
||||
* PGN headers + move data plus per-player metadata.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { UA, formatDate, isPlainObject, parseGameUrl } from './utils.js';
|
||||
|
||||
const CALLBACK_BASE = 'https://www.chess.com/callback';
|
||||
|
||||
function stringOrEmpty(value) {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function scalarOrEmpty(value) {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : '';
|
||||
}
|
||||
|
||||
export function summarizeGame({ kind, id, payload }) {
|
||||
if (!isPlainObject(payload) || !isPlainObject(payload.game)) {
|
||||
throw new CommandExecutionError('Chess.com callback returned no game payload');
|
||||
}
|
||||
const g = payload.game;
|
||||
if (g.pgnHeaders !== undefined && !isPlainObject(g.pgnHeaders)) {
|
||||
throw new CommandExecutionError('Chess.com callback returned malformed PGN headers');
|
||||
}
|
||||
if (payload.players !== undefined && !isPlainObject(payload.players)) {
|
||||
throw new CommandExecutionError('Chess.com callback returned malformed player metadata');
|
||||
}
|
||||
const players = payload.players || {};
|
||||
const byColor = {};
|
||||
for (const slot of ['top', 'bottom']) {
|
||||
const p = players[slot];
|
||||
if (p !== undefined && !isPlainObject(p)) {
|
||||
throw new CommandExecutionError('Chess.com callback returned malformed player metadata');
|
||||
}
|
||||
if (p?.color) byColor[p.color] = p;
|
||||
}
|
||||
const white = byColor.white || {};
|
||||
const black = byColor.black || {};
|
||||
const headers = g.pgnHeaders || {};
|
||||
const whiteName = stringOrEmpty(white.username) || stringOrEmpty(headers.White);
|
||||
const blackName = stringOrEmpty(black.username) || stringOrEmpty(headers.Black);
|
||||
const result = stringOrEmpty(headers.Result);
|
||||
if (!whiteName || !blackName || !result) {
|
||||
throw new CommandExecutionError('Chess.com callback payload is missing stable game summary fields');
|
||||
}
|
||||
const headerDate = stringOrEmpty(headers.Date);
|
||||
return {
|
||||
kind,
|
||||
game_id: id,
|
||||
date: headerDate ? headerDate.replace(/\./g, '-') : formatDate(g.endTime),
|
||||
white: whiteName,
|
||||
white_rating: scalarOrEmpty(white.rating) || scalarOrEmpty(headers.WhiteElo),
|
||||
black: blackName,
|
||||
black_rating: scalarOrEmpty(black.rating) || scalarOrEmpty(headers.BlackElo),
|
||||
result,
|
||||
winner_color: stringOrEmpty(g.colorOfWinner),
|
||||
termination: stringOrEmpty(headers.Termination) || stringOrEmpty(g.resultMessage),
|
||||
eco: stringOrEmpty(headers.ECO),
|
||||
time_control: stringOrEmpty(headers.TimeControl) || (typeof g.daysPerTurn === 'number' ? `${g.daysPerTurn}d/turn` : ''),
|
||||
rated: g.isRated === true,
|
||||
ply_count: g.plyCount ?? '',
|
||||
url: `https://www.chess.com/game/${kind}/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'chess',
|
||||
name: 'game',
|
||||
access: 'read',
|
||||
description: 'Chess.com single-game detail (white, black, result, ECO, time control) by full game URL',
|
||||
domain: 'www.chess.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' },
|
||||
],
|
||||
columns: [
|
||||
'kind', 'game_id', 'date',
|
||||
'white', 'white_rating', 'black', 'black_rating',
|
||||
'result', 'winner_color', 'termination',
|
||||
'eco', 'time_control', 'rated', 'ply_count', 'url',
|
||||
],
|
||||
func: async (kwargs) => {
|
||||
const { kind, id } = parseGameUrl(kwargs['game-url']);
|
||||
const url = `${CALLBACK_BASE}/${kind}/game/${id}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`Failed to fetch Chess.com callback ${url}: ${error?.message || error}`);
|
||||
}
|
||||
if (!resp || typeof resp !== 'object') {
|
||||
throw new CommandExecutionError(`Chess.com callback returned an invalid response object for ${url}`);
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
throw new EmptyResultError(`Chess.com has no ${kind} game with id ${id}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`Chess.com callback returned HTTP ${resp.status} for ${url}`);
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`Chess.com callback returned malformed JSON for ${url}: ${error?.message || error}`);
|
||||
}
|
||||
return [summarizeGame({ kind, id, payload })];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { parseGameUrl, summarizeGame };
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './game.js';
|
||||
|
||||
const { summarizeGame } = await import('./game.js').then((m) => m.__test__);
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(payload, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status === 200,
|
||||
status,
|
||||
json: () => Promise.resolve(payload),
|
||||
});
|
||||
}
|
||||
|
||||
describe('chess game command', () => {
|
||||
it('summarizeGame maps the callback payload to the canonical row shape', () => {
|
||||
const row = summarizeGame({
|
||||
kind: 'live',
|
||||
id: '999',
|
||||
payload: {
|
||||
game: {
|
||||
pgnHeaders: {
|
||||
Date: '2026.05.17',
|
||||
White: 'Hikaru',
|
||||
Black: 'tactic',
|
||||
Result: '1-0',
|
||||
ECO: 'A01',
|
||||
WhiteElo: 3454,
|
||||
BlackElo: 2869,
|
||||
TimeControl: '180',
|
||||
Termination: 'Hikaru won by resignation',
|
||||
},
|
||||
colorOfWinner: 'white',
|
||||
isRated: true,
|
||||
plyCount: 111,
|
||||
endTime: 1747584400,
|
||||
},
|
||||
players: {
|
||||
top: { username: 'tactic', color: 'black', rating: 2869 },
|
||||
bottom: { username: 'Hikaru', color: 'white', rating: 3454 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
kind: 'live',
|
||||
game_id: '999',
|
||||
date: '2026-05-17',
|
||||
white: 'Hikaru',
|
||||
white_rating: 3454,
|
||||
black: 'tactic',
|
||||
black_rating: 2869,
|
||||
result: '1-0',
|
||||
winner_color: 'white',
|
||||
termination: 'Hikaru won by resignation',
|
||||
eco: 'A01',
|
||||
time_control: '180',
|
||||
rated: true,
|
||||
ply_count: 111,
|
||||
url: 'https://www.chess.com/game/live/999',
|
||||
});
|
||||
});
|
||||
|
||||
it('summarizeGame falls back to pgnHeaders when players are missing', () => {
|
||||
const row = summarizeGame({
|
||||
kind: 'daily',
|
||||
id: '1',
|
||||
payload: {
|
||||
game: {
|
||||
pgnHeaders: { White: 'A', Black: 'B', Result: '1/2-1/2', WhiteElo: 1200, BlackElo: 1300 },
|
||||
colorOfWinner: '',
|
||||
isRated: false,
|
||||
daysPerTurn: 3,
|
||||
},
|
||||
players: {},
|
||||
},
|
||||
});
|
||||
expect(row.white).toBe('A');
|
||||
expect(row.black_rating).toBe(1300);
|
||||
expect(row.time_control).toBe('3d/turn');
|
||||
expect(row.rated).toBe(false);
|
||||
});
|
||||
|
||||
it('summarizeGame throws CommandExecutionError on missing game payload', () => {
|
||||
expect(() => summarizeGame({ kind: 'live', id: '1', payload: {} })).toThrow(CommandExecutionError);
|
||||
expect(() => summarizeGame({ kind: 'live', id: '1', payload: null })).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('summarizeGame throws CommandExecutionError on malformed nested payloads', () => {
|
||||
expect(() => summarizeGame({
|
||||
kind: 'live',
|
||||
id: '1',
|
||||
payload: { game: { pgnHeaders: [] } },
|
||||
})).toThrow(CommandExecutionError);
|
||||
expect(() => summarizeGame({
|
||||
kind: 'live',
|
||||
id: '1',
|
||||
payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: [] },
|
||||
})).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('summarizeGame requires stable players and result evidence', () => {
|
||||
expect(() => summarizeGame({
|
||||
kind: 'live',
|
||||
id: '1',
|
||||
payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: {} },
|
||||
})).toThrow(CommandExecutionError);
|
||||
expect(() => summarizeGame({
|
||||
kind: 'live',
|
||||
id: '1',
|
||||
payload: { game: { pgnHeaders: { White: 'A', Result: '1-0' } }, players: {} },
|
||||
})).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('command fetches the callback URL and returns a single row', async () => {
|
||||
const fetchMock = mockFetch({
|
||||
game: { pgnHeaders: { White: 'A', Black: 'B', Result: '1-0', WhiteElo: 100, BlackElo: 90 } },
|
||||
players: {},
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('chess/game');
|
||||
const rows = await cmd.func({ 'game-url': 'https://www.chess.com/game/live/42' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].url).toBe('https://www.chess.com/game/live/42');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://www.chess.com/callback/live/game/42',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('command surfaces 404 as EmptyResultError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
||||
const cmd = getRegistry().get('chess/game');
|
||||
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('command surfaces non-2xx as CommandExecutionError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||
const cmd = getRegistry().get('chess/game');
|
||||
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('command maps fetch and JSON failures to CommandExecutionError', async () => {
|
||||
const cmd = getRegistry().get('chess/game');
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
|
||||
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.reject(new SyntaxError('bad json')),
|
||||
}));
|
||||
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('command maps wrong-shape callback JSON to CommandExecutionError', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch([]));
|
||||
const cmd = getRegistry().get('chess/game');
|
||||
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('rejects invalid URL with ArgumentError before any fetch', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('chess/game');
|
||||
await expect(cmd.func({ 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Chess.com recent games from monthly archives. Walks the archive
|
||||
* list newest-first and fetches as few months as needed to fill --limit.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { chessApi, validateUsername, mapGameRow } from './utils.js';
|
||||
|
||||
const MAX_LIMIT = 100;
|
||||
const MAX_ARCHIVE_FETCHES = 6;
|
||||
|
||||
function parseLimit(value) {
|
||||
if (value === undefined || value === null || value === '') return 10;
|
||||
const limit = Number(value);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
||||
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'chess',
|
||||
name: 'games',
|
||||
access: 'read',
|
||||
description: 'Chess.com recent games for a player, newest first',
|
||||
domain: 'api.chess.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: `Number of recent games (1-${MAX_LIMIT})` },
|
||||
],
|
||||
columns: ['date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result', 'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black', 'eco', 'opening_name', 'url'],
|
||||
func: async (kwargs) => {
|
||||
const username = validateUsername(kwargs.username);
|
||||
const limit = parseLimit(kwargs.limit);
|
||||
const archivesList = await chessApi(`/player/${encodeURIComponent(username)}/games/archives`);
|
||||
if (!Array.isArray(archivesList.archives)) {
|
||||
throw new CommandExecutionError('Chess.com archives payload is missing archives array');
|
||||
}
|
||||
const archives = archivesList.archives.slice().reverse();
|
||||
if (archives.length === 0) {
|
||||
throw new EmptyResultError(`Chess.com has no game archives for ${username}`);
|
||||
}
|
||||
const rows = [];
|
||||
for (let i = 0; i < archives.length && i < MAX_ARCHIVE_FETCHES && rows.length < limit; i++) {
|
||||
if (typeof archives[i] !== 'string' || !archives[i].startsWith('https://api.chess.com/pub/player/')) {
|
||||
throw new CommandExecutionError('Chess.com archives payload contains an unexpected archive URL');
|
||||
}
|
||||
const monthly = await chessApi(archives[i]);
|
||||
if (!Array.isArray(monthly.games)) {
|
||||
throw new CommandExecutionError('Chess.com monthly archive payload is missing games array');
|
||||
}
|
||||
const games = monthly.games.slice().reverse();
|
||||
for (const g of games) {
|
||||
rows.push(mapGameRow(g, username));
|
||||
if (rows.length >= limit) break;
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError(`Chess.com has games archives for ${username} but no games in the most recent ${MAX_ARCHIVE_FETCHES} months`);
|
||||
}
|
||||
return rows.slice(0, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = { parseLimit };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './games.js';
|
||||
|
||||
const { parseLimit } = await import('./games.js').then((m) => m.__test__);
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function fetchFor(map) {
|
||||
return vi.fn().mockImplementation((url) => {
|
||||
if (map.has(url)) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(map.get(url)) });
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
function game(white, whiteRating, black, blackRating, endTime, extra = {}) {
|
||||
return {
|
||||
url: `https://www.chess.com/game/live/${endTime}`,
|
||||
end_time: endTime,
|
||||
time_class: 'blitz',
|
||||
rated: true,
|
||||
eco: 'C50',
|
||||
white: { username: white, rating: whiteRating, result: 'win' },
|
||||
black: { username: black, rating: blackRating, result: 'resigned' },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('chess games command', () => {
|
||||
it('parseLimit accepts 1-100, rejects everything else', () => {
|
||||
expect(parseLimit(undefined)).toBe(10);
|
||||
expect(parseLimit(1)).toBe(1);
|
||||
expect(parseLimit(100)).toBe(100);
|
||||
expect(() => parseLimit(0)).toThrow(ArgumentError);
|
||||
expect(() => parseLimit(101)).toThrow(ArgumentError);
|
||||
expect(() => parseLimit(1.5)).toThrow(ArgumentError);
|
||||
expect(() => parseLimit('abc')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('returns recent games newest-first sliced to --limit', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
||||
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/04', 'https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
||||
}],
|
||||
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
|
||||
games: [
|
||||
game('Hikaru', 3286, 'A', 2900, 1777737000),
|
||||
game('Hikaru', 3286, 'B', 2950, 1777737500),
|
||||
game('Hikaru', 3286, 'C', 3000, 1777737900),
|
||||
],
|
||||
}],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
const rows = await cmd.func({ username: 'Hikaru', limit: 2 });
|
||||
expect(rows).toHaveLength(2);
|
||||
// archive is reversed (newest month first), games within are reversed
|
||||
// so the first row corresponds to the LAST game in the JSON array.
|
||||
expect(rows[0].opponent).toBe('C');
|
||||
expect(rows[1].opponent).toBe('B');
|
||||
});
|
||||
|
||||
it('walks multiple months until --limit is filled', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
||||
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/03', 'https://api.chess.com/pub/player/hikaru/games/2026/04'],
|
||||
}],
|
||||
['https://api.chess.com/pub/player/hikaru/games/2026/04', {
|
||||
games: [game('Hikaru', 3286, 'A', 2900, 1777737000)],
|
||||
}],
|
||||
['https://api.chess.com/pub/player/hikaru/games/2026/03', {
|
||||
games: [game('Hikaru', 3286, 'B', 2950, 1774000000)],
|
||||
}],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
const rows = await cmd.func({ username: 'Hikaru', limit: 2 });
|
||||
expect(rows.map((r) => r.opponent)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when archives list is empty', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/someuser/games/archives', { archives: [] }],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when archives payload is wrong-shape', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/someuser/games/archives', { archives: {} }],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when monthly archive games payload is wrong-shape', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
||||
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
||||
}],
|
||||
['https://api.chess.com/pub/player/hikaru/games/2026/05', { games: null }],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when a game row lacks stable identity', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
||||
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
||||
}],
|
||||
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
|
||||
games: [{
|
||||
end_time: 1777737000,
|
||||
white: { username: 'Hikaru', rating: 3286, result: 'win' },
|
||||
black: { username: 'A', rating: 2900, result: 'resigned' },
|
||||
}],
|
||||
}],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when a game row does not include the requested player', async () => {
|
||||
const map = new Map([
|
||||
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
||||
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
||||
}],
|
||||
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
|
||||
games: [game('A', 2900, 'B', 2800, 1777737000)],
|
||||
}],
|
||||
]);
|
||||
vi.stubGlobal('fetch', fetchFor(map));
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws ArgumentError on invalid username before any fetch', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
await expect(cmd.func({ username: 'a b', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers with the expected columns', () => {
|
||||
const cmd = getRegistry().get('chess/games');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result',
|
||||
'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black',
|
||||
'eco', 'opening_name', 'url',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Chess.com player stats across game kinds (rapid / blitz / bullet /
|
||||
* daily / chess960 / etc) via the public stats endpoint.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { chessApi, validateUsername, summarizeStats } from './utils.js';
|
||||
|
||||
const KINDS = ['chess_rapid', 'chess_blitz', 'chess_bullet', 'chess_daily', 'chess960_daily', 'chess_daily_960'];
|
||||
|
||||
cli({
|
||||
site: 'chess',
|
||||
name: 'stats',
|
||||
access: 'read',
|
||||
description: 'Chess.com player ratings + win/loss record across game kinds',
|
||||
domain: 'api.chess.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username (case-insensitive)' },
|
||||
],
|
||||
columns: ['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws'],
|
||||
func: async (kwargs) => {
|
||||
const username = validateUsername(kwargs.username);
|
||||
const stats = await chessApi(`/player/${encodeURIComponent(username)}/stats`);
|
||||
const rows = KINDS.map((k) => summarizeStats(stats, k)).filter(Boolean);
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError(`Chess.com returned no stats for ${username}`);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './stats.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('chess stats command', () => {
|
||||
it('rejects empty username via validateUsername', async () => {
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
await expect(cmd.func({ username: '' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects invalid username characters', async () => {
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
await expect(cmd.func({ username: 'user name' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
|
||||
it('returns one row per known game kind populated in the stats response', async () => {
|
||||
const fetchMock = mockFetch({
|
||||
chess_rapid: { last: { rating: 1700 }, best: { rating: 1800 }, record: { win: 50, loss: 20, draw: 5 } },
|
||||
chess_blitz: { last: { rating: 1500 }, best: { rating: 1600 }, record: { win: 100, loss: 80, draw: 10 } },
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
const rows = await cmd.func({ username: 'someuser' });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].kind).toBe('rapid');
|
||||
expect(rows[1].kind).toBe('blitz');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.chess.com/pub/player/someuser/stats',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('lowercases username in the URL', async () => {
|
||||
const fetchMock = mockFetch({ chess_rapid: { last: { rating: 1 }, best: {}, record: {} } });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
await cmd.func({ username: 'MixedCase' });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.chess.com/pub/player/mixedcase/stats',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when the stats response has no known kinds', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch({}));
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when a populated stats kind is malformed', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch({ chess_rapid: 'bad' }));
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError on HTTP 404', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('registers with the expected columns', () => {
|
||||
const cmd = getRegistry().get('chess/stats');
|
||||
expect(cmd?.columns).toEqual(['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Shared helpers for the public Chess.com REST API
|
||||
* (https://api.chess.com/pub/). No auth, no rate-limit headers.
|
||||
*/
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const API_BASE = 'https://api.chess.com/pub';
|
||||
export const UA = 'Mozilla/5.0 (compatible; opencli/1.0)';
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,25}$/;
|
||||
const GAME_URL_RE = /^https:\/\/www\.chess\.com\/game\/(live|daily)\/(\d+)/i;
|
||||
|
||||
export function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOptionalPlainObject(value) {
|
||||
return value === undefined || value === null || isPlainObject(value);
|
||||
}
|
||||
|
||||
export function validateUsername(value) {
|
||||
const s = String(value ?? '').trim().toLowerCase();
|
||||
if (!s) throw new ArgumentError('<username> is required');
|
||||
if (!USERNAME_RE.test(s)) {
|
||||
throw new ArgumentError(`Invalid Chess.com username "${value}"`, 'Usernames are 3-25 chars: a-z, 0-9, hyphen, underscore.');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function parseGameUrl(value) {
|
||||
const s = String(value ?? '').trim();
|
||||
if (!s) throw new ArgumentError('<game-url> is required');
|
||||
const m = s.match(GAME_URL_RE);
|
||||
if (!m) {
|
||||
throw new ArgumentError(
|
||||
`Invalid Chess.com game URL: "${value}"`,
|
||||
'Expected https://www.chess.com/game/live/<id> or https://www.chess.com/game/daily/<id>.',
|
||||
);
|
||||
}
|
||||
return { kind: m[1].toLowerCase(), id: m[2] };
|
||||
}
|
||||
|
||||
export async function chessApi(path, fetchImpl = fetch) {
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetchImpl(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`Failed to fetch Chess.com API ${url}: ${error?.message || error}`);
|
||||
}
|
||||
if (!resp || typeof resp !== 'object') {
|
||||
throw new CommandExecutionError(`Chess.com API returned an invalid response object for ${url}`);
|
||||
}
|
||||
if (resp.status === 404) throw new EmptyResultError(`Chess.com returned 404 for ${url}`);
|
||||
if (!resp.ok) throw new CommandExecutionError(`Chess.com API returned HTTP ${resp.status} for ${url}`);
|
||||
let payload;
|
||||
try {
|
||||
payload = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`Chess.com API returned malformed JSON for ${url}: ${error?.message || error}`);
|
||||
}
|
||||
if (!isPlainObject(payload)) {
|
||||
throw new CommandExecutionError(`Chess.com API returned an unexpected payload shape for ${url}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Pull rating + record fields out of a stats sub-object (`chess_rapid` etc). */
|
||||
export function summarizeStats(stats, kind) {
|
||||
const k = stats?.[kind];
|
||||
if (!k) return null;
|
||||
if (!isPlainObject(k)) {
|
||||
throw new CommandExecutionError(`Chess.com stats payload for ${kind} is not an object`);
|
||||
}
|
||||
if (!isOptionalPlainObject(k.last)) {
|
||||
throw new CommandExecutionError(`Chess.com stats payload for ${kind}.last is not an object`);
|
||||
}
|
||||
if (!isOptionalPlainObject(k.best)) {
|
||||
throw new CommandExecutionError(`Chess.com stats payload for ${kind}.best is not an object`);
|
||||
}
|
||||
if (!isOptionalPlainObject(k.record)) {
|
||||
throw new CommandExecutionError(`Chess.com stats payload for ${kind}.record is not an object`);
|
||||
}
|
||||
const record = isPlainObject(k.record) ? k.record : {};
|
||||
return {
|
||||
kind: kind.replace(/^chess_/, ''),
|
||||
rating_current: k.last?.rating ?? '',
|
||||
rating_best: k.best?.rating ?? '',
|
||||
wins: record.win ?? '',
|
||||
losses: record.loss ?? '',
|
||||
draws: record.draw ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse an end_time epoch (seconds) into YYYY-MM-DD. */
|
||||
export function formatDate(epochSeconds) {
|
||||
if (!epochSeconds || typeof epochSeconds !== 'number') return '';
|
||||
return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull "Reti Opening: Nimzo-Larsen Variation" out of the Chess.com eco URL
|
||||
* (`https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-...`).
|
||||
* Returns '' for short-code eco values (`A01`) where no name is encoded.
|
||||
*/
|
||||
export function openingName(eco) {
|
||||
if (typeof eco !== 'string' || !eco.startsWith('http')) return '';
|
||||
const tail = eco.replace(/\/+$/, '').split('/').pop() || '';
|
||||
if (!tail) return '';
|
||||
const namePart = tail.match(/^([^.]+?)(?:-\d|\.\.\.|$)/);
|
||||
const cleaned = (namePart ? namePart[1] : tail).replace(/-/g, ' ').trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Chess.com game record (from the monthly archive) to a flat row.
|
||||
* The viewer perspective controls win/loss orientation.
|
||||
*/
|
||||
export function mapGameRow(game, viewerUsername) {
|
||||
if (!isPlainObject(game)) {
|
||||
throw new CommandExecutionError('Chess.com game archive entry is not an object');
|
||||
}
|
||||
if (typeof game.url !== 'string' || !/^https:\/\/www\.chess\.com\/game\/(?:live|daily)\/\d+(?:$|[/?#])/i.test(game.url)) {
|
||||
throw new CommandExecutionError('Chess.com game archive entry is missing a stable game URL');
|
||||
}
|
||||
const white = game?.white || {};
|
||||
const black = game?.black || {};
|
||||
if (!isPlainObject(white) || !isPlainObject(black)) {
|
||||
throw new CommandExecutionError('Chess.com game archive entry has malformed player objects');
|
||||
}
|
||||
if (typeof white.username !== 'string' || !white.username.trim() || typeof black.username !== 'string' || !black.username.trim()) {
|
||||
throw new CommandExecutionError('Chess.com game archive entry is missing stable player identities');
|
||||
}
|
||||
const viewerLower = String(viewerUsername || '').toLowerCase();
|
||||
const viewerIsWhite = String(white.username || '').toLowerCase() === viewerLower;
|
||||
const viewerIsBlack = String(black.username || '').toLowerCase() === viewerLower;
|
||||
if (!viewerIsWhite && !viewerIsBlack) {
|
||||
throw new CommandExecutionError('Chess.com game archive entry does not include the requested player');
|
||||
}
|
||||
const me = viewerIsWhite ? white : black;
|
||||
const opp = viewerIsWhite ? black : white;
|
||||
const eco = game?.eco || '';
|
||||
return {
|
||||
date: formatDate(game?.end_time),
|
||||
time_class: game?.time_class || '',
|
||||
rated: game?.rated === true,
|
||||
my_color: viewerIsWhite ? 'white' : 'black',
|
||||
my_rating: me?.rating ?? '',
|
||||
my_result: me?.result || '',
|
||||
opponent: opp?.username || '',
|
||||
opponent_rating: opp?.rating ?? '',
|
||||
accuracy_white: typeof game?.accuracies?.white === 'number' ? game.accuracies.white : '',
|
||||
accuracy_black: typeof game?.accuracies?.black === 'number' ? game.accuracies.black : '',
|
||||
eco,
|
||||
opening_name: openingName(eco),
|
||||
url: game?.url || '',
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
validateUsername,
|
||||
parseGameUrl,
|
||||
isPlainObject,
|
||||
isOptionalPlainObject,
|
||||
chessApi,
|
||||
summarizeStats,
|
||||
formatDate,
|
||||
mapGameRow,
|
||||
openingName,
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { __test__ } from './utils.js';
|
||||
|
||||
const { validateUsername, parseGameUrl, chessApi, summarizeStats, formatDate, mapGameRow, openingName } = __test__;
|
||||
|
||||
describe('chess utils', () => {
|
||||
it('validateUsername lowercases and accepts 3-25 char usernames', () => {
|
||||
expect(validateUsername('Hikaru')).toBe('hikaru');
|
||||
expect(validateUsername('MagnusCarlsen')).toBe('magnuscarlsen');
|
||||
expect(validateUsername('a-b_c')).toBe('a-b_c');
|
||||
});
|
||||
|
||||
it('validateUsername rejects empty / too-short / invalid chars', () => {
|
||||
expect(() => validateUsername('')).toThrow(ArgumentError);
|
||||
expect(() => validateUsername('ab')).toThrow(ArgumentError);
|
||||
expect(() => validateUsername('user name')).toThrow(ArgumentError);
|
||||
expect(() => validateUsername('a'.repeat(30))).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('parseGameUrl parses both live and daily game URL forms', () => {
|
||||
expect(parseGameUrl('https://www.chess.com/game/live/168842570216'))
|
||||
.toEqual({ kind: 'live', id: '168842570216' });
|
||||
expect(parseGameUrl('https://www.chess.com/game/daily/947761777'))
|
||||
.toEqual({ kind: 'daily', id: '947761777' });
|
||||
expect(parseGameUrl('https://www.chess.com/game/LIVE/1'))
|
||||
.toEqual({ kind: 'live', id: '1' });
|
||||
});
|
||||
|
||||
it('parseGameUrl strips trailing path / query off the URL', () => {
|
||||
expect(parseGameUrl('https://www.chess.com/game/live/123/something?ref=share'))
|
||||
.toEqual({ kind: 'live', id: '123' });
|
||||
});
|
||||
|
||||
it('parseGameUrl rejects empty / non-URL / unsupported-kind inputs', () => {
|
||||
expect(() => parseGameUrl('')).toThrow(ArgumentError);
|
||||
expect(() => parseGameUrl(' ')).toThrow(ArgumentError);
|
||||
expect(() => parseGameUrl('123')).toThrow(ArgumentError);
|
||||
expect(() => parseGameUrl('https://www.chess.com/club/123')).toThrow(ArgumentError);
|
||||
expect(() => parseGameUrl('https://lichess.org/abc')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('summarizeStats projects rating + record fields', () => {
|
||||
const stats = {
|
||||
chess_rapid: {
|
||||
last: { rating: 1600 },
|
||||
best: { rating: 1800 },
|
||||
record: { win: 100, loss: 50, draw: 10 },
|
||||
},
|
||||
};
|
||||
expect(summarizeStats(stats, 'chess_rapid')).toEqual({
|
||||
kind: 'rapid',
|
||||
rating_current: 1600,
|
||||
rating_best: 1800,
|
||||
wins: 100,
|
||||
losses: 50,
|
||||
draws: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('summarizeStats returns null for missing kind', () => {
|
||||
expect(summarizeStats({}, 'chess_rapid')).toBeNull();
|
||||
expect(summarizeStats({ chess_blitz: {} }, 'chess_rapid')).toBeNull();
|
||||
});
|
||||
|
||||
it('summarizeStats typed-fails malformed populated kind objects', () => {
|
||||
expect(() => summarizeStats({ chess_rapid: 'bad' }, 'chess_rapid')).toThrow(CommandExecutionError);
|
||||
expect(() => summarizeStats({ chess_rapid: { record: [] } }, 'chess_rapid')).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('summarizeStats coerces missing numeric fields to empty string', () => {
|
||||
const row = summarizeStats({ chess_daily: { last: {}, record: {} } }, 'chess_daily');
|
||||
expect(row).toEqual({
|
||||
kind: 'daily',
|
||||
rating_current: '',
|
||||
rating_best: '',
|
||||
wins: '',
|
||||
losses: '',
|
||||
draws: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('formatDate converts epoch seconds to YYYY-MM-DD', () => {
|
||||
expect(formatDate(1777737679)).toBe('2026-05-02');
|
||||
expect(formatDate(0)).toBe('');
|
||||
expect(formatDate(null)).toBe('');
|
||||
expect(formatDate('not-a-number')).toBe('');
|
||||
});
|
||||
|
||||
it('mapGameRow returns rows from the viewer perspective when viewer is white', () => {
|
||||
const game = {
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
end_time: 1777737679,
|
||||
time_class: 'blitz',
|
||||
rated: true,
|
||||
eco: 'C50',
|
||||
accuracies: { white: 87.73, black: 80.23 },
|
||||
white: { username: 'Hikaru', rating: 3286, result: 'win' },
|
||||
black: { username: 'Magnus', rating: 2900, result: 'resigned' },
|
||||
};
|
||||
expect(mapGameRow(game, 'Hikaru')).toEqual({
|
||||
date: '2026-05-02',
|
||||
time_class: 'blitz',
|
||||
rated: true,
|
||||
my_color: 'white',
|
||||
my_rating: 3286,
|
||||
my_result: 'win',
|
||||
opponent: 'Magnus',
|
||||
opponent_rating: 2900,
|
||||
accuracy_white: 87.73,
|
||||
accuracy_black: 80.23,
|
||||
eco: 'C50',
|
||||
opening_name: '',
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
});
|
||||
});
|
||||
|
||||
it('mapGameRow leaves accuracy fields empty when chess.com did not compute them', () => {
|
||||
const game = {
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
white: { username: 'A', rating: 1, result: 'win' },
|
||||
black: { username: 'B', rating: 1, result: 'resigned' },
|
||||
};
|
||||
const row = mapGameRow(game, 'A');
|
||||
expect(row.accuracy_white).toBe('');
|
||||
expect(row.accuracy_black).toBe('');
|
||||
});
|
||||
|
||||
it('mapGameRow parses opening_name from the chess.com eco URL', () => {
|
||||
const game = {
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
eco: 'https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-3.Bb2-Bg7-4.d4',
|
||||
white: { username: 'A', rating: 1, result: 'win' },
|
||||
black: { username: 'B', rating: 1, result: 'resigned' },
|
||||
};
|
||||
const row = mapGameRow(game, 'A');
|
||||
expect(row.opening_name).toBe('Reti Opening Nimzo Larsen Variation');
|
||||
});
|
||||
|
||||
it('openingName helper returns clean human-readable name from URL form', () => {
|
||||
expect(openingName('https://www.chess.com/openings/Sicilian-Defense')).toBe('Sicilian Defense');
|
||||
expect(openingName('https://www.chess.com/openings/Kings-Indian-Defense-Semi-Classical-Variation...7.O-O'))
|
||||
.toBe('Kings Indian Defense Semi Classical Variation');
|
||||
expect(openingName('https://www.chess.com/openings/French-Defense-Advance-Variation-3...c5-4.c3'))
|
||||
.toBe('French Defense Advance Variation');
|
||||
});
|
||||
|
||||
it('openingName returns empty for short-code eco or missing input', () => {
|
||||
expect(openingName('A01')).toBe('');
|
||||
expect(openingName('')).toBe('');
|
||||
expect(openingName(undefined)).toBe('');
|
||||
expect(openingName(null)).toBe('');
|
||||
});
|
||||
|
||||
it('mapGameRow flips perspective when viewer is black', () => {
|
||||
const game = {
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
white: { username: 'Hikaru', rating: 3286, result: 'win' },
|
||||
black: { username: 'Magnus', rating: 2900, result: 'resigned' },
|
||||
};
|
||||
const row = mapGameRow(game, 'Magnus');
|
||||
expect(row.my_color).toBe('black');
|
||||
expect(row.my_result).toBe('resigned');
|
||||
expect(row.opponent).toBe('Hikaru');
|
||||
});
|
||||
|
||||
it('mapGameRow matches viewer case-insensitively', () => {
|
||||
const game = {
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
white: { username: 'Hikaru', rating: 3286, result: 'win' },
|
||||
black: { username: 'Magnus', rating: 2900, result: 'resigned' },
|
||||
};
|
||||
expect(mapGameRow(game, 'hikaru').my_color).toBe('white');
|
||||
expect(mapGameRow(game, 'MAGNUS').my_color).toBe('black');
|
||||
});
|
||||
|
||||
it('mapGameRow typed-fails when viewer is neither player', () => {
|
||||
const game = {
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
white: { username: 'A', rating: 1000, result: 'win' },
|
||||
black: { username: 'B', rating: 1100, result: 'resigned' },
|
||||
};
|
||||
expect(() => mapGameRow(game, 'C')).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('mapGameRow handles missing optional fields without throwing', () => {
|
||||
const row = mapGameRow({
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
white: { username: 'x' },
|
||||
black: { username: 'y' },
|
||||
}, 'x');
|
||||
expect(row.date).toBe('');
|
||||
expect(row.url).toBe('https://www.chess.com/game/live/123');
|
||||
expect(row.eco).toBe('');
|
||||
});
|
||||
|
||||
it('mapGameRow typed-fails missing stable URL or player identity', () => {
|
||||
expect(() => mapGameRow({
|
||||
white: { username: 'x' },
|
||||
black: { username: 'y' },
|
||||
}, 'x')).toThrow(CommandExecutionError);
|
||||
expect(() => mapGameRow({
|
||||
url: 'https://www.chess.com/game/live/123',
|
||||
white: { username: 'x' },
|
||||
black: {},
|
||||
}, 'x')).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('chessApi maps network, malformed JSON, and wrong-shape payloads to typed errors', async () => {
|
||||
await expect(chessApi('/x', async () => { throw new TypeError('network down'); }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(chessApi('/x', async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('bad json'); },
|
||||
}))).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(chessApi('/x', async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [],
|
||||
}))).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('chessApi preserves 404 as empty and non-2xx as command execution errors', async () => {
|
||||
await expect(chessApi('/x', async () => ({ ok: false, status: 404 })))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
await expect(chessApi('/x', async () => ({ ok: false, status: 500 })))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasClaudeSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://claude.ai' });
|
||||
return cookies.some(c => c.name === 'sessionKey' && c.value);
|
||||
}
|
||||
|
||||
async function verifyClaudeIdentity(page) {
|
||||
if (!await hasClaudeSessionCookie(page)) {
|
||||
throw new AuthRequiredError('claude.ai', 'Claude sessionKey cookie missing');
|
||||
}
|
||||
await page.goto('https://claude.ai/');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/organizations', { credentials: 'include' });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'Claude /api/organizations HTTP ' + res.status };
|
||||
}
|
||||
if (!res.ok) return { kind: 'http', httpStatus: res.status };
|
||||
const d = await res.json();
|
||||
if (!Array.isArray(d) || d.length === 0) {
|
||||
return { kind: 'auth', detail: 'Claude /api/organizations empty' };
|
||||
}
|
||||
const userIdCookie = (document.cookie.split('; ').find(c => c.startsWith('ajs_user_id=')) || '').split('=')[1] || '';
|
||||
const activeOrgCookie = (document.cookie.split('; ').find(c => c.startsWith('lastActiveOrg=')) || '').split('=')[1] || '';
|
||||
const activeOrg = d.find(o => o.uuid === activeOrgCookie) || d[0];
|
||||
return { ok: true, user_id: userIdCookie, org_name: activeOrg.name || '', org_uuid: activeOrg.uuid || '' };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('claude.ai', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/organizations`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`Claude whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected Claude probe: ${JSON.stringify(result)}`);
|
||||
if (!result.user_id) throw new AuthRequiredError('claude.ai', 'Claude session incomplete — ajs_user_id cookie missing');
|
||||
return { user_id: String(result.user_id), org_name: String(result.org_name), org_uuid: String(result.org_uuid) };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'claude',
|
||||
domain: 'claude.ai',
|
||||
loginUrl: 'https://claude.ai/login',
|
||||
columns: ['user_id', 'org_name', 'org_uuid'],
|
||||
quickCheck: hasClaudeSessionCookie,
|
||||
verify: verifyClaudeIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasClaudeSessionCookie(page)) {
|
||||
throw new AuthRequiredError('claude.ai', 'Waiting for Claude sessionKey cookie');
|
||||
}
|
||||
return verifyClaudeIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
// Shared helpers for Codex conversation management (pin/unpin/archive/rename).
|
||||
//
|
||||
// Codex App exposes 8 actions via the "Chat actions" header dropdown on the
|
||||
// currently-active chat. We use that path because it's the only one that
|
||||
// works regardless of window visibility:
|
||||
//
|
||||
// - The per-row sidebar buttons (Pin chat / Archive chat) are React
|
||||
// hover-only — they're LAZILY MOUNTED only when the row is hovered,
|
||||
// AND only when `document.visibilityState === 'visible'`. When the
|
||||
// Codex window is hidden / minimized, even programmatic mouseenter
|
||||
// won't surface them.
|
||||
//
|
||||
// - The Chat actions menu mounts its items on click, doesn't care about
|
||||
// window visibility, and supports all the operations we need:
|
||||
// Unpin chat ⌥⌘P (or "Pin chat" when not pinned)
|
||||
// Rename chat ⌥⌘R
|
||||
// Archive chat ⇧⌘A
|
||||
// Open side chat, Copy, Fork, Add automation…, Open in new window
|
||||
//
|
||||
// Caveat: this means each action targets the ACTIVE chat. We select the
|
||||
// target first via openCodexConversation (using --project / --conversation
|
||||
// / --index / --thread-id), then trigger the menu and click.
|
||||
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
collectCodexProjectsFromDocument,
|
||||
conversationSelectionArgs,
|
||||
hasConversationTarget,
|
||||
openCodexConversation,
|
||||
} from './sidebar.js';
|
||||
|
||||
export { conversationSelectionArgs };
|
||||
|
||||
export function unwrapEvaluateResult(result) {
|
||||
if (result && typeof result === 'object' && 'data' in result && 'session' in result) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function sameProject(a, b) {
|
||||
const left = cleanText(a).toLowerCase();
|
||||
const right = cleanText(b).toLowerCase();
|
||||
return !left || !right || left === right;
|
||||
}
|
||||
|
||||
export function findCodexConversation(projects, ref) {
|
||||
if (!Array.isArray(projects)) {
|
||||
return null;
|
||||
}
|
||||
for (const project of projects) {
|
||||
for (const conversation of project.conversations || []) {
|
||||
if (ref.threadId && conversation.threadId === ref.threadId) {
|
||||
return { project, conversation };
|
||||
}
|
||||
if (!ref.threadId
|
||||
&& ref.conversation
|
||||
&& cleanText(conversation.title) === cleanText(ref.conversation)
|
||||
&& sameProject(project.project, ref.project)) {
|
||||
return { project, conversation };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findActiveCodexConversation(projects) {
|
||||
const active = [];
|
||||
for (const project of projects || []) {
|
||||
for (const conversation of project.conversations || []) {
|
||||
if (conversation.active) {
|
||||
active.push({ project, conversation });
|
||||
}
|
||||
}
|
||||
}
|
||||
return active.length === 1 ? active[0] : null;
|
||||
}
|
||||
|
||||
export async function readConversationProjects(page) {
|
||||
const projects = unwrapEvaluateResult(await page.evaluate(`(${collectCodexProjectsFromDocument.toString()})()`));
|
||||
if (!Array.isArray(projects)) {
|
||||
throw new CommandExecutionError('Codex sidebar extraction returned an invalid payload.');
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
export async function resolveActionConversation(page, kwargs) {
|
||||
const selected = await openCodexConversation(page, kwargs);
|
||||
const projects = await readConversationProjects(page);
|
||||
const resolved = selected
|
||||
? findCodexConversation(projects, selected)
|
||||
: findActiveCodexConversation(projects);
|
||||
if (!resolved) {
|
||||
const hint = hasConversationTarget(kwargs)
|
||||
? 'The selected Codex conversation was not visible after selection.'
|
||||
: 'Pass --project/--conversation/--index/--thread-id, or keep the active conversation visible in the sidebar.';
|
||||
throw new CommandExecutionError('Could not resolve a stable Codex conversation identity.', hint);
|
||||
}
|
||||
if (!resolved.conversation.threadId) {
|
||||
throw new CommandExecutionError(
|
||||
'Could not resolve a stable Codex conversation identity.',
|
||||
'The selected sidebar row is missing its Codex thread id; selectors may have drifted.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
project: resolved.project.project,
|
||||
projectPath: resolved.project.projectPath,
|
||||
conversation: resolved.conversation.title,
|
||||
threadId: resolved.conversation.threadId,
|
||||
pinned: resolved.conversation.pinned,
|
||||
index: resolved.conversation.index,
|
||||
};
|
||||
}
|
||||
|
||||
function conversationRefForError(ref) {
|
||||
return ref.threadId || `${ref.project || '(unknown project)'}/${ref.conversation || '(unknown conversation)'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "Chat actions" header menu on the currently-active chat and
|
||||
* click the menu item whose visible text matches one of `labelOptions`.
|
||||
*
|
||||
* Single-evaluate so the menu stays mounted while we click — and uses
|
||||
* the full pointer-event chain because radix's menu trigger only responds
|
||||
* to pointerdown/up sequences, not bare .click().
|
||||
*
|
||||
* Returns { ok, clicked? , reason?, detail? }.
|
||||
*/
|
||||
export async function clickChatActionsMenuItem(page, labelOptions) {
|
||||
const labelsJson = JSON.stringify(labelOptions);
|
||||
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const labels = ${labelsJson};
|
||||
|
||||
const trigger = document.querySelector('button[aria-label="Chat actions"]');
|
||||
if (!(trigger instanceof HTMLButtonElement)) {
|
||||
return { ok: false, reason: 'Chat actions button not found in the chat header.' };
|
||||
}
|
||||
|
||||
// Radix listens to pointer events — bare .click() is silently ignored.
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(rect.left + rect.width / 2),
|
||||
clientY: Math.round(rect.top + rect.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));
|
||||
|
||||
// Poll for menu items to mount (typically < 300ms).
|
||||
let menuItems = [];
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await wait(75);
|
||||
menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (menuItems.length) break;
|
||||
}
|
||||
if (!menuItems.length) {
|
||||
return { ok: false, reason: 'Chat actions menu did not open after pointer click.' };
|
||||
}
|
||||
|
||||
// Match by label — menu items render as "<label><kbd>shortcut</kbd>" so
|
||||
// we compare the leading text (innerText through the first newline /
|
||||
// kbd boundary).
|
||||
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);
|
||||
// Close the menu so it doesn't stay open as a side effect.
|
||||
document.body.click();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'No menu item matched the requested label.',
|
||||
detail: 'wanted=' + JSON.stringify(labels) + ' visible=' + JSON.stringify(visible),
|
||||
};
|
||||
}
|
||||
|
||||
// Defer click to a microtask so the eval response returns BEFORE the
|
||||
// action triggers a re-render that could swallow our reply.
|
||||
const matchedLabel = leadingText(target);
|
||||
Promise.resolve().then(() => { try { target.click(); } catch {} });
|
||||
return { ok: true, clicked: matchedLabel };
|
||||
})()`));
|
||||
|
||||
return result || { ok: false, reason: 'Empty result from page.evaluate.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that selects the target first, then clicks the menu.
|
||||
*/
|
||||
export async function selectAndClickAction(page, kwargs, labelOptions) {
|
||||
const selected = await resolveActionConversation(page, kwargs);
|
||||
await page.wait(0.4);
|
||||
const result = await clickChatActionsMenuItem(page, labelOptions);
|
||||
if (!result.ok) {
|
||||
const detail = result.detail ? ` ${result.detail}` : '';
|
||||
throw new CommandExecutionError(
|
||||
`${result.reason || 'Failed to perform action.'}${detail}`,
|
||||
'Make sure Codex Desktop is running and the target conversation is selectable.',
|
||||
);
|
||||
}
|
||||
return { ...result, selected };
|
||||
}
|
||||
|
||||
export async function waitForConversationPostcondition(page, ref, predicate, description, timeoutMs = 4000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastMatch = null;
|
||||
while (Date.now() < deadline) {
|
||||
const projects = await readConversationProjects(page);
|
||||
lastMatch = findCodexConversation(projects, ref);
|
||||
if (predicate(lastMatch)) {
|
||||
return lastMatch;
|
||||
}
|
||||
await page.wait(0.2);
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`Codex ${description} was not verified for ${conversationRefForError(ref)}.`,
|
||||
'The UI action may have failed or the sidebar selectors may have drifted.',
|
||||
);
|
||||
}
|
||||
|
||||
export async function setConversationPinned(page, kwargs, desiredPinned) {
|
||||
const selected = await resolveActionConversation(page, kwargs);
|
||||
if (selected.pinned === desiredPinned) {
|
||||
return { status: desiredPinned ? 'already-pinned' : 'already-unpinned', selected };
|
||||
}
|
||||
const action = await selectAndClickAction(page, kwargs, [desiredPinned ? 'Pin chat' : 'Unpin chat']);
|
||||
await waitForConversationPostcondition(
|
||||
page,
|
||||
action.selected,
|
||||
match => match?.conversation?.pinned === desiredPinned,
|
||||
desiredPinned ? 'pin' : 'unpin',
|
||||
);
|
||||
return { status: desiredPinned ? 'pinned' : 'unpinned', selected: action.selected };
|
||||
}
|
||||
|
||||
export async function archiveConversation(page, kwargs) {
|
||||
const selected = await selectAndClickAction(page, kwargs, ['Archive chat']);
|
||||
await waitForConversationPostcondition(
|
||||
page,
|
||||
selected.selected,
|
||||
match => !match,
|
||||
'archive',
|
||||
);
|
||||
return { status: 'archived', selected: selected.selected };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { archiveConversation, conversationSelectionArgs, resolveActionConversation } from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'codex',
|
||||
name: 'archive',
|
||||
access: 'write',
|
||||
description: 'Archive (Codex\'s term for delete) the selected conversation via the Chat actions header menu. No confirmation in UI — pass --yes to actually archive.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually archive (default: dry-run preview)' },
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['status', 'thread_id', 'project', 'conversation'],
|
||||
func: async (page, kwargs) => {
|
||||
const yes = kwargs.yes === true || kwargs.yes === 'true' || kwargs.yes === '1';
|
||||
if (!yes) {
|
||||
// Resolve target so the dry-run still names what WOULD be archived.
|
||||
const selected = await resolveActionConversation(page, kwargs);
|
||||
return [{
|
||||
status: 'dry-run',
|
||||
thread_id: selected.threadId,
|
||||
project: selected.project,
|
||||
conversation: selected.conversation,
|
||||
}];
|
||||
}
|
||||
const result = await archiveConversation(page, kwargs);
|
||||
return [{
|
||||
status: result.status,
|
||||
thread_id: result.selected.threadId,
|
||||
project: result.selected.project,
|
||||
conversation: result.selected.conversation,
|
||||
}];
|
||||
},
|
||||
});
|
||||
+249
-41
@@ -1,56 +1,264 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
|
||||
|
||||
// Codex Desktop App exposes the active model + reasoning level on a button
|
||||
// in the composer bottom toolbar. As of 2026-05-31 the button has no
|
||||
// stable aria-label or data-testid — we anchor to it by walking up from
|
||||
// the composer contenteditable and finding the button whose visible text
|
||||
// matches a known pattern (model version like "5.5"/"5.4" OR reasoning
|
||||
// level "Low"/"Medium"/"High"/"Extra High"/"Auto"/"Speed").
|
||||
//
|
||||
// Clicking the button opens a menu with BOTH:
|
||||
// - Reasoning levels: Low / Medium / High / Extra High / Speed / Auto
|
||||
// - Model versions: GPT-5.5 / GPT-5.4 / ...
|
||||
// Either kind of value can be selected via 'opencli codex model <name>'.
|
||||
|
||||
const MODEL_BTN_TEXT_RE = /5\.\d|[Ee]xtra [Hh]igh|^High$|^Medium$|^Low$|^Auto$|^Fast$|^Speed$|^Pro$|GPT-/;
|
||||
const MODEL_BTN_PATTERN = MODEL_BTN_TEXT_RE.source;
|
||||
|
||||
function unwrapEvaluateResult(result) {
|
||||
if (result && typeof result === 'object' && 'data' in result && 'session' in result) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeModelText(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\bgpt[-\s]*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const REASONING_OPTIONS = ['extra high', 'medium', 'high', 'low', 'auto', 'fast', 'speed', 'pro'];
|
||||
|
||||
function extractReasoning(value) {
|
||||
return REASONING_OPTIONS.find(option => value === option || value.endsWith(` ${option}`)) || '';
|
||||
}
|
||||
|
||||
function extractModelVersion(value) {
|
||||
const match = value.match(/(?:^|\s)(\d+(?:\.\d+)?)(?=\s|$)/);
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
export function findUniqueModelOption(labels, rawName) {
|
||||
const name = normalizeModelText(rawName);
|
||||
if (!name) {
|
||||
throw new ArgumentError('model name cannot be empty');
|
||||
}
|
||||
const normalized = labels.map((label) => ({ label, normalized: normalizeModelText(label) }));
|
||||
const exact = normalized.filter(item => item.normalized === name);
|
||||
if (exact.length === 1) {
|
||||
return exact[0].label;
|
||||
}
|
||||
if (exact.length > 1) {
|
||||
throw new CommandExecutionError(`Model name "${rawName}" is ambiguous.`, `Matches: ${exact.map(item => item.label).join(', ')}`);
|
||||
}
|
||||
const partial = normalized.filter(item => item.normalized.includes(name));
|
||||
if (partial.length === 1) {
|
||||
return partial[0].label;
|
||||
}
|
||||
if (partial.length > 1) {
|
||||
throw new CommandExecutionError(`Model name "${rawName}" is ambiguous.`, `Matches: ${partial.map(item => item.label).join(', ')}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function modelSelectionVerified(current, chosen) {
|
||||
const active = normalizeModelText(current);
|
||||
const selected = normalizeModelText(chosen);
|
||||
if (!active || !selected) {
|
||||
return false;
|
||||
}
|
||||
if (active === selected) {
|
||||
return true;
|
||||
}
|
||||
if (REASONING_OPTIONS.includes(selected)) {
|
||||
return extractReasoning(active) === selected;
|
||||
}
|
||||
const selectedModel = extractModelVersion(selected);
|
||||
if (selectedModel) {
|
||||
return extractModelVersion(active) === selectedModel;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'model',
|
||||
access: 'read',
|
||||
description: 'Get or switch the currently active AI model in Codex Desktop',
|
||||
access: 'write',
|
||||
description: 'Read, list, or switch the active model / reasoning level in Codex Desktop. The composer toolbar button toggles a menu that mixes model variants (GPT-5.5, Speed) with reasoning levels (Low/Medium/High/Extra High).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'model-name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. gpt-4)' }
|
||||
{ name: 'name', required: false, positional: true, help: 'Substring (case-insensitive) of a model / reasoning level to switch to. Omit to read current.' },
|
||||
{ name: 'list', type: 'boolean', default: false, help: 'List all menu options (does not switch)' },
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page, kwargs) => {
|
||||
const desiredModel = kwargs['model-name'];
|
||||
if (!desiredModel) {
|
||||
// Just read the current model. We traverse iframes/webviews if needed.
|
||||
const currentModel = await page.evaluate(`
|
||||
(function() {
|
||||
// Look for any typical model switcher selectors in the DOM
|
||||
let m = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
|
||||
|
||||
if (!m && document.querySelector('webview, iframe')) {
|
||||
// Not directly in main DOM, might be in a webview, but Playwright evaluate doesn't cross origin boundaries easily without frames[].
|
||||
return 'Unknown (Likely inside a WebView, please focus the Chat tab)';
|
||||
}
|
||||
return m ? (m.textContent || m.getAttribute('title') || m.getAttribute('aria-label')).trim() : 'Unknown or Not Found';
|
||||
})()
|
||||
`);
|
||||
return [
|
||||
{
|
||||
Status: 'Active',
|
||||
Model: currentModel,
|
||||
},
|
||||
];
|
||||
const name = String(kwargs.name || '').trim().toLowerCase();
|
||||
const listOnly = kwargs.list === true || kwargs.list === 'true';
|
||||
const patternJson = JSON.stringify(MODEL_BTN_PATTERN);
|
||||
|
||||
const current = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
const re = new RegExp(${patternJson});
|
||||
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
||||
const last = composers[composers.length - 1];
|
||||
if (!last) return '';
|
||||
let root = last;
|
||||
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
||||
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
const match = btns.find((b) => re.test((b.textContent || '').trim()));
|
||||
return match ? (match.textContent || '').trim() : '';
|
||||
})()`));
|
||||
if (!current) {
|
||||
throw selectorError('Codex model button (composer toolbar). Make sure a chat is open.');
|
||||
}
|
||||
else {
|
||||
// Try to switch model (click dropdown, type/select model)
|
||||
const success = await page.evaluate(`
|
||||
(function(targetModel) {
|
||||
const dropdown = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
|
||||
if (!dropdown) return 'Dropdown not found';
|
||||
|
||||
dropdown.click();
|
||||
return 'Dropdown clicked. Generic interaction initiated.';
|
||||
})(${JSON.stringify(desiredModel)})
|
||||
`);
|
||||
return [
|
||||
{
|
||||
Status: success,
|
||||
Model: desiredModel,
|
||||
},
|
||||
];
|
||||
|
||||
if (!name && !listOnly) {
|
||||
return [{ Status: 'Active', Model: current }];
|
||||
}
|
||||
|
||||
const namejson = JSON.stringify(listOnly ? '' : name);
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const re = new RegExp(${patternJson});
|
||||
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
||||
const last = composers[composers.length - 1];
|
||||
if (!last) return { ok: false, reason: 'composer not found' };
|
||||
let root = last;
|
||||
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
||||
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
const trigger = btns.find((b) => re.test((b.textContent || '').trim()));
|
||||
if (!trigger) return { ok: false, reason: 'model trigger button not found in composer' };
|
||||
|
||||
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));
|
||||
|
||||
let items = [];
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
await wait(80);
|
||||
items = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (items.length) break;
|
||||
}
|
||||
if (!items.length) {
|
||||
return { ok: false, reason: 'Model 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();
|
||||
}
|
||||
// Filter unrelated Chat-actions menu items so they don't pollute
|
||||
// the model list — Codex sometimes shares the menu root with
|
||||
// 'Pin chat / Rename chat / Archive chat / Open side chat / Copy /
|
||||
// Fork / Add automation… / Open in new window'.
|
||||
const CHAT_ACTION_LABELS = new Set([
|
||||
'Pin chat', 'Unpin chat', 'Rename chat', 'Archive chat',
|
||||
'Open side chat', 'Copy', 'Fork', 'Add automation…', 'Open in new window',
|
||||
]);
|
||||
const modelItems = items.filter((it) => {
|
||||
const t = leadingText(it).replace(/[⌥⌘⌃⇧⏎].*$/, '').trim();
|
||||
return t && !CHAT_ACTION_LABELS.has(t);
|
||||
});
|
||||
|
||||
const labels = modelItems.map(leadingText);
|
||||
const target = ${namejson};
|
||||
if (!target) {
|
||||
document.body.click();
|
||||
return { ok: true, labels };
|
||||
}
|
||||
const wanted = target.replace(/\\bgpt[-\\s]*/g, '').replace(/\\s+/g, ' ').trim();
|
||||
const normalizedLabels = labels.map((l) => l.toLowerCase().replace(/\\bgpt[-\\s]*/g, '').replace(/\\s+/g, ' ').trim());
|
||||
let matches = normalizedLabels
|
||||
.map((label, index) => ({ label, index }))
|
||||
.filter((item) => item.label === wanted);
|
||||
if (matches.length === 0) {
|
||||
matches = normalizedLabels
|
||||
.map((label, index) => ({ label, index }))
|
||||
.filter((item) => item.label.includes(wanted));
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
document.body.click();
|
||||
return { ok: false, reason: 'No model matched.', detail: 'wanted=' + target + ' available=' + JSON.stringify(labels) };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
document.body.click();
|
||||
return { ok: false, reason: 'Model name is ambiguous.', detail: 'wanted=' + target + ' matches=' + JSON.stringify(matches.map((m) => labels[m.index])) };
|
||||
}
|
||||
const idx = matches[0].index;
|
||||
const chosen = modelItems[idx];
|
||||
const chosenLabel = labels[idx];
|
||||
|
||||
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) {
|
||||
throw new CommandExecutionError(result.reason, result.detail || '');
|
||||
}
|
||||
if (listOnly) {
|
||||
return result.labels.map((m) => ({ Status: m === current ? 'Active' : 'Available', Model: m }));
|
||||
}
|
||||
const selected = findUniqueModelOption(result.labels || [], name);
|
||||
if (!selected) {
|
||||
throw new CommandExecutionError('No model matched.', `wanted=${name} available=${JSON.stringify(result.labels || [])}`);
|
||||
}
|
||||
if (selected !== result.chosen) {
|
||||
throw new CommandExecutionError('Codex model selection was inconsistent.', `expected=${selected} chosen=${result.chosen}`);
|
||||
}
|
||||
let verified = '';
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await page.wait(0.25);
|
||||
const reread = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
const re = new RegExp(${patternJson});
|
||||
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
||||
const last = composers[composers.length - 1];
|
||||
if (!last) return '';
|
||||
let root = last;
|
||||
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
||||
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
const match = btns.find((b) => re.test((b.textContent || '').trim()));
|
||||
return match ? (match.textContent || '').trim() : '';
|
||||
})()`));
|
||||
if (modelSelectionVerified(reread, selected)) {
|
||||
verified = reread;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
throw new CommandExecutionError(
|
||||
`Codex model switch to "${selected}" was not verified.`,
|
||||
'The model menu click may have failed or the model selector text may have drifted.',
|
||||
);
|
||||
}
|
||||
return [{ Status: 'switched', Model: verified }];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { conversationSelectionArgs, setConversationPinned } from './_actions.js';
|
||||
|
||||
function defineToggle(name, desiredPinned) {
|
||||
cli({
|
||||
site: 'codex',
|
||||
name,
|
||||
access: 'write',
|
||||
description: `${name === 'pin' ? 'Pin' : 'Unpin'} the selected Codex conversation via the Chat actions header menu.`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [...conversationSelectionArgs],
|
||||
columns: ['status', 'thread_id', 'project', 'conversation'],
|
||||
func: async (page, kwargs) => {
|
||||
const result = await setConversationPinned(page, kwargs, desiredPinned);
|
||||
return [{
|
||||
status: result.status,
|
||||
thread_id: result.selected.threadId,
|
||||
project: result.selected.project,
|
||||
conversation: result.selected.conversation,
|
||||
}];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The Chat actions menu only shows the CURRENT state's label (Pin chat OR
|
||||
// Unpin chat, never both). Each command binds to its matching label.
|
||||
defineToggle('pin', true);
|
||||
defineToggle('unpin', false);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
conversationSelectionArgs,
|
||||
selectAndClickAction,
|
||||
unwrapEvaluateResult,
|
||||
waitForConversationPostcondition,
|
||||
} from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'codex',
|
||||
name: 'rename',
|
||||
access: 'write',
|
||||
description: 'Rename the selected Codex conversation. Opens the Chat actions menu → "Rename chat", then types the new title.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'title', required: true, positional: true, help: 'New title (single line, no newlines)' },
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['status', 'title', 'thread_id', 'project'],
|
||||
func: async (page, kwargs) => {
|
||||
const title = String(kwargs.title || '').trim();
|
||||
if (!title) throw new ArgumentError('title cannot be empty');
|
||||
if (title.includes('\n')) throw new ArgumentError('title must be a single line');
|
||||
|
||||
// 1. Select the target chat AND click "Rename chat" in the menu.
|
||||
const action = await selectAndClickAction(page, kwargs, ['Rename chat']);
|
||||
await page.wait(0.5);
|
||||
|
||||
// 2. The rename input is the only non-ProseMirror editable that just appeared.
|
||||
// Fill it via execCommand insertText (Codex uses a contenteditable, not a plain input).
|
||||
const filled = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let input = null;
|
||||
for (let attempt = 0; attempt < 15; attempt += 1) {
|
||||
const candidates = Array.from(document.querySelectorAll('input[type="text"], input:not([type]), [contenteditable="true"]'))
|
||||
.filter((el) => el.offsetParent && !el.classList.contains('ProseMirror'));
|
||||
if (candidates.length) {
|
||||
candidates.sort((a, b) => (a.getBoundingClientRect().left || 9999) - (b.getBoundingClientRect().left || 9999));
|
||||
input = candidates[0];
|
||||
break;
|
||||
}
|
||||
await wait(120);
|
||||
}
|
||||
if (!input) return { ok: false, reason: 'Rename input did not appear after menu click.' };
|
||||
input.focus();
|
||||
const newTitle = ${JSON.stringify(title)};
|
||||
if (input instanceof HTMLInputElement) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(input, '');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
setter.call(input, newTitle);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(input);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.execCommand('delete');
|
||||
document.execCommand('insertText', false, newTitle);
|
||||
}
|
||||
return { ok: true };
|
||||
})()`));
|
||||
if (!filled?.ok) {
|
||||
throw new CommandExecutionError(filled?.reason || 'Failed to fill rename input.', '');
|
||||
}
|
||||
|
||||
await page.pressKey('Enter');
|
||||
await waitForConversationPostcondition(
|
||||
page,
|
||||
action.selected,
|
||||
match => (match?.conversation?.title || '').trim() === title,
|
||||
'rename',
|
||||
);
|
||||
|
||||
return [{
|
||||
status: 'renamed',
|
||||
title,
|
||||
thread_id: action.selected.threadId,
|
||||
project: action.selected.project,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,15 @@ import {
|
||||
openCodexConversation,
|
||||
selectCodexConversationInDocument,
|
||||
} from './sidebar.js';
|
||||
import {
|
||||
findActiveCodexConversation,
|
||||
findCodexConversation,
|
||||
resolveActionConversation,
|
||||
} from './_actions.js';
|
||||
import {
|
||||
findUniqueModelOption,
|
||||
modelSelectionVerified,
|
||||
} from './model.js';
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div', attrs = {}, children = [], text = '') {
|
||||
@@ -255,6 +264,56 @@ describe('codex sidebar helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('finds a postcondition target by stable thread id', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
|
||||
const result = findCodexConversation(projects, {
|
||||
threadId: 'local:trading-agents',
|
||||
project: 'wrong project',
|
||||
conversation: 'wrong title',
|
||||
});
|
||||
|
||||
expect(result?.project.project).toBe('stock');
|
||||
expect(result?.conversation.title).toBe('借鉴 TradingAgents');
|
||||
});
|
||||
|
||||
it('requires exactly one active conversation for active-chat write postconditions', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
expect(findActiveCodexConversation(projects)?.conversation.threadId).toBe('local:stock-sync');
|
||||
|
||||
projects[1].conversations[0].active = true;
|
||||
expect(findActiveCodexConversation(projects)).toBeNull();
|
||||
});
|
||||
|
||||
it('matches model options without allowing ambiguous substrings', () => {
|
||||
const labels = ['GPT-5.5', 'GPT-5.4', 'Medium', 'Extra High'];
|
||||
|
||||
expect(findUniqueModelOption(labels, 'medium')).toBe('Medium');
|
||||
expect(findUniqueModelOption(labels, '5.5')).toBe('GPT-5.5');
|
||||
expect(() => findUniqueModelOption(labels, '5')).toThrowError(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('verifies model switch postconditions against the visible selector text', () => {
|
||||
expect(modelSelectionVerified('5.5 Extra High', 'GPT-5.5')).toBe(true);
|
||||
expect(modelSelectionVerified('5.5 Medium', 'Medium')).toBe(true);
|
||||
expect(modelSelectionVerified('5 High', 'GPT-5')).toBe(true);
|
||||
expect(modelSelectionVerified('5.5 Extra High', 'Medium')).toBe(false);
|
||||
expect(modelSelectionVerified('5.5 Extra High', 'High')).toBe(false);
|
||||
expect(modelSelectionVerified('5.5 Medium', 'GPT-5')).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a stable thread id for write action postconditions', async () => {
|
||||
const doc = fixtureDocument();
|
||||
const row = doc.querySelectorAll('[data-app-action-sidebar-thread-row]')[0];
|
||||
row.attrs['data-app-action-sidebar-thread-id'] = '';
|
||||
|
||||
const page = {
|
||||
evaluate: async () => collectCodexProjectsFromDocument(doc),
|
||||
};
|
||||
|
||||
await expect(resolveActionConversation(page, {})).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('reports exact thread-id misses as not found', () => {
|
||||
const result = selectCodexConversationInDocument({
|
||||
project: 'stock',
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { describe, expect, it, afterEach, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './page.js';
|
||||
import './search.js';
|
||||
import './create.js';
|
||||
import './update.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ATLASSIAN_CONFLUENCE_BASE_URL',
|
||||
'ATLASSIAN_DEPLOYMENT',
|
||||
'ATLASSIAN_EMAIL',
|
||||
'ATLASSIAN_API_TOKEN',
|
||||
'ATLASSIAN_USERNAME',
|
||||
'ATLASSIAN_PASSWORD',
|
||||
'ATLASSIAN_PAT',
|
||||
];
|
||||
|
||||
function clearEnv() {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
}
|
||||
|
||||
function setCloudEnv() {
|
||||
clearEnv();
|
||||
process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://team.atlassian.net/wiki';
|
||||
process.env.ATLASSIAN_DEPLOYMENT = 'cloud';
|
||||
process.env.ATLASSIAN_EMAIL = 'bot@example.com';
|
||||
process.env.ATLASSIAN_API_TOKEN = 'secret';
|
||||
}
|
||||
|
||||
function jsonResponse(body) {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
async function withTempMarkdown(markdown, fn) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'opencli-confluence-'));
|
||||
const file = join(dir, 'doc.md');
|
||||
await writeFile(file, markdown);
|
||||
try {
|
||||
return await fn(file);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('confluence commands', () => {
|
||||
it('registers expected REST commands', () => {
|
||||
for (const name of ['page', 'search', 'create', 'update']) {
|
||||
const cmd = getRegistry().get(`confluence/${name}`);
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd.browser).toBe(false);
|
||||
expect(cmd.strategy).toBe('public');
|
||||
}
|
||||
});
|
||||
|
||||
it('requires --execute before create performs remote writes', async () => {
|
||||
const cmd = getRegistry().get('confluence/create');
|
||||
await expect(cmd.func({ space: '123', title: 'Doc', file: 'doc.md' })).rejects.toThrow(/--execute/);
|
||||
});
|
||||
|
||||
it('creates a Cloud page from Markdown storage', async () => {
|
||||
setCloudEnv();
|
||||
await withTempMarkdown('# RCA\n\n- Payment failed', async (file) => {
|
||||
const fetchMock = vi.fn(async (url, init) => {
|
||||
expect(String(url)).toBe('https://team.atlassian.net/wiki/api/v2/pages');
|
||||
const payload = JSON.parse(init.body);
|
||||
expect(payload).toMatchObject({ spaceId: '987', title: 'PROJ-1 RCA' });
|
||||
expect(payload.body.value).toContain('<h1>RCA</h1>');
|
||||
expect(payload.body.value.replace(/\s*\n\s*/g, '')).toContain('<li>Payment failed</li>');
|
||||
return jsonResponse({
|
||||
id: '555',
|
||||
title: 'PROJ-1 RCA',
|
||||
status: 'current',
|
||||
spaceId: '987',
|
||||
version: { number: 1, createdAt: '2026-05-01T00:00:00Z' },
|
||||
body: { storage: { value: '<h1>RCA</h1>' } },
|
||||
_links: { webui: '/spaces/ENG/pages/555' },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('confluence/create');
|
||||
const rows = await cmd.func({ space: '987', title: 'PROJ-1 RCA', file, representation: 'markdown', execute: true });
|
||||
expect(rows[0]).toMatchObject({ status: 'created', id: '555', title: 'PROJ-1 RCA', version: 1 });
|
||||
expect(rows[0].url).toBe('https://team.atlassian.net/wiki/spaces/ENG/pages/555');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a page by incrementing the current version', async () => {
|
||||
setCloudEnv();
|
||||
await withTempMarkdown('Updated body', async (file) => {
|
||||
const fetchMock = vi.fn(async (url, init) => {
|
||||
if (init.method === 'GET') {
|
||||
expect(String(url)).toBe('https://team.atlassian.net/wiki/api/v2/pages/555?body-format=storage');
|
||||
return jsonResponse({
|
||||
id: '555',
|
||||
title: 'Existing',
|
||||
status: 'current',
|
||||
version: { number: 7 },
|
||||
body: { storage: { value: '<p>Old</p>' } },
|
||||
});
|
||||
}
|
||||
expect(init.method).toBe('PUT');
|
||||
const payload = JSON.parse(init.body);
|
||||
expect(payload.version).toMatchObject({ number: 8, message: 'Sync from Jira' });
|
||||
expect(payload.title).toBe('Existing');
|
||||
return jsonResponse({
|
||||
id: '555',
|
||||
title: 'Existing',
|
||||
status: 'current',
|
||||
version: { number: 8 },
|
||||
body: { storage: { value: '<p>Updated body</p>' } },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('confluence/update');
|
||||
const rows = await cmd.func({ id: '555', file, representation: 'markdown', 'version-message': 'Sync from Jira', execute: true });
|
||||
expect(rows[0]).toMatchObject({ status: 'updated', id: '555', version: 8 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update a Confluence page when the current version is malformed', async () => {
|
||||
setCloudEnv();
|
||||
await withTempMarkdown('Updated body', async (file) => {
|
||||
const fetchMock = vi.fn(async (url, init) => {
|
||||
expect(init.method).toBe('GET');
|
||||
return jsonResponse({
|
||||
id: '555',
|
||||
title: 'Existing',
|
||||
status: 'current',
|
||||
body: { storage: { value: '<p>Old</p>' } },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('confluence/update');
|
||||
await expect(cmd.func({ id: '555', file, representation: 'markdown', execute: true }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('searches with CQL scoped to a Data Center space', async () => {
|
||||
clearEnv();
|
||||
process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://conf.example.com/confluence';
|
||||
process.env.ATLASSIAN_DEPLOYMENT = 'datacenter';
|
||||
process.env.ATLASSIAN_PAT = 'pat';
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
const parsed = new URL(String(url));
|
||||
expect(parsed.searchParams.get('cql')).toBe('space = "ENG" and (type = page)');
|
||||
return jsonResponse({
|
||||
results: [{
|
||||
content: {
|
||||
id: '123',
|
||||
type: 'page',
|
||||
status: 'current',
|
||||
title: 'Runbook',
|
||||
space: { key: 'ENG' },
|
||||
_links: { webui: '/display/ENG/Runbook' },
|
||||
},
|
||||
lastModified: '2026-05-02T00:00:00Z',
|
||||
}],
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const cmd = getRegistry().get('confluence/search');
|
||||
const rows = await cmd.func({ cql: 'type = page', space: 'ENG', limit: 10 });
|
||||
expect(rows[0]).toMatchObject({ id: '123', title: 'Runbook', spaceKey: 'ENG' });
|
||||
expect(rows[0].url).toBe('https://conf.example.com/confluence/display/ENG/Runbook');
|
||||
});
|
||||
|
||||
it('separates Confluence search empty results from malformed search payloads', async () => {
|
||||
setCloudEnv();
|
||||
const cmd = getRegistry().get('confluence/search');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })));
|
||||
await expect(cmd.func({ cql: 'type = page', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ items: [] })));
|
||||
await expect(cmd.func({ cql: 'type = page', limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('fails typed when Confluence page payload lacks stable page identity', async () => {
|
||||
setCloudEnv();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ title: 'Missing id', version: { number: 1 } })));
|
||||
const cmd = getRegistry().get('confluence/page');
|
||||
await expect(cmd.func({ id: '555' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
|
||||
import { confluenceConfig, createPagePayload, normalizeConfluencePage, readPageBodyFile } from './shared.js';
|
||||
import { atlassianRequest } from '../_atlassian/shared.js';
|
||||
|
||||
cli({
|
||||
site: 'confluence',
|
||||
name: 'create',
|
||||
access: 'write',
|
||||
description: 'Create a Confluence page from Markdown or storage XHTML',
|
||||
domain: 'atlassian.net',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'space', type: 'string', required: true, help: 'Cloud space id, or Data Center space key' },
|
||||
{ name: 'title', type: 'string', required: true, help: 'Page title' },
|
||||
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
|
||||
{ name: 'parent', type: 'string', help: 'Optional parent page id' },
|
||||
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
|
||||
{ name: 'execute', type: 'boolean', help: 'Actually create the remote page' },
|
||||
],
|
||||
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
|
||||
func: async (args) => {
|
||||
requireExecute(args, 'confluence create');
|
||||
requireString(args.space, 'Confluence space');
|
||||
requireString(args.title, 'Confluence page title');
|
||||
const storage = await readPageBodyFile(args);
|
||||
const config = confluenceConfig();
|
||||
const payload = createPagePayload(config, args, storage);
|
||||
const path = config.deployment === 'cloud' ? '/api/v2/pages' : '/rest/api/content';
|
||||
const page = requirePayloadObject(await atlassianRequest(config, path, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
label: 'confluence create',
|
||||
}), 'confluence create');
|
||||
const normalized = normalizeConfluencePage(page, config);
|
||||
return [{ ...normalized, pageStatus: normalized.status, status: 'created' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requireString } from '../_atlassian/shared.js';
|
||||
import { confluenceConfig, getPage, normalizeConfluencePage } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'confluence',
|
||||
name: 'page',
|
||||
access: 'read',
|
||||
description: 'Confluence page by id with storage and Markdown body',
|
||||
domain: 'atlassian.net',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
|
||||
],
|
||||
columns: ['id', 'title', 'status', 'spaceId', 'spaceKey', 'version', 'url'],
|
||||
func: async (args) => {
|
||||
const config = confluenceConfig();
|
||||
const id = requireString(args.id, 'Confluence page id');
|
||||
const page = await getPage(config, id);
|
||||
return [normalizeConfluencePage(page, config)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { atlassianRequest, parseLimit, queryString, requireNonEmptyRows, requireString } from '../_atlassian/shared.js';
|
||||
import { confluenceConfig, confluenceResults, normalizeSearchResult, withSpaceCql } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'confluence',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Search Confluence content with CQL',
|
||||
domain: 'atlassian.net',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'cql', positional: true, required: true, help: 'CQL query, e.g. "type = page and title ~ \\"RCA\\""' },
|
||||
{ name: 'space', type: 'string', help: 'Limit search to a Confluence space key' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max results to return (1-100)' },
|
||||
],
|
||||
columns: ['id', 'title', 'type', 'spaceKey', 'status', 'lastModified', 'url'],
|
||||
func: async (args) => {
|
||||
const config = confluenceConfig();
|
||||
const cql = withSpaceCql(requireString(args.cql, 'CQL'), args.space);
|
||||
const limit = parseLimit(args.limit, 20, 100, 'confluence limit');
|
||||
// CQL search is still exposed through Confluence REST v1 for Cloud;
|
||||
// page CRUD uses v2 where available.
|
||||
const path = `/rest/api/search${queryString({ cql, limit })}`;
|
||||
const data = await atlassianRequest(config, path, { label: 'confluence search' });
|
||||
const results = confluenceResults(data, 'confluence search');
|
||||
return requireNonEmptyRows(
|
||||
results.map((result) => normalizeSearchResult(result, config)),
|
||||
'confluence search',
|
||||
`No Confluence content matched "${cql}".`,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
atlassianRequest,
|
||||
getConfluenceConfig,
|
||||
htmlToMarkdown,
|
||||
markdownToConfluenceStorage,
|
||||
queryString,
|
||||
requirePayloadArray,
|
||||
requirePayloadObject,
|
||||
requirePayloadString,
|
||||
readUtf8File,
|
||||
requireString,
|
||||
} from '../_atlassian/shared.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export function confluenceConfig() {
|
||||
return getConfluenceConfig();
|
||||
}
|
||||
|
||||
function confluenceUrl(config, link) {
|
||||
if (!link) return '';
|
||||
if (/^https?:\/\//i.test(link)) return link;
|
||||
return `${config.baseUrl}${link.startsWith('/') ? link : `/${link}`}`;
|
||||
}
|
||||
|
||||
function pageStorageBody(page) {
|
||||
return page?.body?.storage?.value
|
||||
?? page?.body?.view?.value
|
||||
?? '';
|
||||
}
|
||||
|
||||
export function normalizeConfluencePage(page, config) {
|
||||
const row = requirePayloadObject(page, 'confluence page');
|
||||
const id = requirePayloadString(row.id, 'page id', 'confluence page');
|
||||
const title = requirePayloadString(row.title, 'title', 'confluence page');
|
||||
const storage = pageStorageBody(row);
|
||||
const version = row.version?.number != null ? Number(row.version.number) : undefined;
|
||||
const links = row._links && typeof row._links === 'object' && !Array.isArray(row._links) ? row._links : {};
|
||||
const webui = links.webui ?? links.tinyui ?? '';
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
status: String(row.status ?? ''),
|
||||
spaceId: row.spaceId != null ? String(row.spaceId) : undefined,
|
||||
spaceKey: row.space?.key ? String(row.space.key) : undefined,
|
||||
parentId: row.parentId != null ? String(row.parentId) : undefined,
|
||||
version,
|
||||
createdAt: row.createdAt ? String(row.createdAt) : undefined,
|
||||
updatedAt: row.version?.createdAt ?? row.version?.when ?? undefined,
|
||||
url: confluenceUrl(config, webui),
|
||||
body: {
|
||||
storage,
|
||||
markdown: htmlToMarkdown(storage),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPage(config, pageId) {
|
||||
if (config.deployment === 'cloud') {
|
||||
const page = await atlassianRequest(config, `/api/v2/pages/${encodeURIComponent(pageId)}${queryString({ 'body-format': 'storage' })}`, {
|
||||
label: `confluence page ${pageId}`,
|
||||
});
|
||||
return requirePayloadObject(page, `confluence page ${pageId}`);
|
||||
}
|
||||
const page = await atlassianRequest(config, `/rest/api/content/${encodeURIComponent(pageId)}${queryString({ expand: 'body.storage,version,space,ancestors' })}`, {
|
||||
label: `confluence page ${pageId}`,
|
||||
});
|
||||
return requirePayloadObject(page, `confluence page ${pageId}`);
|
||||
}
|
||||
|
||||
export async function readPageBodyFile(args) {
|
||||
const text = await readUtf8File(args.file);
|
||||
if (args.representation === 'storage') return text;
|
||||
return markdownToConfluenceStorage(text);
|
||||
}
|
||||
|
||||
export function createPagePayload(config, args, storage) {
|
||||
const title = requireString(args.title, 'Confluence page title');
|
||||
const space = requireString(args.space, 'Confluence space');
|
||||
if (config.deployment === 'cloud') {
|
||||
return {
|
||||
spaceId: space,
|
||||
status: 'current',
|
||||
title,
|
||||
...(args.parent ? { parentId: String(args.parent) } : {}),
|
||||
body: { representation: 'storage', value: storage },
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'page',
|
||||
status: 'current',
|
||||
title,
|
||||
space: { key: space },
|
||||
...(args.parent ? { ancestors: [{ id: String(args.parent) }] } : {}),
|
||||
body: { storage: { representation: 'storage', value: storage } },
|
||||
};
|
||||
}
|
||||
|
||||
export function updatePagePayload(config, current, args, storage) {
|
||||
const page = requirePayloadObject(current, 'confluence current page');
|
||||
const id = requirePayloadString(page.id, 'page id', 'confluence current page');
|
||||
const title = args.title ? requireString(args.title, 'Confluence page title') : requirePayloadString(page.title, 'title', 'confluence current page');
|
||||
const currentVersion = Number(page.version?.number);
|
||||
if (!Number.isSafeInteger(currentVersion) || currentVersion < 1) {
|
||||
throw new CommandExecutionError('confluence update could not determine the current page version.');
|
||||
}
|
||||
const nextVersion = currentVersion + 1;
|
||||
if (config.deployment === 'cloud') {
|
||||
return {
|
||||
id,
|
||||
status: 'current',
|
||||
title,
|
||||
body: { representation: 'storage', value: storage },
|
||||
version: {
|
||||
number: nextVersion,
|
||||
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type: 'page',
|
||||
status: 'current',
|
||||
title,
|
||||
body: { storage: { representation: 'storage', value: storage } },
|
||||
version: {
|
||||
number: nextVersion,
|
||||
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSearchResult(result, config) {
|
||||
const row = requirePayloadObject(result, 'confluence search result');
|
||||
const content = row.content ?? row;
|
||||
const contentObject = requirePayloadObject(content, 'confluence search result content');
|
||||
const id = requirePayloadString(contentObject.id, 'content id', 'confluence search result');
|
||||
const title = requirePayloadString(row.title ?? contentObject.title, 'title', 'confluence search result');
|
||||
const space = row.space ?? contentObject.space ?? {};
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type: String(contentObject.type ?? row.entityType ?? ''),
|
||||
spaceKey: String(space?.key ?? ''),
|
||||
status: String(contentObject.status ?? ''),
|
||||
lastModified: String(row.lastModified ?? contentObject.version?.when ?? contentObject.version?.createdAt ?? ''),
|
||||
url: confluenceUrl(config, row.url ?? contentObject._links?.webui ?? ''),
|
||||
excerpt: row.excerpt ? htmlToMarkdown(row.excerpt) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function confluenceResults(data, label) {
|
||||
const payload = requirePayloadObject(data, label);
|
||||
return requirePayloadArray(payload.results, label);
|
||||
}
|
||||
|
||||
export function withSpaceCql(cql, space) {
|
||||
const q = String(cql ?? '').trim();
|
||||
const s = String(space ?? '').trim();
|
||||
if (!s) return q;
|
||||
const escaped = s.replace(/"/g, '\\"');
|
||||
if (!q) return `space = "${escaped}"`;
|
||||
return `space = "${escaped}" and (${q})`;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
createPagePayload,
|
||||
getPage,
|
||||
normalizeConfluencePage,
|
||||
normalizeSearchResult,
|
||||
readPageBodyFile,
|
||||
updatePagePayload,
|
||||
withSpaceCql,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { atlassianRequest, requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
|
||||
import { confluenceConfig, getPage, normalizeConfluencePage, readPageBodyFile, updatePagePayload } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'confluence',
|
||||
name: 'update',
|
||||
access: 'write',
|
||||
description: 'Update a Confluence page body from Markdown or storage XHTML',
|
||||
domain: 'atlassian.net',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
|
||||
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
|
||||
{ name: 'title', type: 'string', help: 'Optional replacement title; defaults to current title' },
|
||||
{ name: 'version-message', type: 'string', help: 'Confluence version message' },
|
||||
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
|
||||
{ name: 'execute', type: 'boolean', help: 'Actually update the remote page' },
|
||||
],
|
||||
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
|
||||
func: async (args) => {
|
||||
requireExecute(args, 'confluence update');
|
||||
const config = confluenceConfig();
|
||||
const id = requireString(args.id, 'Confluence page id');
|
||||
const current = await getPage(config, id);
|
||||
const storage = await readPageBodyFile(args);
|
||||
const payload = updatePagePayload(config, current, args, storage);
|
||||
const path = config.deployment === 'cloud' ? `/api/v2/pages/${encodeURIComponent(id)}` : `/rest/api/content/${encodeURIComponent(id)}`;
|
||||
const page = requirePayloadObject(await atlassianRequest(config, path, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
label: `confluence update ${id}`,
|
||||
}), `confluence update ${id}`);
|
||||
const normalized = normalizeConfluencePage(page, config);
|
||||
return [{ ...normalized, pageStatus: normalized.status, status: 'updated' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasCoupangSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.coupang.com' });
|
||||
return cookies.some(c => /^(AID|MEMBER_ID|LMSESSIONID)$/.test(c.name) && c.value);
|
||||
}
|
||||
|
||||
async function verifyCoupangIdentity(page) {
|
||||
if (!await hasCoupangSessionCookie(page)) {
|
||||
throw new AuthRequiredError('coupang.com', 'Coupang session cookies (AID/MEMBER_ID/LMSESSIONID) missing');
|
||||
}
|
||||
await page.goto('https://www.coupang.com/np/mypage');
|
||||
await page.wait(3);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
if (/login\\.coupang\\.com\\/login/.test(location.href)) {
|
||||
return { kind: 'auth', detail: 'Coupang mypage redirected to login — anonymous' };
|
||||
}
|
||||
if (/Access Denied/i.test(document.title)) {
|
||||
return { kind: 'auth', detail: 'Coupang Access Denied — anti-bot or non-KR IP' };
|
||||
}
|
||||
const el = document.querySelector('.my-nickname, .member-name, .mp-user-info-name, [class*=memberName]');
|
||||
const name = (el?.textContent || '').trim();
|
||||
if (!name) {
|
||||
return { kind: 'auth', detail: 'Coupang mypage 200 but no member-name surface' };
|
||||
}
|
||||
return { ok: true, name };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('coupang.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Coupang probe: ${JSON.stringify(probe)}`);
|
||||
return { name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'coupang',
|
||||
domain: 'coupang.com',
|
||||
loginUrl: 'https://login.coupang.com/login/login.pang',
|
||||
columns: ['name'],
|
||||
verify: verifyCoupangIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasCoupangSessionCookie(page)) {
|
||||
throw new AuthRequiredError('coupang.com', 'Waiting for Coupang session cookies');
|
||||
}
|
||||
return verifyCoupangIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasCtripLoginUid(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.ctrip.com' });
|
||||
const loginUid = cookies.find(c => c.name === 'login_uid');
|
||||
return Boolean(loginUid && loginUid.value);
|
||||
}
|
||||
|
||||
async function verifyCtripIdentity(page) {
|
||||
if (!await hasCtripLoginUid(page)) {
|
||||
throw new AuthRequiredError('ctrip.com', 'Ctrip login_uid cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://my.ctrip.com/myinfo/MyInfoIndex.aspx');
|
||||
await page.wait(2);
|
||||
const cookies = await page.getCookies({ url: 'https://www.ctrip.com' });
|
||||
const cookieMap = Object.fromEntries(cookies.map(c => [c.name, c.value]));
|
||||
const loginUid = cookieMap['login_uid'] || '';
|
||||
if (!loginUid) {
|
||||
throw new AuthRequiredError('ctrip.com', 'Ctrip login_uid cookie absent after navigation');
|
||||
}
|
||||
const aheadRaw = cookieMap['AHeadUserInfo'] || '';
|
||||
const params = new URLSearchParams(aheadRaw);
|
||||
const userNameRaw = params.get('UserName') || '';
|
||||
let userName = '';
|
||||
if (userNameRaw) {
|
||||
try {
|
||||
userName = decodeURIComponent(userNameRaw);
|
||||
} catch {
|
||||
userName = userNameRaw;
|
||||
}
|
||||
}
|
||||
const vipGrade = params.get('VipGrade') || '';
|
||||
return { user_id: loginUid, name: userName, vip_grade: vipGrade };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'ctrip',
|
||||
domain: 'ctrip.com',
|
||||
loginUrl: 'https://passport.ctrip.com/user/login',
|
||||
columns: ['user_id', 'name', 'vip_grade'],
|
||||
quickCheck: hasCtripLoginUid,
|
||||
verify: verifyCtripIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasCtripLoginUid(page)) {
|
||||
throw new AuthRequiredError('ctrip.com', 'Waiting for Ctrip login_uid cookie');
|
||||
}
|
||||
return verifyCtripIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
// DeepSeek authenticates via a Bearer token stored in localStorage (userToken),
|
||||
// not a cookie, so credentials:include alone returns code 40002 (anonymous).
|
||||
// The probe reads the token and calls the confirmed /api/v0/users/current
|
||||
// endpoint (anonymous → HTTP 200 + body code 40002).
|
||||
const WHOAMI_PROBE = `(async () => {
|
||||
try {
|
||||
let token = '';
|
||||
const raw = localStorage.getItem('userToken');
|
||||
if (raw) { try { token = JSON.parse(raw).value || ''; } catch { token = raw; } }
|
||||
if (!token) return { kind: 'auth', detail: 'DeepSeek userToken missing from localStorage — anonymous' };
|
||||
const r = await fetch('/api/v0/users/current', {
|
||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||
});
|
||||
if (r.status === 401 || r.status === 403) return { kind: 'auth', detail: 'DeepSeek users/current HTTP ' + r.status };
|
||||
if (!r.ok) return { kind: 'http', httpStatus: r.status };
|
||||
const d = await r.json();
|
||||
if (!d || d.code !== 0) return { kind: 'auth', detail: 'DeepSeek users/current code=' + String(d && d.code) + ' — anonymous' };
|
||||
const u = (d.data && (d.data.biz_data || d.data.user || d.data)) || {};
|
||||
const userId = String(u.id || u.user_id || u.uuid || '');
|
||||
const name = String(u.name || u.nickname || u.username || '');
|
||||
if (!userId && !name) return { kind: 'render-error', detail: 'DeepSeek users/current ok but no id/name field — response shape drift' };
|
||||
return { ok: true, user_id: userId, name };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`;
|
||||
|
||||
async function verifyDeepseekIdentity(page) {
|
||||
await page.goto('https://chat.deepseek.com/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(WHOAMI_PROBE);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('chat.deepseek.com', probe.detail);
|
||||
if (probe?.kind === 'http') throw new CommandExecutionError(`HTTP ${probe.httpStatus} from DeepSeek users/current`);
|
||||
if (probe?.kind === 'render-error') throw new CommandExecutionError(probe.detail);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`DeepSeek whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected DeepSeek probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'deepseek',
|
||||
domain: 'chat.deepseek.com',
|
||||
loginUrl: 'https://chat.deepseek.com/sign_in',
|
||||
columns: ['user_id', 'name'],
|
||||
verify: verifyDeepseekIdentity,
|
||||
poll: async (page) => {
|
||||
const probe = await page.evaluate(WHOAMI_PROBE);
|
||||
if (!probe?.ok) throw new AuthRequiredError('chat.deepseek.com', 'Waiting for DeepSeek login');
|
||||
return { user_id: probe.user_id, name: probe.name };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasDianpingSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.dianping.com' });
|
||||
return cookies.some(c => c.name === 'dper' && c.value);
|
||||
}
|
||||
|
||||
async function verifyDianpingIdentity(page) {
|
||||
if (!await hasDianpingSessionCookie(page)) {
|
||||
throw new AuthRequiredError('dianping.com', 'Dianping dper cookie missing');
|
||||
}
|
||||
await page.goto('https://www.dianping.com/member/myinformation');
|
||||
await page.wait(2);
|
||||
const finalUrl = await page.evaluate(`location.href`);
|
||||
if (/account\.dianping\.com\/(pc)?login/.test(String(finalUrl || ''))) {
|
||||
throw new AuthRequiredError('dianping.com', `Dianping member page redirected to login: ${finalUrl}`);
|
||||
}
|
||||
const info = await page.evaluate(`
|
||||
(() => {
|
||||
const nicknameEl = document.querySelector('.user-name, .username, .nickname, .user-info .name');
|
||||
const nickname = (nicknameEl?.textContent || '').trim();
|
||||
const profileLink = Array.from(document.querySelectorAll('a[href*="/member/"]'))
|
||||
.map(a => a.getAttribute('href') || '')
|
||||
.find(h => /\\/member\\/\\d+/.test(h));
|
||||
const uidMatch = String(profileLink || '').match(/\\/member\\/(\\d+)/);
|
||||
return { user_id: uidMatch?.[1] || '', nickname };
|
||||
})()
|
||||
`);
|
||||
if (!info?.user_id) {
|
||||
throw new CommandExecutionError('Dianping member page rendered but no user_id link found — stale dper or layout drift');
|
||||
}
|
||||
return { user_id: String(info.user_id), nickname: String(info.nickname || '') };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'dianping',
|
||||
domain: 'dianping.com',
|
||||
loginUrl: 'https://account.dianping.com/pclogin',
|
||||
columns: ['user_id', 'nickname'],
|
||||
quickCheck: hasDianpingSessionCookie,
|
||||
verify: verifyDianpingIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasDianpingSessionCookie(page)) {
|
||||
throw new AuthRequiredError('dianping.com', 'Waiting for Dianping dper cookie');
|
||||
}
|
||||
return verifyDianpingIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasDoubanSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.douban.com' });
|
||||
const names = new Set(cookies.map(c => c.name));
|
||||
return names.has('dbcl2') || names.has('ck');
|
||||
}
|
||||
|
||||
async function verifyDoubanIdentity(page) {
|
||||
if (!await hasDoubanSessionCookie(page)) {
|
||||
throw new AuthRequiredError('douban.com', 'Douban dbcl2 / ck cookies missing');
|
||||
}
|
||||
await page.goto('https://www.douban.com/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
const navUser = document.querySelector('.nav-user-account .bn-more, .top-nav-info a.bn-more');
|
||||
if (!navUser) {
|
||||
return { kind: 'auth', detail: 'Douban nav-user element missing — not signed in' };
|
||||
}
|
||||
const href = navUser.getAttribute('href') || '';
|
||||
const m = href.match(/people\\/(\\d+)\\/?/);
|
||||
const user_id = m ? m[1] : '';
|
||||
const name = (navUser.textContent || '').trim();
|
||||
if (!user_id) {
|
||||
return { kind: 'auth', detail: 'Douban user_id parse failed: href=' + href };
|
||||
}
|
||||
return { ok: true, user_id, name };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('douban.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Douban probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'douban',
|
||||
domain: 'douban.com',
|
||||
loginUrl: 'https://accounts.douban.com/passport/login',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: hasDoubanSessionCookie,
|
||||
verify: verifyDoubanIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasDoubanSessionCookie(page)) {
|
||||
throw new AuthRequiredError('douban.com', 'Waiting for Douban dbcl2 / ck cookies');
|
||||
}
|
||||
return verifyDoubanIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasDoubaoSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.doubao.com' });
|
||||
return cookies.some(c => c.name === 'passport_csrf_token' && c.value);
|
||||
}
|
||||
|
||||
async function verifyDoubaoIdentity(page) {
|
||||
if (!await hasDoubaoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('www.doubao.com', 'Doubao passport_csrf_token cookie missing');
|
||||
}
|
||||
await page.goto('https://www.doubao.com/chat/');
|
||||
await page.wait(3);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('/passport/account/info/v2/', { credentials: 'include', headers: { 'Accept': 'application/json' } });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'Doubao /passport/account/info HTTP ' + res.status };
|
||||
}
|
||||
if (!res.ok) return { kind: 'http', httpStatus: res.status };
|
||||
const d = await res.json();
|
||||
const data = d && d.data;
|
||||
if (!data || !data.user_id_str) {
|
||||
return { kind: 'auth', detail: 'Doubao /passport/account/info returned no user_id_str' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
user_id: String(data.user_id_str),
|
||||
name: String(data.name || data.screen_name || ''),
|
||||
};
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('www.doubao.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /passport/account/info`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`Doubao whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected Doubao probe: ${JSON.stringify(result)}`);
|
||||
return { user_id: result.user_id, name: result.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'doubao',
|
||||
domain: 'www.doubao.com',
|
||||
loginUrl: 'https://www.doubao.com/chat/',
|
||||
columns: ['user_id', 'name'],
|
||||
verify: verifyDoubaoIdentity,
|
||||
// passport_csrf_token is set for anonymous sessions too, so a cookie gate
|
||||
// would navigate away mid-login. Probe the account API on the current page
|
||||
// (no goto) and only confirm once a real user_id is present.
|
||||
poll: async (page) => {
|
||||
const loggedIn = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const r = await fetch('/passport/account/info/v2/', { credentials: 'include', headers: { Accept: 'application/json' } });
|
||||
if (!r.ok) return false;
|
||||
const d = await r.json();
|
||||
return !!(d?.data?.user_id_str);
|
||||
} catch { return false; }
|
||||
})()`);
|
||||
if (!loggedIn) {
|
||||
throw new AuthRequiredError('www.doubao.com', 'Waiting for Doubao login');
|
||||
}
|
||||
return verifyDoubaoIdentity(page);
|
||||
},
|
||||
});
|
||||
+19
-12
@@ -166,11 +166,13 @@ function getTurnsScript() {
|
||||
// 2026-05 Doubao DOM refactor: no more receive-message / bg-g-receive-msg-bubble
|
||||
// markers on assistant turns. Wrappers are now [class*="inner-item-"] /
|
||||
// [class*="top-item-"] and the only reliable assistant signal is the
|
||||
// .flow-markdown-body content container WITHOUT any send-bubble marker.
|
||||
// .flow-markdown-body / .md-box-root content container WITHOUT any
|
||||
// send-bubble marker.
|
||||
if (
|
||||
(root.matches('[class*="inner-item-"], [class*="top-item-"]')
|
||||
|| root.closest('[class*="inner-item-"], [class*="top-item-"]'))
|
||||
&& (root.matches('.flow-markdown-body') || root.querySelector('.flow-markdown-body'))
|
||||
&& (root.matches('.flow-markdown-body, .md-box-root, [class*="md-box-root"]')
|
||||
|| root.querySelector('.flow-markdown-body, .md-box-root, [class*="md-box-root"]'))
|
||||
&& !root.matches('[class*="bg-g-send-msg-bubble"]')
|
||||
&& !root.querySelector('[class*="bg-g-send-msg-bubble"]')
|
||||
) {
|
||||
@@ -189,6 +191,8 @@ function getTurnsScript() {
|
||||
'[class*="bg-g-send-msg-bubble"]',
|
||||
'[class*="bg-g-receive-msg-bubble"]',
|
||||
'.flow-markdown-body',
|
||||
'.md-box-root',
|
||||
'[class*="md-box-root"]',
|
||||
'[class*="bubble"]',
|
||||
];
|
||||
const messageImageSelector = messageTextSelectors.map((s) => s + ' img').join(', ');
|
||||
@@ -232,9 +236,6 @@ function getTurnsScript() {
|
||||
return text ? text + '\\n' + imageLines.join('\\n') : imageLines.join('\\n');
|
||||
};
|
||||
|
||||
const messageList = document.querySelector('[class*="message-list-S2Fv2S"], .container-PvPoAn, .scroll-view-OEiNXD, [data-testid="message-list"]');
|
||||
if (!messageList) return [];
|
||||
|
||||
const itemSelectors = [
|
||||
// 2026-05 Doubao DOM refactor wrappers (prepended; outer ones win via
|
||||
// ancestor-keep dedup below).
|
||||
@@ -248,15 +249,21 @@ function getTurnsScript() {
|
||||
'[class*="bg-g-receive-msg-bubble"]',
|
||||
];
|
||||
|
||||
const messageLists = Array.from(document.querySelectorAll('[class*="message-list-"], .container-PvPoAn, .scroll-view-OEiNXD, [data-testid="message-list"]'))
|
||||
.filter((el) => isVisible(el));
|
||||
if (messageLists.length === 0) return [];
|
||||
|
||||
const allRoots = [];
|
||||
const seen = new Set();
|
||||
for (const sel of itemSelectors) {
|
||||
messageList.querySelectorAll(sel).forEach((el) => {
|
||||
if (!seen.has(el)) {
|
||||
seen.add(el);
|
||||
allRoots.push(el);
|
||||
}
|
||||
});
|
||||
for (const messageList of messageLists) {
|
||||
for (const sel of itemSelectors) {
|
||||
messageList.querySelectorAll(sel).forEach((el) => {
|
||||
if (!seen.has(el)) {
|
||||
seen.add(el);
|
||||
allRoots.push(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const roots = allRoots
|
||||
.filter((el) => isVisible(el) && !el.closest('script, style, noscript'))
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('doubao receive strategy', () => {
|
||||
|
||||
it('keeps both the new skin selectors and the older structural fallbacks in the turns script', () => {
|
||||
const turnsScript = __test__.getTurnsScript();
|
||||
expect(turnsScript).toContain('[class*="message-list-S2Fv2S"]');
|
||||
expect(turnsScript).toContain('[class*="message-list-"]');
|
||||
expect(turnsScript).toContain('.container-PvPoAn');
|
||||
expect(turnsScript).toContain('[data-testid="message-list"]');
|
||||
expect(turnsScript).toContain('[class*="bg-g-receive-msg-bubble"]');
|
||||
@@ -191,6 +191,7 @@ describe('doubao receive strategy', () => {
|
||||
// bg-g-receive-msg-bubble markup. Only signal is .flow-markdown-body content
|
||||
// container without send-bubble.
|
||||
expect(turnsScript).toContain('.flow-markdown-body');
|
||||
expect(turnsScript).toContain('.md-box-root');
|
||||
});
|
||||
|
||||
it('extracts clean assistant turns from the 2026-05 wrapper DOM without using whole-page chrome', () => {
|
||||
@@ -218,6 +219,57 @@ describe('doubao receive strategy', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts turns from the current hashed message list and markdown box DOM', () => {
|
||||
const turns = runTurnsScript(`
|
||||
<main>
|
||||
<aside>历史对话</aside>
|
||||
<section class="message-list-zLoNs1 opacity-100">
|
||||
<div class="top-item-bAlX0F"></div>
|
||||
<div class="inner-item-BjaxFt">
|
||||
<div data-message-id="46370507058831106" class="flex-row flex w-full justify-end">
|
||||
<div class="bg-g-send-msg-bubble-bg">请联网查找太原红星天铂</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inner-item-BjaxFt">
|
||||
<div data-message-id="46370507058842882" class="relative flex-row flex w-full">
|
||||
<div class="container-qX9Csx md-box-root"><h3>太原红星天铂公开信息整理</h3><p>项目位于南内环东街与东中环交汇处东北角。</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
`);
|
||||
|
||||
expect(turns).toEqual([
|
||||
{ Role: 'User', Text: '请联网查找太原红星天铂' },
|
||||
{ Role: 'Assistant', Text: '太原红星天铂公开信息整理项目位于南内环东街与东中环交汇处东北角。' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not let a stale hidden hashed message list mask the visible current one', () => {
|
||||
const turns = runTurnsScript(`
|
||||
<main>
|
||||
<section class="message-list-old" style="display:none">
|
||||
<div class="inner-item-old">
|
||||
<div class="bg-g-send-msg-bubble-bg">旧问题</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="message-list-current">
|
||||
<div class="inner-item-current">
|
||||
<div class="bg-g-send-msg-bubble-bg">当前问题</div>
|
||||
</div>
|
||||
<div class="inner-item-current">
|
||||
<div class="md-box-root"><p>当前回答</p></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
`);
|
||||
|
||||
expect(turns).toEqual([
|
||||
{ Role: 'User', Text: '当前问题' },
|
||||
{ Role: 'Assistant', Text: '当前回答' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('extends transcript-noise cleanup for the current zh-CN chrome copy', () => {
|
||||
const transcriptScript = __test__.getTranscriptLinesScript();
|
||||
expect(transcriptScript).toContain('请仔细甄别');
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
import { browserFetch } from './_shared/browser-fetch.js';
|
||||
|
||||
async function hasDouyinSessionCookies(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://creator.douyin.com' });
|
||||
const names = new Set(cookies.map(cookie => cookie.name));
|
||||
return names.has('sessionid') || names.has('uid_tt') || names.has('passport_csrf_token');
|
||||
}
|
||||
|
||||
async function verifyDouyinIdentity(page) {
|
||||
await page.goto('https://creator.douyin.com');
|
||||
const url = 'https://creator.douyin.com/web/api/media/user/info/?aid=1128';
|
||||
const payload = await browserFetch(page, 'GET', url);
|
||||
const user = payload.user_info ?? payload.user;
|
||||
if (!user) {
|
||||
throw new CommandExecutionError('Douyin user info response is missing user_info');
|
||||
}
|
||||
return {
|
||||
id: user.uid ?? '',
|
||||
username: user.nickname ?? '',
|
||||
followers: user.follower_count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'douyin',
|
||||
domain: 'creator.douyin.com',
|
||||
loginUrl: 'https://creator.douyin.com/',
|
||||
columns: ['id', 'username', 'followers'],
|
||||
quickCheck: hasDouyinSessionCookies,
|
||||
verify: verifyDouyinIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasDouyinSessionCookies(page)) {
|
||||
throw new AuthRequiredError('creator.douyin.com', 'Waiting for Douyin creator session cookies');
|
||||
}
|
||||
return verifyDouyinIdentity(page);
|
||||
},
|
||||
});
|
||||
+84
-23
@@ -1,6 +1,40 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { browserFetch } from './_shared/browser-fetch.js';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireListField(res, field, action) {
|
||||
if (!isPlainObject(res)) {
|
||||
throw new CommandExecutionError(`douyin hashtag ${action}: API returned malformed payload`);
|
||||
}
|
||||
const list = res[field];
|
||||
if (list === undefined || list === null) return [];
|
||||
if (!Array.isArray(list)) {
|
||||
throw new CommandExecutionError(`douyin hashtag ${action}: API returned malformed "${field}"`);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function validateHashtagArgs(kwargs) {
|
||||
const action = kwargs.action;
|
||||
if (action === 'search') {
|
||||
const keyword = String(kwargs.keyword ?? '').trim();
|
||||
if (!keyword) {
|
||||
throw new ArgumentError('douyin hashtag search 需要 --keyword <关键词>', '示例: opencli douyin hashtag search --keyword 美食');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'suggest') {
|
||||
const cover = String(kwargs.cover ?? '').trim();
|
||||
if (!cover) {
|
||||
throw new ArgumentError('douyin hashtag suggest 需要 --cover <cover_uri>', 'suggest 基于已上传的视频封面做 AI 推荐, 不是关键词搜索. 关键词搜索请用 `douyin hashtag search --keyword <词>`.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'douyin',
|
||||
name: 'hashtag',
|
||||
@@ -9,43 +43,70 @@ cli({
|
||||
domain: 'creator.douyin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 suggest=AI推荐 hot=热点词' },
|
||||
{ name: 'keyword', default: '', help: '搜索关键词(search/hot 使用)' },
|
||||
{ name: 'cover', default: '', help: '封面 URI(suggest 使用)' },
|
||||
{ name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 (--keyword 必填), suggest=AI推荐 (--cover 必填), hot=热点词 (--keyword 可选)' },
|
||||
{ name: 'keyword', default: '', help: '搜索关键词. search 必填; hot 可选; suggest 不使用 (传 --cover)' },
|
||||
{ name: 'cover', default: '', help: '封面 URI (cover_uri). suggest 必填; 其它 action 不使用' },
|
||||
{ name: 'limit', type: 'int', default: 10 },
|
||||
],
|
||||
columns: ['name', 'id', 'view_count'],
|
||||
validateArgs: validateHashtagArgs,
|
||||
func: async (page, kwargs) => {
|
||||
validateHashtagArgs(kwargs);
|
||||
const action = kwargs.action;
|
||||
if (action === 'search') {
|
||||
const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(kwargs.keyword)}&count=${kwargs.limit}&aid=1128`;
|
||||
const keyword = String(kwargs.keyword ?? '').trim();
|
||||
const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(keyword)}&count=${kwargs.limit}&aid=1128`;
|
||||
const res = await browserFetch(page, 'GET', url);
|
||||
return (res.challenge_list ?? []).map(c => ({
|
||||
name: c.challenge_info.cha_name,
|
||||
id: c.challenge_info.cid,
|
||||
view_count: c.challenge_info.view_count,
|
||||
}));
|
||||
const list = requireListField(res, 'challenge_list', 'search');
|
||||
const rows = list.flatMap(c => {
|
||||
const info = c?.challenge_info;
|
||||
if (!isPlainObject(info)) return [];
|
||||
return [{
|
||||
name: info.cha_name,
|
||||
id: info.cid,
|
||||
view_count: info.view_count,
|
||||
}];
|
||||
});
|
||||
if (list.length > 0 && rows.length === 0) {
|
||||
throw new CommandExecutionError('douyin hashtag search: API returned challenges but none had stable challenge_info shape');
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
if (action === 'suggest') {
|
||||
const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(kwargs.cover)}&aid=1128`;
|
||||
const cover = String(kwargs.cover ?? '').trim();
|
||||
const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(cover)}&aid=1128`;
|
||||
const res = await browserFetch(page, 'GET', url);
|
||||
return (res.hashtag_list ?? []).map(h => ({ name: h.name, id: h.id, view_count: h.view_count }));
|
||||
const list = requireListField(res, 'hashtag_list', 'suggest');
|
||||
return list.map(h => ({ name: h?.name ?? '', id: h?.id ?? '', view_count: h?.view_count ?? 0 }));
|
||||
}
|
||||
if (action === 'hot') {
|
||||
const kw = kwargs.keyword;
|
||||
const kw = String(kwargs.keyword ?? '').trim();
|
||||
const url = `https://creator.douyin.com/aweme/v1/hotspot/recommend/?${kw ? `keyword=${encodeURIComponent(kw)}&` : ''}aid=1128`;
|
||||
const res = await browserFetch(page, 'GET', url);
|
||||
const items = res.hotspot_list
|
||||
?? res.all_sentences?.map(h => ({
|
||||
sentence: h.word ?? '',
|
||||
hot_value: h.hot_value,
|
||||
sentence_id: h.sentence_id ?? '',
|
||||
}))
|
||||
?? [];
|
||||
if (!isPlainObject(res)) {
|
||||
throw new CommandExecutionError('douyin hashtag hot: API returned malformed payload');
|
||||
}
|
||||
const hotspotList = res.hotspot_list;
|
||||
const allSentences = res.all_sentences;
|
||||
if (hotspotList !== undefined && hotspotList !== null && !Array.isArray(hotspotList)) {
|
||||
throw new CommandExecutionError('douyin hashtag hot: API returned malformed "hotspot_list"');
|
||||
}
|
||||
if (allSentences !== undefined && allSentences !== null && !Array.isArray(allSentences)) {
|
||||
throw new CommandExecutionError('douyin hashtag hot: API returned malformed "all_sentences"');
|
||||
}
|
||||
const items = Array.isArray(hotspotList)
|
||||
? hotspotList
|
||||
: Array.isArray(allSentences)
|
||||
? allSentences.map(h => ({
|
||||
sentence: h?.word ?? '',
|
||||
hot_value: h?.hot_value,
|
||||
sentence_id: h?.sentence_id ?? '',
|
||||
}))
|
||||
: [];
|
||||
return items.slice(0, kwargs.limit).map(h => ({
|
||||
name: h.sentence,
|
||||
id: 'sentence_id' in h ? h.sentence_id : '',
|
||||
view_count: h.hot_value,
|
||||
name: h?.sentence ?? '',
|
||||
id: h && 'sentence_id' in h ? h.sentence_id : '',
|
||||
view_count: h?.hot_value ?? 0,
|
||||
}));
|
||||
}
|
||||
throw new ArgumentError(`未知的 action: ${action}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
const { browserFetchMock } = vi.hoisted(() => ({
|
||||
browserFetchMock: vi.fn(),
|
||||
}));
|
||||
@@ -31,6 +32,118 @@ describe('douyin hashtag', () => {
|
||||
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
|
||||
expect(cmd?.strategy).toBe('cookie');
|
||||
});
|
||||
it('registers action-specific validation so missing args fail before browser pre-navigation', () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
expect(cmd?.validateArgs).toBeTypeOf('function');
|
||||
expect(() => cmd.validateArgs({ action: 'search', keyword: '', cover: '', limit: 10 }))
|
||||
.toThrow(ArgumentError);
|
||||
expect(() => cmd.validateArgs({ action: 'suggest', keyword: '速效救心丸', cover: '', limit: 10 }))
|
||||
.toThrow(ArgumentError);
|
||||
expect(() => cmd.validateArgs({ action: 'hot', keyword: '', cover: '', limit: 10 }))
|
||||
.not.toThrow();
|
||||
expect(browserFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('search throws ArgumentError when --keyword is missing or blank (#1689)', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
await expect(cmd.func({}, { action: 'search', keyword: '', cover: '', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(cmd.func({}, { action: 'search', keyword: ' ', cover: '', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(browserFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suggest throws ArgumentError when --cover is missing (#1689 root cause)', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
await expect(cmd.func({}, { action: 'suggest', keyword: '速效救心丸', cover: '', limit: 10 }))
|
||||
.rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--cover') });
|
||||
await expect(cmd.func({}, { action: 'suggest', keyword: '', cover: ' ', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(browserFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hot accepts empty --keyword (it is optional for hot)', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({ hotspot_list: [{ sentence: '热点1', hot_value: 100, sentence_id: 'h1' }] });
|
||||
const rows = await cmd.func({}, { action: 'hot', keyword: '', cover: '', limit: 5 });
|
||||
expect(rows[0]).toEqual({ name: '热点1', id: 'h1', view_count: 100 });
|
||||
const url = browserFetchMock.mock.calls[0][2];
|
||||
expect(url).not.toContain('keyword=');
|
||||
});
|
||||
|
||||
it('search threads --keyword + count into the challenge/search URL', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({
|
||||
challenge_list: [{ challenge_info: { cha_name: '美食', cid: '123', view_count: 5000 } }],
|
||||
});
|
||||
const rows = await cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 });
|
||||
expect(rows).toEqual([{ name: '美食', id: '123', view_count: 5000 }]);
|
||||
const url = browserFetchMock.mock.calls[0][2];
|
||||
expect(url).toContain('challenge/search');
|
||||
expect(url).toContain('keyword=' + encodeURIComponent('美食'));
|
||||
expect(url).toContain('count=10');
|
||||
});
|
||||
|
||||
it('suggest threads --cover into the hashtag/rec URL on success', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({
|
||||
hashtag_list: [{ name: '推荐话题', id: 'h99', view_count: 1234 }],
|
||||
});
|
||||
const rows = await cmd.func({}, { action: 'suggest', keyword: '', cover: 'tos-cn-i-cover/abc', limit: 10 });
|
||||
expect(rows).toEqual([{ name: '推荐话题', id: 'h99', view_count: 1234 }]);
|
||||
const url = browserFetchMock.mock.calls[0][2];
|
||||
expect(url).toContain('hashtag/rec');
|
||||
expect(url).toContain('cover_uri=' + encodeURIComponent('tos-cn-i-cover/abc'));
|
||||
});
|
||||
|
||||
it('search throws CommandExecutionError when API returns a non-object payload', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce(null);
|
||||
await expect(cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('search throws CommandExecutionError when challenge_list has wrong shape', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({ challenge_list: 'not-an-array' });
|
||||
await expect(cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('search throws CommandExecutionError when challenges return but none parse', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({
|
||||
challenge_list: [{ challenge_info: null }, { other_field: 1 }],
|
||||
});
|
||||
await expect(cmd.func({}, { action: 'search', keyword: '美食', cover: '', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('suggest throws CommandExecutionError when hashtag_list has wrong shape', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({ hashtag_list: 'oops' });
|
||||
await expect(cmd.func({}, { action: 'suggest', keyword: '', cover: 'tos-cn-i-cover/x', limit: 10 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('hot throws CommandExecutionError when hotspot_list has wrong shape', async () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'hashtag');
|
||||
browserFetchMock.mockResolvedValueOnce({ hotspot_list: { malformed: true } });
|
||||
await expect(cmd.func({}, { action: 'hot', keyword: '', cover: '', limit: 5 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('parses the current hotspot recommendation shape', async () => {
|
||||
const registry = getRegistry();
|
||||
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'hashtag');
|
||||
|
||||
@@ -66,6 +66,42 @@ describe('douyin publish upload identifier handling', () => {
|
||||
const createCall = mocks.browserFetch.mock.calls.find((call) => String(call[2]).includes('/aweme/create_v2/'));
|
||||
expect(createCall?.[3]?.body.item.common.video_id).toBe('canonical-video-id');
|
||||
expect(createCall?.[3]?.body.item.common.video_id).not.toBe('object-key-returned-by-complete');
|
||||
expect(createCall?.[3]?.body.item.common.text).toBe('OpenCLI自测');
|
||||
});
|
||||
|
||||
it('keeps title-prefixed publish text and hashtag offsets aligned for create_v2', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'douyin-publish-text-'));
|
||||
const video = path.join(tmpDir, 'video.mp4');
|
||||
fs.writeFileSync(video, Buffer.from('fake-video'));
|
||||
|
||||
const { getRegistry } = await import('@jackwener/opencli/registry');
|
||||
getRegistry().delete('douyin/publish');
|
||||
await import('./publish.js');
|
||||
const cmd = getRegistry().get('douyin/publish');
|
||||
if (!cmd) throw new Error('douyin publish command not registered');
|
||||
|
||||
await cmd.func({}, {
|
||||
video,
|
||||
title: 'OpenCLI标题',
|
||||
schedule: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
||||
caption: '正文 #话题',
|
||||
visibility: 'private',
|
||||
no_safety_check: true,
|
||||
});
|
||||
|
||||
const createCall = mocks.browserFetch.mock.calls.find((call) => String(call[2]).includes('/aweme/create_v2/'));
|
||||
const common = createCall?.[3]?.body.item.common;
|
||||
expect(common.text).toBe('OpenCLI标题 正文 #话题');
|
||||
expect(common.caption).toBe('正文 #话题');
|
||||
expect(common.item_title).toBe('OpenCLI标题');
|
||||
const textExtra = JSON.parse(common.text_extra);
|
||||
expect(textExtra).toEqual([
|
||||
expect.objectContaining({
|
||||
hashtag_name: '话题',
|
||||
start: 'OpenCLI标题 正文 '.length,
|
||||
end: 'OpenCLI标题 正文 #话题'.length,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('continues to create_v2 when the legacy fast detect API returns an empty response', async () => {
|
||||
|
||||
@@ -256,12 +256,18 @@ cli({
|
||||
hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
|
||||
searchFrom = idx + name.length + 1;
|
||||
}
|
||||
const textExtraArr = parseTextExtra(caption, hashtags);
|
||||
const publishText = caption ? `${title} ${caption}` : title;
|
||||
const captionOffset = caption ? title.length + 1 : 0;
|
||||
const textExtraArr = parseTextExtra(publishText, hashtags.map((hashtag) => ({
|
||||
...hashtag,
|
||||
start: hashtag.start + captionOffset,
|
||||
end: hashtag.end + captionOffset,
|
||||
})));
|
||||
const publishBody = {
|
||||
item: {
|
||||
common: {
|
||||
text: caption,
|
||||
caption: '',
|
||||
text: publishText,
|
||||
caption: caption,
|
||||
item_title: title,
|
||||
activity: JSON.stringify(kwargs.activity ? [kwargs.activity] : []),
|
||||
text_extra: JSON.stringify(textExtraArr),
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Douyin search — keyword video search on www.douyin.com.
|
||||
*
|
||||
* Strategy: DOM extraction from the server-rendered search results page.
|
||||
*
|
||||
* Why not XHR interception:
|
||||
* The `www.douyin.com/search/<q>?type=video` page renders results into
|
||||
* `<ul data-e2e="scroll-list">` server-side during initial navigation
|
||||
* and (for the OpenCLI-bridged browser context) does NOT fire a
|
||||
* subsequent `/aweme/v1/web/general/search/single/` XHR — we confirmed
|
||||
* this by `wait xhr "general/search/single"` timing out at 20s on a
|
||||
* logged-in profile that has visible result cards in the DOM. Direct
|
||||
* synthesis of the XHR from page context returns
|
||||
* `status_code: 0, data: [], search_nil_info: { search_nil_type:
|
||||
* "verify_check" }` because the bare URL lacks the SPA-computed
|
||||
* `a_bogus` / `msToken` signature.
|
||||
*
|
||||
* DOM extraction sidesteps both blockers: the data is already in the
|
||||
* rendered HTML at the moment of navigation, signature-free.
|
||||
*
|
||||
* Selector approach:
|
||||
* Douyin obfuscates card classnames (e.g. `.ckopQfVu`, `.cIiU4Muu`)
|
||||
* and they churn between builds. We pin only the stable hooks:
|
||||
* - container: `[data-e2e="scroll-list"]`
|
||||
* - row: `li` inside the container
|
||||
* - url: `a[href*="/video/"]`
|
||||
* - other fields are extracted from the row's leaf text nodes by
|
||||
* SHAPE (digit+万/亿 → likes; HH:MM or MM:SS → duration; text after
|
||||
* `@` → author nickname; longest remaining → desc).
|
||||
*
|
||||
* Output fields mirror `tiktok search` (rank, desc, author, url, plays,
|
||||
* likes, comments, shares) so downstream tools that already normalize
|
||||
* tiktok rows can consume douyin rows without per-adapter glue. The
|
||||
* search results page only surfaces the like count — plays/comments/
|
||||
* shares are not in the card markup and we expose them as 0 rather
|
||||
* than fabricate values; clients that need them should fetch
|
||||
* /aweme/v1/web/aweme/detail/?aweme_id=... for the relevant id.
|
||||
*
|
||||
* Prerequisite: the bound Chrome profile must be logged in to
|
||||
* https://www.douyin.com. The search results page renders an empty
|
||||
* skeleton for anonymous visitors, which we surface as AuthRequiredError.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const MAX_SEARCH_LIMIT = 30;
|
||||
// Time budget for the SPA's initial DOM commit. Empirically the
|
||||
// scroll-list `<li>` rows appear within 2-4s of navigation when logged
|
||||
// in; 15s covers slow networks without blocking on a permanently-empty
|
||||
// page (anonymous gate, network error).
|
||||
export const RENDER_TIMEOUT_MS = 15000;
|
||||
|
||||
export function parseSearchLimit(raw) {
|
||||
const parsed = Number(raw ?? 10);
|
||||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
||||
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_SEARCH_LIMIT}, got ${JSON.stringify(raw)}`);
|
||||
}
|
||||
if (parsed < 1 || parsed > MAX_SEARCH_LIMIT) {
|
||||
throw new ArgumentError(`--limit must be between 1 and ${MAX_SEARCH_LIMIT}, got ${parsed}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Douyin display count like "1.9万", "3.1万", "4702", "1.2亿"
|
||||
* into a plain integer. Returns 0 for unparseable input rather than
|
||||
* throwing — the CLI promises numeric columns and missing data is
|
||||
* common enough on real result rows that a soft fallback is the right
|
||||
* choice.
|
||||
*/
|
||||
export function parseDouyinCount(text) {
|
||||
if (typeof text !== 'string') return 0;
|
||||
const m = text.replace(/\s/g, '').match(/^(\d+(?:\.\d+)?)([万亿])?$/);
|
||||
if (!m) {
|
||||
const plain = Number(text.replace(/[,\s]/g, ''));
|
||||
return Number.isFinite(plain) ? Math.round(plain) : 0;
|
||||
}
|
||||
const n = Number(m[1]);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
if (m[2] === '万') return Math.round(n * 10_000);
|
||||
if (m[2] === '亿') return Math.round(n * 100_000_000);
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
export function extractDouyinVideoId(href) {
|
||||
if (typeof href !== 'string' || !href) return '';
|
||||
let full = href;
|
||||
if (full.startsWith('//')) full = 'https:' + full;
|
||||
else if (full.startsWith('/')) full = 'https://www.douyin.com' + full;
|
||||
try {
|
||||
const parsed = new URL(full);
|
||||
if (!/(^|\.)douyin\.com$/.test(parsed.hostname)) return '';
|
||||
const match = parsed.pathname.match(/^\/video\/(\d+)$/);
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve scheme-relative or absolute Douyin video links to the canonical
|
||||
* https://www.douyin.com/video/<id> shape. Returns '' for unparseable
|
||||
* input rather than throwing — callers expect a string column.
|
||||
*/
|
||||
export function normalizeDouyinVideoUrl(href) {
|
||||
const id = extractDouyinVideoId(href);
|
||||
return id ? `https://www.douyin.com/video/${id}` : '';
|
||||
}
|
||||
|
||||
function isSearchCardMetadataText(text) {
|
||||
if (!text) return true;
|
||||
if (/^\d{1,2}:\d{2}(?::\d{2})?$/.test(text)) return true;
|
||||
if (/^\d+(?:\.\d+)?[万亿]?$/.test(text)) return true;
|
||||
if (/^(合集|视频|作者)$/.test(text)) return true;
|
||||
if (/^(刚刚|今天|昨天|前天)$/.test(text)) return true;
|
||||
if (/^\d+\s*(秒|分钟|小时|天|周|个月|月|年)前$/.test(text)) return true;
|
||||
if (/^\d{4}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?$/.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a single rendered card into the canonical row shape. Operates
|
||||
* on a serialized card payload (the raw `{href, leafTexts}` we collect
|
||||
* via page.evaluate) so this function is unit-testable without a real
|
||||
* browser.
|
||||
*
|
||||
* `leafTexts` is the ordered list of `textContent.trim()` for every leaf
|
||||
* element inside the card (no children). The fields we want are
|
||||
* identified by shape:
|
||||
* - duration: matches `HH:MM:SS` or `MM:SS`
|
||||
* - likes: matches `<digits>(.<digits>)?(万|亿)?` and ISN'T the duration
|
||||
* - author: the text node immediately following an `@` text node
|
||||
* - desc: the longest remaining leaf text
|
||||
*/
|
||||
export function projectCard(card, index) {
|
||||
const url = normalizeDouyinVideoUrl(card?.url ?? card?.href);
|
||||
const texts = Array.isArray(card?.leafTexts) ? card.leafTexts.map((t) => String(t ?? '').trim()).filter(Boolean) : [];
|
||||
|
||||
const DURATION_RE = /^\d{1,2}:\d{2}(?::\d{2})?$/;
|
||||
const COUNT_RE = /^\d+(?:\.\d+)?[万亿]?$/;
|
||||
|
||||
let likes = 0;
|
||||
let author = '';
|
||||
let longest = '';
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const t = texts[i];
|
||||
if (DURATION_RE.test(t)) continue;
|
||||
if (!likes && COUNT_RE.test(t)) {
|
||||
likes = parseDouyinCount(t);
|
||||
continue;
|
||||
}
|
||||
if (t === '@' && !author) {
|
||||
author = (texts[i + 1] ?? '').trim();
|
||||
continue;
|
||||
}
|
||||
if (t === author) continue;
|
||||
if (isSearchCardMetadataText(t)) continue;
|
||||
if (t.length > longest.length) longest = t;
|
||||
}
|
||||
let desc = longest;
|
||||
// Strip a leading "@author" that some renders fuse into the desc text node.
|
||||
if (author && desc.startsWith('@' + author)) {
|
||||
desc = desc.slice(author.length + 1).trim();
|
||||
}
|
||||
return {
|
||||
rank: index + 1,
|
||||
desc,
|
||||
author,
|
||||
url,
|
||||
plays: 0,
|
||||
likes,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectedRowUsable(row) {
|
||||
return Boolean(row?.url && row?.desc);
|
||||
}
|
||||
|
||||
export function projectSearchCards(cards, limit) {
|
||||
const window = Array.isArray(cards) ? cards.slice(0, limit) : [];
|
||||
const rows = window.map((card, index) => projectCard(card, index));
|
||||
const invalidCount = rows.filter((row) => !isProjectedRowUsable(row)).length;
|
||||
return { rows: rows.filter(isProjectedRowUsable), invalidCount };
|
||||
}
|
||||
|
||||
// JS snippet that waits for the scroll-list to populate, then returns
|
||||
// `{state: 'rendered', cards}` or `{state: 'login_wall'}` /
|
||||
// `{state: 'timeout'}`. Runs inside page.evaluate so we don't pay a
|
||||
// round-trip per poll iteration.
|
||||
const WAIT_AND_EXTRACT_JS = (timeoutMs) => `
|
||||
new Promise((resolve) => {
|
||||
const collectCards = () => {
|
||||
const cards = [];
|
||||
const lis = document.querySelectorAll('[data-e2e="scroll-list"] li');
|
||||
for (const li of lis) {
|
||||
const a = li.querySelector('a[href*="/video/"]');
|
||||
if (!a) continue;
|
||||
const leafTexts = [];
|
||||
for (const el of li.querySelectorAll('*')) {
|
||||
if (el.children.length > 0) continue;
|
||||
const t = (el.textContent || '').trim();
|
||||
if (t) leafTexts.push(t);
|
||||
}
|
||||
cards.push({ href: a.getAttribute('href') || '', leafTexts });
|
||||
}
|
||||
return cards;
|
||||
};
|
||||
const detectState = () => {
|
||||
const cards = collectCards();
|
||||
if (cards.length > 0) return { state: 'rendered', cards };
|
||||
// Anonymous gate: Douyin renders a centered "登录后查看更多内容"
|
||||
// overlay on /search/ for visitors without sessionid. Match either
|
||||
// the literal Chinese prompt or a visible login modal/mask.
|
||||
const text = (document.body && document.body.innerText) || '';
|
||||
if (/登录后查看|请先登录|登录抖音|验证码|验证|verify_check|安全校验/.test(text)) return { state: 'login_wall' };
|
||||
if (/暂无相关搜索结果|没有找到相关结果|搜索结果为空|暂无结果/.test(text)) return { state: 'empty' };
|
||||
const modal = document.querySelector('[class*="login-mask"], [class*="LoginMask"], [class*="login-modal"], dialog[role="dialog"]');
|
||||
if (modal && modal instanceof HTMLElement) {
|
||||
const r = modal.getBoundingClientRect();
|
||||
const s = getComputedStyle(modal);
|
||||
if (r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden') {
|
||||
return { state: 'login_wall' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const found = detectState();
|
||||
if (found) return resolve(found);
|
||||
const observer = new MutationObserver(() => {
|
||||
const s = detectState();
|
||||
if (s) { observer.disconnect(); resolve(s); }
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
const fallback = detectState();
|
||||
resolve(fallback ?? { state: 'timeout' });
|
||||
}, ${timeoutMs});
|
||||
})
|
||||
`;
|
||||
|
||||
function unwrapEvaluateResult(payload) {
|
||||
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
||||
return payload.data;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'douyin',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '关键词搜索抖音视频',
|
||||
domain: 'www.douyin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: '搜索关键词' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: `结果数量 (1-${MAX_SEARCH_LIMIT})` },
|
||||
],
|
||||
columns: ['rank', 'desc', 'author', 'url', 'plays', 'likes', 'comments', 'shares'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = parseSearchLimit(kwargs.limit);
|
||||
const keyword = String(kwargs.query ?? '').trim();
|
||||
if (!keyword) {
|
||||
throw new ArgumentError('douyin search 需要 <query> 关键词');
|
||||
}
|
||||
await page.goto(`https://www.douyin.com/search/${encodeURIComponent(keyword)}?type=video`);
|
||||
let result;
|
||||
try {
|
||||
result = unwrapEvaluateResult(await page.evaluate(WAIT_AND_EXTRACT_JS(RENDER_TIMEOUT_MS)));
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`Douyin search extraction failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new CommandExecutionError('Douyin search: unexpected evaluator payload shape');
|
||||
}
|
||||
if (result.state === 'login_wall') {
|
||||
throw new AuthRequiredError(
|
||||
'www.douyin.com',
|
||||
'Douyin search results are blocked behind a login wall — log in at https://www.douyin.com in Chrome first.',
|
||||
);
|
||||
}
|
||||
if (result.state === 'empty') {
|
||||
throw new EmptyResultError('douyin search', `No Douyin videos matched "${keyword}".`);
|
||||
}
|
||||
if (result.state === 'timeout') {
|
||||
throw new CommandExecutionError('Douyin search did not render result cards within the timeout. Open the same search in Chrome and verify login/security state before retrying.');
|
||||
}
|
||||
if (!Array.isArray(result.cards)) {
|
||||
throw new CommandExecutionError('Douyin search: evaluator returned malformed cards payload');
|
||||
}
|
||||
if (result.cards.length === 0) {
|
||||
throw new EmptyResultError('douyin search', `No Douyin videos matched "${keyword}".`);
|
||||
}
|
||||
const projected = projectSearchCards(result.cards, limit);
|
||||
if (projected.invalidCount > 0) {
|
||||
throw new CommandExecutionError('Douyin search parser found result cards without stable video url or description');
|
||||
}
|
||||
if (projected.rows.length === 0) {
|
||||
throw new EmptyResultError('douyin search', `No Douyin videos matched "${keyword}".`);
|
||||
}
|
||||
return projected.rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
extractDouyinVideoId,
|
||||
MAX_SEARCH_LIMIT,
|
||||
normalizeDouyinVideoUrl,
|
||||
parseDouyinCount,
|
||||
parseSearchLimit,
|
||||
projectCard,
|
||||
projectSearchCards,
|
||||
} from './search.js';
|
||||
|
||||
function createPageMock({ evaluateResult } = {}) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
};
|
||||
}
|
||||
|
||||
describe('douyin search', () => {
|
||||
it('registers the command on www.douyin.com', () => {
|
||||
const registry = getRegistry();
|
||||
const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'search');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.domain).toBe('www.douyin.com');
|
||||
});
|
||||
|
||||
it('rejects invalid limit before navigation', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock();
|
||||
await expect(cmd.func(page, { query: '咖啡', limit: 0 })).rejects.toMatchObject({
|
||||
code: 'ARGUMENT',
|
||||
message: expect.stringContaining('--limit'),
|
||||
});
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects limit above MAX_SEARCH_LIMIT', () => {
|
||||
expect(() => parseSearchLimit(MAX_SEARCH_LIMIT + 1)).toThrow(/--limit/);
|
||||
});
|
||||
|
||||
it('rejects an empty query', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock();
|
||||
await expect(cmd.func(page, { query: ' ', limit: 5 })).rejects.toMatchObject({
|
||||
code: 'ARGUMENT',
|
||||
});
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns ranked cards from the rendered scroll-list', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({
|
||||
evaluateResult: {
|
||||
state: 'rendered',
|
||||
cards: [
|
||||
{
|
||||
url: '//www.douyin.com/video/7585120459717365001',
|
||||
leafTexts: [
|
||||
'合集',
|
||||
'03:55',
|
||||
'1.9万',
|
||||
'Python邪修,5分钟学完Python基础 #python #编程',
|
||||
'@',
|
||||
'校长讲python(无小号)',
|
||||
'5月前',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows = await cmd.func(page, { query: 'python', limit: 5 });
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.douyin.com/search/python?type=video');
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
desc: 'Python邪修,5分钟学完Python基础 #python #编程',
|
||||
author: '校长讲python(无小号)',
|
||||
url: 'https://www.douyin.com/video/7585120459717365001',
|
||||
plays: 0,
|
||||
likes: 19000,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('encodes Chinese keywords in the URL path', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({ evaluateResult: { state: 'rendered', cards: [{ url: '/video/1', leafTexts: ['hi'] }] } });
|
||||
await cmd.func(page, { query: 'AI 编程', limit: 1 });
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.douyin.com/search/AI%20%E7%BC%96%E7%A8%8B?type=video');
|
||||
});
|
||||
|
||||
it('respects --limit cap when the page rendered more cards than requested', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const cards = Array.from({ length: 12 }, (_, i) => ({
|
||||
url: `//www.douyin.com/video/100000${i}`,
|
||||
leafTexts: ['03:00', `${i + 1}万`, `video ${i}`, '@', `user${i}`],
|
||||
}));
|
||||
const page = createPageMock({ evaluateResult: { state: 'rendered', cards } });
|
||||
const rows = await cmd.func(page, { query: 'x', limit: 3 });
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.url)).toEqual([
|
||||
'https://www.douyin.com/video/1000000',
|
||||
'https://www.douyin.com/video/1000001',
|
||||
'https://www.douyin.com/video/1000002',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps the explicit login-wall state to AuthRequiredError', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({ evaluateResult: { state: 'login_wall' } });
|
||||
await expect(cmd.func(page, { query: 'x', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'AUTH_REQUIRED',
|
||||
message: expect.stringContaining('login wall'),
|
||||
});
|
||||
});
|
||||
|
||||
it('maps explicit empty search state to EmptyResultError', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({ evaluateResult: { state: 'empty' } });
|
||||
await expect(cmd.func(page, { query: 'x', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'EMPTY_RESULT',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps timeout state to CommandExecutionError instead of treating parser drift as auth or empty', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({ evaluateResult: { state: 'timeout' } });
|
||||
await expect(cmd.func(page, { query: 'x', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps Browser Bridge {session, data} envelopes before inspecting state', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({
|
||||
evaluateResult: {
|
||||
session: 'site:douyin',
|
||||
data: { state: 'rendered', cards: [{ url: '/video/9', leafTexts: ['demo'] }] },
|
||||
},
|
||||
});
|
||||
const rows = await cmd.func(page, { query: 'x', limit: 1 });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].url).toBe('https://www.douyin.com/video/9');
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError on malformed evaluator payload', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({ evaluateResult: 'not-an-object' });
|
||||
await expect(cmd.func(page, { query: 'x', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError on malformed cards payload', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({ evaluateResult: { state: 'rendered', cards: { bad: true } } });
|
||||
await expect(cmd.func(page, { query: 'x', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed instead of partially returning cards missing stable url or desc', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({
|
||||
evaluateResult: {
|
||||
state: 'rendered',
|
||||
cards: [
|
||||
{ url: '/video/123', leafTexts: ['03:00', 'valid desc'] },
|
||||
{ url: 'https://evil.test/video/456', leafTexts: ['03:00', 'invalid url'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(cmd.func(page, { query: 'x', limit: 2 })).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when a card only has metadata text and no stable desc', async () => {
|
||||
const cmd = getRegistry().get('douyin/search');
|
||||
const page = createPageMock({
|
||||
evaluateResult: {
|
||||
state: 'rendered',
|
||||
cards: [
|
||||
{ url: '/video/123', leafTexts: ['合集', '03:00', '1.2万', '@', '作者名', '5月前'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(cmd.func(page, { query: 'x', limit: 1 })).rejects.toMatchObject({
|
||||
code: 'COMMAND_EXEC',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDouyinCount', () => {
|
||||
it.each([
|
||||
['1.9万', 19_000],
|
||||
['3万', 30_000],
|
||||
['4702', 4702],
|
||||
['1,234', 1234],
|
||||
['1.2亿', 120_000_000],
|
||||
['', 0],
|
||||
['unknown', 0],
|
||||
[null, 0],
|
||||
[undefined, 0],
|
||||
])('parses %j as %i', (input, expected) => {
|
||||
expect(parseDouyinCount(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDouyinVideoUrl', () => {
|
||||
it.each([
|
||||
['//www.douyin.com/video/123', 'https://www.douyin.com/video/123'],
|
||||
['/video/123?foo=bar', 'https://www.douyin.com/video/123'],
|
||||
['https://www.douyin.com/video/123?something', 'https://www.douyin.com/video/123'],
|
||||
['https://evil.test/video/123', ''],
|
||||
['https://www.douyin.com/user/video/123', ''],
|
||||
['', ''],
|
||||
[null, ''],
|
||||
])('normalizes %j → %j', (input, expected) => {
|
||||
expect(normalizeDouyinVideoUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('extracts only stable Douyin video ids', () => {
|
||||
expect(extractDouyinVideoId('https://www.douyin.com/video/123')).toBe('123');
|
||||
expect(extractDouyinVideoId('//www.douyin.com/video/456')).toBe('456');
|
||||
expect(extractDouyinVideoId('https://evil.test/video/123')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectCard', () => {
|
||||
it('extracts duration/likes/desc/author by leaf-text shape, classname-agnostic', () => {
|
||||
const row = projectCard({
|
||||
url: '//www.douyin.com/video/7585120459717365001',
|
||||
leafTexts: ['合集', '03:55', '1.9万', 'Python邪修', '@', '校长', '5月前'],
|
||||
}, 0);
|
||||
expect(row).toEqual({
|
||||
rank: 1,
|
||||
desc: 'Python邪修',
|
||||
author: '校长',
|
||||
url: 'https://www.douyin.com/video/7585120459717365001',
|
||||
plays: 0,
|
||||
likes: 19000,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the longest non-skipped text as desc, not the publish-date suffix', () => {
|
||||
const row = projectCard({
|
||||
url: '/video/1',
|
||||
leafTexts: ['02:00', '4702', 'hi long-text', '@', 'user', '1月前'],
|
||||
}, 0);
|
||||
expect(row.desc).toBe('hi long-text');
|
||||
expect(row.author).toBe('user');
|
||||
});
|
||||
|
||||
it('strips a fused @author prefix from the desc when present', () => {
|
||||
const row = projectCard({
|
||||
url: '/video/1',
|
||||
leafTexts: ['02:00', '100', '@alice this is the caption', '@', 'alice'],
|
||||
}, 0);
|
||||
expect(row.author).toBe('alice');
|
||||
expect(row.desc).toBe('this is the caption');
|
||||
});
|
||||
|
||||
it('returns safe defaults when leafTexts is missing', () => {
|
||||
const row = projectCard({ url: '/video/42', leafTexts: undefined }, 4);
|
||||
expect(row).toEqual({
|
||||
rank: 5,
|
||||
desc: '',
|
||||
author: '',
|
||||
url: 'https://www.douyin.com/video/42',
|
||||
plays: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns rank=index+1 regardless of input', () => {
|
||||
const row = projectCard({ url: '/video/1', leafTexts: ['x'] }, 9);
|
||||
expect(row.rank).toBe(10);
|
||||
});
|
||||
|
||||
it('projects cards and reports malformed rows in the returned window', () => {
|
||||
const result = projectSearchCards([
|
||||
{ url: '/video/1', leafTexts: ['caption'] },
|
||||
{ url: '/video/not-numeric', leafTexts: ['bad'] },
|
||||
{ url: '/video/3', leafTexts: [] },
|
||||
], 3);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.invalidCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not treat metadata-only leaf text as a stable desc', () => {
|
||||
const result = projectSearchCards([
|
||||
{ url: '/video/1', leafTexts: ['合集', '03:55', '1.9万', '@', '校长', '5月前'] },
|
||||
], 1);
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.invalidCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasFacebookCUserCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.facebook.com' });
|
||||
return cookies.some(c => c.name === 'c_user' && c.value);
|
||||
}
|
||||
|
||||
async function verifyFacebookIdentity(page) {
|
||||
if (!await hasFacebookCUserCookie(page)) {
|
||||
throw new AuthRequiredError('www.facebook.com', 'Facebook c_user cookie missing — anonymous session');
|
||||
}
|
||||
const cookies = await page.getCookies({ url: 'https://www.facebook.com' });
|
||||
const cUser = cookies.find(c => c.name === 'c_user')?.value || '';
|
||||
await page.goto('https://www.facebook.com/me');
|
||||
await page.wait(2);
|
||||
const finalUrl = await page.evaluate(`location.href`);
|
||||
const vanityMatch = String(finalUrl || '').match(/facebook\.com\/([^/?#]+)\/?(?:$|[?#])/);
|
||||
const vanity = vanityMatch?.[1] || '';
|
||||
if (!vanity || vanity === 'login.php' || vanity === 'checkpoint') {
|
||||
throw new AuthRequiredError('www.facebook.com', `Facebook /me redirected to ${finalUrl} — logged out or in checkpoint`);
|
||||
}
|
||||
return {
|
||||
user_id: String(cUser),
|
||||
vanity: String(vanity),
|
||||
profile_url: `https://www.facebook.com/${vanity}/`,
|
||||
};
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'facebook',
|
||||
domain: 'facebook.com',
|
||||
loginUrl: 'https://www.facebook.com/login.php',
|
||||
columns: ['user_id', 'vanity', 'profile_url'],
|
||||
quickCheck: hasFacebookCUserCookie,
|
||||
verify: verifyFacebookIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasFacebookCUserCookie(page)) {
|
||||
throw new AuthRequiredError('www.facebook.com', 'Waiting for Facebook c_user cookie');
|
||||
}
|
||||
return verifyFacebookIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasFlomoSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://flomoapp.com' });
|
||||
return cookies.some(c => c.name === 'flomo' && c.value);
|
||||
}
|
||||
|
||||
async function verifyFlomoIdentity(page) {
|
||||
if (!await hasFlomoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('flomoapp.com', 'Flomo session cookie missing');
|
||||
}
|
||||
await page.goto('https://v.flomoapp.com/mine');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
if (/\\/login(\\b|$|\\?)/.test(location.href)) {
|
||||
return { kind: 'auth', detail: 'Flomo /mine redirected to /login' };
|
||||
}
|
||||
let userId = '';
|
||||
try {
|
||||
const stack = [window.__INITIAL_STATE__, window.__NUXT__, window.__PINIA__].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.userInfo || node.currentUser;
|
||||
if (u && (u.id || u.user_id || u.uid)) { userId = String(u.id || u.user_id || u.uid); break; }
|
||||
for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
|
||||
}
|
||||
} catch {}
|
||||
return { ok: true, user_id: userId };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('flomoapp.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Flomo probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'flomo',
|
||||
domain: 'flomoapp.com',
|
||||
loginUrl: 'https://v.flomoapp.com/login',
|
||||
columns: ['user_id'],
|
||||
quickCheck: hasFlomoSessionCookie,
|
||||
verify: verifyFlomoIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasFlomoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('flomoapp.com', 'Waiting for Flomo session cookie');
|
||||
}
|
||||
return verifyFlomoIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasGoogleSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://gemini.google.com' });
|
||||
const names = new Set(cookies.map(c => c.name));
|
||||
return names.has('SID') || names.has('SAPISID') || names.has('__Secure-1PSID');
|
||||
}
|
||||
|
||||
async function verifyGeminiIdentity(page) {
|
||||
if (!await hasGoogleSessionCookie(page)) {
|
||||
throw new AuthRequiredError('gemini.google.com', 'Google session cookies (SID / SAPISID) missing');
|
||||
}
|
||||
await page.goto('https://gemini.google.com/app');
|
||||
await page.wait(3);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
const a = document.querySelector('a[aria-label^="Google Account:"]');
|
||||
if (!a) {
|
||||
return { kind: 'auth', detail: 'Gemini account link missing — not signed into Google' };
|
||||
}
|
||||
const label = a.getAttribute('aria-label') || '';
|
||||
const m = label.match(/Google Account:\\s*([^(]+?)\\s*\\(([^)]+)\\)/);
|
||||
if (!m) {
|
||||
return { kind: 'auth', detail: 'Gemini aria-label unparseable: ' + label };
|
||||
}
|
||||
return { ok: true, name: m[1].trim() };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('gemini.google.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Gemini probe: ${JSON.stringify(probe)}`);
|
||||
return { name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'gemini',
|
||||
domain: 'gemini.google.com',
|
||||
loginUrl: 'https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fgemini.google.com%2F',
|
||||
columns: ['name'],
|
||||
quickCheck: hasGoogleSessionCookie,
|
||||
verify: verifyGeminiIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasGoogleSessionCookie(page)) {
|
||||
throw new AuthRequiredError('gemini.google.com', 'Waiting for Google session cookies');
|
||||
}
|
||||
return verifyGeminiIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureGeminiPage: vi.fn(),
|
||||
getGeminiPageState: vi.fn(),
|
||||
getGeminiConversationList: vi.fn(),
|
||||
getGeminiVisibleTurns: vi.fn(),
|
||||
resolveGeminiConversationForQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async () => {
|
||||
const actual = await vi.importActual('./utils.js');
|
||||
return {
|
||||
...actual,
|
||||
ensureGeminiPage: mocks.ensureGeminiPage,
|
||||
getGeminiPageState: mocks.getGeminiPageState,
|
||||
getGeminiConversationList: mocks.getGeminiConversationList,
|
||||
getGeminiVisibleTurns: mocks.getGeminiVisibleTurns,
|
||||
resolveGeminiConversationForQuery: mocks.resolveGeminiConversationForQuery,
|
||||
};
|
||||
});
|
||||
|
||||
import { statusCommand } from './status.js';
|
||||
import { historyCommand, extractGeminiId } from './history.js';
|
||||
import { detailCommand } from './detail.js';
|
||||
import { readCommand } from './read.js';
|
||||
|
||||
function makePage() {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
describe('extractGeminiId', () => {
|
||||
it('parses bare id', () => {
|
||||
expect(extractGeminiId('b8368a89d4242e5f')).toBe('b8368a89d4242e5f');
|
||||
});
|
||||
it('parses /app/<id> path', () => {
|
||||
expect(extractGeminiId('/app/abc123')).toBe('abc123');
|
||||
});
|
||||
it('parses full URL', () => {
|
||||
expect(extractGeminiId('https://gemini.google.com/app/xyz789')).toBe('xyz789');
|
||||
});
|
||||
it('returns empty for garbage', () => {
|
||||
expect(extractGeminiId('')).toBe('');
|
||||
expect(extractGeminiId('not a url with spaces!')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini status', () => {
|
||||
it('returns Connected + Yes when composer present and no sign-in CTA', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiPageState.mockResolvedValue({
|
||||
url: 'https://gemini.google.com/app',
|
||||
title: 'Google Gemini',
|
||||
isSignedIn: true,
|
||||
composerLabel: 'Enter a prompt here',
|
||||
canSend: true,
|
||||
});
|
||||
const rows = await statusCommand.func(makePage(), {});
|
||||
expect(rows).toEqual([{ Status: 'Connected', Login: 'Yes', Url: 'https://gemini.google.com/app' }]);
|
||||
});
|
||||
|
||||
it('returns No login when sign-in CTA detected', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiPageState.mockResolvedValue({
|
||||
url: 'https://gemini.google.com/app',
|
||||
title: 'Google Gemini',
|
||||
isSignedIn: false,
|
||||
composerLabel: '',
|
||||
canSend: false,
|
||||
});
|
||||
const rows = await statusCommand.func(makePage(), {});
|
||||
expect(rows[0].Login).toBe('No');
|
||||
expect(rows[0].Status).toBe('Page not ready');
|
||||
});
|
||||
|
||||
it('treats isSignedIn=null (ambiguous) as logged in when composer is present', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiPageState.mockResolvedValue({
|
||||
url: 'https://gemini.google.com/app',
|
||||
isSignedIn: null,
|
||||
canSend: true,
|
||||
});
|
||||
const rows = await statusCommand.func(makePage(), {});
|
||||
expect(rows[0].Login).toBe('Yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini history', () => {
|
||||
it('returns numbered conversation rows', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiConversationList.mockResolvedValue([
|
||||
{ Title: 'First chat', Url: 'https://gemini.google.com/app/aaa111' },
|
||||
{ Title: 'Second chat', Url: 'https://gemini.google.com/app/bbb222' },
|
||||
]);
|
||||
const rows = await historyCommand.func(makePage(), { limit: 20 });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({
|
||||
Index: 1,
|
||||
Id: 'aaa111',
|
||||
Title: 'First chat',
|
||||
Url: 'https://gemini.google.com/app/aaa111',
|
||||
});
|
||||
expect(rows[1].Id).toBe('bbb222');
|
||||
});
|
||||
|
||||
it('respects --limit', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiConversationList.mockResolvedValue([
|
||||
{ Title: 'a', Url: 'https://gemini.google.com/app/1' },
|
||||
{ Title: 'b', Url: 'https://gemini.google.com/app/2' },
|
||||
{ Title: 'c', Url: 'https://gemini.google.com/app/3' },
|
||||
]);
|
||||
const rows = await historyCommand.func(makePage(), { limit: 2 });
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws ArgumentError for non-positive --limit', async () => {
|
||||
await expect(historyCommand.func(makePage(), { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(historyCommand.func(makePage(), { limit: -1 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(historyCommand.func(makePage(), { limit: 1.5 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when sidebar is empty', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiConversationList.mockResolvedValue([]);
|
||||
await expect(historyCommand.func(makePage(), { limit: 20 })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini detail', () => {
|
||||
it('navigates directly when given a bare id', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiVisibleTurns.mockResolvedValue([
|
||||
{ Role: 'User', Text: 'hello' },
|
||||
{ Role: 'Assistant', Text: 'hi there' },
|
||||
]);
|
||||
const page = makePage();
|
||||
const rows = await detailCommand.func(page, { id: 'b8368a89d4242e5f' });
|
||||
expect(page.goto).toHaveBeenCalledWith(
|
||||
'https://gemini.google.com/app/b8368a89d4242e5f',
|
||||
expect.objectContaining({ waitUntil: 'load' }),
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ Index: 1, Role: 'User', Text: 'hello' },
|
||||
{ Index: 2, Role: 'Assistant', Text: 'hi there' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a sidebar title to its URL', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiConversationList.mockResolvedValue([
|
||||
{ Title: 'Roman empire study', Url: 'https://gemini.google.com/app/rome1' },
|
||||
]);
|
||||
mocks.resolveGeminiConversationForQuery.mockReturnValue({
|
||||
Title: 'Roman empire study',
|
||||
Url: 'https://gemini.google.com/app/rome1',
|
||||
});
|
||||
mocks.getGeminiVisibleTurns.mockResolvedValue([{ Role: 'Assistant', Text: 'reply' }]);
|
||||
const page = makePage();
|
||||
const rows = await detailCommand.func(page, { id: 'roman' });
|
||||
expect(page.goto).toHaveBeenCalledWith(
|
||||
'https://gemini.google.com/app/rome1',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws ArgumentError when id is missing', async () => {
|
||||
await expect(detailCommand.func(makePage(), { id: '' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when title has no match', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiConversationList.mockResolvedValue([
|
||||
{ Title: 'a', Url: 'https://gemini.google.com/app/1' },
|
||||
]);
|
||||
mocks.resolveGeminiConversationForQuery.mockReturnValue(null);
|
||||
await expect(detailCommand.func(makePage(), { id: 'nope' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when navigated page yields zero turns', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiVisibleTurns.mockResolvedValue([]);
|
||||
await expect(detailCommand.func(makePage(), { id: 'abc' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini read', () => {
|
||||
it('returns indexed turns for the current page', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiVisibleTurns.mockResolvedValue([
|
||||
{ Role: 'User', Text: 'q1' },
|
||||
{ Role: 'Assistant', Text: 'a1' },
|
||||
]);
|
||||
const rows = await readCommand.func(makePage());
|
||||
expect(rows).toEqual([
|
||||
{ Index: 1, Role: 'User', Text: 'q1' },
|
||||
{ Index: 2, Role: 'Assistant', Text: 'a1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when no turns are visible', async () => {
|
||||
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
||||
mocks.getGeminiVisibleTurns.mockResolvedValue([]);
|
||||
await expect(readCommand.func(makePage())).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
GEMINI_APP_URL,
|
||||
GEMINI_DOMAIN,
|
||||
ensureGeminiPage,
|
||||
getGeminiConversationList,
|
||||
getGeminiVisibleTurns,
|
||||
resolveGeminiConversationForQuery,
|
||||
} from './utils.js';
|
||||
import { extractGeminiId } from './history.js';
|
||||
|
||||
/**
|
||||
* Resolve the caller-supplied `<id>` argument into an absolute
|
||||
* conversation URL. Accepts:
|
||||
* 1. A bare conversation id (`b8368a89d4242e5f`)
|
||||
* 2. A relative `/app/<id>` path
|
||||
* 3. A full `https://gemini.google.com/app/<id>` URL
|
||||
* 4. A sidebar title — looked up exactly first, then by substring
|
||||
*/
|
||||
async function resolveTargetUrl(page, query) {
|
||||
const raw = String(query || '').trim();
|
||||
if (!raw) {
|
||||
throw new ArgumentError('id', 'must be a conversation id, /app/<id> URL, or sidebar title');
|
||||
}
|
||||
|
||||
// Unambiguously id-shaped inputs (URL, /app/<id> path, or 16-hex bare id)
|
||||
// skip the sidebar lookup. Generic alphanumeric strings always go through
|
||||
// title-matching first so a chat called "Empire study" doesn't get treated
|
||||
// as the literal conversation id "Empire study".
|
||||
const directId = extractGeminiId(raw);
|
||||
const looksLikeId =
|
||||
raw.startsWith('http') ||
|
||||
raw.startsWith('/app/') ||
|
||||
/^[a-f0-9]{16,}$/i.test(raw);
|
||||
if (directId && looksLikeId) {
|
||||
return `${GEMINI_APP_URL}/${directId}`;
|
||||
}
|
||||
|
||||
const conversations = await getGeminiConversationList(page);
|
||||
const match = resolveGeminiConversationForQuery(conversations, raw, 'contains');
|
||||
if (!match || !match.Url) {
|
||||
throw new EmptyResultError(
|
||||
'gemini detail',
|
||||
`No sidebar conversation matched "${raw}". Try the exact id from \`opencli gemini history\` instead.`,
|
||||
);
|
||||
}
|
||||
return match.Url;
|
||||
}
|
||||
|
||||
export const detailCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'detail',
|
||||
access: 'read',
|
||||
description: 'Open a Gemini web conversation by id, URL, or sidebar title and read its turns',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Conversation id, /app/<id> URL, or sidebar title' },
|
||||
],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
func: async (page, kwargs) => {
|
||||
await ensureGeminiPage(page);
|
||||
const target = await resolveTargetUrl(page, kwargs?.id);
|
||||
await page.goto(target, { waitUntil: 'load', settleMs: 2500 });
|
||||
const turns = await getGeminiVisibleTurns(page);
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
throw new EmptyResultError(
|
||||
'gemini detail',
|
||||
`No turns were visible after navigating to ${target}.`,
|
||||
);
|
||||
}
|
||||
return turns.map((t, idx) => ({
|
||||
Index: idx + 1,
|
||||
Role: t.Role || 'System',
|
||||
Text: t.Text || '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
GEMINI_DOMAIN,
|
||||
ensureGeminiPage,
|
||||
getGeminiConversationList,
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* Pull the Gemini conversation id out of a `/app/<id>` URL or accept the
|
||||
* raw id directly. Returns '' when nothing usable is present.
|
||||
*/
|
||||
function extractGeminiId(url) {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const u = new URL(raw, 'https://gemini.google.com');
|
||||
const m = u.pathname.match(/^\/app\/([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
} catch {
|
||||
// Not a URL — fall through to direct id treatment.
|
||||
}
|
||||
// Accept a bare id ('app/<id>' or just '<id>').
|
||||
const trimmed = raw.replace(/^.*\/app\//, '').replace(/\/.*$/, '');
|
||||
return /^[A-Za-z0-9_-]+$/.test(trimmed) ? trimmed : '';
|
||||
}
|
||||
|
||||
export const historyCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'history',
|
||||
access: 'read',
|
||||
description: 'List visible Gemini web conversation history from the sidebar',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
|
||||
],
|
||||
columns: ['Index', 'Id', 'Title', 'Url'],
|
||||
func: async (page, kwargs) => {
|
||||
const rawLimit = Number(kwargs?.limit ?? 20);
|
||||
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 200) {
|
||||
throw new ArgumentError('limit', 'must be a positive integer ≤ 200');
|
||||
}
|
||||
await ensureGeminiPage(page);
|
||||
const conversations = await getGeminiConversationList(page);
|
||||
if (!conversations.length) {
|
||||
throw new EmptyResultError(
|
||||
'gemini history',
|
||||
'No Gemini conversation links were visible in the sidebar. Open the sidebar and confirm at least one chat is listed under Recents.',
|
||||
);
|
||||
}
|
||||
// The sidebar mixes a "New chat" affordance (URL = /app, no id) into
|
||||
// the same link list; drop entries that don't resolve to a real
|
||||
// conversation id so callers get a clean conversation list.
|
||||
const rows = conversations
|
||||
.map((row) => ({ id: extractGeminiId(row.Url), title: row.Title || '', url: row.Url || '' }))
|
||||
.filter((row) => row.id);
|
||||
return rows.slice(0, rawLimit).map((row, idx) => ({
|
||||
Index: idx + 1,
|
||||
Id: row.id,
|
||||
Title: row.title,
|
||||
Url: row.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export { extractGeminiId };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
GEMINI_DOMAIN,
|
||||
ensureGeminiPage,
|
||||
getGeminiVisibleTurns,
|
||||
} from './utils.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'read',
|
||||
access: 'read',
|
||||
description: 'Read the turns visible in the current Gemini web conversation',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
func: async (page) => {
|
||||
await ensureGeminiPage(page);
|
||||
const turns = await getGeminiVisibleTurns(page);
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
throw new EmptyResultError(
|
||||
'gemini read',
|
||||
'No turns were visible. Open a Gemini conversation first or use `opencli gemini detail <id>` to navigate.',
|
||||
);
|
||||
}
|
||||
return turns.map((t, idx) => ({
|
||||
Index: idx + 1,
|
||||
Role: t.Role || 'System',
|
||||
Text: t.Text || '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
GEMINI_DOMAIN,
|
||||
ensureGeminiPage,
|
||||
getGeminiPageState,
|
||||
} from './utils.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'status',
|
||||
access: 'read',
|
||||
description: 'Check Gemini web page availability and login state',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Login', 'Url'],
|
||||
func: async (page) => {
|
||||
await ensureGeminiPage(page);
|
||||
const state = await getGeminiPageState(page);
|
||||
// `isSignedIn` is one of {true, false, null}. `null` means the composer
|
||||
// was visible but no explicit Sign-in CTA was found — treat as logged in.
|
||||
const loggedIn = state.isSignedIn === false ? 'No' : 'Yes';
|
||||
return [{
|
||||
Status: state.canSend ? 'Connected' : 'Page not ready',
|
||||
Login: loggedIn,
|
||||
Url: state.url || '',
|
||||
}];
|
||||
},
|
||||
});
|
||||
+58
-10
@@ -35,6 +35,32 @@ const GEMINI_COMPOSER_SELECTORS = [
|
||||
const GEMINI_COMPOSER_MARKER_ATTR = 'data-opencli-gemini-composer';
|
||||
const GEMINI_COMPOSER_PREPARE_ATTEMPTS = 4;
|
||||
const GEMINI_COMPOSER_PREPARE_WAIT_SECONDS = 1;
|
||||
function isObjectRecord(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
function unwrapGeminiEvaluateResult(value, context) {
|
||||
if (isObjectRecord(value) && Object.prototype.hasOwnProperty.call(value, 'session')) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'data')) {
|
||||
return value.data;
|
||||
}
|
||||
throw new CommandExecutionError(`${context} returned a malformed Browser Bridge envelope`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function requireGeminiArrayResult(value, context) {
|
||||
const unwrapped = unwrapGeminiEvaluateResult(value, context);
|
||||
if (!Array.isArray(unwrapped)) {
|
||||
throw new CommandExecutionError(`${context} returned a malformed result`);
|
||||
}
|
||||
return unwrapped;
|
||||
}
|
||||
function requireGeminiObjectResult(value, context) {
|
||||
const unwrapped = unwrapGeminiEvaluateResult(value, context);
|
||||
if (!isObjectRecord(unwrapped)) {
|
||||
throw new CommandExecutionError(`${context} returned a malformed result`);
|
||||
}
|
||||
return unwrapped;
|
||||
}
|
||||
function buildGeminiComposerLocatorScript() {
|
||||
const selectorsJson = JSON.stringify(GEMINI_COMPOSER_SELECTORS);
|
||||
const markerAttrJson = JSON.stringify(GEMINI_COMPOSER_MARKER_ATTR);
|
||||
@@ -1043,7 +1069,7 @@ export async function waitForGeminiConfirmButton(page, labels, timeoutSeconds) {
|
||||
}
|
||||
export async function getGeminiPageState(page) {
|
||||
await ensureGeminiPage(page);
|
||||
return await page.evaluate(getStateScript());
|
||||
return requireGeminiObjectResult(await page.evaluate(getStateScript()), 'Gemini status');
|
||||
}
|
||||
export async function startNewGeminiChat(page) {
|
||||
await ensureGeminiPage(page);
|
||||
@@ -1056,12 +1082,16 @@ export async function startNewGeminiChat(page) {
|
||||
}
|
||||
export async function getGeminiConversationList(page) {
|
||||
await ensureGeminiPage(page);
|
||||
const raw = await page.evaluate(getGeminiConversationListScript());
|
||||
if (!Array.isArray(raw))
|
||||
return [];
|
||||
return raw
|
||||
.filter((item) => item && typeof item.title === 'string' && typeof item.url === 'string')
|
||||
.map((item) => ({ Title: item.title, Url: item.url }));
|
||||
const raw = requireGeminiArrayResult(await page.evaluate(getGeminiConversationListScript()), 'Gemini conversation list');
|
||||
const rows = raw.flatMap((item) => {
|
||||
if (!isObjectRecord(item) || typeof item.title !== 'string' || typeof item.url !== 'string') {
|
||||
throw new CommandExecutionError('Gemini conversation list returned a malformed row');
|
||||
}
|
||||
if (!isGeminiConversationUrl(item.url))
|
||||
return [];
|
||||
return { Title: item.title, Url: item.url };
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
export async function clickGeminiConversationByTitle(page, query) {
|
||||
await ensureGeminiPage(page);
|
||||
@@ -1082,12 +1112,22 @@ export async function getGeminiVisibleTurns(page) {
|
||||
}
|
||||
async function getGeminiStructuredTurns(page) {
|
||||
await ensureGeminiPage(page);
|
||||
const turns = collapseAdjacentGeminiTurns(await page.evaluate(getTurnsScript()));
|
||||
const raw = requireGeminiArrayResult(await page.evaluate(getTurnsScript()), 'Gemini visible turns');
|
||||
for (const turn of raw) {
|
||||
if (!isObjectRecord(turn) || typeof turn.Role !== 'string' || typeof turn.Text !== 'string') {
|
||||
throw new CommandExecutionError('Gemini visible turns returned a malformed row');
|
||||
}
|
||||
}
|
||||
const turns = collapseAdjacentGeminiTurns(raw);
|
||||
return Array.isArray(turns) ? turns : [];
|
||||
}
|
||||
export async function getGeminiTranscriptLines(page) {
|
||||
await ensureGeminiPage(page);
|
||||
return await page.evaluate(getTranscriptLinesScript());
|
||||
const lines = requireGeminiArrayResult(await page.evaluate(getTranscriptLinesScript()), 'Gemini transcript lines');
|
||||
if (!lines.every((line) => typeof line === 'string')) {
|
||||
throw new CommandExecutionError('Gemini transcript lines returned a malformed row');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
export async function waitForGeminiTranscript(page, attempts = 5) {
|
||||
let lines = [];
|
||||
@@ -1112,7 +1152,15 @@ export async function getLatestGeminiAssistantResponse(page) {
|
||||
}
|
||||
export async function readGeminiSnapshot(page) {
|
||||
await ensureGeminiPage(page);
|
||||
return await page.evaluate(readGeminiSnapshotScript());
|
||||
const snapshot = requireGeminiObjectResult(await page.evaluate(readGeminiSnapshotScript()), 'Gemini page snapshot');
|
||||
if (!Array.isArray(snapshot.turns) ||
|
||||
!Array.isArray(snapshot.transcriptLines) ||
|
||||
typeof snapshot.composerHasText !== 'boolean' ||
|
||||
typeof snapshot.isGenerating !== 'boolean' ||
|
||||
typeof snapshot.structuredTurnsTrusted !== 'boolean') {
|
||||
throw new CommandExecutionError('Gemini page snapshot returned a malformed result');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
function findLastUserTurnIndex(turns) {
|
||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user