diff --git a/src/platforms/xiaohongshu/comment.ts b/src/platforms/xiaohongshu/comment.ts index 1ffc28d..4281080 100644 --- a/src/platforms/xiaohongshu/comment.ts +++ b/src/platforms/xiaohongshu/comment.ts @@ -38,7 +38,7 @@ export async function postComment( feedId: string, xsecToken: string, content: string, -): Promise<{ success: boolean }> { +): Promise<{ success: boolean; comment_id?: string }> { log.info({ feedId }, 'Posting comment on note'); // ------------------------------------------------------------------------- @@ -91,11 +91,16 @@ export async function postComment( // Check for the comment text in the page to verify success. const pageContent = await page.content(); - const success = pageContent.includes(content.slice(0, 20)); + const textHit = pageContent.includes(content.slice(0, 20)); + const commentId = await extractTopLevelCommentId(page, content); + const success = textHit || !!commentId; - log.info({ feedId, success }, 'Comment post complete'); + log.info({ feedId, success, commentId }, 'Comment post complete'); - return { success }; + return { + success, + ...(commentId ? { comment_id: commentId } : {}), + }; } // --------------------------------------------------------------------------- @@ -120,7 +125,7 @@ export async function replyComment( content: string, commentId?: string, userId?: string, -): Promise<{ success: boolean }> { +): Promise<{ success: boolean; reply_id?: string }> { log.info({ feedId, commentId, userId }, 'Replying to comment on note'); // ------------------------------------------------------------------------- @@ -212,11 +217,16 @@ export async function replyComment( await page.waitForTimeout(SUBMIT_SETTLE_MS); const pageContent = await page.content(); - const success = pageContent.includes(content.slice(0, 20)); + const textHit = pageContent.includes(content.slice(0, 20)); + const replyId = await extractReplyCommentId(page, content, commentId); + const success = textHit || !!replyId; - log.info({ feedId, commentId, success }, 'Reply post complete'); + log.info({ feedId, commentId, success, replyId }, 'Reply post complete'); - return { success }; + return { + success, + ...(replyId ? { reply_id: replyId } : {}), + }; } // --------------------------------------------------------------------------- @@ -320,3 +330,149 @@ async function submitComment(page: Page): Promise { await submitBtn.click(); return true; } + +function normalizeText(input: string): string { + return input.replace(/\s+/g, ' ').trim(); +} + +function parseCommentElementId(value: string | null): string { + if (!value) return ''; + return value.replace(/^comment-/, '').trim(); +} + +function matchContent(candidate: string, target: string): boolean { + const c = normalizeText(candidate); + const t = normalizeText(target); + if (!c || !t) return false; + return c === t || c.includes(t) || t.includes(c); +} + +async function extractTopLevelCommentId( + page: Page, + content: string, +): Promise { + const fromStore = await page.evaluate((targetContent: string) => { + const match = (candidate: unknown): boolean => { + const c = String(candidate ?? '').replace(/\s+/g, ' ').trim(); + const t = String(targetContent ?? '').replace(/\s+/g, ' ').trim(); + if (!c || !t) return false; + return c === t || c.includes(t) || t.includes(c); + }; + const normalizeId = (id: unknown): string => + String(id ?? '').replace(/^comment-/, '').trim(); + + const state = (window as unknown as Record).__INITIAL_STATE__ as + Record | undefined; + const note = state?.note as Record | undefined; + const map = note?.noteDetailMap as Record> | undefined; + if (!map) return null; + + for (const entry of Object.values(map)) { + const comments = entry?.comments as { list?: Array> } | undefined; + if (!comments?.list) continue; + for (const one of comments.list) { + if (match(one.content)) { + const id = normalizeId(one.id); + if (id) return id; + } + } + } + return null; + }, content).catch(() => null); + + if (typeof fromStore === 'string' && fromStore) { + return fromStore; + } + + const candidates = await page.$$('.parent-comment .comment-item, [id^="comment-"]'); + for (const candidate of candidates) { + const text = (await candidate.$eval('.content', (el) => el.textContent ?? '').catch(() => '')); + if (!matchContent(text, content)) continue; + + const id = parseCommentElementId( + await candidate.getAttribute('id').catch(() => null), + ) || parseCommentElementId( + await candidate.getAttribute('data-comment-id').catch(() => null), + ) || parseCommentElementId( + await candidate.getAttribute('data-id').catch(() => null), + ); + if (id) return id; + } + + return undefined; +} + +async function extractReplyCommentId( + page: Page, + content: string, + parentCommentId?: string, +): Promise { + const fromStore = await page.evaluate( + (args: { targetContent: string; parentCommentId?: string }) => { + const match = (candidate: unknown): boolean => { + const c = String(candidate ?? '').replace(/\s+/g, ' ').trim(); + const t = String(args.targetContent ?? '').replace(/\s+/g, ' ').trim(); + if (!c || !t) return false; + return c === t || c.includes(t) || t.includes(c); + }; + const normalizeId = (id: unknown): string => + String(id ?? '').replace(/^comment-/, '').trim(); + + const state = (window as unknown as Record).__INITIAL_STATE__ as + Record | undefined; + const note = state?.note as Record | undefined; + const map = note?.noteDetailMap as Record> | undefined; + if (!map) return null; + + for (const entry of Object.values(map)) { + const comments = entry?.comments as { list?: Array> } | undefined; + if (!comments?.list) continue; + for (const parent of comments.list) { + const parentId = normalizeId(parent.id); + if (args.parentCommentId && parentId !== args.parentCommentId) continue; + + const subs = (parent.subComments ?? parent.sub_comments ?? []) as Array>; + for (const sub of subs) { + if (match(sub.content)) { + const id = normalizeId(sub.id); + if (id) return id; + } + } + } + } + return null; + }, + { targetContent: content, parentCommentId }, + ).catch(() => null); + + if (typeof fromStore === 'string' && fromStore) { + return fromStore; + } + + const parentCandidates = parentCommentId + ? await page.$$( + `[id="comment-${parentCommentId}"], [data-comment-id="${parentCommentId}"], [data-id="${parentCommentId}"]`, + ) + : await page.$$('.parent-comment'); + + for (const parent of parentCandidates) { + const replyItems = await parent.$$('.sub-comment-item, [id^="comment-"]'); + for (const item of replyItems) { + const text = await item + .$eval('.content', (el) => el.textContent ?? '') + .catch(() => item.textContent().catch(() => '')); + if (!matchContent(text ?? '', content)) continue; + + const id = parseCommentElementId( + await item.getAttribute('id').catch(() => null), + ) || parseCommentElementId( + await item.getAttribute('data-comment-id').catch(() => null), + ) || parseCommentElementId( + await item.getAttribute('data-id').catch(() => null), + ); + if (id) return id; + } + } + + return undefined; +} diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index 32b1c2d..2a87d61 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -22,6 +22,7 @@ import { setLikeState, setFavoriteState } from './interaction.js'; import { replyNotification } from './notification.js'; import { getNotificationStateStore, + type NotificationKeysetCursor, type NotificationTask, type NotificationTaskStatus, } from './notification-state.js'; @@ -123,6 +124,40 @@ function paginateArray( }; } +function encodeNotificationCursor(cursor: NotificationKeysetCursor): string { + return Buffer.from( + JSON.stringify({ + first_seen_at: cursor.firstSeenAt, + fingerprint: cursor.fingerprint, + }), + 'utf8', + ).toString('base64url'); +} + +function decodeNotificationCursor(cursor?: string): NotificationKeysetCursor | undefined { + if (!cursor) return undefined; + try { + const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + first_seen_at?: unknown; + fingerprint?: unknown; + }; + const firstSeenAt = raw.first_seen_at; + const fingerprint = raw.fingerprint; + if ( + typeof firstSeenAt !== 'number' || + !Number.isInteger(firstSeenAt) || + firstSeenAt < 0 || + typeof fingerprint !== 'string' || + fingerprint.length === 0 + ) { + throw new Error('Invalid notification cursor payload'); + } + return { firstSeenAt, fingerprint }; + } catch { + throw new Error('Invalid cursor for notification keyset pagination'); + } +} + async function runWithIdempotency( toolName: string, requestId: string | undefined, @@ -783,19 +818,20 @@ export const xiaohongshuPlugin: PlatformPlugin = { const store = getNotificationStateStore(); const limit = clampPageSize(args.max_count); - const offset = parseCursor(args.cursor); - const total = store.countByStatuses(statuses); - const tasks = store.listByStatuses(statuses, limit, offset); - const nextOffset = offset + tasks.length; - const nextCursor = nextOffset < total ? String(nextOffset) : undefined; + const keysetCursor = decodeNotificationCursor(args.cursor); + const page = store.listByStatusesKeyset(statuses, limit, keysetCursor); + const nextCursor = page.nextCursor + ? encodeNotificationCursor(page.nextCursor) + : undefined; - return ok(tasks, { + return ok(page.tasks, { ...(syncResult ? { synced: syncResult } : {}), pagination: { - cursor: args.cursor ?? '0', + mode: 'keyset', + cursor: args.cursor ?? null, max_count: limit, - returned: tasks.length, - total, + returned: page.tasks.length, + has_more: page.hasMore, ...(nextCursor ? { next_cursor: nextCursor } : {}), }, }); @@ -888,18 +924,19 @@ export const xiaohongshuPlugin: PlatformPlugin = { const store = getNotificationStateStore(); const statuses: NotificationTaskStatus[] = ['failed']; const limit = clampPageSize(args.max_count); - const offset = parseCursor(args.cursor); - const total = store.countByStatuses(statuses); - const tasks = store.listByStatuses(statuses, limit, offset); - const nextOffset = offset + tasks.length; - const nextCursor = nextOffset < total ? String(nextOffset) : undefined; + const keysetCursor = decodeNotificationCursor(args.cursor); + const page = store.listByStatusesKeyset(statuses, limit, keysetCursor); + const nextCursor = page.nextCursor + ? encodeNotificationCursor(page.nextCursor) + : undefined; - return ok(tasks, { + return ok(page.tasks, { pagination: { - cursor: args.cursor ?? '0', + mode: 'keyset', + cursor: args.cursor ?? null, max_count: limit, - returned: tasks.length, - total, + returned: page.tasks.length, + has_more: page.hasMore, ...(nextCursor ? { next_cursor: nextCursor } : {}), }, }); diff --git a/src/platforms/xiaohongshu/notification-state.ts b/src/platforms/xiaohongshu/notification-state.ts index 497ffcf..bb7f06f 100644 --- a/src/platforms/xiaohongshu/notification-state.ts +++ b/src/platforms/xiaohongshu/notification-state.ts @@ -54,6 +54,11 @@ export interface NotificationUpsertResult { updated: number; } +export interface NotificationKeysetCursor { + firstSeenAt: number; + fingerprint: string; +} + const PLATFORM = 'xiaohongshu'; const DB_FILENAME = 'automation.db'; const log = logger.child({ module: 'xhs-notification-state' }); @@ -221,6 +226,68 @@ export class NotificationStateStore { return rows.map((r) => this.rowToTask(r)); } + listByStatusesKeyset( + statuses: NotificationTaskStatus[], + maxCount: number, + cursor?: NotificationKeysetCursor, + ): { tasks: NotificationTask[]; hasMore: boolean; nextCursor?: NotificationKeysetCursor } { + if (statuses.length === 0 || maxCount <= 0) { + return { tasks: [], hasMore: false }; + } + + const placeholders = statuses.map(() => '?').join(', '); + const condition = cursor + ? ` + AND ( + first_seen_at > ? + OR (first_seen_at = ? AND fingerprint > ?) + ) + ` + : ''; + + const query = ` + SELECT + fingerprint, user_id, nickname, avatar, content, type, time, + feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at, + retry_count, last_attempt_at, replied_at, reply_content, error_message + FROM notification_tasks + WHERE status IN (${placeholders}) + ${condition} + ORDER BY first_seen_at ASC, fingerprint ASC + LIMIT ? + `; + + const stmt = this.db.prepare(query); + const limitWithSentinel = maxCount + 1; + const rows = cursor + ? stmt.all( + ...statuses, + cursor.firstSeenAt, + cursor.firstSeenAt, + cursor.fingerprint, + limitWithSentinel, + ) as unknown as NotificationRow[] + : stmt.all(...statuses, limitWithSentinel) as unknown as NotificationRow[]; + + const hasMore = rows.length > maxCount; + const pageRows = hasMore ? rows.slice(0, maxCount) : rows; + const tasks = pageRows.map((r) => this.rowToTask(r)); + + if (!hasMore || pageRows.length === 0) { + return { tasks, hasMore }; + } + + const last = pageRows[pageRows.length - 1]!; + return { + tasks, + hasMore, + nextCursor: { + firstSeenAt: last.first_seen_at, + fingerprint: last.fingerprint, + }, + }; + } + countByStatuses(statuses: NotificationTaskStatus[]): number { if (statuses.length === 0) return 0; diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index 2482063..c18339d 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -134,6 +134,7 @@ const ReplyNotificationBodySchema = z.object({ const GetUnprocessedNotificationsQuerySchema = z.object({ max_count: GetUnprocessedNotificationsSchema.max_count, + cursor: GetUnprocessedNotificationsSchema.cursor, sync: GetUnprocessedNotificationsSchema.sync, statuses: GetUnprocessedNotificationsSchema.statuses, }); @@ -175,6 +176,40 @@ function errorResponse(code: string, message: string): ApiErrorResponse { return { success: false, error: { code, message } }; } +function encodeNotificationCursor(cursor: { firstSeenAt: number; fingerprint: string }): string { + return Buffer.from( + JSON.stringify({ + first_seen_at: cursor.firstSeenAt, + fingerprint: cursor.fingerprint, + }), + 'utf8', + ).toString('base64url'); +} + +function decodeNotificationCursor(cursor?: string): { firstSeenAt: number; fingerprint: string } | undefined { + if (!cursor) return undefined; + try { + const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + first_seen_at?: unknown; + fingerprint?: unknown; + }; + const firstSeenAt = raw.first_seen_at; + const fingerprint = raw.fingerprint; + if ( + typeof firstSeenAt !== 'number' || + !Number.isInteger(firstSeenAt) || + firstSeenAt < 0 || + typeof fingerprint !== 'string' || + fingerprint.length === 0 + ) { + throw new Error('Invalid notification cursor payload'); + } + return { firstSeenAt, fingerprint }; + } catch { + throw new Error('Invalid cursor for notification keyset pagination'); + } +} + // --------------------------------------------------------------------------- // Rate limiters // --------------------------------------------------------------------------- @@ -773,6 +808,7 @@ export function createXhsRoutes(browser: BrowserManager): Router { const parsed = GetUnprocessedNotificationsQuerySchema.parse({ max_count: req.query.max_count ? Number(req.query.max_count) : undefined, + cursor: req.query.cursor ? String(req.query.cursor) : undefined, sync: req.query.sync === undefined ? undefined : ['1', 'true', 'yes'].includes(String(req.query.sync).toLowerCase()), @@ -789,13 +825,34 @@ export function createXhsRoutes(browser: BrowserManager): Router { ? parsed.statuses : ['new', 'failed']; - const tasks = getNotificationStateStore().listByStatuses(statuses, parsed.max_count); + const store = getNotificationStateStore(); + const keysetCursor = decodeNotificationCursor(parsed.cursor); + const pageResult = store.listByStatusesKeyset(statuses, parsed.max_count, keysetCursor); + const nextCursor = pageResult.nextCursor + ? encodeNotificationCursor(pageResult.nextCursor) + : undefined; res.json(successResponse({ - tasks, + tasks: pageResult.tasks, + pagination: { + mode: 'keyset', + cursor: parsed.cursor ?? null, + max_count: parsed.max_count, + returned: pageResult.tasks.length, + has_more: pageResult.hasMore, + ...(nextCursor ? { next_cursor: nextCursor } : {}), + }, ...(syncResult ? { synced: syncResult } : {}), }) as ApiResponse<{ tasks: NotificationTask[]; + pagination: { + mode: 'keyset'; + cursor: string | null; + max_count: number; + returned: number; + has_more: boolean; + next_cursor?: string; + }; synced?: { fetched: number; inserted: number; updated: number }; }>); } catch (err) { diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 1dc4f9a..4e4a31b 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -34,7 +34,7 @@ export const ListFeedsSchema = { cursor: z .string() .optional() - .describe('Pagination cursor returned by previous call'), + .describe('Keyset pagination cursor returned by previous call'), }; /** xhs_search */ @@ -211,7 +211,7 @@ export const ListMyNotesSchema = { cursor: z .string() .optional() - .describe('Pagination cursor returned by previous call'), + .describe('Keyset pagination cursor returned by previous call'), }; // -- Phase 5: Notifications & automation ----------------------------------- @@ -258,7 +258,7 @@ export const GetUnprocessedNotificationsSchema = { cursor: z .string() .optional() - .describe('Pagination cursor returned by previous call'), + .describe('Keyset pagination cursor returned by previous call'), statuses: z .array(z.enum(['new', 'pending', 'failed'])) .optional()