Skip to content

feat(xiaohongshu): persistent site session for self-guarding commands + real reload on soft-block retry - #2461

Open
yisiliu wants to merge 6 commits into
jackwener:mainfrom
yisiliu:feat/xiaohongshu-persistent-session
Open

feat(xiaohongshu): persistent site session for self-guarding commands + real reload on soft-block retry#2461
yisiliu wants to merge 6 commits into
jackwener:mainfrom
yisiliu:feat/xiaohongshu-persistent-session

Conversation

@yisiliu

@yisiliu yisiliu commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Why

Same motivation as the weibo conversion (#2442), with a sharper reason here: xiaohongshu's own sitemaps/xiaohongshu/pitfalls.md documents that its risk control triggers on velocity — "短时间高频访问". Every ephemeral command pays a fresh tab + navigation; a batch run over notes turns one logical read into a tab-open/homepage-load pattern no human produces. Reusing one warm site:xiaohongshu tab both speeds commands up and makes the traffic look like what it actually is: one logged-in user reading notes.

What converts, and why exactly these eight

note / comments / download / follow / unfollow navigate to parameterized URLs — a new target always really navigates, and follow/unfollow additionally verify location.href matches the requested profile before clicking. ask / creator-profile / creator-stats goto fixed URLs that the extension's same-URL fast-path skips harmlessly on a warm tab (verified live: the relative /api/galaxy/* fetches read live cookies, and ask's conversation store reboots cleanly or reuses the booted SPA the way the chat adapters do).

