优化通知与评论MCP:评论返回ID并将通知分页升级为Keyset游标
This commit is contained in:
@@ -38,7 +38,7 @@ export async function postComment(
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
xsecToken: string,
|
xsecToken: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean; comment_id?: string }> {
|
||||||
log.info({ feedId }, 'Posting comment on note');
|
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.
|
// Check for the comment text in the page to verify success.
|
||||||
const pageContent = await page.content();
|
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,
|
content: string,
|
||||||
commentId?: string,
|
commentId?: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean; reply_id?: string }> {
|
||||||
log.info({ feedId, commentId, userId }, 'Replying to comment on note');
|
log.info({ feedId, commentId, userId }, 'Replying to comment on note');
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -212,11 +217,16 @@ export async function replyComment(
|
|||||||
await page.waitForTimeout(SUBMIT_SETTLE_MS);
|
await page.waitForTimeout(SUBMIT_SETTLE_MS);
|
||||||
|
|
||||||
const pageContent = await page.content();
|
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();
|
await submitBtn.click();
|
||||||
return true;
|
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 { replyNotification } from './notification.js';
|
||||||
import {
|
import {
|
||||||
getNotificationStateStore,
|
getNotificationStateStore,
|
||||||
|
type NotificationKeysetCursor,
|
||||||
type NotificationTask,
|
type NotificationTask,
|
||||||
type NotificationTaskStatus,
|
type NotificationTaskStatus,
|
||||||
} from './notification-state.js';
|
} 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>(
|
async function runWithIdempotency<T>(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
requestId: string | undefined,
|
requestId: string | undefined,
|
||||||
@@ -783,19 +818,20 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
|
|
||||||
const store = getNotificationStateStore();
|
const store = getNotificationStateStore();
|
||||||
const limit = clampPageSize(args.max_count);
|
const limit = clampPageSize(args.max_count);
|
||||||
const offset = parseCursor(args.cursor);
|
const keysetCursor = decodeNotificationCursor(args.cursor);
|
||||||
const total = store.countByStatuses(statuses);
|
const page = store.listByStatusesKeyset(statuses, limit, keysetCursor);
|
||||||
const tasks = store.listByStatuses(statuses, limit, offset);
|
const nextCursor = page.nextCursor
|
||||||
const nextOffset = offset + tasks.length;
|
? encodeNotificationCursor(page.nextCursor)
|
||||||
const nextCursor = nextOffset < total ? String(nextOffset) : undefined;
|
: undefined;
|
||||||
|
|
||||||
return ok(tasks, {
|
return ok(page.tasks, {
|
||||||
...(syncResult ? { synced: syncResult } : {}),
|
...(syncResult ? { synced: syncResult } : {}),
|
||||||
pagination: {
|
pagination: {
|
||||||
cursor: args.cursor ?? '0',
|
mode: 'keyset',
|
||||||
|
cursor: args.cursor ?? null,
|
||||||
max_count: limit,
|
max_count: limit,
|
||||||
returned: tasks.length,
|
returned: page.tasks.length,
|
||||||
total,
|
has_more: page.hasMore,
|
||||||
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -888,18 +924,19 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
const store = getNotificationStateStore();
|
const store = getNotificationStateStore();
|
||||||
const statuses: NotificationTaskStatus[] = ['failed'];
|
const statuses: NotificationTaskStatus[] = ['failed'];
|
||||||
const limit = clampPageSize(args.max_count);
|
const limit = clampPageSize(args.max_count);
|
||||||
const offset = parseCursor(args.cursor);
|
const keysetCursor = decodeNotificationCursor(args.cursor);
|
||||||
const total = store.countByStatuses(statuses);
|
const page = store.listByStatusesKeyset(statuses, limit, keysetCursor);
|
||||||
const tasks = store.listByStatuses(statuses, limit, offset);
|
const nextCursor = page.nextCursor
|
||||||
const nextOffset = offset + tasks.length;
|
? encodeNotificationCursor(page.nextCursor)
|
||||||
const nextCursor = nextOffset < total ? String(nextOffset) : undefined;
|
: undefined;
|
||||||
|
|
||||||
return ok(tasks, {
|
return ok(page.tasks, {
|
||||||
pagination: {
|
pagination: {
|
||||||
cursor: args.cursor ?? '0',
|
mode: 'keyset',
|
||||||
|
cursor: args.cursor ?? null,
|
||||||
max_count: limit,
|
max_count: limit,
|
||||||
returned: tasks.length,
|
returned: page.tasks.length,
|
||||||
total,
|
has_more: page.hasMore,
|
||||||
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export interface NotificationUpsertResult {
|
|||||||
updated: number;
|
updated: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationKeysetCursor {
|
||||||
|
firstSeenAt: number;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
const PLATFORM = 'xiaohongshu';
|
const PLATFORM = 'xiaohongshu';
|
||||||
const DB_FILENAME = 'automation.db';
|
const DB_FILENAME = 'automation.db';
|
||||||
const log = logger.child({ module: 'xhs-notification-state' });
|
const log = logger.child({ module: 'xhs-notification-state' });
|
||||||
@@ -221,6 +226,68 @@ export class NotificationStateStore {
|
|||||||
return rows.map((r) => this.rowToTask(r));
|
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 {
|
countByStatuses(statuses: NotificationTaskStatus[]): number {
|
||||||
if (statuses.length === 0) return 0;
|
if (statuses.length === 0) return 0;
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const ReplyNotificationBodySchema = z.object({
|
|||||||
|
|
||||||
const GetUnprocessedNotificationsQuerySchema = z.object({
|
const GetUnprocessedNotificationsQuerySchema = z.object({
|
||||||
max_count: GetUnprocessedNotificationsSchema.max_count,
|
max_count: GetUnprocessedNotificationsSchema.max_count,
|
||||||
|
cursor: GetUnprocessedNotificationsSchema.cursor,
|
||||||
sync: GetUnprocessedNotificationsSchema.sync,
|
sync: GetUnprocessedNotificationsSchema.sync,
|
||||||
statuses: GetUnprocessedNotificationsSchema.statuses,
|
statuses: GetUnprocessedNotificationsSchema.statuses,
|
||||||
});
|
});
|
||||||
@@ -175,6 +176,40 @@ function errorResponse(code: string, message: string): ApiErrorResponse {
|
|||||||
return { success: false, error: { code, message } };
|
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
|
// Rate limiters
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -773,6 +808,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
|
|
||||||
const parsed = GetUnprocessedNotificationsQuerySchema.parse({
|
const parsed = GetUnprocessedNotificationsQuerySchema.parse({
|
||||||
max_count: req.query.max_count ? Number(req.query.max_count) : undefined,
|
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
|
sync: req.query.sync === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: ['1', 'true', 'yes'].includes(String(req.query.sync).toLowerCase()),
|
: ['1', 'true', 'yes'].includes(String(req.query.sync).toLowerCase()),
|
||||||
@@ -789,13 +825,34 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
? parsed.statuses
|
? parsed.statuses
|
||||||
: ['new', 'failed'];
|
: ['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({
|
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 } : {}),
|
...(syncResult ? { synced: syncResult } : {}),
|
||||||
}) as ApiResponse<{
|
}) as ApiResponse<{
|
||||||
tasks: NotificationTask[];
|
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 };
|
synced?: { fetched: number; inserted: number; updated: number };
|
||||||
}>);
|
}>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const ListFeedsSchema = {
|
|||||||
cursor: z
|
cursor: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Pagination cursor returned by previous call'),
|
.describe('Keyset pagination cursor returned by previous call'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** xhs_search */
|
/** xhs_search */
|
||||||
@@ -211,7 +211,7 @@ export const ListMyNotesSchema = {
|
|||||||
cursor: z
|
cursor: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Pagination cursor returned by previous call'),
|
.describe('Keyset pagination cursor returned by previous call'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- Phase 5: Notifications & automation -----------------------------------
|
// -- Phase 5: Notifications & automation -----------------------------------
|
||||||
@@ -258,7 +258,7 @@ export const GetUnprocessedNotificationsSchema = {
|
|||||||
cursor: z
|
cursor: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Pagination cursor returned by previous call'),
|
.describe('Keyset pagination cursor returned by previous call'),
|
||||||
statuses: z
|
statuses: z
|
||||||
.array(z.enum(['new', 'pending', 'failed']))
|
.array(z.enum(['new', 'pending', 'failed']))
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
Reference in New Issue
Block a user