Compare commits

...

157 Commits

Author SHA1 Message Date
jackwener a8b1d454d1 Add llms.txt for AI visibility (GEO)
Structured AI-readable description of OpenCLI: what it does, key capabilities,
install instructions, supported sites, skills, and links. Helps AI search crawlers
(ChatGPT, Perplexity, Claude) accurately describe and cite this project.
2026-06-08 01:02:20 +08:00
jakevin 678d0086d8 feat(auth): add refresh maintenance command (#1881)
* feat(auth): add refresh maintenance command

* fix(auth): avoid DOM whoami fallback during refresh
2026-06-06 23:54:06 +08:00
jakevin 9139baaef8 feat(auth): wire quickCheck into 50 adapters for auth status (#1880)
Adds the no-navigation `quickCheck` to each adapter's
registerSiteAuthCommands config so `opencli auth status` (PR #1879) resolves
login state in quick mode (CDP getCookies, no per-site goto) instead of
reporting `unknown`.

- quickCheck reuses each adapter's existing poll cookie gate (has<Site>Cookie),
  which is a logged-in-only, no-nav check returning boolean.
- Deliberately NOT wired (stay `unknown` in quick mode, available via --full):
  - gitee/hf/deepseek/quark/reuters/zsxq: no reliable logged-in cookie; they
    detect via no-nav fetch / localStorage / Bearer which need the site origin.
  - doubao/ke/coupang/manus: session cookie is present for anonymous users, so a
    cookie quickCheck would false-positive — `unknown` is more honest.

Live: auth status --site resolves logged_in/not_logged_in for cookie-gate sites
(v2ex/github/zhihu/claude/taobao/twitter/bilibili) in quick mode; excluded
sites report unknown. Audits new=0/new=0; suite 5054 passed.
2026-06-06 21:18:43 +08:00
jakevin f9abec1455 feat(auth): add aggregate status command (#1879) 2026-06-06 21:05:11 +08:00
jakevin 77b29b3d09 feat(auth): add login/whoami for additional sites
Adds site login/whoami coverage for 55 additional auth adapters using the shared site-auth helper, including the final gitee/hf/v2ex/deepseek/quark batch and fixes from live validation.

Review follow-up:
- remove direct email output from ChatGPT/Grok/Gemini/Qwen whoami
- avoid DeepSeek email fallback as display name
- avoid Upwork first/last name output
- avoid leaking Boss wt2 session cookie as user_id
- rebase on latest main and regenerate cli-manifest.json

Validation:
- clean-HOME npm test: 5049 passed, 1 skipped
- npm run check:typed-error-lint: new=0
- npm run check:silent-column-drop: new=0
- npm run build
- npm run docs:build
- git diff --check
- dist list JSON smoke
- GitHub CI green
2026-06-06 19:42:51 +08:00
Semonxue a25a2836e9 fix(xiaohongshu): accept inline topic suggestion with Enter
Reviewed-by: @codex-mini0\nReviewed-by: @First-principles-0-\n\nMerged by @pr-manager under WAWQAQ no-check override: lead+aux content green, local validations passed, GitHub mergeable, statusCheckRollup empty.
2026-06-06 02:56:01 +08:00
flyzstu 5a82ecfd3b feat(grok): add export adapters
Reviewed-by: @codex-mini0\nReviewed-by: @First-principles-0-\n\nMerged by @pr-manager under WAWQAQ no-check override: lead+aux content green, local validations passed, GitHub mergeable, statusCheckRollup empty.
2026-06-06 02:37:58 +08:00
lwyang 4a0d26835f fix(browser): prevent const redeclaration in evaluateWithArgs
Reviewed-by: @codex-mini1\nReviewed-by: @First-principles-1\n\nLead+aux content green; GitHub required checks success.
2026-06-06 02:36:35 +08:00
Archer 72d20c9113 fix(doubao): support current message DOM
Reviewed-by: @codex-mini1\nReviewed-by: @First-principles-1\n\nMerged by @pr-manager under WAWQAQ no-check override: lead+aux content green, local validations passed, GitHub mergeable, statusCheckRollup empty.
2026-06-06 02:27:14 +08:00
jakevin d8fc0a9e2b docs: hide single-command WeChat Channels from site lists (#1865) 2026-06-06 00:51:14 +08:00
jakevin be14222a9f chore(release): 1.8.3 (#1864)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-06-06 00:44:38 +08:00
jakevin 37b1289264 fix(extension): close SW-restart race that spawns duplicate OpenCLI Adapter groups (#1862)
* fix(extension): close SW-restart race that spawns duplicate OpenCLI Adapter groups

User report: after Chrome MV3 SW dies between owned-window/group setup steps,
the next ensure cycle could spawn a second `OpenCLI Adapter` group and a second
owned window, leaving multiple windows each holding an untitled or duplicate group.

Three defects chain together:
1. `createOwnedGroupWithRollback` persisted state only after `tabGroups.update`,
   so an SW crash between `tabs.group` and the title set left an empty-title
   group with no persistent pointer.
2. `collectOwnedGroupCandidates` had three lookup paths (stored groupId / title
   query / automationSessions) that all failed simultaneously after a cold SW
   restart on a partially-built group.
3. `ensureOwnedContainerWindowUnlocked` persisted the new `windowId` only after
   the full group setup, so an SW crash between `windows.create` and the next
   `tabs.group` lost the window pointer and spawned a second owned window on
   the next ensure.

Fixes:
- Persist `groupId` (and `windowId`) inside `createOwnedGroupWithRollback`
  immediately after `chrome.tabs.group` returns, and drop the `tabs.ungroup`
  rollback so `ensureCanonicalGroupTitle` can self-heal on the next cycle.
- Persist `windowId` inside `ensureOwnedContainerWindowUnlocked` immediately
  after `chrome.windows.create` returns so the next ensure reuses the window
  even if the worker dies before the first group is built.
- Add a 4th-layer scan in `collectOwnedGroupCandidates` over every tab group
  in Chrome, filtering by empty title + per-role ownership-tab signal (the
  group must contain a tab matching a still-registered owned session's
  `preferredTabId`). User-built untitled groups never carry that signal, so
  the hijack boundary from #1794/#1816 is preserved.

Tests cover the existing group-race contract plus three new regression gates:
window-race reuse after SW restart, orphan-group adoption via the ownership-tab
signal, and rejection of a user-built untitled group with no owned-tab signal.

* refactor(extension): rename createOwnedGroup to match post-rollback semantics

Both reviewers flagged that the function no longer ungroups on title-update
failure (Fix 1 dropped the rollback), so the -WithRollback suffix misled.
Pure rename, no behavior change.
2026-06-05 22:19:08 +08:00
jakevin 82dda11a2a feat(auth): add site login and whoami commands (#1852)
* feat(auth): add site login and whoami commands

* fix(auth): satisfy docs and column audits

* fix(auth): keep login browser sessions open

* chore(auth): simplify whoami probe handler
2026-06-05 21:28:13 +08:00
jakevin 3f1a723b5c fix(daemon): SIGKILL fallback when stale daemon refuses graceful shutdown (#1861)
* fix(daemon): SIGKILL fallback when stale daemon refuses graceful shutdown

When the CLI detects a stale daemon (`daemonVersion !== PKG_VERSION` after
`npm install -g @jackwener/opencli@latest`), it currently asks the daemon to
exit via `POST /shutdown` and waits up to 3 seconds for the port to release.
If the old daemon hangs, refuses /shutdown, or the endpoint is missing
entirely (pre-shutdown-endpoint version), the port stays held and the user
sees `Stale daemon could not be replaced` with a `opencli daemon stop` hint.

99% of "I just upgraded and have to run `opencli doctor` every time" reports
land here: the user upgraded the CLI but the persistent daemon survived from
a previous install, and graceful shutdown is unreliable across versions.

This patch reads the stale daemon's pid from its existing `/status` response
(daemon.ts:252 already exposes `pid: process.pid`) and falls back to
`process.kill(pid, 'SIGKILL')` after graceful shutdown fails, then waits
another 2s for the port to release. Cross-platform: Node's
`process.kill(_, 'SIGKILL')` maps to `TerminateProcess` on Windows, so no
`taskkill` shell-out is needed.

The user-visible "Stale daemon could not be replaced" error only fires when
both graceful shutdown AND SIGKILL fail (cross-user owner / cross-machine
PID — neither is reachable from a normal CLI invocation anyway). The hint
message is updated to reflect that.

Adds 2 tests:
- SIGKILL succeeds → bridge proceeds past the stale block (and eventually
  fails the no-extension wait, proving the stale branch was passed cleanly).
- SIGKILL throws EPERM AND waitForDaemonStop still returns false → falls
  through to the existing stale-daemon error.

Troubleshooting docs note the new auto-fallback.

* address opus review nits

- bridge.ts: move `await waitForDaemonStop(2000)` out of the try/catch so the
  port poll always runs after `process.kill`, even when the kill itself throws
  ESRCH (target already dead) or EPERM (cross-user owner).
- browser.test.ts: bump the existing 3 stale-daemon test fixtures from
  `pid: 1` to `pid: 999999` so the new SIGKILL fallback no longer fires a
  signal at init when the older tests reach the fallback path.
- browser.test.ts: mock `waitForDaemonStop` in those 3 tests too, since the
  real implementation now polls for 2s in the fallback path (test runtime
  was up to ~6s before; back to ~400ms).
2026-06-05 19:20:10 +08:00
jakevin 880c7d37c4 docs(sitemap): seed xiaohongshu phase 2 with login schema dogfood (#1853)
11 files / 909 lines under sitemaps/xiaohongshu/:
- SITE.md with new login: block (4-tier verify: adapter probe > read probe > cookie > DOM)
- apis.md (Pinia store snapshot endpoints)
- pitfalls.md (8 site-specific gotchas)
- pages/ (_note_card partial + explore + note + profile + compose)
- workflows/ (search + publish + comment)

Workflow Recovery sections reference `opencli xiaohongshu login` with
`# pending: codex task #276` comments — once login MVP ships, drop comments.

Cohesion bias on pitfalls.md / compose.md / publish.md > schema 800-token
soft cap, kept as single file per #1824 audit-flag-explanation loop:
xhs-specific gotchas / creator-center page actions / publish flow each
form a cohesive unit, splitting would add cross-file lookup cost for agents.
2026-06-05 13:40:16 +08:00
Zhongyue Lin 46c8fe8e2e fix(instagram): paginate following endpoint for high limits
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0-
2026-06-05 03:26:33 +08:00
Bo Liu 04fd4f86f8 fix(test): increase runCli maxBuffer for e2e manifest output
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
2026-06-05 03:23:45 +08:00
Bo Liu 944ca3a105 fix(xiaohongshu): prioritize visible title input
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
2026-06-04 15:39:00 +08:00
Zhongyue Lin c08d0e28b2 feat(gemini): add read-only conversation commands
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0-
2026-06-04 15:27:10 +08:00
Zhongyue Lin 19228d721e feat(manus): add read-only manus.im adapter
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
2026-06-04 15:22:14 +08:00
jakevin ec3eddec2a chore(release): 1.8.2 (#1830)
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
2026-06-03 01:29:12 +08:00
jakevin 62bd1175e2 revert: restore smart-search skill (#1829)
* Revert "chore(skills): remove smart-search (#1683)"

This reverts commit 7a2ab47bf8.

* chore(readme): keep smart-search out of README per @WAWQAQ

Restore smart-search skill files and inner-docs refs, but drop the 6
README mentions (3 EN + 3 ZH). Skill is loadable via:

  npx skills add jackwener/opencli --skill smart-search

but no longer surfaced on the README front page.
2026-06-02 20:03:54 +08:00
Bo Liu f192f69761 fix(extension): scope reusable-tab selection to owned group members
Scope reusable owned-container tab selection to canonical group membership so OpenCLI does not overwrite user http(s) tabs when an owned group converges into a user window.

Also hardens lower-probability fallback paths where the persisted owned window remains but the group signal is missing, and where owned-session fallback previously scanned the whole window.

Fixes #1760.
2026-06-02 19:57:23 +08:00
jakevin 53d62b0cc2 refactor(sitemaps): move global seeds to top-level directory
Reviewed-by: opencli-user
2026-06-02 17:21:12 +08:00
Bo Liu 323f5318eb fix(twitter): drop global tweetPhoto from post submit poll
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0-
2026-06-02 17:14:22 +08:00
jakevin c45dd409c0 docs(sitemap-author): add PoC guideline notes
Reviewed-by: opencli-user
2026-06-02 16:37:19 +08:00
jakevin 3dcddb6293 docs(sitemap-promote): seed twitter and hackernews PoC
Reviewed-by: opencli-质量官
2026-06-02 03:10:05 +08:00
jakevin dc67023be8 docs(sitemap-author): schema v1.1 — 12 patches from twitter+hackernews PoC (#1822)
* docs(sitemap-author): schema v1.1 — 12 patches from twitter+hackernews PoC

Cross-validated against two PoCs (twitter 12 files / hackernews 10 files).
v1.1 changelog at top of file. 12 patches in 3 groups:

Group 1 — Scope/boundary (6 clarifications):
- §1.1 CJK token-per-char 30-50% higher than English; split sub-file rather
  than relaxing 800-token limit (which would drift).
- §2.1 auth_strategy = primary strategy, not union; per-page contract_strength
  expresses exceptions.
- §2.5 pitfalls.md is task-executor-level only; adapter-internal pitfalls
  (queryId parsing, envelope unwrap) move to ~/.opencli/sites/<site>/notes.md.
- §2.5 pitfall id / trigger / workaround written from task-executor 1st-person
  view ("when agent does X, ..."), not adapter-implementer view.
- §2.4 apis.md entry adds optional `notes:` field for GraphQL queryId path and
  other meta info (still no URL / method / params / response — those stay in
  endpoints.json).
- §2.2 page Linked APIs may be empty when endpoints.json is still being
  collected; do not insert fake placeholder ids.

Group 2 — Reuse/compactness (3 structural):
- §2.2 + §4 partial pages: `page_id` with `_` prefix and `url_patterns: []`
  for cross-page UI (e.g. _tweet_card.md). Referenced by other pages via the
  existing `action:<id> in pages/_<name>.md` form. Eliminates duplication and
  arbitrary "which page owns the like button" calls.
- §3 introduces Form B compact YAML for actions (~80 token each vs Form A
  markdown ~250). Both forms remain valid; Form B is recommended when page
  density would otherwise blow the 800-token budget.
- §3 drops action-level `verified_at` and `source` — file-level frontmatter
  already covers both, repeated copies just drift.

Group 3 — Execution health/anchors (3 action-level):
- §3.3 cross-page UI primitive actions (the kind that live in partials)
  may write Best/Fallback inline as adapter-first + DOM fallback within a
  single action, rather than being forced up into a workflow Best/Fallback
  pair. Decouples UI-primitive routing from task-level routing.
- §3.4 Recovery may include `adapter_health_update: <adapter> -> suspect`
  directive. Consumption skill (opencli-browser-sitemap) writes the matching
  workflow's adapter_health on the local overlay so the next agent skips the
  broken Best path instead of re-running it. Write-side closure for the
  failure → next-agent-avoidance loop.
- §2.2 testid marked optional; selector_pattern promoted to first-class
  anchor with 5 acceptable shapes (id-anchored / sibling traversal / attribute
  boundary / form name / ARIA) and explicit discouraged-anchor list
  (nth-child, single-class grabs, text-content selectors). Old sites without
  testid (HN, forums) are no longer second-class.

No code changes — pure schema reference. Both PoCs remain local; promotion to
references/site-memory/{twitter,hackernews}/sitemap/ comes once this lands.

* docs(sitemap-author): apply opencli-user review nits

- Form B delimiter table (`|` enum / `||` fallback / `;` sequential) to
  disambiguate `do:` and `recover:` parsing.
- §3.3 like_tweet example updated to `||` fallback form.
- §3.4 explicit note: adapter_health recovery (suspect → healthy) is read
  side, deferred to opencli-browser-sitemap skill spec.

* docs(sitemap): align skills with schema v1.1
2026-06-02 02:29:58 +08:00
jakevin 65cab71b07 docs(sitemap-author): add detailed schema reference (#1821)
* docs(sitemap-author): add detailed schema reference

Companion to #1820 — extends the inline schema in SKILL.md with the
field-level spec promised in the design thread:

- File schemas: SITE.md / pages/<id>.md / workflows/<id>.md / apis.md /
  pitfalls.md with frontmatter fields and required sections.
- Action schema with all 6 required fields (preconditions, postconditions,
  failure_signals, recovery, evidence, plus optional action-level
  state_signature for multi-step internal re-entry).
- Workflow adapter_health enum (healthy / suspect / broken) backing the
  Best path / Fallback path routing rule.
- apis.md endpoint reference format that points at endpoints.json by id
  instead of duplicating endpoint detail (avoids double-stale).
- Two-layer overlay semantics (local wins, stable-id matching, draft
  placement inside sitemap/ to remain discoverable, optional site-alias.json
  for sitemap-without-adapter cases).
- Phase 2 validation rules: file size budget, cross-ref integrity,
  reality check via opencli browser, forbidden-content scan.
- Cross-links to strategy-selection.md (contract_strength / auth_strategy
  enums) and api-discovery.md.

SKILL.md gets a pointer to the new reference plus a draft-placement red
line so authors don't drop drafts at the parent dir where the browser
availability detection cannot see them.

* docs(sitemap): seed authoring from adapter traces
2026-06-02 01:40:55 +08:00
jakevin cc760810a2 feat(browser): surface sitemap context (#1820) 2026-06-02 01:26:30 +08:00
jakevin 7731e36388 docs(author): add strategy-selection reference with empirical contract ladder (#1810)
Companion deep reference for the SKILL.md strategy gate (#1809):

- New `references/strategy-selection.md` with contract-based ladder model,
  empirical fixes/adapter-year data (837 adapters / 30-day window), Pattern A
  judgment rules from `api_candidates` verdicts, and reference cases
  (booking #1680, Twitter GraphQL, xhs signed URL, weread-official).
- Cross-link from SKILL.md inline strategy gate to the deep reference, plus
  one-line empirical hook ("PAGE_FETCH/INTERCEPT 7-8x PUBLIC_API fix rate").
- coverage-matrix.md: Strategy row renamed to 6-enum (PUBLIC_API / COOKIE_API
  / UI_SELECTOR / DOM_STATE / PAGE_FETCH / INTERCEPT) with fixes/adapter-year
  on each entry.
- site-recon.md: Pattern A note that hit alone is not a `PAGE_FETCH` signal —
  must check `api_candidates` verdicts (booking #1680 reference).
2026-06-01 14:10:56 +08:00
jakevin 55d91906d3 feat(author): require network-first strategy evidence (#1809) 2026-06-01 14:03:54 +08:00
pi-dal 24e6be9165 feat(pubmed): add workflow presets and article metadata
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
2026-06-01 02:19:59 +08:00
Zhongyue Lin 2615d331e8 fix(weixin): strip typographic quotes from pasted URLs
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
2026-06-01 02:14:58 +08:00
Zhongyue Lin a73301f7bf fix(launcher): allow Chromium 142 CDP websocket origin
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
2026-06-01 01:18:13 +08:00
jasonyang365 6d99979c4d feat(trae-cn): add desktop adapter
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 01:15:28 +08:00
Zhongyue Lin 1aec5c59dc feat(trae-solo): add desktop adapter
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:55:46 +08:00
Zhongyue Lin 707cebd042 fix(grok): fall back to Enter-key dispatch
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
2026-06-01 00:47:10 +08:00
Aldrich Chen 5e938a3245 fix(daemon): differentiate multi-profile status output
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:44:51 +08:00
cph 98b978eaba fix(douyin publish): handle illegal title errors
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:35:36 +08:00
nightwhite 594c9a628f feat(chatgpt): add web model switch command
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:34:47 +08:00
FSpark 0a4a2cff2f fix(youtube): support lockupViewModel video fallback
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:33:59 +08:00
蛮三 10acaa9541 fix(12306): accept lowercase letters in train_no regex
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:33:08 +08:00
e0_7 8d3e7d459a feat(douyin): add search command
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:29:15 +08:00
RavenLiao cbf1ac1558 feat(wechat-channels): add publish adapter
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:22:06 +08:00
jdy 54270cdc4d fix(chatgpt): ignore image placeholders and upload previews
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:20:35 +08:00
Zhongyue Lin d14930201a feat(codex): add conversation management commands
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:19:05 +08:00
yapeng 6cde68689b feat(xiaohongshu): add draft management commands
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:17:56 +08:00
Zhongyue Lin 203ff56e2d feat(antigravity): add history management and model commands
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:17:00 +08:00
Zhongyue Lin a1555e8f19 feat(grok): add conversation management commands
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:15:55 +08:00
pg-adm1n 76fcc28c99 feat(chatgpt-app): add temporary chat and image attachment support
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:11:46 +08:00
Zhongyue Lin 6126413d60 feat(qoder): add Qoder IDE adapter
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:07:49 +08:00
Zhongyue Lin 5b11251692 feat(kimi): add kimi.com adapter
Reviewed-by: codex-mini1
Reviewed-by: First-principles-1
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:05:48 +08:00
RavenLiao 26ccbaa4c5 fix(xiaohongshu,rednote): return signed note URLs
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
2026-06-01 00:03:49 +08:00
E2ern1ty 221f02f364 fix(xiaohongshu): attach real topics via inline dropdown
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
2026-05-31 20:45:57 +08:00
Gaurav Saxena 3307640a05 feat(twitter): add batch follow and list lifecycle commands
Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
2026-05-31 20:23:54 +08:00
jakevin c3d2fc1f9f docs(readme): prefix Let AI Agents bullet with "Browser User &" (#1796) 2026-05-31 04:56:56 +08:00
jakevin 06daf6f8b9 chore(release): 1.8.1 (#1795)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-05-31 04:41:24 +08:00
jakevin c2f0d33293 fix(extension): converge owned tab groups (#1794) 2026-05-31 04:35:45 +08:00
Bo Liu add386b699 fix(browser): write network cache with owner-only permissions 2026-05-31 03:13:32 +08:00
Bo Liu 68ebb0a277 fix(pixiv): migrate user/detail to pixivFetch helper 2026-05-31 02:30:24 +08:00
Bo Liu c32fb02a74 fix(twitter): drop unknown silent sentinels 2026-05-31 02:27:52 +08:00
jakevin 7143e52093 chore(extension): bump to 1.0.16 (#1792) 2026-05-31 02:10:56 +08:00
jakevin 4e8bad41fb Revert "docs(readme): add Trendshift "trending repo" badge to top of README (#1773)" (#1774)
This reverts commit 29e8fe9a16.
2026-05-28 17:40:50 +08:00
jakevin 29e8fe9a16 docs(readme): add Trendshift "trending repo" badge to top of README (#1773)
Per WAWQAQ DM. OpenCLI is featured on Trendshift
(https://trendshift.io/repositories/23541) — surfacing the badge at
the top of README gives social proof to new visitors and links back
to the Trendshift listing.

Placement: above the `# OpenCLI` heading so it renders as a banner
before the title (standard Trendshift placement pattern). 250×55 inline
SVG. Both EN and ZH READMEs updated.
2026-05-28 17:38:59 +08:00
AstroHan cc13dd0c0c fix(twitter): read profile name/created_at from result.core
fixes #1745
2026-05-27 14:15:58 +08:00
陈家名 56ac98cb3f fix(weread): decode search HTML entities
Decode rendered search-card title and author entities for reader URL matching while keeping output identity from the public API and preserving typed error behavior.
2026-05-27 03:19:58 +08:00
Benjamin Liu 8aa48b1094 feat(xiaohongshu): paginate creator-notes past analyze list cap
Harvest signed creator-note analyze pages in order with dedupe, unwrap Browser Bridge envelopes, and fail closed when known totals cannot be completely captured.
2026-05-27 02:37:16 +08:00
Gaurav Saxena 7ed42a67b8 feat(linkedin): read profile experience
Add a LinkedIn profile-experience reader with visible-DOM extraction, typed empty/auth/parser boundaries, safe http(s) URL output, and documentation.
2026-05-27 02:27:13 +08:00
Benjamin Liu c730a02640 fix(download): write yt-dlp cookie file with 0o600 owner-only permissions
Ensure exported Netscape cookie files are owner-only even when overwriting an existing broad-permission file.
2026-05-26 17:46:02 +08:00
jakevin 3329a23b20 chore(ci): disable Dependabot updates
Remove Dependabot configuration so dependency update PRs no longer open or trigger CI.
2026-05-26 17:02:31 +08:00
lenovobenben 7362ced82d fix(zhihu): decode numeric entities in text output (#1695)
* fix(zhihu): decode numeric entities in text output

* fix(zhihu): decode collection titles

---------

Co-authored-by: lihaidong <lihaidong@kingsoft.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-25 15:22:28 +08:00
Benjamin Liu 6b8d30b12d fix(xiaohongshu): hook dashboard fetch to capture signed datacenter/note/* responses (#1732)
* fix(xiaohongshu): hook dashboard fetch to capture signed datacenter/note/* responses

The four /api/galaxy/creator/datacenter/note/* endpoints behind the
creator-note-detail view require an x-s / x-t / x-s-common signing
interceptor that the dashboard's own JS installs at page load. The
previous in-page roundtrip called fetch() directly from page.evaluate,
which bypasses the interceptor and gets HTTP 406, so 观看来源 / 观众画像 /
趋势数据 rows silently never landed even though the help string promised
them.

Instead of forging signatures, install a fetch + XHR capture hook on
window.__xhsCapture, SPA-navigate to /statistics/note-detail via
history.pushState + popstate (a hard page.goto would wipe the hook
before the first auto-fetch fires), and harvest the dashboard's own
signed responses out of the capture buffer.

Also fix a 1-character endpoint name: /note/audience -> /note/audience/source.
The old path returned 404 even when signed; the page actually fetches
/note/audience/source for the 观看来源 panel. Confirmed against the live
dashboard XHR list while logged in.

Tests updated to mock the new install-hook + SPA-nav + poll-capture
sequence at page.evaluate (the previous burst-wait-between-fetches
assertion no longer applies).

Closes #1728.

Reporter diagnosis: @ppop123 traced the signing bypass + endpoint typo
and verified the hook + SPA-nav workaround on 86 notes.

* test(xiaohongshu): trim installXhsFetchCaptureHook comment to match sibling tone

Sibling helper functions in creator-note-detail.js have no doc-comment
block above the declaration; the 5-line WHY block on the new hook was
out of style. Compress to two lines covering the same WHY (signed API
bypass + 406) and let the rest of the context live in the commit body
of the parent fix.

* test(xiaohongshu): name the creator-note-detail poll bounds

Inline literals (20 iteration cap, 0.5s wait) drift from sibling
convention in clis/xiaohongshu/delete-note.js where the same kind of
post-write polling is named VERIFY_TIMEOUT_MS / VERIFY_POLL_MS. Promote
the two values to CAPTURE_POLL_ATTEMPTS / CAPTURE_POLL_INTERVAL_S so
the loop reads against an explicit budget and future tuning lands in
one place.

* fix(xiaohongshu): address copilot review on creator-note-detail hook

Two polish items from the Copilot review on #1732:

- Buffer reset: window.__xhsCapture is now cleared on every install call
  so stale captures from a previous run on the same tab cannot leak into
  the current navigation's harvest. The wrapper-install guard moves to a
  separate __xhsCaptureInstalled flag so the fetch/XHR monkey-patches
  themselves are still installed exactly once per page lifetime.
- XHR static constants: HookedXHR now copies the readyState constants
  (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE) from the original
  constructor so dashboard code that reads XMLHttpRequest.DONE etc against
  the constructor keeps working.

* fix(xhs): tighten note detail capture matching

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-25 14:33:43 +08:00
Benjamin Liu e318522fbd test(download): retry media-download Windows tests to absorb runner cold-start variance (#1708)
* test(download): retry media-download Windows tests to absorb runner cold-start variance

src/download/media-download.test.ts > 'keeps custom filenames inside the
output directory' timed out at the default 5000ms on CI run 26217100578
(Windows shard 2/2). The other two cases in the same describe block
completed in ~400ms, so the failure is cold-start cost of the first
http.createServer + downloadMedia roundtrip on a loaded GitHub Actions
Windows runner, not a logic regression.

Adopt the same { retry: process.platform === 'win32' ? 2 : 0 } describe
option that src/download/index.test.ts already uses for the same class
of Windows-only network/IO flake.

* test(download): trim media-download retry comment to match sibling tone

src/download/index.test.ts uses a 2-line comment for the same pattern.
The CI run id + redundant cross-reference belong in commit history, not
inline.
2026-05-25 14:05:41 +08:00
jakevin b6965a5973 feat(linkedin): consolidate read commands
Consolidates PRs #1722, #1723, #1724, #1725, #1726, and #1727 after B-group lead+aux review.\n\nReviewed-by: codex-mini1\nReviewed-by: First-principles-1
2026-05-23 17:01:56 +08:00
Benjamin Liu 52a6ce0264 fix(suno): derive current plan from subscription metadata
Merge PR #1706 after A-group lead+aux review.\n\nReviewed-by: codex-mini0\nReviewed-by: First-principles-0-
2026-05-23 16:19:12 +08:00
jakevin 40f270bacb Revert "fix(doctor): poll briefly for extension reconnect" (#1721)
This reverts commit d1076c0deb.
2026-05-22 21:45:43 +08:00
Shawn Shen c90b355ca0 fix(twitter): handle NotAllowed image upload fallback
Merged by pr-manager after lead+aux content green; maintainer explicitly allowed no-check gate override.
2026-05-22 11:34:21 +08:00
Truffle d1076c0deb fix(doctor): poll briefly for extension reconnect
Merged by pr-manager after lead+aux content green; maintainer explicitly allowed no-check gate override.
2026-05-22 11:32:59 +08:00
galaxypluto c40a8547c6 feat(weread): add book search inside WeRead book
Merged by pr-manager after lead+aux content green; maintainer explicitly allowed no-check gate override.
2026-05-22 11:31:20 +08:00
lamb liu 6804324066 feat(geogebra): add GeoGebra browser adapter suite
Merged by pr-manager after lead+aux content green; maintainer explicitly allowed no-check gate override.
2026-05-22 11:30:05 +08:00
NSOiO 6ed93fdbe5 feat(upwork): add search, feed, and detail commands
Merged by pr-manager after lead+aux content green; maintainer explicitly allowed no-check gate override.
2026-05-22 11:28:53 +08:00
Benjamin Liu d4640b2418 feat(notebooklm): add guarded write commands
Add NotebookLM write commands with explicit execute guards, strict notebook identity parsing, Browser Bridge envelope unwrapping, and post-write ID parsing safeguards.
2026-05-21 17:17:48 +08:00
Benjamin Liu a79a977a58 fix(douyin/hashtag): validate action args before navigation
* fix(douyin/hashtag): validate per-action required args before the API call (#1689)

Closes #1689. Reporter @alexcc4 ran:

  opencli douyin hashtag suggest --keyword 速效救心丸

which the previous code happily forwarded to:

  GET creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=&aid=1128

with an empty cover_uri, because the suggest action reads kwargs.cover
(not kwargs.keyword) and there was no upfront validation. The Douyin
server rejected the empty cover_uri with API error 5 (参数不合法),
which surfaces to the user as an opaque server-side error rather than
the obvious adapter-side mismatch.

Fix: validate each action's required args up front and throw
ArgumentError with a concrete hint pointing the user at the right
action / flag combination:

- search requires --keyword (suggest the example command)
- suggest requires --cover (explain it operates on an uploaded video
  cover, not a keyword; redirect keyword-search users to `hashtag
  search --keyword <词>`)
- hot still accepts an empty --keyword (it is optional for hot)

Also tightened the arg help strings to make the per-action
requirements obvious without reading the source.

Tests: 5 new vitest cases covering the validation branches plus URL
shape assertions for search / suggest / hot.

Live verified the reporter's exact failing command now surfaces:

  $ node ./dist/src/main.js douyin hashtag suggest --keyword 速效救心丸
  ok: false
  error:
    code: ARGUMENT
    message: douyin hashtag suggest 需要 --cover <cover_uri>
    help: suggest 基于已上传的视频封面做 AI 推荐, 不是关键词搜索.
          关键词搜索请用 `douyin hashtag search --keyword <词>`.
    exitCode: 2

Zero network calls on the invalid invocation.

* fix(douyin/hashtag): harden adapter boundaries with drift guards

API response shape is now validated before mapping. requireListField
throws CommandExecutionError when the batch payload is non-object or the
expected list field (challenge_list / hashtag_list / hotspot_list /
all_sentences) is the wrong shape. search additionally throws when the
API returns challenges but none have stable challenge_info, which would
otherwise silently flatten to an empty row set and mask upstream drift.

Live re-verified: search missing keyword and suggest missing cover still
throw ArgumentError with the same redirect hint (#1689 fix intact);
hot happy path still returns name / id / view_count rows.

* fix(douyin/hashtag): validate action args before navigation

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-21 17:12:23 +08:00
Benjamin Liu 90e4cb9878 fix(twitter): detect private likes / following empty-timeline shape (#1702)
* fix(twitter): detect private likes / following empty-timeline shape

When the X GraphQL endpoint returns `result.timeline = {}` (an empty
object with no nested `timeline.timeline.instructions`), the twitter
likes / following parsers correctly extracted 0 entries but the likes
caller silently returned `[]` while the following caller threw a generic
"no following accounts found" message. Both paths hide a platform
constraint: X made Likes private by default in mid-2024 and accounts
can also hide their following list.

likes.js now throws EmptyResultError with a privacy hint when the
empty-timeline shape is detected, and unconditionally throws when zero
tweets accumulate (parity with following.js, which already failed
loudly). following.js threads the same detector so the generic
EmptyResultError gains a privacy hint when the platform shape matches.

The detector is exported as looksLikePrivate{Likes,Following}Response
for unit testing and lives alongside the existing pure parsers.

Live-verified against simonw (private likes) and karpathy (public
following): likes now reports the privacy reason instead of returning
an empty list, and following continues to return its public dataset.

Closes #1701 (narrow root cause: the issue reporter's hot-patch is
defensive but their stale-queryId / dropped-args / off-by-one .data
diagnosis does not reproduce on main; the actual reproducible failure
is the silent-empty-timeline path documented here).

* fix(twitter): consolidate private-timeline detector + refresh stale queryId fallbacks + harden followers DOM

Followups on the same #1701 surface area.

Consolidation: the private-timeline detector duplicated between likes.js
and following.js moves to shared.js as looksLikePrivateTwitterTimeline,
and its unit tests collapse from two suites into one in shared.test.js.

Stale queryId fallbacks: live-extracted the current operationName to
queryId mappings from the X bundle (Following, UserByScreenName, Likes,
Followers) and refreshed the defensive fallback constants across
following.js, likes.js, list-add.js, list-remove.js, profile.js. The
dynamic resolver in resolveTwitterQueryId() succeeds in practice (it
parses queryIds from document.scripts text in-page, which is same-origin
and CORS-immune), so these fallbacks are last-resort only, but keeping
them current narrows the blast radius if the bundle parser ever fails.

followers.js Array guard: extractFollowersFromDOM returns whatever
page.evaluate produces, which under transient bridge errors can be
undefined. The subsequent followers.filter(...) call would then surface
as "filter is not a function". The fix coerces non-array results to []
so the loop drains via its existing sameCount break and ends with the
typed EmptyResultError.

Live-reverified all 4 paths on main: likes simonw still emits the new
private-likes hint, following karpathy / followers karpathy still return
data, and profile karpathy resolves under the bumped UserByScreenName
fallback.

Refs #1701. The remaining items in the issue (page.evaluate args drop,
parseFollowing off-by-one .data, twitter followers throwing "filter is
not a function" as a primary failure) do not reproduce on main:
src/browser/utils.ts serializes fn-args via JSON.stringify and
src/browser/utils.test.ts covers it; unwrapBrowserResult only strips
when a session field is present so the GraphQL .data path is correct
(confirmed by debug-dumping the live response shape); followers
returned data for every account I tested. The defensive Array guard
above closes the only plausible code path to that filter error.

* fix(twitter): match sibling EmptyResultError prose style

Single-sentence parenthetical aside on the private-timeline messages
(mirroring 'Account may be private, suspended, or have no media posts'
in twitter/download.js) instead of two-sentence prose, and drops the
trailing period that the dominant sibling no-period convention does not
use.

* fix(twitter): keep private timeline and malformed rows distinct

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-21 16:40:20 +08:00
陈家名 e3e2a97925 fix: stabilize byte formatting
Stabilize download progress byte formatting for invalid, negative, sub-byte, and very large values without changing download state or progress behavior.
2026-05-21 16:34:52 +08:00
jakevin cd2c3ebf81 docs(readme): correct Node floor (>=20 not 21) + drop Prerequisites section (#1705)
Per WAWQAQ DMs:

1. The README stated "Node.js >= 21" in 6 places, but the actual
   runtime floor is 20 (`MIN_SUPPORTED_NODE_MAJOR = 20` in
   src/runtime-detect.ts, `engines.node: ">=20.0.0"` in package.json,
   undici pinned to 6.x in 1.8.0 to keep Node 20 compatibility).
   Stale carryover from before PR #1518/#1524 lowered the floor.
   All 6 mentions (3 EN, 3 ZH) corrected to 20.

2. Prerequisites section was redundant with Quick Start (Node version
   is in step 1 "Install OpenCLI"; Chrome/login state is in step 2
   "Install Browser Bridge Extension" + step 3 "Verify"). Removed in
   both EN and ZH.
2026-05-21 16:15:57 +08:00
asimov 4d1da75baa feat(bilibili): add comment commands
Squash merge PR #1588 after lead+aux review green and required checks passing.
2026-05-20 23:08:07 +08:00
Kagura da84782969 fix(extension): serialize tab group creation to prevent duplicates (fixes #1692) (#1693)
* fix(extension): serialize tab group creation to prevent duplicates (fixes #1692)

Add per-role groupPromise serialization to ensureOwnedContainerTabGroup(),
preventing concurrent callers from each creating a new tab group when they
simultaneously observe no existing group.

The fix mirrors the existing promise serialization pattern used by
ensureOwnedContainerWindow(). When a second caller arrives while group
creation is in-flight, it awaits the first call's promise, then finds the
newly created group via the existing getOwnedContainerGroupId() cache path.

* test(extension): cover concurrent tab group creation

* fix(extension): queue tab group serialization waiters

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-20 19:36:31 +08:00
Benjamin Liu 299c020eb3 feat(chess): add Chess.com adapter
Adds Chess.com stats/games/game/analyze commands using the public Chess.com API/callback endpoints with typed error boundaries and docs/tests.
2026-05-20 18:01:15 +08:00
BruceLoveDecimal 9379556078 add jira confluence support (#1690)
* add jira confluence support

* fix atlassian adapter edge cases

* chore: add adapter docs

* fix(atlassian): harden REST payload boundaries

* fix(jira): guard issue nested collection shapes

---------

Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-20 17:12:44 +08:00
Benjamin Liu 377bc06367 fix(xiaohongshu/download): preserve carousel order via __INITIAL_STATE__.imageList (#1687)
* fix(xiaohongshu/download): preserve carousel order via __INITIAL_STATE__.imageList (#1514)

Closes #1514. Reporter Scofy0123 observed that `opencli xiaohongshu
download` was saving carousel images in a different order from the
order shown on the platform: the visible cover ended up as `_2.jpg`
instead of `_1.jpg`.

Root cause: the IIFE collected images by iterating multiple DOM
selectors (`.swiper-slide img`, `.carousel-image img`, ...) into a
`Set`, then appended that set to `result.media`. JS `Set` preserves
insertion order, but the insertion order is whatever the selector
walk hit first; hidden / preloaded / duplicated / lazy-rendered
slides therefore shifted the saved order away from the canonical
display order. Downstream `downloadMedia` then named files by index
(`<id>_1.jpg`, `<id>_2.jpg`, ...), so the mismatched array order
produced mismatched filenames.

Fix mirrors the video extraction strategy already in this same IIFE:
read the canonical media list from the SSR hydration data first,
fall back to DOM scraping only when the structured state is absent.

- Method 1 (new): walk `window.__INITIAL_STATE__.note.noteDetailMap[id].note.imageList`
  in array order. Each entry exposes the canonical CDN URL via
  `urlDefault` (primary), with `urlPre` / `url` / `infoList.WB_DFT` /
  `infoList[0]` fallbacks for older shapes.
- Method 2 (kept as fallback): the previous multi-selector DOM walk,
  reached only when Method 1 yields zero images. Preview pages
  without full SSR hydration still surface something instead of an
  empty `media` array.

Shared `normalizeImageUrl` helper hoisted out of the inline `.add`
call so both paths apply the same query-string + imageView-resize
strip.

The rednote adapter reuses `buildDownloadExtractJs` verbatim, so this
PR fixes rednote download in the same change.

Tests: 7 new regression tests in `download.test.js` exercise the IIFE
directly via JSDOM (matching the `ctrip buildFlightExtractJs (JSDOM)`
pattern already in the repo):
- canonical order from `imageList` overrides DOM discovery order
  (the exact #1514 repro)
- field fallback chain (urlDefault -> urlPre -> url -> infoList.WB_DFT
  -> infoList[0])
- query-string + imageView-resize stripping
- DOM fallback engaged when imageList is missing
- non-xhscdn / non-xiaohongshu / non-rednote URLs filtered out
- DOM fallback NOT engaged when Method 1 yielded any image (no
  duplicate-from-DOM contamination)
- video extraction still works alongside the image fix

All 12 download tests pass. No live xiaohongshu.com calls made
(pure JSDOM unit tests, respecting the platform's rate-limit
sensitivity).

* fix(xiaohongshu): keep video download order

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-20 16:50:56 +08:00
Ocean 0311ff3c62 fix(bilibili): subtitle 支持 bangumi/PGC bvid(番剧/纪录片/电影/综艺) (#1669)
* fix(bilibili): subtitle works for bangumi/PGC bvids (movies/纪录片/番剧)

opencli `bilibili subtitle <bvid>` 对绑定到 bangumi 的 bvid 报 SELECTOR 错:
`Could not find element: videoData.cid`。根因是旧实现 page.goto(/video/<bvid>)
后从 `window.__INITIAL_STATE__.videoData.cid` 读 cid,但 bangumi (番剧/纪录片/
电影/综艺) 页面会重定向到 `/bangumi/play/ep<id>`,state 在 `epList[]` 不在
`videoData`,selector 永远找不到。

改:换成 `apiGet(page, '/x/web-interface/view', {params:{bvid}})` 拿 cid。
view 端点对 UGC 和 PGC bvid 都返 cid + redirect_url,且与 DOM 结构无关,
跟 `comments.js` 已有 view→aid 路径完全同款。顺手补 `domain: 'www.bilibili.com'`
让 strategy 显式地落到 bilibili origin(apiGet 的 credentials:'include' 依赖)。

验证:
- 5/5 vitest pass(新增"bangumi-bound bvid 走同一代码路径"回归 case)
- typecheck pass
- 端到端:BV1Py4y1D781 (ep371508《灭绝的真相》) 不再 SELECTOR 错;UGC
  BV1UbyZB9ERb (TED 合集) 字幕完整返回,与原行为一致

* fix(bilibili): harden subtitle response boundaries

* fix(bilibili): guard malformed player payloads

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-20 04:12:35 +08:00
jakevin 7d0f46d009 docs(readme): CLI Hub brand aliases + Exit Codes split to docs (#1685)
Per WAWQAQ DM:

1. **CLI Hub**: bare-name enumeration ("ntn", "discord") didn't tell
   readers what those binaries map to. Switched to the `opencli external
   list` brand-alias format: `ntn(notion)`, `discord(discord-cli)`,
   `dws(DingTalk Workspace)`, `wecom-cli(企业微信)`, `tg(tg-cli)`,
   `wx(wx-cli)`. Names that are already self-explanatory (gh / docker /
   vercel / wrangler / obsidian / longbridge / lark-cli) stay bare.

2. **Exit Codes**: the 9-row table + example block was disproportionate
   for a README. Compressed to one sentence with the 7 actionable codes
   inline, full table relocated to:
   - EN: `docs/guide/exit-codes.md` (new)
   - ZH: `docs/zh/guide/exit-codes.md` (new)
2026-05-20 03:58:27 +08:00
jakevin 5cb075d102 docs(readme): drop For Developers section (#1684)
Per WAWQAQ: from-source install instructions are infrastructure detail
that don't belong in a public-facing README. Contributors finding
themselves in this repo will already know `npm install / build / link`
patterns; users who reach the README from npm don't need them.

Removed in both EN and ZH.
2026-05-20 03:55:37 +08:00
jakevin ce432c2428 chore(release): 1.8.0 (#1682)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
* chore(release): 1.8.0

Substantial release: weread-official adapter, wider LinkedIn / Twitter / Reddit / Zhihu coverage, 12306 / Suno / Xianyu additions, security and reliability fixes, plus a 20% README shrink.

* chore: remove orphan docs/adapters-doc/ones.md

The file was a leftover from PR #386 (2026-04-10) and has been
superseded by docs/adapters/browser/ones.md. Bundled into the 1.8.0
release commit chain so the release doesn't ship with a dead docs
file alongside the new docs.

Skipped from opus-reviewer's audit (Tier 1 #1-#4) for this release:
- #1 smart-search dead refs (10 spots) — owned by @codex-coder's
  skill-deletion PR; release PR will rebase on top of it.
- #3 clis/test-utils.js relocation — touches 19 importers, separate
  refactor PR.
- #4 clis/slock/ orphan — needs WAWQAQ design call.
- #6 opencli-usage:161 wording — current "Commands that used to
  exist" framing is already clear enough.
- #7 docs/adapters/index.md sync (8-20 missing sites) — broader docs
  PR, not release-time bundling.

* chore: remove clis/slock + sync docs/adapters/index.md (audit #4 + #7)

Per WAWQAQ post-audit directive on #OpenCLI:f046ece7:

- `clis/slock/` was a half-finished orphan with only `_utils.js` and no
  command entry points. Removed.
- `docs/adapters/index.md` was missing 11 browser adapters: 12306,
  suno, weread-official, qwen, 1point3acres, brave, duckduckgo, cnki,
  flomo, jianyu, taobao. Added all with commands sourced from
  cli-manifest.json. Desktop section already covered all 7 desktop
  adapters (Cursor / Codex / Antigravity / ChatGPT App / ChatWise /
  Discord / Doubao App).
2026-05-20 03:31:57 +08:00
jakevin 7ee16aa087 feat(booking): add search adapter for Booking.com hotel listings (#1680)
* feat(booking): add search adapter for Booking.com hotel listings

New `opencli booking search <destination> --checkin --checkout` adapter
scrapes the server-rendered hotel cards on www.booking.com via stable
`[data-testid=property-card]` selectors. No login required (Strategy.PUBLIC
+ browser:true).

Highlights
- 12 columns: rank, name, country, slug, star_rating, review_score,
  review_count, price_amount, price_currency, distance, recommended_room,
  url. `slug` + URL stay stable across locales (better round-trip key
  than `name`, which Booking sometimes localizes from session cookies).
- Score parser anchors on `(\d{1,2})\.(\d)` so the duplicated "8.68.6" /
  "评分8.68.6很棒" rendering doesn't mis-parse to 8.68.
- Currency symbol → ISO 4217 map (US$/€/£/¥/¥/₹/₩/HK$/A$/NT$/S$/CN¥);
  honor `--currency` URL param for stable codes.
- Pagination via `--offset` (Booking pages 25/request); `rank` includes
  the offset so paginated calls stay sortable.
- Captcha-page detection short-circuits to CommandExecutionError instead
  of silent empty rows.

Typed errors (no silent clamp / fallback)
- Bad date / out-of-range adults/rooms/children/limit/offset / unknown lang
  / malformed currency → ArgumentError up front (before any navigation).
- Browser nav failure → CommandExecutionError.
- Zero cards rendered → EmptyResultError with a hint.
- Captcha page → CommandExecutionError.

29 unit tests cover the helpers, the registry shape, every typed-error
path, the {session,data} CDP envelope unwrap, and offset-aware rank
numbering. Silent-column-drop + typed-error-lint audits unchanged.
Live-verified against Tokyo + Paris.

* fix(booking): harden search parser boundaries

* fix(booking): separate no-card drift from empty
2026-05-20 03:29:03 +08:00
jakevin 7a2ab47bf8 chore(skills): remove smart-search (#1683) 2026-05-20 03:23:32 +08:00
jakevin 2c8b50c4fd docs(readme): shrink CLI Hub + Core Concepts + merge Update into Install (#1681)
Per WAWQAQ:

1. **CLI Hub**: drop the 13-row 3-column table; enumerate just the
   names inline ("gh · docker · vercel · wrangler · ntn · obsidian · …")
   plus one-liner register / list commands. Removes "Manual install"
   ntn note (search lives in external-clis.yaml / ntn's own docs).
   Compresses the 7-row Desktop App Adapters table to a single inline
   line pointing at docs/adapters/desktop/.

2. **Core Concepts** section dissolved: its four subsections
   ("browser", "Built-in adapters", "Writing a new adapter",
   "CLI Hub and desktop adapters") duplicated the intro 3-bullet
   + later dedicated sections. Kept the substantive "Writing a new
   adapter" callout as its own top-level section. The "For AI Agents
   (Developer Guide)" tail block at the bottom was a third copy of
   the same recipe — removed.

3. **Update** merged with **Install skills**: install header now
   reads "Install skills (also refreshes existing installs)", and
   the standalone Update section collapses to a single command
   (`npm install -g @jackwener/opencli@latest && npx skills add ...`).

Net: EN 410 → 326 (-20%), ZH 455 → 366 (-20%). Same coverage; just
less repetition.
2026-05-20 03:12:36 +08:00
Benjamin Liu 51a9456305 feat(linkedin): add people-search command (#1649)
* feat(linkedin): add people-search command (#1621)

Closes #1621. Adds opencli linkedin people-search <keywords> for
finding people on standard LinkedIn (not Sales Navigator).

Architecture note. Standard LinkedIn moved its people search results
page to Server-Driven UI / React Server Components on the
/flagship-web/rsc-action/... path stack. The legacy Voyager REST
endpoint /voyager/api/search/dash/clusters returns HTTP 500 from a
web context; its modern camelCase rename voyagerSearchDashClusters
returns the same. The result list is rendered server-side and the
page HTML IS the result payload; Voyager calls from the page are
sidebar / notification concerns, not search results.

Extraction strategy. LinkedIn SSR uses obfuscated CSS class hashes
(e.g. _997b7c77) that rotate on every deploy AND display:contents
wrappers that flatten the DOM tree. Class-based selectors, walk-up-
to-card logic, and anchor-pair element ranges all fail because no
element boundary matches a person's card.

Working approach: extract main.innerText once, split by newline,
slice between consecutive person names. The names come from the
aria-hidden spans of /in/<handle> anchors. LinkedIn's SSR emits a
card as a name line followed by degree badge / headline / location
/ action labels before the next card's name line - a layout that
has been stable through several DOM refactors.

Critical filter: /in/<handle> anchors over-count because LinkedIn
renders each mutual connection as a /in/ anchor inside another
card's result. The skip() predicate during name-line lookup drops
mutual-connection lines ("X, Y and N other mutual connections"), so
anchors that don't have a real name line are filtered out.

CUL caveat. LinkedIn imposes a monthly Commercial Use Limit on
people search against the standard site. Burst behaviour is
irrelevant - the limit is a calendar-month counter. The adapter
runs one navigation per invocation (no pagination) so a single call
costs exactly one CUL query. --limit is capped at 10 to keep a
single call's information density high without surfacing the
"reached commercial use limit" yellow banner faster.

Schema:
  rank, name, headline, location, profile_url

Live verified against kyfw 12306-style throttled cadence (sleep 60s
between dev iterations to keep CUL consumption visible): 5/5 rows
populated with name + headline + location + profile_url for the
keyword "reinforcement learning". Mutual-connection anchors
correctly filtered out so the row order matches LinkedIn's own
ranking.

Tests: 10 unit tests covering URL construction, limit validation,
extraction-script invariants (anchor enumeration, text-slice
approach, mutual-connection filter, aria-hidden span as name source),
limit slicing, AuthRequiredError on missing JSESSIONID, CUL-
flavoured CommandExecutionError on redirect, EmptyResultError on
zero rows, ArgumentError on empty keywords, and registry shape.

* fix(linkedin): harden people search typed boundaries

* fix(linkedin): fail people search candidate parser drift

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-20 00:40:53 +08:00
Benjamin Liu 62592547a4 fix(adapters): migrate empty-data throws to EmptyResultError across 5 commands (#1674 follow-up) (#1678)
* fix(adapters): migrate empty-data throws to EmptyResultError across 5 commands (#1674 follow-up)

Continues the structured-error migration owner started in #1674
(fix(xhs,youtube): 把合法空数据语义切到 EmptyResultError). Same
motivation: callers need to distinguish "the platform legitimately
has no data for this target" from "fetch infrastructure is broken,
retry me", because downstream automation pipelines that batch over
seed lists conflate the two and trip soft-rate-limit heuristics.

Sites converted (5 commands, 6 throw sites):

powerchina/search.js (2 sites):
- "[taxonomy=empty_result] ... extracted only navigation/portal rows"
- "[taxonomy=empty_result] ... api/dom yielded no result"
  Both already self-labelled with the empty_result taxonomy tag,
  making this the canonical fix.

xiaohongshu/creator-notes.js, creator-notes-summary.js (both):
- "No notes found. Are you logged into creator.xiaohongshu.com?"
  The "is logged in" hint is preserved in the empty message so users
  can self-diagnose, while the error type is now structured.

xiaohongshu/creator-stats.js:
- "No data for period <X>. Available: <a, b, c>"
  Empty-data condition: requested period exists in the API surface
  but has zero numeric data; available periods are still surfaced
  in the message.

xiaohongshu/creator-note-detail.js:
- "No note detail data found. Check note_id and login status..."

Shape: exit code 66, stderr code: EMPTY_RESULT, matching
bilibili/subtitle, xhs/user, youtube/transcript precedent.

Out of scope:
- tiktok/{user,notifications,explore}.js: throws live inside
  page.evaluate template strings and run in browser context; the
  Node-side caller already regex-routes them via
  throwTikTokPageContextError({emptyPattern: /No videos found/, ...})
  to EmptyResultError. The existing design is correct.
- eastmoney/_secid.js / antigravity/serve.js / instagram/collection-*:
  input-validation throws, ArgumentError territory not EmptyResultError.

* test(adapters): cover empty-result migrations

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-20 00:30:54 +08:00
jakevin 4a3a55634a docs(readme): curate built-in commands to popular sites + add wrangler (#1679)
Per WAWQAQ:

1. Built-in Commands table cut from 30 EN rows / 86 ZH rows down to a
   curated 11-site list (xiaohongshu, bilibili, zhihu, hackernews,
   linkedin, reddit, twitter, claude, gemini, notebooklm, amazon).
   The README is meant to surface high-traffic / well-known sites;
   the long-tail (100+ adapters) is one click away via
   docs/adapters/index.md. linkedin (full) replaces linkedin-learning
   in the curated set per the spec.

2. Add Cloudflare Wrangler as a new external CLI passthrough:
   - src/external-clis.yaml entry (binary: wrangler, npm -g)
   - CLI Hub table row in EN + ZH READMEs
   - cli-manifest.json regen reflects the new entry (857 entries)
2026-05-19 23:17:15 +08:00
lenovobenben da497f0b02 feat(zhihu): add answer comments reader
* feat(zhihu): add answer comments reader

* fix(zhihu): harden answer-comments boundaries

* fix(zhihu): keep answer comments flat

---------

Co-authored-by: lihaidong <lihaidong@kingsoft.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 19:59:42 +08:00
jakevin 2590278f43 fix(chatgpt): detect generated image surfaces (#1677) 2026-05-19 19:50:14 +08:00
Benjamin Liu 488e407a65 feat(twitter): add device-follow notification stream command
* feat(twitter): add device-follow command for /i/timeline notification stream (#1628)

Closes #1628. Adds the twitter device-follow command, which reads the
curated tweet list aggregated under a bell-icon "new posts from @userA
and N others" notification. Direct GET /i/timeline redirects to /home,
so the data is only reachable via the legacy v1.1 REST endpoint
/i/api/2/notifications/device_follow.json , none of the existing
twitter commands cover this stream:

- twitter timeline    home for-you / following feed (different endpoint)
- twitter notifications  the notification list itself, not aggregated
                         tweets inside any one notification
- twitter search     search-based, can't reproduce the aggregation

Endpoint discovery + field-mapping originally proposed by @traddo in
#1628; this PR upstreams a clean implementation that:

- Strategy.COOKIE + ct0 from CDP cookie jar + the public web bearer
  token from clis/twitter/utils.js (same auth path as twitter timeline)
- Hits /i/api/2/notifications/device_follow.json directly via
  page.evaluate fetch on the x.com origin so SameSite=Lax cookies are
  preserved
- Joins each entry.content.item.content.tweet.id to
  globalObjects.tweets[id] and resolves the author via
  globalObjects.users[tweet.user_id_str]
- Returns the canonical twitter row columns (id, author, text, likes,
  retweets, replies, views, created_at, url), matching twitter timeline
  minus has_media / media_urls / card / quoted_tweet which the legacy
  v1.1 endpoint does not surface
- Sets views: null rather than a 0 sentinel; the legacy endpoint does
  not return view counts even with include_ext_views=true, and the
  GraphQL TweetResultByRestId round-trip per tweet was judged too
  expensive for a list command (typed-errors §3: no scalar sentinels
  that lie about real engagement)
- parseLimit enforces strict 1-200 integer validation with no silent
  clamping; the only baseline addition is the silent-sentinel on the
  "unknown" author fallback, which matches the exact precedent in
  twitter/timeline.js:76 that is already baselined

Tests: 17 unit tests in device-follow.test.js cover parseLimit strict
validation, URL parameter shape, entry/tweet join, user-resolution
fallback, dedup via the seen set, empty-stream shape, the canonical
column registration, AuthRequiredError on missing ct0, and
CommandExecutionError on non-2xx fetch.

Live verified the endpoint shape end-to-end against the logged-in
session: HTTP 200 with the expected
{globalObjects: {tweets, users}, timeline: {id: 'tweet_notifications',
instructions: [{addEntries: {entries: []}}]}} envelope. The tester
account has no bell-notification follows enabled, so entries is empty,
but the shape and auth path are confirmed against the documented
spec.

* fix(twitter): harden device-follow typed boundaries

* fix(twitter): fail fast on device-follow drift

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 19:34:30 +08:00
jakevin e682c1c30a fix(deps): restore Node 20 runtime compatibility (#1673) 2026-05-19 19:20:38 +08:00
Ocean 86f57c0846 feat(reddit): 在 listing 命令上暴露 post_hint / url / preview / gallery 4 个媒体路由列 (#1676)
* feat(reddit): 在 listing 命令上暴露 post_hint / url / preview / gallery 4 个媒体路由列

5 个 reddit listing 命令(popular / hot / frontpage / search / subreddit)
每行新增 4 列,下游消费者不用 scrape selftext 也能区分 image / gallery /
hosted:video / link / self 五类内容:

- `post_hint` — Reddit 自报的内容类型(image | hosted:video | link | self 等)
- `url_overridden_by_dest` — 外链帖的原始 URL(image/link 类型才有)
- `preview_image_url` — 缩略图地址(HTML-decoded,Reddit 即便 raw_json=1
  也会在 preview URL 里返回 `&amp;`)
- `gallery_urls` — 多图相册数组(HTML-decoded)

## 实现

每个 adapter 的 evaluate 块内嵌两个 helper:

- `decodeHtml(s)` — 6 个 HTML entity 替换(&amp; / &lt; / &gt; / &quot; /
  &#x27; / &#39;)
- `extractRedditMedia(d)` — 从 post `data` 中抽 4 个字段,gallery_urls 从
  `gallery_data.items[].media_id` × `media_metadata[id].s.u` 组合得到

helper 在每个 adapter 里 inline 复制(reddit 没有 shared 文件,模式跟现有
adapter 一致)。`clis/reddit/extract-media.test.js` 把 helper 行为锁在
8 个 fixture(plain / image / gallery / hosted-video / link /
html-decode / 缺字段 / nullish input);每个 adapter 的 .test.js 额外
grep 自己源码里有 `function extractRedditMedia` 和 `...extractRedditMedia(c.data)`
两处接入痕迹,并断言 columns 数组形状。

frontpage 和 subreddit 之前 evaluate 返回原始 `children`、map 块按
`item.data.title` 索引;为了让 `gallery_urls` 这种数组字段能被 map 块的
模板字符串渲染,refactor 成和 popular/hot/search 一致的"evaluate 内部
就 map 成中间对象、map 块按 `item.title` 索引"模式。

## 范围

只覆盖 5 个 listing 命令。**`read` 不在本 PR 内**:它的 evaluate 块在
post-#1651 时代已经是 error-kind-discriminated 的富结构(`kind: 'inaccessible'`
/ `kind: 'http'` / `kind: 'malformed'`),原始 commit 的"POST 行带 media、
comment 行空"模式和当前结构冲突太深,单独的 read 接入留作后续 PR。

完全 additive:既有字段名、顺序、值都不变;新字段加在每行末尾。

## 验证

- `npx vitest run clis/reddit/ --project adapter` → 84/84 通过
- `node scripts/check-silent-column-drop.mjs` → current=97, baseline=97, new=0
- `npx tsc --noEmit` 干净
- `npm run build` 干净

* feat(reddit): expose home media route columns

---------

Co-authored-by: huanghe <he.huang@extremevision.mo>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 19:14:37 +08:00
Ocean 4a6cfe8060 fix(xhs,youtube): 把合法空数据语义切到 EmptyResultError (#1674)
* fix(xhs,youtube): 把"合法空数据"语义切到 EmptyResultError

把 xiaohongshu/user 和 youtube/transcript 跟 bilibili/subtitle 已有的
structured-error 模式对齐,让下游能区分"用户/视频没内容"和"fetch 真的挂了"。

## xiaohongshu user

返回 `No public notes found for this Xiaohongshu user` 的场景——目标用户
零公开笔记(销号 / 私密 / 全删)——原来抛 plain `Error`,下游无法和
"真的 fetch 失败 / cookie 死"区分。

改抛 `EmptyResultError`,exit code 变 66,stderr 携带 `code: EMPTY_RESULT`,
跟 `bilibili subtitle` empty 同 shape。

## youtube transcript

`No captions available for this video`(作者没开 CC、YouTube 也没自动生成)
是数据条件,不是基础设施失败。原来跟 HTTP/解析错误一样抛
`CommandExecutionError`,造成调用方反复重试。

改这个特定 case 抛 `EmptyResultError`;其他 caption 错误(HTTP / parse /
empty response)继续走 `CommandExecutionError` 触发重试。

## 为什么 downstream 需要这个

调用方(如自动化采集流水线)通常对 "data.length === 0 && exitCode !== 0"
做 soft-rate-limit 启发式:N 次连续 soft fail 触发 24h 平台跳过。当 seed 列表
里有变质条目(XHS 账号销号 / YouTube 视频丢失字幕),"empty" 响应堆积会
误触跳过——cookies 和平台本身都健康。EmptyResultError 让调用方能区分
"这个用户没内容"和"API 挂了"。

## 测试

- `npx vitest run clis/xiaohongshu clis/youtube` —— 全过
- `npx tsc --noEmit` 干净

* fix(xhs): distinguish empty user notes from parser drift

* fix(empty): tighten legal empty evidence

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 19:02:32 +08:00
Ocean bcb0fb362f feat(twitter): expose bio on read command
* feat(twitter): 在 read 命令上暴露 bio(用户简介)

`list-tweets` / `timeline` / `search` 三个读命令现在每行多一列 `bio`,从
`user.legacy.description` 抽。匹配 `profile` 命令已有的 `bio` 字段,让下游
消费者展示作者画像时省去"读了推文还要再读作者主页"的 roundtrip。

bio 在 user 对象缺失或没 description 时回落到 `''`。columns 数组同步更新,
`--format columns` 会渲染 bio。完全 additive:既有字段名、顺序、值都不变。

延续 #1660 (card binding_values) 和 #1667 (quoted_tweet) 的同一类
read-side enrichment 模式。

## 验证

- `clis/twitter/list-tweets.test.js` / `clis/twitter/search.test.js` 已有
  shape assertion 补上 `bio: ''` 行
- `timeline.test.js` 用 `toMatchObject`(子集匹配),新增 bio 不会破断言
- `npx vitest run clis/twitter/list-tweets.test.js clis/twitter/timeline.test.js
  clis/twitter/search.test.js --project adapter` → 48/48 通过
- `npx tsc --noEmit` 干净
- `npm run build` 干净
- `node scripts/check-silent-column-drop.mjs` → current=97, baseline=97, new=0

* test(twitter): cover inline bio extraction

* feat(twitter): expose thread author bio

---------

Co-authored-by: huanghe <he.huang@extremevision.mo>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 18:56:38 +08:00
lenovobenben 85b1c07ba9 feat(zhihu): include answer links in question results
* feat(zhihu): include answer links in question results

* fix(zhihu): avoid fake answer links for malformed ids

* fix(zhihu): dedupe answers by trusted id

---------

Co-authored-by: lihaidong <lihaidong@kingsoft.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 15:34:24 +08:00
Ocean 6fbaf0d5b8 feat(twitter): 在 read 命令上暴露 quoted_tweet(被引用的推文) (#1667)
* feat(twitter): expose quoted_tweet on read commands

When a tweet quotes another tweet (embedded preview with commentary), the
quoted tweet's content is in `tweet.quoted_status_result.result` — same
`legacy / core / card / note_tweet` shape as the outer tweet. Until now
none of the 5 read commands (list-tweets / timeline / thread / tweets /
search) surfaced this nested object, so downstream consumers couldn't
render the quoted preview card.

Adds `extractQuotedTweet(tw)` in shared.js (mirrors the
`extractMedia` / `extractCard` helper pattern) and threads it through
all 5 read commands plus their CLI `columns:` declarations.

Output shape is a deliberately small subset of the main tweet
(id/author/name/text/created_at/url + media + card). Counts and full
author bio are intentionally omitted to keep timeline payloads from
ballooning 2-3x; consumers needing those can re-fetch
`twitter thread <quoted_id>`.

Notable edge cases tested in shared.test.js:
- plain tweets (no `is_quote_status`) -> null
- tombstoned / unavailable quoted tweets (deleted / privacy-restricted) -> null
- TweetWithVisibilityResults `result.tweet` shim unwrap
- long-form note_tweet text preferred over truncated full_text
- quote-of-a-quote does NOT recurse (avoids payload explosion on threads
  where every reply re-quotes the root)

* fix(twitter): require quoted tweet render evidence

* fix(twitter): validate quoted tweet author shape

---------

Co-authored-by: ml-scout <ml-scout@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 14:56:24 +08:00
Ocean 34f351e59f feat(reddit/subscribed): 接入 LoginWallError 嗅探(#1650 的第一个 caller) (#1668)
* feat(errors,utils): 添加 LoginWallError 与 HTML-as-JSON 响应嗅探器

部分 adapter(twitter list-tweets/thread、reddit search/subreddit 等)历史上
直接 `JSON.parse(await r.text())` 或 `await r.json()` 解析响应。当服务端返回
登录墙、限流页或 WAF 拦截页(而不是 JSON)时,body 以 `<!DOCTYPE html>` 或
`<html ...>` 开头,解析直接抛出晦涩的
`SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON`,
调用方无法把它和真正的 JSON 解析失败区分开。

本 PR 添加三块共享基础设施,让 adapter 能识别 HTML 情况并抛出结构化的
`LoginWallError`(带 status / url / body 预览),而不是裸的解析栈:

  - `LoginWallError`(src/errors.ts):新的 `CliError` 子类,含 `status`、
    `url`、`bodyPreview` 字段,hint 提示"重新登录或等待限流过期",
    退出码映射到 `EXIT_CODES.NOPERM`。
  - `parseJsonOrThrowLoginWall(response)`(src/utils.ts):Node 端 helper,
    供从 daemon 侧 fetch 的 adapter 使用(接收 Fetch Response)。
  - `BROWSER_JSON_SNIFF_FN` + `throwIfLoginWall(value)`(src/utils.ts):
    browser 端等价物。字符串片段嵌入到 `page.evaluate` 里,返回值是
    解析后的 JSON,或 `{ error: status }` HTTP 形状,或 `LoginWallSignal`
    哨兵对象(`{ __loginWall: true, status, url, ... }`),Node 侧拿到后
    转成 `LoginWallError`。

行为是 opt-in:现有 adapter 不调用这些 helper 就完全不受影响。后续 PR
会把 reddit / twitter adapter 接入这些 helper。`src/utils.test.ts` 新增
13 个单测,覆盖 Node 端 + browser 端两条路径以及 body 预览的 100 字截断。

* feat(reddit/subscribed): 接入 LoginWallError 嗅探(#1650 的第一个 caller)

`reddit subscribed` 是从 daemon-backed browser session 调 Reddit 的 JSON API
(`/api/me.json` + `/subreddits/mine/subscriptions.json`)。原本两处 `await res.json()`
在 Reddit 返回登录墙 / WAF / over-18 拦截页(HTML body + 200 OK)时会抛
`SyntaxError: Unexpected token '<'`,被外层 try/catch 兜底成 `kind: 'exception'`
→ Node 侧报成 `CommandExecutionError: subscribed failed: SyntaxError ...`,
看不出 root cause。

接入 #1650 的 helper 后:

- **Browser 侧**:用 `BROWSER_JSON_SNIFF_FN` 提供的 `fetchJsonOrLoginWall(url, init)`
  替换裸 `fetch + .json()`。helper 内部 sniff `Content-Type: text/html` 或
  `<!DOCTYPE` / `<html` body 前缀,返回 `{ __loginWall: true, status, url,
  contentType, bodyPreview }` 哨兵(不抛,交给调用者)。
- **Cross-boundary**:两处 fetch 站点(me.json + subscriptions.json)发现哨兵后
  返回 `{ kind: 'login-wall', sentinel, where }` 透传给 Node。
- **Node 侧**:`throwIfLoginWall(result.sentinel, { url: result.where })` 把
  哨兵转成结构化 `LoginWallError`(含 `status` / `url` / `bodyPreview` 字段,
  exit code `EXIT_CODES.NOPERM`,hint 提示重新登录或等限流过期)。

这是 #1650 的第一个真实 caller,覆盖 3 块 export 全部(`BROWSER_JSON_SNIFF_FN`
+ `throwIfLoginWall` + `LoginWallError`)。其它 adapter 后续按这个模板逐个接入。

回归测试新增 1 个:mock evaluate 返回 `{ kind: 'login-wall', sentinel }`,
断言 Node 端抛 `LoginWallError` 且 `status` / `url` / `bodyPreview` 字段正确。
原有 12 个测试不动,全部通过。
2026-05-19 14:52:42 +08:00
jakevin acc18be999 docs(readme): tighten skill attribution + remove redundant Highlights (#1666)
Per WAWQAQ T1 + T2 review:

T1 — skill attribution carries the same intent PR #1654 started but
hadn't fully cleaned up:
- Skill table row for `opencli-adapter-author` no longer claims it
  "operate[s] a site in real time" (SKILL.md explicitly says ad-hoc
  driving lives in `opencli-browser`). Browser-op example
  ("Help me check my Xiaohongshu notifications") moved to the
  `opencli-browser` row where it belongs.
- "How it works" section's 5 browser primitives (navigate / read /
  interact / extract / wait) now point to `opencli-browser` instead
  of `opencli-adapter-author`.
- Skill references list re-orders to surface `opencli-browser` first
  with a concrete description, and `opencli-adapter-author` no longer
  claims to cover "browser operation".

T2 — drop the Highlights section. Pre-Quick-Start had four parallel
summary blocks (3-line tagline / 3-bullet automation intro / CLI-hub
+ desktop line / 5-bullet Highlights) that all said the same thing.
Highlights was the most-recent and most-redundant of the four; the
remaining three carry the value props cleanly: tagline → three usage
modes → CLI-hub + desktop scope.

EN + ZH READMEs synced.
2026-05-19 13:28:44 +08:00
Benjamin Liu 67ed9e9c81 feat(linkedin-learning): add search / trending / course read commands (#1657)
* feat(linkedin-learning): add search / trending / course read commands (#1021)

Closes #1021. Adds a new linkedin-learning site adapter with three
read-only commands against LinkedIn Learning's public learning-api
REST surface. Shares cookie session with linkedin.com; Learning
queries are not subject to the people-search CUL.

Commands:
- linkedin-learning search <keywords>  searchV2?q=keywords
- linkedin-learning trending            feedRecommendationGroups?q=learner
- linkedin-learning course <slug>      courses?q=slug

Endpoints were discovered via browser network capture on
/learning/search and /learning/<slug> pages: searchV2 returns a flat
list of courses/videos/paths keyed by entityType, headline.title.text
holds the canonical title, length is a TimeSpan in seconds, and rating
is averaged from ratingSum/ratingCount when averageRating is missing.

trending walks the carousels array on each recommendation group, flattens
cards across them, dedups by slug, and respects --limit. Group is
labeled with the carousel title (e.g. "Top picks for you") or the
upstream annotation tag (TOP_PICKS).

course accepts either a bare slug or a full /learning/<slug> URL, then
hits /learning-api/courses?q=slug. The detail endpoint omits rating
fields even when search reports them; this is documented in the
adapter doc rather than fixed via a second /reviews fetch to keep the
PR scoped to one endpoint per command.

CUL caveat: Learning's API has no per-month limit, so dev iterations
can be much more aggressive than the people-search adapter (#1649).
Three commands were live-verified against a logged-in account with
60s sleeps between calls (conservative for first-pass safety).

Tests: 28 unit tests across search.test.js (12), trending.test.js (6),
course.test.js (10) cover URL construction, limit validation, author
join, duration / rating coercion, row mapping, carousel flattening
and dedup, slug parsing from URL forms, and the standard auth /
empty / fetch-failure error paths.

Live verified:
- search "AI agent" --limit 3: 3 rows with title/instructor/rating
- trending --limit 3: 3 personalized course picks
- course agentic-ai-build-your-first-agentic-ai-system: title, 3932s
  duration, 18 videos, release date 2026-03-27

* fix(linkedin-learning): harden read result boundaries

* fix(linkedin-learning): require course title evidence

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 13:17:33 +08:00
Benjamin Liu 8577d88ee7 fix(cli): escape leading-dash positional values via argv preprocessor (#1658)
* fix(cli): escape leading-dash positional values via argv preprocessor (#1160)

Closes #1160. `opencli boss detail -abc123def` failed with
`error: unknown option '-abc123def'` because commander treats any
argv token starting with `-` as an option. BOSS 直聘 securityId
tokens are opaque base64-ish strings that can legitimately start
with `-`, and the same shape is possible for any adapter that takes
an opaque-id positional.

Adds escapeLeadingDashPositional() to src/cli-argv-preprocess.ts,
called from main.ts after the existing rewriteBrowserArgv pass. The
preprocessor:

- Reads cli-manifest.json (the same manifest the registry uses) and
  builds a set of `<site>/<cmd>` keys whose first positional is
  required.
- Walks past root flags (matching the existing rewriteBrowserArgv
  walker) to find the site + command tokens.
- If the next argv token starts with `-`, is not the recognised
  short flags `-f` / `-v` / `-h`, is not `--*`, and is not the
  pre-escaped `--` separator, inserts `--` before it.

Tests: 12 new unit tests in cli-argv-preprocess.test.ts cover the
basic insertion, trailing-flag preservation, non-touched cases
(normal values, recognised short flags, long flags, already-escaped,
non-positional commands, unknown commands, short argv, and the
`--profile work boss detail -abc` form that walks past a root
value flag).

Live verified: `node ./dist/src/main.js boss detail -abc123def`
no longer raises 'unknown option'. The adapter now receives the
dash-leading value and proceeds to fetch, where it correctly
surfaces an upstream "missing required parameter" error for the
fake id used in this smoke test.

* fix(cli): preserve options around dash positionals

* fix(cli): preserve attached short option values

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 13:13:24 +08:00
Ocean cd731bd2aa feat(twitter): 在 read 命令上暴露 card binding_values(链接预览卡片) (#1660)
* feat(twitter): expose card binding_values on read commands

Surface tweet link-preview cards (title, description, image, domain, landing URL)
on `search`, `list-tweets`, `thread`, and `timeline` so downstream renderers
can build native-style link cards without re-fetching. Pure GraphQL-response
extractor — no query strategy, interceptor, or network changes.

extractCard returns null when the tweet has no card or when the card is
structurally empty (no url AND no title/description). Missing fields are
omitted from the output to keep JSON consumers clean.

* fix(twitter): bind cards to matching URL entity

---------

Co-authored-by: ml-scout <ml-scout@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 12:55:19 +08:00
Ocean d1c714ecd3 feat(twitter): 新增 list-create 命令(GraphQL CreateList mutation) (#1656)
* feat(twitter): add list-create command

Adds a new `twitter list-create` command so users can create Twitter/X
lists from the CLI (the existing list commands only covered reading,
adding, and removing members). Uses the GraphQL CreateList mutation
with the same cookie + CSRF pattern as list-add, no UI clicks needed.

Args: name (positional, max 25), --description (max 100), --mode (public|private).
QueryId resolved at runtime via resolveTwitterQueryId, with a known
fallback for offline / bundle-scan misses.

* fix(twitter): pin list-create queryId + features to a working pair

Twitter's GraphQL rejects CreateList when queryId and the features
schema drift apart (DecodeException). Stop resolving the queryId
dynamically (which would pull a newer schema), hardcode a known-good
queryId, and trim features to the minimal set the real web client
sends.

Also: Twitter sometimes returns a non-fatal errors array from a
side-effect serializer while still creating the list. Check for a
valid list payload first and only treat errors as fatal when no
list came back.

* fix(twitter): add missing access:'write' on list-create (#9)

`twitter/list-create` was missing the required `access` field, which made
manifest validation fail on every opencli invocation and spam stderr with:

  ⚠  Failed to load manifest .../cli-manifest.json: Command
     twitter/list-create must declare access: 'read' | 'write'

Per docs/conventions/convention-audit.md (rule missing-access-metadata),
every adapter command must declare access. Since list-create is a create
action, set access: 'write'.

Also rebuilds cli-manifest.json — picks up missing `quoted_tweet` columns
on list-tweets / search / list-tweets-username from PR #8 (which didn't
rebuild the manifest).

* fix(twitter): harden list-create mutation contract

* fix(twitter): verify created list name

---------

Co-authored-by: huanghe <he.huang@extremevision.mo>
Co-authored-by: Kary <karyhe1019@gmail.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 12:51:57 +08:00
dependabot[bot] 8182ffbe89 chore(deps): bump tsx from 4.21.0 to 4.22.2 (#1663)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.21.0 to 4.22.2.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.2)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.22.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:41:48 +08:00
dependabot[bot] 5a4984789d chore(deps): bump @types/node from 25.6.0 to 25.9.0 (#1664)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.0 to 25.9.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:40:20 +08:00
dependabot[bot] e1185da882 chore(deps): bump ws from 8.20.0 to 8.20.1 (#1662)
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:40:14 +08:00
dependabot[bot] 9446bddb60 chore(deps): bump undici from 6.25.0 to 8.3.0 (#1661)
Bumps [undici](https://github.com/nodejs/undici) from 6.25.0 to 8.3.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.25.0...v8.3.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:37:31 +08:00
Ocean 0c4bcdbb86 feat(reddit): 新增 subscribed 命令 + 在 listing 命令上暴露 id / created_utc / selftext (#1651)
* feat(reddit): subscribed command + expose id/created_utc/selftext on listing commands

Adds `opencli reddit subscribed` to list the user's subscribed subreddits,
mirroring `saved.js`'s cookie auth + AuthRequiredError pattern. Auto-paginates
via `/subreddits/mine/subscriptions.json` (max 1000 subs, default 100).

Also extends the JSON output of `popular` / `search` / `subreddit` with
`id`, `created_utc`, `selftext` (and `author` on popular) — the table
view stays clean (columns: unchanged), but `--format json` now surfaces
fields needed for downstream content-recommendation tooling that filters
by post age, dedupes by post id, or uses self-post bodies for embeddings.

Tests: 4 new vitest cases for subscribed.js (happy / auth fail / HTTP /
--limit truncation). All existing reddit tests still pass.

Note on cli-manifest.json diff: the rebuild on fork/main drops 13 entries
whose source files import lowercase `selectorError` from
`@jackwener/opencli/errors` (the actual export is `SelectorError` —
casing bug pre-existing in fork/main). Not introduced by this PR.

* fix(reddit): harden subscribed listing contract

* fix(reddit): require subreddit identity for subscriptions

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 02:36:17 +08:00
lenovobenben ec3b7dadf3 fix(zhihu): decode numeric entities in answer detail (#1629)
Co-authored-by: lihaidong <lihaidong@kingsoft.com>
2026-05-19 02:14:48 +08:00
ele-yufo 4de04c43ad feat(suno): add suno.com music-generation adapter (#1638)
* fix(suno): harden generation adapter contracts

* fix(suno): separate session auth and API failures

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 02:12:03 +08:00
Benjamin Liu 40592ea5fd fix(adapters): drop silent-sentinel row fallbacks across Apple Podcasts, Reddit, and Gitee (#1634)
* fix(adapters): drop silent-sentinel row fallbacks across Apple Podcasts, Reddit, and Gitee

Continues the audit-baseline cleanup from #1611 (lesswrong) and #1631
(wikipedia / 36kr / xiaoyuzhou / zhihu), and follows the direction set
by 71646158 (silent-empty-fallback resolutions across Douyin / Jike /
WeRead) and ee54eb8e (ignore sentinels in thrown errors).

Replaces silent-sentinel row fallbacks with the empty-string signal so
agents can tell apart "field has value Unknown" from "upstream returned
no value":

- apple-podcasts/search: episodes, genre
- reddit/saved: title
- reddit/upvoted: title
- gitee/search: language, description

All four files audited for downstream sentinel checks via
`grep -nE "=== ?['\"](Unknown|unknown|-)['\"]"`. None reference the
swapped values in control flow (verified against the v2ex/me.js class
of regression caught in #1631).

Intentionally skipped in this batch (will not flip to empty):
- gitee/trending.js:272: downstream `project.description !== '-'`
  check drives the mergedDescription fallback. Same control-flow
  sentinel pattern as v2ex/me.js. Stays on baseline.
- web/read.js x4: `'-'` lives inside rendered diagnostic lines
  (`lines.push(...)`), not row fields. Empty would render
  `  GET    /a/b` with a doubled space. UX placeholder.
- yollomi/{edit,video}.js x6: `file: '-'`, `size: '-'`, `credits: '-'`
  are user-facing status rows displayed to humans. Empty would
  collapse columns visually.
- zsxq/dynamics.js: `title: '[${d.action || 'unknown'}]'` is a
  template-literal-rendered title prefix. Empty would render `[]`.

Verified live: `opencli apple-podcasts search "lex fridman" --limit 2`
returns populated episodes/genre. `opencli gitee search "vue" --limit 2`
returns populated language/description. Baseline shrinks accordingly.

* test(adapters): add empty-signal coverage for the cluster-3 sentinel swap

Mirrors the cluster-2 test additions, pairing the sentinel value swap
in this PR with focused unit tests that mock the upstream to return
null / missing fields and assert the row surfaces an empty-string
signal instead of the old fabricated '-' / 'unknown' sentinel.

Coverage:

- clis/apple-podcasts/commands.test.js (+1 case): stubs the iTunes
  Search response with a result that has collectionId / collectionName
  / artistName populated but no trackCount and no primaryGenreName.
  Asserts episodes and genre render as '' (was '-' before this PR).

- clis/gitee/search.test.js (new): mocks Gitee's `so.gitee.com/v1/search`
  fetch with two cases - a hit that has only title + url (no langs,
  no description), and a hit that has all fields populated. Asserts
  the missing fields render as '' (was '-' before) and that populated
  fields pass through verbatim.

The reddit/saved and reddit/upvoted changes in this PR live inside a
page.evaluate template literal that fetches from reddit.com inside
the browser context, so the empty-signal branch is executed inside
the page rather than in adapter JS. They are 1-char `|| '-'` ->
`|| ''` swaps with no downstream sentinel consumer and the same JS
semantics demonstrated by the gitee + apple-podcasts tests above.

* chore: rebuild cli-manifest.json to drop stale entries from rebase

The previous rebase left a stale linkedin/people-search entry in
cli-manifest.json that was carried over from a sibling feature branch.
This branch does not include the people-search source file, so the
entry was an orphan; CI's build-manifest safety check correctly
refused to overwrite it. Regenerating with --allow-removals to drop
the orphaned entry, after which a normal `npm run build` is a no-op.
2026-05-19 01:52:52 +08:00
Ocean 942539a695 fix(twitter/lists): 跳过 "Discover new Lists" 推荐区块,避免被当成用户的 list 抓取 (#1652)
* fix(twitter): skip "Discover new Lists" recommendations in lists adapter

The X.com /<user>/lists page powers two sections from a single
ListsManagementPageTimeline GraphQL response: "Discover new Lists"
(algorithmic recommendations) and "Your Lists" (owned + subscribed).
The previous parser ignored entry.entryId entirely and returned every
list it found, so recommendations leaked through and downstream
consumers treated them as the user's own lists.

X distinguishes the sections by entry.entryId prefix:

  owned-subscribed-list-module-*  → owned + subscribed (keep)
  list-to-follow-module-*         → Discover recommendations (drop)
  cursor-*                         → pagination cursor (no list payload)

Filter on the owned-subscribed prefix in parseListsManagement and
expose isOwnedSubscribedEntry for testing. The existing test fixture
used a fictional entryId shape that no longer matches real responses;
update it to the nested-module shape Twitter actually returns and add
two new tests: one proving Discover entries are skipped, and one for
the entryId classifier.

Verified end-to-end against a live account: 10 raw entries (3 Discover
+ 7 owned/subscribed) now correctly return 7 owned/subscribed lists
with zero leakage.

* fix(twitter): harden lists parser boundary

* fix(twitter): require list-remove postcondition evidence

---------

Co-authored-by: huanghe <he.huang@extremevision.mo>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 01:00:19 +08:00
Ocean dc645a5bcf fix(youtube/transcript): 把 timedtext URL 匹配限定到当前 videoId,修跨视频字幕串台 (#1655)
* fix(youtube/transcript): scope timedtext URL match to current videoId

YouTube watch-page is an SPA — page.goto between watch URLs preserves
performance.getEntriesByType('resource') entries from prior videos.
findTimedtextUrl filtered only by lang, so a previously-viewed
same-language video's timedtext URL could be picked up by the polling
loop before the current video's fetch hook captured a fresh one,
returning the wrong video's captions to the caller.

Fix: require URLs to contain v=<currentVideoId> across all three paths:
  - in-page findTimedtextUrl (resource-buffer scan)
  - in-page isJson3TimedtextUrl (fetch/XHR hook)
  - Node-side extractSegmentsFromNetworkCapture (CDP capture)

Most likely to hit callers that reuse a single daemon tab to fetch
many transcripts back-to-back (e.g. ml-scout). Confirmed in the wild:
a Fox News Ukraine clip got Whisper Flow promo captions written to
its row when the prior call on the same tab pulled an English
Whisper Flow video.

Adds one source-contract assertion (both in-page sites use a shared
videoIdMarker) and one behavioral test (CDP capture buffer with a
stale 'v=prev' entry alongside the current 'v=abc' returns only the
current video's captions).

* fix(youtube): exact-match transcript timedtext video id

---------

Co-authored-by: ml-scout <ml-scout@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 00:53:39 +08:00
jakevin f0d9aa187c docs(readme): fix skill attribution for "operate any website" use case (#1654)
Per WAWQAQ feedback: the intro section's "Let AI Agents operate any
website" bullet mistakenly references `opencli-adapter-author`, which
is the skill for **writing** adapters (correctly referenced in the
adjacent "Write new adapters" bullet). The skill for ad-hoc browser
driving is `opencli-browser` — its own SKILL.md frontmatter explicitly
says "Not for writing adapters — see opencli-adapter-author for that",
and `opencli-adapter-author` says "For ad-hoc browser driving (no
adapter), see opencli-browser instead".

Two locations affected with the same error: the intro bullet and the
Core Concepts > `browser` section. Both EN and ZH READMEs updated.
2026-05-19 00:37:32 +08:00
Benjamin Liu e82e32abc6 feat(12306): add full read adapter (stations / trains / train / price / me / passengers / orders) (#1637)
* feat(12306): add stations / trains / train read commands (no login required)

Adds a first-pass 12306 (中国铁路) adapter for the public anonymous
query endpoints. Closes the no-login slice of #1589. The
authenticated `me / passengers / orders` commands the issue
proposes are explicitly left as a follow-up.

Commands:
- 12306 stations <keyword>             search station bundle
- 12306 trains <from> <to> --date YYYY-MM-DD  availability between stations
- 12306 train <train-no> --from <s> --to <s> --date  stop list

All three use Strategy.PUBLIC + browser: false, anonymous, no cookie
storage, no CAPTCHA bypass. Sensitive behaviors the issue rules out
(ticket sniping, order submission, payment, anti-abuse circumvention,
password storage) are not implemented.

Notes worth flagging for review:

- 12306 rejects anonymous query endpoints with HTTP 302 to
  /mormhweb/logFiles/error.html. The adapter first hits
  /otn/leftTicket/init to mint JSESSIONID / route / BIGipServerotn
  cookies, then attaches them to subsequent queries. No CAPTCHA path.

- 12306 rotates the train-query endpoint name (queryO / queryZ /
  queryA / queryG) every few weeks. When the wrong name is hit the
  server returns `{c_url: "leftTicket/queryX", status: false}`
  pointing to the current correct name. The adapter walks a list of
  known names, captures the rotation hint, and retries; the runtime
  list is also mutated so subsequent calls in the same process skip
  the warm-up round trip.

- The `|`-separated train wire format includes a booking-handshake
  `secret` field at position 0. Since this PR is read-only and the
  issue explicitly rules out booking, that field is parsed but not
  surfaced in the returned row, and a unit test asserts it cannot
  leak via the public adapter contract.

- Station resolution accepts Chinese name (`上海虹桥`), telecode
  (`AOH`), full pinyin (`shanghaihongqiao`), or short alias (`shhq`).
  Anything else raises ArgumentError with a hint.

- `limit` arguments use a tight validator that throws ArgumentError
  on non-integer / out-of-range input rather than silently clamping,
  matching the typed-error pattern used in #1397 (grok) and #1370
  (coupang).

Live verified anonymously against kyfw.12306.cn:
- `12306 stations 上海 --limit 5` returns 5 stations including
  上海 (SHH) / 上海南 (SNH) / 上海虹桥 (AOH).
- `12306 trains 北京 上海 --date 2026-05-22 --limit 1` returns
  G547 06:18 -> 12:11 with first / second / business / no-seat
  availability columns populated.
- `12306 train 24000000G10L --from 北京南 --to 上海虹桥 --date 2026-05-22`
  returns the 7-stop G1 route from 北京南 through 沧州西 / 德州东 /
  曲阜东 / 南京南 / 苏州北 to 上海虹桥, with arrival / departure /
  stopover times.

Tests: 18 unit tests covering parseStationBundle, resolveStation
(including ambiguous / case-insensitive cases), validateDate,
buildCookieHeader, parseTrainRecord (including a regression test
asserting the `secret` field cannot leak into the row).

Deliberately deferred to a follow-up: `12306 price`. The
queryTicketPrice endpoint needs train_no + per-stop station_no +
per-train seat-type letters, so an ergonomic `12306 price <code>`
would cascade three API calls (trains -> stops -> price) per
invocation. Wanted to keep this PR's blast radius small. If the
maintainer prefers a Phase 1 that includes price even with the
cascading-call cost, happy to add it.

* feat(12306): add me / passengers / orders / price authenticated + price read commands

Completes the #1589 12306 (中国铁路) adapter on top of the
stations / trains / train slice landed in the prior commit of this
branch. The full command set is now:

  Anonymous (no login):
    12306 stations  search station bundle by Chinese / telecode / pinyin
    12306 trains    list trains between two stations on a date
    12306 train     list stops of one train
    12306 price     ticket prices for one train segment + date

  Authenticated (cookie session):
    12306 me        account summary (sensitive fields masked by default)
    12306 passengers  saved-passenger list (sensitive fields masked)
    12306 orders    in-progress orders (not yet ridden / refunded)

Notes worth flagging for review:

- 12306 sets the auth cookie `tk` and the session cookie `JSESSIONID`
  with `Path=/otn`. CDP `Network.getCookies` filters by URL path, so
  `page.getCookies({ url: 'https://kyfw.12306.cn' })` returns 7
  cookies without `tk` / `JSESSIONID`, even on a freshly-navigated
  logged-in tab. Switched the login check to read `document.cookie`
  via `page.evaluate`, which the current navigated page exposes
  regardless of cookie path. Centralized as `require12306Login` in
  utils.js so all three authenticated commands share the same check.

- All authenticated commands mask sensitive fields by default:
  - `me`: real name (Chinese mask), email, mobile (12306 already
    masks server-side), birth date (year only).
  - `passengers`: name + birth year by default; 12306 already masks
    ID number and mobile server-side and this adapter never decodes
    those.
  - Both expose `--include-sensitive` to opt back into the unmasked
    fields the user is entitled to see on their own account.

- `orders` returns the `queryMyOrderNoComplete` slice (orders that
  have not yet been ridden / refunded / completed). The historical
  `queryMyOrderApi` endpoint requires extra page-state handshakes
  that proved fragile when probed; left as a follow-up so this
  command can ship reliably for the immediate "what's still on my
  account" use case.

- `price` cascades three anonymous API calls per invocation:
  init -> queryByTrainNo (to resolve segment station_no within the
  train route) -> queryTicketPrice. 12306 returns prices keyed by
  one-or-two-letter seat codes (`A9` 商务座 / `M` 一等座 /
  `O` 二等座 / `WZ` 无座 / etc.) and additionally doubles some up
  as bare numeric codes (e.g. `"9": "21580"` mirrors
  `"A9": "¥2158.0"`); the bare-numeric duplicates are filtered out
  so the row set is one-per-seat-class.

- Strictly anonymous queries; no CAPTCHA / slider / SMS bypass, no
  credential storage, no ticket sniping, no order submission, no
  payment - per the issue's Non-goals list.

Live verified anonymously and authenticated against kyfw.12306.cn,
sleeping 15-25 seconds between hits to keep 12306's anti-abuse
throttle gentle:

  - 12306 me: account summary returned with real_name / email /
    mobile / birth date all masked at the adapter level, on top of
    12306's own server-side mobile mask.
  - 12306 passengers: every saved passenger returned with name
    masked to `<surname>*<...>` and 12306-side ID/mobile masks
    preserved verbatim.
  - 12306 orders: empty for this test account (no in-progress
    orders), correct EmptyResultError surface.
  - 12306 price G1 北京南 -> 上海虹桥 2026-05-22: returns
    商务座 ¥2158 / 特等座 ¥1163 / 一等座 ¥1035 / 二等座 ¥626 /
    无座 ¥626, sorted desc.

Tests: 23 unit tests (5 new beyond the prior commit's 18) cover
the mask helpers (email / mobile / Chinese name) plus the
parsePriceData filter that drops the bare-numeric duplicates and
sorts by descending price.

* fix(12306): harden browser auth boundaries

* fix(12306): tighten API drift boundaries

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 22:58:48 +08:00
陈家名 254d51835f fix: keep media filenames in output directory (#1642)
* fix: keep media filenames in output directory

* fix(download): sanitize media filename segments

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 22:55:31 +08:00
Ocean 87dfb68e74 fix(browser): goto 重试时回收陈旧 page identity + 把 -32000 "Cannot find default execution context" 归类为可重试 (#1645)
* fix(browser): recover from stale page identity on goto retry (#5)

When a chrome-backed adapter pre-navigates after its cached `_page`
targetId has been invalidated (tab closed externally, identity evicted),
the extension throws `Page not found: <id> — stale page identity` and
the failure cascades — every subsequent persistent-site session call in
the same process keeps re-sending the same dead targetId.

Observed in a downstream parallel multi-platform recall: a single dead page handle
got reused across 4+ calls (twitter thread / twitter search / reddit search)
because there was no detection or recovery. The same hash appeared in
adapter pre-navigations to youtube, twitter, reddit, xhs back-to-back in
seconds, suggesting the cached `_page` was shared via persistent site
session leases (`site:youtube` etc) and never cleared after the first
"stale page identity" response.

Page.goto() now catches that specific error, drops `_page`, and retries
once without the stale id. The retry navigates via session-lease
resolution in the extension (resolveTab → preferredTabId / new owned tab),
which already handles tab eviction correctly. No effect on the happy path.

Three regression tests in src/browser/page.test.ts cover:
- recovery: stale id dropped, retry succeeds with new identity
- no-cache safety: fresh page with no _page → error propagates unchanged
  (nothing to drop, retrying would loop)
- error scoping: unrelated extension errors (e.g. disconnected) still
  surface immediately — no implicit retry

* fix(errors): classify -32000 "Cannot find default execution context" as retryable (#6)

classifyBrowserError previously only matched CDP -32000 errors when the
message contained "target" (e.g., "target closed"). It missed
"Cannot find default execution context", a CDP protocol error that also
indicates the inspected target went away — observed in a downstream parallel
adapter recall against youtube channels.

Widening the secondary check to `/target|context/i` lets the existing
target-navigation retry path (200ms delay + re-attach) recover instead of
surfacing the error as non-retryable.

* fix(browser): tighten stale page recovery notes

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 22:30:28 +08:00
lenovobenben 000c867f3a fix(zhihu): harden search pagination (#1615)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 20:44:08 +08:00
Jun 24f643af16 feat(xianyu): add inbox, messages, and reply commands (#1639)
* fix: tighten internal callback types

* feat(xianyu): add private message commands

* fix(xianyu): harden IM command contracts

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 20:41:51 +08:00
hanzi 1f30a9027b feat(linkedin): consolidate messaging and Sales Navigator commands (#1647)
* fix(linkedin): harden sales navigator commands

* fix(linkedin): harden salesnav message boundaries

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 19:58:34 +08:00
jakevin 72f2b020de feat(weread-official): add official gateway CLI
Add the WeRead official Agent Gateway as an in-tree pure HTTP adapter with 8 commands, typed errors, tests, and docs.
2026-05-18 19:46:00 +08:00
Ocean 261b8bfbb5 build: restore +x on dist/src/main.js after tsc rebuild (#1644)
clean-dist deletes dist/ and tsc --build re-emits files without preserving
the executable bit on the bin entry. Symlinked global install then hits
EACCES on spawn until manually chmod'd. Chain a chmodSync into the existing
prebuild-manifest hook so any future rebuild self-heals.

node -e instead of bare `chmod +x` to keep the script portable (npm runs
on Windows via Git Bash where chmod is a no-op, but fs.chmodSync still
silently no-ops there too — no extra branching needed).

Co-authored-by: Kary <karyhe1019@gmail.com>
2026-05-18 19:43:28 +08:00
Benjamin Liu 1e7ebe7f27 feat(twitter): rewrite download profile path on GraphQL UserMedia with cursor pagination (#1636)
* fix(twitter): harden profile media download

* fix(twitter): fail closed on repeated media cursor

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 19:33:29 +08:00
Benjamin Liu 7e44e71150 fix(lesswrong): drop "Unknown" silent sentinel in author column (#1611)
* fix(lesswrong): drop "Unknown" silent sentinel in author column

Twelve lesswrong commands had `author: item.user?.displayName ?? 'Unknown'`
which masks the missing-author signal: an agent reading the result row
cannot distinguish "post has no associated user" from "author is literally
named Unknown". The repo's typed-error lint flags this pattern
(silent-sentinel rule, see scripts/check-typed-error-lint.mjs:323).

Replace `?? 'Unknown'` with `?? ''` so the missing-author case stays
visible as an empty string. Consistent with `clis/lesswrong/_helpers.js:68`
which was already using the empty-signal form.

Shrinks scripts/typed-error-lint-baseline.json from 173 to 161 entries.

Follows the same direction as #1603 (fix(adapters): surface silent empty
fallbacks).

Verified live: `opencli lesswrong frontpage --limit 2 -f json` returns
real posts with non-empty author values; empty-author rows would now
show `"author": ""` instead of fabricating `"Unknown"`.

* test(lesswrong): add empty-signal coverage for the author sentinel swap

Per owner's pattern in 71646158 (douyin/user-videos.test.js +
jike/read.test.js + weread/search-regression.test.js), pairs the
silent-sentinel value swap in this PR with a focused unit test that
mocks the upstream LessWrong GraphQL response to return posts where
`user` is null or `user.displayName` is missing, and asserts the row
surfaces `author: ''` instead of the old fabricated `'Unknown'`.

`clis/lesswrong/frontpage.test.js` is representative for the twelve
identical `author: item.user?.displayName ?? ''` swaps across
comments / curated / frontpage / new / read / sequences / shortform /
tag / top / top-month / top-week / top-year, all of which share the
exact same expression with no downstream sentinel consumer.

The empty-signal path is exercised live too: a deleted-account or
permission-restricted user shows up in the GraphQL response with
`user: null`, surfaces as `author: ''` post this PR (was 'Unknown'
before).
2026-05-18 19:18:51 +08:00
Benjamin Liu 76a9c78261 feat(weibo): add delete command to remove user's own posts (#1620)
* feat(weibo): add delete command to remove user's own posts

Adds `opencli weibo delete <id>` so the same workflow that creates a
post can also remove one without leaving the CLI. The id positional
accepts either the numeric `idstr` (e.g. `5299336218674412`) or the
base62 `mblogid` (e.g. `QFGbHAoBS`) found in any weibo URL or in the
output of `weibo me` / `weibo feed` / `weibo post`.

Implementation lives in a single `page.evaluate` IIFE so cookies +
the XSRF-TOKEN double-submit token stay first-party:

  1. Resolve mblogid / idstr via `GET /ajax/statuses/show?id=<input>`,
     which returns the canonical `idstr`. Empty result -> 404 path.
  2. Read the `XSRF-TOKEN` cookie via `document.cookie`.
  3. `POST /ajax/statuses/destroy` with `id=<idstr>` body and the
     `X-Xsrf-Token` header.
  4. Return `[{ status: 'deleted', id, mblogid }]`.

Typed errors:
- 401 / 403 from either show or destroy -> `AuthRequiredError`
- `show` returning no `idstr` -> `EmptyResultError`
- Non-2xx HTTP on either call -> `CommandExecutionError` with status
- API response `ok !== 1` -> `CommandExecutionError` with the API msg

Closes #1619.

Verified live on macOS / opencli v1.7.22, weibo cookie session:
- Deleted the lingering test post from #1602 verification
  (idstr=5299336218674412, mblogid=QFGbHAoBS):
  `weibo delete QFGbHAoBS` returned
  `[{ status: 'deleted', id: '5299336218674412', mblogid: 'QFGbHAoBS' }]`
- `weibo me` shows `statuses: 3` (was 4 before the delete)
- `weibo post QFGbHAoBS` now throws "Post not found"

Unit tests: 8 / 8 in `clis/weibo/delete.test.js` (happy path,
empty-id, auth, not-found, show-http, destroy-http, api-msg, envelope
unwrap). Full weibo suite: 38 / 38 pass.

* fix(weibo): require delete postcondition evidence

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 19:09:47 +08:00
Benjamin Liu 030a0ad885 feat(xiaohongshu): add delete-note command to remove published notes (#1624)
* fix(xiaohongshu/publish): invoke shadow-DOM publish handler directly

XHS creator center now wraps the publish/save-draft button in an
`<xhs-publish-btn>` web component backed by a CLOSED shadow root.
Calling `.click()` on the host element does not dispatch into the
internal handler, and CDP coordinate clicks cannot penetrate the
shadow boundary. The previous text-match `button.click()` loop hit
the host element, returned `ok`, and yet the note silently stayed
on the publish page as a draft, so the adapter reported the soft
`⚠️ 操作完成,请在浏览器中确认` status while nothing was actually
posted.

Invoke the publish/save method directly on the `<xhs-publish-btn>`
host (`_onPublish` / `_onSave` and a few candidate names XHS has
shipped historically). Fall back to the legacy
`<button>`/`[role="button"]` text-match click for older
creator-center variants that still expose plain buttons.

Patch shape suggested by the OpenCLI autofix report in #1606 from
@chcc-funny (who verified an end-to-end real publish locally).

Closes #1606.

Verified live on macOS / opencli v1.7.22 / extension v1.0.15,
with creator center logged in:
- `opencli xiaohongshu publish ... --draft` -> ` 暂存成功`,
  creator home shows "草稿箱中有未发布的作品"
- `opencli xiaohongshu publish ...` (real publish) -> ` 发布成功`,
  note appeared on the account feed (visible from mobile app);
  test note deleted after verification

Unit tests: 12 / 12 in `clis/xiaohongshu/publish.test.js` pass
(mocks updated to reflect the new `{ ok, via, name|text }` invoke
result shape).

* feat(xiaohongshu): add delete-note command to remove published notes

Adds `opencli xiaohongshu delete-note <note-id>` so the workflow that
creates a note can also remove one without leaving the CLI, mirroring
`weibo delete` (#1619 / #1620).

The creator-center HTTP delete API requires the `X-S-Common` signature
header that `publish.js` deliberately avoids, so this follows the same
UI automation route. Flow:

  1. Navigate to creator note-manager
  2. Switch to "已发布" tab (delete entry only appears there; "审核中"
     and "未通过" rows have no web delete action, mobile app only)
  3. Locate the `.note` row whose `data-impression` JSON contains the
     target noteId (exact JSON-parsed match, not substring, so values
     that happen to share the noteId prefix in other fields cannot
     match the wrong row)
  4. Click the inline `<span class="control data-del">` action
  5. Click "确定" in the `.d-modal-footer` confirmation modal
  6. Poll for the row disappearing (iteration-bounded so tests with
     mocked `page.wait` exhaust the loop quickly)

Typed errors:
- /login redirect after navigation: AuthRequiredError
- 已发布 tab not found / not clickable: CommandExecutionError (UI drift)
- target noteId not present in the rendered list: EmptyResultError with
  a hint about review-state limitation
- row found but no delete action visible: CommandExecutionError
- confirmation modal missing / no 确定 button: CommandExecutionError
- row still visible after the configured poll window: CommandExecutionError

Closes #1623.

Verified live: published a test note, deleted via this adapter, follow-up
`xiaohongshu creator-notes` confirms it is gone. Unit tests: 8 / 8 cover
happy path, empty-id ArgumentError, login redirect AuthRequiredError,
tab-not-found CommandExecutionError, row-not-found EmptyResultError,
no-delete-action / no-modal / unverified-delete CommandExecutionError
paths.

Built on top of #1613 (xiaohongshu publish shadow-DOM fix) so the live
verify could exercise publish-then-delete end to end. Will rebase onto
main once #1613 lands.

* fix(xhs): make delete-note fail closed

* fix(xiaohongshu): harden delete-note boundary

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 18:55:57 +08:00
Benjamin Liu e29150bab5 fix(weibo/publish): replace brittle CSS-module hash with placeholder selector (#1625)
* fix(weibo/publish): replace brittle CSS-module hash with placeholder selector

`clis/weibo/publish.js` matched the compose textarea via
`textarea._input_13iqr_8`, where `_input_13iqr_8` is the Vite CSS-module
hash Weibo rebuilds on every frontend deploy. The hash drifted (current
build emits `_input_1f5hn_8`), so step 4 of the publish flow throws
"Weibo compose editor did not appear" before anything else can run.
Reported in #1602.

Replace the single hashed selector with a placeholder-text-based chain
that survives Weibo's CSS-module rebuilds:

  textarea[placeholder*="有什么新鲜事"]
  textarea[placeholder*="新鲜事"]
  textarea._input_13iqr_8     // legacy hash kept last for older variants

Two visible textareas can match on the home feed (the always-rendered
"home-strip" prompt + the post-click modal compose). Pick the LAST
visible candidate: the modal opens on top and is appended to DOM later,
so the last-visible textarea is the modal. Both the editor-visibility
poll (Step 4) and the text-insertion step (Step 6) use the same chain.

Also drops `evaluateWithArgs` from Step 8 success polling. The IIFE
there does not reference any outer args, but `evaluateWithArgs` injects
its `const`-bound parameter names into the page context, and re-running
on each iteration of the success-poll loop threw `Identifier
'maxIterations' has already been declared` after the first iteration.
This was masked previously because Step 4 always failed first; with the
selector fixed, the latent Step 8 bug surfaces. Switched to plain
`page.evaluate` to avoid re-declaring per loop.

Closes #1602.

Verified live on macOS / opencli built locally / extension v1.0.15,
weibo cookie session:
- `opencli weibo publish "明洞那家店真不错"` returned
  `status: success, message: 发布成功, text: 明洞那家店真不错`
- Confirmed via `/ajax/statuses/mymblog`: the post landed at
  `idstr=5299403716821218`, `mblogid=QFHWzsCvE`, text matches what
  was typed (proves selector chain picks the right textarea and the
  text insertion path works end-to-end)
- Cleaned up: deleted via the same `/ajax/statuses/destroy` path that
  PR #1620 exposes as `weibo delete`

Unit tests: 8 / 8 in `clis/weibo/publish.test.js` pass (mocks updated
to reflect the new `evaluate`-vs-`evaluateWithArgs` split for Step 8
and the longer poll window).

* test(weibo): lock publish placeholder selector path

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 18:44:46 +08:00
Benjamin Liu a50074d684 fix(adapters): drop silent-sentinel row fallbacks across 6 read commands (#1631)
* fix(adapters): drop silent-sentinel row fallbacks across 6 read commands

Continues the audit-baseline cleanup started in #1611 (lesswrong) and
the direction set by #1599 / #1603 / #1604. Replaces the
`silent-sentinel` row-data fallbacks (`'Unknown'` / `'-'` / `'unknown'`
that mask missing fields) with the empty-string signal so agents can
tell apart "field really has the value Unknown" from "upstream returned
no value".

Touched 6 read adapters, 10 baseline entries:
- wikipedia/trending: title, description
- 36kr/article: author, date, body
- xiaoyuzhou/download: podcast
- xiaoyuzhou/transcript: podcast
- zhihu/collection: dedup key + type field (the empty prefix still
  produces a unique-per-content dedup key, just without the `unknown:`
  noise)
- zhihu/download: author

Intentionally skipped (line-by-line audited):
- v2ex/me.js: `'Unknown'` is an in-band control-flow sentinel. Line 35
  initialises `let username = 'Unknown';`, line 41 uses
  `if (username === 'Unknown')` to trigger the profileEl fallback
  selector, line 75 uses the same check to raise the auth error.
  Empty would silently bypass both checks and return a row with an
  empty username as if auth succeeded.
- v2ex/daily.js: `'未知'` is user-facing 签到 success text in the
  rendered status message, not a row field. Empty would render a
  broken sentence.
- weibo/comments.js, weibo/feed.js: the sentinel sits inside an in-IIFE
  error-message string composition (`'API error: ' + (data.msg || 'unknown')`),
  not in a returned row. Empty would silently truncate diagnostic
  output. Both stay on baseline.

Verified live: `opencli wikipedia trending --limit 3` and `opencli 36kr
hot --limit 2` both return populated rows; the empty-string signal only
kicks in when the upstream value is actually missing.

* test(adapters): add empty-signal coverage for the cluster-2 sentinel swap

Per owner's pattern in 71646158 (douyin/user-videos.test.js +
jike/read.test.js + weread/search-regression.test.js), pairs the
silent-sentinel value swap in this PR with focused unit tests that
mock the upstream to return null / missing fields and assert the row
surfaces an empty-string signal instead of the old fabricated
'Unknown' / '-' / 'unknown' sentinel.

Coverage:

- clis/wikipedia/trending.test.js (new): mocks wikiFetch to return
  three articles - one with both title + description populated, one
  with no title and no description, one with title only. Asserts the
  missing fields render as '' (was '-' before this PR).

- clis/36kr/article.test.js (new): mocks page.evaluate to return a
  scrape where title is present but author / date / body are empty.
  Asserts those three fields render as '' in the row pair output
  (was '-' before this PR). Also covers the NOT_FOUND and
  INVALID_ARGUMENT error paths that already existed.

- clis/zhihu/collection.test.js (+1 case): mocks the zhihu collection
  API to return an item with content.id but no content.type. Asserts
  type renders as '' (was 'unknown' before this PR); the new dedup
  key prefix is :id rather than unknown:id, semantically identical
  for dedup purposes.

The other three files in this PR (xiaoyuzhou/download,
xiaoyuzhou/transcript, zhihu/download) use the same `|| 'unknown'` ->
`|| ''` value swap with no downstream sentinel consumer. They are
covered by the same JS language semantics the three tests above
demonstrate.

* fix(adapters): fail typed on missing row identity

* fix(adapters): tighten sentinel row identity guards

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 18:35:14 +08:00
Benjamin Liu 368581ea4d fix(electron-apps): move codex CDP port off 9222 to avoid browser-bridge collision (#1630)
* fix(electron-apps): move codex CDP port off 9222 to avoid browser-bridge collision

`src/electron-apps.ts` had `codex: { port: 9222 }`, but `9222` is the
default Chrome DevTools port that opencli's own browser-bridge Chrome
binds whenever `opencli doctor` is OK. On every normal opencli install
the bridge owns 9222 first, so Codex Desktop can never bind it, and
`opencli codex status` (plus every other codex command) fails with:

  App launched but CDP not available on port 9222 after 15s

`~/.opencli/apps.yaml` is documented as "additive only, does not
override builtins", so users have no supported way to relocate the
port from the user side.

Reported in #1626 with full repro (Codex Desktop + active opencli
browser-bridge Chrome) and root-cause pointer at
`dist/src/electron-apps.js:13`. Every other electron app in the
builtin registry already uses a distinct port in the 9224-9236
band (cursor 9226, doubao-app 9225, chatwise 9228, discord-app 9232,
antigravity 9234, chatgpt-app 9236); codex was the only one that
collided with the browser bridge.

Move codex to 9238 (the next free slot in that band, also the value
the reporter recommended). Update the test that asserts the port and
the two docs references that mention codex=9222. The pitfall entry
in `docs/advanced/electron.md` is also annotated to explicitly call
out 9222 as the bridge's port to avoid future collisions.

Closes #1626.

Verified live: `opencli codex status -v` now emits
`[verbose] [launcher] Probing CDP on port 9238...` (was 9222 before
the fix), confirming the code path picks up the new port. Full
end-to-end with a real Codex Desktop install is left to the reporter
and reviewer; the change here is a single-value config update plus
docs/tests sync.

Unit tests: 7 / 7 in `src/electron-apps.test.ts` pass (the codex-port
assertion updated to 9238). Both audit gates pass.

* docs(electron): sync codex CDP port guidance

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 18:29:14 +08:00
jakevin 0c488bbf51 docs(readme): simplify Highlights from 9 to 5 bullets (#1605)
Per WAWQAQ feedback: the previous Highlights list was bloated with hollow
marketing phrases and overlapping bullets (e.g. "Browser Automation for AI
Agents" + "AI Agent ready" said the same thing twice, "Pipeable, scriptable,
CI-friendly" is generic CLI filler).

Cut "AI Agent ready", "Account-safe" (folded into Live Browser Automation),
"Deterministic"'s second sentence (folded into Zero LLM cost), and merged
"Website → CLI" with "CLI Hub" into "100+ adapters + CLI Hub". Result is 5
concrete capability bullets instead of 9, each tied to a real feature.

EN and ZH READMEs kept in sync.
2026-05-16 20:57:33 +08:00
Jun 86792d2954 fix(barchart): surface greeks fetch failures (#1599)
* fix(barchart): surface greeks fetch failures

* fix(barchart): harden greeks failure contract

* fix(barchart): reject malformed greeks row identity

---------

Co-authored-by: 你的用户名 <你的邮箱>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-16 17:10:09 +08:00
jakevin ee54eb8e62 fix(audit): ignore sentinels in thrown errors
Avoid classifying fallback text inside thrown error messages as silent row data.
2026-05-16 16:51:06 +08:00
asimov 663b3387ee feat(bilibili): add summary command for the official AI video summary (#1590)
* feat(bilibili): add summary command for the official AI video summary

Adds `opencli bilibili summary <bvid>` — fetches Bilibili's official
AI-generated video summary (the "AI总结" shown on the video page) via
/x/web-interface/view/conclusion/get.

Returns the overall summary followed by the timestamped section outline,
so you get a structured digest of a video without watching it.

- Resolves cid + up_mid from the view endpoint (both required by the
  conclusion API), then calls the WBI-signed conclusion endpoint.
- Throws a clear EmptyResultError when a video has no AI summary —
  Bilibili only generates them for some videos.

Covered by clis/bilibili/summary.test.js (5 cases): summary + outline,
summary without outline, no-summary, view-resolution failure, API error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bilibili): harden summary command contract

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-16 16:50:48 +08:00
jakevin 716461581a fix(adapters): surface silent empty fallbacks
Resolve the remaining silent-empty-fallback typed-error baseline entries across Douyin, Jike, and WeRead adapters.
2026-05-16 16:43:13 +08:00
hanzi 854cf01aad feat(linkedin): add messaging commands (#1597)
* feat(linkedin): add messaging commands

Add fail-closed LinkedIn inbox, connect, safe-send, and thread-snapshot commands with adapter tests and docs.

* fix(linkedin): align commands with current UI

Update inbox to read LinkedIn's normalized messaging API response and connect to use the current custom-invite route.

* chore(linkedin): sync cli-manifest.json with rebuilt inbox command

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(linkedin): pass silent-column-drop gate

Drop the intermediate timestamp_ms field from inbox rows (it is converted to the timestamp column) and baseline the connect command internal profile-probe object.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(linkedin): validate inbox --limit with a typed error

Reject an out-of-range --limit with ArgumentError instead of silently clamping it, satisfying the typed-error lint gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(linkedin): harden messaging command contracts

* fix(linkedin): reject inbox conversations without thread id

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-16 14:21:45 +08:00
胡大头 baf1522420 feat: add Youdao Notes shared note reader adapter (#1547)
* feat: add Youdao Notes shared note reader adapter

Add a new adapter for reading publicly shared Youdao Notes (有道云笔记).

- youdao note <url>: Fetches a public shared note by its share URL
  using browser-based DOM extraction. Extracts title, content, and
  keyword tags from the React-rendered page.
- Supports note.youdao.com and note.youdao.cn share URLs.
- Includes test coverage (3 tests) and documentation.

Closes #1418

* fix: extract full note content from React Redux store

Previously the adapter only extracted the AI summary section from the
DOM. Now it accesses the React fiber tree to read the full note content
from the Redux store (store.content.data.content), which contains the
complete note body in Youdao's structured format.

The extractor recursively walks Youdao's proprietary node format (key '8'
for text content) to reconstruct the full note as plain text.

* fix(youdao): harden shared note reader contract

---------

Co-authored-by: huzekang <huzekang@opencode.ai>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-16 14:16:25 +08:00
jakevin e3995df25c docs(readme): tighten tagline + add form-filling example (#1596)
- Replace 2-line tagline (websites/browser/electron/local + reuse logged-in browser) with a single line emphasizing the two core capabilities side by side: 把任意网站变成 CLI & 让 AI Agent 操控登录态浏览器
- Add "Help me fill out this form" as the leading opencli-browser skill example so the table surfaces browser-side capabilities, not just scraping
2026-05-16 13:15:37 +08:00
jakevin 4682ffc3de feat(douyin): restore publish and delete flow (#1587)
* feat(douyin): restore publish and delete flow

- Use upload auth v5 API instead of legacy STS2 for VOD credentials
- Switch TOS upload from AWS4-signature to gateway multipart protocol (init/transfer/finish)
- Add ApplyUploadInner → CommitUploadInner pipeline for VOD upload
- Bypass enable/transend endpoints that hang for gateway-uploaded videos
- Handle fast_detect/pre_check empty responses gracefully with retry+backoff
- Add creator backend delete fallback (via work_list id matching) when legacy delete returns permission error
- Use CommitUploadInner Vid for create_v2, not completed TOS object key
- Accept item_id as fallback when create_v2 returns no aweme_id

* fix(douyin): harden publish delete write contracts

---------

Co-authored-by: Lukin <mylukin@gmail.com>
2026-05-15 18:21:14 +08:00
胡大头 e3140af5ee feat: add Flomo memos reader adapter (#1549)
* feat: add Flomo memos reader adapter

Read your Flomo memos via the signed API.

- flomo memos: Lists recent memos with content, tags, timestamps
  Uses the Flomo v1 API with MD5 signing (secret embedded).
  Requires FLOMO_ACCESS_TOKEN env variable.
  Supports pagination via --slug cursor and --limit.

* fix: add --token arg for Flomo auth

* fix: use COOKIE strategy with browser-based API call

Use Strategy.COOKIE + browser:true instead of PUBLIC + manual token.
The adapter now reads flomo_token from localStorage in the browser,
and makes the signed API call from within the page context via fetch().
Signature is computed in Node.js and injected into the browser eval.
No env var or --token flag needed.

* fix: use access_token from localStorage.me for API auth

Flomo API requires Bearer token from access_token field in
localStorage.me (not api_token). Adapter now reads access_token
from the browser's localStorage and calls the signed API from
Node.js with the Bearer header.

* feat: add --since filter, refine flomo adapter API

- Add --since <unix_ts> to filter memos by updated_at
- Add --limit 200 to fetch all memos in one call
- Mark --slug as experimental (cursor pagination unreliable)
- 5 tests passing

* feat: add images column to flomo memos output

* docs: add flomo adapter documentation

* fix: use clampInt and rebuild manifest

* fix(flomo): harden memos reader contract

---------

Co-authored-by: huzekang <huzekang@opencode.ai>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-15 18:08:56 +08:00
ele-yufo 43f5c6e1cf fix(chatgpt): unwrap page.evaluate envelope across browser commands (#1580)
* fix(chatgpt): unwrap page.evaluate envelope across browser commands

The browser bridge wraps every `page.evaluate(...)` return value in a
`{ session, data }` envelope. Adapters that read `.length` or
`Array.isArray(payload)` directly on the envelope silently see "no
data" — same failure mode addressed for `xiaohongshu`/`rednote` in
#1561 and `weibo` in #1568.

This sweep applies the same `unwrapEvaluateResult` helper across every
chatgpt `page.evaluate` consumer site, plus typed shape guards
(`requireArrayEvaluateResult`, `requireObjectEvaluateResult`) on the
critical extraction paths so envelope misses fail loud instead of
silently returning empty.

## Sites wrapped

`clis/chatgpt/utils.js`:

- `currentChatGPTUrl` — string URL
- `getPageState` — login/composer probe object
- `sendChatGPTMessage` — composer write + send-button readiness
- `getVisibleMessages` — conversation transcript array
- `getConversationList` / `extractConversationLinks` — sidebar items
- `waitForChatGPTUploadPreview` — image upload readiness probe
- `uploadChatGPTImages` fallback — DataTransfer upload result
- `isGenerating` — boolean "still generating?" probe
- `getChatGPTVisibleImageUrls` — visible image URL array
- `waitForChatGPTImages` — inline `window.location.href` poll
- `getChatGPTImageAssets` — exported asset array

`clis/chatgpt/image.js`:

- `currentChatGPTLink` — used for error hints + conv link reporting

## Drive-by

`getChatGPTImageAssets` was also passing a redundant `urls` second arg
to `page.evaluate(string, urls)`. The IIFE inside the string already
receives the URL list via the `${urlsJson}` template substitution, and
the browser bridge guard in `browser/utils.ts` rejects the second form
for string scripts with:

    page.evaluate string input does not accept args;
    use page.evaluate(fn, ...args) instead

So `opencli chatgpt image <prompt>` blows up at the download step
without `--sd true`. Drop the trailing arg as part of the asset-export
cleanup. (This supersedes #1556 — same one-line fix is included here.)

## Validation

- `npx tsc --noEmit` — clean
- `npx vitest run --project adapter clis/chatgpt/` — 38/38 pass
  (25 existing + 13 new in `envelope.test.js`)
- `npm test` — 3644 passing across 364 files
- Live (browser bridge, daemon v1.7.19):
  `opencli chatgpt image "<prompt>"` → end-to-end generate + download
  succeeds; the envelope wrap is defensive in 1.7.19 (no envelope
  observed yet), but pre-empts the same silent-failure mode that hit
  the merged xiaohongshu/weibo PRs.

* fix(chatgpt): fail fast on malformed evaluate payloads

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-15 17:54:44 +08:00
Yabin Zheng 68ef95659f Fix YouTube transcript caption fetching (#1499)
* fix(youtube): unwrap transcript caption results

* fix(youtube): validate transcript caption info shape

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-15 17:49:02 +08:00
chonglinghuc c922a39a7d 微博新增用户搜索导出博文命令opencli weibo search_by_user 1670458304 --start 2025-06-01 --end 2025-06-02 (#1379)
* docs: add weibo search_by_user command design spec

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(weibo): add search_by_user helper function tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(weibo): add search_by_user command for timed post download to Markdown

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(weibo): remove dead hasori ternary and hardcoded hastext/haspic filters

The hasori ternary always evaluated to 1 (bug), and hastext=1 + haspic=1
silently excluded text-only and link-only posts from results.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(weibo): add integration tests for search_by_user helpers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* bak

* fix(weibo): reshape user posts into read adapter

---------

Co-authored-by: andrew.asa <asa.andrew@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-15 17:44:47 +08:00
jakevin aae6e823b4 chore(release): 1.7.22 (#1586)
Release / release (push) Has been cancelled
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
External CLI ergonomics + two adapter envelope/auth fixes.

- feat(external): longbridge CLI passthrough (#1584)
- feat(external-cli): brand alias rendering for ntn/dws/wecom-cli (#1585)
- fix(boss): map code=24 → AuthRequiredError (#1573)
- fix(weibo): unwrap page.evaluate envelope in read adapters (#1568)
2026-05-15 17:30:34 +08:00
658 changed files with 78919 additions and 3627 deletions
-27
View File
@@ -1,27 +0,0 @@
version: 2
updates:
# npm dependencies
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
commit-message:
prefix: "chore(deps)"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "chore(ci)"
+182 -3
View File
@@ -1,11 +1,186 @@
# Changelog
## Unreleased
## [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
* **external** — add the Longbridge CLI as a built-in external CLI passthrough (`opencli longbridge ...`) for Longbridge OpenAPI market data, account, and trading commands.
* **external-cli** — render brand alias `name(package)` in `opencli list` and root help when the bare executable name is ambiguous. Built-in entries `ntn``ntn(notion)`, `dws``dws(DingTalk Workspace)`, `wecom-cli``wecom-cli(企业微信)` now self-explain in help output. `package` field is repurposed to cover both upstream distribution names (e.g. `tg-cli`) and human-readable brand labels (e.g. `notion`, `企业微信`).
* **site auth subsystem** — new `opencli <site> login` and `opencli <site> whoami` commands, registered through a shared `clis/_shared/site-auth.js` helper. `login` opens the site's auth page in a foreground persistent session and polls the configured `verify` probe (cookie, JSON API, DOM scrape) until the browser session reports logged-in; `whoami` runs the same probe without opening the page. First five sites: twitter, github, bilibili, douyin, xiaohongshu. `whoami` outputs are PII-scrubbed (no email / phone / token in row columns). ([#1852](https://github.com/jackwener/opencli/pull/1852))
* **gemini** — add read-only conversation commands (list / read / search).
* **manus** — add a read-only `manus.im` adapter.
### Docs / Sitemap
* **sitemaps/xiaohongshu** — Phase 2 sitemap content seeded with login schema dogfood, the first non-PoC consumer of the v1.1 sitemap schema. ([#1853](https://github.com/jackwener/opencli/pull/1853))
### Internal
* **test(e2e)** — raise `runCli` `maxBuffer` so manifest-output snapshots no longer truncate on macOS / Windows CI.
## [1.8.2](https://github.com/jackwener/opencli/compare/v1.8.1...v1.8.2) (2026-06-03)
Mid-cycle release: introduces the **Site Maps Hub** subsystem (agent-facing per-site navigation knowledge), restores the **smart-search** skill, and ships a wide batch of new adapters / commands plus a long tail of read-path fixes. Extension bumped to 1.0.18 for an owned-group reusable-tab scope fix.
### Site Maps Hub (new subsystem)
* **`sitemaps/<site>/` top-level seed directory** — sitemap content lives alongside `clis/` and `skills/`, parallel first-class repo citizens. Twitter and HackerNews seeded as v1 baselines.
* **`opencli browser open` / `analyze` surface sitemap availability** — when the requested site has a sitemap (global seed or local overlay `~/.opencli/sites/<site>/sitemap/`), the JSON envelope gains an optional `sitemap` field with `{ available, source, hint }`. `open` emits the hint once per session per site (deduped via `~/.opencli/cache/browser-sitemap-hints/`); `analyze` emits every call since it is a planning command. Adds no new browser-action behavior and no `~/.opencli/sites/` writes unless an agent explicitly invokes a sitemap skill.
* **Two new skills**:
* `opencli-sitemap-author` — create / maintain per-site sitemaps. Two-layer storage (global repo seed + local overlay), Form B compact YAML action schema with `pre / do / post / fail / recover / evidence`, `adapter_health_update` directives, `selector_pattern` as first-class anchor type, partial pages (`_<name>.md`) for cross-page UI, and a size-guidance table with hard 800-token / 1500-3000 cohesion / >3000 split tiers.
* `opencli-browser-sitemap` — consume site sitemaps while executing browser tasks. Lazy load, Trust-Reality rule (`browser state` is truth, sitemap is hint), stale-on-conflict writeback, `adapter_health` write-back closure so subsequent agents skip a known-suspect adapter.
* **`references/sitemap-schema.md`** — full field-level spec for `SITE.md / pages/<id>.md / workflows/<id>.md / apis.md / pitfalls.md`, action `state_signature` for re-entry, `adapter_health` enum, stable-id matching across overlay layers, draft placement rule, Phase 2 validation hooks.
* **Twitter + HackerNews v1.1 seeds** under `sitemaps/{twitter,hackernews}/` validating the schema on dense React UI and simple SSR HTML respectively.
### Features
* **smart-search** — restored as a skill (`skills/smart-search/`) with per-category source guides (AI / info / media / shopping / social / tech / travel / other).
* **twitter** — batch follow + list lifecycle (`list-create` / `list-delete` / `list-add` / `list-remove` batch forms).
* **xiaohongshu** — draft management commands (`drafts` / `draft-open` / `draft-delete` / `draft-clear`).
* **chatgpt-app** — temporary chat + multi-modal image attachment support.
* **antigravity** — history mgmt (`history` / `delete` / `mark-read`) and model read/switch commands.
* **codex** — conversation management (`pin` / `unpin` / `archive` / `rename`) plus model selector fix.
* **grok** — conversation management (`delete` / `pin` / `unpin`) with locale-independent selectors.
* **kimi** — new adapter for `kimi.com` (21 commands).
* **qoder** — new adapter for Qoder IDE (19 commands).
* **trae-cn** — new desktop adapter (Trae CN Electron app).
* **trae-solo** — new desktop adapter (Trae SOLO Electron app).
* **chatgpt** — add web model switch command.
* **douyin** — add `search` command for keyword video search.
* **wechat-channels** — add WeChat Video Channels (视频号) publish adapter.
* **pubmed** — add workflow presets and richer article metadata.
### Bug Fixes
* **extension 1.0.18** — scope reusable-tab selection to owned-group members (follow-up to the v1.0.17 owned-container convergence model; ensures `findReusableOwnedContainerTab` does not pick up user tabs that were dragged into the owned window).
* **chatgpt** — ignore image placeholders and upload previews when extracting the latest assistant message.
* **xiaohongshu** — attach real topics via inline dropdown; feed returns signed note URLs for drill-down; carousel order preserved on download.
* **twitter** — drop global tweetPhoto selector from the post-submit poll to avoid matching the wrong button.
* **grok** — fall back to `Enter` key dispatch when send button is hidden behind layout shifts.
* **daemon** — differentiate multi-profile status output so multiple Chrome profiles do not collapse into a single status row.
* **youtube** — Videos tab fallback now supports `lockupViewModel` format alongside the legacy `gridVideoRenderer`.
* **12306** — accept lowercase letters in `train_no` regex.
* **weixin** — strip typographic quotes from pasted URLs.
* **launcher** — Chromium 142+ CDP websocket origin check needs `--remote-allow-origins=*`.
* **douyin/publish** — handle illegal-title errors with a typed error rather than a silent retry.
### Docs
* **opencli-adapter-author** — add `references/strategy-selection.md` codifying the empirical contract ladder (PUBLIC_API / COOKIE_API / UI_SELECTOR / DOM_STATE as contracted vs PAGE_FETCH / INTERCEPT as internal-unstable, with fixes/adapter-year data from a 837-adapter / 30-day window) and update SKILL.md to require a `strategy` evidence block at the top of every new adapter.
* **opencli-adapter-author** — `browser analyze` upgrade: each candidate API gets `real_data_score` and a `likely_data` / `maybe_data` / `noise` verdict so Pattern A is no longer fired by analytics XHRs.
* **readme** — prefix "Let AI Agents operate any website" bullet with "Browser User &" in both EN and zh-CN.
## [1.8.1](https://github.com/jackwener/opencli/compare/v1.8.0...v1.8.1) (2026-05-31)
Patch release focused on the extension tab-group convergence fix, plus 10 new adapters/commands and a wave of read-path / security hardening across browser, download, and adapters.
### Features
* **chess** — add Chess.com browser adapter.
* **geogebra** — add GeoGebra browser adapter suite.
* **jira / confluence** — add Atlassian Jira and Confluence adapter support. ([#1690](https://github.com/jackwener/opencli/pull/1690))
* **upwork** — add `search`, `feed`, and `detail` commands.
* **notebooklm** — add guarded write commands.
* **bilibili** — add comment commands.
* **weread** — add book search inside an open WeRead book.
* **linkedin** — consolidate read commands and add `profile-experience`.
* **xiaohongshu** — paginate `creator-notes` past the analyze list cap.
### Bug Fixes
* **extension 1.0.16** — ship the `OpenCLI Browser` / `OpenCLI Adapter` tab-group race fix from [#1693](https://github.com/jackwener/opencli/pull/1693). The extension now serializes owned tab-group creation per role so concurrent adapter/browser leases reuse the same group instead of creating duplicate same-title groups.
* **extension 1.0.17** — replace owned tab-group management with a Chrome-state-as-truth convergence model. The extension now keeps one canonical `OpenCLI Browser` / `OpenCLI Adapter` group per profile role, recovers renamed groups from stored hints or owned lease tabs, merges same-window and cross-window duplicates into the canonical group, and normalizes legacy or user-renamed container titles back to the canonical owned-container title. ⚠️ User-renamed `OpenCLI Browser` / `OpenCLI Adapter` groups are now force-renamed back; treat these as extension-managed automation containers, not user free-form bins. ([#1794](https://github.com/jackwener/opencli/pull/1794))
* **browser** — write the network response cache file with `0o600` owner-only permissions to keep captured response bodies out of other local users' reach.
* **download** — write the yt-dlp cookie file with `0o600` owner-only permissions.
* **pixiv** — migrate `user/detail` to the shared `pixivFetch` helper.
* **twitter** — drop unknown silent sentinels; read profile `name` / `created_at` from `result.core`; handle `NotAllowed` image-upload fallback; detect private `likes` / `following` empty-timeline shape. ([#1702](https://github.com/jackwener/opencli/pull/1702))
* **weread** — decode HTML entities in search results.
* **zhihu** — decode numeric HTML entities in text output. ([#1695](https://github.com/jackwener/opencli/pull/1695))
* **xiaohongshu** — hook dashboard fetch to capture signed `datacenter/note/*` responses ([#1732](https://github.com/jackwener/opencli/pull/1732)); preserve carousel order via `__INITIAL_STATE__.imageList` on download ([#1687](https://github.com/jackwener/opencli/pull/1687)).
* **bilibili** — subtitle support for bangumi / PGC bvid (番剧 / 纪录片 / 电影 / 综艺). ([#1669](https://github.com/jackwener/opencli/pull/1669))
* **suno** — derive current plan from subscription metadata.
* **douyin/hashtag** — validate action args before navigation.
* **byte-formatting** — stabilize byte formatting output.
### Docs
* **readme** — correct Node floor (>=20, not 21) and drop the Prerequisites section ([#1705](https://github.com/jackwener/opencli/pull/1705)); add CLI Hub brand aliases and split Exit Codes into the dedicated docs page ([#1685](https://github.com/jackwener/opencli/pull/1685)); drop the For Developers section ([#1684](https://github.com/jackwener/opencli/pull/1684)).
### Internal
* **ci** — disable Dependabot automated updates.
* **test(download)** — retry media-download Windows tests to absorb runner cold-start variance. ([#1708](https://github.com/jackwener/opencli/pull/1708))
## [1.8.0](https://github.com/jackwener/opencli/compare/v1.7.22...v1.8.0) (2026-05-20)
Substantial release: a new official-API adapter (`weread-official`), wider LinkedIn / Twitter / Reddit / Zhihu coverage, the 12306 / Suno / Xianyu inbox additions, security and reliability fixes for the Browser Bridge and media downloads, plus a 20% README shrink. Node 20 compatibility is restored after an automated `undici` bump regression.
### Features
* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter.
* **12306** — add full read adapter (`stations` / `trains` / `train` / `price` / `me` / `passengers` / `orders`). ([#1637](https://github.com/jackwener/opencli/issues/1637))
* **xianyu** — add `inbox`, `messages`, and `reply` commands. ([#1639](https://github.com/jackwener/opencli/issues/1639))
* **suno** — add Suno.com music-generation adapter. ([#1638](https://github.com/jackwener/opencli/issues/1638))
* **linkedin** — consolidate messaging and Sales Navigator commands (`connect`, `inbox`, `safe-send`, `salesnav-search`, `salesnav-inbox`, `salesnav-message`, `salesnav-thread`, `sent-invitations`, `thread-snapshot`, `timeline`). ([#1647](https://github.com/jackwener/opencli/issues/1647))
* **linkedin/people-search** — add a dedicated people-search command. ([#1649](https://github.com/jackwener/opencli/issues/1649))
* **linkedin-learning** — add `search` / `trending` / `course` read commands. ([#1657](https://github.com/jackwener/opencli/issues/1657))
* **twitter** — rewrite the download-profile path on GraphQL UserMedia with cursor pagination. ([#1636](https://github.com/jackwener/opencli/issues/1636))
* **twitter** — add `list-create` (GraphQL CreateList mutation). ([#1656](https://github.com/jackwener/opencli/issues/1656))
* **twitter** — add `device-follow` notification-stream command.
* **twitter** — expose `card.binding_values` on read commands for inline link-preview metadata. ([#1660](https://github.com/jackwener/opencli/issues/1660))
* **twitter** — expose `quoted_tweet` on read commands. ([#1667](https://github.com/jackwener/opencli/issues/1667))
* **twitter** — expose `bio` on read commands.
* **reddit/subscribed** — new `subscribed` command + listing-level `id` / `created_utc` / `selftext` exposure. ([#1651](https://github.com/jackwener/opencli/issues/1651))
* **reddit** — expose `post_hint` / `url` / `preview` / `gallery` media routes on listing commands. ([#1676](https://github.com/jackwener/opencli/issues/1676))
* **zhihu** — add answer-comments reader; include answer links in question results.
* **chatgpt** — detect generated image surfaces (CSS background and canvas, not just `<img>`) so image generation works after UI drift. ([#1677](https://github.com/jackwener/opencli/issues/1677))
* **external** — add Cloudflare Wrangler as a built-in external CLI passthrough. ([#1679](https://github.com/jackwener/opencli/pull/1679))
### Bug Fixes
* **deps** — restore Node 20 runtime compatibility by pinning runtime `undici` back to the 6.x line (an automated dependabot bump to 8.x had moved the engines floor to Node ≥22.19, silently breaking the published Node 20 promise), and clear the docs build audit chain by overriding VitePress' Vite/PostCSS transitive dependencies to patched versions. ([#1673](https://github.com/jackwener/opencli/issues/1673))
* **download** — keep custom media filenames inside the requested output directory by stripping POSIX/Windows path components and sanitizing the generated fallback prefix. Prevents remote-controlled fields (e.g. video titles used as filename) from escaping the output directory via `../`. ([#1642](https://github.com/jackwener/opencli/pull/1642))
* **browser** — recover `Page.goto()` from stale page identities by clearing the cached targetId and retrying navigation once through the session lease; classify CDP `-32000 Cannot find default execution context` as retryable target navigation. ([#1645](https://github.com/jackwener/opencli/issues/1645))
* **cli** — escape leading-dash positional values via the argv preprocessor so users can pass tokens starting with `-` without commander mis-classifying them as flags. ([#1658](https://github.com/jackwener/opencli/issues/1658))
* **chatgpt/image** — fix ChatGPT web image generation after UI drift by letting the composer locator continue into the caller's readiness check and detecting generated images rendered as CSS backgrounds or canvases, not just plain `<img>` elements.
* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors (Douyin user video comments, Jike SSR JSON parse, WeRead search-page fetch). True empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`.
* **adapters** — drop silent-sentinel row fallbacks across Apple Podcasts / Reddit / Gitee. ([#1634](https://github.com/jackwener/opencli/issues/1634))
* **adapters** — migrate legal empty-data branches to `EmptyResultError` for `xhs` / YouTube and 5 follow-up commands. ([#1674](https://github.com/jackwener/opencli/issues/1674), [#1678](https://github.com/jackwener/opencli/issues/1678))
* **lesswrong** — drop the `"Unknown"` silent sentinel in the author column; missing authors now propagate as `null`. ([#1611](https://github.com/jackwener/opencli/issues/1611))
* **youtube/transcript** — scope timedtext URL matching to the current `videoId` across the in-page resource-buffer scan, the in-page fetch/XHR hook, and the Node-side CDP capture. SPA-style watch→watch navigation no longer returns a predecessor video's captions. ([#1655](https://github.com/jackwener/opencli/issues/1655))
* **twitter/lists** — skip the "Discover new Lists" recommendation block so it is no longer treated as one of the user's lists. ([#1652](https://github.com/jackwener/opencli/issues/1652))
* **zhihu** — harden search pagination. ([#1615](https://github.com/jackwener/opencli/issues/1615))
* **zhihu** — decode numeric HTML entities in `answer-detail`. ([#1629](https://github.com/jackwener/opencli/issues/1629))
### Docs
* **readme** — major shrink and reframing: tagline rephrased around "Browser Use", Highlights and Update sections folded into adjacent content, Built-in Commands curated to 11 popular sites, CLI Hub table reduced to a name enumeration, Desktop App Adapters collapsed to a one-liner, skill-attribution references audited against `SKILL.md` frontmatter, "For AI Agents (Developer Guide)" merged into "Writing a new adapter". Net: EN 410 → 326 (-20%), ZH 455 → 371 (-18%). ([#1654](https://github.com/jackwener/opencli/pull/1654), [#1666](https://github.com/jackwener/opencli/pull/1666), [#1679](https://github.com/jackwener/opencli/pull/1679), [#1681](https://github.com/jackwener/opencli/pull/1681))
### Internal
* **audit** — stop flagging sentinel fallback strings inside thrown error messages as `silent-sentinel` violations. These are typed failure diagnostics rather than fake row data, reducing the typed-error baseline to actual adapter output fallbacks.
## [1.7.22](https://github.com/jackwener/opencli/compare/v1.7.21...v1.7.22) (2026-05-15)
External CLI ergonomics + two adapter envelope/auth fixes. New `longbridge` external CLI entry; `opencli list` / root help now render human-readable brand labels for executables whose bare name is ambiguous.
### Features
* **external** — add the Longbridge CLI as a built-in external CLI passthrough (`opencli longbridge ...`) for Longbridge OpenAPI market data, account, and trading commands. ([#1584](https://github.com/jackwener/opencli/issues/1584))
* **external-cli** — render brand alias `name(package)` in `opencli list` and root help when the bare executable name is ambiguous. Built-in entries `ntn``ntn(notion)`, `dws``dws(DingTalk Workspace)`, `wecom-cli``wecom-cli(企业微信)` now self-explain in help output. `package` field is repurposed to cover both upstream distribution names (e.g. `tg-cli`) and human-readable brand labels (e.g. `notion`, `企业微信`). ([#1585](https://github.com/jackwener/opencli/issues/1585))
### Bug Fixes
* **boss** — map `code=24` (identity mismatch) to `AuthRequiredError` so re-login is signaled instead of surfacing as a generic API error. ([#1573](https://github.com/jackwener/opencli/issues/1573))
* **weibo** — unwrap Browser Bridge `page.evaluate` envelopes in read adapters. ([#1568](https://github.com/jackwener/opencli/issues/1568))
## [1.7.21](https://github.com/jackwener/opencli/compare/v1.7.20...v1.7.21) (2026-05-14)
@@ -210,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.
+43 -196
View File
@@ -1,7 +1,8 @@
# OpenCLI
> **Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.**
> Reuse your logged-in browser, automate live workflows, and crystallize repeated actions into reusable CLI commands.
> **Convert any website into a CLI & run Browser Use on your logged-in Chrome.**
> Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.
> Or run Browser Use against any page — navigate, fill forms, click, extract, automate.
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
@@ -11,30 +12,16 @@
OpenCLI gives you one surface for three different kinds of automation:
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Let AI Agents operate any website** — install the `opencli-browser` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `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.
## Highlights
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, etc.) directly from the terminal via CDP.
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type/fill, extract, screenshot — all through your logged-in Chrome session.
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification.
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, tg, discord, wx, etc).
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
---
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Trae CN, Codex, Antigravity, ChatGPT, and Trae SOLO.
## Quick Start
### 1. Install OpenCLI
OpenCLI requires **Node.js >= 21**.
OpenCLI requires **Node.js >= 20**.
```bash
node --version
@@ -105,7 +92,7 @@ If you want to add your own commands, start with the [Extending OpenCLI guide](.
OpenCLI's browser commands are designed to be used by AI Agents — not run manually. Install skills into your AI agent (Claude Code, Cursor, etc.), and the agent operates websites on your behalf using your logged-in Chrome session.
### Install skills
### Install skills (also refreshes existing installs)
```bash
npx skills add jackwener/opencli
@@ -117,23 +104,25 @@ Or install only what you need:
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-browser-sitemap
npx skills add jackwener/opencli --skill opencli-sitemap-author
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
### Which skill to use
| Skill | When to use | Example prompt to your AI agent |
|-------|------------|-------------------------------|
| **opencli-adapter-author** | Operate a site in real time, or write a reusable adapter for a new site | "Help me check my Xiaohongshu notifications" / "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
| **opencli-adapter-author** | Write a reusable adapter for a new site or add a command to an existing site | "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
| **opencli-autofix** | Repair a broken adapter when a built-in command fails | "`opencli zhihu hot` is returning empty — fix it" |
| **opencli-browser** | Browser automation reference for AI agents | "Use browser commands to scrape this page" |
| **opencli-browser** | Drive a real Chrome page ad-hoc — navigate, fill forms, click, extract | "Help me check my Xiaohongshu notifications" / "Help me fill out this form" / "Use browser commands to scrape this page" |
| **opencli-browser-sitemap** | Consume site sitemap context while driving a browser task | "Use the sitemap to navigate this website without blind clicking" |
| **opencli-sitemap-author** | Create or update site sitemap knowledge for browser agents | "Record the stable workflow you just discovered for this site" |
| **opencli-usage** | Quick reference for all OpenCLI commands and sites | "What commands does OpenCLI have for Twitter?" |
| **smart-search** | Search across existing OpenCLI capabilities | "Find me a Bilibili trending adapter" |
### How it works
Once `opencli-adapter-author` is installed, your AI agent can:
Once `opencli-browser` is installed, your AI agent can:
1. **Navigate** to any URL using your logged-in browser
2. **Read** page content via structured DOM snapshots (not screenshots)
@@ -144,53 +133,27 @@ Once `opencli-adapter-author` is installed, your AI agent can:
The agent handles all the `opencli browser` commands internally — you just describe what you want done in natural language.
**Skill references:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — browser operation + adapter authoring, end-to-end
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — drive Chrome ad-hoc (navigate, fill forms, click, extract)
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — use sitemap context while driving a browser task
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — create or update site sitemap knowledge
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — write a new adapter end-to-end
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — repair broken adapters
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — browser automation reference
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — capability search
Available browser commands include `open`, `state`, `click`, `type`, `fill`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
`opencli browser` commands require a `<session>` positional immediately after `browser`. `opencli browser work open <url>` and `opencli browser work tab new [url]` both return a target ID. Use `opencli browser work tab list` to inspect target IDs, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted commands in the same session.
## Core Concepts
## Writing a new adapter
### `browser`: AI Agent browser control
When the site you need is not yet covered, use the `opencli-adapter-author` skill end-to-end:
`opencli browser` commands are the low-level primitives that AI Agents use to operate websites. You don't run these manually — instead, install the `opencli-adapter-author` skill into your AI agent, describe what you want in natural language, and the agent handles the browser operations.
For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser <session> open`, `state`, `click`, etc. under the hood.
### Built-in adapters: stable commands
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists. These are deterministic and work without browser — ideal for both humans and AI agents.
### Writing a new adapter
When the site you need is not yet covered, use the `opencli-adapter-author` skill. It takes the agent end-to-end:
1. Recon the site and classify its pattern (SPA / SSR / JSONP / Token / Streaming).
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`.
4. Decode response fields and design output columns.
5. `opencli browser recon analyze <url>` for one-shot recon, then `opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon verify <site>/<name>`.
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
### CLI Hub and desktop adapters
OpenCLI is not only for websites. It can also:
- expose local binaries like `gh`, `docker`, `obsidian`, `tg`, `discord`, `wx`, or custom tools through `opencli <tool> ...`
- control Electron desktop apps through dedicated adapters and CDP-backed integrations
## Prerequisites
- **Node.js**: >= 21.0.0 (required for the standard npm install path)
- **Bun**: >= 1.0 (optional alternative runtime)
- **Chrome or Chromium** running and logged into the target site for browser-backed commands
> **Important**: Browser-backed commands reuse your Chrome/Chromium login session. If you get empty data or permission-like failures, first confirm the site is already open and authenticated in Chrome/Chromium.
1. **Recon** the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
2. **Discover** the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. **Pick auth**`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`.
4. **Decode** response fields and design output columns.
5. `opencli browser recon analyze <url>``opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon verify <site>/<name>`.
6. Site knowledge persists to `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context.
## Configuration
@@ -208,121 +171,35 @@ OpenCLI is not only for websites. It can also:
`opencli browser *` requires an explicit `<session>` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser <session> close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab.
## Update
```bash
npm install -g @jackwener/opencli@latest
# If you use the packaged OpenCLI skills, refresh them too
npx skills add jackwener/opencli
```
Or refresh only the skills you actually use:
```bash
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
## For Developers
Install from source:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
To load the source Browser Bridge extension:
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select this repository's `extension/` directory.
## Built-in Commands
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
| **tieba** | `hot` `posts` `search` `read` |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `summary` `video` `user-videos` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **1688** | `search` `item` `assets` `download` `store` |
| **gitee** | `trending` `search` `user` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` `publish` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
| **uiverse** | `code` `preview` |
| **baidu-scholar** | `search` |
| **google-scholar** | `search` `cite` `profile` |
| **gov-law** | `search` `recent` |
| **gov-policy** | `search` `recent` |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` |
| **wanfang** | `search` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **upwork** | `search` `feed` `detail` |
100+ site surfaces in total**[→ see all supported sites & commands](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast`, `podcast-episodes`, `episode`, `download`, and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
Curated highlights**[→ see all 100+ supported sites & commands](./docs/adapters/index.md)** (douyin / weibo / spotify / 1688 / quark / nowcoder / google-scholar / hupu / xianyu / weread / weread-official / xiaoyuzhou / Chess.com / and more).
## CLI Hub
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install when a safe package-manager command is configured.
Unified passthrough for your existing command-line tools. Run `opencli <tool> ...` for any of:
| External CLI | Description | Example |
|--------------|-------------|---------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker | `opencli docker ps` |
| **longbridge** | Longbridge CLI — market data, account management, and trading via Longbridge OpenAPI | `opencli longbridge quote TSLA.US --format json` |
| **ntn** | Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments | `opencli ntn pages list` |
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
| **dws** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom-cli msg send --to user "hello"` |
| **tg(tg-cli)** | Telegram — local-first sync, search, and export via MTProto for AI agents | `opencli tg search "AI news" -f json` |
| **discord(discord-cli)** | Discord — local-first sync, search, and export via SQLite for AI agents | `opencli discord recent --channel general` |
| **wx(wx-cli)** | WeChat — query local WeChat data: sessions, messages, search, contacts, export | `opencli wx search "OpenCLI"` |
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
Register your own with `opencli external register <name>`; list everything with `opencli external list`.
```bash
opencli external register mycli
```
**Manual install** — some external CLIs use official shell-script installers rather than shell-free package-manager commands. For `ntn`, install from <https://ntn.dev> first, then run `opencli ntn ...`.
### Desktop App Adapters
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
| App | Description | Doc |
|-----|-------------|-----|
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
**Desktop app adapters** (Electron, via CDP): Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/).
## Download Support
@@ -368,25 +245,7 @@ opencli bilibili hot -v # Verbose: show pipeline debug steps
## Exit Codes
opencli follows Unix `sysexits.h` conventions so it integrates naturally with shell pipelines and CI scripts:
| Code | Meaning | When |
|------|---------|------|
| `0` | Success | Command completed normally |
| `1` | Generic error | Unexpected / unclassified failure |
| `2` | Usage error | Bad arguments or unknown command |
| `66` | Empty result | No data returned (`EX_NOINPUT`) |
| `69` | Service unavailable | Browser Bridge not connected (`EX_UNAVAILABLE`) |
| `75` | Temporary failure | Command timed out — retry (`EX_TEMPFAIL`) |
| `77` | Auth required | Not logged in to target site (`EX_NOPERM`) |
| `78` | Config error | Missing credentials or bad config (`EX_CONFIG`) |
| `130` | Interrupted | Ctrl-C / SIGINT |
```bash
opencli spotify status || echo "exit $?" # 69 if browser not running
opencli gh issue list 2>/dev/null
[ $? -eq 77 ] && opencli gh auth login # auto-auth if not logged in
```
opencli follows Unix `sysexits.h` so CI / scripts can branch on failure mode: `0` success, `66` empty result, `69` Browser Bridge down, `75` timeout, `77` auth required, `78` config error, `130` Ctrl-C. Full reference: [docs/guide/exit-codes.md](./docs/guide/exit-codes.md).
## Plugins
@@ -408,18 +267,6 @@ opencli plugin uninstall my-tool
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
## For AI Agents (Developer Guide)
Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end:
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
- Discover the right endpoint via `opencli browser <session> network`, `eval`, or the interceptor fallback.
- Decide auth strategy (`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`).
- Run `opencli browser recon analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser recon init`.
- Verify with `opencli browser recon verify <site>/<name>` before shipping.
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
## Testing
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
@@ -429,7 +276,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 21**. Run `node --version`, upgrade Node if needed, then retry.
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 20**. Run `node --version`, upgrade Node if needed, then retry.
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
## Star History
+48 -260
View File
@@ -1,7 +1,8 @@
# OpenCLI
> **把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。**
> 复用浏览器登录态,先自动化真实操作,再把高频流程沉淀成可复用的 CLI 命令
> **把任意网站变成 CLI & 在你的登录态浏览器上跑 Browser Use。**
> 把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口
> 或者在任意页面上跑 Browser Use —— 导航、填表单、点击、抓取、自动化。
[![English](https://img.shields.io/badge/docs-English-1D4ED8?style=flat-square)](./README.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
@@ -11,27 +12,16 @@
OpenCLI 可以用同一套 CLI 做三类事情:
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
- **让 AI Agent 操作任意网站**:在你的 AI AgentClaude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。
- **让 AI Agent 操作任意网站**:在你的 AI AgentClaude 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 应用。
## 亮点
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT 等)。
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入/填充、提取、截图——全部通过你的已登录 Chrome 会话完成。
- **网站 → CLI** — 把任何网站变成确定性 CLI:100+ 站点能力已注册,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian、tg、discord、wx 等)。
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker``longbridge``tg``discord``wx``ntn`Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Trae CN、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。
## 快速开始
### 1. 安装 OpenCLI
OpenCLI 要求 **Node.js >= 21**
OpenCLI 要求 **Node.js >= 20**
```bash
node --version
@@ -89,7 +79,7 @@ opencli bilibili hot --limit 5
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI AgentClaude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
### 安装 skill
### 安装 skill(同时也用于更新)
```bash
npx skills add jackwener/opencli
@@ -101,23 +91,25 @@ npx skills add jackwener/opencli
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-browser-sitemap
npx skills add jackwener/opencli --skill opencli-sitemap-author
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
### 选择哪个 skill
| Skill | 适用场景 | 你对 AI Agent 说的话 |
|-------|---------|-------------------|
| **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-adapter-author** | 为新站点写可复用适配器,或给已有站点添加命令 | "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" |
| **opencli-browser** | 实时驱动 Chrome 页面——导航、填表单、点击、抓取 | "帮我看看小红书的通知" / "帮我填一下这个表单" / "用浏览器命令抓取这个页面" |
| **opencli-browser-sitemap** | 使用站点 sitemap 上下文来操作浏览器任务 | "用 sitemap 帮我少走弯路地操作这个网站" |
| **opencli-sitemap-author** | 创建或更新面向浏览器 Agent 的站点 sitemap | "把刚发现的稳定流程记录到这个站点的 sitemap" |
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
| **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" |
### 工作原理
安装 `opencli-adapter-author` skill 后,你的 AI Agent 可以:
安装 `opencli-browser` skill 后,你的 AI Agent 可以:
1. **导航**到任意 URL,使用你的已登录浏览器
2. **读取**页面内容——通过结构化 DOM 快照(不是截图)
@@ -128,53 +120,27 @@ npx skills add jackwener/opencli --skill smart-search
Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自然语言描述想做的事。
**Skill 参考文档:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 浏览器操作 + 适配器编写,全流程
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 实时驱动 Chrome(导航、填表单、点击、抓取)
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — 操作浏览器任务时消费 sitemap 上下文
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — 创建或更新站点 sitemap 知识
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 给新站点写适配器,全流程
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — 修复已有适配器
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 浏览器自动化参考
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — 能力搜索
`browser` 可用命令包括:`open``state``click``type``fill``select``keys``wait``get``find``extract``frames``screenshot``scroll``back``eval``network``tab list``tab new``tab select``tab close``init``verify``close`
`opencli browser` 命令必须紧跟一个 `<session>` 位置参数。`opencli browser work open <url>``opencli browser work tab new [url]` 都会返回 target ID。`opencli browser work tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为同一 session 后续未指定 target 的默认目标。
## 核心概念
## 为新站点写适配器
### `browser`AI Agent 的浏览器控制层
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,全流程:
`opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作。
比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser <session> open``state``click` 等命令。
### 内置适配器:稳定命令
当某个站点能力已经存在时,优先使用 `opencli hackernews top``opencli reddit hot` 这类稳定命令。这些命令是确定性的,无需浏览器——人类和 AI Agent 都可以直接使用。
### 为新站点写适配器
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,它会把 Agent 带到闭环:
1. 侦察站点,分类 patternSPA / SSR / JSONP / Token / Streaming
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. 定认证策略——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
4. 字段解码 + 设计输出列
5. `opencli browser recon analyze <url>` 一步侦察,再 `opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
### CLI 枢纽与桌面端适配器
OpenCLI 不只是网站 CLI,还可以:
- 统一代理本地二进制工具,例如 `gh``docker``obsidian``tg``discord``wx`
- 通过专门适配器和 CDP 集成控制 Electron 桌面应用
## 前置要求
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
- **Bun**: >= 1.0(可选替代运行时)
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
1. **侦察**站点,分类 patternSPA / SSR / JSONP / Token / Streaming
2. **发现** endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. **定认证**——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
4. **字段解码** + 设计输出列
5. `opencli browser recon analyze <url>``opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
6. 站点知识沉到 `~/.opencli/sites/<site>/`,下次同站点直接吃缓存
## 配置
@@ -191,180 +157,37 @@ OpenCLI 不只是网站 CLI,还可以:
`opencli browser *` 必须紧跟一个 `<session>` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser <session> close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`
## 更新
```bash
npm install -g @jackwener/opencli@latest
# 如果你在用打包发布的 OpenCLI skills,也一起刷新
npx skills add jackwener/opencli
```
如果你只装了部分 skill,也可以只刷新自己在用的:
```bash
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill smart-search
```
## 面向开发者
从源码安装:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
加载源码版 Browser Bridge 扩展:
1. 打开 `chrome://extensions` 并启用 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库里的 `extension/` 目录
## 内置命令
运行 `opencli list` 查看完整注册表。
| 站点 | 命令 | 模式 |
|------|------|------|
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `history` `export` | 桌面端 |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
| **uiverse** | `code` `preview` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **baidu-scholar** | `search` | 公开 |
| **google-scholar** | `search` `cite` `profile` | 公开 |
| **gov-law** | `search` `recent` | 公开 |
| **gov-policy** | `search` `recent` | 公开 |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` | 公开 / 浏览器 |
| **wanfang** | `search` | 公开 |
| **xiaoyuzhou** | `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` `auth` | 本地凭证 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
| **bbc** | `news` | 公共 API |
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
| **ctrip** | `search` | 浏览器 |
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **pubmed** | `search` `article` `author` `citations` `related` | 公开 |
| **openreview** | `search` `venue` `paper` `reviews` | 公开 |
| **paperreview** | `submit` `review` `feedback` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **jd** | `item` | 浏览器 |
| **linkedin** | `search` `timeline` | 浏览器 |
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **web** | `read` | 浏览器 |
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 |
| **yahoo-finance** | `quote` | 浏览器 |
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
| **chaoxing** | `assignments` `exams` | 浏览器 |
| **grok** | `ask` `image` | 浏览器 |
| **hf** | `top` | 公开 |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
| **linux-do** | `feed` `search` `categories` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` | 浏览器 |
| **1688** | `search` `item` `assets` `download` `store` | 浏览器 |
| **gitee** | `trending` `search` `user` | 公开 / 浏览器 |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` | 浏览器 |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | 浏览器 |
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
| **lobsters** | `hot` `newest` `active` `tag` `read` | 公开 |
| **medium** | `feed` `search` `user` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
| **substack** | `feed` `search` `publication` | 浏览器 |
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **xianyu** | `search` `item` `chat` `publish` | 浏览器 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |
| 站点 | 命令 |
|------|------|
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `people-search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **upwork** | `search` `feed` `detail` |
100+ 站点能力**[→ 查看完整命令列表](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast``podcast-episodes``episode``download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
精选清单**[→ 查看全部 100+ 站点和命令](./docs/adapters/index.md)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Chess.com / Bilibili / 等)。
### 外部 CLI 枢纽
OpenCLI 也可以作为你现有命令行工具统一入口,负责发现、自动安装和纯透传执行。
现有命令行工具统一接入 `opencli <tool> ...`
| 外部 CLI | 描述 | 示例 |
|----------|------|------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **longbridge** | Longbridge CLI — 通过 Longbridge OpenAPI 获取行情、账户和交易能力 | `opencli longbridge quote TSLA.US --format json` |
| **ntn** | Notion CLI — 基于官方 Notion API 的页面、数据库、块、搜索、评论命令 | `opencli ntn pages list` |
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
| **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` |
| **tg(tg-cli)** | Telegram CLI — 基于 MTProto 的本地优先同步、搜索、导出,面向 AI Agent | `opencli tg search "AI news" -f json` |
| **discord(discord-cli)** | Discord CLI — 基于 SQLite 的本地优先同步、搜索、导出,面向 AI Agent | `opencli discord recent --channel general` |
| **wx(wx-cli)** | 微信本地数据 CLI — 会话、聊天记录、搜索、联系人、导出 | `opencli wx search "OpenCLI"` |
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为
注册自定义本地 CLI`opencli external register <name>`;查看所有:`opencli external list`
**自动安装**:如果某个外部 CLI 配置了安全的包管理器安装命令,OpenCLI 会优先尝试安装后再执行;`ntn` 的官方安装方式是 shell 脚本,请先按 <https://ntn.dev> 手动安装
**注册自定义本地 CLI**
```bash
opencli register mycli
```
### 桌面应用适配器
每个桌面适配器都有自己详细的文档说明,包括命令参考、启动配置与使用示例:
| 应用 | 描述 | 文档 |
|-----|-------------|-----|
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
**桌面应用适配器**Electron,通过 CDP):Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)
## 下载支持
@@ -456,28 +279,7 @@ opencli bilibili hot -v # 详细模式:展示管线执行步骤调试
## 退出码
opencli 遵循 Unix `sysexits.h` 惯例,可无缝接入 shell 管道和 CI 脚本:
| 退出码 | 含义 | 触发场景 |
|--------|------|----------|
| `0` | 成功 | 命令正常完成 |
| `1` | 通用错误 | 未分类的意外错误 |
| `2` | 用法错误 | 参数错误或未知命令 |
| `66` | 无数据 | 命令返回空结果(`EX_NOINPUT` |
| `69` | 服务不可用 | Browser Bridge 未连接(`EX_UNAVAILABLE` |
| `75` | 临时失败 | 命令超时,可重试(`EX_TEMPFAIL` |
| `77` | 需要认证 | 未登录目标网站(`EX_NOPERM` |
| `78` | 配置错误 | 凭证缺失或配置有误(`EX_CONFIG` |
| `130` | 中断 | Ctrl-C / SIGINT |
```bash
opencli bilibili hot 2>/dev/null
case $? in
0) echo "ok" ;;
69) echo "请先启动 Browser Bridge" ;;
77) echo "请先登录 bilibili.com" ;;
esac
```
opencli 遵循 Unix `sysexits.h`CI / 脚本可按失败模式分支:`0` 成功、`66` 无数据、`69` Browser Bridge 未连接、`75` 超时、`77` 需要认证、`78` 配置错误、`130` Ctrl-C。完整参考:[docs/zh/guide/exit-codes.md](./docs/zh/guide/exit-codes.md)。
## 插件
@@ -502,20 +304,6 @@ opencli plugin uninstall my-tool # 卸载
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
## 致 AI Agent(开发者指南)
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来:
- 侦察站点,选定 patternSPA / SSR / JSONP / Token / Streaming
-`opencli browser <name> network``eval`、interceptor 等找到目标 endpoint
- 定认证策略(`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
- 先用 `opencli browser recon analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser recon init` 生成骨架
- 交付前用 `opencli browser recon verify <site>/<name>` 验证
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
## 常见问题排查
- **"Extension not connected" 报错**
@@ -525,7 +313,7 @@ opencli plugin uninstall my-tool # 卸载
- **返回空数据,或者报错 "Unauthorized"**
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
- OpenCLI 要求 **Node.js >= 21**。先执行 `node --version`,如果版本过低先升级,再重试命令。
- OpenCLI 要求 **Node.js >= 20**。先执行 `node --version`,如果版本过低先升级,再重试命令。
- **Daemon 问题**
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
+11673 -41
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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);
},
});
+73
View File
@@ -0,0 +1,73 @@
/**
* 12306 account summary for the logged-in user.
*
* Returns non-sensitive identity fields plus masked email / mobile.
* Use `--include-sensitive` to surface unmasked values from 12306's
* own response (12306 already masks the ID number server-side; this
* adapter never decodes that mask).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskEmail, maskMobile, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
const ACCOUNT_INFO_URL = 'https://kyfw.12306.cn/otn/modifyUser/initQueryUserInfoApi';
cli({
site: '12306',
name: 'me',
access: 'read',
description: 'Show the logged-in 12306 account summary. Sensitive fields (real name, email, mobile, birth date) are masked by default; pass --include-sensitive to opt in.',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real name / email / mobile / birth date. The 12306 ID-number mask is server-side and never decoded.' },
],
columns: ['username', 'real_name', 'email', 'mobile', 'birth_date', 'sex', 'country', 'user_type', 'member', 'active'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 me');
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const json = requireEvaluateObject(await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(ACCOUNT_INFO_URL)}, { credentials: 'include' });
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'account info');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for account info`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 account info returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
}
if (json?.status !== true || !json?.data?.userDTO) {
throw new CommandExecutionError('12306 account info payload missing userDTO');
}
const dto = json.data.userDTO;
const loginDto = dto.loginUserDTO || {};
const username = loginDto.user_name || loginDto.name || '';
const realName = loginDto.real_name || loginDto.realname || '';
const include = kwargs['include-sensitive'] === true;
return [{
username,
real_name: include ? realName : maskChineseName(realName),
email: include ? (dto.email || '') : maskEmail(dto.email || ''),
mobile: include ? (dto.mobile_no || '') : maskMobile(dto.mobile_no || ''),
birth_date: include ? (dto.born_date || '') : (dto.born_date || '').slice(0, 4),
sex: dto.sex_code === 'M' ? '男' : (dto.sex_code === 'F' ? '女' : ''),
country: dto.country_code || '',
user_type: json.data.userTypeName || '',
member: dto.flag_member === '1',
active: dto.is_active === '1',
}];
},
});
+96
View File
@@ -0,0 +1,96 @@
/**
* 12306 in-progress orders for the logged-in user.
*
* Returns orders that have not yet been ridden / refunded / completed
* (the `noComplete` slice). Order history covering completed and
* refunded tickets uses a separate endpoint that requires extra
* referer / page-state handshakes and is left for a follow-up so this
* command can ship reliably.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
const NO_COMPLETE_URL = 'https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete';
cli({
site: '12306',
name: 'orders',
access: 'read',
description: 'List in-progress 12306 orders (not yet ridden, refunded, or completed) for the logged-in user',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked passenger names in order rows. Masked by default.' },
],
columns: ['order_id', 'order_date', 'train_code', 'from_station', 'to_station', 'departure', 'passengers', 'status', 'amount'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 orders');
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const include = kwargs['include-sensitive'] === true;
const json = requireEvaluateObject(await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(NO_COMPLETE_URL)}, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_json_att=', credentials: 'include',
});
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'orders');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for queryMyOrderNoComplete`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 orders returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
}
if (json?.status !== true) {
throw new CommandExecutionError('12306 queryMyOrderNoComplete returned a failure status');
}
let orders;
if (Array.isArray(json?.data?.orderDBList)) {
orders = json.data.orderDBList;
} else if (Array.isArray(json?.data?.orderDTODataList)) {
orders = json.data.orderDTODataList;
} else if (Array.isArray(json?.data?.orders)) {
orders = json.data.orders;
} else if (Array.isArray(json?.data)) {
orders = json.data;
} else {
throw new CommandExecutionError('12306 queryMyOrderNoComplete payload missing order list array');
}
if (orders.length === 0) {
throw new EmptyResultError('No in-progress 12306 orders on this account');
}
return orders.map((o) => {
const tickets = Array.isArray(o.tickets) ? o.tickets : [];
const passengerNames = tickets
.map((t) => t.passenger_name || '')
.filter(Boolean)
.map((name) => include ? name : maskChineseName(name))
.join(', ');
return {
order_id: o.sequence_no || o.order_id || o.sequenceNo || '',
order_date: o.order_date || '',
train_code: o.train_code_page || o.station_train_code || o.train_code || '',
from_station: o.from_station_name_page || o.from_station_name || '',
to_station: o.to_station_name_page || o.to_station_name || '',
departure: o.start_train_date_page || o.start_train_date || '',
passengers: passengerNames,
status: o.ticket_status_name || o.order_status_name || o.statusName || '',
amount: o.ticket_total_price_page || o.ticket_total_price || '',
};
});
},
});
+90
View File
@@ -0,0 +1,90 @@
/**
* 12306 saved passenger list for the logged-in user.
*
* 12306 already masks ID numbers (`xxxx***********xxx`) and mobile
* numbers (`138****xxxx`) server-side. This adapter further masks the
* passenger's Chinese real name and birth date by default; pass
* `--include-sensitive` to surface the unmasked-by-12306 fields.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
const PASSENGER_QUERY_URL = 'https://kyfw.12306.cn/otn/passengers/query';
const MAX_PAGE_SIZE = 50;
function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) throw new ArgumentError(`limit must be a positive integer (1-${max})`);
if (n > max) throw new ArgumentError(`limit must be <= ${max}`);
return n;
}
cli({
site: '12306',
name: 'passengers',
access: 'read',
description: 'List the logged-in user\'s saved 12306 passengers. Sensitive fields are masked by default; pass --include-sensitive to opt in.',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: `Max passengers to return (1-${MAX_PAGE_SIZE})` },
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real names and birth dates. The 12306 ID-number / mobile masks are server-side and never decoded.' },
],
columns: ['name', 'sex', 'born_year', 'id_type', 'id_no', 'mobile', 'passenger_type', 'country'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 passengers');
const limit = normalizeLimit(kwargs.limit, 20, MAX_PAGE_SIZE);
const include = kwargs['include-sensitive'] === true;
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const json = requireEvaluateObject(await page.evaluate(`async () => {
const body = "pageIndex=1&pageSize=${MAX_PAGE_SIZE}";
const r = await fetch(${JSON.stringify(PASSENGER_QUERY_URL)}, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body, credentials: 'include',
});
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'passengers');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for passengers/query`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 passengers returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
}
if (json?.status !== true || !Array.isArray(json?.data?.datas)) {
throw new CommandExecutionError('12306 passengers payload missing data.datas array');
}
const datas = json.data.datas;
if (datas.length === 0) {
throw new EmptyResultError('No saved passengers on this 12306 account');
}
return datas.slice(0, limit).map((p) => ({
name: include ? (p.passenger_name || '') : maskChineseName(p.passenger_name || ''),
sex: p.sex_name || '',
born_year: (p.born_date || '').slice(0, 4),
id_type: p.passenger_id_type_name || '',
id_no: p.passenger_id_no || '',
mobile: p.mobile_no || '',
passenger_type: p.passenger_type_name || '',
country: p.country_code || '',
}));
},
});
export const __test__ = { normalizeLimit };
+166
View File
@@ -0,0 +1,166 @@
/**
* 12306 ticket price lookup for a single train + segment.
*
* Cascades three anonymous API calls:
* 1. /otn/leftTicket/init: mint session cookies
* 2. /otn/czxx/queryByTrainNo: resolve from/to station_no within the
* train route (price endpoint addresses stops by station_no, not
* telecode)
* 3. /otn/leftTicket/queryTicketPrice: ticket prices keyed by seat
* letter (M=一等座, O=二等座, A9=商务座, A1=硬座, A3=硬卧,
* A4=软卧, F=动卧, P=特等座, WZ=无座, etc.)
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
const SEAT_TYPES_RE = /^[A-Z0-9]{1,32}$/;
const SEAT_LETTERS = {
'A9': '商务座',
'P': '特等座',
'M': '一等座',
'O': '二等座',
'A1': '硬座',
'A3': '硬卧',
'A4': '软卧',
'F': '动卧',
'WZ': '无座',
};
async function queryStopsForPrice(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
const resp = await fetchImpl(url, {
headers: {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
},
});
if (!resp.ok) throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
let json;
try {
json = await resp.json();
} catch {
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
}
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
throw new CommandExecutionError('12306 queryByTrainNo returned an unexpected payload shape');
}
return json.data.data;
}
function pickStationNos(stops, fromCode, toCode, fromName, toName) {
const matches = (s, code, name) => (s.station_name && name && s.station_name === name);
const fromStop = stops.find((s) => matches(s, fromCode, fromName));
const toStop = stops.find((s) => matches(s, toCode, toName));
if (!fromStop) throw new CommandExecutionError(`Train does not stop at ${fromName}`);
if (!toStop) throw new CommandExecutionError(`Train does not stop at ${toName}`);
return { fromNo: fromStop.station_no, toNo: toStop.station_no };
}
async function queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/leftTicket/queryTicketPrice?train_no=${trainNo}&from_station_no=${fromNo}&to_station_no=${toNo}&seat_types=${seatTypes}&train_date=${date}`;
const resp = await fetchImpl(url, {
headers: {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
},
});
if (!resp.ok) throw new CommandExecutionError(`12306 queryTicketPrice returned HTTP ${resp.status}`);
let json;
try {
json = await resp.json();
} catch {
throw new CommandExecutionError('12306 queryTicketPrice returned non-JSON body');
}
if (json?.status !== true || !json?.data) {
throw new CommandExecutionError('12306 queryTicketPrice returned an unexpected payload shape');
}
return json.data;
}
function parsePriceData(priceData) {
const rows = [];
for (const [letter, value] of Object.entries(priceData)) {
if (letter === 'train_no' || letter === 'OT') continue;
if (typeof value !== 'string' || !value) continue;
// 12306 doubles up some prices as bare numerics ("9": "21580"), which
// mirror their letter sibling ("A9": "¥2158.0") in cents/no-decimal
// form. Skip the bare numeric letter codes to avoid duplicates.
if (/^\d+$/.test(letter)) continue;
if (!/^[A-Z]/.test(letter)) continue;
const numeric = value.replace(/^¥/, '');
if (!/^[\d.]+$/.test(numeric)) continue;
rows.push({
seat_code: letter,
seat_name: SEAT_LETTERS[letter] || letter,
price: numeric,
currency: 'CNY',
});
}
rows.sort((a, b) => Number(b.price) - Number(a.price));
return rows;
}
cli({
site: '12306',
name: 'price',
access: 'read',
description: 'Look up 12306 ticket prices by seat class for one train on a given date and segment (anonymous, no login required)',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L)' },
{ name: 'from', required: true, help: 'Origin station (Chinese name, telecode, or pinyin) - must be a stop of this train' },
{ name: 'to', required: true, help: 'Destination station - must be a stop of this train' },
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
{ name: 'seat-types', default: 'OM9PA1A3A4FWZ', help: 'Seat-type letters to query (default covers the common classes). Examples: OM9 (二等/一等/商务), A1A3A4 (硬座/硬卧/软卧).' },
],
columns: ['seat_code', 'seat_name', 'price', 'currency'],
func: async (kwargs) => {
const trainNo = String(kwargs['train-no'] ?? '').trim();
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
if (!TRAIN_NO_RE.test(trainNo)) {
throw new ArgumentError(
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
);
}
const fromArg = String(kwargs.from ?? '').trim();
const toArg = String(kwargs.to ?? '').trim();
if (!fromArg) throw new ArgumentError('--from station must not be empty');
if (!toArg) throw new ArgumentError('--to station must not be empty');
const date = validateDate(kwargs.date);
const seatTypes = String(kwargs['seat-types'] ?? '').trim() || 'OM9PA1A3A4FWZ';
if (!SEAT_TYPES_RE.test(seatTypes)) {
throw new ArgumentError('--seat-types must contain only 12306 seat letters/digits (A-Z, 0-9)');
}
const stations = await fetchStationBundle();
const fromStation = resolveStation(stations, fromArg);
const toStation = resolveStation(stations, toArg);
if (fromStation.code === toStation.code) {
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
}
const cookieHeader = await mintSession();
const stops = await queryStopsForPrice(cookieHeader, trainNo, fromStation.code, toStation.code, date);
const { fromNo, toNo } = pickStationNos(stops, fromStation.code, toStation.code, fromStation.name, toStation.name);
const priceData = await queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date);
const rows = parsePriceData(priceData);
if (rows.length === 0) {
throw new EmptyResultError(
`No prices returned for train_no=${trainNo} ${fromStation.name} -> ${toStation.name} on ${date}`,
'Try a different seat-types letter set, or check that this train operates on the date.',
);
}
return rows;
},
});
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS, TRAIN_NO_RE };
+66
View File
@@ -0,0 +1,66 @@
/**
* 12306 station search.
*
* Queries the public `station_name.js` bundle and filters by the user's
* keyword. Anonymous, no session needed.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle } from './utils.js';
const MAX_LIMIT = 50;
function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
}
if (n > max) {
throw new ArgumentError(`limit must be <= ${max}`);
}
return n;
}
cli({
site: '12306',
name: 'stations',
access: 'read',
description: 'Search 12306 (China Railway) stations by Chinese name, telecode, or pinyin keyword',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'keyword', positional: true, required: true, help: 'Chinese substring (上海), telecode (AOH), or pinyin (shanghai)' },
{ name: 'limit', type: 'int', default: 20, help: `Maximum results (1-${MAX_LIMIT})` },
],
columns: ['name', 'code', 'pinyin', 'abbr', 'city'],
func: async (kwargs) => {
const keyword = String(kwargs.keyword ?? '').trim();
if (!keyword) throw new ArgumentError('keyword must not be empty');
const limit = normalizeLimit(kwargs.limit, 20, MAX_LIMIT);
const stations = await fetchStationBundle();
const lower = keyword.toLowerCase();
const matches = stations.filter((s) =>
s.name.includes(keyword)
|| s.code === keyword.toUpperCase()
|| s.pinyin.includes(lower)
|| s.abbr.includes(lower)
|| s.short.includes(lower)
|| s.city.includes(keyword),
);
if (matches.length === 0) {
throw new EmptyResultError(`No 12306 stations match "${keyword}"`);
}
return matches.slice(0, limit).map((s) => ({
name: s.name,
code: s.code,
pinyin: s.pinyin,
abbr: s.abbr,
city: s.city,
}));
},
});
export const __test__ = { normalizeLimit };
+91
View File
@@ -0,0 +1,91 @@
/**
* 12306 train stop details - list every station a train calls at,
* with arrival / departure / stopover time.
*
* Requires the internal `train_no` returned by `12306 trains`
* (`24000000G10L`), not the public train code (`G1`).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
const resp = await fetchImpl(url, {
headers: {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
},
});
if (!resp.ok) {
throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
}
let json;
try {
json = await resp.json();
} catch {
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
}
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
throw new CommandExecutionError(`12306 queryByTrainNo returned an unexpected payload shape`);
}
return json.data.data;
}
cli({
site: '12306',
name: 'train',
access: 'read',
description: 'List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)' },
{ name: 'from', required: true, help: 'Origin station for the segment: Chinese name, telecode, or pinyin' },
{ name: 'to', required: true, help: 'Destination station for the segment' },
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
],
columns: ['station_no', 'station_name', 'arrive_time', 'start_time', 'stopover_time'],
func: async (kwargs) => {
const trainNo = String(kwargs['train-no'] ?? '').trim();
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
if (!TRAIN_NO_RE.test(trainNo)) {
throw new ArgumentError(
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
);
}
const fromArg = String(kwargs.from ?? '').trim();
const toArg = String(kwargs.to ?? '').trim();
if (!fromArg) throw new ArgumentError('--from station must not be empty');
if (!toArg) throw new ArgumentError('--to station must not be empty');
const date = validateDate(kwargs.date);
const stations = await fetchStationBundle();
const fromStation = resolveStation(stations, fromArg);
const toStation = resolveStation(stations, toArg);
if (fromStation.code === toStation.code) {
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
}
const cookieHeader = await mintSession();
const stops = await queryStops(cookieHeader, trainNo, fromStation.code, toStation.code, date);
if (stops.length === 0) {
throw new EmptyResultError(`No stops returned for train_no=${trainNo} on ${date}`);
}
return stops.map((s) => ({
station_no: s.station_no || '',
station_name: s.station_name || '',
arrive_time: s.arrive_time === '----' ? '' : (s.arrive_time || ''),
start_time: s.start_time === '----' ? '' : (s.start_time || ''),
stopover_time: s.stopover_time === '----' ? '' : (s.stopover_time || ''),
}));
},
});
export const __test__ = { queryStops, TRAIN_NO_RE };
+119
View File
@@ -0,0 +1,119 @@
/**
* 12306 train availability between two stations on a given date.
*
* Flow:
* 1. Fetch the station bundle (cached implicitly via per-process module state).
* 2. Mint anonymous session cookies via /otn/leftTicket/init.
* 3. Query /otn/leftTicket/queryG; if 12306 returns
* `{c_url: "leftTicket/queryX"}` (endpoint rotation), retry once
* against the suggested name.
* 4. Parse the `|`-separated train records.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, resolveStation, validateDate, parseTrainRecord } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const QUERY_ENDPOINTS = ['queryG', 'queryO', 'queryZ', 'queryA'];
const MAX_LIMIT = 100;
function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
}
if (n > max) {
throw new ArgumentError(`limit must be <= ${max}`);
}
return n;
}
async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
const headers = {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
};
const queryParams = `leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromCode}&leftTicketDTO.to_station=${toCode}&purpose_codes=ADULT`;
let lastResponseText = '';
for (const endpoint of QUERY_ENDPOINTS) {
const url = `https://kyfw.12306.cn/otn/leftTicket/${endpoint}?${queryParams}`;
const resp = await fetch(url, { headers });
if (!resp.ok) {
if (resp.status === 302) continue;
throw new CommandExecutionError(`12306 ${endpoint} returned HTTP ${resp.status}`);
}
const text = await resp.text();
lastResponseText = text;
let json;
try { json = JSON.parse(text); } catch {
throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
}
if (json?.c_url && typeof json.c_url === 'string') {
const rotated = json.c_url.replace('leftTicket/', '').trim();
if (rotated && !QUERY_ENDPOINTS.includes(rotated)) {
QUERY_ENDPOINTS.unshift(rotated);
}
continue;
}
if (Array.isArray(json?.data?.result)) {
return json.data.result;
}
throw new CommandExecutionError(`12306 ${endpoint} returned an unexpected payload shape`);
}
throw new CommandExecutionError(`12306 rejected every known query endpoint name (${QUERY_ENDPOINTS.join(', ')}); the wire protocol may have changed. Last body: ${lastResponseText.slice(0, 200)}`);
}
cli({
site: '12306',
name: 'trains',
access: 'read',
description: 'List trains between two 12306 stations on a given date (anonymous, no login required)',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'from', positional: true, required: true, help: 'Origin station: Chinese name (北京), telecode (BJP), or pinyin (beijing)' },
{ name: 'to', positional: true, required: true, help: 'Destination station: same forms as <from>' },
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
{ name: 'limit', type: 'int', default: 50, help: `Maximum rows (1-${MAX_LIMIT})` },
],
columns: [
'code', 'from_station', 'to_station', 'start_time', 'arrive_time',
'duration', 'available', 'business_seat', 'first_seat', 'second_seat',
'soft_sleeper', 'hard_sleeper', 'hard_seat', 'no_seat', 'train_no',
],
func: async (kwargs) => {
const fromArg = String(kwargs.from ?? '').trim();
const toArg = String(kwargs.to ?? '').trim();
if (!fromArg) throw new ArgumentError('<from> station must not be empty');
if (!toArg) throw new ArgumentError('<to> station must not be empty');
const date = validateDate(kwargs.date);
const limit = normalizeLimit(kwargs.limit, 50, MAX_LIMIT);
const stations = await fetchStationBundle();
const fromStation = resolveStation(stations, fromArg);
const toStation = resolveStation(stations, toArg);
if (fromStation.code === toStation.code) {
throw new ArgumentError(`<from> and <to> must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
}
const stationByCode = new Map(stations.map((s) => [s.code, s]));
const cookieHeader = await mintSession();
const rawRows = await queryLeftTickets(cookieHeader, fromStation.code, toStation.code, date);
const decoded = rawRows
.map((line) => parseTrainRecord(decodeURIComponent(line.replace(/%0A/g, '')), stationByCode))
.filter(Boolean);
if (decoded.length === 0) {
throw new EmptyResultError(
`No trains found from ${fromStation.name} to ${toStation.name} on ${date}`,
'Try a different date or check whether the route is operated by 12306.',
);
}
return decoded.slice(0, limit);
},
});
export const __test__ = { normalizeLimit, queryLeftTickets };
+272
View File
@@ -0,0 +1,272 @@
/**
* 12306 (中国铁路) shared helpers.
*
* - Station lookup: parses the public `station_name.js` bundle into
* structured records.
* - Cookie session: 12306's query endpoints reject anonymous requests
* with `HTTP 302 -> error.html`, so callers must hit `/otn/leftTicket/init`
* first to mint the JSESSIONID / route / BIGipServerotn cookies.
* - Query endpoint rotation: 12306 rotates the train-query endpoint
* name (queryO / queryZ / queryA / queryG / ...) every few weeks.
* When the wrong name is hit, the server returns
* `{"c_url":"leftTicket/queryG","c_name":"CLeftTicketUrl","status":false}`
* pointing to the current correct name; retry once with that name.
*/
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
const STATION_BUNDLE_URL = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
const INIT_URL = 'https://kyfw.12306.cn/otn/leftTicket/init';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const STATION_CODE_RE = /^[A-Z]{2,4}$/;
/**
* Parse the `station_name.js` bundle into a station record array.
*
* Bundle format (single line, `@`-delimited records, each `|`-delimited):
* `var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||...';`
*
* Per-record fields (positional):
* [0] short pinyin alias (e.g. `bjb`)
* [1] Chinese station name (e.g. `北京北`)
* [2] telecode (3-4 uppercase letters, e.g. `VAP`) - this is the
* wire format 12306 uses for `from_station` / `to_station`.
* [3] full pinyin (e.g. `beijingbei`)
* [4] short alias (duplicate of [0] usually)
* [5] index/rank
* [6] city code
* [7] city name (e.g. `北京`)
*/
export function parseStationBundle(text) {
const match = text.match(/'([^']+)'/);
if (!match) {
throw new CommandExecutionError('Failed to parse 12306 station_name.js: source string not found');
}
const raw = match[1];
const records = raw.split('@').filter(Boolean);
const stations = [];
for (const r of records) {
const parts = r.split('|');
if (parts.length < 8 || !parts[2]) continue;
stations.push({
short: parts[0] || '',
name: parts[1] || '',
code: parts[2] || '',
pinyin: parts[3] || '',
abbr: parts[4] || '',
city: parts[7] || '',
});
}
if (stations.length === 0) {
throw new CommandExecutionError('Failed to parse 12306 station_name.js: no station records found');
}
return stations;
}
/**
* Resolve a user-supplied station identifier to a telecode.
*
* Accepts Chinese name (`上海虹桥`), telecode (`AOH`), pinyin
* (`shanghaihongqiao`), short alias (`shh`), or city name with a
* preference for the city's main station.
*/
export function resolveStation(stations, input) {
const trimmed = String(input ?? '').trim();
if (!trimmed) throw new ArgumentError('station must not be empty');
if (STATION_CODE_RE.test(trimmed)) {
const exact = stations.find((s) => s.code === trimmed);
if (exact) return exact;
throw new ArgumentError(`Unknown 12306 station telecode "${trimmed}"`);
}
const lower = trimmed.toLowerCase();
const exactName = stations.find((s) => s.name === trimmed);
if (exactName) return exactName;
const exactPinyin = stations.find((s) => s.pinyin === lower);
if (exactPinyin) return exactPinyin;
const exactAbbr = stations.find((s) => s.abbr === lower || s.short === lower);
if (exactAbbr) return exactAbbr;
throw new ArgumentError(`Unknown 12306 station "${trimmed}"`, 'Try the Chinese name (上海虹桥), the 3-4 letter telecode (AOH), or full pinyin (shanghaihongqiao).');
}
export function validateDate(value) {
if (!DATE_RE.test(String(value ?? ''))) {
throw new ArgumentError(`date must be YYYY-MM-DD, got "${value}"`);
}
const [y, m, d] = value.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
throw new ArgumentError(`date "${value}" is not a real calendar date`);
}
return value;
}
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
export function buildCookieHeader(setCookieHeaders) {
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
return setCookieHeaders
.map((line) => line.split(';')[0])
.filter(Boolean)
.join('; ');
}
export async function fetchStationBundle(fetchImpl = fetch) {
const resp = await fetchImpl(STATION_BUNDLE_URL, {
headers: { 'User-Agent': UA },
});
if (!resp.ok) {
throw new CommandExecutionError(`Failed to fetch 12306 station bundle: HTTP ${resp.status}`);
}
return parseStationBundle(await resp.text());
}
/** Mint a 12306 anonymous session by hitting /otn/leftTicket/init. */
export async function mintSession(fetchImpl = fetch) {
const resp = await fetchImpl(INIT_URL, {
headers: { 'User-Agent': UA },
redirect: 'follow',
});
if (!resp.ok) {
throw new CommandExecutionError(`Failed to mint 12306 session: HTTP ${resp.status}`);
}
const setCookies = typeof resp.headers.getSetCookie === 'function'
? resp.headers.getSetCookie()
: resp.headers.raw?.()['set-cookie'] || [];
const cookieHeader = buildCookieHeader(setCookies);
if (!cookieHeader) {
throw new CommandExecutionError('12306 init returned no session cookies');
}
return cookieHeader;
}
/**
* Twelve-row train query record (LEFT_TICKET_DTO).
*
* 12306 returns each train as a `|`-separated string with ~36 fields.
* Positions used here come from the public web client; unused
* positions are documented inline so future maintainers can extend
* the row shape without re-reverse-engineering.
*/
export function parseTrainRecord(line, stationByCode) {
const f = line.split('|');
if (f.length < 33) return null;
return {
train_no: f[2] || '',
code: f[3] || '',
from_station: stationByCode.get(f[6])?.name || f[6] || '',
to_station: stationByCode.get(f[7])?.name || f[7] || '',
from_code: f[6] || '',
to_code: f[7] || '',
start_time: f[8] || '',
arrive_time: f[9] || '',
duration: f[10] || '',
available: (f[1] || '').trim() === '预订' || (f[11] || '').trim() === 'Y',
business_seat: f[32] || '',
first_seat: f[31] || '',
second_seat: f[30] || '',
soft_sleeper: f[23] || '',
hard_sleeper: f[28] || '',
hard_seat: f[29] || '',
no_seat: f[26] || '',
};
}
/**
* Mask helpers for sensitive identity fields rendered by 12306.
*
* 12306 already masks ID numbers and mobile numbers server-side
* (`xxxx***********xxx` / `138****xxxx`); these helpers handle the
* remaining fields (email, real Chinese name) so the adapter never
* leaks unmasked PII without an explicit `--include-sensitive` opt-in.
*/
export function maskEmail(value) {
const v = String(value || '').trim();
if (!v) return '';
const at = v.indexOf('@');
if (at <= 0) return v;
const local = v.slice(0, at);
const domain = v.slice(at);
if (local.length <= 2) return local[0] + '*' + domain;
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local.slice(-1) + domain;
}
export function maskMobile(value) {
const v = String(value || '').trim();
if (!v) return '';
if (/\*/.test(v)) return v;
if (v.length < 7) return v.replace(/.(?=.)/g, '*');
return v.slice(0, 3) + '*'.repeat(v.length - 7) + v.slice(-4);
}
export function maskChineseName(value) {
const v = String(value || '').trim();
if (!v) return '';
if (v.length === 1) return v;
if (v.length === 2) return v[0] + '*';
return v[0] + '*'.repeat(v.length - 2) + v.slice(-1);
}
export function unwrapEvaluateResult(value) {
if (
value
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.prototype.hasOwnProperty.call(value, 'session')
&& Object.prototype.hasOwnProperty.call(value, 'data')
) {
return value.data;
}
return value;
}
export function requireEvaluateObject(value, label) {
const payload = unwrapEvaluateResult(value);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError(`12306 ${label} returned a malformed browser payload`);
}
return payload;
}
export function isAuthLikePayload(payload) {
if (!payload || typeof payload !== 'object') return false;
const parts = [];
if (Array.isArray(payload.messages)) parts.push(...payload.messages);
if (payload.message) parts.push(payload.message);
if (payload.msg) parts.push(payload.msg);
if (payload.validateMessages && typeof payload.validateMessages === 'object') {
parts.push(...Object.values(payload.validateMessages).flat());
}
const text = parts.map((item) => String(item ?? '')).join(' ');
return /未登录|登录|请登录|身份|认证|session|Session|login/i.test(text);
}
/**
* Detect the 12306 login marker by reading `document.cookie` from the
* current adapter page. Cannot use `page.getCookies({url})` here:
* 12306 sets the auth cookie `tk` and `JSESSIONID` with `Path=/otn`,
* and CDP `Network.getCookies` with a bare URL filter excludes
* cookies whose path does not match the URL path. `document.cookie`
* returns all non-httponly cookies visible to the current page
* regardless of path, which is what we need to confirm login.
*/
export async function require12306Login(page, AuthRequiredErrorClass) {
const docCookie = unwrapEvaluateResult(await page.evaluate(`document.cookie || ''`));
const cookieStr = typeof docCookie === 'string' ? docCookie : '';
if (!/\btk=/.test(cookieStr) || !/JSESSIONID=/.test(cookieStr)) {
throw new AuthRequiredErrorClass('kyfw.12306.cn', 'Not logged into 12306. Sign in at https://kyfw.12306.cn first.');
}
}
export const __test__ = {
parseStationBundle,
resolveStation,
validateDate,
buildCookieHeader,
parseTrainRecord,
maskEmail,
maskMobile,
maskChineseName,
unwrapEvaluateResult,
requireEvaluateObject,
isAuthLikePayload,
};
+355
View File
@@ -0,0 +1,355 @@
import { describe, expect, it } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import { __test__ } from './utils.js';
import { __test__ as priceTest } from './price.js';
import { __test__ as trainTest } from './train.js';
import './orders.js';
const { parseStationBundle, resolveStation, validateDate, buildCookieHeader, parseTrainRecord, maskEmail, maskMobile, maskChineseName, unwrapEvaluateResult, requireEvaluateObject, isAuthLikePayload } = __test__;
const { parsePriceData, queryStopsForPrice, queryPrice, TRAIN_NO_RE: PRICE_TRAIN_NO_RE } = priceTest;
const { queryStops, TRAIN_NO_RE: TRAIN_TRAIN_NO_RE } = trainTest;
describe('12306 utils - parseStationBundle', () => {
it('parses the `@`-delimited station bundle into structured records', () => {
const bundle = "var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||@bji|北京|BJP|beijing|bj|2|0357|北京|||@aoh|上海虹桥|AOH|shanghaihongqiao|shhq|10|7600|上海|||';";
const stations = parseStationBundle(bundle);
expect(stations).toHaveLength(3);
expect(stations[1]).toEqual({
short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京',
});
});
it('skips records that lack a telecode', () => {
const bundle = "var station_names ='@xxx|||||||||@bji|北京|BJP|beijing|bj|2|0357|北京|||';";
const stations = parseStationBundle(bundle);
expect(stations).toHaveLength(1);
expect(stations[0].code).toBe('BJP');
});
it('throws CommandExecutionError when the bundle has no parseable station rows', () => {
expect(() => parseStationBundle("var station_names ='@xxx|||||||||';")).toThrow(CommandExecutionError);
});
});
describe('12306 utils - resolveStation', () => {
const stations = [
{ short: 'bjb', name: '北京北', code: 'VAP', pinyin: 'beijingbei', abbr: 'bjb', city: '北京' },
{ short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京' },
{ short: 'aoh', name: '上海虹桥', code: 'AOH', pinyin: 'shanghaihongqiao', abbr: 'shhq', city: '上海' },
];
it('matches by exact Chinese name', () => {
expect(resolveStation(stations, '上海虹桥').code).toBe('AOH');
});
it('matches by uppercase telecode', () => {
expect(resolveStation(stations, 'BJP').code).toBe('BJP');
});
it('matches by full pinyin (case-insensitive)', () => {
expect(resolveStation(stations, 'Beijing').code).toBe('BJP');
});
it('matches by short alias / abbr', () => {
expect(resolveStation(stations, 'shhq').code).toBe('AOH');
});
it('throws ArgumentError for empty input', () => {
expect(() => resolveStation(stations, ' ')).toThrow(ArgumentError);
});
it('throws ArgumentError for unknown station', () => {
expect(() => resolveStation(stations, '某不存在站')).toThrow(ArgumentError);
});
it('throws ArgumentError for telecode-shaped but unknown input', () => {
expect(() => resolveStation(stations, 'XYZ')).toThrow(ArgumentError);
});
});
describe('12306 utils - validateDate', () => {
it('accepts valid YYYY-MM-DD', () => {
expect(validateDate('2026-05-22')).toBe('2026-05-22');
});
it('throws ArgumentError on wrong format', () => {
expect(() => validateDate('2026/05/22')).toThrow(ArgumentError);
expect(() => validateDate('26-05-22')).toThrow(ArgumentError);
expect(() => validateDate('today')).toThrow(ArgumentError);
expect(() => validateDate('')).toThrow(ArgumentError);
});
it('throws ArgumentError on impossible calendar dates', () => {
expect(() => validateDate('2026-02-30')).toThrow(ArgumentError);
expect(() => validateDate('2026-13-01')).toThrow(ArgumentError);
});
});
describe('12306 utils - buildCookieHeader', () => {
it('joins set-cookie lines into a single Cookie header', () => {
const headers = [
'JSESSIONID=ABC123; Path=/otn',
'BIGipServerotn=xxx.yyy; Path=/',
'route=zzz; Expires=Sat, 01 Jan 2027 00:00:00 GMT',
];
expect(buildCookieHeader(headers)).toBe('JSESSIONID=ABC123; BIGipServerotn=xxx.yyy; route=zzz');
});
it('returns empty string for empty input', () => {
expect(buildCookieHeader([])).toBe('');
expect(buildCookieHeader(undefined)).toBe('');
});
});
describe('12306 utils - parseTrainRecord', () => {
const stationByCode = new Map([
['VNP', { name: '北京南', code: 'VNP' }],
['AOH', { name: '上海虹桥', code: 'AOH' }],
]);
it('extracts the canonical train fields from a wire record', () => {
// 33 `|`-separated fields, with positions used by parseTrainRecord populated.
const fields = new Array(36).fill('');
fields[0] = 'SECRET_TOKEN';
fields[1] = '预订';
fields[2] = '240000G54700';
fields[3] = 'G547';
fields[6] = 'VNP';
fields[7] = 'AOH';
fields[8] = '06:18';
fields[9] = '12:11';
fields[10] = '05:53';
fields[11] = 'Y';
fields[23] = ''; // soft sleeper
fields[26] = '无'; // no seat
fields[28] = ''; // hard sleeper
fields[29] = ''; // hard seat
fields[30] = '有'; // second seat
fields[31] = '有'; // first seat
fields[32] = '无'; // business seat
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(row).toEqual({
train_no: '240000G54700',
code: 'G547',
from_station: '北京南',
to_station: '上海虹桥',
from_code: 'VNP',
to_code: 'AOH',
start_time: '06:18',
arrive_time: '12:11',
duration: '05:53',
available: true,
business_seat: '无',
first_seat: '有',
second_seat: '有',
soft_sleeper: '',
hard_sleeper: '',
hard_seat: '',
no_seat: '无',
});
});
it('does not expose the booking-handshake secret token', () => {
const fields = new Array(36).fill('');
fields[0] = 'SECRET_TOKEN_DO_NOT_LEAK';
fields[2] = 't_no'; fields[3] = 'X1'; fields[6] = 'VNP'; fields[7] = 'AOH';
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(Object.values(row)).not.toContain('SECRET_TOKEN_DO_NOT_LEAK');
expect('secret' in row).toBe(false);
});
it('falls back to the telecode when the station bundle has no name', () => {
const fields = new Array(36).fill('');
fields[2] = 'X'; fields[3] = 'X'; fields[6] = 'ZZZ'; fields[7] = 'YYY';
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(row.from_station).toBe('ZZZ');
expect(row.to_station).toBe('YYY');
});
it('returns null for short records', () => {
expect(parseTrainRecord('a|b|c', stationByCode)).toBeNull();
});
});
describe('12306 utils - mask helpers', () => {
it('masks the local-part of an email', () => {
expect(maskEmail('hello@example.com')).toBe('h***o@example.com');
expect(maskEmail('ab@x.cn')).toBe('a*@x.cn');
expect(maskEmail('a@x.cn')).toBe('a*@x.cn');
expect(maskEmail('')).toBe('');
expect(maskEmail('not-an-email')).toBe('not-an-email');
});
it('masks Chinese mobile numbers while preserving 12306-side masks', () => {
expect(maskMobile('13800001234')).toBe('138****1234');
expect(maskMobile('138****1234')).toBe('138****1234');
expect(maskMobile('')).toBe('');
expect(maskMobile('123')).toBe('**3');
});
it('masks Chinese real names', () => {
expect(maskChineseName('张三')).toBe('张*');
expect(maskChineseName('李四明')).toBe('李*明');
expect(maskChineseName('欧阳锋')).toBe('欧*锋');
expect(maskChineseName('张')).toBe('张');
expect(maskChineseName('')).toBe('');
});
});
describe('12306 price - parsePriceData', () => {
it('returns seat rows sorted by descending price and drops dup numeric codes', () => {
const data = {
train_no: '24000000G10L',
'OT': [],
'A9': '¥2158.0',
'9': '21580',
'P': '¥1163.0',
'M': '¥1035.0',
'O': '¥626.0',
'WZ': '¥626.0',
'INVALID': 'not-a-price',
};
const rows = parsePriceData(data);
const codes = rows.map((r) => r.seat_code);
expect(codes).not.toContain('9');
expect(codes).not.toContain('OT');
expect(codes).not.toContain('train_no');
expect(codes).not.toContain('INVALID');
expect(codes).toEqual(['A9', 'P', 'M', 'O', 'WZ']);
expect(rows[0]).toEqual({ seat_code: 'A9', seat_name: '商务座', price: '2158.0', currency: 'CNY' });
expect(rows[4]).toEqual({ seat_code: 'WZ', seat_name: '无座', price: '626.0', currency: 'CNY' });
});
it('keeps unknown letter codes with the letter as the name', () => {
const data = { 'A9': '¥100.0', 'ZZ': '¥50.0' };
const rows = parsePriceData(data);
const zz = rows.find((r) => r.seat_code === 'ZZ');
expect(zz?.seat_name).toBe('ZZ');
});
});
describe('12306 train_no validation regex', () => {
// 12306 train_no values returned by /otn/leftTicket/query sometimes contain
// lowercase letters (e.g. "5l000G1970A3" for G1970 上海虹桥 -> 宝鸡南).
// Both `12306 price` and `12306 train` must accept the raw value emitted
// by `12306 trains`, otherwise the two adapters drift apart and downstream
// calls fail with ARGUMENT before ever hitting 12306.
for (const [label, re] of [['price', PRICE_TRAIN_NO_RE], ['train', TRAIN_TRAIN_NO_RE]]) {
describe(label, () => {
it('accepts an all-uppercase train_no', () => {
expect(re.test('24000000G10L')).toBe(true);
});
it('accepts a train_no with lowercase letters (real 12306 payload)', () => {
expect(re.test('5l000G1970A3')).toBe(true);
});
it('rejects public codes like G1970', () => {
expect(re.test('G1970')).toBe(false);
});
it('rejects values with disallowed characters', () => {
expect(re.test('5l000-G1970A3')).toBe(false);
});
});
}
});
describe('12306 public API typed boundaries', () => {
const nonJsonFetch = async () => ({
ok: true,
json: async () => {
throw new SyntaxError('Unexpected token <');
},
});
it('wraps non-JSON train stop bodies as CommandExecutionError', async () => {
await expect(queryStops('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('wraps non-JSON price helper bodies as CommandExecutionError', async () => {
await expect(queryStopsForPrice('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(queryPrice('cookie=1', '24000000G10L', '01', '02', 'OM9', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('12306 browser evaluate boundaries', () => {
it('unwraps Browser Bridge {session,data} evaluate envelopes only at the boundary', () => {
expect(unwrapEvaluateResult({ session: 's1', data: 'JSESSIONID=1; tk=2' })).toBe('JSESSIONID=1; tk=2');
expect(unwrapEvaluateResult({ status: true, data: { value: 1 } })).toEqual({ status: true, data: { value: 1 } });
expect(requireEvaluateObject({ session: 's1', data: { status: true } }, 'test')).toEqual({ status: true });
expect(() => requireEvaluateObject({ session: 's1', data: null }, 'test')).toThrow(CommandExecutionError);
});
it('classifies 12306 login-like API envelopes as auth failures', () => {
expect(isAuthLikePayload({ status: false, messages: ['用户未登录'] })).toBe(true);
expect(isAuthLikePayload({ status: false, validateMessages: { global: ['请登录后再试'] } })).toBe(true);
expect(isAuthLikePayload({ status: false, messages: ['系统繁忙'] })).toBe(false);
});
it('masks passenger names in orders by default and supports explicit sensitive opt-in', async () => {
const command = getRegistry().get('12306/orders');
const makePage = () => ({
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return { session: 'browser', data: 'JSESSIONID=abc; tk=def' };
return {
session: 'browser',
data: {
status: true,
data: {
orderDBList: [{
sequence_no: 'E123',
order_date: '2026-05-18 10:00',
train_code_page: 'G1',
from_station_name_page: '北京南',
to_station_name_page: '上海虹桥',
start_train_date_page: '2026-05-22 07:00',
ticket_status_name: '未出行',
ticket_total_price_page: '626.0',
tickets: [{ passenger_name: '张三' }, { passenger_name: '李四明' }],
}],
},
},
};
},
});
await expect(command.func(makePage(), {})).resolves.toMatchObject([
{ order_id: 'E123', passengers: '张*, 李*明' },
]);
await expect(command.func(makePage(), { 'include-sensitive': true })).resolves.toMatchObject([
{ order_id: 'E123', passengers: '张三, 李四明' },
]);
});
it('maps login-like order payloads to AuthRequiredError instead of parser drift', async () => {
const command = getRegistry().get('12306/orders');
const page = {
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
return { status: false, messages: ['用户未登录'] };
},
};
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
});
it('treats missing order list shape as parser drift but known empty arrays as empty result', async () => {
const command = getRegistry().get('12306/orders');
const makePage = (payload) => ({
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
return payload;
},
});
await expect(command.func(makePage({ status: true, data: {} }), {}))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ status: true, data: { orderDBList: [] } }), {}))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
+46
View File
@@ -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);
},
});
+52
View File
@@ -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);
},
});
+6 -3
View File
@@ -52,12 +52,15 @@ cli({
if (!data?.title) {
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
}
if (!data.body) {
throw new CliError('PARSE_ERROR', 'Article body not found', '36kr page loaded but no article body paragraphs were extracted');
}
return [
{ field: 'title', value: data.title },
{ field: 'author', value: data.author || '-' },
{ field: 'date', value: data.date || '-' },
{ field: 'author', value: data.author || '' },
{ field: 'date', value: data.date || '' },
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
{ field: 'body', value: data.body || '-' },
{ field: 'body', value: data.body || '' },
];
},
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import './article.js';
function makePage(evaluateResult) {
return {
installInterceptor: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(evaluateResult),
};
}
describe('36kr article', () => {
it('emits empty-string for missing optional author / date instead of a sentinel', async () => {
const command = getRegistry().get('36kr/article');
expect(command?.func).toBeDefined();
const page = makePage({ title: 'Real Title', author: '', date: '', body: 'Real article body' });
const rows = await command.func(page, { id: '1234567' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.title).toBe('Real Title');
expect(byField.author).toBe('');
expect(byField.date).toBe('');
expect(byField.body).toBe('Real article body');
expect(byField.url).toBe('https://36kr.com/p/1234567');
});
it('throws CliError NOT_FOUND when the page exposes no title', async () => {
const command = getRegistry().get('36kr/article');
const page = makePage({ title: '', author: 'x', date: 'y', body: 'z' });
await expect(command.func(page, { id: '1234567' })).rejects.toBeInstanceOf(CliError);
});
it('throws CliError PARSE_ERROR when the page exposes title but no body', async () => {
const command = getRegistry().get('36kr/article');
const page = makePage({ title: 'Real Title', author: 'x', date: 'y', body: '' });
await expect(command.func(page, { id: '1234567' })).rejects.toMatchObject({ code: 'PARSE_ERROR' });
});
it('throws CliError INVALID_ARGUMENT when no numeric id can be parsed', async () => {
const command = getRegistry().get('36kr/article');
const page = makePage({});
await expect(command.func(page, { id: 'not-a-url' })).rejects.toBeInstanceOf(CliError);
});
});
+577
View File
@@ -0,0 +1,577 @@
import { readFile, stat } from 'node:fs/promises';
import { htmlToMarkdown as coreHtmlToMarkdown } from '@jackwener/opencli/utils';
import {
ArgumentError,
AuthRequiredError,
CommandExecutionError,
ConfigError,
EmptyResultError,
} from '@jackwener/opencli/errors';
const USER_AGENT = 'opencli-atlassian-adapter (+https://github.com/jackwener/opencli)';
const DEPLOYMENTS = new Set(['cloud', 'datacenter', 'auto']);
function firstEnv(names) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return value;
}
return '';
}
function normalizeBaseUrl(value, label) {
const raw = String(value ?? '').trim();
if (!raw) {
throw new ConfigError(`Missing ${label}`, `Set ${label}, for example https://example.atlassian.net`);
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new ConfigError(`Invalid ${label}: ${raw}`, 'Use an absolute http(s) URL.');
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new ConfigError(`Invalid ${label}: ${raw}`, 'Use an http(s) URL.');
}
parsed.hash = '';
parsed.search = '';
return parsed.toString().replace(/\/+$/, '');
}
function parseDeployment(raw, baseUrl) {
const value = String(raw || 'auto').trim().toLowerCase();
if (!DEPLOYMENTS.has(value)) {
throw new ConfigError('Invalid ATLASSIAN_DEPLOYMENT', 'Expected one of: cloud, datacenter, auto.');
}
if (value !== 'auto') return value;
const host = new URL(baseUrl).hostname;
return host === 'atlassian.net' || host.endsWith('.atlassian.net') ? 'cloud' : 'datacenter';
}
function appendPath(baseUrl, suffix) {
const base = new URL(baseUrl);
const path = base.pathname.replace(/\/+$/, '');
base.pathname = `${path}${suffix}`;
return base.toString().replace(/\/+$/, '');
}
function normalizeConfluenceBaseUrl(baseUrl, deployment) {
if (deployment !== 'cloud') return baseUrl;
const parsed = new URL(baseUrl);
const normalized = parsed.pathname.replace(/\/+$/, '');
if (normalized === '/wiki' || normalized.endsWith('/wiki')) return baseUrl;
return appendPath(baseUrl, '/wiki');
}
function basicAuth(user, token) {
return `Basic ${Buffer.from(`${user}:${token}`, 'utf8').toString('base64')}`;
}
function resolveAuthHeaders(deployment, productLabel) {
const bearer = firstEnv(['ATLASSIAN_BEARER_TOKEN', 'ATLASSIAN_OAUTH_TOKEN']);
if (bearer) return { Authorization: `Bearer ${bearer}` };
const pat = firstEnv(['ATLASSIAN_PAT', `${productLabel.toUpperCase()}_PAT`]);
if (deployment === 'datacenter' && pat) return { Authorization: `Bearer ${pat}` };
const prefix = productLabel.toUpperCase();
const email = firstEnv(['ATLASSIAN_EMAIL', 'ATLASSIAN_USERNAME', `${prefix}_EMAIL`, `${prefix}_USERNAME`]);
const token = firstEnv(['ATLASSIAN_API_TOKEN', 'ATLASSIAN_PASSWORD', `${prefix}_API_TOKEN`, `${prefix}_PASSWORD`]);
if (email && token) return { Authorization: basicAuth(email, token) };
if (deployment === 'cloud') {
throw new ConfigError(
'Missing Atlassian Cloud credentials',
'Set ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN, or set ATLASSIAN_BEARER_TOKEN for OAuth.',
);
}
throw new ConfigError(
'Missing Atlassian Data Center credentials',
'Set ATLASSIAN_PAT, ATLASSIAN_BEARER_TOKEN, or ATLASSIAN_USERNAME plus ATLASSIAN_PASSWORD.',
);
}
export function getJiraConfig() {
const baseUrl = normalizeBaseUrl(firstEnv(['ATLASSIAN_JIRA_BASE_URL', 'JIRA_BASE_URL']), 'ATLASSIAN_JIRA_BASE_URL');
const deployment = parseDeployment(process.env.ATLASSIAN_DEPLOYMENT, baseUrl);
return {
product: 'jira',
baseUrl,
deployment,
authHeaders: resolveAuthHeaders(deployment, 'jira'),
};
}
export function getConfluenceConfig() {
const initialBaseUrl = normalizeBaseUrl(
firstEnv(['ATLASSIAN_CONFLUENCE_BASE_URL', 'CONFLUENCE_BASE_URL']),
'ATLASSIAN_CONFLUENCE_BASE_URL',
);
const deployment = parseDeployment(process.env.ATLASSIAN_DEPLOYMENT, initialBaseUrl);
return {
product: 'confluence',
baseUrl: normalizeConfluenceBaseUrl(initialBaseUrl, deployment),
deployment,
authHeaders: resolveAuthHeaders(deployment, 'confluence'),
};
}
function joinUrl(baseUrl, apiPath) {
if (/^https?:\/\//i.test(apiPath)) return apiPath;
const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
return `${baseUrl}${path}`;
}
function summarizeApiError(parsed, fallback) {
if (parsed && typeof parsed === 'object') {
const messages = [];
if (Array.isArray(parsed.errorMessages)) messages.push(...parsed.errorMessages.filter(Boolean));
if (typeof parsed.message === 'string') messages.push(parsed.message);
if (typeof parsed.error === 'string') messages.push(parsed.error);
if (typeof parsed.reason === 'string') messages.push(parsed.reason);
if (parsed.errors && typeof parsed.errors === 'object') {
for (const [key, value] of Object.entries(parsed.errors)) {
messages.push(`${key}: ${String(value)}`);
}
}
if (messages.length) return messages.join(' · ');
}
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim().slice(0, 300);
return fallback;
}
async function parseResponseBody(resp, label) {
let text;
try {
text = await resp.text();
} catch (err) {
throw new CommandExecutionError(
`${label} response body could not be read: ${err?.message ?? err}`,
'Check whether the Atlassian instance, proxy, or network interrupted the response.',
);
}
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export async function atlassianRequest(config, apiPath, options = {}) {
const method = (options.method ?? 'GET').toUpperCase();
const label = options.label ?? `${config.product} ${method} ${apiPath}`;
const headers = {
'user-agent': USER_AGENT,
accept: 'application/json',
...config.authHeaders,
...(options.headers ?? {}),
};
let body;
if (options.body !== undefined) {
headers['content-type'] = headers['content-type'] ?? 'application/json';
body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
}
let resp;
const url = joinUrl(config.baseUrl, apiPath);
try {
resp = await fetch(url, { method, headers, body });
} catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check the Atlassian base URL, VPN/network access, and proxy settings.',
);
}
const parsed = await parseResponseBody(resp, label);
if (resp.status === 401) {
throw new AuthRequiredError(
config.baseUrl,
`${label} returned HTTP 401`,
'Check Atlassian credentials and whether this instance accepts the configured auth method.',
);
}
if (resp.status === 403) {
throw new AuthRequiredError(
config.baseUrl,
`${label} returned HTTP 403: ${summarizeApiError(parsed, 'forbidden')}`,
'The authenticated user lacks permission for this Jira issue, Confluence page, or space.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `Atlassian returned 404 for ${url}.`);
}
if (resp.status === 409) {
throw new CommandExecutionError(
`${label} returned HTTP 409: ${summarizeApiError(parsed, 'version conflict')}`,
'Reload the current Confluence page version and retry the update.',
);
}
if (resp.status === 429) {
throw new CommandExecutionError(`${label} returned HTTP 429 (rate limited)`, 'Wait and retry with a smaller limit.');
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}: ${summarizeApiError(parsed, resp.statusText)}`);
}
if (typeof parsed === 'string') {
throw new CommandExecutionError(
`${label} returned a non-JSON response`,
'Expected Atlassian REST API JSON. Check the base URL and whether an HTML login, SSO, or proxy page was returned.',
);
}
return parsed;
}
export function queryString(params) {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
for (const item of value) qs.append(key, String(item));
} else {
qs.set(key, String(value));
}
}
const s = qs.toString();
return s ? `?${s}` : '';
}
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`${label} is required`);
return s;
}
export function requirePayloadObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an object.`);
}
return value;
}
export function requirePayloadArray(value, label) {
if (!Array.isArray(value)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array.`);
}
return value;
}
export function requirePayloadString(value, field, label) {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new CommandExecutionError(`${label} did not include a stable ${field}.`);
}
const s = String(value).trim();
if (!s) throw new CommandExecutionError(`${label} did not include a stable ${field}.`);
return s;
}
export function requireNonEmptyRows(rows, label, hint) {
if (!rows.length) throw new EmptyResultError(label, hint);
return rows;
}
export function parseLimit(value, defaultValue = 20, maxValue = 100, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`${label} must be <= ${maxValue}`);
}
return n;
}
export function requireExecute(args, commandName) {
if (args.execute !== true) {
throw new ArgumentError(`${commandName} requires --execute to perform a remote write`);
}
}
export async function readUtf8File(filePath) {
const path = requireString(filePath, '--file');
let fileStat;
try {
fileStat = await stat(path);
} catch {
throw new ArgumentError(`File not found: ${path}`);
}
if (!fileStat.isFile()) {
throw new ArgumentError(`File must be a readable text file: ${path}`);
}
let raw;
try {
raw = await readFile(path);
} catch {
throw new ArgumentError(`File could not be read: ${path}`);
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(raw);
} catch {
throw new ArgumentError(`File could not be decoded as UTF-8 text: ${path}`);
}
}
export function htmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function htmlToMarkdown(html) {
return coreHtmlToMarkdown(String(html ?? ''));
}
function applyAdfMarks(text, marks = []) {
let out = text;
for (const mark of marks) {
const type = mark?.type;
if (type === 'link' && mark.attrs?.href) out = `[${out}](${mark.attrs.href})`;
else if (type === 'strong') out = `**${out}**`;
else if (type === 'em') out = `_${out}_`;
else if (type === 'code') out = `\`${out}\``;
else if (type === 'strike') out = `~~${out}~~`;
}
return out;
}
function renderAdfNode(node, depth = 0) {
if (!node || typeof node !== 'object') return '';
const content = Array.isArray(node.content) ? node.content : [];
const renderChildren = (sep = '') => content.map((child) => renderAdfNode(child, depth)).filter(Boolean).join(sep);
switch (node.type) {
case 'doc':
return content.map((child) => renderAdfNode(child, depth)).filter(Boolean).join('\n\n').trim();
case 'paragraph':
return renderChildren('');
case 'text':
return applyAdfMarks(String(node.text ?? ''), Array.isArray(node.marks) ? node.marks : []);
case 'hardBreak':
return '\n';
case 'heading':
return `${'#'.repeat(Math.max(1, Math.min(6, Number(node.attrs?.level ?? 2))))} ${renderChildren('')}`;
case 'bulletList':
return content.map((child) => renderAdfListItem(child, depth, '-')).join('\n');
case 'orderedList':
return content.map((child, i) => renderAdfListItem(child, depth, `${i + 1}.`)).join('\n');
case 'listItem':
return renderChildren('\n');
case 'codeBlock':
return `\`\`\`\n${renderChildren('')}\n\`\`\``;
case 'blockquote':
return renderChildren('\n').split('\n').map((line) => `> ${line}`).join('\n');
case 'rule':
return '---';
case 'table':
return renderAdfTable(content);
case 'tableRow':
return content.map((cell) => escapeMarkdownTableCell(renderAdfNode(cell, depth))).join(' | ');
case 'tableHeader':
case 'tableCell':
return renderChildren(' ').replace(/\s+/g, ' ').trim();
case 'mention':
return node.attrs?.text ? String(node.attrs.text) : '';
case 'emoji':
return String(node.attrs?.shortName ?? node.attrs?.text ?? '');
case 'inlineCard':
return node.attrs?.url ? String(node.attrs.url) : '';
default:
return renderChildren('');
}
}
function renderAdfListItem(node, depth, marker) {
const indent = ' '.repeat(depth);
const body = renderAdfNode(node, depth + 1).trim();
const lines = body.split('\n');
const [first, ...rest] = lines;
return `${indent}${marker} ${first ?? ''}${rest.length ? `\n${rest.map((line) => `${indent} ${line}`).join('\n')}` : ''}`;
}
function escapeMarkdownTableCell(value) {
return String(value ?? '').replace(/\|/g, '\\|').replace(/\n+/g, '<br>').trim();
}
function renderAdfTable(rows) {
const matrix = rows
.map((row) => {
const cells = Array.isArray(row?.content) ? row.content : [];
return cells.map((cell) => escapeMarkdownTableCell(renderAdfNode(cell)));
})
.filter((row) => row.length > 0);
if (!matrix.length) return '';
const colCount = Math.max(...matrix.map((row) => row.length));
const normalize = (row) => Array.from({ length: colCount }, (_value, index) => row[index] ?? '').join(' | ');
return [
normalize(matrix[0]),
Array.from({ length: colCount }, () => '---').join(' | '),
...matrix.slice(1).map(normalize),
].join('\n');
}
export function adfToMarkdown(value) {
if (!value) return '';
if (typeof value === 'string') return value.trim();
return renderAdfNode(value).trim();
}
function renderInlineMarkdown(value) {
const src = String(value ?? '');
const linkRe = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
let out = '';
let last = 0;
for (const match of src.matchAll(linkRe)) {
out += htmlEscape(src.slice(last, match.index));
out += `<a href="${htmlEscape(match[2])}">${htmlEscape(match[1])}</a>`;
last = match.index + match[0].length;
}
out += htmlEscape(src.slice(last));
return out
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
}
function isMarkdownTable(lines, index) {
return lines[index]?.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1] ?? '');
}
function parseTableRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
}
function renderMarkdownTable(lines, start) {
const rows = [];
let index = start;
rows.push(parseTableRow(lines[index]));
index += 2;
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
rows.push(parseTableRow(lines[index]));
index += 1;
}
const htmlRows = rows.map((row, rowIndex) => {
const tag = rowIndex === 0 ? 'th' : 'td';
return `<tr>${row.map((cell) => `<${tag}>${renderInlineMarkdown(cell)}</${tag}>`).join('')}</tr>`;
}).join('');
return { html: `<table><tbody>${htmlRows}</tbody></table>`, next: index };
}
export function markdownToConfluenceStorage(markdown) {
const lines = String(markdown ?? '').replace(/\r\n/g, '\n').split('\n');
const out = [];
let i = 0;
let inCode = false;
let codeLines = [];
const listStack = [];
const closeOneList = () => {
const current = listStack.pop();
if (!current) return;
if (current.liOpen) out.push('</li>');
out.push(`</${current.tag}>`);
};
const closeListsTo = (indent) => {
while (listStack.length && listStack[listStack.length - 1].indent > indent) closeOneList();
};
const closeAllLists = () => {
while (listStack.length) closeOneList();
};
const openList = (tag, indent) => {
out.push(`<${tag}>`);
listStack.push({ tag, indent, liOpen: false });
};
const renderListItem = (tag, indent, text) => {
closeListsTo(indent);
let current = listStack[listStack.length - 1];
if (current && current.indent === indent && current.tag !== tag) {
closeOneList();
current = listStack[listStack.length - 1];
}
if (!current || current.indent < indent) {
openList(tag, indent);
current = listStack[listStack.length - 1];
}
if (current.indent === indent && current.liOpen) {
out.push('</li>');
current.liOpen = false;
}
out.push(`<li>${renderInlineMarkdown(text)}`);
current.liOpen = true;
};
while (i < lines.length) {
const line = lines[i];
const fence = line.match(/^```/);
if (fence) {
if (inCode) {
out.push(`<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[${codeLines.join('\n')}]]></ac:plain-text-body></ac:structured-macro>`);
codeLines = [];
inCode = false;
} else {
closeAllLists();
inCode = true;
}
i += 1;
continue;
}
if (inCode) {
codeLines.push(line);
i += 1;
continue;
}
if (!line.trim()) {
closeAllLists();
i += 1;
continue;
}
if (isMarkdownTable(lines, i)) {
closeAllLists();
const table = renderMarkdownTable(lines, i);
out.push(table.html);
i = table.next;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
closeAllLists();
out.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
i += 1;
continue;
}
const unordered = line.match(/^(\s*)[-*]\s+(.+)$/);
const ordered = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (unordered || ordered) {
const match = unordered || ordered;
const indent = match[1].replace(/\t/g, ' ').length;
renderListItem(unordered ? 'ul' : 'ol', indent, match[2]);
i += 1;
continue;
}
closeAllLists();
out.push(`<p>${renderInlineMarkdown(line)}</p>`);
i += 1;
}
closeAllLists();
if (inCode) {
out.push(`<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[${codeLines.join('\n')}]]></ac:plain-text-body></ac:structured-macro>`);
}
return out.join('\n');
}
export const __test__ = {
adfToMarkdown,
atlassianRequest,
getConfluenceConfig,
getJiraConfig,
htmlToMarkdown,
markdownToConfluenceStorage,
parseLimit,
queryString,
};
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it, afterEach, vi } from 'vitest';
import { __test__ } from './shared.js';
import { CommandExecutionError } from '@jackwener/opencli/errors';
const ENV_KEYS = [
'ATLASSIAN_CONFLUENCE_BASE_URL',
'ATLASSIAN_DEPLOYMENT',
'ATLASSIAN_EMAIL',
'ATLASSIAN_API_TOKEN',
'ATLASSIAN_PAT',
'ATLASSIAN_JIRA_BASE_URL',
];
function clearEnv() {
for (const key of ENV_KEYS) delete process.env[key];
}
afterEach(() => {
clearEnv();
vi.unstubAllGlobals();
});
describe('atlassian shared helpers', () => {
it('infers Confluence Cloud and appends /wiki', () => {
clearEnv();
process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://example.atlassian.net';
process.env.ATLASSIAN_EMAIL = 'bot@example.com';
process.env.ATLASSIAN_API_TOKEN = 'secret';
const config = __test__.getConfluenceConfig();
expect(config.deployment).toBe('cloud');
expect(config.baseUrl).toBe('https://example.atlassian.net/wiki');
expect(config.authHeaders.Authorization).toMatch(/^Basic /);
});
it('uses Data Center PAT as bearer auth', () => {
clearEnv();
process.env.ATLASSIAN_JIRA_BASE_URL = 'https://jira.example.com';
process.env.ATLASSIAN_DEPLOYMENT = 'datacenter';
process.env.ATLASSIAN_PAT = 'pat-123';
const config = __test__.getJiraConfig();
expect(config.deployment).toBe('datacenter');
expect(config.authHeaders.Authorization).toBe('Bearer pat-123');
});
it('converts Jira ADF to Markdown', () => {
const markdown = __test__.adfToMarkdown({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Broken ', marks: [{ type: 'strong' }] },
{ type: 'text', text: 'checkout', marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] },
],
},
{
type: 'bulletList',
content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'retry payment' }] }] }],
},
],
});
expect(markdown).toContain('**Broken **');
expect(markdown).toContain('[checkout](https://example.com)');
expect(markdown).toContain('- retry payment');
});
it('escapes pipe characters inside ADF table cells', () => {
const markdown = __test__.adfToMarkdown({
type: 'doc',
content: [{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Service' }] }] },
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Notes' }] }] },
],
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'payments' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a | b' }] }] },
],
},
],
}],
});
expect(markdown).toContain('Service | Notes');
expect(markdown).toContain('--- | ---');
expect(markdown).toContain('payments | a \\| b');
});
it('converts nested HTML to Markdown through the shared Turndown converter', () => {
const markdown = __test__.htmlToMarkdown('<ul><li><strong>Root</strong><ul><li>Child</li></ul></li></ul><table><tr><th>A</th></tr><tr><td>B</td></tr></table>');
expect(markdown).toContain('**Root**');
expect(markdown).toContain('Child');
expect(markdown).toContain('A');
expect(markdown).toContain('B');
});
it('converts Markdown to conservative Confluence storage XHTML', () => {
const storage = __test__.markdownToConfluenceStorage([
'# RCA',
'',
'- Impacted checkout',
'',
'| Service | Status |',
'| --- | --- |',
'| payments | fixed |',
].join('\n'));
expect(storage).toContain('<h1>RCA</h1>');
expect(storage).toContain('<ul>');
expect(storage).toContain('<table>');
expect(storage).toContain('<td>fixed</td>');
});
it('preserves nested Markdown lists in Confluence storage XHTML', () => {
const storage = __test__.markdownToConfluenceStorage([
'- Parent',
' - Child',
'- Next',
].join('\n'));
const compact = storage.replace(/\s*\n\s*/g, '');
expect(compact).toContain('<ul><li>Parent<ul><li>Child</li></ul></li><li>Next</li></ul>');
});
it('sends JSON requests with configured auth headers', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const data = await __test__.atlassianRequest({
product: 'jira',
baseUrl: 'https://jira.example.com',
deployment: 'datacenter',
authHeaders: { Authorization: 'Bearer token' },
}, '/rest/api/2/myself', { label: 'jira myself' });
expect(data).toEqual({ ok: true });
expect(fetchMock.mock.calls[0][0]).toBe('https://jira.example.com/rest/api/2/myself');
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer token');
});
it('maps auth and rate-limit responses to typed errors', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ message: 'bad token' }), { status: 401 })));
await expect(__test__.atlassianRequest({
product: 'jira',
baseUrl: 'https://jira.example.com',
deployment: 'datacenter',
authHeaders: { Authorization: 'Bearer token' },
}, '/rest/api/2/myself', { label: 'jira myself' })).rejects.toMatchObject({ code: 'AUTH_REQUIRED' });
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ message: 'slow down' }), { status: 429 })));
await expect(__test__.atlassianRequest({
product: 'jira',
baseUrl: 'https://jira.example.com',
deployment: 'datacenter',
authHeaders: { Authorization: 'Bearer token' },
}, '/rest/api/2/myself', { label: 'jira myself' })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
});
it('fails typed when a successful Atlassian REST response is not JSON', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>login</html>', { status: 200, headers: { 'content-type': 'text/html' } })));
await expect(__test__.atlassianRequest({
product: 'jira',
baseUrl: 'https://jira.example.com',
deployment: 'datacenter',
authHeaders: { Authorization: 'Bearer token' },
}, '/rest/api/2/myself', { label: 'jira myself' })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
+117
View File
@@ -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),
);
},
});
}
+98
View File
@@ -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');
});
});
+53
View File
@@ -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);
},
});
+318
View File
@@ -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)',
},
];
+172
View File
@@ -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);
});
});
+341
View File
@@ -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' }];
},
});
+60
View File
@@ -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.',
);
},
});
+26
View File
@@ -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 }));
},
});
+52
View File
@@ -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
View File
@@ -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)'}`,
);
},
});
+33
View File
@@ -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.',
'',
);
},
});
+366
View File
@@ -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;
},
});
+20
View File
@@ -41,6 +41,26 @@ describe('apple-podcasts search command', () => {
}),
]);
});
it('emits empty-string for missing trackCount and primaryGenreName instead of a sentinel', async () => {
const cmd = getRegistry().get('apple-podcasts/search');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
results: [
{
collectionId: 99,
collectionName: 'No-Meta Show',
artistName: 'Anon Host',
collectionViewUrl: 'https://example.com/p/99',
},
],
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await cmd.func({ query: 'no-meta', limit: 1 });
expect(result[0].episodes).toBe('');
expect(result[0].genre).toBe('');
});
});
describe('apple-podcasts top command', () => {
beforeEach(() => {
+2 -2
View File
@@ -23,8 +23,8 @@ cli({
id: p.collectionId,
title: p.collectionName,
author: p.artistName,
episodes: p.trackCount ?? '-',
genre: p.primaryGenreName ?? '-',
episodes: p.trackCount ?? '',
genre: p.primaryGenreName ?? '',
url: p.collectionViewUrl || '',
}));
},
+62
View File
@@ -0,0 +1,62 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
async function hasBandSessionCookie(page) {
const cookies = await page.getCookies({ url: 'https://www.band.us' });
return cookies.some(c => c.name === 'band_session' && c.value);
}
async function verifyBandIdentity(page) {
if (!await hasBandSessionCookie(page)) {
throw new AuthRequiredError('band.us', 'Band band_session cookie missing');
}
await page.goto('https://www.band.us/feed');
await page.wait(2);
const probe = await page.evaluate(`
(() => {
if (/auth\\.band\\.us\\/login/.test(location.href)) {
return { kind: 'auth', detail: 'Band /feed redirected to auth login' };
}
let userId = '';
try {
const stack = [window.__INITIAL_STATE__, window.__BAND_STORE__].filter(Boolean);
const seen = new Set();
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object' || seen.has(node)) continue;
seen.add(node);
if (Array.isArray(node)) { stack.push(...node); continue; }
const u = node.user || node.me || node.currentUser;
if (u && (u.user_no || u.user_id || u.userId || u.id)) {
userId = String(u.user_no || u.user_id || u.userId || u.id);
break;
}
for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
}
} catch {}
if (!userId) {
const el = document.querySelector('[data-user-no], [data-user_no]');
userId = el?.getAttribute('data-user-no') || el?.getAttribute('data-user_no') || '';
}
return { ok: true, user_id: userId };
})()
`);
if (probe?.kind === 'auth') throw new AuthRequiredError('band.us', probe.detail);
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Band probe: ${JSON.stringify(probe)}`);
return { user_id: probe.user_id };
}
registerSiteAuthCommands({
site: 'band',
domain: 'band.us',
loginUrl: 'https://auth.band.us/login',
columns: ['user_id'],
quickCheck: hasBandSessionCookie,
verify: verifyBandIdentity,
poll: async (page) => {
if (!await hasBandSessionCookie(page)) {
throw new AuthRequiredError('band.us', 'Waiting for Band band_session cookie');
}
return verifyBandIdentity(page);
},
});
+144 -56
View File
@@ -4,6 +4,47 @@
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const DEFAULT_LIMIT = 10;
const MIN_LIMIT = 1;
const MAX_LIMIT = 100;
function normalizeSymbol(value) {
const symbol = String(value ?? '').trim().toUpperCase();
if (!symbol) throw new ArgumentError('symbol is required');
return symbol;
}
function normalizeExpiration(value) {
const expiration = String(value ?? '').trim();
if (!expiration) return '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(expiration)) {
throw new ArgumentError('--expiration must use YYYY-MM-DD format');
}
const parsed = new Date(`${expiration}T00:00:00Z`);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== expiration) {
throw new ArgumentError('--expiration must be a valid calendar date');
}
return expiration;
}
function parseLimit(value) {
if (value === undefined || value === null || value === '') return DEFAULT_LIMIT;
const limit = Number(value);
if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`);
}
return limit;
}
function unwrapBrowserResult(value) {
if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
return value.data;
}
return value;
}
cli({
site: 'barchart',
name: 'greeks',
@@ -14,19 +55,19 @@ cli({
args: [
{ name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
{ name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' },
{ name: 'limit', type: 'int', default: 10, help: 'Number of near-the-money strikes per type' },
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Number of near-the-money strikes per type (1-100)' },
],
columns: [
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
'volume', 'openInterest', 'expiration',
],
func: async (page, kwargs) => {
const symbol = kwargs.symbol.toUpperCase().trim();
const expiration = kwargs.expiration ?? '';
const limit = kwargs.limit ?? 10;
const symbol = normalizeSymbol(kwargs.symbol);
const expiration = normalizeExpiration(kwargs.expiration);
const limit = parseLimit(kwargs.limit);
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
await page.wait(4);
const data = await page.evaluate(`
const data = unwrapBrowserResult(await page.evaluate(`
(async () => {
const sym = ${JSON.stringify(symbol)};
const expDate = ${JSON.stringify(expiration)};
@@ -45,39 +86,53 @@ cli({
+ '&fields=' + fields + '&raw=1';
if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate);
const resp = await fetch(url, { credentials: 'include', headers });
if (resp.ok) {
const d = await resp.json();
let items = d?.data || [];
if (!resp.ok) {
return { ok: false, reason: 'http', status: resp.status, statusText: resp.statusText || '' };
}
if (!expDate) {
const expirations = items
.map(i => (i.raw || i).expirationDate || null)
.filter(Boolean)
.sort((a, b) => {
const aTime = Date.parse(a);
const bTime = Date.parse(b);
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
if (Number.isNaN(aTime)) return 1;
if (Number.isNaN(bTime)) return -1;
return aTime - bTime;
});
const nearestExpiration = expirations[0];
if (nearestExpiration) {
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
}
const d = await resp.json();
const allItems = d?.data;
if (!Array.isArray(allItems)) {
return { ok: false, reason: 'malformed' };
}
let items = allItems;
if (!expDate) {
const expirations = items
.map(i => (i.raw || i).expirationDate || null)
.filter(Boolean)
.sort((a, b) => {
const aTime = Date.parse(a);
const bTime = Date.parse(b);
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
if (Number.isNaN(aTime)) return 1;
if (Number.isNaN(bTime)) return -1;
return aTime - bTime;
});
const nearestExpiration = expirations[0];
if (nearestExpiration) {
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
}
}
// Separate calls and puts, sort by distance from current price
const calls = items
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
.slice(0, limit);
const puts = items
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
.slice(0, limit);
// Separate calls and puts, sort by distance from current price.
const calls = items
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
.slice(0, limit);
const puts = items
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
.slice(0, limit);
const selected = [...calls, ...puts];
return [...calls, ...puts].map(i => {
if (items.length > 0 && selected.length === 0) {
return { ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' };
}
return {
ok: true,
rows: selected.map(i => {
const r = i.raw || i;
return {
type: r.optionType,
@@ -93,28 +148,61 @@ cli({
openInterest: r.openInterest,
expiration: r.expirationDate,
};
});
}
} catch(e) {}
return [];
})
};
} catch(e) {
return { ok: false, reason: 'exception', message: e?.message || String(e) };
}
})()
`);
if (!data || !Array.isArray(data))
return [];
return data.map(r => ({
type: r.type || '',
strike: r.strike,
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
volume: r.volume,
openInterest: r.openInterest,
expiration: r.expiration ?? null,
}));
`));
if (!data || data.ok !== true) {
if (data?.reason === 'http') {
throw new CommandExecutionError(`Barchart greeks request failed: HTTP ${data.status}${data.statusText ? ` ${data.statusText}` : ''}`);
}
if (data?.reason === 'malformed') {
throw new CommandExecutionError(`Barchart greeks returned an unreadable options payload${data.message ? `: ${data.message}` : ''}`);
}
if (data?.reason === 'exception') {
throw new CommandExecutionError(`Barchart greeks request failed: ${data.message || 'unknown error'}`);
}
throw new CommandExecutionError(`Failed to fetch Barchart greeks for ${symbol}`);
}
if (!Array.isArray(data.rows)) {
throw new CommandExecutionError('Barchart greeks returned an unreadable options payload');
}
if (data.rows.length === 0) {
throw new EmptyResultError('barchart greeks', `No option greeks were returned for ${symbol}. Confirm the symbol, expiration, and Barchart login state.`);
}
return data.rows.map(r => {
if (!r || typeof r !== 'object' || Array.isArray(r)) {
throw new CommandExecutionError('Barchart greeks returned a malformed option row');
}
const type = String(r.type || '').trim();
const expirationValue = String(r.expiration || '').trim();
if (!/^(call|put)$/i.test(type) || r.strike === undefined || r.strike === null || r.strike === '' || !expirationValue) {
throw new CommandExecutionError('Barchart greeks returned a malformed option row identity');
}
return {
type,
strike: r.strike,
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
volume: r.volume,
openInterest: r.openInterest,
expiration: expirationValue,
};
});
},
});
export const __test__ = {
normalizeSymbol,
normalizeExpiration,
parseLimit,
unwrapBrowserResult,
};
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './greeks.js';
const { normalizeExpiration, normalizeSymbol, parseLimit, unwrapBrowserResult } = await import('./greeks.js').then((m) => m.__test__);
function makePage(evaluateResult) {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(evaluateResult),
};
}
describe('barchart greeks command', () => {
const command = getRegistry().get('barchart/greeks');
it('registers with the expected shape', () => {
expect(command).toBeDefined();
expect(command.access).toBe('read');
expect(command.browser).toBe(true);
expect(command.columns).toEqual([
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
'volume', 'openInterest', 'expiration',
]);
});
it('maps returned option rows without changing the declared output shape', async () => {
const page = makePage({
session: 'site:barchart',
data: {
ok: true,
rows: [
{
type: 'Call',
strike: 190,
last: 3.456,
iv: 21.234,
delta: 0.56789,
gamma: 0.01234,
theta: -0.12345,
vega: 0.23456,
rho: 0.03456,
volume: 123,
openInterest: 456,
expiration: '2026-06-19',
},
],
},
});
const rows = await command.func(page, { symbol: 'aapl', limit: 1 });
expect(page.goto).toHaveBeenCalledWith('https://www.barchart.com/stocks/quotes/AAPL/options');
expect(page.wait).toHaveBeenCalledWith(4);
expect(rows).toEqual([
{
type: 'Call',
strike: 190,
last: 3.46,
iv: '21.23%',
delta: 0.5679,
gamma: 0.0123,
theta: -0.1235,
vega: 0.2346,
rho: 0.0346,
volume: 123,
openInterest: 456,
expiration: '2026-06-19',
},
]);
});
it('validates args before browser navigation and unwraps bridge envelopes', async () => {
expect(normalizeSymbol(' aapl ')).toBe('AAPL');
expect(normalizeExpiration('2026-06-19')).toBe('2026-06-19');
expect(parseLimit(undefined)).toBe(10);
expect(parseLimit(100)).toBe(100);
expect(unwrapBrowserResult({ session: 'site:barchart', data: { ok: true } })).toEqual({ ok: true });
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: '', limit: 1 }))
.rejects.toBeInstanceOf(ArgumentError);
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', expiration: '2026-02-30', limit: 1 }))
.rejects.toBeInstanceOf(ArgumentError);
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', limit: 101 }))
.rejects.toBeInstanceOf(ArgumentError);
});
it('embeds expiration and limit in the browser-side request script', async () => {
const page = makePage({
ok: true,
rows: [{
type: 'Put',
strike: 185,
last: null,
iv: null,
delta: null,
gamma: null,
theta: null,
vega: null,
rho: null,
volume: 0,
openInterest: 0,
expiration: '2026-07-17',
}],
});
await command.func(page, { symbol: 'MSFT', expiration: '2026-07-17', limit: 7 });
const script = page.evaluate.mock.calls[0][0];
expect(script).toContain('const expDate = "2026-07-17"');
expect(script).toContain('const limit = 7');
expect(script).toContain("url += '&expirationDate=' + encodeURIComponent(expDate)");
});
it('throws CommandExecutionError for HTTP, malformed, exception, and missing payload states', async () => {
await expect(command.func(makePage({ ok: false, reason: 'http', status: 403, statusText: 'Forbidden' }), { symbol: 'AAPL' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ ok: false, reason: 'malformed' }), { symbol: 'AAPL' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ ok: false, reason: 'exception', message: 'network down' }), { symbol: 'AAPL' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' }), { symbol: 'AAPL' }))
.rejects.toThrow('call or put identities');
await expect(command.func(makePage(null), { symbol: 'AAPL' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ ok: true, rows: 'bad' }), { symbol: 'AAPL' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ ok: true, rows: [{ type: 'Call', strike: null, expiration: '' }] }), { symbol: 'AAPL' }))
.rejects.toThrow('malformed option row identity');
});
it('throws EmptyResultError when Barchart returns no greeks rows', async () => {
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL' }))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
+36
View File
@@ -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);
},
});
+125
View File
@@ -0,0 +1,125 @@
/**
* Bilibili comment — posts a top-level comment or a reply on a video via the official API.
* Uses /x/v2/reply/add, authenticated by the logged-in cookie + bili_jct CSRF token.
* @username mentions in the message are resolved to real mentions (at_name_to_mid).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { apiGet, apiPost, resolveBvid, resolveUid } from './utils.js';
function isAuthLikeBilibiliError(code, message) {
return code === -101 || code === -111 || code === -403 || /csrf|登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
}
function requireOkPayload(payload, label) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
}
if (payload.code !== 0) {
const message = payload.message ?? 'unknown error';
if (isAuthLikeBilibiliError(payload.code, message)) {
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
}
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
}
return payload.data;
}
function readPositiveInteger(value, label) {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`bilibili comment ${label} must be a positive integer`);
}
return n;
}
cli({
site: 'bilibili',
name: 'comment',
access: 'write',
description: '在 B站视频下发表评论或回复(官方 API,需登录;消息里的 @用户 会被解析为真实提及)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
{ name: 'message', required: true, positional: true, help: 'Comment text. Any @username in it is resolved to a real mention' },
{ name: 'parent', type: 'int', help: 'top-level/root rpid to reply under (omit for a top-level comment)' },
{ name: 'execute', type: 'boolean', help: 'Actually post the comment. Without it the command refuses to write.' },
],
columns: ['rpid', 'bvid', 'oid', 'message', 'url'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili comment');
}
const message = String(kwargs.message ?? '').trim();
if (!message)
throw new ArgumentError('bilibili comment message cannot be empty');
// Write guard: posting is public and irreversible-ish, so require an explicit opt-in.
if (!kwargs.execute)
throw new ArgumentError('Refusing to post: pass --execute to actually publish this comment');
const parent = kwargs.parent != null ? readPositiveInteger(kwargs.parent, 'parent') : null;
let bvid;
try {
bvid = await resolveBvid(kwargs.bvid);
}
catch (error) {
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
}
// Resolve bvid → aid (the reply API addresses videos by aid, as `oid`)
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const viewData = requireOkPayload(view, 'view');
const oid = viewData?.aid;
if (!oid)
throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
// Resolve @username mentions to uids. Bilibili only turns "@name" into a real
// mention — one that notifies the mentioned user — when the request carries
// at_name_to_mid; a plain-text "@name" is otherwise inert and notifies nobody.
/** @type {Record<string, number>} */
const atNameToMid = {};
for (const match of message.matchAll(/@([^\s@]+)/g)) {
const name = match[1];
if (name in atNameToMid)
continue;
try {
const mid = Number(await resolveUid(page, name));
if (!Number.isInteger(mid) || mid <= 0) {
throw new CommandExecutionError(`Bilibili user search returned malformed mid for @${name}`);
}
atNameToMid[name] = mid;
}
catch (error) {
if (!(error instanceof EmptyResultError)) {
throw error;
}
// Unresolvable @name (typo, or not a user) — leave it as plain text.
}
}
// For a reply, Bilibili needs both `root` (top-level comment) and `parent`.
// Replying to a top-level comment means root === parent.
const params = {
oid,
type: 1,
message,
plat: 1,
...(parent != null
? { root: parent, parent }
: {}),
...(Object.keys(atNameToMid).length > 0
? { at_name_to_mid: JSON.stringify(atNameToMid) }
: {}),
};
const payload = await apiPost(page, '/x/v2/reply/add', { params });
const postData = requireOkPayload(payload, 'reply add');
const rpid = postData?.rpid;
if (!rpid) {
throw new CommandExecutionError('Bilibili reply add API did not return rpid for the posted comment');
}
return [{
rpid: String(rpid),
bvid,
oid: String(oid),
message,
url: `https://www.bilibili.com/video/${bvid}#reply${rpid}`,
}];
},
});
+153
View File
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet, mockApiPost, mockResolveUid } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
mockApiPost: vi.fn(),
mockResolveUid: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiGet: mockApiGet,
apiPost: mockApiPost,
resolveUid: mockResolveUid,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './comment.js';
describe('bilibili comment', () => {
const command = getRegistry().get('bilibili/comment');
beforeEach(() => {
mockApiGet.mockReset();
mockApiPost.mockReset();
mockResolveUid.mockReset();
});
it('refuses to post without --execute', async () => {
await expect(
command.func({}, { bvid: 'BV1WtAGzYEBm', message: 'hi' }),
).rejects.toThrow(/--execute/);
expect(mockApiPost).not.toHaveBeenCalled();
});
it('rejects an empty message before calling the API', async () => {
await expect(
command.func({}, { bvid: 'BV1xxx', message: ' ', execute: true }),
).rejects.toThrow(/empty/i);
expect(mockApiGet).not.toHaveBeenCalled();
});
it('posts a top-level comment, resolving @mentions to at_name_to_mid', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } });
mockResolveUid.mockResolvedValueOnce('1141159409'); // @AI视频小助理 → mid
mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 99887766 } });
const result = await command.func({}, {
bvid: 'BV1WtAGzYEBm', message: '@AI视频小助理 总结一下', execute: true,
});
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
expect(mockResolveUid).toHaveBeenCalledWith({}, 'AI视频小助理');
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
params: {
oid: 12345,
type: 1,
message: '@AI视频小助理 总结一下',
plat: 1,
at_name_to_mid: '{"AI视频小助理":1141159409}',
},
});
expect(result).toEqual([{
rpid: '99887766',
bvid: 'BV1WtAGzYEBm',
oid: '12345',
message: '@AI视频小助理 总结一下',
url: 'https://www.bilibili.com/video/BV1WtAGzYEBm#reply99887766',
}]);
});
it('still posts when an @mention cannot be resolved, leaving it as plain text', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
mockResolveUid.mockRejectedValueOnce(new EmptyResultError('bilibili user search'));
mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 5 } });
await command.func({}, { bvid: 'BV1xxx', message: '@幽灵用户zzz hi', execute: true });
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
params: { oid: 7, type: 1, message: '@幽灵用户zzz hi', plat: 1 },
});
});
it('fails closed when mention resolution has parser or transport errors', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
mockResolveUid.mockRejectedValueOnce(new CommandExecutionError('search API drift'));
await expect(
command.func({}, { bvid: 'BV1xxx', message: '@用户 hi', execute: true }),
).rejects.toBeInstanceOf(CommandExecutionError);
expect(mockApiPost).not.toHaveBeenCalled();
});
it('fails closed when mention resolution returns a malformed mid', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
mockResolveUid.mockResolvedValueOnce('not-a-mid');
await expect(
command.func({}, { bvid: 'BV1xxx', message: '@用户 hi', execute: true }),
).rejects.toBeInstanceOf(CommandExecutionError);
expect(mockApiPost).not.toHaveBeenCalled();
});
it('posts a reply under an existing comment when --parent is given', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 1 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 2 } });
await command.func({}, { bvid: 'BV1xxx', message: 'thanks', parent: 555, execute: true });
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
params: { oid: 1, type: 1, message: 'thanks', plat: 1, root: 555, parent: 555 },
});
});
it('throws when the bvid cannot be resolved to an aid', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} });
await expect(
command.func({}, { bvid: 'BVbroken', message: 'hi', execute: true }),
).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws with the API code and message when Bilibili rejects the comment', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
mockApiPost.mockResolvedValueOnce({ code: 12025, message: '评论字数过多' });
await expect(
command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
).rejects.toBeInstanceOf(CommandExecutionError);
});
it('maps login/csrf failures from the write API to AuthRequiredError', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
mockApiPost.mockResolvedValueOnce({ code: -111, message: 'csrf 校验失败' });
await expect(
command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
).rejects.toBeInstanceOf(AuthRequiredError);
});
it('rejects invalid parent ids before posting', async () => {
await expect(
command.func({}, { bvid: 'BV1xxx', message: 'x', parent: 0, execute: true }),
).rejects.toBeInstanceOf(ArgumentError);
expect(mockApiGet).not.toHaveBeenCalled();
expect(mockApiPost).not.toHaveBeenCalled();
});
it('fails closed when the write API omits rpid', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
await expect(
command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
).rejects.toBeInstanceOf(CommandExecutionError);
});
});
+116 -21
View File
@@ -1,41 +1,136 @@
/**
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
* Bilibili comments — fetches comments via the official API.
* Top-level comments come from /x/v2/reply/main (WBI-signed); with --parent,
* the replies nested under a given comment come from /x/v2/reply/reply.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
const MAX_LIMIT = 50;
function isAuthLikeBilibiliError(code, message) {
return code === -101 || code === -403 || /登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
}
function parseLimit(value) {
const raw = value == null ? 20 : value;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
throw new ArgumentError(`bilibili comments limit must be an integer between 1 and ${MAX_LIMIT}`);
}
return limit;
}
function parseParent(value) {
if (value == null) {
return null;
}
const parent = Number(value);
if (!Number.isInteger(parent) || parent <= 0) {
throw new ArgumentError('bilibili comments parent must be a positive integer rpid');
}
return parent;
}
function requireOkPayload(payload, label) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
}
if (payload.code !== 0) {
const message = payload.message ?? 'unknown error';
if (isAuthLikeBilibiliError(payload.code, message)) {
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
}
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
}
return payload.data;
}
function requireReplies(data, label) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new CommandExecutionError(`Bilibili ${label} API returned malformed data`);
}
if (!Object.hasOwn(data, 'replies')) {
throw new CommandExecutionError(`Bilibili ${label} API did not return replies`);
}
if (data.replies === null) {
return [];
}
if (!Array.isArray(data.replies)) {
throw new CommandExecutionError(`Bilibili ${label} API returned malformed replies`);
}
return data.replies;
}
function formatReplyRow(reply, index) {
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) {
throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was malformed`);
}
const rpid = String(reply.rpid ?? '').trim();
if (!rpid) {
throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was missing rpid`);
}
const ctime = Number(reply.ctime);
if (!Number.isFinite(ctime)) {
throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was missing ctime`);
}
return {
rank: index + 1,
rpid,
author: String(reply.member?.uname ?? ''),
text: String(reply.content?.message ?? '').replace(/\n/g, ' ').trim(),
likes: reply.like ?? 0,
replies: reply.rcount ?? 0,
time: new Date(ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
};
}
cli({
site: 'bilibili',
name: 'comments',
access: 'read',
description: '获取 B站视频评论(使用官方 API + WBI 签名',
description: '获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
{ name: 'parent', type: 'int', help: 'rpid of a comment — fetch the replies under it instead of top-level comments' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
],
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
columns: ['rank', 'rpid', 'author', 'text', 'likes', 'replies', 'time'],
func: async (page, kwargs) => {
const bvid = await resolveBvid(kwargs.bvid);
const limit = Math.min(Number(kwargs.limit) || 20, 50);
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili comments');
}
let bvid;
try {
bvid = await resolveBvid(kwargs.bvid);
}
catch (error) {
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
}
const limit = parseLimit(kwargs.limit);
const parent = parseParent(kwargs.parent);
// Resolve bvid → aid (required by reply API)
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const aid = view?.data?.aid;
const viewData = requireOkPayload(view, 'view');
const aid = viewData?.aid;
if (!aid)
throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
const payload = await apiGet(page, '/x/v2/reply/main', {
params: { oid: aid, type: 1, mode: 3, ps: limit },
signed: true,
});
const replies = payload?.data?.replies ?? [];
return replies.slice(0, limit).map((r, i) => ({
rank: i + 1,
author: r.member?.uname ?? '',
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
likes: r.like ?? 0,
replies: r.rcount ?? 0,
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
}));
throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
const payload = parent != null
? await apiGet(page, '/x/v2/reply/reply', {
params: { oid: aid, type: 1, root: parent, pn: 1, ps: limit },
})
: await apiGet(page, '/x/v2/reply/main', {
params: { oid: aid, type: 1, mode: 3, ps: limit },
signed: true,
});
const label = parent != null ? 'reply thread' : 'reply main';
const replies = requireReplies(requireOkPayload(payload, label), label);
if (replies.length === 0) {
throw new EmptyResultError(parent != null ? `bilibili comment replies: ${parent}` : `bilibili comments: ${bvid}`);
}
return replies.slice(0, limit).map(formatReplyRow);
},
});
+80 -21
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
@@ -15,11 +16,13 @@ describe('bilibili comments', () => {
});
it('resolves bvid to aid and fetches replies', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
.mockResolvedValueOnce({
code: 0,
data: {
replies: [
{
rpid: 777,
member: { uname: 'Alice' },
content: { message: 'Great video!' },
like: 42,
@@ -38,6 +41,7 @@ describe('bilibili comments', () => {
expect(result).toEqual([
{
rank: 1,
rpid: '777',
author: 'Alice',
text: 'Great video!',
likes: 42,
@@ -46,38 +50,93 @@ describe('bilibili comments', () => {
},
]);
});
it('throws when aid cannot be resolved', async () => {
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toThrow('Cannot resolve aid for bvid: BVinvalid123');
});
it('returns empty array when replies is missing', async () => {
it('fetches replies under a comment via /x/v2/reply/reply when --parent is given', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 99 } })
.mockResolvedValueOnce({ data: {} }); // no replies key
const result = await command.func({}, { bvid: 'BV1xxx', limit: 5 });
expect(result).toEqual([]);
});
it('caps limit at 50', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 1 } })
.mockResolvedValueOnce({ data: { replies: [] } });
await command.func({}, { bvid: 'BV1xxx', limit: 999 });
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
params: { oid: 1, type: 1, mode: 3, ps: 50 },
signed: true,
.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
.mockResolvedValueOnce({
code: 0,
data: {
replies: [
{
rpid: 888,
member: { uname: 'AI视频小助理' },
content: { message: '视频总结:作者开了一家咖啡馆' },
like: 8,
rcount: 0,
ctime: 1700000000,
},
],
},
});
const result = await command.func({}, { bvid: 'BV1WtAGzYEBm', parent: 777, limit: 5 });
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/reply', {
params: { oid: 12345, type: 1, root: 777, pn: 1, ps: 5 },
});
expect(result[0].author).toBe('AI视频小助理');
expect(result[0].rpid).toBe('888');
expect(result[0].text).toBe('视频总结:作者开了一家咖啡馆');
});
it('throws when aid cannot be resolved', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} }); // no aid
await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when replies is missing', async () => {
mockApiGet
.mockResolvedValueOnce({ code: 0, data: { aid: 99 } })
.mockResolvedValueOnce({ code: 0, data: {} }); // no replies key
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('rejects out-of-range limits instead of silently clamping', async () => {
await expect(command.func({}, { bvid: 'BV1xxx', limit: 999 }))
.rejects.toBeInstanceOf(ArgumentError);
expect(mockApiGet).not.toHaveBeenCalled();
});
it('rejects invalid parent ids before fetching comments', async () => {
await expect(command.func({}, { bvid: 'BV1xxx', parent: 0, limit: 5 }))
.rejects.toBeInstanceOf(ArgumentError);
expect(mockApiGet).not.toHaveBeenCalled();
});
it('maps auth-like API errors to AuthRequiredError', async () => {
mockApiGet
.mockResolvedValueOnce({ code: -101, message: '账号未登录', data: null });
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
.rejects.toBeInstanceOf(AuthRequiredError);
});
it('throws EmptyResultError for explicit empty comments', async () => {
mockApiGet
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
.mockResolvedValueOnce({ code: 0, data: { replies: [] } });
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
.rejects.toBeInstanceOf(EmptyResultError);
});
it('collapses newlines in comment text', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 1 } })
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
.mockResolvedValueOnce({
code: 0,
data: {
replies: [
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
{ rpid: 123, member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
],
},
});
const result = (await command.func({}, { bvid: 'BV1xxx', limit: 5 }));
expect(result[0].text).toBe('line1 line2 line3');
});
it('throws CommandExecutionError when a comment row lacks rpid', async () => {
mockApiGet
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
.mockResolvedValueOnce({
code: 0,
data: {
replies: [
{ member: { uname: 'Bob' }, content: { message: 'hi' }, like: 0, rcount: 0, ctime: 0 },
],
},
});
await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
});
+77 -32
View File
@@ -1,11 +1,12 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
cli({
site: 'bilibili',
name: 'subtitle',
access: 'read',
description: '获取 Bilibili 视频的字幕',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Bilibili 视频 BV ID(如 BV1xx411c7mD),或视频 URL / b23.tv 短链' },
@@ -16,53 +17,78 @@ cli({
if (!page)
throw new CommandExecutionError('Browser session required for bilibili subtitle');
const bvid = await resolveBvid(kwargs.bvid);
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
// 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
const cid = await page.evaluate(`(async () => {
const state = window.__INITIAL_STATE__ || {};
return state?.videoData?.cid;
})()`);
if (!cid) {
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
// 1. 通过 view API 拿 cid。
// 以前的实现走 page.goto(/video/<bvid>) + window.__INITIAL_STATE__.videoData.cid
// bangumi 绑定的 bvid(番剧/纪录片/电影/综艺)页面 state 不在 videoData 而在 epList
// 导致 SELECTOR 错。view API 接受任何 bvidUGC + PGC 都通),且不依赖 DOM 结构。
let view;
try {
view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
}
catch (err) {
throw new CommandExecutionError(`获取视频信息失败: ${err?.message || err}`);
}
if (view?.code !== 0) {
throw new CommandExecutionError(`获取视频信息失败: ${view?.message ?? 'unknown'} (${view?.code})`);
}
const cid = view?.data?.cid;
if (!cid) {
throw new CommandExecutionError(`无法从 view API 拿到 cid (bvid=${bvid})`);
}
// 2. 用带 Wbi 签名的 player/v2 拿字幕列表(之前 evaluate 里 fetch 因为没签名会 403
let payload;
try {
payload = await apiGet(page, '/x/player/wbi/v2', {
params: { bvid, cid },
signed: true,
});
}
catch (err) {
throw new CommandExecutionError(`获取视频播放信息失败: ${err?.message || err}`);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError('获取到的视频播放信息对象不符合预期格式');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
const payload = await apiGet(page, '/x/player/wbi/v2', {
params: { bvid, cid },
signed: true, // 开启 wbi_sign 自动签名
});
if (payload.code !== 0) {
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
}
const needLoginSubtitle = payload.data?.need_login_subtitle === true;
const subtitles = payload.data?.subtitle?.subtitles || [];
const subtitles = payload.data?.subtitle?.subtitles;
if (!Array.isArray(subtitles)) {
throw new CommandExecutionError('获取到的字幕列表对象不符合数组格式');
}
if (subtitles.length === 0) {
if (needLoginSubtitle) {
throw new AuthRequiredError('bilibili.com', 'Bilibili subtitles are hidden behind login for this video. Please log in to bilibili.com in Chrome and retry.');
}
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
}
// 4. 选择目标字幕语言
// 3. 选择目标字幕语言
const target = kwargs.lang
? subtitles.find((s) => s.lan === kwargs.lang) || subtitles[0]
: subtitles[0];
const targetSubUrl = target.subtitle_url;
if (!targetSubUrl || targetSubUrl === '') {
if (!target || typeof target !== 'object' || !Object.hasOwn(target, 'subtitle_url')) {
throw new CommandExecutionError('字幕条目缺少 subtitle_url 字段');
}
const targetSubUrl = typeof target.subtitle_url === 'string' ? target.subtitle_url.trim() : '';
if (!targetSubUrl) {
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
}
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
// 5. 解析并拉取 CDN 的 JSON 文件
if (!/^https?:\/\//i.test(finalUrl)) {
throw new CommandExecutionError(`字幕 URL 非法: ${finalUrl}`);
}
// 4. 解析并拉取 CDN 的 JSON 文件
const fetchJs = `
(async () => {
const url = ${JSON.stringify(finalUrl)};
const res = await fetch(url);
const text = await res.text();
if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
return { error: 'HTML', text: text.substring(0, 100), url };
}
try {
const subJson = JSON.parse(text);
// B站真实返回格式是 { font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", type: "json" , body: [{from: 0, to: 0, content: ""}] }
@@ -74,20 +100,39 @@ cli({
}
})()
`;
const items = await page.evaluate(fetchJs);
let items;
try {
items = await page.evaluate(fetchJs);
}
catch (err) {
throw new CommandExecutionError(`字幕获取失败: ${err?.message || err}`);
}
if (items?.error) {
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
}
const finalItems = items?.data || [];
if (!items || typeof items !== 'object' || items.success !== true) {
throw new CommandExecutionError('字幕获取结果对象不符合预期格式');
}
const finalItems = items.data;
if (!Array.isArray(finalItems)) {
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
}
// 6. 数据映射
return finalItems.map((item, idx) => ({
index: idx + 1,
from: Number(item.from || 0).toFixed(2) + 's',
to: Number(item.to || 0).toFixed(2) + 's',
content: item.content
}));
if (finalItems.length === 0) {
throw new EmptyResultError('bilibili subtitle', '字幕文件中没有字幕片段。');
}
// 5. 数据映射
return finalItems.map((item, idx) => {
const from = Number(item?.from);
const to = Number(item?.to);
if (!item || typeof item !== 'object' || !Number.isFinite(from) || !Number.isFinite(to)) {
throw new CommandExecutionError('字幕片段缺少有效 from/to 时间戳');
}
return {
index: idx + 1,
from: from.toFixed(2) + 's',
to: to.toFixed(2) + 's',
content: String(item.content ?? '')
};
});
},
});
+156 -9
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
@@ -20,30 +20,177 @@ describe('bilibili subtitle', () => {
page.goto.mockClear();
page.evaluate.mockReset();
});
// 帮助函数:第一发 apiGetview)固定返 cid=123456 的 OK 响应
const mockViewOk = (cid = 123456) =>
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm', cid } });
it('throws AuthRequiredError when bilibili hides subtitles behind login', async () => {
page.evaluate.mockResolvedValueOnce(123456);
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: true,
subtitle: {
subtitles: [],
},
subtitle: { subtitles: [] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy((err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message));
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy(
(err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message),
);
});
it('throws EmptyResultError when a video truly has no subtitles', async () => {
page.evaluate.mockResolvedValueOnce(123456);
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when view API returns non-zero code', async () => {
// 番剧/地区限制等场景下 view API 也会返非零;之前路径走 SELECTOR 错,现在统一走 view 错
mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('wraps view API fetch/json exceptions as CommandExecutionError', async () => {
mockApiGet.mockRejectedValueOnce(new SyntaxError('Unexpected token <'));
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when view API succeeds but lacks cid', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm' /* no cid */ } });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(/cid/);
});
it('throws CommandExecutionError when player subtitle payload is malformed', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: { lan: 'zh-CN' } },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when player API returns a non-object payload', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce(null);
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
mockApiGet.mockReset();
mockViewOk();
mockApiGet.mockResolvedValueOnce([]);
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws AuthRequiredError only for explicit empty subtitle_url entries', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '' }] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(AuthRequiredError);
});
it('throws CommandExecutionError when subtitle entry lacks subtitle_url field', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN' }] },
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('wraps subtitle file fetch exceptions as CommandExecutionError', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockRejectedValueOnce(new Error('Failed to fetch'));
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('throws EmptyResultError when subtitle file has no cue rows', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockResolvedValueOnce({ success: true, data: [] });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when subtitle cue rows have malformed time ranges', async () => {
mockViewOk();
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 'bad', to: 1.5, content: 'hello' }] });
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
});
it('works for bangumi-bound bvid (PGC content) — same code path, view API returns cid + redirect_url', async () => {
// 回归保护:以前 page.goto(/video/<bvid>) 对 bangumi 走重定向,
// window.__INITIAL_STATE__.videoData 不存在 → SELECTOR 错。view API 不依赖页面结构,bangumi 同样能拿 cid。
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1Py4y1D781',
cid: 267270412,
redirect_url: 'https://www.bilibili.com/bangumi/play/ep371508',
title: '【纪录片】灭绝的真相',
},
});
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: {
subtitles: [],
subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }],
},
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
page.evaluate.mockResolvedValueOnce({
success: true,
data: [
{ from: 0, to: 1.5, content: 'hello' },
{ from: 1.5, to: 3.2, content: 'world' },
],
});
const out = await command.func(page, { bvid: 'BV1Py4y1D781' });
expect(out).toEqual([
{ index: 1, from: '0.00s', to: '1.50s', content: 'hello' },
{ index: 2, from: '1.50s', to: '3.20s', content: 'world' },
]);
// 关键:不再依赖 page.goto,所有 cid 解析走 apiGet
expect(page.goto).not.toHaveBeenCalled();
// 第一发 apiGet 一定是 view 端点
const firstCall = mockApiGet.mock.calls[0];
expect(firstCall[1]).toBe('/x/web-interface/view');
expect(firstCall[2]?.params?.bvid).toBe('BV1Py4y1D781');
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Bilibili summary — fetches the official AI-generated video summary (the "AI总结"
* shown on the video page) via /x/web-interface/view/conclusion/get.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
const BILIBILI_HOST_RE = /(^|\.)bilibili\.com$/i;
const B23_HOST_RE = /(^|\.)b23\.tv$/i;
const BVID_RE = /^BV[A-Za-z0-9]+$/;
function formatTime(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
}
async function readBvid(raw) {
const input = String(raw ?? '').trim();
if (!input) {
throw new ArgumentError('bilibili summary bvid cannot be empty', 'Pass a BV ID, Bilibili video URL, or b23.tv short link.');
}
if (BVID_RE.test(input)) {
return input;
}
let parsed = null;
try {
parsed = new URL(input);
} catch {
// Bare b23.tv short codes are accepted by the shared resolver.
}
if (parsed) {
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new ArgumentError('Bilibili summary URL must use http or https');
}
if (BILIBILI_HOST_RE.test(parsed.hostname)) {
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
if (!match) {
throw new ArgumentError('Bilibili summary URL must contain a BV video id');
}
return match[1];
}
if (!B23_HOST_RE.test(parsed.hostname)) {
throw new ArgumentError('Bilibili summary URL must be a bilibili.com or b23.tv URL');
}
}
try {
return await resolveBvid(input);
} catch (error) {
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${input}`, error instanceof Error ? error.message : String(error));
}
}
function requireOkPayload(payload, label) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
}
if (payload.code !== 0) {
const message = payload.message ?? 'unknown error';
if (payload.code === -101 || payload.code === -403 || /登录|权限|forbidden|permission|login/i.test(String(message))) {
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
}
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
}
return payload.data;
}
function readModelResult(data, bvid) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new CommandExecutionError('Bilibili conclusion API returned malformed data');
}
if (data.code !== 0) {
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
}
let modelResult = data.model_result;
if (typeof modelResult === 'string') {
try {
modelResult = JSON.parse(modelResult);
} catch {
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result JSON');
}
}
if (!modelResult || typeof modelResult !== 'object' || Array.isArray(modelResult)) {
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result');
}
const summary = String(modelResult.summary ?? '').trim();
if (!summary) {
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
}
const outline = modelResult.outline ?? [];
if (!Array.isArray(outline)) {
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline');
}
return { summary, outline };
}
function rowsFromModel(model) {
const rows = [{ time: '', content: model.summary }];
for (const section of model.outline) {
if (!section || typeof section !== 'object' || Array.isArray(section)) {
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline section');
}
const sectionTitle = String(section.title ?? '').trim();
const sectionTime = formatTime(section.timestamp);
if (sectionTitle) {
rows.push({ time: sectionTime, content: `# ${sectionTitle}` });
}
const points = section.part_outline ?? [];
if (!Array.isArray(points)) {
throw new CommandExecutionError('Bilibili conclusion API returned malformed part outline');
}
for (const point of points) {
if (!point || typeof point !== 'object' || Array.isArray(point)) {
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline point');
}
const content = String(point.content ?? '').trim();
if (content) {
rows.push({ time: formatTime(point.timestamp), content });
}
}
}
return rows;
}
var command = cli({
site: 'bilibili',
name: 'summary',
access: 'read',
description: '获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
],
columns: ['time', 'content'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili summary');
}
const bvid = await readBvid(kwargs.bvid);
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const viewData = requireOkPayload(view, 'view');
const cid = viewData?.cid;
const upMid = viewData?.owner?.mid;
if (!cid || !upMid) {
throw new CommandExecutionError(`Bilibili view API did not return cid/up_mid for ${bvid}`);
}
const conclusion = await apiGet(page, '/x/web-interface/view/conclusion/get', {
params: { bvid, cid, up_mid: upMid },
signed: true,
});
const conclusionData = requireOkPayload(conclusion, 'conclusion');
return rowsFromModel(readModelResult(conclusionData, bvid));
},
});
export const __test__ = {
command,
formatTime,
readBvid,
readModelResult,
rowsFromModel,
};
+210
View File
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet, mockResolveBvid } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
mockResolveBvid: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiGet: mockApiGet,
resolveBvid: mockResolveBvid,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './summary.js';
describe('bilibili summary', () => {
const command = getRegistry().get('bilibili/summary');
const page = {};
beforeEach(() => {
mockApiGet.mockReset();
mockResolveBvid.mockReset();
mockResolveBvid.mockRejectedValue(new Error('short link not found'));
});
function mockView(data = { aid: 114, cid: 222, owner: { mid: 333 } }) {
mockApiGet.mockResolvedValueOnce({ code: 0, data });
}
function mockConclusion(modelResult) {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
code: 0,
model_result: modelResult,
},
});
}
it('returns the summary plus timestamped outline rows', async () => {
mockView();
mockConclusion({
summary: '整体总结',
outline: [
{
title: '第一节',
timestamp: 0,
part_outline: [
{ timestamp: 12, content: '要点A' },
{ timestamp: 3725, content: '要点B' },
],
},
],
});
const result = await command.func(page, { bvid: 'BV1xxx' });
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1xxx' } });
expect(mockApiGet).toHaveBeenNthCalledWith(2, page, '/x/web-interface/view/conclusion/get', {
params: { bvid: 'BV1xxx', cid: 222, up_mid: 333 },
signed: true,
});
expect(result).toEqual([
{ time: '', content: '整体总结' },
{ time: '00:00', content: '# 第一节' },
{ time: '00:12', content: '要点A' },
{ time: '1:02:05', content: '要点B' },
]);
});
it('returns just the summary when the video has no outline', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion({ summary: '只有总结', outline: [] });
await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([
{ time: '', content: '只有总结' },
]);
});
it('parses model_result when Bilibili returns it as a JSON string', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion(JSON.stringify({ summary: '字符串总结', outline: [] }));
await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([
{ time: '', content: '字符串总结' },
]);
});
it('normalizes Bilibili video URLs before calling the APIs', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion({ summary: 'URL 总结', outline: [] });
await command.func(page, {
bvid: 'https://www.bilibili.com/video/BV1abc12345/?spm_id_from=333.1007',
});
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1abc12345' } });
});
it('resolves b23.tv short links through the shared resolver', async () => {
mockResolveBvid.mockResolvedValueOnce('BVshort12345');
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion({ summary: '短链总结', outline: [] });
await command.func(page, { bvid: 'https://b23.tv/abc' });
expect(mockResolveBvid).toHaveBeenCalledWith('https://b23.tv/abc');
expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BVshort12345' } });
});
it('rejects invalid inputs before calling Bilibili APIs', async () => {
const cases = [
'',
'javascript:alert(1)',
'https://example.com/video/BV1abc12345',
'https://share.note.youdao.com/video/BV1abc12345',
'https://www.bilibili.com/read/cv12345',
];
for (const bvid of cases) {
await expect(command.func(page, { bvid })).rejects.toBeInstanceOf(ArgumentError);
}
expect(mockApiGet).not.toHaveBeenCalled();
});
it('maps unresolved short-code inputs to ArgumentError without calling APIs', async () => {
await expect(command.func(page, { bvid: 'not-a-bv' })).rejects.toBeInstanceOf(ArgumentError);
expect(mockResolveBvid).toHaveBeenCalledWith('not-a-bv');
expect(mockApiGet).not.toHaveBeenCalled();
});
it('throws EmptyResultError when Bilibili has not generated an AI summary for the video', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockApiGet.mockResolvedValueOnce({ code: 0, data: { code: 1, model_result: {} } });
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(EmptyResultError);
});
it('throws CommandExecutionError when the view payload is malformed', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} });
await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /cid\/up_mid/.test(err.message),
);
});
it('throws CommandExecutionError when the view API returns a non-auth error', async () => {
mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /啥都木有.*-404/.test(err.message),
);
});
it('maps conclusion auth or permission errors to AuthRequiredError', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockApiGet.mockResolvedValueOnce({ code: -403, message: '访问权限不足' });
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(AuthRequiredError);
});
it('maps conclusion non-auth API errors to CommandExecutionError', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockApiGet.mockResolvedValueOnce({ code: -500, message: 'server error' });
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /server error.*-500/.test(err.message),
);
});
it('throws CommandExecutionError for malformed conclusion API payloads', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockApiGet.mockResolvedValueOnce(null);
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError for malformed model_result JSON', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion('{bad json');
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /model_result JSON/.test(err.message),
);
});
it('throws CommandExecutionError for malformed outline shapes', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion({ summary: '坏 outline', outline: {} });
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /outline/.test(err.message),
);
});
it('throws CommandExecutionError for malformed part outline shapes', async () => {
mockView({ aid: 1, cid: 2, owner: { mid: 3 } });
mockConclusion({
summary: '坏 part_outline',
outline: [{ title: '段落', timestamp: 0, part_outline: {} }],
});
await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /part outline/.test(err.message),
);
});
});
+63 -5
View File
@@ -2,7 +2,7 @@
* Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution.
*/
import https from 'node:https';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
/**
* Resolve Bilibili short URL / short code to BV ID.
* Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa
@@ -12,7 +12,22 @@ export function resolveBvid(input) {
if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) {
return Promise.resolve(trimmed);
}
try {
const parsed = new URL(trimmed);
if (/(\.|^)bilibili\.com$/i.test(parsed.hostname)) {
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
if (match) {
return Promise.resolve(match[1]);
}
}
}
catch {
// Non-URL inputs fall through to b23.tv short-code resolution.
}
const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, '');
if (!/^[A-Za-z0-9]+$/.test(shortCode)) {
return Promise.reject(new Error(`Cannot resolve BV ID from invalid b23.tv short code: ${trimmed}`));
}
const url = 'https://b23.tv/' + shortCode;
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
@@ -29,7 +44,7 @@ export function resolveBvid(input) {
reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`));
});
req.on('error', reject);
req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
req.setTimeout(4000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
});
}
const MIXIN_KEY_ENC_TAB = [
@@ -104,6 +119,38 @@ export async function fetchJson(page, url) {
}
`);
}
/**
* POST form-encoded params to a Bilibili API endpoint.
* Runs inside the logged-in browser context and auto-attaches the bili_jct CSRF token,
* which Bilibili requires on every authenticated write request.
*/
export async function apiPost(page, path, opts = {}) {
const params = opts.params ?? {};
const stringified = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]));
const paramsJs = JSON.stringify(stringified);
const urlJs = JSON.stringify(`https://api.bilibili.com${path}`);
return page.evaluate(`
async () => {
const csrf = (document.cookie.match(/bili_jct=([^;]+)/) || [])[1] || "";
const body = new URLSearchParams(${paramsJs});
body.set("csrf", csrf);
const res = await fetch(${urlJs}, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
// Bilibili write endpoints can return an HTML risk-control page (e.g. HTTP 412)
// instead of JSON. Surface that as a structured error rather than a parse crash.
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { code: -1, message: "Non-JSON response (HTTP " + res.status + "): " + text.slice(0, 200) };
}
}
`);
}
export async function getSelfUid(page) {
const nav = await getNavData(page);
const mid = nav?.data?.mid;
@@ -119,8 +166,19 @@ export async function resolveUid(page, input) {
params: { search_type: 'bili_user', keyword: input },
signed: true,
});
const results = payload?.data?.result ?? [];
if (results.length > 0)
return String(results[0].mid);
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data) || !Object.hasOwn(payload.data, 'result')) {
throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
}
const results = payload.data.result;
if (!Array.isArray(results)) {
throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
}
if (results.length > 0) {
const mid = String(results[0]?.mid ?? '').trim();
if (!mid) {
throw new CommandExecutionError(`Bilibili user search returned malformed mid for ${input}`);
}
return mid;
}
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
}
+45 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveBvid } from './utils.js';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { resolveBvid, resolveUid } from './utils.js';
describe('resolveBvid', () => {
it('passes through a valid BV ID', async () => {
expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
@@ -10,8 +11,51 @@ describe('resolveBvid', () => {
it('handles non-string input via String() coercion', async () => {
expect(await resolveBvid('BV123abc')).toBe('BV123abc');
});
it('extracts BV IDs from bilibili video URLs', async () => {
expect(await resolveBvid('https://www.bilibili.com/video/BV1xx411c7mD/?spm_id_from=333.1007')).toBe('BV1xx411c7mD');
expect(await resolveBvid('https://m.bilibili.com/video/BV1Je9EBnEha')).toBe('BV1Je9EBnEha');
});
it('rejects invalid input that cannot be resolved', async () => {
// A random string that b23.tv won't resolve — should timeout or fail
await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
});
});
describe('resolveUid', () => {
function pageWithUserSearchResult(result) {
return {
evaluate: async (script) => {
if (String(script).includes('/x/web-interface/nav')) {
return {
data: {
wbi_img: {
img_url: 'https://i0.hdslb.com/bfs/wbi/abcdefghijklmnopqrstuvwxyz123456.png',
sub_url: 'https://i0.hdslb.com/bfs/wbi/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456.png',
},
},
};
}
return result;
},
};
}
it('returns numeric uid input without searching', async () => {
expect(await resolveUid({}, '12345')).toBe('12345');
});
it('fails closed when user search payload lacks result', async () => {
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: {} }), 'missing'))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails closed when user search result row lacks mid', async () => {
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [{}] } }), 'missing-mid'))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('keeps explicit no-user result as EmptyResultError', async () => {
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [] } }), 'nobody'))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
+356
View File
@@ -0,0 +1,356 @@
import { describe, expect, it } from 'vitest';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import './search.js';
import { __test__ } from './search.js';
const {
normalizePositiveInt,
normalizeNonNegativeInt,
normalizeDate,
normalizeCurrency,
normalizeLang,
hasPositiveResultCount,
buildSearchUrl,
} = __test__;
describe('booking helpers — normalizePositiveInt (no silent clamp)', () => {
it('returns default when value is undefined/null/empty', () => {
expect(normalizePositiveInt(undefined, 2, 'adults', 30)).toBe(2);
expect(normalizePositiveInt(null, 2, 'adults', 30)).toBe(2);
});
it('accepts integers in range', () => {
expect(normalizePositiveInt(1, 2, 'adults', 30)).toBe(1);
expect(normalizePositiveInt(30, 2, 'adults', 30)).toBe(30);
});
it('rejects zero / negative / out-of-range / non-integer (no silent clamp)', () => {
expect(() => normalizePositiveInt(0, 2, 'adults', 30)).toThrow(ArgumentError);
expect(() => normalizePositiveInt(-1, 2, 'adults', 30)).toThrow(ArgumentError);
expect(() => normalizePositiveInt(31, 2, 'adults', 30)).toThrow(ArgumentError);
expect(() => normalizePositiveInt(1.5, 2, 'adults', 30)).toThrow(ArgumentError);
expect(() => normalizePositiveInt('abc', 2, 'adults', 30)).toThrow(ArgumentError);
});
});
describe('booking helpers — normalizeNonNegativeInt', () => {
it('accepts zero', () => {
expect(normalizeNonNegativeInt(0, 0, 'children', 10)).toBe(0);
});
it('rejects negative / out-of-range (no silent clamp)', () => {
expect(() => normalizeNonNegativeInt(-1, 0, 'children', 10)).toThrow(ArgumentError);
expect(() => normalizeNonNegativeInt(11, 0, 'children', 10)).toThrow(ArgumentError);
});
});
describe('booking helpers — normalizeDate', () => {
it('accepts YYYY-MM-DD', () => {
expect(normalizeDate('2026-06-15', 'checkin')).toBe('2026-06-15');
});
it('rejects bad format / nonsense dates with ArgumentError', () => {
expect(() => normalizeDate('', 'checkin')).toThrow(ArgumentError);
expect(() => normalizeDate('06/15/2026', 'checkin')).toThrow(ArgumentError);
expect(() => normalizeDate('2026-13-40', 'checkin')).toThrow(ArgumentError);
expect(() => normalizeDate('2026-02-31', 'checkin')).toThrow(ArgumentError);
});
});
describe('booking helpers — normalizeCurrency', () => {
it('passes 3-letter codes uppercased', () => {
expect(normalizeCurrency('usd')).toBe('USD');
expect(normalizeCurrency('JPY')).toBe('JPY');
});
it('returns empty for unset', () => {
expect(normalizeCurrency(undefined)).toBe('');
expect(normalizeCurrency('')).toBe('');
});
it('rejects non-3-letter codes', () => {
expect(() => normalizeCurrency('US')).toThrow(ArgumentError);
expect(() => normalizeCurrency('US$')).toThrow(ArgumentError);
expect(() => normalizeCurrency('USDX')).toThrow(ArgumentError);
});
});
describe('booking helpers — normalizeLang whitelist', () => {
it('lowercases supported langs', () => {
expect(normalizeLang('EN-US')).toBe('en-us');
expect(normalizeLang('zh-cn')).toBe('zh-cn');
});
it('rejects unknown langs', () => {
expect(() => normalizeLang('xx-yy')).toThrow(ArgumentError);
expect(() => normalizeLang('en')).toThrow(ArgumentError);
});
});
describe('booking helpers — buildSearchUrl', () => {
it('constructs canonical search URL with required params', () => {
const url = buildSearchUrl({
destination: 'Tokyo',
checkin: '2026-06-15',
checkout: '2026-06-17',
adults: 2,
rooms: 1,
children: 0,
offset: 0,
currency: 'USD',
lang: 'en-us',
});
expect(url).toContain('https://www.booking.com/searchresults.en-us.html');
expect(url).toContain('ss=Tokyo');
expect(url).toContain('checkin=2026-06-15');
expect(url).toContain('checkout=2026-06-17');
expect(url).toContain('group_adults=2');
expect(url).toContain('no_rooms=1');
expect(url).toContain('group_children=0');
expect(url).toContain('selected_currency=USD');
expect(url).not.toContain('offset=');
});
it('omits lang file segment when lang is empty', () => {
const url = buildSearchUrl({
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
adults: 2, rooms: 1, children: 0, offset: 0, currency: '', lang: '',
});
expect(url).toMatch(/booking\.com\/searchresults\.html\?/);
});
it('emits offset only when > 0', () => {
const url = buildSearchUrl({
destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17',
adults: 2, rooms: 1, children: 0, offset: 25, currency: '', lang: '',
});
expect(url).toContain('offset=25');
});
});
describe('booking helpers — hasPositiveResultCount', () => {
it('detects positive Booking result-count evidence', () => {
expect(hasPositiveResultCount('Tokyo: 1,234 properties found')).toBe(true);
expect(hasPositiveResultCount('1 stay found')).toBe(true);
});
it('does not treat no-results text as positive evidence', () => {
expect(hasPositiveResultCount('No properties found')).toBe(false);
expect(hasPositiveResultCount('0 properties found')).toBe(false);
});
});
describe('booking adapter registry shape', () => {
it('search is registered as read with id-shaped column for round-trip', () => {
const search = getRegistry().get('booking/search');
expect(search).toBeDefined();
expect(search.access).toBe('read');
expect(search.browser).toBe(true);
// slug + country together form the round-trip identity (URL: /hotel/<country>/<slug>.html)
expect(search.columns).toContain('slug');
expect(search.columns).toContain('country');
expect(search.columns).toContain('url');
});
it('search columns stay <= 12 to honor agent-native row shape', () => {
const search = getRegistry().get('booking/search');
expect(search.columns.length).toBeLessThanOrEqual(12);
});
});
describe('booking search — typed errors (no silent fallback)', () => {
const fakePage = { goto: () => { throw new Error('should not navigate'); } };
it('rejects empty destination with ArgumentError', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: ' ', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(ArgumentError);
});
it('rejects missing checkin/checkout with ArgumentError', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: 'Tokyo' })).rejects.toThrow(ArgumentError);
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15' })).rejects.toThrow(ArgumentError);
});
it('rejects checkout <= checkin with ArgumentError', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-17', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError);
});
it('rejects out-of-range --limit with ArgumentError (no silent clamp to 100)', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', limit: 999 })).rejects.toThrow(ArgumentError);
});
it('rejects negative --offset with ArgumentError', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: -1 })).rejects.toThrow(ArgumentError);
});
it('rejects unsupported --lang with ArgumentError', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', lang: 'xx-yy' })).rejects.toThrow(ArgumentError);
});
it('rejects malformed --currency with ArgumentError', async () => {
const search = getRegistry().get('booking/search');
await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'US$' })).rejects.toThrow(ArgumentError);
});
it('wraps browser navigation failures as CommandExecutionError', async () => {
const search = getRegistry().get('booking/search');
const downPage = { goto: () => Promise.reject(new Error('browser down')) };
await expect(search.func(downPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
});
it('throws EmptyResultError when extractor returns no cards', async () => {
const search = getRegistry().get('booking/search');
const emptyPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'No properties found' }),
};
await expect(search.func(emptyPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when result-count evidence exists but no cards were parsed', async () => {
const search = getRegistry().get('booking/search');
const driftPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'Tokyo: 1,234 properties found' }),
};
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when captcha is detected', async () => {
const search = getRegistry().get('booking/search');
const blockedPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({ ok: true, items: [], blocked: true, totalText: 'Verify you are human' }),
};
await expect(search.func(blockedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when extractor payload is malformed instead of treating it as empty', async () => {
const search = getRegistry().get('booking/search');
const malformedPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({ ok: true, blocked: false, totalText: 'Tokyo hotels' }),
};
await expect(search.func(malformedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
});
it('throws CommandExecutionError when rendered cards lack stable hotel URL identity', async () => {
const search = getRegistry().get('booking/search');
const driftPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({
ok: true,
blocked: false,
totalText: 'Tokyo hotels',
items: [{
name: 'Unlinked Hotel',
country: '',
slug: '',
url: '',
distance: '',
review_score: null,
review_count: null,
star_rating: null,
price_currency: '',
price_amount: null,
recommended_room: '',
}],
}),
};
await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError);
});
it('unwraps {session, data} envelope from CDP bridge before validating', async () => {
const search = getRegistry().get('booking/search');
const envelopePage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({
session: 1,
data: {
ok: true,
blocked: false,
totalText: '',
items: [{
name: 'Test Hotel',
country: 'jp',
slug: 'test-hotel',
url: 'https://www.booking.com/hotel/jp/test-hotel.html',
distance: '1 km from centre',
review_score: 8.6,
review_count: 100,
star_rating: 4,
price_currency: 'USD',
price_amount: 120,
recommended_room: 'Standard double',
}],
},
}),
};
const rows = await search.func(envelopePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' });
expect(rows).toHaveLength(1);
expect(rows[0].rank).toBe(1);
expect(rows[0].slug).toBe('test-hotel');
expect(rows[0].url).toBe('https://www.booking.com/hotel/jp/test-hotel.html');
});
it('uses requested selected_currency as the output source when price is present', async () => {
const search = getRegistry().get('booking/search');
const currencyPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({
ok: true,
blocked: false,
totalText: '',
items: [{
name: 'Currency Hotel',
country: 'cn',
slug: 'currency-hotel',
url: 'https://www.booking.com/hotel/cn/currency-hotel.html',
distance: '',
review_score: null,
review_count: null,
star_rating: null,
price_currency: 'JPY',
price_amount: 880,
recommended_room: '',
}],
}),
};
const rows = await search.func(currencyPage, { destination: 'Shanghai', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'CNY' });
expect(rows[0].price_currency).toBe('CNY');
});
it('respects offset for rank numbering when paginating', async () => {
const search = getRegistry().get('booking/search');
const pagedPage = {
goto: async () => {},
wait: async () => {},
evaluate: async () => ({
ok: true,
blocked: false,
totalText: '',
items: [
{ name: 'A', country: 'jp', slug: 'a', url: 'https://www.booking.com/hotel/jp/a.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
{ name: 'B', country: 'jp', slug: 'b', url: 'https://www.booking.com/hotel/jp/b.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' },
],
}),
};
const rows = await search.func(pagedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: 50 });
expect(rows[0].rank).toBe(51);
expect(rows[1].rank).toBe(52);
});
});
+351
View File
@@ -0,0 +1,351 @@
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function normalizePositiveInt(value, defaultValue, label, max) {
const raw = value ?? defaultValue;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`${label} must be a positive integer`);
}
if (typeof max === 'number' && n > max) {
throw new ArgumentError(`${label} must be <= ${max}`);
}
return n;
}
function normalizeNonNegativeInt(value, defaultValue, label, max) {
const raw = value ?? defaultValue;
const n = Number(raw);
if (!Number.isInteger(n) || n < 0) {
throw new ArgumentError(`${label} must be a non-negative integer`);
}
if (typeof max === 'number' && n > max) {
throw new ArgumentError(`${label} must be <= ${max}`);
}
return n;
}
function normalizeDate(value, label) {
const v = String(value || '').trim();
if (!v) {
throw new ArgumentError(`${label} is required (YYYY-MM-DD)`);
}
if (!DATE_RE.test(v)) {
throw new ArgumentError(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
}
const [year, month, day] = v.split('-').map(Number);
const d = new Date(Date.UTC(year, month - 1, day));
if (
Number.isNaN(d.getTime()) ||
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== month - 1 ||
d.getUTCDate() !== day
) {
throw new ArgumentError(`${label} is not a valid calendar date: ${v}`);
}
return v;
}
function normalizeCurrency(value) {
if (value == null || value === '') return '';
const v = String(value).trim().toUpperCase();
if (!/^[A-Z]{3}$/.test(v)) {
throw new ArgumentError(`currency must be a 3-letter ISO code (e.g. USD, JPY, CNY), got ${JSON.stringify(value)}`);
}
return v;
}
const ALLOWED_LANGS = new Set([
'en-us', 'en-gb', 'zh-cn', 'zh-tw', 'ja', 'ko', 'de', 'fr', 'es', 'it',
'pt-br', 'pt-pt', 'ru', 'th', 'vi', 'tr', 'pl', 'nl', 'ar',
]);
function normalizeLang(value) {
if (value == null || value === '') return '';
const v = String(value).trim().toLowerCase();
if (!ALLOWED_LANGS.has(v)) {
throw new ArgumentError(`lang must be one of: ${[...ALLOWED_LANGS].join(', ')}`);
}
return v;
}
function hasPositiveResultCount(text) {
const value = String(text || '').replace(/\u00a0/g, ' ');
const resultCount = value.match(/\b([1-9][0-9,.\s]*)\s+(?:properties|property|stays|stay|hotels|hotel)\b/i);
if (!resultCount) return false;
const digits = resultCount[1].replace(/\D/g, '');
return Boolean(digits) && Number(digits) > 0;
}
function buildSearchUrl({
destination,
checkin,
checkout,
adults,
rooms,
children,
offset,
currency,
lang,
}) {
const file = lang ? `searchresults.${lang}.html` : 'searchresults.html';
const params = new URLSearchParams();
params.set('ss', destination);
params.set('checkin', checkin);
params.set('checkout', checkout);
params.set('group_adults', String(adults));
params.set('no_rooms', String(rooms));
params.set('group_children', String(children));
if (offset > 0) params.set('offset', String(offset));
if (currency) params.set('selected_currency', currency);
return `https://www.booking.com/${file}?${params.toString()}`;
}
const EXTRACTOR = `
(() => {
const trim = (v) => (v == null ? '' : String(v).replace(/\\s+/g, ' ').trim());
const cards = Array.from(document.querySelectorAll('[data-testid=property-card]'));
// Detect blocking / captcha pages: no cards but body shows a verification prompt.
if (cards.length === 0) {
const text = [
(document.title || ''),
(document.body && document.body.innerText) || '',
(location && location.pathname) || '',
].join(' ');
const blocked = /captcha|challenge|verify\\s*you\\s*are|access\\s*denied|forbidden|robot|unusual\\s*traffic/i.test(text);
const totalEl = document.querySelector('h1');
const totalText = trim(totalEl && totalEl.textContent);
return { ok: true, items: [], blocked, totalText };
}
const items = cards.map((card) => {
const titleEl = card.querySelector('[data-testid=title]');
const link = card.querySelector('a[data-testid=title-link]');
const href = (link && link.href) || '';
let country = '';
let slug = '';
let canonicalUrl = '';
try {
const u = new URL(href, 'https://www.booking.com');
const m = u.pathname.match(/^\\/hotel\\/([a-z]{2})\\/([^./]+)/);
if (m) {
country = m[1];
slug = m[2];
canonicalUrl = 'https://www.booking.com/hotel/' + country + '/' + slug + '.html';
}
} catch (_) {}
const reviewTextRaw = trim(card.querySelector('[data-testid=review-score]')?.textContent);
// Booking renders the score twice (a11y + visual), text reads like "Scored 8.6 8.6 Very Good 6,151 reviews"
// or "评分8.68.6很棒 6,151条住客点评". Take only the first numeric occurrence.
const scoreMatch = reviewTextRaw.match(/(\\d{1,2})\\.(\\d)/);
const reviewScore = scoreMatch ? Number(scoreMatch[1] + '.' + scoreMatch[2]) : null;
const countMatch = reviewTextRaw.match(/([0-9][0-9,]*)\\s*(?:reviews|reseñas|avis|recensioni|条住客点评|条评论|レビュー|리뷰)/i);
const reviewCount = countMatch ? Number(countMatch[1].replace(/,/g, '')) : null;
// Star rating: aria-label often "5 out of 5" / "4 星 (满分 5 星)" / "Hôtel 4 étoiles"
let starRating = null;
const starEl = card.querySelector('[data-testid=rating-stars], [data-testid=quality-rating]');
if (starEl) {
const aria = starEl.getAttribute('aria-label') || starEl.textContent || '';
const m = aria.match(/(\\d)(?:\\s*(?:out of|\\/|星|颗星|stars?|étoiles?)|\\s*$)/i);
if (m) starRating = Number(m[1]);
if (starRating == null) {
const count = starEl.querySelectorAll('svg, [aria-hidden=true]').length;
if (count >= 1 && count <= 5) starRating = count;
}
}
const priceEl = card.querySelector('[data-testid=price-and-discounted-price]');
const priceText = trim(priceEl && priceEl.textContent);
// currency symbol → ISO best-effort
const currencySymbolMap = {
'$': 'USD', 'US$': 'USD', 'A$': 'AUD', 'C$': 'CAD', 'HK$': 'HKD',
'€': 'EUR', '£': 'GBP', '¥': 'JPY', '¥': 'CNY', '₹': 'INR', '₩': 'KRW',
'CN¥': 'CNY', 'CN¥': 'CNY', 'NT$': 'TWD', 'S$': 'SGD',
};
let priceCurrency = '';
let priceAmount = null;
const sym = priceText.match(/(US\\$|A\\$|C\\$|HK\\$|NT\\$|S\\$|CN¥|CN¥|[$€£¥¥₹₩])/);
if (sym) priceCurrency = currencySymbolMap[sym[1]] || '';
const num = priceText.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)/);
if (num) priceAmount = Number(num[1]);
return {
name: trim(titleEl?.textContent),
country,
slug,
url: canonicalUrl,
distance: trim(card.querySelector('[data-testid=distance]')?.textContent),
review_score: reviewScore,
review_count: reviewCount,
star_rating: starRating,
price_currency: priceCurrency,
price_amount: priceAmount,
recommended_room: trim(card.querySelector('[data-testid=recommended-units]')?.textContent),
};
});
const totalEl = document.querySelector('h1');
const totalText = trim(totalEl && totalEl.textContent);
return { ok: true, items, blocked: false, totalText };
})()
`;
cli({
site: 'booking',
name: 'search',
description: 'Search Booking.com hotels by destination and dates (server-rendered card scrape).',
access: 'read',
example: 'opencli booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml',
domain: 'www.booking.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'destination', required: true, positional: true, help: 'Destination keyword (city, district, or hotel name)' },
{ name: 'checkin', required: true, help: 'Check-in date YYYY-MM-DD' },
{ name: 'checkout', required: true, help: 'Check-out date YYYY-MM-DD' },
{ name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-30)' },
{ name: 'rooms', type: 'int', default: 1, help: 'Number of rooms (1-30)' },
{ name: 'children', type: 'int', default: 0, help: 'Number of children (0-10)' },
{ name: 'currency', required: false, help: 'Force result currency (e.g. USD, JPY, CNY)' },
{ name: 'lang', required: false, help: 'Force result language (e.g. en-us, zh-cn, ja)' },
{ name: 'limit', type: 'int', default: 25, help: 'Max rows to return (1-100; Booking pages 25 per request)' },
{ name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (multiple of 25)' },
],
columns: [
'rank',
'name',
'country',
'slug',
'star_rating',
'review_score',
'review_count',
'price_amount',
'price_currency',
'distance',
'recommended_room',
'url',
],
func: async (page, kwargs) => {
const destination = String(kwargs.destination || '').trim();
if (!destination) throw new ArgumentError('destination is required');
const checkin = normalizeDate(kwargs.checkin, 'checkin');
const checkout = normalizeDate(kwargs.checkout, 'checkout');
if (checkin >= checkout) {
throw new ArgumentError(`checkout (${checkout}) must be after checkin (${checkin})`);
}
const adults = normalizePositiveInt(kwargs.adults, 2, 'adults', 30);
const rooms = normalizePositiveInt(kwargs.rooms, 1, 'rooms', 30);
const children = normalizeNonNegativeInt(kwargs.children, 0, 'children', 10);
const currency = normalizeCurrency(kwargs.currency);
const lang = normalizeLang(kwargs.lang);
const limit = normalizePositiveInt(kwargs.limit, 25, 'limit', 100);
const offset = normalizeNonNegativeInt(kwargs.offset, 0, 'offset', 1000);
const url = buildSearchUrl({ destination, checkin, checkout, adults, rooms, children, offset, currency, lang });
try {
await page.goto(url);
} catch (err) {
throw new CommandExecutionError(`Failed to load Booking.com search page: ${err?.message || err}`);
}
// Booking lazy-loads price cells; wait for at least the first card price to settle.
try {
await page.wait('selector', '[data-testid=property-card]', { timeoutMs: 20000 });
} catch (_) {
// selector wait is best-effort — extractor handles empty case explicitly
}
let raw;
try {
raw = await page.evaluate(EXTRACTOR);
} catch (err) {
throw new CommandExecutionError(`Failed to extract Booking.com cards: ${err?.message || err}`);
}
if (raw && typeof raw === 'object' && raw.data && raw.session) {
raw = raw.data;
}
if (!raw || typeof raw !== 'object') {
throw new CommandExecutionError('Booking.com page returned no extractable data');
}
if (raw.blocked) {
throw new CommandExecutionError('Booking.com served a verification / captcha page; retry later or change profile');
}
if (raw.ok !== true) {
throw new CommandExecutionError('Booking.com extractor returned an invalid status');
}
if (!Array.isArray(raw.items)) {
throw new CommandExecutionError('Booking.com extractor returned malformed items');
}
const items = raw.items;
if (items.length === 0) {
const totalText = String(raw.totalText || '').trim();
if (hasPositiveResultCount(totalText)) {
throw new CommandExecutionError(
`Booking.com page declared results but no property cards were parsed: ${totalText}`,
);
}
throw new EmptyResultError(
`booking search ${JSON.stringify(destination)}`,
totalText
? `No hotels rendered (${totalText}). Try a broader destination, different dates, or check the URL in a browser.`
: 'No hotels rendered. Try a broader destination, different dates, or check the URL in a browser.',
);
}
return items.slice(0, limit).map((it, i) => {
if (!it || typeof it !== 'object') {
throw new CommandExecutionError('Booking.com extractor returned malformed hotel row');
}
const name = String(it.name || '').trim();
const country = String(it.country || '').trim();
const slug = String(it.slug || '').trim();
const urlValue = String(it.url || '').trim();
const expectedUrl = country && slug
? `https://www.booking.com/hotel/${country}/${slug}.html`
: '';
if (!name || !/^[a-z]{2}$/.test(country) || !slug || urlValue !== expectedUrl) {
throw new CommandExecutionError('Booking.com hotel row is missing stable name/url identity');
}
return {
rank: offset + i + 1,
name,
country,
slug,
star_rating: it.star_rating,
review_score: it.review_score,
review_count: it.review_count,
price_amount: it.price_amount,
price_currency: it.price_amount == null ? '' : (currency || it.price_currency || ''),
distance: it.distance,
recommended_room: it.recommended_room,
url: urlValue,
};
});
},
});
export const __test__ = {
normalizePositiveInt,
normalizeNonNegativeInt,
normalizeDate,
normalizeCurrency,
normalizeLang,
hasPositiveResultCount,
buildSearchUrl,
EXTRACTOR,
};
+47
View File
@@ -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);
},
});
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+64 -4
View File
@@ -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');
});
});
+45
View File
@@ -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
View File
@@ -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));
}
},
});
+5 -2
View File
@@ -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
View File
@@ -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 }];
},
});
+52
View File
@@ -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);
},
});
+53
View File
@@ -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
View File
@@ -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;
},
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import {
requireArrayEvaluateResult,
requireBooleanEvaluateResult,
requireObjectEvaluateResult,
unwrapEvaluateResult,
} from './utils.js';
describe('chatgpt page.evaluate envelope helpers', () => {
describe('unwrapEvaluateResult', () => {
it('unwraps a { session, data } envelope produced by the browser bridge', () => {
const envelope = { session: 'site:chatgpt:abc', data: [{ id: 'msg-1' }] };
expect(unwrapEvaluateResult(envelope)).toEqual([{ id: 'msg-1' }]);
});
it('passes raw arrays through unchanged (back-compat with older bridge versions)', () => {
const raw = [1, 2, 3];
expect(unwrapEvaluateResult(raw)).toBe(raw);
});
it('passes primitive return values (URL strings, booleans) through unchanged', () => {
expect(unwrapEvaluateResult('https://chatgpt.com/c/abc')).toBe('https://chatgpt.com/c/abc');
expect(unwrapEvaluateResult(true)).toBe(true);
expect(unwrapEvaluateResult(0)).toBe(0);
});
it('passes plain non-envelope objects through unchanged', () => {
const obj = { ok: true, reason: 'all good' };
expect(unwrapEvaluateResult(obj)).toBe(obj);
});
it('handles null and undefined defensively', () => {
expect(unwrapEvaluateResult(null)).toBe(null);
expect(unwrapEvaluateResult(undefined)).toBe(undefined);
});
});
describe('requireArrayEvaluateResult', () => {
it('returns the payload when it is an array', () => {
const rows = [{ id: 1 }, { id: 2 }];
expect(requireArrayEvaluateResult(rows, 'chatgpt test')).toBe(rows);
});
it('throws a typed CommandExecutionError when the payload is the raw envelope (caller forgot to unwrap)', () => {
const envelope = { session: 'site:chatgpt:abc', data: [{ id: 1 }] };
expect(() => requireArrayEvaluateResult(envelope, 'chatgpt visible image url extraction'))
.toThrowError(CommandExecutionError);
expect(() => requireArrayEvaluateResult(envelope, 'chatgpt visible image url extraction'))
.toThrow(/malformed extraction payload/);
});
it('surfaces the inner error message when the payload carries an `error` field', () => {
const errPayload = { error: 'image generator returned 500' };
expect(() => requireArrayEvaluateResult(errPayload, 'chatgpt image asset export'))
.toThrow(/chatgpt image asset export: image generator returned 500/);
});
it('throws when the payload is null or a primitive', () => {
expect(() => requireArrayEvaluateResult(null, 'chatgpt test')).toThrowError(CommandExecutionError);
expect(() => requireArrayEvaluateResult('a string', 'chatgpt test')).toThrowError(CommandExecutionError);
});
});
describe('requireObjectEvaluateResult', () => {
it('returns the payload when it is a plain object', () => {
const obj = { url: 'https://chatgpt.com', isLoggedIn: true };
expect(requireObjectEvaluateResult(obj, 'chatgpt page state')).toBe(obj);
});
it('throws when the payload is an array or a primitive', () => {
expect(() => requireObjectEvaluateResult([], 'chatgpt page state')).toThrowError(CommandExecutionError);
expect(() => requireObjectEvaluateResult('string', 'chatgpt page state')).toThrowError(CommandExecutionError);
expect(() => requireObjectEvaluateResult(null, 'chatgpt page state')).toThrowError(CommandExecutionError);
});
});
describe('requireBooleanEvaluateResult', () => {
it('returns booleans and rejects wrong-shape values', () => {
expect(requireBooleanEvaluateResult(true, 'chatgpt generation state')).toBe(true);
expect(requireBooleanEvaluateResult(false, 'chatgpt generation state')).toBe(false);
expect(() => requireBooleanEvaluateResult({ ok: true }, 'chatgpt generation state'))
.toThrowError(CommandExecutionError);
});
});
describe('end-to-end envelope sweep', () => {
// The bridge envelope is shaped like { session, data } where `session` is
// any string and `data` is the actual return value. Verify the helpers
// chain correctly: unwrap → require* yields the inner shape.
it('unwrap + requireArray pipes an envelope through to the underlying array', () => {
const envelope = {
session: 'site:chatgpt:img-export',
data: [
{ url: 'https://a.example/1.png', dataUrl: 'data:image/png;base64,xxx', mimeType: 'image/png' },
],
};
expect(requireArrayEvaluateResult(unwrapEvaluateResult(envelope), 'chatgpt image asset export'))
.toEqual(envelope.data);
});
it('unwrap + requireObject pipes an envelope through to the underlying object', () => {
const envelope = { session: 'site:chatgpt:state', data: { url: 'https://chatgpt.com', isLoggedIn: true } };
expect(requireObjectEvaluateResult(unwrapEvaluateResult(envelope), 'chatgpt page state'))
.toEqual(envelope.data);
});
});
});
+2 -2
View File
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { saveBase64ToFile } from '@jackwener/opencli/utils';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
const CHATGPT_DOMAIN = 'chatgpt.com';
@@ -54,7 +54,7 @@ function buildPrompt(prompt, imageCount) {
}
async function currentChatGPTLink(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
return typeof url === 'string' && url ? url : 'https://chatgpt.com';
}
+6
View File
@@ -24,6 +24,12 @@ vi.mock('./utils.js', () => ({
},
prepareChatGPTImagePaths: mocks.prepareChatGPTImagePaths,
sendChatGPTMessage: mocks.sendChatGPTMessage,
unwrapEvaluateResult: (payload) => {
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
},
uploadChatGPTImages: mocks.uploadChatGPTImages,
waitForChatGPTImages: mocks.waitForChatGPTImages,
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
+26
View File
@@ -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
View File
@@ -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);
+613 -61
View File
@@ -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;
}
@@ -74,7 +89,6 @@ function buildComposerLocatorScript() {
};
findComposer.toString = () => 'findComposer';
return { findComposer, markerAttr };
`;
}
@@ -103,19 +117,86 @@ 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.
//
// The browser bridge wraps every `page.evaluate(...)` return value in a
// `{ session, data }` envelope. Adapters that read `.length` or
// `Array.isArray(payload)` directly on the envelope silently see "no data" —
// this matches the failure mode fixed for xiaohongshu/rednote (#1561) and
// weibo (#1568).
//
// `unwrapEvaluateResult` is a defensive ternary: it unwraps when the payload
// looks like an envelope, otherwise passes the value through unchanged so
// older bridge versions and primitive return values still work.
// ─────────────────────────────────────────────────────────────────────────────
export function unwrapEvaluateResult(payload) {
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
export function requireArrayEvaluateResult(payload, label) {
if (!Array.isArray(payload)) {
if (payload && typeof payload === 'object' && 'error' in payload) {
throw new CommandExecutionError(`${label}: ${String(payload.error)}`);
}
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
}
return payload;
}
export function requireObjectEvaluateResult(payload, label) {
if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
}
return payload;
}
export function requireBooleanEvaluateResult(payload, label) {
if (typeof payload !== 'boolean') {
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
}
return payload;
}
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',
);
}
export async function currentChatGPTUrl(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
return typeof url === 'string' ? url : '';
}
@@ -160,8 +241,19 @@ 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 await page.evaluate(`(() => {
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
@@ -187,7 +279,7 @@ export async function getPageState(page) {
isLoggedIn: hasComposer || !!userMenu || !hasLoginGate,
hasLoginGate,
};
})()`);
})()`)), 'chatgpt page state');
}
export async function ensureChatGPTLogin(page, message = 'ChatGPT requires a logged-in browser session.') {
@@ -206,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(`
(() => {
@@ -258,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 = 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 = '';
@@ -274,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))),
};
})()
`);
if (!typeResult) return false;
`)), 'chatgpt composer readiness');
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');
@@ -304,21 +640,36 @@ export async function sendChatGPTMessage(page, text) {
let sent = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.wait(0.5);
sent = await page.evaluate(`
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');
if (sent?.sendBtnFound) break;
}
@@ -328,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();
})()
`);
@@ -339,7 +710,7 @@ export async function sendChatGPTMessage(page, text) {
}
export async function getVisibleMessages(page) {
const result = await page.evaluate(`(() => {
const result = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
@@ -385,8 +756,7 @@ export async function getVisibleMessages(page) {
rows.push({ role, text, html });
}
return rows;
})()`);
if (!Array.isArray(result)) return [];
})()`)), 'chatgpt visible messages');
return result.map((item, index) => ({
Index: index + 1,
Role: item?.role === 'Assistant' ? 'Assistant' : 'User',
@@ -395,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();
@@ -448,7 +882,7 @@ export async function getConversationList(page) {
// so the previous standalone 2 s settle is redundant.
await ensureOnChatGPT(page);
const openSidebar = await page.evaluate(`(() => {
const openSidebar = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button'))
.find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || ''));
if (button instanceof HTMLElement) {
@@ -456,7 +890,7 @@ export async function getConversationList(page) {
return true;
}
return false;
})()`);
})()`)), 'chatgpt sidebar open state');
if (openSidebar) {
try {
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
@@ -480,7 +914,7 @@ export async function getConversationList(page) {
}
async function extractConversationLinks(page) {
const items = await page.evaluate(`(() => {
const items = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
@@ -505,15 +939,13 @@ async function extractConversationLinks(page) {
});
}
return rows;
})()`);
return Array.isArray(items)
? items.map((item, index) => ({
})()`)), 'chatgpt conversation link extraction');
return items.map((item, index) => ({
Index: index + 1,
Id: String(item?.Id || ''),
Title: String(item?.Title || '(untitled)').trim() || '(untitled)',
Url: String(item?.Url || ''),
})).filter((item) => item.Id)
: [];
})).filter((item) => item.Id);
}
function imageMimeFromPath(filePath) {
@@ -556,7 +988,7 @@ async function waitForChatGPTUploadPreview(page, fileNames) {
const namesJson = JSON.stringify(fileNames);
for (let attempt = 0; attempt < 10; attempt += 1) {
await page.wait(1);
const ready = await page.evaluate(`
const ready = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const names = ${namesJson};
const text = document.body ? (document.body.innerText || '') : '';
@@ -569,10 +1001,21 @@ 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');
if (ready) return true;
}
return false;
@@ -606,7 +1049,7 @@ export async function uploadChatGPTImages(page, imagePaths) {
mime: imageMimeFromPath(absPath),
base64: fs.default.readFileSync(absPath).toString('base64'),
}));
const fallbackResult = await page.evaluate(`
const fallbackResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const files = ${JSON.stringify(files)};
const input = document.querySelector('input[type="file"]');
@@ -642,7 +1085,7 @@ export async function uploadChatGPTImages(page, imagePaths) {
}
return { ok: true };
})()
`);
`)), 'chatgpt image upload fallback');
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
}
@@ -656,21 +1099,26 @@ export async function uploadChatGPTImages(page, imagePaths) {
* Check if ChatGPT is still generating a response.
*/
export async function isGenerating(page) {
return await page.evaluate(`
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');
}
/**
* Get visible image URLs from the ChatGPT page (excluding profile/avatar images).
*/
export async function getChatGPTVisibleImageUrls(page) {
return await page.evaluate(`
return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
@@ -680,32 +1128,115 @@ export async function getChatGPTVisibleImageUrls(page) {
return rect.width > 32 && rect.height > 32;
};
const urls = [];
const seen = new Set();
const normalizeUrl = (value) => {
const raw = String(value || '').trim();
if (!raw || raw === 'none') return '';
if (/^(?:https?:|blob:|data:)/i.test(raw)) return raw;
try {
return new URL(raw, window.location.href).href;
} catch {
return raw;
}
};
const addUrl = (value) => {
const src = normalizeUrl(value);
if (!src || seen.has(src)) return;
seen.add(src);
urls.push(src);
};
const isDecorative = (el, src = '') => {
const alt = (el.getAttribute('alt') || '').toLowerCase();
const cls = String(el.className || '').toLowerCase();
const testId = (el.getAttribute('data-testid') || '').toLowerCase();
const label = (el.getAttribute('aria-label') || '').toLowerCase();
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)
);
const urls = [];
const seen = new Set();
for (const img of imgs) {
const src = img.currentSrc || img.src || '';
const alt = (img.getAttribute('alt') || '').toLowerCase();
const cls = (img.className || '').toLowerCase();
const width = img.naturalWidth || img.width || 0;
const height = img.naturalHeight || img.height || 0;
if (!src) continue;
if (alt.includes('avatar') || alt.includes('profile') || alt.includes('logo') || alt.includes('icon')) continue;
if (cls.includes('avatar') || cls.includes('profile') || cls.includes('icon')) continue;
if (isDecorative(img, src)) continue;
if (isUserUploadPreview(img)) continue;
if (width < 128 && height < 128) continue;
if (seen.has(src)) continue;
addUrl(src);
}
seen.add(src);
urls.push(src);
// ChatGPT occasionally renders generated images as CSS background
// thumbnails instead of plain <img> nodes. Treat visible, large
// background images as generated-image candidates too.
for (const el of Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]'))) {
if (!(el instanceof HTMLElement) || !isVisible(el) || isDecorative(el)) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 128 && rect.height < 128) continue;
const backgroundImage = window.getComputedStyle(el).backgroundImage || '';
for (const match of backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)) {
const src = match[2];
if (!src || isDecorative(el, src)) continue;
addUrl(src);
}
}
// 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 {
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;
})()
`);
`)), 'chatgpt visible image url extraction');
}
/**
@@ -723,7 +1254,7 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
let currentUrl = '';
if (convUrl && convUrl.includes('/c/')) {
currentUrl = await page.evaluate('window.location.href').catch(() => '');
currentUrl = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
await page.goto(convUrl);
await page.wait(3);
@@ -766,6 +1297,7 @@ export const __test__ = {
SEND_BUTTON_FALLBACK_SELECTORS,
SEND_BUTTON_LABELS,
CLOSE_SIDEBAR_LABELS,
buildComposerLocatorScript,
isSameChatGPTConversation,
parseChatGPTConversationId,
imageMimeFromPath,
@@ -776,7 +1308,7 @@ export const __test__ = {
*/
export async function getChatGPTImageAssets(page, urls) {
const urlsJson = JSON.stringify(urls);
return await page.evaluate(`
return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(async (targetUrls) => {
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -809,6 +1341,26 @@ export async function getChatGPTImageAssets(page, urls) {
if (img) {
width = img.naturalWidth || img.width || 0;
height = img.naturalHeight || img.height || 0;
} else {
const backgroundEl = Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]')).find(el => {
if (!(el instanceof HTMLElement)) return false;
const backgroundImage = window.getComputedStyle(el).backgroundImage || '';
return Array.from(backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)).some(match => {
const raw = String(match[2] || '').trim();
if (!raw) return false;
if (raw === targetUrl) return true;
try {
return new URL(raw, window.location.href).href === targetUrl;
} catch {
return false;
}
});
});
if (backgroundEl) {
const rect = backgroundEl.getBoundingClientRect();
width = Math.round(rect.width || 0);
height = Math.round(rect.height || 0);
}
}
try {
@@ -850,5 +1402,5 @@ export async function getChatGPTImageAssets(page, urls) {
return results;
})(${urlsJson})
`, urls);
`)), 'chatgpt image asset export');
}
+480 -5
View File
@@ -1,8 +1,10 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { __test__, 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 = [];
@@ -36,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';
@@ -78,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');
});
@@ -85,15 +101,250 @@ 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', () => {
it('inlines the composer locator without returning before caller code runs', () => {
const dom = new JSDOM('<!doctype html><div id="prompt-textarea" contenteditable="true"></div>', {
url: 'https://chatgpt.com/',
runScripts: 'outside-only',
});
const composer = dom.window.document.querySelector('#prompt-textarea');
composer.getBoundingClientRect = () => ({ width: 320, height: 48 });
const result = dom.window.eval(`
(() => {
${__test__.buildComposerLocatorScript()}
const composer = findComposer();
return !!composer && composer.getAttribute(markerAttr) === '1';
})()
`);
expect(result).toBe(true);
});
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 });
@@ -106,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 () => {
@@ -113,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 });
@@ -138,11 +390,178 @@ 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', '关闭边栏']));
});
});
describe('chatgpt generated image detection', () => {
function createDomPage(html, setup = () => {}) {
const dom = new JSDOM(html, {
url: 'https://chatgpt.com/c/demo',
runScripts: 'outside-only',
});
setup(dom.window);
return {
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
};
}
it('detects visible CSS background images when ChatGPT does not render a plain img', async () => {
const page = createDomPage(`
<!doctype html>
<main>
<div class="avatar" style="background-image: url('https://chatgpt.com/avatar.png')"></div>
<button data-testid="generated-image" style="background-image: url('/backend-api/generated/foo.webp')"></button>
</main>
`, (window) => {
for (const el of window.document.querySelectorAll('div, button')) {
el.getBoundingClientRect = () => ({ width: 512, height: 512 });
}
});
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
'https://chatgpt.com/backend-api/generated/foo.webp',
]);
});
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==';
});
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
'data:image/png;base64,ZmFrZQ==',
]);
});
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(`
<!doctype html>
<button style="background-image: url('/backend-api/generated/foo.webp')"></button>
`, (window) => {
const button = window.document.querySelector('button');
button.getBoundingClientRect = () => ({ width: 512, height: 512 });
window.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: async () => new window.Blob(['fake-image'], { type: 'image/webp' }),
});
});
await expect(getChatGPTImageAssets(page, [imageUrl])).resolves.toEqual([
expect.objectContaining({
url: imageUrl,
mimeType: 'image/webp',
width: 512,
height: 512,
}),
]);
});
});
describe('chatgpt image upload helper', () => {
it('validates local images without a browser page', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
@@ -218,7 +637,10 @@ describe('chatgpt image upload helper', () => {
setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn((script) => {
return Promise.resolve({ ok: true });
if (String(script).includes('new DataTransfer()')) {
return Promise.resolve({ ok: true });
}
return Promise.resolve(true);
}),
};
@@ -232,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');
+35
View File
@@ -0,0 +1,35 @@
/**
* Open a Chess.com game in the browser's analysis view. Thin wrapper:
* navigates the bound session to the `/analysis` form of the game URL
* and reports the resolved page URL.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { parseGameUrl } from './utils.js';
cli({
site: 'chess',
name: 'analyze',
access: 'read',
description: 'Open a Chess.com game in the browser analysis board',
domain: 'www.chess.com',
strategy: Strategy.UI,
browser: true,
navigateBefore: false,
args: [
{ name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' },
],
columns: ['kind', 'game_id', 'analysis_url'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for chess analyze');
const { kind, id } = parseGameUrl(kwargs['game-url']);
const analysisUrl = `https://www.chess.com/analysis/game/${kind}/${id}`;
try {
await page.goto(analysisUrl);
await page.wait(2);
} catch (error) {
throw new CommandExecutionError(`Failed to open Chess.com analysis board: ${error?.message || error}`);
}
return [{ kind, game_id: id, analysis_url: analysisUrl }];
},
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import './analyze.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const manifestPath = resolve(__dirname, '../../cli-manifest.json');
function loadManifestCommand(name) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
return manifest.find(cmd => cmd.site === 'chess' && cmd.name === name);
}
function makePage() {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
};
}
describe('chess analyze command', () => {
it('navigates to /analysis/game/<kind>/<id> and reports the URL', async () => {
const cmd = getRegistry().get('chess/analyze');
const page = makePage();
const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' });
expect(rows).toEqual([{ kind: 'live', game_id: '42', analysis_url: 'https://www.chess.com/analysis/game/live/42' }]);
expect(page.goto).toHaveBeenCalledWith('https://www.chess.com/analysis/game/live/42');
});
it('preserves daily kind in the analysis URL', async () => {
const cmd = getRegistry().get('chess/analyze');
const page = makePage();
const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/daily/123' });
expect(rows[0].analysis_url).toBe('https://www.chess.com/analysis/game/daily/123');
});
it('rejects invalid URL with ArgumentError before navigation', async () => {
const cmd = getRegistry().get('chess/analyze');
const page = makePage();
await expect(cmd.func(page, { 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('throws CommandExecutionError without a browser page', async () => {
const cmd = getRegistry().get('chess/analyze');
await expect(cmd.func(null, { 'game-url': 'https://www.chess.com/game/live/42' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when browser navigation fails', async () => {
const cmd = getRegistry().get('chess/analyze');
const page = makePage();
page.goto.mockRejectedValue(new Error('navigation failed'));
await expect(cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('registers with the expected columns + browser flag', () => {
const cmd = getRegistry().get('chess/analyze');
expect(cmd?.columns).toEqual(['kind', 'game_id', 'analysis_url']);
expect(cmd?.browser).toBe(true);
expect(cmd?.navigateBefore).toBe(false);
});
it('build manifest keeps analyze pre-navigation disabled and game source attribution stable', () => {
expect(loadManifestCommand('analyze')).toMatchObject({
navigateBefore: false,
modulePath: 'chess/analyze.js',
sourceFile: 'chess/analyze.js',
});
expect(loadManifestCommand('game')).toMatchObject({
modulePath: 'chess/game.js',
sourceFile: 'chess/game.js',
});
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Chess.com single-game detail by URL, via the internal callback
* endpoint `/callback/{live|daily}/game/{id}`. Returns the canonical
* PGN headers + move data plus per-player metadata.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { UA, formatDate, isPlainObject, parseGameUrl } from './utils.js';
const CALLBACK_BASE = 'https://www.chess.com/callback';
function stringOrEmpty(value) {
return typeof value === 'string' ? value : '';
}
function scalarOrEmpty(value) {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : '';
}
export function summarizeGame({ kind, id, payload }) {
if (!isPlainObject(payload) || !isPlainObject(payload.game)) {
throw new CommandExecutionError('Chess.com callback returned no game payload');
}
const g = payload.game;
if (g.pgnHeaders !== undefined && !isPlainObject(g.pgnHeaders)) {
throw new CommandExecutionError('Chess.com callback returned malformed PGN headers');
}
if (payload.players !== undefined && !isPlainObject(payload.players)) {
throw new CommandExecutionError('Chess.com callback returned malformed player metadata');
}
const players = payload.players || {};
const byColor = {};
for (const slot of ['top', 'bottom']) {
const p = players[slot];
if (p !== undefined && !isPlainObject(p)) {
throw new CommandExecutionError('Chess.com callback returned malformed player metadata');
}
if (p?.color) byColor[p.color] = p;
}
const white = byColor.white || {};
const black = byColor.black || {};
const headers = g.pgnHeaders || {};
const whiteName = stringOrEmpty(white.username) || stringOrEmpty(headers.White);
const blackName = stringOrEmpty(black.username) || stringOrEmpty(headers.Black);
const result = stringOrEmpty(headers.Result);
if (!whiteName || !blackName || !result) {
throw new CommandExecutionError('Chess.com callback payload is missing stable game summary fields');
}
const headerDate = stringOrEmpty(headers.Date);
return {
kind,
game_id: id,
date: headerDate ? headerDate.replace(/\./g, '-') : formatDate(g.endTime),
white: whiteName,
white_rating: scalarOrEmpty(white.rating) || scalarOrEmpty(headers.WhiteElo),
black: blackName,
black_rating: scalarOrEmpty(black.rating) || scalarOrEmpty(headers.BlackElo),
result,
winner_color: stringOrEmpty(g.colorOfWinner),
termination: stringOrEmpty(headers.Termination) || stringOrEmpty(g.resultMessage),
eco: stringOrEmpty(headers.ECO),
time_control: stringOrEmpty(headers.TimeControl) || (typeof g.daysPerTurn === 'number' ? `${g.daysPerTurn}d/turn` : ''),
rated: g.isRated === true,
ply_count: g.plyCount ?? '',
url: `https://www.chess.com/game/${kind}/${id}`,
};
}
cli({
site: 'chess',
name: 'game',
access: 'read',
description: 'Chess.com single-game detail (white, black, result, ECO, time control) by full game URL',
domain: 'www.chess.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' },
],
columns: [
'kind', 'game_id', 'date',
'white', 'white_rating', 'black', 'black_rating',
'result', 'winner_color', 'termination',
'eco', 'time_control', 'rated', 'ply_count', 'url',
],
func: async (kwargs) => {
const { kind, id } = parseGameUrl(kwargs['game-url']);
const url = `${CALLBACK_BASE}/${kind}/game/${id}`;
let resp;
try {
resp = await fetch(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
} catch (error) {
throw new CommandExecutionError(`Failed to fetch Chess.com callback ${url}: ${error?.message || error}`);
}
if (!resp || typeof resp !== 'object') {
throw new CommandExecutionError(`Chess.com callback returned an invalid response object for ${url}`);
}
if (resp.status === 404) {
throw new EmptyResultError(`Chess.com has no ${kind} game with id ${id}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`Chess.com callback returned HTTP ${resp.status} for ${url}`);
}
let payload;
try {
payload = await resp.json();
} catch (error) {
throw new CommandExecutionError(`Chess.com callback returned malformed JSON for ${url}: ${error?.message || error}`);
}
return [summarizeGame({ kind, id, payload })];
},
});
export const __test__ = { parseGameUrl, summarizeGame };
+178
View File
@@ -0,0 +1,178 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './game.js';
const { summarizeGame } = await import('./game.js').then((m) => m.__test__);
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetch(payload, status = 200) {
return vi.fn().mockResolvedValue({
ok: status === 200,
status,
json: () => Promise.resolve(payload),
});
}
describe('chess game command', () => {
it('summarizeGame maps the callback payload to the canonical row shape', () => {
const row = summarizeGame({
kind: 'live',
id: '999',
payload: {
game: {
pgnHeaders: {
Date: '2026.05.17',
White: 'Hikaru',
Black: 'tactic',
Result: '1-0',
ECO: 'A01',
WhiteElo: 3454,
BlackElo: 2869,
TimeControl: '180',
Termination: 'Hikaru won by resignation',
},
colorOfWinner: 'white',
isRated: true,
plyCount: 111,
endTime: 1747584400,
},
players: {
top: { username: 'tactic', color: 'black', rating: 2869 },
bottom: { username: 'Hikaru', color: 'white', rating: 3454 },
},
},
});
expect(row).toMatchObject({
kind: 'live',
game_id: '999',
date: '2026-05-17',
white: 'Hikaru',
white_rating: 3454,
black: 'tactic',
black_rating: 2869,
result: '1-0',
winner_color: 'white',
termination: 'Hikaru won by resignation',
eco: 'A01',
time_control: '180',
rated: true,
ply_count: 111,
url: 'https://www.chess.com/game/live/999',
});
});
it('summarizeGame falls back to pgnHeaders when players are missing', () => {
const row = summarizeGame({
kind: 'daily',
id: '1',
payload: {
game: {
pgnHeaders: { White: 'A', Black: 'B', Result: '1/2-1/2', WhiteElo: 1200, BlackElo: 1300 },
colorOfWinner: '',
isRated: false,
daysPerTurn: 3,
},
players: {},
},
});
expect(row.white).toBe('A');
expect(row.black_rating).toBe(1300);
expect(row.time_control).toBe('3d/turn');
expect(row.rated).toBe(false);
});
it('summarizeGame throws CommandExecutionError on missing game payload', () => {
expect(() => summarizeGame({ kind: 'live', id: '1', payload: {} })).toThrow(CommandExecutionError);
expect(() => summarizeGame({ kind: 'live', id: '1', payload: null })).toThrow(CommandExecutionError);
});
it('summarizeGame throws CommandExecutionError on malformed nested payloads', () => {
expect(() => summarizeGame({
kind: 'live',
id: '1',
payload: { game: { pgnHeaders: [] } },
})).toThrow(CommandExecutionError);
expect(() => summarizeGame({
kind: 'live',
id: '1',
payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: [] },
})).toThrow(CommandExecutionError);
});
it('summarizeGame requires stable players and result evidence', () => {
expect(() => summarizeGame({
kind: 'live',
id: '1',
payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: {} },
})).toThrow(CommandExecutionError);
expect(() => summarizeGame({
kind: 'live',
id: '1',
payload: { game: { pgnHeaders: { White: 'A', Result: '1-0' } }, players: {} },
})).toThrow(CommandExecutionError);
});
it('command fetches the callback URL and returns a single row', async () => {
const fetchMock = mockFetch({
game: { pgnHeaders: { White: 'A', Black: 'B', Result: '1-0', WhiteElo: 100, BlackElo: 90 } },
players: {},
});
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('chess/game');
const rows = await cmd.func({ 'game-url': 'https://www.chess.com/game/live/42' });
expect(rows).toHaveLength(1);
expect(rows[0].url).toBe('https://www.chess.com/game/live/42');
expect(fetchMock).toHaveBeenCalledWith(
'https://www.chess.com/callback/live/game/42',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('command surfaces 404 as EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
const cmd = getRegistry().get('chess/game');
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
.rejects.toBeInstanceOf(EmptyResultError);
});
it('command surfaces non-2xx as CommandExecutionError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
const cmd = getRegistry().get('chess/game');
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('command maps fetch and JSON failures to CommandExecutionError', async () => {
const cmd = getRegistry().get('chess/game');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
.rejects.toBeInstanceOf(CommandExecutionError);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('bad json')),
}));
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('command maps wrong-shape callback JSON to CommandExecutionError', async () => {
vi.stubGlobal('fetch', mockFetch([]));
const cmd = getRegistry().get('chess/game');
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('rejects invalid URL with ArgumentError before any fetch', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('chess/game');
await expect(cmd.func({ 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Chess.com recent games from monthly archives. Walks the archive
* list newest-first and fetches as few months as needed to fill --limit.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { chessApi, validateUsername, mapGameRow } from './utils.js';
const MAX_LIMIT = 100;
const MAX_ARCHIVE_FETCHES = 6;
function parseLimit(value) {
if (value === undefined || value === null || value === '') return 10;
const limit = Number(value);
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
}
return limit;
}
cli({
site: 'chess',
name: 'games',
access: 'read',
description: 'Chess.com recent games for a player, newest first',
domain: 'api.chess.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username' },
{ name: 'limit', type: 'int', default: 10, help: `Number of recent games (1-${MAX_LIMIT})` },
],
columns: ['date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result', 'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black', 'eco', 'opening_name', 'url'],
func: async (kwargs) => {
const username = validateUsername(kwargs.username);
const limit = parseLimit(kwargs.limit);
const archivesList = await chessApi(`/player/${encodeURIComponent(username)}/games/archives`);
if (!Array.isArray(archivesList.archives)) {
throw new CommandExecutionError('Chess.com archives payload is missing archives array');
}
const archives = archivesList.archives.slice().reverse();
if (archives.length === 0) {
throw new EmptyResultError(`Chess.com has no game archives for ${username}`);
}
const rows = [];
for (let i = 0; i < archives.length && i < MAX_ARCHIVE_FETCHES && rows.length < limit; i++) {
if (typeof archives[i] !== 'string' || !archives[i].startsWith('https://api.chess.com/pub/player/')) {
throw new CommandExecutionError('Chess.com archives payload contains an unexpected archive URL');
}
const monthly = await chessApi(archives[i]);
if (!Array.isArray(monthly.games)) {
throw new CommandExecutionError('Chess.com monthly archive payload is missing games array');
}
const games = monthly.games.slice().reverse();
for (const g of games) {
rows.push(mapGameRow(g, username));
if (rows.length >= limit) break;
}
}
if (rows.length === 0) {
throw new EmptyResultError(`Chess.com has games archives for ${username} but no games in the most recent ${MAX_ARCHIVE_FETCHES} months`);
}
return rows.slice(0, limit);
},
});
export const __test__ = { parseLimit };
+164
View File
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './games.js';
const { parseLimit } = await import('./games.js').then((m) => m.__test__);
afterEach(() => {
vi.unstubAllGlobals();
});
function fetchFor(map) {
return vi.fn().mockImplementation((url) => {
if (map.has(url)) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(map.get(url)) });
}
return Promise.resolve({ ok: false, status: 404 });
});
}
function game(white, whiteRating, black, blackRating, endTime, extra = {}) {
return {
url: `https://www.chess.com/game/live/${endTime}`,
end_time: endTime,
time_class: 'blitz',
rated: true,
eco: 'C50',
white: { username: white, rating: whiteRating, result: 'win' },
black: { username: black, rating: blackRating, result: 'resigned' },
...extra,
};
}
describe('chess games command', () => {
it('parseLimit accepts 1-100, rejects everything else', () => {
expect(parseLimit(undefined)).toBe(10);
expect(parseLimit(1)).toBe(1);
expect(parseLimit(100)).toBe(100);
expect(() => parseLimit(0)).toThrow(ArgumentError);
expect(() => parseLimit(101)).toThrow(ArgumentError);
expect(() => parseLimit(1.5)).toThrow(ArgumentError);
expect(() => parseLimit('abc')).toThrow(ArgumentError);
});
it('returns recent games newest-first sliced to --limit', async () => {
const map = new Map([
['https://api.chess.com/pub/player/hikaru/games/archives', {
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/04', 'https://api.chess.com/pub/player/hikaru/games/2026/05'],
}],
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
games: [
game('Hikaru', 3286, 'A', 2900, 1777737000),
game('Hikaru', 3286, 'B', 2950, 1777737500),
game('Hikaru', 3286, 'C', 3000, 1777737900),
],
}],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
const rows = await cmd.func({ username: 'Hikaru', limit: 2 });
expect(rows).toHaveLength(2);
// archive is reversed (newest month first), games within are reversed
// so the first row corresponds to the LAST game in the JSON array.
expect(rows[0].opponent).toBe('C');
expect(rows[1].opponent).toBe('B');
});
it('walks multiple months until --limit is filled', async () => {
const map = new Map([
['https://api.chess.com/pub/player/hikaru/games/archives', {
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/03', 'https://api.chess.com/pub/player/hikaru/games/2026/04'],
}],
['https://api.chess.com/pub/player/hikaru/games/2026/04', {
games: [game('Hikaru', 3286, 'A', 2900, 1777737000)],
}],
['https://api.chess.com/pub/player/hikaru/games/2026/03', {
games: [game('Hikaru', 3286, 'B', 2950, 1774000000)],
}],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
const rows = await cmd.func({ username: 'Hikaru', limit: 2 });
expect(rows.map((r) => r.opponent)).toEqual(['A', 'B']);
});
it('throws EmptyResultError when archives list is empty', async () => {
const map = new Map([
['https://api.chess.com/pub/player/someuser/games/archives', { archives: [] }],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('throws CommandExecutionError when archives payload is wrong-shape', async () => {
const map = new Map([
['https://api.chess.com/pub/player/someuser/games/archives', { archives: {} }],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when monthly archive games payload is wrong-shape', async () => {
const map = new Map([
['https://api.chess.com/pub/player/hikaru/games/archives', {
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
}],
['https://api.chess.com/pub/player/hikaru/games/2026/05', { games: null }],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when a game row lacks stable identity', async () => {
const map = new Map([
['https://api.chess.com/pub/player/hikaru/games/archives', {
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
}],
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
games: [{
end_time: 1777737000,
white: { username: 'Hikaru', rating: 3286, result: 'win' },
black: { username: 'A', rating: 2900, result: 'resigned' },
}],
}],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when a game row does not include the requested player', async () => {
const map = new Map([
['https://api.chess.com/pub/player/hikaru/games/archives', {
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
}],
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
games: [game('A', 2900, 'B', 2800, 1777737000)],
}],
]);
vi.stubGlobal('fetch', fetchFor(map));
const cmd = getRegistry().get('chess/games');
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws ArgumentError on invalid username before any fetch', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('chess/games');
await expect(cmd.func({ username: 'a b', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('registers with the expected columns', () => {
const cmd = getRegistry().get('chess/games');
expect(cmd?.columns).toEqual([
'date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result',
'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black',
'eco', 'opening_name', 'url',
]);
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Chess.com player stats across game kinds (rapid / blitz / bullet /
* daily / chess960 / etc) via the public stats endpoint.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { chessApi, validateUsername, summarizeStats } from './utils.js';
const KINDS = ['chess_rapid', 'chess_blitz', 'chess_bullet', 'chess_daily', 'chess960_daily', 'chess_daily_960'];
cli({
site: 'chess',
name: 'stats',
access: 'read',
description: 'Chess.com player ratings + win/loss record across game kinds',
domain: 'api.chess.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username (case-insensitive)' },
],
columns: ['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws'],
func: async (kwargs) => {
const username = validateUsername(kwargs.username);
const stats = await chessApi(`/player/${encodeURIComponent(username)}/stats`);
const rows = KINDS.map((k) => summarizeStats(stats, k)).filter(Boolean);
if (rows.length === 0) {
throw new EmptyResultError(`Chess.com returned no stats for ${username}`);
}
return rows;
},
});
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './stats.js';
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetch(body) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
}
describe('chess stats command', () => {
it('rejects empty username via validateUsername', async () => {
const cmd = getRegistry().get('chess/stats');
await expect(cmd.func({ username: '' })).rejects.toBeInstanceOf(ArgumentError);
});
it('rejects invalid username characters', async () => {
const cmd = getRegistry().get('chess/stats');
await expect(cmd.func({ username: 'user name' })).rejects.toBeInstanceOf(ArgumentError);
});
it('returns one row per known game kind populated in the stats response', async () => {
const fetchMock = mockFetch({
chess_rapid: { last: { rating: 1700 }, best: { rating: 1800 }, record: { win: 50, loss: 20, draw: 5 } },
chess_blitz: { last: { rating: 1500 }, best: { rating: 1600 }, record: { win: 100, loss: 80, draw: 10 } },
});
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('chess/stats');
const rows = await cmd.func({ username: 'someuser' });
expect(rows).toHaveLength(2);
expect(rows[0].kind).toBe('rapid');
expect(rows[1].kind).toBe('blitz');
expect(fetchMock).toHaveBeenCalledWith(
'https://api.chess.com/pub/player/someuser/stats',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('lowercases username in the URL', async () => {
const fetchMock = mockFetch({ chess_rapid: { last: { rating: 1 }, best: {}, record: {} } });
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('chess/stats');
await cmd.func({ username: 'MixedCase' });
expect(fetchMock).toHaveBeenCalledWith(
'https://api.chess.com/pub/player/mixedcase/stats',
expect.any(Object),
);
});
it('throws EmptyResultError when the stats response has no known kinds', async () => {
vi.stubGlobal('fetch', mockFetch({}));
const cmd = getRegistry().get('chess/stats');
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
});
it('throws CommandExecutionError when a populated stats kind is malformed', async () => {
vi.stubGlobal('fetch', mockFetch({ chess_rapid: 'bad' }));
const cmd = getRegistry().get('chess/stats');
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws EmptyResultError on HTTP 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
const cmd = getRegistry().get('chess/stats');
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
});
it('registers with the expected columns', () => {
const cmd = getRegistry().get('chess/stats');
expect(cmd?.columns).toEqual(['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws']);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Shared helpers for the public Chess.com REST API
* (https://api.chess.com/pub/). No auth, no rate-limit headers.
*/
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const API_BASE = 'https://api.chess.com/pub';
export const UA = 'Mozilla/5.0 (compatible; opencli/1.0)';
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,25}$/;
const GAME_URL_RE = /^https:\/\/www\.chess\.com\/game\/(live|daily)\/(\d+)/i;
export function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isOptionalPlainObject(value) {
return value === undefined || value === null || isPlainObject(value);
}
export function validateUsername(value) {
const s = String(value ?? '').trim().toLowerCase();
if (!s) throw new ArgumentError('<username> is required');
if (!USERNAME_RE.test(s)) {
throw new ArgumentError(`Invalid Chess.com username "${value}"`, 'Usernames are 3-25 chars: a-z, 0-9, hyphen, underscore.');
}
return s;
}
export function parseGameUrl(value) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError('<game-url> is required');
const m = s.match(GAME_URL_RE);
if (!m) {
throw new ArgumentError(
`Invalid Chess.com game URL: "${value}"`,
'Expected https://www.chess.com/game/live/<id> or https://www.chess.com/game/daily/<id>.',
);
}
return { kind: m[1].toLowerCase(), id: m[2] };
}
export async function chessApi(path, fetchImpl = fetch) {
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
let resp;
try {
resp = await fetchImpl(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
} catch (error) {
throw new CommandExecutionError(`Failed to fetch Chess.com API ${url}: ${error?.message || error}`);
}
if (!resp || typeof resp !== 'object') {
throw new CommandExecutionError(`Chess.com API returned an invalid response object for ${url}`);
}
if (resp.status === 404) throw new EmptyResultError(`Chess.com returned 404 for ${url}`);
if (!resp.ok) throw new CommandExecutionError(`Chess.com API returned HTTP ${resp.status} for ${url}`);
let payload;
try {
payload = await resp.json();
} catch (error) {
throw new CommandExecutionError(`Chess.com API returned malformed JSON for ${url}: ${error?.message || error}`);
}
if (!isPlainObject(payload)) {
throw new CommandExecutionError(`Chess.com API returned an unexpected payload shape for ${url}`);
}
return payload;
}
/** Pull rating + record fields out of a stats sub-object (`chess_rapid` etc). */
export function summarizeStats(stats, kind) {
const k = stats?.[kind];
if (!k) return null;
if (!isPlainObject(k)) {
throw new CommandExecutionError(`Chess.com stats payload for ${kind} is not an object`);
}
if (!isOptionalPlainObject(k.last)) {
throw new CommandExecutionError(`Chess.com stats payload for ${kind}.last is not an object`);
}
if (!isOptionalPlainObject(k.best)) {
throw new CommandExecutionError(`Chess.com stats payload for ${kind}.best is not an object`);
}
if (!isOptionalPlainObject(k.record)) {
throw new CommandExecutionError(`Chess.com stats payload for ${kind}.record is not an object`);
}
const record = isPlainObject(k.record) ? k.record : {};
return {
kind: kind.replace(/^chess_/, ''),
rating_current: k.last?.rating ?? '',
rating_best: k.best?.rating ?? '',
wins: record.win ?? '',
losses: record.loss ?? '',
draws: record.draw ?? '',
};
}
/** Parse an end_time epoch (seconds) into YYYY-MM-DD. */
export function formatDate(epochSeconds) {
if (!epochSeconds || typeof epochSeconds !== 'number') return '';
return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
}
/**
* Pull "Reti Opening: Nimzo-Larsen Variation" out of the Chess.com eco URL
* (`https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-...`).
* Returns '' for short-code eco values (`A01`) where no name is encoded.
*/
export function openingName(eco) {
if (typeof eco !== 'string' || !eco.startsWith('http')) return '';
const tail = eco.replace(/\/+$/, '').split('/').pop() || '';
if (!tail) return '';
const namePart = tail.match(/^([^.]+?)(?:-\d|\.\.\.|$)/);
const cleaned = (namePart ? namePart[1] : tail).replace(/-/g, ' ').trim();
return cleaned;
}
/**
* Map a Chess.com game record (from the monthly archive) to a flat row.
* The viewer perspective controls win/loss orientation.
*/
export function mapGameRow(game, viewerUsername) {
if (!isPlainObject(game)) {
throw new CommandExecutionError('Chess.com game archive entry is not an object');
}
if (typeof game.url !== 'string' || !/^https:\/\/www\.chess\.com\/game\/(?:live|daily)\/\d+(?:$|[/?#])/i.test(game.url)) {
throw new CommandExecutionError('Chess.com game archive entry is missing a stable game URL');
}
const white = game?.white || {};
const black = game?.black || {};
if (!isPlainObject(white) || !isPlainObject(black)) {
throw new CommandExecutionError('Chess.com game archive entry has malformed player objects');
}
if (typeof white.username !== 'string' || !white.username.trim() || typeof black.username !== 'string' || !black.username.trim()) {
throw new CommandExecutionError('Chess.com game archive entry is missing stable player identities');
}
const viewerLower = String(viewerUsername || '').toLowerCase();
const viewerIsWhite = String(white.username || '').toLowerCase() === viewerLower;
const viewerIsBlack = String(black.username || '').toLowerCase() === viewerLower;
if (!viewerIsWhite && !viewerIsBlack) {
throw new CommandExecutionError('Chess.com game archive entry does not include the requested player');
}
const me = viewerIsWhite ? white : black;
const opp = viewerIsWhite ? black : white;
const eco = game?.eco || '';
return {
date: formatDate(game?.end_time),
time_class: game?.time_class || '',
rated: game?.rated === true,
my_color: viewerIsWhite ? 'white' : 'black',
my_rating: me?.rating ?? '',
my_result: me?.result || '',
opponent: opp?.username || '',
opponent_rating: opp?.rating ?? '',
accuracy_white: typeof game?.accuracies?.white === 'number' ? game.accuracies.white : '',
accuracy_black: typeof game?.accuracies?.black === 'number' ? game.accuracies.black : '',
eco,
opening_name: openingName(eco),
url: game?.url || '',
};
}
export const __test__ = {
validateUsername,
parseGameUrl,
isPlainObject,
isOptionalPlainObject,
chessApi,
summarizeStats,
formatDate,
mapGameRow,
openingName,
};
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { __test__ } from './utils.js';
const { validateUsername, parseGameUrl, chessApi, summarizeStats, formatDate, mapGameRow, openingName } = __test__;
describe('chess utils', () => {
it('validateUsername lowercases and accepts 3-25 char usernames', () => {
expect(validateUsername('Hikaru')).toBe('hikaru');
expect(validateUsername('MagnusCarlsen')).toBe('magnuscarlsen');
expect(validateUsername('a-b_c')).toBe('a-b_c');
});
it('validateUsername rejects empty / too-short / invalid chars', () => {
expect(() => validateUsername('')).toThrow(ArgumentError);
expect(() => validateUsername('ab')).toThrow(ArgumentError);
expect(() => validateUsername('user name')).toThrow(ArgumentError);
expect(() => validateUsername('a'.repeat(30))).toThrow(ArgumentError);
});
it('parseGameUrl parses both live and daily game URL forms', () => {
expect(parseGameUrl('https://www.chess.com/game/live/168842570216'))
.toEqual({ kind: 'live', id: '168842570216' });
expect(parseGameUrl('https://www.chess.com/game/daily/947761777'))
.toEqual({ kind: 'daily', id: '947761777' });
expect(parseGameUrl('https://www.chess.com/game/LIVE/1'))
.toEqual({ kind: 'live', id: '1' });
});
it('parseGameUrl strips trailing path / query off the URL', () => {
expect(parseGameUrl('https://www.chess.com/game/live/123/something?ref=share'))
.toEqual({ kind: 'live', id: '123' });
});
it('parseGameUrl rejects empty / non-URL / unsupported-kind inputs', () => {
expect(() => parseGameUrl('')).toThrow(ArgumentError);
expect(() => parseGameUrl(' ')).toThrow(ArgumentError);
expect(() => parseGameUrl('123')).toThrow(ArgumentError);
expect(() => parseGameUrl('https://www.chess.com/club/123')).toThrow(ArgumentError);
expect(() => parseGameUrl('https://lichess.org/abc')).toThrow(ArgumentError);
});
it('summarizeStats projects rating + record fields', () => {
const stats = {
chess_rapid: {
last: { rating: 1600 },
best: { rating: 1800 },
record: { win: 100, loss: 50, draw: 10 },
},
};
expect(summarizeStats(stats, 'chess_rapid')).toEqual({
kind: 'rapid',
rating_current: 1600,
rating_best: 1800,
wins: 100,
losses: 50,
draws: 10,
});
});
it('summarizeStats returns null for missing kind', () => {
expect(summarizeStats({}, 'chess_rapid')).toBeNull();
expect(summarizeStats({ chess_blitz: {} }, 'chess_rapid')).toBeNull();
});
it('summarizeStats typed-fails malformed populated kind objects', () => {
expect(() => summarizeStats({ chess_rapid: 'bad' }, 'chess_rapid')).toThrow(CommandExecutionError);
expect(() => summarizeStats({ chess_rapid: { record: [] } }, 'chess_rapid')).toThrow(CommandExecutionError);
});
it('summarizeStats coerces missing numeric fields to empty string', () => {
const row = summarizeStats({ chess_daily: { last: {}, record: {} } }, 'chess_daily');
expect(row).toEqual({
kind: 'daily',
rating_current: '',
rating_best: '',
wins: '',
losses: '',
draws: '',
});
});
it('formatDate converts epoch seconds to YYYY-MM-DD', () => {
expect(formatDate(1777737679)).toBe('2026-05-02');
expect(formatDate(0)).toBe('');
expect(formatDate(null)).toBe('');
expect(formatDate('not-a-number')).toBe('');
});
it('mapGameRow returns rows from the viewer perspective when viewer is white', () => {
const game = {
url: 'https://www.chess.com/game/live/123',
end_time: 1777737679,
time_class: 'blitz',
rated: true,
eco: 'C50',
accuracies: { white: 87.73, black: 80.23 },
white: { username: 'Hikaru', rating: 3286, result: 'win' },
black: { username: 'Magnus', rating: 2900, result: 'resigned' },
};
expect(mapGameRow(game, 'Hikaru')).toEqual({
date: '2026-05-02',
time_class: 'blitz',
rated: true,
my_color: 'white',
my_rating: 3286,
my_result: 'win',
opponent: 'Magnus',
opponent_rating: 2900,
accuracy_white: 87.73,
accuracy_black: 80.23,
eco: 'C50',
opening_name: '',
url: 'https://www.chess.com/game/live/123',
});
});
it('mapGameRow leaves accuracy fields empty when chess.com did not compute them', () => {
const game = {
url: 'https://www.chess.com/game/live/123',
white: { username: 'A', rating: 1, result: 'win' },
black: { username: 'B', rating: 1, result: 'resigned' },
};
const row = mapGameRow(game, 'A');
expect(row.accuracy_white).toBe('');
expect(row.accuracy_black).toBe('');
});
it('mapGameRow parses opening_name from the chess.com eco URL', () => {
const game = {
url: 'https://www.chess.com/game/live/123',
eco: 'https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-3.Bb2-Bg7-4.d4',
white: { username: 'A', rating: 1, result: 'win' },
black: { username: 'B', rating: 1, result: 'resigned' },
};
const row = mapGameRow(game, 'A');
expect(row.opening_name).toBe('Reti Opening Nimzo Larsen Variation');
});
it('openingName helper returns clean human-readable name from URL form', () => {
expect(openingName('https://www.chess.com/openings/Sicilian-Defense')).toBe('Sicilian Defense');
expect(openingName('https://www.chess.com/openings/Kings-Indian-Defense-Semi-Classical-Variation...7.O-O'))
.toBe('Kings Indian Defense Semi Classical Variation');
expect(openingName('https://www.chess.com/openings/French-Defense-Advance-Variation-3...c5-4.c3'))
.toBe('French Defense Advance Variation');
});
it('openingName returns empty for short-code eco or missing input', () => {
expect(openingName('A01')).toBe('');
expect(openingName('')).toBe('');
expect(openingName(undefined)).toBe('');
expect(openingName(null)).toBe('');
});
it('mapGameRow flips perspective when viewer is black', () => {
const game = {
url: 'https://www.chess.com/game/live/123',
white: { username: 'Hikaru', rating: 3286, result: 'win' },
black: { username: 'Magnus', rating: 2900, result: 'resigned' },
};
const row = mapGameRow(game, 'Magnus');
expect(row.my_color).toBe('black');
expect(row.my_result).toBe('resigned');
expect(row.opponent).toBe('Hikaru');
});
it('mapGameRow matches viewer case-insensitively', () => {
const game = {
url: 'https://www.chess.com/game/live/123',
white: { username: 'Hikaru', rating: 3286, result: 'win' },
black: { username: 'Magnus', rating: 2900, result: 'resigned' },
};
expect(mapGameRow(game, 'hikaru').my_color).toBe('white');
expect(mapGameRow(game, 'MAGNUS').my_color).toBe('black');
});
it('mapGameRow typed-fails when viewer is neither player', () => {
const game = {
url: 'https://www.chess.com/game/live/123',
white: { username: 'A', rating: 1000, result: 'win' },
black: { username: 'B', rating: 1100, result: 'resigned' },
};
expect(() => mapGameRow(game, 'C')).toThrow(CommandExecutionError);
});
it('mapGameRow handles missing optional fields without throwing', () => {
const row = mapGameRow({
url: 'https://www.chess.com/game/live/123',
white: { username: 'x' },
black: { username: 'y' },
}, 'x');
expect(row.date).toBe('');
expect(row.url).toBe('https://www.chess.com/game/live/123');
expect(row.eco).toBe('');
});
it('mapGameRow typed-fails missing stable URL or player identity', () => {
expect(() => mapGameRow({
white: { username: 'x' },
black: { username: 'y' },
}, 'x')).toThrow(CommandExecutionError);
expect(() => mapGameRow({
url: 'https://www.chess.com/game/live/123',
white: { username: 'x' },
black: {},
}, 'x')).toThrow(CommandExecutionError);
});
it('chessApi maps network, malformed JSON, and wrong-shape payloads to typed errors', async () => {
await expect(chessApi('/x', async () => { throw new TypeError('network down'); }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(chessApi('/x', async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('bad json'); },
}))).rejects.toBeInstanceOf(CommandExecutionError);
await expect(chessApi('/x', async () => ({
ok: true,
status: 200,
json: async () => [],
}))).rejects.toBeInstanceOf(CommandExecutionError);
});
it('chessApi preserves 404 as empty and non-2xx as command execution errors', async () => {
await expect(chessApi('/x', async () => ({ ok: false, status: 404 })))
.rejects.toBeInstanceOf(EmptyResultError);
await expect(chessApi('/x', async () => ({ ok: false, status: 500 })))
.rejects.toBeInstanceOf(CommandExecutionError);
});
});
+55
View File
@@ -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);
},
});
+270
View File
@@ -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 };
}
+37
View File
@@ -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
View File
@@ -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 }];
},
});
+30
View File
@@ -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);
+86
View File
@@ -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,
}];
},
});
+59
View File
@@ -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',
+195
View File
@@ -0,0 +1,195 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { describe, expect, it, afterEach, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './page.js';
import './search.js';
import './create.js';
import './update.js';
const ENV_KEYS = [
'ATLASSIAN_CONFLUENCE_BASE_URL',
'ATLASSIAN_DEPLOYMENT',
'ATLASSIAN_EMAIL',
'ATLASSIAN_API_TOKEN',
'ATLASSIAN_USERNAME',
'ATLASSIAN_PASSWORD',
'ATLASSIAN_PAT',
];
function clearEnv() {
for (const key of ENV_KEYS) delete process.env[key];
}
function setCloudEnv() {
clearEnv();
process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://team.atlassian.net/wiki';
process.env.ATLASSIAN_DEPLOYMENT = 'cloud';
process.env.ATLASSIAN_EMAIL = 'bot@example.com';
process.env.ATLASSIAN_API_TOKEN = 'secret';
}
function jsonResponse(body) {
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
}
async function withTempMarkdown(markdown, fn) {
const dir = await mkdtemp(join(tmpdir(), 'opencli-confluence-'));
const file = join(dir, 'doc.md');
await writeFile(file, markdown);
try {
return await fn(file);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
afterEach(() => {
clearEnv();
vi.unstubAllGlobals();
});
describe('confluence commands', () => {
it('registers expected REST commands', () => {
for (const name of ['page', 'search', 'create', 'update']) {
const cmd = getRegistry().get(`confluence/${name}`);
expect(cmd).toBeDefined();
expect(cmd.browser).toBe(false);
expect(cmd.strategy).toBe('public');
}
});
it('requires --execute before create performs remote writes', async () => {
const cmd = getRegistry().get('confluence/create');
await expect(cmd.func({ space: '123', title: 'Doc', file: 'doc.md' })).rejects.toThrow(/--execute/);
});
it('creates a Cloud page from Markdown storage', async () => {
setCloudEnv();
await withTempMarkdown('# RCA\n\n- Payment failed', async (file) => {
const fetchMock = vi.fn(async (url, init) => {
expect(String(url)).toBe('https://team.atlassian.net/wiki/api/v2/pages');
const payload = JSON.parse(init.body);
expect(payload).toMatchObject({ spaceId: '987', title: 'PROJ-1 RCA' });
expect(payload.body.value).toContain('<h1>RCA</h1>');
expect(payload.body.value.replace(/\s*\n\s*/g, '')).toContain('<li>Payment failed</li>');
return jsonResponse({
id: '555',
title: 'PROJ-1 RCA',
status: 'current',
spaceId: '987',
version: { number: 1, createdAt: '2026-05-01T00:00:00Z' },
body: { storage: { value: '<h1>RCA</h1>' } },
_links: { webui: '/spaces/ENG/pages/555' },
});
});
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('confluence/create');
const rows = await cmd.func({ space: '987', title: 'PROJ-1 RCA', file, representation: 'markdown', execute: true });
expect(rows[0]).toMatchObject({ status: 'created', id: '555', title: 'PROJ-1 RCA', version: 1 });
expect(rows[0].url).toBe('https://team.atlassian.net/wiki/spaces/ENG/pages/555');
});
});
it('updates a page by incrementing the current version', async () => {
setCloudEnv();
await withTempMarkdown('Updated body', async (file) => {
const fetchMock = vi.fn(async (url, init) => {
if (init.method === 'GET') {
expect(String(url)).toBe('https://team.atlassian.net/wiki/api/v2/pages/555?body-format=storage');
return jsonResponse({
id: '555',
title: 'Existing',
status: 'current',
version: { number: 7 },
body: { storage: { value: '<p>Old</p>' } },
});
}
expect(init.method).toBe('PUT');
const payload = JSON.parse(init.body);
expect(payload.version).toMatchObject({ number: 8, message: 'Sync from Jira' });
expect(payload.title).toBe('Existing');
return jsonResponse({
id: '555',
title: 'Existing',
status: 'current',
version: { number: 8 },
body: { storage: { value: '<p>Updated body</p>' } },
});
});
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('confluence/update');
const rows = await cmd.func({ id: '555', file, representation: 'markdown', 'version-message': 'Sync from Jira', execute: true });
expect(rows[0]).toMatchObject({ status: 'updated', id: '555', version: 8 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
it('does not update a Confluence page when the current version is malformed', async () => {
setCloudEnv();
await withTempMarkdown('Updated body', async (file) => {
const fetchMock = vi.fn(async (url, init) => {
expect(init.method).toBe('GET');
return jsonResponse({
id: '555',
title: 'Existing',
status: 'current',
body: { storage: { value: '<p>Old</p>' } },
});
});
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('confluence/update');
await expect(cmd.func({ id: '555', file, representation: 'markdown', execute: true }))
.rejects.toBeInstanceOf(CommandExecutionError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
it('searches with CQL scoped to a Data Center space', async () => {
clearEnv();
process.env.ATLASSIAN_CONFLUENCE_BASE_URL = 'https://conf.example.com/confluence';
process.env.ATLASSIAN_DEPLOYMENT = 'datacenter';
process.env.ATLASSIAN_PAT = 'pat';
const fetchMock = vi.fn(async (url) => {
const parsed = new URL(String(url));
expect(parsed.searchParams.get('cql')).toBe('space = "ENG" and (type = page)');
return jsonResponse({
results: [{
content: {
id: '123',
type: 'page',
status: 'current',
title: 'Runbook',
space: { key: 'ENG' },
_links: { webui: '/display/ENG/Runbook' },
},
lastModified: '2026-05-02T00:00:00Z',
}],
});
});
vi.stubGlobal('fetch', fetchMock);
const cmd = getRegistry().get('confluence/search');
const rows = await cmd.func({ cql: 'type = page', space: 'ENG', limit: 10 });
expect(rows[0]).toMatchObject({ id: '123', title: 'Runbook', spaceKey: 'ENG' });
expect(rows[0].url).toBe('https://conf.example.com/confluence/display/ENG/Runbook');
});
it('separates Confluence search empty results from malformed search payloads', async () => {
setCloudEnv();
const cmd = getRegistry().get('confluence/search');
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })));
await expect(cmd.func({ cql: 'type = page', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ items: [] })));
await expect(cmd.func({ cql: 'type = page', limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails typed when Confluence page payload lacks stable page identity', async () => {
setCloudEnv();
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ title: 'Missing id', version: { number: 1 } })));
const cmd = getRegistry().get('confluence/page');
await expect(cmd.func({ id: '555' })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
import { confluenceConfig, createPagePayload, normalizeConfluencePage, readPageBodyFile } from './shared.js';
import { atlassianRequest } from '../_atlassian/shared.js';
cli({
site: 'confluence',
name: 'create',
access: 'write',
description: 'Create a Confluence page from Markdown or storage XHTML',
domain: 'atlassian.net',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'space', type: 'string', required: true, help: 'Cloud space id, or Data Center space key' },
{ name: 'title', type: 'string', required: true, help: 'Page title' },
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
{ name: 'parent', type: 'string', help: 'Optional parent page id' },
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
{ name: 'execute', type: 'boolean', help: 'Actually create the remote page' },
],
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
func: async (args) => {
requireExecute(args, 'confluence create');
requireString(args.space, 'Confluence space');
requireString(args.title, 'Confluence page title');
const storage = await readPageBodyFile(args);
const config = confluenceConfig();
const payload = createPagePayload(config, args, storage);
const path = config.deployment === 'cloud' ? '/api/v2/pages' : '/rest/api/content';
const page = requirePayloadObject(await atlassianRequest(config, path, {
method: 'POST',
body: payload,
label: 'confluence create',
}), 'confluence create');
const normalized = normalizeConfluencePage(page, config);
return [{ ...normalized, pageStatus: normalized.status, status: 'created' }];
},
});
+23
View File
@@ -0,0 +1,23 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { requireString } from '../_atlassian/shared.js';
import { confluenceConfig, getPage, normalizeConfluencePage } from './shared.js';
cli({
site: 'confluence',
name: 'page',
access: 'read',
description: 'Confluence page by id with storage and Markdown body',
domain: 'atlassian.net',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
],
columns: ['id', 'title', 'status', 'spaceId', 'spaceKey', 'version', 'url'],
func: async (args) => {
const config = confluenceConfig();
const id = requireString(args.id, 'Confluence page id');
const page = await getPage(config, id);
return [normalizeConfluencePage(page, config)];
},
});
+34
View File
@@ -0,0 +1,34 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { atlassianRequest, parseLimit, queryString, requireNonEmptyRows, requireString } from '../_atlassian/shared.js';
import { confluenceConfig, confluenceResults, normalizeSearchResult, withSpaceCql } from './shared.js';
cli({
site: 'confluence',
name: 'search',
access: 'read',
description: 'Search Confluence content with CQL',
domain: 'atlassian.net',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'cql', positional: true, required: true, help: 'CQL query, e.g. "type = page and title ~ \\"RCA\\""' },
{ name: 'space', type: 'string', help: 'Limit search to a Confluence space key' },
{ name: 'limit', type: 'int', default: 20, help: 'Max results to return (1-100)' },
],
columns: ['id', 'title', 'type', 'spaceKey', 'status', 'lastModified', 'url'],
func: async (args) => {
const config = confluenceConfig();
const cql = withSpaceCql(requireString(args.cql, 'CQL'), args.space);
const limit = parseLimit(args.limit, 20, 100, 'confluence limit');
// CQL search is still exposed through Confluence REST v1 for Cloud;
// page CRUD uses v2 where available.
const path = `/rest/api/search${queryString({ cql, limit })}`;
const data = await atlassianRequest(config, path, { label: 'confluence search' });
const results = confluenceResults(data, 'confluence search');
return requireNonEmptyRows(
results.map((result) => normalizeSearchResult(result, config)),
'confluence search',
`No Confluence content matched "${cql}".`,
);
},
});
+173
View File
@@ -0,0 +1,173 @@
import {
atlassianRequest,
getConfluenceConfig,
htmlToMarkdown,
markdownToConfluenceStorage,
queryString,
requirePayloadArray,
requirePayloadObject,
requirePayloadString,
readUtf8File,
requireString,
} from '../_atlassian/shared.js';
import { CommandExecutionError } from '@jackwener/opencli/errors';
export function confluenceConfig() {
return getConfluenceConfig();
}
function confluenceUrl(config, link) {
if (!link) return '';
if (/^https?:\/\//i.test(link)) return link;
return `${config.baseUrl}${link.startsWith('/') ? link : `/${link}`}`;
}
function pageStorageBody(page) {
return page?.body?.storage?.value
?? page?.body?.view?.value
?? '';
}
export function normalizeConfluencePage(page, config) {
const row = requirePayloadObject(page, 'confluence page');
const id = requirePayloadString(row.id, 'page id', 'confluence page');
const title = requirePayloadString(row.title, 'title', 'confluence page');
const storage = pageStorageBody(row);
const version = row.version?.number != null ? Number(row.version.number) : undefined;
const links = row._links && typeof row._links === 'object' && !Array.isArray(row._links) ? row._links : {};
const webui = links.webui ?? links.tinyui ?? '';
return {
id,
title,
status: String(row.status ?? ''),
spaceId: row.spaceId != null ? String(row.spaceId) : undefined,
spaceKey: row.space?.key ? String(row.space.key) : undefined,
parentId: row.parentId != null ? String(row.parentId) : undefined,
version,
createdAt: row.createdAt ? String(row.createdAt) : undefined,
updatedAt: row.version?.createdAt ?? row.version?.when ?? undefined,
url: confluenceUrl(config, webui),
body: {
storage,
markdown: htmlToMarkdown(storage),
},
};
}
export async function getPage(config, pageId) {
if (config.deployment === 'cloud') {
const page = await atlassianRequest(config, `/api/v2/pages/${encodeURIComponent(pageId)}${queryString({ 'body-format': 'storage' })}`, {
label: `confluence page ${pageId}`,
});
return requirePayloadObject(page, `confluence page ${pageId}`);
}
const page = await atlassianRequest(config, `/rest/api/content/${encodeURIComponent(pageId)}${queryString({ expand: 'body.storage,version,space,ancestors' })}`, {
label: `confluence page ${pageId}`,
});
return requirePayloadObject(page, `confluence page ${pageId}`);
}
export async function readPageBodyFile(args) {
const text = await readUtf8File(args.file);
if (args.representation === 'storage') return text;
return markdownToConfluenceStorage(text);
}
export function createPagePayload(config, args, storage) {
const title = requireString(args.title, 'Confluence page title');
const space = requireString(args.space, 'Confluence space');
if (config.deployment === 'cloud') {
return {
spaceId: space,
status: 'current',
title,
...(args.parent ? { parentId: String(args.parent) } : {}),
body: { representation: 'storage', value: storage },
};
}
return {
type: 'page',
status: 'current',
title,
space: { key: space },
...(args.parent ? { ancestors: [{ id: String(args.parent) }] } : {}),
body: { storage: { representation: 'storage', value: storage } },
};
}
export function updatePagePayload(config, current, args, storage) {
const page = requirePayloadObject(current, 'confluence current page');
const id = requirePayloadString(page.id, 'page id', 'confluence current page');
const title = args.title ? requireString(args.title, 'Confluence page title') : requirePayloadString(page.title, 'title', 'confluence current page');
const currentVersion = Number(page.version?.number);
if (!Number.isSafeInteger(currentVersion) || currentVersion < 1) {
throw new CommandExecutionError('confluence update could not determine the current page version.');
}
const nextVersion = currentVersion + 1;
if (config.deployment === 'cloud') {
return {
id,
status: 'current',
title,
body: { representation: 'storage', value: storage },
version: {
number: nextVersion,
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
},
};
}
return {
id,
type: 'page',
status: 'current',
title,
body: { storage: { representation: 'storage', value: storage } },
version: {
number: nextVersion,
...(args['version-message'] ? { message: String(args['version-message']) } : {}),
},
};
}
export function normalizeSearchResult(result, config) {
const row = requirePayloadObject(result, 'confluence search result');
const content = row.content ?? row;
const contentObject = requirePayloadObject(content, 'confluence search result content');
const id = requirePayloadString(contentObject.id, 'content id', 'confluence search result');
const title = requirePayloadString(row.title ?? contentObject.title, 'title', 'confluence search result');
const space = row.space ?? contentObject.space ?? {};
return {
id,
title,
type: String(contentObject.type ?? row.entityType ?? ''),
spaceKey: String(space?.key ?? ''),
status: String(contentObject.status ?? ''),
lastModified: String(row.lastModified ?? contentObject.version?.when ?? contentObject.version?.createdAt ?? ''),
url: confluenceUrl(config, row.url ?? contentObject._links?.webui ?? ''),
excerpt: row.excerpt ? htmlToMarkdown(row.excerpt) : '',
};
}
export function confluenceResults(data, label) {
const payload = requirePayloadObject(data, label);
return requirePayloadArray(payload.results, label);
}
export function withSpaceCql(cql, space) {
const q = String(cql ?? '').trim();
const s = String(space ?? '').trim();
if (!s) return q;
const escaped = s.replace(/"/g, '\\"');
if (!q) return `space = "${escaped}"`;
return `space = "${escaped}" and (${q})`;
}
export const __test__ = {
createPagePayload,
getPage,
normalizeConfluencePage,
normalizeSearchResult,
readPageBodyFile,
updatePagePayload,
withSpaceCql,
};
+38
View File
@@ -0,0 +1,38 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { atlassianRequest, requireExecute, requirePayloadObject, requireString } from '../_atlassian/shared.js';
import { confluenceConfig, getPage, normalizeConfluencePage, readPageBodyFile, updatePagePayload } from './shared.js';
cli({
site: 'confluence',
name: 'update',
access: 'write',
description: 'Update a Confluence page body from Markdown or storage XHTML',
domain: 'atlassian.net',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', positional: true, required: true, help: 'Confluence page id' },
{ name: 'file', type: 'string', required: true, help: 'Markdown file path' },
{ name: 'title', type: 'string', help: 'Optional replacement title; defaults to current title' },
{ name: 'version-message', type: 'string', help: 'Confluence version message' },
{ name: 'representation', type: 'string', default: 'markdown', choices: ['markdown', 'storage'], help: 'Input file format' },
{ name: 'execute', type: 'boolean', help: 'Actually update the remote page' },
],
columns: ['status', 'id', 'title', 'spaceId', 'spaceKey', 'version', 'url'],
func: async (args) => {
requireExecute(args, 'confluence update');
const config = confluenceConfig();
const id = requireString(args.id, 'Confluence page id');
const current = await getPage(config, id);
const storage = await readPageBodyFile(args);
const payload = updatePagePayload(config, current, args, storage);
const path = config.deployment === 'cloud' ? `/api/v2/pages/${encodeURIComponent(id)}` : `/rest/api/content/${encodeURIComponent(id)}`;
const page = requirePayloadObject(await atlassianRequest(config, path, {
method: 'PUT',
body: payload,
label: `confluence update ${id}`,
}), `confluence update ${id}`);
const normalized = normalizeConfluencePage(page, config);
return [{ ...normalized, pageStatus: normalized.status, status: 'updated' }];
},
});
+48
View File
@@ -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);
},
});
+50
View File
@@ -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);
},
});
+54
View File
@@ -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 };
},
});
+49
View File
@@ -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);
},
});
+50
View File
@@ -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);
},
});
+66
View File
@@ -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
View File
@@ -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'))

Some files were not shown because too many files have changed in this diff Show More