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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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");
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
async function invokeMcpTool(
|
2026-07-30 12:06:41 +08:00
|
|
|
|
fetchImpl: typeof fetch,
|
|
|
|
|
|
endpoint: string,
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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 {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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("采集结果缺少互动数据");
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const count = (value: unknown, label: string) =>
|
|
|
|
|
|
value === null || value === undefined || value === ""
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: metricValue(value, label);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
return {
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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
|
|
|
|
"收藏数",
|
|
|
|
|
|
),
|
2026-08-15 03:53:09 +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
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 03:53:09 +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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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,.万千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 };
|
|
|
|
|
|
}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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(""", '"')
|
|
|
|
|
|
.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,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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 {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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/")
|
|
|
|
|
|
) {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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) {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
return { nickname: "", redId: "", followers: null, ipLocation: "" };
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
const html = await response.text();
|
|
|
|
|
|
return {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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 {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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") ||
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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");
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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("账号主页采集结果缺少可用字段");
|
|
|
|
|
|
}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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(
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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("账号主页识别结果缺少作者信息");
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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),
|
2026-08-15 03:53:09 +08:00
|
|
|
|
gender: "",
|
|
|
|
|
|
bio: "",
|
|
|
|
|
|
recentNoteTitles: [],
|
|
|
|
|
|
providerTags: [],
|
2026-07-30 12:06:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function completeAccountProfile(
|
|
|
|
|
|
profile: XhsAccountProfile,
|
|
|
|
|
|
fetchImpl: typeof fetch,
|
|
|
|
|
|
endpoint: string,
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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,
|
|
|
|
|
|
);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const noteResult = await callMcpTool(
|
2026-07-30 12:06:41 +08:00
|
|
|
|
fetchImpl,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
timeoutMs,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
"fetch_content_detail",
|
2026-07-30 12:06:41 +08:00
|
|
|
|
{
|
2026-08-15 03:53:09 +08:00
|
|
|
|
link: publishUrl,
|
|
|
|
|
|
plant: "xhs",
|
|
|
|
|
|
include_comments: false,
|
|
|
|
|
|
auto_cookie: true,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const noteData = successfulToolData(
|
|
|
|
|
|
noteResult,
|
|
|
|
|
|
"无法识别小红书笔记",
|
2026-07-30 12:06:41 +08:00
|
|
|
|
);
|
2026-08-15 03:53:09 +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,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
plant: platform === "抖音" ? "dy" : "xhs",
|
2026-07-30 12:06:41 +08:00
|
|
|
|
include_comments: false,
|
|
|
|
|
|
auto_cookie: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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 {
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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采集服务暂时不可用");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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("账号主页数据采集服务暂时不可用");
|
|
|
|
|
|
}
|