改进MCP发布体验:URL媒体下载、内容限制校验、发布返回笔记链接、新增查看已发布笔记工具;整合发布入口至小红书页面modal;端口统一改为9527;新增pnpm run restart脚本
This commit is contained in:
+1
-1
@@ -100,7 +100,7 @@ export interface AppConfig {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const config: AppConfig = {
|
||||
port: envInt('PORT', 3000),
|
||||
port: envInt('PORT', 9527),
|
||||
host,
|
||||
headless: envBool('HEADLESS', true),
|
||||
browserBin: process.env['BROWSER_BIN'] || undefined,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Router } from 'express';
|
||||
import type { BrowserManager } from '../../browser/manager.js';
|
||||
import { config } from '../../config/index.js';
|
||||
import { withErrorHandling } from '../../utils/errors.js';
|
||||
import { validateMediaPath } from '../../utils/downloader.js';
|
||||
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
|
||||
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
|
||||
import { listFeeds } from './feeds.js';
|
||||
import { searchFeeds } from './search.js';
|
||||
@@ -12,6 +12,7 @@ import { getFeedDetail } from './feed-detail.js';
|
||||
import { getUserProfile } from './user-profile.js';
|
||||
import { publishImageNote } from './publish.js';
|
||||
import { publishVideoNote } from './publish-video.js';
|
||||
import { listMyNotes } from './my-notes.js';
|
||||
import { postComment, replyComment } from './comment.js';
|
||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||
import { createXhsRoutes } from './routes.js';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
GetUserProfileSchema,
|
||||
PublishImageSchema,
|
||||
PublishVideoSchema,
|
||||
ListMyNotesSchema,
|
||||
PostCommentSchema,
|
||||
ReplyCommentSchema,
|
||||
LikeSchema,
|
||||
@@ -287,6 +289,38 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
||||
},
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// My published notes (1 tool)
|
||||
// =====================================================================
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// xhs_list_my_notes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
server.tool(
|
||||
'xhs_list_my_notes',
|
||||
'List your published notes on Xiaohongshu from the creator center',
|
||||
ListMyNotesSchema,
|
||||
async () => {
|
||||
return withErrorHandling('xhs_list_my_notes', async () => {
|
||||
const timeoutMs =
|
||||
config.operationTimeouts['feed_list'] ??
|
||||
config.operationTimeouts['default'] ??
|
||||
60_000;
|
||||
|
||||
const notes = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) => listMyNotes(page),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(notes) }],
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// Phase 4: Content publishing (2 tools)
|
||||
// =====================================================================
|
||||
@@ -301,40 +335,39 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
||||
PublishImageSchema,
|
||||
async (args) => {
|
||||
return withErrorHandling('xhs_publish_image', async () => {
|
||||
// Fail fast: validate all image paths BEFORE acquiring a browser page.
|
||||
const validatedPaths: string[] = [];
|
||||
for (const imagePath of args.images) {
|
||||
const resolved = await validateMediaPath(imagePath, {
|
||||
maxSizeMB: IMAGE_MAX_SIZE_MB,
|
||||
});
|
||||
validatedPaths.push(resolved);
|
||||
// Resolve all images (local path or URL download) before acquiring browser.
|
||||
const resolved: Array<{ path: string; temporary: boolean }> = [];
|
||||
for (const img of args.images) {
|
||||
resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB }));
|
||||
}
|
||||
const validatedPaths = resolved.map((r) => r.path);
|
||||
|
||||
const timeoutMs =
|
||||
config.operationTimeouts['publish'] ??
|
||||
config.operationTimeouts['default'] ??
|
||||
300_000;
|
||||
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishImageNote(page, args.title, args.content, validatedPaths, {
|
||||
tags: args.tags,
|
||||
scheduleAt: args.schedule_at,
|
||||
isOriginal: args.is_original,
|
||||
visibility: args.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishImageNote(page, args.title, args.content, validatedPaths, {
|
||||
tags: args.tags,
|
||||
scheduleAt: args.schedule_at,
|
||||
isOriginal: args.is_original,
|
||||
visibility: args.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} finally {
|
||||
for (const r of resolved) {
|
||||
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -349,35 +382,32 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
||||
PublishVideoSchema,
|
||||
async (args) => {
|
||||
return withErrorHandling('xhs_publish_video', async () => {
|
||||
// Fail fast: validate the video path BEFORE acquiring a browser page.
|
||||
const validatedPath = await validateMediaPath(args.video, {
|
||||
maxSizeMB: VIDEO_MAX_SIZE_MB,
|
||||
});
|
||||
// Resolve video (local path or URL download) before acquiring browser.
|
||||
const resolvedVideo = await resolveMediaInput(args.video, { maxSizeMB: VIDEO_MAX_SIZE_MB });
|
||||
|
||||
const timeoutMs =
|
||||
config.operationTimeouts['publish'] ??
|
||||
config.operationTimeouts['default'] ??
|
||||
300_000;
|
||||
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishVideoNote(page, args.title, args.content, validatedPath, {
|
||||
tags: args.tags,
|
||||
scheduleAt: args.schedule_at,
|
||||
visibility: args.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishVideoNote(page, args.title, args.content, resolvedVideo.path, {
|
||||
tags: args.tags,
|
||||
scheduleAt: args.schedule_at,
|
||||
visibility: args.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} finally {
|
||||
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Page } from 'rebrowser-playwright';
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAIN_EXPLORE_URL = 'https://www.xiaohongshu.com/explore';
|
||||
const CREATOR_WORKS_URL = 'https://creator.xiaohongshu.com/publish/works';
|
||||
|
||||
const log = logger.child({ module: 'xhs-my-notes' });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MyNote {
|
||||
noteId: string;
|
||||
title: string;
|
||||
coverUrl?: string;
|
||||
noteUrl: string;
|
||||
type: 'image' | 'video' | 'unknown';
|
||||
publishTime?: string;
|
||||
likeCount?: number;
|
||||
commentCount?: number;
|
||||
collectCount?: number;
|
||||
viewCount?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listMyNotes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Navigate to the creator center works page and extract the list of published notes.
|
||||
*/
|
||||
export async function listMyNotes(page: Page): Promise<MyNote[]> {
|
||||
log.info('Navigating to creator works page');
|
||||
|
||||
// Establish session on main site first (same pattern as publish).
|
||||
await page.goto(MAIN_EXPLORE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
await page.goto(CREATOR_WORKS_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
throw new Error('Creator center redirected to login — cookies may be expired');
|
||||
}
|
||||
|
||||
// Wait for note items to appear.
|
||||
await page.waitForSelector('.works-item, .note-item, [class*="works"], [class*="note-card"]', {
|
||||
timeout: 15_000,
|
||||
}).catch(() => {
|
||||
log.debug('Note list selector not found, attempting extraction anyway');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const notes = await page.evaluate((): MyNote[] => {
|
||||
const results: MyNote[] = [];
|
||||
|
||||
// Try multiple selector patterns for different creator center versions.
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll('.works-item, .note-item, [class*="works-item"], [class*="note-card"]'),
|
||||
);
|
||||
|
||||
for (const el of candidates) {
|
||||
// Extract note ID from link href.
|
||||
const link = el.querySelector('a[href*="/explore/"], a[href*="/note/"]') as HTMLAnchorElement | null;
|
||||
const href = link?.href ?? '';
|
||||
|
||||
const idMatch = href.match(/\/explore\/([a-f0-9]+)|\/note\/([a-f0-9]+)/);
|
||||
const noteId = idMatch?.[1] ?? idMatch?.[2] ?? '';
|
||||
if (!noteId) continue;
|
||||
|
||||
const noteUrl = `https://www.xiaohongshu.com/explore/${noteId}`;
|
||||
|
||||
// Title.
|
||||
const titleEl = el.querySelector('.title, [class*="title"], h3, h4');
|
||||
const title = titleEl?.textContent?.trim() ?? '';
|
||||
|
||||
// Cover image.
|
||||
const imgEl = el.querySelector('img') as HTMLImageElement | null;
|
||||
const coverUrl = imgEl?.src ?? undefined;
|
||||
|
||||
// Note type — look for video indicator.
|
||||
const hasVideo = el.querySelector('[class*="video"], video, [class*="play"]') !== null;
|
||||
const type: 'image' | 'video' | 'unknown' = hasVideo ? 'video' : title || coverUrl ? 'image' : 'unknown';
|
||||
|
||||
// Stats — try to find numeric values in stat spans.
|
||||
const statEls = Array.from(el.querySelectorAll('[class*="count"], [class*="stat"], [class*="data"]'));
|
||||
const nums = statEls.map((s) => {
|
||||
const t = s.textContent?.trim() ?? '';
|
||||
const n = parseFloat(t.replace(/[万w]/i, '0000').replace(/[,,]/g, ''));
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
});
|
||||
|
||||
// Publish time.
|
||||
const timeEl = el.querySelector('time, [class*="time"], [class*="date"]');
|
||||
const publishTime = timeEl?.textContent?.trim() ?? undefined;
|
||||
|
||||
results.push({
|
||||
noteId,
|
||||
title,
|
||||
coverUrl,
|
||||
noteUrl,
|
||||
type,
|
||||
publishTime,
|
||||
likeCount: nums[0],
|
||||
commentCount: nums[1],
|
||||
collectCount: nums[2],
|
||||
viewCount: nums[3],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
log.info({ count: notes.length }, 'My notes extracted');
|
||||
return notes;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function publishVideoNote(
|
||||
content: string,
|
||||
videoPath: string,
|
||||
options?: PublishVideoOptions,
|
||||
): Promise<{ success: boolean; noteId?: string }> {
|
||||
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||
log.info(
|
||||
{ hasOptions: !!options },
|
||||
'Starting video note publish',
|
||||
@@ -260,7 +260,7 @@ async function setSchedule(page: Page, scheduleAt: string): Promise<void> {
|
||||
*/
|
||||
async function waitForPublishResult(
|
||||
page: Page,
|
||||
): Promise<{ success: boolean; noteId?: string }> {
|
||||
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||
const urlChangePromise = page
|
||||
.waitForURL(sel.publishSuccessUrlPattern, { timeout: 30_000 })
|
||||
.then(() => true)
|
||||
@@ -290,8 +290,9 @@ async function waitForPublishResult(
|
||||
}
|
||||
|
||||
const noteId = extractNoteIdFromUrl(page.url());
|
||||
const noteUrl = noteId ? `https://www.xiaohongshu.com/explore/${noteId}` : undefined;
|
||||
|
||||
return { success: true, noteId };
|
||||
return { success: true, noteId, noteUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function publishImageNote(
|
||||
content: string,
|
||||
imagePaths: string[],
|
||||
options?: PublishImageOptions,
|
||||
): Promise<{ success: boolean; noteId?: string }> {
|
||||
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||
log.info(
|
||||
{ imageCount: imagePaths.length, hasOptions: !!options },
|
||||
'Starting image note publish',
|
||||
@@ -354,7 +354,7 @@ async function setSchedule(page: Page, scheduleAt: string): Promise<void> {
|
||||
*/
|
||||
async function waitForPublishResult(
|
||||
page: Page,
|
||||
): Promise<{ success: boolean; noteId?: string }> {
|
||||
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||
// Strategy 1: Wait for the URL to change to a success page.
|
||||
// Strategy 2: Wait for a success element to appear.
|
||||
// Use Promise.all so both run concurrently.
|
||||
@@ -391,8 +391,9 @@ async function waitForPublishResult(
|
||||
|
||||
// Try to extract the note ID from the current URL if available.
|
||||
const noteId = extractNoteIdFromUrl(page.url());
|
||||
const noteUrl = noteId ? `https://www.xiaohongshu.com/explore/${noteId}` : undefined;
|
||||
|
||||
return { success: true, noteId };
|
||||
return { success: true, noteId, noteUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BrowserManager } from '../../browser/manager.js';
|
||||
import { config } from '../../config/index.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js';
|
||||
import { validateMediaPath } from '../../utils/downloader.js';
|
||||
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
|
||||
import { rateLimiter } from '../../server/middleware.js';
|
||||
import { cookieStore } from '../../cookie/store.js';
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getFeedDetail } from './feed-detail.js';
|
||||
import { getUserProfile } from './user-profile.js';
|
||||
import { publishImageNote } from './publish.js';
|
||||
import { publishVideoNote } from './publish-video.js';
|
||||
import { listMyNotes } from './my-notes.js';
|
||||
import { postComment, replyComment } from './comment.js';
|
||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||
|
||||
@@ -356,6 +357,34 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
||||
})();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// My notes
|
||||
// =========================================================================
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /my-notes
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/my-notes', readRateLimiter, (_req, res) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const timeoutMs =
|
||||
config.operationTimeouts['feed_list'] ??
|
||||
config.operationTimeouts['default'] ??
|
||||
60_000;
|
||||
|
||||
const notes = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) => listMyNotes(page),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
res.json(successResponse(notes) as ApiResponse<typeof notes>);
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Content publishing
|
||||
// =========================================================================
|
||||
@@ -368,33 +397,36 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
||||
try {
|
||||
const body = PublishImageBodySchema.parse(req.body);
|
||||
|
||||
// Validate all image paths before acquiring a browser page.
|
||||
const validatedPaths: string[] = [];
|
||||
for (const imagePath of body.images) {
|
||||
const resolved = await validateMediaPath(imagePath, {
|
||||
maxSizeMB: IMAGE_MAX_SIZE_MB,
|
||||
});
|
||||
validatedPaths.push(resolved);
|
||||
// Resolve all images (local path or URL download) before acquiring browser.
|
||||
const resolved: Array<{ path: string; temporary: boolean }> = [];
|
||||
for (const img of body.images) {
|
||||
resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB }));
|
||||
}
|
||||
const validatedPaths = resolved.map((r) => r.path);
|
||||
|
||||
const timeoutMs =
|
||||
config.operationTimeouts['publish'] ??
|
||||
config.operationTimeouts['default'] ??
|
||||
300_000;
|
||||
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishImageNote(page, body.title, body.content, validatedPaths, {
|
||||
tags: body.tags,
|
||||
scheduleAt: body.schedule_at,
|
||||
isOriginal: body.is_original,
|
||||
visibility: body.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||
try {
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishImageNote(page, body.title, body.content, validatedPaths, {
|
||||
tags: body.tags,
|
||||
scheduleAt: body.schedule_at,
|
||||
isOriginal: body.is_original,
|
||||
visibility: body.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||
} finally {
|
||||
for (const r of resolved) {
|
||||
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
@@ -409,28 +441,29 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
||||
try {
|
||||
const body = PublishVideoBodySchema.parse(req.body);
|
||||
|
||||
// Validate the video path before acquiring a browser page.
|
||||
const validatedPath = await validateMediaPath(body.video, {
|
||||
maxSizeMB: VIDEO_MAX_SIZE_MB,
|
||||
});
|
||||
// Resolve video (local path or URL download) before acquiring browser.
|
||||
const resolvedVideo = await resolveMediaInput(body.video, { maxSizeMB: VIDEO_MAX_SIZE_MB });
|
||||
|
||||
const timeoutMs =
|
||||
config.operationTimeouts['publish'] ??
|
||||
config.operationTimeouts['default'] ??
|
||||
300_000;
|
||||
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishVideoNote(page, body.title, body.content, validatedPath, {
|
||||
tags: body.tags,
|
||||
scheduleAt: body.schedule_at,
|
||||
visibility: body.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||
try {
|
||||
const result = await browser.withPage(
|
||||
PLATFORM,
|
||||
async (page) =>
|
||||
publishVideoNote(page, body.title, body.content, resolvedVideo.path, {
|
||||
tags: body.tags,
|
||||
scheduleAt: body.schedule_at,
|
||||
visibility: body.visibility,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||
} finally {
|
||||
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
|
||||
@@ -67,12 +67,13 @@ export const GetUserProfileSchema = {
|
||||
|
||||
/** xhs_publish_image */
|
||||
export const PublishImageSchema = {
|
||||
title: z.string().min(1).describe('Note title'),
|
||||
content: z.string().describe('Note body text'),
|
||||
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
||||
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
||||
images: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe('Array of image file paths or URLs'),
|
||||
.max(18, 'Maximum 18 images per note')
|
||||
.describe('Array of local file paths or HTTP/HTTPS URLs (1–18 images)'),
|
||||
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
||||
schedule_at: z
|
||||
.string()
|
||||
@@ -92,9 +93,9 @@ export const PublishImageSchema = {
|
||||
|
||||
/** xhs_publish_video */
|
||||
export const PublishVideoSchema = {
|
||||
title: z.string().min(1).describe('Note title'),
|
||||
content: z.string().describe('Note body text'),
|
||||
video: z.string().describe('Video file path or URL'),
|
||||
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
||||
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
||||
video: z.string().describe('Local file path or HTTP/HTTPS URL for the video'),
|
||||
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
||||
schedule_at: z
|
||||
.string()
|
||||
@@ -136,6 +137,9 @@ export const LikeSchema = {
|
||||
.describe('Set to true to unlike'),
|
||||
};
|
||||
|
||||
/** xhs_list_my_notes — no parameters. */
|
||||
export const ListMyNotesSchema = {};
|
||||
|
||||
/** xhs_favorite */
|
||||
export const FavoriteSchema = {
|
||||
feed_id: z.string().describe('Feed ID to favorite'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { open, stat, unlink, writeFile, mkdir } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -225,6 +226,44 @@ export async function downloadFile(
|
||||
// cleanupFile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveMediaInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a media input that is either a local file path or a remote URL.
|
||||
*
|
||||
* - If `input` is an HTTP/HTTPS URL, the file is downloaded to a temporary
|
||||
* directory, validated, and returned with `temporary: true` so the caller
|
||||
* can clean it up after use.
|
||||
* - Otherwise the path is validated in-place and returned with `temporary: false`.
|
||||
*
|
||||
* @returns `{ path, temporary }` where `path` is the local file path ready for use.
|
||||
*/
|
||||
export async function resolveMediaInput(
|
||||
input: string,
|
||||
opts?: { maxSizeMB?: number; tempDir?: string },
|
||||
): Promise<{ path: string; temporary: boolean }> {
|
||||
if (input.startsWith("http://") || input.startsWith("https://")) {
|
||||
const dir = opts?.tempDir ?? path.join(os.tmpdir(), "social-mcp");
|
||||
const downloaded = await downloadFile(input, dir);
|
||||
try {
|
||||
await validateMediaPath(downloaded, { maxSizeMB: opts?.maxSizeMB });
|
||||
} catch (err) {
|
||||
await cleanupFile(downloaded).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
return { path: downloaded, temporary: true };
|
||||
}
|
||||
|
||||
const validated = await validateMediaPath(input, { maxSizeMB: opts?.maxSizeMB });
|
||||
return { path: validated, temporary: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cleanupFile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete a local file. Silently succeeds if the file does not exist.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user