* fix(xiaohongshu): locate 点点 conversation store by fingerprint, not a hardcoded module id
webpackRequire(6404) throws "Cannot read properties of undefined (reading
'call')" once Xiaohongshu renumbers its webpack chunks, which it did
(6404 -> 32914). Every `ask` call then fails in ~4s, before any chat happens.
Reported in #2408.
A hardcoded numeric module id has no contract with the site, so patching in the
new number only resets the clock until the next redeploy. This tries the known
ids first (zero scan cost in the common case), then locates the module by what
it *is*: scan webpackRequire.m for a factory whose source mentions both
createConversation and sendMessage. On a live page that is 1 candidate out of
~1600 modules. The scan only calls .toString(), which has no side effects; a
candidate is executed only after its source matches the fingerprint.
Tests execute the generated page script against a fake webpack runtime, so the
lookup is exercised rather than asserted as a substring - including the case
that matters: a store sitting at an id in neither the known list nor any
previous build. Verified the guard can fail: breaking the fingerprint string
turns that one test red and nothing else.
* fix(xiaohongshu): enter ask through /ai_chat instead of search_result?keyword=
`ask` navigated to search_result?keyword=<query> to reach the 点点 conversation
store. That entry point costs something and buys nothing.
Measured on a live logged-in session (in-page fetch/XHR/WebSocket hooks plus
performance resource entries):
1. Cost. Loading search_result?keyword=... fires a real note search
(so.xiaohongshu.com/api/sns/web/v2/search/notes) before any chat happens - a
search request spent purely as a side effect of the URL chosen to reach a
chat store. /ai_chat serves the same conversation store and fires zero
search/notes for the whole page lifetime.
2. Risk control. #1224 documented that direct navigation to
search_result?keyword=... triggers Xiaohongshu's security verification in the
automation browser, and xiaohongshu/search was reworked away from exactly
that pattern. ask still used it.
Controlled A/B, identical query, same session, minutes apart:
search_result -> 1238 chars, 5 sources, 1x search/notes
/ai_chat -> 1195 chars, 5 sources, 0x search/notes
The navigation contributes nothing to answer quality or citation count; the
3.5% length difference is ordinary model variance. (A first pass compared two
*different* queries and saw 16131 vs 1195, which reads as though the entry page
matters enormously - it was query variance. Only the same-query control settled
it.)
The test now pins /ai_chat and additionally asserts goto is never called with a
search_result URL, so a regression to a search-spending entry point fails loudly
rather than silently costing a request per call.
---------
Co-authored-by: V <vecsat@foxmail.com>
Reading XHS note-detail pages (note / comments / download) back-to-back trips
velocity-based risk control: a soft block that redirects to
website-login/error?error_code=300017/300031 or renders "安全限制" /
"访问链接异常" (#1825, #962). Today the first block fails the command outright,
and an unattended loop keeps hammering — which escalates the risk state toward
the account-violation / ban path (#842, #677).
XHS soft blocks are frequently transient per-request challenges, so a single
reload after a real cooldown recovers many of them. Add a shared
`readXhsDetailPage` helper that navigates, settles, extracts, and — only on a
security block — waits one long randomized cooldown (8–18s) and reloads exactly
ONCE before surfacing SECURITY_BLOCK. The single-retry cap is structural (a
guarded `if`, no caller-tunable retry count) so it can never devolve into a
hammer loop; `retryOnBlock: false` opts into the previous fail-fast behavior.
note / comments / download now share this helper, replacing three copies of the
inline securityBlock detection. This does NOT throttle request velocity across
separate CLI invocations — that needs session-level pacing and is left as a
follow-up.
Adds offline tests for the helper (transient-block recovery, the structural
single-retry cap, fail-fast, custom settle bounds, non-block pass-through); the
existing note/comments/download command tests pass unchanged.
* fix(errors): duck-typing for cross-package CliError in toEnvelope
Plugins resolve their own copy of @jackwener/opencli (own node_modules), so
fails across package copies and every plugin error
degrades to code: UNKNOWN with the hint lost. Switch to shape-based detection
(code + message strings, optional hint) — real CliError instances behave
identically, plain Errors still map to UNKNOWN.
Also serializes error-like plain objects carrying code/message. Adds tests
for the cross-package shape, plain-object passthrough, and UNKNOWN fallback.
* fix(errors): require exitCode when duck-typing CliError
The shape check accepted any object with string code+message, which also
matches Node system errors (ENOENT, ECONNREFUSED, EACCES) and library
errors carrying a string code. Those would surface their errno as the
envelope code, widening the machine-readable contract callers switch on:
before this commit: ENOENT -> code "ENOENT"
intended/base: ENOENT -> code "UNKNOWN"
CliError's constructor always assigns exitCode (defaulting to
GENERIC_ERROR) while Node system errors never do, so requiring a numeric
exitCode separates genuine cross-package CliError copies from foreign
errors without adding any new concept.
Tests: make the cross-package mock faithful by giving it exitCode, pin
the bare {code,message} object as UNKNOWN, and add an ENOENT regression.
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* fix(dribbble): distinguish empty states from drift
* fix(dribbble): handle promoted and nested list items
---------
Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
turndown pulls in @mixmark-io/domino, which publishes its full test suite
to npm: 959 files / 7 MB, ~94% of the package's file count. Upstream has
been unmaintained since 2024 (mixmark-io/domino#2), so remove the test
directory in our postinstall instead. Runs before the CI early-return so
packaged app bundles (OpenCLIApp stages node_modules into Resources/)
shrink as well. Best-effort: resolution failure or a missing dir never
fails the install.
Co-authored-by: exe.dev user <exedev@koala-fife.exe.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The published tarball ships 601 compiled *.test.js files, ~100 *.test.d.ts,
stray *.test.ts sources, and clis/**/__fixtures__/ HTML snapshots — none of
which are used at runtime. Excluding them shrinks the package from 2340 to
1638 files and from 14.0 MB to 9.2 MB unpacked (tarball 3.1 MB -> 2.2 MB),
which noticeably speeds up npm install.
Verified with npm pack --dry-run against the extracted 1.8.7 tarball
contents: no .test.* or __fixtures__ entries remain.
Co-authored-by: exe.dev user <exedev@koala-fife.exe.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
renderMarkdown escaped pipes but not newlines, so a cell containing a
line break terminated the table row and broke the markdown structure.
Render embedded newlines as <br>, matching how renderCsv already
handles multi-line values.
* fix(trip): read airport codes from leaf nodes, not the dropped font-black class
`buildFlightExtractJs` sourced the departure/arrival IATA codes from
`[class*="font-black"]`. Trip.com no longer emits that class, so `codes` was
always empty, the `codes.length < 2` guard dropped every card, and `flight` /
`flight-round` failed with EMPTY_RESULT on every route — including the
`LON -> NYC` example in the adapter docs.
The codes are still in the DOM as plain leaf nodes, and the extractor already
walks the leaves to collect times. Filter the codes out of that same scan and
drop the `font-black` dependency.
The unit fixture pinned the stale markup (`<div class="font-black_x">LGW</div>`),
which is why 114 adapter tests stayed green while the adapter returned nothing
against the live site. Replace it with markup captured from Trip.com in 2026-08
so the fixture can actually falsify the extractor.
Verified against the live site: 0/8 result cards parsed before, 8/8 after;
`opencli trip flight` now returns rows for PVG->LHR, SHA->LON and LON->NYC.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(trip): mark later-day arrivals with a +N suffix
Overnight legs lost a day. The extractor built `arrivalTime` from the `HH:MM`
and `AM`/`PM` leaf nodes alone, so a China Southern SHA -> LGW leg departing
19:50 on 2026-09-07 and landing 06:30 on 2026-09-08 was reported as a plain
`6:30 AM`, indistinguishable from a same-day arrival.
Each card already carries `data-testid="flight-time-<ISO>"` on both legs. Diff
the date halves of those two anchors and append `+N` to the arrival time, which
is how Trip.com itself renders it (the card shows a literal `+1` beside the
arrival airport).
Same-day arrivals are unchanged, so the `columns` contract is untouched and only
the previously-wrong rows move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(trip): bind flight routes to endpoint anchors
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
Clears the esbuild, nanoid, and postcss dev-chain advisories
(6 findings → 3). package-lock.json only; package.json ranges
untouched; production dependency audit remains at zero. The remaining
vite/launch-editor + vitepress findings have no in-range fix upstream
yet (dev-only, Windows-only vector) and are documented in the PR.
undici 7.29.0 (pinned since the CVE-2026-13697 fix) requires Node
>=20.18.1, but engines still promised >=20.0.0. First-principles call:
Node 20 reached EOL in April 2026, so 20.0-20.17 users are on an
unpatched runtime either way — staying on the maintained undici 7.x
line and updating the declaration honestly beats downgrading to the
aging 6.x line for a contract that protects nobody. Updates engines,
the lock root mirror, and both README Node claims.
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.
* 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>
* 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>
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>
#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.