Unlike weibo (#2442), none of these navigates to a domain root, so no adapter-side origin guard is needed — the conversion is declaration-only. A new convention block in navigation.test.js locks the boundary in both directions: these eight must declare persistent, and the commands with documented warm-tab hazards must stay ephemeral until each hazard is actually solved — feed/user/saved/liked (read __INITIAL_STATE__/Pinia state that goes stale on a warm tab; measured live: a warm /explore tab returns the identical feed forever), search (replaces the session tab mid-run), the creator capture trio (their signed x-s XHRs only fire because navigation happens), and publish/delete-note/draft-* (a warm composer can still hold the previous note's content, and XHS autosaves drafts — data-integrity, not flakiness).

Bug fix folded in: the soft-block retry never actually reloaded

Pre-existing, but persistence would have amplified it: readXhsDetailPage's single cooldown retry re-ran page.goto(url) — which the extension fast-paths without reloading when the tab is already at that URL. The in-page block variant ("安全限制" rendered at an unchanged URL) therefore retried against the same blocked document. The retry now forces location.reload() when the tab still sits at the target URL; the redirect variant (error_code=300017/300031, URL changed) keeps the plain goto. The healthy-page path deliberately keeps the fast-path: notecomments on the same URL reads the warm DOM with zero extra server traffic.

Verification

  • TDD: 8 declaration tests + 14 ephemeral-boundary tests + 2 retry-variant tests written first and watched fail. 388 xiaohongshu+rednote adapter tests green; full suite, typecheck, and both lint gates clean; manifest regenerated (8 entries).
  • Live, logged in, with OPENCLI_SITE_SESSION=persistent before baking in, then re-verified after: whoamicreator-profile warm 1.3s with live data; notecomments same URL 4.6s off the warm DOM (real comments); ask twice (11.6s cold boot from a note page, 7.9s warm store reuse); creator-stats crossing back from www to creator cleanly.
  • follow/unfollow: the property that makes them warm-tab-safe — abort when the landed URL is a different profile or a non-xiaohongshu host — is covered by existing unit tests for both commands (follow.test.js:80-85,132-138 and the unfollow block at follow.test.js:178-191). They were deliberately not exercised live — a live test mutates a real social graph.

Part of the persistent-session series: weibo (#2442) → xiaohongshu (this) → bilibili/twitter planned.

🤖 Generated with Claude Code


Phase 2 (added 2026-09-06): page-state readers, with their staleness hazards solved

feed / user / saved / liked read in-page state (Pinia store, __INITIAL_STATE__) that a warm persistent tab serves stale forever — the extension fast-paths a goto to the tab's current URL without loading anything (verified live: a warm /explore tab returned the identical feed on every call). Each converts with its hazard handled, and the navigation.test.js boundary block moves them from the must-stay-ephemeral list to the must-be-persistent list:

  • shared navigateFresh(page, url) — forces location.reload() when the tab already sits at the target URL, plain goto otherwise. feed uses it unconditionally (its purpose is fresh content); measured live: warm reload 2.5s vs 5.6s cold, and consecutive runs return different content again.
  • userUSER_SNAPSHOT_JS now carries location.pathname, giving user the same landed-page verification follow/unfollow already had: a snapshot from another profile re-navigates once, then fails typed TAB_CONTENTION instead of returning another user's notes. The hydration retry also stops immediately on a wrong-profile snapshot instead of burning 8×2s.
  • saved/liked — two pre-existing bugs fixed along the way: (1) the XHR interceptor was installed before the goto, so navigation wiped the in-page patch and the capture path never captured anything — the DOM fallback silently did all the work; install now happens after navigation. (2) resolveXhsUserId now polls through the hydration window before reloading — location.reload() resolves before the page loads, and a reload-eager first version raced hydration and reported a logged-in user as logged out (caught live, regression-tested).

Phase-2 verification: 15 new unit tests (navigateFresh matrix, feed warm-freshness, user contention interleavings, resolveXhsUserId polling/reload semantics, interceptor ordering), boundary lists re-asserted both ways, full suite 7355 green on this branch, and live: feed twice with different content, saved returning correct EMPTY_RESULT semantics on an account with no saved notes.


Phase 3 (added 2026-09-06): search

The blocker was replaceCollapsedTab: it creates a fresh tab and closes the one that, under a persistent session, is the stable site:xiaohongshu tab. Verified against extension/src/background.ts that this is safe by construction — the tabs-create handler rebinds the lease's preferredTabId to the new tab via setLeaseSession before the old tab closes, so tabs.onRemoved no longer matches the session and the lease survives on the replacement tab.

search navigates to a query-parameterized URL, so different queries always load for real; repeating the same query on a warm tab would fast-path onto the previous run's scroll-accumulated DOM — it now goes through navigateFresh. Live: same query twice returns fresh (re-ranked, partially new) results; the warm same-query repeat costs ~16s vs ~3s for a new query, because the reload re-runs content-wait and filter settling — a rare path (callers dedupe repeated queries) traded deliberately for correctness. Note: search stays functionally broken until #2460 (the stacked-chip filter fix) also lands; the two changes are code-independent and tested together on an integration branch (combined suite 7382 green).

The must-stay-ephemeral boundary list now contains only publish/delete-note/draft-* (dirty composer hazard) and the creator capture trio (navigation-triggered signed XHRs) — each with its reason asserted in navigation.test.js.


Part of the xiaohongshu risk-control/persistence arc — umbrella issue with full motivation and cross-PR context: #2470


Also included (2026-09-06): xsec_source=pc_share fix

buildFeedNoteUrl (shared with rednote) and ask's citation URL builder emitted xsec_source= with an empty value — an anomalous shape no official surface produces, which interacts badly with token validation / risk control. Both now label the context explicitly as pc_share (profile-context builders already correctly use pc_user). Verified live: feed URLs carry pc_share and note drill-down on them keeps working. (Originally opened as #2474, folded here since this PR already touches both files.)

…, real reload on soft-block retry

Convert the eight commands whose navigation is already warm-tab-safe to
siteSession: 'persistent': note/comments/download and follow/unfollow
navigate to parameterized URLs (a new target always really navigates,
and follow/unfollow verify location.href before clicking), while
ask/creator-profile/creator-stats goto fixed URLs that the extension
fast-paths harmlessly on a warm tab. No adapter-side origin guard is
needed for any of them - unlike weibo, none navigates to a domain root.

A navigation.test.js convention block locks the phase boundary both
ways: the eight commands must declare persistent, and the commands with
documented warm-tab hazards (feed/user/saved/liked page-state reads,
search tab replacement, the creator capture trio's navigation-triggered
signed XHRs, publish/delete-note/draft composer state) must stay on the
ephemeral default until each hazard is solved.

Also fixes a pre-existing bug the conversion would have amplified: the
soft-block retry in readXhsDetailPage re-ran goto to the same URL, which
the extension fast-paths without reloading, so the in-page "安全限制"
variant retried against the same blocked document. The retry now forces
location.reload() when the tab still sits at the target URL; the
redirect variant keeps the plain goto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rning another note

Integration testing of the persistent session with the pacing layer
surfaced a data-corruption race: two parallel note commands share the
site:xiaohongshu tab, the second navigation lands during the first
command's settle wait, and the first extract silently returns the
SECOND note's content with a success exit (the other command dies with
a detach error).

readXhsDetailPage now verifies the landed page's note id against the
requested URL (all three detail extracts already return pageUrl):
a mismatch re-navigates once to win the tab back, and a second mismatch
throws a typed TAB_CONTENTION error telling the caller to run same-site
commands sequentially. Payloads without an extractable note id (login
walls, error pages) are untouched, and a contention retry that lands on
a security block still honors the never-return-a-block contract.

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

yisiliu commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Integration testing update: merged this PR together with #2460 and #2464 on a local integration branch (zero conflicts, combined suite 7370 green) and ran a live combined sweep. It surfaced a real race this PR's caveat had only documented: two parallel note commands on the shared persistent tab — one silently returned the other note's content with a success exit (the pacing layer's 2s spacing made the interleave deterministic).

Pushed a fix commit (e4eaaf7): readXhsDetailPage now verifies the landed page's note id against the requested URL (all three detail extracts already return pageUrl), re-navigates once on mismatch, and throws a typed TAB_CONTENTION error instead of returning another note. 6 new unit tests cover the interleavings; live re-run confirms the silent-corruption path is gone (the losing parallel command still fails loudly with a transport detach — graceful read arbitration would need framework-level read leases, left out of scope here).

🤖 Generated with Claude Code

yisiliu and others added 2 commits September 6, 2026 10:54
…eness handling

feed/user/saved/liked read in-page state (Pinia store, __INITIAL_STATE__)
that a warm persistent tab serves stale forever: the extension fast-paths
a goto to the tab's current URL without loading anything. Verified live —
a warm /explore tab returned the identical feed on every call. Each
command converts WITH its staleness hazard solved:

- shared navigateFresh(page, url): forces location.reload() when the tab
  already sits at the target URL, plain goto otherwise (falls back to
  goto if the in-place reload cannot be issued).
- feed: always loads fresh (its purpose is new content). Warm reload is
  also faster than the old cold tab: 2.5s vs 5.6s measured.
- user: navigateFresh + a landed-pathname contention guard (the
  USER_SNAPSHOT_JS snapshot now carries location.pathname): a snapshot
  from another profile re-navigates once, then fails typed with
  TAB_CONTENTION instead of returning another user's notes. The
  hydration retry also stops immediately on a wrong-profile snapshot
  instead of burning 8 waits.
- saved/liked: the XHR interceptor was installed BEFORE the goto, so the
  navigation wiped the in-page patch and the capture path never worked -
  the DOM fallback silently did everything (pre-existing bug). Install
  now happens after navigation. resolveXhsUserId polls through the
  hydration window before reloading: location.reload() resolves before
  the page loads, and the first reload-eagerly version raced hydration
  and reported a logged-in user as logged out (caught live).

navigation.test.js moves the four commands from the ephemeral boundary
list into the persistent list, keeping both directions asserted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blocker was replaceCollapsedTab: it creates a fresh tab and closes
the one that, under a persistent session, IS the stable site:xiaohongshu
tab. Verified against extension/src/background.ts that this is safe by
construction: the tabs-create handler rebinds the lease's preferredTabId
to the new tab via setLeaseSession BEFORE the old tab closes, so
tabs.onRemoved no longer matches the session and the lease survives on
the replacement tab.

search navigates to a query-parameterized URL, so different queries
always load for real; repeating the SAME query on a warm tab would
fast-path onto the previous run's scroll-accumulated DOM — it now goes
through navigateFresh, which forces a reload in that case. The boundary
convention test moves search into the persistent list, leaving only
publish/delete-note/draft-* (dirty composer) and the creator capture
trio (navigation-triggered signed XHRs) deliberately ephemeral.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yisiliu
yisiliu force-pushed the feat/xiaohongshu-persistent-session branch from e3a4d1b to e6730cb Compare September 6, 2026 04:05
yisiliu added a commit to yisiliu/OpenCLI-1 that referenced this pull request Sep 6, 2026
… stream URLs

The web /livelist page server-renders __INITIAL_STATE__.liveList with
everything a viewer needs per room: id/title/viewer count (tRoomInfo),
host identity (tLiveHostInfo), and - inside the double-nested JSON
string roomExtraInfo.live_stream_info - the FLV master URL, which the
CDN serves without cookies (verified live: HTTP 200, video/x-flv, 1.6MB
delivered in an 8s probe). No room-page visit is needed for discovery
or playback; `mpv <stream_url>` plays the room.

extractLiveStreamUrl parses the nested JSON node-side and falls back to
the flvUrl param of the xhsdiscover:// deeplink for malformed payloads.
The command declares a persistent site session and forces a real reload
when the warm tab already shows the live list (listings churn
constantly); the tiny navigateFresh helper is a deliberate private copy
of the one jackwener#2461 adds to shared.js, keeping this PR merge-order
independent - switch to the shared import once that lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in signed note URLs

buildFeedNoteUrl (shared with rednote) and ask's citation URL builder
set xsec_source to an empty string, on the theory that it mirrored the
site's own feed links and the value was not validated. An empty source
is an anomalous shape no official surface produces and interacts badly
with token validation / risk control; label the context explicitly as
pc_share, consistent with the pc_user label the profile-context
builders in this adapter already use.

Verified live: feed URLs now carry xsec_source=pc_share and drill-down
via `xiaohongshu note` on those URLs keeps working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… panel

The current search_result layout renders every filter chip twice inside
the same .tag-container, with both copies visible at pixel-identical
positions. findOption required exactly one visible text match, so every
search failed with ambiguous_option before any filter was applied.

Matches whose bounding rects are identical are one visual control -
treat them as one option (preferring the copy carrying .active so the
activation checks see it). Matches at distinct positions still fail
closed as ambiguous_option; the fixture now places the second copy of a
genuinely ambiguous chip at a different rect to keep that path covered.

Verified live: default-filter search and --sort latest (which clicks a
deduplicated chip) both return results again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant