recommendation.service.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import { config } from '../config.ts';
  2. import { searchCreators, type JustOneCreator } from './justone.service.ts';
  3. import type { ContentSample } from './tikhub.service.ts';
  4. import { recordRetrievalEvent, searchLocalCreators } from './local-creator-db.service.ts';
  5. export interface NormalizedCandidate {
  6. id: string;
  7. platform: string;
  8. platformUserId: string;
  9. displayName: string;
  10. profileUrl: string;
  11. location: string;
  12. fansCount: number;
  13. contentTags: string[];
  14. personaTags: string[];
  15. gender?: string;
  16. likedCollectCount?: number;
  17. contentType?: string;
  18. city?: string;
  19. geoLocation?: string;
  20. xiaohongshuUrl?: string;
  21. cooperationMethod?: string;
  22. imagePrice: number;
  23. videoPrice: number;
  24. minPrice: number;
  25. cooperationStatus: string;
  26. sourceProvider: string;
  27. sourceConfidence: number;
  28. score: number;
  29. styleMatch: number;
  30. recommendStatus: '强推荐' | '备选' | '需复核' | '已剔除';
  31. recommendReason: string;
  32. riskNote: string;
  33. contentSamples?: ContentSample[];
  34. }
  35. interface SearchCriteria {
  36. platforms: string[];
  37. keywords: string[];
  38. fanRange: { min: number; max: number };
  39. budgetRange: { min: number; max: number };
  40. region?: string | string[];
  41. gender?: string;
  42. contentTags?: string[];
  43. excludeTags?: string[];
  44. targetCount?: number;
  45. }
  46. interface ScoringWeights {
  47. fanMatch: number;
  48. priceMatch: number;
  49. contentRelevance: number;
  50. activityLevel: number;
  51. cooperationReady: number;
  52. }
  53. const DEFAULT_WEIGHTS: ScoringWeights = {
  54. fanMatch: 20,
  55. priceMatch: 20,
  56. contentRelevance: 30,
  57. activityLevel: 15,
  58. cooperationReady: 15,
  59. };
  60. const MAX_KEYWORDS_PER_PLATFORM = 1;
  61. const PAGES_PER_PLATFORM = 3;
  62. const DEFAULT_MIN_CANDIDATE_POOL = 30;
  63. /**
  64. * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序
  65. */
  66. export async function generateRecommendations(
  67. criteria: SearchCriteria,
  68. onProgress?: (stage: string, detail: string) => void
  69. ): Promise<NormalizedCandidate[]> {
  70. criteria = normalizeSearchCriteria(criteria);
  71. console.log('[Recommend] ========== 开始推荐流程 ==========');
  72. console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2));
  73. const targetCandidatePool = resolveTargetCandidatePool(criteria);
  74. const requestedPlatforms = uniquePlatforms(criteria.platforms);
  75. const targetPerPlatform = Math.max(1, Math.ceil(targetCandidatePool / Math.max(1, requestedPlatforms.length)));
  76. onProgress?.('search', `正在检索候选达人,目标候选池 ${targetCandidatePool} 位...`);
  77. // Step 1: 优先从本地/自有达人库召回候选
  78. const allCandidates: JustOneCreator[] = [];
  79. const localCandidates: JustOneCreator[] = [];
  80. for (const platform of requestedPlatforms) {
  81. const platformLocalCandidates = await searchLocalCreators(
  82. { ...criteria, platforms: [platform] },
  83. Math.min(config.recommendation.maxCandidates, targetPerPlatform),
  84. );
  85. localCandidates.push(...platformLocalCandidates);
  86. allCandidates.push(...platformLocalCandidates);
  87. console.log(`[Recommend] 本地达人库 ${platform} 命中: ${platformLocalCandidates.length}/${targetPerPlatform} 位`);
  88. }
  89. if (localCandidates.length > 0) {
  90. console.log(`[Recommend] 本地达人库命中: ${localCandidates.length} 位`);
  91. onProgress?.('search', `本地达人库命中 ${localCandidates.length}/${targetCandidatePool} 位候选达人`);
  92. }
  93. // JustOne API 仅支持小红书和抖音
  94. const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音'];
  95. let providerCandidateCount = 0;
  96. if (platformsNeedSupplement(allCandidates, requestedPlatforms, targetPerPlatform)) {
  97. const shortage = targetCandidatePool - allCandidates.length;
  98. console.log(`[Recommend] 本地候选不足,继续调用 JustOne 补足: 缺口 ${shortage} 位`);
  99. onProgress?.('search', `本地候选不足,继续调用 JustOne 补足 ${shortage} 位...`);
  100. const searchKeywords = buildCompactSearchKeywords(criteria);
  101. for (const platform of requestedPlatforms) {
  102. const platformName = mapPlatformName(platform);
  103. if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) {
  104. console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`);
  105. continue;
  106. }
  107. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) {
  108. console.log(`[Recommend] ${platformName} 已满足平台候选目标 ${targetPerPlatform} 位,跳过补量`);
  109. continue;
  110. }
  111. for (const keyword of searchKeywords) {
  112. for (let page = 1; page <= PAGES_PER_PLATFORM; page++) {
  113. console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}, 页=${page}`);
  114. onProgress?.('search', `搜索 ${platformName} - ${keyword} 第 ${page}/${PAGES_PER_PLATFORM} 页...`);
  115. const results = await searchCreators({
  116. keyword,
  117. platform: platformName,
  118. minFans: criteria.fanRange.min,
  119. maxFans: criteria.fanRange.max,
  120. minPrice: criteria.budgetRange.min,
  121. maxPrice: criteria.budgetRange.max,
  122. gender: criteria.gender,
  123. location: Array.isArray(criteria.region) ? criteria.region.join(',') : criteria.region,
  124. page,
  125. pageSize: 50,
  126. });
  127. console.log(`[Recommend] ${platformName}/${keyword}/page-${page} 返回 ${results.length} 条结果`);
  128. allCandidates.push(...results);
  129. providerCandidateCount += results.length;
  130. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break;
  131. if (results.length === 0) break;
  132. }
  133. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break;
  134. }
  135. }
  136. } else {
  137. console.log(`[Recommend] 本地候选已满足目标候选池 ${targetCandidatePool} 位,跳过 JustOne`);
  138. }
  139. console.log(`[Recommend] 总召回: ${allCandidates.length} 位候选达人`);
  140. onProgress?.('search', `召回 ${allCandidates.length} 位候选达人`);
  141. // Step 2: 去重
  142. const uniqueMap = new Map<string, JustOneCreator>();
  143. for (const c of allCandidates) {
  144. const key = `${mapPlatformName(c.platform)}:${c.userId}`;
  145. if (!uniqueMap.has(key)) {
  146. uniqueMap.set(key, c);
  147. }
  148. }
  149. const uniqueCandidates = Array.from(uniqueMap.values());
  150. await recordRetrievalEvent({
  151. queryText: buildCompactSearchKeywords(criteria).join(' '),
  152. criteria,
  153. localHitCount: localCandidates.length,
  154. providerHitCount: providerCandidateCount,
  155. finalCount: uniqueCandidates.length,
  156. }).catch((error) => console.error('[Recommend] 记录检索事件失败:', error));
  157. console.log(`[Recommend] 去重后: ${uniqueCandidates.length} 位候选`);
  158. onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`);
  159. // Step 3: 标准化并评分
  160. const normalized = uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index));
  161. if (normalized.length > 0) {
  162. console.log('[Recommend] 评分样例 (前3位):');
  163. normalized.slice(0, 3).forEach(c => {
  164. console.log(` - ${c.displayName} | 粉丝=${c.fansCount} | 报价=${c.minPrice} | 评分=${c.score} | 风格=${c.styleMatch}`);
  165. });
  166. }
  167. // Step 4: 使用 JustOne 搜索结果内置的标签、报价和近期内容进行验证
  168. const topCandidates = normalized
  169. .sort((a, b) => b.score - a.score)
  170. .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length));
  171. onProgress?.('processing', `已用 JustOne 内置字段完成 Top ${topCandidates.length} 位候选评分...`);
  172. // Step 5: 最终排序和状态标记
  173. onProgress?.('processing', '计算最终推荐排序...');
  174. const finalCandidates = topCandidates.map((c) => assignRecommendStatus(c, criteria));
  175. // 按分数排序
  176. finalCandidates.sort((a, b) => b.score - a.score);
  177. const strong = finalCandidates.filter(c => c.recommendStatus === '强推荐').length;
  178. const backup = finalCandidates.filter(c => c.recommendStatus === '备选').length;
  179. const review = finalCandidates.filter(c => c.recommendStatus === '需复核').length;
  180. const removed = finalCandidates.filter(c => c.recommendStatus === '已剔除').length;
  181. console.log(`[Recommend] 最终结果: 总${finalCandidates.length}位 | 强推荐=${strong} | 备选=${backup} | 需复核=${review} | 已剔除=${removed}`);
  182. console.log('[Recommend] ========== 推荐流程结束 ==========');
  183. onProgress?.('processing', `完成!共 ${finalCandidates.length} 位候选达人`);
  184. return finalCandidates;
  185. }
  186. function normalizeCandidateFromJustOne(creator: JustOneCreator, criteria: SearchCriteria, index: number): NormalizedCandidate {
  187. const fanScore = scoreFanMatch(creator.fansCount, criteria.fanRange);
  188. // 取最优非零价格评分(Douyin 无报价时用 CPM 估算价,避免强制给 60 分)
  189. const bestPrice = creator.minPrice || creator.videoPrice || creator.imagePrice || 0;
  190. const priceScore = scorePriceMatch(bestPrice, criteria.budgetRange);
  191. if (index < 3) console.log(`[Score] #${index} ${creator.nickname} | contentTags=${JSON.stringify(creator.contentTags)} | keywords=${JSON.stringify(criteria.keywords)}`);
  192. const contentScore = scoreContentRelevance(creator.contentTags, criteria.keywords);
  193. const cooperationScore = creator.cooperationStatus ? 85 : 60;
  194. const activityScore = 75; // 默认中等,需要内容采样后更新
  195. const totalScore = Math.round(
  196. fanScore * (DEFAULT_WEIGHTS.fanMatch / 100) +
  197. priceScore * (DEFAULT_WEIGHTS.priceMatch / 100) +
  198. contentScore * (DEFAULT_WEIGHTS.contentRelevance / 100) +
  199. activityScore * (DEFAULT_WEIGHTS.activityLevel / 100) +
  200. cooperationScore * (DEFAULT_WEIGHTS.cooperationReady / 100)
  201. );
  202. const platform = creator.platform || guessPlatformFromCreator(creator);
  203. const profileUrl = platform === 'douyin'
  204. ? `https://www.douyin.com/user/${creator.userId}`
  205. : `https://www.xiaohongshu.com/user/profile/${creator.userId}`;
  206. return {
  207. id: `C-${String(index + 1).padStart(4, '0')}`,
  208. platform,
  209. platformUserId: creator.userId,
  210. displayName: creator.nickname,
  211. profileUrl,
  212. location: creator.location,
  213. fansCount: creator.fansCount,
  214. contentTags: creator.contentTags,
  215. personaTags: creator.personalTags,
  216. gender: creator.gender,
  217. likedCollectCount: creator.likedCollectCount,
  218. contentType: creator.contentType || creator.contentTags.join('、'),
  219. city: creator.city || creator.location,
  220. geoLocation: creator.geoLocation || creator.location,
  221. xiaohongshuUrl: creator.xiaohongshuUrl || (platform === 'xiaohongshu' ? profileUrl : ''),
  222. cooperationMethod: creator.cooperationMethod,
  223. imagePrice: creator.imagePrice,
  224. videoPrice: creator.videoPrice,
  225. minPrice: creator.minPrice,
  226. cooperationStatus: creator.cooperationStatus,
  227. sourceProvider: creator.sourceProvider || 'justone',
  228. sourceConfidence: 80,
  229. score: totalScore,
  230. styleMatch: contentScore,
  231. recommendStatus: '需复核',
  232. recommendReason: '',
  233. riskNote: '',
  234. contentSamples: creator.contentSamples,
  235. };
  236. }
  237. function assignRecommendStatus(candidate: NormalizedCandidate, criteria: SearchCriteria): NormalizedCandidate {
  238. let status: '强推荐' | '备选' | '需复核' | '已剔除' = '需复核';
  239. let reason = '';
  240. let risk = '';
  241. if (candidate.score >= 78 && candidate.styleMatch >= 70) {
  242. status = '强推荐';
  243. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,内容标签与Brief高度吻合。`;
  244. } else if (candidate.score >= 65) {
  245. status = '备选';
  246. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,满足基本要求。`;
  247. } else if (candidate.score < 55) {
  248. status = '已剔除';
  249. reason = '综合评分过低。';
  250. } else {
  251. status = '需复核';
  252. reason = '部分指标不确定,需要人工确认。';
  253. }
  254. // 风险检查
  255. if (candidate.fansCount === 0) {
  256. risk += '粉丝数据缺失;';
  257. }
  258. if (candidate.minPrice > criteria.budgetRange.max) {
  259. risk += '报价超出预算上限;';
  260. }
  261. if (!candidate.cooperationStatus) {
  262. risk += '合作状态未知;';
  263. }
  264. return {
  265. ...candidate,
  266. recommendStatus: status,
  267. recommendReason: reason,
  268. riskNote: risk || '暂无明显风险',
  269. };
  270. }
  271. function scoreFanMatch(fans: number, range: { min: number; max: number }): number {
  272. if (fans === 0) return 50;
  273. if (fans >= range.min && fans <= range.max) return 90;
  274. if (fans < range.min) {
  275. const ratio = fans / range.min;
  276. return Math.max(40, Math.round(90 * ratio));
  277. }
  278. // Slightly over max is still acceptable
  279. const overRatio = range.max / fans;
  280. return Math.max(50, Math.round(90 * overRatio));
  281. }
  282. function scorePriceMatch(price: number, range: { min: number; max: number }): number {
  283. if (price === 0) return 60; // 价格缺失
  284. if (price >= range.min && price <= range.max) return 90;
  285. if (price < range.min) return 75; // 低于预算更好
  286. const overRatio = range.max / price;
  287. return Math.max(30, Math.round(90 * overRatio));
  288. }
  289. function scoreContentRelevance(tags: string[], keywords: string[]): number {
  290. if (tags.length === 0 || keywords.length === 0) return 60;
  291. let matches = 0;
  292. for (const keyword of keywords) {
  293. const keywordTerms = extractMatchTerms(keyword);
  294. for (const tag of tags) {
  295. const tagTerms = extractMatchTerms(tag);
  296. if (
  297. tag.includes(keyword) ||
  298. keyword.includes(tag) ||
  299. keywordTerms.some((term) => tag.includes(term)) ||
  300. tagTerms.some((term) => keyword.includes(term))
  301. ) {
  302. matches++;
  303. break;
  304. }
  305. }
  306. }
  307. const matchRatio = matches / keywords.length;
  308. return Math.min(95, Math.round(60 + matchRatio * 50));
  309. }
  310. function extractMatchTerms(text: string): string[] {
  311. const clean = text.trim();
  312. const domainTerms = ['家居', '家装', '探店', '生活', '精致', '美食', '出行', '旅游', '母婴', '记录'];
  313. const terms = domainTerms.filter((term) => clean.includes(term));
  314. if (clean.length >= 2) terms.push(clean);
  315. return [...new Set(terms)];
  316. }
  317. function calculateStyleMatch(samples: ContentSample[], keywords: string[]): number {
  318. if (samples.length === 0 || keywords.length === 0) return 60;
  319. let matchingSamples = 0;
  320. let highEngagement = 0;
  321. for (const sample of samples) {
  322. const text = `${sample.title} ${sample.content}`.toLowerCase();
  323. const hasKeyword = keywords.some(kw => text.includes(kw.toLowerCase()));
  324. if (hasKeyword) matchingSamples++;
  325. if (sample.likeCount > 500 || sample.collectCount > 200) highEngagement++;
  326. }
  327. // 匹配率映射到 60–95,与 scoreContentRelevance 区间一致
  328. const matchRatio = matchingSamples / samples.length;
  329. const engagementRatio = highEngagement / samples.length;
  330. return Math.min(95, Math.round(60 + matchRatio * 45 + engagementRatio * 5));
  331. }
  332. function buildCompactSearchKeywords(criteria: SearchCriteria): string[] {
  333. const candidates = [...(criteria.contentTags || []), ...criteria.keywords]
  334. .map((keyword) => keyword.trim())
  335. .filter(Boolean);
  336. const unique = [...new Set(candidates)];
  337. return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0
  338. ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM)
  339. : ['生活方式'];
  340. }
  341. function normalizeSearchCriteria(criteria: SearchCriteria): SearchCriteria {
  342. return {
  343. ...criteria,
  344. fanRange: normalizeFanRange(criteria.fanRange),
  345. budgetRange: normalizeBudgetRange(criteria.budgetRange),
  346. platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu'],
  347. keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'],
  348. targetCount: normalizeTargetCount(criteria.targetCount),
  349. };
  350. }
  351. function resolveTargetCandidatePool(criteria: SearchCriteria): number {
  352. const targetCount = normalizeTargetCount(criteria.targetCount);
  353. if (targetCount > 0) {
  354. return Math.min(config.recommendation.maxCandidates, Math.max(DEFAULT_MIN_CANDIDATE_POOL, targetCount * config.recommendation.multiplier));
  355. }
  356. return Math.min(config.recommendation.maxCandidates, DEFAULT_MIN_CANDIDATE_POOL);
  357. }
  358. function normalizeTargetCount(value?: number): number | undefined {
  359. const count = Number(value || 0);
  360. return count > 0 ? Math.ceil(count) : undefined;
  361. }
  362. function normalizeFanRange(range: { min: number; max: number }): { min: number; max: number } {
  363. const min = Number(range?.min || 0);
  364. const max = Number(range?.max || 0);
  365. // LLM 容易把“1万-15万”解析成 1-15;统一换算成真实粉丝数。
  366. if (max > 0 && max <= 1000) {
  367. return { min: Math.max(0, min * 10000), max: max * 10000 };
  368. }
  369. return { min, max };
  370. }
  371. function normalizeBudgetRange(range: { min: number; max: number }): { min: number; max: number } {
  372. return {
  373. min: Number(range?.min || 0),
  374. max: Number(range?.max || 0),
  375. };
  376. }
  377. function uniquePlatforms(platforms: string[]): string[] {
  378. const normalized = platforms.map(mapPlatformName).filter(Boolean);
  379. const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin');
  380. return [...new Set(supported.length > 0 ? supported : ['xiaohongshu'])];
  381. }
  382. function platformsNeedSupplement(candidates: JustOneCreator[], platforms: string[], targetPerPlatform: number): boolean {
  383. return platforms.some((platform) => countCreatorsByPlatform(candidates, platform) < targetPerPlatform);
  384. }
  385. function countCreatorsByPlatform(candidates: JustOneCreator[], platform: string): number {
  386. const normalizedPlatform = mapPlatformName(platform);
  387. const uniqueIds = new Set<string>();
  388. for (const candidate of candidates) {
  389. if (mapPlatformName(candidate.platform) === normalizedPlatform && candidate.userId) {
  390. uniqueIds.add(candidate.userId);
  391. }
  392. }
  393. return uniqueIds.size;
  394. }
  395. function mapPlatformName(platform: string): string {
  396. const map: Record<string, string> = {
  397. xiaohongshu: 'xiaohongshu',
  398. '小红书': 'xiaohongshu',
  399. douyin: 'douyin',
  400. '抖音': 'douyin',
  401. bilibili: 'bilibili',
  402. 'B站': 'bilibili',
  403. weibo: 'weibo',
  404. '微博': 'weibo',
  405. weixin: 'weixin',
  406. '微信': 'weixin',
  407. };
  408. return map[platform] || platform;
  409. }
  410. function guessPlatformFromCreator(creator: JustOneCreator): string {
  411. if (creator.redId) return 'xiaohongshu';
  412. // 抖音星图 userId 是纯数字长 ID(约19位),无 redId
  413. if (/^\d{15,}$/.test(creator.userId)) return 'douyin';
  414. return 'xiaohongshu';
  415. }