Compare commits
57 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 |
+82
-4
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.3](https://github.com/jackwener/opencli/compare/v1.8.2...v1.8.3) (2026-06-06)
|
||||
|
||||
Patch release focused on two architectural fixes around extension and daemon lifecycle, plus the first wave of the new site auth subsystem.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.19** — close the MV3 Service Worker race that spawned duplicate `OpenCLI Adapter` tab groups (and, in the worst case, duplicate Adapter windows). The extension now persists the owned `windowId` immediately after `chrome.windows.create` returns and persists the owned `groupId` immediately after `chrome.tabs.group` returns, so a worker death between those API calls and the subsequent `chrome.tabGroups.update` no longer leaves a titleless orphan group and no longer drops the window pointer. Title-update failure no longer ungroups (it lets `ensureCanonicalGroupTitle` self-heal on the next ensure cycle), and `collectOwnedGroupCandidates` gains a fourth recovery layer: a global scan for empty-title groups containing a known owned `preferredTabId` for the role, with explicit hijack defense for user-built untitled groups. Closes the duplicate-tab-group bug report users had reported across the 1.8.2 window. ([#1862](https://github.com/jackwener/opencli/pull/1862))
|
||||
* **daemon** — SIGKILL fallback when the stale daemon refuses graceful shutdown. After `npm install -g @jackwener/opencli@latest`, the CLI detects a version-mismatched daemon (`daemonVersion !== PKG_VERSION`), asks it to exit via `/shutdown`, and now — if the port is still held after 3 s — reads the stale daemon's pid from its own `/status` response and `process.kill(pid, 'SIGKILL')` (cross-platform: maps to `TerminateProcess` on Windows). The previous flow surfaced `Stale daemon could not be replaced` and asked users to run `opencli daemon stop && opencli doctor`; this is now automatic. ([#1861](https://github.com/jackwener/opencli/pull/1861))
|
||||
* **xiaohongshu/publish** — prioritize the visible title input when the editor renders both a hidden draft input and a visible publish input.
|
||||
* **xiaohongshu/publish** — accept inline topic suggestions with Enter when the dropdown lives inside a Shadow DOM surface, while still verifying the topic marker appears in the editor.
|
||||
* **instagram/following** — paginate beyond the first endpoint page so high `--limit` values return more than the initial batch.
|
||||
|
||||
### Features
|
||||
|
||||
* **site auth subsystem** — new `opencli <site> login` and `opencli <site> whoami` commands, registered through a shared `clis/_shared/site-auth.js` helper. `login` opens the site's auth page in a foreground persistent session and polls the configured `verify` probe (cookie, JSON API, DOM scrape) until the browser session reports logged-in; `whoami` runs the same probe without opening the page. First five sites: twitter, github, bilibili, douyin, xiaohongshu. `whoami` outputs are PII-scrubbed (no email / phone / token in row columns). ([#1852](https://github.com/jackwener/opencli/pull/1852))
|
||||
* **gemini** — add read-only conversation commands (list / read / search).
|
||||
* **manus** — add a read-only `manus.im` adapter.
|
||||
|
||||
### Docs / Sitemap
|
||||
|
||||
* **sitemaps/xiaohongshu** — Phase 2 sitemap content seeded with login schema dogfood, the first non-PoC consumer of the v1.1 sitemap schema. ([#1853](https://github.com/jackwener/opencli/pull/1853))
|
||||
|
||||
### Internal
|
||||
|
||||
* **test(e2e)** — raise `runCli` `maxBuffer` so manifest-output snapshots no longer truncate on macOS / Windows CI.
|
||||
|
||||
## [1.8.2](https://github.com/jackwener/opencli/compare/v1.8.1...v1.8.2) (2026-06-03)
|
||||
|
||||
Mid-cycle release: introduces the **Site Maps Hub** subsystem (agent-facing per-site navigation knowledge), restores the **smart-search** skill, and ships a wide batch of new adapters / commands plus a long tail of read-path fixes. Extension bumped to 1.0.18 for an owned-group reusable-tab scope fix.
|
||||
|
||||
### Site Maps Hub (new subsystem)
|
||||
|
||||
* **`sitemaps/<site>/` top-level seed directory** — sitemap content lives alongside `clis/` and `skills/`, parallel first-class repo citizens. Twitter and HackerNews seeded as v1 baselines.
|
||||
* **`opencli browser open` / `analyze` surface sitemap availability** — when the requested site has a sitemap (global seed or local overlay `~/.opencli/sites/<site>/sitemap/`), the JSON envelope gains an optional `sitemap` field with `{ available, source, hint }`. `open` emits the hint once per session per site (deduped via `~/.opencli/cache/browser-sitemap-hints/`); `analyze` emits every call since it is a planning command. Adds no new browser-action behavior and no `~/.opencli/sites/` writes unless an agent explicitly invokes a sitemap skill.
|
||||
* **Two new skills**:
|
||||
* `opencli-sitemap-author` — create / maintain per-site sitemaps. Two-layer storage (global repo seed + local overlay), Form B compact YAML action schema with `pre / do / post / fail / recover / evidence`, `adapter_health_update` directives, `selector_pattern` as first-class anchor type, partial pages (`_<name>.md`) for cross-page UI, and a size-guidance table with hard 800-token / 1500-3000 cohesion / >3000 split tiers.
|
||||
* `opencli-browser-sitemap` — consume site sitemaps while executing browser tasks. Lazy load, Trust-Reality rule (`browser state` is truth, sitemap is hint), stale-on-conflict writeback, `adapter_health` write-back closure so subsequent agents skip a known-suspect adapter.
|
||||
* **`references/sitemap-schema.md`** — full field-level spec for `SITE.md / pages/<id>.md / workflows/<id>.md / apis.md / pitfalls.md`, action `state_signature` for re-entry, `adapter_health` enum, stable-id matching across overlay layers, draft placement rule, Phase 2 validation hooks.
|
||||
* **Twitter + HackerNews v1.1 seeds** under `sitemaps/{twitter,hackernews}/` validating the schema on dense React UI and simple SSR HTML respectively.
|
||||
|
||||
### Features
|
||||
|
||||
* **smart-search** — restored as a skill (`skills/smart-search/`) with per-category source guides (AI / info / media / shopping / social / tech / travel / other).
|
||||
* **twitter** — batch follow + list lifecycle (`list-create` / `list-delete` / `list-add` / `list-remove` batch forms).
|
||||
* **xiaohongshu** — draft management commands (`drafts` / `draft-open` / `draft-delete` / `draft-clear`).
|
||||
* **chatgpt-app** — temporary chat + multi-modal image attachment support.
|
||||
* **antigravity** — history mgmt (`history` / `delete` / `mark-read`) and model read/switch commands.
|
||||
* **codex** — conversation management (`pin` / `unpin` / `archive` / `rename`) plus model selector fix.
|
||||
* **grok** — conversation management (`delete` / `pin` / `unpin`) with locale-independent selectors.
|
||||
* **kimi** — new adapter for `kimi.com` (21 commands).
|
||||
* **qoder** — new adapter for Qoder IDE (19 commands).
|
||||
* **trae-cn** — new desktop adapter (Trae CN Electron app).
|
||||
* **trae-solo** — new desktop adapter (Trae SOLO Electron app).
|
||||
* **chatgpt** — add web model switch command.
|
||||
* **douyin** — add `search` command for keyword video search.
|
||||
* **wechat-channels** — add WeChat Video Channels (视频号) publish adapter.
|
||||
* **pubmed** — add workflow presets and richer article metadata.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension 1.0.18** — scope reusable-tab selection to owned-group members (follow-up to the v1.0.17 owned-container convergence model; ensures `findReusableOwnedContainerTab` does not pick up user tabs that were dragged into the owned window).
|
||||
* **chatgpt** — ignore image placeholders and upload previews when extracting the latest assistant message.
|
||||
* **xiaohongshu** — attach real topics via inline dropdown; feed returns signed note URLs for drill-down; carousel order preserved on download.
|
||||
* **twitter** — drop global tweetPhoto selector from the post-submit poll to avoid matching the wrong button.
|
||||
* **grok** — fall back to `Enter` key dispatch when send button is hidden behind layout shifts.
|
||||
* **daemon** — differentiate multi-profile status output so multiple Chrome profiles do not collapse into a single status row.
|
||||
* **youtube** — Videos tab fallback now supports `lockupViewModel` format alongside the legacy `gridVideoRenderer`.
|
||||
* **12306** — accept lowercase letters in `train_no` regex.
|
||||
* **weixin** — strip typographic quotes from pasted URLs.
|
||||
* **launcher** — Chromium 142+ CDP websocket origin check needs `--remote-allow-origins=*`.
|
||||
* **douyin/publish** — handle illegal-title errors with a typed error rather than a silent retry.
|
||||
|
||||
### Docs
|
||||
|
||||
* **opencli-adapter-author** — add `references/strategy-selection.md` codifying the empirical contract ladder (PUBLIC_API / COOKIE_API / UI_SELECTOR / DOM_STATE as contracted vs PAGE_FETCH / INTERCEPT as internal-unstable, with fixes/adapter-year data from a 837-adapter / 30-day window) and update SKILL.md to require a `strategy` evidence block at the top of every new adapter.
|
||||
* **opencli-adapter-author** — `browser analyze` upgrade: each candidate API gets `real_data_score` and a `likely_data` / `maybe_data` / `noise` verdict so Pattern A is no longer fired by analytics XHRs.
|
||||
* **readme** — prefix "Let AI Agents operate any website" bullet with "Browser User &" in both EN and zh-CN.
|
||||
|
||||
## [1.8.1](https://github.com/jackwener/opencli/compare/v1.8.0...v1.8.1) (2026-05-31)
|
||||
|
||||
Patch release focused on the extension tab-group convergence fix, plus 10 new adapters/commands and a wave of read-path / security hardening across browser, download, and adapters.
|
||||
@@ -45,10 +123,6 @@ Patch release focused on the extension tab-group convergence fix, plus 10 new ad
|
||||
|
||||
Substantial release: a new official-API adapter (`weread-official`), wider LinkedIn / Twitter / Reddit / Zhihu coverage, the 12306 / Suno / Xianyu inbox additions, security and reliability fixes for the Browser Bridge and media downloads, plus a 20% README shrink. Node 20 compatibility is restored after an automated `undici` bump regression.
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **skills** — remove the `smart-search` skill. Use `opencli-usage` for command/site reference, `opencli-browser` for ad-hoc browser operation, and `opencli-adapter-author` for writing new adapters.
|
||||
|
||||
### Features
|
||||
|
||||
* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter.
|
||||
@@ -311,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,7 +15,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -176,7 +182,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
||||
| **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` |
|
||||
@@ -193,7 +199,7 @@ Unified passthrough for your existing command-line tools. Run `opencli <tool> ..
|
||||
|
||||
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
|
||||
|
||||
|
||||
+9
-3
@@ -15,7 +15,7 @@ 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 应用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -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) — 命令和站点参考
|
||||
@@ -164,7 +170,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
| **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` |
|
||||
@@ -181,7 +187,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
|
||||
注册自定义本地 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/)。
|
||||
|
||||
## 下载支持
|
||||
|
||||
|
||||
+7882
-18
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,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,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,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,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);
|
||||
},
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
+122
-1
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { __test__, collectGeminiTranscriptAdditions, pickGeminiDeepResearchExportUrl, sanitizeGeminiResponseText, sendGeminiMessage, } from './utils.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { __test__, collectGeminiTranscriptAdditions, getGeminiConversationList, getGeminiPageState, getGeminiVisibleTurns, pickGeminiDeepResearchExportUrl, readGeminiSnapshot, sanitizeGeminiResponseText, sendGeminiMessage, } from './utils.js';
|
||||
function createPageMock() {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -176,6 +177,126 @@ describe('gemini turn normalization', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('gemini evaluate result boundaries', () => {
|
||||
it('unwraps Browser Bridge envelopes for conversation lists', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({
|
||||
session: 'site:gemini',
|
||||
data: [{ title: 'Chat A', url: 'https://gemini.google.com/app/abc123' }],
|
||||
});
|
||||
await expect(getGeminiConversationList(page)).resolves.toEqual([
|
||||
{ Title: 'Chat A', Url: 'https://gemini.google.com/app/abc123' },
|
||||
]);
|
||||
});
|
||||
it('drops non-conversation /app affordances from conversation lists', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce([
|
||||
{ title: 'New chat', url: 'https://gemini.google.com/app' },
|
||||
{ title: 'Chat A', url: 'https://gemini.google.com/app/abc123' },
|
||||
]);
|
||||
await expect(getGeminiConversationList(page)).resolves.toEqual([
|
||||
{ Title: 'Chat A', Url: 'https://gemini.google.com/app/abc123' },
|
||||
]);
|
||||
});
|
||||
it('typed-fails malformed Browser Bridge envelopes instead of treating them as empty', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({ session: 'site:gemini' });
|
||||
await expect(getGeminiConversationList(page)).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
it('typed-fails malformed conversation list rows', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce([{ title: 'Chat A' }]);
|
||||
await expect(getGeminiConversationList(page)).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
it('unwraps structured turns and transcript fallback results', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({
|
||||
session: 'site:gemini',
|
||||
data: [{ Role: 'User', Text: 'hello' }],
|
||||
});
|
||||
await expect(getGeminiVisibleTurns(page)).resolves.toEqual([{ Role: 'User', Text: 'hello' }]);
|
||||
|
||||
const fallbackPage = createPageMock();
|
||||
const fallbackEvaluate = vi.mocked(fallbackPage.evaluate);
|
||||
fallbackEvaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({
|
||||
session: 'site:gemini',
|
||||
data: ['plain transcript line'],
|
||||
});
|
||||
await expect(getGeminiVisibleTurns(fallbackPage)).resolves.toEqual([
|
||||
{ Role: 'System', Text: 'plain transcript line' },
|
||||
]);
|
||||
});
|
||||
it('typed-fails malformed visible turn rows', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce([{ Role: 'Assistant' }]);
|
||||
await expect(getGeminiVisibleTurns(page)).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
it('unwraps and validates status and snapshot objects', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({
|
||||
session: 'site:gemini',
|
||||
data: { url: 'https://gemini.google.com/app', canSend: true, isSignedIn: true },
|
||||
});
|
||||
await expect(getGeminiPageState(page)).resolves.toMatchObject({ canSend: true });
|
||||
|
||||
const snapshotPage = createPageMock();
|
||||
const snapshotEvaluate = vi.mocked(snapshotPage.evaluate);
|
||||
snapshotEvaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({
|
||||
session: 'site:gemini',
|
||||
data: {
|
||||
turns: [],
|
||||
transcriptLines: [],
|
||||
composerHasText: false,
|
||||
isGenerating: false,
|
||||
structuredTurnsTrusted: true,
|
||||
},
|
||||
});
|
||||
await expect(readGeminiSnapshot(snapshotPage)).resolves.toMatchObject({
|
||||
structuredTurnsTrusted: true,
|
||||
});
|
||||
});
|
||||
it('typed-fails malformed page snapshots', async () => {
|
||||
const page = createPageMock();
|
||||
const evaluate = vi.mocked(page.evaluate);
|
||||
evaluate
|
||||
.mockResolvedValueOnce('https://gemini.google.com/app')
|
||||
.mockResolvedValueOnce({
|
||||
turns: {},
|
||||
transcriptLines: [],
|
||||
composerHasText: false,
|
||||
isGenerating: false,
|
||||
structuredTurnsTrusted: true,
|
||||
});
|
||||
await expect(readGeminiSnapshot(page)).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
describe('pickGeminiDeepResearchExportUrl', () => {
|
||||
it('prefers docs.google.com document url over sheets and noise endpoints', () => {
|
||||
const picked = pickGeminiDeepResearchExportUrl([
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
// Gitee's logged-in cookie (gitee-session-n) is httpOnly and its exact name
|
||||
// rotates, so the poll uses a no-navigation API probe instead of a cookie gate.
|
||||
const WHOAMI_PROBE = `(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/v5/user', { credentials: 'include', headers: { Accept: 'application/json' } });
|
||||
if (r.status === 401 || r.status === 403) return { kind: 'auth', detail: 'Gitee /api/v5/user HTTP ' + r.status };
|
||||
if (!r.ok) return { kind: 'http', httpStatus: r.status };
|
||||
const d = await r.json();
|
||||
if (!d || !d.id || !d.login) return { kind: 'auth', detail: 'Gitee /api/v5/user has no id/login — anonymous' };
|
||||
return { ok: true, user_id: String(d.id), username: String(d.login), name: String(d.name || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`;
|
||||
|
||||
async function verifyGiteeIdentity(page) {
|
||||
await page.goto('https://gitee.com/');
|
||||
await page.wait(1);
|
||||
const probe = await page.evaluate(WHOAMI_PROBE);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('gitee.com', probe.detail);
|
||||
if (probe?.kind === 'http') throw new CommandExecutionError(`HTTP ${probe.httpStatus} from Gitee /api/v5/user`);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`Gitee whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Gitee probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, username: probe.username, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'gitee',
|
||||
domain: 'gitee.com',
|
||||
loginUrl: 'https://gitee.com/login',
|
||||
columns: ['user_id', 'username', 'name'],
|
||||
verify: verifyGiteeIdentity,
|
||||
poll: async (page) => {
|
||||
const probe = await page.evaluate(WHOAMI_PROBE);
|
||||
if (!probe?.ok) throw new AuthRequiredError('gitee.com', 'Waiting for Gitee login');
|
||||
return { user_id: probe.user_id, username: probe.username, name: probe.name };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AuthRequiredError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasGithubSessionCookies(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://github.com' });
|
||||
const names = new Set(cookies.map(cookie => cookie.name));
|
||||
return names.has('user_session') || names.has('dotcom_user') || names.has('logged_in');
|
||||
}
|
||||
|
||||
async function verifyGithubIdentity(page) {
|
||||
await page.goto('https://github.com/settings/profile');
|
||||
await page.wait(1);
|
||||
const identity = await page.evaluate(`() => {
|
||||
const meta = (name) => document.querySelector('meta[name="' + name + '"]')?.getAttribute('content') || '';
|
||||
const username = meta('octolytics-actor-login');
|
||||
const id = meta('octolytics-actor-id');
|
||||
const name = document.querySelector('input#user_profile_name')?.value || '';
|
||||
return { username, id, name, url: location.href };
|
||||
}`);
|
||||
if (!identity?.username || /\/login(?:\?|$)/.test(String(identity?.url ?? ''))) {
|
||||
throw new AuthRequiredError('github.com', 'Could not detect a logged-in GitHub account');
|
||||
}
|
||||
return {
|
||||
id: identity.id || '',
|
||||
username: identity.username,
|
||||
name: identity.name || '',
|
||||
url: `https://github.com/${identity.username}`,
|
||||
};
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'github',
|
||||
domain: 'github.com',
|
||||
loginUrl: 'https://github.com/login',
|
||||
columns: ['id', 'username', 'name', 'url'],
|
||||
quickCheck: hasGithubSessionCookies,
|
||||
verify: verifyGithubIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasGithubSessionCookies(page)) {
|
||||
throw new AuthRequiredError('github.com', 'Waiting for GitHub session cookies');
|
||||
}
|
||||
return verifyGithubIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasGrokSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://grok.com' });
|
||||
return cookies.some(c => c.name === '__Secure-next-auth.session-token' && c.value);
|
||||
}
|
||||
|
||||
async function verifyGrokIdentity(page) {
|
||||
if (!await hasGrokSessionCookie(page)) {
|
||||
throw new AuthRequiredError('grok.com', 'Grok __Secure-next-auth.session-token cookie missing');
|
||||
}
|
||||
await page.goto('https://grok.com/');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/session', { credentials: 'include', headers: { 'Accept': 'application/json' } });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'Grok /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: 'Grok /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('grok.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/auth/session`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`Grok whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected Grok probe: ${JSON.stringify(result)}`);
|
||||
return { user_id: result.user_id, name: result.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'grok',
|
||||
domain: 'grok.com',
|
||||
loginUrl: 'https://grok.com/auth/sign-in',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: hasGrokSessionCookie,
|
||||
verify: verifyGrokIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasGrokSessionCookie(page)) {
|
||||
throw new AuthRequiredError('grok.com', 'Waiting for Grok session cookie');
|
||||
}
|
||||
return verifyGrokIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
GROK_DOMAIN,
|
||||
ensureOnGrok,
|
||||
authRequired,
|
||||
isLoggedIn,
|
||||
parseGrokSessionId,
|
||||
clickConversationMenuItem,
|
||||
normalizeBooleanFlag,
|
||||
waitForConversationToDisappear,
|
||||
} from './utils.js';
|
||||
|
||||
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
|
||||
|
||||
cli({
|
||||
site: 'grok',
|
||||
name: 'delete',
|
||||
access: 'write',
|
||||
description: 'Delete a Grok conversation by ID. Grok takes effect immediately with no confirmation dialog — require --yes to actually delete.',
|
||||
domain: GROK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
args: [
|
||||
{ name: 'id', positional: true, type: 'string', required: true, help: 'Conversation UUID or grok.com/c/<uuid> URL' },
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually delete (default is a dry-run preview)' },
|
||||
],
|
||||
columns: ['status', 'id'],
|
||||
func: async (page, kwargs) => {
|
||||
const id = parseGrokSessionId(kwargs.id);
|
||||
const yes = normalizeBooleanFlag(kwargs.yes);
|
||||
|
||||
await ensureOnGrok(page);
|
||||
if (!(await isLoggedIn(page))) throw authRequired();
|
||||
|
||||
if (!yes) {
|
||||
return [{ status: 'dry-run (pass --yes to actually delete)', id }];
|
||||
}
|
||||
|
||||
const result = await clickConversationMenuItem(page, id, ['删除', 'delete']);
|
||||
if (!result || !result.ok) {
|
||||
const detail = result?.detail ? ` ${result.detail}` : '';
|
||||
throw new CommandExecutionError(`${result?.reason || 'Failed to click delete menu item.'}${detail}`, SESSION_HINT);
|
||||
}
|
||||
if (!(await waitForConversationToDisappear(page, id))) {
|
||||
throw new CommandExecutionError(
|
||||
'Delete menu item was clicked, but the conversation is still visible in the sidebar.',
|
||||
SESSION_HINT,
|
||||
);
|
||||
}
|
||||
return [{ status: 'deleted', id }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
normalizeConversationRows,
|
||||
normalizeManifestRows,
|
||||
requireBooleanEvaluateResult,
|
||||
requireObjectEvaluateResult,
|
||||
} from './export-utils.js';
|
||||
|
||||
const GROK_DOMAIN = 'grok.com';
|
||||
const GROK_URL = 'https://grok.com/';
|
||||
|
||||
function normalizeInteger(value, defaultValue, label, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n)) {
|
||||
throw new ArgumentError(label, `must be an integer`);
|
||||
}
|
||||
if (n < min) {
|
||||
throw new ArgumentError(label, `must be >= ${min}`);
|
||||
}
|
||||
if (n > max) {
|
||||
throw new ArgumentError(label, `must be <= ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function waitRandom(page, minMs, maxMs) {
|
||||
if (maxMs <= 0) return;
|
||||
const span = Math.max(0, maxMs - minMs);
|
||||
const ms = minMs + Math.floor(Math.random() * (span + 1));
|
||||
if (ms > 0) await page.wait(ms / 1000);
|
||||
}
|
||||
|
||||
function readManifest(manifestPath, { offset, limit }) {
|
||||
const path = String(manifestPath || '').trim();
|
||||
if (!path) return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new ArgumentError('manifestPath', `failed to read JSON manifest: ${error?.message || error}`);
|
||||
}
|
||||
const rows = normalizeManifestRows(parsed);
|
||||
const sliced = limit ? rows.slice(offset, offset + limit) : rows.slice(offset);
|
||||
if (!sliced.length) {
|
||||
throw new EmptyResultError('grok export-all', `No manifest rows after offset=${offset}, limit=${limit}`);
|
||||
}
|
||||
return sliced;
|
||||
}
|
||||
|
||||
async function collectHistory(page, { offset, limit, maxScrolls }) {
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(2);
|
||||
const rawResult = await page.evaluate(`(async () => {
|
||||
const targetLimit = ${JSON.stringify(limit > 0 ? offset + limit : 0)};
|
||||
const maxScrolls = ${JSON.stringify(maxScrolls)};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const isVisible = (node) => {
|
||||
if (!(node instanceof Element)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const hasHistoryEntry = Boolean(document.querySelector('a[href^="/c/"]'));
|
||||
const hasHistoryLauncher = Array.from(document.querySelectorAll('button, [role="button"]'))
|
||||
.some((node) => isVisible(node) && /^(查看全部|show all|view all)$/i.test((node.textContent || '').trim()));
|
||||
const signInCta = Array.from(document.querySelectorAll('button, a'))
|
||||
.some((node) => isVisible(node) && /^(sign in|log in)$/i.test((node.textContent || '').trim()));
|
||||
if (signInCta && !hasHistoryEntry && !hasHistoryLauncher) {
|
||||
return { ok: false, code: 'AUTH' };
|
||||
}
|
||||
|
||||
const clickAllHistory = () => {
|
||||
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'))
|
||||
.filter((node) => node instanceof HTMLElement && isVisible(node));
|
||||
const target = buttons.find((node) => /^(查看全部|show all|view all)$/i.test((node.textContent || '').trim()));
|
||||
if (!target) return false;
|
||||
target.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!document.querySelector('[role="listbox"] a[href^="/c/"]')) {
|
||||
clickAllHistory();
|
||||
}
|
||||
|
||||
let listbox = null;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
listbox = document.querySelector('[role="listbox"]');
|
||||
if (listbox && listbox.querySelector('a[href^="/c/"]')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
if (!listbox || !listbox.querySelector('a[href^="/c/"]')) {
|
||||
return { ok: false, code: 'NO_DIALOG' };
|
||||
}
|
||||
|
||||
const scroller = Array.from(listbox.querySelectorAll('*'))
|
||||
.find((node) => node.scrollHeight > node.clientHeight + 20) || listbox;
|
||||
const seen = new Map();
|
||||
const collect = () => {
|
||||
for (const a of Array.from(listbox.querySelectorAll('a[href^="/c/"]'))) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
const match = href.match(/^\\/c\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
||||
if (!match) continue;
|
||||
const id = match[1].toLowerCase();
|
||||
const option = a.closest('[role="option"]') || a.parentElement;
|
||||
const lines = (option?.innerText || option?.textContent || a.textContent || '')
|
||||
.split(/\\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
seen.set(id, {
|
||||
id,
|
||||
title: lines[0] || '',
|
||||
date: lines.slice(1).find(Boolean) || '',
|
||||
url: 'https://grok.com/c/' + id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
scroller.scrollTop = 0;
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
await sleep(250);
|
||||
|
||||
let lastCount = -1;
|
||||
let lastHeight = -1;
|
||||
let stableRounds = 0;
|
||||
for (let attempt = 0; attempt < maxScrolls; attempt += 1) {
|
||||
collect();
|
||||
if (targetLimit > 0 && seen.size >= targetLimit) break;
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
await sleep(350);
|
||||
collect();
|
||||
if (targetLimit > 0 && seen.size >= targetLimit) break;
|
||||
if (seen.size === lastCount && scroller.scrollHeight === lastHeight) {
|
||||
stableRounds += 1;
|
||||
} else {
|
||||
stableRounds = 0;
|
||||
lastCount = seen.size;
|
||||
lastHeight = scroller.scrollHeight;
|
||||
}
|
||||
if (targetLimit === 0 && stableRounds >= 8) break;
|
||||
}
|
||||
|
||||
return { ok: true, rows: Array.from(seen.values()) };
|
||||
})()`);
|
||||
|
||||
const result = requireObjectEvaluateResult(rawResult, 'grok export-all history dialog');
|
||||
if (result.ok !== true) {
|
||||
if (result?.code === 'AUTH') {
|
||||
throw new AuthRequiredError(GROK_DOMAIN, 'Sign in to grok.com in the browser, then retry.');
|
||||
}
|
||||
if (result?.code === 'NO_DIALOG') {
|
||||
throw new TimeoutError('grok export-all history dialog', 8);
|
||||
}
|
||||
throw new EmptyResultError('grok export-all', 'No Grok conversation history was visible.');
|
||||
}
|
||||
const validRows = normalizeConversationRows(result.rows, 'grok export-all history dialog');
|
||||
if (!validRows.length) {
|
||||
throw new EmptyResultError('grok export-all', 'No Grok conversations found in the signed-in account history.');
|
||||
}
|
||||
return limit ? validRows.slice(offset, offset + limit) : validRows.slice(offset);
|
||||
}
|
||||
|
||||
async function readConversation(page, conversation, { pageTimeoutMs, pageScrolls, delayMinMs, delayMaxMs }) {
|
||||
await page.goto(conversation.url);
|
||||
const startedAt = Date.now();
|
||||
let loaded = false;
|
||||
|
||||
while (Date.now() - startedAt < pageTimeoutMs) {
|
||||
let loadedPayload;
|
||||
try {
|
||||
loadedPayload = await page.evaluate(`(() => {
|
||||
return Boolean(document.querySelector('[data-testid="user-message"], [data-testid="assistant-message"]'));
|
||||
})()`);
|
||||
loaded = requireBooleanEvaluateResult(loadedPayload, 'grok export-all page load check');
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: `Page load check failed: ${error?.message || error}`,
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
if (loaded) break;
|
||||
await page.wait(1);
|
||||
}
|
||||
if (!loaded) {
|
||||
return {
|
||||
status: 'empty',
|
||||
error: 'No visible message bubbles after waiting for page load.',
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
|
||||
await waitRandom(page, delayMinMs, delayMaxMs);
|
||||
|
||||
let rawResult;
|
||||
let result;
|
||||
try {
|
||||
rawResult = await page.evaluate(`(async () => {
|
||||
const maxScrolls = ${JSON.stringify(pageScrolls)};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const isVisible = (node) => {
|
||||
if (!(node instanceof Element)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const messageSelector = '[data-testid="user-message"], [data-testid="assistant-message"]';
|
||||
const findScrollableAncestors = () => {
|
||||
const first = document.querySelector(messageSelector);
|
||||
const out = [document.scrollingElement || document.documentElement];
|
||||
let node = first ? first.parentElement : null;
|
||||
while (node && node !== document.body) {
|
||||
if (node.scrollHeight > node.clientHeight + 20) out.push(node);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return Array.from(new Set(out));
|
||||
};
|
||||
const scrollables = findScrollableAncestors();
|
||||
const countMessages = () => Array.from(document.querySelectorAll(messageSelector)).filter(isVisible).length;
|
||||
|
||||
let lastCount = -1;
|
||||
let lastBottom = -1;
|
||||
let stableRounds = 0;
|
||||
for (let attempt = 0; attempt < maxScrolls && stableRounds < 5; attempt += 1) {
|
||||
for (const scroller of scrollables) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
}
|
||||
window.scrollTo(0, document.documentElement.scrollHeight || document.body.scrollHeight);
|
||||
await sleep(300);
|
||||
const count = countMessages();
|
||||
const bottom = Math.max(
|
||||
document.documentElement.scrollHeight || 0,
|
||||
document.body.scrollHeight || 0,
|
||||
...scrollables.map((node) => node.scrollHeight || 0),
|
||||
);
|
||||
if (count === lastCount && bottom === lastBottom) {
|
||||
stableRounds += 1;
|
||||
} else {
|
||||
stableRounds = 0;
|
||||
lastCount = count;
|
||||
lastBottom = bottom;
|
||||
}
|
||||
}
|
||||
|
||||
const findResponseId = (node) => {
|
||||
let parent = node.parentElement;
|
||||
while (parent && parent !== document.body) {
|
||||
const id = parent.getAttribute('id') || '';
|
||||
if (id.startsWith('response-')) return id.slice('response-'.length);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const messages = [];
|
||||
let position = 0;
|
||||
for (const node of Array.from(document.querySelectorAll(messageSelector))) {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) continue;
|
||||
const isAssistant = node.getAttribute('data-testid') === 'assistant-message';
|
||||
const text = (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
const html = node.innerHTML || '';
|
||||
if (!text && !html) continue;
|
||||
messages.push({
|
||||
messageIndex: messages.length + 1,
|
||||
messageId: findResponseId(node) || ('pos-' + position),
|
||||
messageRole: isAssistant ? 'assistant' : 'user',
|
||||
messageText: text,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
return { messages };
|
||||
})()`);
|
||||
result = requireObjectEvaluateResult(rawResult, 'grok export-all conversation reader');
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: `Conversation reader failed: ${error?.message || error}`,
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(result.messages)) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: 'Conversation reader returned malformed message rows.',
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
const messages = [];
|
||||
for (let index = 0; index < result.messages.length; index += 1) {
|
||||
const message = result.messages[index];
|
||||
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: `Conversation reader returned malformed message row ${index + 1}.`,
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
const messageId = String(message.messageId || '').trim();
|
||||
const messageText = String(message.messageText || '').trim();
|
||||
if (!messageId || !messageText || (message.messageRole !== 'assistant' && message.messageRole !== 'user')) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: `Conversation reader returned malformed message row ${index + 1}.`,
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
messages.push({
|
||||
messageIndex: Number.isInteger(message.messageIndex) ? message.messageIndex : index + 1,
|
||||
messageId,
|
||||
messageRole: message.messageRole,
|
||||
messageText,
|
||||
});
|
||||
}
|
||||
if (!messages.length) {
|
||||
return {
|
||||
status: 'empty',
|
||||
error: 'Page loaded, but no visible message text was found after scrolling to bottom.',
|
||||
messageCount: 0,
|
||||
messagesJson: '[]',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
error: null,
|
||||
messageCount: messages.length,
|
||||
messagesJson: JSON.stringify(messages),
|
||||
};
|
||||
}
|
||||
|
||||
export const grokExportAllCommand = cli({
|
||||
site: 'grok',
|
||||
name: 'export-all',
|
||||
description: 'Export Grok conversation history and each conversation transcript',
|
||||
access: 'read',
|
||||
example: 'opencli grok export-all --limit 5 -f json',
|
||||
domain: GROK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 0, help: 'Max conversations to export; 0 means all loaded history' },
|
||||
{ name: 'offset', type: 'int', default: 0, help: 'Skip this many conversations before exporting' },
|
||||
{ name: 'manifestPath', type: 'string', default: '', help: 'Optional grok/export JSON manifest path; skips history dialog and visits listed /c pages directly' },
|
||||
{ name: 'maxScrolls', type: 'int', default: 80, help: 'Max history-list scroll rounds when limit is 0 (max 500)' },
|
||||
{ name: 'pageScrolls', type: 'int', default: 30, help: 'Max per-conversation scroll-to-bottom rounds (max 200)' },
|
||||
{ name: 'pageTimeoutMs', type: 'int', default: 30000, help: 'Max wait for each conversation page to show messages' },
|
||||
{ name: 'delayMinMs', type: 'int', default: 0, help: 'Minimum polite delay after a conversation page loads' },
|
||||
{ name: 'delayMaxMs', type: 'int', default: 5000, help: 'Maximum polite delay after a conversation page loads' },
|
||||
],
|
||||
columns: ['index', 'id', 'title', 'date', 'url', 'status', 'messageCount', 'error', 'messagesJson'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = normalizeInteger(kwargs.limit, 0, 'limit', { min: 0 });
|
||||
const offset = normalizeInteger(kwargs.offset, 0, 'offset', { min: 0 });
|
||||
const maxScrolls = normalizeInteger(kwargs.maxScrolls, 80, 'maxScrolls', { min: 1, max: 500 });
|
||||
const pageScrolls = normalizeInteger(kwargs.pageScrolls, 30, 'pageScrolls', { min: 1, max: 200 });
|
||||
const pageTimeoutMs = normalizeInteger(kwargs.pageTimeoutMs, 30000, 'pageTimeoutMs', { min: 5000, max: 180000 });
|
||||
const delayMinMs = normalizeInteger(kwargs.delayMinMs, 0, 'delayMinMs', { min: 0, max: 60000 });
|
||||
const delayMaxMs = normalizeInteger(kwargs.delayMaxMs, 5000, 'delayMaxMs', { min: 0, max: 60000 });
|
||||
if (delayMaxMs < delayMinMs) {
|
||||
throw new ArgumentError('delayMaxMs', 'must be >= delayMinMs');
|
||||
}
|
||||
|
||||
const conversations = readManifest(kwargs.manifestPath, { offset, limit })
|
||||
|| await collectHistory(page, { offset, limit, maxScrolls });
|
||||
const rows = [];
|
||||
for (let i = 0; i < conversations.length; i += 1) {
|
||||
const conversation = conversations[i];
|
||||
const transcript = await readConversation(page, conversation, {
|
||||
pageTimeoutMs,
|
||||
pageScrolls,
|
||||
delayMinMs,
|
||||
delayMaxMs,
|
||||
});
|
||||
rows.push({
|
||||
index: offset + i + 1,
|
||||
id: conversation.id,
|
||||
title: conversation.title || null,
|
||||
date: conversation.date || null,
|
||||
url: conversation.url,
|
||||
status: transcript.status,
|
||||
messageCount: transcript.messageCount,
|
||||
error: transcript.error,
|
||||
messagesJson: transcript.messagesJson,
|
||||
});
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
throw new EmptyResultError('grok export-all', 'No Grok conversations were exported.');
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeInteger,
|
||||
readManifest,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const GROK_CONVERSATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function unwrapEvaluateResult(value) {
|
||||
if (
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& !Array.isArray(value)
|
||||
&& Object.hasOwn(value, 'session')
|
||||
&& Object.hasOwn(value, 'data')
|
||||
) {
|
||||
return value.data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireObjectEvaluateResult(value, label) {
|
||||
const payload = unwrapEvaluateResult(value);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new CommandExecutionError(`${label} returned a malformed payload`, 'Expected an object payload from the Grok page.');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function requireBooleanEvaluateResult(value, label) {
|
||||
const payload = unwrapEvaluateResult(value);
|
||||
if (typeof payload !== 'boolean') {
|
||||
throw new CommandExecutionError(`${label} returned a malformed payload`, 'Expected a boolean payload from the Grok page.');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function normalizeGrokUrl(value, id, makeError) {
|
||||
const fallback = `https://grok.com/c/${id}`;
|
||||
const raw = value == null || value === '' ? fallback : String(value);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw makeError(`invalid url for conversation ${id}`);
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const match = parsed.pathname.match(/^\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i);
|
||||
if (parsed.protocol !== 'https:' || (host !== 'grok.com' && !host.endsWith('.grok.com')) || !match) {
|
||||
throw makeError(`invalid url for conversation ${id}`);
|
||||
}
|
||||
if (match[1].toLowerCase() !== id) {
|
||||
throw makeError(`url id mismatch for conversation ${id}`);
|
||||
}
|
||||
return `https://grok.com/c/${id}`;
|
||||
}
|
||||
|
||||
export function normalizeConversationRows(rows, label) {
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new CommandExecutionError(`${label} returned malformed rows`, 'Expected rows to be an array.');
|
||||
}
|
||||
return rows.map((row, index) => {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||||
throw new CommandExecutionError(`${label} returned a malformed row`, `Row ${index + 1} is not an object.`);
|
||||
}
|
||||
const id = String(row.id || '').trim().toLowerCase();
|
||||
if (!GROK_CONVERSATION_ID_RE.test(id)) {
|
||||
throw new CommandExecutionError(`${label} returned a malformed row`, `Row ${index + 1} is missing a valid Grok conversation id.`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: row.title == null || row.title === '' ? '' : String(row.title),
|
||||
date: row.date == null || row.date === '' ? '' : String(row.date),
|
||||
url: normalizeGrokUrl(row.url, id, (reason) => new CommandExecutionError(`${label} returned a malformed row`, reason)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeManifestRows(rows) {
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new ArgumentError('manifestPath', 'must point to a JSON array exported by grok/export');
|
||||
}
|
||||
return rows.map((row, index) => {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||||
throw new ArgumentError('manifestPath', `row ${index + 1} must be an object`);
|
||||
}
|
||||
const id = String(row.id || '').trim().toLowerCase();
|
||||
if (!GROK_CONVERSATION_ID_RE.test(id)) {
|
||||
throw new ArgumentError('manifestPath', `row ${index + 1} is missing a valid Grok conversation id`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: row.title == null || row.title === '' ? '' : String(row.title),
|
||||
date: row.date == null || row.date === '' ? '' : String(row.date),
|
||||
url: normalizeGrokUrl(row.url, id, (reason) => new ArgumentError('manifestPath', `row ${index + 1}: ${reason}`)),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors';
|
||||
import { normalizeConversationRows, requireObjectEvaluateResult } from './export-utils.js';
|
||||
|
||||
const GROK_DOMAIN = 'grok.com';
|
||||
const GROK_URL = 'https://grok.com/';
|
||||
|
||||
function normalizeLimit(value) {
|
||||
const raw = value ?? 0;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new ArgumentError('limit', 'must be 0 or a positive integer');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeMaxScrolls(value) {
|
||||
const raw = value ?? 80;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new ArgumentError('maxScrolls', 'must be a positive integer');
|
||||
}
|
||||
if (n > 500) {
|
||||
throw new ArgumentError('maxScrolls', 'must be <= 500');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export const grokExportCommand = cli({
|
||||
site: 'grok',
|
||||
name: 'export',
|
||||
description: 'Export all visible Grok conversation history metadata',
|
||||
access: 'read',
|
||||
example: 'opencli grok export -f yaml',
|
||||
domain: GROK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 0, help: 'Max conversations to export; 0 means all loaded history' },
|
||||
{ name: 'maxScrolls', type: 'int', default: 80, help: 'Max history-list scroll rounds when limit is 0 (max 500)' },
|
||||
],
|
||||
columns: ['index', 'id', 'title', 'date', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = normalizeLimit(kwargs.limit);
|
||||
const maxScrolls = normalizeMaxScrolls(kwargs.maxScrolls);
|
||||
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(2);
|
||||
|
||||
const rawResult = await page.evaluate(`(async () => {
|
||||
const targetLimit = ${JSON.stringify(limit)};
|
||||
const maxScrolls = ${JSON.stringify(maxScrolls)};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const isVisible = (node) => {
|
||||
if (!(node instanceof Element)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const hasHistoryEntry = Boolean(document.querySelector('a[href^="/c/"]'));
|
||||
const hasHistoryLauncher = Array.from(document.querySelectorAll('button, [role="button"]'))
|
||||
.some((node) => isVisible(node) && /^(查看全部|show all|view all)$/i.test((node.textContent || '').trim()));
|
||||
const signInCta = Array.from(document.querySelectorAll('button, a'))
|
||||
.some((node) => isVisible(node) && /^(sign in|log in)$/i.test((node.textContent || '').trim()));
|
||||
if (signInCta && !hasHistoryEntry && !hasHistoryLauncher) {
|
||||
return { ok: false, code: 'AUTH' };
|
||||
}
|
||||
|
||||
const clickAllHistory = () => {
|
||||
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'))
|
||||
.filter((node) => node instanceof HTMLElement && isVisible(node));
|
||||
const target = buttons.find((node) => /^(查看全部|show all|view all)$/i.test((node.textContent || '').trim()));
|
||||
if (!target) return false;
|
||||
target.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!document.querySelector('[role="listbox"] a[href^="/c/"]')) {
|
||||
clickAllHistory();
|
||||
}
|
||||
|
||||
let listbox = null;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
listbox = document.querySelector('[role="listbox"]');
|
||||
if (listbox && listbox.querySelector('a[href^="/c/"]')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
if (!listbox || !listbox.querySelector('a[href^="/c/"]')) {
|
||||
return { ok: false, code: 'NO_DIALOG' };
|
||||
}
|
||||
|
||||
const findScroller = () => {
|
||||
const nodes = Array.from(listbox.querySelectorAll('*'));
|
||||
return nodes.find((node) => node.scrollHeight > node.clientHeight + 20) || listbox;
|
||||
};
|
||||
const scroller = findScroller();
|
||||
const seen = new Map();
|
||||
const collect = () => {
|
||||
const anchors = Array.from(listbox.querySelectorAll('a[href^="/c/"]'));
|
||||
for (const a of anchors) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
const match = href.match(/^\\/c\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
||||
if (!match) continue;
|
||||
const id = match[1].toLowerCase();
|
||||
const option = a.closest('[role="option"]') || a.parentElement;
|
||||
const lines = (option?.innerText || option?.textContent || a.textContent || '')
|
||||
.split(/\\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
seen.set(id, {
|
||||
id,
|
||||
title: lines[0] || '',
|
||||
date: lines.slice(1).find(Boolean) || '',
|
||||
url: 'https://grok.com/c/' + id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
scroller.scrollTop = 0;
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
await sleep(250);
|
||||
|
||||
let lastCount = -1;
|
||||
let stableRounds = 0;
|
||||
let lastScrollHeight = -1;
|
||||
const stableTarget = targetLimit > 0 ? 5 : 8;
|
||||
const settleMs = targetLimit > 0 ? 300 : 350;
|
||||
const maxAttempts = targetLimit > 0 ? maxScrolls : Math.min(maxScrolls, 500);
|
||||
for (let attempt = 0; attempt < maxAttempts && stableRounds < stableTarget; attempt += 1) {
|
||||
collect();
|
||||
if (targetLimit > 0 && seen.size >= targetLimit) break;
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
await sleep(settleMs);
|
||||
collect();
|
||||
if (targetLimit > 0 && seen.size >= targetLimit) break;
|
||||
const count = seen.size;
|
||||
const height = scroller.scrollHeight;
|
||||
if (count === lastCount && height === lastScrollHeight) {
|
||||
stableRounds += 1;
|
||||
} else {
|
||||
stableRounds = 0;
|
||||
lastCount = count;
|
||||
lastScrollHeight = height;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rows: Array.from(seen.values()),
|
||||
scrollTop: scroller.scrollTop,
|
||||
scrollHeight: scroller.scrollHeight,
|
||||
};
|
||||
})()`);
|
||||
|
||||
const result = requireObjectEvaluateResult(rawResult, 'grok export history dialog');
|
||||
if (result.ok !== true) {
|
||||
if (result?.code === 'AUTH') {
|
||||
throw new AuthRequiredError(GROK_DOMAIN, 'Sign in to grok.com in the browser, then retry.');
|
||||
}
|
||||
if (result?.code === 'NO_DIALOG') {
|
||||
throw new TimeoutError('grok export history dialog', 8);
|
||||
}
|
||||
throw new EmptyResultError('grok export', 'No Grok conversation history was visible.');
|
||||
}
|
||||
|
||||
const validRows = normalizeConversationRows(result.rows, 'grok export history dialog');
|
||||
if (!validRows.length) {
|
||||
throw new EmptyResultError('grok export', 'No Grok conversations found in the signed-in account history.');
|
||||
}
|
||||
|
||||
const slicedRows = limit ? validRows.slice(0, limit) : validRows;
|
||||
return slicedRows.map((row, i) => ({
|
||||
index: i + 1,
|
||||
id: row.id,
|
||||
title: row.title || null,
|
||||
date: row.date || null,
|
||||
url: row.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeLimit,
|
||||
normalizeMaxScrolls,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { __test__ as exportTest, grokExportCommand } from './export.js';
|
||||
import { __test__ as exportAllTest, grokExportAllCommand } from './export-all.js';
|
||||
import {
|
||||
normalizeConversationRows,
|
||||
normalizeManifestRows,
|
||||
requireObjectEvaluateResult,
|
||||
} from './export-utils.js';
|
||||
|
||||
const ID = '7c4197f2-10a1-4ebb-a84a-fea89f4f1d06';
|
||||
const ID2 = '8c4197f2-10a1-4ebb-a84a-fea89f4f1d07';
|
||||
|
||||
function makePage(evaluateResults) {
|
||||
const queue = [...evaluateResults];
|
||||
return {
|
||||
gotos: [],
|
||||
waits: [],
|
||||
async goto(url) {
|
||||
this.gotos.push(url);
|
||||
},
|
||||
async wait(seconds) {
|
||||
this.waits.push(seconds);
|
||||
},
|
||||
async evaluate() {
|
||||
if (!queue.length) throw new Error('unexpected evaluate call');
|
||||
const next = queue.shift();
|
||||
if (next instanceof Error) throw next;
|
||||
return next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('grok export helpers', () => {
|
||||
it('validates export arguments without silent clamps', () => {
|
||||
expect(exportTest.normalizeLimit(0)).toBe(0);
|
||||
expect(exportTest.normalizeLimit(25)).toBe(25);
|
||||
expect(() => exportTest.normalizeLimit(-1)).toThrow(ArgumentError);
|
||||
expect(() => exportTest.normalizeLimit(1.5)).toThrow(ArgumentError);
|
||||
expect(exportTest.normalizeMaxScrolls(undefined)).toBe(80);
|
||||
expect(() => exportTest.normalizeMaxScrolls(0)).toThrow(ArgumentError);
|
||||
expect(() => exportTest.normalizeMaxScrolls(501)).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('unwraps Browser Bridge evaluate envelopes', () => {
|
||||
expect(requireObjectEvaluateResult({ session: 's1', data: { ok: true } }, 'label')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('typed-fails malformed history rows instead of filtering them to empty', () => {
|
||||
expect(() => normalizeConversationRows({}, 'grok export')).toThrow(CommandExecutionError);
|
||||
expect(() => normalizeConversationRows([{ title: 'missing id' }], 'grok export')).toThrow(CommandExecutionError);
|
||||
expect(() => normalizeConversationRows([{ id: ID, url: `https://evil.com/c/${ID}` }], 'grok export')).toThrow(CommandExecutionError);
|
||||
expect(() => normalizeConversationRows([{ id: ID, url: `https://grok.com/c/${ID2}` }], 'grok export')).toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('normalizes valid history rows', () => {
|
||||
expect(normalizeConversationRows([{ id: ID.toUpperCase(), title: 123, date: null }], 'grok export')).toEqual([
|
||||
{ id: ID, title: '123', date: '', url: `https://grok.com/c/${ID}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates manifest rows as input arguments', () => {
|
||||
expect(() => normalizeManifestRows({})).toThrow(ArgumentError);
|
||||
expect(() => normalizeManifestRows([{ id: 'bad' }])).toThrow(ArgumentError);
|
||||
expect(normalizeManifestRows([{ id: ID, title: 'Hello' }])).toEqual([
|
||||
{ id: ID, title: 'Hello', date: '', url: `https://grok.com/c/${ID}` },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grok export command', () => {
|
||||
it('maps Browser Bridge history payload to rows', async () => {
|
||||
const page = makePage([
|
||||
{ session: 'browser', data: { ok: true, rows: [{ id: ID, title: 'T', date: 'Today' }] } },
|
||||
]);
|
||||
const rows = await grokExportCommand.func(page, { limit: 1, maxScrolls: 3 });
|
||||
expect(rows).toEqual([{ index: 1, id: ID, title: 'T', date: 'Today', url: `https://grok.com/c/${ID}` }]);
|
||||
});
|
||||
|
||||
it('typed-fails malformed history payloads', async () => {
|
||||
await expect(grokExportCommand.func(makePage([{ ok: true, rows: [{ title: 'missing id' }] }]), {}))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('keeps true empty history as EmptyResultError', async () => {
|
||||
await expect(grokExportCommand.func(makePage([{ ok: true, rows: [] }]), {}))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grok export-all command', () => {
|
||||
let tempDir = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
function writeManifest(rows) {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'opencli-grok-export-test-'));
|
||||
const path = join(tempDir, 'manifest.json');
|
||||
writeFileSync(path, JSON.stringify(rows), 'utf8');
|
||||
return path;
|
||||
}
|
||||
|
||||
it('reads and slices a valid manifest', () => {
|
||||
const path = writeManifest([
|
||||
{ id: ID, title: 'One' },
|
||||
{ id: ID2, title: 'Two' },
|
||||
]);
|
||||
expect(exportAllTest.readManifest(path, { offset: 1, limit: 1 })).toEqual([
|
||||
{ id: ID2, title: 'Two', date: '', url: `https://grok.com/c/${ID2}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid manifest rows instead of silently dropping them', () => {
|
||||
const path = writeManifest([{ title: 'missing id' }]);
|
||||
expect(() => exportAllTest.readManifest(path, { offset: 0, limit: 0 })).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('exports transcript rows from a manifest and unwraps page evaluate envelopes', async () => {
|
||||
const path = writeManifest([{ id: ID, title: 'One' }]);
|
||||
const page = makePage([
|
||||
true,
|
||||
{
|
||||
session: 'browser',
|
||||
data: {
|
||||
messages: [
|
||||
{ messageIndex: 1, messageId: 'u1', messageRole: 'user', messageText: 'Hello' },
|
||||
{ messageIndex: 2, messageId: 'a1', messageRole: 'assistant', messageText: 'Hi' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const rows = await grokExportAllCommand.func(page, { manifestPath: path, limit: 0, offset: 0, pageScrolls: 1 });
|
||||
expect(rows).toEqual([{
|
||||
index: 1,
|
||||
id: ID,
|
||||
title: 'One',
|
||||
date: null,
|
||||
url: `https://grok.com/c/${ID}`,
|
||||
status: 'ok',
|
||||
messageCount: 2,
|
||||
error: null,
|
||||
messagesJson: JSON.stringify([
|
||||
{ messageIndex: 1, messageId: 'u1', messageRole: 'user', messageText: 'Hello' },
|
||||
{ messageIndex: 2, messageId: 'a1', messageRole: 'assistant', messageText: 'Hi' },
|
||||
]),
|
||||
}]);
|
||||
});
|
||||
|
||||
it('records per-conversation failed status for malformed transcript payloads', async () => {
|
||||
const path = writeManifest([{ id: ID, title: 'One' }]);
|
||||
const page = makePage([true, { messages: 'bad' }]);
|
||||
const rows = await grokExportAllCommand.func(page, { manifestPath: path, limit: 0, offset: 0, pageScrolls: 1 });
|
||||
expect(rows[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
messageCount: 0,
|
||||
error: 'Conversation reader returned malformed message rows.',
|
||||
messagesJson: '[]',
|
||||
});
|
||||
});
|
||||
|
||||
it('records per-conversation failed status for malformed page-load checks', async () => {
|
||||
const path = writeManifest([{ id: ID, title: 'One' }]);
|
||||
const page = makePage([{ session: 'browser', data: { loaded: true } }]);
|
||||
const rows = await grokExportAllCommand.func(page, {
|
||||
manifestPath: path,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
pageScrolls: 1,
|
||||
pageTimeoutMs: 5000,
|
||||
});
|
||||
expect(rows[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
messageCount: 0,
|
||||
error: expect.stringContaining('Page load check failed:'),
|
||||
messagesJson: '[]',
|
||||
});
|
||||
});
|
||||
|
||||
it('records per-conversation failed status for malformed transcript envelopes', async () => {
|
||||
const path = writeManifest([{ id: ID, title: 'One' }]);
|
||||
const page = makePage([true, { session: 'browser', data: [] }]);
|
||||
const rows = await grokExportAllCommand.func(page, { manifestPath: path, limit: 0, offset: 0, pageScrolls: 1 });
|
||||
expect(rows[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
messageCount: 0,
|
||||
error: expect.stringContaining('Conversation reader failed:'),
|
||||
messagesJson: '[]',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not silently drop malformed transcript rows into a partial success', async () => {
|
||||
const path = writeManifest([{ id: ID, title: 'One' }]);
|
||||
const page = makePage([true, { messages: [{ messageId: '', messageRole: 'assistant', messageText: 'bad' }] }]);
|
||||
const rows = await grokExportAllCommand.func(page, { manifestPath: path, limit: 0, offset: 0, pageScrolls: 1 });
|
||||
expect(rows[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
messageCount: 0,
|
||||
error: 'Conversation reader returned malformed message row 1.',
|
||||
messagesJson: '[]',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
GROK_DOMAIN,
|
||||
ensureOnGrok,
|
||||
authRequired,
|
||||
isLoggedIn,
|
||||
parseGrokSessionId,
|
||||
clickConversationMenuItem,
|
||||
getPinStateFromMenuLabels,
|
||||
readConversationMenuLabels,
|
||||
waitForConversationPinState,
|
||||
} from './utils.js';
|
||||
|
||||
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
|
||||
|
||||
function defineToggle(name, accessLabels) {
|
||||
cli({
|
||||
site: 'grok',
|
||||
name,
|
||||
access: 'write',
|
||||
description: `${name === 'pin' ? 'Pin' : 'Unpin'} a Grok conversation by ID`,
|
||||
domain: GROK_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
args: [
|
||||
{ name: 'id', positional: true, type: 'string', required: true, help: 'Conversation UUID or grok.com/c/<uuid> URL' },
|
||||
],
|
||||
columns: ['status', 'id'],
|
||||
func: async (page, kwargs) => {
|
||||
const id = parseGrokSessionId(kwargs.id);
|
||||
await ensureOnGrok(page);
|
||||
if (!(await isLoggedIn(page))) throw authRequired();
|
||||
|
||||
const expectedState = name === 'pin' ? 'pinned' : 'unpinned';
|
||||
const before = await readConversationMenuLabels(page, id);
|
||||
if (!before.ok) {
|
||||
const detail = before.detail ? ` ${before.detail}` : '';
|
||||
throw new CommandExecutionError(`${before.reason || `Failed to inspect ${name} state.`}${detail}`, SESSION_HINT);
|
||||
}
|
||||
if (getPinStateFromMenuLabels(before.labels) === expectedState) {
|
||||
return [{ status: `already-${expectedState}`, id }];
|
||||
}
|
||||
|
||||
const result = await clickConversationMenuItem(page, id, accessLabels);
|
||||
if (!result || !result.ok) {
|
||||
const detail = result?.detail ? ` ${result.detail}` : '';
|
||||
throw new CommandExecutionError(`${result?.reason || `Failed to ${name} conversation.`}${detail}`, SESSION_HINT);
|
||||
}
|
||||
const verified = await waitForConversationPinState(page, id, expectedState);
|
||||
if (!verified.ok) {
|
||||
const labels = verified.labels?.length ? ` labels=${JSON.stringify(verified.labels)}` : '';
|
||||
throw new CommandExecutionError(
|
||||
`${name} menu item was clicked, but the conversation did not verify as ${expectedState}.${labels}`,
|
||||
SESSION_HINT,
|
||||
);
|
||||
}
|
||||
return [{ status: name === 'pin' ? 'pinned' : 'unpinned', id }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Grok's context menu shows EITHER "置顶" OR "取消置顶" depending on the
|
||||
// current pin state, never both. We register two commands that bind to
|
||||
// the matching label so callers can use whichever they want.
|
||||
defineToggle('pin', ['置顶', 'pin']);
|
||||
defineToggle('unpin', ['取消置顶', 'unpin']);
|
||||
+326
-12
@@ -106,11 +106,26 @@ export async function getCurrentSessionId(page) {
|
||||
return match ? match[1].toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Model picker trigger has stable id="model-select-trigger". Use that as the
|
||||
// primary selector — aria-label localizes (e.g. "Model select" in English,
|
||||
// "模型选择" in Chinese, etc.) and is unreliable across browser locales.
|
||||
const MODEL_TRIGGER_SELECTORS = [
|
||||
'#model-select-trigger',
|
||||
'button[aria-label="Model select"]',
|
||||
'button[aria-label="模型选择"]',
|
||||
'button[aria-label="モデル選択"]',
|
||||
];
|
||||
|
||||
export async function getModelLabel(page) {
|
||||
const selectorJson = JSON.stringify(MODEL_TRIGGER_SELECTORS);
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const trigger = Array.from(document.querySelectorAll('button[aria-label="Model select"]'))
|
||||
.find((node) => isVisible(node));
|
||||
const selectors = ${selectorJson};
|
||||
let trigger = null;
|
||||
for (const sel of selectors) {
|
||||
trigger = Array.from(document.querySelectorAll(sel)).find((node) => isVisible(node));
|
||||
if (trigger) break;
|
||||
}
|
||||
if (!trigger) return '';
|
||||
return (trigger.innerText || trigger.textContent || '').trim().split('\\n')[0].trim();
|
||||
})()`);
|
||||
@@ -225,11 +240,269 @@ export async function startNewChat(page) {
|
||||
await page.wait(2);
|
||||
}
|
||||
|
||||
// Open the sidebar conversation context menu (right-click on the /c/<id>
|
||||
// link), wait for it to appear, click the menu item whose visible text
|
||||
// matches one of the localized labels. Returns whether the click happened.
|
||||
//
|
||||
// Menu items observed on grok.com (2026-05-31):
|
||||
// "打开新标签页" / "Open in new tab"
|
||||
// "重命名" / "Rename"
|
||||
// "置顶" or "取消置顶" / "Pin" or "Unpin"
|
||||
// "删除" / "Delete"
|
||||
//
|
||||
// Grok's delete action takes effect IMMEDIATELY — no confirmation dialog —
|
||||
// so callers must enforce their own --yes / dry-run gating.
|
||||
export async function clickConversationMenuItem(page, conversationId, labelOptions) {
|
||||
const id = String(conversationId).toLowerCase();
|
||||
const idJson = JSON.stringify(id);
|
||||
const labelsJson = JSON.stringify(labelOptions.map((l) => l.toLowerCase()));
|
||||
return await page.evaluate(`(async () => {
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const id = ${idJson};
|
||||
const labels = ${labelsJson};
|
||||
// Find the sidebar anchor for this conversation.
|
||||
let link = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
link = Array.from(document.querySelectorAll('a[href^="/c/"]'))
|
||||
.find((a) => a instanceof HTMLElement && a.offsetParent && (a.getAttribute('href') || '').toLowerCase().includes(id));
|
||||
if (link) break;
|
||||
await waitFor(300);
|
||||
}
|
||||
if (!link) {
|
||||
return { ok: false, reason: 'Conversation not found in sidebar.', detail: 'id=' + id };
|
||||
}
|
||||
// Trigger the radix context menu — radix listens to PointerEvent +
|
||||
// contextmenu, so plain MouseEvent('contextmenu') alone is ignored.
|
||||
// Dispatch the full sequence pointerdown/mousedown/contextmenu/pointerup/mouseup
|
||||
// with right-button state to mirror a real right-click.
|
||||
{
|
||||
const rect = link.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 2, buttons: 2,
|
||||
clientX: Math.round(rect.left + Math.min(rect.width / 2, 16)),
|
||||
clientY: Math.round(rect.top + Math.min(rect.height / 2, 16)),
|
||||
};
|
||||
link.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
link.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
link.dispatchEvent(new MouseEvent('contextmenu', init));
|
||||
link.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
link.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
}
|
||||
// Wait for menu items to appear.
|
||||
let items = [];
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
items = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (items.length) break;
|
||||
await waitFor(150);
|
||||
}
|
||||
if (!items.length) {
|
||||
return { ok: false, reason: 'Context menu did not open for the conversation.' };
|
||||
}
|
||||
const target = items.find((it) => {
|
||||
const text = (it.textContent || '').trim().toLowerCase();
|
||||
return labels.some((l) => text === l);
|
||||
});
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'No menu item matched the requested label.',
|
||||
detail: 'available=' + JSON.stringify(items.map((it) => (it.textContent || '').trim())),
|
||||
};
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, clicked: (target.textContent || '').trim() };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export function getPinStateFromMenuLabels(labels) {
|
||||
const normalized = (Array.isArray(labels) ? labels : [])
|
||||
.map((label) => String(label || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (normalized.some((label) => label === '取消置顶' || label === 'unpin')) {
|
||||
return 'pinned';
|
||||
}
|
||||
if (normalized.some((label) => label === '置顶' || label === 'pin')) {
|
||||
return 'unpinned';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function isConversationVisibleInSidebar(page, conversationId) {
|
||||
const id = String(conversationId).toLowerCase();
|
||||
const idJson = JSON.stringify(id);
|
||||
const result = await page.evaluate(`(() => {
|
||||
const id = ${idJson};
|
||||
return Array.from(document.querySelectorAll('a[href^="/c/"]'))
|
||||
.some((a) => a instanceof HTMLElement && a.offsetParent && (a.getAttribute('href') || '').toLowerCase().includes(id));
|
||||
})()`);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export async function waitForConversationToDisappear(page, conversationId, timeoutMs = 5_000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (!(await isConversationVisibleInSidebar(page, conversationId))) return true;
|
||||
await page.wait(0.25);
|
||||
}
|
||||
return !(await isConversationVisibleInSidebar(page, conversationId));
|
||||
}
|
||||
|
||||
export async function readConversationMenuLabels(page, conversationId) {
|
||||
const id = String(conversationId).toLowerCase();
|
||||
const idJson = JSON.stringify(id);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const id = ${idJson};
|
||||
let link = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
link = Array.from(document.querySelectorAll('a[href^="/c/"]'))
|
||||
.find((a) => a instanceof HTMLElement && a.offsetParent && (a.getAttribute('href') || '').toLowerCase().includes(id));
|
||||
if (link) break;
|
||||
await waitFor(300);
|
||||
}
|
||||
if (!link) {
|
||||
return { ok: false, reason: 'Conversation not found in sidebar.', detail: 'id=' + id, labels: [] };
|
||||
}
|
||||
const rect = link.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 2, buttons: 2,
|
||||
clientX: Math.round(rect.left + Math.min(rect.width / 2, 16)),
|
||||
clientY: Math.round(rect.top + Math.min(rect.height / 2, 16)),
|
||||
};
|
||||
link.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
link.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
link.dispatchEvent(new MouseEvent('contextmenu', init));
|
||||
link.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
link.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
|
||||
let items = [];
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
items = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (items.length) break;
|
||||
await waitFor(150);
|
||||
}
|
||||
const labels = items.map((it) => (it.textContent || '').trim()).filter(Boolean);
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return items.length
|
||||
? { ok: true, labels }
|
||||
: { ok: false, reason: 'Context menu did not open for the conversation.', labels: [] };
|
||||
})()`);
|
||||
try {
|
||||
await page.keys('Escape');
|
||||
} catch {
|
||||
// Best-effort cleanup; some fake pages and browser adapters do not expose keys().
|
||||
}
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { ok: false, reason: 'Malformed context-menu result.', labels: [] };
|
||||
}
|
||||
return {
|
||||
ok: Boolean(result.ok),
|
||||
reason: String(result.reason || ''),
|
||||
detail: String(result.detail || ''),
|
||||
labels: Array.isArray(result.labels) ? result.labels.map((label) => String(label || '')) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForConversationPinState(page, conversationId, expectedState, timeoutMs = 5_000) {
|
||||
const started = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
last = await readConversationMenuLabels(page, conversationId);
|
||||
if (last.ok && getPinStateFromMenuLabels(last.labels) === expectedState) {
|
||||
return { ok: true, state: expectedState, labels: last.labels };
|
||||
}
|
||||
await page.wait(0.25);
|
||||
}
|
||||
last = await readConversationMenuLabels(page, conversationId);
|
||||
const state = last.ok ? getPinStateFromMenuLabels(last.labels) : '';
|
||||
return {
|
||||
ok: last.ok && state === expectedState,
|
||||
state,
|
||||
labels: last.labels || [],
|
||||
reason: last.reason || `Conversation did not reach ${expectedState} state.`,
|
||||
detail: last.detail || '',
|
||||
};
|
||||
}
|
||||
|
||||
// After clickConversationMenuItem opens an inline rename input, fill it and
|
||||
// commit by pressing Enter. Returns the new title we set (best-effort).
|
||||
export async function fillRenameInputAndSubmit(page, newTitle) {
|
||||
const titleJson = JSON.stringify(newTitle);
|
||||
return await page.evaluate(`(async () => {
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let input = null;
|
||||
for (let attempt = 0; attempt < 15; attempt += 1) {
|
||||
// The inline rename UI surfaces as a single visible <input> or a
|
||||
// contenteditable inside the sidebar row.
|
||||
input = Array.from(document.querySelectorAll('input[type="text"], [contenteditable="true"]:not(.ProseMirror)'))
|
||||
.find((el) => el instanceof HTMLElement && el.offsetParent && el.getBoundingClientRect().left < 260);
|
||||
if (input) break;
|
||||
await waitFor(200);
|
||||
}
|
||||
if (!input) {
|
||||
return { ok: false, reason: 'Inline rename input did not appear.' };
|
||||
}
|
||||
input.focus();
|
||||
if (input instanceof HTMLInputElement) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(input, ${titleJson});
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
// contenteditable path
|
||||
input.innerText = '';
|
||||
document.execCommand('insertText', false, ${titleJson});
|
||||
}
|
||||
// Commit by Enter (Grok accepts Enter to confirm, Escape to cancel).
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
return { ok: true, value: ${titleJson} };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function sendMessage(page, prompt) {
|
||||
const promptJson = JSON.stringify(prompt);
|
||||
return await page.evaluate(`(async () => {
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const composerSelector = '.ProseMirror[contenteditable="true"]';
|
||||
const isVisible = (node) => {
|
||||
if (!(node instanceof Element)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const responseIdFor = (node) => {
|
||||
let parent = node.parentElement;
|
||||
while (parent && parent !== document.body) {
|
||||
const id = parent.getAttribute('id') || '';
|
||||
if (id.startsWith('response-')) return id.slice('response-'.length);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const userTurns = () => Array.from(document.querySelectorAll('[data-testid="user-message"]'))
|
||||
.filter((node) => node instanceof HTMLElement && isVisible(node))
|
||||
.map((node, index) => ({
|
||||
id: responseIdFor(node) || ('pos-' + index),
|
||||
text: normalize(node.innerText || node.textContent || ''),
|
||||
}))
|
||||
.filter((turn) => turn.text);
|
||||
const promptText = normalize(${promptJson});
|
||||
const beforeTurns = userTurns();
|
||||
const beforeKeys = new Set(beforeTurns.map((turn) => turn.id + '\\n' + turn.text));
|
||||
const waitForSubmittedUserTurn = async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const turns = userTurns();
|
||||
const latest = turns[turns.length - 1];
|
||||
const hasNewMatchingTurn = turns.some((turn) => turn.text === promptText && !beforeKeys.has(turn.id + '\\n' + turn.text));
|
||||
const appendedMatchingTurn = turns.length > beforeTurns.length && latest?.text === promptText;
|
||||
if (hasNewMatchingTurn || appendedMatchingTurn) return true;
|
||||
await waitFor(250);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let composer = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const candidate = document.querySelector(composerSelector);
|
||||
@@ -257,22 +530,62 @@ export async function sendMessage(page, prompt) {
|
||||
const isClickableSubmit = (node) => {
|
||||
if (!(node instanceof HTMLButtonElement)) return false;
|
||||
if (node.disabled) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
return isVisible(node);
|
||||
};
|
||||
// Prefer data-testid (locale-independent); fall back to aria-label per
|
||||
// language. As of 2026-05-31 Grok renders button[data-testid="chat-submit"]
|
||||
// once the composer has content. The aria-label varies: "Submit" (en),
|
||||
// "提交" (zh-CN), and presumably other locales.
|
||||
const submitSelectors = [
|
||||
'button[data-testid="chat-submit"]',
|
||||
'button[aria-label="Submit"]',
|
||||
'button[aria-label="提交"]',
|
||||
'button[aria-label="送信"]',
|
||||
];
|
||||
let submit = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const candidate = Array.from(document.querySelectorAll('button[aria-label="Submit"]')).find(isClickableSubmit);
|
||||
if (candidate instanceof HTMLButtonElement) { submit = candidate; break; }
|
||||
for (const sel of submitSelectors) {
|
||||
const candidate = Array.from(document.querySelectorAll(sel)).find(isClickableSubmit);
|
||||
if (candidate instanceof HTMLButtonElement) { submit = candidate; break; }
|
||||
}
|
||||
if (submit) break;
|
||||
await waitFor(500);
|
||||
}
|
||||
if (!(submit instanceof HTMLButtonElement)) {
|
||||
return { ok: false, reason: 'Grok submit button did not reach a clickable state after prompt insertion.' };
|
||||
if (submit instanceof HTMLButtonElement) {
|
||||
submit.click();
|
||||
if (!(await waitForSubmittedUserTurn())) {
|
||||
return { ok: false, reason: 'Grok submit button was clicked but no new user turn appeared.' };
|
||||
}
|
||||
return { ok: true, submittedVia: 'submit-button' };
|
||||
}
|
||||
// Fallback: some Grok deployments / locales never surface a
|
||||
// submit-labelled button (see #1782); the composer commits on Enter
|
||||
// when there is no Shift modifier, and Tiptap honours the synthetic
|
||||
// keypress because the editor is focused. Dispatch a full keydown +
|
||||
// keypress + keyup chain so any modifier / IME listener stays
|
||||
// consistent with a real user pressing Enter.
|
||||
const dispatchEnter = (target) => {
|
||||
const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', opts));
|
||||
target.dispatchEvent(new KeyboardEvent('keypress', opts));
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', opts));
|
||||
};
|
||||
try {
|
||||
// Re-focus first; insertContent above may have moved focus elsewhere.
|
||||
editor.commands.focus();
|
||||
const focusTarget = document.activeElement instanceof HTMLElement ? document.activeElement : composer;
|
||||
dispatchEnter(focusTarget);
|
||||
if (!(await waitForSubmittedUserTurn())) {
|
||||
return { ok: false, reason: 'Grok Enter-key fallback fired but no new user turn appeared.' };
|
||||
}
|
||||
return { ok: true, submittedVia: 'enter-key' };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Grok submit button never appeared and Enter-key fallback failed.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
submit.click();
|
||||
return { ok: true };
|
||||
})()`);
|
||||
}
|
||||
|
||||
@@ -323,4 +636,5 @@ export async function waitForAnswer(page, prompt, timeoutSeconds, baselineLastAs
|
||||
|
||||
export const __test__ = {
|
||||
GROK_SESSION_ID_RE,
|
||||
getPinStateFromMenuLabels,
|
||||
};
|
||||
|
||||
+39
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { isOnGrok, normalizeBooleanFlag, parseGrokSessionId } from './utils.js';
|
||||
import { __test__, isOnGrok, normalizeBooleanFlag, parseGrokSessionId, sendMessage } from './utils.js';
|
||||
|
||||
describe('grok parseGrokSessionId', () => {
|
||||
const id = '7c4197f2-10a1-4ebb-a84a-fea89f4f1d06';
|
||||
@@ -101,3 +101,41 @@ describe('grok normalizeBooleanFlag', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('grok getPinStateFromMenuLabels', () => {
|
||||
it('detects pinned state from unpin labels without substring collisions', () => {
|
||||
expect(__test__.getPinStateFromMenuLabels(['Open in new tab', 'Unpin', 'Delete'])).toBe('pinned');
|
||||
expect(__test__.getPinStateFromMenuLabels(['打开新标签页', '取消置顶', '删除'])).toBe('pinned');
|
||||
});
|
||||
|
||||
it('detects unpinned state from pin labels', () => {
|
||||
expect(__test__.getPinStateFromMenuLabels(['Open in new tab', 'Pin', 'Delete'])).toBe('unpinned');
|
||||
expect(__test__.getPinStateFromMenuLabels(['打开新标签页', '置顶', '删除'])).toBe('unpinned');
|
||||
});
|
||||
|
||||
it('returns empty string when neither state label is visible', () => {
|
||||
expect(__test__.getPinStateFromMenuLabels(['Open in new tab', 'Delete'])).toBe('');
|
||||
expect(__test__.getPinStateFromMenuLabels([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('grok sendMessage', () => {
|
||||
it('requires a submitted user turn after both submit-button and Enter fallback paths', async () => {
|
||||
let script = '';
|
||||
const page = {
|
||||
evaluate: async (value) => {
|
||||
script = String(value);
|
||||
return { ok: false, reason: 'test-capture' };
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessage(page, 'hello from test');
|
||||
|
||||
expect(script).toContain('waitForSubmittedUserTurn');
|
||||
expect(script).toContain('Grok submit button was clicked but no new user turn appeared.');
|
||||
expect(script).toContain('Grok Enter-key fallback fired but no new user turn appeared.');
|
||||
expect(script).toContain('[data-testid="user-message"]');
|
||||
expect(script).toContain("submittedVia: 'submit-button'");
|
||||
expect(script).toContain("submittedVia: 'enter-key'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
// Hugging Face's `token` cookie is httpOnly; gate the login poll on the
|
||||
// documented /api/whoami-v2 endpoint (401 when anonymous) via a no-nav probe.
|
||||
const WHOAMI_PROBE = `(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/whoami-v2', { credentials: 'include', headers: { Accept: 'application/json' } });
|
||||
if (r.status === 401 || r.status === 403) return { kind: 'auth', detail: 'HF /api/whoami-v2 HTTP ' + r.status };
|
||||
if (!r.ok) return { kind: 'http', httpStatus: r.status };
|
||||
const d = await r.json();
|
||||
if (!d || !d.name || d.type === undefined) return { kind: 'auth', detail: 'HF /api/whoami-v2 has no name — anonymous' };
|
||||
return { ok: true, username: String(d.name), fullname: String(d.fullname || ''), type: String(d.type || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`;
|
||||
|
||||
async function verifyHfIdentity(page) {
|
||||
await page.goto('https://huggingface.co/');
|
||||
await page.wait(1);
|
||||
const probe = await page.evaluate(WHOAMI_PROBE);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('huggingface.co', probe.detail);
|
||||
if (probe?.kind === 'http') throw new CommandExecutionError(`HTTP ${probe.httpStatus} from HF /api/whoami-v2`);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`HF whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected HF probe: ${JSON.stringify(probe)}`);
|
||||
return { username: probe.username, fullname: probe.fullname, type: probe.type };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'hf',
|
||||
domain: 'huggingface.co',
|
||||
loginUrl: 'https://huggingface.co/login',
|
||||
columns: ['username', 'fullname', 'type'],
|
||||
verify: verifyHfIdentity,
|
||||
poll: async (page) => {
|
||||
const probe = await page.evaluate(WHOAMI_PROBE);
|
||||
if (!probe?.ok) throw new AuthRequiredError('huggingface.co', 'Waiting for Hugging Face login');
|
||||
return { username: probe.username, fullname: probe.fullname, type: probe.type };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasHupuUserCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://my.hupu.com' });
|
||||
return cookies.some(c => c.name === 'u' && c.value);
|
||||
}
|
||||
|
||||
async function verifyHupuIdentity(page) {
|
||||
if (!await hasHupuUserCookie(page)) {
|
||||
throw new AuthRequiredError('hupu.com', 'Hupu u cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://my.hupu.com/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
if (/passport\\.hupu\\.com\\/.*login/.test(location.href)) {
|
||||
return { kind: 'auth', detail: 'Hupu my page redirected to passport login' };
|
||||
}
|
||||
const uCookie = (document.cookie.split('; ').find(c => c.startsWith('u=')) || '').split('=')[1] || '';
|
||||
const el = document.querySelector('.user-name, .username, .nick, [class*="userName"]');
|
||||
const username = (el?.innerText || '').trim();
|
||||
if (!uCookie) {
|
||||
return { kind: 'auth', detail: 'Hupu my page rendered but u cookie absent — stale session' };
|
||||
}
|
||||
return { ok: true, user_id: uCookie, username };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('hupu.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Hupu probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, username: probe.username };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'hupu',
|
||||
domain: 'hupu.com',
|
||||
loginUrl: 'https://passport.hupu.com/pc/login',
|
||||
columns: ['user_id', 'username'],
|
||||
quickCheck: hasHupuUserCookie,
|
||||
verify: verifyHupuIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasHupuUserCookie(page)) {
|
||||
throw new AuthRequiredError('hupu.com', 'Waiting for Hupu u cookie');
|
||||
}
|
||||
return verifyHupuIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasInstagramSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.instagram.com' });
|
||||
return cookies.some(c => c.name === 'sessionid' && c.value);
|
||||
}
|
||||
|
||||
async function verifyInstagramIdentity(page) {
|
||||
if (!await hasInstagramSessionCookie(page)) {
|
||||
throw new AuthRequiredError('www.instagram.com', 'Instagram sessionid cookie missing');
|
||||
}
|
||||
await page.goto('https://www.instagram.com/');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const uid = (document.cookie.split('; ').find(c => c.startsWith('ds_user_id=')) || '').split('=')[1] || '';
|
||||
if (!uid) return { kind: 'auth', detail: 'Instagram ds_user_id cookie missing' };
|
||||
const r = await fetch('/api/v1/users/' + uid + '/info/', {
|
||||
credentials: 'include',
|
||||
headers: { 'X-IG-App-ID': '936619743392459', 'Accept': 'application/json' },
|
||||
});
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
return { kind: 'auth', detail: 'Instagram /users/info HTTP ' + r.status };
|
||||
}
|
||||
if (!r.ok) return { kind: 'http', httpStatus: r.status };
|
||||
const d = await r.json();
|
||||
const user = d?.user;
|
||||
if (!user || !user.pk) {
|
||||
return { kind: 'auth', detail: 'Instagram /users/info returned no pk — session likely expired' };
|
||||
}
|
||||
return { ok: true, user_id: String(user.pk), username: String(user.username || ''), full_name: String(user.full_name || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('www.instagram.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from Instagram /users/info`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`Instagram whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected Instagram probe: ${JSON.stringify(result)}`);
|
||||
return { user_id: result.user_id, username: result.username, full_name: result.full_name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'instagram',
|
||||
domain: 'instagram.com',
|
||||
loginUrl: 'https://www.instagram.com/accounts/login/',
|
||||
columns: ['user_id', 'username', 'full_name'],
|
||||
quickCheck: hasInstagramSessionCookie,
|
||||
verify: verifyInstagramIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasInstagramSessionCookie(page)) {
|
||||
throw new AuthRequiredError('www.instagram.com', 'Waiting for Instagram sessionid cookie');
|
||||
}
|
||||
return verifyInstagramIdentity(page);
|
||||
},
|
||||
});
|
||||
+57
-13
@@ -15,6 +15,7 @@ cli({
|
||||
{ evaluate: `(async () => {
|
||||
const username = \${{ args.username | json }};
|
||||
const limit = \${{ args.limit }};
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new Error('limit must be a positive integer');
|
||||
const headers = { 'X-IG-App-ID': '936619743392459' };
|
||||
const opts = { credentials: 'include', headers };
|
||||
|
||||
@@ -27,19 +28,62 @@ cli({
|
||||
const userId = d1?.data?.user?.id;
|
||||
if (!userId) throw new Error('User not found: ' + username);
|
||||
|
||||
const r2 = await fetch(
|
||||
'https://www.instagram.com/api/v1/friendships/' + userId + '/following/?count=' + limit,
|
||||
opts
|
||||
);
|
||||
if (!r2.ok) throw new Error('Failed to fetch following: HTTP ' + r2.status);
|
||||
const d2 = await r2.json();
|
||||
return (d2?.users || []).slice(0, limit).map((u, i) => ({
|
||||
rank: i + 1,
|
||||
username: u.username || '',
|
||||
name: u.full_name || '',
|
||||
verified: u.is_verified ? 'Yes' : 'No',
|
||||
private: u.is_private ? 'Yes' : 'No',
|
||||
}));
|
||||
const PAGE_SIZE = 50;
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
const seenCursors = new Set();
|
||||
let maxId = undefined;
|
||||
const baseUrl = 'https://www.instagram.com/api/v1/friendships/' + userId + '/following/';
|
||||
|
||||
while (results.length < limit) {
|
||||
const params = new URLSearchParams({ count: String(PAGE_SIZE) });
|
||||
if (maxId) params.set('max_id', maxId);
|
||||
const r2 = await fetch(baseUrl + '?' + params.toString(), opts);
|
||||
if (!r2.ok) throw new Error('Failed to fetch following: HTTP ' + r2.status);
|
||||
const d2 = await r2.json();
|
||||
if (!d2 || typeof d2 !== 'object' || !Array.isArray(d2.users)) {
|
||||
throw new Error('Instagram following returned malformed users payload');
|
||||
}
|
||||
const users = d2.users;
|
||||
const sizeBefore = results.length;
|
||||
for (const u of users) {
|
||||
if (!u || typeof u !== 'object') {
|
||||
throw new Error('Instagram following returned malformed user row');
|
||||
}
|
||||
const pk = String(u.pk ?? u.pk_id ?? u.id ?? '');
|
||||
const usernameValue = typeof u.username === 'string' ? u.username.trim() : '';
|
||||
if (!pk || !usernameValue) {
|
||||
throw new Error('Instagram following returned malformed user row');
|
||||
}
|
||||
if (!pk || seen.has(pk)) continue;
|
||||
seen.add(pk);
|
||||
results.push({
|
||||
rank: results.length + 1,
|
||||
username: usernameValue,
|
||||
name: typeof u.full_name === 'string' ? u.full_name : '',
|
||||
verified: u.is_verified ? 'Yes' : 'No',
|
||||
private: u.is_private ? 'Yes' : 'No',
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
if (results.length >= limit) break;
|
||||
if (results.length === sizeBefore) break; // no new unique users this page
|
||||
if (d2.next_max_id != null && typeof d2.next_max_id !== 'string' && typeof d2.next_max_id !== 'number') {
|
||||
throw new Error('Instagram following returned malformed pagination cursor');
|
||||
}
|
||||
if (d2.has_more != null && typeof d2.has_more !== 'boolean') {
|
||||
throw new Error('Instagram following returned malformed has_more flag');
|
||||
}
|
||||
const nextCursor = d2.next_max_id == null ? '' : String(d2.next_max_id);
|
||||
const hasMore = typeof d2.has_more === 'boolean' ? d2.has_more : !!nextCursor;
|
||||
if (!hasMore || users.length === 0) break;
|
||||
if (!nextCursor) throw new Error('Instagram following returned has_more without pagination cursor');
|
||||
if (seenCursors.has(nextCursor)) break; // cursor loop guard
|
||||
seenCursors.add(nextCursor);
|
||||
maxId = nextCursor;
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
return results.slice(0, limit);
|
||||
})()
|
||||
` },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import './following.js';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
|
||||
/**
|
||||
* Extract the evaluate JS source from the following command pipeline
|
||||
* so we can test the pagination logic in-process via eval().
|
||||
*/
|
||||
function getFollowingEvaluateJs() {
|
||||
const cmd = getRegistry().get('instagram/following');
|
||||
const evalStep = cmd.pipeline.find((s) => s.evaluate);
|
||||
return evalStep.evaluate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the following evaluate script with a mock fetch in global scope.
|
||||
* Returns the resolved value.
|
||||
*/
|
||||
async function runFollowingEvaluate(fetchFn, args = { username: 'testuser', limit: 20 }) {
|
||||
const jsTemplate = getFollowingEvaluateJs();
|
||||
// Replace the template placeholders with actual values
|
||||
const js = jsTemplate
|
||||
.replace('${{ args.username | json }}', JSON.stringify(args.username))
|
||||
.replace('${{ args.limit }}', String(args.limit));
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = fetchFn;
|
||||
try {
|
||||
return await eval(js);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single-page Instagram following API response.
|
||||
*/
|
||||
function buildFollowingResponse(users, nextMaxId = null, hasMore = undefined) {
|
||||
return {
|
||||
users,
|
||||
next_max_id: nextMaxId,
|
||||
...(hasMore === undefined ? {} : { has_more: hasMore }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user object with a given pk and username.
|
||||
*/
|
||||
function makeUser(pk, username = null) {
|
||||
return {
|
||||
pk,
|
||||
pk_id: String(pk),
|
||||
username: username || ('user_' + pk),
|
||||
full_name: 'User ' + pk,
|
||||
is_verified: pk % 2 === 0,
|
||||
is_private: pk % 3 === 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('instagram/following pagination', () => {
|
||||
it('returns a single page when results fit within one request', async () => {
|
||||
const users = Array.from({ length: 10 }, (_, i) => makeUser(1000 + i));
|
||||
const fetchFn = vi.fn()
|
||||
// First call: profile info
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '12345' } } }),
|
||||
})
|
||||
// Second call: following page (no next_max_id)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(users)),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'alice', limit: 20 });
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result[0]).toEqual({
|
||||
rank: 1,
|
||||
username: 'user_1000',
|
||||
name: 'User 1000',
|
||||
verified: 'Yes',
|
||||
private: 'No',
|
||||
});
|
||||
expect(result[9].rank).toBe(10);
|
||||
// Only 2 fetch calls total (profile + 1 following page)
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('paginates across multiple pages via next_max_id cursor', async () => {
|
||||
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
|
||||
const page2Users = Array.from({ length: 50 }, (_, i) => makeUser(2000 + i));
|
||||
const page3Users = Array.from({ length: 20 }, (_, i) => makeUser(3000 + i));
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
// Profile info
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '99999' } } }),
|
||||
})
|
||||
// Page 1: 50 users, has next_max_id
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'max_cursor_1')),
|
||||
})
|
||||
// Page 2: 50 users, has next_max_id
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'max_cursor_2')),
|
||||
})
|
||||
// Page 3: 20 users, no next_max_id
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page3Users)),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'bob', limit: 120 });
|
||||
|
||||
expect(result).toHaveLength(120);
|
||||
// Ranks should be sequential across pages
|
||||
expect(result[0].rank).toBe(1);
|
||||
expect(result[49].rank).toBe(50);
|
||||
expect(result[50].rank).toBe(51);
|
||||
expect(result[119].rank).toBe(120);
|
||||
// 4 fetch calls: profile + 3 pages
|
||||
expect(fetchFn).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('deduplicates users by pk when cursor overlaps', async () => {
|
||||
const sharedUser = makeUser(1000, 'shared_user');
|
||||
const page1Users = [sharedUser, makeUser(1001), makeUser(1002)];
|
||||
const page2Users = [sharedUser, makeUser(1003), makeUser(1004)];
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '55555' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'cursor_overlap')),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page2Users)),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'carol', limit: 20 });
|
||||
|
||||
// 5 unique users total (shared_user counted once)
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((r) => r.username)).toEqual([
|
||||
'shared_user', 'user_1001', 'user_1002', 'user_1003', 'user_1004',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects the limit and stops early even with more data available', async () => {
|
||||
const users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '77777' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(users, 'more_available')),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'dave', limit: 5 });
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[4].rank).toBe(5);
|
||||
// Should NOT have fetched a second following page
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('honors explicit has_more=false even if a cursor is present', async () => {
|
||||
const users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '77778' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(users, 'stale_cursor', false)),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(Array.from({ length: 50 }, (_, i) => makeUser(2000 + i)))),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'done', limit: 200 });
|
||||
|
||||
expect(result).toHaveLength(50);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles empty following list gracefully', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '88888' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse([])),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'empty_user', limit: 20 });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('breaks when next_max_id repeats (cursor loop guard)', async () => {
|
||||
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
|
||||
const page2Users = Array.from({ length: 50 }, (_, i) => makeUser(2000 + i));
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '44444' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'cursor_loop')),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'cursor_loop')),
|
||||
})
|
||||
// If guard fails this would be reached and trigger the assertion below.
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'cursor_loop')),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'loopy', limit: 500 });
|
||||
|
||||
expect(result).toHaveLength(100);
|
||||
// Profile + page1 + page2 = 3 calls; cursor loop guard prevents the 4th.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('breaks when a page yields zero new unique users', async () => {
|
||||
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
|
||||
// Page 2 returns the same users with a fresh cursor — dedupe leaves nothing new.
|
||||
const page2Users = [...page1Users];
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '33333' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'cursor_a')),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'cursor_b')),
|
||||
});
|
||||
|
||||
const result = await runFollowingEvaluate(fetchFn, { username: 'stuck', limit: 500 });
|
||||
|
||||
expect(result).toHaveLength(50);
|
||||
// Profile + 2 following pages, then stop on zero-growth detection.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects non-positive limits before fetching', async () => {
|
||||
const fetchFn = vi.fn();
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'noop', limit: 0 }),
|
||||
).rejects.toThrow('limit must be a positive integer');
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates HTTP errors that occur on a mid-pagination page', async () => {
|
||||
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
|
||||
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '11111' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'next')),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 429 });
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'broken', limit: 200 }),
|
||||
).rejects.toThrow('Failed to fetch following: HTTP 429');
|
||||
});
|
||||
|
||||
it('typed-fails malformed following payloads instead of returning empty rows', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '10101' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ users: null }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'malformed', limit: 20 }),
|
||||
).rejects.toThrow('Instagram following returned malformed users payload');
|
||||
});
|
||||
|
||||
it('typed-fails malformed user identity rows instead of emitting blank usernames', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '10102' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse([{ pk: '1', full_name: 'No username' }])),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'badrow', limit: 20 }),
|
||||
).rejects.toThrow('Instagram following returned malformed user row');
|
||||
});
|
||||
|
||||
it('typed-fails malformed pagination cursors', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '10103' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse([makeUser(1)], { cursor: 'bad' })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'badcursor', limit: 20 }),
|
||||
).rejects.toThrow('Instagram following returned malformed pagination cursor');
|
||||
});
|
||||
|
||||
it('typed-fails has_more=true without a pagination cursor', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '10104' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse([makeUser(1)], null, true)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'missingcursor', limit: 20 }),
|
||||
).rejects.toThrow('Instagram following returned has_more without pagination cursor');
|
||||
});
|
||||
|
||||
it('typed-fails malformed has_more flags', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { user: { id: '10105' } } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(buildFollowingResponse([makeUser(1)], 'next', 'yes')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runFollowingEvaluate(fetchFn, { username: 'badhasmore', limit: 20 }),
|
||||
).rejects.toThrow('Instagram following returned malformed has_more flag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasJdSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.jd.com' });
|
||||
const names = new Set(cookies.map(c => c.name));
|
||||
return names.has('pin') || names.has('thor');
|
||||
}
|
||||
|
||||
async function verifyJdIdentity(page) {
|
||||
if (!await hasJdSessionCookie(page)) {
|
||||
throw new AuthRequiredError('jd.com', 'JD pin / thor cookie missing');
|
||||
}
|
||||
await page.goto('https://home.jd.com/');
|
||||
await page.wait(3);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
const pinCookie = (document.cookie.split('; ').find(c => c.startsWith('pin=')) || '').split('=')[1] || '';
|
||||
const decoded = pinCookie ? decodeURIComponent(pinCookie) : '';
|
||||
if (!decoded) {
|
||||
return { kind: 'auth', detail: 'JD pin cookie empty after decode' };
|
||||
}
|
||||
const nickEl = document.querySelector('.user-info, #aliveUserName, .name, .user-name');
|
||||
const nickname = (nickEl && nickEl.textContent && nickEl.textContent.trim()) || '';
|
||||
return { ok: true, pin: decoded, nickname };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('jd.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected JD probe: ${JSON.stringify(probe)}`);
|
||||
return { pin: probe.pin, nickname: probe.nickname };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'jd',
|
||||
domain: 'jd.com',
|
||||
loginUrl: 'https://passport.jd.com/new/login.aspx',
|
||||
columns: ['pin', 'nickname'],
|
||||
quickCheck: hasJdSessionCookie,
|
||||
verify: verifyJdIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasJdSessionCookie(page)) {
|
||||
throw new AuthRequiredError('jd.com', 'Waiting for JD pin / thor cookie');
|
||||
}
|
||||
return verifyJdIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasJianyuUserCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.jianyu360.cn' });
|
||||
return cookies.some(c => c.name === 'userid_secure' && c.value);
|
||||
}
|
||||
|
||||
async function verifyJianyuIdentity(page) {
|
||||
if (!await hasJianyuUserCookie(page)) {
|
||||
throw new AuthRequiredError('jianyu360.cn', 'Jianyu userid_secure cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://www.jianyu360.cn/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const r = await fetch('/swordfish/frontPage/customer/sess/index', { credentials: 'include' });
|
||||
const text = await r.text();
|
||||
if (/<title>\\s*登录\\s*[-—]\\s*剑鱼标讯/.test(text)) {
|
||||
return { kind: 'auth', detail: 'Jianyu protected page returned login page — anonymous' };
|
||||
}
|
||||
const userIdMeta = text.match(/<meta[^>]+name=[\"'](?:user-id|userId)[\"'][^>]+content=[\"']([^\"']+)[\"']/)?.[1] || '';
|
||||
const userScript = text.match(/window\\.__USER__\\s*=\\s*(\\{[^}]+\\})/)?.[1] || '';
|
||||
let userId = userIdMeta;
|
||||
let name = '';
|
||||
if (userScript) {
|
||||
try {
|
||||
const u = JSON.parse(userScript);
|
||||
userId = userId || String(u.id || u.userId || '');
|
||||
name = String(u.name || u.realName || u.nickName || '');
|
||||
} catch {}
|
||||
}
|
||||
const cookieUid = (document.cookie.split('; ').find(c => c.startsWith('userid_secure=')) || '').split('=')[1] || '';
|
||||
userId = userId || cookieUid;
|
||||
if (!userId && !name) {
|
||||
return { kind: 'auth', detail: 'Jianyu protected page 200 but no user identity surface' };
|
||||
}
|
||||
return { ok: true, user_id: userId, name };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('jianyu360.cn', probe.detail);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`Jianyu whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Jianyu probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'jianyu',
|
||||
domain: 'jianyu360.cn',
|
||||
loginUrl: 'https://www.jianyu360.cn/',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: hasJianyuUserCookie,
|
||||
verify: verifyJianyuIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasJianyuUserCookie(page)) {
|
||||
throw new AuthRequiredError('jianyu360.cn', 'Waiting for Jianyu userid_secure cookie');
|
||||
}
|
||||
return verifyJianyuIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasKeSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.ke.com' });
|
||||
return cookies.some(c => c.name === 'lianjia_token' && c.value);
|
||||
}
|
||||
|
||||
async function verifyKeIdentity(page) {
|
||||
if (!await hasKeSessionCookie(page)) {
|
||||
throw new AuthRequiredError('ke.com', 'Ke lianjia_token cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://www.ke.com/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`
|
||||
(() => {
|
||||
const loginBtn = document.querySelector('.btn-login, a[class*=actLoginBtn], .login-btn');
|
||||
if (loginBtn && /登录|登陆/.test(loginBtn.innerText || '')) {
|
||||
return { kind: 'auth', detail: 'Ke shows 登录 button — anonymous session' };
|
||||
}
|
||||
const el = document.querySelector('.userNick, .user-name, .myInfo a, [class*=userNick]');
|
||||
const username = (el?.innerText || '').trim();
|
||||
if (!username) {
|
||||
return { kind: 'auth', detail: 'Ke no user-name DOM anchor — anonymous or SSR failed' };
|
||||
}
|
||||
return { ok: true, username };
|
||||
})()
|
||||
`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('ke.com', probe.detail);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Ke probe: ${JSON.stringify(probe)}`);
|
||||
return { username: probe.username };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'ke',
|
||||
domain: 'ke.com',
|
||||
loginUrl: 'https://clogin.ke.com/login/?service=https%3A%2F%2Fwww.ke.com',
|
||||
columns: ['username'],
|
||||
verify: verifyKeIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasKeSessionCookie(page)) {
|
||||
throw new AuthRequiredError('ke.com', 'Waiting for Ke lianjia_token cookie');
|
||||
}
|
||||
return verifyKeIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Shared helpers for the Kimi (kimi.com) web adapter.
|
||||
//
|
||||
// Kimi is Moonshot's web app at kimi.com (formerly kimi.moonshot.cn).
|
||||
// The UI uses SVG icons identified by `name="XXX"` attributes rather
|
||||
// than aria-labels — finding buttons typically means walking up from
|
||||
// `<svg role="img" name="Copy">` to the nearest <div> or <button>.
|
||||
|
||||
export const KIMI_DOMAIN = 'kimi.com';
|
||||
export const KIMI_URL = 'https://www.kimi.com/';
|
||||
|
||||
export const IS_VISIBLE_JS = `
|
||||
const isVisible = (el) => {
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') return false;
|
||||
return true;
|
||||
};
|
||||
`;
|
||||
|
||||
// Ensure the current page is on kimi.com (any subpath). If not, navigate to root.
|
||||
export function isKimiUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
const host = url.hostname.toLowerCase();
|
||||
return url.protocol === 'https:' && (host === KIMI_DOMAIN || host === `www.${KIMI_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOnKimi(page) {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (isKimiUrl(url)) return;
|
||||
await page.goto(KIMI_URL);
|
||||
await page.wait(2);
|
||||
}
|
||||
|
||||
// Parse a chat ID (UUID-like) from either a raw id or a /chat/<id> URL.
|
||||
const CHAT_ID_RE = /^[0-9a-f-]{8,}$/i;
|
||||
|
||||
export function parseChatId(input) {
|
||||
const s = String(input || '').trim();
|
||||
if (!s) return '';
|
||||
const normalizeId = (value) => (CHAT_ID_RE.test(value) ? value.toLowerCase() : '');
|
||||
if (/^https?:\/\//i.test(s)) {
|
||||
try {
|
||||
const url = new URL(s);
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (url.protocol !== 'https:' || (host !== KIMI_DOMAIN && host !== `www.${KIMI_DOMAIN}`)) return '';
|
||||
const match = url.pathname.match(/^\/chat\/([0-9a-f-]{8,})$/i);
|
||||
return match ? normalizeId(match[1]) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (s.startsWith('/')) {
|
||||
try {
|
||||
const url = new URL(s, KIMI_URL);
|
||||
const match = url.pathname.match(/^\/chat\/([0-9a-f-]{8,})$/i);
|
||||
return match ? normalizeId(match[1]) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return normalizeId(s);
|
||||
}
|
||||
|
||||
// Build a JS snippet that clicks a button containing an <svg name="X">.
|
||||
// Kimi nests them: <div onClick><svg role=img name=Copy /></div>.
|
||||
// We walk up from the SVG to the first <div role=button> or clickable
|
||||
// ancestor and dispatch the pointer chain.
|
||||
export function clickBySvgNameScript(svgName, opts = {}) {
|
||||
const { last = true } = opts;
|
||||
return `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const svgs = Array.from(document.querySelectorAll('svg[name="' + ${JSON.stringify(svgName)} + '"], svg[role="img"][name="' + ${JSON.stringify(svgName)} + '"]')).filter(isVisible);
|
||||
if (!svgs.length) return { ok: false, reason: 'No visible svg[name="' + ${JSON.stringify(svgName)} + '"].' };
|
||||
const svg = ${last ? 'svgs[svgs.length - 1]' : 'svgs[0]'};
|
||||
// Walk up to the nearest clickable ancestor.
|
||||
let target = svg;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const parent = target.parentElement;
|
||||
if (!parent) break;
|
||||
target = parent;
|
||||
if (target.tagName === 'BUTTON' || target.getAttribute('role') === 'button' || target.onclick || target.tagName === 'A') break;
|
||||
}
|
||||
const r = target.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', opts));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', opts));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
target.click();
|
||||
return { ok: true };
|
||||
})()`;
|
||||
}
|
||||
|
||||
// Click first link whose href matches a pattern (used for sidebar mode nav).
|
||||
export function navByHrefScript(hrefPattern) {
|
||||
return `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const anchors = Array.from(document.querySelectorAll('a[href]')).filter(isVisible);
|
||||
const target = anchors.find((a) => a.getAttribute('href') === ${JSON.stringify(hrefPattern)} || a.getAttribute('href').startsWith(${JSON.stringify(hrefPattern)}));
|
||||
if (!target) return { ok: false, reason: 'No visible <a href="' + ${JSON.stringify(hrefPattern)} + '">.' };
|
||||
target.click();
|
||||
return { ok: true, href: target.getAttribute('href') };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Deep-audit gap closers for Kimi (kimi.com).
|
||||
//
|
||||
// Discovered via direct DOM enumeration across mode pages (/slides, /docs,
|
||||
// /deep-research, /agent, /settings, /chat/history) — these wrap buttons
|
||||
// not covered by the initial chat/ui/storage commands:
|
||||
//
|
||||
// sign-out — click SignOut svg (in /settings page)
|
||||
// upgrade — click Upgrade button (sidebar bottom)
|
||||
// dismiss-banner — close the "Make a Review & Earn Credit" or
|
||||
// "获取应用程序" banner
|
||||
// templates [--mode] — list template cards on a mode page (PPT /docs/
|
||||
// deep-research / agent — each shows curated
|
||||
// example projects)
|
||||
// history-edit <chat-id> <new-title> — click the per-row Edit button
|
||||
// on /chat/history (each conv has an inline Edit)
|
||||
// user-rules — read/write the rules from /settings (best-effort)
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import {
|
||||
KIMI_DOMAIN,
|
||||
KIMI_URL,
|
||||
IS_VISIBLE_JS,
|
||||
ensureOnKimi,
|
||||
parseChatId,
|
||||
clickBySvgNameScript,
|
||||
} from './_utils.js';
|
||||
|
||||
const AUDIT_EXTRA_COLUMNS = ['Status', 'Index', 'Category', 'Title', 'ChatId', 'NewTitle'];
|
||||
|
||||
// -------- sign-out --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'sign-out',
|
||||
access: 'write',
|
||||
description: 'Click SignOut on the Kimi /settings page. Navigates to /settings first if not already there. Requires --yes.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually sign out (default: dry-run)' },
|
||||
],
|
||||
columns: AUDIT_EXTRA_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const yes = kwargs?.yes === true || kwargs?.yes === 'true' || kwargs?.yes === '1';
|
||||
if (!yes) {
|
||||
return [{ Status: 'dry-run — pass --yes to actually sign out' }];
|
||||
}
|
||||
const url = await page.evaluate('window.location.href');
|
||||
if (!String(url || '').includes('/settings')) {
|
||||
await page.goto(`${KIMI_URL}settings`);
|
||||
await page.wait(1.5);
|
||||
}
|
||||
const res = await page.evaluate(clickBySvgNameScript('SignOut'));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'SignOut svg not found', '');
|
||||
await page.wait(1);
|
||||
return [{ Status: 'signed-out' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- upgrade --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'upgrade',
|
||||
access: 'write',
|
||||
description: 'Click the "Upgrade" button (or 升级会员) in the Kimi sidebar — opens the membership/upgrade page or dialog.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: AUDIT_EXTRA_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const res = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const btns = Array.from(document.querySelectorAll('button, [role="button"], a')).filter(isVisible);
|
||||
const target = btns.find((b) => {
|
||||
const t = (b.innerText || b.textContent || '').trim();
|
||||
return /^Upgrade$|^升级|^会员计划|^开通会员/i.test(t) && t.length < 30;
|
||||
});
|
||||
if (!target) return { ok: false, reason: 'No Upgrade-style button visible.' };
|
||||
const r = target.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', opts));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', opts));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
target.click();
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'upgrade click failed', '');
|
||||
await page.wait(0.6);
|
||||
return [{ Status: 'clicked' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- dismiss-banner --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'dismiss-banner',
|
||||
access: 'write',
|
||||
description: 'Close any visible sidebar banner (e.g., "Make a Review & Earn Credit", "获取应用程序") by clicking its Close svg.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: AUDIT_EXTRA_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const res = await page.evaluate(clickBySvgNameScript('Close', { last: false }));
|
||||
if (!res?.ok) {
|
||||
throw new EmptyResultError('kimi dismiss-banner', 'No Close svg visible — no banner to dismiss?');
|
||||
}
|
||||
return [{ Status: 'dismissed' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- templates --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'templates',
|
||||
access: 'read',
|
||||
description: 'List template cards visible on a Kimi mode page (PPT/docs/deep-research/agent). Each mode shows curated example projects organized by category. Pass --mode to navigate first.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'mode', required: false, help: 'Navigate to mode first: ppt|docs|deep-research|agent|websites|sheets|agent-swarm|code' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 30 },
|
||||
],
|
||||
columns: AUDIT_EXTRA_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const mode = String(kwargs?.mode || '').trim().toLowerCase();
|
||||
const modeMap = {
|
||||
ppt: '/slides',
|
||||
docs: '/docs',
|
||||
'deep-research': '/deep-research',
|
||||
agent: '/agent',
|
||||
websites: '/websites',
|
||||
sheets: '/sheets',
|
||||
'agent-swarm': '/agent-swarm',
|
||||
code: '/code',
|
||||
};
|
||||
if (mode) {
|
||||
const target = modeMap[mode];
|
||||
if (!target) throw new ArgumentError('mode', `unknown mode "${mode}"`);
|
||||
await page.goto(`${KIMI_URL}${target.slice(1)}`);
|
||||
await page.wait(2);
|
||||
} else {
|
||||
await ensureOnKimi(page);
|
||||
}
|
||||
// Templates: visible <a> or [role=button] whose text follows the
|
||||
// pattern "category\n\ntitle" (e.g., "商业财经\n\n宁德时代财报分析").
|
||||
const cards = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const els = Array.from(document.querySelectorAll('a, [role="button"], button')).filter(isVisible);
|
||||
const out = [];
|
||||
for (const el of els) {
|
||||
const tx = (el.innerText || '').trim();
|
||||
// Match "category\\n\\ntitle" or "category\\ntitle" patterns
|
||||
const m = tx.match(/^([^\\n]+)\\n+(.+)$/);
|
||||
if (m && m[1].length < 30 && m[2].length < 100 && m[1] !== m[2]) {
|
||||
out.push({ category: m[1].trim(), title: m[2].trim() });
|
||||
}
|
||||
}
|
||||
// Dedupe by title
|
||||
const seen = new Set();
|
||||
return out.filter((c) => {
|
||||
if (seen.has(c.title)) return false;
|
||||
seen.add(c.title);
|
||||
return true;
|
||||
});
|
||||
})()`);
|
||||
if (!cards.length) {
|
||||
throw new EmptyResultError('kimi templates', 'No template cards visible. Are you on a mode page?');
|
||||
}
|
||||
const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 30;
|
||||
return cards.slice(0, limit).map((c, i) => ({ Index: i + 1, Category: c.category, Title: c.title }));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- history-rename --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'history-rename',
|
||||
access: 'write',
|
||||
description: 'Rename a chat from the /chat/history page (clicks the inline Edit svg next to a chat row, types the new title, and saves). Requires --yes.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'chat-id', positional: true, required: true, help: 'Chat id (UUID-like)' },
|
||||
{ name: 'new-title', positional: true, required: true, help: 'New title' },
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually rename (default: dry-run)' },
|
||||
],
|
||||
columns: AUDIT_EXTRA_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const id = parseChatId(kwargs?.['chat-id']);
|
||||
const newTitle = String(kwargs?.['new-title'] || '').trim();
|
||||
if (!id) throw new ArgumentError('chat-id', 'is required');
|
||||
if (!newTitle) throw new ArgumentError('new-title', 'is required');
|
||||
const yes = kwargs?.yes === true || kwargs?.yes === 'true' || kwargs?.yes === '1';
|
||||
if (!yes) {
|
||||
return [{ Status: 'dry-run — pass --yes to rename', ChatId: id, NewTitle: newTitle }];
|
||||
}
|
||||
// Navigate to history page
|
||||
await page.goto(`${KIMI_URL}chat/history`);
|
||||
await page.wait(2);
|
||||
// Find the row with href containing the chat id, then click its Edit svg
|
||||
const clickRes = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const row = Array.from(document.querySelectorAll('a[href*="/chat/' + ${JSON.stringify(id)} + '"]')).find(isVisible);
|
||||
if (!row) return { ok: false, reason: 'Chat row not found in history.' };
|
||||
// The Edit svg is in a sibling or descendant.
|
||||
let container = row.parentElement || row;
|
||||
for (let i = 0; i < 4 && container.parentElement; i++) container = container.parentElement;
|
||||
const editSvg = container.querySelector('svg[name="Edit"]');
|
||||
if (!editSvg) return { ok: false, reason: 'Edit svg not found near chat row.' };
|
||||
let editBtn = editSvg;
|
||||
for (let i = 0; i < 5 && editBtn.parentElement; i++) { editBtn = editBtn.parentElement; if (editBtn.getAttribute('role') === 'button' || editBtn.tagName === 'BUTTON') break; }
|
||||
editBtn.click();
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!clickRes?.ok) throw new CommandExecutionError(clickRes?.reason || 'Edit click failed', '');
|
||||
await page.wait(0.5);
|
||||
// Fill the inline input + submit via Enter
|
||||
const fillRes = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const inputs = Array.from(document.querySelectorAll('input[type="text"], input:not([type])')).filter(isVisible);
|
||||
const input = inputs[inputs.length - 1];
|
||||
if (!input) return { ok: false, reason: 'No input mounted after clicking Edit.' };
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(input, ${JSON.stringify(newTitle)});
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!fillRes?.ok) throw new CommandExecutionError(fillRes?.reason || 'Rename input fill failed', '');
|
||||
await page.wait(1);
|
||||
const verified = await page.evaluate(`(() => {
|
||||
const row = Array.from(document.querySelectorAll('a[href*="/chat/' + ${JSON.stringify(id)} + '"]'))
|
||||
.find((el) => (el.innerText || el.textContent || '').includes(${JSON.stringify(newTitle)}));
|
||||
return !!row;
|
||||
})()`);
|
||||
if (!verified) {
|
||||
throw new CommandExecutionError(
|
||||
`Kimi history rename was not verified for chat ${id}`,
|
||||
'The edit input was submitted, but the history row did not show the requested title.',
|
||||
);
|
||||
}
|
||||
return [{ Status: 'renamed', ChatId: id, NewTitle: newTitle }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasKimiSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.kimi.com' });
|
||||
const names = new Set(cookies.map(c => c.name));
|
||||
return names.has('access_token') || names.has('refresh_token');
|
||||
}
|
||||
|
||||
async function verifyKimiIdentity(page) {
|
||||
// Source the token via CDP getCookies (works even if access_token is httpOnly,
|
||||
// which document.cookie cannot read).
|
||||
const cookies = await page.getCookies({ url: 'https://www.kimi.com' });
|
||||
const token = cookies.find(c => c.name === 'access_token')?.value || '';
|
||||
if (!token) {
|
||||
throw new AuthRequiredError('kimi.com', 'Kimi access_token cookie missing');
|
||||
}
|
||||
await page.goto('https://www.kimi.com/');
|
||||
await page.wait(3);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const token = ${JSON.stringify(token)};
|
||||
const res = await fetch('/api/user', { credentials: 'include', headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/json' } });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'Kimi /api/user HTTP ' + res.status };
|
||||
}
|
||||
if (!res.ok) return { kind: 'http', httpStatus: res.status };
|
||||
const d = await res.json();
|
||||
if (!d || !d.id) {
|
||||
return { kind: 'auth', detail: 'Kimi /api/user returned no id — anonymous' };
|
||||
}
|
||||
return { ok: true, user_id: String(d.id), name: String(d.name || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('kimi.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/user`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`Kimi whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected Kimi probe: ${JSON.stringify(result)}`);
|
||||
return { user_id: result.user_id, name: result.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'kimi',
|
||||
domain: 'kimi.com',
|
||||
loginUrl: 'https://www.kimi.com/',
|
||||
columns: ['user_id', 'name'],
|
||||
quickCheck: hasKimiSessionCookie,
|
||||
verify: verifyKimiIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasKimiSessionCookie(page)) {
|
||||
throw new AuthRequiredError('kimi.com', 'Waiting for Kimi auth cookies');
|
||||
}
|
||||
return verifyKimiIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
// Chat lifecycle + per-message actions for Kimi.
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
TimeoutError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import {
|
||||
KIMI_DOMAIN,
|
||||
KIMI_URL,
|
||||
IS_VISIBLE_JS,
|
||||
ensureOnKimi,
|
||||
parseChatId,
|
||||
clickBySvgNameScript,
|
||||
} from './_utils.js';
|
||||
|
||||
const CHAT_COLUMNS = ['Field', 'Value', 'Status', 'Url', 'Index', 'Title', 'ChatId', 'Role', 'Text', 'Length', 'ClipboardClicked', 'Reaction', 'WaitedSeconds', 'ReplyPreview'];
|
||||
|
||||
// Helper: if --conv passed, navigate to that chat URL and wait briefly
|
||||
// for messages to mount. Otherwise just ensure we're on kimi.com.
|
||||
async function maybeNavigateConv(page, convArg) {
|
||||
if (!convArg) {
|
||||
await ensureOnKimi(page);
|
||||
return;
|
||||
}
|
||||
const id = parseChatId(convArg);
|
||||
if (!id) throw new ArgumentError('conv', 'must be a Kimi chat id or https://www.kimi.com/chat/<id> URL');
|
||||
// CRITICAL: Kimi's React app only triggers the messages fetch when the
|
||||
// URL has ?chat_enter_method=history (or other valid entry methods).
|
||||
// Plain /chat/<id> loads the conversation TITLE but leaves the message
|
||||
// container empty (clientHeight=0). Append the query param.
|
||||
await page.goto(`${KIMI_URL}chat/${id}?chat_enter_method=history`);
|
||||
await page.wait(2);
|
||||
// Poll up to 15s for .chat-content-list items to appear.
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const ok = await page.evaluate(`(() => {
|
||||
const list = document.querySelector('.chat-content-list') || document.querySelector('.message-list');
|
||||
if (!list) return false;
|
||||
// Check for actual chat-content-item rows (the new container) OR any direct children (older container)
|
||||
return list.querySelectorAll('.chat-content-item, .segment').length > 0 || list.children.length > 0;
|
||||
})()`);
|
||||
if (ok) return;
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- status --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'status',
|
||||
access: 'read',
|
||||
description: 'Check Kimi page connection, login state, and current URL.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const data = await page.evaluate(`(() => {
|
||||
// Kimi marks logged-in users via the user avatar at bottom-left.
|
||||
const avatar = document.querySelector('img[src*="avatar.moonshot.cn"]');
|
||||
const loggedIn = !!avatar;
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
loggedIn,
|
||||
userLabel: loggedIn ? (avatar.alt || '') : '',
|
||||
};
|
||||
})()`);
|
||||
return [
|
||||
{ Field: 'Status', Value: 'Connected' },
|
||||
{ Field: 'Url', Value: data.url },
|
||||
{ Field: 'Title', Value: data.title },
|
||||
{ Field: 'LoggedIn', Value: data.loggedIn ? 'Yes' : 'No' },
|
||||
{ Field: 'User', Value: data.userLabel || '(unknown)' },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- new --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'new',
|
||||
access: 'write',
|
||||
description: 'Start a new Kimi chat (navigates to / with chat_enter_method=new_chat).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page) => {
|
||||
await page.goto(`${KIMI_URL}?chat_enter_method=new_chat`);
|
||||
await page.wait(1);
|
||||
const url = await page.evaluate('window.location.href');
|
||||
return [{ Status: 'started', Url: String(url || '') }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- history --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'history',
|
||||
access: 'read',
|
||||
description: 'List recent Kimi chats from the sidebar (with chat IDs extracted from href).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', required: false, default: 30 },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await ensureOnKimi(page);
|
||||
const items = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const anchors = Array.from(document.querySelectorAll('a[href*="/chat/"]')).filter(isVisible);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const a of anchors) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
const m = href.match(/\\/chat\\/([0-9a-f-]{8,})/i);
|
||||
if (!m) continue;
|
||||
const id = m[1].toLowerCase();
|
||||
if (id === 'history' || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const title = (a.innerText || a.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
|
||||
out.push({ id, title: title || '(untitled)' });
|
||||
}
|
||||
return out;
|
||||
})()`);
|
||||
if (!items.length) {
|
||||
throw new EmptyResultError('kimi history', 'No chats visible in sidebar. Are you logged in?');
|
||||
}
|
||||
const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 30;
|
||||
return items.slice(0, limit).map((r, i) => ({ Index: i + 1, Title: r.title, ChatId: r.id }));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- detail --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'detail',
|
||||
access: 'read',
|
||||
description: 'Open a Kimi chat by ID and return its visible messages.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Chat ID or full /chat/<id> URL' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 20 },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const id = parseChatId(kwargs.id);
|
||||
if (!id) throw new ArgumentError('id', 'is required');
|
||||
// Same trick as maybeNavigateConv: include chat_enter_method=history
|
||||
// to actually trigger Kimi's messages fetch.
|
||||
await page.goto(`${KIMI_URL}chat/${id}?chat_enter_method=history`);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const ok = await page.evaluate(`(() => {
|
||||
const list = document.querySelector('.chat-content-list') || document.querySelector('.message-list');
|
||||
return !!list && list.querySelectorAll('.chat-content-item, .segment').length > 0;
|
||||
})()`);
|
||||
if (ok) break;
|
||||
await page.wait(1);
|
||||
}
|
||||
const turns = await readKimiTurns(page);
|
||||
if (!turns.length) {
|
||||
throw new EmptyResultError('kimi detail', `No messages found in /chat/${id}.`);
|
||||
}
|
||||
const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 20;
|
||||
return turns.slice(0, limit).map((t, i) => ({ Index: i + 1, Role: t.role, Text: (t.text || '').slice(0, 1200) }));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- read --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'read',
|
||||
access: 'read',
|
||||
description: 'Read messages in the current Kimi chat. Pass --conv <id> to navigate to a specific chat first.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'conv', required: false, help: 'Chat id or URL (navigates there before reading)' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 20 },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await maybeNavigateConv(page, kwargs?.conv);
|
||||
const turns = await readKimiTurns(page);
|
||||
if (!turns.length) {
|
||||
throw new EmptyResultError('kimi read', 'No chat turns found on current page.');
|
||||
}
|
||||
const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 20;
|
||||
return turns.slice(0, limit).map((t, i) => ({ Index: i + 1, Role: t.role, Text: (t.text || '').slice(0, 1200) }));
|
||||
},
|
||||
});
|
||||
|
||||
// Helper for read / detail: extract turns from the current chat content list.
|
||||
// Kimi renders messages in `.chat-content-list` as `.chat-content-item`
|
||||
// children — each gets a `chat-content-item-user` or `chat-content-item-assistant`
|
||||
// modifier class (also mirrored on the inner `.segment-user` / `.segment-assistant`).
|
||||
async function readKimiTurns(page) {
|
||||
return await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
// Prefer .chat-content-list (the actual messages container); fall back
|
||||
// to .message-list (older Kimi UI) and .chat-detail-content (parent).
|
||||
const box = document.querySelector('.chat-content-list') || document.querySelector('.message-list') || document.querySelector('.chat-detail-content');
|
||||
if (!box) return [];
|
||||
// Find every chat-content-item or segment row.
|
||||
const rows = Array.from(box.querySelectorAll('.chat-content-item, .segment')).filter(isVisible);
|
||||
const turns = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const tx = (row.innerText || row.textContent || '').trim().replace(/\\s+/g, ' ');
|
||||
if (!tx || tx.length < 2) continue;
|
||||
if (seen.has(tx)) continue;
|
||||
const cls = (row.className || '').toString().toLowerCase();
|
||||
let role = 'Turn';
|
||||
if (/chat-content-item-user|segment-user|user|sent-by-user|me-/i.test(cls)) role = 'User';
|
||||
else if (/chat-content-item-assistant|segment-assistant|assistant|ai-|kimi-|response/i.test(cls)) role = 'Assistant';
|
||||
else if (row.querySelector('svg[name="Copy"], svg[name="Refresh"], svg[name="Like"]')) role = 'Assistant';
|
||||
else role = 'User';
|
||||
seen.add(tx);
|
||||
turns.push({ role, text: tx });
|
||||
}
|
||||
return turns;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function sendKimiMessage(page, text) {
|
||||
const prompt = String(text || '').trim();
|
||||
if (!prompt) throw new ArgumentError('text', 'is required');
|
||||
await ensureOnKimi(page);
|
||||
const beforeUsers = await page.evaluate(`(() => {
|
||||
return Array.from(document.querySelectorAll('.chat-content-list .chat-content-item, .message-list > *, .segment'))
|
||||
.filter((row) => /user|sent-by-user|me-/i.test(String(row.className || ''))).length;
|
||||
})()`);
|
||||
const typeRes = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const editor = document.querySelector('[contenteditable="true"][role="textbox"]');
|
||||
if (!editor || !isVisible(editor)) return { ok: false, reason: 'Kimi composer not visible.' };
|
||||
editor.focus();
|
||||
document.execCommand('selectAll', false);
|
||||
document.execCommand('insertText', false, ${JSON.stringify(prompt)});
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!typeRes?.ok) throw new CommandExecutionError(typeRes?.reason || 'composer type failed', '');
|
||||
await page.wait(0.3);
|
||||
const sendRes = await page.evaluate(clickBySvgNameScript('Send'));
|
||||
if (!sendRes?.ok) throw new CommandExecutionError(sendRes?.reason || 'Send button click failed', '');
|
||||
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline) {
|
||||
const verified = await page.evaluate(`(() => {
|
||||
const normalize = (value) => String(value || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
||||
const prompt = normalize(${JSON.stringify(prompt)});
|
||||
const before = Number(${JSON.stringify(beforeUsers)}) || 0;
|
||||
const rows = Array.from(document.querySelectorAll('.chat-content-list .chat-content-item, .message-list > *, .segment'))
|
||||
.filter((row) => /user|sent-by-user|me-/i.test(String(row.className || '')));
|
||||
return rows.slice(before).some((row) => normalize(row.innerText || row.textContent).includes(prompt));
|
||||
})()`);
|
||||
if (verified) return { prompt };
|
||||
await page.wait(0.2);
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
'Kimi message submission was not verified',
|
||||
'The prompt was injected and Send was clicked, but no new user turn containing that prompt appeared.',
|
||||
);
|
||||
}
|
||||
|
||||
// -------- send --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'send',
|
||||
access: 'write',
|
||||
description: 'Send a message in the current Kimi chat (fire-and-forget; does not wait for reply).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'text', positional: true, required: true, help: 'Message text' },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const result = await sendKimiMessage(page, kwargs?.text);
|
||||
return [{ Status: 'sent', Length: String(result.prompt.length) }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- copy-message --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'copy-message',
|
||||
access: 'write',
|
||||
description: 'Return the text of the last assistant message. Pass --conv <id> to navigate to a specific chat first.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'conv', required: false, help: 'Chat id or URL (navigates there before reading)' },
|
||||
{ name: 'click-button', type: 'boolean', default: false, help: 'Also click in-UI Copy button (writes to clipboard)' },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await maybeNavigateConv(page, kwargs?.conv);
|
||||
const turns = await readKimiTurns(page);
|
||||
const assistantTurns = turns.filter((t) => t.role === 'Assistant');
|
||||
if (!assistantTurns.length) {
|
||||
throw new EmptyResultError('kimi copy-message', 'No assistant message visible.');
|
||||
}
|
||||
const last = assistantTurns[assistantTurns.length - 1];
|
||||
if (kwargs?.['click-button'] === true || kwargs?.['click-button'] === 'true') {
|
||||
const clickRes = await page.evaluate(clickBySvgNameScript('Copy'));
|
||||
if (!clickRes?.ok) throw new CommandExecutionError(clickRes?.reason || 'Copy button not visible', '');
|
||||
}
|
||||
return [
|
||||
{ Field: 'Length', Value: String((last.text || '').length) + ' chars' },
|
||||
{ Field: 'ClipboardClicked', Value: (kwargs?.['click-button'] === true || kwargs?.['click-button'] === 'true') ? 'yes' : 'no' },
|
||||
{ Field: 'Text', Value: last.text || '' },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- regenerate --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'regenerate',
|
||||
access: 'write',
|
||||
description: 'Click Refresh (Kimi\'s regenerate button) on the last assistant message. Pass --conv <id> to target a specific chat.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'conv', required: false, help: 'Chat id or URL' },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await maybeNavigateConv(page, kwargs?.conv);
|
||||
const res = await page.evaluate(clickBySvgNameScript('Refresh'));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Refresh button not visible', '');
|
||||
return [{ Status: 'regenerated' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- react --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'react',
|
||||
access: 'write',
|
||||
description: 'Like or dislike the last assistant message. Pass --conv <id> to target a specific chat.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'kind', positional: true, required: true, help: 'like or dislike' },
|
||||
{ name: 'conv', required: false, help: 'Chat id or URL' },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const kind = String(kwargs?.kind || '').trim().toLowerCase();
|
||||
if (kind !== 'like' && kind !== 'dislike') throw new ArgumentError('kind', 'must be "like" or "dislike"');
|
||||
await maybeNavigateConv(page, kwargs?.conv);
|
||||
const svgName = kind === 'like' ? 'Like' : 'Dislike';
|
||||
const res = await page.evaluate(clickBySvgNameScript(svgName));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || `${kind} button not visible`, '');
|
||||
return [{ Status: 'clicked', Reaction: kind }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- share --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'share',
|
||||
access: 'write',
|
||||
description: 'Click Share on the last assistant message (opens Kimi\'s share dialog). Pass --conv <id> to target a specific chat.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'conv', required: false, help: 'Chat id or URL' },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await maybeNavigateConv(page, kwargs?.conv);
|
||||
const res = await page.evaluate(clickBySvgNameScript('Share_a'));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Share button not visible', '');
|
||||
await page.wait(1);
|
||||
return [{ Status: 'share dialog opened' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- ask --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'ask',
|
||||
access: 'write',
|
||||
description: 'Send a message and wait up to --timeout seconds for the assistant reply (best-effort: polls for turn count to grow + stabilize).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'text', positional: true, required: true, help: 'Prompt text' },
|
||||
{ name: 'timeout', type: 'int', required: false, default: 120 },
|
||||
],
|
||||
columns: CHAT_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const text = String(kwargs?.text || '').trim();
|
||||
if (!text) throw new ArgumentError('text', 'is required');
|
||||
const timeoutSec = Number.isInteger(kwargs?.timeout) && kwargs.timeout > 0 ? kwargs.timeout : 120;
|
||||
await ensureOnKimi(page);
|
||||
const baselineAssistant = (await readKimiTurns(page)).filter((t) => t.role === 'Assistant').length;
|
||||
await sendKimiMessage(page, text);
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + timeoutSec * 1000;
|
||||
let latestText = '';
|
||||
let stable = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await page.wait(1.5);
|
||||
const turns = await readKimiTurns(page).catch(() => []);
|
||||
const assistantTurns = turns.filter((t) => t.role === 'Assistant');
|
||||
if (assistantTurns.length <= baselineAssistant) continue;
|
||||
const next = assistantTurns[assistantTurns.length - 1]?.text || '';
|
||||
if (next && next === latestText) {
|
||||
stable++;
|
||||
} else {
|
||||
latestText = next;
|
||||
stable = 0;
|
||||
}
|
||||
if (latestText && stable >= 2) break;
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
if (!latestText) {
|
||||
throw new TimeoutError('kimi ask', timeoutSec, 'No new Kimi assistant reply was visible before the timeout.');
|
||||
}
|
||||
return [{
|
||||
Status: 'reply-received',
|
||||
Length: String(text.length),
|
||||
WaitedSeconds: String(elapsed),
|
||||
ReplyPreview: latestText.slice(0, 300),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
|
||||
import { isKimiUrl, parseChatId } from './_utils.js';
|
||||
import './chat.js';
|
||||
import './ui.js';
|
||||
import './storage.js';
|
||||
import './audit-extras.js';
|
||||
|
||||
function makePage(evaluateResults = []) {
|
||||
const queue = [...evaluateResults];
|
||||
return {
|
||||
evaluate: vi.fn(async () => (queue.length ? queue.shift() : null)),
|
||||
goto: vi.fn(async () => {}),
|
||||
wait: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('kimi adapter registration', () => {
|
||||
it('registers read/write command access by maximum side effect', () => {
|
||||
const expected = {
|
||||
status: 'read',
|
||||
history: 'read',
|
||||
detail: 'read',
|
||||
read: 'read',
|
||||
send: 'write',
|
||||
ask: 'write',
|
||||
new: 'write',
|
||||
'copy-message': 'write',
|
||||
regenerate: 'write',
|
||||
react: 'write',
|
||||
share: 'write',
|
||||
model: 'write',
|
||||
'history-rename': 'write',
|
||||
'sign-out': 'write',
|
||||
};
|
||||
for (const [name, access] of Object.entries(expected)) {
|
||||
const cmd = getRegistry().get(`kimi/${name}`);
|
||||
expect(cmd, `kimi/${name}`).toBeDefined();
|
||||
expect(cmd.access).toBe(access);
|
||||
expect(cmd.domain).toBe('kimi.com');
|
||||
expect(cmd.siteSession).toBe('persistent');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('kimi chat id parsing', () => {
|
||||
it('accepts bare ids and exact Kimi chat URLs only', () => {
|
||||
expect(parseChatId('1234abcd')).toBe('1234abcd');
|
||||
expect(parseChatId('/chat/1234ABCD?x=1')).toBe('1234abcd');
|
||||
expect(parseChatId('/chat/1234ABCD')).toBe('1234abcd');
|
||||
expect(parseChatId('https://www.kimi.com/chat/1234ABCD?x=1#top')).toBe('1234abcd');
|
||||
expect(parseChatId('http://www.kimi.com/chat/1234abcd')).toBe('');
|
||||
expect(parseChatId('https://kimi.com.evil/chat/1234abcd')).toBe('');
|
||||
expect(parseChatId('https://evil.example/chat/1234abcd')).toBe('');
|
||||
expect(parseChatId('https://www.kimi.com/chat/1234abcd/extra')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kimi target boundary', () => {
|
||||
it('accepts only https kimi hosts as the current app target', () => {
|
||||
expect(isKimiUrl('https://kimi.com/')).toBe(true);
|
||||
expect(isKimiUrl('https://www.kimi.com/chat/1234abcd')).toBe(true);
|
||||
expect(isKimiUrl('http://www.kimi.com/')).toBe(false);
|
||||
expect(isKimiUrl('https://kimi.com.evil/chat/1234abcd')).toBe(false);
|
||||
expect(isKimiUrl('https://evil.example/?next=https://kimi.com/chat/1234abcd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kimi write postconditions', () => {
|
||||
let sendCommand;
|
||||
let askCommand;
|
||||
let modelCommand;
|
||||
|
||||
beforeAll(() => {
|
||||
sendCommand = getRegistry().get('kimi/send');
|
||||
askCommand = getRegistry().get('kimi/ask');
|
||||
modelCommand = getRegistry().get('kimi/model');
|
||||
});
|
||||
|
||||
it('send fails closed when clicking Send does not create a matching user turn', async () => {
|
||||
const page = makePage([
|
||||
'https://www.kimi.com/',
|
||||
0,
|
||||
{ ok: true },
|
||||
{ ok: true },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
let now = 1_000;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
now += 1_000;
|
||||
return now;
|
||||
});
|
||||
try {
|
||||
await expect(sendCommand.func(page, { text: 'ping' }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('ask throws typed timeout instead of returning a timeout success row', async () => {
|
||||
const page = makePage([
|
||||
'https://www.kimi.com/',
|
||||
[],
|
||||
'https://www.kimi.com/',
|
||||
0,
|
||||
{ ok: true },
|
||||
{ ok: true },
|
||||
true,
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
let now = 1_000;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
now += 2_000;
|
||||
return now;
|
||||
});
|
||||
try {
|
||||
await expect(askCommand.func(page, { text: 'ping', timeout: 1 }))
|
||||
.rejects.toBeInstanceOf(TimeoutError);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('model rejects ambiguous partial names before clicking an option', async () => {
|
||||
const page = makePage([
|
||||
'https://www.kimi.com/',
|
||||
'K2.6',
|
||||
undefined,
|
||||
['K2.6 思考', 'K2.6 快速'],
|
||||
]);
|
||||
await expect(modelCommand.func(page, { set: 'K2.6' }))
|
||||
.rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// Browser-side state on kimi.com (analog to Grok's storage commands).
|
||||
//
|
||||
// storage-keys [--storage local|session] [--filter]
|
||||
// storage-get <key> [--storage] [--max-bytes]
|
||||
// cookies — list JS-visible cookies
|
||||
// idb-list — list IndexedDB databases on kimi.com
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import { KIMI_DOMAIN, ensureOnKimi } from './_utils.js';
|
||||
|
||||
const STORAGE_COLUMNS = ['Field', 'Value', 'Index', 'Key', 'Bytes', 'Name', 'Preview', 'Database', 'Version'];
|
||||
|
||||
function pickStore(args) {
|
||||
const s = String(args?.storage || 'local').trim().toLowerCase();
|
||||
if (s !== 'local' && s !== 'session') {
|
||||
throw new ArgumentError('storage', 'must be "local" or "session"');
|
||||
}
|
||||
return s === 'session' ? 'sessionStorage' : 'localStorage';
|
||||
}
|
||||
|
||||
// -------- storage-keys --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'storage-keys',
|
||||
access: 'read',
|
||||
description: 'List localStorage / sessionStorage keys on kimi.com (with byte sizes).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'storage', required: false, default: 'local', help: '"local" or "session"' },
|
||||
{ name: 'filter', required: false, help: 'Case-insensitive substring filter over keys' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 100 },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await ensureOnKimi(page);
|
||||
const store = pickStore(kwargs);
|
||||
const raw = 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('kimi 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 }));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- storage-get --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'storage-get',
|
||||
access: 'read',
|
||||
description: 'Read a single localStorage / sessionStorage value on kimi.com. Auto-decodes JSON.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'key', positional: true, required: true, help: 'Storage key' },
|
||||
{ name: 'storage', required: false, default: 'local' },
|
||||
{ name: 'max-bytes', type: 'int', required: false, default: 4000 },
|
||||
],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const key = String(kwargs?.key || '').trim();
|
||||
if (!key) throw new ArgumentError('key', 'is required');
|
||||
await ensureOnKimi(page);
|
||||
const store = pickStore(kwargs);
|
||||
const raw = await page.evaluate(`${store}.getItem(${JSON.stringify(key)})`);
|
||||
if (raw === null || raw === undefined) {
|
||||
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;
|
||||
let 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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- cookies --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'cookies',
|
||||
access: 'read',
|
||||
description: 'List kimi.com cookies visible to JavaScript (httpOnly cookies are deliberately not shown).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const raw = await page.evaluate('document.cookie');
|
||||
if (!raw) {
|
||||
throw new EmptyResultError('kimi cookies', 'document.cookie is empty (likely all cookies are httpOnly).');
|
||||
}
|
||||
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 ? '…' : ''),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- idb-list --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'idb-list',
|
||||
access: 'read',
|
||||
description: 'List IndexedDB databases on kimi.com.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: STORAGE_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const dbs = await page.evaluate(`(async () => {
|
||||
if (!indexedDB.databases) return [];
|
||||
return await indexedDB.databases();
|
||||
})()`);
|
||||
if (!Array.isArray(dbs) || !dbs.length) {
|
||||
throw new EmptyResultError('kimi idb-list', 'No IndexedDB databases.');
|
||||
}
|
||||
return dbs.map((d, i) => ({ Index: i + 1, Database: d.name || '(unnamed)', Version: String(d.version || '') }));
|
||||
},
|
||||
});
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
// Sidebar / mode-navigation commands for Kimi.
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import {
|
||||
KIMI_DOMAIN,
|
||||
KIMI_URL,
|
||||
IS_VISIBLE_JS,
|
||||
ensureOnKimi,
|
||||
clickBySvgNameScript,
|
||||
navByHrefScript,
|
||||
} from './_utils.js';
|
||||
|
||||
const UI_COLUMNS = ['Mode', 'Status', 'Url', 'Field', 'Value', 'Index', 'Model', 'Active'];
|
||||
|
||||
// Kimi exposes 7 specialized work modes via sidebar links.
|
||||
// Mapping: cli name → URL hash
|
||||
const MODES = {
|
||||
ppt: '/slides',
|
||||
docs: '/docs',
|
||||
'deep-research': '/deep-research',
|
||||
websites: '/websites',
|
||||
sheets: '/sheets',
|
||||
'agent-swarm': '/agent-swarm',
|
||||
code: '/code',
|
||||
};
|
||||
|
||||
// -------- mode --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'mode',
|
||||
access: 'write',
|
||||
description: 'Switch to a Kimi work mode: ppt | docs | deep-research | websites | sheets | agent-swarm | code. With no argument, lists modes.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'name', positional: true, required: false, help: 'Mode name (omit to list all)' },
|
||||
],
|
||||
columns: UI_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
const name = String(kwargs?.name || '').trim().toLowerCase();
|
||||
if (!name) {
|
||||
return Object.entries(MODES).map(([m, url]) => ({
|
||||
Mode: m,
|
||||
Status: 'available',
|
||||
Url: KIMI_URL + url.slice(1),
|
||||
}));
|
||||
}
|
||||
const target = MODES[name];
|
||||
if (!target) {
|
||||
throw new ArgumentError('name', `unknown mode "${name}". Known: ${Object.keys(MODES).join(', ')}`);
|
||||
}
|
||||
await ensureOnKimi(page);
|
||||
await page.goto(`${KIMI_URL}${target.slice(1)}`);
|
||||
await page.wait(1);
|
||||
const url = await page.evaluate('window.location.href');
|
||||
return [{ Mode: name, Status: 'navigated', Url: String(url || '') }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- sidebar-toggle --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'sidebar-toggle',
|
||||
access: 'write',
|
||||
description: 'Click the LeftBar svg to toggle the Kimi sidebar.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: UI_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const res = await page.evaluate(clickBySvgNameScript('LeftBar', { last: false }));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'LeftBar svg not visible', '');
|
||||
return [{ Status: 'toggled' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- view-all-history --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'view-all-history',
|
||||
access: 'write',
|
||||
description: 'Navigate to /chat/history (full conversation list page).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: UI_COLUMNS,
|
||||
func: async (page) => {
|
||||
await page.goto(`${KIMI_URL}chat/history`);
|
||||
await page.wait(1);
|
||||
const url = await page.evaluate('window.location.href');
|
||||
return [{ Status: 'navigated', Url: String(url || '') }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- settings --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'settings',
|
||||
access: 'write',
|
||||
description: 'Open the Kimi settings page (/settings).',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: UI_COLUMNS,
|
||||
func: async (page) => {
|
||||
await page.goto(`${KIMI_URL}settings`);
|
||||
await page.wait(1);
|
||||
const url = await page.evaluate('window.location.href');
|
||||
return [{ Status: 'navigated', Url: String(url || '') }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- account --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'account',
|
||||
access: 'read',
|
||||
description: 'Read account info from the Kimi sidebar (display name + plan label, e.g. "Allegretto").',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: UI_COLUMNS,
|
||||
func: async (page) => {
|
||||
await ensureOnKimi(page);
|
||||
const data = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const avatar = document.querySelector('img[src*="avatar.moonshot.cn"]');
|
||||
if (!avatar) return null;
|
||||
// The display name + plan are siblings in a parent container.
|
||||
let container = avatar;
|
||||
for (let i = 0; i < 5; i++) { if (container.parentElement) container = container.parentElement; }
|
||||
const spans = Array.from(container.querySelectorAll('span, div')).filter(isVisible)
|
||||
.map((el) => (el.innerText || el.textContent || '').trim())
|
||||
.filter((t) => t && t.length < 60);
|
||||
const uniq = [...new Set(spans)];
|
||||
return {
|
||||
avatarUrl: avatar.src,
|
||||
avatarAlt: avatar.alt || '',
|
||||
labels: uniq.slice(0, 10),
|
||||
};
|
||||
})()`);
|
||||
if (!data) {
|
||||
throw new CommandExecutionError('No avatar found — not logged in?', '');
|
||||
}
|
||||
const rows = [
|
||||
{ Field: 'AvatarAlt', Value: data.avatarAlt || '(none)' },
|
||||
{ Field: 'AvatarUrl', Value: data.avatarUrl },
|
||||
];
|
||||
data.labels.forEach((l, i) => rows.push({ Field: `Label[${i + 1}]`, Value: l }));
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
// -------- model --------
|
||||
cli({
|
||||
site: 'kimi',
|
||||
name: 'model',
|
||||
access: 'write',
|
||||
description: 'Read the current Kimi model (e.g. "K2.6 思考") or switch by clicking the model dropdown. With no argument, returns current; with --list, opens dropdown + lists; with --set <name>, switches.',
|
||||
domain: KIMI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
siteSession: 'persistent',
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'list', type: 'boolean', default: false, help: 'Open model dropdown + list options' },
|
||||
{ name: 'set', required: false, help: 'Substring (case-insensitive) of model to switch to' },
|
||||
],
|
||||
columns: UI_COLUMNS,
|
||||
func: async (page, kwargs) => {
|
||||
await ensureOnKimi(page);
|
||||
const wantList = kwargs?.list === true || kwargs?.list === 'true';
|
||||
const wantSet = String(kwargs?.set || '').trim();
|
||||
|
||||
// Read current model label from the composer toolbar.
|
||||
const cur = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
// The model button is a div containing a Down_b svg + a span with model name.
|
||||
const svgs = Array.from(document.querySelectorAll('svg[name="Down_b"]')).filter(isVisible);
|
||||
for (const svg of svgs) {
|
||||
let p = svg.parentElement;
|
||||
for (let i = 0; i < 4 && p; i++) {
|
||||
const spans = p.querySelectorAll('span');
|
||||
for (const s of spans) {
|
||||
const t = (s.textContent || '').trim();
|
||||
if (/^K\\d|^Kimi |^Pro\\b|^Auto/.test(t)) return t;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})()`);
|
||||
if (!wantList && !wantSet) {
|
||||
return [{ Index: 1, Model: cur || '(unknown)', Active: 'yes' }];
|
||||
}
|
||||
|
||||
// Open the dropdown by clicking the Down_b svg next to the model name.
|
||||
await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const svgs = Array.from(document.querySelectorAll('svg[name="Down_b"]')).filter(isVisible);
|
||||
// The model trigger is usually the one with a sibling span starting with "K"
|
||||
for (const svg of svgs) {
|
||||
let p = svg.parentElement;
|
||||
for (let i = 0; i < 4 && p; i++) {
|
||||
const spans = p.querySelectorAll('span');
|
||||
const match = Array.from(spans).find((s) => /^K\\d|^Kimi |^Pro\\b|^Auto/.test((s.textContent || '').trim()));
|
||||
if (match) {
|
||||
p.click();
|
||||
return;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
}
|
||||
})()`);
|
||||
await page.wait(0.5);
|
||||
const opts = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
// Dropdown items appear in a floating popover.
|
||||
const popovers = Array.from(document.querySelectorAll('[class*="popover"i], [class*="dropdown"i], [role="menu"], [role="listbox"]')).filter(isVisible)
|
||||
.filter((el) => { const r = el.getBoundingClientRect(); return r.width < 600 && r.height < 600; });
|
||||
if (!popovers.length) return [];
|
||||
const pop = popovers[popovers.length - 1];
|
||||
return Array.from(pop.querySelectorAll('div, li, button, [role="option"], [role="menuitem"]'))
|
||||
.filter(isVisible)
|
||||
.map((el) => (el.innerText || el.textContent || '').trim().replace(/\\s+/g, ' '))
|
||||
.filter((t) => t && t.length < 80 && (/K\\d|Kimi|Pro\\b|Auto|思考/.test(t)))
|
||||
.filter((t, i, arr) => arr.indexOf(t) === i)
|
||||
.slice(0, 20);
|
||||
})()`);
|
||||
|
||||
if (wantSet) {
|
||||
const normalizeModel = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9.\u4e00-\u9fa5]+/g, '');
|
||||
const needle = normalizeModel(wantSet);
|
||||
const exactIdx = opts.findIndex((t) => normalizeModel(t) === needle);
|
||||
const partialMatches = exactIdx >= 0 ? [] : opts
|
||||
.map((model, index) => ({ model, index }))
|
||||
.filter((item) => normalizeModel(item.model).includes(needle));
|
||||
if (exactIdx < 0 && partialMatches.length > 1) {
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
throw new ArgumentError('set', `Model "${wantSet}" is ambiguous: ${partialMatches.map(item => item.model).join(', ')}`);
|
||||
}
|
||||
const idx = exactIdx >= 0 ? exactIdx : partialMatches[0]?.index ?? -1;
|
||||
if (idx < 0) {
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
throw new ArgumentError('set', `No model matched "${wantSet}". Available: ${opts.join(', ')}`);
|
||||
}
|
||||
const clickRes = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const popovers = Array.from(document.querySelectorAll('[class*="popover"i], [class*="dropdown"i], [role="menu"], [role="listbox"]')).filter(isVisible);
|
||||
const pop = popovers[popovers.length - 1];
|
||||
if (!pop) return { ok: false };
|
||||
const items = Array.from(pop.querySelectorAll('div, li, button, [role="option"], [role="menuitem"]'))
|
||||
.filter(isVisible)
|
||||
.filter((el) => { const t = (el.innerText || '').trim(); return t && t.length < 80 && (/K\\d|Kimi|Pro\\b|Auto|思考/.test(t)); });
|
||||
const target = items[${idx}];
|
||||
if (!target) return { ok: false };
|
||||
target.click();
|
||||
return { ok: true, clicked: (target.innerText || '').trim() };
|
||||
})()`);
|
||||
if (!clickRes?.ok) throw new CommandExecutionError('Failed to click model option', '');
|
||||
await page.wait(0.5);
|
||||
const verified = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const svgs = Array.from(document.querySelectorAll('svg[name="Down_b"]')).filter(isVisible);
|
||||
for (const svg of svgs) {
|
||||
let p = svg.parentElement;
|
||||
for (let i = 0; i < 4 && p; i++) {
|
||||
const spans = p.querySelectorAll('span');
|
||||
for (const s of spans) {
|
||||
const t = (s.textContent || '').trim();
|
||||
if (/^K\\d|^Kimi |^Pro\\b|^Auto/.test(t)) return t;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})()`);
|
||||
if (normalizeModel(verified) !== normalizeModel(clickRes.clicked)) {
|
||||
throw new CommandExecutionError(
|
||||
`Kimi model switch did not verify the requested model: requested "${wantSet}", current "${verified || 'not visible'}"`,
|
||||
'Open the Kimi model menu and verify the requested model is selectable, then retry.',
|
||||
);
|
||||
}
|
||||
return [{ Index: 1, Model: clickRes.clicked, Active: 'switched' }];
|
||||
}
|
||||
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
if (!opts.length) {
|
||||
throw new EmptyResultError('kimi model', 'Model dropdown opened but no options detected.');
|
||||
}
|
||||
return opts.map((m, i) => ({ Index: i + 1, Model: m, Active: m === cur ? 'yes' : '' }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasLinkedinSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
|
||||
return cookies.some(c => c.name === 'li_at' && c.value);
|
||||
}
|
||||
|
||||
async function verifyLinkedinLearningIdentity(page) {
|
||||
if (!await hasLinkedinSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linkedin.com', 'LinkedIn li_at cookie missing');
|
||||
}
|
||||
await page.goto('https://www.linkedin.com/learning/');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const jsessionRaw = (document.cookie.split('; ').find(c => c.startsWith('JSESSIONID=')) || '').split('=')[1] || '';
|
||||
const csrf = jsessionRaw.replace(/^"|"$/g, '');
|
||||
if (!csrf) return { kind: 'auth', detail: 'LinkedIn JSESSIONID missing — csrf token unavailable' };
|
||||
const res = await fetch('/voyager/api/me', { credentials: 'include', headers: { 'csrf-token': csrf, 'Accept': 'application/json' } });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'LinkedIn /voyager/api/me HTTP ' + res.status };
|
||||
}
|
||||
if (!res.ok) return { kind: 'http', httpStatus: res.status };
|
||||
const d = await res.json();
|
||||
const mini = d && d.miniProfile;
|
||||
if (!mini || !mini.publicIdentifier) {
|
||||
return { kind: 'auth', detail: 'LinkedIn /voyager/api/me 200 but miniProfile missing' };
|
||||
}
|
||||
const firstName = (mini.firstName && (mini.firstName.text || mini.firstName)) || '';
|
||||
const lastName = (mini.lastName && (mini.lastName.text || mini.lastName)) || '';
|
||||
return {
|
||||
ok: true,
|
||||
public_id: String(mini.publicIdentifier),
|
||||
plain_id: String(d.plainId || ''),
|
||||
name: String((firstName + ' ' + lastName).trim()),
|
||||
};
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('linkedin.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /voyager/api/me`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`LinkedIn Learning whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected LinkedIn Learning probe: ${JSON.stringify(result)}`);
|
||||
return { public_id: result.public_id, plain_id: result.plain_id, name: result.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'linkedin-learning',
|
||||
domain: 'linkedin.com',
|
||||
loginUrl: 'https://www.linkedin.com/login?session_redirect=%2Flearning%2F',
|
||||
columns: ['public_id', 'plain_id', 'name'],
|
||||
quickCheck: hasLinkedinSessionCookie,
|
||||
verify: verifyLinkedinLearningIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasLinkedinSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linkedin.com', 'Waiting for LinkedIn li_at cookie');
|
||||
}
|
||||
return verifyLinkedinLearningIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasLinkedinSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
|
||||
return cookies.some(c => c.name === 'li_at' && c.value);
|
||||
}
|
||||
|
||||
async function verifyLinkedinIdentity(page) {
|
||||
if (!await hasLinkedinSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linkedin.com', 'LinkedIn li_at cookie missing');
|
||||
}
|
||||
await page.goto('https://www.linkedin.com/feed/');
|
||||
await page.wait(2);
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const jsessionRaw = (document.cookie.split('; ').find(c => c.startsWith('JSESSIONID=')) || '').split('=')[1] || '';
|
||||
const csrf = jsessionRaw.replace(/^"|"$/g, '');
|
||||
if (!csrf) return { kind: 'auth', detail: 'LinkedIn JSESSIONID missing — csrf token unavailable' };
|
||||
const res = await fetch('/voyager/api/me', { credentials: 'include', headers: { 'csrf-token': csrf, 'Accept': 'application/json' } });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { kind: 'auth', detail: 'LinkedIn /voyager/api/me HTTP ' + res.status };
|
||||
}
|
||||
if (!res.ok) return { kind: 'http', httpStatus: res.status };
|
||||
const d = await res.json();
|
||||
const mini = d && d.miniProfile;
|
||||
if (!mini || !mini.publicIdentifier) {
|
||||
return { kind: 'auth', detail: 'LinkedIn /voyager/api/me 200 but miniProfile missing' };
|
||||
}
|
||||
const firstName = (mini.firstName && (mini.firstName.text || mini.firstName)) || '';
|
||||
const lastName = (mini.lastName && (mini.lastName.text || mini.lastName)) || '';
|
||||
return {
|
||||
ok: true,
|
||||
public_id: String(mini.publicIdentifier),
|
||||
plain_id: String(d.plainId || ''),
|
||||
name: String((firstName + ' ' + lastName).trim()),
|
||||
};
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (result?.kind === 'auth') throw new AuthRequiredError('linkedin.com', result.detail);
|
||||
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /voyager/api/me`);
|
||||
if (result?.kind === 'exception') throw new CommandExecutionError(`LinkedIn whoami failed: ${result.detail}`);
|
||||
if (!result?.ok) throw new CommandExecutionError(`Unexpected LinkedIn probe: ${JSON.stringify(result)}`);
|
||||
return { public_id: result.public_id, plain_id: result.plain_id, name: result.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'linkedin',
|
||||
domain: 'www.linkedin.com',
|
||||
loginUrl: 'https://www.linkedin.com/login',
|
||||
columns: ['public_id', 'plain_id', 'name'],
|
||||
quickCheck: hasLinkedinSessionCookie,
|
||||
verify: verifyLinkedinIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasLinkedinSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linkedin.com', 'Waiting for LinkedIn li_at cookie');
|
||||
}
|
||||
return verifyLinkedinIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasLinuxDoSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://linux.do' });
|
||||
return cookies.some(c => c.name === '_t' && c.value);
|
||||
}
|
||||
|
||||
async function verifyLinuxDoIdentity(page) {
|
||||
if (!await hasLinuxDoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linux.do', 'Linux.do _t cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://linux.do/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const u = document.querySelector('meta[name="current-user-username"]')?.getAttribute('content') || '';
|
||||
if (!u) return { kind: 'auth', detail: 'Linux.do meta[current-user-username] missing — anonymous' };
|
||||
const r = await fetch('/u/' + encodeURIComponent(u) + '.json', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
return { kind: 'auth', detail: 'Linux.do /u/<self>.json HTTP ' + r.status };
|
||||
}
|
||||
if (!r.ok) return { kind: 'http', httpStatus: r.status };
|
||||
const d = await r.json();
|
||||
const user = d?.user;
|
||||
if (!user || !user.id) return { kind: 'auth', detail: 'Linux.do /u/<self>.json missing user.id' };
|
||||
return { ok: true, user_id: String(user.id), username: String(user.username || u), name: String(user.name || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('linux.do', probe.detail);
|
||||
if (probe?.kind === 'http') throw new CommandExecutionError(`HTTP ${probe.httpStatus} from Linux.do /u/<self>.json`);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`Linux.do whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Linux.do probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, username: probe.username, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'linux-do',
|
||||
domain: 'linux.do',
|
||||
loginUrl: 'https://linux.do/login',
|
||||
columns: ['user_id', 'username', 'name'],
|
||||
quickCheck: hasLinuxDoSessionCookie,
|
||||
verify: verifyLinuxDoIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasLinuxDoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linux.do', 'Waiting for Linux.do _t cookie');
|
||||
}
|
||||
return verifyLinuxDoIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Shared helpers for the Manus (manus.im) web adapter.
|
||||
//
|
||||
// Manus is an AI agent platform. Auth uses a `session_id` cookie
|
||||
// (JWT, ~357 bytes) readable on the manus.im domain. The API uses
|
||||
// Connect-RPC (POST + JSON + Connect-Protocol-Version: 1).
|
||||
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const MANUS_DOMAIN = 'manus.im';
|
||||
export const MANUS_URL = 'https://manus.im/app';
|
||||
export const API_HOST = 'https://api.manus.im';
|
||||
|
||||
export function isManusUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
const host = url.hostname.toLowerCase();
|
||||
return url.protocol === 'https:' && (host === MANUS_DOMAIN || host === `www.${MANUS_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `--limit N` argument: must be a positive integer ≤ `max`.
|
||||
* Negatives, zero, NaN, Infinity, and non-integers all reject. Manus's
|
||||
* Connect-RPC backend enforces these server-side via Buf Validate; failing
|
||||
* client-side gives the user a clearer error and skips a wasted round-trip.
|
||||
*/
|
||||
export function validatedLimit(raw, fallback, max = 1000) {
|
||||
const n = raw == null ? fallback : Number(raw);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > max) {
|
||||
throw new ArgumentError('limit', `must be a positive integer ≤ ${max}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function unwrapEvaluateResult(payload) {
|
||||
if (
|
||||
payload
|
||||
&& typeof payload === 'object'
|
||||
&& !Array.isArray(payload)
|
||||
&& Object.prototype.hasOwnProperty.call(payload, 'session')
|
||||
&& Object.prototype.hasOwnProperty.call(payload, 'data')
|
||||
) {
|
||||
return payload.data;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload) {
|
||||
if (!payload || typeof payload !== 'object') return '';
|
||||
const candidates = [
|
||||
payload.message,
|
||||
payload.error,
|
||||
payload.errorMessage,
|
||||
payload.details,
|
||||
];
|
||||
return candidates.find((value) => typeof value === 'string' && value.trim())?.trim() || '';
|
||||
}
|
||||
|
||||
export function requireObject(payload, label) {
|
||||
const value = unwrapEvaluateResult(payload);
|
||||
if (value?.__authRequired) {
|
||||
throw new AuthRequiredError(MANUS_DOMAIN, value.message || 'Authentication required — please sign in to Manus in the browser');
|
||||
}
|
||||
if (value?.__httpError) {
|
||||
const message = extractErrorMessage(value);
|
||||
throw new CommandExecutionError(message ? `Manus ${label} failed (HTTP ${value.__httpError}): ${message}` : `Manus ${label} failed (HTTP ${value.__httpError})`);
|
||||
}
|
||||
if (value?.__error) {
|
||||
throw new CommandExecutionError(`Manus ${label} failed: ${value.message || value.__error}`);
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new CommandExecutionError(`Manus ${label} returned a malformed API payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireArray(value, label) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new CommandExecutionError(`Manus ${label} returned a malformed API payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireString(value, label) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) {
|
||||
throw new CommandExecutionError(`Manus ${label} returned a malformed API payload`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function ensureOnManus(page) {
|
||||
const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
|
||||
if (isManusUrl(url)) return;
|
||||
await page.goto(MANUS_URL);
|
||||
await page.wait(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* IIFE preamble injected into page.evaluate() calls.
|
||||
* Provides `callManusAPI(rpcPath, body)` which reads the `session_id`
|
||||
* cookie and makes a Connect-RPC POST to api.manus.im.
|
||||
*/
|
||||
export const MANUS_API_CALL_JS = `
|
||||
const callManusAPI = async (rpcPath, body) => {
|
||||
const jwt = document.cookie.split('session_id=')[1]?.split(';')[0];
|
||||
if (!jwt) return { __authRequired: true, message: 'session_id cookie missing' };
|
||||
const r = await fetch('${API_HOST}/' + rpcPath, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + jwt,
|
||||
'Connect-Protocol-Version': '1',
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
return {
|
||||
__httpError: r.status,
|
||||
message: t.slice(0, 200),
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await r.json();
|
||||
} catch (error) {
|
||||
return { __error: 'invalid_json', message: error?.message || String(error) };
|
||||
}
|
||||
};
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user