集成互动功能到帖子详情,删除独立互动页面

- 点赞/收藏改为纯 toggle,移除 unlike/unfavorite 参数
- 帖子详情 API 返回 isLiked/isFavorited 状态(SVG xlink:href 检测)
- 前端两个切换按钮替代原四个独立按钮
- 修复 __INITIAL_STATE__ Vue 响应式代理序列化(structuredClone + fallback)
- 修复 overlay 场景下点赞按钮误点 feed 列表元素(.last() 定位)
- 删除 InteractionsPage 及相关路由/导航
This commit is contained in:
2026-03-02 14:39:15 +08:00
parent def0828815
commit 5a1f88de95
15 changed files with 308 additions and 442 deletions
+25 -1
View File
@@ -217,8 +217,28 @@ export async function getFeedDetail(
}
}
// -----------------------------------------------------------------------
// Detect current user's like / favorite state from the overlay DOM.
// .interact-container is unique to the overlay (feed list cards don't have it).
// XHS loads user state asynchronously, so wait up to 3s for the buttons.
// -----------------------------------------------------------------------
await page.waitForSelector('.interact-container .like-wrapper', { timeout: 3_000 }).catch(() => {});
await page.waitForTimeout(1_000); // extra time for async state update
// XHS uses SVG xlink:href to indicate state: #like vs #liked, #collect vs #collected
const interactionState = await page.evaluate(() => {
const likeIcon = document.querySelector('.interact-container .like-wrapper use');
const favIcon = document.querySelector('.interact-container .collect-wrapper use');
return {
isLiked: likeIcon?.getAttribute('xlink:href') === '#liked',
isFavorited: favIcon?.getAttribute('xlink:href') === '#collected',
};
});
detail.isLiked = interactionState.isLiked ?? false;
detail.isFavorited = interactionState.isFavorited ?? false;
log.info(
{ feedId, commentCount: detail.comments.length, imageCount: detail.images.length },
{ feedId, commentCount: detail.comments.length, imageCount: detail.images.length, isLiked: detail.isLiked, isFavorited: detail.isFavorited },
'Feed detail extraction complete',
);
@@ -358,6 +378,8 @@ function parseDetailFromState(
collectCount,
commentCount,
shareCount,
isLiked: false,
isFavorited: false,
createTime,
lastUpdateTime,
ipLocation,
@@ -513,6 +535,8 @@ async function scrapeDetailFromDom(
collectCount,
commentCount,
shareCount,
isLiked: false,
isFavorited: false,
createTime,
lastUpdateTime: '',
ipLocation,
+36 -2
View File
@@ -155,8 +155,42 @@ export async function listFeeds(page: Page): Promise<Feed[]> {
*/
async function extractInitialState(page: Page): Promise<InitialState | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state: unknown = await page.evaluate('window.__INITIAL_STATE__');
// Only extract keys we care about — the full __INITIAL_STATE__ can contain
// circular references or be too large, causing serialization errors.
// Return a JSON string from the browser to avoid Playwright's own
// serialization hitting Vue reactive proxy circular references.
// We use structuredClone to break Vue proxy wrappers, then stringify.
const json: string | null = await page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = (window as any).__INITIAL_STATE__;
if (!s || typeof s !== 'object') return null;
try {
// structuredClone strips Vue proxies and produces a plain object.
const plain = structuredClone({ noteData: s.noteData, note: s.note, feed: s.feed, feeds: s.feeds, user: s.user });
return JSON.stringify(plain);
} catch {
// structuredClone may fail on some Vue internals — fall back to
// stringify with a depth counter to avoid stack overflow.
let depth = 0;
const MAX_DEPTH = 20;
const seen = new Set<unknown>();
const result = JSON.stringify(
{ noteData: s.noteData, note: s.note, feed: s.feed, feeds: s.feeds, user: s.user },
function (_key, value) {
if (typeof value === 'function') return undefined;
if (typeof value === 'object' && value !== null) {
if (seen.has(value) || depth > MAX_DEPTH) return undefined;
seen.add(value);
depth++;
}
return value;
},
);
return result;
}
});
const state: unknown = json ? JSON.parse(json) : null;
if (state && typeof state === 'object') {
return state as InitialState;
+4 -9
View File
@@ -497,7 +497,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
server.tool(
'xhs_like',
'Like or unlike a Xiaohongshu note',
'Toggle like on a Xiaohongshu note',
LikeSchema,
async (args) => {
return withErrorHandling('xhs_like', async () => {
@@ -509,7 +509,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
const result = await browser.withPage(
PLATFORM,
async (page) =>
toggleLike(page, args.feed_id, args.xsec_token, args.unlike),
toggleLike(page, args.feed_id, args.xsec_token),
timeoutMs,
);
@@ -531,7 +531,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
server.tool(
'xhs_favorite',
'Favorite or unfavorite a Xiaohongshu note',
'Toggle favorite on a Xiaohongshu note',
FavoriteSchema,
async (args) => {
return withErrorHandling('xhs_favorite', async () => {
@@ -543,12 +543,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
const result = await browser.withPage(
PLATFORM,
async (page) =>
toggleFavorite(
page,
args.feed_id,
args.xsec_token,
args.unfavorite,
),
toggleFavorite(page, args.feed_id, args.xsec_token),
timeoutMs,
);
+74 -185
View File
@@ -12,203 +12,92 @@ const FEED_DETAIL_URL = 'https://www.xiaohongshu.com/explore';
/** Wait after clicking like/favorite to let the state update. */
const TOGGLE_SETTLE_MS = 1_000;
const selInteraction = XHS_SELECTORS.interaction;
const selDetail = XHS_SELECTORS.feedDetail;
const log = logger.child({ module: 'xhs-interaction' });
// ---------------------------------------------------------------------------
// toggleLike
// ---------------------------------------------------------------------------
/**
* Like or unlike a Xiaohongshu note.
*
* @param page - Playwright Page managed by BrowserManager.
* @param feedId - The note / feed ID.
* @param xsecToken - Security token for accessing the feed page.
* @param unlike - If true, unlike the note (toggle off). Default: false.
* @returns Object with success status and the resulting liked state.
*/
export async function toggleLike(
page: Page,
feedId: string,
xsecToken: string,
unlike?: boolean,
): Promise<{ success: boolean; liked: boolean }> {
log.info({ feedId, unlike: unlike ?? false }, 'Toggling like on note');
// -------------------------------------------------------------------------
// 1. Navigate to the feed detail page
// -------------------------------------------------------------------------
const feedUrl = buildFeedUrl(feedId, xsecToken);
await page.goto(feedUrl, { waitUntil: 'domcontentloaded' });
// Wait for the note container and interaction bar to be visible.
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
await page.waitForTimeout(1_000);
// -------------------------------------------------------------------------
// 2. Check the current like state
// -------------------------------------------------------------------------
const isCurrentlyLiked = await isElementActive(
page,
selInteraction.likeButtonActive,
);
log.debug({ isCurrentlyLiked, desiredUnlike: unlike ?? false }, 'Current like state');
// Determine whether we need to toggle.
// - unlike=true means we want the note to NOT be liked → toggle only if currently liked.
// - unlike=false means we want the note to BE liked → toggle only if currently not liked.
const shouldToggle = unlike ? isCurrentlyLiked : !isCurrentlyLiked;
if (!shouldToggle) {
// Already in the desired state — no action needed.
const liked = !unlike;
log.info({ feedId, liked, alreadyInState: true }, 'Like already in desired state');
return { success: true, liked };
}
// -------------------------------------------------------------------------
// 3. Click the like button
// -------------------------------------------------------------------------
const likeBtn = await page.$(selInteraction.likeButton);
if (!likeBtn) {
log.warn('Like button not found on feed detail page');
return { success: false, liked: isCurrentlyLiked };
}
await likeBtn.click();
await page.waitForTimeout(TOGGLE_SETTLE_MS);
// -------------------------------------------------------------------------
// 4. Verify the new state
// -------------------------------------------------------------------------
const isNowLiked = await isElementActive(
page,
selInteraction.likeButtonActive,
);
const expectedLiked = !unlike;
const success = isNowLiked === expectedLiked;
log.info({ feedId, liked: isNowLiked, success }, 'Like toggle complete');
return { success, liked: isNowLiked };
}
// ---------------------------------------------------------------------------
// toggleFavorite
// ---------------------------------------------------------------------------
/**
* Favorite or unfavorite a Xiaohongshu note.
*
* @param page - Playwright Page managed by BrowserManager.
* @param feedId - The note / feed ID.
* @param xsecToken - Security token for accessing the feed page.
* @param unfavorite - If true, unfavorite the note (toggle off). Default: false.
* @returns Object with success status and the resulting favorited state.
*/
export async function toggleFavorite(
page: Page,
feedId: string,
xsecToken: string,
unfavorite?: boolean,
): Promise<{ success: boolean; favorited: boolean }> {
log.info({ feedId, unfavorite: unfavorite ?? false }, 'Toggling favorite on note');
// -------------------------------------------------------------------------
// 1. Navigate to the feed detail page
// -------------------------------------------------------------------------
const feedUrl = buildFeedUrl(feedId, xsecToken);
await page.goto(feedUrl, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
await page.waitForTimeout(1_000);
// -------------------------------------------------------------------------
// 2. Check the current favorite state
// -------------------------------------------------------------------------
const isCurrentlyFavorited = await isElementActive(
page,
selInteraction.favoriteButtonActive,
);
log.debug(
{ isCurrentlyFavorited, desiredUnfavorite: unfavorite ?? false },
'Current favorite state',
);
const shouldToggle = unfavorite ? isCurrentlyFavorited : !isCurrentlyFavorited;
if (!shouldToggle) {
const favorited = !unfavorite;
log.info(
{ feedId, favorited, alreadyInState: true },
'Favorite already in desired state',
);
return { success: true, favorited };
}
// -------------------------------------------------------------------------
// 3. Click the favorite button
// -------------------------------------------------------------------------
const favBtn = await page.$(selInteraction.favoriteButton);
if (!favBtn) {
log.warn('Favorite button not found on feed detail page');
return { success: false, favorited: isCurrentlyFavorited };
}
await favBtn.click();
await page.waitForTimeout(TOGGLE_SETTLE_MS);
// -------------------------------------------------------------------------
// 4. Verify the new state
// -------------------------------------------------------------------------
const isNowFavorited = await isElementActive(
page,
selInteraction.favoriteButtonActive,
);
const expectedFavorited = !unfavorite;
const success = isNowFavorited === expectedFavorited;
log.info({ feedId, favorited: isNowFavorited, success }, 'Favorite toggle complete');
return { success, favorited: isNowFavorited };
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Build the URL for a feed detail page.
*/
function buildFeedUrl(feedId: string, xsecToken: string): string {
return `${FEED_DETAIL_URL}/${feedId}?xsec_token=${encodeURIComponent(xsecToken)}&xsec_source=pc_search`;
}
/**
* Check whether an element matching the given selector exists on the page.
* Used to determine the active/inactive state of like/favorite buttons.
*
* The selector for the "active" state uses CSS classes that are only present
* when the button is in its toggled-on state (e.g. `.like-wrapper.active`).
* Click the LAST element matching the selector (the overlay's button).
* XHS opens note detail as an overlay on /explore — the overlay is rendered
* LAST in DOM, so .last() targets the correct button.
*/
async function isElementActive(page: Page, selector: string): Promise<boolean> {
const el = await page.$(selector);
return el !== null;
async function clickLastMatch(page: Page, selector: string): Promise<boolean> {
try {
await page.locator(selector).last().click({ timeout: 5_000 });
return true;
} catch {
return false;
}
}
/**
* Read post-click state by checking the SVG icon href inside the button.
* XHS uses xlink:href #like vs #liked, #collect vs #collected.
*/
async function readState(page: Page, btnSelector: string, activeHref: string): Promise<boolean> {
return page.locator(btnSelector).last()
.evaluate((el, href) => el.querySelector('use')?.getAttribute('xlink:href') === href, activeHref)
.catch(() => false);
}
// ---------------------------------------------------------------------------
// toggleLike — pure toggle, clicks the like button once
// ---------------------------------------------------------------------------
export async function toggleLike(
page: Page,
feedId: string,
xsecToken: string,
): Promise<{ success: boolean; liked: boolean }> {
log.info({ feedId }, 'Toggling like on note');
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
if (!clicked) {
log.warn('Like button not found in note detail overlay');
return { success: false, liked: false };
}
await page.waitForTimeout(TOGGLE_SETTLE_MS);
const liked = await readState(page, '.engage-bar-style .like-wrapper', '#liked');
log.info({ feedId, liked }, 'Like toggle complete');
return { success: true, liked };
}
// ---------------------------------------------------------------------------
// toggleFavorite — pure toggle, clicks the favorite button once
// ---------------------------------------------------------------------------
export async function toggleFavorite(
page: Page,
feedId: string,
xsecToken: string,
): Promise<{ success: boolean; favorited: boolean }> {
log.info({ feedId }, 'Toggling favorite on note');
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
if (!clicked) {
log.warn('Favorite button not found in note detail overlay');
return { success: false, favorited: false };
}
await page.waitForTimeout(TOGGLE_SETTLE_MS);
const favorited = await readState(page, '.engage-bar-style .collect-wrapper', '#collected');
log.info({ feedId, favorited }, 'Favorite toggle complete');
return { success: true, favorited };
}
+2 -9
View File
@@ -107,13 +107,11 @@ const ReplyCommentBodySchema = z.object({
const LikeBodySchema = z.object({
feed_id: LikeSchema.feed_id,
xsec_token: LikeSchema.xsec_token,
unlike: LikeSchema.unlike,
});
const FavoriteBodySchema = z.object({
feed_id: FavoriteSchema.feed_id,
xsec_token: FavoriteSchema.xsec_token,
unfavorite: FavoriteSchema.unfavorite,
});
// ---------------------------------------------------------------------------
@@ -551,7 +549,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
const result = await browser.withPage(
PLATFORM,
async (page) =>
toggleLike(page, body.feed_id, body.xsec_token, body.unlike),
toggleLike(page, body.feed_id, body.xsec_token),
timeoutMs,
);
@@ -578,12 +576,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
const result = await browser.withPage(
PLATFORM,
async (page) =>
toggleFavorite(
page,
body.feed_id,
body.xsec_token,
body.unfavorite,
),
toggleFavorite(page, body.feed_id, body.xsec_token),
timeoutMs,
);
+2 -12
View File
@@ -128,13 +128,8 @@ export const ReplyCommentSchema = {
/** xhs_like */
export const LikeSchema = {
feed_id: z.string().describe('Feed ID to like'),
feed_id: z.string().describe('Feed ID to toggle like'),
xsec_token: z.string().describe('Security token for the feed'),
unlike: z
.boolean()
.optional()
.default(false)
.describe('Set to true to unlike'),
};
/** xhs_list_my_notes — no parameters. */
@@ -142,11 +137,6 @@ export const ListMyNotesSchema = {};
/** xhs_favorite */
export const FavoriteSchema = {
feed_id: z.string().describe('Feed ID to favorite'),
feed_id: z.string().describe('Feed ID to toggle favorite'),
xsec_token: z.string().describe('Security token for the feed'),
unfavorite: z
.boolean()
.optional()
.default(false)
.describe('Set to true to unfavorite'),
};
+4 -4
View File
@@ -190,15 +190,15 @@ export const XHS_SELECTORS = {
interaction: {
/** Like button on the feed detail page. */
likeButton: '.engage-bar .like-wrapper, span.like-wrapper',
likeButton: '.engage-bar-style .like-wrapper',
/** Like button in active/liked state. */
likeButtonActive: '.engage-bar .like-wrapper.active, span.like-wrapper.active',
likeButtonActive: '.engage-bar-style .like-wrapper.like-active',
/** Like count element next to the like button. */
likeCount: '.engage-bar .like-wrapper .count',
/** Favorite / collect button on the feed detail page. */
favoriteButton: '.engage-bar .collect-wrapper, span.collect-wrapper',
favoriteButton: '.engage-bar-style .collect-wrapper',
/** Favorite button in active/favorited state. */
favoriteButtonActive: '.engage-bar .collect-wrapper.active, span.collect-wrapper.active',
favoriteButtonActive: '.engage-bar-style .collect-wrapper.collect-active',
/** Favorite count element next to the favorite button. */
favoriteCount: '.engage-bar .collect-wrapper .count',
/** Container for the interaction bar at the bottom of a feed detail. */
+2
View File
@@ -54,6 +54,8 @@ export interface FeedDetail {
collectCount: number;
commentCount: number;
shareCount: number;
isLiked: boolean;
isFavorited: boolean;
createTime: string;
lastUpdateTime: string;
ipLocation: string;