feat(xiaohongshu): persistent site session for self-guarding commands + real reload on soft-block retry - #2461
Conversation
…, 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>
|
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 Pushed a fix commit (e4eaaf7): 🤖 Generated with Claude Code |
…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>
e3a4d1b to
e6730cb
Compare
… 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>
Why
Same motivation as the weibo conversion (#2442), with a sharper reason here: xiaohongshu's own
sitemaps/xiaohongshu/pitfalls.mddocuments 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 warmsite:xiaohongshutab 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/unfollownavigate to parameterized URLs — a new target always really navigates, and follow/unfollow additionally verifylocation.hrefmatches the requested profile before clicking.ask/creator-profile/creator-statsgoto 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, andask'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.jslocks the boundary in both directions: these eight must declarepersistent, 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/exploretab 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), andpublish/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-ranpage.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 forceslocation.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:note→commentson the same URL reads the warm DOM with zero extra server traffic.Verification
OPENCLI_SITE_SESSION=persistentbefore baking in, then re-verified after:whoami→creator-profilewarm 1.3s with live data;note→commentssame URL 4.6s off the warm DOM (real comments);asktwice (11.6s cold boot from a note page, 7.9s warm store reuse);creator-statscrossing back fromwwwtocreatorcleanly.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-138and the unfollow block atfollow.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/likedread 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/exploretab returned the identical feed on every call). Each converts with its hazard handled, and thenavigation.test.jsboundary block moves them from the must-stay-ephemeral list to the must-be-persistent list:navigateFresh(page, url)— forceslocation.reload()when the tab already sits at the target URL, plain goto otherwise.feeduses it unconditionally (its purpose is fresh content); measured live: warm reload 2.5s vs 5.6s cold, and consecutive runs return different content again.user—USER_SNAPSHOT_JSnow carrieslocation.pathname, givinguserthe same landed-page verificationfollow/unfollowalready had: a snapshot from another profile re-navigates once, then fails typedTAB_CONTENTIONinstead 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)resolveXhsUserIdnow 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,
savedreturning 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 stablesite:xiaohongshutab. Verified againstextension/src/background.tsthat this is safe by construction — the tabs-create handler rebinds the lease'spreferredTabIdto the new tab viasetLeaseSessionbefore the old tab closes, sotabs.onRemovedno longer matches the session and the lease survives on the replacement tab.searchnavigates 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 throughnavigateFresh. 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 innavigation.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) andask's citation URL builder emittedxsec_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 aspc_share(profile-context builders already correctly usepc_user). Verified live: feed URLs carrypc_shareand note drill-down on them keeps working. (Originally opened as #2474, folded here since this PR already touches both files.)