优化通知与评论MCP:评论返回ID并将通知分页升级为Keyset游标
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user