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.
* 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>
* 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>
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)
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.
* 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).
* 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
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.
* 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>
* 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>
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)
* 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>
* 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>
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.
* 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>
* 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>
* 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>
* 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>
* 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>
* 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.
* 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>
* 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>
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.
* 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>
* 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>
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>