feat: KOC 资源导入后自动爬取主页资料并调用 LLM 打标签

- 新增 structured_text_process 打标客户端(文本构造、标签解析清洗、MCP 调用)
- 写库后后台打标(tagUntaggedAccounts),单条新增 / 批量导入 / 每日补全三个入口统一
- 标签只补空,UPDATE 带 tags 为空守卫,不覆盖已有标签
- 新增 AI_TAGGING_TOOL 配置项(留空关闭),更新配置示例与文档
- 新增 16 个打标测试用例,全量 131 个测试通过
This commit is contained in:
ABAPPLO
2026-08-26 15:21:28 +08:00
parent 9697b5890d
commit 4a64feee02
13 changed files with 557 additions and 11 deletions

View File

@@ -1484,3 +1484,154 @@ export async function resolveXhsProfileDetailsFromMcp(
? lastError
: new Error("账号主页数据采集服务暂时不可用");
}
export type AccountTaggingInput = {
platform: string;
nickname: string;
bio: string;
gender: string;
ipLocation: string;
followers: number | null;
providerTags: string[];
recentNoteTitles: string[];
};
export type AccountTaggingEnv = {
AI_TAGGING_TOOL?: string;
};
export function buildAccountTagText(input: AccountTaggingInput): string {
const facts: string[] = [`平台:${input.platform.trim() || "小红书"}`];
if (input.nickname.trim()) facts.push(`昵称:${input.nickname.trim()}`);
if (input.bio.trim()) facts.push(`简介:${input.bio.trim()}`);
if (input.gender.trim()) facts.push(`性别:${input.gender.trim()}`);
if (input.ipLocation.trim()) {
facts.push(`IP属地:${input.ipLocation.trim()}`);
}
if (input.followers !== null && input.followers > 0) {
facts.push(`粉丝数:${input.followers}`);
}
if (input.providerTags.length > 0) {
facts.push(`平台标签:${input.providerTags.slice(0, 5).join("、")}`);
}
if (input.recentNoteTitles.length > 0) {
const titles = input.recentNoteTitles
.slice(0, 20)
.map((title) => `《${title}》`)
.join("");
facts.push(`最近笔记标题:${titles}`);
}
return facts.join("\n");
}
function sanitizeTagList(items: unknown[]): string[] {
const tags: string[] = [];
for (const item of items) {
if (typeof item !== "string") continue;
const tag = item.trim().replace(/^#+|#+$/g, "").trim();
if (!tag || tag.length > 12) continue;
if (!tags.includes(tag)) tags.push(tag);
}
return tags.slice(0, 5);
}
export function parseAccountTags(text: string): string[] {
const stripped = text
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```\s*$/, "");
const candidates = [stripped];
const arrayMatch = stripped.match(/\[[\s\S]*\]/);
if (arrayMatch) candidates.push(arrayMatch[0]);
for (const candidate of candidates) {
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
continue;
}
if (!Array.isArray(parsed)) continue;
const tags = sanitizeTagList(parsed);
if (tags.length > 0) return tags;
}
return [];
}
function parseTagsFromToolPayload(payload: unknown, depth = 0): string[] {
if (typeof payload === "string") return parseAccountTags(payload);
if (Array.isArray(payload)) {
const direct = sanitizeTagList(payload);
if (direct.length > 0) return direct;
if (depth < 2) {
for (const item of payload) {
const tags = parseTagsFromToolPayload(item, depth + 1);
if (tags.length > 0) return tags;
}
}
return [];
}
const record = asRecord(payload);
if (record && depth < 2) {
for (const key of [
"tags",
"labels",
"result",
"data",
"response",
"output",
"text",
"content",
"message",
]) {
if (key in record) {
const tags = parseTagsFromToolPayload(record[key], depth + 1);
if (tags.length > 0) return tags;
}
}
}
return [];
}
export async function generateAccountTagsFromMcp(
input: AccountTaggingInput,
config: CollectionMcpConfig,
taggingEnv: AccountTaggingEnv,
fetchImpl: typeof fetch = fetch,
): Promise<string[]> {
const toolName = String(taggingEnv.AI_TAGGING_TOOL ?? "").trim();
if (!toolName || !config.endpoint.trim()) return [];
try {
const endpoint = buildMcpUrl(config);
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 60_000);
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
const result = await invokeMcpTool(
fetchImpl,
endpoint,
sessionId,
timeoutMs,
toolName,
{
objective: "为KOC账号生成运营标签",
text: buildAccountTagText(input),
rules: [
"最多生成 5 个标签",
"每个标签不超过 8 个字",
"从内容领域、人设风格、受众画像等维度概括",
"只依据文本中给到的资料,不要编造",
],
output_schema: {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
},
required: ["tags"],
additionalProperties: false,
},
},
);
if (result.isError) return [];
return parseTagsFromToolPayload(result.payload);
} catch {
return [];
}
}