Files
koc-loop/lib/mcp-collection-client.ts
2026-08-15 03:53:09 +08:00

1487 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, unknown>,
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<string, unknown>,
): Promise<ToolResult> {
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 (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<string, unknown>,
): Promise<ToolResult> {
const nested = await invokeMcpTool(
fetchImpl,
endpoint,
sessionId,
timeoutMs,
name,
{ request: args },
);
if (!isToolArgumentShapeError(nested)) return nested;
return invokeMcpTool(
fetchImpl,
endpoint,
sessionId,
timeoutMs,
name,
args,
);
}
function metricsFromToolResult(result: ToolResult): 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)
) {
throw new Error(
safeMessage(
response?.msg ?? response?.message ?? root?.message,
"公开数据采集失败",
),
);
}
if (!data) throw new Error("采集结果缺少互动数据");
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<string, unknown>) => boolean,
depth = 0,
): Record<string, unknown> | 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<string>,
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,.万千wkW+]+)/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("&quot;", '"')
.replaceAll("&#34;", '"')
.replaceAll("&amp;", "&")
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
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);
}
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("账号主页数据采集服务暂时不可用");
}