1576 lines
42 KiB
TypeScript
1576 lines
42 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;
|
||
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>,
|
||
allowText = false,
|
||
): 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 (allowText) {
|
||
return {
|
||
isError: result.envelope?.result?.isError === true,
|
||
payload: { text },
|
||
};
|
||
}
|
||
if (result.envelope?.result?.isError === true) {
|
||
return {
|
||
isError: true,
|
||
payload: { message: text },
|
||
};
|
||
}
|
||
throw new Error("MCP采集工具返回了无法解析的数据");
|
||
}
|
||
return {
|
||
isError: result.envelope?.result?.isError === true,
|
||
payload,
|
||
};
|
||
}
|
||
|
||
function isToolArgumentShapeError(result: ToolResult) {
|
||
if (!result.isError) return false;
|
||
const root = asRecord(result.payload);
|
||
const message = String(root?.message ?? "");
|
||
return /input validation error/i.test(message);
|
||
}
|
||
|
||
async function callMcpTool(
|
||
fetchImpl: typeof fetch,
|
||
endpoint: string,
|
||
sessionId: string | undefined,
|
||
timeoutMs: number,
|
||
name: string,
|
||
args: Record<string, unknown>,
|
||
allowText = false,
|
||
): Promise<ToolResult> {
|
||
const nested = await invokeMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
name,
|
||
{ request: args },
|
||
allowText,
|
||
);
|
||
if (!isToolArgumentShapeError(nested)) return nested;
|
||
return invokeMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
name,
|
||
args,
|
||
allowText,
|
||
);
|
||
}
|
||
|
||
function imageToolText(value: unknown) {
|
||
if (typeof value === "string") return value;
|
||
const root = asRecord(value);
|
||
if (!root) return "";
|
||
return [root.text, root.description, root.content, root.message, root.error]
|
||
.flatMap((item) => (Array.isArray(item) ? item : [item]))
|
||
.map((item) => {
|
||
if (typeof item === "string") return item;
|
||
const record = asRecord(item);
|
||
return String(record?.text ?? record?.description ?? "");
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
|
||
function metricFromImageText(text: string, labels: string[], label: string) {
|
||
const pattern = labels.join("|");
|
||
const match = text.match(
|
||
new RegExp(`(?:${pattern})\\s*[::]?\\s*([\\d,.]+(?:万|w|千|k)?)`, "i"),
|
||
);
|
||
if (!match) return null;
|
||
try {
|
||
return metricValue(match[1], label);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function creatorMetricsFromImageResult(result: ToolResult) {
|
||
if (result.isError) {
|
||
const detail = imageToolText(result.payload);
|
||
throw new Error(
|
||
`图片识别 MCP 调用失败${detail ? `:${safeMessage(detail, "")}` : ""}`,
|
||
);
|
||
}
|
||
const text = imageToolText(result.payload);
|
||
const exposure = metricFromImageText(
|
||
text,
|
||
["曝光量", "曝光", "impressions", "exposure"],
|
||
"曝光量",
|
||
);
|
||
const views = metricFromImageText(
|
||
text,
|
||
["阅读量", "阅读", "views", "view_count", "view count"],
|
||
"阅读量",
|
||
);
|
||
if (exposure === null || views === null) {
|
||
throw new Error("图片识别未找到曝光量和阅读量");
|
||
}
|
||
return { exposure, views };
|
||
}
|
||
|
||
export async function extractCreatorMetricsFromMcp(
|
||
imageUrl: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
const endpoint = buildMcpUrl(config);
|
||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||
const result = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"analyze_image",
|
||
{ image_url: imageUrl },
|
||
true,
|
||
);
|
||
return creatorMetricsFromImageResult(result);
|
||
}
|
||
|
||
function metricsFromToolResult(
|
||
result: ToolResult,
|
||
toolName = "fetch_content_detail",
|
||
): XhsPublicMetrics {
|
||
const root = asRecord(result.payload);
|
||
const response = asRecord(root?.response) ?? root;
|
||
const data = asRecord(response?.data) ?? asRecord(root?.data);
|
||
const code = Number(response?.code);
|
||
const success = response?.success;
|
||
|
||
if (
|
||
result.isError ||
|
||
success === false ||
|
||
(Number.isFinite(code) && code >= 400)
|
||
) {
|
||
const providerCode = Number(response?.code);
|
||
const codeLabel = Number.isFinite(providerCode)
|
||
? `(code ${providerCode})`
|
||
: "";
|
||
throw new Error(
|
||
`MCP工具 ${toolName} 返回失败${codeLabel}:${safeMessage(
|
||
response?.msg ?? response?.message ?? root?.message,
|
||
"公开数据采集失败",
|
||
)}`,
|
||
);
|
||
}
|
||
if (!data) throw new Error(`MCP工具 ${toolName} 未返回互动数据`);
|
||
|
||
const count = (value: unknown, label: string) =>
|
||
value === null || value === undefined || value === ""
|
||
? 0
|
||
: metricValue(value, label);
|
||
return {
|
||
likes: count(
|
||
data.likes ??
|
||
data.liked_count ??
|
||
data.likedCount ??
|
||
data.like_count ??
|
||
data.likeCount ??
|
||
data.digg_count ??
|
||
data.diggCount,
|
||
"点赞数",
|
||
),
|
||
comments: count(
|
||
data.comments ?? data.comment_count ?? data.commentCount,
|
||
"评论数",
|
||
),
|
||
collects: count(
|
||
data.collects ??
|
||
data.collected_count ??
|
||
data.collectedCount ??
|
||
data.favorites ??
|
||
data.favourites ??
|
||
data.collect_count ??
|
||
data.collectCount,
|
||
"收藏数",
|
||
),
|
||
shares: count(
|
||
data.shares ??
|
||
data.share_count ??
|
||
data.shareCount ??
|
||
data.forwards ??
|
||
data.forward_count ??
|
||
data.forwardCount,
|
||
"转发数",
|
||
),
|
||
};
|
||
}
|
||
|
||
function usableDouyinSecUid(value: unknown) {
|
||
const candidate = stringValue(value);
|
||
return candidate && !/^\d+$/.test(candidate) && /^[A-Za-z0-9_-]{20,220}$/.test(candidate)
|
||
? candidate
|
||
: "";
|
||
}
|
||
|
||
function verifiedDouyinProfileUrl(value: unknown) {
|
||
const candidate = stringValue(value);
|
||
if (!candidate) return "";
|
||
try {
|
||
const parsed = new URL(candidate);
|
||
const secUid = decodeURIComponent(parsed.pathname.match(/^\/user\/([^/?#]+)/)?.[1] ?? "");
|
||
if (
|
||
parsed.protocol === "https:" &&
|
||
(parsed.hostname === "douyin.com" || parsed.hostname.endsWith(".douyin.com")) &&
|
||
usableDouyinSecUid(secUid)
|
||
) {
|
||
return parsed.toString();
|
||
}
|
||
} catch {
|
||
// The public redirect fallback below can still recover the profile URL.
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function douyinProfileFromPublicRedirect(
|
||
publishUrl: string,
|
||
fetchImpl: typeof fetch,
|
||
timeoutMs: number,
|
||
) {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(publishUrl);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (
|
||
parsed.protocol !== "https:" ||
|
||
!(parsed.hostname === "douyin.com" || parsed.hostname.endsWith(".douyin.com"))
|
||
) {
|
||
return null;
|
||
}
|
||
try {
|
||
const response = await fetchImpl(parsed.toString(), {
|
||
method: "GET",
|
||
redirect: "manual",
|
||
signal: AbortSignal.timeout(Math.min(timeoutMs, 15_000)),
|
||
headers: {
|
||
"user-agent":
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
|
||
},
|
||
});
|
||
const location = response.headers.get("location");
|
||
if (!location) return null;
|
||
const redirectUrl = new URL(location, parsed);
|
||
const secUid = usableDouyinSecUid(
|
||
redirectUrl.searchParams.get("sec_uid") ??
|
||
redirectUrl.searchParams.get("sec_user_id"),
|
||
);
|
||
return secUid
|
||
? {
|
||
platformUid: secUid,
|
||
profileUrl: `https://www.douyin.com/user/${encodeURIComponent(secUid)}`,
|
||
}
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function successfulToolData(result: ToolResult, fallbackMessage: string) {
|
||
const root = asRecord(result.payload);
|
||
const response = asRecord(root?.response) ?? root;
|
||
const data = asRecord(response?.data) ?? asRecord(root?.data);
|
||
const code = Number(response?.code);
|
||
if (
|
||
result.isError ||
|
||
response?.success === false ||
|
||
(Number.isFinite(code) && code >= 400)
|
||
) {
|
||
throw new Error(
|
||
safeMessage(
|
||
response?.msg ?? response?.message ?? root?.message,
|
||
fallbackMessage,
|
||
),
|
||
);
|
||
}
|
||
if (!data) throw new Error(fallbackMessage);
|
||
return data;
|
||
}
|
||
|
||
function stringValue(value: unknown) {
|
||
return typeof value === "string" ? value.trim() : "";
|
||
}
|
||
|
||
function findRecord(
|
||
value: unknown,
|
||
predicate: (record: Record<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,.万千wkWKW+]+)/i)?.[1],
|
||
);
|
||
}
|
||
if (depth > 12) return null;
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const found = followerCountFromPayload(item, depth + 1);
|
||
if (found !== null) return found;
|
||
}
|
||
return null;
|
||
}
|
||
const record = asRecord(value);
|
||
if (!record) return null;
|
||
const label = stringValue(
|
||
record.name ?? record.label ?? record.title ?? record.type,
|
||
).toLowerCase();
|
||
if (["粉丝", "粉丝数", "fans", "followers"].includes(label)) {
|
||
const labeled = optionalCountValue(
|
||
record.count ?? record.value ?? record.i18nCount,
|
||
);
|
||
if (labeled !== null) return labeled;
|
||
}
|
||
for (const child of Object.values(record)) {
|
||
const found = followerCountFromPayload(child, depth + 1);
|
||
if (found !== null) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function xhsNoteIdFromUrl(input: string) {
|
||
try {
|
||
const url = new URL(input);
|
||
if (
|
||
!(
|
||
url.hostname === "xiaohongshu.com" ||
|
||
url.hostname.endsWith(".xiaohongshu.com")
|
||
)
|
||
) {
|
||
return "";
|
||
}
|
||
return (
|
||
url.pathname.match(
|
||
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
|
||
)?.[1] ?? ""
|
||
);
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
async function xhsNoteIdFromShortLink(
|
||
input: string,
|
||
fallbackNickname: string,
|
||
fetchImpl: typeof fetch,
|
||
timeoutMs: number,
|
||
) {
|
||
let url: URL;
|
||
try {
|
||
url = new URL(input);
|
||
} catch {
|
||
return { noteId: "", profile: null };
|
||
}
|
||
if (
|
||
url.protocol !== "http:" &&
|
||
url.protocol !== "https:"
|
||
) {
|
||
return { noteId: "", profile: null };
|
||
}
|
||
const isShortLink =
|
||
url.hostname === "xhslink.cn" ||
|
||
url.hostname.endsWith(".xhslink.cn");
|
||
const isXhsPage =
|
||
url.hostname === "xiaohongshu.com" ||
|
||
url.hostname.endsWith(".xiaohongshu.com");
|
||
if (!isShortLink && !isXhsPage) {
|
||
return { noteId: "", profile: null };
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const response = await fetchImpl(url.toString(), {
|
||
method: "GET",
|
||
redirect: "follow",
|
||
signal: controller.signal,
|
||
headers: {
|
||
"user-agent":
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
|
||
},
|
||
});
|
||
const location = response.headers.get("location");
|
||
const resolvedUrl = location
|
||
? new URL(location, url).toString()
|
||
: response.url;
|
||
const noteId = xhsNoteIdFromUrl(resolvedUrl);
|
||
const html =
|
||
response.ok &&
|
||
response.headers.get("content-type")?.includes("text/html")
|
||
? await response.text()
|
||
: "";
|
||
return {
|
||
noteId,
|
||
profile: accountProfileFromPublicPage(
|
||
html,
|
||
fallbackNickname,
|
||
),
|
||
};
|
||
} catch {
|
||
return { noteId: "", profile: null };
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
function accountProfileFromPublicPage(
|
||
html: string,
|
||
fallbackNickname: string,
|
||
): XhsAccountProfile | null {
|
||
if (!html) return null;
|
||
const decoded = html
|
||
.replaceAll(""", '"')
|
||
.replaceAll(""", '"')
|
||
.replaceAll("&", "&")
|
||
.replaceAll("'", "'")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
const noteData = decoded.match(
|
||
/"noteData":\{[\s\S]{0,30000}?"user":\{([\s\S]{0,1800}?)\}/,
|
||
)?.[1];
|
||
if (!noteData) return null;
|
||
const platformUid =
|
||
noteData.match(/"userId":"([A-Za-z0-9_-]{8,80})"/)?.[1] ?? "";
|
||
if (!platformUid) return null;
|
||
const nickname =
|
||
noteData.match(/"(?:nickName|nickname)":"([^"]+)"/)?.[1] ??
|
||
fallbackNickname.trim();
|
||
const redId =
|
||
noteData.match(/"(?:redId|red_id)":"([^"]+)"/)?.[1] ??
|
||
decoded.match(/"(?:redId|red_id)":"([^"]+)"/)?.[1] ??
|
||
decoded.match(/小红书号[::]\s*([^<"\s]+)/)?.[1] ??
|
||
"";
|
||
return {
|
||
platformUid,
|
||
nickname,
|
||
profileUrl: `https://www.xiaohongshu.com/user/profile/${encodeURIComponent(
|
||
platformUid,
|
||
)}`,
|
||
redId,
|
||
ipLocation: "待识别",
|
||
followers: null,
|
||
gender: "",
|
||
bio: "",
|
||
recentNoteTitles: [],
|
||
providerTags: [],
|
||
};
|
||
}
|
||
|
||
export async function resolveXhsPublicAccountDetails(
|
||
profileUrl: string,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(profileUrl);
|
||
} catch {
|
||
return { nickname: "", redId: "", followers: null, ipLocation: "" };
|
||
}
|
||
if (
|
||
parsed.protocol !== "https:" ||
|
||
!(
|
||
parsed.hostname === "xiaohongshu.com" ||
|
||
parsed.hostname.endsWith(".xiaohongshu.com")
|
||
) ||
|
||
!parsed.pathname.startsWith("/user/profile/")
|
||
) {
|
||
return { nickname: "", redId: "", followers: null, ipLocation: "" };
|
||
}
|
||
try {
|
||
const response = await fetchImpl(parsed.toString(), {
|
||
method: "GET",
|
||
redirect: "follow",
|
||
signal: AbortSignal.timeout(15_000),
|
||
headers: {
|
||
"user-agent":
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/138.0 Safari/537.36",
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
return { nickname: "", redId: "", followers: null, ipLocation: "" };
|
||
}
|
||
const html = await response.text();
|
||
return {
|
||
nickname:
|
||
html.match(/"(?:nickname|nickName)":"([^"]+)"/)?.[1] ?? "",
|
||
redId:
|
||
html.match(/"(?:redId|red_id)":"([^"]+)"/)?.[1] ??
|
||
html.match(/小红书号[::]\s*([^<"\s]+)/)?.[1] ??
|
||
"",
|
||
followers:
|
||
optionalCountValue(
|
||
html.match(
|
||
/"name"\s*:\s*"粉丝"\s*,\s*"count"\s*:\s*"([^"]+)"/,
|
||
)?.[1],
|
||
) ??
|
||
followerCountFromPayload(html),
|
||
ipLocation: "",
|
||
};
|
||
} catch {
|
||
return { nickname: "", redId: "", followers: null, ipLocation: "" };
|
||
}
|
||
}
|
||
|
||
export async function resolveXhsPublicAccountId(
|
||
profileUrl: string,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
return (await resolveXhsPublicAccountDetails(profileUrl, fetchImpl)).redId;
|
||
}
|
||
|
||
function profileDetailsFromToolResult(result: ToolResult) {
|
||
const root = asRecord(result.payload);
|
||
const response = asRecord(root?.response) ?? root;
|
||
const code = Number(response?.code);
|
||
if (
|
||
result.isError ||
|
||
response?.success === false ||
|
||
(Number.isFinite(code) && code >= 400)
|
||
) {
|
||
throw new Error(
|
||
safeMessage(
|
||
response?.msg ?? response?.message ?? root?.message,
|
||
"账号主页数据采集失败",
|
||
),
|
||
);
|
||
}
|
||
const payload =
|
||
asRecord(response?.data) ?? asRecord(root?.data) ?? result.payload;
|
||
const followers = followerCountFromPayload(payload);
|
||
const nickname =
|
||
findStringByKey(payload, "nickname") ||
|
||
findStringByKey(payload, "nickName");
|
||
const redId =
|
||
findStringByKey(payload, "red_id") ||
|
||
findStringByKey(payload, "redId") ||
|
||
findStringByKey(payload, "unique_id") ||
|
||
findStringByKey(payload, "uniqueId") ||
|
||
findStringByKey(payload, "short_id") ||
|
||
findStringByKey(payload, "shortId") ||
|
||
findStringByKey(payload, "douyin_id") ||
|
||
findStringByKey(payload, "userId") ||
|
||
findStringByKey(payload, "user_id");
|
||
const ipLocation =
|
||
findStringByKey(payload, "ip_location") ||
|
||
findStringByKey(payload, "ipLocation");
|
||
const gender = profileGender(findValueByKeys(payload, new Set(["gender", "sex"])));
|
||
const bio =
|
||
findStringByKey(payload, "desc") ||
|
||
findStringByKey(payload, "description") ||
|
||
findStringByKey(payload, "signature") ||
|
||
findStringByKey(payload, "bio");
|
||
const recentNoteTitles = recentNoteTitlesFromPayload(payload);
|
||
const providerTags = providerTagsFromUser(payload);
|
||
if (
|
||
followers === null &&
|
||
!nickname &&
|
||
!redId &&
|
||
!ipLocation &&
|
||
!gender &&
|
||
!bio &&
|
||
recentNoteTitles.length === 0
|
||
) {
|
||
throw new Error("账号主页采集结果缺少可用字段");
|
||
}
|
||
return {
|
||
nickname,
|
||
followers,
|
||
redId,
|
||
ipLocation,
|
||
gender,
|
||
bio,
|
||
recentNoteTitles,
|
||
providerTags,
|
||
};
|
||
}
|
||
|
||
function accountProfileFromToolResult(
|
||
result: ToolResult,
|
||
fallbackNickname: string,
|
||
): XhsAccountProfile {
|
||
const data = successfulToolData(result, "账号主页识别失败");
|
||
const user = findRecord(
|
||
data,
|
||
(record) =>
|
||
Boolean(
|
||
stringValue(record.user_id ?? record.userid ?? record.userId) &&
|
||
(stringValue(record.profile_url) ||
|
||
stringValue(record.nickname ?? record.name)),
|
||
),
|
||
);
|
||
if (!user) throw new Error("账号主页识别结果缺少作者信息");
|
||
const platformUid = stringValue(
|
||
user.user_id ?? user.userid ?? user.userId,
|
||
);
|
||
const candidateProfileUrl = stringValue(
|
||
user.profile_url ?? user.profileUrl,
|
||
);
|
||
let profileUrl = "";
|
||
if (candidateProfileUrl) {
|
||
try {
|
||
const parsed = new URL(candidateProfileUrl);
|
||
if (
|
||
parsed.protocol === "https:" &&
|
||
(parsed.hostname === "xiaohongshu.com" ||
|
||
parsed.hostname.endsWith(".xiaohongshu.com")) &&
|
||
parsed.pathname.startsWith("/user/profile/")
|
||
) {
|
||
profileUrl = parsed.toString();
|
||
}
|
||
} catch {
|
||
// Fall back to the verified user id below.
|
||
}
|
||
}
|
||
if (!profileUrl) {
|
||
profileUrl = `https://www.xiaohongshu.com/user/profile/${encodeURIComponent(
|
||
platformUid,
|
||
)}`;
|
||
}
|
||
return {
|
||
platformUid,
|
||
nickname:
|
||
stringValue(user.nickname ?? user.name) || fallbackNickname.trim(),
|
||
profileUrl,
|
||
redId: stringValue(user.red_id),
|
||
ipLocation: findStringByKey(data, "ip_location") || "待识别",
|
||
followers: followerCountFromPayload(data),
|
||
gender: "",
|
||
bio: "",
|
||
recentNoteTitles: [],
|
||
providerTags: [],
|
||
};
|
||
}
|
||
|
||
async function completeAccountProfile(
|
||
profile: XhsAccountProfile,
|
||
fetchImpl: typeof fetch,
|
||
endpoint: string,
|
||
sessionId: string | undefined,
|
||
timeoutMs: number,
|
||
) {
|
||
let completed = profile;
|
||
for (const [name, args] of [
|
||
[
|
||
"parse_xhs_user_summary",
|
||
{ url: profile.profileUrl, use_proxy: true },
|
||
],
|
||
] as const) {
|
||
try {
|
||
const result = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
name,
|
||
args,
|
||
);
|
||
const details = profileDetailsFromToolResult(result);
|
||
completed = {
|
||
...completed,
|
||
followers: details.followers ?? completed.followers,
|
||
redId: details.redId || completed.redId,
|
||
ipLocation:
|
||
details.ipLocation && details.ipLocation !== "待识别"
|
||
? details.ipLocation
|
||
: completed.ipLocation,
|
||
gender: details.gender || completed.gender,
|
||
bio: details.bio || completed.bio,
|
||
recentNoteTitles:
|
||
details.recentNoteTitles.length > 0
|
||
? details.recentNoteTitles
|
||
: completed.recentNoteTitles,
|
||
providerTags:
|
||
details.providerTags.length > 0
|
||
? details.providerTags
|
||
: completed.providerTags,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof McpSessionLostError) throw error;
|
||
}
|
||
}
|
||
|
||
if (completed.followers === null || !completed.redId) {
|
||
const details = await resolveXhsPublicAccountDetails(
|
||
completed.profileUrl,
|
||
fetchImpl,
|
||
);
|
||
completed = {
|
||
...completed,
|
||
followers: details.followers ?? completed.followers,
|
||
redId: details.redId || completed.redId,
|
||
};
|
||
}
|
||
return completed;
|
||
}
|
||
|
||
async function resolveProfileDetailsInSession(
|
||
profileUrl: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch,
|
||
) {
|
||
const endpoint = buildMcpUrl(config);
|
||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||
const result = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"parse_xhs_user_summary",
|
||
{
|
||
url: profileUrl,
|
||
use_proxy: true,
|
||
},
|
||
);
|
||
return profileDetailsFromToolResult(result);
|
||
}
|
||
|
||
async function resolveAccountInSession(
|
||
publishUrl: string,
|
||
fallbackNickname: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch,
|
||
) {
|
||
const endpoint = buildMcpUrl(config);
|
||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||
let noteId = xhsNoteIdFromUrl(publishUrl);
|
||
const linkPage = await xhsNoteIdFromShortLink(
|
||
publishUrl,
|
||
fallbackNickname,
|
||
fetchImpl,
|
||
timeoutMs,
|
||
);
|
||
noteId = noteId || linkPage.noteId;
|
||
const publicPageProfile = linkPage.profile;
|
||
|
||
try {
|
||
const sessionId = await createMcpSession(
|
||
fetchImpl,
|
||
endpoint,
|
||
timeoutMs,
|
||
);
|
||
const noteResult = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"fetch_content_detail",
|
||
{
|
||
link: publishUrl,
|
||
plant: "xhs",
|
||
include_comments: false,
|
||
auto_cookie: true,
|
||
},
|
||
);
|
||
const noteData = successfulToolData(
|
||
noteResult,
|
||
"无法识别小红书笔记",
|
||
);
|
||
noteId =
|
||
noteId ||
|
||
stringValue(noteData.noteId ?? noteData.note_id) ||
|
||
findStringByKey(noteData, "noteId") ||
|
||
findStringByKey(noteData, "note_id");
|
||
if (!noteId) throw new Error("无法从发布链接识别小红书笔记ID");
|
||
let profile: XhsAccountProfile;
|
||
try {
|
||
profile = accountProfileFromToolResult(
|
||
noteResult,
|
||
fallbackNickname,
|
||
);
|
||
} catch {
|
||
if (!publicPageProfile) throw new Error("笔记数据缺少公开作者主页");
|
||
profile = publicPageProfile;
|
||
}
|
||
return completeAccountProfile(
|
||
profile,
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof McpSessionLostError) throw error;
|
||
if (publicPageProfile) {
|
||
const details = await resolveXhsPublicAccountDetails(
|
||
publicPageProfile.profileUrl,
|
||
fetchImpl,
|
||
);
|
||
return {
|
||
...publicPageProfile,
|
||
redId: details.redId || publicPageProfile.redId,
|
||
followers: details.followers,
|
||
};
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function collectInSession(
|
||
publishUrl: string,
|
||
platform: "小红书" | "抖音",
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch,
|
||
) {
|
||
const endpoint = buildMcpUrl(config);
|
||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||
|
||
const primary = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"fetch_content_detail",
|
||
{
|
||
link: publishUrl,
|
||
plant: platform === "抖音" ? "dy" : "xhs",
|
||
include_comments: false,
|
||
auto_cookie: true,
|
||
},
|
||
);
|
||
return metricsFromToolResult(primary, "fetch_content_detail");
|
||
}
|
||
|
||
export function resolveCollectionMcpConfig(
|
||
bindings: CollectionMcpBindings,
|
||
): CollectionMcpConfig {
|
||
return {
|
||
endpoint:
|
||
bindings.AI_TOOL_CENTER_MCP_URL?.trim() ||
|
||
DEFAULT_COLLECTION_MCP_URL,
|
||
key: bindings.AI_TOOL_CENTER_MCP_KEY?.trim(),
|
||
};
|
||
}
|
||
|
||
export async function collectXhsMetricsFromMcp(
|
||
publishUrl: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(publishUrl);
|
||
} catch {
|
||
throw new Error("发布链接无效");
|
||
}
|
||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||
throw new Error("发布链接无效");
|
||
}
|
||
|
||
let lastError: unknown;
|
||
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
return await collectInSession(parsed.toString(), "小红书", config, fetchImpl);
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (!isRetryableTransportError(error)) throw error;
|
||
}
|
||
}
|
||
if (
|
||
lastError instanceof Error &&
|
||
["AbortError", "TimeoutError"].includes(lastError.name)
|
||
) {
|
||
throw new Error("MCP采集服务响应超时,请稍后重试");
|
||
}
|
||
throw lastError instanceof Error
|
||
? lastError
|
||
: new Error("MCP采集服务暂时不可用");
|
||
}
|
||
|
||
export async function collectMetricsFromMcp(
|
||
publishUrl: string,
|
||
platform: "小红书" | "抖音",
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
if (platform === "小红书") {
|
||
return collectXhsMetricsFromMcp(publishUrl, config, fetchImpl);
|
||
}
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(publishUrl);
|
||
} catch {
|
||
throw new Error("发布链接无效");
|
||
}
|
||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||
throw new Error("发布链接无效");
|
||
}
|
||
let lastError: unknown;
|
||
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
return await collectInSession(parsed.toString(), platform, config, fetchImpl);
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (!isRetryableTransportError(error)) throw error;
|
||
}
|
||
}
|
||
throw lastError instanceof Error
|
||
? lastError
|
||
: new Error("MCP采集服务暂时不可用");
|
||
}
|
||
|
||
function douyinProfileFromToolResult(
|
||
result: ToolResult,
|
||
fallbackNickname: string,
|
||
): XhsAccountProfile {
|
||
const data = successfulToolData(result, "无法识别抖音作品");
|
||
const author = findRecord(data, (record) =>
|
||
Boolean(
|
||
stringValue(
|
||
record.sec_uid ?? record.secUid ?? record.uid ?? record.user_id ?? record.userId,
|
||
) && stringValue(record.nickname ?? record.name ?? record.unique_id ?? record.uniqueId),
|
||
),
|
||
);
|
||
if (!author) throw new Error("抖音作品数据缺少作者信息");
|
||
const verifiedSecUid = usableDouyinSecUid(author.sec_uid ?? author.secUid);
|
||
const fallbackUid = stringValue(author.uid ?? author.user_id ?? author.userId);
|
||
const platformUid = verifiedSecUid || fallbackUid;
|
||
const publicId = stringValue(
|
||
author.unique_id ?? author.uniqueId ?? author.short_id ?? author.shortId ?? author.douyin_id,
|
||
);
|
||
const candidateProfileUrl = verifiedDouyinProfileUrl(
|
||
author.profile_url ?? author.profileUrl,
|
||
);
|
||
const profileUrl = candidateProfileUrl ||
|
||
(verifiedSecUid
|
||
? `https://www.douyin.com/user/${encodeURIComponent(verifiedSecUid)}`
|
||
: "");
|
||
return {
|
||
platformUid,
|
||
nickname: stringValue(author.nickname ?? author.name) || fallbackNickname.trim(),
|
||
profileUrl,
|
||
redId: publicId,
|
||
ipLocation:
|
||
stringValue(author.ip_location ?? author.ipLocation) ||
|
||
findStringByKey(data, "ip_location") ||
|
||
findStringByKey(data, "ipLocation") ||
|
||
"待识别",
|
||
followers: followerCountFromPayload(author) ?? followerCountFromPayload(data),
|
||
gender: profileGender(author.gender ?? author.sex),
|
||
bio: stringValue(author.desc ?? author.description ?? author.signature ?? author.bio),
|
||
recentNoteTitles: recentNoteTitlesFromPayload(data),
|
||
providerTags: providerTagsFromUser(data),
|
||
};
|
||
}
|
||
|
||
async function resolveDouyinAccountInSession(
|
||
publishUrl: string,
|
||
fallbackNickname: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch,
|
||
) {
|
||
const endpoint = buildMcpUrl(config);
|
||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||
const result = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"fetch_content_detail",
|
||
{
|
||
link: publishUrl,
|
||
plant: "dy",
|
||
include_comments: false,
|
||
auto_cookie: true,
|
||
},
|
||
);
|
||
let profile = douyinProfileFromToolResult(result, fallbackNickname);
|
||
if (!verifiedDouyinProfileUrl(profile.profileUrl)) {
|
||
const resolved = await douyinProfileFromPublicRedirect(
|
||
publishUrl,
|
||
fetchImpl,
|
||
timeoutMs,
|
||
);
|
||
if (resolved) {
|
||
profile = {
|
||
...profile,
|
||
platformUid: resolved.platformUid,
|
||
profileUrl: resolved.profileUrl,
|
||
};
|
||
}
|
||
}
|
||
if (!verifiedDouyinProfileUrl(profile.profileUrl)) return profile;
|
||
try {
|
||
const detailsResult = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"parse_dy_user_summary",
|
||
{ url: profile.profileUrl },
|
||
);
|
||
const details = profileDetailsFromToolResult(detailsResult);
|
||
profile = {
|
||
...profile,
|
||
nickname: details.nickname || profile.nickname,
|
||
redId: details.redId || profile.redId,
|
||
followers: details.followers ?? profile.followers,
|
||
ipLocation:
|
||
details.ipLocation && details.ipLocation !== "待识别"
|
||
? details.ipLocation
|
||
: profile.ipLocation,
|
||
gender: details.gender || profile.gender,
|
||
bio: details.bio || profile.bio,
|
||
recentNoteTitles:
|
||
details.recentNoteTitles.length > 0
|
||
? details.recentNoteTitles
|
||
: profile.recentNoteTitles,
|
||
providerTags:
|
||
details.providerTags.length > 0
|
||
? details.providerTags
|
||
: profile.providerTags,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof McpSessionLostError) throw error;
|
||
}
|
||
return profile;
|
||
}
|
||
|
||
export async function resolveAccountProfileFromMcp(
|
||
publishUrl: string,
|
||
fallbackNickname: string,
|
||
platform: "小红书" | "抖音",
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
if (platform === "小红书") {
|
||
return resolveXhsAccountProfileFromMcp(
|
||
publishUrl,
|
||
fallbackNickname,
|
||
config,
|
||
fetchImpl,
|
||
);
|
||
}
|
||
let lastError: unknown;
|
||
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
return await resolveDouyinAccountInSession(
|
||
publishUrl,
|
||
fallbackNickname,
|
||
config,
|
||
fetchImpl,
|
||
);
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (!isRetryableTransportError(error)) throw error;
|
||
}
|
||
}
|
||
throw lastError instanceof Error ? lastError : new Error("抖音账号识别失败");
|
||
}
|
||
|
||
export async function resolveProfileDetailsFromMcp(
|
||
profileUrl: string,
|
||
platform: "小红书" | "抖音",
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
if (platform === "小红书") {
|
||
return resolveXhsProfileDetailsFromMcp(profileUrl, config, fetchImpl);
|
||
}
|
||
const endpoint = buildMcpUrl(config);
|
||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||
const result = await callMcpTool(
|
||
fetchImpl,
|
||
endpoint,
|
||
sessionId,
|
||
timeoutMs,
|
||
"parse_dy_user_summary",
|
||
{ url: profileUrl },
|
||
);
|
||
return profileDetailsFromToolResult(result);
|
||
}
|
||
|
||
export async function resolveXhsAccountProfileFromMcp(
|
||
publishUrl: string,
|
||
fallbackNickname: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(publishUrl);
|
||
} catch {
|
||
throw new Error("发布链接无效");
|
||
}
|
||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||
throw new Error("发布链接无效");
|
||
}
|
||
|
||
let lastError: unknown;
|
||
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
return await resolveAccountInSession(
|
||
parsed.toString(),
|
||
fallbackNickname,
|
||
config,
|
||
fetchImpl,
|
||
);
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (!isRetryableTransportError(error)) throw error;
|
||
}
|
||
}
|
||
throw lastError instanceof Error
|
||
? lastError
|
||
: new Error("账号主页识别服务暂时不可用");
|
||
}
|
||
|
||
export async function resolveXhsProfileDetailsFromMcp(
|
||
profileUrl: string,
|
||
config: CollectionMcpConfig,
|
||
fetchImpl: typeof fetch = fetch,
|
||
) {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(profileUrl);
|
||
} catch {
|
||
throw new Error("账号主页链接无效");
|
||
}
|
||
if (
|
||
parsed.protocol !== "https:" ||
|
||
!(
|
||
parsed.hostname === "xiaohongshu.com" ||
|
||
parsed.hostname.endsWith(".xiaohongshu.com")
|
||
) ||
|
||
!parsed.pathname.startsWith("/user/profile/")
|
||
) {
|
||
throw new Error("账号主页链接无效");
|
||
}
|
||
|
||
let lastError: unknown;
|
||
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
return await resolveProfileDetailsInSession(
|
||
parsed.toString(),
|
||
config,
|
||
fetchImpl,
|
||
);
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (!isRetryableTransportError(error)) throw error;
|
||
}
|
||
}
|
||
throw lastError instanceof Error
|
||
? lastError
|
||
: new Error("账号主页数据采集服务暂时不可用");
|
||
}
|