Compare commits

...

279 Commits

Author SHA1 Message Date
jakevin a2dd8c2701 chore(slock): share task identity postcondition 2026-08-23 20:23:32 +08:00
jakevin 2929dfa2c0 chore(linux-do): share formatting helpers 2026-08-23 20:06:18 +08:00
jakevin 2beaf83d62 chore(zhihu): share answer normalization helpers 2026-08-23 19:53:24 +08:00
jakevin 9057441221 chore(zhihu): share answer target parser 2026-08-23 19:37:34 +08:00
jakevin 31d80af07d chore(grok): share site identity helpers (#2341) 2026-08-23 19:18:31 +08:00
jakevin c0c8e60b25 chore(12306): share limit normalization helper (#2340) 2026-08-23 19:00:31 +08:00
jakevin adddc5733a chore(twitter): share archive JSONL helpers (#2339) 2026-08-23 18:40:22 +08:00
jakevin 7145d8b5d0 chore(twitter): share user lookup URL builder (#2338) 2026-08-23 18:11:55 +08:00
jakevin 240930fc8c fix(twitter): repair block and hide reply flows
Fixes #2334, #2335, and #2336.\n\nRepairs Twitter block/unblock profile-state scoping and localized block menu matching, and makes hide-reply retry from the parent conversation using only the preceding article time permalink.\n\nLocal gates: focused block/unblock/hide-reply tests 20/20, full twitter tests 531/531, typecheck, build, validate twitter, typed-error lint new=0, silent-column-drop new=0, diff-check. Hosted checks terminal green on exact head 57d1927d.
2026-08-23 17:54:31 +08:00
jakevin bd4c1e39e1 feat(twitter): add muted word command
Add twitter mute-word <keyword> as a UI write command against the visible Twitter/X muted-word settings form. Confirmation only accepts click-after route transition, new success toast, or new exact muted-word row; pre-write targeting stays scoped to the settings surface.\n\nLocal gates: focused twitter write/block/unblock tests 21/21, full twitter tests 523/523, typecheck, build, validate twitter, diff-check. Hosted checks terminal green on exact head 114a7c7f.
2026-08-23 17:26:56 +08:00
jakevin ae86f7f5ff refactor(bilibili): share relation helpers
Share duplicated Bilibili follow/unfollow relation helpers in a site-local relation module while preserving command-specific validation text and the existing utils.js mock boundary.
2026-08-23 16:53:40 +08:00
jakevin 1d3c97e477 refactor(linkedin-learning): share API fetch helpers 2026-08-23 15:25:43 +08:00
jakevin 80d5d3d6c9 chore: remove orphan internal test hooks (#2330) 2026-08-23 15:02:06 +08:00
jakevin 07aee7cd9f chore(browser): remove dead internal error exports (#2329) 2026-08-23 14:50:15 +08:00
jakevin 14d4665f5d chore(tui): remove unused checkbox prompt (#2328) 2026-08-23 14:40:36 +08:00
jakevin a34705a67f chore(browser): remove legacy DOM click helpers (#2327) 2026-08-23 14:29:26 +08:00
jakevin 70890c7e26 chore(scripts): remove retired explore helpers (#2325) 2026-08-23 14:19:06 +08:00
jakevin 451cd0276c chore(browser): remove retired tab helpers (#2324) 2026-08-23 14:09:07 +08:00
jakevin ff1d59ffec chore(core): remove dead internal symbols (#2323) 2026-08-23 14:00:21 +08:00
jakevin 9846c59f1c fix(adapter): copy shared deps on eject (#2321) 2026-08-23 13:39:32 +08:00
bingame c45105d6d1 fix: 兼容 Windows prepare 脚本 (#2271)
* fix: 兼容 Windows prepare 脚本

* fix(prepare): handle native package-manager runners

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-08-23 03:29:00 +08:00
Bo Liu f909f1e27f fix(browser): prefer the main Electron window over routed auxiliary windows (#2244) 2026-08-23 03:26:27 +08:00
WeiHaoxuan 078984204c fix(completion): fall back on invalid manifests (#2298)
* fix(completion): fall back on invalid manifests

* test(completion): cover all manifest fallback paths

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-08-23 03:25:57 +08:00
WeiHaoxuan 60a94cab27 fix(plugin): honor caret ranges below 1.0.0 (#2299) 2026-08-23 03:25:22 +08:00
WeiHaoxuan aa0e6b0743 fix(args): reject invalid integer values (#2300) 2026-08-23 03:24:49 +08:00
Mai Hoàng Anh Vũ 083f78acf5 fix(chatgpt): use data-turn to detect upload previews vs generated images (#2292)
`chatgpt image` with 2+ --image attachments could return the just-uploaded
reference thumbnails instead of the actual generated image.

isUserUploadPreview() classified an <img> as a user upload (to exclude it
from waitForChatGPTImages' before/after diff) using two signals, both
broken against ChatGPT's current DOM:

- turn.querySelector('h4')?.innerText: the heading is visually hidden, so
  real Chrome's innerText resolves to '' (layout-dependent) even though
  .textContent correctly reads "You said:" / "ChatGPT said:". jsdom's
  innerText is always undefined, so the test suite never exercised this
  path either - it happened to pass via the aria-label/alt fallback below.
- button[aria-label^="Open image:"]: ChatGPT's current label for a
  multi-file attachment reads "Open image N of M: <name>", which no
  longer starts with "Open image:", so this selector stopped matching.

With both signals dead, classification fell through to alt-text sniffing.
Right after upload, an attachment thumbnail's alt/aria-label haven't
populated yet, so for a poll or two every uploaded image is misclassified
as "new". waitForChatGPTImages returns as soon as two consecutive polls
agree on a URL set - long enough for that transient window to win when
multiple attachments are involved, so it can return the uploads instead of
the real result.

Fix: check the turn <section>'s own data-turn="user"|"assistant"
attribute first. It's set structurally as soon as the turn mounts, not
tied to the attachment's async metadata, so it isn't subject to the race.
Keep the heading/aria-label checks as a fallback (now using textContent
and a substring aria-label match) for markup that lacks data-turn.

Verified live against chatgpt.com: reproduced the bug with 3 reference
images, then confirmed the patched build returns exactly the one real
generated image instead of the 3 uploaded thumbnails.

Adds regression tests for both the data-turn race and the aria-label
format change; confirmed both fail against the pre-fix code.


Claude-Session: https://claude.ai/code/session_01L29nrhaeQ4W5rjNr27z47h

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:21:56 +08:00
Coco-cielleo da333c99a7 fix(xiaohongshu): scope note title/desc/author to #noteContainer (#2317)
* fix(xiaohongshu): scope note fields to #noteContainer

`#detail-title, .title` was queried against the whole document. A note
detail page also renders a recommendation feed whose cards each carry a
`.title`, and `querySelector` returns the first match in document order.
For a note with no title of its own (`#detail-title` absent) the selector
fell through to that feed and reported an unrelated card's title as the
note's title -- on one real note, two consecutive runs returned two
different unrelated titles while the note itself has no title at all.

Scope title/desc/author to `#noteContainer` (falling back to `document`
for older layouts). This is the same class of fix already applied to the
`.interact-container` counts a few lines below.

Also adds JSDOM regression tests for NOTE_EXTRACT_JS, following the
pattern used in clis/aibase/news.test.js.

* fix(xiaohongshu): tighten note fallback scope

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-23 03:21:27 +08:00
一禅小和尚 b173b176aa fix(xiaohongshu): scan text-card media from publish roots (#2297)
* fix(xiaohongshu): detect composer media from document.body

opencli's currentComposerMediaCount() picked the composer root via
titleEl.closest('form, [class*=publish], ...'), but Xiaohongshu's new
React DOM renders the image/card editor in a different subtree, so the
matched root never contained the generated media and the count was
always 0. That broke the native '--card-text' (文字生成图片) flow with
'expected at least N visible media item(s), got 0'.

- Use document.body as the scan root so generated cards are found.
- Add 'image, svg' to the media selector for completeness.

This is the maintained fork of @jackwener/opencli (liuxinyea/OpenCLI).

* fix(xiaohongshu): scope text-card media count

* fix(xiaohongshu): keep publish media scan scoped

---------

Co-authored-by: liuxinye <liuxinye@zingfront.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-23 03:21:01 +08:00
Bo Liu ca25f148d6 fix(twitter): fail typed when a write command does not go through (#2256)
* fix(twitter): fail typed when a write command does not go through

* fix(twitter): preserve uncertain write outcomes

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-08-23 03:09:22 +08:00
HARRY-BEAR d981c8b1f3 fix(chatgpt): filter Chinese user-upload previews from generated images (#2261)
* fix(chatgpt): filter user-uploaded images in Chinese UI and allow large image payloads

- chatgpt image adapter: the attachment filter in getChatGPTVisibleImageUrls
  only matched the English 'Open image:' button label and English keywords
  (upload/uploaded/attachment). In the Chinese ChatGPT UI the button is
  labeled '打开图片:用户上传的图片' (Open image: user uploaded image) and
  the image alt is empty, so user-uploaded reference images escaped the
  filter and were reported as generated results (the original photo was
  downloaded instead of the generated image). Add the Chinese button label
  prefix and the '上传' keyword to the filter.
- daemon: raise MAX_BODY from 1 MB to 32 MB. The chatgpt image upload
  fallback (base64-in-evaluate) serializes the image into the command body;
  a typical 2 MB photo becomes a >1 MB base64 payload and the daemon
  rejected it with a connection reset ('fetch failed').

* test(chatgpt): cover Chinese upload previews

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-08-23 03:05:22 +08:00
HARRY-BEAR 7323deb885 fix(1688): extract detail images from shadow DOM with lazy-render scrolling (#2272)
* fix(1688): extract detail images from shadow DOM with lazy-render scrolling

The product detail section lives inside the shadow root of a custom
element (v-detail-e with class html-description). Plain CSS selectors
like `.html-description img` cannot pierce shadowRoot, so the detail
group never matched any element and detail_images was always empty.

Fix by collecting all img/source elements (walking shadow roots) and
checking ancestry through the shadow host chain with closest(), plus
scrolling further and settling on the detail container so its lazy
content renders before extraction.

Adds a jsdom regression test covering shadow-root detail images,
light-DOM main gallery images, and plain-class detail containers.

* fix(1688): address review — restore deleted tests, dedupe the traversal, poll instead of sleep

Review fixes on top of the shadow-DOM detail extraction:

- Restore the two tests this PR replaced. `normalizeAssets` (grouping,
  counts, blob: filtering) and `normalizeMediaUrl` both lost all coverage;
  the 14 -> 15 test count hid that, since three new cases were added while
  two existing ones were removed. Now 17, with the new jsdom cases
  alongside the originals rather than in place of them.

- Inject the module-level `inDetailContainer` via toString() instead of
  keeping a hand-copied twin inside the evaluated script. The copy meant
  the unit tests exercised code that was not what ran in the page, and the
  two could drift silently. This is the convention already used in
  clis/gov-policy/search.js and clis/codex/sidebar.js.

- Check `node.closest(selector)` at each level of the walk, not only
  `host.matches(...)`. A detail container that is a plain element inside a
  shadow root rather than the host itself was previously missed.

- Replace `autoScroll(6) + autoScroll(4) + wait(3)` with one autoScroll,
  a scrollIntoView, and a bounded poll on the deep detail-image count.
  autoScroll keeps no state between calls, so 6+4 was identical to a
  single 10 and the comment about a "second confirmation pass" described
  something that did not happen; the fixed 3s wait was then paid on every
  call even when the content had already rendered. The poll returns as
  soon as the count is stable, capped at ~5s.

- Use an <img> rather than a <source srcset> in the shadow fixture:
  defaultSrcProps does not read srcset, so asserting on it implied
  coverage the adapter does not actually have.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-23 03:01:18 +08:00
Kagura fea093fe05 fix(extension): omit credentials from daemon ping (fixes #2278) (#2282)
* fix(extension): omit credentials from daemon ping (fixes #2278)

A large localhost cookie jar can push the extension ping past the Node default header limit. The daemon then responds 431, but the extension silently retries and never reaches the WebSocket connection.

Send the ping without credentials so browser cookies are not attached, and log non-OK HTTP statuses so future probe failures remain visible. Keep connection errors quiet because a stopped daemon is the expected idle state.

* build(extension): rebuild dist for daemon ping credentials:omit

extension/dist/background.js is a tracked artifact (.gitignore un-ignores
it via !extension/dist/), so the source-only change in 62d1f202 never
reached the bundle Chrome actually loads — the #2278 431 wedge would have
persisted in production despite the fix being merged.

Rebuild only; no source change. Diff is exactly the credentials:'omit'
and the non-OK warn from the parent commit.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-23 02:57:31 +08:00
aliouswe 69454ae9e4 fix(doctor): use windowless connectivity probe (#2206) 2026-08-23 02:57:21 +08:00
haoyu zhang 5d6594f12e fix(boss): restore read-only job search and detail (#2291)
* fix(boss): read current search and detail pages

* fix(boss): harden read-only job discovery

* fix(boss): address review — drop the site-auth fork, flatten detail columns

Review fixes on top of the read-only search/detail restore:

- Drop the adapter-local fork of `_shared/site-auth.js`. The fork had
  already diverged — it lost `normalizeRefreshResult` and the
  `config.refresh` branch, which silently removes `opencli auth refresh`
  support for boss. `clis/_shared/site-auth.js` is imported by 65
  adapters; `adapter eject` not copying `_shared/` is a real bug, but it
  affects every one of them and belongs in `src/cli.ts` eject, not in a
  per-adapter copy. Also removes the tautological test that only
  `readFileSync`'d auth.js and asserted on its own import string.

- Flatten `detail`'s row and `columns` back to scalars. The nested
  `location` / `recruiter` / `companyInfo` objects rendered as
  `[object Object]` in table, plain, csv and markdown output, because
  every renderer coerces cells with `String(v)` (`src/output.ts`) and
  none resolves dotted paths — only `-f json/yaml` was usable. Field
  names match the previous flat contract.

- Fix `stageText`, which matched `/融资|上市|不需要融资/` and therefore
  never matched the common `D轮及以上` / `天使轮` forms, leaving `stage`
  permanently empty. The industry filter directly above already excluded
  `轮`.

- Prefer BOSS's semantic `.text-city` / `.text-experiece` / `.text-degree`
  classes over positional `limits[0..2]`, which shifted every field when
  the header gained or lost a node. Positional order remains a fallback.

- Drop the `district` column instead of shipping one that is always null:
  the extractor hardcoded `districtText: ''` and the rendered page
  exposes no district anywhere in the captured fixture.

- Classify a login bounce as `AuthRequiredError`. The retry loop swallows
  every read error, so a session pushed to the login wall previously
  surfaced as "did not expose a complete job posting" — the API path this
  replaced got that classification for free via `assertOk`.

Regenerates `cli-manifest.json` for the new columns.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-23 02:56:56 +08:00
jakevin 5c0aa36a60 fix(adapters): handle recent extraction drift (#2293)
* fix(adapters): handle recent extraction drift

* fix(adapters): narrow issue-sweep fallbacks

Reject Douban login redirects before accepting cookie identity, avoid unrelated profile-link fallbacks, keep Twitter profile metadata to the proven query-id/features contract, and drop the Douyin error-wording-only change whose test did not execute the browser guard.

* fix(notebooklm): share exact host allowlist

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-08-23 02:51:20 +08:00
fanxiaoyu db2718fc13 fix(ke,douban): update auth anchors for 2026-08 site redesign (#2305)
* fix(ke,douban): update auth anchors for 2026-08 site redesign

- ke: username moved to .typeShowUser (masked phone e.g. 15****93) in new
  SSR header; legacy .userNick/.user-name/.myInfo anchors no longer render,
  causing false AUTH_REQUIRED despite a valid lianjia_token cookie.
- douban: .bn-more href changed from /people/<id>/ to /passport/setting/,
  breaking the user_id regex. Login detection now keys on account element +
  ck cookie; user_id falls back to any /people/<id>/ link or the dbcl2
  cookie, and may be empty on the new homepage without misreporting auth.

Verified locally via shadow adapters in ~/.opencli/clis: both whoami
commands return logged_in:true, and downstream commands (ke zufang,
douban search) return live data again.

* fix(ke): keep only the ke auth anchor widening; drop the douban change

The douban half of this PR is superseded by #2293, which rewrites
verifyDoubanIdentity with a strictly better mechanism, and it introduced
three problems of its own:

- It read `document.cookie` inside the page, but this file already reads
  cookies at CDP level in hasDoubanSessionCookie and discards them. dbcl2
  is HttpOnly, so the in-page path can never see it — the PR's own comment
  admits this ("HttpOnly 时 JS 取不到,静默跳过").
- `document.querySelector('a[href*="/people/"]')` takes the first
  /people/ link anywhere on the douban homepage, which renders a friends'
  activity feed full of other users' profile links, so whoami could
  silently report a stranger's user_id. #2293 removed this exact selector
  for this exact reason.
- The `ck` guard was unreachable in practice: verifyDoubanIdentity
  already throws upstream when neither dbcl2 nor ck exists, so the new
  branch only added another false AUTH_REQUIRED path to a change whose
  stated purpose was removing false AUTH_REQUIRED.

The ke half stands on its own: prepending `.typeShowUser a span,
.typeShowUser a` while keeping every previous anchor is purely additive
and cannot regress a profile where the old anchors still resolve.

---------

Co-authored-by: fanxiaoyu0 <fanxiaoyu0@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-23 02:49:32 +08:00
ele-yufo 42755b9e93 fix(douyin): restore stats via creator item list after metrics_trend retired (#2307)
* fix(douyin): restore stats via creator item list after metrics_trend retired

`item_analysis/metrics_trend` now answers every request with `status_code 4`,
so `douyin stats` fails for all works regardless of age or account (#2197).
Replaying the endpoint with the old unix-timestamp params, with `start_date`/
`end_date`, and with `item_id` instead of `aweme_id` all return the same code,
so the endpoint is gone rather than re-shaped.

The creator item list still serves the full per-work metric set the creator
dashboard renders — 26 fields including view_count, bounce_rate_2s,
completion_rate_5s, avg_view_second, cover_show, cover_click_rate,
fan_view_proportion and subscribe_count — which is a superset of the four
counters metrics_trend used to return. Walk its cursor and pick the requested
work out of the page.

Two details worth keeping:

- The endpoint serializes the work id as a JSON number, so the browser has
  already rounded it past IEEE-754 integer precision before the adapter sees
  it. `sameAwemeId` compares numerically as a fallback, otherwise every lookup
  misses.
- A work that exists but carries no metrics is reported with a distinct hint
  from a work that is absent from the account, so callers can tell "not yours /
  wrong id" apart from "no data yet".

Verified live against a logged-in creator account: 26 metrics returned for a
published work, EMPTY_RESULT for an unknown id, ARGUMENT for a malformed id.

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

* docs(douyin): state what the item list provides instead of naming the dead endpoint

A comment that names a retired endpoint puts it back into the reader's choice
space. The PR description carries the history; the source should carry the
current contract.

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

* chore: rebuild cli-manifest for the douyin stats description

The adapter description changed, and cli-manifest.json is generated and checked
in, so CI's freshness gate fails until it is rebuilt.

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

---------

Co-authored-by: ele-yufo <gentanaka606@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:45:01 +08:00
Bo Liu 3c337d7385 fix(douyin): search hashtags through the live endpoint and report empty responses (#2254) 2026-08-23 02:44:38 +08:00
fanxiaoyu 124602d437 fix(dianping): correct 4 wrong cityIds in static city map (#2280)
The trailing rows of CITY_ID had incorrect cityIds, causing dianping
search to silently fall back to the cookie's default city for these
cities. Verified against live https://www.dianping.com/<slug> resolution:

- kunming 昆明: 25  -> 267
- fuzhou  福州: 110 -> 14
- xiamen  厦门: 14  -> 15
- hefei   合肥: 26  -> 110

(fuzhou and hefei previously shared the same id 110, indicating the
last few rows were transposed when the table was hand-written.)

Co-authored-by: fanxiaoyu0 <fanxiaoyu0@users.noreply.github.com>
2026-08-23 02:39:57 +08:00
Bo Liu 50565efdde fix(browser): thread the preferred profile through readiness and status checks (#2262)
The /status path dropped preferredContextId, so a configured default read as multi-profile ambiguity (#2259).
2026-08-17 19:34:12 +08:00
genoooool a86d64705c docs(plugin): add X Article publisher to examples (#2190) 2026-08-09 23:46:03 +08:00
bulexu a93f6e71bb fix(chat): preserve selected models and wait for Kimi replies (#2266)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-09 22:52:27 +08:00
Bo Liu 27ba33fcb8 test(launcher): skip pgrep-backed app detection tests on Windows (#2269)
findAppProcessPids intentionally returns [] on win32; five of the six app-scoped tests from #2232 fail on the Windows CI shard and the sixth passes only vacuously.
2026-08-08 23:28:05 +08:00
Bo Liu 18f1ceba32 fix(codex): resolve the ChatGPT executable inside Codex.app (#2232)
* fix(codex): resolve the ChatGPT executable inside Codex.app

* fix(codex): try the ChatGPT executable first and sync the launch doc

* fix(codex): scope executable process detection to app bundle

* fix(codex): resolve symlinked app process identity

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 20:05:32 +08:00
Bo Liu 46dd226875 fix(discovery): warn when yaml adapters are skipped instead of dropping them silently (#2229)
* fix(discovery): warn when yaml adapters are skipped instead of dropping them silently

* fix(discovery): stay quiet for yaml adapters that already have a .js replacement

* fix(discovery): audit skipped yaml adapters in manifest path

* fix(discovery): require loadable js replacements for yaml warning suppression

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 19:51:49 +08:00
oliver 12831fc793 fix(discord-app): extract guild ids from navigation items (#2233)
* fix(discord-app): extract guild ids from navigation items

* fix(discord-app): scope guild nav extraction

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 19:09:02 +08:00
Bo Liu 2065a4f90e fix(twitter): read the profile link until it settles in whoami (#2253)
* fix(twitter): read the profile link until it settles in whoami

* fix(twitter): harden whoami identity settling

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 18:48:24 +08:00
Bo Liu da3eb951eb fix(twitter): stop reporting another tweet as the posted permalink (#2251)
* fix(twitter): stop reporting another tweet as the posted permalink

* fix(twitter): require fresh post success evidence

* fix(twitter): require fresh reply success evidence

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 18:25:50 +08:00
Bo Liu 865f5aa021 fix(instagram): download through the media info endpoint and expand ~ in --path (#2248)
* fix(instagram): download through the media info endpoint and expand ~ in --path

* fix(instagram): harden media info downloads

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 17:58:45 +08:00
Bo Liu ce5f3762a1 fix(gemini): fail typed on image failures and expand ~ in the output path (#2246)
* fix(gemini): fail typed on image failures and expand ~ in the output path

* fix(gemini): unwrap image bridge envelopes

* fix(gemini): clear transient image candidates

* fix(gemini): fail closed on malformed image probes

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 17:36:49 +08:00
Bo Liu e169cc19b3 fix(instagram): like and unlike posts through the post page controls (#2243)
* fix(instagram): like and unlike posts through the post page controls

* fix(instagram): verify post like persistence

* fix(instagram): confirm already-like state in feed

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 17:26:41 +08:00
Bo Liu 073c214507 fix(instagram): stop depending on web_profile_info for business accounts (#2238)
* fix(instagram): resolve business-account user ids without web_profile_info

* fix(instagram): harden business account fallback

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 16:55:48 +08:00
Bo Liu 05cfbee66e fix(codex): select slash-command picker options from send (#2239)
* fix(codex): select slash-command picker options from send

* fix(codex): unwrap bridge envelopes in send flows

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 16:41:14 +08:00
Bo Liu 9a689c98ef fix(chatgpt): never save in-progress canvas frames as generated images (#2237)
* fix(chatgpt): never save in-progress canvas frames as generated images

* fix(chatgpt): reject data image candidates case-insensitively

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 16:23:48 +08:00
Bo Liu 0850d8f4d4 chore(deps): bump js-yaml to 4.3.1 for GHSA-5p4m-2wfm-xmqj (#2267)
The advisory published 2026-08-06 fails the audit CI job on every branch; 4.3.1 is the patched release.
2026-08-08 16:14:24 +08:00
lingjiuu 8b9faef054 fix(weread-official): bump skill version to 1.0.4 (#2227) 2026-08-08 15:52:16 +08:00
ngcat 399c0de2a7 feat(twitter): add resumable likes and bookmarks archives (#2143)
* feat: port twitter full-sync and close-window hardening onto 1.8.6

Rebase our xfetch-oriented OpenCLI mods onto upstream main organically:
keep the 1.8.x likes/bookmarks media metadata and auth hardening, then
add --all/--resume-file/--output-file JSONL streaming with U+2028/U+2029
escaping, raise the full-archive page budget, retry browser lease close
failures, and expose browser tab current-window diagnostics.

* fix(twitter): preserve resume state when max-pages stops early

--max-pages is a safety budget, not archive exhaustion. Keep the resume
file and report complete=false so full-sync can continue instead of
restarting from the top.

* test(cli): expect browser tab current-window in structured help

The full-sync branch adds `browser tab current-window`, so the nested
tab help snapshot must count 5 commands instead of 4.

* fix(twitter): make archive resume state fail closed

* fix(twitter): reject mismatched archive resume output

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-30 20:36:27 +08:00
Alex Su dc9dde41f3 feat(pinterest): add Pinterest adapter suite (#2177)
* feat(pinterest): add Pinterest adapter suite

Adds `opencli pinterest` with 19 commands over Pinterest's internal resource API
(`POST /resource/<Name>Resource/<action>/`, form-urlencoded `source_url` + `data`,
`X-CSRFToken` + `X-Pinterest-PWS-Handler` headers, `bookmark` paging).

- Read: search-pins / search-boards / search-users, pin, user, user-pins,
  user-boards, board-pins, board-sections, download. Reads work anonymously
  because Pinterest issues a csrftoken to logged-out sessions too.
- Write: save (boardless repin lands in "Quick saves"), pin-create /
  pin-update / pin-delete, board-create / board-update / board-delete,
  board-section-create / board-section-delete.
- Deletes require `--confirm`; without it the command resolves and names the
  target, then exits non-zero via ArgumentError (pin title + board for
  pin-delete, pin count for board-delete, section title for
  board-section-delete).
- Boards are addressed by `<username>/<slug>`, a board URL, or the numeric
  `boardId` (BoardResource accepts `board_id` and reports the board's url, which
  is reused so the id path costs no extra round trip); sections by id or slug.
  Display names are not accepted — a name alone cannot say which account a board
  belongs to. A Pinterest site route such as a `/pin/<id>/` URL is rejected as
  such instead of being parsed as the board `pin/<id>`.
- Board URLs are percent-decoded before use: Pinterest hands out encoded slugs
  for non-ASCII board names, and posting those verbatim answers HTTP 404. Slugs
  are compared Unicode-normalized so an NFD-composed accent still matches.
- Sections can only be set by a follow-up move. PinResource/create and
  RepinResource/create accept a section key, answer HTTP 200, and file the pin
  at the board root anyway; only PinResource/update honours it (under
  `board_section_id`, not `section_id`). So `save --section` and
  `pin-create --section` create then move, and report the created pin id if the
  move fails rather than claiming success.
- Omitting an optional text flag leaves the field alone; passing an empty string
  clears it. Pinterest refuses link edits on pins it scraped, and answers 401
  for that, so its own message is surfaced rather than only "log in".
- Typed errors throughout: ArgumentError for bad refs, unknown sections,
  `--section` without `--board`, and limits (validated before any request, with
  no silent clamp); AuthRequiredError on 401 (and 403 on writes only, since
  reads are anonymous); CommandExecutionError for malformed payloads and
  unresolvable targets.

Live-verified end-to-end against a logged-in account: all 10 read commands, and
the full write cycle (board-create → board-section-create → board-update →
pin-create → pin-update → save → the three deletes, preview and confirmed),
including section placement checked on each pin's own `section` field, non-ASCII
board/section slugs, board-id addressing, and clearing a description. 131 tests;
full suite 6258 passed; `tsc --noEmit` clean; `opencli validate` 0 errors;
typed-error-lint and silent-column-drop both new=0; doc coverage 174/174.

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

* fix(pinterest): drop board-update --privacy empty default

`coerceAndValidateArgs` applies an arg's default and then enforces `choices`
against it, so `default: ''` on a public|secret flag rejected every run that
omitted `--privacy`:

    $ opencli pinterest board-update janedoe/my-board --name Foo
    error: ARGUMENT  Argument "privacy" must be one of: public, secret. Received: ""

Leaving the default off keeps the flag optional; the command already reads it
as `String(kwargs.privacy ?? '')`.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:31:59 +08:00
ele-yufo 7702d8d534 feat(midjourney): add image generation adapter (#2201)
* feat(midjourney): add complete browser adapter

* fix(midjourney): verify paid action settings

---------

Co-authored-by: yufo <yufo@MacBook-Pro.local>
Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-30 20:25:49 +08:00
INKWWW 0ae77bdedc feat(twitter): add bounded collection command (#2173)
* refactor(twitter): share user timeline transport

* feat(twitter): add bounded collection command

* fix(twitter): fail closed on incomplete collection timelines

---------

Co-authored-by: Hanyue Chen <hanyuec@Hanyues-MacBook-Pro.local>
Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-30 20:12:43 +08:00
jakevin a80e5a3d58 fix(facebook): handle current feed and profile DOM (#2200)
Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-30 01:27:36 +08:00
jakevin 0de1d56796 fix(tiktok): use the current explore feed endpoint (#2199)
Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-30 01:14:37 +08:00
jakevin 03ba558386 fix(boss): probe the current geek jobs route (#2198) 2026-07-30 01:07:44 +08:00
ele-yufo 77812f0e3d fix(douyin): walk work_list cursor and reject non-sec_uid input (#2196) 2026-07-29 20:30:07 +08:00
Ao Liu b4f5df9d19 fix(xiaohongshu): extract direct comment reply target (#2175) 2026-07-29 20:26:21 +08:00
Sebastion b58f26006d fix(autoresearch): pass claude prompt via stdin (#2184)
* fix(autoresearch): pass Claude prompt via stdin to prevent shell injection

The modify() function in autoresearch/commands/run.ts interpolated a prompt
string — built from git log messages and scope file names — directly into
a shell command executed by execSync. The double-quote escaping only handled
literal quotes, leaving $(...), backticks and backslashes able to trigger
command substitution.

Switch to the same pattern already used in autoresearch/commands/fix.ts:
pass the prompt via the execSync 'input' option so it is delivered on stdin
and never parsed by the shell.

* fix(autoresearch): invoke Claude without a shell

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-29 20:19:58 +08:00
Bo Liu ab28a1f2eb fix(amazon): honor the input marketplace instead of rewriting to amazon.com (#2185)
* fix(amazon): honor the input marketplace instead of rewriting to amazon.com

* fix(amazon): reject amazon.<label>.<tld> look-alikes and localize the auth hint

* fix(amazon): allow only known marketplace domains

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-29 20:14:38 +08:00
Semianchuk Vitalii da8d32fc59 fix: escape pipes in markdown output and fix contributing docs (#2059)
markdown table cells with | in them were breaking the table layout —
added escaping so pipes get rendered as \| properly.

also fixed a few things in CONTRIBUTING.md:
- the page.evaluate example had a template injection issue where user
  input could break out of the template string. switched to passing
  args through the function parameter instead.
- pipeline adapter example was missing the required access field, so
  anyone following the guide would get a crash on registration
- removed a pointless .map(h => h) identity copy on table headers
- fixed consoleMessages('error') filter that was also returning warnings
2026-07-29 20:11:42 +08:00
Bo Liu 13635649e4 fix(download): keep a row-less table from crashing markdown conversion (#2187)
* fix(download): keep a row-less table from crashing markdown conversion

* test(download): pin row-less table text with a caption fixture
2026-07-29 20:09:37 +08:00
Felo Restrepo 89fe2f2288 fix(twitter article): include images from atomic blocks in markdown output (#2189)
* fix(twitter article): include images, canonicalize URLs, add metadata fields

The article adapter skipped atomic blocks entirely, silently dropping all
images from Twitter article markdown output.

This patch:
1. Resolves atomic blocks -> entity -> mediaId -> media_entities -> image URL
   and emits images as ![caption](url?format=jpg&name=large) inline.
2. Canonicalizes pbs.twimg.com URLs to the ?format=<ext>&name=large form
   used by the standard Twitter media CDN (matches reference clipping format).
3. Adds two new output columns: published_at (from tweet.legacy.created_at)
   and preview_text (from articleResults.preview_text) to support building
   Obsidian-style frontmatter at save time.

Tested with https://x.com/0xblacklight/status/2069503920918106370
10 images with captions, canonical URLs, all metadata populated.

* fix(twitter/article): resolve media by Draft entity key

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-29 20:06:23 +08:00
Haoqian b866dbeebe fix(huodongxing): handle temporary busy event pages (#2103) 2026-07-29 19:58:50 +08:00
Zhongyue Lin 03c0157614 fix(twitter/profile): recover counts + bio after X relocates them out of legacy (#2188) (#2193)
* fix(twitter/profile): recover counts + bio after X relocates them out of legacy (#2188)

`twitter profile` returned followers/following/tweets/likes = 0 and an empty
bio while name/screen_name/created_at/verified stayed correct. X moved the count
fields (followers_count/friends_count/statuses_count/favourites_count) and the
bio (description) out of `result.legacy` into a new container — the same drift
#1745 handled for name/created_at by reading `result.core`.

Rather than hard-code the (unknown) new path, resolve each field from its known
homes first (legacy → core → top-level result), then fall back to a bounded
breadth-first search that returns the shallowest match. The BFS refuses to cross
into containers describing a *different* entity (pinned_tweet, entities, media,
…) so it can never report an embedded tweet's favourites_count as the user's
likes or its text as the bio — a wrong-but-confident value would be worse than
0 / ''. This restores the counts/bio today and stays robust if X relocates them
again.

Legacy-path responses resolve identically (existing test unchanged). Adds
offline regression tests for the relocated-field case, legacy precedence over a
deeper decoy, the embedded-tweet guard, all-missing fallbacks, and resolver
type/empty handling.

* fix(twitter/profile): map observed current schema

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-29 19:58:11 +08:00
Jian Cui da3c26e610 fix(1point3acres): detect login via current Discuz X user-panel markup (#2145)
whoami and login-gated commands reported AUTH_REQUIRED for logged-in users
because the identity probe only matched the legacy Discuz member panel
(`#um .vwmy h4 a`), which the site no longer renders.

- Match the current header username link (`a[title="访问我的空间"]`) while
  keeping the legacy selectors as fallbacks; the existing uid regex already
  handles the `space-uid-<uid>.html` href.
- Also accept the logged-in header menu ids (`#g_upmine`, `#extcreditmenu`)
  as a login signal, so a future wording/markup change of the username link
  does not reintroduce a false AUTH_REQUIRED.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 19:53:37 +08:00
Zhongyue Lin a8dddcb291 fix(facebook/search): preserve query identity + drop redirect shims (#2090) (#2194)
* fix(facebook/search): preserve query identity + drop redirect shims (#2090)

The #2126 extractor deduped and reported result URLs as `origin + pathname`,
dropping the query string. But `permalink.php?story_fbid=…`, `story.php?…` and
`watch/?v=…` carry their identity in the query — so two *different* posts or
videos collapsed into a single row and only the first survived dedup.

Add `entityKey(u)` that keeps only the identity params (story_fbid, fbid, id, v,
story_id) and strips FB's per-render tracking nonces (__cft__, __tn__, ref).
Distinct posts now stay distinct, while the same post rendered twice with
different nonces still dedupes to one row. Vanity paths without identity params
keep collapsing to the bare pathname (unchanged).

Also reject `l.` / `lm.` `facebook.com` hosts: their `/l.php?u=…` outbound-link
wrappers passed the host regex and the vanity path catch-all, leaking external
redirect shims into the results.

Adds offline regression tests for distinct permalink/watch identities, nonce
dedup, and the redirect-shim guard.

* fix(facebook): scope query identity by destination

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-07-29 19:49:06 +08:00
AriesWarrior 254c54a99b fix(boss): distinguish environment rejection from auth expiry (#2127) 2026-07-29 19:43:59 +08:00
Bo Liu 4d859546d3 chore(deps): bump js-yaml to 4.3.0 for GHSA-52cp-r559-cp3m (#2186) 2026-07-29 19:39:33 +08:00
Bo Liu 5256711a25 enrich(ctrip): expand the adapter across Ctrip's travel verticals (#2156)
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
* enrich(ctrip): add train ticket search command

ctrip search already suggests railway stations but there was no way to query the
actual departures. ctrip train <from> <to> --date fills that gap on the public
trains.ctrip.com list page, browser-mode + cookie like flight/hotel-search. Rows
are read by stable class-keyed fields rather than positional innerText;
incomplete cards are dropped, not sentinel-filled.

* enrich(ctrip): add hotel detail command

Single-hotel profile from the detail-page SSR: rating sub-scores, hot facilities, check-in/out policy.

* enrich(ctrip): add bus ticket search command

Intercity coach search via the newbus results deep link (landing SPA does not hydrate under the bridge).

* enrich(ctrip): add ferry ticket search command

Passenger ferry sailings via the ship.ctrip.com results deep link, sibling of bus.

* enrich(ctrip): add cruise package search command

Resolves a departure port name to its legacy per-port code, then reads the .route_info cards.

* enrich(ctrip): add tour package search command

Group and self-guided tour search via the vacations sv=<destination> deep link, stable-class cards.

* enrich(ctrip): add flight+hotel package search command

Shares the vacations product extractor with tour (freetravel section); folds a 万 count multiplier into the shared parser.

* enrich(ctrip): raise CommandExecutionError on rendered-but-unparsed results

Matches the drift handling bus/ferry/train use, so genuine-empty stays EmptyResultError.

* enrich(ctrip): generalize shared list helpers, drop dead train constants

parseListLimit / parsePlaceName replace the train-named helpers now reused across bus/ferry/cruise/tour/package with neutral hints; ferry ship-name/duration read by pattern, not position.

* enrich(ctrip): add attraction listing command

* enrich(ctrip): add round-trip flight search command

* enrich(ctrip): scope attraction to city id and harden flight-round

* fix(ctrip): repoint one-way flight to Ctrip's migrated .flight-item cards

* fix(ctrip): harden travel adapter boundaries

* fix(ctrip): preserve raw limit strings

* test(ctrip): avoid adapter src import

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-21 01:58:26 +08:00
Bo Liu 0124c4eb7d feat(trip): add Trip.com international adapter (#2158)
* feat(trip): add Trip.com international flight search adapter

Trip.com is the English-facing sibling of the ctrip adapter. trip flight
searches worldwide one-way flights, browser-mode + cookie like ctrip flight.
Results are read from .result-item cards by stable data-testid anchors rather
than positional innerText; incomplete cards are dropped, not sentinel-filled.

Closes #2157

* enrich(trip): add hotel-search command

* enrich(trip): add hotel detail command

Single-hotel profile from the detail-page SSR (same shape ctrip hotel uses); also documents the existing hotel-search command.

* enrich(trip): add round-trip flight search command

Reuses the shared .result-item flight extractor against a triptype=rt search URL.

* enrich(trip): rename parseFlightLimit to parseListLimit

The 1-50 limit parser is shared by hotel-search and both flight commands, so a neutral name reads truer than the flight-specific one.

* enrich(trip): add attractions and experiences search command

Anchors on each things-to-do card's stable detail link (name + per-row url) and reads rating/reviews/booked/price by data-format pattern, since the cards use hashed CSS-module classes.

* enrich(trip): add train route timetable command

Reads the per-country SEO route timetable (departure/arrival times, stations, duration, changes) by stable class fields; per-journey fares sit behind the booking step.

* enrich(trip): add car-rental listing command

* enrich(trip): add airport-transfer listing command

* enrich(trip): add tour-package search command

* enrich(trip): add public destination-suggest command

* enrich(trip): add flight+hotel package search command

* docs(trip): note eSIM plans surface via attraction search

* enrich(trip): add live-promotions deals command

* enrich(trip): treat empty deals parse as drift, not empty result

* enrich(trip): split tour no-match (empty) from schema drift

* fix(trip): type public fetch drift failures

* fix(trip): require package flight identity

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-21 01:35:12 +08:00
cypggs c1ee31d063 feat(kimi/usage): read quota from membership subscription page (#2104)
* feat(kimi/usage): read quota from membership subscription page

Replace the /code/console page with /membership/subscription?tab=quota so the command surfaces the total usage percentage plus 5h/7h rate limits, gift quota, and booster balance.

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

* feat(deepseek/usage): add usage command for platform.deepseek.com

Reads DeepSeek platform usage data from https://platform.deepseek.com/usage
via internal API (get_user_summary) for account-level data and DOM extraction
for time-dimension summary cards.

Output columns:
- balance / bonusBalance (充值/赠送余额)
- cumulativeSpend (累计消费金额)
- monthlySpend / monthlyApiCalls / monthlyTokens (本月数据)
- currentTokenEstimation (当前可用 Tokens 预估)
- timePeriod / periodSpend / periodApiCalls / periodTokens (时间维度)

* test(usage): harden kimi and deepseek usage contracts

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-20 17:57:27 +08:00
pi-dal 0e73c3c2c2 feat(google): add images search adapter
Adds a read-only Google Images browser search adapter with typed parser boundaries.\n\nReviewed-by: codex-mini0\nReviewed-by: First-principles-0
2026-07-19 19:07:23 +08:00
AstroHan 1cb353d57a feat(chatgpt): add GPT-5.6 Pro model target
Adds the GPT-5.6 Pro ChatGPT model target with exact postcondition proof.\n\nReviewed-by: codex-mini1\nReviewed-by: First-principles-1
2026-07-19 19:04:02 +08:00
Zhongyue Lin 5add09f078 fix(eastmoney): correct mislabeled convertible ytm/remainingYears columns (#2109) (#2131)
* fix(eastmoney): correct mislabeled convertible ytm/remainingYears columns (#2109)

eastmoney convertible emitted systematically impossible ytm / remainingYears
(20/20 wrong). Cross-verification (12/12 fingerprint) shows the clist fields
were mislabeled: f239 is the putback trigger price (= convPrice × 0.7), not YTM,
and f238 is the pure-bond premium %, not the remaining term.

Relabel to the true semantics (pureBondPremiumPct / putTriggerPrice) and drop
the known-wrong ytm / remainingYears columns rather than keep emitting garbage.
Rename SORTS.ytm -> 'put-trigger' so --sort no longer claims to order by a value
it doesn't hold. Extract mapConvertibleRows() and add JSON-fixture tests.

Real YTM / remaining term aren't in this response's fields; adding the correct
f-codes needs a live push2 field dump cross-checked against jisilu — left as a
follow-up.

* fix(eastmoney): harden convertible field output

* fix(eastmoney): require convertible identity strings

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-19 17:30:00 +08:00
jakevin 9a53369bd6 fix(instagram): fetch user feed by username (#2153) 2026-07-19 17:25:17 +08:00
jakevin f0c07e781c fix(zhihu): accept http pagination next urls (#2151) 2026-07-19 17:25:03 +08:00
jakevin 95420d27ae fix(ths): fetch hot rank from public API (#2152) 2026-07-19 17:23:49 +08:00
Bo Liu f3586293d0 feat(toutiao): add recommend channel feed, fix hot --limit being ignored (#2149)
* feat(toutiao): add recommend channel feed, fix hot --limit being ignored

hot declared func(_page, kwargs) while browser:false commands receive a single
args object, so kwargs was always undefined and --limit silently fell back to
30. Its unit tests passed only because they called func(null, kwargs) by hand.

* fix(toutiao): require recommend article identity

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-19 17:15:36 +08:00
Ocean cde6198f3d feat(xiaohongshu): 评论输出 images 字段 + 滚动加载健壮性改进 (#2136)
* feat(xiaohongshu): extract comment images and improve scroll-loading robustness

Add an images field to xiaohongshu/rednote comments (top-level and nested
replies), scraped from .comment-picture galleries while excluding avatars
and inline note-content-emoji stickers. Also make the comment-loading
scroll loop keep going until --limit is satisfied or growth stalls for
several rounds (instead of bailing after one stalled round), and drive
scroll through the scroller element, scrollIntoView, and window.scrollTo
together since the actual scrollable ancestor varies by layout.

* fix(xiaohongshu): validate comment image payloads

* fix(xiaohongshu): scope comment image extraction

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-19 17:12:12 +08:00
jakevin b0f84c99c9 fix: click hit-testing + facebook feed/search DOM drift (#2076 #2071 #2089 #2090) (#2126)
* fix(browser): hit-test the click point and retarget handler-less nodes (#2076 #2071)

click() reported {clicked:true} whenever a CDP Input.dispatchMouseEvent
didn't throw, even when the synthetic click silently landed on an overlay
(#2076) or on a handler-less child like an <svg> icon whose click handler
lives on the wrapping <div> (#2071).

Now boundingRectResolvedJs (in a click-only mode) hit-tests the centre via
elementFromPoint and classifies it: target (element/descendant) and ancestor
(open shadow-DOM host or own wrapper — a CDP click still reaches the target)
are trusted; an unrelated overlay ('other') forces a direct DOM-click fallback.
On a miss it probes inset points for a hitting one. If the resolved node owns
no click handler, the click retargets to a nearby clickable ancestor so the
handler fires — cursor:pointer is excluded from that decision because it is
inherited. The result now surfaces click_method (cdp|js|ax), hit, and
retargeted so agents can tell a trusted click from the fallback. hover() and
dblClick() keep their original plain-centre behaviour (click-only opt-in).

Runtime tests execute the generated JS against a fake DOM (with cursor
inheritance modelled) covering target/ancestor/other, retarget, and probe.

* fix(facebook): extract modern feed posts via the action-menu anchor (#2089)

Modern facebook.com no longer wraps feed posts in [role="article"] nor
exposes the Like/Comment/Share aria-labels the fallback keyed on, so feed
extracted 0 rows. Add a container source that anchors on each post's
"Actions for this post" menu and walks up to the highest ancestor holding
exactly one such menu (stopping before page landmarks), a bounded
scroll-to-load loop so lazily-streamed posts render, all-digit decoy author
rejection, and hidden-char / Reels-carousel decoy filtering. jsdom fixtures
cover the modern shape and keep the legacy [role="article"] path working.

* fix(facebook): extract search results from role=feed entity links (#2090)

Modern /search/top renders results inside [role="feed"] as entity/content
links (people, pages, groups, posts) rather than [role="article"]/[role=
"listitem"], and seeds hidden-char decoy links back to /search. Rewrite the
adapter (pipeline -> func, so the extractor is unit-testable) to collect
anchors inside the feed, keep only real facebook.com entity/content hrefs,
and drop /search decoys, chrome links, off-domain spam, and obfuscated text.
Preserves the #625 navigate-before-extract guard. jsdom fixtures included.
2026-07-13 03:22:40 +08:00
jakevin 654019eeba fix: batch of 5 issue fixes (#1753 #2087 #2091 #2095 #2108) (#2125)
* fix(plugin): pass --ignore-scripts to plugin npm install (#1753)

Plugin repos are cloned from untrusted third-party Git URLs. Without
--ignore-scripts, `npm install` runs preinstall/install/postinstall
lifecycle scripts (of the plugin and every transitive dep) at install
time with the user's privileges. Adapter plugins don't need lifecycle
scripts — adapter code is loaded later by the discovery path — so deny
that execution vector unconditionally. Adds a test asserting the flag.

* fix(chatgpt): verify whoami via /api/auth/session, not legacy cookie (#2087)

verifyChatgptIdentity hard-gated on the legacy
`__Secure-next-auth.session-token` cookie before probing
/api/auth/session, so logged-in users on cookie-less sessions got a
false AUTH_REQUIRED. The session endpoint (200 + user.id) is
authoritative; drop the cookie precondition from verify. The login
`poll` keeps its cheap non-navigating cookie gate so verify (which
navigates) doesn't run every ~2s and yank the user off the OAuth form.
Also prefix-match the session cookie so the quickCheck/status/refresh
fast paths stop false-negativing on NextAuth chunked (.0/.1) cookies.

* fix(instagram): collect explore_grid media across nested layouts (#2091)

Instagram stopped populating the flat layout_content.medias[] path;
media now nest across mixed layout shapes (one_by_two_item.clips.items[]
.media, fill_items[].media, ...), so explore returned []. Recursively
walk each sectional item collecting every distinct node.media, dedupe by
pk/id/code (skipping descent into a collected media so carousel children
aren't counted as separate posts), and fall back to play_count for
clips/reels engagement.

* fix(extension): upload files via file-chooser interception (#2108)

DOM.setFileInputFiles with a nodeId/backendNodeId is rejected "-32000 Not
allowed" when the debugger is attached via chrome.debugger (crbug
928255), breaking file upload on every site. Switch setFileInputFiles to
the file-chooser interception flow: enable Page.setInterceptFileChooser-
Dialog, programmatically open the chooser, and use the backendNodeId from
the intercepted Page.fileChooserOpened event (which Chrome accepts). The
event listener is registered before the click and settles on any matching
event so a malformed one rejects fast. Includes the rebuilt bundle.

* fix(chatgpt): use page.sleep in the poll loops #2099 missed (#2095)

#2099 converted the main streaming loops to page.sleep but did not touch
image.js, deep-research-result.js, or the image-poll re-navigation waits
in utils.js. Those still called page.wait(n>=1), which injects a whole-
subtree+attributes MutationObserver DOM-stability wait rather than a
sleep — during ChatGPT streaming the observer never goes quiet and pegs
the renderer. Convert the remaining poll-loop sleeps to page.sleep;
one-shot post-navigation settles are left as-is.
2026-07-13 02:36:43 +08:00
Marvin c1ad69676f Improve ChatGPT Deep Research progress reporting (#2061)
* Improve ChatGPT Deep Research progress reporting

* fix(chatgpt): preserve deep research progress rows

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-12 01:36:49 +08:00
jakevin ce9151f363 feat(linkedin): add connections command (#2083)
* feat(linkedin): add connections command to list first-degree connections

Adds `opencli linkedin connections` — lists your first-degree LinkedIn
connections (name, headline/occupation, public id, connected-at, profile URL)
via the voyager `/voyager/api/relationships/connections` REST endpoint.

- Reuses the shared JSESSIONID csrf-token + x-restli-protocol-version voyager
  fetch pattern; paginates start/count up to --limit (max 500).
- Typed errors: AuthRequiredError (missing session / 401 / 403),
  CommandExecutionError (malformed payload / missing miniProfile),
  EmptyResultError (no connections).

Live-verified end-to-end against a logged-in account (real connections with
occupation + profile URLs). 7 new tests; full suite 5959 passed; audits new=0.

* fix(linkedin): harden connections identity handling
2026-07-12 01:16:43 +08:00
jakevin 63ae0d81fc feat(linkedin): add company command (#2088)
* feat(linkedin): add company command to read a company page

Adds `opencli linkedin company <name>` — reads a LinkedIn company's About
page: industry, size, headquarters, founded, website, specialties, follower
count, and about text.

- Accepts a bare universal name (`nvidia`), a `/company/<name>` path, or a
  full company URL; navigates to the About page and scrapes the dt/dd fact
  list + follower count (same DOM-extraction style as profile-read).
- Typed errors: AuthRequiredError via assertLinkedInAuthenticated,
  CommandExecutionError on malformed payload / missing company name.

Live-verified end-to-end (NVIDIA: 42M followers, Computer Hardware
Manufacturing, founded 1993; Databricks via full URL). 4 tests; audits new=0.

* fix(linkedin): harden company identity output
2026-07-12 01:11:24 +08:00
AstroHan c4e6aab925 fix(extension): stop SW wake events from wiping the lease registry, add owned-group ledger (#2098)
* fix(extension): stop SW wake events from wiping the lease registry, add owned-group ledger

Root cause of #2097: an MV3 service worker woken by an event could run
windows.onRemoved / tabs.onRemoved / the lease idle alarm before
initialize()'s recovery chain rehydrated in-memory state, and each of
those handlers ends in persistRuntimeState(). The empty pre-recovery
snapshot overwrote the persisted registry, destroying the groupId
self-heal pointer (#1862) and every lease record. An untitled orphan
group left by a crash between chrome.tabs.group and tabGroups.update
then became invisible to all discovery layers, so the next command
created another "OpenCLI Browser" group — and orphans accumulated with
no path to cleanup.

Fixes:
- Gate every state-persisting event entry point (onAlarm,
  windows.onRemoved, tabs.onRemoved) and connect() on a workerReady
  promise that resolves once contextId + registry recovery complete.
  The gate always resolves, and connect() keeps synchronous
  connectInFlight coalescing via a settled-state mirror.
- Persist a ledger of every owned interactive group id and use it as a
  discovery layer in collectOwnedGroupCandidates, so untitled orphans
  stay findable without leases or a title. Stale ids are pruned when
  chrome.tabGroups.get fails.
- Run one interactive group convergence at the end of reconcile so
  orphans are adopted, retitled, and merged at startup instead of
  accumulating.

Fixes #2097

* fix(extension): scope the orphan-group ledger to the browser session

Review follow-up: tab group ids are only meaningful within one browser
session, so persisting the ledger in chrome.storage.local risked a
stale id colliding with a recycled id on a user-created group after a
restart — the ledger layer would then retitle or merge the user's
group. Cross-restart persistence also buys nothing: restored groups get
fresh ids and are rediscovered by the title layer.

Move the ledger to chrome.storage.session (survives MV3 worker
restarts, cleared with the browser session) as interactive-only
module state, drop the dead groupIds field from the automation
container and the durable StoredRegistry, and add a regression test
that legacy groupIds left in storage.local are never trusted.

* fix(extension): drop tab group ids from the durable registry entirely

Tab group ids are browser-session scoped, so the singular
ownedContainers.interactive.groupId persisted in chrome.storage.local
carried the same hijack hazard as the plural groupIds ledger fixed in
the previous commit: after a browser restart the stale id can collide
with a recycled user-created group, which the canonical convergence
path would then retitle or merge.

The durable registry now stores windowId only. Within one browser
session, group recovery is fully covered by the session ledger, the
title layer, and the lease layer, so the local pointer was redundant.
Adds a regression test seeding a legacy groupId that collides with a
live user group and asserting reconcile leaves it untouched.

* fix(extension): move the lease registry to browser-session storage

Window ids and tab ids are browser-session scoped, exactly like the
group ids removed in the previous two commits, so persisting the lease
registry in chrome.storage.local carried the same recycled-id hazard:
after a browser restart a stale windowId/preferredTabId could collide
with a user window or tab, and the recovery path would claim, group,
navigate, or close it.

The registry's only purpose is surviving MV3 service-worker restarts,
and every meaningful field in it is a runtime id — there is no stable
cross-restart state to keep. chrome.storage.session has exactly the
right lifetime: it survives worker restarts and is cleared when the
ids die. initialize() best-effort removes the legacy storage.local
key so old data can never be trusted again.

Adds regression tests: a legacy local registry claiming a live user
window or user tab is ignored (no focus/group/navigate/remove), and
the legacy local key is removed on startup.

* refactor(extension): fold the orphan-group ledger into the session registry

The registry and the interactive group ledger both live in
chrome.storage.session with identical lifetimes, so the separate
ledger key and its restore/persist pair were redundant. The ledger
is now a groupIds array on the registry's interactive container;
the in-memory Set and all pruning/adoption logic are unchanged, and
the crash-self-heal persist between chrome.tabs.group and
tabGroups.update stays at the same point (now one storage write
instead of two).

Also documents the recovery boundary: storage.session is cleared on
extension disable/reload/update as well as browser restart, so
recovery is only promised across service-worker restarts within one
browser session.
2026-07-12 01:02:43 +08:00
AstroHan 183c5e6fed fix(chatgpt): use pure sleeps and cheap generation checks in polling loops (#2099)
* fix(chatgpt): use pure sleeps and cheap generation checks in polling loops (#2095)

During a long `chatgpt ask` (10-20 min answers) the chatgpt.com renderer hit
~700% CPU and >4GB RSS. Root causes, all in the poll loops that run for the
whole generation:

- `page.wait(n>=1)` does not sleep client-side; it injects a whole-body
  MutationObserver (DOM-stable probe) that never goes quiet while the answer
  streams, so it re-arms and fires on every mutation for the full interval.
  Add `page.sleep(seconds)` (bare setTimeout, no page evaluation) to BasePage
  and IPage, and switch the poll-interval waits to it: waitForChatGPTResponse,
  waitForChatGPTDetailRows, waitForChatGPTDeepResearchResult,
  waitForChatGPTImages, waitForChatGPTUploadPreview, and the ask pre-send
  settle loop. One-shot post-navigation settle waits keep `page.wait` for its
  DOM-stable early return.

- `isGenerating` read `document.body.innerText` every poll, forcing a full-page
  reflow and a conversation-sized string allocation. Rewrite it to cheap
  signals: stop-button test id, control aria-labels, and a `textContent`
  (no reflow) scan scoped to the composer + last turn.

- `getVisibleMessages` read both innerHTML and innerText per turn. Add a
  `textOnly` option that skips innerHTML and use it from the response poll
  loop, whose output is text-only; read/detail markdown paths are unchanged.

* fix(chatgpt): cover both message shapes in the scoped isGenerating scan (#2095)

The scoped text fallback only looked at article conversation turns, but
CONVERSATION_MESSAGE_SELECTOR supports bare [data-message-author-role]
nodes too. On that DOM shape a plain-text Thinking pill (no stop button,
no aria-label) would read as idle and waitForChatGPTResponse could
return a truncated answer. Prefer the article turn (wider container),
fall back to the last role-attribute node when articles are absent.

Addresses the P2 from external review of PR #2099.

* fix(chatgpt): only leaf pills outside message content count as generating (#2095)

Scanning whole-scope textContent flags any finished answer that merely
mentions "Thinking" / "正在思考" (prose or backticked code spans) as
still generating — e.g. a conversation reviewing this very code —
permanently blocking follow-up sends. Count only short leaf elements
outside .markdown/pre/code as status pills.

Verified against a live conversation whose messages discuss isGenerating:
detail reported Generating=true before, false after; a real streaming
pill still matches (leaf, short, outside rendered content).

* fix(chatgpt): don't read the Thinking model label as a generating state (#2095)

'Thinking' is a supported idle model label (CHATGPT_MODEL_TARGETS.advanced)
rendered as a composer-form button, so both the page-wide aria-label match
and the composer-scope leaf scan flagged an idle conversation with that
model selected as generating forever, blocking sends. Drop bare 'Thinking'
from aria-label matching (the stop button covers English streaming states)
and only count it inside the last conversation turn.

Addresses the round-2 P2 from external review of PR #2099.

* fix(chatgpt): keep text-only polls off innerText

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-12 00:58:15 +08:00
AstroHan 189462c0dd feat(daemon): fail fast when a write command already holds the site session (#2100)
* feat(daemon): fail fast when a write command already holds the site session (#2095)

A long `chatgpt ask` (10-20 min) is hundreds of short 'exec' round-trips
against one persistent site session. When an outer agent times out and
retries while the first process is still alive, both drive the same Chrome
tab, multiplying renderer load. There was no arbitration: persistent
sessions resolve to a fixed `site:<site>` name, and the extension's
activeCommandCounts is only a teardown refcount.

Add a per-(surface, session) write lease in the daemon — the single local
process that sees every CLI client:

- The CLI attaches a stable runId (`run_<pid>_<ts>_<rand>`), command name,
  and access level to every command via a module-level run context
  (mirrors setDaemonCommandTimeoutSeconds). Set only for persistent write
  commands; read and ephemeral commands are never arbitrated.
- The daemon acquires the lease on the first eligible command, refreshes it
  on same-runId execs (the ~3s poll is a natural heartbeat), and rejects a
  concurrent different-runId write BEFORE dispatching to the extension. The
  busy response names the holder command, its pid, and how long it has held
  the lease, plus a "wait or kill" hint; the CLI throws SessionBusyError
  (CliError, EX_TEMPFAIL) so the message is the primary output.
- Stale leases self-expire after 45s of inactivity, so a retry after a
  kill -9 / crash succeeds within a bounded time. Normal completion and
  error paths release explicitly (best-effort; TTL is the backstop).
- /status exposes current lease holders (who owns each session).

Arbitration logic lives in a pure, testable src/session-lease.ts; no
extension change. Non-browser, ephemeral, read, and different-session
commands are unaffected.

* fix(daemon): profile-scoped lease keys and in-flight liveness for session leases (#2100)

Addresses two P2 findings from external review of PR #2100.

1. Lease key ignored the Chrome profile: arbitration ran before profile
   routing and keyed only on (surface, session), so the same persistent
   session name (e.g. site:chatgpt) in two different Chrome profiles —
   two different browsers — produced a false session_busy. Arbitration
   now runs AFTER resolveExtensionConnection (still before any dispatch)
   and the resolved contextId is part of the lease key. lease-release is
   keyed by runId alone (globally unique), scanning the registry instead
   of re-resolving the profile route, which may have disconnected by
   release time.

2. A single exec longer than the 45s TTL let the lease be stolen
   mid-run: liveness only refreshed on command arrival, so a slow
   navigate produced no heartbeat until it settled and a challenger
   could take the lease while the holder was still driving the tab.
   Pending entries now record the holder's runId; touch() accepts a
   hasPendingWork predicate (registry stays pure) so a TTL-stale holder
   with a command in flight still rejects challengers, and settlePending
   heartbeats the lease so the TTL clock restarts cleanly after a long
   exec.

* fix(daemon): keep lease through unknown-outcome failures and show pending-alive holders in status

* fix(daemon): keep lease when CLI timeout leaves the adapter running or pre-nav outcome is unknown

A CLI-layer runWithTimeout win does not cancel the adapter promise, and
the pre-nav CommandExecutionError wrapper hid unknown-outcome navigate
failures from the cause chain. Both paths released the lease while the
session could still be driven; they now fall back to TTL reclamation.

* fix(daemon): keep a timed-out adapter's run identity bound until it settles

Skipping the explicit release was not enough: the finally still cleared
the run context, so a zombie adapter's follow-up commands carried no
runId, never heartbeat the lease, and a challenger could acquire it
after the 45s TTL while the zombie kept driving the tab (the CLI error
path uses process.exitCode, so the event loop keeps the zombie alive).
Defer both cleanup steps to the adapter promise's own settlement; the
runId-guarded clear cannot strip a newer run's context.

* fix(daemon): apply the unknown-outcome rule to deferred lease cleanup

A timed-out adapter that finally rejects with command_result_unknown /
command_lost / result_evicted may leave a browser-side command running;
the deferred settle now skips the explicit release for those endings,
matching the immediate path, and lets the TTL reclaim the lease.
2026-07-12 00:52:33 +08:00
jakevin 00a1d8b1e9 docs: remove daemon port env from zh readme (#2124) 2026-07-12 00:45:06 +08:00
IAM DAVAID dc8c75f7f7 fix(twitter): pass user args through JSON.stringify in page.evaluate (#2121)
* fix(twitter): pass user args through JSON.stringify in page.evaluate

The `tweet-id` (article) and `username` (profile) arguments are
interpolated raw into the page.evaluate script string, while `ct0` and
the bearer token in the same functions already go through JSON.stringify.
A `tweet-id` that is not a status/article URL is used verbatim, so a
value containing a double quote escapes the string literal and injects
executable code into the evaluated page context. Route both arguments
through JSON.stringify, matching the existing handling of ct0/bearer.

* test(twitter): cover article evaluate arg escaping

* test(twitter): avoid article test ordering conflict

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-12 00:44:56 +08:00
IAM DAVAID f1f28cb2aa fix(twitter): harden article API response handling (#2123)
* fix(twitter): harden article API response handling

Two failure modes on the article command's GraphQL response were
unhandled and surfaced as opaque page.evaluate crashes:

- A 2xx response with a non-JSON body (logged-out HTML page, block or
  challenge page) made `await resp.json()` throw. Wrap it in try/catch
  and return a structured {error, hint}, mirroring the guard profile.js
  already has. The raw parser message is not surfaced, since V8's JSON
  SyntaxError echoes a fragment of the response body.
- A valid JSON `null` body made `d.data?.` throw a TypeError, bypassing
  the structured-error path. Guard the root with `d?.data?.`.

Both paths are turned into a clear CommandExecutionError by the existing
outer handler.

* fix(twitter): harden article response handling

* fix(twitter): fail closed on malformed article payloads

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-12 00:39:23 +08:00
陈家名 229b3b00d4 fix(utils): detect mixed-case HTML login walls (#2120)
* fix(utils): detect mixed-case HTML login walls

* fix(utils): tighten HTML login wall sniffing

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-12 00:26:43 +08:00
IAM DAVAID e2e9a1261e fix(skills): quote sitemap-author description to keep frontmatter valid YAML (#2122)
The `description` value is an unquoted plain scalar containing ": "
(colon-space) in "...OpenCLI site sitemaps: agent-facing...". A strict
YAML parser treats ": " as a mapping indicator and rejects the
front matter. Wrapping the value in double quotes makes it a valid
scalar without changing the text.
2026-07-12 00:20:06 +08:00
jakevin 6129bb3953 ci(e2e): place each gate where its runner can run it deterministically (#2082)
#2081 tried to make the real-browser AX smoke run everywhere via
--headless=new. That fixed headed macOS's Mach-port crash but exposed a
second environment property: hosted runners don't reliably start the MV3
extension service worker in headless, so main went red on macOS anyway.
Chasing headless reliability across runner images is the wrong axis.

First principles: gate each check on the environment that can run it
deterministically, and make sure every OS has a real blocking gate.

- Real-browser extension smoke (AX tree + cross-frame CDP): Linux under
  xvfb is the one hosted environment where a real Chrome reliably starts
  an MV3 extension. It runs there, headed, release-blocking. It is not
  scheduled on macOS/Windows because neither can run it deterministically
  (headed macOS crashes on Mach port rendezvous outside an Aqua session;
  headless connects no SW).
- Daemon transport contracts: no browser, deterministic, so they run
  blocking on all three OSes including Windows — macOS/Windows now have a
  real gate over the exact layer our recent bugs lived in (#2067/#2070/
  #2073), not a skipped test that proves nothing.
- Windows joins the matrix for the first time (transport gate); the
  setup-chrome action is skipped there since it hangs on the MSI path and
  Windows needs no browser.

Local Chrome launch stays headed by default; OPENCLI_E2E_HEADLESS=1 opts
into headless for display-less local runs.
2026-07-04 02:12:22 +08:00
jakevin 67344d5e36 test(e2e): run AX smoke headless on all platforms; add daemon transport contract E2E (#2081)
The AX real-Chrome smoke's contract is "the extension bridge works in a
real Chrome", not "a window appears" — Linux already admitted that by
faking a display with xvfb. Hosted macOS runners fail headed Chrome at
the OS level (child processes lose the Mach port rendezvous with the
browser process because CI jobs run outside a regular Aqua session), and
PR #2079 papered over that by skipping the platform. Running the smoke
with --headless=new removes the display/GUI-session dependency entirely:
new headless is a full browser (MV3 service worker, chrome.debugger,
--load-extension), verified locally against the same Chrome for Testing
build CI uses. The smoke is release-blocking on every OS again; headed
mode stays available locally via OPENCLI_E2E_HEADED=1.

New daemon-transport contract E2E: the real dist/src/daemon.js process
with a scripted fake extension, pinning the cross-layer contracts end to
end — duplicate ids attach to the pending command without re-dispatch,
deadlines produce a structured 408 command_result_unknown, extension
death after dispatch yields command_result_unknown, a stale
preferredContextId falls back to the only connected profile while an
explicit contextId fails loud, and graceful shutdown flushes structured
daemon_shutting_down 503s with exit code 0. No browser required; runs in
the fixed-port project on every OS.
2026-07-04 01:37:38 +08:00
jakevin cad35e7a6a chore(release): bump version to 1.8.6
Release / release (push) Has been cancelled
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Bump @jackwener/opencli to 1.8.6.
2026-07-04 01:10:55 +08:00
jakevin 8de1184da6 test(e2e): treat mac AX bridge startup as optional
Keep Linux AX smoke release-blocking while allowing hosted macOS to skip when command-line unpacked extension startup is unavailable.
2026-07-04 00:55:09 +08:00
Zhongyue Lin 1db7b5f1e8 fix(twitter): match localized delete menu and poll for late-hydrating article (#2001) (#2026)
* fix(twitter): match localized delete menu and poll for late-hydrating article (#2001)

twitter delete failed on a Simplified-Chinese X detail page: (1) the More
caret was matched by aria-label === 'More', which X localizes (zh-Hans 更多),
and (2) findTargetArticle() ran before the article's self-referential
/status/<id> link hydrated on slow networks. Inside buildDeleteScript:

- Prefer the language-agnostic [data-testid="caret"] (scoped to the matched
  article), falling back to a multilingual /^(More|更多)/ aria-label match.
- Poll findTargetArticle() for ~5s (20 x 250ms) before giving up.
- Broaden the Delete menu item to Delete/删除 and exclude the Lists item in
  both languages (List/列表).

* fix(twitter): harden delete menu result handling

* fix(twitter): scope delete menu items to opened menu

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-04 00:46:58 +08:00
jakevin f7ad36ba12 ci(e2e): pin macOS headed runner
Pin headed E2E macOS coverage to macos-15 while macos-latest migrates to macOS 26.
2026-07-04 00:44:03 +08:00
Zhongyue Lin 63db56d07b fix(weibo): resolve uid before the full auth probe to avoid HTTP 400 (#2047) (#2055)
* fix(weibo): resolve uid before the full auth probe to avoid HTTP 400 (#2047)

`auth status --site weibo --full` failed with `HTTP 400 from /ajax/profile/info`
even on a logged-in session: verifyWeiboIdentity fetched the bare
/ajax/profile/info, which the current Weibo web app rejects without a uid.
`weibo me` already works because it resolves the current uid first.

Mirror that path: call getSelfUid(page) (which throws AuthRequiredError when no
logged-in uid resolves), then probe /ajax/profile/info?uid=<uid>. Extract the
probe into buildWeiboIdentityProbe(uid) and add clis/weibo/auth.test.js.

* fix(weibo): unwrap auth identity probes

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-04 00:42:23 +08:00
jakevin 338dc794a7 test(e2e): stabilize headed Chrome gate
Use stable Chrome for Testing for headed E2E and keep e2e child-process timeouts below Vitest's framework timeout.
2026-07-04 00:32:46 +08:00
jakevin 9387cd9262 fix(browser): stale default profile must not veto live connections (#2073)
* fix(browser): stale default profile must not veto live connections

A persisted default profile (browser-profiles.json defaultContextId) has a
lifetime that routinely exceeds the extension instance it names — reinstalling
the extension or resetting Chrome regenerates the contextId. Since #1235 that
stale preference was folded together with --profile/OPENCLI_PROFILE into one
hard contextId on every command, so the daemon refused to serve it
(profile_disconnected) even when exactly one live profile was connected,
breaking the documented promise "with only one connected profile, OpenCLI
uses it automatically" — and making doctor hang waiting for a dead profile.

First-principles fix: distinguish REQUIREMENT from PREFERENCE end to end and
let the component that knows live state arbitrate.

- profile.ts resolves a ProfileSelection tagged 'explicit' (--profile arg,
  OPENCLI_PROFILE env — fail loud when offline) or 'preferred' (config
  default); profileRouteParams() maps it to the wire fields.
- New wire field preferredContextId (both protocol copies); contextId keeps
  its strict semantics. Old daemons ignore the new field, which degrades to
  the no-contextId single-profile auto-use — exactly the documented behavior.
- The daemon arbitrates via a pure, tested resolveProfileRoute(): requested →
  strict; preferred → use when connected, fall back to the only connected
  profile when not (logged once per stale id), ask with a stale-default hint
  when multiple are connected.
- bridge/ensure only pin readiness to a profile for explicit requirements —
  a stale preference no longer makes connect()/doctor wait for a dead
  profile.
- doctor surfaces the stale default with the fallback status and the
  recovery command (opencli profile use).

* fix(cli): key saved-tab scope by the selected profile in getPageScope

From adversarial review: getBrowserPage computed the target scope from the
profile SELECTION (explicit or preferred), but getPageScope read only the
explicit Page.contextId — so with a config-default profile the remembered
tab was saved under "<session>" and looked up under "<contextId>:<session>",
silently forgetting the selection on every command. Both sites now key the
scope by the selected profile.
2026-07-03 22:13:32 +08:00
jakevin 18dce783da fix(external): run Windows .cmd shims through the shell; non-zero exit on signal death (#2075)
npm-installed CLIs on Windows are .cmd shims: `where` finds them (so the
installed-check passes), but Node refuses to spawn them directly since the
CVE-2024-27980 hardening — spawnSync fails with EINVAL/ENOENT and every
CLI-hub passthrough to an npm-installed tool breaks (#1958). On that
specific failure the passthrough now retries through the shell with each
token quoted for cmd.exe.

Also: a child killed by a signal left status null and opencli exited 0,
reporting success to the calling shell/agent; signal death now maps to a
non-zero exit code.
2026-07-03 22:11:34 +08:00
jakevin 08d50d9b24 fix(cli): tolerate OPENCLI_DAEMON_PORT when it equals the default port (#2074)
OpenCLIApp injects OPENCLI_DAEMON_PORT=19825 into the environment of every
CLI it manages. The CLI hard-rejected the variable regardless of its value,
so fresh OpenCLIApp installs failed on every command — including --version
and doctor — with EX_CONFIG, and the daemon never started (#2068, #2072).

A value equal to the default port carries no configuration at all; only a
NON-default value is a genuine misconfiguration worth failing on. All three
rejection points (main.ts entry, daemon startup, transport assert) now share
isIgnorableDaemonPortEnv().

Also fixes the README multi-profile example that was missing the required
browser <session> positional (#1893).
2026-07-03 22:10:09 +08:00
Adong 928b1e548d feat(hltv): add HLTV adapters (#2028)
* feat(hltv): add HLTV adapters

* fix(hltv): harden row identity contracts

---------

Co-authored-by: Adong <jhdong8855@gmail.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-03 20:50:22 +08:00
Louie 96cbeb4f65 docs: align adapter contribution example with the JavaScript adapter layer (#2017)
The "Create a file like clis/<site>/<command>.ts" example predates #928, which
converted the entire adapter layer from TypeScript to JavaScript. The repo now
ships 0 .ts and 1259 .js built-in adapters, so a contributor following the doc
creates a file in the wrong format. Update the example to JavaScript (keeping a
pointer to the still-supported TypeScript path), and fix the adapter test
command, which pointed at src/ rather than the adapter's own clis/ test file.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:21:47 +08:00
jakevin bab040985a refactor(transport): exactly-once command transport — journal, waiters, absolute deadlines (#2070)
* refactor(transport): exactly-once command transport — journal, waiters, absolute deadlines

Rebuilds the CLI→daemon→extension command transport around one principle:
exactly-once = at-least-once retry + an idempotent executor. This replaces
the accumulated per-layer compensation (three client retry flags, cause-code
walking, duplicate-id 409s, an inner extension retry loop, a phased
reconnect state machine) with three small primitives:

1. Command journal (extension/src/journal.ts, chrome.storage.session).
   Every command id executes exactly once: duplicates attach to the
   in-flight promise, completed ids replay the recorded result, and ids
   whose worker died mid-execution report `command_lost` honestly.
   storage.session survives service-worker restarts and clears on browser
   exit — precisely the lifetime a retry cares about.

2. Stable ids + daemon waiters. Transport retries keep the SAME id; the
   daemon attaches duplicate ids to the pending command instead of 409ing.
   With the executor idempotent, every transport failure becomes safely
   retryable (gated on extension >= 1.0.22; legacy extensions keep the old
   conservative pre-connect-only retry). Semantic retries (attach_failed /
   tab_gone — failures BEFORE any page code ran) are the only place a new
   id is minted, once.

3. Absolute deadline (`deadlineAt`, epoch ms) instead of per-hop durations.
   Same machine, one clock: every layer computes remaining = deadlineAt -
   now, so daemon queueing and service-worker wake latency no longer
   silently shrink the innermost budget or invert the layering.

Error classification now happens once, at the failure site: the extension
tags results with machine-readable codes (attach_failed, tab_gone,
target_navigated, detached_mid_command, cdp_timeout) and errors.ts prefers
codes over the legacy message-pattern tables (kept only for old
extensions). detached_mid_command / cdp_timeout are now correctly
non-retryable — they die mid-execution, so a blind re-run could
double-apply a write.

Deletions and stability fixes riding the same contract:
- extension evaluate()'s inner retry loop (the client owns semantic
  retries now); the fast/slow reconnect window + notifyDaemonReachable
  rescheduling (plain exponential backoff with jitter, reset on success);
  the three parallel session-override Maps (one record per lease).
- WS application-level keepalive ({type:'ping'} every 20s): Chrome 116+
  only extends the service worker's lifetime on WS *activity*, so an idle
  socket lived on a knife-edge between the 30s idle kill and the 30s
  keepalive alarm.
- idle-lease release is deferred while a command is executing on the
  lease (refcount) — a 30s idle timer can no longer tear the tab down
  mid-command; completion re-arms the timer.
- daemon shutdown flushes structured 503s to waiting clients before
  exiting instead of process.exit() killing the queued responses.
- results are delivered on the freshest open socket after a reconnect
  instead of being dropped when the executing socket was superseded.

* fix(transport): gate daemon_shutting_down resend on journal capability; bound ensure by deadline

From adversarial review of the transport-v2 work:

- daemon_shutting_down was resent with the same id regardless of the
  extension's journal capability. The daemon fires it for DISPATCHED
  commands too, so on a pre-journal extension the resend re-executes a
  write. The daemon now returns the pre-dispatch contract for commands
  that never reached the extension (safe to resend anywhere) and
  daemon_shutting_down only for dispatched ones; the client resends
  those only when the extension journals ids, else surfaces
  command_result_unknown.

- ensureBridge's connect wait was a fixed 45s regardless of the
  command's remaining budget — repeated daemon failures could stretch a
  30s --timeout command past two minutes. The wait is now clamped to
  the remaining deadline.
2026-07-03 14:25:38 +08:00
jakevin 23cf6e5239 fix(browser): end-to-end command deadlines, safe transport retries, CDP timeouts (#2067)
* fix(browser): end-to-end command deadlines, safe transport retries, CDP timeouts

Three connectivity/stability fixes that share one root cause: the timeout
and retry contracts between CLI, daemon, and extension were disconnected.

1. Plumb one command deadline through all three layers. The client HTTP
   request was hardcoded to 30s while the daemon default was 120s and no
   caller ever set body.timeout — every command slower than 30s died with
   an opaque client-side AbortError while still running in the browser.
   Now the transport computes an effective timeout (user --timeout via
   setDaemonCommandTimeoutSeconds, or timeoutMs + margin for extension-side
   waits like wait-download), sends it as body.timeout, and aborts the HTTP
   request only after the daemon's structured 408 should have arrived.
   The daemon timer now rejects with the command_result_unknown contract
   instead of a bare Error the client cannot classify.

2. Stop replaying possibly-dispatched commands on fetch TypeError. Any
   `TypeError: fetch failed` used to trigger ensure + resend with a fresh
   id, bypassing the daemon's duplicate-id guard — a daemon crash mid-click
   could double-submit a form. Only pre-connect failures (ECONNREFUSED and
   friends, checked via err.cause) are retried now; post-connect drops
   surface as command_result_unknown per the existing contract.

3. Give chrome.debugger commands a real deadline. The extension's CDP
   calls had none (sendCommandInFrameTarget declared _timeoutMs and never
   used it), so a page-blocking native dialog (alert/confirm/beforeunload)
   hung Runtime.evaluate forever and wedged every later command on the tab.
   All sendCommand calls now race a timer; exec/cdp commands derive their
   deadline from the transport's body.timeout, undercut by 5s so the more
   specific extension error beats the daemon's generic timer.

* fix(browser): swallow post-timeout CDP rejections; short deadline for doctor probe

Two issues found in self-review of the deadline work:

- sendDebuggerCommand raced the command promise against a timer but left
  the losing command promise unobserved — if it rejected later (debugger
  detach on tab close long after the timeout fired) it surfaced as an
  unhandled rejection in the service worker. Swallow it on a side branch.

- doctor's checkConnectivity probe inherited the default 120s transport
  deadline, so a daemon that accepts requests but never answers made
  doctor hang for 2 minutes before reporting FAIL. A health probe wants
  the opposite: shrink the per-command deadline to the probe budget (8s)
  and restore it afterwards.

* fix(extension): honor derived CDP deadline in evaluateInFrame warm-up; pin deadline tests

From adversarial review of the deadline work:

- evaluateInFrame's Runtime.enable warm-up on the frame-target fallback
  path dropped the caller's derived deadline and fell back to the 60s
  default — a blocked iframe could burn the whole daemon budget in the
  warm-up alone, so the daemon's generic 408 always beat the extension's
  specific error on the cross-frame path.

- Two untested links in the deadline chain are now pinned by regression
  tests: the client HTTP abort fires exactly at timeout*1000 + 10s (not
  before the daemon's structured 408 can arrive), and handleExec derives
  115s from a 120s transport timeout (10s floor for tiny timeouts).
2026-07-03 13:35:09 +08:00
Marvin d6a7011454 Add ChatGPT Deep Research result extraction (#2023)
* Add ChatGPT Deep Research result extraction

* fix(chatgpt): bind deep research results to requested conversation

* fix(chatgpt): preserve deep research payload failures

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-01 02:12:06 +08:00
Marvin e7bdad4783 Fix ChatGPT intelligence level selection (#2022)
* Fix ChatGPT intelligence level selection

* Document ChatGPT model adapter usage in browser skill

* fix(chatgpt): verify model config selection

* fix(chatgpt): classify model preference api drift

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-01 02:01:22 +08:00
jakevin 0a90179322 fix(extension): cdp network-capture + frame-eval robustness (#1984)
Three independent CDP-layer correctness fixes:

1. Redirect wiped the captured POST body. On an HTTP 30x, CDP re-fires
   Network.requestWillBeSent with the SAME requestId (the prior hop in
   `redirectResponse`) for the redirect target — usually a GET with no
   postData. The handler overwrote the entry's request-body fields
   unconditionally, destroying the original POST body. Now the body is
   only populated on the initial send (guarded on `!redirectResponse`).

2. responseReceived created orphan entries. If readNetworkCapture()
   drained the entries (clearing requestToIndex) while a request was in
   flight, the later Network.responseReceived ran getOrCreate and made a
   new half-entry with a defaulted method ('GET') and no request data.
   Now it is lookup-only, mirroring loadingFinished.

3. evaluateInFrame had no retry on a stale cached context. A navigated/
   reloaded frame invalidates its cached execution-context id, but the
   executionContextDestroyed event may not be processed yet, so
   Runtime.evaluate rejects with "Cannot find context with specified id".
   Now that rejection drops the stale id and falls through to the
   frame-target path (mirrors evaluate()'s re-resolution); genuine page
   errors still propagate.

Tests: redirect-body preservation, orphan-entry prevention, and
stale-context fallback — all reverse-validated. cdp suite 15/15, tsc
clean, extension/dist rebuilt.
2026-07-01 01:35:33 +08:00
lizkaiman 215a73dc56 feat(gemini): add model and thinking selection (#2044)
* feat(gemini): add model and thinking selection

Co-authored-by: multica-agent <github@multica.ai>

* fix(gemini): harden model selection contracts

---------

Co-authored-by: coder-SOTA-hm <coder-SOTA-hm@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-01 01:33:43 +08:00
jakevin 1f8b09bd1c fix(extension): preserve network capture across ensureAttached re-attach (#1978)
A forced detach inside ensureAttached's re-attach loop fires
chrome.debugger.onDetach, whose handler deletes the tab's armed
networkCaptures state; the detach also disables the CDP Network domain,
and re-attach only re-issued Runtime.enable. So any non-navigate command
that triggered a re-attach (a stale-attach health-check failure during SPA
navigation, or third-party debugger interference) left
network-capture-read returning [] even though requests fired — the
recorded "0 captures" symptom.

Snapshot the capture before the re-attach and, on success, re-enable the
Network domain and restore the accumulated state (restored last so it
wins over the onDetach handler's delete). Adds a regression test that
fails without the restore.
2026-07-01 01:31:25 +08:00
jakevin 9a4e11d3fa fix(extension): honor persisted remaining idle lifetime on reconcile (#1980)
reconcileTargetLeaseRegistry computed each lease's remaining lifetime
(stored.idleDeadlineAt - now) but used it only to decide expire-vs-keep;
the keep branch called resetWindowIdleTimer(leaseKey), which always
schedules a fresh FULL idle timeout, discarding the remaining time.

Under MV3 service-worker churn (the SW is evicted/restarted routinely),
a lease's idle deadline was refreshed to the full timeout on every
restart, so an owned adapter tab/placeholder that should auto-release
could linger far past its idle timeout — effectively indefinitely.

Add an optional remainingMs override to resetWindowIdleTimer and pass the
computed remaining from reconcile, clamped to [0, timeout]. Adds a
regression test (5s-remaining lease must schedule a ~5s alarm, not 30s);
reverse-validated.
2026-07-01 01:26:25 +08:00
iynewz d174f724ec feat(adapter): add Mercury reimbursement helpers (#2052)
* Add Mercury reimbursement helpers

* fix(mercury): fail closed reimbursement drafts

* fix(mercury): harden reimbursement draft safety checks

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-07-01 01:25:09 +08:00
jakevin 244ec45278 fix(core): stop silently swallowing pipeline context + daemon WS errors (#1979)
Two unrelated try/catch blocks were eating errors with no observable
signal, both reachable from production paths:

1) `src/pipeline/template.ts:215` `sanitizeContext` (the JSON round-trip
   that severs prototype chains before handing pipeline context to the VM
   sandbox) caught any `JSON.stringify` failure and returned `{}`. The
   most common cause is a BigInt anywhere in `data` / `args` / `item` /
   `root` (e.g. GraphQL 64-bit IDs). After collapse, every template
   expression referencing that branch resolved to `undefined`, producing
   silent column-drops downstream with no warning.

   Fix:
   - Add a JSON.stringify replacer that coerces BigInt to string, so the
     common BigInt-in-context case survives the sandbox copy.
   - For everything else (circular references, Symbol, etc.), the
     fallback is still `{}` but now log.warn so the failure shows up in
     `~/.opencli` logs and doctor output instead of silently producing
     blank rows.

2) `src/daemon.ts:445` the WS message handler from the extension caught
   `JSON.parse` failures and ran the `// Ignore malformed messages`
   comment. A malformed message presents downstream as a generic command
   timeout (`pending` never resolves), so the actual protocol drift /
   version skew between daemon and extension never surfaced in the log.

   Fix: log.warn the parse error plus the first 200 chars of the offending
   frame so the root cause is visible during triage.

Both changes are observability-only: no successful path changes behavior;
only previously-silent failure paths get a log line, plus BigInt now
serializes to a string instead of nuking its containing branch.

Tests:
- `src/pipeline/template.test.ts`: two new cases covering the BigInt
  preservation path (forces the VM sandbox via `String(args.id)`, not the
  resolvePath fast path) and the circular-ref no-crash invariant.
- daemon WS handler change is log-only; existing daemon tests cover the
  message dispatch path.
2026-07-01 01:22:06 +08:00
陈家名 5d2e87ad16 test(slock): cover trimmed task status filters (#2041) 2026-07-01 01:05:06 +08:00
jakevin b3695a2468 chore(browser): share bind command handling (#2043) 2026-07-01 00:59:34 +08:00
jakevin 01022d9c09 chore(daemon): read status through transport layer (#2040) 2026-07-01 00:59:18 +08:00
jakevin 52396b2da2 chore(browser): extract network interceptor script (#2042) 2026-07-01 00:59:03 +08:00
jakevin 046712ab42 chore(extension): bump version to 1.0.21 (#2039)
Build Chrome Extension / build (push) Has been cancelled
2026-06-28 10:30:20 +08:00
jakevin 9161d99d96 chore(release): bump version to 1.8.5 (#2038)
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-28 02:12:13 +08:00
jakevin 616cc88603 test(e2e): isolate fixed-port browser tab tests (#2037) 2026-06-28 01:54:40 +08:00
jakevin cbb9e23394 test(e2e): align browser tests with fixed bridge port (#2036) 2026-06-28 01:22:51 +08:00
jakevin fc4c7c151f test(extension): isolate background reconnect timers (#2035) 2026-06-28 00:49:37 +08:00
jakevin 6b99276a8d fix(twitter): default tweet page delay to two seconds
Change twitter tweets pagination delay default from 1s to 2s and update manifest/tests.
2026-06-27 23:52:31 +08:00
jakevin b056e420b1 fix(twitter): allow paginated tweet backfills
Remove the silent 200-row cap from twitter tweets, add paginated request delay support, and cover >200 cursor pagination.
2026-06-27 23:24:29 +08:00
jakevin 70629fb9b0 fix(bridge): unify active ensure and reconnect recovery
Unify Browser Bridge active daemon ensure and per-command pre-dispatch recovery, harden MV3 extension reconnect cadence, and increase connect timeout headroom for Chrome alarm wake floor.
2026-06-27 21:55:03 +08:00
jakevin ba2dcc0dcc fix(browser): enforce fixed daemon bridge port (#2031) 2026-06-27 21:51:54 +08:00
Louie df8ca8d440 feat(xianyu): search 服务端价格区间 / 地区筛选 (mtop API) (#2013)
* feat(xianyu): search 支持服务端价格区间 / 地区筛选(--min-price/--max-price/--province/--city)

闲鱼 search 之前只有 query + limit。本次改成直接调 goofish 自己的搜索接口
`mtop.taobao.idlemtopsearch.pc.search`(沿用 item.js 里 window.lib.mtop.request
的用法,签名由页面自带,无需手搓),把价格区间和地区交给服务端筛选 + 分页,
而不是抓一屏 DOM 再在本地过滤。

筛选编码是在登录态浏览器里 hook window.lib.mtop.request、实际操作筛选面板抓到
真实请求后逆出来的,并逐条用接口返回 + 详情接口 publishCity 做了 ground-truth 校验:

- --min-price / --max-price → propValueStr.searchFilter = "priceRange:<min>,<max>;"
  (元;单边区间用 0 / 99999999 兜底)。实测 priceRange:100000,150000 返回价格
  全部落在 112999–149900。
- --province / --city → extraFilterValue = JSON({divisionList:[{province,city}],
  excludeMultiPlacesSellers:"0",extraDivision:""})。city 可单独使用(province 留空)。
  实测「广东」「北京」结果集完全不相交;--city 深圳 / 湛江 的结果用详情接口校验
  publishCity 全部命中。
- 任一筛选生效时 fromFilter=true。limit 最多 60,按需翻页(rowsPerPage=30)。

返回字段改为结构化解析(item_id / title / price / location / want / url),
鉴权与风控错误处理沿用 item.js 的成熟逻辑(AuthRequired / Empty / mtop-not-ready)。

已在登录态浏览器里真跑验证(深圳+10–20万、湛江、无筛选基线均正确),
clis/xianyu 45 个单测全绿,tsc --noEmit 通过,cli-manifest.json 同步重建。

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

* fix(xianyu): harden search filter result handling

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-27 18:28:47 +08:00
Bo Liu 1dd712d609 feat(juejin): add Juejin (掘金) read-only adapter (#2007)
* feat(juejin): add Juejin (掘金) read-only adapter

Two PUBLIC commands for the Juejin developer community: `recommend` (homepage feed) and `hot` (article ranking by category). Native fetch against api.juejin.cn; no browser, no auth. Category aliases (`backend`, `frontend`, `android`, `ios`, `ai`) resolve to Juejin's stable numeric ids.

Closes #1711

* fix(juejin): fail closed on API shape drift

* fix(juejin): expose recommendation cursor

* fix(juejin): classify response cursor drift

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-24 06:18:24 +08:00
Louie ee4820ef59 feat(adapters): add Chinese car-platform adapters — 懂车帝 / 瓜子二手车 / 汽车之家 (no-login) (#2009)
* feat(adapters): add 懂车帝 (dongchedi) + 瓜子二手车 (guazi) car adapters

Two no-login PUBLIC adapters for Chinese car platforms. Both read
server-rendered data (no cookies, no signature, no browser) and ship
pure parsers unit-tested against frozen real-data fixtures.

dongchedi (6 commands) — parses __NEXT_DATA__ SSR JSON:
  search  车系搜索 + 指导价/经销商价
  series  车系概览(品牌/价格/懂车分/销量排名/款型数)
  models  款型列表 + 价格
  specs   配置概览(尺寸/动力/四驱/悬挂/气囊)
  score   懂车分 8 维评分 + 同级对比
  koubei  车主口碑/评价正文
  (Dongchedi's /motor XHR APIs are ByteDance-signature gated; the SSR
   pages expose the same data unsigned, so the adapter reads those.)

guazi (2 commands) — parses m.guazi.com mobile SSR HTML:
  browse  分城市在售二手车列表(售价/里程/年份)
  car     车源详情(售价/上牌/里程/过户/配置/车况)
  (Desktop www.guazi.com is signature-locked; mobile SSR is open. Deep
   pagination/filtering uses the signed API and is intentionally omitted.)

Gates green: tsc, doc-coverage --strict, silent-column-drop (new=0),
typed-error-lint (no new), 24 adapter tests passing.

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

* feat(adapters): add 汽车之家 (autohome) — brand catalog + 口碑 ratings

Third no-login PUBLIC car adapter (search by brand, not free text).

autohome (2 commands):
  brand  按品牌列出全部车系 + 厂商指导价(grade/carhtml/<INITIAL>.html,
         中文品牌名→拼音首字母目录页,DL 块按品牌定位)
  score  车系口碑评分:总分 + 各维度 + 故障率PPH + 竞品对比
         (k.autohome.com.cn/<id> 的 __NEXT_DATA__.baseData,免登录免签名)

Deliberately omitted (would be silently-wrong without a browser running
Autohome's signing/anti-scrape code): free-text keyword search (signature
gated) and full per-trim 参数配置 (rotating CSS font-glyph obfuscation).
Use dongchedi search/specs/koubei for those. Documented in the adapter doc.

Gates green: tsc, doc-coverage --strict (170/170), silent-column-drop
(new=0), typed-error-lint (no new); 31 adapter tests passing across the
three car adapters.

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

* fix(car-adapters): fail closed on parser drift

* fix(guazi): fail closed on empty SSR listings

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-24 06:07:01 +08:00
Ocean 55b053a6f6 feat(bilibili): 给 video / subtitle / download 加 --page 支持分P选集 (#2003)
* feat(bilibili): 给 video / subtitle / download 加 --page 支持分P选集

多P视频(视频选集)此前 video / subtitle / download 都丢弃 `?p=` 参数、永远解析到 P1 的 cid / 标题 / 字幕 / 视频流。新增可选 `--page N`:

- **video**:从 view API 的 `data.pages` 取第 N 集,`title` 换成分集标题,额外透出 `page` / `cid` / `series_title` 字段;缺省不加,保持整集旧行为
- **subtitle**:用 `pages[N-1].cid` 取代默认 P1 cid,拿该集字幕
- **download**:拼 `?p=N` 给 yt-dlp 原生定位该集
- **utils**:共享 `parsePageArg` / `selectVideoPart`(越界结构化报错)

向后兼容:不传 `--page` 时三命令行为完全不变。新增 9 个测试,bilibili 全量 109 通过;`tsc --noEmit` 干净;`check:silent-column-drop` new=0。

* fix(bilibili): harden multipart page selection

---------

Co-authored-by: ml-scout <ml-scout@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-24 05:47:25 +08:00
jakevin 80548c7de9 feat(cli): split opencli list table into App vs Site sections (#2000)
* feat(cli): split `opencli list` table into App vs Site sections

Per @WAWQAQ: `opencli list` (default table format) grouped every adapter
under a flat `site:` heading, so desktop-app adapters like `trae-cn`,
`cursor`, `codex` looked the same as web-site adapters like `bilibili`
or `twitter` — a user couldn't tell which entries drove a real browser
session vs an Electron app via CDP.

`opencli --help` already classified adapters via `classifyAdapter(domain)`
(from `src/help.ts`), grouping them into "App adapters" and "Site adapters"
sections; `opencli list` was the lone outlier still using the flat layout.

Mirror that classification in `list`:

- Walk commands once, partition each command's `site` group into
  `appsBySite` or `sitesBySite` based on `classifyAdapter(cmd.domain)`.
- Render section headers ("App adapters" / "Site adapters") before each
  group, then keep the existing per-site layout untouched.
- Update the summary footer from
  `... across N sites, M external CLIs` to
  `... across X apps + Y sites, M external CLIs`
  so the split is visible numerically too.
- Skip empty sections — a user with only sites (no Electron apps) won't
  see a stray "App adapters" header.

Non-table formats (json / yaml / md / csv) are unchanged; structured
consumers already get the `domain` field per row and can re-classify
themselves if they care.

`npx tsc --noEmit` clean; `npx vitest run --project unit src/cli.test.ts`
shows the same 157/163 pre-existing pass/fail counts as `main` (the 6
failing browser-tab targeting tests are unrelated, pre-existing on
`7af50abd`).

* fix(cli): classify loopback adapters as apps
2026-06-23 15:47:46 +08:00
leo1in88 7af50abd04 feat(github): add github trending adapter (#1953)
* feat(github): add `github trending` adapter

Add a PUBLIC adapter that lists repositories from
https://github.com/trending — the trending view is a public HTML page with
no official REST API, so the data was previously unreachable through opencli.

The adapter fetches the page server-side (no browser, no auth) and parses
each repo's full name, description, primary language, total stars, forks,
and stars gained in the period.

Flags:
- `--since`    daily | weekly | monthly (default daily)
- `--language` filter by language slug, e.g. python, rust, "c++"
- `--limit`    1..25 (GitHub lists at most 25)

Typed errors: ArgumentError for bad --since / --limit, CommandExecutionError
on request/HTTP failure, EmptyResultError when the page yields no repos.

Tests cover parsing (stars/forks/language/description/url, missing language),
limit truncation, language+since URL building, argument validation, the
empty-result path, and non-ok HTTP.

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

* refactor(github-trending): rename to standalone `github-trending repos`

Move the trending scraper out of the `github` site namespace into a
dedicated `github-trending` adapter to avoid confusion with the bundled
`gh` external CLI. Command is now `opencli github-trending repos`
(site=github-trending, name=repos), leaving room for a future
`developers` subcommand. The `github` site retains only login/whoami.

Regenerated cli-manifest.json; 8 fixture tests + typecheck pass.

* fix(github-trending): fail closed on parser drift

---------

Co-authored-by: minh <claude@ttfy.cc>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-22 21:27:57 +08:00
songsp77 e5bbce4fe2 fix(12306): handle endpoint rotation via 302 redirects (#1999)
* fix(12306): handle endpoint rotation via 302 redirects

Three related bugs caused the trains command to fail with
   "non-JSON body" when 12306 rotated its query endpoint:

   1. Node.js fetch defaulted to redirect: 'follow', silently
      following 12306's HTTP 302 to error.html and returning
      HTML instead of JSON.

   2. The 302 response body contained rotation info
      (e.g. {"c_url":"leftTicket/queryB"}) but was never read
      because resp.status === 302 triggered continue before
      consuming the body.

   3. Mutating QUERY_ENDPOINTS via unshift() during a for...of
      loop caused infinite iteration when the new endpoint was
      skipped by the iterator.

   Changes:
   - Set redirect: 'manual' on fetch to capture 302 responses
   - Parse c_url from 302 body and enqueue the rotated endpoint
   - Replace for...of with a while queue + Set-based dedup to
     safely handle dynamic endpoint discovery

   Fixes the trains command against the current 12306 wire
   protocol (queryG → 302 → queryB rotation).

* fix(12306): bound leftTicket endpoint rotation

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-22 18:00:13 +08:00
Mingming Lou 5848754943 fix(gemini/history): expand collapsed Recents sidebar before extraction (#1962)
* fix(gemini/history): expand collapsed Recents sidebar before extraction

opencli gemini history returned EMPTY_RESULT ("No conversation links were
visible in the sidebar") even when logged in, because Gemini collapses the
sidebar "最近"/Recents section by default — the /app/<id> conversation
anchors are absent from the DOM until that section is expanded.

getGeminiConversationList now retries extraction (up to 3 times) and, while
empty, clicks the sidebar-open button plus the Recents expand/collapse toggle
(matched by aria-label in both zh and en) before waiting for the React
sidebar to render and re-extracting.

Verified: opencli gemini history --limit 5 returns 5 conversations (2.5s);
opencli gemini detail <id> reads full conversation content.

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

Generated on: cmcc-i5

Generated by: home-cc

* test(gemini): cover collapsed recents history extraction

* fix(gemini): avoid collapsing expanded recents

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-22 16:53:42 +08:00
pi-dal 19e64da565 feat(chatgpt): add project commands (#1992)
* feat(chatgpt): add project commands

Add ChatGPT project management adapters for listing visible projects and uploading local files into project knowledge.

The new project-list command extracts project links from stable sidebar anchors first, with a React Fiber fallback for sidebar builds that do not expose hrefs. The project-file-add command uploads through the project knowledge flow, validates local files before browser interaction, and waits for filename confirmation before reporting success.

For the current ChatGPT project UI, project knowledge uploads live behind the Sources tab rather than the older Add files dialog. The upload helper now prefers that Sources surface, avoids mistaking the chat composer plus button for project knowledge upload, and dispatches a browser-like pointer/mouse sequence so Radix-powered tabs activate reliably in live sessions before setting the source file input.

This intentionally avoids command-level system proxy mutation. Users who need a proxy should configure the browser or network environment outside this adapter, rather than letting a single command toggle OS proxy settings.

Also update the generated CLI manifest and focused adapter tests for command registration, argument contracts, project id parsing, project link extraction, upload confirmation, live Sources-tab upload behavior, and failure wrapping.

Validation:
- pnpm exec tsx src/main.ts chatgpt project-list -f json --trace retain-on-failure --window foreground --keep-tab true (live authenticated local session; returned visible projects)
- pnpm exec tsx src/main.ts chatgpt project-file-add /tmp/opencli-chatgpt-project-upload-validation-pointer-20260621215147.txt --id 6a1791df8fa88191afb5a016ce1f497e -f json --trace retain-on-failure --window foreground --keep-tab true (live authenticated local session; uploaded one text file to project knowledge)
- pnpm exec vitest run --project adapter clis/chatgpt/commands.test.js clis/chatgpt/envelope.test.js clis/chatgpt/image.test.js clis/chatgpt/model.test.js clis/chatgpt/utils.test.js
- pnpm exec tsc --noEmit
- pnpm run build-manifest

* feat(chatgpt): support project chat routing

Add --project to chatgpt ask/send so messages can start a new chat inside a specified ChatGPT project. Reject --project with --conversation before navigation, and parse project-scoped /g/g-p-.../c/<id> conversation URLs so ask can report the created conversation id.

Validation:

- pnpm exec vitest run --project adapter clis/chatgpt/commands.test.js clis/chatgpt/envelope.test.js clis/chatgpt/image.test.js clis/chatgpt/model.test.js clis/chatgpt/utils.test.js

- pnpm exec tsc --noEmit

- pnpm run build-manifest

* feat(chatgpt): extend project routing

Add --project routing to chatgpt new, image, and model so project-scoped work is available beyond ask/send. New and image open the specified project before preparing the composer; model opens the project before switching the intelligence level.

Validation:

- pnpm exec vitest run --project adapter clis/chatgpt/commands.test.js clis/chatgpt/envelope.test.js clis/chatgpt/image.test.js clis/chatgpt/model.test.js clis/chatgpt/utils.test.js

- pnpm exec tsc --noEmit

- pnpm run build-manifest

- pnpm exec tsx src/main.ts chatgpt new --help / image --help / model --help

* fix(chatgpt): harden project command boundaries

* fix(chatgpt): require stable project ids

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-22 16:26:59 +08:00
Bo Liu acfab26a52 feat(semanticscholar): add Semantic Scholar academic graph adapter (#1994)
* feat(semanticscholar): add Semantic Scholar academic graph adapter

Native fetch against api.semanticscholar.org. Four read commands cover paper detail (with influentialCitationCount and tldr), citation list, AI-curated recommendations, and free-text search. Optional SEMANTIC_SCHOLAR_API_KEY env var lifts the anonymous rate limit.

Closes #1993

* fix(semanticscholar): fail closed on malformed paper rows

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-22 16:25:11 +08:00
jakevin 22bedf21eb docs(readme): recommend OpenCLIApp install path 2026-06-22 03:01:19 +08:00
jakevin 6557622156 fix(adapters): surface empty results as EmptyResultError, not sentinel rows (#1981)
* fix(adapters): surface empty results as EmptyResultError, not sentinel rows

Four adapters returned a fabricated row (exit 0) on the not-found/empty
path instead of throwing a typed error, so an agent reading the exit code
or rows could not tell "no results" from success — the framework assigns
EMPTY_RESULT its own exit code precisely so this is detectable:

- maimai/search-talents: returned [{error, query}] on zero candidates.
  Worse, `error`/`query` are not in `columns`, so the message was dropped
  by column projection — the user saw an empty/garbage row, never the
  reason. Now throws EmptyResultError (fixes the silent-column-drop too).
- discord-app/search: returned a synthetic "System" row on no matches.
- pixiv/download: returned a failed sentinel when an illust had 0 pages
  (the file already throws typed errors elsewhere). Test updated to
  assert the throw.
- xiaohongshu/download: returned a failed sentinel when a note had no
  media (the file already throws CliError for the security-block branch).

Per-image partial-failure status rows in the download loops are left
as-is (legitimate batch reporting). Auth-path conversions
(tiktok/facebook string-prefix + maimai in-page throw) are a separate
follow-up since they involve in-page-throw handling.

Adapter suites green; typed-error-lint and silent-column-drop audits
report no new violations.

* fix(adapters): fail closed on malformed empty payloads

* fix(audit): bump undici in lockfile

* fix(pixiv): fail closed on missing pages payload

---------

Co-authored-by: codex-mini0 <codex-mini0@slock.local>
2026-06-21 15:44:19 +08:00
jakevin de2ef4ca56 fix(qwen): anchor waitForAnswer to stop returning the previous answer (#1982)
* fix(qwen): anchor waitForAnswer on pre-send turn to stop returning the previous answer

qwen `waitForAnswer` took no baseline and never skipped stale turns — the
`seenAssistantId` variable was assigned but never read (dead code). Since
`getMessageBubbles` returns every turn including the already-complete
previous answer, a follow-up `qwen ask` into an existing conversation
(persistent site session, no --new) saw that prior answer on the first
polls. It was already stable, so the stability check returned it as if it
were the reply to the new prompt — silently wrong output, no error.

Mirror grok's reference fix: capture the last assistant turn's id before
sending (`getBaselineLastAssistantId` in ask.js) and `continue` in
waitForAnswer while the latest assistant id equals that baseline. Removes
the dead `seenAssistantId`.

Tests: getBaselineLastAssistantId helper (mirrors grok), plus a direct
waitForAnswer test asserting the pre-send turn is skipped (times out
rather than returning the stale answer) — reverse-validated.

* fix(qwen): bind answer wait to sent prompt turn

* fix(audit): bump undici in lockfile

* fix(qwen): fail closed when answer anchor is not visible

---------

Co-authored-by: codex-mini0 <codex-mini0@slock.local>
2026-06-21 15:18:36 +08:00
jakevin 001abdf481 fix(deepseek): throw TimeoutError on no-reply instead of a silent sentinel row (#1983)
* fix(deepseek): throw TimeoutError on no-reply instead of a silent sentinel row

deepseek `ask` returned `[{ response: '[NO RESPONSE] No reply within Ns.' }]`
(exit 0) on both the normal and --file paths when no reply arrived — the
same sentinel-row anti-pattern fixed for other adapters in #1981. Every
sibling chat adapter throws a typed error here (claude EmptyResultError,
grok/qwen TimeoutError), so an agent branching on exit code / error type
saw "success" and consumed the literal `[NO RESPONSE] ...` string as if it
were the model's answer.

Both paths now throw TimeoutError (exit code TIMEOUT), making the failure
observable. Tests cover both the normal and --file timeout paths;
reverse-validated.

Note: the deeper root cause — `sendMessage` in utils.js can silently
no-op server-side (execCommand + fixed 800ms, the exact pattern send.js
warns against) — is a separate follow-up: it needs the proven
nativeType + aria-disabled-poll path (extracted as a shared helper with
send.js) plus live smoke against the site, which can't be verified
offline. This PR at least converts that silent no-op into a loud timeout.

* fix(audit): bump undici in lockfile

---------

Co-authored-by: codex-mini0 <codex-mini0@slock.local>
2026-06-21 14:52:09 +08:00
jakevin 1d87cde513 feat(zhihu): add user/answers/articles/following/followers/pins read commands (#1986)
* feat(zhihu): add user profile + answers/articles/following/followers/pins read commands

Enriches Zhihu read coverage with 6 new /api/v4 commands (all live-verified
against a logged-in account):

- `zhihu user <user>`        — profile (follower/following/answer/article/voteup counts)
- `zhihu user-answers <user>`— a user's answers (votes/comments/url)
- `zhihu user-articles <user>`— a user's articles (专栏)
- `zhihu following <user>`   — followees
- `zhihu followers <user>`   — followers
- `zhihu pins <user>`        — 想法 (short posts)

Each accepts a url_token, `user:<slug>`, or people URL (shared `parseZhihuUser`).
List commands share a `fetchZhihuList` paginator (cookie fetch + paging.next +
typed errors: AuthRequiredError on 401/403, NOT_FOUND on 404, FETCH_ERROR else).

Tests: 6 new suites (happy path + auth/limit edges). Full suite 5551 passed;
typed-error-lint and silent-column-drop new=0.

* fix(zhihu): harden user read commands

* fix(zhihu): keep user pagination on same endpoint
2026-06-21 14:48:35 +08:00
Ocean bcd9c124c4 perf(reddit): 删除冗余的首页预导航步,每命令双导航→单导航 (#1987)
reddit 的 popular / subreddit / search / read 命令都先用框架 navigateBefore(domain=reddit.com → 302 到 www.reddit.com)把页面带到 reddit origin,**又**额外硬编码一步导航到 `https://www.reddit.com` 首页,然后才发相对 fetch(`/r/popular.json`、`/comments/<id>.json` 等)。两次导航到同一站点首页纯属冗余 —— 框架那次已经够让相对 fetch 工作。

实测:一次串行抓取仅 reddit 就因此重复导航首页约 24 次(每次 6-15s)。

## 改动

- 删掉 popular / subreddit / search 的 pipeline `{ navigate: 'https://www.reddit.com' }` 步;
- 删掉 read.js func 里的 `await page.goto('https://www.reddit.com')`;
- 框架 navigateBefore 仍把页面带到 reddit origin,相对 fetch 照常工作。

## 配套测试调整

- `read.test.js`:原断言「导航到首页」改为**反向回归保护**(`page.goto` 不再以首页 URL 被调用),锁住本次优化不被回退;
- `popular / search / subreddit.test.js`:删掉一个 pipeline 步后 evaluate / map 的索引各前移一位(`[1]→[0]`、`[2]→[1]`),同步更新断言。

## 验证

- 逐一实测 `opencli reddit popular / subreddit / search / read` 均正常返回数据;
- reddit 全量适配器测试 88 通过;`tsc --noEmit` 干净;`npm run build` 干净。

Co-authored-by: ml-scout <ml-scout@anthropic.com>
2026-06-21 14:28:19 +08:00
cypggs 7879b774e9 feat(kimi): add usage adapter (#1985)
* feat(kimi): add code-console adapter

Read Kimi Code console usage cards: weekly quota, rate limit,
membership, and model permission.

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

* refactor(kimi): rename code-console adapter to usage

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

* refactor(kimi): remove code-console.js after renaming to usage

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

* fix(kimi): register and harden usage adapter

* fix(kimi): fail closed on missing usage cards

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: codex-mini0 <codex-mini0@slock.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-06-20 23:57:22 +08:00
Singh 84dde77359 fix(linkedin): scope connect invites to profile owner
Final head ff120c1c4e3eca5e47c415c6c80f5a5b4711383c.

Contract: linkedin connect remains a write command and dry-run by default. --profile-url must be an exact LinkedIn profile URL, and --expected-name is required with strict actual profile-name match. Connect availability, More availability, and invite anchors are now scoped to the owner top-card / name-bearing owner action controls, so sidebar or People-also-viewed Connect/More buttons cannot make dry-run falsely connectable or provide the invite URL for the target profile. Top-level anchor Connect accepts only trusted /preload/custom-invite/ links from owner action scope. Button/More path opens Connect only from an owner-named action bar and fails closed when owner controls cannot be proven. Delivery still requires sent-invitations verification for sent_verified; unverified sends return send_unverified rather than a verified success.

Validation: lead+aux content green. Remote statusCheckRollup is empty, merged under the standing no-check override after final poll confirmed OPEN/non-draft, head unchanged, and MERGEABLE/UNSTABLE with no conflict/dirty/content blocker. Local reviewer validation covered focused clis/linkedin/connect.test.js 1 file/21, full LinkedIn adapter 24 files/213, typecheck, build manifest 1225, docs-build, node --check touched LinkedIn files plus dist/src/main.js, typed-error-lint and silent-column-drop no new, listing-id advisory unchanged, and diff-check clean.
2026-06-19 04:37:41 +08:00
Bo Liu d2abdcc4b7 feat(archive): add Internet Archive read-only adapter
Final head 5f5661c2702f717e23fd997ca210aa474e131310.

Contract: adds a public read-only Internet Archive adapter with archive search, item, wayback, and snapshots commands. All commands are read access, browser:false, with no login, write, upload, or browser UI side effects. Source of truth is Internet Archive Advanced Search response.docs, /metadata/<identifier>, Wayback available closest snapshot, and CDX JSON header/rows. Fail-closed boundaries: search requires response.docs array, true empty maps to EmptyResultError, rows require stable identifier and numeric downloads; item requires metadata.identifier equals requested identifier and files array, while missing metadata/404 remains empty; wayback distinguishes no closest snapshot true empty from available:true missing URL or 14-digit timestamp malformed CommandExecutionError; snapshots requires top-level CDX array, header array, required columns, and per-row timestamp/original/statuscode/mimetype cells, with malformed shapes typed CommandExecutionError rather than empty snapshot URLs or empty status columns.

Validation: lead+aux final green. GitHub final gate OPEN/non-draft/CLEAN, required checks SUCCESS, adapter/smoke skipped. Local reviewer validation covered current-main merge-tree clean, targeted/full clis/archive 1 file/18 tests, typecheck, build manifest 1231, docs-build, doc coverage 165/165, prod audit clean, node --check touched Archive files/tests, diff-check, typed-error-lint and silent-column-drop no new.
2026-06-19 04:28:56 +08:00
Zhongyue Lin e416f5f071 fix(gemini): match Traditional Chinese send label
Final head 1eb03c85816a3d74fa281439070d56a7bb9896c7.

Contract: Gemini composer submit-button detection expands the existing submit label matcher from send/发送/submit/提交 to also include Traditional Chinese 傳送. The change is scoped to clis/gemini/utils.js and tests. Button search remains constrained to the composer-near root, requires visible and enabled candidates, excludes main menu, microphone, upload, mode/tools/settings/new chat and other non-submit controls, and keeps the existing vertical-distance/right-side small-button scoring. If no valid button is found, send/ask still fall back to Enter. No change to Gemini send/ask submit confirmation semantics, command surface, docs, or manifest behavior.

Validation: lead+aux final green. GitHub final gate OPEN/non-draft/CLEAN, required checks SUCCESS, adapter/smoke skipped. Local reviewer validation covered focused clis/gemini/utils.test.js 1 file/27, full Gemini adapter 6 files/91, typecheck, build manifest 1227, docs-build, node --check touched Gemini files plus dist/src/main.js, typed-error-lint and silent-column-drop no new, listing-id advisory unchanged, and diff-check clean.
2026-06-19 04:19:14 +08:00
jakevin b0de4e6cc8 fix(cli): accept trailing browser --window option
Final head 3113483ab3679d98ae4e82e22c14a12936acfdd6.

Contract: the compatibility rewrite is scoped to the browser root command's <session> positional rewrite path. Existing browser <session> <subcommand> to internal browser --session <session> <subcommand> behavior remains unchanged. Non-browser roots are not scanned. The public --session form remains rejected. Trailing --window <mode> / --window=<mode> after a browser leaf command and before literal -- is hoisted into the browser namespace option slot, allowing natural forms such as opencli --profile sandbox browser work state --window background. Bare --window does not consume a value and remains for Commander/existing validation. Literal -- stops hoisting so eval/argument payloads are not rewritten. This keeps the compatibility layer in argv preprocessing rather than adding --window to every browser leaf command.

Validation: lead+aux final green. GitHub final gate OPEN/non-draft/CLEAN, required checks SUCCESS, adapter/smoke skipped. Local reviewer validation covered focused src/cli-argv-preprocess.test.ts 1 file/35, isolated OPENCLI_CONFIG_DIR src/cli.test.ts 1 file/163, typecheck, build manifest 1227, docs-build, typed-error-lint and silent-column-drop no new, node --check dist/src/cli-argv-preprocess.js and dist/src/main.js, and diff-check clean.
2026-06-17 22:33:04 +08:00
Marvin efd3c03d49 fix(chatgpt): support current intelligence levels
Final head 2279aca321c6d9264815f9ff091713b3ec4d881b.

Contract: chatgpt model keeps the existing write surface and supports instant, medium, high, extra-high, and pro intelligence levels; thinking remains a backward-compatible alias for high. Unsupported or unknown requested levels fail upfront with ArgumentError. The selection path requires a logged-in ChatGPT composer and native click. The model selector prefers stable test id / exact visible option text, covering current English Instant/Medium/High/Extra High/Pro and Chinese 极速/均衡/高级/超高/专业 labels. Unknown localization falls back to order only when composer-intelligence-picker-content exists and exactly five visible menuitemradio options are present; otherwise it typed-fails with CommandExecutionError instead of treating an ordinary menu or drifted DOM as success. Postcondition re-reads current selector/test id after click; when label recognition is unavailable, it reopens the picker and verifies the target checked index in the five-option intelligence picker. High vs Extra High matching uses longest/exact ordering to avoid substring false success. No ask/send/read/image/history surface changes.

Validation: lead+aux content green. Remote statusCheckRollup is empty, merged under the standing no-check override after final poll confirmed OPEN/non-draft, head unchanged, and MERGEABLE/UNSTABLE with no conflict/dirty/content blocker. Local reviewer validation covered current-main replay clean, focused ChatGPT utils+commands tests 77/77, full ChatGPT 6 files/118, typecheck, build manifest 1227, docs-build, doc coverage 164/164, prod audit clean, node --check touched files/tests, diff-check, typed-error-lint and silent-column-drop no new, and merge-tree clean.
2026-06-17 22:04:30 +08:00
AstroHan 8b765236fb feat(xiaohongshu): expose ask source metadata
Final head 53686e6a6222059ee319d815bb9858fc4fcb1a80.

Contract: xiaohongshu ask remains a browser-backed write command. Answer success still requires the same-send message_id/conversation_id plus a finished non-empty answer. Source identity/url/xsec_token are still trusted only from a 24-hex note id, xhsdiscover://item/<id>, or trusted XHS note URL. New source metadata is a minimal optional enrichment: note_type, user_id, and published_at are forwarded from the 点点 source payload and omitted when empty; like_count is parsed only from non-negative safe integers, strict decimal compact counts with 万/w/W/亿 and optional +, or legal pure digit/thousands strings. Malformed values such as 1e2, 0x10, 1..2万, bad comma grouping, negatives, and decimal numbers are omitted rather than coerced into successful counts. No extra note/detail round-trip or new write surface.

Validation: lead+aux final green. GitHub final gate OPEN/non-draft/CLEAN, required checks SUCCESS, adapter/smoke skipped. Local reviewer validation covered focused clis/xiaohongshu/ask.test.js 1 file/15, full XHS adapter 20 files/270, typecheck, build manifest 1227, docs-build, node --check ask/touched tests plus dist/src/main.js, diff-check clean, and local merge-tree clean.
2026-06-17 22:01:01 +08:00
Zhongyue Lin ac1684a691 feat(smzdm): expose search metrics and update time
Final head e3da72afcb09043db9cf96687828435cbf78f8f6.

Contract: SMZDM search remains a read-only listing command and now enriches rows with updated_at, zhi_count, buzhi_count, favorite_count, and comments while preserving a complete column set with stable defaults. Argument validation for --limit is strict and pre-navigation: only integer numbers or decimal digit strings are accepted, constrained to 1..100; exponent, hex, blank/coercive forms are rejected. Browser Bridge {session,data} envelopes are unwrapped at the boundary. Non-array extraction payloads typed-fail with CommandExecutionError instead of silently returning an empty list. Result URLs are canonicalized in the browser script and only trusted https://www.smzdm.com, https://post.smzdm.com, or trusted relative paths are kept; off-domain/non-https/malformed rows are skipped. Compact metrics such as 1.2万 and k/K counts normalize correctly. No new command/API abstraction or write behavior.

Validation: lead+aux final green. GitHub final gate OPEN/non-draft/CLEAN, required checks SUCCESS, adapter/smoke skipped. Local reviewer validation covered focused clis/smzdm/search.test.js 1 file/8, typecheck, build manifest 1227, docs-build, node --check touched SMZDM files plus dist/src/main.js, typed-error-lint and silent-column-drop no new, listing-id advisory unchanged, and diff-check clean.
2026-06-17 21:39:16 +08:00
Aldrich Chen 3b14f2f080 fix(download): match video platforms by host boundary
Final head 168cc8075b74c44a5fbc6c187e62d7e6c65f0dc9.

Contract: download video-platform detection now parses the URL and matches only the hostname by exact host or real subdomain. Substring false positives such as netflix.com, max.com, phoenix.com, notx.com, or a path containing youtu.be no longer route to yt-dlp. True youtube.com, youtu.be, bilibili.com, twitter.com, x.com, tiktok.com, vimeo.com, twitch.tv and their subdomains still match. Direct media extensions such as .mp4/.m3u8 remain video content type, but non-platform hosts do not force yt-dlp and can continue down direct HTTP handling. Unparseable URLs return false from requiresYtdlp and detectContentType keeps its existing valid-URL expectation. No download write/cookie/redirect/progress/adapter surface changes.

Validation: lead+aux final green. GitHub final gate OPEN/non-draft/CLEAN, required checks SUCCESS, adapter/smoke skipped. Local reviewer validation covered current-main replay clean, focused download tests 2 files/13, typecheck, build manifest 1227, prod audit clean, node --check touched files/tests, diff-check, typed-error-lint and silent-column-drop no new, and merge-tree clean.
2026-06-17 21:34:27 +08:00
Henry a28390d36f feat(xiaohongshu): support text-image publishing
Final head d41552312ec26c3fb98f157babbe4249e6dd7df2.

Contract: xiaohongshu publish gains text-image publishing behind --card-text. --card-text/--images require at least one content source. Normal image suffix/path validation and text-image gif append are pre-navigation ArgumentError. Text-image flow enters 文字配图, writes/verifies each card, waits for a new active empty card before multi-card input, generates previews, then Next enters the standard editor before title/body/topics. Explicit --card-style must resolve/click or typed-fail with CommandExecutionError, with no silent fallback to 基础. After Generate/Next and image append, visible media count postconditions must prove cards and appended images landed. Publish/draft final success requires a success marker or leaving the publish page; warning-like text cannot fake success.

Validation: lead+aux final green on d4155231. GitHub final gate OPEN/non-draft/CLEAN with required checks SUCCESS and adapter/smoke skipped. Local reviewer validation covered publish tests 31/31, full Xiaohongshu 17 files/246 tests, typecheck, build manifest 1225, docs-build, doc coverage 164/164, production audit clean, node --check, diff-check, typed-error-lint/silent-column-drop no new, and merge-tree clean.
2026-06-17 01:54:14 +08:00
G6-CSE-2246 0a239af512 feat(xiaohongshu): add saved and liked collection scrapers
Adds Xiaohongshu saved and liked collection read commands.\n\nFinal review contract:\n- saved/liked are read-only current-user collection scrapers; no write, unlike, or favorite side effects.\n- --limit validates strictly in 1..100 before browser navigation.\n- Collection page location is read back after goto and after each scroll before trusting captures/DOM.\n- Location must be exactly https://www.xiaohongshu.com/user/profile/<resolvedUserId>; /login or login-wall text maps to AuthRequiredError; other host/path/profile drift maps to CommandExecutionError.\n- Browser Bridge {session,data} envelopes are unwrapped; malformed location, non-array intercepted requests, and non-array DOM extraction fail closed.\n- API/DOM notes require stable note id and xsec_token; output URLs round-trip to note/detail.\n- Auth/private/empty/malformed/API/parser/selector drift typed boundaries are preserved.\n\nValidation:\n- Lead and aux final green on ef12a197.\n- Final GitHub poll: open, non-draft, head ef12a197, statusCheckRollup empty only; merged under standing no-check override because content review is green and no conflict/dirty/content blocker remains.\n- Focused collection/saved/liked tests 3 files / 19.\n- Full XHS adapter 20 files / 253.\n- typecheck, build/manifest 1227, docs-build.\n- node --check touched collection files + dist/src/main.js.\n- typed-error-lint and silent-column-drop no new issues; listing-id advisory 13; diff-check clean.
2026-06-17 01:50:15 +08:00
Ocean 09a0af7a23 fix(browser): retry stale page identity only
Narrows goto retry handling for stale browser page identity.\n\nFinal review contract:\n- goto retry only handles browser bridge stale page identity when a cached _page exists.\n- Retry matches existing stale page identity errors or complete bare target-id errors shaped as Page not found: <id>.\n- Fresh Page without identity does not retry.\n- Extension disconnected and other non-stale errors do not retry.\n- Real navigation/content messages such as Navigation failed: upstream says Page not found: /missing do not retry.\n- 404/auth/content/selector/timeout failures are not swallowed.\n- Retry still uses session lease/fresh tab resolution and records the new result.page; existing waitUntil/settle/stealth behavior is unchanged.\n\nValidation:\n- Lead and aux final green on 3002fe5f.\n- Final GitHub poll MERGEABLE/CLEAN; required checks SUCCESS; adapter/smoke skipped.\n- current-main merge-tree clean.\n- focused src/browser/page.test.ts, src/browser/errors.test.ts, src/browser.test.ts: 3 files / 56.\n- page.test 26/26.\n- build manifest 1052, typecheck, docs-build, doc coverage 162/162.\n- node --check src/browser/page.ts src/browser/page.test.ts; diff-check clean.\n- typed-error-lint and silent-column-drop no new issues.
2026-06-15 18:15:37 +08:00
Ocean 1ff4de3119 fix(xiaohongshu/user): handle login wall during hydration
Handles Xiaohongshu user login walls and hydration races without weakening read contracts.\n\nFinal review contract:\n- xiaohongshu/user remains read-only.\n- Output columns id/title/type/likes/url are unchanged.\n- Profile note URLs remain bound by profile user id + note id + xsec token.\n- Hydration retry waits only when initial user store/notes are not populated and the page is not a login wall.\n- First read with existing notes does not wait unnecessarily.\n- Real empty/private/deleted users still exhaust retry and return EMPTY_RESULT.\n- Initial profile login wall and scroll-continuation login wall both raise AuthRequiredError.\n- Login wall does not degrade into malformed snapshot, generic CommandExecutionError, or empty success.\n- Non-object snapshots, missing store, missing notes, or non-array noteGroups still fail closed with CommandExecutionError.\n- Browser evaluate errors are not swallowed.\n\nValidation:\n- Lead and aux final green on 0cf2dc27.\n- Final GitHub poll MERGEABLE/CLEAN; required checks SUCCESS; adapter/smoke skipped.\n- focused user/user-helpers/rednote tests 3 files / 44.\n- full XHS adapter 17 files / 232.\n- typecheck, build/manifest 1222, docs:build.\n- node --check touched user files + dist/src/main.js.\n- typed-error-lint and silent-column-drop no new issues; diff-check clean; current-main merge-tree clean.
2026-06-15 18:08:39 +08:00
Ocean 98a2e4f91d feat(bilibili,youtube): expose paid video metadata
Exposes paid/member metadata for Bilibili and YouTube video reads.\n\nFinal review contract:\n- Scope remains Bilibili/Youtube video read-only metadata; no download, write, or navigation contract changes.\n- Bilibili paid source is /x/web-interface/view data.rights plus upower and redirect_url fields.\n- Bilibili view payload is unwrapped from Browser Bridge {session,data} before reading paid fields.\n- Missing or type-drifted Bilibili paid metadata fails closed with CommandExecutionError rather than defaulting to free/non-member.\n- YouTube source is watch bootstrap playabilityStatus plus locale-independent BADGE_STYLE_TYPE_MEMBERS_ONLY, after Browser Bridge unwrap.\n- YouTube requires string playabilityStatus/playabilityReason and boolean membersOnly; malformed payload fails closed.\n- Existing row identity and output contract are preserved with metadata additions only.\n\nValidation:\n- Lead and aux final green on de431644.\n- Final GitHub poll MERGEABLE/CLEAN; required checks SUCCESS; adapter/smoke skipped.\n- current-main merge-tree clean.\n- Targeted Bilibili/Youtube video tests 2 files / 18.\n- Full Bilibili + YouTube adapters 15 files / 144.\n- build manifest 1222, typecheck, docs-build, doc coverage 164/164.\n- node --check touched files/tests, diff-check clean.\n- typed-error-lint and silent-column-drop no new issues.
2026-06-15 17:59:26 +08:00
Ocean 237741afd5 fix(bilibili/download): block paid content before download
Adds a paid-content precheck before Bilibili download side effects.\n\nFinal review contract:\n- bilibili/download remains a download command, but checks /x/web-interface/view before any downloadMedia or yt-dlp call.\n- rights.pay covers VIP/paid OGV; rights.ugc_pay or rights.arc_pay covers UGC single-purchase paid videos; is_upower_exclusive covers charging-exclusive content.\n- Paid hits throw structured PAID_CONTENT / NOPERM before download side effects.\n- VIP content is allowed only when /x/web-interface/nav returns vipStatus === 1.\n- --force fully skips precheck for already-entitled users or cases where cheap entitlement probing is insufficient.\n- Transport reject or non-zero view API code preserves old compatibility and does not block, but successful code:0 envelopes must include object data and object data.rights or fail closed with CommandExecutionError.\n- Free video path, BVID/URL identity, title/cookie/output path, quality format, and yt-dlp missing-result semantics are preserved.\n\nValidation:\n- Lead and aux final green on a7297072.\n- Final GitHub poll MERGEABLE/CLEAN; required checks SUCCESS; adapter/smoke skipped.\n- Focused clis/bilibili/download.test.js 1 file / 7.\n- Full Bilibili adapter 10 files / 94.\n- typecheck, build/manifest 1222, docs:build, node --check touched download files + dist/src/main.js.\n- typed-error-lint and silent-column-drop no new issues; diff-check clean; current-main merge-tree clean.
2026-06-15 17:52:37 +08:00
jakevin b16a9d6cd6 chore(release): 1.8.4 (#1954)
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
Bumps the npm package to 1.8.4 and the bundled extension to 1.0.20.
See CHANGELOG.md for the full notes; highlights:

- skills list/read commands + skills/opencli-* in the npm package
- auth aggregate status + 50-adapter quickCheck + refresh maintenance
- bilibili / xiaohongshu follow + unfollow
- xiaohongshu ask adapter with citations
- twitter media poster URLs + SearchTimeline hardening
- reddit media columns
- extension 1.0.20 drops the visible Adapter tab group
2026-06-15 17:39:58 +08:00
jakevin b4fb1509da feat(cli): expose bundled opencli skills (#1948) 2026-06-15 17:27:21 +08:00
AlexYue b85ab89938 feat(discord-app): add targeted read navigation
Adds targeted Discord desktop-app read/navigation commands with fail-closed identity checks.\n\nFinal review contract:\n- Surface remains read/navigation only: goto, channels, servers, threads, read, and thread-read; no Discord write command is introduced.\n- Browser Bridge evaluate results are unwrapped and shape-guarded at the Node boundary for route state, list rows, messages, and thread lists.\n- True empty list/read/thread-read results map to EmptyResultError; malformed rows or browser output map to CommandExecutionError.\n- Targeted read and thread-read verify message rows bind to the requested channel/thread via channel_id; stale, wrong-channel, parent-channel, or missing identity rows typed-fail.\n- List rows require stable identities: channels require Channel/guild_id/channel_id/url, servers require Server/guild_id/url, threads require Thread/guild_id/channel_id/thread_id/url.\n\nValidation:\n- Lead final and aux final green on e84a7d85.\n- merge-tree clean against base 8ed8ca26.\n- Discord app tests 19/19.\n- build manifest 1225, typecheck, docs-build, doc coverage 164/164, node --check touched files/tests, diff-check.\n- typed-error-lint and silent-column-drop no new issues.\n- GitHub required checks SUCCESS; adapter/smoke skipped.
2026-06-15 17:06:30 +08:00
Ocean 8ed8ca26dd feat(twitter): expose media poster URLs
feat(twitter): expose media poster URLs

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
Co-authored-by: First-principles-1 <first-principles-1@users.noreply.github.com>
2026-06-14 04:29:16 +08:00
Zhongyue Lin 1c88aff23f fix(douban): make title splitting self-contained for page evaluate
fix(douban): make title splitting self-contained for page evaluate

Co-authored-by: First-principles-0 <first-principles-0@users.noreply.github.com>
Co-authored-by: codex-mini0 <codex-mini0@users.noreply.github.com>
2026-06-14 04:22:35 +08:00
AstroHan c027944380 feat(xiaohongshu): add ask adapter with citations
feat(xiaohongshu): add ask adapter with citations

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
Co-authored-by: First-principles-1 <first-principles-1@users.noreply.github.com>
2026-06-14 04:13:56 +08:00
sundyli ac0a8ba725 feat(huodongxing): add events adapter
feat(huodongxing): add events adapter

Co-authored-by: codex-mini0 <codex-mini0@users.noreply.github.com>
Co-authored-by: First-principles-0 <first-principles-0@users.noreply.github.com>
2026-06-14 04:12:38 +08:00
Jacky 7382a3541f feat(slock): add collaboration adapter
feat(slock): add collaboration adapter

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
Co-authored-by: First-principles-1 <first-principles-1@users.noreply.github.com>
2026-06-13 02:52:05 +08:00
jakevin 08dc81d715 refactor(extension): remove visible adapter tab group (#1925)
* refactor(extension): remove visible adapter tab group

* chore(extension): clarify owned tab group naming
2026-06-12 18:21:53 +08:00
gucasbrg 1e40165107 fix(bloomberg): read Businessweek from section page
fix(bloomberg): read Businessweek from section page

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
Co-authored-by: First-principles-1 <first-principles-1@users.noreply.github.com>
2026-06-12 18:11:59 +08:00
Bo Liu 2be23cf534 fix(deepseek): reject search with incompatible models pre-navigation
fix(deepseek): reject search with incompatible models pre-navigation

Co-authored-by: codex-mini0 <codex-mini0@users.noreply.github.com>
Co-authored-by: First-principles-0 <first-principles-0@users.noreply.github.com>
2026-06-12 18:04:39 +08:00
Zane a2bd694bdb fix(chatgpt): stabilize response extraction under virtual scrolling
fix(chatgpt): stabilize response extraction under virtual scrolling

Co-authored-by: codex-mini0 <codex-mini0@users.noreply.github.com>
Co-authored-by: First-principles-0 <first-principles-0@users.noreply.github.com>
2026-06-12 02:38:25 +08:00
Ocean 531ef27436 fix(twitter): harden API errors and SearchTimeline metadata
fix(twitter): harden API errors and SearchTimeline metadata

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
Co-authored-by: First-principles-1 <first-principles-1@users.noreply.github.com>
2026-06-12 02:27:38 +08:00
Ocean aa468589c5 feat(bilibili,xiaohongshu): add follow and unfollow commands
feat(bilibili,xiaohongshu): add follow and unfollow commands

Co-authored-by: codex-mini0 <codex-mini0@users.noreply.github.com>
Co-authored-by: First-principles-0 <first-principles-0@users.noreply.github.com>
2026-06-12 02:13:10 +08:00
Ocean c50386dcf9 feat(xiaohongshu): add commenter user identity columns
feat(xiaohongshu): add commenter user identity columns

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
Co-authored-by: First-principles-1 <first-principles-1@users.noreply.github.com>
2026-06-12 02:10:21 +08:00
Ocean 54cb1f76b3 feat(reddit): add media columns to read output
feat(reddit): add media columns to read output

Co-authored-by: codex-mini1 <codex-mini1@users.noreply.github.com>
2026-06-12 01:56:50 +08:00
Bo Liu 221366d7b6 feat(auth): add login/whoami for nowcoder, jike, maimai, jimeng (#1878)
Covers the four single-site login/whoami TODOs from the tracking issue, each verified against a logged-in session.

Refs #1876
2026-06-11 03:18:29 +08:00
jakevin f68ea38f31 Add llms.txt for AI visibility (GEO) (#1889)
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-11 03:17:40 +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
1095 changed files with 128508 additions and 6586 deletions
+3 -1
View File
@@ -13,7 +13,9 @@ runs:
uses: browser-actions/setup-chrome@v2
id: setup-chrome
with:
chrome-version: latest
# Stable Chrome for Testing keeps headed E2E on a released browser.
# `latest` pulls Chromium snapshots, which can break extension startup.
chrome-version: stable
- name: Verify Chrome installation
shell: bash
-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)"
+31 -12
View File
@@ -34,9 +34,15 @@ jobs:
strategy:
fail-fast: false
matrix:
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
# Chrome MSI installation on Windows runners (known issue).
os: [ubuntu-latest, macos-latest]
# Gate placement by what each runner can run deterministically:
# - the real-browser extension smoke needs a Chrome that reliably runs
# an MV3 extension, which only Linux+xvfb provides on hosted runners
# (headed macOS crashes on Mach port rendezvous outside an Aqua
# session; headless does not connect the extension SW there);
# - the daemon transport contracts need no browser and run blocking on
# every OS, so macOS/Windows get a real gate, not a skipped one.
# macOS pinned to 15 while the macOS 26 image stabilizes.
os: [ubuntu-latest, macos-15, windows-latest]
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
@@ -49,7 +55,11 @@ jobs:
- name: Install dependencies
run: npm ci
# Linux runs the extension smoke and macOS runs the full real-site e2e
# suite; both need a real Chrome. Windows runs only the browser-free
# transport gate, and the setup-chrome action hangs on Windows anyway.
- name: Setup Chrome
if: runner.os != 'Windows'
uses: ./.github/actions/setup-chrome
id: setup-chrome
@@ -59,21 +69,28 @@ jobs:
- name: Build extension
run: npm run build --prefix extension
- name: Run AX Chrome smoke (Linux, via xvfb)
# Real-browser extension smoke: Linux under xvfb is the one hosted
# environment where a real Chrome reliably starts an MV3 extension, so
# this is the release-blocking browser gate. Headed (not headless):
# headless does not connect the extension service worker on hosted
# runners. See the matrix comment for why macOS/Windows don't run it.
- name: Run AX Chrome smoke (Linux, real extension via xvfb)
if: runner.os == 'Linux'
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '1'
OPENCLI_E2E_HEADED: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
- name: Run AX Chrome smoke (macOS / Windows)
if: runner.os != 'Linux'
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '1'
run: npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
# Transport contract E2E: real daemon process + scripted fake extension.
# Pins the cross-layer contracts (waiter attach, deadline 408, dispatched
# disconnect, profile fallback, graceful shutdown) end to end with the
# actual daemon binary — no browser required, so this is the blocking
# gate on EVERY OS, including macOS and Windows.
- name: Run daemon transport contract E2E
run: npx vitest run --project e2e-fixed-port tests/e2e/daemon-transport.test.ts --reporter=verbose
- name: Run E2E tests (Linux, via xvfb)
if: runner.os == 'Linux'
@@ -83,8 +100,10 @@ jobs:
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/e2e/ --reporter=verbose
- name: Run E2E tests (macOS / Windows)
if: runner.os != 'Linux'
# Real-site adapter e2e stays on Linux/macOS; Windows runs the two
# deterministic gates above (unit coverage in ci.yml already spans it).
- name: Run E2E tests (macOS)
if: runner.os == 'macOS'
env:
OPENCLI_AX_E2E: '0'
run: npx vitest run tests/e2e/ --reporter=verbose
+149 -4
View File
@@ -1,13 +1,154 @@
# Changelog
## [1.8.4](https://github.com/jackwener/opencli/compare/v1.8.3...v1.8.4) (2026-06-15)
Patch release surfacing the bundled skills directory, expanding the auth subsystem across 50+ adapters, refactoring the extension's tab-group model, and adding ten or so new adapter capabilities.
### Features
* **skills** — new `opencli skills list` and `opencli skills read <skill> [path]` commands expose the bundled `skills/opencli-*` directories as a canonical, version-bound source of agent-facing guidance. Skills are now published as part of the npm package (`skills/opencli-*/**`), so the Browser Bridge App's bundled OpenCLI carries the same skills the CLI version itself documents. Non-opencli skills, `../` path traversal, and unknown skill names are rejected with friendly error messages. ([#1948](https://github.com/jackwener/opencli/pull/1948))
* **auth** — `opencli auth status` aggregate command lists per-adapter session health; `quickCheck` wired into 50 adapters so the aggregate is fast; `auth refresh` maintenance command extends the daily auth-refresh model; first auth coverage for `nowcoder`, `jike`, `maimai`, `jimeng` and another batch of sites. ([#1878](https://github.com/jackwener/opencli/pull/1878), [#1879](https://github.com/jackwener/opencli/pull/1879), [#1880](https://github.com/jackwener/opencli/pull/1880), [#1881](https://github.com/jackwener/opencli/pull/1881))
* **extension 1.0.20** — `refactor(extension): remove visible adapter tab group` drops the visible Adapter tab-group surface; OpenCLI no longer creates a user-visible group for adapter tabs. ([#1925](https://github.com/jackwener/opencli/pull/1925))
* **xiaohongshu** — `ask` adapter with citations; `follow` / `unfollow` commands; commenter user-identity columns on `read`.
* **bilibili** — `follow` / `unfollow` commands.
* **twitter** — expose media poster URLs in tweet output; harden SearchTimeline metadata and API error paths.
* **reddit** — media columns surfaced in `read` output.
* **discord-app** — targeted `read` navigation.
* **huodongxing** — new `events` adapter.
* **slock** — new collaboration adapter.
* **manus** / **gemini** — Patch release backports (carried in from 1.8.3 timeline coverage gap).
* **llms.txt** — generated for AI visibility / GEO. ([#1889](https://github.com/jackwener/opencli/pull/1889))
### Bug Fixes
* **douban** — `title` splitting is now self-contained for the `page.evaluate` call (was depending on outer scope under chunked extraction).
* **bloomberg** — Businessweek reads now traverse from the section page instead of the legacy article landing.
* **deepseek** — reject search with incompatible models pre-navigation (saves a wasted page load).
* **chatgpt** — response extraction stabilized under virtual scrolling.
## [1.8.3](https://github.com/jackwener/opencli/compare/v1.8.2...v1.8.3) (2026-06-06)
Patch release focused on two architectural fixes around extension and daemon lifecycle, plus the first wave of the new site auth subsystem.
### Bug Fixes
* **extension 1.0.19** — close the MV3 Service Worker race that spawned duplicate `OpenCLI Adapter` tab groups (and, in the worst case, duplicate Adapter windows). The extension now persists the owned `windowId` immediately after `chrome.windows.create` returns and persists the owned `groupId` immediately after `chrome.tabs.group` returns, so a worker death between those API calls and the subsequent `chrome.tabGroups.update` no longer leaves a titleless orphan group and no longer drops the window pointer. Title-update failure no longer ungroups (it lets `ensureCanonicalGroupTitle` self-heal on the next ensure cycle), and `collectOwnedGroupCandidates` gains a fourth recovery layer: a global scan for empty-title groups containing a known owned `preferredTabId` for the role, with explicit hijack defense for user-built untitled groups. Closes the duplicate-tab-group bug report users had reported across the 1.8.2 window. ([#1862](https://github.com/jackwener/opencli/pull/1862))
* **daemon** — SIGKILL fallback when the stale daemon refuses graceful shutdown. After `npm install -g @jackwener/opencli@latest`, the CLI detects a version-mismatched daemon (`daemonVersion !== PKG_VERSION`), asks it to exit via `/shutdown`, and now — if the port is still held after 3 s — reads the stale daemon's pid from its own `/status` response and `process.kill(pid, 'SIGKILL')` (cross-platform: maps to `TerminateProcess` on Windows). The previous flow surfaced `Stale daemon could not be replaced` and asked users to run `opencli daemon stop && opencli doctor`; this is now automatic. ([#1861](https://github.com/jackwener/opencli/pull/1861))
* **xiaohongshu/publish** — prioritize the visible title input when the editor renders both a hidden draft input and a visible publish input.
* **xiaohongshu/publish** — accept inline topic suggestions with Enter when the dropdown lives inside a Shadow DOM surface, while still verifying the topic marker appears in the editor.
* **instagram/following** — paginate beyond the first endpoint page so high `--limit` values return more than the initial batch.
### Features
* **site auth subsystem** — new `opencli <site> login` and `opencli <site> whoami` commands, registered through a shared `clis/_shared/site-auth.js` helper. `login` opens the site's auth page in a foreground persistent session and polls the configured `verify` probe (cookie, JSON API, DOM scrape) until the browser session reports logged-in; `whoami` runs the same probe without opening the page. First five sites: twitter, github, bilibili, douyin, xiaohongshu. `whoami` outputs are PII-scrubbed (no email / phone / token in row columns). ([#1852](https://github.com/jackwener/opencli/pull/1852))
* **gemini** — add read-only conversation commands (list / read / search).
* **manus** — add a read-only `manus.im` adapter.
### Docs / Sitemap
* **sitemaps/xiaohongshu** — Phase 2 sitemap content seeded with login schema dogfood, the first non-PoC consumer of the v1.1 sitemap schema. ([#1853](https://github.com/jackwener/opencli/pull/1853))
### Internal
* **test(e2e)** — raise `runCli` `maxBuffer` so manifest-output snapshots no longer truncate on macOS / Windows CI.
## [1.8.2](https://github.com/jackwener/opencli/compare/v1.8.1...v1.8.2) (2026-06-03)
Mid-cycle release: introduces the **Site Maps Hub** subsystem (agent-facing per-site navigation knowledge), restores the **smart-search** skill, and ships a wide batch of new adapters / commands plus a long tail of read-path fixes. Extension bumped to 1.0.18 for an owned-group reusable-tab scope fix.
### Site Maps Hub (new subsystem)
* **`sitemaps/<site>/` top-level seed directory** — sitemap content lives alongside `clis/` and `skills/`, parallel first-class repo citizens. Twitter and HackerNews seeded as v1 baselines.
* **`opencli browser open` / `analyze` surface sitemap availability** — when the requested site has a sitemap (global seed or local overlay `~/.opencli/sites/<site>/sitemap/`), the JSON envelope gains an optional `sitemap` field with `{ available, source, hint }`. `open` emits the hint once per session per site (deduped via `~/.opencli/cache/browser-sitemap-hints/`); `analyze` emits every call since it is a planning command. Adds no new browser-action behavior and no `~/.opencli/sites/` writes unless an agent explicitly invokes a sitemap skill.
* **Two new skills**:
* `opencli-sitemap-author` — create / maintain per-site sitemaps. Two-layer storage (global repo seed + local overlay), Form B compact YAML action schema with `pre / do / post / fail / recover / evidence`, `adapter_health_update` directives, `selector_pattern` as first-class anchor type, partial pages (`_<name>.md`) for cross-page UI, and a size-guidance table with hard 800-token / 1500-3000 cohesion / >3000 split tiers.
* `opencli-browser-sitemap` — consume site sitemaps while executing browser tasks. Lazy load, Trust-Reality rule (`browser state` is truth, sitemap is hint), stale-on-conflict writeback, `adapter_health` write-back closure so subsequent agents skip a known-suspect adapter.
* **`references/sitemap-schema.md`** — full field-level spec for `SITE.md / pages/<id>.md / workflows/<id>.md / apis.md / pitfalls.md`, action `state_signature` for re-entry, `adapter_health` enum, stable-id matching across overlay layers, draft placement rule, Phase 2 validation hooks.
* **Twitter + HackerNews v1.1 seeds** under `sitemaps/{twitter,hackernews}/` validating the schema on dense React UI and simple SSR HTML respectively.
### Features
* **smart-search** — restored as a skill (`skills/smart-search/`) with per-category source guides (AI / info / media / shopping / social / tech / travel / other).
* **twitter** — batch follow + list lifecycle (`list-create` / `list-delete` / `list-add` / `list-remove` batch forms).
* **xiaohongshu** — draft management commands (`drafts` / `draft-open` / `draft-delete` / `draft-clear`).
* **chatgpt-app** — temporary chat + multi-modal image attachment support.
* **antigravity** — history mgmt (`history` / `delete` / `mark-read`) and model read/switch commands.
* **codex** — conversation management (`pin` / `unpin` / `archive` / `rename`) plus model selector fix.
* **grok** — conversation management (`delete` / `pin` / `unpin`) with locale-independent selectors.
* **kimi** — new adapter for `kimi.com` (21 commands).
* **qoder** — new adapter for Qoder IDE (19 commands).
* **trae-cn** — new desktop adapter (Trae CN Electron app).
* **trae-solo** — new desktop adapter (Trae SOLO Electron app).
* **chatgpt** — add web model switch command.
* **douyin** — add `search` command for keyword video search.
* **wechat-channels** — add WeChat Video Channels (视频号) publish adapter.
* **pubmed** — add workflow presets and richer article metadata.
### Bug Fixes
* **extension 1.0.18** — scope reusable-tab selection to owned-group members (follow-up to the v1.0.17 owned-container convergence model; ensures `findReusableOwnedContainerTab` does not pick up user tabs that were dragged into the owned window).
* **chatgpt** — ignore image placeholders and upload previews when extracting the latest assistant message.
* **xiaohongshu** — attach real topics via inline dropdown; feed returns signed note URLs for drill-down; carousel order preserved on download.
* **twitter** — drop global tweetPhoto selector from the post-submit poll to avoid matching the wrong button.
* **grok** — fall back to `Enter` key dispatch when send button is hidden behind layout shifts.
* **daemon** — differentiate multi-profile status output so multiple Chrome profiles do not collapse into a single status row.
* **youtube** — Videos tab fallback now supports `lockupViewModel` format alongside the legacy `gridVideoRenderer`.
* **12306** — accept lowercase letters in `train_no` regex.
* **weixin** — strip typographic quotes from pasted URLs.
* **launcher** — Chromium 142+ CDP websocket origin check needs `--remote-allow-origins=*`.
* **douyin/publish** — handle illegal-title errors with a typed error rather than a silent retry.
### Docs
* **opencli-adapter-author** — add `references/strategy-selection.md` codifying the empirical contract ladder (PUBLIC_API / COOKIE_API / UI_SELECTOR / DOM_STATE as contracted vs PAGE_FETCH / INTERCEPT as internal-unstable, with fixes/adapter-year data from a 837-adapter / 30-day window) and update SKILL.md to require a `strategy` evidence block at the top of every new adapter.
* **opencli-adapter-author** — `browser analyze` upgrade: each candidate API gets `real_data_score` and a `likely_data` / `maybe_data` / `noise` verdict so Pattern A is no longer fired by analytics XHRs.
* **readme** — prefix "Let AI Agents operate any website" bullet with "Browser User &" in both EN and zh-CN.
## [1.8.1](https://github.com/jackwener/opencli/compare/v1.8.0...v1.8.1) (2026-05-31)
Patch release focused on the extension tab-group convergence fix, plus 10 new adapters/commands and a wave of read-path / security hardening across browser, download, and adapters.
### Features
* **chess** — add Chess.com browser adapter.
* **geogebra** — add GeoGebra browser adapter suite.
* **jira / confluence** — add Atlassian Jira and Confluence adapter support. ([#1690](https://github.com/jackwener/opencli/pull/1690))
* **upwork** — add `search`, `feed`, and `detail` commands.
* **notebooklm** — add guarded write commands.
* **bilibili** — add comment commands.
* **weread** — add book search inside an open WeRead book.
* **linkedin** — consolidate read commands and add `profile-experience`.
* **xiaohongshu** — paginate `creator-notes` past the analyze list cap.
### Bug Fixes
* **extension 1.0.16** — ship the `OpenCLI Browser` / `OpenCLI Adapter` tab-group race fix from [#1693](https://github.com/jackwener/opencli/pull/1693). The extension now serializes owned tab-group creation per role so concurrent adapter/browser leases reuse the same group instead of creating duplicate same-title groups.
* **extension 1.0.17** — replace owned tab-group management with a Chrome-state-as-truth convergence model. The extension now keeps one canonical `OpenCLI Browser` / `OpenCLI Adapter` group per profile role, recovers renamed groups from stored hints or owned lease tabs, merges same-window and cross-window duplicates into the canonical group, and normalizes legacy or user-renamed container titles back to the canonical owned-container title. ⚠️ User-renamed `OpenCLI Browser` / `OpenCLI Adapter` groups are now force-renamed back; treat these as extension-managed automation containers, not user free-form bins. ([#1794](https://github.com/jackwener/opencli/pull/1794))
* **browser** — write the network response cache file with `0o600` owner-only permissions to keep captured response bodies out of other local users' reach.
* **download** — write the yt-dlp cookie file with `0o600` owner-only permissions.
* **pixiv** — migrate `user/detail` to the shared `pixivFetch` helper.
* **twitter** — drop unknown silent sentinels; read profile `name` / `created_at` from `result.core`; handle `NotAllowed` image-upload fallback; detect private `likes` / `following` empty-timeline shape. ([#1702](https://github.com/jackwener/opencli/pull/1702))
* **weread** — decode HTML entities in search results.
* **zhihu** — decode numeric HTML entities in text output. ([#1695](https://github.com/jackwener/opencli/pull/1695))
* **xiaohongshu** — hook dashboard fetch to capture signed `datacenter/note/*` responses ([#1732](https://github.com/jackwener/opencli/pull/1732)); preserve carousel order via `__INITIAL_STATE__.imageList` on download ([#1687](https://github.com/jackwener/opencli/pull/1687)).
* **bilibili** — subtitle support for bangumi / PGC bvid (番剧 / 纪录片 / 电影 / 综艺). ([#1669](https://github.com/jackwener/opencli/pull/1669))
* **suno** — derive current plan from subscription metadata.
* **douyin/hashtag** — validate action args before navigation.
* **byte-formatting** — stabilize byte formatting output.
### Docs
* **readme** — correct Node floor (>=20, not 21) and drop the Prerequisites section ([#1705](https://github.com/jackwener/opencli/pull/1705)); add CLI Hub brand aliases and split Exit Codes into the dedicated docs page ([#1685](https://github.com/jackwener/opencli/pull/1685)); drop the For Developers section ([#1684](https://github.com/jackwener/opencli/pull/1684)).
### Internal
* **ci** — disable Dependabot automated updates.
* **test(download)** — retry media-download Windows tests to absorb runner cold-start variance. ([#1708](https://github.com/jackwener/opencli/pull/1708))
## [1.8.0](https://github.com/jackwener/opencli/compare/v1.7.22...v1.8.0) (2026-05-20)
Substantial release: a new official-API adapter (`weread-official`), wider LinkedIn / Twitter / Reddit / Zhihu coverage, the 12306 / Suno / Xianyu inbox additions, security and reliability fixes for the Browser Bridge and media downloads, plus a 20% README shrink. Node 20 compatibility is restored after an automated `undici` bump regression.
### ⚠ BREAKING CHANGES
* **skills** — remove the `smart-search` skill. Use `opencli-usage` for command/site reference, `opencli-browser` for ad-hoc browser operation, and `opencli-adapter-author` for writing new adapters.
### Features
* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter.
@@ -270,6 +411,10 @@ Extension bumped to 1.0.9 (Accessibility.enable allowlist + downloads permission
Extension bumped to 1.0.6 (screenshot `--width` / `--height` / `--full-page` flags, automation tab group color marker, automation container reuse fix).
### Bug Fixes
* **xiaohongshu** — fix `publish --topics` leaving bare `#` characters with no linked topics. The adapter now types `#keyword` into the body editor to trigger the inline suggestion dropdown and selects the matching topic, matching the current creator-center UI.
### ⚠ BREAKING CHANGES
* **linux-do** — remove deprecated compatibility shims `linux-do hot`, `linux-do category`, `linux-do latest`. Use `linux-do feed --view top --period <period>`, `linux-do feed --category <id-or-name>`, and `linux-do feed --view latest` instead.
+7 -8
View File
@@ -40,6 +40,7 @@ cli({
description: 'Trending posts on MySite',
domain: 'www.mysite.com',
strategy: Strategy.PUBLIC,
access: 'read',
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
@@ -84,14 +85,12 @@ cli({
const { query, limit = 10 } = kwargs;
await page.goto('https://www.mysite.com');
const data = await page.evaluate(`
(async () => {
const res = await fetch('/api/search?q=${encodeURIComponent(query)}', {
credentials: 'include'
});
return (await res.json()).results;
})()
`);
const data = await page.evaluate(async (q: string) => {
const res = await fetch('/api/search?q=' + encodeURIComponent(q), {
credentials: 'include'
});
return (await res.json()).results;
}, query);
return data.slice(0, Number(limit)).map((item: any) => ({
title: item.title,
+36 -57
View File
@@ -15,13 +15,23 @@ OpenCLI gives you one surface for three different kinds of automation:
- **Let AI Agents operate any website** — install the `opencli-browser` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT.
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Trae CN, Codex, Antigravity, ChatGPT, and Trae SOLO.
## Quick Start
### 1. Install OpenCLI
OpenCLI requires **Node.js >= 21**.
For desktop use, start with **OpenCLIApp**. It bundles the OpenCLI runtime,
keeps the managed `opencli` command installed, and gives you a system tray UI
for setup, diagnostics, updates, browser-login keepalive, and Web → Markdown.
**Option A — OpenCLIApp (recommended for macOS / Windows):**
Download the latest app from <https://opencli.info/download>, install it, then
open the app once and use the System page to install or repair the `opencli`
command.
**Option B — npm global install (CLI-only / CI / servers):**
OpenCLI requires **Node.js >= 20** when installed through npm.
```bash
node --version
@@ -54,7 +64,7 @@ Each Chrome profile runs its own OpenCLI extension instance. If you use multiple
opencli profile list
opencli profile rename <contextId> work
opencli profile use work
opencli --profile work browser state
opencli --profile work browser main state
```
With only one connected profile, OpenCLI uses it automatically. With multiple connected profiles and no default, OpenCLI asks you to choose instead of guessing.
@@ -104,6 +114,8 @@ Or install only what you need:
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-browser-sitemap
npx skills add jackwener/opencli --skill opencli-sitemap-author
npx skills add jackwener/opencli --skill opencli-usage
```
@@ -114,6 +126,8 @@ npx skills add jackwener/opencli --skill opencli-usage
| **opencli-adapter-author** | Write a reusable adapter for a new site or add a command to an existing site | "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
| **opencli-autofix** | Repair a broken adapter when a built-in command fails | "`opencli zhihu hot` is returning empty — fix it" |
| **opencli-browser** | Drive a real Chrome page ad-hoc — navigate, fill forms, click, extract | "Help me check my Xiaohongshu notifications" / "Help me fill out this form" / "Use browser commands to scrape this page" |
| **opencli-browser-sitemap** | Consume site sitemap context while driving a browser task | "Use the sitemap to navigate this website without blind clicking" |
| **opencli-sitemap-author** | Create or update site sitemap knowledge for browser agents | "Record the stable workflow you just discovered for this site" |
| **opencli-usage** | Quick reference for all OpenCLI commands and sites | "What commands does OpenCLI have for Twitter?" |
### How it works
@@ -130,6 +144,8 @@ The agent handles all the `opencli browser` commands internally — you just des
**Skill references:**
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — drive Chrome ad-hoc (navigate, fill forms, click, extract)
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — use sitemap context while driving a browser task
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — create or update site sitemap knowledge
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — write a new adapter end-to-end
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — repair broken adapters
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
@@ -149,22 +165,13 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
5. `opencli browser recon analyze <url>``opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon verify <site>/<name>`.
6. Site knowledge persists to `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context.
## Prerequisites
- **Node.js**: >= 21.0.0 (required for the standard npm install path)
- **Bun**: >= 1.0 (optional alternative runtime)
- **Chrome or Chromium** running and logged into the target site for browser-backed commands
> **Important**: Browser-backed commands reuse your Chrome/Chromium login session. If you get empty data or permission-like failures, first confirm the site is already open and authenticated in Chrome/Chromium.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge |
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
| `OPENCLI_WINDOW` | command default | Set to `foreground` or `background` to override Browser Bridge window placement. Browser-backed commands also accept `--window <foreground\|background>`. |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `45` | Seconds to wait for browser connection |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
| `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) |
@@ -173,50 +180,39 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
`opencli browser *` requires an explicit `<session>` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser <session> close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab.
## For Developers
Install from source:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
To load the source Browser Bridge extension:
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select this repository's `extension/` directory.
## Built-in Commands
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `summary` `video` `user-videos` |
| **xiaohongshu** | `search` `ask` `note` `comments` `feed` `user` `download` `publish` `follow` `unfollow` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `follow` `unfollow` `me` `subtitle` `summary` `video` `user-videos` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **linkedin** | `connect` `inbox` `safe-send` `search` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` |
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **upwork** | `search` `feed` `detail` |
| **slock** | `message-send` `message-read` `message-search` `channel-list` `channel-info` `channel-create` `channel-members` `channel-join` `task-list` `task-create` `task-claim` `task-status` `task-convert` `task-delete` `thread-list` `thread-follow` `attachment-upload` `attachment-download` `bookmark-add` `inbox` `dm-list` `server-list` `server-use` `whoami` |
| **huodongxing** | `events` |
| **midjourney** | `login` `whoami` `settings` `quota` `generate` `describe` `history` `status` `action` `download` |
Curated highlights — **[→ see all 100+ supported sites & commands](./docs/adapters/index.md)** (douyin / weibo / spotify / 1688 / quark / nowcoder / google-scholar / hupu / xianyu / weread / weread-official / xiaoyuzhou / and more).
Curated highlights — **[→ see all 100+ supported sites & commands](./docs/adapters/index.md)** (douyin / weibo / spotify / 1688 / quark / nowcoder / google-scholar / hupu / xianyu / weread / weread-official / xiaoyuzhou / Chess.com / and more).
## CLI Hub
Unified passthrough for your existing command-line tools. Run `opencli <tool> ...` for any of:
`gh` · `docker` · `vercel` · `wrangler` · `ntn` · `obsidian` · `longbridge` · `lark-cli` · `dws` · `wecom-cli` · `tg` · `discord` · `wx`
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
Register your own with `opencli external register <name>`; list everything with `opencli external list`.
**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — see [`docs/adapters/desktop/`](./docs/adapters/desktop/).
**Desktop app adapters** (Electron, via CDP): Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/).
## Download Support
@@ -262,25 +258,7 @@ opencli bilibili hot -v # Verbose: show pipeline debug steps
## Exit Codes
opencli follows Unix `sysexits.h` conventions so it integrates naturally with shell pipelines and CI scripts:
| Code | Meaning | When |
|------|---------|------|
| `0` | Success | Command completed normally |
| `1` | Generic error | Unexpected / unclassified failure |
| `2` | Usage error | Bad arguments or unknown command |
| `66` | Empty result | No data returned (`EX_NOINPUT`) |
| `69` | Service unavailable | Browser Bridge not connected (`EX_UNAVAILABLE`) |
| `75` | Temporary failure | Command timed out — retry (`EX_TEMPFAIL`) |
| `77` | Auth required | Not logged in to target site (`EX_NOPERM`) |
| `78` | Config error | Missing credentials or bad config (`EX_CONFIG`) |
| `130` | Interrupted | Ctrl-C / SIGINT |
```bash
opencli spotify status || echo "exit $?" # 69 if browser not running
opencli gh issue list 2>/dev/null
[ $? -eq 77 ] && opencli gh auth login # auto-auth if not logged in
```
opencli follows Unix `sysexits.h` so CI / scripts can branch on failure mode: `0` success, `66` empty result, `69` Browser Bridge down, `75` timeout, `77` auth required, `78` config error, `130` Ctrl-C. Full reference: [docs/guide/exit-codes.md](./docs/guide/exit-codes.md).
## Plugins
@@ -299,6 +277,7 @@ opencli plugin uninstall my-tool
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | JS | Multi-platform trending aggregator |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | JS | 稀土掘金 (Juejin) hot articles |
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | JS | VK (VKontakte) wall, feed, and search |
| [opencli-plugin-x-article-publisher](https://github.com/genoooool/opencli-plugin-x-article-publisher) | JS | Publish Markdown with local images as X long-form Articles via OpenCLI and xPoster |
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
@@ -311,7 +290,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 21**. Run `node --version`, upgrade Node if needed, then retry.
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 20**. Run `node --version`, upgrade Node if needed, then retry.
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
## Star History
+36 -59
View File
@@ -15,13 +15,22 @@ OpenCLI 可以用同一套 CLI 做三类事情:
- **让 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 应用。
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker``longbridge``tg``discord``wx``ntn`Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Trae CN、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。
## 快速开始
### 1. 安装 OpenCLI
OpenCLI 要求 **Node.js >= 21**
如果你是在自己的电脑上使用,优先安装 **OpenCLIApp**它会内置
OpenCLI runtime,帮你安装 / 修复受管理的 `opencli` 命令,并提供系统托盘
UI 来做环境诊断、更新、浏览器登录态保活和网页转 Markdown。
**方式 A — OpenCLIAppmacOS / Windows 推荐):**
从 <https://opencli.info/download> 下载最新版 App,安装后打开一次,在
System 页面安装或修复 `opencli` 命令。
**方式 B — npm 全局安装(纯 CLI / CI / 服务器):**
通过 npm 安装时,OpenCLI 要求 **Node.js >= 20**
```bash
node --version
@@ -91,6 +100,8 @@ npx skills add jackwener/opencli
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-browser-sitemap
npx skills add jackwener/opencli --skill opencli-sitemap-author
npx skills add jackwener/opencli --skill opencli-usage
```
@@ -101,6 +112,8 @@ npx skills add jackwener/opencli --skill opencli-usage
| **opencli-adapter-author** | 为新站点写可复用适配器,或给已有站点添加命令 | "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
| **opencli-browser** | 实时驱动 Chrome 页面——导航、填表单、点击、抓取 | "帮我看看小红书的通知" / "帮我填一下这个表单" / "用浏览器命令抓取这个页面" |
| **opencli-browser-sitemap** | 使用站点 sitemap 上下文来操作浏览器任务 | "用 sitemap 帮我少走弯路地操作这个网站" |
| **opencli-sitemap-author** | 创建或更新面向浏览器 Agent 的站点 sitemap | "把刚发现的稳定流程记录到这个站点的 sitemap" |
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
### 工作原理
@@ -117,6 +130,8 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
**Skill 参考文档:**
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 实时驱动 Chrome(导航、填表单、点击、抓取)
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — 操作浏览器任务时消费 sitemap 上下文
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — 创建或更新站点 sitemap 知识
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 给新站点写适配器,全流程
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — 修复已有适配器
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
@@ -136,75 +151,57 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
5. `opencli browser recon analyze <url>``opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
6. 站点知识沉到 `~/.opencli/sites/<site>/`,下次同站点直接吃缓存
## 前置要求
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
- **Bun**: >= 1.0(可选替代运行时)
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
## 配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
| `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground``background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window <foreground\|background>` |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `45` | 浏览器连接超时(秒) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com` |
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
Browser Bridge daemon 与扩展的通信端口固定为 `localhost:19825`,不再支持通过 `OPENCLI_DAEMON_PORT` 配置自定义端口。
`opencli browser *` 必须紧跟一个 `<session>` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser <session> close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`
## 面向开发者
从源码安装:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
加载源码版 Browser Bridge 扩展:
1. 打开 `chrome://extensions` 并启用 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库里的 `extension/` 目录
## 内置命令
运行 `opencli list` 查看完整注册表。
| 站点 | 命令 |
|------|------|
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` |
| **xiaohongshu** | `search` `ask` `note` `comments` `notifications` `feed` `user` `saved` `liked` `download` `publish` `follow` `unfollow` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **linkedin** | `connect` `inbox` `safe-send` `search` `people-search` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` |
| **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` |
| **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `people-search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **upwork** | `search` `feed` `detail` |
| **slock** | `message-send` `message-read` `message-search` `channel-list` `channel-info` `channel-create` `channel-members` `channel-join` `task-list` `task-create` `task-claim` `task-status` `task-convert` `task-delete` `thread-list` `thread-follow` `attachment-upload` `attachment-download` `bookmark-add` `inbox` `dm-list` `server-list` `server-use` `whoami` |
| **huodongxing** | `events` |
| **midjourney** | `login` `whoami` `settings` `quota` `generate` `describe` `history` `status` `action` `download` |
精选清单 — **[→ 查看全部 100+ 站点和命令](./docs/adapters/index.md)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Bilibili / 等)。
精选清单 — **[→ 查看全部 100+ 站点和命令](./docs/adapters/index.md)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Chess.com / Bilibili / 等)。
### 外部 CLI 枢纽
把现有命令行工具统一接入 `opencli <tool> ...`
`gh` · `docker` · `vercel` · `wrangler` · `ntn` · `obsidian` · `longbridge` · `lark-cli` · `dws` · `wecom-cli` · `tg` · `discord` · `wx`
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
注册自定义本地 CLI`opencli external register <name>`;查看所有:`opencli external list`
**桌面应用适配器**Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
**桌面应用适配器**Electron,通过 CDP):Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
## 下载支持
@@ -296,28 +293,7 @@ opencli bilibili hot -v # 详细模式:展示管线执行步骤调试
## 退出码
opencli 遵循 Unix `sysexits.h` 惯例,可无缝接入 shell 管道和 CI 脚本:
| 退出码 | 含义 | 触发场景 |
|--------|------|----------|
| `0` | 成功 | 命令正常完成 |
| `1` | 通用错误 | 未分类的意外错误 |
| `2` | 用法错误 | 参数错误或未知命令 |
| `66` | 无数据 | 命令返回空结果(`EX_NOINPUT` |
| `69` | 服务不可用 | Browser Bridge 未连接(`EX_UNAVAILABLE` |
| `75` | 临时失败 | 命令超时,可重试(`EX_TEMPFAIL` |
| `77` | 需要认证 | 未登录目标网站(`EX_NOPERM` |
| `78` | 配置错误 | 凭证缺失或配置有误(`EX_CONFIG` |
| `130` | 中断 | Ctrl-C / SIGINT |
```bash
opencli bilibili hot 2>/dev/null
case $? in
0) echo "ok" ;;
69) echo "请先启动 Browser Bridge" ;;
77) echo "请先登录 bilibili.com" ;;
esac
```
opencli 遵循 Unix `sysexits.h`CI / 脚本可按失败模式分支:`0` 成功、`66` 无数据、`69` Browser Bridge 未连接、`75` 超时、`77` 需要认证、`78` 配置错误、`130` Ctrl-C。完整参考:[docs/zh/guide/exit-codes.md](./docs/zh/guide/exit-codes.md)。
## 插件
@@ -339,6 +315,7 @@ opencli plugin uninstall my-tool # 卸载
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | JS | 多平台热榜聚合 |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | JS | 稀土掘金热门文章 |
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | JS | VK (VKontakte) 动态、信息流和搜索 |
| [opencli-plugin-x-article-publisher](https://github.com/genoooool/opencli-plugin-x-article-publisher) | JS | 通过 OpenCLI 与 xPoster 将带本地图片的 Markdown 发布为 X 长文 |
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
@@ -351,7 +328,7 @@ opencli plugin uninstall my-tool # 卸载
- **返回空数据,或者报错 "Unauthorized"**
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
- OpenCLI 要求 **Node.js >= 21**。先执行 `node --version`,如果版本过低先升级,再重试命令。
- OpenCLI 要求 **Node.js >= 20**。先执行 `node --version`,如果版本过低先升级,再重试命令。
- **Daemon 问题**
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
+36 -11
View File
@@ -11,7 +11,7 @@
* Engine handles commit, verify, guard, keep/discard, and logging.
*/
import { execSync } from 'node:child_process';
import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, type AutoResearchConfig } from '../config.js';
@@ -20,6 +20,31 @@ import { PRESETS } from '../presets/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const CLAUDE_ALLOWED_TOOLS = 'Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep';
export function buildClaudeModifyInvocation(prompt: string) {
const options: ExecFileSyncOptionsWithStringEncoding = {
cwd: ROOT,
timeout: 300_000,
encoding: 'utf-8',
input: prompt,
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
};
return {
command: 'claude',
args: [
'-p',
'--dangerously-skip-permissions',
'--allowedTools',
CLAUDE_ALLOWED_TOOLS,
'--output-format',
'text',
'--no-session-persistence',
],
options,
};
}
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
const recent = ctx.recentLog.slice(-10).map(r =>
@@ -60,15 +85,13 @@ async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<s
console.log(' Claude Code making a change...');
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{
cwd: ROOT,
timeout: 300_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}
// Keep command structure and the repository-derived prompt out of a shell.
// Claude reads the prompt from stdin when -p has no positional prompt.
const invocation = buildClaudeModifyInvocation(prompt);
const result = execFileSync(
invocation.command,
invocation.args,
invocation.options
).trim();
// Extract description from Claude's response (last non-empty line or summary)
@@ -135,4 +158,6 @@ async function main() {
}
}
main();
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}
+17131 -86
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);
},
});
+2 -12
View File
@@ -7,20 +7,12 @@
* `--include-sensitive` to surface the unmasked-by-12306 fields.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskChineseName, normalizeLimit, require12306Login, requireEvaluateObject } from './utils.js';
const PASSENGER_QUERY_URL = 'https://kyfw.12306.cn/otn/passengers/query';
const MAX_PAGE_SIZE = 50;
function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) throw new ArgumentError(`limit must be a positive integer (1-${max})`);
if (n > max) throw new ArgumentError(`limit must be <= ${max}`);
return n;
}
cli({
site: '12306',
name: 'passengers',
@@ -86,5 +78,3 @@ cli({
}));
},
});
export const __test__ = { normalizeLimit };
+2 -2
View File
@@ -15,7 +15,7 @@ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwen
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const TRAIN_NO_RE = /^[0-9A-Z]{8,18}$/;
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
const SEAT_TYPES_RE = /^[A-Z0-9]{1,32}$/;
const SEAT_LETTERS = {
@@ -163,4 +163,4 @@ cli({
},
});
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS };
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS, TRAIN_NO_RE };
+1 -15
View File
@@ -6,22 +6,10 @@
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle } from './utils.js';
import { fetchStationBundle, normalizeLimit } from './utils.js';
const MAX_LIMIT = 50;
function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
}
if (n > max) {
throw new ArgumentError(`limit must be <= ${max}`);
}
return n;
}
cli({
site: '12306',
name: 'stations',
@@ -62,5 +50,3 @@ cli({
}));
},
});
export const __test__ = { normalizeLimit };
+1 -1
View File
@@ -10,7 +10,7 @@ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwen
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const TRAIN_NO_RE = /^[0-9A-Z]{8,18}$/;
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
+51 -16
View File
@@ -11,22 +11,45 @@
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, resolveStation, validateDate, parseTrainRecord } from './utils.js';
import { fetchStationBundle, mintSession, normalizeLimit, resolveStation, validateDate, parseTrainRecord } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const QUERY_ENDPOINTS = ['queryG', 'queryO', 'queryZ', 'queryA'];
const MAX_LIMIT = 100;
const QUERY_ENDPOINT_RE = /^query[A-Z]$/;
function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
function extractQueryEndpoint(value) {
const raw = String(value ?? '').trim();
if (!raw) return '';
const direct = raw.replace(/^leftTicket\//, '').trim();
if (QUERY_ENDPOINT_RE.test(direct)) return direct;
try {
const url = new URL(raw, 'https://kyfw.12306.cn');
if (url.hostname !== 'kyfw.12306.cn') return '';
const match = url.pathname.match(/\/leftTicket\/(query[A-Z])$/);
return match ? match[1] : '';
}
if (n > max) {
throw new ArgumentError(`limit must be <= ${max}`);
catch {
return '';
}
return n;
}
async function parseRotationEndpoint(resp, endpoint, bodyText) {
let json;
if (bodyText) {
try { json = JSON.parse(bodyText); } catch { /* body may be HTML on non-rotation redirects */ }
}
const bodyEndpoint = extractQueryEndpoint(json?.c_url);
if (bodyEndpoint) return bodyEndpoint;
const locationEndpoint = extractQueryEndpoint(resp.headers?.get?.('location'));
if (locationEndpoint) return locationEndpoint;
if (resp.status === 302) {
throw new CommandExecutionError(`12306 ${endpoint} redirected without a leftTicket query endpoint`);
}
if (json?.c_url) {
throw new CommandExecutionError(`12306 ${endpoint} returned an invalid rotation endpoint`);
}
return '';
}
async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
@@ -37,11 +60,23 @@ async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
};
const queryParams = `leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromCode}&leftTicketDTO.to_station=${toCode}&purpose_codes=ADULT`;
let lastResponseText = '';
for (const endpoint of QUERY_ENDPOINTS) {
const queue = [...QUERY_ENDPOINTS];
const tried = new Set();
while (queue.length > 0) {
const endpoint = queue.shift();
if (tried.has(endpoint)) continue;
tried.add(endpoint);
const url = `https://kyfw.12306.cn/otn/leftTicket/${endpoint}?${queryParams}`;
const resp = await fetch(url, { headers });
const resp = await fetch(url, { headers, redirect: 'manual' });
if (!resp.ok) {
if (resp.status === 302) continue;
if (resp.status === 302) {
const body = await resp.text();
const rotated = await parseRotationEndpoint(resp, endpoint, body);
if (rotated && !tried.has(rotated)) {
queue.unshift(rotated);
}
continue;
}
throw new CommandExecutionError(`12306 ${endpoint} returned HTTP ${resp.status}`);
}
const text = await resp.text();
@@ -51,9 +86,9 @@ async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
}
if (json?.c_url && typeof json.c_url === 'string') {
const rotated = json.c_url.replace('leftTicket/', '').trim();
if (rotated && !QUERY_ENDPOINTS.includes(rotated)) {
QUERY_ENDPOINTS.unshift(rotated);
const rotated = await parseRotationEndpoint(resp, endpoint, text);
if (rotated && !tried.has(rotated)) {
queue.unshift(rotated);
}
continue;
}
@@ -116,4 +151,4 @@ cli({
},
});
export const __test__ = { normalizeLimit, queryLeftTickets };
export const __test__ = { extractQueryEndpoint, queryLeftTickets };
+12
View File
@@ -101,6 +101,18 @@ export function validateDate(value) {
return value;
}
export function normalizeLimit(value, defaultValue, max) {
if (value === undefined || value === null || value === '') return defaultValue;
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
}
if (n > max) {
throw new ArgumentError(`limit must be <= ${max}`);
}
return n;
}
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
export function buildCookieHeader(setCookieHeaders) {
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
+125 -4
View File
@@ -1,14 +1,20 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import { __test__ } from './utils.js';
import { __test__, normalizeLimit } from './utils.js';
import { __test__ as priceTest } from './price.js';
import { __test__ as trainTest } from './train.js';
import { __test__ as trainsTest } from './trains.js';
import './orders.js';
const { parseStationBundle, resolveStation, validateDate, buildCookieHeader, parseTrainRecord, maskEmail, maskMobile, maskChineseName, unwrapEvaluateResult, requireEvaluateObject, isAuthLikePayload } = __test__;
const { parsePriceData, queryStopsForPrice, queryPrice } = priceTest;
const { queryStops } = trainTest;
const { parsePriceData, queryStopsForPrice, queryPrice, TRAIN_NO_RE: PRICE_TRAIN_NO_RE } = priceTest;
const { queryStops, TRAIN_NO_RE: TRAIN_TRAIN_NO_RE } = trainTest;
const { queryLeftTickets, extractQueryEndpoint } = trainsTest;
afterEach(() => {
vi.unstubAllGlobals();
});
describe('12306 utils - parseStationBundle', () => {
it('parses the `@`-delimited station bundle into structured records', () => {
@@ -86,6 +92,34 @@ describe('12306 utils - validateDate', () => {
});
});
describe('12306 utils - normalizeLimit', () => {
it('uses the default for omitted, null, and empty values', () => {
expect(normalizeLimit(undefined, 20, 50)).toBe(20);
expect(normalizeLimit(null, 30, 80)).toBe(30);
expect(normalizeLimit('', 50, 100)).toBe(50);
});
it('accepts numeric values and numeric strings at legal boundaries', () => {
expect(normalizeLimit(1, 20, 50)).toBe(1);
expect(normalizeLimit('25', 20, 50)).toBe(25);
expect(normalizeLimit(50, 20, 50)).toBe(50);
});
it('rejects non-integer and non-positive values with the positive integer message', () => {
expect(() => normalizeLimit('abc', 20, 50)).toThrow(ArgumentError);
expect(() => normalizeLimit('abc', 20, 50)).toThrow('limit must be a positive integer (1-50)');
expect(() => normalizeLimit(1.5, 20, 50)).toThrow('limit must be a positive integer (1-50)');
expect(() => normalizeLimit(0, 20, 50)).toThrow('limit must be a positive integer (1-50)');
expect(() => normalizeLimit(-1, 20, 50)).toThrow('limit must be a positive integer (1-50)');
});
it('rejects values over the command max with the max message', () => {
expect(() => normalizeLimit(51, 20, 50)).toThrow(ArgumentError);
expect(() => normalizeLimit(51, 20, 50)).toThrow('limit must be <= 50');
expect(() => normalizeLimit('101', 50, 100)).toThrow('limit must be <= 100');
});
});
describe('12306 utils - buildCookieHeader', () => {
it('joins set-cookie lines into a single Cookie header', () => {
const headers = [
@@ -229,6 +263,30 @@ describe('12306 price - parsePriceData', () => {
});
});
describe('12306 train_no validation regex', () => {
// 12306 train_no values returned by /otn/leftTicket/query sometimes contain
// lowercase letters (e.g. "5l000G1970A3" for G1970 上海虹桥 -> 宝鸡南).
// Both `12306 price` and `12306 train` must accept the raw value emitted
// by `12306 trains`, otherwise the two adapters drift apart and downstream
// calls fail with ARGUMENT before ever hitting 12306.
for (const [label, re] of [['price', PRICE_TRAIN_NO_RE], ['train', TRAIN_TRAIN_NO_RE]]) {
describe(label, () => {
it('accepts an all-uppercase train_no', () => {
expect(re.test('24000000G10L')).toBe(true);
});
it('accepts a train_no with lowercase letters (real 12306 payload)', () => {
expect(re.test('5l000G1970A3')).toBe(true);
});
it('rejects public codes like G1970', () => {
expect(re.test('G1970')).toBe(false);
});
it('rejects values with disallowed characters', () => {
expect(re.test('5l000-G1970A3')).toBe(false);
});
});
}
});
describe('12306 public API typed boundaries', () => {
const nonJsonFetch = async () => ({
ok: true,
@@ -250,6 +308,69 @@ describe('12306 public API typed boundaries', () => {
});
});
describe('12306 trains endpoint rotation', () => {
const successBody = { data: { result: ['row|payload'] } };
it('extracts only leftTicket query endpoints from rotation hints', () => {
expect(extractQueryEndpoint('leftTicket/queryB')).toBe('queryB');
expect(extractQueryEndpoint('/otn/leftTicket/queryC')).toBe('queryC');
expect(extractQueryEndpoint('https://kyfw.12306.cn/otn/leftTicket/queryD')).toBe('queryD');
expect(extractQueryEndpoint('/otn/error.html')).toBe('');
expect(extractQueryEndpoint('https://example.com/leftTicket/queryB')).toBe('');
expect(extractQueryEndpoint('leftTicket/querybad')).toBe('');
});
it('follows a 302 JSON c_url rotation signal before trying fallback endpoints', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ c_url: 'leftTicket/queryB' }), { status: 302 }))
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toContain('/leftTicket/queryG?');
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryB?');
});
it('follows a 302 Location header rotation signal when the body is not JSON', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('<html>redirect</html>', {
status: 302,
headers: { location: '/otn/leftTicket/queryB' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryB?');
});
it('typed-fails a 302 that does not identify a leftTicket query endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('<html>error</html>', {
status: 302,
headers: { location: '/otn/error.html' },
}));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22'))
.rejects.toBeInstanceOf(CommandExecutionError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('deduplicates rotation endpoints request-locally and keeps fallback bounded', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ c_url: 'leftTicket/queryG' }), { status: 302 }))
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toContain('/leftTicket/queryG?');
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryO?');
});
});
describe('12306 browser evaluate boundaries', () => {
it('unwraps Browser Bridge {session,data} evaluate envelopes only at the boundary', () => {
expect(unwrapEvaluateResult({ session: 's1', data: 'JSESSIONID=1; tk=2' })).toBe('JSESSIONID=1; tk=2');
+85 -3
View File
@@ -1,5 +1,28 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { assertAuthenticatedState, buildDetailUrl, buildProvenance, cleanText, extractOfferId, gotoAndReadState, uniqueMediaSources, } from './shared.js';
// 1688 商品详情区位于自定义元素 v-detail-e 的 shadow DOM 内(懒渲染),
// 普通 CSS selector 无法穿透 shadowRoot,需沿 shadow host 链判断归属。
export const DETAIL_CONTAINER_SELECTOR = '.de-description-detail, #detailContentContainer, .html-description, .desc-lazyload-container';
export function inDetailContainer(el, selector = DETAIL_CONTAINER_SELECTOR) {
let node = el;
while (node) {
// Check ancestors within the current root first: a detail container can
// be a plain element inside a shadow root, not only the host itself.
if (node.closest && node.closest(selector))
return true;
const rootNode = node.getRootNode ? node.getRootNode() : null;
if (rootNode && rootNode.host) {
const host = rootNode.host;
if (host && host.matches && host.matches(selector))
return true;
node = host;
}
else {
node = null;
}
}
return false;
}
function scriptToReadAssets() {
return `
(() => {
@@ -11,8 +34,12 @@ function scriptToReadAssets() {
{ key: 'main', type: 'image', selectors: ['#dt-tab img', '.detail-gallery-turn img.detail-gallery-img', '.img-list-wrapper img.od-gallery-img', '.od-scroller-item span'] },
{ key: 'video', type: 'video', selectors: ['.lib-video video', 'video[src]', 'video source[src]'] },
{ key: 'sku', type: 'image', selectors: ['.pc-sku-wrapper .prop-item-inner-wrapper', '.sku-item-wrapper', '.specification-cell', '.sku-filter-button', '.expand-view-item', '.feature-item img'], srcProps: ['backgroundImage'] },
{ key: 'detail', type: 'image', selectors: ['.de-description-detail img', '#detailContentContainer img', '.html-description img', '.html-description source', '.desc-lazyload-container img'] },
];
const detailContainerSelector = ${JSON.stringify(DETAIL_CONTAINER_SELECTOR)};
// Inject the module-level implementation rather than hand-copying it, so
// the unit tests exercise the same code that runs in the page.
const inDetailContainerImpl = ${inDetailContainer.toString()};
const inDetailContainer = (el) => inDetailContainerImpl(el, detailContainerSelector);
const assets = [];
const seen = new Set();
@@ -108,6 +135,14 @@ function scriptToReadAssets() {
}
}
// 详情区素材:全量收集 img/source(穿透 shadowRoot+ host 链归属判断
for (const element of [...queryAllDeep('img'), ...queryAllDeep('source')]) {
if (!inDetailContainer(element)) continue;
for (const value of valuesFromElement(element)) {
push('image', 'detail', value, 'shadow:html-description');
}
}
const scriptTexts = Array.from(document.scripts).map((script) => script.textContent || '');
const videoRegex = /https?:\\/\\/[^"'\\s]+\\.(?:mp4|m3u8)(?:\\?[^"'\\s]*)?/gi;
for (const scriptText of scriptTexts) {
@@ -171,10 +206,55 @@ function normalizeAssets(payload) {
async function readAssetsPayload(page, itemUrl) {
const state = await gotoAndReadState(page, itemUrl, 2500, 'assets');
assertAuthenticatedState(state, 'assets');
await page.autoScroll({ times: 3, delayMs: 400 });
await page.wait(1);
// The detail section renders lazily inside a shadow root. Scroll once to the
// bottom to trigger it, bring the container into view, then poll until the
// deep detail-image count stops growing. autoScroll keeps no state across
// calls, so calling it twice was identical to one longer call, and a fixed
// page.wait(3) paid the full cost on every invocation even when the content
// was already there.
await page.autoScroll({ times: 6, delayMs: 500 });
await page.evaluate(`(() => {
const el = document.querySelector('.html-description, v-detail-e, .de-description-detail, #detailContentContainer');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
})()`);
await waitForDetailImages(page);
return await page.evaluate(scriptToReadAssets());
}
/**
* Poll until the detail-image count is stable across two reads (or the cap is
* reached). Returns as soon as the content settles instead of always sleeping.
*/
async function waitForDetailImages(page, { attempts = 10, intervalSeconds = 0.5 } = {}) {
const countJs = `(() => {
const sel = ${JSON.stringify(DETAIL_CONTAINER_SELECTOR)};
let total = 0;
const walk = (root) => {
for (const node of root.querySelectorAll('*')) {
if (node.shadowRoot) walk(node.shadowRoot);
}
for (const host of root.querySelectorAll(sel)) {
total += host.querySelectorAll('img, source').length;
if (host.shadowRoot) total += host.shadowRoot.querySelectorAll('img, source').length;
}
};
walk(document);
return total;
})()`;
let previous = -1;
for (let attempt = 0; attempt < attempts; attempt++) {
let current = 0;
try {
current = Number(await page.evaluate(countJs)) || 0;
}
catch {
return; // Reading the count is best-effort; fall through to extraction.
}
if (current > 0 && current === previous)
return;
previous = current;
await page.wait(intervalSeconds);
}
}
export async function extractAssetsForInput(page, input) {
const itemUrl = buildDetailUrl(String(input ?? ''));
const payload = await readAssetsPayload(page, itemUrl);
@@ -202,4 +282,6 @@ cli({
});
export const __test__ = {
normalizeAssets,
inDetailContainer,
DETAIL_CONTAINER_SELECTOR,
};
+50
View File
@@ -1,6 +1,56 @@
import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { __test__ } from './assets.js';
import { __test__ as sharedTest } from './shared.js';
function makeDetailHostDom() {
// 模拟 1688 详情页:详情图片位于 v-detail-eclass=html-description
// 的 shadow DOM 内,普通 CSS selector 无法穿透 shadowRoot。
const dom = new JSDOM(
`<html><body>
<div class="detail-gallery-turn"><img src="https://img.example.com/main-1.jpg"></div>
<v-detail-e class="html-description"></v-detail-e>
<div class="de-description-detail"><img src="https://img.example.com/light-1.jpg"></div>
</body></html>`,
{ url: 'https://detail.1688.com/offer/887904326744.html' },
);
const { window } = dom;
const host = window.document.querySelector('v-detail-e');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<img src="https://img.example.com/detail-1.jpg">
<img data-lazyload-src="https://img.example.com/detail-2.jpg">
<img src="https://img.example.com/detail-3.jpg">
`;
return { window, host, shadow };
}
describe('1688 assets shadow-DOM detail container detection', () => {
it('detects images inside the v-detail-e shadow root as detail assets', () => {
const { window, shadow } = makeDetailHostDom();
const shadowImgs = [...shadow.querySelectorAll('img, source')];
expect(shadowImgs.length).toBe(3);
for (const el of shadowImgs) {
expect(__test__.inDetailContainer(el)).toBe(true);
}
});
it('does not match light-DOM main gallery images', () => {
const { window } = makeDetailHostDom();
const mainImg = window.document.querySelector('.detail-gallery-turn img');
expect(__test__.inDetailContainer(mainImg)).toBe(false);
});
it('matches light-DOM detail containers that use plain classes', () => {
const { window } = makeDetailHostDom();
const lightDetail = window.document.querySelector('.de-description-detail img');
expect(__test__.inDetailContainer(lightDetail)).toBe(true);
});
});
// Restored from main: this PR originally replaced these two rather than adding
// alongside them, which silently dropped all coverage of normalizeAssets and
// normalizeMediaUrl.
describe('1688 assets normalization', () => {
it('normalizes gallery and scanned assets into grouped media lists', () => {
const result = __test__.normalizeAssets({
+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);
},
});
+61
View File
@@ -0,0 +1,61 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
export const IDENTITY_PROBE_JS = `
(() => {
if (/auth\\.1point3acres\\.com\\/login/.test(location.href)) {
return { kind: 'auth', detail: '1point3acres bbs redirected to auth login' };
}
const loginLink = document.querySelector('a[href*="auth.1point3acres.com/login"], a[href*="member.php?mod=logging&action=login"]');
if (loginLink && /登录/.test(loginLink.innerText || '')) {
return { kind: 'auth', detail: '1point3acres bbs shows 登录 link — anonymous' };
}
const nameEl = document.querySelector('a[title="访问我的空间"], #um .vwmy h4 a, a.username, .vwmy a');
const username = (nameEl?.innerText || nameEl?.textContent || '').trim();
const uid = (nameEl?.getAttribute('href') || '').match(/uid[=-](\\d+)/)?.[1] || '';
if (!uid && !username) {
const hasLoggedInMenu = !!document.querySelector('#g_upmine, #extcreditmenu');
return {
kind: hasLoggedInMenu ? 'shape' : 'auth',
detail: hasLoggedInMenu
? '1point3acres bbs rendered logged-in menus but no identity link'
: '1point3acres bbs rendered but no logged-in identity',
};
}
return { ok: true, user_id: uid, username };
})()
`;
async function has1Point3AcresAuthCookie(page) {
const host = await page.getCookies({ url: 'https://www.1point3acres.com' });
const root = await page.getCookies({ url: 'https://.1point3acres.com' });
return [...host, ...root].some(c => /_auth$/.test(c.name) && c.value);
}
async function verify1Point3AcresIdentity(page) {
if (!await has1Point3AcresAuthCookie(page)) {
throw new AuthRequiredError('1point3acres.com', '1point3acres Discuz *_auth cookie missing');
}
await page.goto('https://www.1point3acres.com/bbs/');
await page.wait(2);
const probe = await page.evaluate(IDENTITY_PROBE_JS);
if (probe?.kind === 'auth') throw new AuthRequiredError('1point3acres.com', probe.detail);
if (probe?.kind === 'shape') throw new CommandExecutionError(probe.detail);
if (!probe?.ok) throw new CommandExecutionError(`Unexpected 1point3acres probe: ${JSON.stringify(probe)}`);
return { user_id: probe.user_id, username: probe.username };
}
registerSiteAuthCommands({
site: '1point3acres',
domain: '1point3acres.com',
loginUrl: 'https://auth.1point3acres.com/login',
columns: ['user_id', 'username'],
quickCheck: has1Point3AcresAuthCookie,
verify: verify1Point3AcresIdentity,
poll: async (page) => {
if (!await has1Point3AcresAuthCookie(page)) {
throw new AuthRequiredError('1point3acres.com', 'Waiting for 1point3acres Discuz *_auth cookie');
}
return verify1Point3AcresIdentity(page);
},
});
+45
View File
@@ -0,0 +1,45 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it } from 'vitest';
import { IDENTITY_PROBE_JS } from './auth.js';
function runIdentityProbe(html, url = 'https://www.1point3acres.com/bbs/') {
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
return dom.window.eval(IDENTITY_PROBE_JS);
}
describe('1point3acres auth identity probe', () => {
it('detects the current Discuz user-panel identity link', () => {
const result = runIdentityProbe(`
<div id="um">
<a href="space-uid-123456.html" title="访问我的空间">test_user</a>
</div>
`);
expect(result).toEqual({ ok: true, user_id: '123456', username: 'test_user' });
});
it('keeps legacy identity selectors as fallbacks', () => {
const result = runIdentityProbe(`
<div id="um">
<div class="vwmy"><h4><a href="home.php?mod=space&uid=42">legacy_user</a></h4></div>
</div>
`);
expect(result).toEqual({ ok: true, user_id: '42', username: 'legacy_user' });
});
it('does not report a successful blank identity when only logged-in menu ids render', () => {
const result = runIdentityProbe('<div id="g_upmine"></div><div id="extcreditmenu"></div>');
expect(result).toMatchObject({
kind: 'shape',
detail: '1point3acres bbs rendered logged-in menus but no identity link',
});
});
it('treats an anonymous login link as auth required', () => {
const result = runIdentityProbe('<a href="https://auth.1point3acres.com/login">登录</a>');
expect(result).toMatchObject({ kind: 'auth' });
});
});
+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);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { AuthRequiredError, TimeoutError, getErrorMessage } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
const DEFAULT_TIMEOUT_SECONDS = 300;
const POLL_INTERVAL_MS = 2000;
function normalizeIdentity(site, identity) {
const row = identity && typeof identity === 'object' && !Array.isArray(identity)
? identity
: {};
return { logged_in: true, site, ...row };
}
function isAuthRequired(error) {
return error instanceof AuthRequiredError;
}
async function tryProbe(config, page, phase) {
const probe = phase === 'poll' && config.poll ? config.poll : config.verify;
return normalizeIdentity(config.site, await probe(page, { phase }));
}
function authHint(config) {
return `Run \`opencli ${config.site} login\` to open the login page, then retry.`;
}
function commandColumns(config) {
const identityColumns = config.columns ?? ['id', 'username', 'name'];
return ['logged_in', 'site', ...identityColumns];
}
function normalizeQuickCheck(result) {
if (typeof result === 'boolean') return { logged_in: result };
if (result && typeof result === 'object' && !Array.isArray(result)) {
return { logged_in: !!result.logged_in, ...result };
}
return { logged_in: false };
}
function normalizeRefreshResult(result) {
if (result && typeof result === 'object' && !Array.isArray(result)) return result;
return { touched: true };
}
export function registerSiteAuthCommands(config) {
if (!config?.site || !config?.domain || !config?.loginUrl || typeof config.verify !== 'function') {
throw new Error('registerSiteAuthCommands requires site, domain, loginUrl, and verify(page)');
}
cli({
site: config.site,
name: 'whoami',
access: 'read',
description: config.whoamiDescription ?? `Show the current logged-in ${config.site} account`,
domain: config.domain,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
siteSession: 'persistent',
args: [],
columns: commandColumns(config),
authStatus: {
...(typeof config.quickCheck === 'function'
? { quickCheck: async (page) => normalizeQuickCheck(await config.quickCheck(page)) }
: {}),
...(typeof config.refresh === 'function'
? { refresh: async (page, kwargs) => normalizeRefreshResult(await config.refresh(page, kwargs)) }
: {}),
},
func: async (page) => tryProbe(config, page, 'identity'),
});
cli({
site: config.site,
name: 'login',
access: 'write',
description: config.loginDescription ?? `Open ${config.site} login and wait until the browser session is authenticated`,
domain: config.domain,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultWindowMode: 'foreground',
siteSession: 'persistent',
args: [
{ name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
],
columns: ['status', ...commandColumns(config)],
func: async (page, kwargs) => {
try {
return { status: 'already_logged_in', ...await tryProbe(config, page, 'identity') };
} catch (error) {
if (!isAuthRequired(error)) throw error;
}
await page.goto(config.loginUrl);
const timeoutSeconds = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
const deadline = Date.now() + timeoutSeconds * 1000;
let lastAuthMessage = '';
while (Date.now() < deadline) {
await page.wait(Math.min(POLL_INTERVAL_MS / 1000, Math.max(0.2, (deadline - Date.now()) / 1000)));
try {
const identity = await tryProbe(config, page, 'poll');
return { status: 'login_complete', ...identity };
} catch (error) {
if (!isAuthRequired(error)) throw error;
lastAuthMessage = getErrorMessage(error);
}
}
throw new TimeoutError(
`${config.site} login`,
timeoutSeconds,
lastAuthMessage ? `${authHint(config)} Last auth check: ${lastAuthMessage}` : authHint(config),
);
},
});
}
+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);
},
});
+5 -4
View File
@@ -1,6 +1,6 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
import { DOMAIN, amazonHostFromInput, buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
function normalizeDiscussionPayload(payload) {
const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
const asin = extractAsin(payload.href ?? '') ?? null;
@@ -9,7 +9,7 @@ function normalizeDiscussionPayload(payload) {
const provenance = buildProvenance(sourceUrl);
return {
asin,
product_url: asin ? normalizeProductUrl(asin) : null,
product_url: asin ? normalizeProductUrl(sourceUrl) : null,
discussion_url: sourceUrl,
...provenance,
average_rating_text: averageRatingText,
@@ -71,7 +71,7 @@ async function readDiscussionPayload(page, input, limit) {
const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
assertUsableState(productState, 'discussion');
if (isSignInState(reviewState) && isSignInState(productState)) {
throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
throw new AuthRequiredError(amazonHostFromInput(input) ?? DOMAIN, 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
}
const productPayload = await readCurrentDiscussionPayload(page, limit);
if (hasDiscussionSummary(productPayload)) {
@@ -111,7 +111,8 @@ cli({
const payload = await readDiscussionPayload(page, input, limit);
const normalized = normalizeDiscussionPayload(payload);
if (!normalized.average_rating_text && !normalized.total_review_count_text) {
throw new CommandExecutionError('amazon discussion page did not expose review summary', 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
const landedUrl = cleanText(payload.href) || buildDiscussionUrl(input);
throw new CommandExecutionError(`amazon discussion page did not expose review summary (landed on ${landedUrl})`, 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
}
return [normalized];
},
+62
View File
@@ -41,6 +41,68 @@ describe('amazon discussion normalization', () => {
]);
});
it('keeps the review marketplace in every emitted url', () => {
const result = __test__.normalizeDiscussionPayload({
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
average_rating_text: '4.4 out of 5',
total_review_count_text: '40 global ratings',
qa_links: [],
review_samples: [],
});
expect(result.asin).toBe('B0FGCPFY9L');
expect(result.discussion_url).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
expect(result.product_url).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
});
it('requests the review page on the marketplace the input names', async () => {
const command = getRegistry().get('amazon/discussion');
const page = createPageMock([
{
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
title: 'Amazon.co.uk: Example product',
body_text: 'Customer reviews',
},
{
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
average_rating_text: '4.4 out of 5',
total_review_count_text: '40 global ratings',
review_samples: [],
},
]);
await command.func(page, { input: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', limit: 1 });
expect(page.goto.mock.calls[0][0]).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
});
it('names the loaded url when neither page exposes a review summary', async () => {
const command = getRegistry().get('amazon/discussion');
const emptyPayload = { href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', average_rating_text: '', total_review_count_text: '', review_samples: [] };
const page = createPageMock([
{ href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Customer reviews' },
emptyPayload,
{ href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Product' },
emptyPayload,
]);
await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 }))
.rejects.toThrow('landed on https://www.amazon.co.uk/dp/B0FGCPFY9L');
});
it('points a gated non-US review page at that marketplace, not the US store', async () => {
const command = getRegistry().get('amazon/discussion');
const signIn = { href: 'https://www.amazon.co.uk/ap/signin', title: 'Amazon Sign-In', body_text: 'Sign in Create account' };
const page = createPageMock([
signIn,
{ href: signIn.href, average_rating_text: '', total_review_count_text: '', review_samples: [] },
signIn,
]);
await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 }))
.rejects.toMatchObject({ domain: 'www.amazon.co.uk' });
});
it('falls back to the product page when the review page redirects to sign-in', async () => {
const command = getRegistry().get('amazon/discussion');
const page = createPageMock([
+2 -1
View File
@@ -83,7 +83,8 @@ cli({
const input = String(kwargs.input ?? '');
const payload = await readProductPayload(page, input);
if (!cleanText(payload.product_title)) {
throw new CommandExecutionError('amazon product page did not expose product content', 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
const landedUrl = cleanText(payload.href) || buildProductUrl(input);
throw new CommandExecutionError(`amazon product page did not expose product content (landed on ${landedUrl})`, 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
}
return [normalizeProductPayload(payload)];
},
+57 -4
View File
@@ -20,6 +20,42 @@ export const PRIMARY_PRICE_SELECTORS = [
'#priceblock_dealprice',
'#tp_price_block_total_price_ww',
];
// Keep this explicit because these hosts are navigation targets in the user's
// signed-in browser. A shape-only `amazon.<tld>` pattern also accepts unrelated
// registrable domains such as amazon.shop or amazon.zip.
const MARKETPLACE_DOMAINS = new Set([
'amazon.com',
'amazon.ca',
'amazon.com.mx',
'amazon.com.br',
'amazon.co.uk',
'amazon.de',
'amazon.fr',
'amazon.it',
'amazon.es',
'amazon.nl',
'amazon.pl',
'amazon.se',
'amazon.com.be',
'amazon.ie',
'amazon.com.tr',
'amazon.ae',
'amazon.sa',
'amazon.eg',
'amazon.co.za',
'amazon.in',
'amazon.co.jp',
'amazon.com.au',
'amazon.sg',
]);
function isAmazonMarketplaceHost(hostname) {
const normalized = cleanText(hostname).toLowerCase().replace(/\.$/, '');
for (const domain of MARKETPLACE_DOMAINS) {
if (normalized === domain || normalized.endsWith(`.${domain}`))
return true;
}
return false;
}
const ROBOT_TEXT_PATTERNS = [
'Sorry, we just need to make sure you\'re not a robot',
'Enter the characters you see below',
@@ -91,19 +127,33 @@ export function extractAsin(input) {
const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i);
return match ? match[1].toUpperCase() : null;
}
export function amazonHostFromInput(input) {
const normalized = cleanText(input);
if (!normalized)
return null;
try {
const url = new URL(normalized);
return isAmazonMarketplaceHost(url.hostname) ? url.hostname : null;
}
catch {
return null;
}
}
export function buildProductUrl(input) {
const asin = extractAsin(input);
if (!asin) {
throw new ArgumentError('amazon product expects an ASIN or product URL', 'Example: opencli amazon product B0FJS72893');
}
return `${PRODUCT_URL_PREFIX}${asin}`;
const host = amazonHostFromInput(input);
return host ? `https://${host}/dp/${asin}` : `${PRODUCT_URL_PREFIX}${asin}`;
}
export function buildDiscussionUrl(input) {
const asin = extractAsin(input);
if (!asin) {
throw new ArgumentError('amazon discussion expects an ASIN or product URL', 'Example: opencli amazon discussion B0FJS72893');
}
return `${DISCUSSION_URL_PREFIX}${asin}`;
const host = amazonHostFromInput(input);
return host ? `https://${host}/product-reviews/${asin}` : `${DISCUSSION_URL_PREFIX}${asin}`;
}
function getRankingSpec(listType) {
return AMAZON_RANKING_SPECS[listType];
@@ -206,7 +256,7 @@ export function resolveBestsellersUrl(input) {
export function canonicalizeAmazonUrl(input) {
try {
const url = new URL(input);
if (!url.hostname.endsWith(DOMAIN)) {
if (!isAmazonMarketplaceHost(url.hostname)) {
throw new Error('not-amazon');
}
return url.toString();
@@ -230,7 +280,7 @@ export function normalizeProductUrl(value) {
const normalized = cleanText(value);
const asin = extractAsin(normalized);
if (asin)
return buildProductUrl(asin);
return buildProductUrl(normalized);
return toAbsoluteAmazonUrl(normalized);
}
export function parsePriceText(text) {
@@ -347,8 +397,11 @@ export function assertUsableState(state, action) {
export const __test__ = {
buildSearchUrl,
extractAsin,
amazonHostFromInput,
buildProductUrl,
buildDiscussionUrl,
normalizeProductUrl,
canonicalizeAmazonUrl,
resolveBestsellersUrl,
resolveRankingUrl,
isSupportedRankingPath,
+29
View File
@@ -6,6 +6,35 @@ describe('amazon shared helpers', () => {
expect(__test__.buildProductUrl('https://www.amazon.com/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('https://www.amazon.com/dp/B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
});
it('keeps the input marketplace instead of rewriting it to the US store', () => {
expect(__test__.buildProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
expect(__test__.buildProductUrl('https://www.amazon.de/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.de/dp/B0FJS72893');
expect(__test__.buildProductUrl('https://www.amazon.com.au/dp/B0FJS72893')).toBe('https://www.amazon.com.au/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L?pageNumber=1')).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
expect(__test__.normalizeProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
});
it('defaults to the US store for bare ASINs and non-marketplace hosts', () => {
expect(__test__.amazonHostFromInput('B0FJS72893')).toBeNull();
expect(__test__.buildProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
expect(__test__.normalizeProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
});
it('accepts sibling marketplaces but rejects look-alike hosts', () => {
expect(__test__.amazonHostFromInput('https://www.amazon.co.uk/dp/B0FJS72893')).toBe('www.amazon.co.uk');
expect(__test__.amazonHostFromInput('https://amazon.de/dp/B0FJS72893')).toBe('amazon.de');
expect(__test__.amazonHostFromInput('https://amazon.com.au/dp/B0FJS72893')).toBe('amazon.com.au');
expect(__test__.amazonHostFromInput('https://smile.amazon.com.be/dp/B0FJS72893')).toBe('smile.amazon.com.be');
expect(__test__.amazonHostFromInput('https://evilamazon.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.com.evil.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.evil.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://x.amazon.evil.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.attacker.io/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.shop/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.zip/dp/B0FJS72893')).toBeNull();
expect(() => __test__.canonicalizeAmazonUrl('https://amazon.evil.com/gp/bestsellers')).toThrow('Invalid Amazon URL');
expect(__test__.canonicalizeAmazonUrl('https://www.amazon.co.uk/gp/bestsellers/books')).toBe('https://www.amazon.co.uk/gp/bestsellers/books');
expect(() => __test__.canonicalizeAmazonUrl('https://evilamazon.com/gp/bestsellers')).toThrow('Invalid Amazon URL');
});
it('parses price, rating, and review-count text', () => {
expect(__test__.parsePriceText('1 offer from $34.11')).toEqual({
price_text: '$34.11',
+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;
},
});
+262
View File
@@ -0,0 +1,262 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './search.js';
import './item.js';
import './wayback.js';
import './snapshots.js';
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('archive adapter registry contracts', () => {
it('declares archive search columns so identifier round-trips into archive item', () => {
const search = getRegistry().get('archive/search');
const item = getRegistry().get('archive/item');
expect(search).toBeDefined();
expect(item).toBeDefined();
expect(search.columns).toEqual(['rank', 'identifier', 'title', 'creator', 'date', 'mediatype', 'downloads', 'url']);
expect(item.columns).toContain('identifier');
});
it('declares wayback and snapshots columns so URL round-trips between them', () => {
const wayback = getRegistry().get('archive/wayback');
const snapshots = getRegistry().get('archive/snapshots');
expect(wayback).toBeDefined();
expect(snapshots).toBeDefined();
expect(wayback.columns).toContain('snapshot_url');
expect(snapshots.columns).toContain('snapshot_url');
expect(wayback.columns).toContain('original_url');
expect(snapshots.columns).toContain('original_url');
});
it('marks every archive command as read access on the archive.org domain', () => {
for (const name of ['search', 'item', 'wayback', 'snapshots']) {
const cmd = getRegistry().get(`archive/${name}`);
expect(cmd, name).toBeDefined();
expect(cmd.access, name).toBe('read');
expect(cmd.domain, name).toBe('archive.org');
expect(cmd.browser, name).toBe(false);
}
});
});
describe('archive search command', () => {
const command = getRegistry().get('archive/search');
it('returns stable identifier rows that round-trip to archive item', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
response: {
docs: [{
identifier: 'sample_item-1',
title: 'Sample Item',
creator: ['Alice', 'Bob'],
date: '2020-01-02T00:00:00Z',
mediatype: 'texts',
downloads: '42',
}],
},
}));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ query: 'sample', limit: 1 })).resolves.toEqual([{
rank: 1,
identifier: 'sample_item-1',
title: 'Sample Item',
creator: 'Alice, Bob',
date: '2020-01-02',
mediatype: 'texts',
downloads: 42,
url: 'https://archive.org/details/sample_item-1',
}]);
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.searchParams.get('q')).toBe('sample');
expect(url.searchParams.getAll('fl[]')).toContain('identifier');
});
it('rejects invalid arguments before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ query: ' ', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ query: 'x', mediatype: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ query: 'x', sort: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ query: 'x', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps true empty search results to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ response: { docs: [] } })));
await expect(command.func({ query: 'zz-no-hit', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('typed-fails malformed search payloads instead of emitting empty identifiers', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ response: { docs: [{ title: 'No id' }] } })));
await expect(command.func({ query: 'bad', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('archive item command', () => {
const command = getRegistry().get('archive/item');
it('returns metadata for the requested stable identifier', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
metadata: {
identifier: 'sample_item-1',
title: 'Sample Item',
creator: 'Alice',
date: '2020',
mediatype: 'texts',
collection: ['opensource'],
description: ['Line one.', 'Line two.'],
},
files: [{ name: 'a.txt' }, { name: 'b.txt' }],
})));
await expect(command.func({ identifier: 'sample_item-1' })).resolves.toEqual([{
identifier: 'sample_item-1',
title: 'Sample Item',
creator: 'Alice',
date: '2020',
mediatype: 'texts',
collection: 'opensource',
description: 'Line one. Line two.',
file_count: 2,
url: 'https://archive.org/details/sample_item-1',
}]);
});
it('rejects invalid identifiers before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ identifier: '' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ identifier: '../secret' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps missing public metadata to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
await expect(command.func({ identifier: 'missing_item' })).rejects.toBeInstanceOf(EmptyResultError);
});
it('typed-fails mismatched identity and malformed files payload', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(jsonResponse({ metadata: { identifier: 'other_item' }, files: [] }))
.mockResolvedValueOnce(jsonResponse({ metadata: { identifier: 'sample_item' }, files: {} })));
await expect(command.func({ identifier: 'sample_item' })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func({ identifier: 'sample_item' })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('archive wayback command', () => {
const command = getRegistry().get('archive/wayback');
it('returns the closest snapshot with normalized timestamp input', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
url: 'example.com',
archived_snapshots: {
closest: {
available: true,
timestamp: '20200102030405',
url: 'https://web.archive.org/web/20200102030405/https://example.com/',
status: '200',
},
},
}));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ url: 'example.com', timestamp: '2020-01-02T03:04:05' })).resolves.toEqual([{
original_url: 'example.com',
requested_timestamp: '20200102030405',
snapshot_timestamp: '20200102030405',
snapshot_url: 'https://web.archive.org/web/20200102030405/https://example.com/',
status: '200',
}]);
expect(new URL(fetchMock.mock.calls[0][0]).searchParams.get('timestamp')).toBe('20200102030405');
});
it('rejects invalid URL/timestamp arguments before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ url: '' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ url: 'example.com', timestamp: '202' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('distinguishes no snapshot from malformed closest snapshot', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(jsonResponse({ archived_snapshots: {} }))
.mockResolvedValueOnce(jsonResponse({ archived_snapshots: { closest: { available: true, url: 'x' } } })));
await expect(command.func({ url: 'example.com' })).rejects.toBeInstanceOf(EmptyResultError);
await expect(command.func({ url: 'example.com' })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('archive snapshots command', () => {
const command = getRegistry().get('archive/snapshots');
it('returns CDX snapshots with stable Wayback permalinks', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([
['urlkey', 'timestamp', 'original', 'mimetype', 'statuscode'],
['com,example)/', '20200102030405', 'https://example.com/', 'text/html', '200'],
]));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ url: 'example.com', from: '2020', limit: 1 })).resolves.toEqual([{
timestamp: '20200102030405',
snapshot_url: 'https://web.archive.org/web/20200102030405/https://example.com/',
status: '200',
mimetype: 'text/html',
original_url: 'https://example.com/',
}]);
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.protocol).toBe('http:');
expect(url.searchParams.get('from')).toBe('2020');
});
it('rejects invalid arguments before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ url: '', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ url: 'example.com', limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ url: 'example.com', from: '2020-01' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps no CDX rows to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype']])));
await expect(command.func({ url: 'missing.example', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('typed-fails malformed CDX headers and rows', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(jsonResponse([['timestamp', 'original'], ['20200102030405', 'https://example.com/']]))
.mockResolvedValueOnce(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype'], ['', 'https://example.com/', '200', 'text/html']]))
.mockResolvedValueOnce(jsonResponse({ timestamp: '20200102030405' }))
.mockResolvedValueOnce(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype'], ['20200102030405', 'https://example.com/']])));
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
+92
View File
@@ -0,0 +1,92 @@
// archive item: Internet Archive item metadata (one row per identifier).
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
const IDENTIFIER_RE = /^[A-Za-z0-9._-]+$/;
cli({
site: 'archive',
name: 'item',
access: 'read',
description: 'Fetch metadata for a single Internet Archive item by identifier.',
domain: 'archive.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'identifier', positional: true, required: true, help: 'Archive item identifier (e.g. "open-syllabus", "FinalFantasy2_356").' },
],
columns: ['identifier', 'title', 'creator', 'date', 'mediatype', 'collection', 'description', 'file_count', 'url'],
func: async (args) => {
const identifier = String(args.identifier ?? '').trim();
if (!identifier) {
throw new ArgumentError(
'archive item identifier cannot be empty',
'Example: opencli archive item open-syllabus',
);
}
if (!IDENTIFIER_RE.test(identifier)) {
throw new ArgumentError(
`archive item identifier "${args.identifier}" is not valid`,
'Archive item identifiers may only contain letters, digits, ".", "_", "-".',
);
}
const url = `https://archive.org/metadata/${encodeURIComponent(identifier)}`;
let resp;
try {
resp = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
},
});
} catch (error) {
throw new CommandExecutionError(`archive item request failed: ${error?.message || error}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`archive item failed: HTTP ${resp.status}`);
}
let data;
try {
data = await resp.json();
} catch (error) {
throw new CommandExecutionError(`archive item returned malformed JSON: ${error?.message || error}`);
}
const meta = data?.metadata;
// The metadata endpoint returns {} for missing or dark items.
if (!meta || typeof meta !== 'object' || !meta.identifier) {
throw new EmptyResultError('archive item', `No public metadata for "${identifier}" on archive.org.`);
}
const responseIdentifier = String(meta.identifier);
if (!IDENTIFIER_RE.test(responseIdentifier)) {
throw new CommandExecutionError('archive item returned malformed payload: metadata.identifier is not stable');
}
if (responseIdentifier !== identifier) {
throw new CommandExecutionError(`archive item returned metadata for "${responseIdentifier}" instead of "${identifier}"`);
}
const creator = Array.isArray(meta.creator) ? meta.creator.join(', ') : String(meta.creator ?? '');
const collection = Array.isArray(meta.collection) ? meta.collection.join(', ') : String(meta.collection ?? '');
const description = Array.isArray(meta.description) ? meta.description.join(' ') : String(meta.description ?? '');
if (!Array.isArray(data.files)) {
throw new CommandExecutionError('archive item returned malformed payload: files must be an array');
}
return [{
identifier: responseIdentifier,
title: String(meta.title ?? ''),
creator,
date: meta.date ? String(meta.date).slice(0, 10) : '',
mediatype: String(meta.mediatype ?? ''),
collection,
description,
file_count: data.files.length,
url: `https://archive.org/details/${responseIdentifier}`,
}];
},
});
+115
View File
@@ -0,0 +1,115 @@
// archive search: Internet Archive Advanced Search across all mediatypes.
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
const SORT_OPTIONS = ['downloads', 'date', 'addeddate', 'week', 'title'];
const SORT_ALIAS = { added: 'addeddate', published: 'date' };
const MEDIATYPES = ['texts', 'movies', 'audio', 'software', 'image', 'web', 'data', 'collection'];
const IDENTIFIER_RE = /^[A-Za-z0-9._-]+$/;
cli({
site: 'archive',
name: 'search',
access: 'read',
description: 'Search Internet Archive items across books, movies, audio, software, and web.',
domain: 'archive.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Full-text query (matches title, description, creator, subject).' },
{ name: 'mediatype', type: 'string', required: false, help: `Restrict to mediatype: ${MEDIATYPES.join(', ')}` },
{ name: 'sort', type: 'string', default: 'downloads', help: `Sort key: ${SORT_OPTIONS.join(', ')}` },
{ name: 'limit', type: 'int', default: 20, help: 'Max items (max 100; one API page).' },
],
columns: ['rank', 'identifier', 'title', 'creator', 'date', 'mediatype', 'downloads', 'url'],
func: async (args) => {
const sortRaw = String(args.sort ?? 'downloads').toLowerCase();
const sort = SORT_ALIAS[sortRaw] ?? sortRaw;
if (!SORT_OPTIONS.includes(sort)) {
throw new ArgumentError(`archive search sort must be one of ${SORT_OPTIONS.join(', ')}`);
}
if (args.mediatype && !MEDIATYPES.includes(String(args.mediatype))) {
throw new ArgumentError(`archive search mediatype must be one of ${MEDIATYPES.join(', ')}`);
}
const limit = Number(args.limit ?? 20);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError('archive search limit must be a positive integer');
}
if (limit > 100) {
throw new ArgumentError('archive search limit must be <= 100');
}
const query = String(args.query ?? '').trim();
if (!query) {
throw new ArgumentError('archive search query must not be empty');
}
const fullQuery = args.mediatype
? `(${query}) AND mediatype:${args.mediatype}`
: query;
const url = new URL('https://archive.org/advancedsearch.php');
url.searchParams.set('q', fullQuery);
url.searchParams.set('output', 'json');
url.searchParams.set('rows', String(limit));
url.searchParams.set('sort[]', `${sort} desc`);
for (const fl of ['identifier', 'title', 'creator', 'date', 'mediatype', 'downloads']) {
url.searchParams.append('fl[]', fl);
}
let resp;
try {
resp = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
},
});
} catch (error) {
throw new CommandExecutionError(`archive search request failed: ${error?.message || error}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`archive search failed: HTTP ${resp.status}`);
}
let data;
try {
data = await resp.json();
} catch (error) {
throw new CommandExecutionError(`archive search returned malformed JSON: ${error?.message || error}`);
}
const docs = data?.response?.docs;
if (!Array.isArray(docs)) {
throw new CommandExecutionError('archive search returned malformed payload: response.docs must be an array');
}
if (docs.length === 0) {
throw new EmptyResultError('archive search', `No items match "${query}" on archive.org.`);
}
return docs.slice(0, limit).map((d, i) => {
const id = String(d.identifier ?? '');
if (!IDENTIFIER_RE.test(id)) {
throw new CommandExecutionError('archive search returned malformed payload: result row is missing a stable identifier');
}
const downloads = Number(d.downloads ?? 0);
if (!Number.isFinite(downloads)) {
throw new CommandExecutionError(`archive search returned malformed payload for "${id}": downloads must be numeric`);
}
const creator = Array.isArray(d.creator) ? d.creator.join(', ') : String(d.creator ?? '');
return {
rank: i + 1,
identifier: id,
title: String(d.title ?? ''),
creator,
date: d.date ? String(d.date).slice(0, 10) : '',
mediatype: String(d.mediatype ?? ''),
downloads,
url: id ? `https://archive.org/details/${id}` : '',
};
});
},
});
+129
View File
@@ -0,0 +1,129 @@
// archive snapshots: Wayback Machine CDX history for a URL.
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
function buildWaybackUrl(timestamp, original) {
if (!timestamp || !original) return '';
return `https://web.archive.org/web/${timestamp}/${original}`;
}
function requireCdxColumn(cols, name) {
const index = cols[name];
if (!Number.isInteger(index)) {
throw new CommandExecutionError(`archive snapshots returned malformed CDX payload: missing "${name}" column`);
}
return index;
}
cli({
site: 'archive',
name: 'snapshots',
access: 'read',
description: 'List Wayback Machine snapshots over time for a URL via the CDX API.',
domain: 'archive.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'url', positional: true, required: true, help: 'URL to look up (with or without scheme).' },
{ name: 'from', type: 'string', required: false, help: 'Earliest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])' },
{ name: 'to', type: 'string', required: false, help: 'Latest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])' },
{ name: 'limit', type: 'int', default: 20, help: 'Max snapshots to return (max 1000).' },
],
columns: ['timestamp', 'snapshot_url', 'status', 'mimetype', 'original_url'],
func: async (args) => {
const target = String(args.url ?? '').trim();
if (!target) {
throw new ArgumentError(
'archive snapshots url cannot be empty',
'Example: opencli archive snapshots wikipedia.org',
);
}
const limit = Number(args.limit ?? 20);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError('archive snapshots limit must be a positive integer');
}
if (limit > 1000) {
throw new ArgumentError('archive snapshots limit must be <= 1000');
}
for (const key of ['from', 'to']) {
const v = args[key];
if (v != null && !/^\d{4,14}$/.test(String(v))) {
throw new ArgumentError(`archive snapshots ${key} must be a digit-only timestamp (YYYY[MM[DD[hh[mm[ss]]]]])`);
}
}
// Wayback CDX is served on HTTP only; the HTTPS endpoint returns 503.
const apiUrl = new URL('http://web.archive.org/cdx/search/cdx');
apiUrl.searchParams.set('url', target);
apiUrl.searchParams.set('output', 'json');
apiUrl.searchParams.set('limit', String(limit));
if (args.from) apiUrl.searchParams.set('from', String(args.from));
if (args.to) apiUrl.searchParams.set('to', String(args.to));
let resp;
try {
resp = await fetch(apiUrl, {
headers: {
'Accept': 'application/json',
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
},
});
} catch (error) {
throw new CommandExecutionError(`archive snapshots request failed: ${error?.message || error}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`archive snapshots failed: HTTP ${resp.status}`);
}
let data;
try {
data = await resp.json();
} catch (error) {
throw new CommandExecutionError(`archive snapshots returned malformed JSON: ${error?.message || error}`);
}
// CDX returns an array of arrays; the first row is the header.
if (!Array.isArray(data)) {
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: top-level payload must be an array');
}
if (data.length < 2) {
throw new EmptyResultError('archive snapshots', `No Wayback snapshots for "${target}".`);
}
const [header, ...rows] = data;
if (!Array.isArray(header)) {
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: header row must be an array');
}
const cols = {};
header.forEach((name, i) => { cols[name] = i; });
const timestampCol = requireCdxColumn(cols, 'timestamp');
const originalCol = requireCdxColumn(cols, 'original');
const statusCol = requireCdxColumn(cols, 'statuscode');
const mimetypeCol = requireCdxColumn(cols, 'mimetype');
return rows.slice(0, limit).map(row => {
if (!Array.isArray(row)) {
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row must be an array');
}
const timestamp = String(row[timestampCol] ?? '');
const original = String(row[originalCol] ?? '');
const status = row[statusCol];
const mimetype = row[mimetypeCol];
if (!/^\d{14}$/.test(timestamp) || !original) {
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row is missing timestamp/original URL');
}
if (status == null || mimetype == null || String(status) === '' || String(mimetype) === '') {
throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row is missing statuscode/mimetype');
}
return {
timestamp,
snapshot_url: buildWaybackUrl(timestamp, original),
status: String(status),
mimetype: String(mimetype),
original_url: original,
};
});
},
});
+83
View File
@@ -0,0 +1,83 @@
// archive wayback: Wayback Machine closest-snapshot lookup for a URL.
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
function normalizeTimestamp(raw) {
// Accept YYYY, YYYYMM, YYYYMMDD, YYYYMMDDhh, YYYYMMDDhhmm, YYYYMMDDhhmmss,
// YYYY-MM-DD, or YYYY-MM-DDThh:mm:ss. Strip non-digits and validate length.
const digits = String(raw).replace(/[^0-9]/g, '');
if (!/^\d{4,14}$/.test(digits) || digits.length % 2 !== 0 && digits.length !== 4) {
throw new ArgumentError('archive wayback timestamp must be YYYY[MM[DD[hh[mm[ss]]]]] or an ISO date');
}
return digits;
}
cli({
site: 'archive',
name: 'wayback',
access: 'read',
description: 'Look up the closest Wayback Machine snapshot for a URL.',
domain: 'archive.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'url', positional: true, required: true, help: 'URL to look up (with or without scheme).' },
{ name: 'timestamp', type: 'string', required: false, help: 'Target timestamp (YYYY[MM[DD[hh[mm[ss]]]]] or ISO date). Defaults to most recent snapshot.' },
],
columns: ['original_url', 'requested_timestamp', 'snapshot_timestamp', 'snapshot_url', 'status'],
func: async (args) => {
const target = String(args.url ?? '').trim();
if (!target) {
throw new ArgumentError(
'archive wayback url cannot be empty',
'Example: opencli archive wayback wikipedia.org',
);
}
const timestamp = args.timestamp ? normalizeTimestamp(args.timestamp) : '';
const apiUrl = new URL('https://archive.org/wayback/available');
apiUrl.searchParams.set('url', target);
if (timestamp) apiUrl.searchParams.set('timestamp', timestamp);
let resp;
try {
resp = await fetch(apiUrl, {
headers: {
'Accept': 'application/json',
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
},
});
} catch (error) {
throw new CommandExecutionError(`archive wayback request failed: ${error?.message || error}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`archive wayback failed: HTTP ${resp.status}`);
}
let data;
try {
data = await resp.json();
} catch (error) {
throw new CommandExecutionError(`archive wayback returned malformed JSON: ${error?.message || error}`);
}
const snap = data?.archived_snapshots?.closest;
if (!snap || !snap.available) {
throw new EmptyResultError('archive wayback', `No Wayback snapshot for "${target}".`);
}
if (typeof snap.url !== 'string' || !snap.url || !/^\d{14}$/.test(String(snap.timestamp ?? ''))) {
throw new CommandExecutionError('archive wayback returned malformed payload: closest snapshot is missing url/timestamp');
}
return [{
original_url: String(data.url ?? target),
requested_timestamp: timestamp,
snapshot_timestamp: String(snap.timestamp ?? ''),
snapshot_url: String(snap.url),
status: String(snap.status ?? ''),
}];
},
});
+11
View File
@@ -0,0 +1,11 @@
<!doctype html><html><body><dl id="15" olr="5"> <dt><a href="//car.autohome.com.cn/price/brand-15.html#pvareaid=2042362"><img width="50" height="50" src="//car2.autoimg.cn/cardfs/series/g28/M08/10/45/autohomecar__CjIFVGUNeJWAOukrAADdG-QkWXI004.png"></a><div><a href="//car.autohome.com.cn/price/brand-15.html#pvareaid=2042362">宝马</a></div></dt> <dd> <li id="s7344">
<h4><a href='//www.autohome.com.cn/7344/#levelsource=000000000_0&pvareaid=101594'>宝马i5</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/7344/price.html#pvareaid=101446'>43.99-53.99万</a></div><div><a href='//car.autohome.com.cn/price/series-7344.html#pvareaid=103446'>报价</a> <a id='atk_7344' href='//car.autohome.com.cn/pic/series/7344.html#pvareaid=103448'>图库</a> <a data-value='7344' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-7344-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/7344/#pvareaid=103459'>口碑</a></div>
</li> <li id="s5758">
<h4><a href='//www.autohome.com.cn/5758/#levelsource=000000000_0&pvareaid=101594'>宝马iX3</a><i class='icon icon-jseason' title='将上市'></i></h4>指导价:暂无<div><span class='text-through'>报价</span> <a id='atk_5758' href='//car.autohome.com.cn/pic/series/5758.html#pvareaid=103448'>图库</a> <a data-value='5758' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-5758-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/5758/#pvareaid=103459'>口碑</a></div>
</li> <li id="s7827">
<h4><a href='//www.autohome.com.cn/7827/#levelsource=000000000_0&pvareaid=101594'>宝马2系</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/7827/price.html#pvareaid=101446'>20.80-22.80万</a></div><div><a href='//car.autohome.com.cn/price/series-7827.html#pvareaid=103446'>报价</a> <a id='atk_7827' href='//car.autohome.com.cn/pic/series/7827.html#pvareaid=103448'>图库</a> <a data-value='7827' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-7827-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/7827/#pvareaid=103459'>口碑</a></div>
</li> <li id="s66">
<h4><a href='//www.autohome.com.cn/66/#levelsource=000000000_0&pvareaid=101594'>宝马3系</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/66/price.html#pvareaid=101446'>25.80-33.80万</a></div><div><a href='//car.autohome.com.cn/price/series-66.html#pvareaid=103446'>报价</a> <a id='atk_66' href='//car.autohome.com.cn/pic/series/66.html#pvareaid=103448'>图库</a> <a data-value='66' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-66-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/66/#pvareaid=103459'>口碑</a></div>
</li> <li id="s6544">
<h4><a href='//www.autohome.com.cn/6544/#levelsource=000000000_0&pvareaid=101594'>宝马i3</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/6544/price.html#pvareaid=101446'>27.80-33.80万</a></div><div><a href='//car.autohome.com.cn/price/series-6544.html#pvareaid=103446'>报价</a> <a id='atk_6544' href='//car.autohome.com.cn/pic/series/6544.html#pvareaid=103448'>图库</a> <a data-value='6544' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-6544-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/6544/#pvareaid=103459'>口碑</a></div>
</li> </dd> </dl></body></html>
+116
View File
@@ -0,0 +1,116 @@
{
"baseData": {
"seriesname": "宝马X5",
"brandName": "宝马",
"levelname": "中大型SUV",
"pricerange": "59.80-74.80",
"average": "4.41",
"seriesAverage": "4.41",
"seriesScoreList": [
{
"typeName": "空间",
"typeKey": 3,
"score": 4.91,
"rank": 0
},
{
"typeName": "驾驶感受",
"typeKey": 4,
"score": 4.75,
"rank": 0
},
{
"typeName": "油耗",
"typeKey": 6,
"score": 4.02,
"rank": 0
},
{
"typeName": "外观",
"typeKey": 8,
"score": 4.75,
"rank": 0
},
{
"typeName": "内饰",
"typeKey": 9,
"score": 4.17,
"rank": 0
},
{
"typeName": "性价比",
"typeKey": 15,
"score": 4.22,
"rank": 0
},
{
"typeName": "配置",
"typeKey": 40,
"score": 4.02,
"rank": 0
}
],
"cmpSeriesScore": [
{
"seriesId": 8449,
"newCarPPH": 0,
"newCarPPHUserCount": 0,
"seriesName": "奥迪E7X",
"score": "4.59",
"maxItemScore": "4.90",
"maxItemName": "动力",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 8529,
"newCarPPH": 0,
"newCarPPHUserCount": 0,
"seriesName": "问界M6",
"score": "4.58",
"maxItemScore": "4.80",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 8183,
"newCarPPH": 42,
"newCarPPHUserCount": 33,
"seriesName": "理想i6",
"score": "4.57",
"maxItemScore": "4.82",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 8171,
"newCarPPH": 123,
"newCarPPHUserCount": 84,
"seriesName": "钛7",
"score": "4.52",
"maxItemScore": "4.70",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 6643,
"newCarPPH": 47,
"newCarPPHUserCount": 53,
"seriesName": "问界M7",
"score": "4.51",
"maxItemScore": "4.68",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
}
],
"seriesid": 6548
},
"qualityData": {
"pph": 136,
"userCount": 53
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Unit tests for the 汽车之家 (Autohome) adapter.
*
* `brand` parses the catalog HTML; `score` parses koubei __NEXT_DATA__.
* Both pure parsers run against frozen real-data fixtures (宝马 / series 6548).
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import {
BRAND_COLUMNS,
SCORE_COLUMNS,
resolveBrandInitial,
normalizeSeriesId,
extractPageProps,
requireLimit,
} from './utils.js';
import { parseBrandSeries } from './brand.js';
import { parseScore } from './score.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CATALOG = readFileSync(join(__dirname, '__fixtures__/catalog.html'), 'utf8');
const KOUBEI = JSON.parse(readFileSync(join(__dirname, '__fixtures__/koubei.json'), 'utf8'));
describe('autohome adapter — registration', () => {
it('registers brand + score as PUBLIC (no browser)', () => {
for (const n of ['brand', 'score']) {
const cmd = getRegistry().get(`autohome/${n}`);
expect(cmd, n).toBeTruthy();
expect(cmd.strategy, n).toBe(Strategy.PUBLIC);
expect(cmd.browser, n).toBe(false);
expect(cmd.access, n).toBe('read');
}
expect(getRegistry().get('autohome/brand').columns).toEqual(BRAND_COLUMNS);
expect(getRegistry().get('autohome/score').columns).toEqual(SCORE_COLUMNS);
});
});
describe('autohome adapter — utils', () => {
it('resolveBrandInitial maps brands and letters', () => {
expect(resolveBrandInitial('宝马')).toBe('B');
expect(resolveBrandInitial('比亚迪')).toBe('B');
expect(resolveBrandInitial('理想')).toBe('L');
expect(resolveBrandInitial('丰田')).toBe('F');
expect(resolveBrandInitial('b')).toBe('B');
expect(() => resolveBrandInitial('不存在的牌子')).toThrow();
expect(() => resolveBrandInitial('')).toThrow();
});
it('normalizeSeriesId accepts numbers and URLs', () => {
expect(normalizeSeriesId('6548')).toBe('6548');
expect(normalizeSeriesId('https://k.autohome.com.cn/6548')).toBe('6548');
expect(normalizeSeriesId('s6548')).toBe('6548');
expect(() => normalizeSeriesId('宝马')).toThrow();
});
it('requireLimit rejects invalid limits instead of silently falling back', () => {
expect(requireLimit(undefined, 60, 120)).toBe(60);
expect(requireLimit('5', 60, 120)).toBe(5);
expect(() => requireLimit('abc', 60, 120)).toThrow(/integer/);
expect(() => requireLimit(121, 60, 120)).toThrow(/integer/);
});
it('extractPageProps returns null on missing blob', () => {
expect(extractPageProps('<html>no</html>')).toBeNull();
});
});
describe('autohome adapter — parsers against frozen fixtures', () => {
it('parseBrandSeries lists a brand\'s series with id + guide price', () => {
const rows = parseBrandSeries(CATALOG, '宝马', 60);
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) {
expect(Object.keys(r).sort()).toEqual([...BRAND_COLUMNS].sort());
expect(r.series_id).toMatch(/^\d+$/);
expect(r.name).toContain('宝马');
expect(r.url).toContain(`/${r.series_id}/`);
}
expect(rows.some((r) => //.test(r.price))).toBe(true);
});
it('parseBrandSeries returns [] for a brand not on the page', () => {
expect(parseBrandSeries(CATALOG, '丰田', 60)).toEqual([]);
});
it('parseBrandSeries rejects catalog pages without brand blocks', () => {
expect(() => parseBrandSeries('<html></html>', '宝马', 60)).toThrow(/unexpected HTML shape/);
});
it('parseBrandSeries rejects malformed series cards', () => {
expect(() => parseBrandSeries('<dl><dt><div><a>宝马</a></div></dt><li id="s6548"></li></dl>', '宝马', 60))
.toThrow(/stable text value/);
});
it('parseScore builds a rating sheet with overall + axes + pph', () => {
const rows = parseScore(KOUBEI, '6548');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.name).toBe('宝马X5');
expect(map.brand).toBe('宝马');
expect(map.guide_price).toMatch(/万$/);
expect(typeof map.overall).toBe('number');
expect(map.overall).toBeGreaterThan(0);
expect(map.overall).toBeLessThanOrEqual(5);
// a known axis from the fixture
expect(typeof map['空间']).toBe('number');
expect(typeof map.pph_每百车故障).toBe('number');
expect(map.url).toContain('/6548');
});
it('parseScore rejects malformed koubei payloads', () => {
expect(() => parseScore({}, '6548')).toThrow(/unexpected payload shape/);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* autohome brand — list a brand's car series with guide prices.
*
* Fetches the brand catalog page `grade/carhtml/<INITIAL>.html` (UTF-8, fully
* server-rendered), isolates the `<dl>` block whose `<dt>` names the brand,
* and reads each `<li id="s<seriesId>">` series + its 指导价. Pure HTML→rows
* so it is unit-tested against a frozen catalog slice.
*
* This is Autohome's login-free "search": you search by brand (the catalog is
* brand-organized). Free-text model search is signature-gated and not offered;
* for that, use `dongchedi search`.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
AH_BASE,
BRAND_COLUMNS,
CommandExecutionError,
EmptyResultError,
ahFetch,
clean,
requireLimit,
requireStableId,
requireText,
resolveBrandInitial,
} from './utils.js';
/**
* Pure parser: catalog HTML + brand name → series rows. Exported for tests.
*/
export function parseBrandSeries(html, brandName, limit) {
const source = String(html || '');
const blocks = source.match(/<dl[^>]*>[\s\S]*?<\/dl>/g);
if (!blocks) {
throw new CommandExecutionError('autohome brand catalog returned an unexpected HTML shape; expected brand <dl> blocks.');
}
const want = String(brandName || '').replace(/[·\s]/g, '');
// No brand name (single-letter catalog mode): scan the whole page.
// Otherwise isolate the <dl> block whose <dt> names the brand.
let block = html;
if (want) {
block = null;
for (const b of blocks) {
const nameM = b.match(/<dt>[\s\S]*?<div>\s*<a[^>]*>([^<]+)<\/a>/);
const name = nameM ? clean(nameM[1]).replace(/[·\s]/g, '') : '';
if (name && (name === want || name.startsWith(want) || want.startsWith(name))) {
block = b;
break;
}
}
if (!block) return [];
}
const rows = [];
const liRe = /<li id="s(\d+)">([\s\S]*?)<\/li>/g;
let m;
while ((m = liRe.exec(block)) !== null) {
const seriesId = requireStableId(m[1], `autohome brand row ${rows.length + 1}`);
const li = m[2];
const nameM = li.match(/<h4>\s*<a[^>]*>([^<]+)<\/a>/) || li.match(/<a[^>]*>([^<]+)<\/a>/);
const name = requireText(nameM && nameM[1], `autohome brand row ${rows.length + 1} name`);
const priceM = li.match(/指导价[:]\s*<[^>]*>([^<]+)</) || li.match(/指导价[:]\s*([^<]+)</);
let price = clean(priceM && priceM[1]);
if (/暂无|未上市|停售/.test(price)) price = '';
rows.push({
series_id: seriesId,
name,
price,
url: `${AH_BASE}/${seriesId}/`,
});
if (rows.length >= limit) break;
}
return rows;
}
cli({
site: 'autohome',
name: 'brand',
access: 'read',
aliases: ['series'],
description: '汽车之家按品牌列出全部车系 + 厂商指导价(免登录)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'brand', required: true, positional: true, help: '品牌名(宝马 / 比亚迪 / 理想 / 丰田 …)或车系目录首字母 A-Z' },
{ name: 'limit', type: 'int', default: 60, help: '返回的车系数量(最多 120' },
],
columns: BRAND_COLUMNS,
func: async (args) => {
const brand = String(args.brand || '').trim();
const initial = resolveBrandInitial(brand);
const limit = requireLimit(args.limit, 60, 120);
const html = await ahFetch(
`${AH_BASE}/grade/carhtml/${initial}.html`,
`brand ${brand}`,
);
const rows = parseBrandSeries(html, /^[A-Za-z]$/.test(brand) ? '' : brand, limit);
if (rows.length === 0) {
throw new EmptyResultError(
`autohome brand ${brand}`,
`No series found for '${brand}'. Check the brand name spelling (simplified Chinese), or try a single A-Z catalog letter.`,
);
}
return rows;
},
});
+103
View File
@@ -0,0 +1,103 @@
/**
* autohome score — 口碑 (owner-rating) summary for a car series.
*
* Reads `__NEXT_DATA__.props.pageProps.baseData` (+ `qualityData`) from the
* koubei page `k.autohome.com.cn/<seriesId>`: overall rating, per-dimension
* scores, level, guide price, the reliability PPH (每百辆车故障数), and the
* competitor comparison. All unsigned, login-free. Returns a key/value sheet.
*
* Note: Autohome's per-review TEXT list loads from a separate signed XHR and
* is intentionally not scraped — this command surfaces the aggregate only.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
AH_KOUBEI_BASE,
SCORE_COLUMNS,
CommandExecutionError,
EmptyResultError,
assertPlainObject,
ahFetch,
clean,
extractPageProps,
normalizeSeriesId,
} from './utils.js';
/** Number or null. */
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* Pure parser: koubei pageProps → field/value rows. Exported for unit tests.
*/
export function parseScore(pp, seriesId) {
const bd = assertPlainObject(pp?.baseData, 'autohome baseData');
const qd = (pp && pp.qualityData) || {};
const competitors = (Array.isArray(bd.cmpSeriesScore) ? bd.cmpSeriesScore : [])
.map((c) => {
const name = clean(c.seriesname || c.seriesName);
const s = c.average || c.score;
return name ? `${name}(${s})` : '';
})
.filter(Boolean)
.slice(0, 4)
.join(', ');
const fields = [
['series_id', String(seriesId)],
['name', clean(bd.seriesname)],
['brand', clean(bd.brandName)],
['level', clean(bd.levelname)],
['guide_price', bd.pricerange ? `${clean(bd.pricerange)}` : ''],
['overall', num(bd.average ?? bd.seriesAverage)],
];
for (const axis of (Array.isArray(bd.seriesScoreList) ? bd.seriesScoreList : [])) {
const label = clean(axis.typeName);
if (label) fields.push([label, num(axis.score)]);
}
fields.push(['pph_每百车故障', num(qd.pph)]);
fields.push(['review_users', num(qd.userCount)]);
fields.push(['competitors', competitors]);
fields.push(['url', `${AH_KOUBEI_BASE}/${seriesId}`]);
return fields.map(([field, value]) => ({ field, value }));
}
cli({
site: 'autohome',
name: 'score',
access: 'read',
aliases: ['koubei', 'rating'],
description: '汽车之家车系口碑评分(总分 + 各维度 + 故障率PPH + 竞品对比,免登录)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID(来自 brand 的 series_id,或 k.autohome.com.cn/<id> URL' },
],
columns: SCORE_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const html = await ahFetch(`${AH_KOUBEI_BASE}/${seriesId}`, `score ${seriesId}`);
const pp = extractPageProps(html);
if (!pp) {
throw new CommandExecutionError(
`autohome score ${seriesId}`,
'No koubei data found — the series id may be wrong, or Autohome changed its page.',
);
}
const rows = parseScore(pp, seriesId);
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
if (!map.name && map.overall == null) {
throw new EmptyResultError(
`autohome score ${seriesId}`,
'This series has no koubei rating yet.',
);
}
return rows;
},
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Shared helpers for the 汽车之家 (Autohome) adapter.
*
* Autohome's keyword-search and per-trim-config JSON APIs are app-signature
* gated (and the config page additionally uses CSS font-glyph obfuscation),
* so those are deliberately NOT used — they cannot be read reliably without a
* browser running Autohome's signing code, and faking partial data would be
* worse than omitting it. Two sources ARE clean, no-login, plain-HTTP:
*
* 1. The brand catalog `grade/carhtml/<INITIAL>.html` — every series of a
* brand with its 指导价 (guide price), keyed by the brand's pinyin
* initial letter (hence the BRAND_INITIAL map below).
* 2. The 口碑 page `k.autohome.com.cn/<seriesId>` — a Next.js page whose
* `__NEXT_DATA__.props.pageProps.baseData` carries the aggregate owner
* rating (overall + per-dimension), level, price, competitors, and the
* reliability PPH (每百辆车故障数).
*
* So the adapter searches by BRAND (you almost always know the brand) and
* reads ratings by seriesId — both unsigned, both login-free.
*/
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
export const AH_BASE = 'https://www.autohome.com.cn';
export const AH_KOUBEI_BASE = 'https://k.autohome.com.cn';
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/126.0 Safari/537.36';
export const BRAND_COLUMNS = ['series_id', 'name', 'price', 'url'];
export const SCORE_COLUMNS = ['field', 'value'];
/**
* 中文品牌名 → 车系目录页的拼音首字母 (grade/carhtml/<X>.html).
* Covers the brands people actually search; unknown brands raise a clear
* error rather than guessing the wrong page.
*/
export const BRAND_INITIAL = {
奥迪: 'A', 阿斯顿马丁: 'A', 阿尔法罗密欧: 'A', 阿维塔: 'A', 埃安: 'A', 极狐: 'A',
宝马: 'B', 奔驰: 'B', 比亚迪: 'B', 别克: 'B', 本田: 'B', 标致: 'B', 保时捷: 'B', 宝骏: 'B', 北京: 'B', 北汽: 'B', 宾利: 'B', 北京现代: 'B',
长安: 'C', 长城: 'C', 长安启源: 'C', 长安欧尚: 'C', 传祺: 'C',
大众: 'D', 东风: 'D', 道奇: 'D', 东风风行: 'D', 东风小康: 'D',
法拉利: 'F', 福特: 'F', 丰田: 'F', 菲亚特: 'F', 福田: 'F', 方程豹: 'F', 飞凡: 'F',
广汽: 'G', 广汽丰田: 'G', 广汽本田: 'G', 高合: 'G',
哈弗: 'H', 红旗: 'H', 海马: 'H', 悍马: 'H', 哈飞: 'H', 华晨: 'H',
吉利: 'J', 捷豹: 'J', 极氪: 'J', 江淮: 'J', 几何: 'J', 捷途: 'J', 金杯: 'J', 江铃: 'J', 吉普: 'J', 极石: 'J',
凯迪拉克: 'K', 克莱斯勒: 'K', 开瑞: 'K', 凯翼: 'K',
兰博基尼: 'L', 路虎: 'L', 雷克萨斯: 'L', 林肯: 'L', 铃木: 'L', 劳斯莱斯: 'L', 雷诺: 'L', 理想: 'L', 领克: 'L', 零跑: 'L', 路特斯: 'L', 岚图: 'L', 猎豹: 'L',
马自达: 'M', 迈巴赫: 'M', 名爵: 'M', 玛莎拉蒂: 'M', 迈凯伦: 'M',
哪吒: 'N',
欧拉: 'O',
奇瑞: 'Q', 起亚: 'Q',
日产: 'R', 荣威: 'R',
斯巴鲁: 'S', 斯柯达: 'S', 三菱: 'S', 上汽大通: 'S', 思皓: 'S', 赛力斯: 'S', smart: 'S',
特斯拉: 'T', 腾势: 'T', 坦克: 'T',
沃尔沃: 'W', 五菱: 'W', 蔚来: 'W', 威马: 'W', 魏牌: 'W', 问界: 'W',
现代: 'X', 雪佛兰: 'X', 雪铁龙: 'X', 小鹏: 'X', 星途: 'X', 小米: 'X',
英菲尼迪: 'Y', 一汽: 'Y', 野马: 'Y', 仰望: 'Y',
智己: 'Z', 中华: 'Z', 众泰: 'Z',
};
/** Resolve a brand name to its catalog initial letter. */
export function resolveBrandInitial(brandArg) {
const raw = String(brandArg || '').trim();
if (!raw) throw new ArgumentError('brand must be a non-empty value');
// single A-Z letter passes through (advanced: fetch a whole letter page)
if (/^[A-Za-z]$/.test(raw)) return raw.toUpperCase();
const key = raw.replace(/[·\s]/g, '');
if (BRAND_INITIAL[key]) return BRAND_INITIAL[key];
if (BRAND_INITIAL[raw]) return BRAND_INITIAL[raw];
throw new ArgumentError(
'brand',
`unknown brand '${brandArg}'. Pass a known Chinese brand name (e.g. 宝马 / 比亚迪 / 理想) or a single A-Z catalog letter.`,
);
}
/** Normalize a series id: a bare number or an autohome URL containing it. */
export function normalizeSeriesId(rawInput) {
const raw = String(rawInput || '').trim();
if (!raw) throw new ArgumentError('series_id must be a non-empty value');
const m = raw.match(/\/(?:s)?(\d+)(?:\/|$|\.)/) || raw.match(/^s?(\d+)$/);
if (!m) {
throw new ArgumentError(`'${rawInput}' does not look like an autohome series id (a number, or a k.autohome.com.cn/<id> URL)`);
}
return m[1];
}
export function clean(s) {
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
}
export function requireLimit(value, def, max) {
const raw = value == null || value === '' ? def : value;
const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
if (!Number.isInteger(n) || n < 1 || n > max) {
throw new ArgumentError(`limit must be an integer between 1 and ${max}`);
}
return n;
}
export function requireStableId(value, label) {
const id = String(value ?? '').trim();
if (!/^\d+$/.test(id)) throw new CommandExecutionError(`${label} did not include a stable numeric id.`);
return id;
}
export function requireText(value, label) {
const text = clean(value);
if (!text) throw new CommandExecutionError(`${label} did not include a stable text value.`);
return text;
}
export function assertPlainObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an object.`);
}
return value;
}
/** Fetch an Autohome page as text. The grade + koubei pages are UTF-8. */
export async function ahFetch(url, contextHint) {
let resp;
try {
resp = await fetch(url, {
headers: {
'User-Agent': UA,
Referer: `${AH_BASE}/`,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
});
} catch (err) {
throw new CommandExecutionError(`autohome ${contextHint} network error: ${err?.message || err}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`autohome ${contextHint} HTTP ${resp.status}`);
}
return resp.text();
}
/** Extract __NEXT_DATA__ pageProps from a koubei page (pure, testable). */
export function extractPageProps(html) {
const m = String(html || '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return null;
try {
const data = JSON.parse(m[1]);
return (data && data.props && data.props.pageProps) || null;
} catch {
return null;
}
}
export { ArgumentError, CommandExecutionError, EmptyResultError };
+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);
},
});
+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);
},
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Bilibili comment — posts a top-level comment or a reply on a video via the official API.
* Uses /x/v2/reply/add, authenticated by the logged-in cookie + bili_jct CSRF token.
* @username mentions in the message are resolved to real mentions (at_name_to_mid).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { apiGet, apiPost, requireOkPayload, resolveBvid, resolveUid } from './utils.js';
function readPositiveInteger(value, label) {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`bilibili comment ${label} must be a positive integer`);
}
return n;
}
cli({
site: 'bilibili',
name: 'comment',
access: 'write',
description: '在 B站视频下发表评论或回复(官方 API,需登录;消息里的 @用户 会被解析为真实提及)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
{ name: 'message', required: true, positional: true, help: 'Comment text. Any @username in it is resolved to a real mention' },
{ name: 'parent', type: 'int', help: 'top-level/root rpid to reply under (omit for a top-level comment)' },
{ name: 'execute', type: 'boolean', help: 'Actually post the comment. Without it the command refuses to write.' },
],
columns: ['rpid', 'bvid', 'oid', 'message', 'url'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili comment');
}
const message = String(kwargs.message ?? '').trim();
if (!message)
throw new ArgumentError('bilibili comment message cannot be empty');
// Write guard: posting is public and irreversible-ish, so require an explicit opt-in.
if (!kwargs.execute)
throw new ArgumentError('Refusing to post: pass --execute to actually publish this comment');
const parent = kwargs.parent != null ? readPositiveInteger(kwargs.parent, 'parent') : null;
let bvid;
try {
bvid = await resolveBvid(kwargs.bvid);
}
catch (error) {
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
}
// Resolve bvid → aid (the reply API addresses videos by aid, as `oid`)
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const viewData = requireOkPayload(view, 'view');
const oid = viewData?.aid;
if (!oid)
throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
// Resolve @username mentions to uids. Bilibili only turns "@name" into a real
// mention — one that notifies the mentioned user — when the request carries
// at_name_to_mid; a plain-text "@name" is otherwise inert and notifies nobody.
/** @type {Record<string, number>} */
const atNameToMid = {};
for (const match of message.matchAll(/@([^\s@]+)/g)) {
const name = match[1];
if (name in atNameToMid)
continue;
try {
const mid = Number(await resolveUid(page, name));
if (!Number.isInteger(mid) || mid <= 0) {
throw new CommandExecutionError(`Bilibili user search returned malformed mid for @${name}`);
}
atNameToMid[name] = mid;
}
catch (error) {
if (!(error instanceof EmptyResultError)) {
throw error;
}
// Unresolvable @name (typo, or not a user) — leave it as plain text.
}
}
// For a reply, Bilibili needs both `root` (top-level comment) and `parent`.
// Replying to a top-level comment means root === parent.
const params = {
oid,
type: 1,
message,
plat: 1,
...(parent != null
? { root: parent, parent }
: {}),
...(Object.keys(atNameToMid).length > 0
? { at_name_to_mid: JSON.stringify(atNameToMid) }
: {}),
};
const payload = await apiPost(page, '/x/v2/reply/add', { params });
const postData = requireOkPayload(payload, 'reply add');
const rpid = postData?.rpid;
if (!rpid) {
throw new CommandExecutionError('Bilibili reply add API did not return rpid for the posted comment');
}
return [{
rpid: String(rpid),
bvid,
oid: String(oid),
message,
url: `https://www.bilibili.com/video/${bvid}#reply${rpid}`,
}];
},
});
+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);
});
});
+103 -7
View File
@@ -8,9 +8,91 @@
* - yt-dlp must be installed: pip install yt-dlp
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
import { checkYtdlp, sanitizeFilename } from '@jackwener/opencli/download';
import { downloadMedia } from '@jackwener/opencli/download/media-download';
import { resolveBvid } from './utils.js';
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
const PAYMENT_LABELS = {
vip: '大会员专享/付费 OGV',
ugc_pay: 'UGC 单点付费',
upower: '充电专属',
};
function isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
/**
* 下载前付费预检:付费/会员视频 yt-dlp 只能拿到试看流或直接失败,
* 与其跑一半吐一坨 yt-dlp stderr,不如提前抛结构化 PAID_CONTENTexit 77)。
*
* 大会员专享(vip)会再查一次 nav API:当前账号大会员有效就放行(cookie 喂给
* yt-dlp 能下完整流)。ugc_pay / upower 的购买/充电状态没有廉价查询端点,保守
* 拦截,已购用户用 --force 跳过。预检自身的 API 失败不阻塞下载(保持旧行为)。
*/
async function assertNotPaidContent(page, bvid) {
let d;
try {
const payload = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
if (!isObject(payload) || !Object.hasOwn(payload, 'code')) {
throw new CommandExecutionError('Bilibili view API returned a malformed payload during paid-content pre-check');
}
if (payload.code !== 0)
return;
if (!isObject(payload.data) || !isObject(payload.data.rights)) {
throw new CommandExecutionError('Bilibili view API returned malformed paid-content metadata');
}
d = payload.data;
}
catch (error) {
if (error instanceof CommandExecutionError) {
throw error;
}
return;
}
const rights = d.rights;
const paymentType = rights.pay
? 'vip'
: (rights.ugc_pay || rights.arc_pay)
? 'ugc_pay'
: d.is_upower_exclusive
? 'upower'
: '';
if (!paymentType)
return;
if (paymentType === 'vip') {
try {
const nav = await apiGet(page, '/x/web-interface/nav');
if (nav.code === 0 && Number(nav.data?.vipStatus) === 1)
return;
}
catch {
// nav 查询失败按"无会员"保守处理,走下面的拦截
}
}
throw new CliError(
'PAID_CONTENT',
`该视频为付费内容(${PAYMENT_LABELS[paymentType]}),当前账号无观看权益,无法获取完整视频流`,
'若已购买/已充电/已开通会员,加 --force 跳过本检查直接下载',
EXIT_CODES.NOPERM,
);
}
async function loadSelectedPart(page, bvid, pageNum) {
let payload;
try {
payload = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
}
catch (error) {
throw new CommandExecutionError(`获取视频分P信息失败: ${error?.message || error}`);
}
if (!isObject(payload) || payload.code !== 0) {
throw new CommandExecutionError(`获取视频分P信息失败: ${payload?.message ?? 'unknown'} (${payload?.code ?? 'malformed'})`);
}
return selectVideoPart(payload.data, pageNum);
}
cli({
site: 'bilibili',
name: 'download',
@@ -22,12 +104,20 @@ cli({
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g., BV1xxx)' },
{ name: 'output', default: './bilibili-downloads', help: 'Output directory' },
{ name: 'quality', default: 'best', help: 'Video quality (best, 1080p, 720p, 480p)' },
{ name: 'force', type: 'boolean', default: false, help: '跳过付费内容预检直接下载(已购买/已充电/已开通会员时用)' },
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频下载该集;缺省下载默认 P1' },
],
columns: ['bvid', 'title', 'status', 'size'],
func: async (page, kwargs) => {
const bvid = await resolveBvid(kwargs.bvid);
const output = kwargs.output;
const quality = kwargs.quality;
const selectedPage = parsePageArg(kwargs.page);
const selectedPart = selectedPage != null ? await loadSelectedPart(page, bvid, selectedPage) : null;
// yt-dlp 原生支持分P URL?p=N),直接拼到 watch URL 即可定位到该集。
const watchUrl = selectedPage != null
? `https://www.bilibili.com/video/${bvid}?p=${selectedPage}`
: `https://www.bilibili.com/video/${bvid}`;
// Check yt-dlp availability
if (!checkYtdlp()) {
return [{
@@ -37,9 +127,13 @@ cli({
size: 'yt-dlp not installed. Run: pip install yt-dlp',
}];
}
// Navigate to video page to get title and cookies
await page.goto(`https://www.bilibili.com/video/${bvid}`);
// Navigate to video page to get title and cookies(分P 时定位到该集)
await page.goto(watchUrl);
await page.wait(3);
// 付费内容预检(--force 跳过)
if (!kwargs.force) {
await assertNotPaidContent(page, bvid);
}
// Extract video info
const data = await page.evaluate(`
(() => {
@@ -48,7 +142,9 @@ cli({
return { title, author };
})()
`);
const title = sanitizeFilename(data?.title || 'video');
const partTitle = typeof selectedPart?.part === 'string' ? selectedPart.part.trim() : '';
const displayTitle = partTitle || data?.title || 'video';
const title = sanitizeFilename(displayTitle);
// Extract cookies for yt-dlp
const browserCookies = await page.getCookies({ domain: 'bilibili.com' });
// Build yt-dlp format string based on quality
@@ -62,8 +158,8 @@ cli({
else if (quality === '480p') {
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
}
const videoUrl = `https://www.bilibili.com/video/${bvid}`;
const filename = `${bvid}_${title}.mp4`;
const videoUrl = watchUrl;
const filename = selectedPage != null ? `${bvid}_p${selectedPage}_${title}.mp4` : `${bvid}_${title}.mp4`;
const results = await downloadMedia([{ type: 'video-ytdlp', url: videoUrl, filename }], {
output,
browserCookies,
@@ -74,7 +170,7 @@ cli({
const r = results[0] || { status: 'failed', size: '-' };
return [{
bvid,
title: data?.title || 'video',
title: displayTitle,
status: r.status,
size: r.size,
}];
+173
View File
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, CliError, CommandExecutionError } from '@jackwener/opencli/errors';
const { mockApiGet, mockDownloadMedia, mockCheckYtdlp } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
mockDownloadMedia: vi.fn(),
mockCheckYtdlp: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiGet: mockApiGet,
}));
vi.mock('@jackwener/opencli/download', () => ({
checkYtdlp: mockCheckYtdlp,
sanitizeFilename: (s) => s,
}));
vi.mock('@jackwener/opencli/download/media-download', () => ({
downloadMedia: mockDownloadMedia,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './download.js';
/** view API 成功响应的最小骨架 */
function viewPayload(extra = {}) {
return { code: 0, data: { bvid: 'BV1xx411c7mD', rights: {}, ...extra } };
}
describe('bilibili download paid-content pre-check', () => {
const command = getRegistry().get('bilibili/download');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ title: '标题', author: 'UP主' }),
getCookies: vi.fn().mockResolvedValue([]),
};
beforeEach(() => {
mockApiGet.mockReset();
mockDownloadMedia.mockReset();
mockCheckYtdlp.mockReset();
mockCheckYtdlp.mockReturnValue(true);
mockDownloadMedia.mockResolvedValue([{ status: 'success', size: '10MB' }]);
page.goto.mockClear();
page.evaluate.mockClear();
});
it('downloads normal (free) video without interference', async () => {
mockApiGet.mockResolvedValueOnce(viewPayload());
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
expect(rows[0].status).toBe('success');
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});
it('throws PAID_CONTENT for member-only bangumi when account has no vip', async () => {
mockApiGet
.mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } })) // view
.mockResolvedValueOnce({ code: 0, data: { vipStatus: 0 } }); // nav
await expect(
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
expect(mockDownloadMedia).not.toHaveBeenCalled();
});
it('allows member-only content when account has active vip', async () => {
mockApiGet
.mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } }))
.mockResolvedValueOnce({ code: 0, data: { vipStatus: 1 } });
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
expect(rows[0].status).toBe('success');
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});
it('throws PAID_CONTENT for upower-exclusive video (no entitlement endpoint, conservative block)', async () => {
mockApiGet.mockResolvedValueOnce(viewPayload({ is_upower_exclusive: true }));
await expect(
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
// upower 没有权益查询端点,不应再打 nav API
expect(mockApiGet).toHaveBeenCalledTimes(1);
expect(mockDownloadMedia).not.toHaveBeenCalled();
});
it('fails closed when successful view payload lacks paid-content metadata', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1xx411c7mD' } });
await expect(
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /paid-content metadata/.test(err.message),
);
expect(mockDownloadMedia).not.toHaveBeenCalled();
});
it('skips pre-check entirely with --force', async () => {
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: true });
expect(rows[0].status).toBe('success');
expect(mockApiGet).not.toHaveBeenCalled();
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});
it('does not block download when the pre-check API itself fails', async () => {
mockApiGet.mockRejectedValueOnce(new Error('network down'));
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
expect(rows[0].status).toBe('success');
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});
it('targets the selected 分P part URL (?p=N) when --page is given', async () => {
mockApiGet
.mockResolvedValueOnce(viewPayload({
pages: [
{ cid: 1001, page: 1, part: 'P1' },
{ cid: 1003, page: 3, part: 'P3 标题' },
],
}))
.mockResolvedValueOnce(viewPayload());
await command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '3' });
// goto 与 yt-dlp 下载 URL 都应带 ?p=3
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1h6V16SEpg?p=3');
const job = mockDownloadMedia.mock.calls[0][0][0];
expect(job.url).toBe('https://www.bilibili.com/video/BV1h6V16SEpg?p=3');
expect(job.filename).toContain('_p3_P3 标题');
});
it('rejects malformed --page before download side effects', async () => {
await expect(
command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '1e2' }),
).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
expect(mockApiGet).not.toHaveBeenCalled();
expect(mockDownloadMedia).not.toHaveBeenCalled();
});
it('fails before yt-dlp when selected --page is absent from view API pages', async () => {
mockApiGet.mockResolvedValueOnce(viewPayload({
pages: [{ cid: 1001, page: 1, part: 'P1' }],
}));
await expect(
command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '9' }),
).rejects.toBeInstanceOf(CommandExecutionError);
expect(page.goto).not.toHaveBeenCalled();
expect(mockDownloadMedia).not.toHaveBeenCalled();
});
it('downloads default P1 (no ?p=) when --page is omitted', async () => {
mockApiGet.mockResolvedValueOnce(viewPayload());
await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD');
const job = mockDownloadMedia.mock.calls[0][0][0];
expect(job.url).toBe('https://www.bilibili.com/video/BV1xx411c7mD');
expect(job.filename).not.toContain('_p');
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Bilibili follow — establishes a follow relation via the official write API.
* Authenticated by logged-in cookie + bili_jct CSRF token (handled by apiPost).
*
* Accepts target as: numeric uid, username, or a space.bilibili.com profile URL.
* Pre-checks the current relation so the result row reports `already-following`
* accurately instead of relying on the modify API's idempotent silent success.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { parseSpaceMidUrl, fetchRelationAttribute, waitForRelation } from './relation.js';
import { apiPost, getSelfUid, requireOkPayload, resolveUid } from './utils.js';
/**
* Pull a uid out of a `space.bilibili.com/<uid>` URL before falling back to the
* generic resolver. `resolveUid` only handles bare digits or usernames; without
* this short-circuit a profile URL would get sent to the user-search endpoint
* and likely return nothing.
*/
async function resolveTargetMid(page, raw) {
const trimmed = String(raw ?? '').trim();
if (!trimmed) {
throw new ArgumentError('bilibili follow target cannot be empty');
}
if (/^(?:https?:\/\/)?space\.bilibili\.com\//i.test(trimmed)) {
const mid = parseSpaceMidUrl(trimmed);
if (!mid) {
throw new ArgumentError('bilibili follow target must be a valid space.bilibili.com/<uid> URL');
}
return mid;
}
try {
return await resolveUid(page, trimmed);
} catch (error) {
if (error instanceof EmptyResultError) throw error;
throw new ArgumentError(
`Cannot resolve Bilibili target from input: ${trimmed}`,
error instanceof Error ? error.message : String(error),
);
}
}
cli({
site: 'bilibili',
name: 'follow',
access: 'write',
description: '关注 B站用户(官方 API,需登录)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'target',
required: true,
positional: true,
help: '目标 UID / 用户名 / space.bilibili.com 链接',
},
],
columns: ['mid', 'name', 'status', 'url'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili follow');
}
const mid = await resolveTargetMid(page, kwargs.target);
const self = await getSelfUid(page);
if (mid === self) {
throw new ArgumentError('Cannot follow yourself');
}
const attribute = await fetchRelationAttribute(page, mid);
const url = `https://space.bilibili.com/${mid}`;
if (attribute === 2 || attribute === 6) {
return [{ mid, name: '', status: 'already-following', url }];
}
if (attribute === 128) {
throw new CommandExecutionError(
`Bilibili user ${mid} is blocked; unblock first before following.`,
);
}
// act=1 follow, act=2 unfollow. re_src=11 is the community-standard
// "web" source value used by third-party libs (bilibili-api-python etc.);
// omitting it makes the modify API reject with a vague code.
const payload = await apiPost(page, '/x/relation/modify', {
params: { fid: mid, act: 1, re_src: 11 },
});
requireOkPayload(payload, 'relation modify');
await waitForRelation(page, mid, (nextAttribute) => nextAttribute === 2 || nextAttribute === 6, 'following');
return [{ mid, name: '', status: 'followed', url }];
},
});
+241
View File
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiPost, mockFetchJson, mockGetSelfUid, mockResolveUid } = vi.hoisted(() => ({
mockApiPost: vi.fn(),
mockFetchJson: vi.fn(),
mockGetSelfUid: vi.fn(),
mockResolveUid: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiPost: mockApiPost,
fetchJson: mockFetchJson,
getSelfUid: mockGetSelfUid,
resolveUid: mockResolveUid,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './follow.js';
import './unfollow.js';
async function expectRejectsWithMessage(promise, type, message) {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(type);
expect(error.message).toBe(message);
return;
}
throw new Error(`Expected rejection with message: ${message}`);
}
describe('bilibili follow', () => {
const command = getRegistry().get('bilibili/follow');
beforeEach(() => {
mockApiPost.mockReset();
mockFetchJson.mockReset();
mockGetSelfUid.mockReset();
mockResolveUid.mockReset();
mockResolveUid.mockImplementation(async (_page, input) => String(input));
mockGetSelfUid.mockResolvedValue('11111111');
});
it('follows a user by numeric uid', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
const result = await command.func({}, { target: '9617619' });
expect(mockFetchJson).toHaveBeenCalledWith({}, 'https://api.bilibili.com/x/relation?fid=9617619');
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/relation/modify', {
params: { fid: '9617619', act: 1, re_src: 11 },
});
expect(result).toEqual([{
mid: '9617619', name: '', status: 'followed',
url: 'https://space.bilibili.com/9617619',
}]);
});
it('extracts uid from a space.bilibili.com URL without calling resolveUid', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
await command.func({}, { target: 'https://space.bilibili.com/9617619' });
expect(mockResolveUid).not.toHaveBeenCalled();
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/relation/modify', {
params: { fid: '9617619', act: 1, re_src: 11 },
});
});
it('resolves a username via resolveUid', async () => {
mockResolveUid.mockResolvedValueOnce('555');
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
const result = await command.func({}, { target: '某up主' });
expect(mockResolveUid).toHaveBeenCalledWith({}, '某up主');
expect(result[0].mid).toBe('555');
});
it('reports already-following without calling modify when attribute is 2', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
const result = await command.func({}, { target: '9617619' });
expect(mockApiPost).not.toHaveBeenCalled();
expect(result[0].status).toBe('already-following');
});
it('reports already-following for mutual-follow (attribute=6)', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
const result = await command.func({}, { target: '9617619' });
expect(mockApiPost).not.toHaveBeenCalled();
expect(result[0].status).toBe('already-following');
});
it('refuses to follow yourself', async () => {
mockGetSelfUid.mockResolvedValueOnce('9617619');
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(ArgumentError);
expect(mockFetchJson).not.toHaveBeenCalled();
expect(mockApiPost).not.toHaveBeenCalled();
});
it('rejects an empty target before touching the API', async () => {
await expectRejectsWithMessage(
command.func({}, { target: ' ' }),
ArgumentError,
'bilibili follow target cannot be empty',
);
expect(mockGetSelfUid).not.toHaveBeenCalled();
});
it('rejects malformed Bilibili profile URLs instead of searching the whole URL', async () => {
await expectRejectsWithMessage(
command.func({}, { target: 'https://space.bilibili.com/not-a-uid' }),
ArgumentError,
'bilibili follow target must be a valid space.bilibili.com/<uid> URL',
);
expect(mockResolveUid).not.toHaveBeenCalled();
expect(mockFetchJson).not.toHaveBeenCalled();
});
it('propagates EmptyResultError when resolveUid finds no user', async () => {
mockResolveUid.mockRejectedValueOnce(new EmptyResultError('bilibili user search'));
await expect(command.func({}, { target: '幽灵用户zzz' })).rejects.toBeInstanceOf(EmptyResultError);
expect(mockApiPost).not.toHaveBeenCalled();
});
it('refuses to follow when the target is blocked (attribute=128)', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 128 } });
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(CommandExecutionError);
expect(mockApiPost).not.toHaveBeenCalled();
});
it('maps login/csrf failures from modify to AuthRequiredError', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
mockApiPost.mockResolvedValueOnce({ code: -101, message: '账号未登录' });
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(AuthRequiredError);
});
it('requires the relation to verify as following after modify succeeds', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
await expect(command.func({}, { target: '9617619' })).rejects.toThrow(/did not verify following/);
});
it('throws when relation query returns malformed attribute', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: {} });
await expect(command.func({}, { target: '9617619' })).rejects.toThrow(/malformed attribute/);
expect(mockApiPost).not.toHaveBeenCalled();
});
it('throws CommandExecutionError with the upstream code on non-auth modify failure', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
mockApiPost.mockResolvedValueOnce({ code: 22002, message: 'follow too fast' });
await expect(command.func({}, { target: '9617619' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws when no browser session is provided', async () => {
await expect(command.func(null, { target: '9617619' })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('bilibili unfollow', () => {
const command = getRegistry().get('bilibili/unfollow');
beforeEach(() => {
mockApiPost.mockReset();
mockFetchJson.mockReset();
mockGetSelfUid.mockReset();
mockResolveUid.mockReset();
mockResolveUid.mockImplementation(async (_page, input) => String(input));
mockGetSelfUid.mockResolvedValue('11111111');
});
it('unfollows a followed user and verifies the relation flipped', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 2 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
const result = await command.func({}, { target: '9617619' });
expect(mockApiPost).toHaveBeenCalledWith({}, '/x/relation/modify', {
params: { fid: '9617619', act: 2, re_src: 11 },
});
expect(result[0].status).toBe('unfollowed');
});
it('returns not-following without calling modify when already not following', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 0 } });
const result = await command.func({}, { target: '9617619' });
expect(mockApiPost).not.toHaveBeenCalled();
expect(result[0].status).toBe('not-following');
});
it('requires the relation to verify as not-following after modify succeeds', async () => {
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
mockFetchJson.mockResolvedValueOnce({ code: 0, data: { attribute: 6 } });
await expect(command.func({}, { target: '9617619' })).rejects.toThrow(/did not verify not following/);
});
it('rejects an empty target with unfollow-specific text before touching the API', async () => {
await expectRejectsWithMessage(
command.func({}, { target: ' ' }),
ArgumentError,
'bilibili unfollow target cannot be empty',
);
expect(mockGetSelfUid).not.toHaveBeenCalled();
});
it('rejects malformed profile URLs with unfollow-specific text', async () => {
await expectRejectsWithMessage(
command.func({}, { target: 'https://space.bilibili.com/not-a-uid' }),
ArgumentError,
'bilibili unfollow target must be a valid space.bilibili.com/<uid> URL',
);
expect(mockResolveUid).not.toHaveBeenCalled();
expect(mockFetchJson).not.toHaveBeenCalled();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { fetchJson, requireOkPayload } from './utils.js';
const RELATION_VERIFY_TIMEOUT_MS = 5000;
const RELATION_VERIFY_POLL_MS = 500;
export function parseSpaceMidUrl(raw) {
const trimmed = String(raw ?? '').trim();
if (!trimmed) return '';
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
let parsed;
try {
parsed = new URL(candidate);
} catch {
return '';
}
if (parsed.hostname.toLowerCase() !== 'space.bilibili.com') return '';
const match = parsed.pathname.match(/^\/(\d+)\/?$/);
return match ? match[1] : '';
}
export async function fetchRelationAttribute(page, mid) {
const payload = await fetchJson(page, `https://api.bilibili.com/x/relation?fid=${mid}`);
requireOkPayload(payload, 'relation query');
const attribute = payload?.data?.attribute;
if (typeof attribute !== 'number') {
throw new CommandExecutionError('Bilibili relation query returned a malformed attribute');
}
return attribute;
}
export async function waitForRelation(page, mid, predicate, expectedLabel) {
const deadline = Date.now() + RELATION_VERIFY_TIMEOUT_MS;
let lastAttribute;
while (Date.now() <= deadline) {
lastAttribute = await fetchRelationAttribute(page, mid);
if (predicate(lastAttribute)) return lastAttribute;
if (typeof page.wait !== 'function') break;
await page.wait({ time: RELATION_VERIFY_POLL_MS / 1000 });
}
throw new CommandExecutionError(
`Bilibili relation modify did not verify ${expectedLabel}; last attribute=${lastAttribute}`,
);
}
+81 -33
View File
@@ -1,68 +1,97 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
cli({
site: 'bilibili',
name: 'subtitle',
access: 'read',
description: '获取 Bilibili 视频的字幕',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Bilibili 视频 BV ID(如 BV1xx411c7mD),或视频 URL / b23.tv 短链' },
{ name: 'lang', required: false, help: '字幕语言代码 (如 zh-CN, en-US, ai-zh),默认取第一个' },
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频取该集字幕;缺省取默认 P1' },
],
columns: ['index', 'from', 'to', 'content'],
func: async (page, kwargs) => {
if (!page)
throw new CommandExecutionError('Browser session required for bilibili subtitle');
const bvid = await resolveBvid(kwargs.bvid);
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
// 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
const cid = await page.evaluate(`(async () => {
const state = window.__INITIAL_STATE__ || {};
return state?.videoData?.cid;
})()`);
if (!cid) {
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
const selectedPage = parsePageArg(kwargs.page);
// 1. 通过 view API 拿 cid。
// 以前的实现走 page.goto(/video/<bvid>) + window.__INITIAL_STATE__.videoData.cid
// bangumi 绑定的 bvid(番剧/纪录片/电影/综艺)页面 state 不在 videoData 而在 epList
// 导致 SELECTOR 错。view API 接受任何 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})`);
}
// --page 给定时用该集 cidselectVideoPart 越界抛错);缺省取整集默认 cid(P1,旧行为)。
const cid = selectedPage != null ? selectVideoPart(view?.data, selectedPage).cid : view?.data?.cid;
if (!cid) {
throw new CommandExecutionError(`无法从 view API 拿到 cid (bvid=${bvid})`);
}
// 2. 用带 Wbi 签名的 player/v2 拿字幕列表(之前 evaluate 里 fetch 因为没签名会 403
let payload;
try {
payload = await apiGet(page, '/x/player/wbi/v2', {
params: { bvid, cid },
signed: true,
});
}
catch (err) {
throw new CommandExecutionError(`获取视频播放信息失败: ${err?.message || err}`);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError('获取到的视频播放信息对象不符合预期格式');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
const payload = await apiGet(page, '/x/player/wbi/v2', {
params: { bvid, cid },
signed: true, // 开启 wbi_sign 自动签名
});
if (payload.code !== 0) {
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
}
const needLoginSubtitle = payload.data?.need_login_subtitle === true;
const subtitles = payload.data?.subtitle?.subtitles || [];
const subtitles = payload.data?.subtitle?.subtitles;
if (!Array.isArray(subtitles)) {
throw new CommandExecutionError('获取到的字幕列表对象不符合数组格式');
}
if (subtitles.length === 0) {
if (needLoginSubtitle) {
throw new AuthRequiredError('bilibili.com', 'Bilibili subtitles are hidden behind login for this video. Please log in to bilibili.com in Chrome and retry.');
}
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
}
// 4. 选择目标字幕语言
// 3. 选择目标字幕语言
const target = kwargs.lang
? subtitles.find((s) => s.lan === kwargs.lang) || subtitles[0]
: subtitles[0];
const targetSubUrl = target.subtitle_url;
if (!targetSubUrl || targetSubUrl === '') {
if (!target || typeof target !== 'object' || !Object.hasOwn(target, 'subtitle_url')) {
throw new CommandExecutionError('字幕条目缺少 subtitle_url 字段');
}
const targetSubUrl = typeof target.subtitle_url === 'string' ? target.subtitle_url.trim() : '';
if (!targetSubUrl) {
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
}
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
// 5. 解析并拉取 CDN 的 JSON 文件
if (!/^https?:\/\//i.test(finalUrl)) {
throw new CommandExecutionError(`字幕 URL 非法: ${finalUrl}`);
}
// 4. 解析并拉取 CDN 的 JSON 文件
const fetchJs = `
(async () => {
const url = ${JSON.stringify(finalUrl)};
const res = await fetch(url);
const text = await res.text();
if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
return { error: 'HTML', text: text.substring(0, 100), url };
}
try {
const subJson = JSON.parse(text);
// B站真实返回格式是 { font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", type: "json" , body: [{from: 0, to: 0, content: ""}] }
@@ -74,20 +103,39 @@ cli({
}
})()
`;
const items = await page.evaluate(fetchJs);
let items;
try {
items = await page.evaluate(fetchJs);
}
catch (err) {
throw new CommandExecutionError(`字幕获取失败: ${err?.message || err}`);
}
if (items?.error) {
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
}
const finalItems = items?.data || [];
if (!items || typeof items !== 'object' || items.success !== true) {
throw new CommandExecutionError('字幕获取结果对象不符合预期格式');
}
const finalItems = items.data;
if (!Array.isArray(finalItems)) {
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
}
// 6. 数据映射
return finalItems.map((item, idx) => ({
index: idx + 1,
from: Number(item.from || 0).toFixed(2) + 's',
to: Number(item.to || 0).toFixed(2) + 's',
content: item.content
}));
if (finalItems.length === 0) {
throw new EmptyResultError('bilibili subtitle', '字幕文件中没有字幕片段。');
}
// 5. 数据映射
return finalItems.map((item, idx) => {
const from = Number(item?.from);
const to = Number(item?.to);
if (!item || typeof item !== 'object' || !Number.isFinite(from) || !Number.isFinite(to)) {
throw new CommandExecutionError('字幕片段缺少有效 from/to 时间戳');
}
return {
index: idx + 1,
from: from.toFixed(2) + 's',
to: to.toFixed(2) + 's',
content: String(item.content ?? '')
};
});
},
});
+200 -9
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
@@ -20,30 +20,221 @@ describe('bilibili subtitle', () => {
page.goto.mockClear();
page.evaluate.mockReset();
});
// 帮助函数:第一发 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('uses the selected 分P part cid when --page is given', async () => {
// view 返回 pages 数组;--page 3 应改用 pages[2].cid,而非 data.cid(默认 P1
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1h6V16SEpg',
cid: 1001, // P1 默认 cid
pages: [
{ cid: 1001, page: 1, part: '01' },
{ cid: 1002, page: 2, part: '02' },
{ cid: 1003, page: 3, part: '03 人生的价值' },
],
},
});
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
},
});
page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 0, to: 1, content: 'a' }] });
await command.func(page, { bvid: 'BV1h6V16SEpg', page: '3' });
// 第二发 apiGetplayer/wbi/v2)的 cid 必须是第 3 集的 1003
const playerCall = mockApiGet.mock.calls[1];
expect(playerCall[1]).toBe('/x/player/wbi/v2');
expect(playerCall[2]?.params?.cid).toBe(1003);
});
it('throws CommandExecutionError when --page is out of range', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1h6V16SEpg', cid: 1001, pages: [{ cid: 1001, page: 1, part: '01' }] },
});
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '9' })).rejects.toThrow(CommandExecutionError);
});
it('rejects malformed --page before querying subtitle APIs', async () => {
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
expect(mockApiGet).not.toHaveBeenCalled();
});
it('works for bangumi-bound bvid (PGC content) — same code path, view API returns cid + redirect_url', async () => {
// 回归保护:以前 page.goto(/video/<bvid>) 对 bangumi 走重定向,
// window.__INITIAL_STATE__.videoData 不存在 → SELECTOR 错。view API 不依赖页面结构,bangumi 同样能拿 cid。
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1Py4y1D781',
cid: 267270412,
redirect_url: 'https://www.bilibili.com/bangumi/play/ep371508',
title: '【纪录片】灭绝的真相',
},
});
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: {
subtitles: [],
subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }],
},
},
});
await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
page.evaluate.mockResolvedValueOnce({
success: true,
data: [
{ from: 0, to: 1.5, content: 'hello' },
{ from: 1.5, to: 3.2, content: 'world' },
],
});
const out = await command.func(page, { bvid: 'BV1Py4y1D781' });
expect(out).toEqual([
{ index: 1, from: '0.00s', to: '1.50s', content: 'hello' },
{ index: 2, from: '1.50s', to: '3.20s', content: 'world' },
]);
// 关键:不再依赖 page.goto,所有 cid 解析走 apiGet
expect(page.goto).not.toHaveBeenCalled();
// 第一发 apiGet 一定是 view 端点
const firstCall = mockApiGet.mock.calls[0];
expect(firstCall[1]).toBe('/x/web-interface/view');
expect(firstCall[2]?.params?.bvid).toBe('BV1Py4y1D781');
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Bilibili unfollow — removes a follow relation via the official write API.
* Mirror of follow.js with act=2. If the viewer is not currently following the
* target, the API call is skipped and `not-following` is returned without
* touching state.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { parseSpaceMidUrl, fetchRelationAttribute, waitForRelation } from './relation.js';
import { apiPost, getSelfUid, requireOkPayload, resolveUid } from './utils.js';
async function resolveTargetMid(page, raw) {
const trimmed = String(raw ?? '').trim();
if (!trimmed) {
throw new ArgumentError('bilibili unfollow target cannot be empty');
}
if (/^(?:https?:\/\/)?space\.bilibili\.com\//i.test(trimmed)) {
const mid = parseSpaceMidUrl(trimmed);
if (!mid) {
throw new ArgumentError('bilibili unfollow target must be a valid space.bilibili.com/<uid> URL');
}
return mid;
}
try {
return await resolveUid(page, trimmed);
} catch (error) {
if (error instanceof EmptyResultError) throw error;
throw new ArgumentError(
`Cannot resolve Bilibili target from input: ${trimmed}`,
error instanceof Error ? error.message : String(error),
);
}
}
cli({
site: 'bilibili',
name: 'unfollow',
access: 'write',
description: '取消关注 B站用户(官方 API,需登录)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'target',
required: true,
positional: true,
help: '目标 UID / 用户名 / space.bilibili.com 链接',
},
],
columns: ['mid', 'name', 'status', 'url'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili unfollow');
}
const mid = await resolveTargetMid(page, kwargs.target);
const self = await getSelfUid(page);
if (mid === self) {
throw new ArgumentError('Cannot unfollow yourself');
}
const attribute = await fetchRelationAttribute(page, mid);
const url = `https://space.bilibili.com/${mid}`;
// attribute 2=following, 6=mutual. Anything else means the viewer isn't
// currently following — skip the POST and return idempotent status.
if (attribute !== 2 && attribute !== 6) {
return [{ mid, name: '', status: 'not-following', url }];
}
const payload = await apiPost(page, '/x/relation/modify', {
params: { fid: mid, act: 2, re_src: 11 },
});
requireOkPayload(payload, 'relation modify');
await waitForRelation(page, mid, (nextAttribute) => nextAttribute !== 2 && nextAttribute !== 6, 'not following');
return [{ mid, name: '', status: 'unfollowed', url }];
},
});
+152 -5
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 { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
/**
* Resolve Bilibili short URL / short code to BV ID.
* Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa
@@ -12,7 +12,22 @@ export function resolveBvid(input) {
if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) {
return Promise.resolve(trimmed);
}
try {
const parsed = new URL(trimmed);
if (/(\.|^)bilibili\.com$/i.test(parsed.hostname)) {
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
if (match) {
return Promise.resolve(match[1]);
}
}
}
catch {
// Non-URL inputs fall through to b23.tv short-code resolution.
}
const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, '');
if (!/^[A-Za-z0-9]+$/.test(shortCode)) {
return Promise.reject(new Error(`Cannot resolve BV ID from invalid b23.tv short code: ${trimmed}`));
}
const url = 'https://b23.tv/' + shortCode;
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
@@ -29,9 +44,73 @@ export function resolveBvid(input) {
reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`));
});
req.on('error', reject);
req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
req.setTimeout(4000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
});
}
/**
* 解析 --page 选集序号(分P / 视频选集)。
* 缺省/空串 → null(不下钻,保持整集默认 P1 旧行为)。
* 非正十进制整数 → 抛 ArgumentError(参数错误,不静默吞)。
*/
export function parsePageArg(value) {
if (value == null || value === '') return null;
if (typeof value === 'number') {
if (Number.isSafeInteger(value) && value >= 1) return value;
throw new ArgumentError(`--page must be a positive decimal integer, got: ${value}`);
}
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
throw new ArgumentError(`--page must be a positive decimal integer, got: ${String(value)}`);
}
const n = Number(value);
if (!Number.isSafeInteger(n)) {
throw new ArgumentError(`--page is too large: ${value}`);
}
return n;
}
function readApiPositiveInteger(value, label) {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1) {
return value;
}
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) {
const n = Number(value);
if (Number.isSafeInteger(n)) return n;
}
throw new CommandExecutionError(`Bilibili view API returned a malformed ${label}`);
}
/**
* 从 view API 的 data.pages 数组取第 N 集(1-based)。
* page/cid 都以 view API 的 pages[] 为 source-of-truth;缺失、重复或畸形都 fail closed。
* 返回该集 raw 对象(含 cid / part(分集标题) / page / duration)。
*/
export function selectVideoPart(viewData, pageNum) {
const pages = Array.isArray(viewData?.pages) ? viewData.pages : null;
if (!pages || pages.length === 0) {
throw new CommandExecutionError('Bilibili view API did not return pages[] for --page selection');
}
const matches = [];
for (const entry of pages) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new CommandExecutionError('Bilibili view API returned a malformed pages[] entry');
}
const apiPage = readApiPositiveInteger(entry.page, 'page number');
if (apiPage === pageNum) {
matches.push(entry);
}
}
if (matches.length > 1) {
throw new CommandExecutionError(`Bilibili view API returned duplicate page entries for p=${pageNum}`);
}
const part = matches[0];
if (!part) {
const total = pages.length || viewData?.videos || 1;
throw new CommandExecutionError(`分P 序号超出范围:p=${pageNum}(该视频共 ${total} 集)`);
}
readApiPositiveInteger(part.cid, `cid for p=${pageNum}`);
return part;
}
const MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
@@ -104,6 +183,63 @@ export async function fetchJson(page, url) {
}
`);
}
/**
* Bilibili write APIs return a JSON envelope `{ code, message, data }`. A non-zero
* `code` carries either an auth/permission failure (login expired, CSRF rejected,
* forbidden) or an application-level error (rate limit, validation, etc.). These
* two helpers route the envelope to the right typed error so every write adapter
* surfaces login problems as `AuthRequiredError`, not a generic execution error.
*/
export function isAuthLikeBilibiliError(code, message) {
return code === -101 || code === -111 || code === -403 || /csrf|登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
}
export function requireOkPayload(payload, label) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
}
if (payload.code !== 0) {
const message = payload.message ?? 'unknown error';
if (isAuthLikeBilibiliError(payload.code, message)) {
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
}
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
}
return payload.data;
}
/**
* POST form-encoded params to a Bilibili API endpoint.
* Runs inside the logged-in browser context and auto-attaches the bili_jct CSRF token,
* which Bilibili requires on every authenticated write request.
*/
export async function apiPost(page, path, opts = {}) {
const params = opts.params ?? {};
const stringified = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]));
const paramsJs = JSON.stringify(stringified);
const urlJs = JSON.stringify(`https://api.bilibili.com${path}`);
return page.evaluate(`
async () => {
const csrf = (document.cookie.match(/bili_jct=([^;]+)/) || [])[1] || "";
const body = new URLSearchParams(${paramsJs});
body.set("csrf", csrf);
const res = await fetch(${urlJs}, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
// Bilibili write endpoints can return an HTML risk-control page (e.g. HTTP 412)
// instead of JSON. Surface that as a structured error rather than a parse crash.
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { code: -1, message: "Non-JSON response (HTTP " + res.status + "): " + text.slice(0, 200) };
}
}
`);
}
export async function getSelfUid(page) {
const nav = await getNavData(page);
const mid = nav?.data?.mid;
@@ -119,8 +255,19 @@ export async function resolveUid(page, input) {
params: { search_type: 'bili_user', keyword: input },
signed: true,
});
const results = payload?.data?.result ?? [];
if (results.length > 0)
return String(results[0].mid);
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data) || !Object.hasOwn(payload.data, 'result')) {
throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
}
const results = payload.data.result;
if (!Array.isArray(results)) {
throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
}
if (results.length > 0) {
const mid = String(results[0]?.mid ?? '').trim();
if (!mid) {
throw new CommandExecutionError(`Bilibili user search returned malformed mid for ${input}`);
}
return mid;
}
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
}
+81 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveBvid } from './utils.js';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { parsePageArg, resolveBvid, resolveUid, selectVideoPart } from './utils.js';
describe('resolveBvid', () => {
it('passes through a valid BV ID', async () => {
expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
@@ -10,8 +11,87 @@ describe('resolveBvid', () => {
it('handles non-string input via String() coercion', async () => {
expect(await resolveBvid('BV123abc')).toBe('BV123abc');
});
it('extracts BV IDs from bilibili video URLs', async () => {
expect(await resolveBvid('https://www.bilibili.com/video/BV1xx411c7mD/?spm_id_from=333.1007')).toBe('BV1xx411c7mD');
expect(await resolveBvid('https://m.bilibili.com/video/BV1Je9EBnEha')).toBe('BV1Je9EBnEha');
});
it('rejects invalid input that cannot be resolved', async () => {
// A random string that b23.tv won't resolve — should timeout or fail
await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
});
});
describe('resolveUid', () => {
function pageWithUserSearchResult(result) {
return {
evaluate: async (script) => {
if (String(script).includes('/x/web-interface/nav')) {
return {
data: {
wbi_img: {
img_url: 'https://i0.hdslb.com/bfs/wbi/abcdefghijklmnopqrstuvwxyz123456.png',
sub_url: 'https://i0.hdslb.com/bfs/wbi/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456.png',
},
},
};
}
return result;
},
};
}
it('returns numeric uid input without searching', async () => {
expect(await resolveUid({}, '12345')).toBe('12345');
});
it('fails closed when user search payload lacks result', async () => {
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: {} }), 'missing'))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails closed when user search result row lacks mid', async () => {
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [{}] } }), 'missing-mid'))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('keeps explicit no-user result as EmptyResultError', async () => {
await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [] } }), 'nobody'))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
describe('parsePageArg', () => {
it('accepts omitted page and strict positive decimal integers', () => {
expect(parsePageArg(undefined)).toBeNull();
expect(parsePageArg(null)).toBeNull();
expect(parsePageArg('')).toBeNull();
expect(parsePageArg('1')).toBe(1);
expect(parsePageArg('12')).toBe(12);
expect(parsePageArg(3)).toBe(3);
});
it('rejects malformed or coerced page values as argument errors', () => {
for (const value of ['0', '-1', '1.5', '1e2', '0x10', ' 1 ', '01', 'abc', Number.NaN, 1.2]) {
expect(() => parsePageArg(value)).toThrow(ArgumentError);
}
});
});
describe('selectVideoPart', () => {
it('selects by unique API page number and preserves cid', () => {
const part = selectVideoPart({
pages: [
{ page: 1, cid: 1001, part: 'P1' },
{ page: 3, cid: '1003', part: 'P3' },
],
}, 3);
expect(part).toMatchObject({ page: 3, cid: '1003', part: 'P3' });
});
it('fails closed for missing, duplicate, or malformed page identity', () => {
expect(() => selectVideoPart({ pages: [] }, 1)).toThrow(CommandExecutionError);
expect(() => selectVideoPart({ pages: [{ page: 1, cid: 1 }, { page: 1, cid: 2 }] }, 1)).toThrow(CommandExecutionError);
expect(() => selectVideoPart({ pages: [{ page: '1e0', cid: 1 }] }, 1)).toThrow(CommandExecutionError);
expect(() => selectVideoPart({ pages: [{ page: 1, cid: 0 }] }, 1)).toThrow(CommandExecutionError);
});
});
+93 -7
View File
@@ -1,6 +1,33 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
function requireObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new CommandExecutionError(`${label} returned a malformed payload`);
}
return value;
}
function unwrapBrowserResult(value) {
if (value && typeof value === 'object' && typeof value.session === 'string' && Object.hasOwn(value, 'data')) {
return value.data;
}
return value;
}
function readOptionalFlag(value, label) {
if (value == null) return false;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
throw new CommandExecutionError(`${label} returned a malformed flag`);
}
function readOptionalString(value, label) {
if (value == null) return '';
if (typeof value === 'string') return value;
throw new CommandExecutionError(`${label} returned a malformed string`);
}
cli({
site: 'bilibili',
@@ -10,6 +37,7 @@ cli({
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频指定某一集,title/cid 返回该集;缺省取整集默认(P1)' },
],
columns: ['field', 'value'],
func: async (page, kwargs) => {
@@ -17,6 +45,9 @@ cli({
throw new CommandExecutionError('Browser session required for bilibili video');
}
// 选集序号(--page):缺省 null = 不下钻分P,保持整集(P1)旧行为。
const selectedPage = parsePageArg(kwargs.page);
// Resolve BV ID from three advertised input forms:
// 1. Bare "BV..." id
// 2. Full bilibili.com/video/<BV>... URL (with or without query string / www / m.)
@@ -30,26 +61,66 @@ cli({
// Navigate to video page first so subsequent api call shares a primed session.
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
const payload = await apiGet(page, '/x/web-interface/view', {
const payload = unwrapBrowserResult(await apiGet(page, '/x/web-interface/view', {
params: { bvid },
});
}));
requireObject(payload, 'Bilibili view API');
if (payload.code !== 0) {
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
}
const d = payload.data || {};
const d = requireObject(payload.data, 'Bilibili view API data');
const stat = d.stat || {};
const owner = d.owner || {};
// 付费/会员标记:view API 的 rights 位 + 充电专属字段本来就在响应里,
// 透出给下游在下载/截屏前判断"拿不到视频流"。
// rights.pay=1 → 付费 OGV(大会员专享/单点付费番剧、影视;实测会员番剧单集 pay=1)
// rights.ugc_pay=1 / arc_pay=1 → UGC 单点付费 / 付费合集
// is_upower_exclusive=true → 充电专属视频
// redirect_url 非空(指向 /bangumi/play/ep<id>= OGV 内容,细分可再查 pgc season API。
const rights = requireObject(d.rights, 'Bilibili view API data.rights');
const rightsPay = readOptionalFlag(rights.pay, 'Bilibili rights.pay');
const rightsUgcPay = readOptionalFlag(rights.ugc_pay, 'Bilibili rights.ugc_pay');
const rightsArcPay = readOptionalFlag(rights.arc_pay, 'Bilibili rights.arc_pay');
const upowerExclusive = readOptionalFlag(d.is_upower_exclusive, 'Bilibili is_upower_exclusive');
const paymentType = rightsPay
? 'vip'
: (rightsUgcPay || rightsArcPay)
? 'ugc_pay'
: upowerExclusive
? 'upower'
: '';
const payPreview = readOptionalFlag(rights.ugc_pay_preview, 'Bilibili rights.ugc_pay_preview')
|| readOptionalFlag(d.is_upower_preview, 'Bilibili is_upower_preview');
const redirectUrl = readOptionalString(d.redirect_url, 'Bilibili redirect_url');
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
const dur = d.duration || 0;
// 选集下钻:--page 给定时从 data.pages 取该集,title 用分集标题(part),
// 越界由 selectVideoPart 抛结构化错。缺省保持整集 title = d.title(旧行为不变)。
let title = d.title ?? '';
let partCid = '';
let partDur = d.duration || 0;
if (selectedPage != null) {
const part = selectVideoPart(d, selectedPage);
partCid = String(part.cid ?? '');
const partTitle = typeof part.part === 'string' ? part.part.trim() : '';
title = partTitle || `${d.title ?? ''} P${selectedPage}`;
// 分集时长(pages[].duration)比整集 d.duration 更贴合该集;缺则回退整集。
if (Number.isFinite(Number(part.duration)) && Number(part.duration) > 0) {
partDur = Number(part.duration);
}
}
const dur = partDur || 0;
const mm = Math.floor(dur / 60);
const ss = dur % 60;
return [
const rows = [
{ field: 'bvid', value: d.bvid ?? '' },
{ field: 'aid', value: String(d.aid ?? '') },
{ field: 'title', value: d.title ?? '' },
{ field: 'title', value: title },
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
{ field: 'category', value: d.tname_v2 || d.tname || '' },
{ field: 'publish_time', value: pubDate },
@@ -64,6 +135,21 @@ cli({
{ field: 'parts', value: String(d.videos ?? 1) },
{ field: 'thumbnail', value: d.pic ?? '' },
{ field: 'description', value: d.desc ?? '' },
{ field: 'requires_payment', value: String(!!paymentType) },
{ field: 'payment_type', value: paymentType },
// 可试看(ugc_pay_preview / 充电预览):有预览流但拿不到完整正片
{ field: 'pay_preview', value: String(payPreview) },
{ field: 'redirect_url', value: redirectUrl },
];
// --page 时透出分集专属字段:page(选集序号)、cid(该集弹幕/字幕轴 id)、
// series_title(整集标题,给下游做"分集标题为空"兜底)。缺省不加,保持旧输出。
if (selectedPage != null) {
rows.push({ field: 'page', value: String(selectedPage) });
rows.push({ field: 'cid', value: partCid });
rows.push({ field: 'series_title', value: d.title ?? '' });
}
return rows;
},
});
+181 -5
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
@@ -39,6 +39,7 @@ describe('bilibili video', () => {
videos: 1,
pic: 'https://i1.hdslb.com/some.jpg',
desc: 'Obsidian 教程',
rights: {},
owner: { mid: 507578555, name: 'IOI科技' },
stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
},
@@ -60,6 +61,11 @@ describe('bilibili video', () => {
expect(byField.duration).toBe('7m14s (434s)');
expect(byField.view).toBe('6128');
expect(byField.like).toBe('162');
// 普通视频:无任何付费标记
expect(byField.requires_payment).toBe('false');
expect(byField.payment_type).toBe('');
expect(byField.pay_preview).toBe('false');
expect(byField.redirect_url).toBe('');
// Navigation primes the session
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
@@ -79,10 +85,26 @@ describe('bilibili video', () => {
);
});
it('unwraps Browser Bridge envelopes before reading view API data', async () => {
mockApiGet.mockResolvedValueOnce({
session: 'browser:default',
data: {
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: { pay: 1 } },
},
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.requires_payment).toBe('true');
expect(byField.payment_type).toBe('vip');
});
it('extracts BV ID from full bilibili.com URL input', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: {} },
});
await command.func(page, { bvid: 'https://www.bilibili.com/video/BV1xx411c7mD/' });
@@ -94,7 +116,7 @@ describe('bilibili video', () => {
it('extracts BV ID from bilibili URL with trailing query string', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '' },
data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '', rights: {} },
});
await command.func(page, {
@@ -107,7 +129,7 @@ describe('bilibili video', () => {
it('extracts BV ID from m.bilibili.com mobile URL', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: {} },
});
await command.func(page, { bvid: 'https://m.bilibili.com/video/BV1xx411c7mD' });
@@ -115,11 +137,165 @@ describe('bilibili video', () => {
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
});
it('flags member-only bangumi episode as vip paid content', async () => {
// 实测数据形状:会员番剧单集(如 国王排名 02)view API 返回 rights.pay=1
// + redirect_url 指向 bangumi ep 页
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1HR4y1J7Sp',
title: '【10月】国王排名 02【独家正版】',
stat: {},
owner: {},
desc: '',
rights: { pay: 1, hd5: 1 },
redirect_url: 'https://www.bilibili.com/bangumi/play/ep424606',
},
});
const rows = await command.func(page, { bvid: 'BV1HR4y1J7Sp' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.requires_payment).toBe('true');
expect(byField.payment_type).toBe('vip');
expect(byField.redirect_url).toBe('https://www.bilibili.com/bangumi/play/ep424606');
});
it('flags upower-exclusive video and ugc_pay preview', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1xx411c7mD',
stat: {},
owner: {},
desc: '',
rights: { ugc_pay_preview: 1 },
is_upower_exclusive: true,
},
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.requires_payment).toBe('true');
expect(byField.payment_type).toBe('upower');
expect(byField.pay_preview).toBe('true');
});
it('flags ugc_pay video', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: { ugc_pay: 1 } },
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.requires_payment).toBe('true');
expect(byField.payment_type).toBe('ugc_pay');
});
it('typed-fails when paid marker source fields are missing', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
});
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('typed-fails malformed paid marker flags instead of defaulting to free', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '', rights: { pay: '0' } },
});
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('selects a specific 分P part via --page: title=part, plus cid/page/series_title fields', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1h6V16SEpg',
title: '《道德经的奥秘》',
stat: {},
owner: { mid: 1, name: 'UP' },
desc: '',
rights: {},
videos: 21,
pages: [
{ cid: 1001, page: 1, part: '01 上士闻道', duration: 1391 },
{ cid: 1002, page: 2, part: '02 上士闻道', duration: 1391 },
{ cid: 1003, page: 3, part: '03 人生的价值', duration: 1391 },
],
},
});
const rows = await command.func(page, { bvid: 'BV1h6V16SEpg', page: '3' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.title).toBe('03 人生的价值');
expect(byField.cid).toBe('1003');
expect(byField.page).toBe('3');
expect(byField.series_title).toBe('《道德经的奥秘》');
expect(byField.parts).toBe('21');
expect(byField.duration).toBe('23m11s (1391s)');
});
it('falls back to "<series> P<n>" when the part has no title', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1h6V16SEpg', title: '合集标题', stat: {}, owner: {}, desc: '', rights: {},
videos: 2,
pages: [
{ cid: 1, page: 1, part: '', duration: 60 },
{ cid: 2, page: 2, part: ' ', duration: 60 },
],
},
});
const rows = await command.func(page, { bvid: 'BV1h6V16SEpg', page: '2' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.title).toBe('合集标题 P2');
expect(byField.cid).toBe('2');
});
it('throws CommandExecutionError when --page is out of range', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1h6V16SEpg', title: 't', stat: {}, owner: {}, desc: '', rights: {},
videos: 2, pages: [{ cid: 1, page: 1, part: 'a' }, { cid: 2, page: 2, part: 'b' }],
},
});
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: '99' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws ArgumentError when --page is not a strict positive decimal integer', async () => {
for (const pageArg of ['0', 'abc', '1e2', '0x10', ' 1 ']) {
await expect(command.func(page, { bvid: 'BV1h6V16SEpg', page: pageArg })).rejects.toBeInstanceOf(ArgumentError);
}
expect(mockApiGet).not.toHaveBeenCalled();
});
it('omits cid/page/series_title fields when --page is not given (backward compat)', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', title: 't', stat: {}, owner: {}, desc: '', rights: {}, videos: 3, pages: [{ cid: 1, page: 1, part: 'a' }] },
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
const fields = rows.map((r) => r.field);
expect(fields).not.toContain('cid');
expect(fields).not.toContain('page');
expect(fields).not.toContain('series_title');
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.title).toBe('t'); // 整集标题,不下钻
});
it('returns full description without truncation or whitespace collapse', async () => {
const longDesc = '第一行描述\n\n第二段,有多个空格 和换行\n\n' + 'x'.repeat(500);
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc },
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc, rights: {} },
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
+115 -8
View File
@@ -1,18 +1,125 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchBloombergFeed } from './utils.js';
cli({
import { ArgumentError, CliError } from '@jackwener/opencli/errors';
const SECTION_URL = 'https://www.bloomberg.com/businessweek';
export function parseBusinessweekLimit(value) {
const limit = value == null || value === '' ? 1 : Number(value);
if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
throw new ArgumentError('bloomberg businessweek --limit must be an integer between 1 and 20', 'Example: opencli bloomberg businessweek --limit 5');
}
return limit;
}
export function normalizeBusinessweekStoryPath(path) {
const raw = typeof path === 'string' ? path.trim() : '';
if (!raw)
return '';
let url;
try {
url = new URL(raw, 'https://www.bloomberg.com');
}
catch {
return '';
}
if (url.protocol !== 'https:' || url.hostname !== 'www.bloomberg.com')
return '';
if (!/^\/(?:news|features)\//.test(url.pathname))
return '';
return `${url.pathname}${url.search}`;
}
export function extractBusinessweekStoriesFromNextData(data) {
const modules = data && data.props && data.props.pageProps
&& data.props.pageProps.initialState && data.props.pageProps.initialState.modulesById;
if (!modules || typeof modules !== 'object')
return null;
const seen = new Set();
const stories = [];
for (const mod of Object.values(modules)) {
const items = mod && Array.isArray(mod.items) ? mod.items : [];
for (const it of items) {
const headline = it && typeof it.headline === 'string' ? it.headline.trim() : '';
const storyPath = normalizeBusinessweekStoryPath(it && typeof it.url === 'string' ? it.url : '');
if (!headline || !storyPath)
continue;
const key = storyPath.split('?')[0];
if (seen.has(key))
continue;
seen.add(key);
const summary = (it.summary && String(it.summary).trim())
|| (it.eyebrow && it.eyebrow.text ? String(it.eyebrow.text).trim() : '');
const img = (it.image && (it.image.baseUrl || it.image.url))
|| (it.lede && (it.lede.baseUrl || it.lede.url)) || '';
stories.push({
title: headline,
summary,
link: `https://www.bloomberg.com${storyPath}`,
mediaLinks: img ? [img] : [],
});
}
}
return stories;
}
// Bloomberg now serves the Businessweek RSS feed empty (feeds.bloomberg.com/businessweek/news.rss
// returns a maintained-but-item-less channel), while the Businessweek section page keeps
// publishing. Like `bloomberg news`, the page ships its data as Next.js __NEXT_DATA__; the
// section's stories live under props.pageProps.initialState.modulesById[*].items[]. So we read
// the section page in the browser and pull the story list out of the embedded SSR state.
export const command = cli({
site: 'bloomberg',
name: 'businessweek',
access: 'read',
description: 'Bloomberg Businessweek top stories (RSS)',
domain: 'feeds.bloomberg.com',
description: 'Bloomberg Businessweek top stories',
domain: 'www.bloomberg.com',
strategy: Strategy.PUBLIC,
browser: false,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
{ name: 'limit', type: 'int', default: 1, help: 'Number of stories to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (kwargs) => {
return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
func: async (page, kwargs) => {
const count = parseBusinessweekLimit(kwargs.limit);
await page.goto(SECTION_URL);
await page.wait({ selector: '#__NEXT_DATA__', timeout: 8 });
const normalizeStoryPathSource = normalizeBusinessweekStoryPath.toString();
const extractStoriesSource = extractBusinessweekStoriesFromNextData.toString();
const loadStories = async () => page.evaluate(`(() => {
${normalizeStoryPathSource}
${extractStoriesSource}
const el = document.getElementById('__NEXT_DATA__');
if (!el) return { ok: false, error: 'NO_NEXT_DATA', title: document.title };
let data;
try { data = JSON.parse(el.textContent); }
catch (err) { return { ok: false, error: 'BAD_NEXT_DATA', message: String(err) }; }
const stories = extractBusinessweekStoriesFromNextData(data);
if (!stories) return { ok: false, error: 'NO_MODULES' };
return { ok: true, stories };
})()`);
let result = await loadStories();
// Next.js sometimes hydrates slowly — retry once before giving up.
if (result && result.ok === false && (result.error === 'NO_NEXT_DATA' || result.error === 'NO_MODULES')) {
await page.wait(4);
result = await loadStories();
}
if (!result || typeof result !== 'object') {
throw new CliError('PARSE_ERROR', 'Bloomberg Businessweek page returned malformed story data', 'Bloomberg may have changed the page structure.');
}
if (result.ok === false) {
throw new CliError('PARSE_ERROR', `Bloomberg Businessweek page did not expose story data (${result.error})`, 'Bloomberg may have changed the page structure.');
}
const stories = Array.isArray(result.stories) ? result.stories : [];
if (!stories.length) {
throw new CliError('NOT_FOUND', 'No Bloomberg Businessweek stories found', 'Bloomberg may have changed the page structure.');
}
return stories.slice(0, count);
},
});
export const __test__ = {
command,
parseBusinessweekLimit,
normalizeBusinessweekStoryPath,
extractBusinessweekStoriesFromNextData,
};
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest';
import { ArgumentError, CliError } from '@jackwener/opencli/errors';
import { __test__ } from './businessweek.js';
const {
command,
extractBusinessweekStoriesFromNextData,
normalizeBusinessweekStoryPath,
parseBusinessweekLimit,
} = __test__;
function makePage(evaluateResults) {
const results = Array.isArray(evaluateResults) ? evaluateResults : [evaluateResults];
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockImplementation(() => Promise.resolve(results.shift())),
};
}
function nextDataWithItems(items) {
return {
props: {
pageProps: {
initialState: {
modulesById: {
lede_story_large: { items },
},
},
},
},
};
}
describe('Bloomberg Businessweek section feed', () => {
it('registers as a public browser read command with stable columns', () => {
expect(command.site).toBe('bloomberg');
expect(command.name).toBe('businessweek');
expect(command.access).toBe('read');
expect(command.browser).toBe(true);
expect(command.strategy).toBe('public');
expect(command.domain).toBe('www.bloomberg.com');
expect(command.columns).toEqual(['title', 'summary', 'link', 'mediaLinks']);
});
it('validates --limit instead of silently clamping invalid values', () => {
expect(parseBusinessweekLimit(undefined)).toBe(1);
expect(parseBusinessweekLimit('')).toBe(1);
expect(parseBusinessweekLimit('20')).toBe(20);
expect(() => parseBusinessweekLimit(0)).toThrow(ArgumentError);
expect(() => parseBusinessweekLimit(21)).toThrow(ArgumentError);
expect(() => parseBusinessweekLimit(1.5)).toThrow(ArgumentError);
expect(() => parseBusinessweekLimit('abc')).toThrow(ArgumentError);
});
it('accepts Bloomberg news and feature story paths from the section page only', () => {
expect(normalizeBusinessweekStoryPath('/news/features/2026-06-08/story?srnd=phx-businessweek'))
.toBe('/news/features/2026-06-08/story?srnd=phx-businessweek');
expect(normalizeBusinessweekStoryPath('/features/2026-ice-detention-center/?srnd=phx-businessweek'))
.toBe('/features/2026-ice-detention-center/?srnd=phx-businessweek');
expect(normalizeBusinessweekStoryPath('https://www.bloomberg.com/features/2026-story/'))
.toBe('/features/2026-story/');
expect(normalizeBusinessweekStoryPath('https://example.com/features/2026-story/')).toBe('');
expect(normalizeBusinessweekStoryPath('/markets')).toBe('');
expect(normalizeBusinessweekStoryPath('javascript:alert(1)')).toBe('');
});
it('extracts current section-page stories, including /features paths, and dedupes by canonical path', () => {
const rows = extractBusinessweekStoriesFromNextData(nextDataWithItems([
{
headline: 'SpaceX IPO Demands Trust',
summary: 'A feature summary',
url: '/news/features/2026-06-08/spacex-ipo?srnd=phx-businessweek',
image: { baseUrl: 'https://assets.bwbx.io/spacex.jpg' },
},
{
headline: 'ICE Warehouse Jails',
eyebrow: { text: 'Feature' },
url: '/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/?srnd=phx-businessweek',
lede: { url: 'https://assets.bwbx.io/ice.jpg' },
},
{
headline: 'Duplicate without query',
url: '/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/',
},
{
headline: 'Non-story module link',
url: '/markets',
},
]));
expect(rows).toEqual([
{
title: 'SpaceX IPO Demands Trust',
summary: 'A feature summary',
link: 'https://www.bloomberg.com/news/features/2026-06-08/spacex-ipo?srnd=phx-businessweek',
mediaLinks: ['https://assets.bwbx.io/spacex.jpg'],
},
{
title: 'ICE Warehouse Jails',
summary: 'Feature',
link: 'https://www.bloomberg.com/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/?srnd=phx-businessweek',
mediaLinks: ['https://assets.bwbx.io/ice.jpg'],
},
]);
});
it('returns rows from the browser section payload and respects validated limit', async () => {
const page = makePage({
ok: true,
stories: [
{ title: 'One', summary: 'A', link: 'https://www.bloomberg.com/news/a', mediaLinks: [] },
{ title: 'Two', summary: 'B', link: 'https://www.bloomberg.com/features/b', mediaLinks: [] },
],
});
await expect(command.func(page, { limit: 1 })).resolves.toEqual([
{ title: 'One', summary: 'A', link: 'https://www.bloomberg.com/news/a', mediaLinks: [] },
]);
expect(page.goto).toHaveBeenCalledWith('https://www.bloomberg.com/businessweek');
expect(page.wait).toHaveBeenCalledWith({ selector: '#__NEXT_DATA__', timeout: 8 });
});
it('retries slow hydration diagnostics before failing', async () => {
const page = makePage([
{ ok: false, error: 'NO_NEXT_DATA', title: 'Businessweek' },
{
ok: true,
stories: [
{ title: 'Hydrated', summary: '', link: 'https://www.bloomberg.com/news/hydrated', mediaLinks: [] },
],
},
]);
await expect(command.func(page, { limit: 5 })).resolves.toEqual([
{ title: 'Hydrated', summary: '', link: 'https://www.bloomberg.com/news/hydrated', mediaLinks: [] },
]);
expect(page.wait).toHaveBeenCalledWith(4);
expect(page.evaluate).toHaveBeenCalledTimes(2);
});
it('fails typed for malformed section payloads', async () => {
const page = makePage({ ok: false, error: 'NO_MODULES' });
await expect(command.func(page, { limit: 5 })).rejects.toBeInstanceOf(CliError);
await expect(command.func(makePage({ ok: true, stories: [] }), { limit: 5 })).rejects.toBeInstanceOf(CliError);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'crypto',
access: 'read',
description: 'Bloomberg Crypto top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (kwargs) => {
return fetchBloombergFeed('crypto', kwargs.limit ?? 1);
},
});
+18
View File
@@ -0,0 +1,18 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'green',
access: 'read',
description: 'Bloomberg Green (climate & energy) top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (kwargs) => {
return fetchBloombergFeed('green', kwargs.limit ?? 1);
},
});
+18
View File
@@ -0,0 +1,18 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'pursuits',
access: 'read',
description: 'Bloomberg Pursuits (lifestyle) top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (kwargs) => {
return fetchBloombergFeed('pursuits', kwargs.limit ?? 1);
},
});
+29 -13
View File
@@ -6,28 +6,44 @@ export const BLOOMBERG_FEEDS = {
industries: 'https://feeds.bloomberg.com/industries/news.rss',
tech: 'https://feeds.bloomberg.com/technology/news.rss',
politics: 'https://feeds.bloomberg.com/politics/news.rss',
businessweek: 'https://feeds.bloomberg.com/businessweek/news.rss',
opinions: 'https://feeds.bloomberg.com/bview/news.rss',
green: 'https://feeds.bloomberg.com/green/news.rss',
crypto: 'https://feeds.bloomberg.com/crypto/news.rss',
pursuits: 'https://feeds.bloomberg.com/pursuits/news.rss',
};
// Note: the Businessweek RSS feed (feeds.bloomberg.com/businessweek/news.rss) is now served
// empty by Bloomberg, so the `businessweek` command reads the section page instead (see
// businessweek.js). Other sections still publish working RSS feeds.
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; opencli)';
// Bloomberg's edge occasionally serves a transient empty/non-OK RSS response under load; a
// couple of quick retries turn those intermittent misses into a successful fetch instead of a
// hard NOT_FOUND. A feed that is genuinely empty still surfaces NOT_FOUND after the retries.
export async function fetchBloombergFeed(name, limit = 1) {
const feedUrl = BLOOMBERG_FEEDS[name];
if (!feedUrl) {
throw new CliError('ARGUMENT', `Unknown Bloomberg feed: ${name}`);
}
const resp = await fetch(feedUrl, {
headers: { 'User-Agent': DEFAULT_USER_AGENT },
});
if (!resp.ok) {
throw new CliError('FETCH_ERROR', `Bloomberg RSS HTTP ${resp.status}`, 'Bloomberg may be temporarily unavailable; try again later.');
let lastError;
for (let attempt = 0; attempt < 3; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, 400 * attempt));
}
const resp = await fetch(feedUrl, {
headers: { 'User-Agent': DEFAULT_USER_AGENT },
});
if (!resp.ok) {
lastError = new CliError('FETCH_ERROR', `Bloomberg RSS HTTP ${resp.status}`, 'Bloomberg may be temporarily unavailable; try again later.');
continue;
}
const xml = await resp.text();
const items = parseBloombergRss(xml);
if (items.length) {
const count = Math.max(1, Math.min(Number(limit) || 1, 20));
return items.slice(0, count);
}
lastError = new CliError('NOT_FOUND', 'Bloomberg RSS feed returned no items', 'Bloomberg may have changed the feed format.');
}
const xml = await resp.text();
const items = parseBloombergRss(xml);
if (!items.length) {
throw new CliError('NOT_FOUND', 'Bloomberg RSS feed returned no items', 'Bloomberg may have changed the feed format.');
}
const count = Math.max(1, Math.min(Number(limit) || 1, 20));
return items.slice(0, count);
throw lastError;
}
export function parseBloombergRss(xml) {
const items = [];
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html>
<head><title>供应链实习生_OpenCLI招聘-BOSS直聘</title></head>
<body>
<section class="job-primary">
<div class="name"><h1 title="供应链实习生">供应链实习生</h1><span class="salary">180-230元/天</span></div>
<p><a class="text-desc text-city" href="/shanghai/">上海</a><span class="text-desc text-experiece">5天/周 6个月</span><span class="text-desc text-degree">本科</span></p>
<div class="tag-all job-tags"><span>五险一金</span><span>餐补</span></div>
</section>
<main class="job-detail">
<section class="job-detail-section">
<ul class="job-keyword-list"><li>供应链/物流类专业</li><li>采购/供应商管理经验</li></ul>
<div class="job-sec-text">负责订单、仓储与采购协同,使用 Excel 制作供应链报表。</div>
</section>
<div class="job-boss-info">
<h2 class="name">张女士<span class="boss-active-time">本周活跃</span></h2>
<div class="boss-info-attr">OpenCLI<em class="vdot">·</em>供应链负责人</div>
</div>
<div class="detail-section-item company-address"><h3>工作地址</h3><div class="location-address">上海示例路 8 号</div><p>点击查看地图</p></div>
</main>
<aside class="job-sider">
<p class="title">公司基本信息</p>
<a href="/gongsi/example.html" title="OpenCLI">OpenCLI</a>
<p>D轮及以上</p>
<p>1000-9999人</p>
<p><a href="/i101001/">互联网</a></p>
<a class="look-all">查看全部职位</a>
</aside>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
// Keep the helper within the adapter so `opencli adapter eject boss` remains runnable.
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
const BOSS_GEEK_JOBS_URL = 'https://www.zhipin.com/web/geek/jobs';
async function hasBossSessionCookie(page) {
const cookies = await page.getCookies({ url: 'https://www.zhipin.com' });
const names = new Set(cookies.map(c => c.name));
return names.has('wt2') || names.has('t');
}
async function verifyBossIdentity(page) {
if (!await hasBossSessionCookie(page)) {
throw new AuthRequiredError('zhipin.com', 'Boss wt2 / t cookies missing');
}
await page.goto(BOSS_GEEK_JOBS_URL);
await page.wait(3);
const probe = await page.evaluate(`
(() => {
const path = location.pathname || '';
if (/\\/web\\/user\\/login|\\/login\\.html/.test(location.href)) {
return { kind: 'auth', detail: 'Boss redirected to login page' };
}
const userType = /\\/web\\/geek\\//.test(path) ? 'geek' : /\\/web\\/(boss|recruit|chat\\/boss)/.test(path) ? 'recruiter' : '';
if (!userType) {
return { kind: 'auth', detail: 'Boss path does not look like authenticated geek/recruiter page: ' + path };
}
return { ok: true, user_type: userType };
})()
`);
if (probe?.kind === 'auth') throw new AuthRequiredError('zhipin.com', probe.detail);
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Boss probe: ${JSON.stringify(probe)}`);
return { user_type: probe.user_type };
}
registerSiteAuthCommands({
site: 'boss',
domain: 'zhipin.com',
loginUrl: 'https://login.zhipin.com/',
columns: ['user_type'],
quickCheck: hasBossSessionCookie,
verify: verifyBossIdentity,
poll: async (page) => {
if (!await hasBossSessionCookie(page)) {
throw new AuthRequiredError('zhipin.com', 'Waiting for Boss wt2 / t cookies');
}
return verifyBossIdentity(page);
},
});
export const __test__ = {
BOSS_GEEK_JOBS_URL,
verifyBossIdentity,
};
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
import { __test__ } from './auth.js';
describe('boss auth identity probe', () => {
it('navigates to the current geek jobs route instead of the retired reload-loop route', async () => {
const page = {
getCookies: vi.fn().mockResolvedValue([{ name: 'wt2' }]),
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ ok: true, user_type: 'geek' }),
};
await expect(__test__.verifyBossIdentity(page)).resolves.toEqual({ user_type: 'geek' });
expect(page.goto).toHaveBeenCalledOnce();
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/jobs');
expect(__test__.BOSS_GEEK_JOBS_URL).not.toContain('job-recommend');
});
});
+164 -41
View File
@@ -1,63 +1,186 @@
/**
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
* BOSS直聘 job detail — extract the fully rendered job page. Read-only.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { readRequiredString, requirePage, navigateTo, verbose } from './utils.js';
const BOSS_DOMAIN = 'www.zhipin.com';
function cleanText(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function domSnapshotToRow(raw, jobId) {
if (!raw || typeof raw !== 'object' || !cleanText(raw.jobName)) return null;
const unique = values => [...new Set(values.map(cleanText).filter(Boolean))];
const nullable = value => cleanText(value) || null;
return {
name: cleanText(raw.jobName),
salary: cleanText(raw.salaryText),
experience: cleanText(raw.experienceText),
degree: cleanText(raw.degreeText),
city: nullable(raw.cityText),
address: nullable(raw.addressText),
description: cleanText(raw.descriptionText),
skills: unique(Array.isArray(raw.skillTexts) ? raw.skillTexts : []).join(', '),
welfare: unique(Array.isArray(raw.welfareTexts) ? raw.welfareTexts : []).join(', '),
boss_name: nullable(raw.recruiterName),
boss_title: nullable(raw.recruiterTitle),
active_time: nullable(raw.recruiterActiveTime),
company: cleanText(raw.companyName),
industry: nullable(raw.industryText),
scale: nullable(raw.scaleText),
stage: nullable(raw.stageText),
url: `https://www.zhipin.com/job_detail/${jobId}.html`,
};
}
export function extractRenderedJob() {
const text = (selector, root = document) => {
const element = root.querySelector(selector);
return element ? (element.innerText || element.textContent || '').trim() : '';
};
const texts = (selector, root = document) => Array.from(root.querySelectorAll(selector))
.map(element => (element.innerText || element.textContent || '').trim())
.filter(Boolean);
const first = selectors => {
for (const selector of selectors) {
const value = text(selector);
if (value) return value;
}
return '';
};
const title = first(['.job-primary .name h1', '.job-primary h1', 'h1[title]']);
const limits = texts('.job-primary .job-limit a, .job-primary .job-limit span, .job-primary p a, .job-primary p span');
const companyCandidates = Array.from(document.querySelectorAll('.job-sider a[href^="/gongsi/"], .detail-op a[href*="/gongsi/"]'))
.map(element => (element.getAttribute('title') || element.innerText || element.textContent || '').trim())
.filter(value => value && !/查看.*职位/.test(value));
const companyFromTitle = document.title.match(/_([^_]+)招聘-BOSS直聘$/)?.[1] || '';
const company = companyCandidates[0] || companyFromTitle;
const sideFacts = texts('.job-sider p, .job-sider a[href^="/i"]')
.filter(value => value !== '公司基本信息' && value !== '查看全部职位');
const bossBlock = document.querySelector('.job-boss-info');
const bossHeading = bossBlock?.querySelector('h2');
const bossName = bossHeading
? Array.from(bossHeading.childNodes)
.filter(node => node.nodeType === 3)
.map(node => node.textContent.trim()).filter(Boolean).join(' ')
: '';
const bossLines = bossBlock
? (bossBlock.innerText || bossBlock.textContent || '').split(/\n|·/).map(value => value.trim()).filter(Boolean)
: [];
const activeTime = bossHeading ? text('span', bossHeading) : bossLines.find(value => /活跃|在线/.test(value)) || '';
const resolvedBossName = bossName || bossLines[0] || '';
const bossAttributes = text('.boss-info-attr', bossBlock).split('·').map(value => value.trim()).filter(Boolean);
const bossTitle = bossAttributes.findLast(value => value !== company) ||
bossLines.findLast(value => value !== resolvedBossName && value !== activeTime && value !== company) || '';
const addressBlock = document.querySelector('.company-address');
const address = text('.location-address', addressBlock) || (addressBlock
? (addressBlock.innerText || addressBlock.textContent || '').split('\n').map(value => value.trim())
.filter(value => value && value !== '工作地址' && value !== '点击查看地图')[0] || ''
: '');
return {
jobName: title,
salaryText: first(['.job-primary .salary', '.job-banner .salary', '.job-primary .name + span']),
// Prefer BOSS's semantic classes; fall back to positional order only if
// they are absent. Positional indexing alone shifts every field when the
// header gains or loses a node.
cityText: text('.job-primary .text-city') || limits[0] || '',
experienceText: text('.job-primary .text-experiece') || limits[1] || '',
degreeText: text('.job-primary .text-degree') || limits[2] || '',
descriptionText: first(['.job-detail .job-detail-section .job-sec-text:not(.fold-text)', '.job-detail .job-sec-text:not(.fold-text)', '.job-sec-text:not(.fold-text)']),
skillTexts: texts('.job-detail .job-keyword-list li, .job-keyword-list li'),
welfareTexts: texts('.job-banner .job-tags span, .job-primary .job-tags span'),
recruiterName: resolvedBossName,
recruiterTitle: bossTitle,
recruiterActiveTime: activeTime,
companyName: company,
industryText: sideFacts.find(value => !/人|融资|上市|轮/.test(value)) || '',
scaleText: sideFacts.find(value => /\d+.*/.test(value)) || '',
// `D轮及以上` / `天使轮` are the common shapes; the industry filter above
// already excludes 轮, so omitting it here left stage permanently empty.
stageText: sideFacts.find(value => /|融资|上市/.test(value)) || '',
addressText: address,
};
}
async function readRenderedPage(page, jobId) {
const raw = await page.evaluate(extractRenderedJob);
return domSnapshotToRow(raw, jobId);
}
async function captureJobDetail(page, jobId) {
const url = `https://www.zhipin.com/job_detail/${encodeURIComponent(jobId)}.html`;
await navigateTo(page, 'https://www.zhipin.com/web/geek/jobs', 2);
for (let navigationAttempt = 0; navigationAttempt < 2; navigationAttempt++) {
await navigateTo(page, url, 5);
for (let attempt = 0; attempt < 5; attempt++) {
try {
const domRow = await readRenderedPage(page, jobId);
if (domRow?.name && domRow?.description && domRow?.company) return domRow;
} catch { /* wait for a complete render */ }
if (attempt < 4) await page.wait(1);
}
}
// The retry loop above swallows every read error, so without this check a
// session that got bounced to the login wall reports "incomplete posting".
// The API path this command replaced classified that as AuthRequiredError
// via assertOk; keep that signal rather than losing it to the UI rewrite.
if (await isLoginWall(page)) {
throw new AuthRequiredError(BOSS_DOMAIN, 'BOSS redirected the job detail page to the login flow');
}
throw new CommandExecutionError('BOSS detail page did not expose a complete job posting');
}
async function isLoginWall(page) {
try {
return await page.evaluate(`
(() => {
const href = window.location.href || '';
if (/\\/login|\\/user\\/login|passport/.test(href)) return true;
return !!document.querySelector('.sign-form, .login-card, [class*="login-register"]');
})()
`) === true;
} catch {
return false;
}
}
cli({
site: 'boss',
name: 'detail',
access: 'read',
description: 'BOSS直聘查看职位详情',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
strategy: Strategy.UI,
navigateBefore: false,
browser: true,
defaultWindowMode: 'background',
siteSession: 'persistent',
args: [
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (securityId field)' },
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (security_id field)' },
],
columns: [
'name', 'salary', 'experience', 'degree', 'city', 'district',
'name', 'salary', 'experience', 'degree',
'city', 'address',
'description', 'skills', 'welfare',
'boss_name', 'boss_title', 'active_time',
'company', 'industry', 'scale', 'stage',
'address', 'url',
'company', 'industry', 'scale', 'stage', 'url',
],
func: async (page, kwargs) => {
requirePage(page);
const securityId = kwargs['security-id'];
verbose('Fetching job detail...');
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
await navigateTo(page, 'https://www.zhipin.com/web/geek/job');
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
const data = await bossFetch(page, targetUrl);
const zpData = data.zpData || {};
const jobInfo = zpData.jobInfo || {};
const bossInfo = zpData.bossInfo || {};
const brandComInfo = zpData.brandComInfo || {};
if (!jobInfo.jobName) {
throw new Error('该职位信息不存在或已下架');
const jobId = readRequiredString(kwargs['security-id'], 'security-id');
if (!/^[A-Za-z0-9_-]+$/.test(jobId)) {
throw new ArgumentError('boss security-id contains unsupported characters', 'Pass the security_id returned by `opencli boss search`');
}
return [{
name: jobInfo.jobName || '',
salary: jobInfo.salaryDesc || '',
experience: jobInfo.experienceName || '',
degree: jobInfo.degreeName || '',
city: jobInfo.locationName || '',
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
description: jobInfo.postDescription || '',
skills: (jobInfo.showSkills || []).join(', '),
welfare: (brandComInfo.labels || []).join(', '),
boss_name: bossInfo.name || '',
boss_title: bossInfo.title || '',
active_time: bossInfo.activeTimeDesc || '',
company: brandComInfo.brandName || bossInfo.brandName || '',
industry: brandComInfo.industryName || '',
scale: brandComInfo.scaleName || '',
stage: brandComInfo.stageName || '',
address: jobInfo.address || '',
url: jobInfo.encryptId
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
: '',
}];
verbose('Fetching job detail from the rendered BOSS page...');
return [await captureJobDetail(page, jobId)];
},
});
export const __test__ = { cleanText, domSnapshotToRow };
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { JSDOM } from 'jsdom';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { __test__, extractRenderedJob } from './detail.js';
import './detail.js';
const fixture = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '__fixtures__/detail.html'), 'utf8');
const originalDocument = globalThis.document;
afterEach(() => {
globalThis.document = originalDocument;
});
describe('boss detail', () => {
const command = getRegistry().get('boss/detail');
it('is registered as a read-only rendered-page command', () => {
expect(command).toMatchObject({ access: 'read', strategy: Strategy.UI });
});
it('normalizes the rendered detail snapshot into the documented row', () => {
const row = __test__.domSnapshotToRow({
jobName: ' 数据分析实习生 ', salaryText: '150-200/天',
cityText: '上海', experienceText: '在校/应届', degreeText: '本科',
descriptionText: '负责数据分析', skillTexts: ['SQL', 'SQL'], welfareTexts: ['餐补', '餐补'],
recruiterName: '张三', recruiterTitle: '技术负责人', recruiterActiveTime: '刚刚活跃', companyName: 'OpenCLI',
}, 'job-id');
// Flat scalar columns only: the table/plain/csv/markdown renderers coerce
// each cell with String(v) (src/output.ts), so a nested object column would
// print as [object Object] in the default output.
expect(Object.keys(row)).toHaveLength(17);
expect(row).toMatchObject({
name: '数据分析实习生', city: '上海', experience: '在校/应届', degree: '本科',
skills: 'SQL', welfare: '餐补',
boss_name: '张三', boss_title: '技术负责人', active_time: '刚刚活跃',
company: 'OpenCLI', url: 'https://www.zhipin.com/job_detail/job-id.html',
});
for (const value of Object.values(row)) {
expect(typeof value === 'object' && value !== null).toBe(false);
}
});
it('extracts current BOSS detail selectors from a sanitized live-page fixture', () => {
const dom = new JSDOM(fixture, { url: 'https://www.zhipin.com/job_detail/job-id.html' });
globalThis.document = dom.window.document;
expect(extractRenderedJob()).toMatchObject({
jobName: '供应链实习生', salaryText: '180-230元/天', cityText: '上海',
experienceText: '5天/周 6个月', degreeText: '本科', companyName: 'OpenCLI',
recruiterName: '张女士', recruiterActiveTime: '本周活跃', addressText: '上海示例路 8 号',
skillTexts: ['供应链/物流类专业', '采购/供应商管理经验'],
welfareTexts: ['五险一金', '餐补'],
});
});
it('reads the rendered current job page instead of the retired detail API', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({
jobName: '数据分析实习生', salaryText: '150-200/天', cityText: '上海', experienceText: '在校/应届', degreeText: '本科',
descriptionText: '负责数据分析', skillTexts: ['SQL'], welfareTexts: ['餐补'], companyName: 'OpenCLI',
}),
};
const rows = await command.func(page, { 'security-id': 'job-id' });
expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.zhipin.com/web/geek/jobs');
expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.zhipin.com/job_detail/job-id.html');
expect(rows[0]).toMatchObject({ name: '数据分析实习生', company: 'OpenCLI' });
});
it('rejects malformed detail ids before navigation', async () => {
await expect(command.func({ goto: vi.fn(), wait: vi.fn(), evaluate: vi.fn() }, {
'security-id': '../write-action',
})).rejects.toThrow(ArgumentError);
});
});
+50 -16
View File
@@ -2,8 +2,8 @@
* BOSS直聘 job search — browser cookie API.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { assertOk, readPositiveInteger, requirePage, navigateTo, verbose } from './utils.js';
/** City name → BOSS Zhipin city code mapping */
const CITY_CODES = {
'全国': '100010000', '北京': '101010100', '上海': '101020100',
@@ -25,8 +25,8 @@ const CITY_CODES = {
const EXP_MAP = {
'不限': '0',
'在校/应届': '108',
'在校生': '108', '在校': '108',
'应届生': '102', '应届': '102',
'在校生(实习)': '108', '在校生': '108', '在校': '108',
'应届生(校招)': '102', '应届生': '102', '应届': '102',
'经验不限': '101',
'1年以内': '103',
'1-3年': '104',
@@ -62,7 +62,7 @@ function resolveCity(input) {
if (name.includes(input))
return code;
}
return '101010100';
throw new ArgumentError(`Invalid BOSS city: ${input}`, 'Use a supported city name or a numeric BOSS city code');
}
function resolveMap(input, map) {
if (!input)
@@ -91,15 +91,49 @@ function formatBossOnline(value) {
return 'N';
return '';
}
async function captureJobList(page, url) {
if (typeof page.startNetworkCapture !== 'function' ||
typeof page.readNetworkCapture !== 'function' ||
!await page.startNetworkCapture('joblist.json')) {
throw new CommandExecutionError('BOSS search requires CDP network capture');
}
await page.readNetworkCapture();
for (let attempt = 0; attempt < 2; attempt++) {
const separator = url.includes('?') ? '&' : '?';
await navigateTo(page, `${url}${separator}_opencli=${Date.now()}`, 5);
await page.wait(1);
const captures = await page.readNetworkCapture();
for (const entry of Array.isArray(captures) ? captures : []) {
if (!String(entry?.url || '').includes('joblist.json') ||
Number(entry?.responseStatus || 0) !== 200 ||
typeof entry?.responsePreview !== 'string') {
continue;
}
let payload;
try {
payload = JSON.parse(entry.responsePreview);
} catch {
continue;
}
if (payload && typeof payload === 'object' && 'code' in payload && payload.code !== 0) {
assertOk(payload, 'BOSS search failed');
}
if (Array.isArray(payload?.zpData?.jobList)) return payload;
}
}
throw new CommandExecutionError('BOSS search page did not expose its job-list response');
}
cli({
site: 'boss',
name: 'search',
access: 'read',
description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
strategy: Strategy.INTERCEPT,
navigateBefore: false,
browser: true,
defaultWindowMode: 'background',
siteSession: 'persistent',
args: [
{ name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
{ name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
@@ -117,15 +151,13 @@ cli({
const query = String(kwargs.query ?? '').trim();
const cityCode = resolveCity(kwargs.city);
verbose('Navigating to set referrer context...');
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
await new Promise(r => setTimeout(r, 1000));
const expVal = resolveMap(kwargs.experience, EXP_MAP);
const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
const jobTypeVal = resolveJobType(kwargs.jobType);
const limit = kwargs.limit || 15;
let currentPage = kwargs.page || 1;
const limit = readPositiveInteger(kwargs.limit, 'limit', 15, 100);
let currentPage = readPositiveInteger(kwargs.page, 'page', 1);
let allJobs = [];
const seenIds = new Set();
while (allJobs.length < limit) {
@@ -133,11 +165,9 @@ cli({
await new Promise(r => setTimeout(r, 1000 + Math.random() * 2000));
}
const qs = new URLSearchParams({
scene: '1',
query,
city: cityCode,
page: String(currentPage),
pageSize: '15',
});
if (expVal)
qs.set('experience', expVal);
@@ -149,9 +179,9 @@ cli({
qs.set('industry', industryVal);
if (jobTypeVal)
qs.set('jobType', jobTypeVal);
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
const data = await bossFetch(page, targetUrl);
const targetUrl = `https://www.zhipin.com/web/geek/jobs?${qs.toString()}`;
verbose(`Capturing page ${currentPage}... (current jobs: ${allJobs.length})`);
const data = await captureJobList(page, targetUrl);
const zpData = data.zpData || {};
const batch = zpData.jobList || [];
if (batch.length === 0)
@@ -171,7 +201,7 @@ cli({
skills: (j.skills || []).join(','),
boss: j.bossName + ' · ' + j.bossTitle,
bossOnline: formatBossOnline(j.bossOnline),
security_id: j.securityId || '',
security_id: j.encryptJobId || '',
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
});
addedInBatch++;
@@ -186,11 +216,15 @@ cli({
break;
currentPage++;
}
if (allJobs.length === 0) {
throw new EmptyResultError('boss search', query ? `No BOSS jobs found for "${query}"` : 'BOSS returned no recommended jobs');
}
return allJobs;
},
});
export const __test__ = {
EXP_MAP,
resolveCity,
resolveMap,
resolveJobType,
formatBossOnline,
+41 -9
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { __test__ } from './search.js';
import './search.js';
@@ -8,13 +8,24 @@ function createPageMock(response) {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(response),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
url: 'https://www.zhipin.com/wapi/zpgeek/search/joblist.json',
responseStatus: 200,
responsePreview: JSON.stringify(response),
}]),
};
}
describe('boss search', () => {
const command = getRegistry().get('boss/search');
it('is registered as a read-only intercepted listing command', () => {
expect(command).toMatchObject({ access: 'read', strategy: Strategy.INTERCEPT });
});
it('keeps legacy 在校/应届 experience input compatible', () => {
expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
@@ -24,6 +35,10 @@ describe('boss search', () => {
expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
});
it('fails fast on unknown city names instead of silently searching Beijing', () => {
expect(() => __test__.resolveCity('不存在的城市')).toThrow(ArgumentError);
});
it('accepts supported jobType labels and raw codes', () => {
expect(__test__.resolveJobType('全职')).toBe('1901');
expect(__test__.resolveJobType('实习')).toBe('1902');
@@ -31,7 +46,7 @@ describe('boss search', () => {
expect(__test__.resolveJobType('1902')).toBe('1902');
});
it('keeps empty query empty and sends jobType filter to the API', async () => {
it('captures the current jobs page response instead of calling the retired API directly', async () => {
const page = createPageMock({
code: 0,
zpData: {
@@ -65,14 +80,31 @@ describe('boss search', () => {
page: 1,
});
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
const fetchScript = page.evaluate.mock.calls.at(-1)[0];
expect(fetchScript).toContain('query=');
expect(fetchScript).not.toContain('query=undefined');
expect(fetchScript).toContain('jobType=1902');
expect(page.startNetworkCapture).toHaveBeenCalledWith('joblist.json');
expect(page.goto.mock.calls[0][0]).toContain('https://www.zhipin.com/web/geek/jobs?query=&city=101010100');
expect(page.goto.mock.calls[0][0]).toContain('jobType=1902');
expect(rows[0]).toMatchObject({
name: '前端开发实习生',
bossOnline: 'N',
security_id: 'abc',
});
});
it('validates page and limit instead of silently replacing invalid values', async () => {
const page = createPageMock({ code: 0, zpData: { hasMore: false, jobList: [] } });
await expect(command.func(page, { city: '北京', page: 0, limit: 1 })).rejects.toThrow(ArgumentError);
await expect(command.func(page, { city: '北京', page: 1, limit: 101 })).rejects.toThrow(ArgumentError);
});
it('returns the stable empty-result exit category when BOSS has no matching jobs', async () => {
const page = createPageMock({ code: 0, zpData: { hasMore: false, jobList: [] } });
await expect(command.func(page, { query: '不存在', city: '北京', page: 1, limit: 1 }))
.rejects.toThrow(EmptyResultError);
});
it('preserves typed auth failures from the captured BOSS response', async () => {
const page = createPageMock({ code: 7, message: '请登录' });
await expect(command.func(page, { query: '供应链', city: '北京', page: 1, limit: 1 }))
.rejects.toThrow(AuthRequiredError);
});
});
+10
View File
@@ -5,6 +5,8 @@ const BOSS_DOMAIN = 'www.zhipin.com';
const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`;
const COOKIE_EXPIRED_CODES = new Set([7, 37]);
const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。';
const AMBIGUOUS_AUTH_CODE = 37;
const ENVIRONMENT_REJECTED_MARKERS = ['环境存在异常', '环境异常', 'abnormal environment'];
const RECRUITER_ONLY_MSG = '该命令仅支持招聘端(BOSS 端)账号,请使用招聘者账号登录后重试。';
const DEFAULT_TIMEOUT = 15_000;
// ── Core helpers ────────────────────────────────────────────────────────────
@@ -56,6 +58,13 @@ export function checkAuth(data) {
throw new AuthRequiredError(BOSS_DOMAIN, COOKIE_EXPIRED_MSG);
}
}
function checkEnvironment(data) {
const message = String(data.message || '').toLowerCase();
if (data.code === AMBIGUOUS_AUTH_CODE &&
ENVIRONMENT_REJECTED_MARKERS.some((marker) => message.includes(marker.toLowerCase()))) {
throw new CommandExecutionError(`Boss rejected the current browser environment: ${data.message || 'Unknown error'} (code=${data.code})`, '重新登录通常无法解决此问题。请保留当前页面,稍后重试,并在问题持续时上报完整错误信息。');
}
}
/**
* Map BOSS code=24 ("请切换身份后再试") to a typed AuthRequiredError.
* Recruiter-only commands (recommend, joblist, stats, resume, mark,
@@ -80,6 +89,7 @@ export function assertOk(data, errorPrefix) {
}
if (data.code === 0)
return;
checkEnvironment(data);
checkAuth(data);
checkRecruiterSide(data);
const prefix = errorPrefix ? `${errorPrefix}: ` : '';
+24
View File
@@ -12,6 +12,30 @@ describe('assertOk', () => {
expect(() => assertOk({ code: 37, message: 'expired' })).toThrow(AuthRequiredError);
});
it('does not misclassify code 37 environment rejection as expired login', () => {
let error;
try {
assertOk({ code: 37, message: '您的环境存在异常.' }, 'Boss search failed');
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(CommandExecutionError);
expect(error).not.toBeInstanceOf(AuthRequiredError);
expect(error.code).toBe('COMMAND_EXEC');
expect(error.message).toContain('环境存在异常');
expect(error.message).toContain('code=37');
expect(error.hint).toContain('重新登录通常无法解决');
});
it('keeps code 37 login-expiry responses as auth required', () => {
expect(() => assertOk({ code: 37, message: '登录状态已失效' })).toThrow(AuthRequiredError);
});
it('keeps code 7 responses as auth required', () => {
expect(() => assertOk({ code: 7, message: '请重新登录' })).toThrow(AuthRequiredError);
});
it('maps code 24 (identity mismatch) to AuthRequiredError with recruiter-only hint', () => {
try {
assertOk({ code: 24, message: '请切换身份后再试' });
+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));
}
},
});
+77 -6
View File
@@ -1,19 +1,40 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import {
CHATGPT_DOMAIN,
CHATGPT_URL,
currentChatGPTUrl,
ensureChatGPTComposer,
ensureOnChatGPT,
getBubbleCount,
getChatGPTResponsePairCounts,
getVisibleMessages,
normalizeBooleanFlag,
openChatGPTConversation,
requireNonEmptyPrompt,
requirePositiveInt,
parseChatGPTConversationId,
sendChatGPTMessage,
selectChatGPTTool,
isGenerating,
startNewChat,
navigateToProject,
waitForChatGPTResponse,
} from './utils.js';
async function waitForConversationUrl(page, timeoutSeconds = 30) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutSeconds * 1000) {
const conversationUrl = await currentChatGPTUrl(page);
try {
const conversationId = parseChatGPTConversationId(conversationUrl);
return { conversationId, conversationUrl };
} catch {
await page.wait(1);
}
}
throw new CommandExecutionError('ChatGPT did not create a conversation URL after sending the message.');
}
export const askCommand = cli({
site: 'chatgpt',
name: 'ask',
@@ -28,8 +49,13 @@ export const askCommand = cli({
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
{ name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/<id> URL' },
{ name: 'project', valueRequired: true, help: 'Start a new chat inside a ChatGPT project ID or /g/g-p-<id> URL' },
{ name: 'wait', type: 'boolean', default: true, help: 'Wait for the assistant response after sending' },
{ name: 'deep-research', type: 'boolean', default: false, help: 'Enable ChatGPT 深度研究 (Deep Research)' },
{ name: 'web-search', type: 'boolean', default: false, help: 'Enable ChatGPT 网页搜索 (Web Search)' },
],
columns: ['response'],
columns: ['conversationId', 'conversationUrl', 'tool', 'response'],
func: async (page, kwargs) => {
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt ask');
const timeout = requirePositiveInt(
@@ -37,8 +63,34 @@ export const askCommand = cli({
'chatgpt ask --timeout',
'Example: opencli chatgpt ask "hello" --timeout 120',
);
const useDeepResearch = normalizeBooleanFlag(kwargs['deep-research'], false);
const useWebSearch = normalizeBooleanFlag(kwargs['web-search'], false);
const shouldWait = normalizeBooleanFlag(kwargs.wait, true);
if (useDeepResearch && useWebSearch) {
throw new ArgumentError(
'chatgpt ask cannot enable both --deep-research and --web-search',
'Choose one ChatGPT composer tool for this message.',
);
}
if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) {
throw new ArgumentError(
'chatgpt ask cannot use --new and --conversation together',
'Choose either a new chat or an existing conversation.',
);
}
if (kwargs.project && kwargs.conversation) {
throw new ArgumentError(
'chatgpt ask cannot use --project and --conversation together',
'Choose either a project new chat or an existing conversation.',
);
}
const tool = useDeepResearch ? 'deep-research' : (useWebSearch ? 'web-search' : null);
if (normalizeBooleanFlag(kwargs.new)) {
if (kwargs.conversation) {
await openChatGPTConversation(page, kwargs.conversation);
} else if (kwargs.project) {
await navigateToProject(page, kwargs.project);
} else if (normalizeBooleanFlag(kwargs.new)) {
await startNewChat(page);
} else {
await ensureOnChatGPT(page);
@@ -46,13 +98,32 @@ export const askCommand = cli({
// startNewChat / ensureOnChatGPT now wait for the composer selector
// after navigating, so the previous standalone 2 s settle is redundant.
await ensureChatGPTComposer(page, 'ChatGPT ask requires a logged-in ChatGPT session with a visible composer.');
const selectedTool = tool ? await selectChatGPTTool(page, tool) : null;
const baseline = await getBubbleCount(page);
const settleStart = Date.now();
while (await isGenerating(page)) {
if (Date.now() - settleStart > timeout * 1000) {
throw new CommandExecutionError('ChatGPT conversation is still generating; wait for it to finish before sending another message.');
}
await page.sleep(3);
}
const baselineMessages = await getVisibleMessages(page);
const baseline = baselineMessages.length;
const baselinePairCounts = getChatGPTResponsePairCounts(baselineMessages, prompt);
const sent = await sendChatGPTMessage(page, prompt);
if (!sent) {
throw new CommandExecutionError('Failed to send message to ChatGPT', `Open ${CHATGPT_URL} and verify the composer is ready.`);
}
return [{ response: await waitForChatGPTResponse(page, baseline, prompt, timeout) }];
const { conversationId, conversationUrl } = await waitForConversationUrl(page);
if (!shouldWait) {
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response: '' }];
}
const response = await waitForChatGPTResponse(page, baseline, prompt, timeout, {
baselinePairCounts,
conversationUrl,
});
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response }];
},
});
+61
View File
@@ -0,0 +1,61 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
async function hasChatgptSessionCookie(page) {
const cookies = await page.getCookies({ url: 'https://chatgpt.com' });
// Prefix match: NextAuth chunks large session tokens into
// `__Secure-next-auth.session-token.0`, `.1`, … so an exact-name check
// false-negatives on chunked sessions (the `auth status`/`refresh`/login
// fast paths that consume this). See issue #2087.
return cookies.some(c => c.name.startsWith('__Secure-next-auth.session-token') && c.value);
}
async function verifyChatgptIdentity(page) {
// The `/api/auth/session` probe below is authoritative — do NOT pre-gate on the
// legacy `__Secure-next-auth.session-token` cookie. Current ChatGPT web
// sessions authenticate without that cookie, so gating on it produced false
// AUTH_REQUIRED for logged-in users. See issue #2087.
await page.goto('https://chatgpt.com/');
await page.wait(2);
const result = await page.evaluate(`(async () => {
try {
const res = await fetch('/api/auth/session', { credentials: 'include' });
if (res.status === 401 || res.status === 403) {
return { kind: 'auth', detail: 'ChatGPT /api/auth/session HTTP ' + res.status };
}
if (!res.ok) return { kind: 'http', httpStatus: res.status };
const d = await res.json();
const user = d && d.user;
if (!user || !user.id) {
return { kind: 'auth', detail: 'ChatGPT /api/auth/session has no user — anonymous' };
}
return { ok: true, user_id: String(user.id), name: String(user.name || '') };
} catch (e) {
return { kind: 'exception', detail: String(e && e.message || e) };
}
})()`);
if (result?.kind === 'auth') throw new AuthRequiredError('chatgpt.com', result.detail);
if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/auth/session`);
if (result?.kind === 'exception') throw new CommandExecutionError(`ChatGPT whoami failed: ${result.detail}`);
if (!result?.ok) throw new CommandExecutionError(`Unexpected ChatGPT probe: ${JSON.stringify(result)}`);
return { user_id: result.user_id, name: result.name };
}
registerSiteAuthCommands({
site: 'chatgpt',
domain: 'chatgpt.com',
loginUrl: 'https://auth.openai.com/log-in',
columns: ['user_id', 'name'],
quickCheck: hasChatgptSessionCookie,
verify: verifyChatgptIdentity,
// Poll keeps the cheap, non-navigating cookie gate: during `login` the browser
// sits on the OAuth page, and verify (which navigates to chatgpt.com) must not
// run every ~2s or it would yank the user off the login form. #2087 is about
// `whoami` (the verify path above); login-completion detection is unchanged.
poll: async (page) => {
if (!await hasChatgptSessionCookie(page)) {
throw new AuthRequiredError('chatgpt.com', 'Waiting for ChatGPT session cookie');
}
return verifyChatgptIdentity(page);
},
});
+398 -1
View File
@@ -1,13 +1,49 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import './ask.js';
import './send.js';
import './read.js';
import './history.js';
import './detail.js';
import './deep-research-result.js';
import './new.js';
import './status.js';
import './image.js';
import './model.js';
import './project-list.js';
import './project-file-add.js';
const tempDirs = [];
afterEach(() => {
vi.restoreAllMocks();
while (tempDirs.length) {
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
function createProjectUploadPageMock() {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
setFileInput: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn((script) => {
const s = String(script);
if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) {
return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } });
}
if (s.includes('expectedFileNames')) return Promise.resolve({ ok: true });
if (s.includes('Add files')) return Promise.resolve(true);
if (s.includes('role="dialog"')) return Promise.resolve(true);
return Promise.resolve(undefined);
}),
};
}
describe('chatgpt browser command registration', () => {
it('registers the baseline web chat commands with persistent site sessions', () => {
@@ -17,9 +53,13 @@ describe('chatgpt browser command registration', () => {
read: 'read',
history: 'read',
detail: 'read',
'deep-research-result': 'read',
new: 'read',
status: 'read',
image: 'write',
model: 'write',
'project-list': 'read',
'project-file-add': 'write',
};
for (const [name, access] of Object.entries(expectedAccess)) {
@@ -40,6 +80,363 @@ describe('chatgpt browser command registration', () => {
expect(ask.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
expect.objectContaining({ name: 'new', type: 'boolean', default: false }),
expect.objectContaining({ name: 'conversation', valueRequired: true }),
expect.objectContaining({ name: 'project', valueRequired: true }),
expect.objectContaining({ name: 'wait', type: 'boolean', default: true }),
expect.objectContaining({ name: 'deep-research', type: 'boolean', default: false }),
expect.objectContaining({ name: 'web-search', type: 'boolean', default: false }),
]));
expect(ask.columns).toEqual(['conversationId', 'conversationUrl', 'tool', 'response']);
});
it('registers send conversation and project routing options', () => {
const send = getRegistry().get('chatgpt/send');
expect(send.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'new', type: 'boolean', default: false }),
expect.objectContaining({ name: 'conversation', valueRequired: true }),
expect.objectContaining({ name: 'project', valueRequired: true }),
]));
});
it('rejects using project and conversation routing together', async () => {
const ask = getRegistry().get('chatgpt/ask');
const send = getRegistry().get('chatgpt/send');
const page = {
goto: () => {
throw new Error('should not navigate');
},
};
await expect(ask.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
await expect(send.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
});
it('registers detail wait options and generation state columns', () => {
const detail = getRegistry().get('chatgpt/detail');
expect(detail.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'wait', type: 'boolean', default: false }),
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
expect.objectContaining({ name: 'stable', type: 'int', default: 6 }),
]));
expect(detail.columns).toEqual(['Index', 'Role', 'Text', 'Generating', 'StableSeconds']);
});
it('registers deep research result command with wait options', () => {
const command = getRegistry().get('chatgpt/deep-research-result');
expect(command.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'id', positional: true, required: true }),
expect.objectContaining({ name: 'wait', type: 'boolean', default: false }),
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
expect.objectContaining({ name: 'stable', type: 'int', default: 6 }),
]));
expect(command.columns).toEqual([
'conversationId',
'status',
'report',
'sources',
'progress',
'asyncTaskConversationId',
'widgetSessionId',
'asyncStatus',
'venusMessageType',
'venusStatus',
'waitingForUserUntil',
'planTitle',
'planId',
'url',
'method',
'diagnostics',
]);
});
it('does not return a success row when no completed deep research report exists', async () => {
const command = getRegistry().get('chatgpt/deep-research-result');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn((script) => {
const s = String(script);
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
return Promise.resolve({
ok: true,
status: 200,
contentType: 'application/json',
text: JSON.stringify({ mapping: {} }),
});
}
if (s.includes("document.querySelectorAll('iframe')")) {
return Promise.resolve({
url: 'https://chatgpt.com/c/requested123',
title: 'ChatGPT',
iframes: [],
deepResearchIframe: null,
});
}
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
return Promise.resolve({
url: 'https://chatgpt.com/c/requested123',
title: 'ChatGPT',
hasComposer: true,
isLoggedIn: true,
hasLoginGate: false,
});
}
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
return Promise.resolve(undefined);
}),
};
await expect(command.func(page, { id: 'requested123' }))
.rejects.toBeInstanceOf(EmptyResultError);
});
it('returns structured deep research progress rows without a completed report', async () => {
const command = getRegistry().get('chatgpt/deep-research-result');
const payload = {
conversation_id: 'requested123',
mapping: {
progress_node: {
message: {
metadata: {
chatgpt_sdk: {
widget_state: JSON.stringify({
status: 'waiting_for_user_response_on_plan',
waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z',
plan: {
plan_id: 'plan-demo',
title: 'Research plan',
},
}),
response_metadata: {
async_task_conversation_id: 'async-conversation-123',
'openai/widgetSessionId': 'widget-session-123',
'openai/asyncStatus': 7,
venus_message_type: 'initial_loading_message',
},
},
},
},
},
},
};
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn((script) => {
const s = String(script);
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
return Promise.resolve({
ok: true,
status: 200,
contentType: 'application/json',
text: JSON.stringify(payload),
});
}
if (s.includes("document.querySelectorAll('iframe')")) {
return Promise.resolve({
url: 'https://chatgpt.com/c/requested123',
title: 'ChatGPT',
iframes: [],
deepResearchIframe: null,
});
}
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
return Promise.resolve({
url: 'https://chatgpt.com/c/requested123',
title: 'ChatGPT',
hasComposer: true,
isLoggedIn: true,
hasLoginGate: false,
});
}
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
return Promise.resolve(undefined);
}),
};
await expect(command.func(page, { id: 'requested123' })).resolves.toEqual([
expect.objectContaining({
conversationId: 'requested123',
status: 'waiting_for_user',
report: '',
sources: [],
asyncTaskConversationId: 'async-conversation-123',
widgetSessionId: 'widget-session-123',
asyncStatus: 7,
venusMessageType: 'initial_loading_message',
venusStatus: 'waiting_for_user_response_on_plan',
waitingForUserUntil: '2026-07-02T02:29:48.298274Z',
planTitle: 'Research plan',
planId: 'plan-demo',
method: 'conversation-widget-progress',
}),
]);
});
it('typed-fails malformed deep research source rows instead of falling back to empty success', async () => {
const command = getRegistry().get('chatgpt/deep-research-result');
const report = `# Executive Summary\n\n${'Completed Deep Research report paragraph with enough detail to pass extraction heuristics. '.repeat(12)}\n\n## Sources`;
const payload = {
conversation_id: 'requested123',
mapping: {
report_node: {
message: {
metadata: {
chatgpt_sdk: {
widget_state: JSON.stringify({
status: 'completed',
report_message: {
id: 'report-msg',
content: { parts: [report] },
metadata: {
search_result_groups: [
{ entries: [{ title: 'Source without URL' }] },
],
},
},
}),
},
},
},
},
},
};
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn((script) => {
const s = String(script);
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
return Promise.resolve({
ok: true,
status: 200,
contentType: 'application/json',
text: JSON.stringify(payload),
});
}
if (s.includes("document.querySelectorAll('iframe')")) {
return Promise.resolve({
url: 'https://chatgpt.com/c/requested123',
title: 'ChatGPT',
iframes: [],
deepResearchIframe: null,
});
}
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
return Promise.resolve({
url: 'https://chatgpt.com/c/requested123',
title: 'ChatGPT',
hasComposer: true,
isLoggedIn: true,
hasLoginGate: false,
});
}
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
return Promise.resolve(undefined);
}),
};
await expect(command.func(page, { id: 'requested123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('registers project routing on chat-starting commands', () => {
for (const name of ['new', 'image', 'model']) {
const cmd = getRegistry().get(`chatgpt/${name}`);
expect(cmd.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'project', valueRequired: true }),
]));
}
});
it('starts a new chat inside a project when new receives project routing', async () => {
const cmd = getRegistry().get('chatgpt/new');
const page = createProjectUploadPageMock();
await expect(cmd.func(page, { project: '12345678' }))
.resolves.toEqual([{ Status: 'New chat started' }]);
expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/g/g-p-12345678', { settleMs: 2000 });
});
it('registers chatgpt model with web model choices', () => {
const model = getRegistry().get('chatgpt/model');
expect(model.args).toEqual(expect.arrayContaining([
expect.objectContaining({
name: 'model',
positional: true,
required: true,
choices: expect.arrayContaining(['fast', 'speed', 'instant', 'balanced', 'balance', 'advanced', 'high', 'thinking', 'very-high', 'ultra', 'xhigh', 'x-high', 'pro', 'professional']),
}),
expect.objectContaining({ name: 'project', valueRequired: true }),
]));
expect(model.columns).toEqual(['Status', 'Model']);
});
it('rejects off-domain conversation URLs before ask/send can navigate', async () => {
const ask = getRegistry().get('chatgpt/ask');
const send = getRegistry().get('chatgpt/send');
const page = {
goto: () => {
throw new Error('should not navigate');
},
};
await expect(ask.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
await expect(send.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
});
it('does not expose command-level system proxy mutation for project-file-add', () => {
const cmd = getRegistry().get('chatgpt/project-file-add');
expect(cmd.args.map(arg => arg.name)).toEqual(['file', 'id']);
});
it('rejects empty project-file-add file input', async () => {
const cmd = getRegistry().get('chatgpt/project-file-add');
await expect(cmd.func(createProjectUploadPageMock(), { file: ' , ', id: '12345678' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
});
it('maps successful project-file-add uploads to table rows', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'report.pdf');
fs.writeFileSync(filePath, 'fake-pdf');
const cmd = getRegistry().get('chatgpt/project-file-add');
await expect(cmd.func(createProjectUploadPageMock(), { file: filePath, id: '12345678' }))
.resolves.toEqual([
{
Status: '📄 uploaded to project knowledge',
File: filePath,
},
]);
});
it('maps project-file-add local file validation failures to argument errors', async () => {
const cmd = getRegistry().get('chatgpt/project-file-add');
await expect(cmd.func(createProjectUploadPageMock(), { file: '/no/such/report.pdf', id: '12345678' }))
.rejects.toMatchObject({
code: 'ARGUMENT',
message: expect.stringContaining('File not found'),
});
});
});
+121
View File
@@ -0,0 +1,121 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import {
CHATGPT_DOMAIN,
CHATGPT_URL,
currentChatGPTUrl,
ensureChatGPTLogin,
getChatGPTDeepResearchResult,
normalizeBooleanFlag,
parseChatGPTConversationId,
requireNonNegativeInt,
requirePositiveInt,
waitForChatGPTDeepResearchResult,
} from './utils.js';
function hasDeepResearchProgress(result) {
return !!result
&& result.status !== 'completed'
&& result.progress
&& typeof result.progress === 'object'
&& !Array.isArray(result.progress)
&& Object.keys(result.progress).length > 0;
}
export const deepResearchResultCommand = cli({
site: 'chatgpt',
name: 'deep-research-result',
access: 'read',
description: 'Read a completed ChatGPT Deep Research report from the conversation payload',
domain: CHATGPT_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
navigateBefore: false,
args: [
{ name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/<id> URL' },
{ name: 'wait', type: 'boolean', default: false, help: 'Wait until Deep Research completes or becomes extractable' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' },
{ name: 'stable', type: 'int', default: 6, help: 'Seconds the report text must remain unchanged when --wait is true' },
],
columns: [
'conversationId',
'status',
'report',
'sources',
'progress',
'asyncTaskConversationId',
'widgetSessionId',
'asyncStatus',
'venusMessageType',
'venusStatus',
'waitingForUserUntil',
'planTitle',
'planId',
'url',
'method',
'diagnostics',
],
func: async (page, kwargs) => {
const id = parseChatGPTConversationId(kwargs.id);
const shouldWait = normalizeBooleanFlag(kwargs.wait, false);
const timeout = requirePositiveInt(
Number(kwargs.timeout ?? 120),
'chatgpt deep-research-result --timeout',
'Example: opencli chatgpt deep-research-result <id> --wait true --timeout 600',
);
const stableSeconds = requireNonNegativeInt(
Number(kwargs.stable ?? 6),
'chatgpt deep-research-result --stable',
'Example: opencli chatgpt deep-research-result <id> --wait true --stable 6',
);
const targetUrl = `${CHATGPT_URL}/c/${id}`;
await page.readNetworkCapture?.().catch(() => []);
const currentUrl = await currentChatGPTUrl(page).catch(() => '');
if (currentUrl.startsWith(targetUrl)) {
await page.goto(`${CHATGPT_URL}/?opencli_dr_result=${Date.now()}`, { waitUntil: 'none' });
await page.wait(1);
}
await page.startNetworkCapture?.('/backend-api/conversation/').catch(() => false);
await page.goto(targetUrl, { waitUntil: 'none' });
await page.sleep(3);
await ensureChatGPTLogin(page, 'ChatGPT deep-research-result requires a logged-in ChatGPT session.');
const result = shouldWait
? await waitForChatGPTDeepResearchResult(page, { conversationId: id, timeoutSeconds: timeout, stableSeconds })
: await getChatGPTDeepResearchResult(page, { conversationId: id, useBridgeProbes: true });
if (result.status !== 'completed' && !hasDeepResearchProgress(result)) {
throw new EmptyResultError(
'chatgpt deep-research-result',
`No completed Deep Research report was found for conversation ${id}.`,
);
}
if (result.status === 'completed' && !result.report) {
throw new EmptyResultError(
'chatgpt deep-research-result',
`No completed Deep Research report was found for conversation ${id}.`,
);
}
return [{
conversationId: id,
status: result.status,
report: result.report || '',
sources: result.sources || [],
progress: result.progress || {},
asyncTaskConversationId: result.asyncTaskConversationId || '',
widgetSessionId: result.widgetSessionId || '',
asyncStatus: result.asyncStatus ?? '',
venusMessageType: result.venusMessageType || '',
venusStatus: result.venusStatus || '',
waitingForUserUntil: result.waitingForUserUntil || '',
planTitle: result.planTitle || '',
planId: result.planId || '',
url: result.url || targetUrl,
method: result.method || '',
diagnostics: result.diagnostics || {},
}];
},
});
+23 -11
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;
},
});
+9 -4
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, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, navigateToProject, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
const CHATGPT_DOMAIN = 'chatgpt.com';
@@ -72,6 +72,7 @@ export const imageCommand = cli({
args: [
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
{ name: 'image', help: 'Local image path to attach before prompting; comma-separated paths are supported' },
{ name: 'project', valueRequired: true, help: 'Start image generation inside a ChatGPT project ID or /g/g-p-<id> URL' },
{ name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
{ name: 'timeout', type: 'int', required: false, default: 240, help: 'Max seconds for the overall command (default: 240)' },
@@ -92,8 +93,12 @@ export const imageCommand = cli({
throw new ArgumentError(preparedImages.reason);
}
// Navigate to chatgpt.com/new with full reload to clear React sidebar state
await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
// Navigate with full reload to clear React sidebar state before editing the draft.
if (kwargs.project) {
await navigateToProject(page, kwargs.project);
} else {
await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
}
await clearChatGPTDraft(page);
if (imagePaths.length) {
@@ -123,7 +128,7 @@ export const imageCommand = cli({
for (let ci = 0; ci < 10; ci++) {
const url = await currentChatGPTLink(page);
if (url.includes('/c/')) { convUrl = url; break; }
await page.wait(2);
await page.sleep(2);
}
if (!convUrl) {
convUrl = await currentChatGPTLink(page);
+19
View File
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
prepareChatGPTImagePaths: vi.fn(),
sendChatGPTMessage: vi.fn(),
uploadChatGPTImages: vi.fn(),
navigateToProject: vi.fn(),
waitForChatGPTImages: vi.fn(),
getChatGPTImageAssets: vi.fn(),
saveBase64ToFile: vi.fn(),
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('./utils.js', () => ({
clearChatGPTDraft: mocks.clearChatGPTDraft,
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
navigateToProject: mocks.navigateToProject,
normalizeBooleanFlag: (value, fallback = false) => {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
@@ -56,6 +58,7 @@ beforeEach(() => {
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
mocks.uploadChatGPTImages.mockReset().mockResolvedValue({ ok: true });
mocks.navigateToProject.mockReset().mockResolvedValue(undefined);
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
url: 'https://images.example/generated.png',
@@ -89,6 +92,22 @@ describe('chatgpt image output paths', () => {
});
describe('chatgpt image upload flow', () => {
it('starts image generation inside a specified project', async () => {
const page = createPage();
await imageCommand.func(page, {
prompt: 'cat in a lab',
project: '12345678',
op: '',
sd: true,
timeout: 240,
});
expect(mocks.navigateToProject).toHaveBeenCalledWith(page, '12345678');
expect(page.goto).not.toHaveBeenCalled();
expect(mocks.clearChatGPTDraft).toHaveBeenCalled();
expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(page, 'Generate an image of: cat in a lab');
});
it('uploads local images before sending an edit prompt', async () => {
mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: true, paths: ['/abs/cat.png', '/abs/dog.jpg'] });
await imageCommand.func(createPage(), {
+31
View File
@@ -0,0 +1,31 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
CHATGPT_DOMAIN,
CHATGPT_MODEL_CHOICES,
navigateToProject,
selectChatGPTModel,
} from './utils.js';
export const modelCommand = cli({
site: 'chatgpt',
name: 'model',
access: 'write',
description: 'Switch ChatGPT web model or intelligence level (GPT-5.6 Pro, fast, balanced, advanced, very-high, pro)',
domain: CHATGPT_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
navigateBefore: false,
args: [
{ name: 'model', required: true, positional: true, help: 'ChatGPT model or intelligence level to switch to', choices: CHATGPT_MODEL_CHOICES },
{ name: 'project', valueRequired: true, help: 'Open a ChatGPT project ID or /g/g-p-<id> URL before switching intelligence level' },
],
columns: ['Status', 'Model'],
func: async (page, kwargs) => {
if (kwargs.project) {
await navigateToProject(page, kwargs.project);
}
const result = await selectChatGPTModel(page, kwargs.model);
return [{ Status: result.Status, Model: result.Model }];
},
});

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