Files
koc-loop/lib/mcp-collection-client.ts

1487 lines
39 KiB
TypeScript
Raw Normal View History

2026-07-30 12:06:41 +08:00
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;
2026-07-30 12:06:41 +08:00
};
export type XhsAccountProfile = {
platformUid: string;
nickname: string;
profileUrl: string;
redId: string;
ipLocation: string;
followers: number | null;
gender: "" | "男" | "女";
bio: string;
recentNoteTitles: string[];
providerTags: string[];
2026-07-30 12:06:41 +08:00
};
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;
2026-07-30 12:06:41 +08:00
await postMcp(
fetchImpl,
endpoint,
{
jsonrpc: "2.0",
method: "notifications/initialized",
params: {},
},
timeoutMs,
sessionId,
true,
);
return sessionId;
}
async function invokeMcpTool(
2026-07-30 12:06:41 +08:00
fetchImpl: typeof fetch,
endpoint: string,
sessionId: string | undefined,
2026-07-30 12:06:41 +08:00
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 },
};
}
2026-07-30 12:06:41 +08:00
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,
);
}
2026-07-30 12:06:41 +08:00
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);
2026-07-30 12:06:41 +08:00
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,
2026-07-30 12:06:41 +08:00
"收藏数",
),
shares: count(
data.shares ??
data.share_count ??
data.shareCount ??
data.forwards ??
data.forward_count ??
data.forwardCount,
"转发数",
),
2026-07-30 12:06:41 +08:00
};
}
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;
}
}
2026-07-30 12:06:41 +08:00
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);
}
2026-07-30 12:06:41 +08:00
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) {
2026-07-30 12:06:41 +08:00
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: [],
2026-07-30 12:06:41 +08:00
};
}
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: "" };
2026-07-30 12:06:41 +08:00
}
if (
parsed.protocol !== "https:" ||
!(
parsed.hostname === "xiaohongshu.com" ||
parsed.hostname.endsWith(".xiaohongshu.com")
) ||
!parsed.pathname.startsWith("/user/profile/")
) {
return { nickname: "", redId: "", followers: null, ipLocation: "" };
2026-07-30 12:06:41 +08:00
}
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: "" };
2026-07-30 12:06:41 +08:00
}
const html = await response.text();
return {
nickname:
html.match(/"(?:nickname|nickName)":"([^"]+)"/)?.[1] ?? "",
2026-07-30 12:06:41 +08:00
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: "" };
2026-07-30 12:06:41 +08:00
}
}
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");
2026-07-30 12:06:41 +08:00
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") ||
2026-07-30 12:06:41 +08:00
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
) {
2026-07-30 12:06:41 +08:00
throw new Error("账号主页采集结果缺少可用字段");
}
return {
nickname,
followers,
redId,
ipLocation,
gender,
bio,
recentNoteTitles,
providerTags,
};
2026-07-30 12:06:41 +08:00
}
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) &&
2026-07-30 12:06:41 +08:00
(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,
);
2026-07-30 12:06:41 +08:00
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: [],
2026-07-30 12:06:41 +08:00
};
}
async function completeAccountProfile(
profile: XhsAccountProfile,
fetchImpl: typeof fetch,
endpoint: string,
sessionId: string | undefined,
2026-07-30 12:06:41 +08:00
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,
2026-07-30 12:06:41 +08:00
};
} 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;
2026-07-30 12:06:41 +08:00
try {
const sessionId = await createMcpSession(
fetchImpl,
endpoint,
timeoutMs,
);
const noteResult = await callMcpTool(
2026-07-30 12:06:41 +08:00
fetchImpl,
endpoint,
sessionId,
timeoutMs,
"fetch_content_detail",
2026-07-30 12:06:41 +08:00
{
link: publishUrl,
plant: "xhs",
include_comments: false,
auto_cookie: true,
2026-07-30 12:06:41 +08:00
},
);
const noteData = successfulToolData(
noteResult,
"无法识别小红书笔记",
2026-07-30 12:06:41 +08:00
);
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;
}
2026-07-30 12:06:41 +08:00
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: "小红书" | "抖音",
2026-07-30 12:06:41 +08:00
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",
2026-07-30 12:06:41 +08:00
include_comments: false,
auto_cookie: true,
},
);
return metricsFromToolResult(primary);
2026-07-30 12:06:41 +08:00
}
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);
2026-07-30 12:06:41 +08:00
} 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);
}
2026-07-30 12:06:41 +08:00
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("账号主页数据采集服务暂时不可用");
}