1054 lines
27 KiB
TypeScript
1054 lines
27 KiB
TypeScript
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;
|
||
};
|
||
|
||
export type XhsAccountProfile = {
|
||
platformUid: string;
|
||
nickname: string;
|
||
profileUrl: string;
|
||
redId: string;
|
||
ipLocation: string;
|
||
followers: number | null;
|
||
};
|
||
|
||
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) throw new Error("MCP采集服务未返回会话标识");
|
||
|
||
await postMcp(
|
||
fetchImpl,
|
||
endpoint,
|
||
{
|
||
jsonrpc: "2.0",
|
||
method: "notifications/initialized",
|
||
params: {},
|
||
},
|
||
timeoutMs,
|
||
sessionId,
|
||
true,
|
||
);
|
||
return sessionId;
|
||
}
|
||
|
||
async function callMcpTool(
|
||
fetchImpl: typeof fetch,
|
||
endpoint: string,
|
||
sessionId: string,
|
||
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 {
|
||
throw new Error("MCP采集工具返回了无法解析的数据");
|
||
}
|
||
return {
|
||
isError: result.envelope?.result?.isError === true,
|
||
payload,
|
||
};
|
||
}
|
||
|
||
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("采集结果缺少互动数据");
|
||
|
||
return {
|
||
likes: metricValue(data.likes, "点赞数"),
|
||
comments: metricValue(data.comments, "评论数"),
|
||
collects: metricValue(
|
||
data.collects ?? data.favorites ?? data.favourites,
|
||
"收藏数",
|
||
),
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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 };
|
||
}
|
||
if (
|
||
url.hostname !== "xhslink.cn" &&
|
||
!url.hostname.endsWith(".xhslink.cn")
|
||
) {
|
||
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,
|
||
};
|
||
}
|
||
|
||
export async function resolveXhsPublicAccountDetails(
|
||
profileUrl: string,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(profileUrl);
|
||
} catch {
|
||
return { redId: "", followers: null, ipLocation: "" };
|
||
}
|
||
if (
|
||
parsed.protocol !== "https:" ||
|
||
!(
|
||
parsed.hostname === "xiaohongshu.com" ||
|
||
parsed.hostname.endsWith(".xiaohongshu.com")
|
||
) ||
|
||
!parsed.pathname.startsWith("/user/profile/")
|
||
) {
|
||
return { 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 { redId: "", followers: null, ipLocation: "" };
|
||
}
|
||
const html = await response.text();
|
||
return {
|
||
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 { 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 redId =
|
||
findStringByKey(payload, "red_id") ||
|
||
findStringByKey(payload, "redId") ||
|
||
findStringByKey(payload, "userId") ||
|
||
findStringByKey(payload, "user_id");
|
||
const ipLocation =
|
||
findStringByKey(payload, "ip_location") ||
|
||
findStringByKey(payload, "ipLocation");
|
||
if (followers === null && !redId && !ipLocation) {
|
||
throw new Error("账号主页采集结果缺少可用字段");
|
||
}
|
||
return { followers, redId, ipLocation };
|
||
}
|
||
|
||
function accountProfileFromToolResult(
|
||
result: ToolResult,
|
||
fallbackNickname: string,
|
||
): XhsAccountProfile {
|
||
const data = successfulToolData(result, "账号主页识别失败");
|
||
const user = findRecord(
|
||
data,
|
||
(record) =>
|
||
Boolean(
|
||
stringValue(record.user_id ?? record.userid) &&
|
||
(stringValue(record.profile_url) ||
|
||
stringValue(record.nickname ?? record.name)),
|
||
),
|
||
);
|
||
if (!user) throw new Error("账号主页识别结果缺少作者信息");
|
||
const platformUid = stringValue(user.user_id ?? user.userid);
|
||
const candidateProfileUrl = stringValue(user.profile_url);
|
||
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),
|
||
};
|
||
}
|
||
|
||
async function completeAccountProfile(
|
||
profile: XhsAccountProfile,
|
||
fetchImpl: typeof fetch,
|
||
endpoint: string,
|
||
sessionId: string,
|
||
timeoutMs: number,
|
||
) {
|
||
let completed = profile;
|
||
for (const [name, args] of [
|
||
[
|
||
"parse_xhs_user_summary",
|
||
{ url: profile.profileUrl, use_proxy: true },
|
||
],
|
||
[
|
||
"fetch_user_detail",
|
||
{ link: profile.profileUrl, plant: "xhs" },
|
||
],
|
||
] as const) {
|
||
if (
|
||
completed.followers !== null &&
|
||
completed.redId &&
|
||
completed.ipLocation !== "待识别"
|
||
) {
|
||
break;
|
||
}
|
||
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,
|
||
};
|
||
} 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);
|
||
let publicPageProfile: XhsAccountProfile | null = null;
|
||
if (!noteId) {
|
||
const shortLink = await xhsNoteIdFromShortLink(
|
||
publishUrl,
|
||
fallbackNickname,
|
||
fetchImpl,
|
||
timeoutMs,
|
||
);
|
||
noteId = shortLink.noteId;
|
||
publicPageProfile = shortLink.profile;
|
||
}
|
||
|
||
try {
|
||
const sessionId = await createMcpSession(
|
||
fetchImpl,
|
||
endpoint,
|
||
timeoutMs,
|
||
);
|
||
if (!noteId) {
|
||
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 =
|
||
stringValue(noteData.noteId ?? noteData.note_id) ||
|
||
findStringByKey(noteData, "noteId") ||
|
||
findStringByKey(noteData, "note_id");
|
||
}
|
||
if (!noteId) throw new Error("无法从发布链接识别小红书笔记ID");
|
||
|
||
const authorResult = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"collect_xhs_wen_note_detail",
|
||
{
|
||
note_id: noteId,
|
||
need_desc: false,
|
||
include_raw: false,
|
||
},
|
||
);
|
||
const profile = accountProfileFromToolResult(
|
||
authorResult,
|
||
fallbackNickname,
|
||
);
|
||
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,
|
||
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: "xhs",
|
||
include_comments: false,
|
||
auto_cookie: true,
|
||
},
|
||
);
|
||
try {
|
||
return metricsFromToolResult(primary);
|
||
} catch {
|
||
const fallback = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"parse_xhs_note",
|
||
{
|
||
url: publishUrl,
|
||
include_comments: false,
|
||
auto_cookie: true,
|
||
},
|
||
);
|
||
return metricsFromToolResult(fallback);
|
||
}
|
||
}
|
||
|
||
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 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("账号主页数据采集服务暂时不可用");
|
||
}
|