优化通知与评论MCP:评论返回ID并将通知分页升级为Keyset游标

This commit is contained in:
2026-03-03 13:02:35 +08:00
parent ceaad6f15b
commit 68f42a9fd4
5 changed files with 348 additions and 31 deletions
+164 -8
View File
@@ -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<boolean> {
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<string | undefined> {
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<string, unknown>).__INITIAL_STATE__ as
Record<string, unknown> | undefined;
const note = state?.note as Record<string, unknown> | undefined;
const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
if (!map) return null;
for (const entry of Object.values(map)) {
const comments = entry?.comments as { list?: Array<Record<string, unknown>> } | 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<string | undefined> {
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<string, unknown>).__INITIAL_STATE__ as
Record<string, unknown> | undefined;
const note = state?.note as Record<string, unknown> | undefined;
const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
if (!map) return null;
for (const entry of Object.values(map)) {
const comments = entry?.comments as { list?: Array<Record<string, unknown>> } | 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<Record<string, unknown>>;
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;
}
+55 -18
View File
@@ -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<T>(
};
}
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<T>(
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 } : {}),
},
});
@@ -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;
+59 -2
View File
@@ -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) {
+3 -3
View File
@@ -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()