集成互动功能到帖子详情,删除独立互动页面
- 点赞/收藏改为纯 toggle,移除 unlike/unfavorite 参数 - 帖子详情 API 返回 isLiked/isFavorited 状态(SVG xlink:href 检测) - 前端两个切换按钮替代原四个独立按钮 - 修复 __INITIAL_STATE__ Vue 响应式代理序列化(structuredClone + fallback) - 修复 overlay 场景下点赞按钮误点 feed 列表元素(.last() 定位) - 删除 InteractionsPage 及相关路由/导航
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface FeedDetail {
|
||||
collectCount: number;
|
||||
commentCount: number;
|
||||
shareCount: number;
|
||||
isLiked: boolean;
|
||||
isFavorited: boolean;
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
ipLocation: string;
|
||||
|
||||
Reference in New Issue
Block a user