Compare commits

...

590 Commits

Author SHA1 Message Date
jackwener eb1be2dc54 chore(upwork): reuse shared browser unwrap
upwork/utils.js carried a byte-identical copy of unwrapBrowserResult
that clis/_shared/search-adapter.js already exports. Re-export the
shared one so feed/detail/search import sites stay untouched.
2026-08-24 14:42:58 +08:00
jakevin 2e373b612e chore(xiaohongshu): share evaluate unwrap helper (#2363)
Collapse seven local unwrapEvaluateResult copies (ask, feed, search,
creator-notes, delete-note, follow, unfollow) into a single
clis/xiaohongshu/shared.js export, and point rednote/search at it.

The three variants differed only in an Array.isArray guard and
condition order; arrays cannot carry the session/data envelope keys
across the CDP JSON boundary, so behavior is identical for every
reachable payload. The canonical copy keeps the guard.

Contract tests move to shared.test.js (strengthened with identity
assertions); the direct duplicates in ask.test.js / search.test.js are
deleted as subsumed. creator-notes __test__ drops the imported key.
2026-08-24 14:36:30 +08:00
jakevin c003a1b1c2 refactor(sinafinance): use rolling news API (#2365)
Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
2026-08-24 09:02:41 +08:00
sleepy dog a0fbe90a7f fix(xiaohongshu): harvest search rows during scroll instead of first/last screen union (#2349)
* fix(xiaohongshu): harvest search rows during scroll instead of first/last screen union

The search results page is a virtualized masonry list: cards scrolled past
are evicted from the DOM. The previous flow extracted once, scrolled to the
bottom, then extracted again, so the result set was only the union of the
first and last screens -- capped near 20 rows regardless of --limit.

Harvest inside the scroll loop instead. A single page.evaluate now drives
the scroll and accumulates rows into a page-land Map keyed by note id, so
nothing is lost when a card is recycled. On a query that previously returned
34 rows, `--limit 100` now saturates at 100 across repeated runs.

Three related fixes ride along:

- Rows are merged rather than deduplicated on first sight. Masonry cards
  render in stages -- the link appears before the title -- so a row first
  seen with an empty title used to be cached empty and then dropped by the
  title filter. Empty fields are now backfilled on a later encounter,
  non-empty fields are never overwritten, and an unsigned /explore/ URL can
  be upgraded to an xsec_token-signed one but never downgraded. The likes
  emptiness check treats '0' as a placeholder because the extractor writes
  '0' for an unrendered count.

  For the same reason the target check counts only rows that already have a
  title. Counting raw discoveries let the loop stop the moment 100 cards
  were known, before the last screen had rendered its titles, and the filter
  then silently cut the output back to 84.

- "No new rows this round" is no longer, on its own, a reason to stop.
  It was observed firing at scrollTop=4500 of scrollHeight=6960 -- squarely
  mid-page, where Xiaohongshu had merely paused lazy-loading. Stopping now
  requires the plateau to coincide with either a real bottom or wedged
  scrolling, alongside the target/round/wall-clock bounds.

- Risk-control interstitials are detected and raise SECURITY_BLOCK instead
  of surfacing as a silently short result set indistinguishable from the
  truncation bug above.

Scrolling advances by a viewport-sized step rather than jumping to
document.body.scrollHeight, which would skip whole screens of cards. Round
and wall-clock budgets are derived from the already-validated --limit, so
no new CLI arguments and no cli-manifest.json change. Small --limit values
now finish sooner than before, since reaching the target ends the loop.

buildScrollUntilJs is untouched and buildSearchExtractJs keeps its signature
and semantics, so clis/rednote/search.js -- which imports both -- is
unaffected.

* fix(xiaohongshu): harden virtual search harvesting

* fix(xiaohongshu): validate harvested search rows

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-24 04:21:02 +08:00
Henry 144629740d feat(bilibili): add --top to fetch pinned comments (#2106)
* feat(bilibili): add --top to fetch pinned comments

The comments command already fetches /x/v2/reply/main, whose response
includes top_replies (置顶评论) alongside regular replies but discarded
them. Add a --top flag that returns only the pinned comments.

- --top reads data.top_replies (reusing formatReplyRow)
- --top is mutually exclusive with --parent (楼中楼 threads have no
  top_replies) and raises ArgumentError before any request
- generalize requireReplies to accept a key; absent top_replies is
  treated as empty, and an empty pinned list raises EmptyResultError

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

* fix(bilibili): harden pinned comment contract

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-24 04:01:50 +08:00
jakevin 5bb8f5ac9d fix(weixin): recover draft cover uploads (#2362) 2026-08-24 03:53:24 +08:00
OctoBored d2aec509e8 docs: point star history chart to a working endpoint (#2301)
The star history chart in the README was broken because its data source is no longer reliable under GitHub's current stargazer API restrictions. Update the chart in both README.md and README.zh-CN.md to use a working endpoint so the chart renders correctly again.

Co-authored-by: OctoBored <212877535+OctoBored@users.noreply.github.com>
2026-08-24 03:21:58 +08:00
jakevin d9b018fa0b fix: sync package-lock with undici 7.29.0 bump (#2361)
#2326 pinned undici 7.29.0 in package.json without regenerating the
lockfile, so npm ci fails with EUSAGE on main and every PR merge ref.
Regenerated with npm install --package-lock-only.
2026-08-24 03:04:33 +08:00
Anupam Mediratta 6d2b11e6ea fix: CVE-2026-13697 security vulnerability (#2326)
Automated dependency upgrade by OrbisAI Security
2026-08-24 02:26:21 +08:00
jakevin 87b60a3659 chore(release): bump version to 1.8.7 (#2359) 2026-08-23 23:58:41 +08:00
jakevin 432cf23101 chore(linkedin): share people search auth helpers (#2358) 2026-08-23 23:48:37 +08:00
jakevin dfc13ea69b chore(linkedin): share messaging thread URL helpers 2026-08-23 23:28:45 +08:00
jakevin f2de8ab808 chore(linkedin): reuse shared unwrap in connect flows 2026-08-23 23:02:43 +08:00
jakevin b158a4a831 chore(linkedin): reuse shared unwrap in salesnav 2026-08-23 22:46:36 +08:00
jakevin 77a4e19f0b chore(linkedin): reuse shared evaluate unwrap
Reuses the existing LinkedIn shared evaluate envelope unwrap helper across general messaging commands while preserving subsystem-local copies for later batches.
2026-08-23 22:28:52 +08:00
jakevin de16476e26 chore(linkedin): share safety URL decoder
Moves the duplicate Node-side LinkedIn safety URL decoder into the site shared helper while preserving page-realm copies and their browser-local contract.
2026-08-23 22:11:25 +08:00
jakevin e48aac2114 chore(xueqiu): share html stripping helper 2026-08-23 21:52:38 +08:00
jakevin dcab73fb95 chore(browser): share CDP page capabilities 2026-08-23 21:37:09 +08:00
jakevin 6058c1536a chore(instagram): share current user helper 2026-08-23 21:01:05 +08:00
jakevin 40999a5eee chore(instagram): share home navigation helper 2026-08-23 20:42:28 +08:00
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
jakevin ce432c2428 chore(release): 1.8.0 (#1682)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
* chore(release): 1.8.0

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

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

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

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

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

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

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

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

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

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

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

* fix(booking): harden search parser boundaries

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

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

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

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

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

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

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

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

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

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

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

Schema:
  rank, name, headline, location, profile_url

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

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

* fix(linkedin): harden people search typed boundaries

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

---------

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

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

Sites converted (5 commands, 6 throw sites):

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

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

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

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

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

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

* test(adapters): cover empty-result migrations

---------

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

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

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

* fix(zhihu): harden answer-comments boundaries

* fix(zhihu): keep answer comments flat

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

## 实现

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

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

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

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

## 范围

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

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

## 验证

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

* feat(reddit): expose home media route columns

---------

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

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

## xiaohongshu user

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

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

## youtube transcript

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

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

## 为什么 downstream 需要这个

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

## 测试

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

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

* fix(empty): tighten legal empty evidence

---------

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

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

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

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

## 验证

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

* test(twitter): cover inline bio extraction

* feat(twitter): expose thread author bio

---------

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

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

* fix(zhihu): dedupe answers by trusted id

---------

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

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

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

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

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

* fix(twitter): require quoted tweet render evidence

* fix(twitter): validate quoted tweet author shape

---------

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

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

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

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

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

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

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

接入 #1650 的 helper 后:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* fix(cli): preserve options around dash positionals

* fix(cli): preserve attached short option values

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* fix(twitter): verify created list name

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(reddit): harden subscribed listing contract

* fix(reddit): require subreddit identity for subscriptions

---------

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

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

---------

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

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

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

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

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

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

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

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

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

Coverage:

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

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

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

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

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

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

X distinguishes the sections by entry.entryId prefix:

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

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

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

* fix(twitter): harden lists parser boundary

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

Notes worth flagging for review:

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

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

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

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

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

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

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

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

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

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

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

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

Notes worth flagging for review:

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

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

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

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

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

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

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

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

* fix(12306): harden browser auth boundaries

* fix(12306): tighten API drift boundaries

---------

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

* fix(download): sanitize media filename segments

---------

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

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

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

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

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

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

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

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

* fix(browser): tighten stale page recovery notes

---------

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

* feat(xianyu): add private message commands

* fix(xianyu): harden IM command contracts

---------

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

* fix(linkedin): harden salesnav message boundaries

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #1619.

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

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

* fix(weibo): require delete postcondition evidence

---------

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

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

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

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

Closes #1606.

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

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

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

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

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

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

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

Closes #1623.

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

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

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

* fix(xiaohongshu): harden delete-note boundary

---------

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

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

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

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

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

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

Closes #1602.

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

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

* test(weibo): lock publish placeholder selector path

---------

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

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

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

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

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

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

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

Coverage:

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

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

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

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

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

* fix(adapters): tighten sentinel row identity guards

---------

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

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

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

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

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

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

Closes #1626.

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

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

* docs(electron): sync codex CDP port guidance

---------

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

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

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

* fix(barchart): harden greeks failure contract

* fix(barchart): reject malformed greeks row identity

---------

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

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

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

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

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

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

* fix(bilibili): harden summary command contract

---------

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

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

* fix(linkedin): align commands with current UI

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

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

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

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

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

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

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

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

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

* fix(linkedin): harden messaging command contracts

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

---------

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

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

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

Closes #1418

* fix: extract full note content from React Redux store

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

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

* fix(youdao): harden shared note reader contract

---------

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

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

* fix(douyin): harden publish delete write contracts

---------

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

Read your Flomo memos via the signed API.

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

* fix: add --token arg for Flomo auth

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

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

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

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

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

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

* feat: add images column to flomo memos output

* docs: add flomo adapter documentation

* fix: use clampInt and rebuild manifest

* fix(flomo): harden memos reader contract

---------

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

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

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

## Sites wrapped

`clis/chatgpt/utils.js`:

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

`clis/chatgpt/image.js`:

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

## Drive-by

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

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

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

## Validation

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

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

---------

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

* fix(youtube): validate transcript caption info shape

---------

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

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

* test(weibo): add search_by_user helper function tests

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

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

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

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

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

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

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

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

* bak

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

---------

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

- feat(external): longbridge CLI passthrough (#1584)
- feat(external-cli): brand alias rendering for ntn/dws/wecom-cli (#1585)
- fix(boss): map code=24 → AuthRequiredError (#1573)
- fix(weibo): unwrap page.evaluate envelope in read adapters (#1568)
2026-05-15 17:30:34 +08:00
jakevin 3f62cc45bf feat(external-cli): render brand alias for ambiguous executable names (#1585)
`ntn`, `dws`, and `wecom-cli` are opaque executable names — users seeing them
in `opencli list` or root help have no way to know they correspond to Notion,
DingTalk Workspace, and 企业微信. Repurpose the existing `package` field to
double as a human-readable brand label, so help output renders as
`ntn(notion)`, `dws(DingTalk Workspace)`, `wecom-cli(企业微信)`.

- `src/external-clis.yaml`: add `package:` to ntn / dws / wecom-cli
- `src/external.ts`: update JSDoc on `package` to cover both upstream
  distribution names (tg-cli, discord-cli) and brand labels (notion, 企业微信)
- `src/cli.ts:629` (`opencli list`): use `formatExternalCliLabel` so the
  listing matches root help, which already used it
- `src/external.test.ts`: regression test for brand-alias labels

Verification:
- npx vitest run --project unit src/external.test.ts: 9/9 pass
- npm run typecheck: clean
- npm run build: 813 manifest entries
- Smoke: `opencli list` and `opencli --help` both render the new labels
2026-05-15 16:37:27 +08:00
jakevin b6f352b318 feat(external): add longbridge cli (#1584) 2026-05-15 16:27:08 +08:00
Benjamin Liu dadf01b56f fix(weibo): unwrap page.evaluate envelope in read adapters (#1568)
* fix(weibo): unwrap page.evaluate envelope in read adapters (#1567)

`page.evaluate(...)` returns a `{ session, data }` envelope rather than
the raw IIFE return value, so all weibo cookie-strategy read adapters
silently dropped their results on v1.7.19:

- `getSelfUid` returned the envelope object instead of the uid string,
  so `'10001' + uid` produced `'10001[object Object]'` and every
  feed/me/favorites request hit a broken list_id.
- `feed`, `hot`, `comments`, `search`, `favorites` did `Array.isArray`
  on the envelope (always false) and returned `[]`.
- `me`, `user`, `post` returned the envelope wrapper itself instead of
  the inner profile/post object.

Same pattern as #1561 for xiaohongshu/rednote. Adds an
`unwrapEvaluateResult` helper to `clis/weibo/utils.js` (kept local
rather than cross-importing from `xiaohongshu/search.js` since weibo
is an unrelated site) and wraps every `await page.evaluate(...)` in
the 8 read adapters plus the two helper calls in `getSelfUid`.

Skipped `publish.js` (write command, out of scope for this read fix).

Verified live:
- `opencli weibo hot --limit 3` returns 3 real trending items
- `opencli weibo feed --limit 3` returns 3 timeline posts with
  correct `https://weibo.com/<uid>/<mblogid>` URLs (proves
  `getSelfUid` unwrap works)
- `opencli weibo me` returns the logged-in profile object
- All 20 weibo unit tests pass (6 new for `unwrapEvaluateResult`)

* fix(weibo): fail typed on malformed evaluate payloads

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 22:34:43 +08:00
Benjamin Liu 1239798d04 fix(boss): map code=24 (identity mismatch) to AuthRequiredError (#1573)
Recruiter-only BOSS commands (recommend, joblist, stats, resume, mark,
exchange, invite, greet, batchgreet) returned a generic
`COMMAND_EXEC: 请切换身份后再试 (code=24)` when called from a job-seeker
account. The original error hid the actionable bit: this command set
needs a recruiter (BOSS-side) account.

chatlist / chatmsg already special-case code=24 by falling back to the
geek-side fetch when --side=auto. Recruiter-only commands have no
geek-side equivalent and were just leaking the raw API code.

Fix: add a `checkRecruiterSide` step inside `assertOk` that maps
code=24 to AuthRequiredError with a clear message. All 9 recruiter-only
commands inherit it through their existing `bossFetch` calls; no
adapter-level changes needed. chatlist / chatmsg are unaffected because
they use `allowNonZero: true` and never hit the auto-error path.

Closes #1572.
2026-05-14 22:26:28 +08:00
jakevin 9ccc896585 chore(release): 1.7.21 (#1571)
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-14 19:36:41 +08:00
jakevin 1a69f40a80 fix(social): use ephemeral adapter site sessions (#1569) 2026-05-14 19:22:42 +08:00
J.Chen 300607f692 fix(facebook/feed): add fallback extraction for empty article nodes (#1538)
* fix(facebook/feed): add fallback extraction for empty article nodes

Add fallback extraction for Facebook feed posts when [role=article] nodes exist but contain empty text. Includes diagnostic errors, content/author cleanup, nested-container dedupe, and an evaluate-script syntax regression test.

* fix(facebook): bound feed fallback extraction

* fix(facebook): keep feed fallback available after chrome articles

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 18:42:50 +08:00
J.Chen 42b5a4e68d feat(boss): support job-seeker chatlist and chatmsg (#1539)
* feat(boss): support job-seeker chatlist and chatmsg

* fix(boss): type chat-side failure boundaries

* fix(boss): guard malformed chat API payloads

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 18:41:02 +08:00
jakevin bccd275d66 test(extension): cover adapter group tiebreaker (#1566) 2026-05-14 18:04:07 +08:00
胡大头 edfa5f0da3 feat: add DuckDuckGo, Brave, and Yahoo web search adapters (#1546)
* feat: add DuckDuckGo, Brave, and Yahoo web search adapters

Add three new search engine adapters with browser-based DOM extraction:

- duckduckgo/search: Search DuckDuckGo via html.duckduckgo.com
  Supports region, time filters, and XHR-based pagination (--offset)
- duckduckgo/suggest: Search suggestion autocomplete (no browser needed)
- brave/search: Search Brave Search via search.brave.com
  Supports GET-based pagination (--offset)
- yahoo/search: Search Yahoo (Bing-powered) via search.yahoo.com
  Supports GET-based pagination (--page)

All search adapters use Strategy.PUBLIC with browser:true, navigating
the target site and extracting results via page.evaluate() DOM queries.
Includes full test coverage (16 tests).

* fix: use clampInt from shared utils and add adapter docs

- Replace Math.max/Math.min patterns with clampInt() from _shared/common.js
  to pass the typed-error-lint gate (4 silent-clamp violations resolved)
- Add adapter documentation for duckduckgo, brave, and yahoo to fix
  the doc-coverage CI check
- Regenerate cli-manifest.json and typed-error-lint-baseline.json

* fix: avoid silent-column-drop overlap in brave/yahoo extractors

Change buildExtractorJs to return arrays instead of objects whose keys
matched columns. This prevents silent-column-drop audit false positives
as per opencli-adapter-author conventions.

* fix(search): tighten browser search adapters

* chore(search): drop baseline churn

* fix(duckduckgo): execute search extractor safely

* fix(yahoo): reject unsafe redirect targets

---------

Co-authored-by: huzekang <huzekang@opencode.ai>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 17:54:34 +08:00
J.Chen 16b02bcc58 fix(extension): reuse existing adapter tab group (#1541)
* fix(extension): reuse existing adapter tab group

* fix(extension): choose best existing adapter group

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 17:15:39 +08:00
Iris Chen 5af2ff1d6c fix(xiaohongshu,rednote): unwrap page.evaluate envelope in search adapter (#1561)
* fix(xiaohongshu,rednote): unwrap page.evaluate envelope in search adapter

`page.evaluate(...)` returns a `{ session, data }` envelope rather than
the raw IIFE return value, but the search adapters were calling
`Array.isArray(payload)` directly on the envelope. `Array.isArray` is
always false on the envelope, so every search result was silently
dropped — status=success, exit 0, empty array, no error.

The rednote adapter had this same bug; both share `buildSearchExtractJs`
from `xiaohongshu/search.js`.

Introduces `unwrapEvaluateResult(payload)` as a shared helper in
`clis/xiaohongshu/search.js` (re-exported via the existing import line
from `rednote/search.js`). The helper is a defensive ternary: it
unwraps when payload looks like an envelope with an array `.data`,
otherwise it passes the value through unchanged. This keeps the change
back-compat with bridge versions that return the raw value, and
preserves the existing `Array.isArray(payload)` typecheck at each call
site.

Verified manually against `opencli xiaohongshu search "补墙洞"` (a query
known to return 20+ results in a logged-in browser tab): previously
`[]`, now returns the expected ranked rows with all declared columns
(`rank, title, author, likes, published_at, url`) populated.

Adds 5 unit tests for `unwrapEvaluateResult` covering raw array passthrough,
envelope unwrap, non-envelope object passthrough, null/undefined safety,
and the "data is not an array" guard. The existing 19 search tests in
`clis/xiaohongshu/search.test.js` still pass — the unwrap is invisible
to the existing mocks which already return raw arrays.

* fix(xhs): unwrap search evaluate envelopes

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 17:05:58 +08:00
jakevin 9c25bc7009 fix(ci): add Windows native binding lock entries (#1563) 2026-05-14 16:45:00 +08:00
jakevin 8c88a3cbf3 chore(release): 1.7.20 (#1562)
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-14 16:33:19 +08:00
jakevin 9c4f4a3d30 fix(cli): show external CLI package aliases (#1560) 2026-05-14 16:28:18 +08:00
jakevin 29c135b656 refactor(notion): replace built-in CDP adapter with external ntn CLI (#1559)
* refactor(notion): replace built-in CDP adapter with external ntn CLI

Notion has shipped an official CLI at https://ntn.dev. It uses the
public Notion API (blocks / databases / properties / comments) instead
of reverse-engineering the Desktop UI, so it survives Notion app
updates and exposes a wider command surface than the in-tree adapter
could.

Changes:
  - `src/external-clis.yaml` — register `ntn` as first-class external CLI
    (binary `ntn`, homepage ntn.dev, install via the shell-pipe script
    on mac/linux)
  - `clis/notion/` — entire directory removed (8 commands: status /
    search / read / new / write / sidebar / favorites / export)
  - `docs/adapters/desktop/notion.md` — removed
  - `docs/.vitepress/config.mts` — drop nav entry
  - `docs/adapters/index.md` — drop adapter row
  - `README.md` / `README.zh-CN.md` — drop notion from feature lines,
    drop adapter table row, add `ntn` to CLI hub examples
  - `docs/index.md` / `docs/zh/index.md` / `docs/guide/getting-started.md`
    — drop notion from electron-control feature copy
  - `skills/opencli-usage/SKILL.md` — drop notion from electron list
  - `cli-manifest.json` — rebuilt with --allow-removals=8

Migration for users:
  `curl -fsSL https://ntn.dev | bash`  (or `opencli external install ntn`)
  Then use `opencli ntn <command>` in place of `opencli notion <command>`.

Rationale: the in-tree adapter was reverse-engineered against Notion
Desktop CDP and shipped only 8 commands. The official CLI gives users
the full Notion API surface and reduces our maintenance burden to zero.
Same pattern as gh / obsidian / lark-cli / tg-cli / discord-cli / wx-cli.

Verification:
  - `npx tsc --noEmit` clean
  - `npx vitest run --project unit` → 1091/1 skipped
  - `npm run build` (with --allow-removals=8) — manifest 809 entries
  - grep notion in user-facing docs (README / docs / skills) — only
    descriptive mentions remain in non-blocking places (comparison /
    site-recon / electron how-to / design doc), no broken adapter
    references

* fix(notion): align ntn external migration

* docs(notion): clarify ntn manual install
2026-05-14 16:12:17 +08:00
jakevin 7edf53783f fix(daemon): report unknown browser command results (#1558) 2026-05-14 14:30:13 +08:00
jakevin af7b94152f feat(twitter): add extractMedia parity to bookmarks + bookmark-folder (#1555)
Mirrors PR #1464 (list-tweets) and the timeline/search/tweets/likes/thread
family: spread `...extractMedia(legacy)` into the row and surface
`has_media` + `media_urls` columns. Pure parity, no behavior change for
existing callers — media keys do not collide with the original columns.

- bookmarks.js: import `extractMedia` from ./shared.js, spread into
  extractBookmarkTweet row, append columns, export __test__.
- bookmark-folder.js: same change on extractFolderTweet, export
  extractFolderTweet via __test__.
- bookmarks.test.js (new): baseline + photo + video + entities-only
  fallback + dedup + envelope + empty-envelope (8 tests).
- bookmark-folder.test.js: update existing baseline expectation with
  has_media/media_urls, add 3 new media tests (photo / mp4 / no-media).
- cli-manifest.json: regenerated; only the two `columns` entries change.

Reverse-validated: tests fail when extractMedia spread is removed.

Audits unchanged: typed-error-lint 189/189, silent-column-drop 102/103
(pre-existing main resolution noted but not consumed here).
2026-05-14 14:24:31 +08:00
Ocean 6b26aedd56 feat(twitter/list-tweets): include media via extractMedia (parity with timeline/search) (#1464)
* feat(twitter/list-tweets): include media via extractMedia (parity with timeline/search)

list-tweets was the only X recall path that dropped media. timeline.js and
search.js both call extractMedia(legacy) and emit has_media/media_urls;
list-tweets returned only text fields, so downstream consumers (e.g.
ml-scout's rate UI) couldn't render image/video thumbnails on tweets pulled
from a list timeline.

Changes:
- Import extractMedia from ./shared.js
- Spread extractMedia(legacy) into extractTimelineTweet return
- Add has_media, media_urls to columns array (--format columns parity)
- Update unit test to assert the new shape; add coverage for photo and
  video extraction

* chore(manifest): rebuild cli-manifest.json for list-tweets media columns

---------

Co-authored-by: ml-scout <ml-scout@anthropic.com>
2026-05-14 14:01:34 +08:00
jakevin 68b18cdbcd fix(extension): coalesce daemon websocket connects (#1554) 2026-05-14 13:57:38 +08:00
J.Chen cddc84776c docs(browser): clarify named session lifecycle (#1542)
* docs(browser): clarify named session lifecycle

* docs(browser): clarify owned versus bound sessions

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 13:38:41 +08:00
J.Chen 4f5fcd9acb fix(extension): keep active daemon websocket
Keep stale Browser Bridge WebSocket events from clobbering the active daemon connection.\n\nCo-authored-by: Jeff Chen <jeff@adtiming.com>
2026-05-14 13:38:05 +08:00
jakevin 40b2f75098 feat(external)!: drop -cli suffix from tg/discord/wx subcommand names (#1544)
The opencli external-CLI name is the user-typed subcommand; the binary is
what gets executed. The convention everywhere else (`gh`, `docker`,
`obsidian`, `vercel`, `dws`) is `name == binary`. Three entries violated
the convention: `tg-cli` / `discord-cli` / `wx-cli` registered an
opencli name with a `-cli` suffix that does NOT exist on the binary,
forcing the awkward double-prefix `opencli discord-cli dc` instead of
`opencli discord dc`.

The README's example column already showed the desired form
(`opencli tg search`, `opencli discord recent`, `opencli wx search`) —
only the yaml registration was out of sync.

Renames in `src/external-clis.yaml`:

* `name: tg-cli`     → `name: tg`       (binary: `tg`)
* `name: discord-cli`→ `name: discord`  (binary: `discord`)
* `name: wx-cli`     → `name: wx`       (binary: `wx`)

The `binary`, `homepage`, and `install` fields are unchanged — the
underlying packages (`kabi-tg-cli`, `kabi-discord-cli`, `@jackwener/wx-cli`)
keep their published names.

Other entries left as-is: `lark-cli`, `wecom-cli`, and `dws` already have
`name == binary` (their actual binaries are `lark-cli`, `wecom-cli`, `dws`).

BREAKING CHANGE: `opencli tg-cli ...`, `opencli discord-cli ...`,
`opencli wx-cli ...` no longer resolve. Use `opencli tg ...`,
`opencli discord ...`, `opencli wx ...` instead. The feature is recent
(shipped 2026-05) so impact is expected to be minimal.
2026-05-14 04:13:39 +08:00
jakevin feab24f76c chore(release): 1.7.19 (#1543)
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-14 02:33:56 +08:00
jakevin 8ef7e903b8 feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug (#1531)
* feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug

Primary: make `opencli twitter tweets` default to the logged-in user
when no username is given, so agents can pull their own posts without
needing to know their own handle. Mirrors the existing self-detection
pattern in twitter/profile and twitter/likes (AppTabBar_Profile_Link
probe on /home, then UserByScreenName lookup). Description + help
string now mention the default so agents discover it.

Consistency pass — profile/likes/following/followers: the
self-detection in these four siblings was silently broken because
page.evaluate() primitive returns come back through the CDP bridge
wrapped as `{session: 'site:twitter', data: '/<handle>'}` (same
envelope root cause as #1525). They called `.replace()` directly on
the envelope object → TypeError surfaced as AUTH_REQUIRED 'Could not
detect logged-in user', even for logged-in users. Wrap each probe
with unwrapBrowserResult so the bare href string survives. Also:
- Add an explicit page.goto('/home') + page.wait(primaryColumn)
  before the probe in likes/following so the AppTabBar sidebar is
  guaranteed rendered (framework pre-nav lands on bare x.com without
  the sidebar mounted).
- following.js: switch its probe from the function-literal form
  `() => {...}` to a template-string. Confirmed live: function-literal
  silently drops primitive returns entirely — bridge returns
  `{session}` with no `data` field at all, while template-string
  returns `{session, data}` as expected.

Out of scope (pre-existing, flagged as follow-up): likes/following
have additional downstream evaluate paths (userId/GraphQL fetch) that
still drop or envelope their results; they return [] or
'Could not find user' even after this PR. Same daemon-side bug class
as #1525.

Live-verified:
  opencli twitter tweets --limit 2     → own tweets (@jakevin7)
  opencli twitter profile              → own profile

Tests 227/227, audits typed-error-lint 189 + silent-column-drop 103
unchanged, manifest stable at 816 entries.

* fix(twitter): validate self-detected handles

* fix(twitter): unwrap downstream self evaluate results
2026-05-13 22:51:48 +08:00
ppop123 7c5bafd49b fix(twitter): repair list-add / list-tweets / lists / following after 2026-05 changes (#1503)
* fix(twitter): unwrap page.evaluate primitive returns in lists/list-tweets/following

The opencli >=1.7.x browser bridge wraps page.evaluate's primitive return
values as { session, data: <value> }. Adapters that destructure .data
inline (e.g. data.queryId, data.viewer) keep working because the wrapper
spreads object-typed responses to the top level, but ones that consume
the return value as a bare string broke:

- twitter list-tweets: the dynamically resolved queryId (a string) became
  {session, data:"..."}. Interpolating that into the GraphQL URL produced
  /i/api/graphql/[object Object]/ListLatestTweetsTimeline, giving "HTTP
  400: queryId may have expired".
- twitter lists: same on ListsManagementPageTimeline queryId.
- twitter following: same shape bug on the href read from the profile
  link, producing "TypeError: href.replace is not a function" when no
  --user is given.

Add a small unwrap() helper at each call site so primitive returns are
extracted from the wrapper before use. Object-typed GraphQL responses
are left as-is since they rely on spread semantics.

* fix(twitter): rewrite list-add to use ListAddMember GraphQL mutation

In 2026-05 X replaced the "Add/remove from Lists" modal dialog with a
full-page route (/i/lists/add_member). The previous UI flow no longer
works:

  Save button not found in dialog (X expected text Save/Done).
  Dialog structure may have changed.

The mutation that the dialog used to fire (ListAddMember) is still the
right primitive — and the surrounding adapter already calls X GraphQL
APIs directly to resolve userId and verify member_count. Drop the UI
flow entirely and call ListAddMember directly via fetch in the page
context.

Wins:
- Works again on current X UI (verified 2026-05-12 on x.com).
- ~10x faster: no goto-profile + click-caret + scroll-dialog round trips.
- One less moving piece — no dependency on Chrome extension's nativeClick
  for this command.

Implementation notes:
- LIST_ADD_MEMBER_QUERY_ID is a 2026-05 fallback; resolveTwitterQueryId
  does live lookup from the loaded client-web bundle, matching the
  pattern already used elsewhere in the twitter clis.
- X's ListAddMember response routinely contains a non-fatal partial
  decode error on default_banner_media_results (code 214, Validation /
  BadRequestError) alongside a fully populated data.list. We treat the
  call as failed only when data.list / member_count is missing, and
  ignore decode-flavored errors confined to banner fields.
- Same opencli >=1.7.x { session, data } primitive-wrap behavior that
  the previous commit addressed applies here: userId from the
  UserByScreenName call needs unwrap before being interpolated into
  the mutation body, otherwise X parses "[object Object]" as user_id
  and returns "strconv.ParseInt ... invalid syntax".

Verified flows:
- noop (already a member) → status: noop, member_count unchanged.
- new add (e.g. @AnthropicAI on a fresh list) → status: success,
  member_count incremented.

Trade-off: rejection signals (e.g. X declining to add @deepseek_ai)
look indistinguishable from noop at the response level, since X returns
HTTP 200 with member_count unchanged. Documented in the success message.

* fix(twitter): integrate list media and harden list-add

---------

Co-authored-by: wangyan <wy@wang-yan-Air.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 22:35:57 +08:00
jakevin f66996a148 fix(twitter): raise cursor pagination guard
Fix hidden pagination ceilings across Twitter cursor-pagination adapters.\n\nCo-authored-by: Mingming Lou <1109198+lmmsoft@users.noreply.github.com>
2026-05-13 22:14:05 +08:00
jakevin 4fac911425 feat(zhihu): add answer-detail to fetch a single answer's full content (#1528)
* feat(zhihu): add answer-detail to fetch a single answer's full content

The existing `zhihu answer` adapter is a write (post an answer); the
listing `zhihu question` truncates each answer's body to 200 chars.
There was no way to fetch one specific answer's full content by id.

New read adapter `zhihu answer-detail`:

- Accepts a bare numeric answer id, a typed target `answer:<qid>:<aid>`,
  or a full Zhihu answer URL (the form you paste from a browser).
- Calls `/api/v4/answers/<aid>?include=content,voteup_count,...,question`
  inside the cookie-bearing page context (Strategy.COOKIE).
- Returns a single row with id / author / votes / comments /
  question_id / question_title / url / created_at / updated_at /
  content. The content column is the full stripped answer body by
  default — no silent truncation. `--max-content N` is an opt-in user
  cap (mirroring the wikipedia `page` flag), and `--max-content 0`
  (the default) means "no cap, full content".

Important precision note: Zhihu answer ids since 2024 routinely
exceed `Number.MAX_SAFE_INTEGER` (the test fixture uses the real id
`1937205528846655537`). `data.id` is round-tripped through browser
`JSON.parse` and would round to `1937205528846655500`, so the adapter
deliberately ignores `data.id` for the canonical row id and anchors
it to the already-validated input string instead. A regression test
locks this contract in by mocking `data.id = 0` and asserting the row
still carries the parsed input id.

Typed errors: bad input → INVALID_INPUT; 401/403 → AuthRequiredError;
other HTTP / null → FETCH_ERROR. No silent fallbacks, no sentinel
strings.

Live-verified against the example URL — fetched 5547 votes / 165
comments / 1937205528846655537-end-to-end. 16 unit tests, audits
unchanged (typed-error-lint 189/189, silent-column-drop 103/103),
manifest 816→817.

* fix(zhihu): tighten answer-detail contracts
2026-05-13 21:47:37 +08:00
xcd_git b52da639a3 fix(google-scholar/search): wrap evaluate return to fix serialization (#1525)
* fix(google-scholar/search): wrap evaluate return to fix serialization

Same issue as google/search: page.evaluate() serializes JS arrays as
plain objects across the CDP boundary, causing Array.isArray() to
return false. The adapter silently returned [] instead of results.

Also replace fixed page.wait(3) with selector-based wait for
.gs_r.gs_or.gs_scl with a 3s fallback.

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

* fix(google-scholar): type search evaluate payload

* chore: rerun google scholar search checks

---------

Co-authored-by: cxiao <chuda.xiao@wuerzburg-dynamics.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 19:07:39 +08:00
Joseph赛博阿隆 b1dca04ddd fix(xiaohongshu): parseLikes should handle 2.1w / 1.5万 / 1.2k shortforms (#1504)
* fix(xiaohongshu): parseLikes should handle 2.1w / 1.5万 / 1.2k shortforms

Xiaohongshu renders top-popular comment like-counts as shortened
strings like '2.1w' / '1.1万' / '1.2k' once they exceed ~10 000.
The previous parseLikes only matched bare digits via /^\d+$/ and
silently returned 0 for any shortform, which inverted the sort
order: the highest-liked comments (often 10k+) ranked last while
mid-tier comments with plain numeric counts (e.g. 7569) appeared
on top.

Repro on any popular xiaohongshu thread (>10 000 likes on a top
comment): with --format json the most-upvoted parent rows show
"likes": 0.

This patch keeps the original fast path for plain integers and
adds a single regex for the well-known shortform suffixes:

  - w / 万 -> *10000
  - k / 千 -> *1000
  - trailing '+' tolerated (e.g. '999+')
  - unknown shapes still fall back to 0 (no behavior change)

Note: parseLikes runs inside the IIFE injected via page.evaluate(),
so the existing comments.test.js mock harness (which stubs
evaluate's return value directly) does not exercise it. A future
refactor that exports parseLikes for direct testing would be a
separate change.

Affects both top-level comments and 楼中楼 sub-replies (same
helper).

* fix(xiaohongshu): parse comment like shortforms safely

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:47:20 +08:00
jakevin f481585ba1 chore: drop util.styleText to support Node v20+ (#1524)
* chore: drop util.styleText to support Node v20+

util.styleText was added in Node v21.7.0 / v20.12.0. v21.0.0-v21.6.x and
v20.0.0-v20.11.x throw `SyntaxError: ... styleText` at startup because the
import resolves before any user code runs (a real user reported this on
v21.2.0).

OpenCLI is primarily agent-facing — terminal colors are noise to consumers,
and the [OK] / [WARN] / [FAIL] / ℹ / ⚠ / ✖ markers we already write carry
the semantic info that colors only repeated. Strip styleText entirely from
logger / output / doctor / tui / update-check / cli / download/progress /
commands/daemon and clean up the resulting awkward `${'literal'}` template
fragments. engines.node now reads ">=20.0.0".

This removes the Node-version coupling that A/B fixes would only have
papered over.

* fix(runtime): truly support Node v20+ by aligning guard + undici

Follow-up to the styleText removal: declaring engines.node >=20.0.0 is
not enough on its own. Two coupled barriers remained:

- src/runtime-detect.ts: MIN_SUPPORTED_NODE_MAJOR = 21 explicitly
  rejected v20 at startup
- undici@^8.0.2 declares engines.node >=22.19.0; Node 20/21 crash on
  webidl.util.markAsUncloneable before any user code runs

Lower the guard to 20 and downgrade undici to ^6.25.0 (engines >=18.17,
retains Agent / EnvHttpProxyAgent / fetch / Dispatcher). Smoke-tested
--help / doctor / list on Node v20.0.0, v21.2.0, v22.22.2. 213/213
targeted unit tests pass.
2026-05-13 18:33:21 +08:00
lenovobenben 723f2b9147 feat(zhihu): paginate question answers and recommendations (#1517)
* feat(zhihu): paginate question answers and recommendations

* fix(zhihu): drop Math.min limit clamp and 'unknown' sentinel

Two audit-driven fixes on top of feat/zhihu-pagination-recommend:

1. question.js: replace `Math.min(answerLimit, 20)` with a named
   constant `ZHIHU_PAGE_SIZE = 20`. The Zhihu API caps `limit` at 20
   per request anyway, and the pagination loop already trims to the
   user-requested `answerLimit` via `answers.length >= answerLimit`,
   so the Math.min silent-clamp was both unnecessary and tripped the
   silent-clamp audit. Updates the existing unit test to expect the
   API-max page size in the fetch URL with an explanatory comment.

2. recommend.js: rebuild the dedup key without the `'unknown'`
   sentinel. The old form `\`\${target.type || 'unknown'}:\${target.id}\``
   collapsed distinct typed items into the same bucket whenever
   `target.type` was missing, and tripped the silent-sentinel audit.
   New form: prefer `type:targetId`, fall back to `__feed:item.id`,
   and when neither id is available keep the row but skip dedup
   (surfacing potentially-duplicate items beats silently dropping
   them).

Audits unchanged (typed-error-lint 189/189, silent-column-drop
103/103). All 88 zhihu tests pass.

---------

Co-authored-by: lihaidong <lihaidong@kingsoft.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:28:44 +08:00
Benjamin Liu 2babed84e9 fix(xiaohongshu+rednote/search): fall back to href-based note cards when section.note-item class is dropped (#1506) (#1507)
Issue #1506 reports `opencli xiaohongshu search` returning `[]` even though
the page visibly has results. Trace evidence: xhs ships a render variant
where each note card is a bare `<section>` (no `note-item` class), so
the three `section.note-item` selectors in this file all match zero
elements.

Three call sites in the shared search IIFEs now use the same defensive
selector strategy: try the legacy `section.note-item` class first, then
fall back to any `<section>` that wraps a `/search_result/...` or
`/explore/...` link. The change is in the xiaohongshu file so the
rednote adapter (which imports `buildSearchExtractJs` and
`buildScrollUntilJs` from here) picks it up automatically.

Extraction-side title selector also gets a fallback: when no
`.title` / `.note-title` element matches, read the first `<span>`
inside the search-result link, which is where the bare-section render
puts the caption per the trace.

## Verification

`npx vitest run --project adapter clis/xiaohongshu/`: 105/105 green
(existing test suite unchanged, passes on both legacy and fallback paths).

Live verify on rednote (same code path, account-safe):

```
$ opencli rednote search "美食" --limit 3 -f json
[ {rank:1, title:"在朋友家吃过一次..."}, {rank:2, title:"我的15💰晚餐..."}, {rank:3, title:"干净饮食🫛..."} ]
```

Legacy `section.note-item` path is exercised here (rednote still renders
the class) and returns identical row shape to before the fix, confirming
no regression on the working path.

Live verify on xiaohongshu cannot be performed here (no logged-in xhs
session on the test machine; xhs account-ban risk per the project's
operational guidance). The fix is structural: the new `<section>` shape
the issue reporter traced is reachable through the fallback, and the
existing test fixture keeps the legacy path green.

`npx tsc --noEmit` clean. `npm run build` 815 manifest entries unchanged
shape. `silent-column-drop` / `typed-error-lint` baselines unchanged.

Closes #1506
Refs #1500
2026-05-13 18:24:55 +08:00
陈家名 a6ca53c7cf fix: clamp download progress percentages (#1520)
* fix: clamp download progress percentages

* test(download): cover unknown progress total

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:24:26 +08:00
jakevin c3912d8e5c feat(reddit/read): --expand-more via /api/morechildren + 7-kind typed errors (#1492)
* feat(reddit/read): add --expand-more via /api/morechildren + 7-kind discriminated union

PR B of the rdt-cli parity follow-up (after PR #1491, see #1481 thread).
Closes the second-largest gap: Reddit's "[+N more replies]" stubs were
opaque markers in the comment tree. With --expand-more, the adapter
follows them by POST-ing the t1 ids to /api/morechildren.json, then
re-threads the returned things back into the tree by parent_id before
walking it.

New args:

- `--expand-more` (bool, default false) — turn on stub expansion.
- `--expand-rounds <N>` (int, default 2, range [1, 5]) — Reddit returns
  fresh "more" stubs at the expansion depth boundary, so up to N rounds
  are run. Strictly validated via `parseExpandRounds` — out-of-range
  raises ArgumentError BEFORE `page.goto`, no silent clamp.

Boy-Scout: the in-browser script now returns a 7-kind discriminated
union instead of a flat row array (matching the PR #1428 / #1491
sediment). Each kind maps 1:1 to a typed error on the Node side:

  - `inaccessible` → EmptyResultError
      401/403/404 on /comments/<id>.json (post-specific access, not
      session-level auth — applies the PR #1491 review-side sediment
      "inaccessible-resource vs session-auth").
  - `auth`         → AuthRequiredError
      401/403 on /api/morechildren (expand-write endpoints often demand
      a logged-in session even when the read endpoint is anonymous).
  - `http`         → CommandExecutionError
  - `malformed`    → CommandExecutionError
      200 with unexpected envelope shape — schema drift, not empty.
  - `parser-drift` → CommandExecutionError
      tree had t1 entries but the walker produced no rows (PR #1491
      review-side sediment "post-construction 0 rows + pre-walk
      non-empty = parser drift, not legitimate empty").
  - `expand-failed`→ CommandExecutionError
      /api/morechildren returned a non-empty json.errors array.
  - `ok`           → returns rows[].

Intermediate keys (kind / detail / httpStatus / where / rows /
expandMeta) deliberately avoid the declared columns (type / author /
score / text) per the PR #1329 silent-column-drop sediment.

Tests:
  clis/reddit/read.test.js — 11 tests
    - Adapter shape (browser / siteSession / columns / args)
    - --expand-more / --expand-rounds present with correct types/defaults
    - parseExpandRounds default / range / non-integer rejection
    - Pre-navigation validation (bad --expand-rounds doesn't reach goto)
    - kind=ok happy path (POST + L0 rows)
    - 6-kind error → typed error mapping
    - Unknown envelope shape → CommandExecutionError
    - Evaluate script embeds expandMore/expandRounds/sort/limit literals
    - Evaluate script contains /api/morechildren POST scaffolding
    - Evaluate script never names declared columns as intermediate keys

Full reddit suite 48/48; full project 3402/3402.

Audits: typed-error-lint 189/189 (0 new), silent-column-drop 103/103
(0 new). Manifest 815 → 815 (existing read entry gets 2 new args).

Existing --limit / --depth / --replies / --max-length keep their
original Math.max-style behaviour (grandfathered in the baseline);
only the new --expand-rounds flag fails fast per the typed-errors
standard.

Refs: https://github.com/jackwener/rdt-cli (browse.read --expand-more)

* fix(reddit): preserve expanded comment tree order

* fix(reddit): fail on partial morechildren expansion
2026-05-13 18:13:11 +08:00
darthjaja 67599ea67c fix(twitter): repair search and tweets readback (#1512)
* fix(twitter): repair search and tweets readback

* fix(twitter): prefer baked operation features when bundle parse returns empty

The bundle parser in resolveTwitterOperationMetadata locates the queryId via
`queryId:"..."` inside a ~2500-char snippet around the operationName marker,
then independently extracts `featureSwitches:[...]` and `fieldToggles:[...]`
via separate regexes. When minification rearranges the snippet (or the
snippet window truncates before the array), either regex can miss while
queryId still resolves; keysToFlags(undefined) then returns {}.

sanitizeTwitterOperationMetadata previously accepted any object as
features / fieldToggles, including {}. Twitter's GraphQL endpoint rejects
SearchTimeline / UserTweets requests with empty features (HTTP 400),
surfacing a misleading "queryId may have expired" error — the queryId is
fresh; only the feature flags are missing.

Guard against this by deferring to the baked fallback whenever the resolved
map is empty. Adds a JSDOM-free unit test that, reverse-validated, fails on
the un-fixed code with the exact silent-fallback shape.

Refs PR #1512

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:11:41 +08:00
darthjaja f321a6096d fix(twitter): make reply submission robust (#1511) 2026-05-13 18:10:19 +08:00
xcd_git 59ebf551f0 fix(google/search): wrap evaluate return value in object to fix serialization (#1523)
page.evaluate() serializes JS arrays as plain objects, causing
Array.isArray() to return false and the adapter to throw NOT_FOUND
even when results exist. Wrap the return value in {items: results}
and extract via wrapper.items to avoid the type check issue.

Co-authored-by: cxiao <chuda.xiao@wuerzburg-dynamics.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 18:05:03 +08:00
jakevin 04a57029b3 ci(adapter-test): gate adapter-test off pull_request trigger (#1522)
Per @WAWQAQ direction (DM): trim PR-time CI to fast-feedback only.
adapter-test (~30-60s) is the next-largest PR wait after e2e-headed
(which #1521 just removed). Adapter authors typically run focused tests
locally before pushing (`npm run test:adapter`); CI duplication adds
queue latency without catching new classes of bugs.

PR-time CI surface now:
  - typecheck / unit (~1 min)
  - lint gates (typed-error / silent-column-drop)
  - build × 3 platforms

Adapter test guards (still strict):
  - push to main / dev
  - nightly cron
  - workflow_dispatch (manual when an adapter-heavy PR really wants the
    signal before merge)

Same gate as smoke-test (`if: github.event_name == 'push' || schedule
|| workflow_dispatch`) for consistency.
2026-05-13 17:55:23 +08:00
jakevin fd438c2109 ci(e2e): drop e2e-headed from pull_request trigger (#1521)
Per-PR e2e-headed Chrome was the dominant PR-time wait (~10-15 min on
two platforms) and on fork PRs blocks behind maintainer approval, while
the actually-blocking failures it caught in the last 30 days were all
e2e-test migrations missed by the authoring PR (#1461 / #1505 workspace
->session) rather than real regressions the unit/typecheck tier missed.

PR feedback path is now:
  - typecheck / unit / lint / adapter / build  ← `pull_request` (ci.yml)
  - extension typecheck / build                ← `pull_request` (build-extension.yml)
  - docs build                                  ← `pull_request` (doc-check.yml)
  - security audit                              ← `pull_request` (security.yml)

E2E-headed Chrome guards:
  - push to main / dev (watched paths)
  - push v* tag (release)
  - nightly cron 08:00 UTC (added: catches Chrome version drift / flake
    drift even when no commits touch watched paths)
  - workflow_dispatch (manual when a PR really wants e2e signal)

smoke-test was already gated on `schedule || workflow_dispatch` only
(ci.yml), so no change needed there.
2026-05-13 17:45:41 +08:00
Xiaohan Li 1eac8e0776 fix(browser): drop session injection from extension exec results (#1518)
`pageScopedResult()` in extension/src/background.ts was spreading the
lease's session into the result `data` for every page-scoped command. For
the `exec` action — which routes user JavaScript through page.evaluate()
— this contaminated arbitrary user-JS returns:

* Array / primitive returns came back as `{ session, data: <value> }`
  envelopes. Adapters that did `Array.isArray(result)` got `false` and
  treated the page as having no rows. Visible repro:
  `opencli google search ...` and `opencli xiaohongshu search ...` —
  Chrome rendered results correctly but adapters extracted an empty array
  (reported in #1518 from the Browser Bridge v1.0.12 envelope).
* Plain-object returns had an extra `session` key spliced in, silently
  overwriting any user `session` field with the lease's value.

Fix in the extension layer instead of compensating client-side:
`pageScopedResult` now returns `{ id, ok, data, page }` — the same form
it had before #1461 added the workspace→session refactor. Client-side
unwrapping is no longer needed and the original PR #1518 `Page.evaluate`
heuristic is dropped (it only covered the array path and would have
missed the plain-object path).

Two adapter improvements kept from the original PR:

* `clis/google/search.js` — wait for `#rso a h3` (with a 5s timeout)
  before extracting. On Chrome 148 / Linux Wayland the DOM can settle
  before SERP anchors are populated, so the existing fixed `wait 2`
  could return empty even with the envelope fix.
* `clis/xiaohongshu/search.js` — extract initially visible cards before
  scrolling, then merge post-scroll rows by URL. Xiaohongshu's
  virtualized masonry can evict the initial note cards from the DOM
  after scroll, causing extraction to return [] even though the
  browser had rendered results correctly.

Extension version bumped to 1.0.14.

Repro environment (from #1518):

* OpenCLI 1.7.18
* Browser Bridge extension 1.0.12 → 1.0.14
* Chrome 148.0.7778.96
* Linux Wayland, Node 22.22.1

Tests: extension/src/background.test.ts navigate same-url assertion
updated to no longer expect `session` in `data`. Three Page.evaluate
unwrap test cases removed.
2026-05-13 17:45:07 +08:00
Benjamin Liu 6af4db2ab5 fix(xueqiu/kline,earnings-date): format dates in Asia/Shanghai instead of UTC (#1498)
* fix(xueqiu/kline,earnings-date): format dates in Asia/Shanghai instead of UTC (#1465)

`xueqiu/kline` and `xueqiu/earnings-date` formatted bar timestamps with
`new Date(ts).toISOString().split('T')[0]`. That string is the UTC
calendar date, always one day earlier than the date xueqiu shows in its
UI (which is Beijing-aligned for every market). Issue #1465 reports
"5月10日跑的,5月8号的k线没有" because the May 8 China trading-day bar
was labeled 2026-05-07. Same off-by-one was present in `earnings-date.js`.

Routes both call sites through a new `formatChinaDate(ts)` helper in
`clis/xueqiu/utils.js` built on `toLocaleDateString('en-CA', { timeZone:
'Asia/Shanghai' })`. Verified live against SZ300136 and AAPL: both now
match the dates shown on xueqiu.com.

Tests: `clis/xueqiu/utils.test.js` (new) pins the Asia/Shanghai semantic
with 4 cases (China midnight, late-evening, 16:00 UTC day boundary, and
nullish input). `npx vitest run --project adapter clis/xueqiu/` 49/49,
`npx tsc --noEmit` clean, `npm run build` 815 entries unchanged shape.

Closes #1465

* fix(xueqiu): stabilize China date formatting

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 17:40:28 +08:00
jakevin 5127211ec1 refactor(extension): remove lease key session backdoor (#1510) 2026-05-12 22:43:30 +08:00
jakevin 587750cad3 refactor(env): remove OPENCLI_KEEP_TAB (#1509)
`OPENCLI_KEEP_TAB` was a debugging shortcut, not a config dimension. It
let users override `--keep-tab` globally via the shell environment,
which contradicts the per-command lifecycle model: `siteSession:'persistent'`
already pins persistent site tabs as a hard adapter-metadata constraint,
and `--keep-tab true|false` covers the ad-hoc override case. The env
just leaked process state across every browser command in the shell.

Changes:
  - src/execution.ts: `resolveKeepTab()` drops the
    `normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)`
    fallback. `--keep-tab` is now the single user override.
  - src/execution.test.ts: two regression tests rewritten to use the
    `executeCommand(cmd, {}, false, { keepTab: 'true' })` signature
    instead of the env. Logic and assertions unchanged.
  - README.md / README.zh-CN.md / skills/opencli-usage/SKILL.md:
    drop the env table row. `--keep-tab` documentation stays.
  - CHANGELOG.md: BREAKING entry under Unreleased.

Note: the 1.7.15 CHANGELOG entry still references the env historically;
that's intentional, historical entries are not retroactively edited.

Verification:
  - npx tsc --noEmit pass
  - npx vitest run --project unit --project extension → 1144/1145 pass
    (1 unrelated skip)
  - typed-error-lint baseline 189
  - silent-column-drop baseline 103
2026-05-12 22:02:17 +08:00
jakevin a77e05847f feat(browser): add function form page evaluate (#1508) 2026-05-12 21:48:44 +08:00
jakevin 0e168d570e refactor(browser): replace --session flag with <sessionname> positional (#1505)
* refactor(browser): replace --session flag with <sessionname> positional

The `--session <name>` flag was semantically required but syntactically
optional, which is an anti-pattern. Required + flag is a contradiction:
flag form implies "optional", required is a runtime patch on top. Session
is OpenCLI's "operation target" identifier — the natural form for that is
a positional argument, like `docker exec <container> <cmd>` or
`git checkout <branch>`.

New surface:

  opencli browser <sessionname> open https://x.com
  opencli browser <sessionname> click 12
  opencli browser <sessionname> bind
  opencli browser <sessionname> unbind

Commander 14 cannot natively combine a parent positional with subcommand
dispatch — the parent's positional is shadowed by subcommand matching. To
bridge that, main.ts now pre-processes argv: when the token after `browser`
is non-flag and not a known subcommand name, it is treated as the
sessionname and rewritten to the internal `--session <name>` flag form
before commander parses it. Help text on the `browser` command is
overridden via `.usage('<sessionname> <command> [options]')` so users see
the positional form.

Reserved subcommand names (33) are listed in cli-argv-preprocess.ts and
tested for parity with cli.ts subcommand registrations. If a future
subcommand is added, the test fails loudly.

Synced surfaces:
  - README.md / README.zh-CN.md — all examples
  - docs/guide/browser-bridge.md (+ zh)
  - skills/opencli-browser/SKILL.md (bind/unbind, examples, table)
  - skills/opencli-usage/SKILL.md
  - tests/e2e/browser-tabs.test.ts
  - CHANGELOG.md (Unreleased BREAKING)

The internal `--session` flag and the unit tests calling
`program.parseAsync(['...', 'browser', '--session', 'foo', ...])` are
preserved as a stable internal API: tests bypass main.ts pre-processing
and exercise commander directly. The pre-processor has its own targeted
test file (cli-argv-preprocess.test.ts, 10 tests, all green).

Verification:
  - npx tsc --noEmit — pass
  - npx vitest run --project unit — 1073/1074 pass (1 unrelated skip)
  - npx vitest run --project extension — 61/61 pass
  - npm run check:typed-error-lint — baseline 189
  - npm run check:silent-column-drop — baseline 103

* fix(cli-argv): only rewrite when `browser` is the root command

The preprocessor was looping through every argv slot and would mis-rewrite
occurrences of the literal word `browser` deeper in argv (e.g. `opencli
adapter init browser/x` or arg values containing `browser`).

Now the preprocessor walks past leading root flags + their values to
identify the root command token, and only acts when that token is
`browser`. The full set of root value-consuming flags
(`ROOT_VALUE_FLAGS`) is documented inline and kept in sync with the
`program.option()` calls in cli.ts.

Adds regression tests:
  - `opencli adapter init browser x` not rewritten
  - URL/path values containing `browser` not rewritten
  - `list browser state` (different root command) not rewritten
  - `--profile work browser foo state` correctly identifies `foo` as
    sessionname (not as --profile's value)
  - `--profile=work` long-form-with-equals consumes one slot only
  - boolean flags (`-v`) don't consume the next value

12/12 preprocessor tests pass.

* fix(cli-argv): hide --session flag, fail-fast on retired form, rename to <session>

Three blockers in #1505 review:

1. `--session` flag was still visible in `opencli browser --help` and could
   be used as a public entrance, contradicting "positional only" UX.
   Fix: switch from `.requiredOption()` to `.addOption(new Option(...).hideHelp())`.
   The flag is preserved as an internal API for the daemon protocol and direct
   `program.parseAsync` callers (tests), but is no longer documented or
   surfaced in structured help.

2. `opencli browser --session foo state` still succeeded. Now the argv
   preprocessor throws `BrowserSessionArgvError` when root `browser` is
   followed by `--session`, and main.ts catches it and exits with a
   user-facing usage error pointing to the positional form.

3. Missing-session error message exposed the internal flag:
   `required option '--session <name>' not specified`. Now `getBrowserSession()`
   in the action body throws `<session> is a required positional argument:
   opencli browser <session> <command>`, and commander no longer guards the
   hidden option.

Also (per @WAWQAQ) rename placeholder `<sessionname>` -> `<session>` everywhere
user-facing — shorter, matches CLI convention. The help text "<session> is a
required positional: pass the name of the browser session..." carries the
"name" semantics in description, not in the placeholder itself.

Sync surfaces:
  - src/cli.ts — usage line, addOption with hideHelp, descriptions
  - src/cli-argv-preprocess.ts — throw on --session form
  - src/cli-argv-preprocess.test.ts — refusal test for old form
  - src/cli.test.ts — assertions updated for hidden option + new error path
  - src/help.ts — read `_usage` private field to respect `.usage()` override
    (commander's `.usage()` getter returns auto-generated form if not set,
    which would otherwise pollute every namespace's usage string)
  - src/main.ts — catch BrowserSessionArgvError, stderr + exit
  - README.md / README.zh-CN.md
  - docs/guide/browser-bridge.md / docs/zh/guide/browser-bridge.md
  - skills/opencli-browser/SKILL.md / skills/opencli-usage/SKILL.md
  - CHANGELOG.md

Manual smoke tests (against built dist):
  - `opencli browser --help` shows `Usage: opencli browser <session> <command> [options]`
  - `opencli browser --help` Options block does NOT show `--session`
  - `opencli browser --session foo state` → friendly error, no commander stacktrace
  - `opencli browser state` → `<session> is a required positional argument: opencli browser <session> <command>`
  - `opencli browser foo state` → parses correctly

* fix: inject <session> into subcommand help paths and drop stale sessions ref

Two follow-up blockers from #1505 review:

1. Subcommand help and structured help still rendered the command path
   without the parent's positional. `opencli browser foo state --help`
   showed `Usage: opencli browser state [options]`, which would lead
   users (and agents reading structured help) to think
   `opencli browser state` was a valid invocation. Now:

   - `commanderPath()` injects an ancestor's leading-positional placeholder
     (extracted from its `.usage()` override) between the ancestor's name
     and the next path segment when building paths upward.
   - `commandPathFromRoot()` strips placeholder segments (e.g. `<session>`)
     from the relative `name` field so agents can still address subcommands
     by their leaf name; placeholders remain in the `command` / `usage`
     display paths.
   - `program.configureHelp({ commandUsage: ... })` is applied recursively
     to every descendant of `browser`, because commander does NOT inherit
     `configureHelp` into subcommands.

   Result:
     opencli browser <session> click --help
     -> Usage: opencli browser <session> click [target] [options]

   Daemon, plugin, adapter, profile namespaces (no `.usage()` override)
   are unaffected.

2. `skills/opencli-browser/SKILL.md` still referenced
   `opencli browser sessions`, which was removed in #1470. Replaced the
   sentence with the underlying invariant ("Bound sessions have no
   OpenCLI idle-close timer; the binding lasts until `unbind`, tab close,
   window close, or daemon restart") without mentioning the deleted
   command.

Tests:
  - cli.test.ts: structured help expectations updated to include
    `<session>` in command/usage paths (3 tests)
  - cli-argv-preprocess.test.ts: 12 tests still green
  - 1136/1137 unit+extension green (1 unrelated skip)
  - typed-error-lint baseline 189
  - silent-column-drop baseline 103
2026-05-12 20:44:29 +08:00
jakevin fa9b38cd92 feat(reddit): add whoami, home, subreddit-info read commands (#1491)
* feat(reddit): add whoami, home, subreddit-info read commands

Closes gap against jackwener/rdt-cli — three commands the existing 17 reddit
adapters were missing:

- `reddit whoami` — show the currently logged-in identity (fields:
  Username, ID, Post / Comment / Total Karma, Account Created, Gold, Mod,
  Verified Email, Has Mail, Inbox Count). Probes `/api/me.json` with
  two-pronged auth detection (401/403 OR `data.name` missing on 200 —
  Reddit returns 200 with an empty body for stale anon sessions, see PR
  #1428).

- `reddit home` — personalized Best feed (`/best.json`). Distinct from
  the public `frontpage`/`r/all` command: enforces login via the same
  two-pronged auth check rather than silently degrading to the
  unauthenticated default feed. `--limit` accepts [1, 100] — out-of-range
  raises `ArgumentError` before navigation, no silent clamp.

- `reddit subreddit-info` — subreddit metadata (Name, Title, Subscribers,
  Active Now, NSFW, Type, Description, Created, URL) from
  `/r/<X>/about.json`. Banned / private / quarantined / 404 subreddits
  raise `EmptyResultError` so the output table never holds a silent
  sentinel row.

All three use Strategy.COOKIE + siteSession:'persistent' matching the
existing reddit adapters, validate args upfront before `page.goto`, and
use the 5-kind discriminated-union pattern (kind: auth/http/missing/
exception/ok) from PR #1428 to map page.evaluate results to typed errors
on the Node side. Intermediate object keys deliberately avoid the
declared columns (`field`/`value`/`rank`/etc.) per the silent-column-drop
audit sediment from PR #1329.

Tests: 28 new (whoami 6, home 9, subreddit-info 13); full reddit suite
38/38. Audits: typed-error-lint 189/189 (0 new), silent-column-drop
103/103 (0 new). Manifest 812 → 815.

Refs: https://github.com/jackwener/rdt-cli

* fix(reddit): tighten new read command failure contracts

* fix(reddit): treat inaccessible subreddit info as empty
2026-05-12 04:10:44 +08:00
jakevin 93bc374437 chore(scripts): auto-refresh dist/ before build-manifest (#1490)
* chore(scripts): auto-refresh dist/ before build-manifest

`build-manifest.ts` is invoked via tsx so its own imports go to TS source,
but the adapter `.js` files it loads import `@jackwener/opencli/registry`
through package exports, which resolves to `dist/src/registry-api.js`.

When `dist/` is stale relative to `src/` (e.g. a contributor edits
`src/registry.ts` and runs only `npm run build-manifest` instead of the
full `npm run build`), the stale dist drops fields like `siteSession`
from the rebuilt manifest. CI catches the resulting diff via the
"cli-manifest.json is up-to-date" gate, but locally it surfaces as
mysterious unrelated diff lines for adapter files the contributor never
touched.

Add an npm pre-script that runs `tsc --build` (incremental, ~0.6s when
warm) so `npm run build-manifest` is safe to use directly. `npm run build`
is unchanged — it still does the full `clean-dist + tsc + copy-yaml +
build-manifest` sequence, and `prebuild-manifest` will be a no-op there
since TS is already compiled by the time it runs.

Verified:
- `rm -rf dist && npm run build-manifest` now restores dist via the
  pre-hook and produces a 0-line diff against committed manifest
- `npm run build` still produces the same clean output

* fix(scripts): force manifest dist refresh

* fix(scripts): avoid duplicate manifest compile
2026-05-12 04:00:24 +08:00
jakevin eb59b7444d feat(ctrip): add hotel-search + flight browser-mode commands (#1481) (#1489)
* feat(ctrip): add hotel-search + flight browser-mode commands

Closes #1481.

Two new browser-mode commands on top of the existing public `search` /
`hotel-suggest` pair:

- `ctrip hotel-search <city> --checkin --checkout [--limit]` reads
  `window.__NEXT_DATA__.props.pageProps.initListData.hotelList` on
  `hotels.ctrip.com/hotels/list`. SSR-rendered first page ships ~13
  entries; the server ignores `&pageSize=N` so limit caps at 30 with
  default 10. AuthRequiredError surfaces when Ctrip redirects to the
  captcha gate.

- `ctrip flight <from> <to> --date [--limit]` searches one-way flights on
  `flights.ctrip.com/online/list/oneway-…`. The post-load XHR is not
  currently captured by the daemon network buffer (per the known
  daemon_capture_pipeline_bug_2026_05_07 in agent memory), so rows are
  pulled from `.flight-list > span > div` cards via a position-anchored
  innerText parser. A generic `buildScrollUntilJs(selector, target)`
  helper mirrors the PR #1487 xiaohongshu scroll-until pattern with the
  selector parameterised. Round-trip + airline filters are out of scope
  for v1.

All argument validation (IATA / ISO date / city ID / limit range) fires
upfront before any `page.goto`, per the PR #1387 boundary standard. No
silent clamps, no sentinel rows: rows missing required fields are
dropped, and end-state checks raise `ArgumentError` /
`AuthRequiredError` / `EmptyResultError` as appropriate. The new
`mapHotelRow` / `pickHotelMapCoords` / `buildFlightExtractJs` /
`buildScrollUntilJs` helpers live in `clis/ctrip/utils.js` alongside the
existing suggest helpers.

Docs at `docs/adapters/browser/ctrip.md` now distinguish the public
suggest commands from the browser-mode commands and document each
command's columns + caveats.

Verified:
- 61/61 vitest tests in `clis/ctrip/ctrip.test.js` (including JSDOM
  exercises of `buildFlightExtractJs` and full `mapHotelRow` shape parity)
- `check:typed-error-lint` 189/189 (0 new)
- `check:silent-column-drop` 103/103 (0 new)
- `build-manifest` clean — 812 entries total (was 810)

* fix(ctrip): harden browser search failure contracts

* fix(ctrip): tighten browser empty-vs-parser failures
2026-05-12 03:43:32 +08:00
jakevin 43d0722264 docs(skill/adapter-author): aria-label / placeholder / title are locale-dependent (#1474) (#1488)
* docs(skill/adapter-author): warn aria-label / placeholder / title is locale-dependent

aria-label changes with the browser's UI language (chrome://settings/languages).
A button labelled `aria-label="Submit"` in English Chrome becomes
`aria-label="提交"` in Chinese Chrome, so CSS selectors hardcoded to one
locale silently match zero elements — `notEmpty` / `types` never fire because
the adapter just returns 0 rows.

First-principles framing in adapter-template:
  - Split DOM attributes into "locale-stable identifiers" (id / class /
    data-testid / data-* / role) vs "locale-dependent text" (aria-label /
    title / placeholder / alt / textContent)
  - Primary selectors must use locale-stable identifiers; locale-dependent
    text is a last-resort tiebreaker
  - When a site (e.g. ChatGPT web) only exposes aria-label, link the existing
    `clis/chatgpt/utils.js` fallback-list pattern (en + zh-CN + stable
    fallback at the front)

Explicitly document why we are NOT building a `find --i18n "zh:提交"` flag
(over-engineering: same indirection as a fallback list plus a translation
dictionary to maintain) and why we are NOT locking Chrome's locale at launch
(opencli doesn't launch Chrome — it connects to the user's running browser
via CDP, so forcing en-US would break users who intentionally run Chinese UI).

Adds pitfall #11 to success-rate-pitfalls.md for the agent-facing checklist.

Closes #1474

* docs(skill): tighten locale selector guidance
2026-05-12 03:13:53 +08:00
jakevin 7df9b80dea fix(xiaohongshu+rednote): scroll until enough rows for --limit > 13 (#1471) (#1487)
* fix(xiaohongshu+rednote): scroll until enough rows are rendered instead of fixed 2x autoScroll

Both search adapters previously called `page.autoScroll({ times: 2 })` which
hard-capped extraction at ~13 notes (xiaohongshu lazy-loads ~5-7 notes per
scroll round) regardless of `--limit`. Reported in #1471: `--limit 40` still
only returned 13 results.

Replace with a dynamic `buildScrollUntilJs(targetCount, maxScrolls=15)`
helper that:
  - counts visible `section.note-item` rows (excluding `.query-note-item`
    related-search rows)
  - breaks early when count >= target
  - breaks early after 2 consecutive scrolls add no new rows (DOM plateaued,
    feed exhausted)
  - hard caps at 15 iterations to bound runtime

Exported from xiaohongshu and reused by rednote (same DOM shape) instead of
duplicating the IIFE.

Fixes #1471

* fix(xiaohongshu): tighten search scroll boundary
2026-05-12 02:59:15 +08:00
jakevin 23e1161ffd chore(release): 1.7.18 (#1486)
Release / release (push) Has been cancelled
2026-05-12 02:50:24 +08:00
jakevin dccf9d00e9 fix(doctor): pass session to connectivity probe (#1485)
* fix(doctor): pass session to connectivity probe

* fix(doctor): isolate probe session name

* fix(cli): mark browser session as required
2026-05-12 02:49:21 +08:00
jakevin b476d2364f fix(doubao/ask): restore Assistant detection after 2026-05 DOM refactor (#1484)
* fix(doubao/ask): restore Assistant turn detection after 2026-05 DOM refactor

Doubao reworked message-item wrappers and dropped all `receive-message` /
`bg-g-receive-msg-bubble` markers from assistant turns. The legacy 6
`itemSelectors` (`item-kDun2N`, `union_message`, `message-block-container`,
`data-message-id`, `bg-g-send-msg-bubble`, `bg-g-receive-msg-bubble`) match 0
elements on the new DOM, so `getTurnsScript` returned [] and `getDoubaoTurns`
fell through to the whole-page transcript scraper. Assistant text came back as
sidebar labels + history titles + adjacent conversation snippets concatenated
with the real reply — silent SELECTOR failure (no thrown error).

Two minimal changes in `clis/doubao/utils.js` `getTurnsScript`:

1. `itemSelectors`: prepend `[class*="inner-item-"]` and `[class*="top-item-"]`
   — the new 2026-05 wrappers. Outer wins via existing ancestor-keep dedup
   below, so we get one root per turn (not one per nested chunk).
2. `getRole`: add a third fallback branch — if the root matches
   `inner-item-*` / `top-item-*`, contains `.flow-markdown-body`, and has NO
   `bg-g-send-msg-bubble` marker (User detection still works), treat it as
   Assistant. `.flow-markdown-body` is already in `messageTextSelectors`, so
   text extraction kicks in unchanged.

Test added asserting both new wrappers and the `.flow-markdown-body` assistant
fallback are present in the generated script.

Fixes #1478

* test(doubao): cover refactored assistant turns
2026-05-12 02:46:53 +08:00
Kagura 6d84009ee8 fix(youtube): request srv3 format for caption URLs (#1420) (#1422)
* fix(youtube): request srv3 format for caption URLs (#1420)

YouTube may return empty responses when caption URLs lack an explicit format
parameter. This adds fmt=srv3 (standard YouTube XML caption format) to the
caption URL when no fmt parameter is already present, with a fallback to the
original URL if srv3 also returns empty.

Also adds HTTP status checking before reading the response body, preventing
silent failures on non-200 responses.

Fixes #1420

* fix(youtube): preserve caption fetch failures

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-12 02:44:21 +08:00
Gaurav Saxena 150551be8c feat(reddit): add reply command for replying to comments (#1428)
* feat(reddit): add reply command for replying to comments

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

* fix(reddit/reply): replace silent-sentinel rows with typed errors

reply.js originally mirror-copied comment.js's failure pattern: returning
[{ status: 'failed', message: 'HTTP 403' }] on auth/HTTP/Reddit errors and
relying on the caller to inspect the row instead of throwing. That's the
'silent-sentinel' anti-pattern from typed-errors.md — failures should
surface as typed errors so an agent can actually branch on them.

Round 21 lesson (f) — "grandfathered-not-exempt + helper-refactor boundary
is new" — applies: comment.js / upvote.js / save.js can stay grandfathered,
but a brand-new file does not inherit that exemption.

Changes:
- Throw AuthRequiredError when /api/me.json or /api/comment returns 401/403,
  or when /api/me.json returns 200 but data.name is missing (stale anon
  session — empty modhash alone isn't a strong enough signal).
- Throw CommandExecutionError for non-2xx HTTP and for non-empty
  data.json.errors (e.g. RATELIMIT, NO_TEXT, TOO_OLD).
- Drop the over-defensive `if (!page) throw ...` — registry guarantees a
  page object when browser:true.
- Intermediate result object uses `kind` discriminator + `detail` /
  `httpStatus` / `where` keys that don't overlap with columns
  ['status','message'], so the silent-column-drop audit stays quiet
  (per PR #1329 sediment).

Verified:
- npx tsc --noEmit clean
- node scripts/check-typed-error-lint.mjs → 189/189, 0 new
- node scripts/check-silent-column-drop.mjs → 103/103, 0 new
- npx vitest run clis/reddit src/convention-audit → 11/11 pass
- node ./dist/src/main.js validate → 0 errors

Success path is unchanged: still returns
[{ status: 'success', message: 'Reply posted on t1_<id>' }].

* fix(reddit): harden reply command contract

* fix(reddit): reject suffixed reply urls

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-12 02:33:09 +08:00
Benjamin Liu 64ac362a40 feat(rednote): add rednote.com adapter mirroring xiaohongshu read commands (#1136) (#1475)
* feat(rednote): add rednote.com adapter mirroring xiaohongshu read commands (#1136)

Implements rednote.com support as discussed in issue #1136. The mainland
xiaohongshu adapter stays in place; international users redirected to
www.rednote.com now have a CLI without a copy-pasted adapter.

Issue #1136 documents that xiaohongshu and rednote share DOM selectors,
URL paths, API paths, response schema, cookies, and the xsec_token auth
mechanism. The only material differences:

  Layer            xiaohongshu                rednote
  Web host         www.xiaohongshu.com        www.rednote.com
  API host         edith.xiaohongshu.com      webapi.rednote.com
  Security host    fe-static.xhscdn.com       as.rednote.com
  Cookie root      .xiaohongshu.com           .rednote.com
  Search gate      Inline text                Full-screen modal + text

## Architecture (minimal)

`clis/xiaohongshu/*` keep all selector / regex / extraction logic. Each
command file is touched minimally to export the IIFE or pipeline so the
sibling adapter can reuse it:

  search.js          + export const buildSearchExtractJs(webHost)
                     + export const command = cli({...})
  note.js            + export const NOTE_EXTRACT_JS
                     + export const command = cli({...})
  comments.js        + export function buildCommentsExtractJs(withReplies)
                     + export parseCommentLimit
                     + export const command = cli({...})
  download.js        + export function buildDownloadExtractJs(noteId)
                       (CDN allowlist now includes rednote alongside xhscdn)
                     + export const command = cli({...})
  user.js            + export const USER_SNAPSHOT_JS
                     + export const command = cli({...})
  feed.js            + export function buildFeedPipeline(webHost)
                     + export const command = cli({...})
  notifications.js   + export function buildNotificationsPipeline(webHost)
                     + export const command = cli({...})
  note-helpers.js    buildNoteUrl now accepts `cookieRoot` + `signedUrlHint`
                     options (defaults preserved so xhs callers and tests
                     are unchanged)
  user-helpers.js    buildXhsNoteUrl / extractXhsUserNotes accept an
                     optional `webHost` argument (default xhs)

The `export const command = cli({...})` pattern matches twitter/lists.js
and clis/discord-app/*; without it the build-manifest scanner attributes
xhs's command to whichever rednote sibling triggered the transitive
import first.

## clis/rednote/ — thin shims

Each rednote command file imports the relevant builder / constant from
its xiaohongshu sibling and calls `cli()` with the rednote host triple.
No selectors, regexes, or extraction logic are duplicated.

  search.js          imports buildSearchExtractJs + noteIdToDate
                     declares its own WAIT_FOR_CONTENT_JS (modal + text
                     login-gate variants — the one xhs behaviour that
                     genuinely differs)
  note.js            imports NOTE_EXTRACT_JS + buildNoteUrl + parseNoteId
  comments.js        imports buildCommentsExtractJs + parseCommentLimit
                     + buildNoteUrl + parseNoteId
  download.js        imports buildDownloadExtractJs + buildNoteUrl + parseNoteId
  user.js            imports USER_SNAPSHOT_JS + extractXhsUserNotes
                     + normalizeXhsUserId

## Scope (initial)

Ships the five commands verified live against the user's logged-in
rednote.com session: search / note / comments / user / download.

`feed` and `notifications` are intentionally left out. Both rely on
intercepting the xiaohongshu Pinia store at the `homefeed` / `you`
capture pattern; live verification on rednote returns `tap → dict
(error)` for the feed step, so shipping them would surface a broken
contract. The mainland xiaohongshu commands continue to work. Adding
the rednote-side feed / notifications is straightforward follow-up
work once someone with rednote access maps the network surface.

Creator-center commands (publish, creator-*) have no rednote
counterpart and stay xiaohongshu-only, per the reporter's note in #1136.

## Verification

  - clis/xiaohongshu/ + clis/rednote/: 103/103 tests green
  - npx tsc --noEmit: clean
  - npm run build: 807 manifest entries (xhs 13 + rednote 5 + everything
    else preserved)
  - silent-column-drop / typed-error-lint: 103 / 189 baseline entries,
    no new violations
  - Live verify against the user's rednote.com session:
      rednote search "travel" --limit 1 → real note row
      rednote note <signed-url>         → 7 field/value rows
      rednote comments <signed-url> --limit 3 → 3 top-level rows
      rednote user 5b21f6564eacab3b38f05c39 --limit 2 → 2 profile notes
    Spaced 15–30s between runs per the xhs/rednote rate-limit guidance;
    no write commands invoked. Regression check: xiaohongshu/feed on
    the existing mainland session still returns the standard 6-field
    rows after the refactor.

Closes #1136

* fix(rednote): tighten adapter failure boundaries

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-12 02:32:25 +08:00
jakevin c1af68b909 chore(release): 1.7.17 (#1483)
Release / release (push) Has been cancelled
2026-05-12 02:24:16 +08:00
E2ern1ty b262d8ffd5 feat(chatgpt): support local image uploads (#1476)
* feat(chatgpt): support local image uploads

* chore: refresh cli manifest

* fix(chatgpt): harden image upload flow

* fix(chatgpt): validate image uploads before navigation

* fix(chatgpt): keep send fallback click in sync

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 17:52:16 +08:00
jakevin 987d9cba48 refactor(doctor): drop --no-live and --sessions flags + dead protocol (#1470)
Doctor's job is browser-bridge health diagnosis. The `--no-live` flag
let users skip the connectivity probe (= the core diagnostic), and
`--sessions` listed automation sessions (a separate concern not part of
health). Both flags accreted features that violated the command's
first-principles purpose.

Cleanup chain (removing dead code surfaced by the flag removal):
- `--no-live` / `--sessions` flags removed from `opencli doctor`
- `DoctorOptions.live` / `DoctorOptions.sessions` removed
- `DoctorReport.sessions` removed
- `[SKIP] Connectivity` render branch removed (always-live now)
- `listSessions()` removed (only consumer was doctor)
- `'sessions'` action removed from daemon-client protocol type
- `BrowserSessionInfo` type removed (no remaining consumers)
- extension `handleSessions` action handler removed (1.0.12)
- extension test "reports sessions per session" removed
- `OPENCLI_BROWSER_IDLE_TIMEOUT` test rewired to 'cookies' action

Verification:
- root typecheck + extension typecheck pass
- doctor.test.ts 17/17 pass
- extension/background.test.ts 49/49 pass
- typed-error-lint 189/189 baseline
- silent-column-drop 103/103 baseline
- build + extension build green
2026-05-11 13:10:06 +08:00
jakevin 467fdd0b62 refactor(adapter): rename site browser reuse to persistent sessions (#1462) 2026-05-11 04:56:34 +08:00
jakevin 9c06e84c89 refactor(browser): replace workspaces with sessions (#1461) 2026-05-11 04:26:51 +08:00
jakevin b56bebdd7a chore(release): 1.7.16 (#1460)
Release / release (push) Has been cancelled
2026-05-11 03:27:06 +08:00
jakevin 1d2e606498 perf(chatgpt): replace fixed-sleep waits with selector-based readiness (D3) (#1456)
Continues the wait→event sweep started in #1449 (deepseek) / #1452 (claude). Same
3-bucket classification across the chatgpt adapter:

CONVERT (5)
- utils.js ensureOnChatGPT/startNewChat: 2s settle → wait({selector: composer, 8s})
- utils.js getConversationList: openSidebar 1.5s + fallback goto 2.5s → selector
- detail.js: post-/c/<id> goto 2s → wait({selector: message bubble, 10s})

DELETE (6)
- ask/send/read.js: standalone 2s settle after ensureOnChatGPT/startNewChat
  (those helpers now wait for composer internally — settle is redundant)
- utils.js sendChatGPTMessage: 0.5s post-closeBtn + 1.5s pre-composer-focus
- utils.js getConversationList: 2s settle after ensureOnChatGPT (helper waits
  for composer; we re-check sidebar selector independently)

KEEP (6)
- utils.js sendChatGPTMessage: ProseMirror React debounce ticks
- utils.js waitForChatGPTResponse: streaming response polling cadence

Verification:
- npx vitest run clis/chatgpt → 20/20 (4 files)
- Full vitest → 3379 pass / 1 skip (2 errors are the unrelated daemon EADDRINUSE
  flake also seen on #1449/#1452/#1454)
- tsc --noEmit clean / typed-error-lint 189 / silent-column-drop 103 unchanged
- npm run build → 802 manifest entries

Diff: +50 / -13 across 5 files (ask.js, detail.js, read.js, send.js, utils.js).
No typed-error harmonization needed — chatgpt's existing helpers already gate
through ensureChatGPTLogin (AuthRequiredError) and ensureChatGPTComposer
(CommandExecutionError) correctly.
2026-05-11 03:22:39 +08:00
jakevin 864af48b0b docs(readme): list tg-cli, discord-cli, wx-cli in External CLI sections (#1459)
Follow-up to feat #1458 (registering tg-cli/discord-cli/wx-cli in
src/external-clis.yaml) — README and README.zh-CN had not been updated
to reflect the new entries.

Updates four spots in each README:
- intro paragraph that names example external CLIs
- "CLI Hub" highlight bullet
- "OpenCLI is not only for websites" bullet list
- the External CLI table itself
2026-05-11 03:19:53 +08:00
jakevin d0127e188a feat(external): register tg-cli, discord-cli, wx-cli (#1458)
Add three local-first messaging CLIs to the External CLI registry so
agents can discover and install them via `opencli external install`:

- `tg-cli` (binary `tg`) — Telegram local sync/search/export via MTProto
- `discord-cli` (binary `discord`) — Discord local sync/search/export
- `wx-cli` (binary `wx`) — WeChat local data CLI

Refresh the External CLI list in skills/opencli-usage/SKILL.md so the
agent-facing skill names stay in sync.
2026-05-11 03:19:27 +08:00
jakevin cd93910fdd feat(help): structured help for daemon/plugin/adapter/profile namespaces (#1407)
Extends A0 (PR #1404) by dogfooding `installCommanderNamespaceStructuredHelp`
on the four remaining built-in Commander namespaces:

- `opencli daemon --help -f yaml|json`
- `opencli plugin --help -f yaml|json`
- `opencli adapter --help -f yaml|json`
- `opencli profile --help -f yaml|json`

Each emits the same payload shape as `browser`: namespace metadata, every
leaf command's positionals + command_options + description + usage,
namespace_options (empty for these), and program-level global_options.
Agents can fetch every leaf's contract in a single call — no per-leaf
`--help` follow-ups.

Each namespace snapshots its original description at declaration time
because `applyRootSubcommandSummaries(program)` later overwrites
`.description()` with a child-name listing; without the snapshot,
structured help would surface `"restart, status, stop"` instead of
`"Manage the opencli daemon"`. Tests lock the snapshot semantics for
`adapter` explicitly.

Tests: 138/138 (4 new — one per namespace, covering description
preservation, leaf names, positionals, command_options).
Typecheck + build clean.
2026-05-11 02:50:09 +08:00
jakevin 64c67c331e chore(extension): rename adapter tab group (#1457)
* chore(extension): rename adapter tab group

* test(extension): update adapter group wording
2026-05-11 02:12:37 +08:00
jakevin 6d87142821 perf(reddit): opt 13 browser adapters into shared site-tab lease (#1455)
* perf(reddit): opt 13 browser adapters into shared site-tab lease

Adds `browserSession: { reuse: 'site' }` to every reddit adapter that
already runs `browser: true` on `domain: 'reddit.com'`. Same metadata-only
follow-up to the twitter sweep merged in #1454 — the framework's
`shouldRunPreNav` short-circuit (src/execution.ts:190) skips the redundant
domain-root pre-nav when a sibling adapter already has the tab on
reddit.com, and idle-bound tabs are reused under the `site:reddit` bucket
until expiry.

Scope (13 files, all on `domain: 'reddit.com'` + `Strategy.COOKIE`):
- read (9): frontpage / popular / saved / search / subreddit / upvoted /
  user / user-comments / user-posts
- write (4): comment / save / subscribe / upvote

Excluded:
- `hot.js` (no browser:true — public Reddit JSON API, no tab)
- `read.js` (Strategy.COOKIE but no browser:true — non-browser pipeline)

No logic changes; only metadata + manifest regeneration.

Verification:
- npm run check:typed-error-lint → 189/189 unchanged
- npm run check:silent-column-drop → 103/103 unchanged
- npm run test:adapter → 264/264 passed (2146 tests)
- npx vitest run --project unit → 72/72 passed (unrelated EADDRINUSE
  flake on daemon.test.ts port 19825, also seen on #1454/#1452)
- tsc --noEmit clean

* fix(reddit): include read in site browser session reuse
2026-05-11 02:12:21 +08:00
Ethon 357dec5969 fix(xiaohongshu): fallback to base64 upload when CDP setFileInput returns 'Not allowed' (#1374)
The uploadImages function catches errors from page.setFileInput and only
falls back to the legacy base64 DataTransfer method when the message
contains 'Unknown action' or 'not supported'. However, Chrome can also
return 'Not allowed' (code -32000), which was not handled — causing the
publish command to fail instead of using the fallback.

Add 'Not allowed' to the fallback condition so image upload works even
when CDP file injection is blocked by Chrome's security policy.

Co-authored-by: together <together@togetherdeMac-mini.local>
2026-05-11 01:56:19 +08:00
Benjamin Liu 674f0e1105 feat(openreview): add author command for ID-explicit publication lookup (#1365)
* feat(openreview): add author command for ID-explicit publication lookup

Closes the missing leaf in the openreview adapter. Among the public-strategy
academic adapters, dblp and arxiv both already ship an `author` command for
ID-explicit publication lookup; openreview only had `search` (full-text),
`paper` (detail by note id), `reviews` (thread by forum id) and `venue`
(listing by invitation / venue text). There was no way to ask "give me every
submission this author put on OpenReview, newest first."

`openreview author <profile>`:
  - takes a canonical profile id (`~First_LastN`); validated by
    `requireProfileId` so a dblp PID or a bare name fails before any
    network call,
  - hits `/notes?content.authorids=~<id>&limit=<n>&sort=cdate:desc`,
  - returns rank-ordered rows with the same shape as `openreview search`
    (id / title / authors / venue / pdate / url),
  - throws `EmptyResultError` when the profile has no public submissions
    instead of returning an empty list,
  - inherits the typed-error envelope from `openreviewFetch` so network
    failure, non-200, malformed JSON, and in-band error envelopes all
    surface as `CommandExecutionError`.

Tests: 6 new `it` blocks plus 1 updated registration test in
`clis/openreview/openreview.test.js`.

  - `requireProfileId` (1 block, 9 assertions): accepts canonical
    `~First_LastN`, `~Bo_Liu17`, and a multi-segment middle-name id;
    rejects empty, whitespace, missing tilde, missing trailing number,
    embedded space, and a dblp-style PID.
  - 5 author runtime cases covering pre-network ArgumentError, empty
    result, non-200, fetch network error, and the happy path with a
    request-shape assertion (`content.authorids` filter + `cdate:desc`
    sort).
  - Registration test extended to expect five commands and lock the new
    `columns` contract.

Manifest auto-regenerated to register the new command.

Live-verified end to end against `~Yoshua_Bengio1`: the most recent ICLR
2026 workshop submissions return with the expected fields. A malformed
profile is rejected before any HTTP call. A nonexistent profile yields
`EMPTY_RESULT`.

* fix(openreview): accept real profile id slugs

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 01:53:46 +08:00
UtoPiaCD 2034e90337 fix(chatgpt): use locale-stable send button selector (#1354)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 01:53:08 +08:00
jakevin a77d9c930a perf(claude): replace fixed-sleep waits with selector-based readiness (#1452)
Convert 9 of 18 page.wait(N) calls in clis/claude/ from fixed-duration
sleeps to event-based readiness checks (page.wait({ selector, timeout }),
backed by MutationObserver). Mirrors the deepseek D1 template (PR #1449).

* Page-ready waits (5 converted): utils.js:29 (ensureOnClaude composer),
  utils.js:114 (getConversationList recents links), new.js:19 (composer),
  detail.js:24 (.font-claude-response message bubble), send.js:26 (composer).
  Each resolves as soon as the selector matches, swallowing the timeout so
  downstream typed-error helpers (ensureClaudeLogin / ensureClaudeComposer
  / EmptyResultError) still surface the right error when the selector
  never mounts (login redirect, empty conversation, etc).

* Dropdown waits (2 converted): utils.js:150 (selectModel post-trigger),
  utils.js:178 (setAdaptiveThinking post-trigger). Wait for menuitemradio
  / menuitem to mount instead of a fixed 0.6 s sleep.

* Resume conversation wait (1 converted): ask.js:51 — wait for the resumed
  message bubble (MESSAGE_SELECTOR) instead of a fixed 2 s sleep.

* Settle/redundant waits removed (6): ask.js:55 standalone settle (next
  ensureClaudeComposer queries composer presence directly via getPageState);
  ask.js:83 / ask.js:90 post-toggle settles (next CDP eval flushes React
  state between roundtrips); ask.js:103 pre-waitForResponse settle (the
  polling loop's first 3 s tick already covers this); read.js:20 post-
  ensureOnClaude sleep (ensureOnClaude now waits for the composer selector
  itself); send.js:29 post-ensureOnClaude sleep (same).

Three remaining page.wait(N) calls are kept: utils.js:231 post-input
1.2 s React debounce inside sendMessage (the ProseMirror editor needs a
debounce window before the send button enables; reducing this risks
silent send-button-disabled drops), and the 3 s / 1 s polling ticks in
waitForResponse / waitForFilePreview (already polling patterns, out of
scope for D-track wait→event sweep).

Targeted tests: clis/claude + src/browser 389/389 pass; tsc clean;
build clean; typed-error 189/189 baseline (no new); silent-column-drop
103/103 baseline (no new).

D2 in the LLM-adapter wait→event sweep started by deepseek (D1, #1449).
2026-05-11 01:52:41 +08:00
jakevin cb64192f06 perf(deepseek): replace fixed-sleep waits with selector-based readiness (#1449)
Convert 10 of 18 `page.wait(N)` calls in clis/deepseek/ from fixed-duration
sleeps to event-based readiness checks (`page.wait({ selector, timeout })`,
backed by MutationObserver):

* Page-ready waits (5): utils.js:46, ask.js:38/52, detail.js:28, new.js:19
  now wait for the composer textarea (TEXTAREA_SELECTOR) or message bubble
  (MESSAGE_SELECTOR) to mount before continuing. Resolves as soon as the
  selector matches instead of always sleeping the full duration.
* Settle/redundant waits removed (5): ask.js:56 standalone settle (already
  covered by upstream selector waits); ask.js:79/105 post-toggle settles
  (next CDP eval gives React time to flush aria-checked updates); ask.js:118
  pre-waitForResponse settle (the polling loop's first 3 s tick already
  covers this); read.js:19 post-ensureOnDeepSeek sleep (ensureOnDeepSeek
  now waits for the textarea selector itself).
* `new.js` now throws CommandExecutionError when the composer fails to
  mount within 8 s instead of silently returning "New chat started" on a
  half-loaded or logged-out page.

Eight remaining `page.wait(N)` calls are kept: in-loop polling ticks in
waitForResponse / pickResumeUrl / getConversationList / waitForFilePreview
/ send-button-enable polling (these are already polling patterns and
out of scope for D1), and the native-input flush + textarea-mount poll in
send.js.

Targeted tests: clis/deepseek 49/49, src/browser 355/355 pass; build,
typecheck, typed-error and silent-column-drop audits clean.

Proof template for the LLM-adapter wait-cleanup follow-ups.
2026-05-11 01:52:19 +08:00
jakevin 833c1c872f perf(twitter): enable browserSession reuse:site on 17 read-only adapters (PR B) (#1454)
Read-only Twitter/X adapters now declare `browserSession: { reuse: 'site' }`,
matching the LLM-site adapters (claude/gemini/yuanbao/etc.) and unblocking
the perf wins WAWQAQ called out for the 35s→9s/3.4s thread.js progression
(#OpenCLI:3889b5cf):

- Tab lease shared across calls under `site:twitter` until idle expiry, so
  the second-and-later command pays no cold-start tab cost.
- Framework's domain-root pre-nav (`https://x.com`) is skipped on subsequent
  calls when the reused tab is already on x.com (`shouldRunPreNav` →
  `isDomainRootPreNav` + `urlMatchesDomain` short-circuit at
  `src/execution.ts:190`).

Files (17 read-only adapters):
- Strategy.COOKIE × 13: article, bookmark-folder, bookmark-folders,
  bookmarks, download, following, likes, list-tweets, lists, profile,
  thread, timeline, trending, tweets
- Strategy.UI × 1: followers
- Strategy.INTERCEPT × 2: notifications, search

Insertion point in each file: after `browser: true,` (or after `strategy:`
in download.js which omits the explicit `browser:` field), matching the
convention used by yuanbao/read.js, claude/read.js, etc.

Manifest regenerated (cli-manifest.json: +85/-17 — 17 entries gain the
`browserSession: { reuse: "site" }` block).

Verification:
- npx tsc --noEmit clean
- npx vitest run clis/twitter → 218/218 pass (25 files)
- npx vitest run src/convention-audit.test.ts → 8/8 pass
- typed-error-lint baseline 189/189 (no new violations)
- silent-column-drop baseline 103/103 (no new violations)

Scope notes (intentionally NOT in this PR):
- Write adapters (post/reply/quote/like/retweet/bookmark/follow/list-add/
  list-remove/delete/hide-reply/block/accept/follow) are kept as one-shot
  by default — `reuse: 'site'` for write paths is a separate decision
  about action idempotency under tab reuse.
- The thread.js / timeline.js comments still say "Cookie context
  auto-established by framework pre-nav"; the deeper truth (CDP
  `getCookies({url})` is origin-independent) was a framing nit on PR C
  (#1451) — left as a doc-only follow-up to keep this PR's diff focused
  on the perf gain.

Refs: #OpenCLI:3889b5cf (WAWQAQ msg=fa209a2c, msg=35c90460, msg=838128ef
"你们继续做啊… 后面还有那么多其他的东西呢")
2026-05-11 01:51:59 +08:00
jakevin a92f382c2d refactor(browser): split interactive and automation windows 2026-05-11 01:48:18 +08:00
jakevin ff7d741a4c perf(twitter): drop redundant goto+wait — framework auto pre-navs (PR C) (#1451)
* perf(twitter): drop redundant goto+wait — framework auto pre-navs (PR C)

Twelve twitter read adapters did `await page.goto('https://x.com'); await
page.wait(2~3)` purely to establish cookie context for the subsequent
`document.cookie` read. After PR #1450 hoisted those reads to
`page.getCookies({url})` (which queries the CDP cookie store directly,
no navigation needed), the explicit goto+wait became dead.

The framework already pre-navigates to `https://${domain}` for any
adapter declaring `Strategy.COOKIE + domain` (`src/registry.ts:191`),
so the cookie store is populated before `func` runs. The 2-3s
`page.wait` was the slowest part of the redundant call.

Files (all read-only, all ct0/cookie-only):
- bookmark-folder / bookmark-folders / bookmarks
- following / likes / list-add / list-remove / list-tweets / lists
- thread / timeline / tweets

Out of scope (kept as-is): goto calls that navigate to a *specific*
URL needed for content/SPA shell — `trending` (`/explore/tabs/trending`),
`notifications` (`/home`), `article` (`/i/article/{id}`), `profile`
(`/${username}`), and `list-add` line 133 (`/${username}` for UI ops).

Verification:
- npx tsc --noEmit ✓
- npx vitest run clis/twitter → 216/216 ✓
- typed-error-lint 189/189, 0 new ✓
- silent-column-drop 103/103, 0 new ✓

* fix(twitter): keep list UI root navigation
2026-05-11 01:24:07 +08:00
jakevin 60dbbd4baa perf(adapters): hoist cookie reads to page.getCookies (Tier 1, 25 files) (#1450)
* perf: replace document.cookie reads with page.getCookies({domain}) (Tier 1 cookie API sweep)

Prior pattern in 25 adapter files round-tripped through `page.evaluate(\`document.cookie.split…\`)` to extract a single cookie value (CSRF token, session ID, etc.). CDP's `page.getCookies({domain})` reads the cookie store directly with zero JS-execution overhead.

Files touched (sites: twitter / linkedin / maimai / youtube):

- twitter (15): thread, timeline, list-add, bookmark-folders, following, list-tweets, bookmarks, list-remove, tweets, bookmark-folder, likes, lists, trending — direct 4-line replacement (cookie was outside `page.evaluate`); article, profile — hoisted ct0 read OUT of `page.evaluate` and threw `AuthRequiredError` upfront so unreachable in-evaluate auth branches got cleaned up too.

- linkedin/search.js — JSESSIONID was read inside the per-batch fetch loop's `page.evaluate`; hoisted once before the loop and pass `csrf` value into the template via `JSON.stringify`.

- maimai/search-talents.js — csrftoken cookie hoisted via getCookies; meta-tag fallback preserved inside `page.evaluate` (reached only when no cookie). Also converted the `page.evaluate(async (body) => …, body)` Playwright-style call to OpenCLI's template-string form so the helper actually runs.

- youtube — `SAPISID_HASH_FN` (used by like / unlike / subscribe / unsubscribe) reworked: sapisid is now passed in as a parameter; new `readYoutubeSapisid(page)` helper reads it via CDP. The HMAC-SHA1 compute still happens browser-side (Web Crypto), only the cookie read is hoisted.

Tests updated where mocks specifically referenced `document.cookie` (twitter following / bookmark-folder / bookmark-folders) to mock `getCookies` instead.

Verification:
- `npx tsc --noEmit` clean
- `npx vitest run clis/twitter clis/linkedin clis/youtube` → 264/264 pass
- typed-error-lint 189/189 (no new violations)
- silent-column-drop 103/103 (no new violations)

Scope notes (not in this PR):
- `goto + wait` redundancy and `browserSession: { reuse: 'site' }` rollout are scoped to follow-up PRs B and C per the #OpenCLI:3889b5cf thread plan.
- `document.cookie.match(...)` patterns (instagram 8 / xiaoe / qwen / hupu / tiktok / 1point3acres — ~13 files) are outside the original \`document.cookie.split\` audit scope and will follow as a Tier 1 expansion sweep.

* fix(adapters): read auth cookies by url scope
2026-05-11 01:06:48 +08:00
jakevin 8f0958a295 chore(release): 1.7.15 (#1448)
Release / release (push) Has been cancelled
- bump opencli to 1.7.15 (was 1.7.14)
- extension stays at 1.0.9 (already bumped during the release cycle)
- finalize CHANGELOG: move Unreleased to 1.7.15 with date

Major release: Browser Agent Runtime project (Phase 0/1/2) — alignment
with vercel-labs/agent-browser model. CDP-primary input, AX snapshot/refs
with stale recovery, semantic locators across all primitives, full form
toolbelt (hover/focus/dblclick/check/uncheck/upload/drag/wait-download),
annotated screenshots, and same-origin iframe AX routing.
2026-05-10 23:12:43 +08:00
jakevin aa6696f6ce feat(browser): add annotated screenshot refs (#1433) 2026-05-10 22:15:56 +08:00
jakevin accdd970a4 test(browser): add real Chrome AX smoke (#1445)
* test(browser): add real Chrome AX smoke

* fix(browser): attach cross-origin frame targets directly

* fix(browser): resolve frame target by URL

* test(browser): include frame target URL in AX smoke

* fix(browser): discover iframe targets before routing

* fix(browser): resolve iframe targets through CDP

* fix(browser): auto-attach iframe targets for routing

* test(browser): make cross-origin AX smoke a capability probe

* docs(browser): mark cross-origin AX as best-effort

* ci(browser): keep AX smoke out of normal e2e sweep
2026-05-10 21:07:54 +08:00
jakevin 1364a11ab2 feat(browser): add semantic locators to input actions
Add semantic locator flags to browser type/fill/select while preserving explicit target syntax.
2026-05-10 19:54:55 +08:00
jakevin 3b44f901eb fix(browser): enable AX in cross-origin frame targets
Enable the Accessibility domain inside cross-origin frame target sessions before AX tree fetches and stale ref recovery.
2026-05-10 19:45:56 +08:00
jakevin 19976723c1 feat(browser): route AX refs through cross-origin frames
Route AX snapshot and AX ref click CDP calls through attachable cross-origin frame targets. Bump Browser Bridge extension to 1.0.9 for frame target routing.
2026-05-10 17:28:09 +08:00
jakevin 65903a09ff feat(browser): wait for downloads (#1441) 2026-05-10 17:15:31 +08:00
jakevin bfe7116e82 feat(browser): extend semantic locators to actions (#1440) 2026-05-10 17:01:18 +08:00
jakevin 4e4bef6474 feat(browser): add drag command (#1439) 2026-05-10 16:54:11 +08:00
jakevin 98fcce7bd3 feat(browser): add upload command (#1438) 2026-05-10 16:50:40 +08:00
jakevin 6e1c56e1e6 feat(browser): add check and uncheck (#1437) 2026-05-10 16:32:29 +08:00
jakevin b69b2e384d feat(browser): add hover focus and dblclick (#1435) 2026-05-10 16:18:19 +08:00
jakevin 76b34b7e87 feat(browser): add semantic locator flags (#1434)
* feat(browser): add semantic locator flags

* fix(browser): report semantic read match totals
2026-05-10 16:05:02 +08:00
jakevin 19130ab1af fix(e2e): match fake daemon version to running CLI (#1432)
PR #1399 added auto-restart of stale daemons in BrowserBridge
(daemonVersion ≠ PKG_VERSION → restart). The browser-tabs e2e fake
daemon hard-coded `daemonVersion: 'test'`, so every test reported as
stale and the bridge tried to /shutdown the fake daemon — which has no
shutdown endpoint — causing all 4 tests in the file to exit with code 1.

This has been the failing signal in `e2e-headed (ubuntu-latest)` and
`e2e-headed (macos-latest)` on every main push since #1399.

Read PKG_VERSION from package.json once at module load and feed that to
the fake /status response. The fake daemon now matches the running CLI
so the stale-daemon path is not triggered.

Verification:
- npx tsc --noEmit clean
- npm run build clean
- npx vitest run --project e2e tests/e2e/browser-tabs.test.ts → 4/4 pass
2026-05-10 15:26:52 +08:00
Henry 85ea18c93b feat(dianping): resolve unknown cities live from www.dianping.com (#1429)
* feat(dianping): resolve unknown cities live from www.dianping.com

The static CITY_ID map in clis/dianping/utils.js only covers ~20 cities,
so passing --city 汕头 (or any other Chinese name / pinyin slug not on
that list) fails with ArgumentError. Adding the missing cityIds by hand
doesn't scale to dianping's full city list and silently goes stale when
the site renumbers cities.

This change adds an async resolver that falls back to dianping.com when
the static map misses:

  - Numeric input → pass through unchanged.
  - Static map hit → fast path, no network (utils.CITY_ID untouched).
  - Pinyin slug (e.g. "shantou") → goto /<slug>, parse cityId out of
    any /search/keyword/{id}/ link rendered on the per-city landing page.
  - Chinese name (e.g. "汕头") → goto /citylist, walk anchors to build a
    Chinese-name → pinyin map, then resolve the slug as above.

Resolved (input → cityId) pairs are memoized per-process so repeat
searches skip both navigations.

Implemented as a new module (clis/dianping/cityResolver.js) so utils.js
stays minimal and the existing synchronous resolveCityId / CITY_ID API
keeps working for direct callers and tests.

Tested:
  - Unit tests cover null/numeric/static fast paths, pinyin fallback +
    cache, Chinese-name fallback via /citylist + cache for both forms,
    rejection of garbage input, rejection of Chinese names not on
    /citylist, and CommandExecutionError when the per-city page lacks
    a /search/keyword/{id}/ link.
  - JSDOM tests cover the pure DOM extractors (buildCitylistMap and
    extractCityIdFromPage) against curated HTML fixtures.
  - npm test: 3196 passed, 1 skipped (no new failures).
  - npx tsc --noEmit: clean.
  - opencli validate: 0 errors.

* fix(dianping): require city resolver links to be authoritative

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-10 14:57:44 +08:00
Kagura 962842cd59 fix(douyin): handle empty response body in browserFetch (#1408)
* fix(douyin): handle empty response body in browserFetch (#1405)

browserFetch calls res.json() directly, which throws SyntaxError when
the API returns an empty body (content-length: 0). This happens when
the Douyin hashtag search endpoint returns HTTP 200 with no content.

Fix: read response as text first, return null for empty bodies, then
throw a descriptive CommandExecutionError at the caller level.

Fixes #1405

* fix(douyin): wrap browser fetch parse failures

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-10 14:53:55 +08:00
jakevin 6f200cc744 fix(browser): enable accessibility before AX snapshots (#1417) 2026-05-09 03:12:27 +08:00
jakevin 70981ef06a docs(browser): document AX validation workflow (#1416) 2026-05-08 19:44:38 +08:00
jakevin 3f8b88cf64 feat(browser): compare observation source metrics (#1415) 2026-05-08 19:40:47 +08:00
jakevin d6e3971c79 feat(browser): route AX refs through same-origin frames (#1414) 2026-05-08 19:35:51 +08:00
jakevin e99cbd4a7e feat(browser): add opt-in AX refs (#1413) 2026-05-08 19:20:21 +08:00
jakevin 136e5888ce fix(browser): drive click through CDP mouse events (#1412) 2026-05-08 19:06:03 +08:00
jakevin 53516f7511 docs(browser): design agent runtime roadmap (#1411)
* docs(browser): design agent runtime roadmap

* docs(browser): tighten runtime MVP criteria
2026-05-08 18:35:37 +08:00
jakevin 4475d4efe3 feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs (#1406)
Round 21 follow-up to #1400 (P0 write-action symmetry, merged `644d4517`). 5 features + help docs unified into one PR per WAWQAQ "全部合成一个 PR" directive.

## Scope

- **P1** (`cf10c098`): `twitter search` `--from / --has / --exclude / --product` filters, mapping to X `from:` / `filter:` / `-filter:` / `f=` operators; legacy `--filter top|live` preserved (--product win on conflict)
- **P2** (`a484a69a`): new `twitter bookmark-folders` + `bookmark-folder <id>`; X Premium GraphQL `bookmarkFoldersSlice` + `BookmarkFolderTimeline`; queryId 三层 fallback (placeholder.json → client-web bundle → pinned constants)
- **P3** (`f209f914`): `--top-by-engagement N` to 7 tweet-shaped read commands (search/timeline/likes/bookmarks/list-tweets/tweets/thread); single helper in `utils.js`; formula `likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5`; **N=0 reference equality no-op** → existing 157 twitter tests 0 churn
- **P4** (`89283fa0`): `TWITTER_BEARER_TOKEN` + composer image helpers extracted to `utils.js` (12 GraphQL adapter dedup); reply hardening; quote adds `--image`
- **P5** (`a3d10a48`): sibling article-scope helper extracted to `shared.js` (9 write commands reuse, dedup with #1400 P0 invariant)
- **docs** (`2a358d80`): help-doc precision (positional-omitted defaults + download/bookmarks/notifications/timeline/lists description thicken; concurrent #1401/#1403 wording preserved)

47 files / +2594/-470. Tests **96 → 216 (+120)**, manifest 798 → 801 (+3), typed-error-lint 190 → 189 (resolved 1 grandfathered sentinel).

## Iteration history (3 review fix commits on top of 6 author commits)

- `7f93779b` — codex-mini1 lead fix1: 3 blocker bundle (P5 host invariant + P2 safe-id + sentinel removal + P1 fallback fail-fast)
- `2a29ecc6` — codex-mini1 lead fix2: P3 help formula consistency (doc/help text matches actual `log10(views+1)×0.5`)
- `df4dcd76` — codex-mini1 lead fix3 (F-P-1 aux catch): P2 `bookmark-folder --limit` upfront validation (`Number(kwargs.limit ?? 20)` + reject non-positive/non-integer + regression `0/negative/fractional/NaN` + `page.goto` zero-call assert)

## 4 progressive blockers caught (codex-mini1 lead 3 rounds + F-P-1 aux 1 round)

1. **P5 host invariant gap** (lead): article-scope helper preserved exact `/status/<id>` path but ignored link host → off-domain `https://evil.com/alice/status/<target>` would satisfy `__twHasLinkToTarget`. Fixed: `https` + X/Twitter host or subdomain + exact `/status/<id>` or `/i/status/<id>` path; query/hash allowed; off-domain/host-suffix/non-https/path-suffix/substring-id rejected; JSDOM positive + 5 negative anchors.

2. **P2 listing→detail round-trip + sentinel** (lead): `bookmark-folders` accepted opaque IDs but `bookmark-folder <id>` only accepted numeric → round-trip broken; new `author: 'unknown'` sentinel created fabricated author URL. Fixed: `[A-Za-z0-9_-]+` opaque safe-id (rejects `/`, `?`, `%`, spaces) + `resolveTwitterQueryId()` sanitization for queryId resolution; sentinel removed → empty author + canonical `/i/status/<id>` URL.

3. **P1 fallback silent tab miss** (lead): pushState fail → fallback typing into search box, `clickProductTabIfNeeded()` silent return on tab not found → user `--product photos` silently degraded to Top results. Fixed: throw `CommandExecutionError` when requested `--product` tab cannot be selected + invalid `--from` / `--limit` upfront pre-nav reject + double-direction tests.

4. **P2 limit silent normalize** (aux): `const limit = kwargs.limit || 20` → `--limit 0` silent → 20; negative/non-integer pre-IO unchecked. Fixed: `Number(kwargs.limit ?? 20)` + require positive integer before `page.goto` + regression covers `0/negative/fractional/NaN` + `page.goto` zero-call.

## Cultural sediment (Round 21 audit checklist 7 rules / 6 dimensions)

This PR **immediately validated 4 of 7 rules** in review pipeline:
- (b) silent-clamp class — P1 fallback silent tab miss (silent semantic-downgrade) + P2 `|| 20` silent normalize
- (e) ID exact-not-substring — P5 host invariant (was only path-exact, not host-exact)
- (f) grandfathered-not-exempt — P5 helper-refactor boundary lost host invariant + P2 new adapter inherited grandfathered `'unknown'` sentinel
- (g) fallback-must-have-success-criterion — P1 fallback path missing post-condition assertion

7 rules / 6 dimensions:
- (a) cross-grep sibling URL pattern — structural
- (b) silent-clamp class — failure mode (input)
- (c) broad querySelector → article-scoping — scope
- (d) missing-validation early reject — boundary
- (e) ID exact-not-substring — identity
- (f) grandfathered-not-exempt (corollary: applies to new file + new helper-refactor boundary; not original-file line-edit) — time-axis
- (g) fallback-must-have-success-criterion (sub-rule g': fallback unit test must include post-condition assertion, not just "doesn't throw") — failure mode (output)

**Cross-PR validation 4-chain on meta-anchor "Structural exactness for identity matching"**:
- #1391 URL layer (`isFacebookAuthRedirectPath`: top-level anchor + `\.php` + `(/|$)` segment edge)
- #1392 URL parser layer (`parseGrokSessionId`: bare UUID exact / URL host-exact-or-subdomain + path-exact)
- #1400 DOM layer (article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex / segment-array exact)
- #1406 P5 helper-refactor boundary (full URL invariant in shared helper: host+path re-anchored after extraction)
- Common invariant: boundary-lock structural shape; **fuzzy match is silent-failure 温床**; lesson lifecycle = surface-shift not add-and-forget.

**Audit framework self-discipline**: each rule must have grep-able detection signal, otherwise rule degenerates to mantra. Framework is "7 rules + sub-instance pattern in new surface", not frozen 7 rules.

**Round 17 race-mitigation 第 9 连续 race-free execution**: standard alternation cadence (#1400 A 组 → #1406 B 组), lead final + aux final + `@pr-monitor squash?` trigger, pr-monitor proactive ack + serial squash, lead silent on closeout.

## Validation gates (final head `df4dcd76`)

Local: Twitter adapter tests `25 files / 216 tests`, focused P1/P2/P3/P5 tests `99/99`, `node --check` touched runtime, `npx tsc --noEmit`, `npm run build`, manifest 801 entries, typed-error-lint `189/189`, silent-column-drop `103/103`, doc-coverage `140/140`, docs:build clean, listing-id advisory `13` unchanged (wikipedia/trending residual non-Twitter), `git diff --check` clean.

GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`.

Reviewers:
- Lead: @codex-mini1 (3 fix rounds, all caught proactively + amend P3 help consistency)
- Aux: @First-principles-1 (better-solution triangulation on P2 queryId 三层 fallback + P5 invariant + P3 N=0 reference no-op + caught P2 limit silent normalize)
- Author: @opencli-user (5-feature scope + 7-rule sediment co-author + corollary contributor)
2026-05-08 02:36:39 +08:00
jakevin 34f793ff5c feat(help): add browser structured help (#1404) 2026-05-08 02:15:59 +08:00
jakevin 407b559a83 feat(help): hard-gate empty positional help text + fix 18 offenders (#1403)
Why
- `opencli twitter followers --help` rendered:
    Arguments:
      user
  with a blank trailing column. Both humans and agents could not
  recover the parameter's purpose without reading source. WAWQAQ
  surfaced this directly: "没有说明当后面的 followers [user] [options]
  如果都没填的时候,获取的是什么?"
- This is metadata completeness, not stylistic taste. Failing closed
  is the only way to keep the help surface trustworthy as adapters
  land.

What
- src/build-manifest.ts: add `findManifestMetadataIssues()` that flags
  any positional with empty / whitespace-only / missing `help`. Wired
  into `main()` after the import-failures gate; build aborts non-zero
  with a per-arg report (`site/cmd positional "name" (sourceFile)`).
- src/build-manifest.test.ts: cover the gate (positives + negatives,
  scoped strictly to positionals — named flags are intentionally
  out-of-scope).
- 18 adapter offenders (16 required + 2 optional) get explicit help
  text:
    twitter: followers/following/list-add/list-remove/list-tweets/
             search/thread
    reddit:  search/subreddit/user/user-comments/user-posts
    douyin:  stats/update
    bilibili: subtitle
    jike:    search
  Optional positionals (`twitter followers/following [user]`) now
  document the omit semantics — fetches the currently logged-in
  account.
- CHANGELOG: document the build gate and the offender list.

Out of scope (planned follow-ups)
- Semantic-quality advisory: optional positional help should also
  contain `default / omit / current / logged-in / required unless …`
  keywords. That belongs to the planned Arg metadata v2 work
  (`when_omitted / when_present / value_format` 3-field schema).
- Named-flag `help` quality. Named flags carry the flag name itself
  in help, so a missing `help` is not as opaque; if we want to gate
  those too, do it as a separate, intentional decision.

Validation
- `npm run build`            → 799 entries, clean.
- `npm run typecheck`        → clean.
- `npx vitest run --project unit --project adapter` → 257 + 4 files,
  all green (build-manifest 13 tests, manifest gate added).
- Smoke: temporarily reverted `followers.js` help to empty → build
  aborts with the exact `twitter/followers positional "user" (...)`
  line; restored, build is clean again.
- `npm run check:silent-column-drop` and `check:typed-error-lint`
  baselines unchanged.
2026-05-08 01:55:19 +08:00
jakevin dc7b88d45b chore(release): 1.7.14 (#1402)
Release / release (push) Has been cancelled
- bump opencli to 1.7.14 (was 1.7.13)
- extension stays at 1.0.6 (no extension changes since v1.7.13)
- finalize CHANGELOG with the three landed PRs:
  * #1399 daemon restart on stale ready state for npm -g upgrade
  * #1400 twitter write-action symmetry (unlike/retweet/unretweet/quote)
  * #1401 agent-friendly adapter help (drop globally-shared option noise)
2026-05-08 01:30:04 +08:00
jakevin 0996f9feba feat(help): make adapter help agent-friendly (#1401) 2026-05-08 01:18:56 +08:00
jakevin 644d45177b feat(twitter): add unlike + retweet + unretweet + quote (write-action symmetry P0) (#1400)
Round 21 P0 — Twitter write-action symmetry (4 of 4: unlike, retweet, unretweet, quote).

## Scope
Closes write-action gap with existing siblings (`like`, `bookmark`, `unbookmark`, `delete`):
- `unlike` (UI strategy, navigateBefore:true)
- `retweet` (UI strategy)
- `unretweet` (UI strategy)
- `quote` (UI strategy, `/compose/post?url=` route — same family as `reply.js` `/compose/post?in_reply_to=`)

+745/-0 in initial commit, plus 3 progressive review fixes. Final: 4 adapters + 4 tests; modified `shared.js`, `shared.test.js`, manifest, docs.

## Iteration history (4 heads, 102/102 tests on final)

- `07836783` — initial 4 adapters + 4 tests, 96/96
- `55a89776` — fix #1: shared `parseTweetUrl()` URL invariant + quote post-submit verify (102/102)
- `dc9eab66` — fix #2: article-scoping for unlike/retweet/unretweet (delete.js sibling pattern)
- `8809d2c1` — fix #3: exact status-id matching (`match?.[1] === tweetId`) + quote-card exact id guard

## 4 progressive blockers caught (codex-mini0 lead + F-P-0 aux)

1. **URL validation (silent-clamp class)**: original passed any host containing `/status/<id>`. Fixed: `parseTweetUrl()` requires `https` + Twitter/X exact host + exact `/<user|i>/status/<id>` path; host-suffix, embedded URL, path-suffix all `ArgumentError` pre-nav.

2. **Quote silent-success illusion**: original click-implies-success without composer/toast verify. Fixed: pre-submit quoted-card exact id render assertion + post-submit success toast OR composer-clear assertion, otherwise return failed row.

3. **Broad querySelector scoping (delete.js sibling pattern)**: original state probe + click + post-click verify on conversation pages picked first matching button. Fixed: scope to `article` containing requested exact status id (sibling `clis/twitter/delete.js:22-23` pattern).

4. **Substring vs exact status-id matching**: `/status/123` substring-matched `/status/1234`. Fixed: regex `/\/status\/${id}(?:\/|$)/` segment-edge anchor + `match?.[1] === tweetId` exact compare.

## Cultural sediment (Round 21)

**Audit checklist 5 rules (pre-write upstream selection net)**:
1. cross-grep sibling URL-construction patterns before adopting
2. silent-clamp class detection (any normalize-then-trust path)
3. broad querySelector → article-scoping requirement
4. missing-validation early reject before navigation/IO
5. ID-based DOM/URL matching exact-not-substring

**Augment framing**: Round 21 audit-first 是 Round 18 字面量 self-check 的 **upstream pre-write 阶段**, 两者作用阶段不同, 共存比替换稳。

**Meta-anchor "Structural exactness for identity matching"** unifying:
- URL layer (#1391 isFacebookAuthRedirectPath: `\.php` + `(/|$)` segment edge)
- URL parser layer (#1392 parseGrokSessionId: bare UUID exact / URL host-exact-or-subdomain + path-exact)
- DOM layer (#1400 article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex or pathname segment-array exact compare)

Common invariant: boundary-lock structural shape, 不 trust substring 模糊 — fuzzy match 是 silent failure 温床。

## Validation gates (final head `8809d2c1`)

Local: Twitter tests 102/102, `node --check` touched files, `npx tsc --noEmit`, `npm run build`, typed-error-lint 189/189, silent-column-drop 103/103, doc-coverage 140/140, docs:build clean, listing-id advisory unchanged 13, `git diff --check` clean, merge-tree clean.

GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test×2 shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`.

## Strategy/UI boundary (better-solution verdict)

UI write path acceptable for P0 symmetry (matches existing Twitter write siblings). GraphQL write migration + structured `idempotent:true` flag are cross-sibling upgrades, P5 candidate, not P0 blockers.

Round 17 race-mitigation 第 8 连续 race-free execution (this round absorbed author scope-uncertainty hold-then-retract event without producing actual race).

Reviewers:
- Lead: @codex-mini0 (4-round iteration, all blockers caught)
- Aux: @First-principles-0 (better-solution triangulation, scope-discipline verdict, regression invariants)
- Author: @opencli-user
2026-05-08 01:10:54 +08:00
jakevin 8d201ae60b fix(browser): restart stale ready daemon (#1399) 2026-05-08 00:39:19 +08:00
jakevin 1fa44bda6b chore(release): 1.7.13 (#1398)
Release / release (push) Has been cancelled
- bump opencli to 1.7.13 (was 1.7.12)
- bump extension to 1.0.6 (was 1.0.5)
- finalize CHANGELOG: move Unreleased section to 1.7.13 with date,
  document Strategy.HEADER removal + OPENCLI_BROWSER_TIMEOUT rename
  as breaking, add fill-step routing fix, qwen detail command, and
  the dead-code/internal cleanup batch
2026-05-07 23:44:18 +08:00
jakevin bf914f20f1 fix(grok): replace sentinel rows + silent-clamp with typed errors, deliver image cmd (#1397)
fix(grok): replace sentinel rows and deliver image command
2026-05-07 22:45:45 +08:00
jakevin 6f45db1be9 fix(manifest): rescue 11 desktop adapter commands from factory pattern (#1396)
fix(manifest): rescue desktop factory commands
2026-05-07 22:02:12 +08:00
jakevin da833d3efe chore(release): clean stale metadata surfaces (#1395)
chore(release): clean stale metadata surfaces
2026-05-07 22:00:51 +08:00
Kagura abfd0e2180 fix(youtube): use watch page HTML for transcript captions (#1378)
Fixes #1376 — YouTube transcript command failed with `No captions available for this video` for all videos.

## Root cause
Transcript adapter used InnerTube `/youtubei/v1/player` API with Android client context (`clientName: 'ANDROID'`, version `20.10.38`) to retrieve caption track URLs. YouTube has restricted/deprecated this approach; the Android client no longer reliably returns captions data.

## Fix
Replace Step 1 (caption track retrieval) with watch page HTML bootstrap parsing — fetch `/watch?v=...` with cookies and extract `ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer`. This is the same approach used by sibling `clis/youtube/video.js`, so it's an alignment to existing site-local stable pattern, not a new invention.

## 2 head iteration
- `cf77f5e8` initial fix (Step 1 caption retrieval switch + 18/18 unit tests)
- `bb30788c` lead test hardening — source-contract regression test in `transcript.test.js`:
  - **positive lock**: must fetch `/watch?v=...`, parse `ytInitialPlayerResponse`, read `playerCaptionsTracklistRenderer`
  - **negative lock**: must NOT use `/youtubei/v1/player` or `clientName: 'ANDROID'` (prevents regression)
  - stale Android-InnerTube file header comment also updated

## Better-solution evaluation
- Official YouTube Data API captions surface (`developers.google.com/youtube/v3/docs/captions/download`) is owner-authorized API, NOT a public transcript replacement
- yt-dlp also relies on watch-page bootstrap path
- Existing `youtube/video.js` already uses the same `ytInitialPlayerResponse` extraction → this PR aligns transcript with stable site-local pattern instead of inventing a new path

## Typed failure / no-silent-empty boundaries
- watch HTML HTTP failure / missing `ytInitialPlayerResponse` / no `captionTracks` → `CommandExecutionError` (typed fail)
- Empty parsed XML → `EmptyResultError` (existing path, preserved)
- `Strategy.COOKIE` matches YouTube adapter family + `video.js`; cookies/session/consent unavailable → typed fail not silent empty success illusion

## Diff containment
Runtime change limited to Step 1 caption track discovery. XML fetch, segment parsing, chapters, raw/grouped formatting all unchanged.

## Verification
Local: YouTube adapter tests `19/19` (+1 from new test), `npm run build`, typed-error-lint `192/192`, silent-column-drop `103/103`, doc coverage `140/140`, `docs:build`, listing-id advisory unchanged `13`, `git diff --check`, merge-tree clean.
GitHub: build × 3 OS, unit × 2 shards, bun-test, adapter-test, audit, doc-coverage, docs-build all SUCCESS. PR CLEAN/MERGEABLE.

Author: kagura-agent (fork). Lead: codex-mini0. Aux: First-principles-0. Coordination: pr-monitor.
2026-05-07 21:50:30 +08:00
E2ern1ty 195333ff8a fix(xiaohongshu): improve image publishing — creator-center URL + tab priority + DataTransfer fallback (#1380)
Xiaohongshu image-note publishing reliability fixes for creator center UI (legacy raw-Error write command, not a typed-error migration).

## 3 changes (one publish-path repair)
1. **Open creator publish in image mode**: append `target=image` to the publish URL so it loads directly in image mode instead of default
2. **Exact `图文` tab priority**: prefer exact tab text matching before broad `startsWith/includes`, reducing parent-container misclicks while keeping fallback for UI wording variants
3. **DataTransfer fallback for `Chrome Not allowed`**: when CDP `setFileInput` returns the permission/bridge denial error, fall through to the existing DataTransfer upload path (CDP-first remains primary to avoid base64 bridge/payload limits)

## Lead hardening (`edf8107d`)
Added `clis/xiaohongshu/publish.test.js` regression coverage for all three claimed behaviors:
- `target=image` creator URL locked
- exact tab text matched before broad fallback
- `Chrome Not allowed` falling into DataTransfer path

## Better-solution evaluation (lead + aux 一致)
- **CDP-first kept**: CDP avoids base64 payload/bridge limits; `Not allowed` is a known permission failure class where fallback is appropriate. DataTransfer-first would weaken the common path and reintroduce large-payload fragility.
- **Exact tab text first**: XHS creator markup is private and volatile, selector-only alternative not clearly more stable. Exact text reduces misclicks while broader fallback + post-click `video_surface` check preserve resilience for wording shifts. If exact text disappears, command fails fast with screenshot instead of silent video-mode publish.
- **Scope boundary self-imposed**: not expanding to typed-error migration (publish.js is legacy raw-Error and typed-error-lint already accounts for it).

## Verification
Local: xiaohongshu publish tests `12/12`, typecheck, build/manifest, docs:build, typed-error-lint `189/189`, silent-column-drop `103/103`, doc coverage `140/140`, node --check, git diff --check.
GitHub: build × 3 OS, unit shards, bun-test, adapter-test, audit, docs-build, doc-coverage all SUCCESS. PR CLEAN/MERGEABLE.

Author: E2ern1ty (fork). Lead: codex-mini1. Aux: First-principles-1. Coordination: pr-monitor.
2026-05-07 21:45:08 +08:00
jakevin 3b585fb4d1 feat(grok): add browser chat baseline commands (read/history/detail/new/send/status) (#1392)
Phase 3 — Grok adapter baseline (LLM browser-chat command family, parallel to ChatGPT/Qwen/Yuanbao).

## Surface
6 commands: `status` / `history` / `read` / `detail` / `new` / `send`. Site-local `clis/grok/utils.js` justified by 6 commands sharing helpers, not over-abstraction.

## 4-head review iteration

1. **`b4e81bad`** — initial baseline (12 Grok/shared files)
2. **`0a8112fc`** — mechanical rebase (CHANGELOG conflict only, all 12 Grok files preserved business-equivalent through rebase)
3. **`481e87e2`** — security fix: `parseGrokSessionId()` SSRF-shape vulnerability close — switched from regex string match to `new URL()` parser with branch separation:
   - Bare UUID mode: only exact UUID shape (no URL/query suffix accepted)
   - URL mode: requires `https` scheme + exact `grok.com` or subdomain host + exact `/c/<uuid>` path
4. **`a082023c`** — test-only hardening: 2 additional negative anchors covering existing implementation rejections (bare UUID `?next=abc` query tail / `grok.com.evil.com` host-suffix trick)

## Negative anchor coverage (8 cases)
http / off-domain / fakegrok / host-suffix subdomain / embedded URL / path suffix / UUID-tail / bare query tail

## Better-solution evidence form
LLM browser-chat family pattern (matching ChatGPT/Qwen/Yuanbao baseline) + 5 live probes — not first-site hostile scrape. TipTap editor API send seam (`editor.commands.focus/clearContent/insertContent`) is correct boundary because Grok ignores DOM input events; isolated in `sendMessage()`. Lack of full TipTap mock = residual risk, not blocker.

## Invariants locked
- `parseGrokSessionId()` URL parser branch separation (bare UUID exact / URL exact path)
- `history --limit` rejects invalid/out-of-range
- `status` uses `null` for unknowns (no fabrication)
- Bubble extraction preserves image-only assistant turns (no silent HTML-only drop)
- Auth/empty semantics aligned with LLM browser-chat baseline family

## Verification
Local: Grok adapter tests `28/28`, typecheck, build/manifest, docs:build, typed-error-lint `189/189`, silent-column-drop `103/103`, doc coverage `140/140`, listing-id advisory `13` unchanged, diff-check clean.
GitHub: build ubuntu/macos/windows × unit-test 1/2 + 2/2, bun-test, adapter-test, audit, doc-coverage, docs-build all SUCCESS. PR CLEAN/MERGEABLE.

Lead: codex-mini1. Aux: First-principles-1. Coordination: pr-monitor.
2026-05-07 21:11:39 +08:00
jakevin 9cae777430 chore(release): pre-release P0/P1 cleanup (#1393)
* chore(release): pre-release P0/P1 cleanup

P0 fixes:
- delete src/analysis.ts (179 lines, 0 importers across src/clis/extension)
- remove dead OPENCLI_DIAGNOSTIC negative test assertion
- rename OPENCLI_BROWSER_TIMEOUT to OPENCLI_BROWSER_IDLE_TIMEOUT — the env
  controls workspace lease idle release, not command runtime; old name was
  misleading and undocumented (no fallback needed)
- add 'fill' to validate.ts KNOWN_STEP_NAMES so adapters using PR #1222's
  fill pipeline step do not trip "unknown step name" warnings during validate

P1 fixes:
- BrowserConnect daemon-not-running hint: replace stale "make sure port is
  available" with actionable "run opencli doctor / opencli daemon restart"
- TimeoutError hint: lead with --timeout flag, demote env var to secondary

* fix(validate): derive step allowlist from pipeline registry

@pr-monitor flagged the prior "add 'fill' to KNOWN_STEP_NAMES" fix as
treating only the symptom — two parallel hand-maintained lists will keep
drifting whenever a new pipeline step is registered.

Address the root cause: pipeline/registry.ts now exports
`getRegisteredStepNames()` and validate.ts builds KNOWN_STEP_NAMES from
that. Adding a step via `registerStep()` automatically allowlists it.

* test(validate): regression guard for pipeline step allowlist linkage

@pr-monitor follow-up: lock the validate ↔ pipeline registry linkage at
the test layer so future drift is caught immediately.

Changes:
- recompute KNOWN_STEP_NAMES per-call (was const at module load) so
  steps registered after validate.ts import (plugins, dynamic registration)
  are honoured
- add src/validate.test.ts with 3 cases:
  1. every step name from getRegisteredStepNames() exists
  2. an adapter using every currently registered step does not warn
  3. a step registered at runtime is automatically allowlisted by
     validate without any source change to validate.ts

* fix(capabilityRouting): add fill to BROWSER_ONLY_STEPS

Same double-list drift pattern as validate.ts KNOWN_STEP_NAMES (audit
follow-up flagged in this PR's evolution thread). The fill step was
registered in pipeline/registry.ts (PR #1222) but never added to the
browser-only allowlist in capabilityRouting.ts.

Concrete impact:
- shouldUseBrowserSession() didn't recognize a `[{ fill: ... }]` pipeline
  as needing a browser, so PUBLIC adapters using fill could end up
  without a page and crash inside stepFill at `page!.fillText(...)`
- pipeline/executor.ts's per-step retry policy (BROWSER_ONLY_STEPS gets
  2 retries on transient errors, others get 0) skipped fill — losing
  retry coverage on a DOM-touching step

Fix:
- add 'fill' to BROWSER_ONLY_STEPS
- add a documenting comment explaining BROWSER_ONLY_STEPS is the
  browser-touching subset of registered steps (not the full set)
- export _validateBrowserOnlyStepsAgainstRegistry() so the test layer
  catches the inverse drift (browser-only step that no longer exists)
- 3 new tests in capabilityRouting.test.ts:
  * pipeline with fill routes to browser session
  * BROWSER_ONLY_STEPS subset of registered step names
  * fill is in both lists

This addresses @pr-monitor follow-up #3 (audit similar double-list
patterns) for the obvious in-scope candidate. Other candidates outside
this PR's scope: build-manifest serialization vs registry shape, error
code unions vs lint baselines.

* test(validate): use Strategy.PUBLIC enum instead of string cast in regression test

Self-review nit: `strategy: 'public' as never` worked but bypassed the
typed CliOptions union. Use `Strategy.PUBLIC` so the test exercises the
real public API.
2026-05-07 21:11:34 +08:00
jakevin b2ebe211d1 feat(yuanbao): add browser-web baseline commands (status/read/detail/history/send) (#1394)
Wire up the standard browser-LLM command surface for Yuanbao, matching the
recently shipped chatgpt + claude + qwen baselines:
- status — login + current model + (agentId, convId) + URL
- read   — render the visible conversation as User/Assistant rows
- detail — open `<agentId>/<convId>` and read its messages
- history — list sidebar conversations with stable IDs
- send   — fire-and-forget, returns once the send button has been clicked

Refactor `ask.js` to share helpers (`sendYuanbaoMessage`, `normalizeBooleanFlag`)
with the new commands via `shared.js`, keeping the public ask behavior intact.

Notable bits:
- `parseYuanbaoSessionId` accepts only full chat URLs or `<agentId>/<convId>`
  pairs — Yuanbao chat URLs encode both, and silently opening the wrong agent
  on a bare UUID is a worse failure mode than throwing. URL regex anchored
  with `(?:[/?#]|$)` so 37+ char tails reject rather than truncate.
- `sendYuanbaoMessage` polls the send button (up to 3s) for the React
  re-render that drops `style__send-btn--disabled___*` after composer input —
  a fixed wait raced the debounce and produced silent no-op clicks.
- `getYuanbaoMessageBubbles` uses `data-conv-id`/`data-conv-idx`/
  `data-conv-speaker` attributes for stable per-turn identity (was relying
  on innerHTML alone).
- Status surfaces both human label (`Yuanbao`) and `dt-model-id`
  (`hunyuan_gpt_175B_0404`) — sentinel strings would silently look like a
  real model name; null is the typed-unknown signal.

Verified: 25 unit tests pass; targeted live smoke for status/read/detail/
history/new/send + ask round-trip on yuanbao.tencent.com.
2026-05-07 20:38:17 +08:00
jakevin b9b87a5c64 refactor(facebook/notifications): pipeline→func + typed errors + 7-col contract + runtime upfront limit (Phase 3 P5, #1391)
First Facebook adapter — Pattern C HTML scrape (lead 5 + author 4 = 7 endpoint family probe matrix dual-source negative evidence: graphql×3 / m.facebook redirect / login.php / checkpoint.php / fetch-patch / Messenger relay / ajax legacy 全 unauth 不可达, DOM walk over rendered notification rows + path-anchored auth detection 是当前 reviewable boundary).

Caller-visible delta: 3 cols (index/text/time) → 7 cols (+unread/+url/+notif_id/+notif_type).

[Bug fix] — 5 silent failures resolved
- silent-bad-shape: text.substring(0,150) → full body via per-row 'Mark as read' aria-label
- silent-bad-shape: time || '-' sentinel → string|null typed unknown
- silent-column-drop: unread badge / anchor href / notif_id / notif_t 暴露
- silent-empty-row: /login(.php)? + /checkpoint(.php)? redirect 返 [] → AuthRequiredError; empty/no-recoverable-text → EmptyResultError
- silent-clamp: limit 越界 silent clamp → ArgumentError (1-100), upfront before any navigation (navigateBefore: false)

[Structural refactor]
- pipeline → cli() func form + Strategy.COOKIE + navigateBefore: false (runtime upfront invariant 与 #1387 standard 拉齐)
- module-level pure exports: normalizeNotificationsLimit, stripMarkAsReadPrefix, stripAnchorChrome, parseNotifQuery, extractNotificationRowsFromDoc, isFacebookAuthRedirectPath, buildNotificationsScript
- Live IIFE 通过 \${fn.toString()} 嵌入 (dianping #1313 / hupu #1387 / xiaoe #1388 lineage)
- Locale 表 6 prefix / 4 badge label 显式列出
- AUTH_REQUIRED: sentinel → Node-side AuthRequiredError mapper

[Typed-error hardening]
- Path-anchored auth helper: isFacebookAuthRedirectPath(/^\/(?:login|checkpoint)(?:\.php)?(?:\/|\$)/i) — domain-invariant-first encoding (FB top-level auth-only invariant), 排除 /loginhelp /help/login /account/login/identify
- Three-layer navigateBefore=false invariant lock: registration assertion + manifest absence + executeCommand runtime page.goto-zero-call (test layer 与 invariant layer 完整对齐)
- Row-level silent-empty-row defense: anchor rows with no recoverable body text 直接 skip, 不 emit text:null success row

[Doc fix]
- docs/adapters/browser/facebook.md notifications enrichment + Output table (列类型 / null vs sentinel 语义) + auth/empty error contract
- Boy Scout audit: cross-checked profile / feed / search / marketplace-listings / marketplace-inbox 例 commands 与 args 定义一致

Tests
- notifications.test.js 39/39 + src/execution.test.ts 21/21
- Anti-pattern regression guards: not.toMatch(/text\.substring\(0,\s*150\)/) + not.toMatch(/time\s*\|\|/)
- JSDOM frozen-fixture (slim 13 lines, 0 blank): header listitem skip / full text / unread badge / query parsing / null time / blank-row skip / relative href absolute / 19-case auth path matrix
- typed-error-lint baseline 192 → 191 (silent-sentinel resolved 1)

Review iterations (4 head, A 组 codex-mini0 lead + First-principles-0 aux):
1. 052d2b18 (initial 29 tests) → 376cb50f (lead gate fix: Ubuntu lint + auth path-segment + anchor.href + 5 typed-error func tests)
2. 376cb50f → 0d6c1340 (pr-monitor grep cross-verify catch /login.php false-negative; lead 加 \\.php 边界)
3. 0d6c1340 → 3e5a5ff0 (opencli-user 19-case 实测 + lead 抽 named helper isFacebookAuthRedirectPath domain-invariant-first encoding + 2 row-shape silent-failure 顺手 catch)
4. 3e5a5ff0 → 36e44f73 (F-P-0 aux blocker: registry-injected navigateBefore 在 limit validation 之前 fire pre-nav 违反 #1387 upfront boundary; navigateBefore:false + 三层断言 registration/manifest/runtime executeCommand)

Closes #1391
2026-05-07 17:49:57 +08:00
jakevin 381f095706 feat(qwen): add detail command + fix stale message bubble selector (#1390)
* feat(qwen): add detail command + fix stale message bubble selector

`getMessageBubbles` was matching `[data-msgid="<id>-question|answer"]` from an
older Qianwen frontend. The reshipped DOM no longer carries that attribute on
chat turns; `[data-message-id]` now lives on citation cards inside assistant
responses, so the old selector silently returned an empty list and `qwen read`
had been silently broken.

Rewire to walk `[data-chat-question-wrap]` and `[data-chat-answers-wrap]` in
DOM order (correct Q/A interleaving) and synthesize stable IDs from the
nearest sibling `data-req-id` so `waitForAnswer.seenAssistantId` and
read/ask/detail dedupe paths keep working. Verified live against an existing
conversation: 3 user turns + 3 assistant turns extracted; old selector
returned 0.

`qwen detail <id|url>`: open a specific conversation by ID or full chat URL,
poll up to 20s for the transcript to render, return Role/Text rows. Adds
`parseQianwenSessionId` (5 unit tests covering ID/URL parsing + ArgumentError
on malformed input). Reuses the same site-level browser session as `read`/
`ask` so consecutive calls continue in the same Qwen tab.

- clis/qwen/detail.js (new)
- clis/qwen/utils.js (parseQianwenSessionId + getMessageBubbles rewire)
- clis/qwen/utils.test.js (new)
- docs/adapters/browser/qwen.md (detail entry + options/columns)
- cli-manifest.json (regenerated)

* fix(qwen): anchor URL regex to reject 33+ hex tail truncation

codex-coder review on PR #1390 caught that
`https://www.qianwen.com/chat/<33+ hex>` would silently truncate to the
first 32 chars and open the wrong conversation. Adds end-of-input /
slash / query / fragment boundary to the URL match group and two new
unit-test cases (digit tail + letters tail) covering the truncation gap.
2026-05-07 17:39:23 +08:00
jakevin 99986c3101 feat(chatgpt): add browser chat baseline commands
Add ChatGPT web ask/send/read/history/detail/new/status alongside existing image support. Tighten ChatGPT web helper selectors and typed error contracts, update docs/changelog, regenerate manifest, and seed local ChatGPT verify fixtures for ask/read.
2026-05-07 17:37:18 +08:00
jakevin 6f7eb6a76a refactor(xiaoe x3): pipeline→func + typed errors + content silent-drop fix (Phase 3 P1)
Phase 3 P1 (xiaoe catalog/courses/content) — pipeline→func refactor + typed-error hardening + content silent-drop bug fix + URL upfront validation + inherited legacy doc fix。

## Tags (PR body honesty 演进 dual-nature framing 试用)

- **[Bug fix]** `xiaoe/content` silent-column-drop (caller-visible delta)
- **[Structural refactor]** `xiaoe/catalog` + `xiaoe/courses` pipeline→func 包壳 (parity by construction, IIFE 字节级保留)
- **[Typed-error hardening]** 三 func `page.goto` + `page.evaluate` failure 包成 `CommandExecutionError`; `content/catalog` URL upfront `ArgumentError` (missing/malformed/non-https/off-domain) before navigation
- **[Doc fix]** `docs/adapters/browser/xiaoe.md` `courses --limit 10` (legacy doc 错误 inherit) + `--url` wording → 实际 positional `url` (manifest aligned)

## Per-tag detail

### [Bug fix] content silent-column-drop (real caller-visible bug)
adapter 名"提取小鹅通图文页面内容为文本", IIFE 返 `{title, content, content_length, image_count, images}`, 但 columns 只声明 `[title, content_length, image_count]` → `content` (那段文本本身) 被 silent drop。**用户拿到 "1234 chars" 但拿不到那 1234 chars** — adapter 名字撒谎了。
- Fix: 公开列 `[title, content, content_length, image_count]`, `content` 真 caller-visible delta
- Choice A (vs B reshape): legacy `images` 是 `JSON.stringify(slice(0, 20))` 截断/stringified 坏合同, **不暴露成新列** (避免把 silent-bad-shape 升级成公开坏合同), 留 follow-up 另开 explicit media/images contract
- `image_count` 用 `countXiaoeImages(doc)` 全页计数, 不 slice (既有 metadata 质量修正)

### [Structural refactor] catalog + courses pipeline→func wrapper (parity by construction)
- `pipeline:[]` form → `func` form
- IIFE body 字节级保留 (Xiaoe 没 public REST, Vue 私有 runtime 是唯一稳定 hook, JSDOM 复刻不了 Vue tree)
- Pure helpers extracted: `pickContentText`, `countXiaoeImages` (content) / `typeLabel`, `buildItemUrl`, `chapterUrlPath` (catalog) / `buildCourseUrl` (courses)
- IIFE 通过 `\${fn.toString()}` 嵌同一份代码 (dianping #1313 / hupu #1387 同模式)
- No live verify acceptable: IIFE 字节级保留 + helper 全 unit-test + manifest column shape 不变 = 行为 parity by construction
- `buildScript` 反向断言 `images.slice(0, 20)` legacy anti-pattern 不出现 (anti-pattern regression guard, 同 #1387 `documentElement.outerHTML` 反向 guard)

### [Typed-error hardening] 三 func navigation + evaluate boundary
- `requireXiaoePageUrl()` for `content/catalog`: missing/malformed/non-https/off-domain URL → upfront `ArgumentError` before `page.goto` (test asserts `expect(page.goto).not.toHaveBeenCalled()`)
- `content/catalog/courses`: `page.goto` moved inside try, navigation/evaluate failures both wrap as `CommandExecutionError`, no raw CDP/browser error path leaks
- Empty shell stays `EmptyResultError` (no reliable login-wall signal to justify `AuthRequiredError`, 避免 false positive — 应用 #1384 secUid 教训)

### [Doc fix] inherited legacy doc errors
- `xiaoe courses --limit 10` example removed (no `--limit` arg in manifest, legacy doc 错误 inherit)
- positional `url` wording aligned with manifest (was incorrectly `--url`)
- 同 #1386 positional docs 教训, 但延伸到 "继承 legacy doc 错误也是新 PR 责任" (Boy Scout typed-error hardening 在 doc 层延伸)

## Tests: 46/46 green
- 3 cmd registration contract
- pure helper unit tests (selector chain / image filter / URL priority / type label fallback / no synthetic URL)
- `buildScript` invariants (`images.slice(0, 20)` 反向断言)
- wire tests: ArgumentError upfront (BEFORE page.goto), EmptyResultError empty rows + empty content, CommandExecutionError navigation/evaluate failure, rows verbatim happy path

## Lint gates
- typed-error-lint 190/190 (no new) ✓
- silent-column-drop 103/103 (no new) ✓ (注: `pipeline:[]` IIFE string template AST walker 看不进, lint follow-up scope)
- doc-coverage 140/140 ✓
- listing-id-pairing advisory unchanged 13 ✓

## GitHub checks (head a6d37d70)
build ×3 / unit-test ×2 / bun-test / adapter-test / audit / doc-coverage / docs-build SUCCESS, smoke skipped, MERGEABLE / CLEAN

## Review
B 组: @codex-mini1 lead + @First-principles-1 aux, double-green confirmed, Round 17 race-mitigation 第 4 轮 protocol clean closeout (第 4 次连续无 race 执行: #1384 / #1386 / #1387 / #1388)。

## Sediment lessons
- Silent-failure 三类 taxonomy: silent-column-drop (列没声明) / silent-bad-shape (字段在但 shape 错) / silent-empty-row (错误状态返空行而不是抛 typed error) — 三类 fix 路径不同, blast radius 不同
- PR body honesty 演进 4 链: #1384 R4 race disclosure → #1386 positional docs 教训 → #1388 silent-failure 三类分开写 + dual-nature tag 矩阵
- F-P-1 first-principles call: 不顺手暴露 legacy 坏合同 (silent-bad-shape ≠ silent-drop, fix 路径完全不同)
2026-05-07 17:02:23 +08:00
jakevin e610260705 refactor(hupu/hot): pipeline→func + querySelectorAll + 4 enrichment columns (Phase 3 P3)
Phase 3 P3 (hupu/hot) — pipeline→func refactor + 2 真 bug 修 + 4 列 enrichment + JSDOM-frozen-fixture test pattern (#1313 复用) + anti-pattern regression guard。

## Summary
- Pipeline form (`pipeline:[]` + `documentElement.outerHTML` regex) → `func` form (`querySelectorAll('.t-info')` DOM walk)
- **Bug 1 修**: outerHTML regex 静默漏行 (markup 抖动就漏, mocked test 抓不到)
- **Bug 2 修**: regex 抓所有 9-digit 锚点 → ~70 个 anchor 但页面只 render 60 个 `.t-info` row → legacy adapter 每次返 ~10 个 phantom 行 (导航链接 conflated 成 thread 行)
- **4 enrichment columns** (4→8): `lights` (亮 count int|null, 万 expanded `1.2万→12000`) / `replies` (回复 count int|null) / `forum` (per-row sub-section) / `is_hot` (bool 暴露 hupu \" hot\" marker, 不 filter 行序保持页面顺序)
- columns/manifest/docs sync: `[rank, tid, title, lights, replies, forum, is_hot, url]`,`null` vs `0` 语义清楚

## Typed errors
- `--limit` 上游 `ArgumentError` for 0/-1/>100/1.5/non-numeric (BEFORE `page.goto`,**不 silent clamp**)
- 空页 `EmptyResultError`
- `page.evaluate` failure 包成 `CommandExecutionError` (test regression locked)

## JSDOM frozen-fixture test pattern (#1313 复用)
- 抽 `extractHupuHotRowsFromDoc(doc, limit, parseCount)` 为 module-level pure export
- in-page IIFE 通过 `\${fn.toString()}` 嵌同一份代码
- JSDOM test 直接调 export against `__fixtures__/hot-home.html` (slim 6-row hand-crafted fixture)
- 17/17 tests green (contract / normalize / parseCount / extract / buildHotScript invariants / wiring / phantom-anchor exclusion / evaluate-error envelope)

## Anti-pattern regression guard (#1313 fixture pattern 延伸)
- `buildHotScript` 反向断言 `not.toContain('documentElement.outerHTML')` 锁不回退到旧 broad regex
- `buildHotScript` 反向断言 `not.toContain('regex.exec')` 同向锁
- fixture 顶部 `.t-info` 外的 9-digit phantom anchor `639999999` 反向锁: 旧 broad regex 会抓到, 新 `.t-info` extractor 不抓 — 把 fixture 反向验证从断言层升到证据层

## Better-solution check (live probe evidence-based)
DOM `.t-info` = 60 visible rows, `window.\$\$data.pageData.threads` = 70 (10 hidden/non-rendered)。对"首页可见 hot rows" 任务, DOM walk 比 bootstrap JSON 更贴 source of truth (后者会引入 hidden/不渲染条目)。这条 60 vs 70 数字是设计决策的硬 justify, 不是设计意见。

## Lint gates
- typed-error-lint 190/190 (no new) ✓
- silent-column-drop 103/103 (no new) ✓
- doc-coverage 140/140 ✓
- listing-id-pairing advisory unchanged 13 ✓

## GitHub checks (head 874d4e4e)
build ×3 / unit-test ×2 / bun-test / adapter-test / audit / doc-coverage / docs-build SUCCESS, smoke skipped, MERGEABLE / CLEAN

## Review
A 组: @codex-mini0 lead + @First-principles-0 aux, double-green confirmed, Round 17 race-mitigation 第 4 轮 protocol clean closeout.
2026-05-07 16:59:17 +08:00
jakevin 464de7059e refactor(tiktok): write commands -> button-walker Route 1 with typed errors (Phase 3 P0.5)
Phase 3 P0.5: refactor 3 TikTok write commands (comment, follow, unfollow) from time-window-wait UI flow to a button-walker + state-verification path with a typed-error boundary, sharing a parallel helper structure to the #1384 read PR.

Two-layer helper boundary (clis/tiktok/utils.js extension):
- BUTTON_WALKER_HELPERS (browser side): button-walker (locate / pre-click state read / click / state-verify post-click) + cleanText reuse + cookie/auth-secUid plumbing for write-auth + plain Error throws on contract violations
- throwButtonWalkerError() (Node side): map browser-thrown errors -> typed CommandExecutionError (button missing / state-verify fail / captcha / rate-limit / navigation/eval/empty-row defensive failures) / AuthRequiredError (cookie + viewer secUid) / ArgumentError (upfront input validation). Explicitly NO EmptyResultError mapping (button contract violation is not an empty result, per #1384 R4 lesson on auth-vs-empty classification).

Per command:
- comment <video-url> <text>: button-walker click + state-verify by checking comment-list state (not wait-2s)
- follow <username>: pre-click state read distinguishes idempotent fast path (`already-following` / `already-friends`) from post-click success (`followed`). Post-click result causality preserved (post-click never returns `already-*`).
- unfollow <username>: pre-click `already-not-following` fast path; post-click `unfollowed`.

result enums (per row):
- comment: `posted` (no idempotent path - comments cannot dedupe)
- follow: `followed` | `already-following` | `already-friends` (last two pre-click only)
- unfollow: `unfollowed` | `already-not-following` (last one pre-click only)

retryable contract (in hint string `retryable=<bool> reason=<...>`):
- comment failures: retryable=false reason=server-fan-out
- follow/unfollow failures: retryable=true reason=idempotent (server-side dedupe is safe)

Lead push iterations during review (codex-mini1 maintainer-fixes-directly):
- f5730f16: rate-limit/captcha -> CommandExecutionError + retryable hint BEFORE auth regex (auth precedence bug); follow post-click success -> `followed` (NOT `already-friends`, fixing causality misclassification); navigation/empty-row defensive failures route through throwButtonWalkerError (containing raw Error leakage).
- b683f46c: parseTikTokVideoUrl() requires canonical /@user/video/<numeric-id> with only optional trailing slash/query; malformed suffixes (e.g. /123abc, extra path) -> upfront ArgumentError.
- f5dc91d6: docs examples updated to actual positional args for write commands (was stale --url/--text/--username flag form), covering write-rewrite + sibling like/unlike/save/unsave on touched docs file (Boy Scout).

Intentionally NOT addressed (separate scope, candidate post-merge follow-ups):
- Direct /api/commit/follow/user/ or /api/comment/publish/ (would require X-Bogus signing reverse engineering, separate risk surface)
- RetryableError as core typed-error metadata (currently encoded in hint string, post-merge candidate to import into engine)
- TikTok Studio creator metrics commands (separate Phase scope)

Validation:
- clis/tiktok/ tests: 64/64 (38 read from #1384 + 22 new write contract + 4 regression for blockers caught during review)
- typed-error-lint: 190/190
- silent-column-drop: 103/103
- doc-coverage: 140/140
- listing-id advisory: 13 unchanged
- docs:build pass, manifest 764 entries
- GitHub gates on f5dc91d6: build x3 / unit x2 / bun / adapter-test / docs-build / doc-coverage / audit all SUCCESS, smoke skipped, CLEAN/MERGEABLE

Reviewers: codex-mini1 (lead, 3 contract pushes f5730f16 -> b683f46c -> f5dc91d6), First-principles-1 (aux, validated 4 contract patches + better-solution check confirming button-walker Route 1 vs /api/commit/* + X-Bogus separation).
2026-05-07 16:33:00 +08:00
jakevin 9a7dd44b3e refactor(tiktok): 6 read commands -> page-context API (Phase 3 P0, absorbs #1382)
Phase 3 P0: refactor 6 TikTok read commands (explore, following, friends, live, notifications, user) from DOM/network-intercept to TikTok web's own page-context API endpoints, sharing one helper boundary.

Helper boundary (clis/tiktok/utils.js):
- BROWSER_HELPERS: in-browser fetchJson + cleanText + asNumber (null/'' -> null preserve missing-vs-zero distinction) + cookie/msToken plumbing
- VIDEO_ITEM_NORMALIZER: normalize page-context item -> row shape
- assertTikTokApiSuccess(data, label): unify TikTok in-band envelope (status_code/statusCode != 0; code 8 or auth-looking message -> AUTH_REQUIRED; other -> upstream label API failed)
- throwTikTokPageContextError() (Node side): map browser-thrown errors -> AuthRequiredError / EmptyResultError / CommandExecutionError

Per command:
- explore: /api/recommend/item_list/ pagination, --limit upfront ArgumentError
- following: /api/user/list/ relationships
- friends: /api/user/list/ + cross-filter
- live: /api/live/discover/ feed
- notifications: /api/notice/multi/ (status 8 -> AUTH_REQUIRED)
- user (absorbed from #1382): secUid resolve via __UNIVERSAL_DATA_FOR_REHYDRATION__ -> /api/user/detail/, /api/post/item_list/ pagination, /api/search/general/full/ exact-author fallback. !secUid -> EmptyResultError (NOT AuthRequiredError; auth still covered by HTTP 401/403 + envelope status_code 8/auth-looking msg). source field = bootstrap | profile-api | search-fallback in row/columns/manifest/docs/tests.

Closes #1382 (absorbed; #1382 closed without separate merge per WAWQAQ direction).

Validation:
- clis/tiktok/ tests: 38/38
- typed-error-lint: 190/190
- silent-column-drop: 103/103
- doc-coverage: 140/140
- docs:build pass, manifest no drift
- GitHub gates: build x3 / unit x2 / bun / adapter-test / audit / doc-coverage / docs-build all SUCCESS, smoke skipped, MERGEABLE

Reviewers: codex-mini0 (lead, push 4 boundary fixes 18cdf930 -> a1f1ada4 -> 53499609 -> 276dce3b), First-principles-0 (aux, caught secUid auth-vs-empty boundary + verified 6 cmd integral helper boundary).
2026-05-07 16:15:30 +08:00
jakevin b327da5b3c feat(llm): reuse browser sessions by site (#1385) 2026-05-07 15:49:25 +08:00
yorick 1b113a60bc pass example field through cli() registration (#1381) 2026-05-07 15:34:32 +08:00
jakevin fa7851bb9a feat(browser): add adapter session reuse (#1383) 2026-05-07 15:24:54 +08:00
Benjamin Liu d527571b7d test(gov-policy): JSDOM-against-frozen-fixture tests for in-browser extractors (#1340)
* test(gov-policy): JSDOM-against-frozen-fixture tests for in-browser extractors

Applies the pattern documented in skills/opencli-adapter-author/references/jsdom-fixture-pattern.md
(introduced in #1319 alongside the dianping reference test in #1313) to the
gov-policy adapter.

Refactor: the inline IIFE inside `page.evaluate` template literal is hoisted
to a top-level `extractSearchRows` / `extractRecentRows` function using bare
`document` / `location`. Same code now runs identically in:

  - the live browser (injected via `${extractor.toString()}`)
  - JSDOM unit tests (with `globalThis.document` / `globalThis.location` swapped)

Tests:

  - 6 new cases in clis/gov-policy/gov-policy.test.js (was commands.test.js).
  - 3 representative search result cards (1 with real article snippet, 2 with
    only publish-time in `.description`) and 5 recent listing rows in the
    fixtures.
  - ok:false fallback path covered for both extractors.
  - Lock-in: `要闻` type-tag prefix fusion in title and empty-source contract
    on recent listings (no `.source` / `.from` elements on that page) are
    asserted explicitly so a future selector tweak can't silently change them.

Reverse-validated against two buggy variants per the reference doc:
breaking the title selector and stripping the `要闻` prefix both fail the
JSDOM assertions with helpful diffs.

Fixture sanitization follows the reference doc step-by-step: scripts /
styles / iframes / comments / preload links stripped, image srcs replaced
with `placeholder.png`, trimmed to the minimum subtree that exercises the
extractor (3 search items, 5 recent rows), all whitespace-only lines
removed.

* fix(gov-policy): use typed errors for touched commands

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-07 12:46:13 +08:00
jakevin c6d5da54ee feat(web): add exhaustive same-origin frame mode (#1373) 2026-05-07 01:15:10 +08:00
jakevin 124adf73d1 fix(web): avoid duplicate iframe diagnostics (#1372) 2026-05-07 00:58:42 +08:00
jakevin 829edfea3a fix(web): include relevant iframes outside main content (#1371) 2026-05-07 00:44:52 +08:00
jakevin 67cde0e263 enrich(coupang): product detail cmd + replace silent clamp/sentinel/Error with typed errors (#1370)
* enrich(coupang): add product detail cmd + replace silent clamp/sentinel/Error with typed errors

Two enrichment changes plus three silent-failure fixes on top of existing
search / add-to-cart.

New cmd: coupang product
─────────────────────────
Pairs with search as the listing↔detail round-trip target. Reads a logged-in
product page and extracts a single canonical row with price, original_price,
discount_rate, rating, review_count, seller, brand, rocket, delivery_promise,
image_url, url. Three-source extractor (JSON-LD Product schema → bootstrap
globals → DOM) merged in priority order, mirroring the search.js pattern.

The columns use string|null typing — null means "upstream did not provide
this field on this product" (e.g. some items have no original_price).
Failures (login wall / page mismatch / page failed to render) raise typed
errors instead of silently returning empty rows, so callers can treat any
returned row as real data.

Search column shape: added product_id
─────────────────────────────────────
Listing must pair with detail by id. The data was already extracted by
normalizeSearchItem; only the columns array needed updating so the field
projects through to the rendered row. Per the listing-id-pairing convention
(PR #1297) the new column lets agents round-trip rows directly into
`coupang product` without re-scraping URLs.

Silent-failure fixes
────────────────────
1. search --limit silent clamp.
   Old: `Math.min(Math.max(Number(kwargs.limit||20),1),50)` silently
        rewrote `--limit 999` to 50 and `--limit 0` to 1.
   New: `parseLimitArg(raw, 20, 50)` throws ArgumentError on out-of-range
        / non-integer / negative input. Same convention as the typed-fail-fast
        memory & PR #1289.

2. search --page silent clamp.
   Old: `Math.max(Number(kwargs.page||1),1)` silently lifted negative pages.
   New: parsePageArg throws ArgumentError on non-positive input.

3. Generic `throw new Error(...)` → typed errors.
   - Empty query, unsupported --filter, missing --product-id/--url
     → ArgumentError
   - Login wall detection → AuthRequiredError('coupang.com', ...)
   - Empty result / filter-not-rendered → EmptyResultError
   - PRODUCT_MISMATCH / OPTION_REQUIRED / button-not-found / unknown
     ack failure (add-to-cart) → CommandExecutionError
   - The PRODUCT_MISMATCH and `actualProductId || 'unknown'` sentinel were
     also fixed (silent-sentinel was the audit hit there).

Coverage
────────
- 21 contract assertions in clis/coupang/coupang.test.js covering
  parseLimitArg / parsePageArg (no silent clamp), registry shape (search has
  product_id, product is read-class with expected columns, add-to-cart is
  write-class), and typed-error pre-flight rejections (empty query / bad
  filter / out-of-range limit & page / missing detail args).
- Manifest 763 → 764 (+1 entry: coupang/product).
- Audits: typed-error-lint 196 → 194 (resolved 2 silent-clamp/sentinel
  baseline entries; baseline updated). silent-column-drop 103/103 unchanged.

* fix(coupang): tighten product id and browser errors

* fix(coupang): require real product urls
2026-05-07 00:18:57 +08:00
jakevin a5a3248a77 refactor(linux-do): remove deprecated hot/category/latest compat shims (#1368)
* refactor(linux-do): remove deprecated hot/category/latest compat shims

The three shims have been pure backward-compat wrappers since linux-do/feed
became the unified entrypoint. With no stable release commitment to preserve,
they are pure surface cost: 3 manifest entries, 3 deprecated branches in help
output, and a `buildLinuxDoCompatFooter` helper that exists only to feed them.

- delete clis/linux-do/{hot,category,latest}.js
- drop now-orphaned `buildLinuxDoCompatFooter` from feed.js and unexport
  `executeLinuxDoFeed` (no external consumers remain)
- remove the Compatibility section in docs/adapters/browser/linux-do.md
- regenerate cli-manifest.json (-125 lines)

BREAKING CHANGE: `opencli linux-do hot|category|latest` are removed. Use
`opencli linux-do feed --view top --period <period>`,
`opencli linux-do feed --category <id-or-name>`, and
`opencli linux-do feed --view latest` instead.

* fix(linux-do): finish compat shim removal
2026-05-06 23:57:15 +08:00
jakevin dcaae37068 refactor(registry): remove dead adapter metadata (#1369)
* refactor(registry): remove dead adapter metadata

* docs(changelog): note header strategy removal
2026-05-06 23:49:39 +08:00
jakevin 12d88e4b23 refactor(runtime): unify command timeout into a single --timeout arg (#1364)
* refactor(runtime): unify command timeout into a single --timeout arg

Drop the cli-level `timeoutSeconds` build-time ceiling field. A command
now opts into runtime-enforced timeouts purely by declaring an arg named
`timeout`; the user-facing `--timeout` value (its default or override)
is the single authoritative knob, used both by the adapter polling loop
and by the runtime ceiling (with a 30s padding for return + closeWindow
+ trace export).

Behavior:
- Browser commands without a `--timeout` arg fall back to
  OPENCLI_BROWSER_COMMAND_TIMEOUT (default 60s, unchanged).
- Non-browser commands without a `--timeout` arg now run unbounded
  rather than against the previously implicit `timeoutSeconds` cap.
  Affected commands keep their old caps via newly added `--timeout` args.
- LLM adapters (gemini/claude/deepseek/doubao/qwen/yuanbao ask) keep
  their current `--timeout` defaults; the runtime ceiling is now strictly
  more generous (userTimeout + 30s vs. the previous 180s cap), so
  `--timeout 600` actually buys 600s of polling rather than dying at 180s.

Closes the design discussion that started from PR #1227, which proposed
a per-site `OPENCLI_GEMINI_ASK_TIMEOUT` env var to work around the same
underlying mismatch.

* fix(timeout): wire --timeout arg into chatgpt/gemini image adapter polling

codex-coder review on PR #1364 caught that the new --timeout arg I added
to chatgpt/image and gemini/image only drove the runtime ceiling — the
adapter still hardcoded `const timeout = 120`, so users passing
--timeout 240/600 saw runtime allow 270s/630s but the adapter stop
polling at 120s. That recreated the same single-knob mismatch this PR
was meant to delete.

Also add the browser-path runWithTimeout assertion codex-coder flagged
as missing: a browser command with --timeout default=5 must call
runWithTimeout with timeout: 35; a browser command without --timeout
arg must fall back to DEFAULT_BROWSER_COMMAND_TIMEOUT.

Image adapters now read kwargs.timeout and reject non-positive-integer
values with ArgumentError (no silent fallback). chatgpt/image.test.js
updated to pass an explicit timeout when calling .func directly (the
test bypasses arg coercion).

* fix(runtime): reject invalid timeout ceilings

* fix(timeout): normalize timeout args to integer values

* fix(timeout): preserve remaining command ceilings

* fix(runtime): validate timeout before browser setup
2026-05-06 23:30:03 +08:00
jakevin 4ef2cb8b1c enrich(toutiao): hot board (public) + bug fixes (silent column drop, partial render) (#1366)
* enrich(toutiao): hot board + bug fixes (silent column drop, partial render)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #3.

## New command
- `toutiao hot` (Strategy.PUBLIC, browser:false) — public homepage hot
  board via the toutiao.com hot-event/hot-board endpoint. No login required.
  Returns 8 stable columns (rank/id/title/query/hot_value/label/url/image).

## Bug fixes for `toutiao articles`
- **Silent column drop fixed**: `parseToutiaoArticlesText` previously
  did `if (title && stats) push(...)`, silently dropping any row where
  the stats span hadn't finished rendering by the time page.innerText
  was read. Slow-render bugs were invisible — adapter looked "complete"
  while writers saw extra rows in the dashboard. Partial rows now
  surface with `null` stat columns.
- **Silent clamp on `--page` removed**: out-of-range / non-integer
  values raise `ArgumentError` with explicit bounds [1, 4]. Same
  validation reused by both `articles` and `hot` via `parseArticlesPage`
  / `parseHotLimit` in `utils.js`.
- **Empty result typed**: zero-row scrape now raises `EmptyResultError`
  instead of returning `[]` silently (would otherwise look like a
  legitimate "no articles" response).

## Refactor
- Parser logic extracted to `clis/toutiao/utils.js` (alongside hot-row
  mapping, validators, and the hot-board URL constant).
- `articles.js` switches from declarative `pipeline:` to imperative
  `func` form so `parseArticlesPage` validation can run before the
  navigation step (declarative pipeline can't pre-validate args).
- Strategy is now explicit: `Strategy.COOKIE, browser: true` for
  articles (creator dashboard is logged-in only).

## hot field map
`ClusterIdStr` (or numeric `ClusterId`) → id; `Title` → title;
`QueryWord` → query (falls back to title); `HotValue` → hot_value
(non-negative numeric, else null); `Label`, `Url`, `Image` →
respective columns. `pickImage` walks `Image.url` → first truthy
`Image.url_list[]`. Empty-title rows are dropped (returns null) before
ranks are densely re-assigned 1..N.

## Tests
29 contract assertions across `parseArticlesPage` / `parseHotLimit` /
`parseToutiaoArticlesText` / `mapHotRow` + registry-level shape checks
+ `hot` adapter func behaviour (typed errors / no silent clamp / fetch
failure paths / dense-rank).

## Audits
- typed-error-lint: 196 = 196 (unchanged baseline)
- silent-column-drop: 103 = 103 (unchanged baseline)
- listing-id-pairing: hot has `id` column (round-trippable when a
  detail command lands later); advisory list unchanged.

## Manifest
757 → 758 entries (+1 for `hot`).

## Doc
- index.md: toutiao mode 🔐🌐/🔐 (hot is public, articles is logged-in)
- toutiao.md: per-command mode/domain table + column docs + prerequisites

* fix(toutiao): tighten hot and articles contracts
2026-05-06 23:17:32 +08:00
jakevin 69ee36f997 fix(linkedin): surface detail_error on --details (no silent catch / no silent empty) (#1363)
* fix(linkedin): surface detail_error on --details (no silent catch / no silent empty)

The previous --details enrichment path had two indistinguishable failure modes
that both produced `description: '', apply_url: ''`:

1. `if (!job.url)` early return — row had no jobId, so we couldn't navigate.
2. `} catch {}` — page.goto / page.evaluate threw (network, timeout, parse error).

Callers couldn't tell "upstream had no description" from "we failed to fetch",
and the catch swallowed every error without logging. For an enrichment that
costs one page navigation per row, silent failure is especially harmful — users
just see an empty cell with no way to debug.

Fix: replace empty strings with `null` for missing/failed rows, add a new
`detail_error` column (string|null) carrying a short typed reason:

  - 'no url'                — row had no jobId
  - 'fetch failed: <msg>'   — page.goto / page.evaluate threw
  - 'missing description'   — page loaded but body was empty
  - null                    — success

Every failure is also logged to stderr with the offending URL so debugging is
possible. Per-row failures still don't abort the batch (the original intent),
but they're now visible.

Tests: 13 new contract assertions in clis/linkedin/search.test.js covering
parseCsvArg, mapFilterValues (ArgumentError on unknown values), decodeLinkedinRedirect,
and 5 enrichJobDetails paths (no-url / goto-throw / empty-description / success /
multi-row-mixed). Added `export const __test__` for testability.

Audits clean: typed-error-lint 196/196, silent-column-drop 103/103.

* fix(linkedin): fail fast on auth walls
2026-05-06 22:57:09 +08:00
jakevin da2453cfbd enrich(reuters): article-detail + bug fixes (silent clamp, silent error envelope) (#1362)
* enrich(reuters): article-detail + bug fixes (silent clamp, silent error envelope)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #2.

- `reuters article-detail` — full article body + canonical metadata for a
  Reuters URL. Pairs with `reuters search` (use the `url` column to
  round-trip).

- **Silent clamp on `--limit` removed**: out-of-range values now raise
  `ArgumentError`. Validation happens before browser navigation.
- **Silent error envelope removed**: the in-page IIFE used to swallow
  `fetch` errors with `catch(e) {}` and return `{error: ...}`, then the node
  side did `if (!Array.isArray(data)) return [];`. Now:
  - in-page IIFE returns `{ ok, status, body, error? }` raw envelope
  - node side throws typed errors:
    - `CommandExecutionError` on in-page exception
    - `CliError(FETCH_ERROR)` on non-2xx upstream
    - `CommandExecutionError` on captcha HTML (200 + non-JSON body)
    - `EmptyResultError` on empty articles array
- **Empty query**: now `ARGUMENT_INVALID` instead of triggering an empty
  upstream call.
- **Column shape enriched**: previously dropped `section_path` and
  `authors` are now stable columns.

- `docs/adapters/browser/reuters.md`: full Commands / Columns / Error
  Behaviour section (was a 3-line stub).
- `docs/adapters/index.md`: add `article-detail` to the commands cell.

27 contract assertions across `parseLimit` / `mapSearchArticles` /
`mapArticleDetail` / `buildSearchScript` / `buildArticleDetailScript`
+ registry-level checks for both commands (Strategy, ARG validation
before nav, every typed-error path, success path).

- typed-error-lint: 196 → 195 (silent-clamp resolved on
  `clis/reuters/search.js:18`); baseline updated.
- silent-column-drop: 103 = 103 (unchanged).
- listing-id-pairing: advisory only (article-detail keys off `url`).

757 → 758 entries (+1 for `article-detail`).

* fix(reuters): type auth and fetch failures

* fix(reuters): preserve search detail round trip
2026-05-06 20:00:36 +08:00
jakevin 61c4637b4c enrich(ctrip): hotel-suggest + bug fixes (silent clamp, dropped columns, fake URL) (#1361)
* enrich(ctrip): hotel-suggest + bug fixes (silent clamp, dropped columns, fake URL)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #1.

## New command
- `ctrip hotel-suggest` — surfaces hotel-context suggestions (cities,
  business areas, individual hotels) via the same backing endpoint with
  searchType=H. Distinct from `ctrip search` (searchType=D) which returns
  destinations / scenic spots / railway stations.

## Bug fixes for `ctrip search`
- **Silent clamp on `--limit` removed**: out-of-range values (≤0, ≥51,
  non-integer) now raise `ArgumentError` with explicit bounds rather than
  silently snapping to [1, 50].
- **Silent column drop fixed**: previously the adapter discarded `id`,
  `cityId`, `cityName`, `provinceName`, `countryName`, `lat`, `lon`, `eName`
  and `displayType` from upstream rows. Now all are surfaced as stable
  columns.
- **Fake URL fixed**: previously `url` was always `''`. Now constructs
  canonical Ctrip URLs by `type` (City / Markland / Hotel / Zone / RailwayStation)
  and returns `null` (no silent fabrication) for unknown types.
- **In-band error envelope typed**: `Result: false` payloads now surface
  as `COMMAND_EXEC` (was previously not handled — adapter returned empty
  rows).

## Doc fix
- `Mode: 🔐 Browser` → `🌐 Public` (search uses public API, no login)
- Add `hotel-suggest` to commands table in both `docs/adapters/index.md`
  and `docs/adapters/browser/ctrip.md`.

## Coords picker
Mainland China rows ship `gdLat`/`gdLon` (gaode); international rows ship
`gLat`/`gLon` (wgs84). Adapter picks the first non-zero pair (zero is the
upstream sentinel for "missing"); returns `null` if all variants are zero.

## Tests
25 contract assertions across `parseLimit` / `pickCoords` / `buildUrl` /
`mapSuggestRow` + registry-level checks for both commands (Strategy /
shape parity / typed errors / no silent clamp).

## Audits
- typed-error-lint: 196 = 196 (unchanged baseline)
- silent-column-drop: 103 = 103 (unchanged baseline)
- listing-id-pairing: advisory only (search has `id` round-trip column)

## Manifest
757 → 758 entries (+1 for `hotel-suggest`).

* fix(ctrip): wrap suggest fetch and json failures
2026-05-06 19:34:13 +08:00
Benjamin Liu 4e92d7163a feat(browser): add --width / --height / --full-page flags to screenshot (#1339)
Closes #1334.

Exposes viewport overrides for `opencli browser screenshot` so an adapter or
ad-hoc shell user can render a page at a fixed width and capture the full
scrollable height. The ljg-card HTML to PNG pipeline use case.

Behavior:
- `--width W` only overrides device-metrics width; height is left unchanged.
- `--height H` only overrides height (ignored under `--full-page`).
- `--full-page` keeps the existing `captureBeyondViewport` shortcut.
- `--full-page --width W` first reflows at W, then re-overrides to (W, contentH)
  so the captured image reflects the layout at the requested width.
- Override is always cleared in `finally`, including on capture failure.
2026-05-06 19:17:14 +08:00
Benjamin Liu 6469a02ea6 feat(deepseek): add detail and send commands for explicit conversation control (#1344)
* feat(deepseek): add detail and send commands for explicit conversation control

doubao already ships `detail <id>` and `send` for ID-explicit conversation
read/write; deepseek had only `read` (current page only) plus the
implicit-resume `ask`. Adding both gives users a stable handle when they
know the conversation ID, without going through `ask`'s resume detection
or its full prompt-then-wait pipeline.

`deepseek detail <id>`:
  - parses a bare UUID or any URL containing `/a/chat/s/<id>`,
  - rejects malformed input via `ArgumentError` before any browser
    navigation,
  - navigates to `https://chat.deepseek.com/a/chat/s/<id>` and returns
    the visible message list,
  - throws `EmptyResultError` when the conversation has no rendered
    messages.

`deepseek send <id> <prompt>`:
  - takes the conversation id as a required positional, because the
    framework runs each browser command in an ephemeral per-command
    workspace (a fresh tab) and there is no shared "current conversation"
    across commands; the navigation must be explicit,
  - drives input through CDP `Input.insertText` via `page.nativeType`,
    mirroring the doubao adapter (#1278); `execCommand('insertText')` plus
    a synthesised input event leaves the React-controlled state desynced
    on a freshly-opened tab and the resulting click silently no-ops,
  - keeps the verification loop inside the same `page.evaluate` so the
    framework cannot close the tab mid-flight; counts user-class bubbles
    by text-match (DeepSeek virtualises the message list, so a numeric
    bubble-count check is unreliable),
  - throws `CommandExecutionError` with a specific reason when the
    textarea did not populate, the send button stayed disabled, the
    bubble never settled, or the optimistic render rolled back during
    a 3s settle window,
  - treats "Promise was collected" from the post-click eval as success,
    matching the existing pattern in `ask --file`.

Helper `parseDeepSeekConversationId` is exported from utils.js so the
same parser feeds both commands and round-trips the canonical lower-case
ID.

Tests:
  - utils.test.js: 5 cases covering bare UUID, upper-case
    normalisation, URL extraction with and without query string, empty /
    null / whitespace input, and non-UUID rejection.
  - detail.test.js: 5 cases covering registration, navigation +
    message return, URL normalisation, ArgumentError before browser
    navigation, and EmptyResultError on no-messages.
  - send.test.js: 7 cases covering registration, ArgumentError on bad
    id, full happy-path through nativeType + IIFE verification, the
    textarea-mount timeout, missing nativeType helper, focus failure,
    IIFE-reason translation to CommandExecutionError, and the
    "Promise was collected" success path.

Manifest auto-regenerated to register both commands.

Live-verified end-to-end against my own DeepSeek session:
  - `detail` returns the canonical message list for a bare UUID, parses
    a full chat URL, and rejects malformed IDs before any browser
    navigation,
  - `send` lands the prompt as the latest user message in the target
    conversation and gets an AI response back; reload of the
    conversation page in a separate tab confirms the message persisted
    server-side.

* docs(deepseek): document detail and send commands

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 19:13:54 +08:00
jakevin 8f2d510408 fix(browser): keep automation container window reusable
Keep the owned automation container window warm across lease release. Non-final owned leases close their tab; the final owned lease resets its tab to about:blank as a reusable placeholder. Update browser close wording to describe lease release rather than window closure.
2026-05-06 19:01:22 +08:00
jakevin b58e43da7f feat(extension): mark automation tabs with group 2026-05-06 17:57:13 +08:00
Benjamin Liu 5137aac036 fix(deepseek): skip pinned conversations and fail fast when resume target unavailable (#1343)
Closes #1342.

`opencli deepseek ask` (without --new) used to resume the most recent
conversation by clicking the first `a[href*="/a/chat/s/"]` in DOM order
after a fixed 2-second wait.

Two bugs:

  1. Pinned conversations sit in their own DOM section ("置顶") that
     renders above "30 天内" and friends. Click-first-anchor lands on the
     pinned thread, not the user's most recent. Reproduced live by
     pinning a conversation through the sidebar context menu and
     observing that the existing logic targets it instead of the most
     recent non-pinned thread.
  2. The 2s wait is fixed. On a slow network the sidebar has not
     populated yet, the click is a no-op, and `ask` silently falls
     through to the new-chat path. The user typed "follow up" and
     a brand-new conversation gets created.

Replace the click-first-anchor + fixed wait with a new helper
`pickResumeUrl(page)` in utils.js that:

  - polls the sidebar for up to 10s (5 attempts × 2s),
  - identifies pinned anchors by a text-based check on the section
    header (`/^\s*(置\s*顶|Pinned)\s*$/i`); DeepSeek's CSS-module
    class names are randomized per build, so the text is the only
    stable signal,
  - returns the URL of the first non-pinned anchor (or falls back to
    the first overall if every visible anchor is pinned),
  - returns null if no anchor surfaces in time.

`ask.js` calls the helper and `page.goto`s the returned URL. When the
helper returns null, `ask` now throws a `CommandExecutionError`
instead of silently navigating to a fresh chat. The user gets a clear
"pass --new" hint and their prompt is never sent to a wrong target.

Tests:
  - utils.test.js: 4 cases covering happy path, polling-then-success,
    timeout returns null, and a structural assertion that the embedded
    DOM walker uses text-based pinned detection.
  - ask.test.js: replaced the prior "still selects model when no
    conversation to resume" test (which exercised the silent
    fall-through) with a fail-fast assertion. Updated the resume-success
    test to mock the new helper.
2026-05-06 17:54:33 +08:00
jakevin f033481e67 feat: 2 read adapters (wttr, openfda) + contract tests (#1355)
* feat: 2 read adapters across 2 new sites + contract tests (wttr, openfda)

Trimmed from original Round 11 per WAWQAQ feedback (msg=3899a382): drop
novelty/niche sites (timeapi / zippopotam / spacedevs / citybik) — keep
only sites with clear real-world utility:

- wttr (current, forecast) — wttr.in weather, no auth, simple text/json toggle
- openfda (drug-label, food-recall) — FDA drug labels + food recall enforcement

13 contract tests across 2 sites cover Lucene operator query construction
(openfda +AND+ literal handling), [string] 1-elem array unwrap, brand-OR-
generic match, wttr [{value:"..."}] array-of-objects 1-elem unwrap.

Manifest 757→759 (+2). Audits clean: typed-error-lint=196 baseline.

* fix(openfda): use brand or generic label search
2026-05-06 17:47:05 +08:00
jakevin 39943c05e9 feat: 12 read adapters across 6 new sites + contract tests (round 7) (#1350)
* feat: 12 read adapters across 6 new sites + contract tests (round 7)

New sites: wikidata, lichess, rest-countries, nuget, flathub, oeis
- wikidata: search (wbsearchentities) + entity (Special:EntityData) — Q/P/L ids,
  localised label/description with English fallback
- lichess: user + top (perf rankings) — closed accounts → EmptyResultError, no
  silent disabled rows; 13 perf types validated
- rest-countries: country (substring) + region — population-sorted by default,
  flattened languages/currencies/capitals
- nuget: search + package (full version history) — registration page-walking
  for 100+ version histories; case-insensitive id with strict shape gate
- flathub: search + app — appId reverse-DNS, dual-shape timestamp coercion
  (search is unix-seconds int, /appstream is ISO string)
- oeis: search (paginated) + sequence — A-id zero-padded, 12-term preview with
  (+N) suffix, surfaces commentCount/formulaCount/etc instead of full graphs

Contract tests: 6 files × 7 assertions = 42 contract assertions, all green.
Live verified all 12 commands against real APIs.

Audits clean: typed-error 196=baseline, silent-column 103=baseline, 0 Round-7
listing-id-pairing violations (all 6 listings carry round-trip ids).

* fix(nuget): fail fast on malformed registration pages
2026-05-06 16:54:21 +08:00
jakevin 9ae44c228a fix(round5): strengthen contract tests (#1348) 2026-05-06 14:19:55 +08:00
jakevin 498ad3930c feat: 13 read adapters across 6 new sites (round 4) (#1347)
Six new public-API sites — package registries + Docker images + OpenAlex
scholarly works — all unauthenticated, no browser required.

  dockerhub  search image
  rubygems   search gem
  homebrew   formula cask popular
  packagist  search package
  maven      search artifact
  openalex   search work

Conventions held:
  - access: 'read' on every command
  - typed errors (ArgumentError / EmptyResultError / CommandExecutionError)
    instead of generic CliError or silent fallback
  - input validators per site (image slugs, gem names, Composer names,
    Maven coordinates, OpenAlex work-id / DOI normalization)
  - listing rows carry an id-shaped column (image / gem / token / package /
    coordinate / id) that round-trips into the corresponding detail command
  - HTTP 429 surfaces with retry hint, 404 → EmptyResultError

Audits:
  - check:typed-error-lint   → no new violations (baseline 196)
  - check:silent-column-drop → no new violations (baseline 103)
  - advise:listing-id-pairing → unchanged at 13
2026-05-06 13:38:43 +08:00
jakevin 55088bbb28 feat: 13 read adapters across 5 new sites + 4 extensions (round 3) (#1346)
New sites (8 commands):
- npm    : search / package / downloads (registry.npmjs.org + api.npmjs.org)
- pypi   : package / downloads (pypi.org + pypistats.org)
- crates : search / crate (crates.io)
- mdn    : search (developer.mozilla.org)
- nvd    : cve (services.nvd.nist.gov)

Extensions (5 commands; +1 dblp/author surfaced in index):
- hf            : spaces (Hugging Face Spaces by likes / created_at / last_modified)
- dblp          : venue (search dblp's venue registry by acronym/topic)
- coingecko     : derivatives (perpetual / futures markets, 24h volume)
- stackoverflow : related (related questions for a given question id)

All commands hit public unauthenticated endpoints (Strategy.PUBLIC, browser:false),
typed-fail-fast on bad inputs (no silent fallback / clamp), and round-trip listing
ids into their detail commands where applicable.

Audits (all green vs baseline):
- typed-error-lint        : 196 = 196 baseline, no new
- silent-column-drop      : 103 = 103 baseline, no new
- listing-id-pairing      : 13 advisory (was 12; +1 = dblp/venue with no
                            corresponding venue-detail command)

Doc coverage : 120/120 adapter dirs documented (+5 new doc pages, +4 updated)
Manifest     : 722 entries (was 709; +13 commands)

Live verified:
- npm search react / npm package react / npm downloads react --period last-week
- npm downloads react --period 2025-01-01:2025-01-05
- pypi package requests / pypi downloads requests --period recent / overall
- crates search tokio / crates crate serde
- mdn search fetch
- nvd cve CVE-2021-44228
- hf spaces --limit 3
- dblp venue ICLR
- coingecko derivatives --limit 3
- stackoverflow related 79935770 --limit 3
- typed-error sanity: invalid CVE id, bad npm name, bad --period
2026-05-06 13:14:41 +08:00
jakevin a78ceb1602 feat: 11 read adapters across 8 sites (round 2) (#1345)
* feat: 11 read adapters across 8 sites (dblp / steam / bbc / devto / lobsters / medium / coingecko / hf)

Round 2 of the adapter expansion sweep. All 11 commands hit public APIs (no
browser, no auth), follow the post-#1332 typed-error / no-silent-failure
discipline, and were live-verified against real endpoints.

New adapters:
- dblp/author      : recent publications for one author (resolve PID by name, or pass --pid)
- steam/search     : storefront name search (storesearch API)
- steam/app        : single app detail (appdetails API; HTML entities decoded)
- bbc/topic        : per-topic RSS (8 canonical BBC News feeds)
- devto/latest     : /api/articles/latest with --page pagination
- lobsters/domain  : stories from a specific source domain (/domains/<d>.json)
- medium/tag       : tag RSS (description full-length, no silent truncation)
- coingecko/exchanges  : trust score + 24h BTC volume leaderboard
- coingecko/categories : sector buckets with 6 sort options
- coingecko/global     : aggregate market totals + BTC/ETH dominance
- hf/paper         : single-paper detail by arXiv id (summary, ai_summary, ai_keywords, upvotes)

Also adds clis/steam/utils.js + clis/bbc/utils.js as shared helpers (HTML entity
decode, RSS parsing). All listings carry a round-trippable id where a detail
sibling exists; advise:listing-id-pairing reports zero new violations. typed-
error-lint and silent-column-drop gates both unchanged from baseline.

Manifest: 698 → 709 (+11 entries).

* fix: tighten adapter round2 contracts
2026-05-06 12:46:14 +08:00
jakevin 6f597a2a4b feat: 8 read adapters across 5 sites (arxiv / SO / coingecko / wikipedia / hf) (#1338)
* feat: add 13 read adapters across 6 sites (github / arxiv / SO / coingecko / wikipedia / hf)

New site:
- github: user, repo, search-repos, user-repos, releases (unauth REST API; 60 req/h IP limit)

Existing sites — gap-fill for high-traffic verticals:
- arxiv author (papers by author, newest first; au:"name" phrase match on the public Atom API)
- stackoverflow user / tag (Stack Exchange API 2.3, with HTML-entity decode for display names / titles)
- coingecko coin / trending (single-coin market detail; 24h trending search-volume)
- wikipedia page (full plain-text article extract; opt-in --paragraphs cap, no silent truncation)
- hf models / datasets (downloads/likes/trending/freshness sorted lists)

All adapters use Node-side func + typed errors per the post-#1332 convention:
- ArgumentError for invalid limit / bad enum / empty positional / malformed owner-repo
- EmptyResultError for genuinely-empty results (no silent return [])
- CommandExecutionError for upstream HTTP/JSON failures (rate limit / 5xx / parse)
- AuthRequiredError reserved for endpoints that genuinely refuse anonymous traffic
- No silent clamp on --limit; no sentinel rows; no scalar 'unknown' / '-' fallbacks

Audit gates locally green:
- check:typed-error-lint        196/196 (no new)
- check:silent-column-drop      103/103 (no new)
- check:doc-coverage --strict   113/113 (added github.md, extended 5 existing pages)
- advise:listing-id-pairing     advisory only (+2 wikipedia entries: title is the
                                round-trippable key into wikipedia/page; not a gate)

* chore: drop github adapter set per WAWQAQ directive

WAWQAQ (#opencli-pr-review): "我们不需要GitHub的adapter,因为已经有GH了"

Removes the 5 github commands + utils + docs added in 664ed1aa
(github/user, github/repo, github/search-repos, github/releases,
github/user-repos). The remaining 8 read commands across 5 sites
(arxiv author, stackoverflow user/tag, coingecko coin/trending,
wikipedia page, hf models/datasets) are unaffected.

Audit gates re-checked:
- check:typed-error-lint: 196/196 (baseline unchanged)
- check:silent-column-drop: 103/103 (baseline unchanged)
- doc-coverage: 112/112 (one less site documented)
- advise:listing-id-pairing: 12 advisory (unchanged)

* fix(adapter-expansion): tighten id and currency contracts
2026-05-06 02:24:16 +08:00
hanzi 2a85152875 feat(browser): add verified fill command (#1222)
* feat(browser): add verified fill command

* feat(browser): implement exact fill primitive

* docs(browser): document fill pipeline submit

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 02:22:50 +08:00
SnakeEye-sudo (Er. Sangam Krishna) 9c9c8f976d feat: add uisdc and aibase news adapters (closes #1201) (#1249)
* Add uisdc news adapter for CLI

Implements a CLI adapter for fetching the latest AI/design news from uisdc.com. Allows specifying the number of news items to return.

* feat(aibase): add aibase daily news adapter

This file implements a news adapter for AIbase that fetches the latest AI industry news and allows for configurable limits on the number of news items returned.

* fix(news): harden uisdc and aibase adapters

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 02:14:12 +08:00
jakevin bb1208149c docs(guide): add remote-orchestration page for SSH/frpc reverse tunnel (#1337)
Document the pattern for running opencli on a remote machine while keeping
the daemon and Chrome on the local machine. Reverse-tunnel local 19825
back to the remote (via SSH -R or frp) so the remote opencli still talks
to its own loopback and the daemon never leaves localhost.

Captures the rationale we landed on after reviewing #636: native
extension-to-remote-daemon support is deferred until the daemon protocol
gains authentication; in the meantime this is the safe, zero-code path
that achieves the same outcome.
2026-05-06 02:06:14 +08:00
Greatkai d0b1b6a89e feat(pubmed): revive public eutils adapter (#819)
Co-authored-by: jackwener <jakevingoo@gmail.com>
Co-authored-by: Greatkai <4587517+Greatkai@users.noreply.github.com>
2026-05-06 01:54:29 +08:00
Shawn f1a8a2ff2d fix(chatwise): target main composer in electron UI (#427)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 01:45:30 +08:00
Luke 1794933d06 feat: add tiktok creator-videos command (#1335)
* feat: add tiktok creator-videos command

TikTok Studio creator content list with views/likes/comments/saves/shares.

Hits the Studio item_list endpoint
(https://www.tiktok.com/tiktok/creator/manage/item_list/v1/?aid=1988) from a
logged-in /tiktokstudio/content session and pages with cursor until limit is
satisfied (server caps size at 50). Username for the resulting video URL is
extracted from the user_text= query param on play_addr / download_info entries,
falling back to scraping a[href*="/video/<id>"] from the Studio page DOM.

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

* fix(tiktok): regen manifest + replace silent-clamp with ArgumentError

- Regenerate cli-manifest.json (CI gate: must match `npm run build` output)
- Replace `Math.max(1, Number(args.limit) || 20)` and
  `Math.min(Math.max(limit, 1), 50)` with an explicit positive-integer
  guard + a server-cap-only ternary, per the silent-clamp guidance in
  references/typed-errors.md (typed-error-lint baseline is unchanged)

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

* fix(tiktok): tighten creator videos contract

---------

Co-authored-by: root <root@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 00:58:38 +08:00
Carson dbf1f6afa1 feat(weixin): add Sogou article search (#1250)
- 新增 `clis/weixin/search.js`:通过 Sogou 微信搜索做公众号文章发现,`access: read`
- typed fail-fast: bad query/page/limit upfront ArgumentError;captcha/频控/goto/wait/evaluate/unreadable payload/selector drift/partial card extraction → CommandExecutionError;no-result 页面 → EmptyResultError;--limit 不 silent-clamp >10 直接拒绝
- maintainer-fixes-directly 闭环:rebase 到 latest main `f4637486`、补 access:read、删除 silent-clamp pattern
- F-P-1 first-principles 评估:Sogou 是 public 搜索页 ≠ 微信官方 API,可接受边界 = fail-fast + 清晰字段契约,不做字段猜测/partial success
- CI 全绿(smoke-test SKIPPED);B 组 codex-mini1 lead green + First-principles-1 aux green on `31c2b035`
- 非阻塞残留:search.url 是 Sogou redirect link,串联 download 需后续单独支持 redirect resolution 或暴露 resolved mp URL

Round 15a B 组对位收口。
2026-05-06 00:52:05 +08:00
jeff_woo f4637486b0 fix(twitter): rewrite followers command using DOM extraction (#1324)
- 重写 `clis/twitter/followers.js` 从 INTERCEPT (已坏) 改为 Strategy.UI DOM extraction
- bio 提取用 `data-testid$="-follow"` selector minus pattern (locale-independent button identification)
- drop `followers` column (DOM 不可靠) — manifest 同步 `strategy=ui` + columns `[screen_name,name,bio]`
- typed fail-fast: bad limit / empty user → ArgumentError;无登录 profile link → AuthRequiredError;followers link selector drift → SELECTOR typed error;空 followers → EmptyResultError;不再 silent `return []` / sentinel rows
- 恢复 `normalizeScreenName()` (opencli-user `b37f7ae3` 误删):`@elonmusk` / `/elonmusk` 不再走错路径
- `page.scroll('bottom')` no-op 改 `page.autoScroll({ times: 1, delayMs: 500 })`,避免 `--limit` 超首屏时 silent partial
- CI 全绿 (smoke-test SKIPPED);A 组 codex-mini0 lead green + First-principles-0 aux green on `dc8ac93`
- F-P-0 first-principles reflection: 长期更稳方向是 GraphQL helper (像 twitter/following),本 PR 是 INTERCEPT 已坏情况下的最小可用收口

Round 14 A 组对位收尾。
2026-05-06 00:42:10 +08:00
Jack He b485e96d90 feat(xianyu): add publish command for listing items (#1282)
- 新增 `clis/xianyu/publish.js`:发布闲鱼商品(标题/描述/分类/价格/图片/condition),`access: write`
- 参数 upfront `ArgumentError`:空 title/description/category、非法 price/original_price、未知 condition、图片格式/数量/文件不存在
- DOM/UI fail-fast:表单缺失、分类选择失败、必填字段未填、file input 缺失/上传失败、submit 失败、发布失败或超时未确认 → `CommandExecutionError`;登录墙 → `AuthRequiredError`
- 删除 `status=failed` success-row anti-pattern:失败/未知发布结果不再作为 success 表格返回
- F-P-1 aux catch real-runtime blocker:`page.url()` 在 IPage/BasePage 无定义,test mock `url` 字段遮住;改 `page.getCurrentUrl()` + fallback publish URL,加 IPage-shape 回归锁住
- xianyu publish JSDOM 回归 + docs/README/index 同步
- B 组 codex-mini1 lead green + First-principles-1 aux green on `2d78144d`

Round 14 (B 组对位)。
2026-05-05 23:35:23 +08:00
YoungCan-Wang 36d22ef4d3 feat(codex): add projects/history sidebar commands with native click + typed fail-fast (#1307)
- 新增 `clis/codex/sidebar.js` 共享 helper,使用 `data-app-action-sidebar-*` DOM 属性 + native click 触发侧边栏导航
- 新增 `clis/codex/projects.js` 与 `clis/codex/history.js`:列表/详情双契约
- typed fail-fast 收口:
  * projects/history 空列表 → EmptyResultError
  * limit/timeout/index/thread-id 非法 → ArgumentError(拒绝 silent-clamp)
  * 目标项找不到 → EmptyResultError
  * sidebar DOM 缺失 → CommandExecutionError
- 新增 8 条 regression 单测(`clis/codex/sidebar.test.js`)
- B 组 codex-mini1 lead green + First-principles-1 aux green on `bcfd88ae`

Round 13 close-out.
2026-05-05 23:03:47 +08:00
jakevin 76869bf8be docs(adapter-author): typed-errors reference + 6 conventions from #1329 (#1332)
纯 doc PR (+251/-37, 3 files),codify Round 12-13 #1329 三轮 review 沉淀的 6 条规则。

**新增 `references/typed-errors.md`** (~190 LOC):
- 5-classification 落点表 (ArgumentError / AuthRequiredError / EmptyResultError / CommandExecutionError + 第五类) 清晰判定边界
- 4 大独立 anti-pattern:silent-clamp / sentinel-row / scalar sentinel (`'-' → null`) / generic `CliError('CODE')`
- 反例引用 commit-pinned GitHub permalink (`384bcd6f` / `42e5303c` / `2b8609b8`) 防 merge 后行号漂移

**`references/adapter-template.md` 翻转**:
- 翻过期"sentinel row 比 [] 安全"建议
- 补 browser-vs-signature callout (#1329 author lesson 8 处 `(_page, args)` 错签)
- 补 intermediate-object naming 规则 (R1 lesson)
- example/COOKIE 骨架改 typed errors,convertible.js 标 grandfathered
- `page.fetchJson()` 正例从 `Number(args.limit) || 20` 改成复用已显式 validate 的 `limit` (F-P-0 catch 模板自洽性 blocker)

**`SKILL.md`**: reference table row + 3 条新约定 (中间对象 key / browser-signature / typed-error routing) 替掉过时的 `CliError('CODE')` 建议

Author: @opencli-user (jackwener)
A 组 review:
- codex-mini0 lead: 推 `971d198` (anti-pattern 拆分 + commit-pinned permalink + adapter-template 措辞) + `7921c6b` (修 page.fetchJson 模板自洽性 blocker)
- First-principles-0 aux: catch page.fetchJson `Number(args.limit) || 20` 模板会被照抄 silent-clamp 的自洽性 blocker
2026-05-05 18:32:59 +08:00
jakevin a5d70466ba feat: add qwen / 1point3acres / coingecko adapters (#1329)
3 new sites / 17 adapters,+2471 LOC。

**adapters**:
- coingecko (PUBLIC): `top` 全球加密货币市值排行
- 1point3acres (Discuz, GBK/UTF-8 mixed): `digest/forum/hot/latest/search/notifications/thread/user`
- qwen (browser, COOKIE chat): `ask/send/image/history/status`

**typed fail-fast 全闭环 (A 组三轮迭代后)**:
- silent-column-drop heuristic key collision 修法:rename intermediate keys 避开 columns 名字
- 所有外部参数 (limit/page/contentLimit/timeout/page_size) 越界/非法 → `ArgumentError`
- fetch / non-2xx / malformed JSON / API error → `CommandExecutionError`
- empty / not-found → `EmptyResultError`
- qwen prompt 缺失 → `ArgumentError` (不是 CommandExecutionError)
- qwen/status 未知 model/session 用 typed `null` (不是 `'-'` sentinel)
- success-row 永远不塞 failure/empty 业务行
- `normalizeLimit(value, default, max, label)` 共享 helper for 1point3acres 5 adapters

Author: @opencli-user (jackwener)
A 组 review (三轮):
  - codex-mini0 lead: 第三轮 maintainer-fixes-directly 直接 push `c40daf7` 收掉 4 类深一层 contract 漏洞
  - First-principles-0 aux: 第二轮 catch typed-error-lint 9 条 + 第三轮 catch generic CliError / silent-clamp on page/timeout / success-row failure / qwen prompt class 4 类 hard blocker
2026-05-05 18:00:33 +08:00
JackyWay 7aeaa053f9 fix(xianyu): chat send button detection + textarea activation (#1328)
- normalizeBtn() 去全部 whitespace 覆盖真实 `发 送`
- async IIFE send path + textarea click/focus 后 set value + dispatch input/change 触发按钮渲染
- send-button-not-found → typed selectorError fail-fast,不再 silent success
- JSDOM 直接执行 in-browser script regression 覆盖 mocked-evaluate 抓不到 DOM 内部 bug

Author: @JackyWay
B 组 review: codex-mini1 lead (rebase + 测试补全) + First-principles-1 final aux green
2026-05-05 17:36:36 +08:00
jakevin 97708ac858 feat(help): split root --help adapters into External CLI / App / Site buckets (#1330)
Per WAWQAQ feedback in #OpenCLI thread on the flat "Site adapters (112)" listing:
the bucket conflates real web sites (bilibili, dianping, ...) with desktop apps
(chatgpt-app, chatwise, codex, cursor, discord-app, doubao-app, antigravity, notion).
Group them so agents that fall back to --help can scan by category.

Three buckets, sourced from existing metadata only — no new adapter schema:

- External CLIs: passthrough binaries from loadExternalClis() (docker, gh, vercel, ...)
- App adapters:  domain is `localhost` or any non-DNS string (no `.`)
- Site adapters: domain contains `.` (real DNS), or domain is unset (default)

The classifier is one line: `domain.includes('.') ? 'site' : 'app'`. Adapters
without a domain field default to site (most are public web scrapers like
arxiv / wikipedia / spotify / ...).

Verified against the live registry: 7 External CLIs, 8 App adapters
(antigravity, chatgpt-app, chatwise, codex, cursor, discord-app, doubao-app,
notion), 104 Site adapters.

Structured help (-f yaml/json) gains parallel `external_clis` / `app_adapters`
/ `site_adapters` keys; `commands` no longer leaks adapter names.

External CLIs are now hidden from the default Commands listing (mirrors how
site adapters were already filtered) and surfaced in their own section.
2026-05-05 15:11:53 +08:00
jakevin 65979f26c2 refactor(test): extract shared page mock, remove dead test (#1321)
- Add clis/test-utils.js with standard createPageMock utility
- Migrate 11 test files to use shared utility (removes ~300 lines of duplication)
- Delete extension/src/cdp.test.ts dead skip test (chrome.scripting.executeScript removed from source)
- Remove clis/pixiv/test-utils.js (superseded by shared utility)
2026-05-05 01:39:22 +08:00
jakevin 05f7217edf bump version to 1.7.12, extension to 1.0.5 (#1320)
Release / release (push) Has been cancelled
2026-05-05 01:10:29 +08:00
jakevin 2b15016801 docs(adapter-author): add jsdom-fixture-pattern reference for in-browser DOM extractors (#1319)
Codify the JSDOM-against-frozen-fixture pattern that PR #1313 introduced
for dianping (and that PR #1318 had to follow up to clean up). The skill
previously had no reference for this category of test, so authors of the
next adapter that hits silent-in-browser-DOM bugs would either reinvent
it or skip it.

Key conventions captured:

  - **Mandatory awk 'NF>0' as the final step of fixture creation.** The
    blank-line noise that PR #1318 removed (84.6% / 54.8% of file content
    in dianping/{shop,search}.html) came from manually stripping
    script/style content without collapsing the surrounding newlines.
    Skipping this step is the silent quality regression that the next
    fixture author would also hit.

  - **Trim-to-minimum but never re-flow content.** Some bugs depend on
    text-node adjacency without intervening whitespace
    (dianping #1312 bug #2: rating "4.8" + reviews "21241条" fused as
    "4.821241条"). Pretty-printing the meaningful mega-line would mask
    the very condition the test is meant to catch.

  - **Reverse-validate the regression guard.** "18/18 tests pass" only
    proves agreement with the current implementation, not that the test
    would have caught the original bug. Reintroducing the buggy variant
    must make the test fail — otherwise the fixture is over-stripped or
    the assertion is too loose.

  - **__fixtures__/ is the documented exception** to the "no committed
    HTML dumps" rule in the skill's "关键约定". Calling that out
    explicitly because the rule otherwise reads as "all HTML in repo is
    bad," which the dianping fixture pattern intentionally violates for
    a real reason.

Background: WAWQAQ in #1313 follow-up thread (`#OpenCLI:36d2f65a`) asked
twice — first about the visible blank-line noise (→ PR #1318 cleanup),
then about the root cause and what should improve in the workflow itself.
This is the workflow improvement.

No new tooling / CI gate / lint introduced (B 1-week gate freeze still
applies). When a fifth fixture site adopts this pattern,
`opencli browser fixture-snapshot` automation can be revisited; until
then, runbook discipline + skill reference is the right scope.
2026-05-05 00:58:49 +08:00
jakevin d071600684 chore(dianping/fixtures): strip whitespace-only lines from frozen HTML fixtures (#1318)
dianping/__fixtures__/search.html and shop.html came out of page.content() with
hundreds of blank lines that JSDOM ignores during parsing — pure visual / disk
noise that bloats reviewer diff and obscures the meaningful DOM subtree the
fixture freezes.

  search.html: 372 → 168 lines (-204 lines, -572 bytes)
  shop.html:    39 →   6 lines (-33 lines,  -64 bytes)

`awk 'NF>0'` keeps every line that has any non-whitespace character, so the
minified mega-line (where the rating-vs-reviews adjacency that triggers #1312
silent fusion lives) is preserved verbatim. dianping.test.js 18 tests still
pass, and reintroducing the buggy `headText.match(/(\d+)条/)` extractor still
makes the regression guard fail with the expected '821241条' (proving the
fusion-bug detection power is intact after the strip).

Per WAWQAQ feedback in #1313 thread; opus independently validated the same
approach before the cleanup.
2026-05-05 00:51:00 +08:00
jakevin 6985187705 test(dianping): JSDOM-against-frozen-fixture tests for in-browser extractors (#1313)
PR #1312 fixed two silent in-browser DOM bugs that the existing mocked
`page.evaluate` tests could not catch:

  1. shop title fallback split on ASCII `[]` while dianping renders
     full-width `【】`, so `name` was always empty (or `"undefined"`).
  2. headText `\s+` collapse fused rating "4.8" with reviews "21241条",
     so a head-wide `/\d+条/` regex captured "4.821241" → 5.

Both bugs only surfaced on live verify; mocked-evaluate unit tests fed
pre-baked results to the func and the real DOM walk never ran.

Make the in-browser extractor logic testable in CI:

  - clis/dianping/shop.js, clis/dianping/search.js: extract the IIFE
    bodies into top-level `extractShopFields()` / `extractSearchRows()`
    using bare `document` / `location`. The live adapters inject these
    via `page.evaluate(\`(\${fn.toString()})()\`)` so behavior is
    unchanged; both commands re-verified end-to-end against live
    dianping (shop returns name=芈重山老火锅(五道口店), reviews=21241,
    rating=4.8; search returns 3 result-shaped rows with correct ids).

  - clis/dianping/__fixtures__/shop.html (3.4KB), search.html (8.4KB):
    sanitized HTML snapshots — scripts/styles/iframes/comments stripped,
    img src placeholdered, only structural attributes kept. Trimmed to
    the minimum subtree needed to exercise the extractors (search keeps
    3 of 15 li cards; shop keeps .shop-head + .desc-info + .review-title
    plus full-width 【】 title and headText with the rating/reviews
    fusion preserved).

  - clis/dianping/dianping.test.js: add a fifth describe block —
    "extractors against frozen HTML fixtures" — that loads the fixtures
    via JSDOM, swaps `globalThis.document` / `globalThis.location`, and
    asserts the post-fix behavior:
      * shop: name=芈重山老火锅(五道口店), reviewsRaw=21241条, rating=4.8,
        breakdown={口味:4.8,环境:4.8,服务:4.8,食材:4.9}, hours, rank, subway.
      * search: 3 rows with correct shop_ids, names, reviewsRaw, priceRaw,
        starClass; round-trip through parseReviewCount/parsePrice mappers
        to lock in {rating:5.0,reviews:21231,price:109} et al.
      * ok:false branches: shop fixture without `.shop-head`, search
        fixture with empty `#shop-all-list`.

Manually verified the fixtures would catch the original bugs by running
buggy extractor variants against shop.html — ASCII-bracket fallback
returns `name="undefined"`, and head-wide `/\d+条/` returns `821241条`
(both fail the new assertions).

Test suite grows 14 → 18 passing tests; live verify of both commands
still produces correct output post-refactor.

Pattern intentionally limited to dianping as a reference point. If other
sites with in-browser DOM extraction encounter similar silent bugs, this
JSDOM-against-frozen-fixture pattern can be adopted per-site.
2026-05-05 00:42:28 +08:00
Benjamin Liu de0cee191c docs(cases): add three researcher workflow examples (#1317)
* docs(cases): add three researcher workflow examples

Add use cases under cases/ that exercise the recently-landed
researcher-friendly adapters:

- daily-rl-research-monitor.md uses arxiv recent + openreview venue
  + hf top to compress a morning paper-skim into one shell pipeline.
- find-paper-implementation.md chains arxiv search/paper + dblp
  search + hf top + openreview search to map a paper's canonical
  record, follow-ups, and community uptake.
- track-conference-papers.md walks openreview venue + reviews to
  shortlist accepted papers and digest review threads in batch.

Each file is a real workflow built on commands from #1289 (arxiv
recent), #1294 (openreview), and #1299 (dblp).

* docs(cases): correct venue ids and forum example to ones that return data

The first revision used "ICLR.cc/2026/Conference" and "ICLR 2026 oral"
as venue strings. Both return EMPTY_RESULT today because the venue is
not open. Update each case to use natural-language venue text that
OpenReview currently exposes ("ICLR 2024 oral", "NeurIPS 2025 oral")
and a real forum id (KS8mIvetg2, "Proving Test Set Contamination in
Black-Box Language Models") in the reviews / paper drill-down. Note
the arxiv free-text-search ranking quirk so the worked DPO example
makes sense.
2026-05-05 00:35:33 +08:00
jakevin 4de1b42ab7 chore(convention): retire listing↔detail id pairing CI gate, keep advisory (#1316)
PR #1297 introduced a CI gate that fails when a site has both a listing
and a detail command but the listing rows don't carry an id-shaped column.
The gate came with a 10-entry EXEMPT map (topic-string trending,
profile-attribute rows, UI-only sessions, ...) where each exemption
recorded a "why this listing legitimately doesn't pair" reason.

By the same filter that closed PR #1311 (write-without-delete-pair gate):

  Is "listing should pair with detail" a *permanent* anti-pattern, or
  case-by-case business judgment?

It's case-by-case. Topic-string listings and profile-attribute rows
genuinely don't pair with a detail command. The fact that we needed an
EXEMPT map with 10 entries and individual reason strings is the smell —
it's not the rule winning, it's the rule failing. Forcing every adapter
PR to either add an id column or file an exemption was a higher cognitive
cost than the silent-loss bugs the rule actually catches.

Changes:

- .github/workflows/ci.yml — drop the "Check listing↔detail id pairing"
  step. Other gates (silent-column-drop, typed-error-lint) stay in place.
- package.json — rename the script from `check:listing-id-pairing` to
  `advise:listing-id-pairing` to make the advisory nature explicit.
- scripts/check-listing-id-pairing.mjs — drop the `--strict` flag and the
  EXEMPT map. The script now always exits 0 and prints an advisory report
  of listings that don't carry an id-shaped column. Reviewers/authors use
  it as guidance, not a gate.
- docs/conventions/listing-detail-id-pairing.md — rewrite from "MUST" to
  "soft convention". Adds an explicit "why advisory, not a gate" section
  that lists the legitimate non-pairing categories so future readers know
  the rule's boundary.
- docs/developer/ts-adapter.md — match the advisory tone in the
  adapter-author guidance.

The doc, the script, and the column patterns table all stay — agents and
adapter authors can still consult them. What's gone is the CI failure and
the per-PR exempt-list maintenance burden.

Net diff: -34 lines (gate + EXEMPT map removed, advisory-tone doc adds
a small "why advisory" section).
2026-05-05 00:34:18 +08:00
jakevin c4a1d2a91c fix(audit): reduce silent column drop false positives (#1315) 2026-05-05 00:29:20 +08:00
jakevin e6b048b86c feat(browser): enforce verify row shape (#1314) 2026-05-05 00:16:51 +08:00
jakevin 5ad0b81d92 ci: gate new typed error lint violations
Adds a baseline CI gate for convention-audit typed-error lint findings. Also refreshes the silent-column-drop baseline for dianping changes already on main.
2026-05-04 23:45:36 +08:00
jakevin 46d0f24f57 ci: gate new silent column drops
Adds a baseline CI gate for convention-audit silent-column-drop findings so CI rejects only newly introduced table-output loss.
2026-05-04 23:24:13 +08:00
jakevin c1bbf0bc5d fix(dianping/shop): correct in-browser name and reviews extraction (#1312)
The merged adapter had two silent in-browser bugs that the mocked-evaluate
unit tests don't catch — only live verify against www.dianping.com surfaces
them:

1. Shop name returned `undefined`. The fallback parsed `document.title` with
   an ASCII-bracket split (`/[\\[\\]]/`) but dianping wraps the name in
   full-width brackets `【芈重山老火锅(五道口店)】...`. Switch to a `【...】`
   regex so the title fallback actually fires.

2. Reviews returned `5` instead of `21241`. The headText was whitespace-
   collapsed to `★★★★★4.821241条...`, fusing the rating and review digits;
   a head-wide `/\d+条/` then captured `4.821241` and rounded to `5`. Read
   the dedicated `.reviews / .review-num` element ("21241条") instead, with
   a `.review-title` "评价(<n>)" fallback.
2026-05-04 23:16:07 +08:00
jakevin 0f806e9473 feat(dianping): browser adapter — search + shop on www.dianping.com (#1309)
* feat(dianping): browser adapter — search + shop on www.dianping.com

Adds two browser-mode adapters for the dianping (大众点评) PC site:

- `dianping search "<keyword>" --city <name|id> --limit <n>`: keyword
  shop/restaurant search. Returns rank, shop_id, name, rating, reviews,
  price, cuisine, district, url. shop_id round-trips into `dianping shop`.
- `dianping shop <shop_id>` (alias `detail`): shop detail sheet
  (field/value rows: name, rating, breakdown 口味/环境/服务/食材, reviews,
  price, rank, hours, address, subway, features, url).

Both use Strategy.COOKIE on www.dianping.com (the PC site renders search
SSR and does not require JS hydration). m.dianping.com is intentionally
crippled for non-mobile UAs, so it's not used.

Auth detection (utils.detectAuthOrEmpty) inspects both response text and
final URL for the Meituan Yoda captcha redirect (verify.meituan.com) and
the dianping login redirect; raises AuthRequiredError with the captcha
URL embedded so the user can clear it manually in the same profile.

Listing↔detail id pairing: search.shop_id → shop.<id>. Adds 'shop' to
DETAIL_NAMES in scripts/check-listing-id-pairing.mjs so the convention
gate scans this site (35 sites / 78 listings now covered).

* fix(dianping): harden browser failure classification

* fix(dianping): fail on partial missing shop ids
2026-05-04 23:00:38 +08:00
jakevin 73dc1295e7 feat(cli): add convention audit command
Adds opencli convention-audit for batch convention scanning, with structured output, strict mode, docs, and startup isolation from local user/plugin discovery.
2026-05-04 22:52:42 +08:00
jakevin f482a6b2b1 feat(youtube/xiaohongshu/xiaoe): surface dropped ids/url on listings (sweep) (#1305)
* feat(youtube/xiaohongshu/xiaoe): surface dropped ids/url on listings (sweep)

Round 8 same silent-column-drop class as #1300/#1301/#1302 — row already
emits the id/url field but `columns` array forgot to project it, so table
view drops it and agent loses the chain into detail commands.

- youtube/feed: rename row.videoId → video_id (snake_case convention),
  add to columns. youtube/video accepts both URL and id, so url-based
  round-trip already worked, but exposing the canonical id removes the
  url-parse step for chained calls.
- xiaohongshu/feed: pipeline map already extracts `id` from the homefeed
  payload, columns now lists it.
- xiaoe/catalog: pipeline map already projects `url`, columns now lists
  it. xiaoe/detail takes a positional url, so this completes the
  round-trip explicitly.

Also fixes one camelCase column violation on youtube/feed (videoId vs the
project's snake_case convention as in twitter `is_retweet`/`created_at`,
douban `subject_id`/`photo_id`, hupu `thread_title`).

CI gate `check:listing-id-pairing` ✓ (34 sites, 77 listings, 10 exempt).
typecheck clean. 114 tests pass for youtube + xiaohongshu.

* fix(youtube): keep feed continuation ids after rename
2026-05-04 22:03:52 +08:00
jakevin 0d37f48626 feat(cli): add agent-native structured help (#1304) 2026-05-04 21:55:09 +08:00
jakevin edf3c07d66 add cases/ directory for collecting user use cases (#1303)
Users can submit PRs adding individual .md files — one per case,
no merge conflicts.
2026-05-04 21:26:10 +08:00
jakevin ac94b75879 feat(1688/hupu/douban/linux-do): surface dropped ids on listings (#1302)
Round 7 — silent-drop sweep. Continues the listing→detail id-pairing
work from #1297. Each row was already extracting these ids/urls
internally; only the `columns` projection was missing, so they showed
up in `-f json` but never on the table view.

| Adapter            | Added columns                       |
|--------------------|-------------------------------------|
| `1688 search`      | `item_url`, `member_id`             |
| `hupu mentions`    | `tid`, `pid`, `url`                 |
| `douban photos`    | `photo_id`, `subject_id`            |
| `linux-do tags`    | `slug`                              |

Round-trip wins:
- `1688 search` → `1688 item <item_url>` (item_url is the canonical
  detail.1688.com URL); `1688 search` → `1688 store <member_id>`
- `hupu mentions` → `hupu detail <tid>` (and `pid` for the deep link)
- `douban photos` → tied back to the parent movie via `subject_id`
- `linux-do tags` → `linux-do feed --tag <slug>` (slug is the URL form)

No logic change — only the column array. JSON output unchanged.
Tests: 45/45 pass for the four affected sites.
2026-05-04 21:22:23 +08:00
jakevin ae9ad4aeec feat(twitter): surface tweet id on bookmarks/likes/tweets listings (#1301)
Round 6 — silent-drop audit follow-up. Sibling twitter listings have
been inconsistent about the canonical tweet `id` (rest_id):

- timeline      ✓ exposes id
- search        ✓ exposes id
- list-tweets   ✓ exposes id
- notifications ✓ exposes id
- bookmarks     ✗ extracts but drops it from columns
- likes         ✗ extracts but drops it from columns
- tweets        ✗ extracts but drops it from columns

The `id` is already in the row object — only the `columns` projection
was missing. With the listing↔detail id-pairing CI gate from #1297 now
on main, surfacing `id` makes round-trip into `twitter thread <id>` /
`twitter delete <id>` / `twitter like <id>` work from the table view too
(previously only via `-f json`).

Other field-presentation drift (`name`, `created_at`, `retweets`)
aligned with sibling adapters where those values are already emitted.

Tests: tweets.test.js asserts `toEqual` on the columns array — updated
that assertion. Other twitter tests use `toMatchObject` and pass
unchanged. 81/81 in `clis/twitter/`.
2026-05-04 21:22:10 +08:00
jakevin 2b9af38db4 feat(pixiv): surface user_id + url on listings, url on user/illusts (#1300)
While auditing instagram/facebook/pixiv coverage gaps, found that pixiv
listings already extract `user_id` and construct `url` per row but drop
both fields from the table view (`columns` doesn't list them). The data
is in the row object — only the column projection was missing.

Per the listing↔detail id pairing convention (#1297), surface them so:
- `user_id` round-trips from `ranking` / `search` → `user` / `illusts`
- `url` is the canonical share link for every illust / user record

Changes:
- `ranking`: + user_id, + url
- `search`:  + user_id, + url
- `illusts`: + url (user_id is the arg, no need to repeat per row)
- `user`:    + url

No behavior change beyond the table view — JSON output already had these
fields, so existing scripts that consume `-f json` keep working.
2026-05-04 21:09:01 +08:00
jakevin 545f91a2d2 feat(dblp): public bibliography adapter — search + paper (#1299)
* feat(dblp): public bibliography adapter — search + paper

Wraps the dblp.org public API:
- `dblp search <query>` → /search/publ/api JSON, projected into one row per hit
- `dblp paper <key>` → /rec/<key>.xml, parsed into a one-row record

Why dblp on top of arxiv/openreview: dblp is the largest, oldest CS
bibliography (7M+ entries) and the only one of the three that consistently
indexes pre-arXiv literature, journal articles, books, and theses. The
canonical record key (e.g. `conf/nips/VaswaniSPUJGKP17`) round-trips
cleanly between the two commands per the listing↔detail convention.

Implementation notes:
- No deps beyond the registry — XML parsed with conservative regexes,
  same approach as the arxiv adapter.
- Polite User-Agent per dblp's API guidance; HTTP 429 mapped to a
  CommandExecutionError with a "lower --limit" hint.
- Author homonym suffixes (`"Smith 0001"`) trimmed for clean output.
- 39 unit tests cover validators, XML extraction, both commands.

* fix(dblp): fail fast on API status envelopes
2026-05-04 21:04:50 +08:00
jakevin 0a85e73aa5 feat(convention): listing↔detail id pairing rule + CI gate (#1297)
* feat(convention): listing↔detail id pairing rule + CI gate

Adds a hard convention: when a site exposes both a listing-class command
(search / hot / top / recent / ...) and a detail-class command (read /
paper / article / view / ...), every listing row MUST surface an id-shaped
column whose value round-trips into the detail command. Without that, an
agent has no way to follow up on a listing row except re-searching by
title or scraping URLs out of band — both of which break the agent-native
contract.

What's in this PR

- docs/conventions/listing-detail-id-pairing.md — full rule, examples
  table, why-it-matters, what counts as id-shaped, exemption taxonomy,
  how to add an id column to a listing.
- scripts/check-listing-id-pairing.mjs — validator that reads
  cli-manifest.json, classifies each entry as listing / detail / other,
  and fails when a listing on a site that also has a read-detail command
  is missing an id-shaped column. Exemption allowlist records WHY each
  pair is exempt so future maintainers know what to verify.
- npm run check:listing-id-pairing — strict-mode wrapper.
- CI: new step in build job runs the validator after the manifest
  freshness check on Linux.
- docs/developer/ts-adapter.md — cross-link from the adapter authoring
  guide.
- docs/.vitepress/config.mts — sidebar entries for the new conventions
  section.

Fixes brought to zero violations

- 1688/search: add offer_id (already extracted, just surfaced)
- bluesky/user: add uri (AT URI round-trips into bluesky/thread)
- tieba/search: add id + url (thread_id already extracted)
- tieba/hot: add url (rows are topics, not threads — url is the
  best-effort round-trip handle, doc'd as such)

Exemptions (intentional, doc'd in EXEMPT map with rationale)

- nowcoder/hot, bluesky/trending, twitter/trending — listing rows are
  topic strings, not posts.
- lesswrong/user, reddit/user — rows are profile-attribute key/value
  pairs, addressed by the username arg.
- discord-app/search — desktop UI session, message ids not extractable.
- notion/search — Strategy.UI Quick Find, page ids not exposed in DOM.

Validator output after this PR: 32 sites scanned, 75 listings checked,
7 exempted, 0 violations.

* fix(convention): tighten listing id gate

* fix(convention): close url-derived id loophole
2026-05-04 20:54:14 +08:00
jakevin 29b4869efd feat(indeed): add search and job adapters (US site) (#1298)
* feat(indeed): add `search` and `job` adapters (US site)

Adds an Indeed adapter that fills the US job-search gap (alongside
existing 51job / boss-zhipin / linkedin coverage). Both commands run
through a real browser session because Indeed sits behind Cloudflare
and answers bare HTTP fetches with `403` + `cf-mitigated: challenge`.

## Commands

- `indeed search <query>` — keyword job search
  - args: `query`, `--location`, `--fromage`, `--sort`, `--start`, `--limit`
  - columns: `rank, id, title, company, location, salary, tags, url`
- `indeed job <jk>` (alias `detail`, `view`) — full job posting
  - args: `id` (positional, the 16-char hex `jk` from `search`)
  - columns: `id, title, company, location, salary, job_type, description, url`

## Listing↔detail id pairing

`search.id` is the Indeed `jk` (job key, 16-char lowercase hex). It feeds
directly into `indeed job <jk>`. Conforms to the listing↔detail id
pairing convention proposed in #1297.

## CF challenge handling

The adapter polls the result selectors for up to 15s after navigation,
giving the browser time to clear the Cloudflare interstitial. If the
challenge is still up after the wait, the adapter throws a
`CommandExecutionError` with a hint pointing the user at the connected
browser to clear it once. Subsequent calls reuse the warmed cookies via
`Strategy.COOKIE`, mirroring the v2ex / boss / linkedin patterns.

## Validation

`utils.js` keeps argument validation pure and unit-testable:

- `requireJobKey` rejects anything that isn't a 16-char lowercase hex
- `requireFromage` only accepts `1` / `3` / `7` / `14` (Indeed's enum)
- `requireSort` only accepts `relevance` / `date`
- `requireBoundedInt(limit, default=15, max=25)` — Indeed serves at most
  one page (10 jobs/page); ArgumentError on out-of-range, no silent
  clamping, per the typed-error feedback in #1289.

## Tests

18 unit tests in `clis/indeed/indeed.test.js` cover registration,
validators, URL builders, and DOM-card normalizers. Browser-driven
verification stays out of CI by design (CF challenge is interactive).

## Docs

- `docs/adapters/browser/indeed.md` — full adapter doc with prerequisite
  CF-challenge notes and listing↔detail id pairing callout.
- Sidebar entry + adapter index row.

* fix(indeed): tighten timeout fail-fast and runtime tests

* fix(indeed): align readiness with search parser
2026-05-04 20:52:16 +08:00
jakevin eea9ff8bfe feat(cli): add command access metadata (#1296) 2026-05-04 19:47:08 +08:00
jakevin 328140966e feat(openreview): public adapter — search/venue/paper/reviews (#1294)
* feat(openreview): add public adapter — search/venue/paper/reviews

OpenReview is the open peer-review platform used by ICLR / TMLR / COLM
and ML workshops. Its v2 API exposes everyone-readable submissions,
reviews, and decisions without auth, so all four commands run with
`browser: false`.

Commands:
- `openreview search <query>` — full-text search
- `openreview venue <venue>` — list submissions; accepts either a venue
  display name (matched against `content.venue`, e.g. "ICLR 2024 oral")
  or a full invitation id (e.g. "ICLR.cc/2025/Conference/-/Submission")
  via `/-/` heuristic; supports offset pagination
- `openreview paper <id>` — single-paper detail with full abstract
- `openreview reviews <forum>` — paper + threaded reviews/decisions/
  comments, ordered chronologically with paper lifted to row 0;
  classifies notes via invitation tail (REVIEW / DECISION / REBUTTAL /
  COMMENT / META_REVIEW / WITHDRAWAL); per-row truncation via
  `--max-length` (min 200)

Listing IDs round-trip into `paper`/`reviews`. PDF URLs normalized to
absolute `https://openreview.net/pdf/...`. `pdate` falls back to
`cdate` when missing, formatted as `YYYY-MM-DD`.

All limits/offsets/ids fail-fast with typed errors (`ArgumentError`,
`EmptyResultError`, `CommandExecutionError`) — no silent clamping, no
empty-array fallbacks. fetch + json + non-2xx + 404 are wrapped so
network/API failures never look like empty results.

Tests: 23 unit tests covering the column contract, content extraction,
date/PDF normalization, invitation-vs-venue dispatch, error paths
(network/JSON/HTTP), pagination offset accounting, and the reviews
classifier + section joiner + truncation.

Live-verified against api2.openreview.net for search ("diffusion
model"), venue ("ICLR 2024 oral"), paper (KS8mIvetg2), and reviews on
that paper's full thread.

* fix(openreview): tighten error and review typing

* fix(openreview): stabilize review contracts
2026-05-04 19:23:55 +08:00
jakevin ed0b2acc82 docs(stackoverflow): clarify read fetches answers up to --answers-limit (not 'all') (#1295)
Follow-up from PR #1293 review: 'all answers' was misleading because
the implementation is limit-bounded (default 10, max 100) rather than
unbounded pagination. Spell out the actual contract — including the
accepted-answer-outside-page fallback path — so users don't expect
infinite-scroll behaviour.

Non-blocking docs-only change flagged by codex-mini1 + First-principles-1
during #1293 review.
2026-05-04 19:20:43 +08:00
jakevin c1a4bd3b7e feat(stackoverflow): surface question_id on listings + new read <id> (#1293)
* feat(stackoverflow): surface question_id + metadata on listings, add `read <id>`

Agent-native gap: all 4 stackoverflow listings (`hot`, `search`,
`unanswered`, `bounties`) only emitted `[title, score, answers, url]`,
which means an agent could see a hot question but had no `id` to round-
trip into a body read, no `tags` to filter by topic, no `views` to gauge
demand, and no `is_answered` / `creation_date` / `author` to triage.
There also wasn't a `read` adapter, so reading a SO question through
opencli was impossible.

Listings (`hot` / `search` / `bounties` / `unanswered`):
- Add `rank`, `id` (question_id), `views`, `is_answered` (skipped on
  `unanswered` since always false), `tags` (joined), `author`
  (owner.display_name), `creation_date` columns.
- Pass `pagesize` to the upstream API instead of fetching the default
  page and trimming locally.

New `stackoverflow read <id>`:
- 4-call fan-out against the public Stack Exchange API
  (`/questions/{id}` + `/questions/{id}/comments` +
  `/questions/{id}/answers` + batched `/answers/a;b;c/comments`).
- Returns `POST` + `Q-COMMENT` + `ANSWER` + `A-COMMENT` rows mirroring
  the `hackernews read` and `lobsters read` shape.
- Accepted answer is always surfaced first and tagged `accepted='true'`;
  remaining answers follow in descending vote order, capped by
  `--answers-limit`.
- HTML body cleanup: tags stripped, `<pre><code>` preserved, `<code>`
  inline-fenced, `<li>` rendered as `- `, comments indented with `> `.
- Entity decoding: a shared `decodeEntities` handles named (incl.
  `&hellip;`/`&copy;`/etc), decimal (`&#246;`), and hex (`&#x27;`)
  forms, applied to both bodies AND `display_name` (otherwise users
  like `Jonas K&#246;lker` come through mojibaked).
- Typed fail-fast: `ArgumentError` for non-numeric id and
  `--max-length < 100` (with no-fetch assertion); `EmptyResultError`
  when `items` is empty; `CommandExecutionError` for HTTP non-2xx and
  for Stack Exchange's in-band `error_id` envelopes (throttle / quota).
  No silent clamps anywhere.

Tests: 14 vitest assertions
- 4 listing column-shape (incl. `unanswered` skipping `is_answered` and
  `bounties` keeping its `bounty` column position)
- 10 read-adapter cases: registration / args / strategy + 3 typed-error
  fail-fast paths (with no-fetch assertion on the pre-fetch ones) + the
  full POST/Q-COMMENT/ANSWER/A-COMMENT row order with accepted-first +
  the answer-comments fetch verified to batch ids semicolon-joined +
  HTML entity decoding (named/decimal/hex) on both body and display_name
  + answers-limit honored when there are more answers than the cap.

Live verification:
- `stackoverflow hot --limit 2` → `id`/`tags`/`views`/`is_answered`/
  `author` populated.
- `stackoverflow search "async await" --limit 1`,
  `stackoverflow unanswered --limit 1` → same shape.
- `stackoverflow read 79935770` and the very-long classic question
  `stackoverflow read 11227809 --answers-limit 1 --comments-limit 2`
  → produces the threaded POST/Q-COMMENT/ANSWER/A-COMMENT structure
  with proper entity decoding (`Jonas Kölker` reads correctly).
- `stackoverflow read not-numeric` → exits with `ARGUMENT`.
- `stackoverflow read 999999999` → exits with `EMPTY_RESULT`.

* fix(stackoverflow): wrap fetch/json/coerce paths in typed errors

Apply the 3 lessons from PR #1292 (devto) review at merge time, before
B-group hits this PR:

1. CLI args may arrive as strings (e.g. `--max-length 50` → `'50'`).
   The bare `Number.isInteger(value)` in `requirePositiveInt` /
   `requireMinInt` would accept negative-but-coerced numbers and reject
   string-form integers. Now the helpers `coerceInt` first then validate,
   and the rejection message echoes the raw input via `JSON.stringify`.

2. `await fetch(url)` and `await res.json()` were not wrapped — a network
   blip would surface as a raw `TypeError` and a maintenance HTML page
   would surface as a raw `SyntaxError`. Both are now caught and rethrown
   as `CommandExecutionError` with hints, matching the in-band error_id
   path.

Tests: +3 cases (17 total)
- fetch network failure → CommandExecutionError
- malformed JSON body → CommandExecutionError
- string-form max-length "50" / "abc" rejected with ArgumentError before
  fetching

* fix(stackoverflow): avoid partial read fanout
2026-05-04 19:10:50 +08:00
jakevin 5a839701ab feat(lobsters): surface short_id + created_at on listings, add read <short_id> (#1291)
* feat(lobsters): surface short_id + created_at on listings, add `read <short_id>`

Same agent-native gap as the just-merged hackernews PR (#1288):

1. The 4 listings (hot / newest / active / tag) didn't surface each story's
   `short_id`. Agents could see the title and a comments URL but couldn't
   pass the id back into a follow-up command. Add `id` (= `short_id`) and
   `created_at` columns; `created_at` is cheap signal for "how stale is this".

2. There was no way to read a story + comment tree from the CLI. Lobsters
   makes this nicer than HN: `https://lobste.rs/s/<short_id>.json` returns
   the story plus a flat `comments[]` array where each entry already carries
   `parent_comment` and `depth`, so we get the full thread in one HTTP call
   and just DFS using the parent map.

`read` mirrors the `hackernews read` shape (POST row + L0/L1/… indented
comments, `[+N more replies]` stubs at depth/limit cutoffs) so the two
adapters feel the same to agents that already learned one. Same typed
fail-fast envelope: `ArgumentError` on bad short_id / non-positive limit /
depth / replies, `EmptyResultError` on 404 or empty body, `CommandExecutionError`
on other HTTP failures.

Tests cover all 4 listings (column shape + map step), `read` registration,
positional arg shape, ArgumentError fail-fast (no fetch on bad input),
EmptyResultError on 404, threaded-tree assembly from a flat `comments[]`,
and the `+N more replies` depth-cutoff path.

* test(lobsters): lock read fail-fast coverage

* docs(lobsters): list read command
2026-05-04 18:55:51 +08:00
jakevin 68485cc54e feat(devto): surface article id on listings + new read <id> (#1292)
* feat(devto): surface article id + published_at on listings, add `read <id>`

Agent-native gap: devto listings (`top`/`tag`/`user`) didn't include the
article `id`, so an agent couldn't round-trip from a listing into a body
read. They also dropped `reading_time` and `published_at`, which are cheap
signals the API gives you for free.

Changes:
- `top` / `tag` / `user`: add `id`, `reading_time`, `published_at` columns
  alongside existing rank/title/etc. `user` keeps its no-author shape since
  it's already user-scoped.
- New `devto read <id>`: hits `dev.to/api/articles/<id>` and returns one
  row with the article body (truncated by `--max-length`, default 20000,
  min 100). DEV.to's public API does not expose comments yet, so this is
  intentionally a single-row reader rather than a HN/lobsters-style
  threaded tree — if/when comments become public we can extend to
  POST + L0/L1.
- Typed fail-fast: `ArgumentError` for non-numeric id and for `--max-length`
  below 100; `EmptyResultError` on 404; `CommandExecutionError` for other
  non-2xx HTTP statuses. No silent clamps.
- Defensive tag normalization: the `/api/articles/<id>` endpoint returns
  `tag_list` as a comma-string and `tags` as an array (the opposite shape
  from listing endpoints). Caught this on live verification — both shapes
  now collapse to a comma-joined string.

Tests: 12 vitest assertions covering listing column shape (all 3) +
register/args/strategy + typed-error fail-fast paths + happy-path body
extraction + truncation marker + alternate tag_list shape.

Live verification: `devto top --limit 3` and `devto read 3602287` both
return the expected agent-native shape.

* fix(devto): harden article read contract
2026-05-04 18:55:14 +08:00
jakevin aa8d4b72f7 fix(twitter): drop permanently-N/A tweets column from trending (#1290)
X removed the post-count caption from each cell on `/explore/tabs/trending`.
The adapter still iterated `divs[2..]` looking for a numeric text node and
fell back to the literal string "N/A" when it found none — which was every
row, on every call. We were emitting a silent-wrong column for every result.

Drop the column and the no-longer-relevant scan loop. Add a regression test
on the columns shape so the column doesn't slip back in.

Live runs of `opencli twitter trending` previously returned rows like
`{rank: 1, topic: "...", tweets: "N/A", category: "..."}` — the `N/A` was
not a transient outage, it was structural.
2026-05-04 18:31:06 +08:00
jakevin e848594519 feat(arxiv): full abstract/authors + surface pdf/categories/comment + new recent <category> (#1289)
* feat(arxiv): full abstract/authors, surface pdf+categories+comment, add `recent <category>`

`paper` was silently truncating the abstract to 200 chars and dropping all but
the first 3 authors — agents calling it for a paper summary lost data. Stop
truncating, return all authors, and surface the rest of what the Atom feed
already gives us: pdf url (`<link rel="related">`), all `categories`,
`primary_category`, and the author `comment` (page count, conference, etc.).

`search` keeps a compact list shape (no abstract column, but adds
`primary_category`).

New `arxiv recent <category>` lists newest submissions in a category sorted by
`submittedDate desc` — fills a gap (previously you had to know a search term
to surface anything). Validates the category string and rejects malformed
input via `ArgumentError`.

`search` also switches its no-results path from `CliError('NOT_FOUND', ...)`
to `EmptyResultError` to match the convention other public-API adapters use.

Tests cover: command registration, full-abstract / all-authors parsing, XML
entity decoding in titles, pdf/categories/comment extraction, and category
validation.

* fix(arxiv): harden category and limit validation
2026-05-04 18:27:13 +08:00
jakevin 977105b0f6 feat(hackernews): add read <id> and surface item id on every listing (#1288)
* feat(hackernews): add `read <id>` and surface item id on every listing

Two related agent-flow gaps in the HN adapters:

1. `top`/`best`/`ask`/`new`/`show`/`jobs`/`search` all carry the HN item
   id internally (firebase items are fetched by id; algolia hits include
   `objectID`) but drop it before output. Without an id column the agent
   can see the title but has no handle to follow up with.

2. There was no way to read a story's discussion. The whole reason an
   agent looks at HN is the comments — and that capability was missing.

This PR adds:

- `id` column on every listing adapter (firebase items: numeric id;
  algolia search hits: `objectID` string). Existing column order is
  preserved otherwise.
- `hackernews read <id>` — public/non-browser adapter that fetches the
  story plus a tree of top-level comments + inline replies via
  `https://hacker-news.firebaseio.com/v0/item/<id>.json`. Mirrors the
  `reddit read` shape (`type/author/score/text`) so agents can use both
  with one mental model. HTML-only fields (comment text) are converted
  to plain text with anchor URLs preserved.
- Column-contract tests covering all listings + the new read adapter.
- Doc entry under `docs/adapters/browser/hackernews.md`.

Tested locally via `~/.opencli/clis/hackernews/` overrides:
  opencli hackernews top --limit 3            # id present
  opencli hackernews search rust --limit 2    # id (objectID) present
  opencli hackernews read 47999636 --limit 5  # threaded output

* fix(hackernews): typed fail-fast for read
2026-05-04 18:25:57 +08:00
jakevin 413bbbe819 fix(douban): drop unparseable fields from movie-hot, add id/votes (#1285)
* fix(douban): drop unparseable fields from movie-hot, add id/votes

The chart page (movie.douban.com/chart) only exposes a single comma-joined
text dump in `.pl2 p`, of the shape:

  <release_dates...> / <actors...> / <regions...> / <director_zh> /
  <runtime>分钟 / <other_titles> / <genres> / <director with English> /
  <languages>

The previous `loadDoubanMovieHot` tried to anchor on the release-date
regex and take `parts[releaseIndex - 1]` as director and
`parts[releaseIndex - 2]` as region. That breaks in two ways:

1. Most entries have multiple release dates back-to-back, so the
   "anchor minus one" position is itself a date. Director output becomes
   `'2025-09-07(多伦多电影节)'` and region is empty — silent wrong data.
2. For entries with a single release date, the offsets land on actor
   names, not director / region.

The page does not actually carry a clean director or region per row —
that's only available on the subject detail page. Trying to reconstruct
either from the chart string is the canonical "verify passes but data is
wrong" failure (success-rate-pitfalls §2 sibling DOM contamination).

Fix: drop `director`, `region`, `quote` from the listing. Surface what
the chart page actually provides reliably:
- `id`     — extracted from the subject URL, ready for `douban subject`
- `votes`  — from `.star .pl` (`(62484人评价)`), useful as popularity signal
- existing `rank`, `title`, `rating`, `year`, `url`

Agents that need director / region should follow up with
`opencli douban subject <id>`, which is already wired for that data.

* fix(douban): fail fast on empty movie hot
2026-05-04 16:12:18 +08:00
jakevin 11ceca4fc2 fix(bilibili,reddit): add identifier and url columns to hot lists (#1284)
* fix(bilibili,reddit): add identifier and url columns to hot lists

Both `bilibili hot` and `reddit hot` previously dropped their per-row
identifier and URL on the way out, breaking the typical agent flow where
the next call needs a `bvid` / `postId` to fetch detail or comments.

- bilibili/hot: add `bvid` and `url` columns (constructed from bvid)
- reddit/hot: surface `postId`, `author`, `url` (already in evaluate but
  dropped in map)

Tested via local `~/.opencli/clis/<site>/hot.js` overrides.

* test(bilibili,reddit): lock hot list identifier columns
2026-05-04 16:10:49 +08:00
jakevin be5234ced1 fix(doctor): remove adapter analyze tip (#1283) 2026-05-04 13:37:13 +08:00
jakevin 1da105edea revert: offscreen daemon bridge
Revert PR #1280 and restore the previous Browser Bridge service-worker transport while PR #1229-style recovery messaging is pursued.
2026-05-03 23:14:37 +08:00
jakevin ca25f65bf7 fix(extension): move daemon bridge to offscreen document
Move the Browser Bridge daemon WebSocket out of the MV3 service worker and into an offscreen document. Remove the popup/action UI and obsolete extension log forwarding now that doctor is the diagnostic surface.
2026-05-03 22:20:54 +08:00
jakevin 0f29790795 feat(browser): add dialog handling and CDP DOM primitives (#1278)
* feat(browser): add dialog handling and CDP DOM primitives

* fix(browser): narrow dialog error detection
2026-05-03 21:28:30 +08:00
Kagura 98062a21c9 fix: isolate browser workspace per command
Fix concurrent browser-backed commands for the same site by using a unique workspace per command execution. Closes #1114.
2026-05-03 21:20:47 +08:00
jakevin a353db5fbe chore(cli): remove duplicate root help summary logic (#1277)
* chore(cli): remove duplicate root help summary logic

* chore(test): remove unused commander adapter imports
2026-05-03 20:06:37 +08:00
jakevin 1d407ab62f fix(cli): show adapter subcommands in root help (#1276)
* fix(cli): show adapter subcommands in root help

* fix(cli): summarize built-in root help groups
2026-05-03 19:55:59 +08:00
jakevin 1b19b3ebe9 chore: bump version to 1.7.11 (#1275)
Release / release (push) Has been cancelled
2026-05-03 19:35:22 +08:00
jakevin d60e1cf43d fix(browser): route type and keys through native input (#1274)
Fixes #1265 by routing browser type/keys through existing native CDP input primitives, with DOM fallbacks and direct CDPPage parity.
2026-05-03 19:32:31 +08:00
jakevin 1cd1253d46 feat(instagram): add collection-delete adapter
Pairs with the new collection-create adapter so users (and future
fixture-teardown logic) can clean up saved-post collections from CLI.

- POST /api/v1/collections/{id}/delete/ with multipart module_name=collection_settings
- Accepts collection name (case-insensitive) or numeric collection_id; resolves
  via /collections/list/ first so unknown / duplicate names error explicitly
  instead of bubbling up a 404 or silently deleting the wrong one.
2026-05-03 19:05:55 +08:00
jakevin 7869bdb2ca feat(browser): polish adapter author verify workflow 2026-05-03 19:03:38 +08:00
jakevin 2e93ac6e63 fix(release): build before manifest drift check (#1269) 2026-05-03 18:40:39 +08:00
jakevin de0d74bf62 fix(build-manifest): fail loud on import errors and refuse stale dist (#1268)
The previous implementation silently skipped any adapter whose import
failed (catch + warn-to-stderr + return []), then printed a successful
" Manifest compiled: N entries". When dist/ was stale (e.g. after
renaming an export the JS adapters re-import) every adapter using that
export would fail to load, get skipped, and the script still exited 0.
An agent reading exit codes to gate work would commit the resulting
manifest and silently delete dozens of unrelated adapter entries.

Three layers of defense:

1. Distinguish skip kinds. Files that don't call `cli(...)` are still
   silently dropped (helpers / type modules). Files that look like CLI
   modules but fail to import now throw `ManifestImportError`. The
   batch scanner aggregates failures and `main()` exits 1 with an
   explicit list, leaving the existing manifest on disk untouched.

2. Net-deletion safety net. `main()` diffs the new entries against the
   committed manifest and refuses to overwrite when entries would be
   removed. `--allow-removals=N` (or bare `--allow-removals` for any)
   is the explicit opt-in; the error message tells the caller exactly
   what value to pass.

3. Runtime dist guard. `node dist/src/build-manifest.js` now refuses
   to run with a clear pointer at `npm run build-manifest` (which uses
   tsx). The npm script itself is migrated to `tsx src/build-manifest.ts`
   so no project-level command points at the compiled copy anymore.

Release CI gains a manifest-drift gate (build-manifest + git diff
--exit-code) so a tag push can never publish stale or silently-shrunk
manifests. The existing CI check on PRs is preserved.

`ManifestEntry` is split into `src/manifest-types.ts` so runtime code
(discovery.ts) imports the type without pulling the build-time
compiler module.

Tests:
- `loadManifestEntries` throws ManifestImportError on import failure
- helper modules without cli() are still silently skipped
- `scanClisDir` aggregates per-adapter failures
- `diffRemovedEntries` returns expected site/name diff
- `parseBuildManifestArgs` reads --allow-removals[=N]
2026-05-03 18:27:23 +08:00
jakevin a9e0ca648f fix(extension): remove status-row left border accent (#1267)
WAWQAQ feedback: the green left border on the status row looked
disconnected — only on the top half of the card, creating an awkward
stub. Connection state is already conveyed clearly by the colored dot
and the "Connected to daemon" / "Disconnected" text, so the border was
redundant decoration.

Drop the .card.connected/.disconnected/.connecting border-left rules.
No JS or layout changes; cleaner surface, fewer visual variants.
2026-05-03 18:18:30 +08:00
jakevin bebc7aa35e chore: bump version to 1.7.10 (extension 1.0.4) (#1266)
Release / release (push) Has been cancelled
2026-05-03 18:00:35 +08:00
jakevin 061fba100d feat(extension): polish popup UI with merged card and copy contextId (#1262)
- Merge status row and profile row into a single rounded card with a
  brand-colored left border accent indicating connection state
- Render contextId inline next to a "Profile" label with a Copy button,
  letting users paste it into `opencli profile rename` without manual
  selection (replaces the old full-width code block treatment)
- Show daemon version inline in the status row when connected, and
  render the extension version as a tag in the popup header — both
  surface version information that helps diagnose stale-daemon issues
- Forward both versions through the existing `getStatus` background
  message: extension reads its own version from the manifest, daemon
  version is fetched best-effort from `/status` with a 1.5s timeout so
  popup never hangs when the daemon is unreachable
2026-05-03 17:37:51 +08:00
jakevin e364ec6b9c feat(browser): pass trace through verify (#1263) 2026-05-03 17:32:02 +08:00
jakevin 765eb56c99 feat(daemon): surface stale versions and restart (#1261) 2026-05-03 17:20:54 +08:00
jakevin 5f72770eff feat(instagram): add collection-create + collection filter for saved (#1192) (#1260)
Closes #1192. Two changes:

1. New `instagram collection-create <name>` adapter wraps
   `POST /api/v1/collections/create/` (multipart `name` +
   `module_name=collection_create`, X-IG-App-ID + X-CSRFToken).
2. `instagram saved` gains an optional `--collection <name>` flag.
   When set, the adapter resolves the name to a collection id via
   `/api/v1/collections/list/` (case-insensitive trim match) and then
   fetches `/api/v1/feed/collection/{id}/posts/`. Unknown names throw
   with the available list so callers can self-correct.

Both verified end-to-end against a live IG account. Verify fixtures
under ~/.opencli/sites/instagram/verify/ ship the
patterns/notEmpty/mustBeTruthy guards from the latest adapter-author
skill (success-rate-pitfalls §1, §4, §8).
2026-05-03 16:33:15 +08:00
jakevin 3017ca78aa chore: bump version to 1.7.9 (extension 1.0.3) (#1259)
Release / release (push) Has been cancelled
2026-05-03 15:46:48 +08:00
jakevin 7e68e19f0d feat(trace): prune retained artifacts (#1258) 2026-05-03 15:34:30 +08:00
jakevin 4ceb3314fe refactor(trace): retire diagnostic repair path (#1257)
* refactor(trace): retire diagnostic repair path

* chore(trace): clarify artifact summary guidance

* chore(trace): version trace receipt schema
2026-05-03 15:19:44 +08:00
Jack He 5f0cce7b22 feat(weibo): add favorites + publish CLI commands (#1253)
* feat(weibo): add favorites + publish CLI commands

Consolidates #1253 (favorites) and #1254 (publish) into a single PR per maintainer request.

- clis/weibo/favorites.ts: cookie-mode fetch of authenticated user's favorites via weibo.com/u/page/fav/{uid}
- clis/weibo/publish.js: UI-automation post (text up to 2000 chars, up to 9 images jpg/png/gif/webp)
- cli-manifest.json regenerated to include the new commands

Note: favorites.ts uses TypeScript syntax but build-manifest.js scans only *.js — favorites is currently NOT registered in the manifest. Reviewers please check whether to rename to .js or whether the manifest scanner should learn .ts.

Authored-by: hszhsz <heshaoz1990@gmail.com>

* fix(weibo): harden favorites and publish commands

* fix(weibo): publish without execute gate

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 15:00:38 +08:00
Benjamin Liu 284c96133b feat(claude): add Claude adapter (#1252)
* feat(claude): add Claude adapter

Adds a Claude (claude.ai) browser adapter family with seven commands
modeled on the existing clis/deepseek/ pattern: ask, send, new, status,
read, history, detail.

Closes #1251

* feat(claude): align send command columns with doubao

Match the established Status / SubmittedBy / InjectedText shape used by
doubao send so agent loops can rely on a consistent fire-and-forget
output across AI chat adapters.

* fix(claude): preserve DOM order in getVisibleMessages

The previous implementation queried user-message and assistant-message
nodes in two passes, which serialized as [u1, u2, u3, a1, a2, a3] for
multi-turn chats instead of the correct conversation order. Single
combined query preserves DOM order so claude read / detail return
turns in the order the user reads them on the page.

* docs(claude): note --live requirement for read across invocations

* fix(claude): fail fast on auth and empty states

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 14:55:41 +08:00
jakevin eac17b361e feat(observation): add runtime trace capture (#1255) 2026-05-03 14:38:59 +08:00
jakevin aa33262ef7 docs: narrow smart-search trigger description (#1248) 2026-05-02 16:51:17 +08:00
jakevin a0b2df1448 docs: refresh stale entry and developer docs (#1244) 2026-05-02 12:31:48 +08:00
jakevin fc7245f9f6 chore: enforce node 21 baseline (#1242) 2026-05-02 09:30:28 +08:00
jakevin 88bcd814ee refactor: simplify diagnostics and low-use errors (#1241) 2026-05-02 09:28:26 +08:00
jakevin 2fd7272559 docs: clarify opencli extension paths (#1240) 2026-05-02 09:27:17 +08:00
2168 changed files with 235985 additions and 14449 deletions
+4 -2
View File
@@ -9,10 +9,12 @@ outputs:
runs:
using: composite
steps:
- name: Install real Chrome (stable)
uses: browser-actions/setup-chrome@v1
- name: Install real Chrome for Testing
uses: browser-actions/setup-chrome@v2
id: setup-chrome
with:
# 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
-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)"
+19 -1
View File
@@ -50,6 +50,20 @@ jobs:
exit 1
fi
# Guard: adapter rows must not silently emit keys omitted from `columns`.
# Existing findings are tracked in scripts/silent-column-drop-baseline.json;
# this gate rejects newly introduced drops while allowing incremental cleanup.
- name: Check silent column drops
if: runner.os == 'Linux'
run: npm run check:silent-column-drop
# Guard: adapters should fail with typed errors instead of silently
# returning empty arrays, clamping user input, or inventing sentinel data.
# Existing findings are tracked in scripts/typed-error-lint-baseline.json.
- name: Check typed-error lint baseline
if: runner.os == 'Linux'
run: npm run check:typed-error-lint
# ── Unit tests (vitest shard) ──
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
@@ -96,8 +110,12 @@ jobs:
- name: Run unit tests under Bun
run: bun vitest run --project unit --reporter=verbose
# Adapter tests are pure unit tests — OS doesn't affect results.
# Adapter tests are pure unit tests — OS doesn't affect results. Gated off
# `pull_request` to keep PR CI under ~2 minutes; adapter authors run focused
# tests locally before pushing, and `push` to main / nightly cron / manual
# dispatch still guard the merged state.
adapter-test:
if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
needs: build
steps:
+56 -17
View File
@@ -1,6 +1,10 @@
name: E2E Headed Chrome
on:
# E2E removed from `pull_request` to keep PR feedback under ~2 minutes; PR-time
# protection is the CI workflow (typecheck / unit / lint / adapter / build).
# E2E still guards `main` directly, runs nightly, and on release tag push so
# protocol/CDP/extension contract regressions are caught before they ship.
push:
branches: [main, dev]
paths:
@@ -13,18 +17,11 @@ on:
- 'tests/smoke/**'
- '.github/actions/setup-chrome/**'
- '.github/workflows/e2e-headed.yml'
pull_request:
branches: [main, dev]
paths:
- 'extension/**'
- 'src/browser/**'
- 'src/daemon.ts'
- 'src/execution.ts'
- 'src/interceptor.ts'
- 'tests/e2e/**'
- 'tests/smoke/**'
- '.github/actions/setup-chrome/**'
- '.github/workflows/e2e-headed.yml'
tags: ['v*']
schedule:
# Daily 08:00 UTC — catch flake / Chrome-version drift even when no commits
# touched the watched paths recently.
- cron: '0 8 * * *'
workflow_dispatch:
concurrency:
@@ -37,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
@@ -52,19 +55,55 @@ 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
- name: Build
run: npm run build
- name: Build extension
run: npm run build --prefix extension
# 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
# 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'
env:
OPENCLI_AX_E2E: '0'
run: |
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
+12 -1
View File
@@ -26,6 +26,17 @@ jobs:
- name: Type check
run: npx tsc --noEmit
# Build before the manifest drift gate: adapter modules import
# @jackwener/opencli/* through package exports, which resolve to dist/.
# A fresh release checkout has no dist/ until the full build runs.
- name: Build package and verify cli-manifest.json is up-to-date
run: |
npm run build
if ! git diff --exit-code -- cli-manifest.json; then
echo "::error::cli-manifest.json drift detected at release time. Run 'npm run build' locally and commit the result before tagging."
exit 1
fi
- name: Install extension dependencies
run: npm ci
working-directory: extension
@@ -40,7 +51,7 @@ jobs:
- name: Create extension ZIP
run: |
EXT_VERSION=$(node -p "require('./extension/package.json').version")
EXT_VERSION=$(jq -r .version extension/package.json)
cd extension-package
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
+439 -1
View File
@@ -1,13 +1,451 @@
# Changelog
## Unreleased
## [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.
### Features
* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter.
* **12306** — add full read adapter (`stations` / `trains` / `train` / `price` / `me` / `passengers` / `orders`). ([#1637](https://github.com/jackwener/opencli/issues/1637))
* **xianyu** — add `inbox`, `messages`, and `reply` commands. ([#1639](https://github.com/jackwener/opencli/issues/1639))
* **suno** — add Suno.com music-generation adapter. ([#1638](https://github.com/jackwener/opencli/issues/1638))
* **linkedin** — consolidate messaging and Sales Navigator commands (`connect`, `inbox`, `safe-send`, `salesnav-search`, `salesnav-inbox`, `salesnav-message`, `salesnav-thread`, `sent-invitations`, `thread-snapshot`, `timeline`). ([#1647](https://github.com/jackwener/opencli/issues/1647))
* **linkedin/people-search** — add a dedicated people-search command. ([#1649](https://github.com/jackwener/opencli/issues/1649))
* **linkedin-learning** — add `search` / `trending` / `course` read commands. ([#1657](https://github.com/jackwener/opencli/issues/1657))
* **twitter** — rewrite the download-profile path on GraphQL UserMedia with cursor pagination. ([#1636](https://github.com/jackwener/opencli/issues/1636))
* **twitter** — add `list-create` (GraphQL CreateList mutation). ([#1656](https://github.com/jackwener/opencli/issues/1656))
* **twitter** — add `device-follow` notification-stream command.
* **twitter** — expose `card.binding_values` on read commands for inline link-preview metadata. ([#1660](https://github.com/jackwener/opencli/issues/1660))
* **twitter** — expose `quoted_tweet` on read commands. ([#1667](https://github.com/jackwener/opencli/issues/1667))
* **twitter** — expose `bio` on read commands.
* **reddit/subscribed** — new `subscribed` command + listing-level `id` / `created_utc` / `selftext` exposure. ([#1651](https://github.com/jackwener/opencli/issues/1651))
* **reddit** — expose `post_hint` / `url` / `preview` / `gallery` media routes on listing commands. ([#1676](https://github.com/jackwener/opencli/issues/1676))
* **zhihu** — add answer-comments reader; include answer links in question results.
* **chatgpt** — detect generated image surfaces (CSS background and canvas, not just `<img>`) so image generation works after UI drift. ([#1677](https://github.com/jackwener/opencli/issues/1677))
* **external** — add Cloudflare Wrangler as a built-in external CLI passthrough. ([#1679](https://github.com/jackwener/opencli/pull/1679))
### Bug Fixes
* **deps** — restore Node 20 runtime compatibility by pinning runtime `undici` back to the 6.x line (an automated dependabot bump to 8.x had moved the engines floor to Node ≥22.19, silently breaking the published Node 20 promise), and clear the docs build audit chain by overriding VitePress' Vite/PostCSS transitive dependencies to patched versions. ([#1673](https://github.com/jackwener/opencli/issues/1673))
* **download** — keep custom media filenames inside the requested output directory by stripping POSIX/Windows path components and sanitizing the generated fallback prefix. Prevents remote-controlled fields (e.g. video titles used as filename) from escaping the output directory via `../`. ([#1642](https://github.com/jackwener/opencli/pull/1642))
* **browser** — recover `Page.goto()` from stale page identities by clearing the cached targetId and retrying navigation once through the session lease; classify CDP `-32000 Cannot find default execution context` as retryable target navigation. ([#1645](https://github.com/jackwener/opencli/issues/1645))
* **cli** — escape leading-dash positional values via the argv preprocessor so users can pass tokens starting with `-` without commander mis-classifying them as flags. ([#1658](https://github.com/jackwener/opencli/issues/1658))
* **chatgpt/image** — fix ChatGPT web image generation after UI drift by letting the composer locator continue into the caller's readiness check and detecting generated images rendered as CSS backgrounds or canvases, not just plain `<img>` elements.
* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors (Douyin user video comments, Jike SSR JSON parse, WeRead search-page fetch). True empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`.
* **adapters** — drop silent-sentinel row fallbacks across Apple Podcasts / Reddit / Gitee. ([#1634](https://github.com/jackwener/opencli/issues/1634))
* **adapters** — migrate legal empty-data branches to `EmptyResultError` for `xhs` / YouTube and 5 follow-up commands. ([#1674](https://github.com/jackwener/opencli/issues/1674), [#1678](https://github.com/jackwener/opencli/issues/1678))
* **lesswrong** — drop the `"Unknown"` silent sentinel in the author column; missing authors now propagate as `null`. ([#1611](https://github.com/jackwener/opencli/issues/1611))
* **youtube/transcript** — scope timedtext URL matching to the current `videoId` across the in-page resource-buffer scan, the in-page fetch/XHR hook, and the Node-side CDP capture. SPA-style watch→watch navigation no longer returns a predecessor video's captions. ([#1655](https://github.com/jackwener/opencli/issues/1655))
* **twitter/lists** — skip the "Discover new Lists" recommendation block so it is no longer treated as one of the user's lists. ([#1652](https://github.com/jackwener/opencli/issues/1652))
* **zhihu** — harden search pagination. ([#1615](https://github.com/jackwener/opencli/issues/1615))
* **zhihu** — decode numeric HTML entities in `answer-detail`. ([#1629](https://github.com/jackwener/opencli/issues/1629))
### Docs
* **readme** — major shrink and reframing: tagline rephrased around "Browser Use", Highlights and Update sections folded into adjacent content, Built-in Commands curated to 11 popular sites, CLI Hub table reduced to a name enumeration, Desktop App Adapters collapsed to a one-liner, skill-attribution references audited against `SKILL.md` frontmatter, "For AI Agents (Developer Guide)" merged into "Writing a new adapter". Net: EN 410 → 326 (-20%), ZH 455 → 371 (-18%). ([#1654](https://github.com/jackwener/opencli/pull/1654), [#1666](https://github.com/jackwener/opencli/pull/1666), [#1679](https://github.com/jackwener/opencli/pull/1679), [#1681](https://github.com/jackwener/opencli/pull/1681))
### Internal
* **audit** — stop flagging sentinel fallback strings inside thrown error messages as `silent-sentinel` violations. These are typed failure diagnostics rather than fake row data, reducing the typed-error baseline to actual adapter output fallbacks.
## [1.7.22](https://github.com/jackwener/opencli/compare/v1.7.21...v1.7.22) (2026-05-15)
External CLI ergonomics + two adapter envelope/auth fixes. New `longbridge` external CLI entry; `opencli list` / root help now render human-readable brand labels for executables whose bare name is ambiguous.
### Features
* **external** — add the Longbridge CLI as a built-in external CLI passthrough (`opencli longbridge ...`) for Longbridge OpenAPI market data, account, and trading commands. ([#1584](https://github.com/jackwener/opencli/issues/1584))
* **external-cli** — render brand alias `name(package)` in `opencli list` and root help when the bare executable name is ambiguous. Built-in entries `ntn``ntn(notion)`, `dws``dws(DingTalk Workspace)`, `wecom-cli``wecom-cli(企业微信)` now self-explain in help output. `package` field is repurposed to cover both upstream distribution names (e.g. `tg-cli`) and human-readable brand labels (e.g. `notion`, `企业微信`). ([#1585](https://github.com/jackwener/opencli/issues/1585))
### Bug Fixes
* **boss** — map `code=24` (identity mismatch) to `AuthRequiredError` so re-login is signaled instead of surfacing as a generic API error. ([#1573](https://github.com/jackwener/opencli/issues/1573))
* **weibo** — unwrap Browser Bridge `page.evaluate` envelopes in read adapters. ([#1568](https://github.com/jackwener/opencli/issues/1568))
## [1.7.21](https://github.com/jackwener/opencli/compare/v1.7.20...v1.7.21) (2026-05-14)
Adapter polish release: new web search adapters, better Browser Bridge tab group reuse, and social adapters returning to one-shot tab leases. Extension package version is bumped to 1.0.15 for the Browser Bridge fix.
### Features
* **search** — add DuckDuckGo, Brave, and Yahoo web search adapters. ([#1546](https://github.com/jackwener/opencli/issues/1546))
* **boss** — support job-seeker `chatlist` and `chatmsg` adapters. ([#1539](https://github.com/jackwener/opencli/issues/1539))
### Bug Fixes
* **extension** — reuse existing `OpenCLI Adapter` tab groups before creating new ones, including cross-window discovery, legacy `OpenCLI` title fallback, and deterministic candidate selection. ([#1541](https://github.com/jackwener/opencli/issues/1541))
* **twitter, reddit** — default browser-backed social adapters back to ephemeral tab leases. Twitter/X and Reddit commands now release their site tab after each run while keeping the shared Adapter window available for reuse; persistent sessions remain reserved for AI/chat-style adapters that need long-lived conversation state. ([#1569](https://github.com/jackwener/opencli/issues/1569))
* **xiaohongshu, rednote** — unwrap Browser Bridge `page.evaluate` envelopes in search adapters. ([#1561](https://github.com/jackwener/opencli/issues/1561))
* **facebook/feed** — add fallback extraction for empty article nodes. ([#1538](https://github.com/jackwener/opencli/issues/1538))
### Internal
* **ci** — add Windows native binding lockfile entries for Rolldown/Rollup optional packages. ([#1563](https://github.com/jackwener/opencli/issues/1563))
* **extension** — add regression coverage for the adapter tab group `groupId` tiebreaker. ([#1566](https://github.com/jackwener/opencli/issues/1566))
## [1.7.20](https://github.com/jackwener/opencli/compare/v1.7.19...v1.7.20) (2026-05-14)
External CLI surface cleanup + Browser Bridge WebSocket lifecycle hardening. Two BREAKING changes around external CLIs: built-in `tg`/`discord`/`wx` (was `tg-cli`/`discord-cli`/`wx-cli`) now match their real binary names, and Notion's in-tree CDP adapter is replaced by the official `ntn` external CLI.
### ⚠ BREAKING CHANGES
* **notion** — remove the in-tree `clis/notion/` CDP-on-Desktop adapter (8 commands: `status` / `search` / `read` / `new` / `write` / `sidebar` / `favorites` / `export`). Notion has shipped an official CLI at <https://ntn.dev>, registered as a first-class external CLI in `external-clis.yaml`. Migration: install `ntn` from <https://ntn.dev> (`curl -fsSL https://ntn.dev | bash`), then use `opencli ntn <command>`. Auto-install is intentionally not configured because the official installer is a shell script while OpenCLI external installs run shell-free command strings. The official CLI uses the public Notion API rather than reverse-engineering the Desktop UI, so it survives Notion app updates and exposes a wider command surface (blocks / databases / properties / comments) than the reverse-engineered adapter could. ([#1559](https://github.com/jackwener/opencli/issues/1559))
* **external** — drop the `-cli` suffix from built-in external CLI subcommand names. `opencli tg-cli`, `opencli discord-cli`, `opencli wx-cli` are now `opencli tg`, `opencli discord`, `opencli wx`, matching the real binary names that those tools install as. Root help still shows the package lineage as `tg(tg-cli)` / `discord(discord-cli)` / `wx(wx-cli)`. ([#1544](https://github.com/jackwener/opencli/issues/1544))
### Features
* **twitter** — `bookmarks` and `bookmark-folder` now include media via `extractMedia`, reaching parity with `timeline` / `search`. ([#1555](https://github.com/jackwener/opencli/issues/1555))
* **twitter/list-tweets** — include media via `extractMedia` (parity with `timeline` / `search`). ([#1464](https://github.com/jackwener/opencli/issues/1464))
### Bug Fixes
* **daemon** — report ambiguous browser command outcomes with a distinct `command_result_unknown` errorCode and `503` when the extension WebSocket drops between command dispatch and result delivery. `sendCommandRaw()` treats this code as hard non-retryable, so write-side commands (`navigate` / `click` / `type` / `eval`) won't be silently re-issued and double-executed. Daemon exposes a `commandResultUnknown` counter on `/status` for future observability. ([#1558](https://github.com/jackwener/opencli/issues/1558))
* **extension** — keep active daemon WebSocket; stale sockets no longer clobber active connection (`onopen` / `onclose` / `onmessage` are all gated by `ws !== thisWs` short-circuit), and `safeSend` only fires when `readyState === OPEN`. ([#1540](https://github.com/jackwener/opencli/issues/1540))
* **extension** — coalesce concurrent daemon WebSocket connects via an in-flight promise. Startup / keepalive / reconnect triggering `connect()` during the daemon-probe or context-lookup async gap no longer creates duplicate real WebSocket connections. ([#1554](https://github.com/jackwener/opencli/issues/1554))
* **external** — distinguish external CLI executable names from distribution/project names in root help. Built-in aliases such as `tg`, `discord`, `wx` remain the callable `opencli <name> ...` entrypoints while help renders `tg(tg-cli)`, `discord(discord-cli)`, `wx(wx-cli)` to show their package lineage. ([#1560](https://github.com/jackwener/opencli/issues/1560))
### Docs
* **browser** — clarify named session lifecycle in the Browser Bridge guide. ([#1542](https://github.com/jackwener/opencli/issues/1542))
## [1.7.19](https://github.com/jackwener/opencli/compare/v1.7.18...v1.7.19) (2026-05-14)
Major hotfix + simplification batch. Extension bumped to 1.0.14. Node floor lowered to v20 so the long tail of Node v20v21.6 users no longer crashes at module load. `opencli browser` user surface replaces required-flag `--session <name>` with a `<session>` positional. `page.evaluate(fn, ...args)` adds a type-safe alternative to the implicit auto-IIFE string form. Twitter cursor pagination no longer silently caps at ~500 items.
### ⚠ BREAKING CHANGES
* **browser** — replace the `--session <name>` flag with a `<session>` positional argument that immediately follows `browser`. `opencli browser work click 12` instead of `opencli browser --session work click 12`; `opencli browser work bind` instead of `opencli browser bind --session work`. Required-flag semantics are now encoded structurally as a positional, matching the Docker/git convention for required operation-target identifiers. The internal `--session` flag is preserved for the daemon protocol and for direct `program.parseAsync` callers but is no longer part of the user-facing surface. ([#1505](https://github.com/jackwener/opencli/issues/1505))
* **env** — remove `OPENCLI_KEEP_TAB`. The flag was a debugging shortcut, not a config dimension: `--keep-tab true|false` on the command line is the single source of truth, and adapter `siteSession: 'persistent'` already pins persistent site tabs as a hard constraint. Removing the env eliminates a globally-leaking process state that overrode every browser command in the shell. ([#1509](https://github.com/jackwener/opencli/issues/1509))
* **extension** — remove the internal `surface\\0session` command-session backdoor. Browser Bridge commands now route only through structured `session` + `surface` fields; lease-key strings remain an extension-internal registry detail. ([#1510](https://github.com/jackwener/opencli/issues/1510))
### Features
* **browser** — add `page.evaluate(fn, ...args)` for type-safe browser-context evaluation with JSON-serialized arguments. String evaluation remains supported, but new adapter code should use function form to avoid implicit `wrapForEval` auto-IIFE magic. ([#1508](https://github.com/jackwener/opencli/issues/1508))
* **twitter** — default `tweets` command to the logged-in user when `user` is omitted, and fix the sibling envelope-unwrap silent bug. ([#1531](https://github.com/jackwener/opencli/issues/1531))
* **zhihu** — add `answer-detail` to fetch a single answer's full content. ([#1528](https://github.com/jackwener/opencli/issues/1528))
* **zhihu** — paginate question answers and recommendations. ([#1517](https://github.com/jackwener/opencli/issues/1517))
* **reddit/read** — `--expand-more` via `/api/morechildren` + 7-kind typed errors. ([#1492](https://github.com/jackwener/opencli/issues/1492))
* **reddit** — add `whoami`, `home`, `subreddit-info` read commands. ([#1491](https://github.com/jackwener/opencli/issues/1491))
* **ctrip** — add `hotel-search` + flight browser-mode commands. ([#1489](https://github.com/jackwener/opencli/issues/1489))
### Bug Fixes
* **browser** — `page.evaluate()` / `evaluateInFrame()` now return the user JavaScript value directly. Browser Bridge `exec` previously routed through a shared `pageScopedResult` helper that spread / wrapped the lease's `session` into the result `data`, contaminating arbitrary user returns: array / primitive returns came back as `{ session, data }` envelopes, and plain-object returns had an extra `session` key injected (overwriting any user `session` field). `google search` and `xiaohongshu search` were the visible repro — Chrome rendered results correctly but adapters extracted an empty array. Fixed in extension 1.0.14 by reverting `pageScopedResult` to its pre-1461 form (`{ id, ok, data, page }`); no client-side unwrap is needed. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **twitter** — raise fixed cursor-pagination caps in `bookmarks` / `likes` / `tweets` / `timeline` / `bookmark-folder` / `list-tweets` / `search` / `following`. The old `i < 5` / `i < 10` literals and following's `Math.ceil(limit / 50) + 2` formula imposed hidden result ceilings below `--limit`; the loop now treats the page count as a high runaway guard while `--limit` and cursor exhaustion control normal pagination. ([#1532](https://github.com/jackwener/opencli/issues/1532))
* **twitter** — repair `list-add` / `list-tweets` / `lists` / `following` after 2026-05 site changes. ([#1503](https://github.com/jackwener/opencli/issues/1503))
* **twitter** — repair `search` and `tweets` readback. ([#1512](https://github.com/jackwener/opencli/issues/1512))
* **twitter** — make reply submission robust. ([#1511](https://github.com/jackwener/opencli/issues/1511))
* **google/search** — wait for `#rso a h3` before extracting, falling back to the existing fixed wait. On Chrome 148 + Linux Wayland the DOM can settle before SERP anchors are populated, making extraction return empty even with the envelope bug fixed. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **google/search** — wrap evaluate return value in object to fix serialization. ([#1523](https://github.com/jackwener/opencli/issues/1523))
* **google-scholar/search** — wrap evaluate return to fix serialization. ([#1525](https://github.com/jackwener/opencli/issues/1525))
* **xiaohongshu/search** — extract initially visible cards before scrolling, then merge post-scroll rows by URL. Xiaohongshu's virtualized masonry layout can evict the initial cards from the DOM after scroll, so the previous always-scroll-then-extract flow could lose the top results. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **xiaohongshu** — `parseLikes` handles `2.1w` / `1.5万` / `1.2k` shortforms. ([#1504](https://github.com/jackwener/opencli/issues/1504))
* **xiaohongshu+rednote/search** — fall back to href-based note cards when `section.note-item` class is dropped. ([#1507](https://github.com/jackwener/opencli/issues/1507))
* **xueqiu** — `kline` / `earnings-date` format dates in Asia/Shanghai instead of UTC. ([#1498](https://github.com/jackwener/opencli/issues/1498))
* **download** — clamp progress percentages. ([#1520](https://github.com/jackwener/opencli/issues/1520))
### Internal
* **runtime** — lower the Node floor to `>=20.0.0`. Three coupled changes: drop all `util.styleText()` usage (added in Node v21.7.0 / v20.12.0; previously crashed v21.0v21.6 at module load), downgrade `undici` from `^8.0.2` (engines `>=22.19.0`) to `^6.25.0` (engines `>=18.17`, retains `Agent` / `EnvHttpProxyAgent` / `fetch`), and lower `MIN_SUPPORTED_NODE_MAJOR` from 21 to 20 so the startup guard matches the declared `engines.node`. Smoke-tested on v20.0.0 / v21.2.0 / v22.22.2. The semantic markers (`[OK]` / `[WARN]` / `[FAIL]` / `` / `⚠` / `✖`) keep their meaning; ANSI colors were redundant for the primarily agent-facing CLI. ([#1524](https://github.com/jackwener/opencli/issues/1524))
* **extension 1.0.14** — `pageScopedResult` no longer injects `session` into `data`. The field had no consumers and contaminated `exec` results with arbitrary user-JS shapes; routing-relevant identity is already exposed via `Result.page`. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **extension 1.0.13** — remove the internal command-session lease-key backdoor. ([#1510](https://github.com/jackwener/opencli/issues/1510))
* **ci** — drop `e2e-headed` and `adapter-test` from `pull_request` triggers (kept on `push` to main / nightly / `workflow_dispatch`). PR-time CI now targets ~2 min wall-time. ([#1521](https://github.com/jackwener/opencli/issues/1521), [#1522](https://github.com/jackwener/opencli/issues/1522))
* **scripts** — auto-refresh `dist/` before `build-manifest`. ([#1490](https://github.com/jackwener/opencli/issues/1490))
## [1.7.18](https://github.com/jackwener/opencli/compare/v1.7.17...v1.7.18) (2026-05-12)
Hotfix release for the 1.7.17 doctor regression: `opencli doctor` failed connectivity probe with `Browser session is required` because the doctor probe didn't pass a session to the new strict-session browser bridge. Also adds new adapters and adapter fixes that were ready immediately after 1.7.17.
### Bug Fixes
* **doctor** — pass an internal `__doctor__` browser session to the live connectivity probe so `opencli doctor` works again under the explicit-session browser model introduced in 1.7.17. ([#1485](https://github.com/jackwener/opencli/issues/1485))
* **browser** — `--session <name>` is now declared as a `requiredOption` so Commander itself rejects calls missing the flag before runtime, and the help line is marked `(required)` instead of being hidden under `Options:`. ([#1485](https://github.com/jackwener/opencli/issues/1485))
* **doubao/ask** — restore Assistant detection after the 2026-05 DOM refactor. ([#1484](https://github.com/jackwener/opencli/issues/1484))
* **youtube** — request `srv3` format for caption URLs. ([#1422](https://github.com/jackwener/opencli/issues/1422))
### Features
* **rednote** — add `rednote.com` adapter mirroring xiaohongshu read commands. ([#1475](https://github.com/jackwener/opencli/issues/1475))
* **reddit** — add `reply` command for replying to comments. ([#1428](https://github.com/jackwener/opencli/issues/1428))
## [1.7.17](https://github.com/jackwener/opencli/compare/v1.7.16...v1.7.17) (2026-05-12)
Extension bumped to 1.0.12 (workspace → session lease routing, drop `handleSessions` handler). Major simplification pass: browser/adapter session model rewrite, `--workspace` removed, doctor surface trimmed to its core job.
### ⚠ BREAKING CHANGES
* **browser session model** — replace the browser-facing `--workspace` model with explicit `--session <name>` on `opencli browser *`. Browser commands now require a session name, `browser bind`/`unbind` use `--session`, and bind no longer accepts `--domain`, `--path-prefix`, or `--allow-navigate-bound`. Browser primitives keep their session tab by design; the browser namespace no longer exposes `--keep-tab`. ([#1461](https://github.com/jackwener/opencli/issues/1461))
* **adapter site sessions** — replace adapter metadata `browserSession: { reuse: 'site' }` with `siteSession: 'persistent'`, and replace the user override `--reuse <none|site>` / `OPENCLI_BROWSER_REUSE` with `--site-session <ephemeral|persistent>`. Persistent site sessions keep a stable site tab open without idle expiry. ([#1462](https://github.com/jackwener/opencli/issues/1462))
* **doctor** — remove `--no-live` and `--sessions` flags from `opencli doctor`. Doctor always runs the live browser connectivity probe (that's its core job); session enumeration was never part of health diagnosis. The underlying `'sessions'` daemon protocol action and the `BrowserSessionInfo` public type are removed as dead code. ([#1470](https://github.com/jackwener/opencli/issues/1470))
### Features
* **chatgpt** — `ask` and `send` now accept local image paths and upload them through the composer before submitting the prompt. ([#1476](https://github.com/jackwener/opencli/issues/1476))
### Internal
* **extension 1.0.12** — drop `handleSessions` action handler (no remaining consumers after doctor cleanup).
* **extension 1.0.11** — switch Browser Bridge lease routing from user-facing workspaces to explicit browser sessions.
## [1.7.16](https://github.com/jackwener/opencli/compare/v1.7.15...v1.7.16) (2026-05-11)
Extension bumped to 1.0.10 (rename adapter-owned tab group `OpenCLI Automation``OpenCLI Adapter`). Performance and stability sweep across browser-backed adapters; new external CLI integrations (tg-cli, discord-cli, wx-cli).
### Features
* **openreview** — add `author` command for ID-explicit publication lookup. ([#1365](https://github.com/jackwener/opencli/issues/1365))
* **external** — register `tg-cli`, `discord-cli`, and `wx-cli` as external CLI integrations. ([#1458](https://github.com/jackwener/opencli/issues/1458))
### Bug Fixes
* **xiaohongshu** — fall back to base64 upload when CDP `DOM.setFileInputFiles` returns `Not allowed` on creator center. ([#1374](https://github.com/jackwener/opencli/issues/1374))
* **chatgpt** — switch to locale-stable send button selector so non-English UIs don't break send. ([#1354](https://github.com/jackwener/opencli/issues/1354))
### Performance
* **adapters** — hoist cookie reads to `page.getCookies` across Tier 1 (25 files), eliminating per-call CDP round trips. ([#1450](https://github.com/jackwener/opencli/issues/1450))
* **twitter** — drop redundant `goto + wait` in adapter steps; framework auto pre-navigates. ([#1451](https://github.com/jackwener/opencli/issues/1451))
* **twitter** — enable `browserSession.reuse: 'site'` on 17 read-only adapters so repeated reads share one tab. ([#1454](https://github.com/jackwener/opencli/issues/1454))
* **reddit** — opt 13 browser-backed adapters into shared site-tab lease. ([#1455](https://github.com/jackwener/opencli/issues/1455))
* **claude** — replace fixed-sleep waits with selector-based readiness on streaming flows. ([#1452](https://github.com/jackwener/opencli/issues/1452))
* **deepseek** — replace fixed-sleep waits with selector-based readiness on streaming flows. ([#1449](https://github.com/jackwener/opencli/issues/1449))
* **chatgpt** — replace fixed-sleep waits with selector-based readiness (D3). ([#1456](https://github.com/jackwener/opencli/issues/1456))
### Refactor
* **browser** — split interactive and automation windows so `opencli browser *` and adapter-driven background commands no longer share one Chrome window; tab groups are isolated by role.
### Internal
* **extension 1.0.10** — rename the adapter-owned Chrome tab group from `OpenCLI Automation` to `OpenCLI Adapter`. ([#1457](https://github.com/jackwener/opencli/issues/1457))
* **docs** — list `tg-cli`, `discord-cli`, `wx-cli` in External CLI README sections. ([#1459](https://github.com/jackwener/opencli/issues/1459))
## [1.7.15](https://github.com/jackwener/opencli/compare/v1.7.14...v1.7.15) (2026-05-10)
Extension bumped to 1.0.9 (Accessibility.enable allowlist + downloads permission + cross-origin frame target attach for AX). Major Browser Agent Runtime release: full Phase 0/1/2 alignment with `vercel-labs/agent-browser` model — CDP-primary input, AX snapshot/refs with stale recovery, semantic locators across all primitives, full form toolbelt (hover/focus/dblclick/check/uncheck/upload/drag/wait-download), annotated screenshots, and same-origin iframe AX routing. Cross-origin OOPIF AX is best-effort (Chrome extension API limitation).
### ⚠ BREAKING CHANGES
* **browser lifecycle** — replace `--focus` / `OPENCLI_WINDOW_FOCUSED` with `--window foreground|background` / `OPENCLI_WINDOW`, and replace `--live` / `OPENCLI_LIVE` with `--keep-tab true|false` / `OPENCLI_KEEP_TAB`. `opencli browser *` defaults to a foreground window and keeps its tab; browser-backed adapter commands default to a background automation window and release their tab unless the adapter uses site-level reuse.
### Features
* **help / browser** — `opencli browser --help -f yaml|json` now emits a structured, agent-ready index of all browser leaf commands (including nested `tab`, `get`, and `dialog` commands), their positionals, command options, namespace options, and root global options. Individual browser commands also support structured help, backed by a shared Commander option/argument spec extractor.
* **help / built-in namespaces** — `opencli daemon|plugin|adapter|profile --help -f yaml|json` now emit the same structured payload as `browser`. One agent call returns every leaf's positionals, options, descriptions, and global options — no per-leaf `--help` follow-ups needed. Original namespace descriptions are preserved through `applyRootSubcommandSummaries()` via a snapshot at namespace declaration time.
* **browser state** — add opt-in AX snapshot refs via `browser state --source ax`, including backend-node click resolution and role/name stale-ref recovery for the Phase 0 browser-agent runtime prototype.
* **browser state** — AX snapshots now include same-origin iframe refs, and `browser state --compare-sources` prints DOM-vs-AX observation metrics for the Phase 1 default-source decision without dumping page contents.
* **browser locators** — `browser find`, `browser click`, and `browser get text|value|attributes` now accept semantic locator flags (`--role`, `--name`, `--label`, `--text`, `--testid`) so agents can act on common controls without a separate state-ref lookup.
* **browser locators** — semantic locator flags now work across input/action primitives (`type`, `fill`, `select`, `hover`, `focus`, `dblclick`, `check`, `uncheck`, `upload`) plus prefixed `--from-*` / `--to-*` locators for `drag`.
* **browser actions** — add `browser hover`, `browser focus`, and `browser dblclick` primitives backed by the same target resolver and CDP input path as `browser click`.
* **browser actions** — add `browser check` and `browser uncheck` primitives that ensure checkbox / radio / aria-checked controls reach the requested state instead of blindly toggling.
* **browser upload** — add `browser upload <target> <file...>` to attach local files to `input[type=file]` targets through CDP `DOM.setFileInputFiles`, with local path validation and file-input verification.
* **browser actions** — add `browser drag <source> <target>` for CDP mouse drag sequences between two resolved element centers.
* **browser wait / extension 1.0.8** — add `browser wait download [pattern]` backed by Chrome's downloads lifecycle API, so agents can wait for file downloads by filename/URL pattern and receive completed/failed download metadata.
* **browser state / extension 1.0.9** — AX snapshots can now route same-origin iframe refs through `frameId`. Cross-origin OOPIF AX routing is best-effort because real Chrome extension smoke tests show `chrome.debugger` may not expose attachable iframe targets to extensions.
* **browser screenshot** — add `browser screenshot --annotate`, which refreshes DOM refs and overlays visible `[N]` labels on the screenshot so visual inspection maps back to `browser click <ref>` targets.
### Bug Fixes
* **browser click** — `browser click` now prefers CDP `Input.dispatchMouseEvent` over DOM `el.click()`, so custom dropdowns that depend on pointer/mouse events (Radix, shadcn, Material UI, Mercury-style category pickers) open and select reliably while retaining JS click as a fallback for older backends or zero-rect targets.
* **browser state / extension 1.0.7** — `browser state --source ax` now enables the CDP Accessibility domain before reading the AX tree, fixing real-Chrome snapshots that previously returned only `RootWebArea` with zero refs.
* **help / build** — every positional arg must now declare a non-empty `help` string. The build-manifest step fails closed when a positional has empty / whitespace-only / missing `help`, so `opencli <site> <cmd> --help` always shows callers what each parameter is for. Pre-existing offenders (`twitter followers/following/list-add/list-remove/list-tweets/search/thread`, `reddit search/subreddit/user/user-comments/user-posts`, `douyin stats/update`, `bilibili subtitle`, `jike search`) now have explicit help text — most notably `twitter followers [user]` and `following [user]` now document that omitting the user fetches the currently logged-in account.
## [1.7.14](https://github.com/jackwener/opencli/compare/v1.7.13...v1.7.14) (2026-05-08)
### Features
* **help** — adapter help is now agent-friendly: per-command listings drop the `[options]` noise from globally-shared options (`--format`, `--trace`, `-v`, `-h`, etc.) and only mention them at the site level, so `opencli twitter` etc. read like a flat command index. ([#1401](https://github.com/jackwener/opencli/issues/1401))
* **twitter** — write-action symmetry P0: add `unlike`, `retweet`, `unretweet`, and `quote` to round out the read/write coverage. ([#1400](https://github.com/jackwener/opencli/issues/1400))
### Bug Fixes
* **browser daemon** — `npm install -g @jackwener/opencli@latest` now correctly auto-restarts a stale ready-state daemon so users pick up the new version without a manual `opencli daemon restart`. ([#1399](https://github.com/jackwener/opencli/issues/1399))
## [1.7.13](https://github.com/jackwener/opencli/compare/v1.7.12...v1.7.13) (2026-05-07)
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.
* **grok ask** — drop the `--web` flag and the legacy `<textarea>` composer path. The default flow is now the only path and uses the current ProseMirror+TipTap composer (the path that used to require `--web true`). Existing scripts passing `--web` will get an "unknown option" error from commander; remove the flag.
* **env** — rename `OPENCLI_BROWSER_TIMEOUT` to `OPENCLI_BROWSER_IDLE_TIMEOUT`. The variable controls workspace lease idle release time, not per-command runtime; the new name reflects that. Old name was undocumented and removed without a fallback.
* **registry** — remove the unused `Strategy.HEADER`; adapter authors should use `Strategy.COOKIE` and set headers explicitly inside browser-side fetches.
### Features
* **observation** — add trace artifact primitives, `browser console`, `browser network --since/--follow/--failed`, and adapter `--trace=retain-on-failure` for failure-retained browser evidence.
* **autofix** — retire `OPENCLI_DIAGNOSTIC`; adapter repair now uses `--trace retain-on-failure`, trace `summary.md`, and error-envelope trace metadata.
* **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929))
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
* **browser session** — adapter commands can opt into site-level tab reuse with `browserSession.reuse = 'site'`; Grok and other browser-backed LLM adapters now keep a shared site tab by default, and users can override with `--reuse <none|site>`.
* **chatgpt** — add browser-web baseline commands: `ask`, `send`, `read`, `history`, `detail`, `new`, and `status`.
* **grok** — add browser-web baseline commands: `read`, `history`, `detail`, `new`, `send`, and `status` (existing `ask` and `image` unchanged).
* **yuanbao** — add browser-web baseline commands: `send`, `status`, `read`, `history`, and `detail` (joining the existing `ask` and `new`).
* **qwen** — add `detail` command for opening a specific historical conversation by id.
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
### Bug Fixes
* **pipeline / capabilityRouting** — the `fill` pipeline step (introduced in [#1222](https://github.com/jackwener/opencli/issues/1222)) now correctly triggers a browser session and gets transient retry coverage; previously a pipeline using only `fill` could crash on a missing page object. ([#1393](https://github.com/jackwener/opencli/issues/1393))
* **xiaohongshu publish** — improve image publishing reliability via creator-center URL routing, tab priority handling, and DataTransfer fallback.
* **youtube** — use watch-page HTML for transcript captions to recover when the public transcript API is unavailable.
* **desktop adapters** — restore 11 desktop adapter commands that were lost from the manifest due to a factory-pattern regression.
### Internal
* **cleanup** — remove dead `src/analysis.ts` (179 lines, 0 importers), retire `OPENCLI_DIAGNOSTIC` test residue, derive validator step allowlist from the live pipeline registry to prevent future drift.
## [1.7.8](https://github.com/jackwener/opencli/compare/v1.7.7...v1.7.8) (2026-04-25)
### Features
+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,
+82 -197
View File
@@ -1,7 +1,8 @@
# OpenCLI
> **Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.**
> Reuse your logged-in browser, automate live workflows, and crystallize repeated actions into reusable CLI commands.
> **Convert any website into a CLI & run Browser Use on your logged-in Chrome.**
> Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.
> Or run Browser Use against any page — navigate, fill forms, click, extract, automate.
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
@@ -11,30 +12,29 @@
OpenCLI gives you one surface for three different kinds of automation:
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Let AI Agents operate any website** — install the `opencli-browser` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion.
## Highlights
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type, extract, screenshot — all through your logged-in Chrome session.
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
- **Website → CLI** — Turn any website into a deterministic CLI: 90+ pre-built adapters, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification.
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, etc).
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
---
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Trae CN, Codex, Antigravity, ChatGPT, and Trae SOLO.
## Quick Start
### 1. Install OpenCLI
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
npm install -g @jackwener/opencli
```
@@ -64,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.
@@ -86,11 +86,23 @@ Use OpenCLI directly when you want a reliable command instead of a live browser
- `opencli external register mycli` exposes a local CLI through the same discovery surface.
- `opencli doctor` helps diagnose browser connectivity.
## Extending OpenCLI
If you want to add your own commands, start with the [Extending OpenCLI guide](./docs/guide/extending-opencli.md). README keeps this short; the guide covers the directory layout, source-control model, and install commands.
| Need | Recommended path |
|------|------------------|
| Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
| Wrap an existing local binary | `opencli external register <name>` |
## For AI Agents
OpenCLI's browser commands are designed to be used by AI Agents — not run manually. Install skills into your AI agent (Claude Code, Cursor, etc.), and the agent operates websites on your behalf using your logged-in Chrome session.
### Install skills
### Install skills (also refreshes existing installs)
```bash
npx skills add jackwener/opencli
@@ -102,23 +114,25 @@ Or install only what you need:
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-browser-sitemap
npx skills add jackwener/opencli --skill opencli-sitemap-author
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
### Which skill to use
| Skill | When to use | Example prompt to your AI agent |
|-------|------------|-------------------------------|
| **opencli-adapter-author** | Operate a site in real time, or write a reusable adapter for a new site | "Help me check my Xiaohongshu notifications" / "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
| **opencli-adapter-author** | Write a reusable adapter for a new site or add a command to an existing site | "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
| **opencli-autofix** | Repair a broken adapter when a built-in command fails | "`opencli zhihu hot` is returning empty — fix it" |
| **opencli-browser** | Browser automation reference for AI agents | "Use browser commands to scrape this page" |
| **opencli-browser** | Drive a real Chrome page ad-hoc — navigate, fill forms, click, extract | "Help me check my Xiaohongshu notifications" / "Help me fill out this form" / "Use browser commands to scrape this page" |
| **opencli-browser-sitemap** | Consume site sitemap context while driving a browser task | "Use the sitemap to navigate this website without blind clicking" |
| **opencli-sitemap-author** | Create or update site sitemap knowledge for browser agents | "Record the stable workflow you just discovered for this site" |
| **opencli-usage** | Quick reference for all OpenCLI commands and sites | "What commands does OpenCLI have for Twitter?" |
| **smart-search** | Search across existing OpenCLI capabilities | "Find me a Bilibili trending adapter" |
### How it works
Once `opencli-adapter-author` is installed, your AI agent can:
Once `opencli-browser` is installed, your AI agent can:
1. **Navigate** to any URL using your logged-in browser
2. **Read** page content via structured DOM snapshots (not screenshots)
@@ -129,178 +143,76 @@ Once `opencli-adapter-author` is installed, your AI agent can:
The agent handles all the `opencli browser` commands internally — you just describe what you want done in natural language.
**Skill references:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — browser operation + adapter authoring, end-to-end
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — drive Chrome ad-hoc (navigate, fill forms, click, extract)
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — use sitemap context while driving a browser task
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — create or update site sitemap knowledge
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — write a new adapter end-to-end
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — repair broken adapters
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — browser automation reference
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — capability search
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
Available browser commands include `open`, `state`, `click`, `type`, `fill`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
`opencli browser open <url>` and `opencli browser tab new [url]` both return a target ID. Use `opencli browser tab list` to inspect the target IDs of tabs that already exist, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted `opencli browser ...` commands.
`opencli browser` commands require a `<session>` positional immediately after `browser`. `opencli browser work open <url>` and `opencli browser work tab new [url]` both return a target ID. Use `opencli browser work tab list` to inspect target IDs, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted commands in the same session.
## Core Concepts
## Writing a new adapter
### `browser`: AI Agent browser control
When the site you need is not yet covered, use the `opencli-adapter-author` skill end-to-end:
`opencli browser` commands are the low-level primitives that AI Agents use to operate websites. You don't run these manually — instead, install the `opencli-adapter-author` skill into your AI agent, describe what you want in natural language, and the agent handles the browser operations.
For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser open`, `state`, `click`, etc. under the hood.
### Built-in adapters: stable commands
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists. These are deterministic and work without browser — ideal for both humans and AI agents.
### Writing a new adapter
When the site you need is not yet covered, use the `opencli-adapter-author` skill. It takes the agent end-to-end:
1. Recon the site and classify its pattern (SPA / SSR / JSONP / Token / Streaming).
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
4. Decode response fields and design output columns.
5. `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
### CLI Hub and desktop adapters
OpenCLI is not only for websites. It can also:
- expose local binaries like `gh`, `docker`, `obsidian`, or custom tools through `opencli <tool> ...`
- control Electron desktop apps through dedicated adapters and CDP-backed integrations
## Prerequisites
- **Node.js**: >= 21.0.0 (or **Bun** >= 1.0)
- **Chrome or Chromium** running and logged into the target site for browser-backed commands
> **Important**: Browser-backed commands reuse your Chrome/Chromium login session. If you get empty data or permission-like failures, first confirm the site is already open and authenticated in Chrome/Chromium.
1. **Recon** the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
2. **Discover** the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. **Pick auth**`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`.
4. **Decode** response fields and design output columns.
5. `opencli browser recon analyze <url>``opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon verify <site>/<name>`.
6. Site knowledge persists to `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context.
## Configuration
| 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_FOCUSED` | `false` | Set to `1` to open the automation container in the foreground (useful for debugging). The `--focus` flag sets this. |
| `OPENCLI_LIVE` | `false` | Set to `1` to keep the automation lease open after an adapter command finishes (useful for inspection). The `--live` flag sets this. |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
| `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` | `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`) |
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
| `OPENCLI_DIAGNOSTIC` | `false` | Set to `1` to capture structured diagnostic context on failures |
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation lease open until you run `opencli browser close` or the idle timeout expires.
## Update
```bash
npm install -g @jackwener/opencli@latest
# If you use the packaged OpenCLI skills, refresh them too
npx skills add jackwener/opencli
```
Or refresh only the skills you actually use:
```bash
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
## For Developers
Install from source:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
To load the source Browser Bridge extension:
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select this repository's `extension/` directory.
`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.
## 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` `video` `user-videos` |
| **tieba** | `hot` `posts` `search` `read` |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **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` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **1688** | `search` `item` `assets` `download` `store` |
| **gitee** | `trending` `search` `user` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
| **uiverse** | `code` `preview` |
| **baidu-scholar** | `search` |
| **google-scholar** | `search` `cite` `profile` |
| **gov-law** | `search` `recent` |
| **gov-policy** | `search` `recent` |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` |
| **wanfang** | `search` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
| **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-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` |
90+ adapters in total**[→ see all supported sites & commands](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast`, `podcast-episodes`, `episode`, `download`, and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
Curated highlights**[→ see all 100+ supported sites & commands](./docs/adapters/index.md)** (douyin / weibo / spotify / 1688 / quark / nowcoder / google-scholar / hupu / xianyu / weread / weread-official / xiaoyuzhou / Chess.com / and more).
## CLI Hub
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
Unified passthrough for your existing command-line tools. Run `opencli <tool> ...` for any of:
| External CLI | Description | Example |
|--------------|-------------|---------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker | `opencli docker ps` |
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
| **dws** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom-cli msg send --to user "hello"` |
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
Register your own with `opencli external register <name>`; list everything with `opencli external list`.
```bash
opencli external register mycli
```
### Desktop App Adapters
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
| App | Description | Doc |
|-----|-------------|-----|
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
**Desktop app adapters** (Electron, via CDP): Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/).
## Download Support
@@ -309,6 +221,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| Platform | Content Types | Notes |
|----------|---------------|-------|
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
| **rednote** | Images, Videos | Downloads all media from a signed rednote note URL |
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | From user media tab or single tweet |
| **douban** | Images | Poster / still image lists |
@@ -323,6 +236,7 @@ For video downloads, install `yt-dlp` first: `brew install yt-dlp`
```bash
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
opencli rednote download "https://www.rednote.com/search_result/<id>?xsec_token=..." --output ./rednote
opencli bilibili download BV1xxx --output ./bilibili
opencli twitter download elonmusk --limit 20 --output ./twitter
opencli 1688 download 841141931191 --output ./1688-downloads
@@ -344,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
@@ -381,21 +277,10 @@ 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.
## For AI Agents (Developer Guide)
Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end:
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
- Decode response fields, design columns, scaffold with `opencli browser init`.
- Verify with `opencli browser verify <site>/<name>` before shipping.
Adapters you write outside the repo live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
## Testing
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
@@ -405,12 +290,12 @@ 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** — Ensure Node.js >= 21. Some features require `node:util` styleText (stable in Node 21+).
- **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
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
[![Star History Chart](https://star-history.dera.page/svg?repos=jackwener/opencli&type=Date)](https://star-history.dera.page/#jackwener/opencli&Date)
## License
+87 -262
View File
@@ -1,7 +1,8 @@
# OpenCLI
> **把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。**
> 复用浏览器登录态,先自动化真实操作,再把高频流程沉淀成可复用的 CLI 命令
> **把任意网站变成 CLI & 在你的登录态浏览器上跑 Browser Use。**
> 把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口
> 或者在任意页面上跑 Browser Use —— 导航、填表单、点击、抓取、自动化。
[![English](https://img.shields.io/badge/docs-English-1D4ED8?style=flat-square)](./README.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
@@ -10,28 +11,29 @@
OpenCLI 可以用同一套 CLI 做三类事情:
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [90+ 站点](#内置命令) 开箱即用。
- **让 AI Agent 操作任意网站**:在你的 AI AgentClaude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入、提取任意网页内容。
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
- **让 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` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
## 亮点
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)。
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入、提取、截图——全部通过你的已登录 Chrome 会话完成。
- **网站 → CLI** — 把任何网站变成确定性 CLI:90+ 内置适配器,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian 等)。
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker``longbridge``tg``discord``wx``ntn`Notion等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Trae CN、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。
## 快速开始
### 1. 安装 OpenCLI
如果你是在自己的电脑上使用,优先安装 **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
npm install -g @jackwener/opencli
```
@@ -67,14 +69,26 @@ opencli bilibili hot --limit 5
- `opencli list` 查看当前所有命令
- `opencli <site> <command>` 调用内置或生成好的适配器
- `opencli register mycli` 把本地 CLI 接入同一发现入口
- `opencli external register mycli` 把本地 CLI 接入同一发现入口
- `opencli doctor` 处理浏览器连通性问题
## 扩展 OpenCLI
如果你想新增自己的命令,先看 [扩展 OpenCLI](./docs/zh/guide/extending-opencli.md)。README 只保留入口;目录结构、源码管理方式和安装命令放在文档里。
| 需求 | 推荐路径 |
|------|----------|
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
| 包装已有本机 binary | `opencli external register <name>` |
## 给 AI Agent
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI AgentClaude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
### 安装 skill
### 安装 skill(同时也用于更新)
```bash
npx skills add jackwener/opencli
@@ -86,23 +100,25 @@ npx skills add jackwener/opencli
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-browser-sitemap
npx skills add jackwener/opencli --skill opencli-sitemap-author
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
### 选择哪个 skill
| Skill | 适用场景 | 你对 AI Agent 说的话 |
|-------|---------|-------------------|
| **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-adapter-author** | 为新站点写可复用适配器,或给已有站点添加命令 | "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" |
| **opencli-browser** | 实时驱动 Chrome 页面——导航、填表单、点击、抓取 | "帮我看看小红书的通知" / "帮我填一下这个表单" / "用浏览器命令抓取这个页面" |
| **opencli-browser-sitemap** | 使用站点 sitemap 上下文来操作浏览器任务 | "用 sitemap 帮我少走弯路地操作这个网站" |
| **opencli-sitemap-author** | 创建或更新面向浏览器 Agent 的站点 sitemap | "把刚发现的稳定流程记录到这个站点的 sitemap" |
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
| **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" |
### 工作原理
安装 `opencli-adapter-author` skill 后,你的 AI Agent 可以:
安装 `opencli-browser` skill 后,你的 AI Agent 可以:
1. **导航**到任意 URL,使用你的已登录浏览器
2. **读取**页面内容——通过结构化 DOM 快照(不是截图)
@@ -113,237 +129,79 @@ npx skills add jackwener/opencli --skill smart-search
Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自然语言描述想做的事。
**Skill 参考文档:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 浏览器操作 + 适配器编写,全流程
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 实时驱动 Chrome(导航、填表单、点击、抓取)
- [`skills/opencli-browser-sitemap/SKILL.md`](./skills/opencli-browser-sitemap/SKILL.md) — 操作浏览器任务时消费 sitemap 上下文
- [`skills/opencli-sitemap-author/SKILL.md`](./skills/opencli-sitemap-author/SKILL.md) — 创建或更新站点 sitemap 知识
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 给新站点写适配器,全流程
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — 修复已有适配器
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 浏览器自动化参考
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — 能力搜索
`browser` 可用命令包括:`open``state``click``type``select``keys``wait``get``find``extract``frames``screenshot``scroll``back``eval``network``tab list``tab new``tab select``tab close``init``verify``close`
`browser` 可用命令包括:`open``state``click``type``fill``select``keys``wait``get``find``extract``frames``screenshot``scroll``back``eval``network``tab list``tab new``tab select``tab close``init``verify``close`
`opencli browser open <url>``opencli browser tab new [url]` 都会返回 target ID。`opencli browser tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为后续未指定 target 的 `opencli browser ...` 命令的默认目标。
`opencli browser` 命令必须紧跟一个 `<session>` 位置参数。`opencli browser work open <url>``opencli browser work tab new [url]` 都会返回 target ID。`opencli browser work tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为同一 session 后续未指定 target 的默认目标。
## 核心概念
## 为新站点写适配器
### `browser`AI Agent 的浏览器控制层
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,全流程:
`opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作。
比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser open``state``click` 等命令。
### 内置适配器:稳定命令
当某个站点能力已经存在时,优先使用 `opencli hackernews top``opencli reddit hot` 这类稳定命令。这些命令是确定性的,无需浏览器——人类和 AI Agent 都可以直接使用。
### 为新站点写适配器
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,它会把 Agent 带到闭环:
1. 侦察站点,分类 patternSPA / SSR / JSONP / Token / Streaming
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
4. 字段解码 + 设计输出列
5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
### CLI 枢纽与桌面端适配器
OpenCLI 不只是网站 CLI,还可以:
- 统一代理本地二进制工具,例如 `gh``docker``obsidian`
- 通过专门适配器和 CDP 集成控制 Electron 桌面应用
## 前置要求
- **Node.js**: >= 21.0.0
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
1. **侦察**站点,分类 patternSPA / SSR / JSONP / Token / Streaming
2. **发现** endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. **定认证**——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
4. **字段解码** + 设计输出列
5. `opencli browser recon analyze <url>``opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
6. 站点知识沉到 `~/.opencli/sites/<site>/`,下次同站点直接吃缓存
## 配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试)。`--focus` 标志会设置此变量 |
| `OPENCLI_LIVE` | `false` | 设为 `1` 时 adapter 命令执行完后保留 automation 窗口不关闭(适合检查页面)。`--live` 标志会设置此变量 |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
| `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground``background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window <foreground\|background>` |
| `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` 也可以) |
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
`--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时
Browser Bridge daemon 与扩展的通信端口固定为 `localhost:19825`,不再支持通过 `OPENCLI_DAEMON_PORT` 配置自定义端口
## 更新
```bash
npm install -g @jackwener/opencli@latest
# 如果你在用打包发布的 OpenCLI skills,也一起刷新
npx skills add jackwener/opencli
```
如果你只装了部分 skill,也可以只刷新自己在用的:
```bash
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill smart-search
```
## 面向开发者
从源码安装:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
加载源码版 Browser Bridge 扩展:
1. 打开 `chrome://extensions` 并启用 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库里的 `extension/` 目录
`opencli browser *` 必须紧跟一个 `<session>` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser <session> close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`
## 内置命令
运行 `opencli list` 查看完整注册表。
| 站点 | 命令 | 模式 |
|------|------|------|
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
| **uiverse** | `code` `preview` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **baidu-scholar** | `search` | 公开 |
| **google-scholar** | `search` `cite` `profile` | 公开 |
| **gov-law** | `search` `recent` | 公开 |
| **gov-policy** | `search` `recent` | 公开 |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` | 公开 / 浏览器 |
| **wanfang** | `search` | 公开 |
| **xiaoyuzhou** | `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` `auth` | 本地凭证 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
| **bbc** | `news` | 公共 API |
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
| **ctrip** | `search` | 浏览器 |
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **paperreview** | `submit` `review` `feedback` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **jd** | `item` | 浏览器 |
| **linkedin** | `search` `timeline` | 浏览器 |
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **web** | `read` | 浏览器 |
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 |
| **yahoo-finance** | `quote` | 浏览器 |
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
| **chaoxing** | `assignments` `exams` | 浏览器 |
| **grok** | `ask` `image` | 浏览器 |
| **hf** | `top` | 公开 |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
| **linux-do** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` | 浏览器 |
| **1688** | `search` `item` `assets` `download` `store` | 浏览器 |
| **gitee** | `trending` `search` `user` | 公开 / 浏览器 |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | 浏览器 |
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
| **medium** | `feed` `search` `user` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
| **substack** | `feed` `search` `publication` | 浏览器 |
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **xianyu** | `search` `item` `chat` | 浏览器 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |
| 站点 | 命令 |
|------|------|
| **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` |
| **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-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` |
90+ 适配器**[→ 查看完整命令列表](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast``podcast-episodes``episode``download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
精选清单**[→ 查看全部 100+ 站点和命令](./docs/adapters/index.md)**(小红书 / B站 / 知乎 / Twitter / Reddit / 抖音 / 微博 / 微信读书 / 小宇宙 / 1688 / 夸克 / Spotify / 牛客 / arxiv / Chess.com / Bilibili / 等)。
### 外部 CLI 枢纽
OpenCLI 也可以作为你现有命令行工具统一入口,负责发现、自动安装和纯透传执行。
现有命令行工具统一接入 `opencli <tool> ...`
| 外部 CLI | 描述 | 示例 |
|----------|------|------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
| **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` |
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
`gh` · `docker` · `vercel` · `wrangler` · `obsidian` · `longbridge` · `lark-cli` · `ntn(notion)` · `dws(DingTalk Workspace)` · `wecom-cli(企业微信)` · `tg(tg-cli)` · `discord(discord-cli)` · `wx(wx-cli)`
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为
注册自定义本地 CLI`opencli external register <name>`;查看所有:`opencli external list`
**自动安装**:如果你运行 `opencli gh ...` 时系统中还没有 `gh`,OpenCLI 会优先尝试通过系统包管理器安装,然后自动重试命令
**注册自定义本地 CLI**
```bash
opencli register mycli
```
### 桌面应用适配器
每个桌面适配器都有自己详细的文档说明,包括命令参考、启动配置与使用示例:
| 应用 | 描述 | 文档 |
|-----|-------------|-----|
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
**桌面应用适配器**Electron,通过 CDP):Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)
## 下载支持
@@ -380,6 +238,7 @@ brew install yt-dlp
# 下载小红书笔记中的图片/视频
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
opencli rednote download "https://www.rednote.com/search_result/<id>?xsec_token=..." --output ./rednote
# 下载B站视频(需要 yt-dlp
opencli bilibili download BV1xxx --output ./bilibili
@@ -434,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)。
## 插件
@@ -477,23 +315,10 @@ 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) 了解如何创建自己的插件。
## 致 AI Agent(开发者指南)
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来:
- 侦察站点,选定 patternSPA / SSR / JSONP / Token / Streaming
-`opencli browser network``eval`、interceptor 等找到目标 endpoint
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
- 字段解码、设计 columns、`opencli browser init` 生成骨架
- 交付前用 `opencli browser verify <site>/<name>` 验证
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
## 常见问题排查
- **"Extension not connected" 报错**
@@ -502,8 +327,8 @@ opencli plugin uninstall my-tool # 卸载
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
- **返回空数据,或者报错 "Unauthorized"**
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
- 确保 Node.js 版本 `>= 21``node:util``styleText` 需要 Node 21+
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
- OpenCLI 要求 **Node.js >= 20**。先执行 `node --version`,如果版本过低先升级,再重试命令
- **Daemon 问题**
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
@@ -511,7 +336,7 @@ opencli plugin uninstall my-tool # 卸载
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
[![Star History Chart](https://star-history.dera.page/svg?repos=jackwener/opencli&type=Date)](https://star-history.dera.page/#jackwener/opencli&Date)
+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();
}
+107 -13
View File
@@ -5,19 +5,22 @@
"": {
"name": "@jackwener/opencli",
"dependencies": {
"chalk": "^5.3.0",
"@mozilla/readability": "^0.6.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"js-yaml": "^4.3.1",
"turndown": "^7.2.2",
"undici": "^7.24.6",
"turndown-plugin-gfm": "^1.0.2",
"undici": "7.29.0",
"ws": "^8.18.0",
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/jsdom": "^27.0.0",
"@types/node": "^25.5.2",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.5.13",
"jsdom": "^29.0.2",
"tsx": "^4.19.3",
"typescript": "^6.0.2",
"vitepress": "^1.6.4",
@@ -25,6 +28,9 @@
},
},
},
"overrides": {
"postcss": "^8.5.10",
},
"packages": {
"@algolia/abtesting": ["@algolia/abtesting@1.15.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA=="],
@@ -62,6 +68,14 @@
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -70,8 +84,22 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
"@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.0", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.8", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
@@ -136,6 +164,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.74", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
@@ -144,6 +174,8 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
@@ -260,6 +292,8 @@
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/jsdom": ["@types/jsdom@27.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
@@ -268,7 +302,9 @@
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
@@ -336,14 +372,14 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
@@ -358,8 +394,14 @@
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -370,7 +412,7 @@
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
@@ -394,13 +436,19 @@
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -426,12 +474,16 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
@@ -446,12 +498,14 @@
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
@@ -460,18 +514,22 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
@@ -480,6 +538,8 @@
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
@@ -504,6 +564,8 @@
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -514,6 +576,14 @@
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.4.10", "", { "dependencies": { "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog=="],
"tldts-core": ["tldts-core@7.4.10", "", {}, "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw=="],
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -522,11 +592,13 @@
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
"turndown-plugin-gfm": ["turndown-plugin-gfm@1.0.2", "", {}, "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
@@ -550,22 +622,44 @@
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" } }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"jsdom/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
"@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"jsdom/parse5/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
+9
View File
@@ -0,0 +1,9 @@
# Use Cases
Real-world examples of how people use OpenCLI.
## Contributing
Want to share your use case? Submit a PR that adds a new `.md` file to this directory.
Each file is one use case — describe what you wanted to do, which commands you used, and the result.
+56
View File
@@ -0,0 +1,56 @@
# Daily RL research monitor
A 30-second morning routine that surfaces what changed overnight in reinforcement-learning and large-model research, without opening a browser.
## What I wanted
Before reading anything, decide where to spend my 20 minutes of paper time:
- which `cs.LG` and `cs.AI` papers landed in the last 24 hours
- which OpenReview submissions at recent venues (NeurIPS 2025 right now, ICLR 2024 / NeurIPS 2024 as historical reference) carry titles and primary areas relevant to my work
- which papers the Hugging Face Daily Papers community is talking about today
Skim signals, then drill in. The point is to filter, not to read everything.
## Commands
```bash
# 1. arxiv recent in the two relevant categories (newest 30 each)
opencli arxiv recent cs.LG --limit 30 -f json > /tmp/lg.json
opencli arxiv recent cs.AI --limit 30 -f json > /tmp/ai.json
# 2. NeurIPS 2025 oral track from OpenReview (use natural-language
# venue text; the EMPTY_RESULT error helpfully echoes valid syntax
# if a venue is not yet open)
opencli openreview venue "NeurIPS 2025 oral" --limit 50 -f json > /tmp/neurips.json
# 3. Hugging Face Daily Papers (community-upvoted research)
opencli hf top --period daily --limit 20 -f json > /tmp/hf.json
```
That is the entire collection step. The four files together are the whole signal surface for one morning.
## What I do with the output
Pipe the four JSON files into a one-shot LLM digest with a fixed prompt:
```
Here are four JSON arrays of papers from the last 24 hours.
Group them into:
1. Direct hits on RLHF / preference optimization / reasoning RL.
2. Adjacent (offline RL, world models, agent benchmarks).
3. Notable infra (training, evaluation, data).
For each, give me title + arxiv id + one-sentence why-it-matters.
Skip everything that is review / survey / position paper.
```
The LLM compresses ~120 entries into a 10-line shortlist in seconds. I then open whichever 2 to 3 papers actually clear the bar.
## Why CLI beats the browser version
- Four pages of clicking and scrolling collapses into four `opencli` calls.
- The output is structured JSON, so the digest prompt can reason about it deterministically. No copy-paste, no "I missed paper 14".
- Works inside any agent loop. A scheduled task can run the four commands, push them to an LLM, and message the digest somewhere. No browser kept open.
- Zero token cost on the OpenCLI side. The only paid step is the digest call at the end.
The arxiv adapter's `recent <category>` (added in #1289) is the lever here. Without it I would have to fall back to the arxiv listings page, which means scraping HTML in agent code instead of consuming a structured listing.
+57
View File
@@ -0,0 +1,57 @@
# Find a paper's implementation and follow-up work
Given a single paper title or arxiv id, walk three sources in one chain to find the canonical reference, follow-up citations, and any community-fine-tuned models or Spaces that already build on it.
## What I wanted
I read a paper abstract, decide it is interesting, and want to answer three questions before deciding to actually re-read the paper or reproduce it:
1. Has anyone already implemented or fine-tuned on top of it (Hugging Face)?
2. Who has cited or extended it (dblp / OpenReview)?
3. What is the canonical bibliographic record (dblp key for citation, full arxiv metadata for reading)?
Doing this in a browser means three tabs and two minutes of context-switching. The point is to compress that into one shell pipeline.
## Commands
Worked example: "Direct Preference Optimization" (DPO).
```bash
# 1. Canonical arxiv record (full abstract, authors, pdf url, categories).
# Note: arxiv free-text search ranks by recency, so the original DPO
# paper does not always come back first. When the canonical id is
# already known, hit `arxiv paper <id>` directly.
opencli arxiv search "Direct Preference Optimization" --limit 5 -f json
opencli arxiv paper 2305.18290 -f json
# 2. dblp bibliography record + co-authors + venue history
opencli dblp search "Direct Preference Optimization" --limit 5 -f json
# 3. Community uptake on Hugging Face: trending Daily Papers that mention DPO
opencli hf top --period monthly --limit 50 -f json | jq '.[] | select(.title | test("DPO|preference"; "i"))'
# 4. Conference review record (if posted to OpenReview)
opencli openreview search "Direct Preference Optimization" --limit 5 -f json
```
Three of the four are public-strategy adapters, no browser session needed. The OpenReview call also lands without auth for public venues.
## What I do with the output
For DPO the chain produces:
- arxiv record: paper id `2305.18290`, full abstract, pdf link.
- dblp record: canonical key `conf/nips/RafailovSMMEF23`, NeurIPS 2023, co-author list (useful to find related work by same lab).
- HF Daily Papers (last 30 days): every paper whose title mentions DPO or preference. Each one is a candidate "follow-up work I should know about".
- OpenReview: the original submission's review thread, if posted (lets me see what reviewers actually pushed back on, which is more useful than the published abstract).
I dump all four JSON outputs into a single LLM call with the prompt: *"Build a one-paragraph 'state of the field' summary for this paper as of today. Cite each follow-up by arxiv id."* That gives me a research-debt brief in 30 seconds.
## Why this is worth a CLI chain
- Each adapter alone is just "search a website". The value is the chain. Four `opencli` calls feed into one LLM call. No browser, no copy-paste.
- Output is identifier-rich (arxiv id, dblp key, venue id, HF paper id). I can re-feed any of those into the next call, e.g. once I find a follow-up arxiv id from HF Daily Papers I run `opencli arxiv paper <new-id>` immediately.
- Survives use inside an agent loop. Same chain runs unattended for a batch of 20 papers from a reading list.
- Zero token cost for the discovery half. Only the final summary step pays for inference.
Without `opencli dblp search` (added in #1299) and `opencli openreview search` (added in #1294), this whole pipeline used to require either web scraping in agent code or paying for a research-paper API. Both adapters being public-strategy means they slot in cleanly.
+75
View File
@@ -0,0 +1,75 @@
# Track a conference's accepted papers and reviews from the terminal
Once an OpenReview venue opens its decisions (or releases reviews publicly during the discussion phase), I want a one-shot way to pull the full venue listing and dive into individual review threads, without clicking through 200+ submission pages.
## What I wanted
For each major venue I follow (ICLR, NeurIPS, ICML), the same three things every time decisions are visible:
1. The full list of accepted papers at the venue, with titles and forum ids.
2. For any paper I flagged interesting from the list: the full review thread, including reviewer scores, rebuttals, and the AC's decision rationale.
3. A way to pipe both into LLM-driven shortlisting ("which of these 100 oral papers actually intersect with my research direction").
The OpenReview UI is fine for one paper at a time, but unusable for batch reasoning across the whole acceptance list.
## Commands
Worked example: ICLR 2024 oral track, then drill into one paper's reviews using a real forum id.
```bash
# 1. Full list of papers at a venue (natural-language venue text;
# if the venue is not yet open OpenReview returns EMPTY_RESULT
# with a help line listing valid forms)
opencli openreview venue "ICLR 2024 oral" --limit 200 -f json > /tmp/iclr-2024.json
# 2. Pick a forum id from the listing, fetch the full review thread.
# Example: "Proving Test Set Contamination in Black-Box Language Models"
opencli openreview reviews KS8mIvetg2 -f json > /tmp/reviews.json
# 3. Single paper metadata if needed
opencli openreview paper KS8mIvetg2 -f json
```
`venue` returns each entry with a forum id you can hand straight back into `reviews` and `paper`. No id lookup gymnastics. `reviews` returns the full thread as a JSON array: a `PAPER` row with the abstract, then one `REVIEW` row per reviewer (with `rating`, `confidence`, summary, weaknesses, questions), followed by author rebuttals and the AC's decision rationale.
## What I do with the output
Two distinct workflows depending on the phase of the venue:
### Phase A: filtering the acceptance list
After `venue` returns 200 entries, dump the JSON into an LLM with the prompt:
```
Here is the full acceptance list at <venue>. Filter to papers that intersect
with my research interests:
- reinforcement learning from preference / reward feedback
- reasoning training (process reward, RLVR, RLHF variants)
- long-horizon agent benchmarks
For each match: title + forum_id + one-sentence why-it-matters.
```
This collapses 200 papers to a 10-paper shortlist in seconds. The forum ids are the keys I will use in Phase B.
### Phase B: depth-reading the shortlist
For each shortlisted forum id, run `opencli openreview reviews <forum-id>` and feed the JSON to an LLM with the prompt:
```
Summarize the review thread:
- reviewer scores
- the strongest critique
- whether the rebuttal addressed it
- final decision and AC rationale
```
This is faster than reading three reviews + rebuttal + meta-review per paper. For 10 papers this turns 60 minutes of OpenReview clicking into 10 minutes of summary reading, then I open the actual reviews only for papers where the summary flagged something worth knowing.
## Why this beats opening OpenReview
- One `venue` call replaces scrolling a paginated UI for 200+ papers.
- `reviews` returns the entire thread as JSON, so an LLM can reason over the whole review-rebuttal-decision arc at once. The web view forces you to scroll three reviews + N rebuttals + meta separately.
- Forum ids returned from `venue` are stable and reusable across calls. Easy to keep a personal reading list as `forum-ids.txt` and run `for id in $(cat forum-ids.txt); do opencli openreview reviews $id; done`.
- The whole loop is public-strategy. No login required for venues with public reviewing.
`opencli openreview` (added in #1294) is the lever. Before this adapter existed, the same workflow needed either OpenReview's Python client or HTML scraping inside agent code. Both have higher friction than `opencli openreview reviews <forum-id>` returning structured JSON in one shot.
+27667 -581
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
async function has12306SessionCookie(page) {
const cookies = await page.getCookies({ url: 'https://kyfw.12306.cn' });
return cookies.some(c => c.name === 'tk' && c.value);
}
async function verify12306Identity(page) {
if (!await has12306SessionCookie(page)) {
throw new AuthRequiredError('12306.cn', '12306 tk auth cookie missing');
}
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await page.wait(2);
const probe = await page.evaluate(`(async () => {
try {
const r = await fetch('/otn/index/initMy12306Api', {
method: 'POST',
credentials: 'include',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (/login\\.html/.test(r.url)) {
return { kind: 'auth', detail: '12306 initMy12306Api redirected to login' };
}
const t = await r.text();
let d = null;
try { d = JSON.parse(t); } catch {}
if (!d || d.status === false || /未登录|登录超时|NotLogin/i.test(t)) {
return { kind: 'auth', detail: '12306 initMy12306Api returned NotLogin' };
}
const userName = d.data?.user_name || d.data?.userName || d.user_name || '';
if (!userName) {
return { kind: 'auth', detail: '12306 initMy12306Api 200 but no user_name surface' };
}
return { ok: true, user_name: String(userName) };
} catch (e) {
return { kind: 'exception', detail: String(e && e.message || e) };
}
})()`);
if (probe?.kind === 'auth') throw new AuthRequiredError('12306.cn', probe.detail);
if (probe?.kind === 'exception') throw new CommandExecutionError(`12306 whoami failed: ${probe.detail}`);
if (!probe?.ok) throw new CommandExecutionError(`Unexpected 12306 probe: ${JSON.stringify(probe)}`);
return { user_name: probe.user_name };
}
registerSiteAuthCommands({
site: '12306',
domain: '12306.cn',
loginUrl: 'https://kyfw.12306.cn/otn/resources/login.html',
columns: ['user_name'],
quickCheck: has12306SessionCookie,
verify: verify12306Identity,
poll: async (page) => {
if (!await has12306SessionCookie(page)) {
throw new AuthRequiredError('12306.cn', 'Waiting for 12306 tk auth cookie');
}
return verify12306Identity(page);
},
});
+73
View File
@@ -0,0 +1,73 @@
/**
* 12306 account summary for the logged-in user.
*
* Returns non-sensitive identity fields plus masked email / mobile.
* Use `--include-sensitive` to surface unmasked values from 12306's
* own response (12306 already masks the ID number server-side; this
* adapter never decodes that mask).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskEmail, maskMobile, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
const ACCOUNT_INFO_URL = 'https://kyfw.12306.cn/otn/modifyUser/initQueryUserInfoApi';
cli({
site: '12306',
name: 'me',
access: 'read',
description: 'Show the logged-in 12306 account summary. Sensitive fields (real name, email, mobile, birth date) are masked by default; pass --include-sensitive to opt in.',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real name / email / mobile / birth date. The 12306 ID-number mask is server-side and never decoded.' },
],
columns: ['username', 'real_name', 'email', 'mobile', 'birth_date', 'sex', 'country', 'user_type', 'member', 'active'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 me');
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const json = requireEvaluateObject(await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(ACCOUNT_INFO_URL)}, { credentials: 'include' });
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'account info');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for account info`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 account info returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
}
if (json?.status !== true || !json?.data?.userDTO) {
throw new CommandExecutionError('12306 account info payload missing userDTO');
}
const dto = json.data.userDTO;
const loginDto = dto.loginUserDTO || {};
const username = loginDto.user_name || loginDto.name || '';
const realName = loginDto.real_name || loginDto.realname || '';
const include = kwargs['include-sensitive'] === true;
return [{
username,
real_name: include ? realName : maskChineseName(realName),
email: include ? (dto.email || '') : maskEmail(dto.email || ''),
mobile: include ? (dto.mobile_no || '') : maskMobile(dto.mobile_no || ''),
birth_date: include ? (dto.born_date || '') : (dto.born_date || '').slice(0, 4),
sex: dto.sex_code === 'M' ? '男' : (dto.sex_code === 'F' ? '女' : ''),
country: dto.country_code || '',
user_type: json.data.userTypeName || '',
member: dto.flag_member === '1',
active: dto.is_active === '1',
}];
},
});
+96
View File
@@ -0,0 +1,96 @@
/**
* 12306 in-progress orders for the logged-in user.
*
* Returns orders that have not yet been ridden / refunded / completed
* (the `noComplete` slice). Order history covering completed and
* refunded tickets uses a separate endpoint that requires extra
* referer / page-state handshakes and is left for a follow-up so this
* command can ship reliably.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
const NO_COMPLETE_URL = 'https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete';
cli({
site: '12306',
name: 'orders',
access: 'read',
description: 'List in-progress 12306 orders (not yet ridden, refunded, or completed) for the logged-in user',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked passenger names in order rows. Masked by default.' },
],
columns: ['order_id', 'order_date', 'train_code', 'from_station', 'to_station', 'departure', 'passengers', 'status', 'amount'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 orders');
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const include = kwargs['include-sensitive'] === true;
const json = requireEvaluateObject(await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(NO_COMPLETE_URL)}, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_json_att=', credentials: 'include',
});
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'orders');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for queryMyOrderNoComplete`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 orders returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
}
if (json?.status !== true) {
throw new CommandExecutionError('12306 queryMyOrderNoComplete returned a failure status');
}
let orders;
if (Array.isArray(json?.data?.orderDBList)) {
orders = json.data.orderDBList;
} else if (Array.isArray(json?.data?.orderDTODataList)) {
orders = json.data.orderDTODataList;
} else if (Array.isArray(json?.data?.orders)) {
orders = json.data.orders;
} else if (Array.isArray(json?.data)) {
orders = json.data;
} else {
throw new CommandExecutionError('12306 queryMyOrderNoComplete payload missing order list array');
}
if (orders.length === 0) {
throw new EmptyResultError('No in-progress 12306 orders on this account');
}
return orders.map((o) => {
const tickets = Array.isArray(o.tickets) ? o.tickets : [];
const passengerNames = tickets
.map((t) => t.passenger_name || '')
.filter(Boolean)
.map((name) => include ? name : maskChineseName(name))
.join(', ');
return {
order_id: o.sequence_no || o.order_id || o.sequenceNo || '',
order_date: o.order_date || '',
train_code: o.train_code_page || o.station_train_code || o.train_code || '',
from_station: o.from_station_name_page || o.from_station_name || '',
to_station: o.to_station_name_page || o.to_station_name || '',
departure: o.start_train_date_page || o.start_train_date || '',
passengers: passengerNames,
status: o.ticket_status_name || o.order_status_name || o.statusName || '',
amount: o.ticket_total_price_page || o.ticket_total_price || '',
};
});
},
});
+80
View File
@@ -0,0 +1,80 @@
/**
* 12306 saved passenger list for the logged-in user.
*
* 12306 already masks ID numbers (`xxxx***********xxx`) and mobile
* numbers (`138****xxxx`) server-side. This adapter further masks the
* passenger's Chinese real name and birth date by default; pass
* `--include-sensitive` to surface the unmasked-by-12306 fields.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { 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;
cli({
site: '12306',
name: 'passengers',
access: 'read',
description: 'List the logged-in user\'s saved 12306 passengers. Sensitive fields are masked by default; pass --include-sensitive to opt in.',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: `Max passengers to return (1-${MAX_PAGE_SIZE})` },
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real names and birth dates. The 12306 ID-number / mobile masks are server-side and never decoded.' },
],
columns: ['name', 'sex', 'born_year', 'id_type', 'id_no', 'mobile', 'passenger_type', 'country'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 passengers');
const limit = normalizeLimit(kwargs.limit, 20, MAX_PAGE_SIZE);
const include = kwargs['include-sensitive'] === true;
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const json = requireEvaluateObject(await page.evaluate(`async () => {
const body = "pageIndex=1&pageSize=${MAX_PAGE_SIZE}";
const r = await fetch(${JSON.stringify(PASSENGER_QUERY_URL)}, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body, credentials: 'include',
});
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'passengers');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for passengers/query`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 passengers returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
}
if (json?.status !== true || !Array.isArray(json?.data?.datas)) {
throw new CommandExecutionError('12306 passengers payload missing data.datas array');
}
const datas = json.data.datas;
if (datas.length === 0) {
throw new EmptyResultError('No saved passengers on this 12306 account');
}
return datas.slice(0, limit).map((p) => ({
name: include ? (p.passenger_name || '') : maskChineseName(p.passenger_name || ''),
sex: p.sex_name || '',
born_year: (p.born_date || '').slice(0, 4),
id_type: p.passenger_id_type_name || '',
id_no: p.passenger_id_no || '',
mobile: p.mobile_no || '',
passenger_type: p.passenger_type_name || '',
country: p.country_code || '',
}));
},
});
+166
View File
@@ -0,0 +1,166 @@
/**
* 12306 ticket price lookup for a single train + segment.
*
* Cascades three anonymous API calls:
* 1. /otn/leftTicket/init: mint session cookies
* 2. /otn/czxx/queryByTrainNo: resolve from/to station_no within the
* train route (price endpoint addresses stops by station_no, not
* telecode)
* 3. /otn/leftTicket/queryTicketPrice: ticket prices keyed by seat
* letter (M=一等座, O=二等座, A9=商务座, A1=硬座, A3=硬卧,
* A4=软卧, F=动卧, P=特等座, WZ=无座, etc.)
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
const SEAT_TYPES_RE = /^[A-Z0-9]{1,32}$/;
const SEAT_LETTERS = {
'A9': '商务座',
'P': '特等座',
'M': '一等座',
'O': '二等座',
'A1': '硬座',
'A3': '硬卧',
'A4': '软卧',
'F': '动卧',
'WZ': '无座',
};
async function queryStopsForPrice(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
const resp = await fetchImpl(url, {
headers: {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
},
});
if (!resp.ok) throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
let json;
try {
json = await resp.json();
} catch {
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
}
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
throw new CommandExecutionError('12306 queryByTrainNo returned an unexpected payload shape');
}
return json.data.data;
}
function pickStationNos(stops, fromCode, toCode, fromName, toName) {
const matches = (s, code, name) => (s.station_name && name && s.station_name === name);
const fromStop = stops.find((s) => matches(s, fromCode, fromName));
const toStop = stops.find((s) => matches(s, toCode, toName));
if (!fromStop) throw new CommandExecutionError(`Train does not stop at ${fromName}`);
if (!toStop) throw new CommandExecutionError(`Train does not stop at ${toName}`);
return { fromNo: fromStop.station_no, toNo: toStop.station_no };
}
async function queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/leftTicket/queryTicketPrice?train_no=${trainNo}&from_station_no=${fromNo}&to_station_no=${toNo}&seat_types=${seatTypes}&train_date=${date}`;
const resp = await fetchImpl(url, {
headers: {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
},
});
if (!resp.ok) throw new CommandExecutionError(`12306 queryTicketPrice returned HTTP ${resp.status}`);
let json;
try {
json = await resp.json();
} catch {
throw new CommandExecutionError('12306 queryTicketPrice returned non-JSON body');
}
if (json?.status !== true || !json?.data) {
throw new CommandExecutionError('12306 queryTicketPrice returned an unexpected payload shape');
}
return json.data;
}
function parsePriceData(priceData) {
const rows = [];
for (const [letter, value] of Object.entries(priceData)) {
if (letter === 'train_no' || letter === 'OT') continue;
if (typeof value !== 'string' || !value) continue;
// 12306 doubles up some prices as bare numerics ("9": "21580"), which
// mirror their letter sibling ("A9": "¥2158.0") in cents/no-decimal
// form. Skip the bare numeric letter codes to avoid duplicates.
if (/^\d+$/.test(letter)) continue;
if (!/^[A-Z]/.test(letter)) continue;
const numeric = value.replace(/^¥/, '');
if (!/^[\d.]+$/.test(numeric)) continue;
rows.push({
seat_code: letter,
seat_name: SEAT_LETTERS[letter] || letter,
price: numeric,
currency: 'CNY',
});
}
rows.sort((a, b) => Number(b.price) - Number(a.price));
return rows;
}
cli({
site: '12306',
name: 'price',
access: 'read',
description: 'Look up 12306 ticket prices by seat class for one train on a given date and segment (anonymous, no login required)',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L)' },
{ name: 'from', required: true, help: 'Origin station (Chinese name, telecode, or pinyin) - must be a stop of this train' },
{ name: 'to', required: true, help: 'Destination station - must be a stop of this train' },
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
{ name: 'seat-types', default: 'OM9PA1A3A4FWZ', help: 'Seat-type letters to query (default covers the common classes). Examples: OM9 (二等/一等/商务), A1A3A4 (硬座/硬卧/软卧).' },
],
columns: ['seat_code', 'seat_name', 'price', 'currency'],
func: async (kwargs) => {
const trainNo = String(kwargs['train-no'] ?? '').trim();
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
if (!TRAIN_NO_RE.test(trainNo)) {
throw new ArgumentError(
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
);
}
const fromArg = String(kwargs.from ?? '').trim();
const toArg = String(kwargs.to ?? '').trim();
if (!fromArg) throw new ArgumentError('--from station must not be empty');
if (!toArg) throw new ArgumentError('--to station must not be empty');
const date = validateDate(kwargs.date);
const seatTypes = String(kwargs['seat-types'] ?? '').trim() || 'OM9PA1A3A4FWZ';
if (!SEAT_TYPES_RE.test(seatTypes)) {
throw new ArgumentError('--seat-types must contain only 12306 seat letters/digits (A-Z, 0-9)');
}
const stations = await fetchStationBundle();
const fromStation = resolveStation(stations, fromArg);
const toStation = resolveStation(stations, toArg);
if (fromStation.code === toStation.code) {
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
}
const cookieHeader = await mintSession();
const stops = await queryStopsForPrice(cookieHeader, trainNo, fromStation.code, toStation.code, date);
const { fromNo, toNo } = pickStationNos(stops, fromStation.code, toStation.code, fromStation.name, toStation.name);
const priceData = await queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date);
const rows = parsePriceData(priceData);
if (rows.length === 0) {
throw new EmptyResultError(
`No prices returned for train_no=${trainNo} ${fromStation.name} -> ${toStation.name} on ${date}`,
'Try a different seat-types letter set, or check that this train operates on the date.',
);
}
return rows;
},
});
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS, TRAIN_NO_RE };
+52
View File
@@ -0,0 +1,52 @@
/**
* 12306 station search.
*
* Queries the public `station_name.js` bundle and filters by the user's
* keyword. Anonymous, no session needed.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, normalizeLimit } from './utils.js';
const MAX_LIMIT = 50;
cli({
site: '12306',
name: 'stations',
access: 'read',
description: 'Search 12306 (China Railway) stations by Chinese name, telecode, or pinyin keyword',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'keyword', positional: true, required: true, help: 'Chinese substring (上海), telecode (AOH), or pinyin (shanghai)' },
{ name: 'limit', type: 'int', default: 20, help: `Maximum results (1-${MAX_LIMIT})` },
],
columns: ['name', 'code', 'pinyin', 'abbr', 'city'],
func: async (kwargs) => {
const keyword = String(kwargs.keyword ?? '').trim();
if (!keyword) throw new ArgumentError('keyword must not be empty');
const limit = normalizeLimit(kwargs.limit, 20, MAX_LIMIT);
const stations = await fetchStationBundle();
const lower = keyword.toLowerCase();
const matches = stations.filter((s) =>
s.name.includes(keyword)
|| s.code === keyword.toUpperCase()
|| s.pinyin.includes(lower)
|| s.abbr.includes(lower)
|| s.short.includes(lower)
|| s.city.includes(keyword),
);
if (matches.length === 0) {
throw new EmptyResultError(`No 12306 stations match "${keyword}"`);
}
return matches.slice(0, limit).map((s) => ({
name: s.name,
code: s.code,
pinyin: s.pinyin,
abbr: s.abbr,
city: s.city,
}));
},
});
+91
View File
@@ -0,0 +1,91 @@
/**
* 12306 train stop details - list every station a train calls at,
* with arrival / departure / stopover time.
*
* Requires the internal `train_no` returned by `12306 trains`
* (`24000000G10L`), not the public train code (`G1`).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
const resp = await fetchImpl(url, {
headers: {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
},
});
if (!resp.ok) {
throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
}
let json;
try {
json = await resp.json();
} catch {
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
}
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
throw new CommandExecutionError(`12306 queryByTrainNo returned an unexpected payload shape`);
}
return json.data.data;
}
cli({
site: '12306',
name: 'train',
access: 'read',
description: 'List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)' },
{ name: 'from', required: true, help: 'Origin station for the segment: Chinese name, telecode, or pinyin' },
{ name: 'to', required: true, help: 'Destination station for the segment' },
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
],
columns: ['station_no', 'station_name', 'arrive_time', 'start_time', 'stopover_time'],
func: async (kwargs) => {
const trainNo = String(kwargs['train-no'] ?? '').trim();
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
if (!TRAIN_NO_RE.test(trainNo)) {
throw new ArgumentError(
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
);
}
const fromArg = String(kwargs.from ?? '').trim();
const toArg = String(kwargs.to ?? '').trim();
if (!fromArg) throw new ArgumentError('--from station must not be empty');
if (!toArg) throw new ArgumentError('--to station must not be empty');
const date = validateDate(kwargs.date);
const stations = await fetchStationBundle();
const fromStation = resolveStation(stations, fromArg);
const toStation = resolveStation(stations, toArg);
if (fromStation.code === toStation.code) {
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
}
const cookieHeader = await mintSession();
const stops = await queryStops(cookieHeader, trainNo, fromStation.code, toStation.code, date);
if (stops.length === 0) {
throw new EmptyResultError(`No stops returned for train_no=${trainNo} on ${date}`);
}
return stops.map((s) => ({
station_no: s.station_no || '',
station_name: s.station_name || '',
arrive_time: s.arrive_time === '----' ? '' : (s.arrive_time || ''),
start_time: s.start_time === '----' ? '' : (s.start_time || ''),
stopover_time: s.stopover_time === '----' ? '' : (s.stopover_time || ''),
}));
},
});
export const __test__ = { queryStops, TRAIN_NO_RE };
+154
View File
@@ -0,0 +1,154 @@
/**
* 12306 train availability between two stations on a given date.
*
* Flow:
* 1. Fetch the station bundle (cached implicitly via per-process module state).
* 2. Mint anonymous session cookies via /otn/leftTicket/init.
* 3. Query /otn/leftTicket/queryG; if 12306 returns
* `{c_url: "leftTicket/queryX"}` (endpoint rotation), retry once
* against the suggested name.
* 4. Parse the `|`-separated train records.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchStationBundle, mintSession, 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 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] : '';
}
catch {
return '';
}
}
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) {
const headers = {
'User-Agent': UA,
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
'Cookie': cookieHeader,
};
const queryParams = `leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromCode}&leftTicketDTO.to_station=${toCode}&purpose_codes=ADULT`;
let lastResponseText = '';
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, redirect: 'manual' });
if (!resp.ok) {
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();
lastResponseText = text;
let json;
try { json = JSON.parse(text); } catch {
throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
}
if (json?.c_url && typeof json.c_url === 'string') {
const rotated = await parseRotationEndpoint(resp, endpoint, text);
if (rotated && !tried.has(rotated)) {
queue.unshift(rotated);
}
continue;
}
if (Array.isArray(json?.data?.result)) {
return json.data.result;
}
throw new CommandExecutionError(`12306 ${endpoint} returned an unexpected payload shape`);
}
throw new CommandExecutionError(`12306 rejected every known query endpoint name (${QUERY_ENDPOINTS.join(', ')}); the wire protocol may have changed. Last body: ${lastResponseText.slice(0, 200)}`);
}
cli({
site: '12306',
name: 'trains',
access: 'read',
description: 'List trains between two 12306 stations on a given date (anonymous, no login required)',
domain: 'kyfw.12306.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'from', positional: true, required: true, help: 'Origin station: Chinese name (北京), telecode (BJP), or pinyin (beijing)' },
{ name: 'to', positional: true, required: true, help: 'Destination station: same forms as <from>' },
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
{ name: 'limit', type: 'int', default: 50, help: `Maximum rows (1-${MAX_LIMIT})` },
],
columns: [
'code', 'from_station', 'to_station', 'start_time', 'arrive_time',
'duration', 'available', 'business_seat', 'first_seat', 'second_seat',
'soft_sleeper', 'hard_sleeper', 'hard_seat', 'no_seat', 'train_no',
],
func: async (kwargs) => {
const fromArg = String(kwargs.from ?? '').trim();
const toArg = String(kwargs.to ?? '').trim();
if (!fromArg) throw new ArgumentError('<from> station must not be empty');
if (!toArg) throw new ArgumentError('<to> station must not be empty');
const date = validateDate(kwargs.date);
const limit = normalizeLimit(kwargs.limit, 50, MAX_LIMIT);
const stations = await fetchStationBundle();
const fromStation = resolveStation(stations, fromArg);
const toStation = resolveStation(stations, toArg);
if (fromStation.code === toStation.code) {
throw new ArgumentError(`<from> and <to> must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
}
const stationByCode = new Map(stations.map((s) => [s.code, s]));
const cookieHeader = await mintSession();
const rawRows = await queryLeftTickets(cookieHeader, fromStation.code, toStation.code, date);
const decoded = rawRows
.map((line) => parseTrainRecord(decodeURIComponent(line.replace(/%0A/g, '')), stationByCode))
.filter(Boolean);
if (decoded.length === 0) {
throw new EmptyResultError(
`No trains found from ${fromStation.name} to ${toStation.name} on ${date}`,
'Try a different date or check whether the route is operated by 12306.',
);
}
return decoded.slice(0, limit);
},
});
export const __test__ = { extractQueryEndpoint, queryLeftTickets };
+284
View File
@@ -0,0 +1,284 @@
/**
* 12306 (中国铁路) shared helpers.
*
* - Station lookup: parses the public `station_name.js` bundle into
* structured records.
* - Cookie session: 12306's query endpoints reject anonymous requests
* with `HTTP 302 -> error.html`, so callers must hit `/otn/leftTicket/init`
* first to mint the JSESSIONID / route / BIGipServerotn cookies.
* - Query endpoint rotation: 12306 rotates the train-query endpoint
* name (queryO / queryZ / queryA / queryG / ...) every few weeks.
* When the wrong name is hit, the server returns
* `{"c_url":"leftTicket/queryG","c_name":"CLeftTicketUrl","status":false}`
* pointing to the current correct name; retry once with that name.
*/
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
const STATION_BUNDLE_URL = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
const INIT_URL = 'https://kyfw.12306.cn/otn/leftTicket/init';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const STATION_CODE_RE = /^[A-Z]{2,4}$/;
/**
* Parse the `station_name.js` bundle into a station record array.
*
* Bundle format (single line, `@`-delimited records, each `|`-delimited):
* `var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||...';`
*
* Per-record fields (positional):
* [0] short pinyin alias (e.g. `bjb`)
* [1] Chinese station name (e.g. `北京北`)
* [2] telecode (3-4 uppercase letters, e.g. `VAP`) - this is the
* wire format 12306 uses for `from_station` / `to_station`.
* [3] full pinyin (e.g. `beijingbei`)
* [4] short alias (duplicate of [0] usually)
* [5] index/rank
* [6] city code
* [7] city name (e.g. `北京`)
*/
export function parseStationBundle(text) {
const match = text.match(/'([^']+)'/);
if (!match) {
throw new CommandExecutionError('Failed to parse 12306 station_name.js: source string not found');
}
const raw = match[1];
const records = raw.split('@').filter(Boolean);
const stations = [];
for (const r of records) {
const parts = r.split('|');
if (parts.length < 8 || !parts[2]) continue;
stations.push({
short: parts[0] || '',
name: parts[1] || '',
code: parts[2] || '',
pinyin: parts[3] || '',
abbr: parts[4] || '',
city: parts[7] || '',
});
}
if (stations.length === 0) {
throw new CommandExecutionError('Failed to parse 12306 station_name.js: no station records found');
}
return stations;
}
/**
* Resolve a user-supplied station identifier to a telecode.
*
* Accepts Chinese name (`上海虹桥`), telecode (`AOH`), pinyin
* (`shanghaihongqiao`), short alias (`shh`), or city name with a
* preference for the city's main station.
*/
export function resolveStation(stations, input) {
const trimmed = String(input ?? '').trim();
if (!trimmed) throw new ArgumentError('station must not be empty');
if (STATION_CODE_RE.test(trimmed)) {
const exact = stations.find((s) => s.code === trimmed);
if (exact) return exact;
throw new ArgumentError(`Unknown 12306 station telecode "${trimmed}"`);
}
const lower = trimmed.toLowerCase();
const exactName = stations.find((s) => s.name === trimmed);
if (exactName) return exactName;
const exactPinyin = stations.find((s) => s.pinyin === lower);
if (exactPinyin) return exactPinyin;
const exactAbbr = stations.find((s) => s.abbr === lower || s.short === lower);
if (exactAbbr) return exactAbbr;
throw new ArgumentError(`Unknown 12306 station "${trimmed}"`, 'Try the Chinese name (上海虹桥), the 3-4 letter telecode (AOH), or full pinyin (shanghaihongqiao).');
}
export function validateDate(value) {
if (!DATE_RE.test(String(value ?? ''))) {
throw new ArgumentError(`date must be YYYY-MM-DD, got "${value}"`);
}
const [y, m, d] = value.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
throw new ArgumentError(`date "${value}" is not a real calendar date`);
}
return value;
}
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 '';
return setCookieHeaders
.map((line) => line.split(';')[0])
.filter(Boolean)
.join('; ');
}
export async function fetchStationBundle(fetchImpl = fetch) {
const resp = await fetchImpl(STATION_BUNDLE_URL, {
headers: { 'User-Agent': UA },
});
if (!resp.ok) {
throw new CommandExecutionError(`Failed to fetch 12306 station bundle: HTTP ${resp.status}`);
}
return parseStationBundle(await resp.text());
}
/** Mint a 12306 anonymous session by hitting /otn/leftTicket/init. */
export async function mintSession(fetchImpl = fetch) {
const resp = await fetchImpl(INIT_URL, {
headers: { 'User-Agent': UA },
redirect: 'follow',
});
if (!resp.ok) {
throw new CommandExecutionError(`Failed to mint 12306 session: HTTP ${resp.status}`);
}
const setCookies = typeof resp.headers.getSetCookie === 'function'
? resp.headers.getSetCookie()
: resp.headers.raw?.()['set-cookie'] || [];
const cookieHeader = buildCookieHeader(setCookies);
if (!cookieHeader) {
throw new CommandExecutionError('12306 init returned no session cookies');
}
return cookieHeader;
}
/**
* Twelve-row train query record (LEFT_TICKET_DTO).
*
* 12306 returns each train as a `|`-separated string with ~36 fields.
* Positions used here come from the public web client; unused
* positions are documented inline so future maintainers can extend
* the row shape without re-reverse-engineering.
*/
export function parseTrainRecord(line, stationByCode) {
const f = line.split('|');
if (f.length < 33) return null;
return {
train_no: f[2] || '',
code: f[3] || '',
from_station: stationByCode.get(f[6])?.name || f[6] || '',
to_station: stationByCode.get(f[7])?.name || f[7] || '',
from_code: f[6] || '',
to_code: f[7] || '',
start_time: f[8] || '',
arrive_time: f[9] || '',
duration: f[10] || '',
available: (f[1] || '').trim() === '预订' || (f[11] || '').trim() === 'Y',
business_seat: f[32] || '',
first_seat: f[31] || '',
second_seat: f[30] || '',
soft_sleeper: f[23] || '',
hard_sleeper: f[28] || '',
hard_seat: f[29] || '',
no_seat: f[26] || '',
};
}
/**
* Mask helpers for sensitive identity fields rendered by 12306.
*
* 12306 already masks ID numbers and mobile numbers server-side
* (`xxxx***********xxx` / `138****xxxx`); these helpers handle the
* remaining fields (email, real Chinese name) so the adapter never
* leaks unmasked PII without an explicit `--include-sensitive` opt-in.
*/
export function maskEmail(value) {
const v = String(value || '').trim();
if (!v) return '';
const at = v.indexOf('@');
if (at <= 0) return v;
const local = v.slice(0, at);
const domain = v.slice(at);
if (local.length <= 2) return local[0] + '*' + domain;
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local.slice(-1) + domain;
}
export function maskMobile(value) {
const v = String(value || '').trim();
if (!v) return '';
if (/\*/.test(v)) return v;
if (v.length < 7) return v.replace(/.(?=.)/g, '*');
return v.slice(0, 3) + '*'.repeat(v.length - 7) + v.slice(-4);
}
export function maskChineseName(value) {
const v = String(value || '').trim();
if (!v) return '';
if (v.length === 1) return v;
if (v.length === 2) return v[0] + '*';
return v[0] + '*'.repeat(v.length - 2) + v.slice(-1);
}
export function unwrapEvaluateResult(value) {
if (
value
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.prototype.hasOwnProperty.call(value, 'session')
&& Object.prototype.hasOwnProperty.call(value, 'data')
) {
return value.data;
}
return value;
}
export function requireEvaluateObject(value, label) {
const payload = unwrapEvaluateResult(value);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError(`12306 ${label} returned a malformed browser payload`);
}
return payload;
}
export function isAuthLikePayload(payload) {
if (!payload || typeof payload !== 'object') return false;
const parts = [];
if (Array.isArray(payload.messages)) parts.push(...payload.messages);
if (payload.message) parts.push(payload.message);
if (payload.msg) parts.push(payload.msg);
if (payload.validateMessages && typeof payload.validateMessages === 'object') {
parts.push(...Object.values(payload.validateMessages).flat());
}
const text = parts.map((item) => String(item ?? '')).join(' ');
return /未登录|登录|请登录|身份|认证|session|Session|login/i.test(text);
}
/**
* Detect the 12306 login marker by reading `document.cookie` from the
* current adapter page. Cannot use `page.getCookies({url})` here:
* 12306 sets the auth cookie `tk` and `JSESSIONID` with `Path=/otn`,
* and CDP `Network.getCookies` with a bare URL filter excludes
* cookies whose path does not match the URL path. `document.cookie`
* returns all non-httponly cookies visible to the current page
* regardless of path, which is what we need to confirm login.
*/
export async function require12306Login(page, AuthRequiredErrorClass) {
const docCookie = unwrapEvaluateResult(await page.evaluate(`document.cookie || ''`));
const cookieStr = typeof docCookie === 'string' ? docCookie : '';
if (!/\btk=/.test(cookieStr) || !/JSESSIONID=/.test(cookieStr)) {
throw new AuthRequiredErrorClass('kyfw.12306.cn', 'Not logged into 12306. Sign in at https://kyfw.12306.cn first.');
}
}
export const __test__ = {
parseStationBundle,
resolveStation,
validateDate,
buildCookieHeader,
parseTrainRecord,
maskEmail,
maskMobile,
maskChineseName,
unwrapEvaluateResult,
requireEvaluateObject,
isAuthLikePayload,
};
+452
View File
@@ -0,0 +1,452 @@
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__, 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, 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', () => {
const bundle = "var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||@bji|北京|BJP|beijing|bj|2|0357|北京|||@aoh|上海虹桥|AOH|shanghaihongqiao|shhq|10|7600|上海|||';";
const stations = parseStationBundle(bundle);
expect(stations).toHaveLength(3);
expect(stations[1]).toEqual({
short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京',
});
});
it('skips records that lack a telecode', () => {
const bundle = "var station_names ='@xxx|||||||||@bji|北京|BJP|beijing|bj|2|0357|北京|||';";
const stations = parseStationBundle(bundle);
expect(stations).toHaveLength(1);
expect(stations[0].code).toBe('BJP');
});
it('throws CommandExecutionError when the bundle has no parseable station rows', () => {
expect(() => parseStationBundle("var station_names ='@xxx|||||||||';")).toThrow(CommandExecutionError);
});
});
describe('12306 utils - resolveStation', () => {
const stations = [
{ short: 'bjb', name: '北京北', code: 'VAP', pinyin: 'beijingbei', abbr: 'bjb', city: '北京' },
{ short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京' },
{ short: 'aoh', name: '上海虹桥', code: 'AOH', pinyin: 'shanghaihongqiao', abbr: 'shhq', city: '上海' },
];
it('matches by exact Chinese name', () => {
expect(resolveStation(stations, '上海虹桥').code).toBe('AOH');
});
it('matches by uppercase telecode', () => {
expect(resolveStation(stations, 'BJP').code).toBe('BJP');
});
it('matches by full pinyin (case-insensitive)', () => {
expect(resolveStation(stations, 'Beijing').code).toBe('BJP');
});
it('matches by short alias / abbr', () => {
expect(resolveStation(stations, 'shhq').code).toBe('AOH');
});
it('throws ArgumentError for empty input', () => {
expect(() => resolveStation(stations, ' ')).toThrow(ArgumentError);
});
it('throws ArgumentError for unknown station', () => {
expect(() => resolveStation(stations, '某不存在站')).toThrow(ArgumentError);
});
it('throws ArgumentError for telecode-shaped but unknown input', () => {
expect(() => resolveStation(stations, 'XYZ')).toThrow(ArgumentError);
});
});
describe('12306 utils - validateDate', () => {
it('accepts valid YYYY-MM-DD', () => {
expect(validateDate('2026-05-22')).toBe('2026-05-22');
});
it('throws ArgumentError on wrong format', () => {
expect(() => validateDate('2026/05/22')).toThrow(ArgumentError);
expect(() => validateDate('26-05-22')).toThrow(ArgumentError);
expect(() => validateDate('today')).toThrow(ArgumentError);
expect(() => validateDate('')).toThrow(ArgumentError);
});
it('throws ArgumentError on impossible calendar dates', () => {
expect(() => validateDate('2026-02-30')).toThrow(ArgumentError);
expect(() => validateDate('2026-13-01')).toThrow(ArgumentError);
});
});
describe('12306 utils - 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 = [
'JSESSIONID=ABC123; Path=/otn',
'BIGipServerotn=xxx.yyy; Path=/',
'route=zzz; Expires=Sat, 01 Jan 2027 00:00:00 GMT',
];
expect(buildCookieHeader(headers)).toBe('JSESSIONID=ABC123; BIGipServerotn=xxx.yyy; route=zzz');
});
it('returns empty string for empty input', () => {
expect(buildCookieHeader([])).toBe('');
expect(buildCookieHeader(undefined)).toBe('');
});
});
describe('12306 utils - parseTrainRecord', () => {
const stationByCode = new Map([
['VNP', { name: '北京南', code: 'VNP' }],
['AOH', { name: '上海虹桥', code: 'AOH' }],
]);
it('extracts the canonical train fields from a wire record', () => {
// 33 `|`-separated fields, with positions used by parseTrainRecord populated.
const fields = new Array(36).fill('');
fields[0] = 'SECRET_TOKEN';
fields[1] = '预订';
fields[2] = '240000G54700';
fields[3] = 'G547';
fields[6] = 'VNP';
fields[7] = 'AOH';
fields[8] = '06:18';
fields[9] = '12:11';
fields[10] = '05:53';
fields[11] = 'Y';
fields[23] = ''; // soft sleeper
fields[26] = '无'; // no seat
fields[28] = ''; // hard sleeper
fields[29] = ''; // hard seat
fields[30] = '有'; // second seat
fields[31] = '有'; // first seat
fields[32] = '无'; // business seat
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(row).toEqual({
train_no: '240000G54700',
code: 'G547',
from_station: '北京南',
to_station: '上海虹桥',
from_code: 'VNP',
to_code: 'AOH',
start_time: '06:18',
arrive_time: '12:11',
duration: '05:53',
available: true,
business_seat: '无',
first_seat: '有',
second_seat: '有',
soft_sleeper: '',
hard_sleeper: '',
hard_seat: '',
no_seat: '无',
});
});
it('does not expose the booking-handshake secret token', () => {
const fields = new Array(36).fill('');
fields[0] = 'SECRET_TOKEN_DO_NOT_LEAK';
fields[2] = 't_no'; fields[3] = 'X1'; fields[6] = 'VNP'; fields[7] = 'AOH';
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(Object.values(row)).not.toContain('SECRET_TOKEN_DO_NOT_LEAK');
expect('secret' in row).toBe(false);
});
it('falls back to the telecode when the station bundle has no name', () => {
const fields = new Array(36).fill('');
fields[2] = 'X'; fields[3] = 'X'; fields[6] = 'ZZZ'; fields[7] = 'YYY';
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(row.from_station).toBe('ZZZ');
expect(row.to_station).toBe('YYY');
});
it('returns null for short records', () => {
expect(parseTrainRecord('a|b|c', stationByCode)).toBeNull();
});
});
describe('12306 utils - mask helpers', () => {
it('masks the local-part of an email', () => {
expect(maskEmail('hello@example.com')).toBe('h***o@example.com');
expect(maskEmail('ab@x.cn')).toBe('a*@x.cn');
expect(maskEmail('a@x.cn')).toBe('a*@x.cn');
expect(maskEmail('')).toBe('');
expect(maskEmail('not-an-email')).toBe('not-an-email');
});
it('masks Chinese mobile numbers while preserving 12306-side masks', () => {
expect(maskMobile('13800001234')).toBe('138****1234');
expect(maskMobile('138****1234')).toBe('138****1234');
expect(maskMobile('')).toBe('');
expect(maskMobile('123')).toBe('**3');
});
it('masks Chinese real names', () => {
expect(maskChineseName('张三')).toBe('张*');
expect(maskChineseName('李四明')).toBe('李*明');
expect(maskChineseName('欧阳锋')).toBe('欧*锋');
expect(maskChineseName('张')).toBe('张');
expect(maskChineseName('')).toBe('');
});
});
describe('12306 price - parsePriceData', () => {
it('returns seat rows sorted by descending price and drops dup numeric codes', () => {
const data = {
train_no: '24000000G10L',
'OT': [],
'A9': '¥2158.0',
'9': '21580',
'P': '¥1163.0',
'M': '¥1035.0',
'O': '¥626.0',
'WZ': '¥626.0',
'INVALID': 'not-a-price',
};
const rows = parsePriceData(data);
const codes = rows.map((r) => r.seat_code);
expect(codes).not.toContain('9');
expect(codes).not.toContain('OT');
expect(codes).not.toContain('train_no');
expect(codes).not.toContain('INVALID');
expect(codes).toEqual(['A9', 'P', 'M', 'O', 'WZ']);
expect(rows[0]).toEqual({ seat_code: 'A9', seat_name: '商务座', price: '2158.0', currency: 'CNY' });
expect(rows[4]).toEqual({ seat_code: 'WZ', seat_name: '无座', price: '626.0', currency: 'CNY' });
});
it('keeps unknown letter codes with the letter as the name', () => {
const data = { 'A9': '¥100.0', 'ZZ': '¥50.0' };
const rows = parsePriceData(data);
const zz = rows.find((r) => r.seat_code === 'ZZ');
expect(zz?.seat_name).toBe('ZZ');
});
});
describe('12306 train_no validation regex', () => {
// 12306 train_no values returned by /otn/leftTicket/query sometimes contain
// lowercase letters (e.g. "5l000G1970A3" for G1970 上海虹桥 -> 宝鸡南).
// Both `12306 price` and `12306 train` must accept the raw value emitted
// by `12306 trains`, otherwise the two adapters drift apart and downstream
// calls fail with ARGUMENT before ever hitting 12306.
for (const [label, re] of [['price', PRICE_TRAIN_NO_RE], ['train', TRAIN_TRAIN_NO_RE]]) {
describe(label, () => {
it('accepts an all-uppercase train_no', () => {
expect(re.test('24000000G10L')).toBe(true);
});
it('accepts a train_no with lowercase letters (real 12306 payload)', () => {
expect(re.test('5l000G1970A3')).toBe(true);
});
it('rejects public codes like G1970', () => {
expect(re.test('G1970')).toBe(false);
});
it('rejects values with disallowed characters', () => {
expect(re.test('5l000-G1970A3')).toBe(false);
});
});
}
});
describe('12306 public API typed boundaries', () => {
const nonJsonFetch = async () => ({
ok: true,
json: async () => {
throw new SyntaxError('Unexpected token <');
},
});
it('wraps non-JSON train stop bodies as CommandExecutionError', async () => {
await expect(queryStops('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('wraps non-JSON price helper bodies as CommandExecutionError', async () => {
await expect(queryStopsForPrice('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(queryPrice('cookie=1', '24000000G10L', '01', '02', 'OM9', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('12306 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');
expect(unwrapEvaluateResult({ status: true, data: { value: 1 } })).toEqual({ status: true, data: { value: 1 } });
expect(requireEvaluateObject({ session: 's1', data: { status: true } }, 'test')).toEqual({ status: true });
expect(() => requireEvaluateObject({ session: 's1', data: null }, 'test')).toThrow(CommandExecutionError);
});
it('classifies 12306 login-like API envelopes as auth failures', () => {
expect(isAuthLikePayload({ status: false, messages: ['用户未登录'] })).toBe(true);
expect(isAuthLikePayload({ status: false, validateMessages: { global: ['请登录后再试'] } })).toBe(true);
expect(isAuthLikePayload({ status: false, messages: ['系统繁忙'] })).toBe(false);
});
it('masks passenger names in orders by default and supports explicit sensitive opt-in', async () => {
const command = getRegistry().get('12306/orders');
const makePage = () => ({
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return { session: 'browser', data: 'JSESSIONID=abc; tk=def' };
return {
session: 'browser',
data: {
status: true,
data: {
orderDBList: [{
sequence_no: 'E123',
order_date: '2026-05-18 10:00',
train_code_page: 'G1',
from_station_name_page: '北京南',
to_station_name_page: '上海虹桥',
start_train_date_page: '2026-05-22 07:00',
ticket_status_name: '未出行',
ticket_total_price_page: '626.0',
tickets: [{ passenger_name: '张三' }, { passenger_name: '李四明' }],
}],
},
},
};
},
});
await expect(command.func(makePage(), {})).resolves.toMatchObject([
{ order_id: 'E123', passengers: '张*, 李*明' },
]);
await expect(command.func(makePage(), { 'include-sensitive': true })).resolves.toMatchObject([
{ order_id: 'E123', passengers: '张三, 李四明' },
]);
});
it('maps login-like order payloads to AuthRequiredError instead of parser drift', async () => {
const command = getRegistry().get('12306/orders');
const page = {
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
return { status: false, messages: ['用户未登录'] };
},
};
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
});
it('treats missing order list shape as parser drift but known empty arrays as empty result', async () => {
const command = getRegistry().get('12306/orders');
const makePage = (payload) => ({
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
return payload;
},
});
await expect(command.func(makePage({ status: true, data: {} }), {}))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ status: true, data: { orderDBList: [] } }), {}))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
+86 -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);
@@ -183,6 +263,7 @@ export async function extractAssetsForInput(page, input) {
cli({
site: '1688',
name: 'assets',
access: 'read',
description: '列出 1688 商品页可提取的图片/视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
@@ -201,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);
},
});
+1
View File
@@ -42,6 +42,7 @@ function toDownloadItems(offerId, assets) {
cli({
site: '1688',
name: 'download',
access: 'read',
description: '批量下载 1688 商品页可提取的图片和视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -156,6 +156,7 @@ async function readItemPayload(page, itemUrl) {
cli({
site: '1688',
name: 'item',
access: 'read',
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+2 -1
View File
@@ -275,6 +275,7 @@ async function collectSearchRows(page, query, limit) {
cli({
site: '1688',
name: 'search',
access: 'read',
description: '1688 商品搜索(结果候选、卖家链接、价格/MOQ/销量文本)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
@@ -293,7 +294,7 @@ cli({
help: `结果数量上限(默认 ${SEARCH_LIMIT_DEFAULT},最大 ${SEARCH_LIMIT_MAX}`,
},
],
columns: ['rank', 'title', 'price_text', 'moq_text', 'seller_name', 'location'],
columns: ['rank', 'offer_id', 'title', 'item_url', 'price_text', 'moq_text', 'seller_name', 'member_id', 'location'],
func: async (page, kwargs) => {
const query = String(kwargs.query ?? '');
const limit = parseSearchLimit(kwargs.limit);
+1
View File
@@ -167,6 +167,7 @@ function hasAnyEvidence(storePayload, contactPayload, seed) {
cli({
site: '1688',
name: 'store',
access: 'read',
description: '1688 店铺/供应商公开信息(联系方式、主营、入驻年限、公开服务信号)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+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' });
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* 一亩三分地 精华帖 — Discuz guide=digest view.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'digest',
access: 'read',
description: '一亩三分地 精华帖(编辑推荐 / 加精)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
func: async (args) => {
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=digest`);
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
lastReplyTime: t.lastReplyTime,
url: t.url,
}));
},
});
+51
View File
@@ -0,0 +1,51 @@
/**
* 一亩三分地 版块帖子列表 — /bbs/forum-<fid>-<page>.html
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { fetchHtml, parseThreadList, parseThreadRows, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'forum',
access: 'read',
description: '浏览一亩三分地某个版块的帖子列表(按 fid)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'fid', required: true, positional: true, help: '版块 ID,例如 145(海外面经)、198(海外职位内推)、27(研究生申请)' },
{ name: 'page', type: 'int', default: 1, help: '页码(默认 1' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'kind', 'title', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
func: async (args) => {
const fid = String(args.fid || '').trim();
if (!/^\d+$/.test(fid)) {
throw new ArgumentError('fid must be a numeric forum id', 'e.g. 145 for 海外面经');
}
const pageNum = Number(args.page ?? 1);
if (!Number.isInteger(pageNum) || pageNum <= 0) {
throw new ArgumentError('page must be a positive integer');
}
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum-${fid}-${pageNum}.html`);
const rows = parseThreadRows(html);
if (rows.length === 0) {
// Forum may be sub-category-only — surface gracefully as empty with hint.
return [];
}
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
kind: t.kind === 'stickthread' ? '置顶' : '普通',
title: t.title,
author: t.author,
replies: t.replies,
views: t.views,
lastReplyTime: t.lastReplyTime,
url: t.url,
}));
},
});
+44
View File
@@ -0,0 +1,44 @@
/**
* 一亩三分地 所有版块清单 — parsed from /bbs/forum.php
*
* Each forum card has:
* <a href="forum-<fid>-1.html" ... class="... overflow-hidden whitespace-nowrap hidden desktop:block">版块名</a>
* and an adjacent description element. We dedupe by fid and return name + url.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, decodeEntities, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'forums',
access: 'read',
description: '一亩三分地 所有版块(fid + 版块名)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'filter', type: 'string', default: '', help: '按版块名关键字过滤(子串匹配,中英文)' },
],
columns: ['fid', 'name', 'url'],
func: async (args) => {
const html = await fetchHtml(`${BASE}/forum.php`);
const seen = new Map();
const re = /<a href="forum-(\d+)-1\.html"[^>]*class="[^"]*overflow-hidden[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/g;
let m;
while ((m = re.exec(html))) {
const fid = m[1];
let name = decodeEntities(m[2].trim());
// Some subforum labels are wrapped in brackets — unwrap for display parity.
name = name.replace(/^\[(.+)\]$/, '$1').trim();
if (!name || seen.has(fid)) continue;
seen.set(fid, name);
}
const filter = String(args.filter || '').toLowerCase().trim();
const out = [];
for (const [fid, name] of seen) {
if (filter && !name.toLowerCase().includes(filter)) continue;
out.push({ fid, name, url: `${BASE}/forum-${fid}-1.html` });
}
return out;
},
});
+35
View File
@@ -0,0 +1,35 @@
/**
* 一亩三分地 热门帖子 — Discuz guide=hot view.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'hot',
access: 'read',
description: '一亩三分地 今日热门帖子(按热度排序,约 50 条)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
func: async (args) => {
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=hot`);
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
lastReplyTime: t.lastReplyTime,
url: t.url,
}));
},
});
+35
View File
@@ -0,0 +1,35 @@
/**
* 一亩三分地 最新帖子 — Discuz guide=new view.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'latest',
access: 'read',
description: '一亩三分地 最新发帖(按发帖时间倒序)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'postTime', 'url'],
func: async (args) => {
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=new`);
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
postTime: t.postTime,
url: t.url,
}));
},
});
+64
View File
@@ -0,0 +1,64 @@
/**
* 一亩三分地 我的通知 — 坛友互动 / 点评 / @我 等
*
* /bbs/home.php?mod=space&do=notice&view=interactive needs login cookie.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, decodeEntities, getCookie, stripHtml, truncate, normalizePositiveInteger, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'notifications',
access: 'read',
description: '一亩三分地 站内通知(互动 / 点评 / @ 我;需要登录)',
domain: 'www.1point3acres.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'kind', type: 'string', default: 'mypost',
help: '通知类型:mypost(我的帖子) / interactive(互动) / system(系统) / app(应用)' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数' },
],
columns: ['index', 'from', 'summary', 'time', 'threadUrl'],
func: async (page, args) => {
const kind = String(args.kind || 'mypost').trim();
const cookie = await getCookie(page);
const url = `${BASE}/home.php?mod=space&do=notice&view=${encodeURIComponent(kind)}`;
const html = await fetchHtml(url, { cookie, headers: { Referer: `${BASE}/` } });
if (/<title>提示信息/.test(html) && /请登录/.test(html)) {
throw new AuthRequiredError('www.1point3acres.com', '请先登录一亩三分地');
}
// "No notifications" is a real empty result, not a synthetic data row.
if (/暂时没有提醒内容/.test(html)) {
throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');
}
const rows = [];
const limit = normalizePositiveInteger(args.limit, 20, 'limit');
// Pattern 1: standard Discuz <dl class="cl">…</dl> block per notice.
const dlRe = /<dl class="[^"]*cl[^"]*"[^>]*>([\s\S]*?)<\/dl>/g;
let m;
let i = 0;
while ((m = dlRe.exec(html)) && rows.length < limit) {
const block = m[1];
const from = decodeEntities((block.match(/<dt>([\s\S]*?)<\/dt>/) || [, ''])[1])
.replace(/<[^>]+>/g, '').trim();
const summaryRaw = (block.match(/<dd class="ntc_body">([\s\S]*?)<\/dd>/) ||
block.match(/<dd>([\s\S]*?)<\/dd>/) || [, ''])[1];
const summary = truncate(stripHtml(summaryRaw), 200);
const time = ((block.match(/<dd class="[^"]*xg1[^"]*"[^>]*>([\s\S]*?)<\/dd>/) || [, ''])[1] || '')
.replace(/<[^>]+>/g, '').trim();
const linkMatch = summaryRaw.match(/href="([^"]*thread-\d+[^"]*)"/);
const threadUrl = linkMatch ? (linkMatch[1].startsWith('http') ? linkMatch[1] : `${BASE}/${linkMatch[1]}`) : '';
i += 1;
if (!from && !summary) continue;
rows.push({ index: i, from, summary, time, threadUrl });
}
return rows;
},
});
+71
View File
@@ -0,0 +1,71 @@
/**
* 一亩三分地 站内搜索 — /bbs/search.php?mod=forum
*
* Guests get a "请登录" alert page, so this command needs the live browser
* session's cookie. Discuz routes search through a 302 redirect to
* search.php?searchid=<ID>. Node fetch follows redirects automatically as
* long as we pass the session cookie along.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, parseSearchList, assertNotGuestAlert, getCookie, decodeEntities, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'search',
access: 'read',
description: '一亩三分地 站内关键字搜索(需要登录)',
domain: 'www.1point3acres.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'query', required: true, positional: true, help: '搜索关键字' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
{ name: 'fid', type: 'string', default: '', help: '限定版块 ID(可选)' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'postTime', 'url'],
func: async (page, args) => {
const query = String(args.query || '').trim();
if (!query) throw new ArgumentError('query 不能为空');
const limit = normalizeLimit(args.limit, 20, 50);
const fid = String(args.fid || '').trim();
const cookie = await getCookie(page);
const qs = new URLSearchParams({
mod: 'forum',
srchtxt: query,
searchsubmit: 'yes',
...(fid ? { srchfid: fid } : {}),
});
const url = `${BASE}/search.php?${qs.toString()}`;
// Node fetch with the session cookie — Discuz's 302 to search.php?searchid=…
// is followed by default.
const html = await fetchHtml(url, {
cookie,
headers: { Referer: `${BASE}/` },
});
assertNotGuestAlert(html);
const items = parseSearchList(html);
if (items.length === 0) {
const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\/p>/);
if (hint) {
throw new EmptyResultError('1point3acres search', decodeEntities(hint[1].trim()));
}
throw new EmptyResultError('1point3acres search', `No results for "${query}"`);
}
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
postTime: t.postTime,
url: t.url,
}));
},
});
+117
View File
@@ -0,0 +1,117 @@
/**
* 一亩三分地 帖子详情 — /bbs/thread-<tid>-<page>-1.html
*
* Returns one row per post on the requested page. First row (floor=1) is the
* main post; the rest are replies. Columns are shaped so `--limit 1` gives
* just the main post, and larger limits walk down the thread.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, decodeEntities, stripHtml, truncate, normalizePositiveInteger, BASE } from './utils.js';
function extract(html, regex, group = 1) {
const m = html.match(regex);
return m ? m[group] : '';
}
cli({
site: '1point3acres',
name: 'thread',
access: 'read',
description: '一亩三分地 帖子详情 + 楼层(主楼 + 回复)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'tid', required: true, positional: true, help: '帖子 ID(数字,见 `hot`/`latest` 返回的 tid' },
{ name: 'page', type: 'int', default: 1, help: '楼层分页页码(默认 1' },
{ name: 'limit', type: 'int', default: 10, help: '返回楼层条数(默认 10,含主楼)' },
{ name: 'contentLimit', type: 'int', default: 400, help: '每楼正文截断长度(默认 400 字符,最少 50)' },
],
columns: ['floor', 'pid', 'author', 'postTime', 'content', 'url'],
func: async (args) => {
const tid = String(args.tid || '').trim();
if (!/^\d+$/.test(tid)) {
throw new ArgumentError('tid must be a numeric thread id');
}
const page = normalizePositiveInteger(args.page, 1, 'page');
const limit = normalizePositiveInteger(args.limit, 10, 'limit');
const contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });
const url = `${BASE}/thread-${tid}-${page}-1.html`;
const html = await fetchHtml(url);
// Sanity: real thread page will contain postlist + at least one post div.
if (!/id="postlist"/.test(html) && !/id="post_\d+"/.test(html)) {
throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在或被删除`);
}
// Split posts: each post block is bounded by <div id="post_<PID>">…</div> next post or postlist end.
// NOTE: intermediate objects intentionally use postId/body/offset (not pid/html/start) to
// avoid being mistaken for row-shaped objects by the silent-column-drop audit.
const postBlocks = [];
const re = /<div id="post_(\d+)"[^>]*>/g;
const offsets = [];
let m;
while ((m = re.exec(html))) offsets.push({ postId: m[1], offset: m.index });
for (let i = 0; i < offsets.length; i++) {
const segStart = offsets[i].offset;
const segEnd = i + 1 < offsets.length ? offsets[i + 1].offset : html.length;
postBlocks.push({ postId: offsets[i].postId, body: html.slice(segStart, segEnd) });
}
const rows = [];
for (let i = 0; i < postBlocks.length && rows.length < limit; i++) {
const { postId: pid, body: block } = postBlocks[i];
// Discuz authi block holds the author link + post time metadata.
const authiMatch = block.match(/<div class="authi"[\s\S]*?<\/div>/);
const authiBlock = authiMatch ? authiMatch[0] : '';
const authorCandidates = [
/<a [^>]*class="[^"]*\bxi2\b[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/,
/<a [^>]*href="space-uid-\d+\.html"[^>]*>\s*([^<]+?)\s*<\/a>/,
/<a [^>]*class="[^"]*\bxw1\b[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/,
];
let author = '';
for (const re of authorCandidates) {
const v = decodeEntities(extract(authiBlock || block, re));
if (v && !/匿名卡|变色卡|关贴卡/.test(v)) { author = v; break; }
}
// Time: prefer <span title="YYYY-MM-DD HH:MM:SS"> (per-post, precise).
// <meta itemprop="datePublished"> is the *thread* publish time on this site — avoid.
const postTime = extract(authiBlock, /<span title="([^"]+)">/) ||
extract(block, /id="authorposton\d+"[^>]*>\s*<span title="([^"]+)">/) ||
extract(block, /id="authorposton\d+"[^>]*>\s*([^<]+?)\s*</) ||
extract(block, /<meta itemprop="datePublished" content="([^"]+)"/);
// Floor: first post on page 1 is the 楼主, subsequent posts carry <em>N#</em>.
const floorEm = extract(block, /<em>(\d+)<\/em>\s*#?\s*<\/a>/) ||
extract(block, /id="postnum\d+"[^>]*>\s*<em>(\d+)<\/em>/);
const isMainPost = page === 1 && i === 0;
const floor = floorEm ? Number(floorEm) : (isMainPost ? 1 : (page - 1) * 10 + i + 1);
const contentMatch = block.match(/id="postmessage_\d+"[^>]*>([\s\S]*?)<\/td>/);
const content = truncate(stripHtml(contentMatch ? contentMatch[1] : ''), contentLimit);
rows.push({
floor,
pid,
author,
postTime: postTime.trim(),
content,
url: `${BASE}/forum.php?mod=redirect&goto=findpost&ptid=${tid}&pid=${pid}`,
});
}
// Attach the thread title + forum name as a leading synthetic row only when rows exist
// and only for page 1, so agents get the title without needing a separate call.
if (page === 1 && rows.length > 0) {
const title = decodeEntities(
extract(html, /<span id="thread_subject">([^<]+)<\/span>/).trim() ||
extract(html, /<title>([^<]+?)\s*[-|]/).trim()
);
rows[0].content = title ? `${title}\n${rows[0].content}` : rows[0].content;
}
if (!rows.length) {
throw new EmptyResultError('1point3acres thread', `帖子 ${tid}${page} 页没有可读取楼层`);
}
return rows;
},
});
+77
View File
@@ -0,0 +1,77 @@
/**
* 一亩三分地 用户资料 — /bbs/space-uid-<uid>.html or /bbs/space-username-<name>.html
*
* Guest-visible fields: username, uid, user group, register/last-access times,
* post/thread/digest counts, credits, rice (大米 — site currency), profile URL.
* Users can be queried by numeric uid or by username (both routes are public).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, decodeEntities, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'user',
access: 'read',
description: '一亩三分地 用户空间(用户组 / 积分 / 大米 / 帖子数 等)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'who', required: true, positional: true, help: '用户名或 uid(纯数字按 uid 查,否则按用户名)' },
],
columns: [
'uid', 'username', 'group', 'credits', 'rice',
'posts', 'threads', 'digests', 'registerTime', 'lastAccess', 'profileUrl',
],
func: async (args) => {
const who = String(args.who || '').trim();
if (!who) throw new ArgumentError('who 不能为空', '传用户名或数字 uid');
const url = /^\d+$/.test(who)
? `${BASE}/space-uid-${who}.html`
: `${BASE}/space-username-${encodeURIComponent(who)}.html`;
const html = await fetchHtml(url);
if (/<title>提示信息/.test(html) && /(没有找到|不存在)/.test(html)) {
throw new EmptyResultError('1point3acres user', `用户 "${who}" 不存在`);
}
const pick = (re) => {
const m = html.match(re);
return m ? decodeEntities(m[1].trim()) : '';
};
// <li>KEY: VAL</li> — tolerant of optional <span>, colons fullwidth/半角, 颗/根/粒 suffixes.
const pickLi = (label) => {
const re = new RegExp(`<li>\\s*${label}[:\\s]*(?:<[^>]+>)?\\s*([^<]+?)\\s*(?:<|$)`);
const m = html.match(re);
return m ? decodeEntities(m[1].trim()) : '';
};
const username =
pick(/<p class="mtm[^"]*"[^>]*>\s*<a [^>]*>([^<]+?)<\/a>/) ||
pick(/<title>([^<]+?)的个人资料/);
const uid = pick(/uid=(\d+)/) || pick(/space-uid-(\d+)\.html/);
const group = pickLi('用户组');
const credits = pickLi('积分');
const rice = pickLi('大米');
const posts = pickLi('帖子数');
const threads = pickLi('主题数');
const digests = pickLi('精华数');
const registerTime = pickLi('注册时间');
const lastAccess = pickLi('最后访问');
return [{
uid,
username,
group,
credits,
rice,
posts,
threads,
digests,
registerTime,
lastAccess,
profileUrl: uid ? `${BASE}/space-uid-${uid}.html` : url,
}];
},
});
+247
View File
@@ -0,0 +1,247 @@
/**
* Shared helpers for 一亩三分地 (1point3acres.com) adapters.
*
* Site is a Discuz!X PHP BBS that serves GBK-encoded HTML.
* - Thread listings: /bbs/forum.php?mod=guide&view={hot|new|digest|newthread}
* - Forum: /bbs/forum-<fid>-<page>.html
* - Thread detail: /bbs/thread-<tid>-<page>-1.html
* - User profile: /bbs/space-uid-<uid>.html or /bbs/space-username-<name>.html
* - Search: /bbs/search.php?mod=forum (COOKIE — guests get an alert page)
*/
import { AuthRequiredError, ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const BASE = 'https://www.1point3acres.com/bbs';
/**
* Validate `limit` per typed-fail-fast convention (no silent clamp).
* Throws ArgumentError on non-positive / non-integer / out-of-range input.
*/
export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {
const limit = normalizePositiveInteger(value, defaultValue, label);
if (limit > maxValue) {
throw new ArgumentError(`${label} must be <= ${maxValue}`);
}
return limit;
}
/** Validate a positive integer argument without silently flooring/clamping. */
export function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {
const raw = value ?? defaultValue;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError(`${label} must be a positive integer`);
}
if (limit < min) {
throw new ArgumentError(`${label} must be >= ${min}`);
}
return limit;
}
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0 Safari/537.36';
/** Fetch a GBK-encoded Discuz page and return decoded UTF-8 HTML. */
export async function fetchHtml(url, { headers = {}, cookie = '' } = {}) {
let res;
try {
res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
...(cookie ? { Cookie: cookie } : {}),
...headers,
},
redirect: 'follow',
});
} catch (error) {
throw new CommandExecutionError(`1point3acres request failed: ${error?.message || error}`);
}
if (!res.ok) {
throw new CommandExecutionError(`1point3acres request failed: HTTP ${res.status} ${res.statusText} from ${url}`);
}
const buf = await res.arrayBuffer();
return new TextDecoder('gbk').decode(buf);
}
/** Pull cookie string from the live browser session for this domain.
* Discuz auth cookies (4Oaf_61d6_*, session) are HttpOnly and set on the
* root domain `.1point3acres.com`, so we need `getCookies` (not document.cookie)
* AND we need to query both host + root domain and merge.
*/
export async function getCookie(page) {
if (!page) return '';
const seen = new Map();
if (typeof page.getCookies === 'function') {
for (const opts of [{ domain: 'www.1point3acres.com' }, { domain: '.1point3acres.com' }]) {
try {
const cookies = await page.getCookies(opts);
for (const c of cookies || []) {
if (!seen.has(c.name)) seen.set(c.name, c.value);
}
} catch { /* try next */ }
}
}
if (seen.size > 0) {
return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');
}
try {
const result = await page.evaluate('document.cookie');
return typeof result === 'string' ? result : '';
} catch {
return '';
}
}
/** Detect the "you are a guest" alert page that Discuz returns for protected actions. */
export function assertNotGuestAlert(html, domain = 'www.1point3acres.com') {
if (/<title>提示信息 \| 一亩三分地<\/title>/.test(html) && /无法进行此操作/.test(html)) {
throw new AuthRequiredError(domain, '需要登录一亩三分地后再使用该命令');
}
}
const ENTITY_MAP = {
'&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>',
'&quot;': '"', '&#39;': "'", '&apos;': "'",
};
/** Decode HTML entities (numeric + common named). */
export function decodeEntities(s) {
if (!s) return '';
return s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, m => ENTITY_MAP[m] || m);
}
/** Strip HTML tags and collapse whitespace, returning plain text. */
export function stripHtml(html) {
if (!html) return '';
return decodeEntities(
String(html)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr)>/gi, '\n')
.replace(/<[^>]+>/g, '')
).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
/** Truncate text to n characters with ellipsis. */
export function truncate(s, n = 300) {
if (!s) return '';
return s.length > n ? s.slice(0, n) + '…' : s;
}
/** Extract all <tbody id="normalthread_*"> blocks from a forum/guide page. */
export function parseThreadRows(html) {
const rows = [];
const re = /<tbody id="(normalthread|stickthread)_(\d+)"[^>]*>([\s\S]*?)<\/tbody>/g;
let m;
while ((m = re.exec(html))) {
const [, kind, tid, inner] = m;
rows.push({ kind, tid, inner });
}
return rows;
}
/** Parse a single Discuz thread row (inner HTML of the tbody). */
export function parseThreadRow({ kind, tid, inner }) {
const titleMatches = [...inner.matchAll(/<a [^>]*class="[^"]*\bxst\b[^"]*"[^>]*>([^<]+)<\/a>/g)];
const title = titleMatches.length
? decodeEntities(titleMatches[titleMatches.length - 1][1].trim())
: '';
const forumMatch = inner.match(/<a href="forum-(\d+)-1\.html"[^>]*target="_blank"[^>]*>([^<]+)<\/a>/);
const fid = forumMatch ? forumMatch[1] : '';
const forumName = forumMatch ? decodeEntities(forumMatch[2].trim()) : '';
// <td class="by"> blocks; first with <cite> = author, last with <cite> = last reply
const byBlocks = [...inner.matchAll(/<td class="by"[^>]*>([\s\S]*?)<\/td>/g)].map(m => m[1]);
const readCite = (block) => {
const m = block.match(/<cite[^>]*>([\s\S]*?)<\/cite>/);
if (!m) return '';
return decodeEntities(m[1].replace(/<[^>]+>/g, '').trim());
};
const readTime = (block) => {
const titleM = block.match(/<span [^>]*title="([^"]+)"[^>]*>/);
if (titleM) return titleM[1].trim();
const plainA = block.match(/<em>[\s\S]*?<a [^>]*>\s*([^<]+?)\s*<\/a>/);
if (plainA) return decodeEntities(plainA[1].trim());
const plainSpan = block.match(/<em>[\s\S]*?<span[^>]*>\s*([^<]+?)\s*<\/span>/);
if (plainSpan) return decodeEntities(plainSpan[1].trim());
const bare = block.match(/<em>\s*([^<]+?)\s*<\/em>/);
return bare ? decodeEntities(bare[1].trim()) : '';
};
let authorBlock = '';
let lastBlock = '';
for (const b of byBlocks) {
if (/<cite/.test(b)) {
if (!authorBlock) authorBlock = b;
lastBlock = b;
}
}
const author = authorBlock ? readCite(authorBlock) : '';
const postTime = authorBlock ? readTime(authorBlock) : '';
const lastReplyUser = lastBlock && lastBlock !== authorBlock ? readCite(lastBlock) : '';
const lastReplyTime = lastBlock && lastBlock !== authorBlock ? readTime(lastBlock) : '';
const numMatch = inner.match(/<td class="num"[^>]*>\s*<a[^>]*class="xi2"[^>]*>(\d+)<\/a>(?:\s*<em>(\d+)<\/em>)?/);
const replies = numMatch ? Number(numMatch[1]) : 0;
const views = numMatch && numMatch[2] ? Number(numMatch[2]) : 0;
return {
tid,
kind,
title,
author,
forum: forumName,
fid,
replies,
views,
postTime,
lastReplyUser,
lastReplyTime,
url: `${BASE}/thread-${tid}-1-1.html`,
};
}
/** Quick one-shot listing parser used by hot/latest/digest/forum. */
export function parseThreadList(html) {
return parseThreadRows(html).map(parseThreadRow).filter(t => t.title);
}
/**
* Parse Discuz search results page (different HTML shape than forum listings).
* Each hit is <li class="pbw" id="TID"> containing h3 > a[href*="tid=TID"],
* <p class="xg1">N 个回复 - M 次查看</p>, and a time/author/forum <p>.
*/
export function parseSearchList(html) {
const items = [];
const re = /<li class="pbw" id="(\d+)">([\s\S]*?)<\/li>/g;
let m;
while ((m = re.exec(html))) {
const [, tid, inner] = m;
const titleMatch = inner.match(/<h3[^>]*>\s*<a [^>]*>([\s\S]*?)<\/a>/);
const titleRaw = titleMatch ? titleMatch[1] : '';
const title = decodeEntities(titleRaw.replace(/<[^>]+>/g, '')).trim();
if (!title) continue;
const statsMatch = inner.match(/<p class="xg1">\s*([\d,]+)\s*个回复\s*-\s*([\d,]+)\s*次查看\s*<\/p>/);
const replies = statsMatch ? Number(statsMatch[1].replace(/,/g, '')) : 0;
const views = statsMatch ? Number(statsMatch[2].replace(/,/g, '')) : 0;
const metaMatch = inner.match(/<p>\s*<span>([^<]+)<\/span>[\s\S]*?<a [^>]*space-uid-\d+[^>]*>([^<]+?)<\/a>[\s\S]*?<a [^>]*href="forum-(\d+)-[^"]*"[^>]*>([^<]+?)<\/a>/);
const postTime = metaMatch ? decodeEntities(metaMatch[1].trim()) : '';
const author = metaMatch ? decodeEntities(metaMatch[2].trim()) : '';
const fid = metaMatch ? metaMatch[3] : '';
const forumName = metaMatch ? decodeEntities(metaMatch[4].trim()) : '';
items.push({
tid, title, author, forum: forumName, fid,
replies, views, postTime,
// Search pages don't show lastReplyTime separately — surface postTime instead.
lastReplyUser: '', lastReplyTime: postTime,
url: `${BASE}/thread-${tid}-1-1.html`,
});
}
return items;
}
export { UA };
+7 -3
View File
@@ -13,6 +13,7 @@ function parseArticleId(input) {
cli({
site: '36kr',
name: 'article',
access: 'read',
description: '获取36氪文章正文内容',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
@@ -51,12 +52,15 @@ cli({
if (!data?.title) {
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
}
if (!data.body) {
throw new CliError('PARSE_ERROR', 'Article body not found', '36kr page loaded but no article body paragraphs were extracted');
}
return [
{ field: 'title', value: data.title },
{ field: 'author', value: data.author || '-' },
{ field: 'date', value: data.date || '-' },
{ field: 'author', value: data.author || '' },
{ field: 'date', value: data.date || '' },
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
{ field: 'body', value: data.body || '-' },
{ field: 'body', value: data.body || '' },
];
},
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import './article.js';
function makePage(evaluateResult) {
return {
installInterceptor: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(evaluateResult),
};
}
describe('36kr article', () => {
it('emits empty-string for missing optional author / date instead of a sentinel', async () => {
const command = getRegistry().get('36kr/article');
expect(command?.func).toBeDefined();
const page = makePage({ title: 'Real Title', author: '', date: '', body: 'Real article body' });
const rows = await command.func(page, { id: '1234567' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.title).toBe('Real Title');
expect(byField.author).toBe('');
expect(byField.date).toBe('');
expect(byField.body).toBe('Real article body');
expect(byField.url).toBe('https://36kr.com/p/1234567');
});
it('throws CliError NOT_FOUND when the page exposes no title', async () => {
const command = getRegistry().get('36kr/article');
const page = makePage({ title: '', author: 'x', date: 'y', body: 'z' });
await expect(command.func(page, { id: '1234567' })).rejects.toBeInstanceOf(CliError);
});
it('throws CliError PARSE_ERROR when the page exposes title but no body', async () => {
const command = getRegistry().get('36kr/article');
const page = makePage({ title: 'Real Title', author: 'x', date: 'y', body: '' });
await expect(command.func(page, { id: '1234567' })).rejects.toMatchObject({ code: 'PARSE_ERROR' });
});
it('throws CliError INVALID_ARGUMENT when no numeric id can be parsed', async () => {
const command = getRegistry().get('36kr/article');
const page = makePage({});
await expect(command.func(page, { id: 'not-a-url' })).rejects.toBeInstanceOf(CliError);
});
});
+1
View File
@@ -26,6 +26,7 @@ function buildHotListUrl(listType, date = new Date()) {
cli({
site: '36kr',
name: 'hot',
access: 'read',
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -5,6 +5,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: '36kr',
name: 'news',
access: 'read',
description: 'Latest tech/startup news from 36kr (36氪)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -8,6 +8,7 @@ import { CliError } from '@jackwener/opencli/errors';
cli({
site: '36kr',
name: 'search',
access: 'read',
description: '搜索36氪文章',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -13,6 +13,7 @@ import { JOBS_ORIGIN, requirePage, navigateTo, parseCompanyJobCard } from './uti
cli({
site: '51job',
name: 'company',
access: 'read',
description: '51job 公司简介 + 在招职位(按 encCoId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -13,6 +13,7 @@ import { JOBS_ORIGIN, requirePage, navigateTo } from './utils.js';
cli({
site: '51job',
name: 'detail',
access: 'read',
description: '51job 职位详情(按 jobId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -16,6 +16,7 @@ import {
cli({
site: '51job',
name: 'hot',
access: 'read',
description: '51job 推荐职位(按城市/行业/排序浏览)',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -20,6 +20,7 @@ import {
cli({
site: '51job',
name: 'search',
access: 'read',
description: '51job 前程无忧关键词职位搜索',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
+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);
});
});
+4
View File
@@ -14,6 +14,7 @@ export function makeScreenshotCommand(site, displayName, extra = {}) {
...extra,
site,
name: 'screenshot',
access: 'read',
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: Strategy.UI,
@@ -46,6 +47,7 @@ export function makeStatusCommand(site, displayName, extra = {}) {
...extra,
site,
name: 'status',
access: 'read',
description: `Check active CDP connection to ${label}`,
domain: 'localhost',
strategy: Strategy.UI,
@@ -67,6 +69,7 @@ export function makeNewCommand(site, displayName, extra = {}) {
...extra,
site,
name: 'new',
access: 'write',
description: `Start a new ${label} session`,
domain: 'localhost',
strategy: Strategy.UI,
@@ -87,6 +90,7 @@ export function makeDumpCommand(site) {
return cli({
site,
name: 'dump',
access: 'read',
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
domain: 'localhost',
strategy: Strategy.UI,
+70
View File
@@ -0,0 +1,70 @@
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export function requireSearchQuery(value, label = 'keyword') {
const query = String(value ?? '').trim();
if (!query) {
throw new ArgumentError(`${label} cannot be empty`);
}
return query;
}
export function requireBoundedInteger(value, defaultValue, min, max, label) {
const raw = value ?? defaultValue;
const parsed = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(parsed)) {
throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
}
if (parsed < min || parsed > max) {
throw new ArgumentError(`${label} must be between ${min} and ${max}, got ${parsed}`);
}
return parsed;
}
export function requireNonNegativeInteger(value, defaultValue, label) {
const raw = value ?? defaultValue;
const parsed = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new ArgumentError(`${label} must be a non-negative integer, got ${JSON.stringify(value)}`);
}
return parsed;
}
export function unwrapBrowserResult(value) {
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
return value.data;
}
return value;
}
export function requireRows(value, label) {
const rows = unwrapBrowserResult(value);
if (!Array.isArray(rows)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array of result rows.`);
}
return rows;
}
export function toHttpsUrl(value, baseUrl) {
const raw = String(value ?? '').trim();
if (!raw) return '';
try {
const url = new URL(raw, baseUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
return url.href;
} catch {
return '';
}
}
export function emptySearchResults(site, query) {
return new EmptyResultError(`${site} search`, `No ${site} results matched "${query}".`);
}
export async function runBrowserStep(label, fn) {
try {
return await fn();
} catch (error) {
if (error?.code || error?.name === 'ArgumentError') throw error;
throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`);
}
}
+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');
});
});
+110
View File
@@ -0,0 +1,110 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError, getErrorMessage } from '@jackwener/opencli/errors';
const AIBASE_DAILY_URL = 'https://www.aibase.com/zh/daily';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
function normalizeLimit(value) {
const raw = value ?? DEFAULT_LIMIT;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError('limit must be a positive integer', `Example: opencli aibase news --limit ${DEFAULT_LIMIT}`);
}
if (limit > MAX_LIMIT) {
throw new ArgumentError(`limit must be <= ${MAX_LIMIT}`, `Example: opencli aibase news --limit ${MAX_LIMIT}`);
}
return limit;
}
function normalizeText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function buildExtractAibaseNewsJs() {
return `
(() => {
const anchors = Array.from(document.querySelectorAll('.bg-white .grid a[href], a[href*="/zh/daily/"]'))
.filter((anchor) => {
const href = anchor.getAttribute('href') || '';
const text = (anchor.innerText || anchor.textContent || '').trim();
return text && href && !href.endsWith('/zh/daily') && !href.endsWith('/zh/daily/');
});
if (anchors.length === 0) {
return {
ok: false,
reason: 'selector-missing',
title: document.title || '',
bodyText: (document.body?.innerText || document.body?.textContent || '').slice(0, 500),
};
}
const seen = new Set();
const rows = [];
for (const anchor of anchors) {
const url = new URL(anchor.getAttribute('href'), location.href).href;
if (seen.has(url)) continue;
seen.add(url);
rows.push({
rank: rows.length + 1,
title: anchor.innerText || anchor.textContent || '',
url,
});
}
return { ok: true, rows };
})()
`;
}
function toRows(payload, limit) {
if (!payload || typeof payload !== 'object') {
throw new CommandExecutionError('AIbase daily page returned an unreadable payload');
}
if (!payload.ok) {
const reason = typeof payload.reason === 'string' && payload.reason.trim() ? payload.reason.trim() : 'selector-drift';
throw new CommandExecutionError(
`AIbase daily selector drift: ${reason}`,
payload.title ? `Page title: ${payload.title}` : undefined,
);
}
const rows = (Array.isArray(payload.rows) ? payload.rows : [])
.map((row, index) => ({
rank: index + 1,
title: normalizeText(row.title),
url: normalizeText(row.url),
}))
.filter((row) => row.title && row.url);
if (rows.length === 0) {
throw new EmptyResultError('aibase news', 'AIbase daily page loaded, but no article rows with title and URL were extracted.');
}
return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
}
async function loadAibaseNews(page, args) {
const limit = normalizeLimit(args.limit);
await page.goto(AIBASE_DAILY_URL, { waitUntil: 'load', settleMs: 3000 });
const payload = await page.evaluate(buildExtractAibaseNewsJs()).catch((error) => {
throw new CommandExecutionError(`Failed to extract AIbase daily news: ${getErrorMessage(error)}`);
});
return toRows(payload, limit);
}
export const aibaseNewsCommand = cli({
site: 'aibase',
name: 'news',
access: 'read',
description: 'AIbase 日报 - 每天三分钟关注AI行业趋势',
domain: 'www.aibase.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of news items to return (max ${MAX_LIMIT})` },
],
columns: ['rank', 'title', 'url'],
func: loadAibaseNews,
});
export const __test__ = {
buildExtractAibaseNewsJs,
normalizeLimit,
toRows,
};
+59
View File
@@ -0,0 +1,59 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { aibaseNewsCommand, __test__ } from './news.js';
function runBrowserScript(html, script, url = 'https://www.aibase.com/zh/daily') {
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
return dom.window.eval(script);
}
function makePage(evaluateResult) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(evaluateResult),
};
}
describe('aibase/news', () => {
it('registers stable URL in columns', () => {
expect(aibaseNewsCommand.access).toBe('read');
expect(aibaseNewsCommand.columns).toEqual(['rank', 'title', 'url']);
});
it('validates limit before browser navigation', async () => {
const page = makePage({ ok: true, rows: [] });
await expect(aibaseNewsCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(aibaseNewsCommand.func(page, { limit: 51 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('extracts and deduplicates AIbase daily rows', async () => {
const html = `
<div class="bg-white">
<div class="grid">
<a href="/zh/daily/123"> First AI daily item </a>
<a href="/zh/daily/123"> First AI daily item duplicate </a>
<a href="/zh/daily/456"> Second AI daily item </a>
</div>
</div>
`;
const payload = runBrowserScript(html, __test__.buildExtractAibaseNewsJs());
const page = makePage(payload);
const rows = await aibaseNewsCommand.func(page, { limit: 2 });
expect(page.goto).toHaveBeenCalledWith('https://www.aibase.com/zh/daily', { waitUntil: 'load', settleMs: 3000 });
expect(rows).toEqual([
{ rank: 1, title: 'First AI daily item', url: 'https://www.aibase.com/zh/daily/123' },
{ rank: 2, title: 'Second AI daily item', url: 'https://www.aibase.com/zh/daily/456' },
]);
});
it('maps selector drift and empty rows to typed errors', async () => {
await expect(aibaseNewsCommand.func(makePage({ ok: false, reason: 'selector-missing' }), { limit: 1 }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(aibaseNewsCommand.func(makePage({ ok: true, rows: [{ title: '', url: '' }] }), { limit: 1 }))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
+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);
},
});
+1
View File
@@ -2,6 +2,7 @@ import { cli } from '@jackwener/opencli/registry';
import { createRankingCliOptions } from './rankings.js';
cli(createRankingCliOptions({
commandName: 'bestsellers',
access: 'read',
listType: 'bestsellers',
description: 'Amazon Best Sellers pages for category candidate discovery',
}));
+6 -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)) {
@@ -85,6 +85,7 @@ async function readDiscussionPayload(page, input, limit) {
cli({
site: 'amazon',
name: 'discussion',
access: 'read',
description: 'Amazon review summary and sample customer discussion from product review pages',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
@@ -110,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];
},
+63 -28
View File
@@ -3,35 +3,8 @@ import { AuthRequiredError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import { __test__ } from './discussion.js';
import './discussion.js';
import { createPageMock } from '../test-utils.js';
function createPageMock(evaluateResults) {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
}
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate,
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
describe('amazon discussion normalization', () => {
it('normalizes review summary and sample reviews', () => {
@@ -68,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([
+1
View File
@@ -2,6 +2,7 @@ import { cli } from '@jackwener/opencli/registry';
import { createRankingCliOptions } from './rankings.js';
cli(createRankingCliOptions({
commandName: 'movers-shakers',
access: 'read',
listType: 'movers_shakers',
description: 'Amazon Movers & Shakers pages for short-term growth signals',
}));
+1
View File
@@ -2,6 +2,7 @@ import { cli } from '@jackwener/opencli/registry';
import { createRankingCliOptions } from './rankings.js';
cli(createRankingCliOptions({
commandName: 'new-releases',
access: 'read',
listType: 'new_releases',
description: 'Amazon New Releases pages for early momentum discovery',
}));
+1
View File
@@ -106,6 +106,7 @@ async function readOfferPayload(page, input) {
cli({
site: 'amazon',
name: 'offer',
access: 'read',
description: 'Amazon seller, buy box, and fulfillment facts from the product page',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+3 -1
View File
@@ -65,6 +65,7 @@ async function readProductPayload(page, input) {
cli({
site: 'amazon',
name: 'product',
access: 'read',
description: 'Amazon product page facts for candidate validation',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
@@ -82,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)];
},
+1
View File
@@ -142,6 +142,7 @@ export function createRankingCliOptions(definition) {
return {
site: 'amazon',
name: definition.commandName,
access: definition.access ?? 'read',
description: definition.description,
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -49,6 +49,7 @@ async function readSearchPayload(page, query) {
cli({
site: 'amazon',
name: 'search',
access: 'read',
description: 'Amazon search results for product discovery and coarse filtering',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+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.',
);
},
});
+1
View File
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
export const dumpCommand = cli({
site: 'antigravity',
name: 'dump',
access: 'read',
description: 'Dump the DOM to help AI understand the UI',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const extractCodeCommand = cli({
site: 'antigravity',
name: 'extract-code',
access: 'read',
description: 'Extract multi-line code blocks from the current Antigravity conversation',
domain: 'localhost',
strategy: Strategy.UI,
+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 -32
View File
@@ -1,44 +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',
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)'}`,
);
},
});
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const newCommand = cli({
site: 'antigravity',
name: 'new',
access: 'read',
description: 'Start a new conversation / clear context in Antigravity',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const readCommand = cli({
site: 'antigravity',
name: 'read',
access: 'read',
description: 'Read the latest chat messages from Antigravity AI',
domain: 'localhost',
strategy: Strategy.UI,
+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.',
'',
);
},
});
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const sendCommand = cli({
site: 'antigravity',
name: 'send',
access: 'write',
description: 'Send a message to Antigravity AI via the internal Lexical editor',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const statusCommand = cli({
site: 'antigravity',
name: 'status',
access: 'read',
description: 'Check Antigravity CDP connection and get current page state',
domain: 'localhost',
strategy: Strategy.UI,
+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;
},
});
+4 -2
View File
@@ -2,12 +2,14 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const watchCommand = cli({
site: 'antigravity',
name: 'watch',
access: 'read',
description: 'Stream new chat messages from Antigravity in real-time',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
timeoutSeconds: 86400, // Run for up to 24 hours
args: [
{ name: 'timeout', type: 'int', required: false, default: 86400, help: 'Max seconds to keep watching (default: 86400 — 24h)' },
],
columns: [], // We use direct stdout streaming
func: async (page) => {
console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
+20
View File
@@ -41,6 +41,26 @@ describe('apple-podcasts search command', () => {
}),
]);
});
it('emits empty-string for missing trackCount and primaryGenreName instead of a sentinel', async () => {
const cmd = getRegistry().get('apple-podcasts/search');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
results: [
{
collectionId: 99,
collectionName: 'No-Meta Show',
artistName: 'Anon Host',
collectionViewUrl: 'https://example.com/p/99',
},
],
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await cmd.func({ query: 'no-meta', limit: 1 });
expect(result[0].episodes).toBe('');
expect(result[0].genre).toBe('');
});
});
describe('apple-podcasts top command', () => {
beforeEach(() => {
+1
View File
@@ -4,6 +4,7 @@ import { itunesFetch, formatDuration, formatDate } from './utils.js';
cli({
site: 'apple-podcasts',
name: 'episodes',
access: 'read',
description: 'List recent episodes of an Apple Podcast (use ID from search)',
strategy: Strategy.PUBLIC,
browser: false,
+3 -2
View File
@@ -4,6 +4,7 @@ import { itunesFetch } from './utils.js';
cli({
site: 'apple-podcasts',
name: 'search',
access: 'read',
description: 'Search Apple Podcasts',
strategy: Strategy.PUBLIC,
browser: false,
@@ -22,8 +23,8 @@ cli({
id: p.collectionId,
title: p.collectionName,
author: p.artistName,
episodes: p.trackCount ?? '-',
genre: p.primaryGenreName ?? '-',
episodes: p.trackCount ?? '',
genre: p.primaryGenreName ?? '',
url: p.collectionViewUrl || '',
}));
},
+1
View File
@@ -6,6 +6,7 @@ const CHARTS_TIMEOUT_MS = 15_000;
cli({
site: 'apple-podcasts',
name: 'top',
access: 'read',
description: 'Top podcasts chart on Apple Podcasts',
strategy: Strategy.PUBLIC,
browser: false,
+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 ?? ''),
}];
},
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { normalizeArxivCategory, normalizeArxivLimit, parseEntries } from './utils.js';
import './paper.js';
import './search.js';
import './recent.js';
const SAMPLE_ENTRY_XML = `<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"
xmlns:arxiv="http://arxiv.org/schemas/atom"
xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>http://arxiv.org/abs/1706.03762v7</id>
<title>Attention Is All You Need &amp; Friends</title>
<updated>2023-08-02T00:41:18Z</updated>
<link href="https://arxiv.org/abs/1706.03762v7" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/1706.03762v7" rel="related" type="application/pdf" title="pdf"/>
<summary>The dominant sequence transduction models are based on complex recurrent or convolutional neural networks. We propose a new simple network architecture, the Transformer, based solely on attention.</summary>
<category term="cs.CL" scheme="http://arxiv.org/schemas/atom"/>
<category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
<published>2017-06-12T17:57:34Z</published>
<arxiv:comment>15 pages, 5 figures</arxiv:comment>
<arxiv:primary_category term="cs.CL"/>
<author><name>Ashish Vaswani</name></author>
<author><name>Noam Shazeer</name></author>
<author><name>Niki Parmar</name></author>
<author><name>Jakob Uszkoreit</name></author>
<author><name>Llion Jones</name></author>
<author><name>Aidan N. Gomez</name></author>
<author><name>Lukasz Kaiser</name></author>
<author><name>Illia Polosukhin</name></author>
</entry>
</feed>`;
describe('arxiv adapter', () => {
it('registers paper, search and recent commands with the expected columns', () => {
const paper = getRegistry().get('arxiv/paper');
const search = getRegistry().get('arxiv/search');
const recent = getRegistry().get('arxiv/recent');
expect(paper).toBeDefined();
expect(search).toBeDefined();
expect(recent).toBeDefined();
expect(paper.columns).toEqual([
'id', 'title', 'authors', 'published', 'updated',
'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url',
]);
expect(search.columns).toEqual([
'id', 'title', 'authors', 'published', 'primary_category', 'url',
]);
expect(recent.columns).toEqual([
'id', 'title', 'authors', 'published', 'primary_category', 'url',
]);
});
it('parseEntries returns full abstract, all authors, pdf, primary category and comment', () => {
const [entry] = parseEntries(SAMPLE_ENTRY_XML);
expect(entry.id).toBe('1706.03762');
expect(entry.title).toBe('Attention Is All You Need & Friends');
// All 8 authors must be present — earlier impl truncated to 3.
expect(entry.authors.split(', ')).toHaveLength(8);
expect(entry.authors).toContain('Ashish Vaswani');
expect(entry.authors).toContain('Illia Polosukhin');
// Full abstract — earlier impl truncated at 200 chars.
expect(entry.abstract.length).toBeGreaterThan(140);
expect(entry.abstract.endsWith('...')).toBe(false);
expect(entry.abstract).toContain('attention');
expect(entry.published).toBe('2017-06-12');
expect(entry.updated).toBe('2023-08-02');
expect(entry.primary_category).toBe('cs.CL');
expect(entry.categories).toBe('cs.CL, cs.LG');
expect(entry.comment).toBe('15 pages, 5 figures');
expect(entry.pdf).toBe('https://arxiv.org/pdf/1706.03762v7');
expect(entry.url).toBe('https://arxiv.org/abs/1706.03762');
});
it('parseEntries returns an empty list for feeds with no entries', () => {
expect(parseEntries('<feed></feed>')).toEqual([]);
});
it('recent rejects malformed category strings', async () => {
const recent = getRegistry().get('arxiv/recent');
await expect(recent.func({ category: 'not a category', limit: 5 })).rejects.toMatchObject({
code: 'ARGUMENT',
});
await expect(recent.func({ category: '', limit: 5 })).rejects.toMatchObject({
code: 'ARGUMENT',
});
});
it('category validation accepts real arXiv archive and subcategory forms', () => {
expect(normalizeArxivCategory('cs.CL')).toBe('cs.CL');
expect(normalizeArxivCategory('math')).toBe('math');
expect(normalizeArxivCategory('physics.comp-ph')).toBe('physics.comp-ph');
expect(normalizeArxivCategory('physics.data-an')).toBe('physics.data-an');
expect(normalizeArxivCategory('cond-mat.soft')).toBe('cond-mat.soft');
expect(normalizeArxivCategory('q-bio.NC')).toBe('q-bio.NC');
expect(() => normalizeArxivCategory('not a category')).toThrow('Invalid arXiv category');
expect(() => normalizeArxivCategory('cs/CL')).toThrow('Invalid arXiv category');
expect(() => normalizeArxivCategory('')).toThrow('Invalid arXiv category');
});
it('limit validation rejects non-positive, non-integer and over-cap values', () => {
expect(normalizeArxivLimit(10, 5, 25)).toBe(10);
expect(normalizeArxivLimit(undefined, 5, 25)).toBe(5);
expect(() => normalizeArxivLimit(0, 5, 25)).toThrow('positive integer');
expect(() => normalizeArxivLimit(1.5, 5, 25)).toThrow('positive integer');
expect(() => normalizeArxivLimit(26, 5, 25)).toThrow('<= 25');
});
});

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