export const DEFAULT_COLLECTION_MCP_URL = "https://middle-aitool.gbotai.cn/mcp"; const MAX_MCP_ATTEMPTS = 10; export type CollectionMcpBindings = { AI_TOOL_CENTER_MCP_URL?: string; AI_TOOL_CENTER_MCP_KEY?: string; }; export type CollectionMcpConfig = { endpoint: string; key?: string; timeoutMs?: number; }; export type XhsPublicMetrics = { likes: number; comments: number; collects: number; shares: number; }; export type XhsAccountProfile = { platformUid: string; nickname: string; profileUrl: string; redId: string; ipLocation: string; followers: number | null; gender: "" | "男" | "女"; bio: string; recentNoteTitles: string[]; providerTags: string[]; }; type JsonRpcEnvelope = { error?: { code?: number; message?: string; }; result?: { isError?: boolean; content?: Array<{ type?: string; text?: string; }>; serverInfo?: { name?: string; version?: string; }; }; }; type ToolResult = { isError: boolean; payload: unknown; }; class McpSessionLostError extends Error {} function isRetryableTransportError(error: unknown) { return ( error instanceof McpSessionLostError || (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name)) || error instanceof TypeError ); } function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function safeMessage(value: unknown, fallback: string) { const message = String(value ?? "").trim(); if (!message) return fallback; return message .replace(/https?:\/\/[^\s"'<>]+/gi, "[链接]") .replace(/eyJ[A-Za-z0-9._-]+/g, "[密钥]") .slice(0, 240); } function metricValue(value: unknown, label: string) { if (typeof value === "number" && Number.isFinite(value) && value >= 0) { return Math.round(value); } if (typeof value !== "string") { throw new Error(`采集结果缺少${label}`); } const normalized = value.trim().toLowerCase().replace(/[,\s]/g, ""); const match = normalized.match(/^(\d+(?:\.\d+)?)(万|w|千|k)?$/); if (!match) throw new Error(`采集结果中的${label}格式异常`); const multiplier = match[2] === "万" || match[2] === "w" ? 10_000 : match[2] === "千" || match[2] === "k" ? 1_000 : 1; return Math.round(Number(match[1]) * multiplier); } function optionalCountValue(value: unknown) { if (value === null || value === undefined || value === "") return null; const normalized = typeof value === "string" ? value.trim().replace(/\+$/, "") : value; try { return metricValue(normalized, "粉丝数"); } catch { return null; } } function parseJsonRpc(raw: string) { const messages = raw .split(/\r?\n/) .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trim()) .filter(Boolean); const candidate = messages.at(-1) ?? raw.trim(); if (!candidate) throw new Error("MCP采集服务返回空响应"); try { return JSON.parse(candidate) as JsonRpcEnvelope; } catch { throw new Error("MCP采集服务返回了无法解析的数据"); } } function buildMcpUrl(config: CollectionMcpConfig) { let endpoint: URL; try { endpoint = new URL(config.endpoint); } catch { throw new Error("MCP采集服务地址无效"); } if (!["http:", "https:"].includes(endpoint.protocol)) { throw new Error("MCP采集服务地址无效"); } if (config.key?.trim()) { endpoint.searchParams.set("key", config.key.trim()); } if (!endpoint.searchParams.get("key")) { throw new Error("MCP采集密钥未配置"); } return endpoint.toString(); } function requestHeaders(sessionId?: string) { const headers = new Headers({ "Content-Type": "application/json", Accept: "application/json, text/event-stream", }); if (sessionId) headers.set("Mcp-Session-Id", sessionId); return headers; } async function postMcp( fetchImpl: typeof fetch, endpoint: string, body: Record, timeoutMs: number, sessionId?: string, allowEmpty = false, ) { const response = await fetchImpl(endpoint, { method: "POST", headers: requestHeaders(sessionId), body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const text = await response.text(); if (!response.ok) { if (/session not found|missing session id/i.test(text)) { throw new McpSessionLostError("MCP采集会话已失效"); } let providerMessage: unknown = text; try { providerMessage = (JSON.parse(text) as JsonRpcEnvelope).error?.message ?? text; } catch { // Keep the original body when the upstream response is not JSON. } throw new Error( safeMessage( providerMessage, `MCP采集服务请求失败(HTTP ${response.status})`, ), ); } if (allowEmpty && !text.trim()) { return { response, envelope: null }; } const envelope = parseJsonRpc(text); if (envelope.error) { const message = safeMessage( envelope.error.message, "MCP采集服务调用失败", ); if (/session not found|missing session id/i.test(message)) { throw new McpSessionLostError("MCP采集会话已失效"); } throw new Error(message); } return { response, envelope }; } async function createMcpSession( fetchImpl: typeof fetch, endpoint: string, timeoutMs: number, ) { const initialize = await postMcp( fetchImpl, endpoint, { jsonrpc: "2.0", id: crypto.randomUUID(), method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "koc-loop-collector", version: "1.0.0", }, }, }, timeoutMs, ); const sessionId = initialize.response.headers.get("mcp-session-id"); if (!sessionId) return undefined; await postMcp( fetchImpl, endpoint, { jsonrpc: "2.0", method: "notifications/initialized", params: {}, }, timeoutMs, sessionId, true, ); return sessionId; } async function invokeMcpTool( fetchImpl: typeof fetch, endpoint: string, sessionId: string | undefined, timeoutMs: number, name: string, args: Record, allowText = false, ): Promise { const result = await postMcp( fetchImpl, endpoint, { jsonrpc: "2.0", id: crypto.randomUUID(), method: "tools/call", params: { name, arguments: args, }, }, timeoutMs, sessionId, ); const content = result.envelope?.result?.content ?? []; const text = content.find((block) => block.type === "text")?.text; if (!text) throw new Error("MCP采集工具未返回数据"); let payload: unknown; try { payload = JSON.parse(text); } catch { if (allowText) { return { isError: result.envelope?.result?.isError === true, payload: { text }, }; } if (result.envelope?.result?.isError === true) { return { isError: true, payload: { message: text }, }; } throw new Error("MCP采集工具返回了无法解析的数据"); } return { isError: result.envelope?.result?.isError === true, payload, }; } function isToolArgumentShapeError(result: ToolResult) { if (!result.isError) return false; const root = asRecord(result.payload); const message = String(root?.message ?? ""); return /input validation error/i.test(message); } async function callMcpTool( fetchImpl: typeof fetch, endpoint: string, sessionId: string | undefined, timeoutMs: number, name: string, args: Record, allowText = false, ): Promise { const nested = await invokeMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, name, { request: args }, allowText, ); if (!isToolArgumentShapeError(nested)) return nested; return invokeMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, name, args, allowText, ); } function imageToolText(value: unknown) { if (typeof value === "string") return value; const root = asRecord(value); if (!root) return ""; return [root.text, root.description, root.content, root.message, root.error] .flatMap((item) => (Array.isArray(item) ? item : [item])) .map((item) => { if (typeof item === "string") return item; const record = asRecord(item); return String(record?.text ?? record?.description ?? ""); }) .filter(Boolean) .join("\n"); } function metricFromImageText(text: string, labels: string[], label: string) { const pattern = labels.join("|"); const match = text.match( new RegExp(`(?:${pattern})\\s*[::]?\\s*([\\d,.]+(?:万|w|千|k)?)`, "i"), ); if (!match) return null; try { return metricValue(match[1], label); } catch { return null; } } function creatorMetricsFromImageResult(result: ToolResult) { if (result.isError) { const detail = imageToolText(result.payload); throw new Error( `图片识别 MCP 调用失败${detail ? `:${safeMessage(detail, "")}` : ""}`, ); } const text = imageToolText(result.payload); const exposure = metricFromImageText( text, ["曝光量", "曝光", "impressions", "exposure"], "曝光量", ); const views = metricFromImageText( text, ["阅读量", "阅读", "views", "view_count", "view count"], "阅读量", ); if (exposure === null || views === null) { throw new Error("图片识别未找到曝光量和阅读量"); } return { exposure, views }; } export async function extractCreatorMetricsFromMcp( imageUrl: string, config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { const endpoint = buildMcpUrl(config); const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000); const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs); const result = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "analyze_image", { image_url: imageUrl }, true, ); return creatorMetricsFromImageResult(result); } function metricsFromToolResult( result: ToolResult, toolName = "fetch_content_detail", ): XhsPublicMetrics { const root = asRecord(result.payload); const response = asRecord(root?.response) ?? root; const data = asRecord(response?.data) ?? asRecord(root?.data); const code = Number(response?.code); const success = response?.success; if ( result.isError || success === false || (Number.isFinite(code) && code >= 400) ) { const providerCode = Number(response?.code); const codeLabel = Number.isFinite(providerCode) ? `(code ${providerCode})` : ""; throw new Error( `MCP工具 ${toolName} 返回失败${codeLabel}:${safeMessage( response?.msg ?? response?.message ?? root?.message, "公开数据采集失败", )}`, ); } if (!data) throw new Error(`MCP工具 ${toolName} 未返回互动数据`); const count = (value: unknown, label: string) => value === null || value === undefined || value === "" ? 0 : metricValue(value, label); return { likes: count( data.likes ?? data.liked_count ?? data.likedCount ?? data.like_count ?? data.likeCount ?? data.digg_count ?? data.diggCount, "点赞数", ), comments: count( data.comments ?? data.comment_count ?? data.commentCount, "评论数", ), collects: count( data.collects ?? data.collected_count ?? data.collectedCount ?? data.favorites ?? data.favourites ?? data.collect_count ?? data.collectCount, "收藏数", ), shares: count( data.shares ?? data.share_count ?? data.shareCount ?? data.forwards ?? data.forward_count ?? data.forwardCount, "转发数", ), }; } function usableDouyinSecUid(value: unknown) { const candidate = stringValue(value); return candidate && !/^\d+$/.test(candidate) && /^[A-Za-z0-9_-]{20,220}$/.test(candidate) ? candidate : ""; } function verifiedDouyinProfileUrl(value: unknown) { const candidate = stringValue(value); if (!candidate) return ""; try { const parsed = new URL(candidate); const secUid = decodeURIComponent(parsed.pathname.match(/^\/user\/([^/?#]+)/)?.[1] ?? ""); if ( parsed.protocol === "https:" && (parsed.hostname === "douyin.com" || parsed.hostname.endsWith(".douyin.com")) && usableDouyinSecUid(secUid) ) { return parsed.toString(); } } catch { // The public redirect fallback below can still recover the profile URL. } return ""; } async function douyinProfileFromPublicRedirect( publishUrl: string, fetchImpl: typeof fetch, timeoutMs: number, ) { let parsed: URL; try { parsed = new URL(publishUrl); } catch { return null; } if ( parsed.protocol !== "https:" || !(parsed.hostname === "douyin.com" || parsed.hostname.endsWith(".douyin.com")) ) { return null; } try { const response = await fetchImpl(parsed.toString(), { method: "GET", redirect: "manual", signal: AbortSignal.timeout(Math.min(timeoutMs, 15_000)), headers: { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148", }, }); const location = response.headers.get("location"); if (!location) return null; const redirectUrl = new URL(location, parsed); const secUid = usableDouyinSecUid( redirectUrl.searchParams.get("sec_uid") ?? redirectUrl.searchParams.get("sec_user_id"), ); return secUid ? { platformUid: secUid, profileUrl: `https://www.douyin.com/user/${encodeURIComponent(secUid)}`, } : null; } catch { return null; } } function successfulToolData(result: ToolResult, fallbackMessage: string) { const root = asRecord(result.payload); const response = asRecord(root?.response) ?? root; const data = asRecord(response?.data) ?? asRecord(root?.data); const code = Number(response?.code); if ( result.isError || response?.success === false || (Number.isFinite(code) && code >= 400) ) { throw new Error( safeMessage( response?.msg ?? response?.message ?? root?.message, fallbackMessage, ), ); } if (!data) throw new Error(fallbackMessage); return data; } function stringValue(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function findRecord( value: unknown, predicate: (record: Record) => boolean, depth = 0, ): Record | null { if (depth > 12) return null; if (Array.isArray(value)) { for (const item of value) { const found = findRecord(item, predicate, depth + 1); if (found) return found; } return null; } const record = asRecord(value); if (!record) return null; if (predicate(record)) return record; for (const child of Object.values(record)) { const found = findRecord(child, predicate, depth + 1); if (found) return found; } return null; } function findStringByKey(value: unknown, key: string, depth = 0): string { if (depth > 12) return ""; if (Array.isArray(value)) { for (const item of value) { const found = findStringByKey(item, key, depth + 1); if (found) return found; } return ""; } const record = asRecord(value); if (!record) return ""; const direct = stringValue(record[key]); if (direct) return direct; for (const child of Object.values(record)) { const found = findStringByKey(child, key, depth + 1); if (found) return found; } return ""; } function findValueByKeys( value: unknown, keys: ReadonlySet, depth = 0, ): unknown { if (depth > 12) return undefined; if (Array.isArray(value)) { for (const item of value) { const found = findValueByKeys(item, keys, depth + 1); if (found !== undefined) return found; } return undefined; } const record = asRecord(value); if (!record) return undefined; for (const [key, child] of Object.entries(record)) { if (keys.has(key) && child !== null && child !== undefined && child !== "") { return child; } } for (const child of Object.values(record)) { const found = findValueByKeys(child, keys, depth + 1); if (found !== undefined) return found; } return undefined; } function profileGender(value: unknown): "" | "男" | "女" { if (value === 1) return "男"; if (value === 2) return "女"; const normalized = String(value ?? "").trim().toLocaleLowerCase("zh-CN"); if (["男", "男性", "male", "m", "1"].includes(normalized)) return "男"; if (["女", "女性", "female", "f", "2"].includes(normalized)) return "女"; return ""; } function recentNoteTitlesFromPayload(value: unknown) { const titles: string[] = []; const visit = (current: unknown, depth = 0) => { if (depth > 12 || titles.length >= 20) return; if (Array.isArray(current)) { current.forEach((item) => visit(item, depth + 1)); return; } const record = asRecord(current); if (!record) return; const title = stringValue(record.title ?? record.note_title ?? record.noteTitle); if ( title && (record.note_id || record.noteId || record.url || record.cover) && !titles.includes(title) ) { titles.push(title); } Object.values(record).forEach((child) => visit(child, depth + 1)); }; visit(value); return titles; } function providerTagsFromUser(value: unknown) { const user = findRecord(value, (record) => Boolean( record.gender !== undefined || record.desc !== undefined || record.signature !== undefined || record.fansCount !== undefined || record.fans_count !== undefined, ), ); const raw = user?.tags; if (!Array.isArray(raw)) return []; return [ ...new Set( raw .map((item) => typeof item === "string" ? item.trim() : stringValue(asRecord(item)?.name ?? asRecord(item)?.title), ) .filter(Boolean), ), ].slice(0, 5); } const FOLLOWER_KEYS = new Set([ "fans", "fans_count", "fansCount", "follower", "followers", "follower_count", "followerCount", ]); function followerCountFromPayload(value: unknown, depth = 0): number | null { const direct = optionalCountValue(findValueByKeys(value, FOLLOWER_KEYS)); if (direct !== null) return direct; if (typeof value === "string") { return optionalCountValue( value.match(/(?:粉丝数?|followers?|fans)\s*[::]\s*([\d,.万千wkWKW+]+)/i)?.[1], ); } if (depth > 12) return null; if (Array.isArray(value)) { for (const item of value) { const found = followerCountFromPayload(item, depth + 1); if (found !== null) return found; } return null; } const record = asRecord(value); if (!record) return null; const label = stringValue( record.name ?? record.label ?? record.title ?? record.type, ).toLowerCase(); if (["粉丝", "粉丝数", "fans", "followers"].includes(label)) { const labeled = optionalCountValue( record.count ?? record.value ?? record.i18nCount, ); if (labeled !== null) return labeled; } for (const child of Object.values(record)) { const found = followerCountFromPayload(child, depth + 1); if (found !== null) return found; } return null; } export function xhsNoteIdFromUrl(input: string) { try { const url = new URL(input); if ( !( url.hostname === "xiaohongshu.com" || url.hostname.endsWith(".xiaohongshu.com") ) ) { return ""; } return ( url.pathname.match( /\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/, )?.[1] ?? "" ); } catch { return ""; } } async function xhsNoteIdFromShortLink( input: string, fallbackNickname: string, fetchImpl: typeof fetch, timeoutMs: number, ) { let url: URL; try { url = new URL(input); } catch { return { noteId: "", profile: null }; } if ( url.protocol !== "http:" && url.protocol !== "https:" ) { return { noteId: "", profile: null }; } const isShortLink = url.hostname === "xhslink.cn" || url.hostname.endsWith(".xhslink.cn"); const isXhsPage = url.hostname === "xiaohongshu.com" || url.hostname.endsWith(".xiaohongshu.com"); if (!isShortLink && !isXhsPage) { return { noteId: "", profile: null }; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl(url.toString(), { method: "GET", redirect: "follow", signal: controller.signal, headers: { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148", }, }); const location = response.headers.get("location"); const resolvedUrl = location ? new URL(location, url).toString() : response.url; const noteId = xhsNoteIdFromUrl(resolvedUrl); const html = response.ok && response.headers.get("content-type")?.includes("text/html") ? await response.text() : ""; return { noteId, profile: accountProfileFromPublicPage( html, fallbackNickname, ), }; } catch { return { noteId: "", profile: null }; } finally { clearTimeout(timeout); } } function accountProfileFromPublicPage( html: string, fallbackNickname: string, ): XhsAccountProfile | null { if (!html) return null; const decoded = html .replaceAll(""", '"') .replaceAll(""", '"') .replaceAll("&", "&") .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">"); const noteData = decoded.match( /"noteData":\{[\s\S]{0,30000}?"user":\{([\s\S]{0,1800}?)\}/, )?.[1]; if (!noteData) return null; const platformUid = noteData.match(/"userId":"([A-Za-z0-9_-]{8,80})"/)?.[1] ?? ""; if (!platformUid) return null; const nickname = noteData.match(/"(?:nickName|nickname)":"([^"]+)"/)?.[1] ?? fallbackNickname.trim(); const redId = noteData.match(/"(?:redId|red_id)":"([^"]+)"/)?.[1] ?? decoded.match(/"(?:redId|red_id)":"([^"]+)"/)?.[1] ?? decoded.match(/小红书号[::]\s*([^<"\s]+)/)?.[1] ?? ""; return { platformUid, nickname, profileUrl: `https://www.xiaohongshu.com/user/profile/${encodeURIComponent( platformUid, )}`, redId, ipLocation: "待识别", followers: null, gender: "", bio: "", recentNoteTitles: [], providerTags: [], }; } export async function resolveXhsPublicAccountDetails( profileUrl: string, fetchImpl: typeof fetch = fetch, ) { let parsed: URL; try { parsed = new URL(profileUrl); } catch { return { nickname: "", redId: "", followers: null, ipLocation: "" }; } if ( parsed.protocol !== "https:" || !( parsed.hostname === "xiaohongshu.com" || parsed.hostname.endsWith(".xiaohongshu.com") ) || !parsed.pathname.startsWith("/user/profile/") ) { return { nickname: "", redId: "", followers: null, ipLocation: "" }; } try { const response = await fetchImpl(parsed.toString(), { method: "GET", redirect: "follow", signal: AbortSignal.timeout(15_000), headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/138.0 Safari/537.36", }, }); if (!response.ok) { return { nickname: "", redId: "", followers: null, ipLocation: "" }; } const html = await response.text(); return { nickname: html.match(/"(?:nickname|nickName)":"([^"]+)"/)?.[1] ?? "", redId: html.match(/"(?:redId|red_id)":"([^"]+)"/)?.[1] ?? html.match(/小红书号[::]\s*([^<"\s]+)/)?.[1] ?? "", followers: optionalCountValue( html.match( /"name"\s*:\s*"粉丝"\s*,\s*"count"\s*:\s*"([^"]+)"/, )?.[1], ) ?? followerCountFromPayload(html), ipLocation: "", }; } catch { return { nickname: "", redId: "", followers: null, ipLocation: "" }; } } export async function resolveXhsPublicAccountId( profileUrl: string, fetchImpl: typeof fetch = fetch, ) { return (await resolveXhsPublicAccountDetails(profileUrl, fetchImpl)).redId; } function profileDetailsFromToolResult(result: ToolResult) { const root = asRecord(result.payload); const response = asRecord(root?.response) ?? root; const code = Number(response?.code); if ( result.isError || response?.success === false || (Number.isFinite(code) && code >= 400) ) { throw new Error( safeMessage( response?.msg ?? response?.message ?? root?.message, "账号主页数据采集失败", ), ); } const payload = asRecord(response?.data) ?? asRecord(root?.data) ?? result.payload; const followers = followerCountFromPayload(payload); const nickname = findStringByKey(payload, "nickname") || findStringByKey(payload, "nickName"); const redId = findStringByKey(payload, "red_id") || findStringByKey(payload, "redId") || findStringByKey(payload, "unique_id") || findStringByKey(payload, "uniqueId") || findStringByKey(payload, "short_id") || findStringByKey(payload, "shortId") || findStringByKey(payload, "douyin_id") || findStringByKey(payload, "userId") || findStringByKey(payload, "user_id"); const ipLocation = findStringByKey(payload, "ip_location") || findStringByKey(payload, "ipLocation"); const gender = profileGender(findValueByKeys(payload, new Set(["gender", "sex"]))); const bio = findStringByKey(payload, "desc") || findStringByKey(payload, "description") || findStringByKey(payload, "signature") || findStringByKey(payload, "bio"); const recentNoteTitles = recentNoteTitlesFromPayload(payload); const providerTags = providerTagsFromUser(payload); if ( followers === null && !nickname && !redId && !ipLocation && !gender && !bio && recentNoteTitles.length === 0 ) { throw new Error("账号主页采集结果缺少可用字段"); } return { nickname, followers, redId, ipLocation, gender, bio, recentNoteTitles, providerTags, }; } function accountProfileFromToolResult( result: ToolResult, fallbackNickname: string, ): XhsAccountProfile { const data = successfulToolData(result, "账号主页识别失败"); const user = findRecord( data, (record) => Boolean( stringValue(record.user_id ?? record.userid ?? record.userId) && (stringValue(record.profile_url) || stringValue(record.nickname ?? record.name)), ), ); if (!user) throw new Error("账号主页识别结果缺少作者信息"); const platformUid = stringValue( user.user_id ?? user.userid ?? user.userId, ); const candidateProfileUrl = stringValue( user.profile_url ?? user.profileUrl, ); let profileUrl = ""; if (candidateProfileUrl) { try { const parsed = new URL(candidateProfileUrl); if ( parsed.protocol === "https:" && (parsed.hostname === "xiaohongshu.com" || parsed.hostname.endsWith(".xiaohongshu.com")) && parsed.pathname.startsWith("/user/profile/") ) { profileUrl = parsed.toString(); } } catch { // Fall back to the verified user id below. } } if (!profileUrl) { profileUrl = `https://www.xiaohongshu.com/user/profile/${encodeURIComponent( platformUid, )}`; } return { platformUid, nickname: stringValue(user.nickname ?? user.name) || fallbackNickname.trim(), profileUrl, redId: stringValue(user.red_id), ipLocation: findStringByKey(data, "ip_location") || "待识别", followers: followerCountFromPayload(data), gender: "", bio: "", recentNoteTitles: [], providerTags: [], }; } async function completeAccountProfile( profile: XhsAccountProfile, fetchImpl: typeof fetch, endpoint: string, sessionId: string | undefined, timeoutMs: number, ) { let completed = profile; for (const [name, args] of [ [ "parse_xhs_user_summary", { url: profile.profileUrl, use_proxy: true }, ], ] as const) { try { const result = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, name, args, ); const details = profileDetailsFromToolResult(result); completed = { ...completed, followers: details.followers ?? completed.followers, redId: details.redId || completed.redId, ipLocation: details.ipLocation && details.ipLocation !== "待识别" ? details.ipLocation : completed.ipLocation, gender: details.gender || completed.gender, bio: details.bio || completed.bio, recentNoteTitles: details.recentNoteTitles.length > 0 ? details.recentNoteTitles : completed.recentNoteTitles, providerTags: details.providerTags.length > 0 ? details.providerTags : completed.providerTags, }; } catch (error) { if (error instanceof McpSessionLostError) throw error; } } if (completed.followers === null || !completed.redId) { const details = await resolveXhsPublicAccountDetails( completed.profileUrl, fetchImpl, ); completed = { ...completed, followers: details.followers ?? completed.followers, redId: details.redId || completed.redId, }; } return completed; } async function resolveProfileDetailsInSession( profileUrl: string, config: CollectionMcpConfig, fetchImpl: typeof fetch, ) { const endpoint = buildMcpUrl(config); const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000); const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs); const result = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "parse_xhs_user_summary", { url: profileUrl, use_proxy: true, }, ); return profileDetailsFromToolResult(result); } async function resolveAccountInSession( publishUrl: string, fallbackNickname: string, config: CollectionMcpConfig, fetchImpl: typeof fetch, ) { const endpoint = buildMcpUrl(config); const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000); let noteId = xhsNoteIdFromUrl(publishUrl); const linkPage = await xhsNoteIdFromShortLink( publishUrl, fallbackNickname, fetchImpl, timeoutMs, ); noteId = noteId || linkPage.noteId; const publicPageProfile = linkPage.profile; try { const sessionId = await createMcpSession( fetchImpl, endpoint, timeoutMs, ); const noteResult = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "fetch_content_detail", { link: publishUrl, plant: "xhs", include_comments: false, auto_cookie: true, }, ); const noteData = successfulToolData( noteResult, "无法识别小红书笔记", ); noteId = noteId || stringValue(noteData.noteId ?? noteData.note_id) || findStringByKey(noteData, "noteId") || findStringByKey(noteData, "note_id"); if (!noteId) throw new Error("无法从发布链接识别小红书笔记ID"); let profile: XhsAccountProfile; try { profile = accountProfileFromToolResult( noteResult, fallbackNickname, ); } catch { if (!publicPageProfile) throw new Error("笔记数据缺少公开作者主页"); profile = publicPageProfile; } return completeAccountProfile( profile, fetchImpl, endpoint, sessionId, timeoutMs, ); } catch (error) { if (error instanceof McpSessionLostError) throw error; if (publicPageProfile) { const details = await resolveXhsPublicAccountDetails( publicPageProfile.profileUrl, fetchImpl, ); return { ...publicPageProfile, redId: details.redId || publicPageProfile.redId, followers: details.followers, }; } throw error; } } async function collectInSession( publishUrl: string, platform: "小红书" | "抖音", config: CollectionMcpConfig, fetchImpl: typeof fetch, ) { const endpoint = buildMcpUrl(config); const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000); const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs); const primary = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "fetch_content_detail", { link: publishUrl, plant: platform === "抖音" ? "dy" : "xhs", include_comments: false, auto_cookie: true, }, ); return metricsFromToolResult(primary, "fetch_content_detail"); } export function resolveCollectionMcpConfig( bindings: CollectionMcpBindings, ): CollectionMcpConfig { return { endpoint: bindings.AI_TOOL_CENTER_MCP_URL?.trim() || DEFAULT_COLLECTION_MCP_URL, key: bindings.AI_TOOL_CENTER_MCP_KEY?.trim(), }; } export async function collectXhsMetricsFromMcp( publishUrl: string, config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { let parsed: URL; try { parsed = new URL(publishUrl); } catch { throw new Error("发布链接无效"); } if (!["http:", "https:"].includes(parsed.protocol)) { throw new Error("发布链接无效"); } let lastError: unknown; for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) { try { return await collectInSession(parsed.toString(), "小红书", config, fetchImpl); } catch (error) { lastError = error; if (!isRetryableTransportError(error)) throw error; } } if ( lastError instanceof Error && ["AbortError", "TimeoutError"].includes(lastError.name) ) { throw new Error("MCP采集服务响应超时,请稍后重试"); } throw lastError instanceof Error ? lastError : new Error("MCP采集服务暂时不可用"); } export async function collectMetricsFromMcp( publishUrl: string, platform: "小红书" | "抖音", config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { if (platform === "小红书") { return collectXhsMetricsFromMcp(publishUrl, config, fetchImpl); } let parsed: URL; try { parsed = new URL(publishUrl); } catch { throw new Error("发布链接无效"); } if (!["http:", "https:"].includes(parsed.protocol)) { throw new Error("发布链接无效"); } let lastError: unknown; for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) { try { return await collectInSession(parsed.toString(), platform, config, fetchImpl); } catch (error) { lastError = error; if (!isRetryableTransportError(error)) throw error; } } throw lastError instanceof Error ? lastError : new Error("MCP采集服务暂时不可用"); } function douyinProfileFromToolResult( result: ToolResult, fallbackNickname: string, ): XhsAccountProfile { const data = successfulToolData(result, "无法识别抖音作品"); const author = findRecord(data, (record) => Boolean( stringValue( record.sec_uid ?? record.secUid ?? record.uid ?? record.user_id ?? record.userId, ) && stringValue(record.nickname ?? record.name ?? record.unique_id ?? record.uniqueId), ), ); if (!author) throw new Error("抖音作品数据缺少作者信息"); const verifiedSecUid = usableDouyinSecUid(author.sec_uid ?? author.secUid); const fallbackUid = stringValue(author.uid ?? author.user_id ?? author.userId); const platformUid = verifiedSecUid || fallbackUid; const publicId = stringValue( author.unique_id ?? author.uniqueId ?? author.short_id ?? author.shortId ?? author.douyin_id, ); const candidateProfileUrl = verifiedDouyinProfileUrl( author.profile_url ?? author.profileUrl, ); const profileUrl = candidateProfileUrl || (verifiedSecUid ? `https://www.douyin.com/user/${encodeURIComponent(verifiedSecUid)}` : ""); return { platformUid, nickname: stringValue(author.nickname ?? author.name) || fallbackNickname.trim(), profileUrl, redId: publicId, ipLocation: stringValue(author.ip_location ?? author.ipLocation) || findStringByKey(data, "ip_location") || findStringByKey(data, "ipLocation") || "待识别", followers: followerCountFromPayload(author) ?? followerCountFromPayload(data), gender: profileGender(author.gender ?? author.sex), bio: stringValue(author.desc ?? author.description ?? author.signature ?? author.bio), recentNoteTitles: recentNoteTitlesFromPayload(data), providerTags: providerTagsFromUser(data), }; } async function resolveDouyinAccountInSession( publishUrl: string, fallbackNickname: string, config: CollectionMcpConfig, fetchImpl: typeof fetch, ) { const endpoint = buildMcpUrl(config); const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000); const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs); const result = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "fetch_content_detail", { link: publishUrl, plant: "dy", include_comments: false, auto_cookie: true, }, ); let profile = douyinProfileFromToolResult(result, fallbackNickname); if (!verifiedDouyinProfileUrl(profile.profileUrl)) { const resolved = await douyinProfileFromPublicRedirect( publishUrl, fetchImpl, timeoutMs, ); if (resolved) { profile = { ...profile, platformUid: resolved.platformUid, profileUrl: resolved.profileUrl, }; } } if (!verifiedDouyinProfileUrl(profile.profileUrl)) return profile; try { const detailsResult = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "parse_dy_user_summary", { url: profile.profileUrl }, ); const details = profileDetailsFromToolResult(detailsResult); profile = { ...profile, nickname: details.nickname || profile.nickname, redId: details.redId || profile.redId, followers: details.followers ?? profile.followers, ipLocation: details.ipLocation && details.ipLocation !== "待识别" ? details.ipLocation : profile.ipLocation, gender: details.gender || profile.gender, bio: details.bio || profile.bio, recentNoteTitles: details.recentNoteTitles.length > 0 ? details.recentNoteTitles : profile.recentNoteTitles, providerTags: details.providerTags.length > 0 ? details.providerTags : profile.providerTags, }; } catch (error) { if (error instanceof McpSessionLostError) throw error; } return profile; } export async function resolveAccountProfileFromMcp( publishUrl: string, fallbackNickname: string, platform: "小红书" | "抖音", config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { if (platform === "小红书") { return resolveXhsAccountProfileFromMcp( publishUrl, fallbackNickname, config, fetchImpl, ); } let lastError: unknown; for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) { try { return await resolveDouyinAccountInSession( publishUrl, fallbackNickname, config, fetchImpl, ); } catch (error) { lastError = error; if (!isRetryableTransportError(error)) throw error; } } throw lastError instanceof Error ? lastError : new Error("抖音账号识别失败"); } export async function resolveProfileDetailsFromMcp( profileUrl: string, platform: "小红书" | "抖音", config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { if (platform === "小红书") { return resolveXhsProfileDetailsFromMcp(profileUrl, config, fetchImpl); } const endpoint = buildMcpUrl(config); const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000); const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs); const result = await callMcpTool( fetchImpl, endpoint, sessionId, timeoutMs, "parse_dy_user_summary", { url: profileUrl }, ); return profileDetailsFromToolResult(result); } export async function resolveXhsAccountProfileFromMcp( publishUrl: string, fallbackNickname: string, config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { let parsed: URL; try { parsed = new URL(publishUrl); } catch { throw new Error("发布链接无效"); } if (!["http:", "https:"].includes(parsed.protocol)) { throw new Error("发布链接无效"); } let lastError: unknown; for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) { try { return await resolveAccountInSession( parsed.toString(), fallbackNickname, config, fetchImpl, ); } catch (error) { lastError = error; if (!isRetryableTransportError(error)) throw error; } } throw lastError instanceof Error ? lastError : new Error("账号主页识别服务暂时不可用"); } export async function resolveXhsProfileDetailsFromMcp( profileUrl: string, config: CollectionMcpConfig, fetchImpl: typeof fetch = fetch, ) { let parsed: URL; try { parsed = new URL(profileUrl); } catch { throw new Error("账号主页链接无效"); } if ( parsed.protocol !== "https:" || !( parsed.hostname === "xiaohongshu.com" || parsed.hostname.endsWith(".xiaohongshu.com") ) || !parsed.pathname.startsWith("/user/profile/") ) { throw new Error("账号主页链接无效"); } let lastError: unknown; for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) { try { return await resolveProfileDetailsInSession( parsed.toString(), config, fetchImpl, ); } catch (error) { lastError = error; if (!isRetryableTransportError(error)) throw error; } } throw lastError instanceof Error ? lastError : new Error("账号主页数据采集服务暂时不可用"); }