recommendation.service.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { config } from '../config.ts';
  2. import { searchCreators, type JustOneCreator } from './justone.service.ts';
  3. import { getUserNotes, type ContentSample } from './tikhub.service.ts';
  4. export interface NormalizedCandidate {
  5. id: string;
  6. platform: string;
  7. platformUserId: string;
  8. displayName: string;
  9. profileUrl: string;
  10. location: string;
  11. fansCount: number;
  12. contentTags: string[];
  13. personaTags: string[];
  14. imagePrice: number;
  15. videoPrice: number;
  16. minPrice: number;
  17. cooperationStatus: string;
  18. sourceProvider: string;
  19. sourceConfidence: number;
  20. score: number;
  21. styleMatch: number;
  22. recommendStatus: '强推荐' | '备选' | '需复核' | '已剔除';
  23. recommendReason: string;
  24. riskNote: string;
  25. contentSamples?: ContentSample[];
  26. }
  27. interface SearchCriteria {
  28. platforms: string[];
  29. keywords: string[];
  30. fanRange: { min: number; max: number };
  31. budgetRange: { min: number; max: number };
  32. region?: string;
  33. gender?: string;
  34. contentTags?: string[];
  35. excludeTags?: string[];
  36. }
  37. interface ScoringWeights {
  38. fanMatch: number;
  39. priceMatch: number;
  40. contentRelevance: number;
  41. activityLevel: number;
  42. cooperationReady: number;
  43. }
  44. const DEFAULT_WEIGHTS: ScoringWeights = {
  45. fanMatch: 20,
  46. priceMatch: 20,
  47. contentRelevance: 30,
  48. activityLevel: 15,
  49. cooperationReady: 15,
  50. };
  51. /**
  52. * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序
  53. */
  54. export async function generateRecommendations(
  55. criteria: SearchCriteria,
  56. onProgress?: (stage: string, detail: string) => void
  57. ): Promise<NormalizedCandidate[]> {
  58. console.log('[Recommend] ========== 开始推荐流程 ==========');
  59. console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2));
  60. onProgress?.('search', '正在从第三方平台召回候选达人...');
  61. // Step 1: 从 JustOne API 召回候选
  62. const allCandidates: JustOneCreator[] = [];
  63. // JustOne API 仅支持小红书和抖音
  64. const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音'];
  65. for (const platform of criteria.platforms) {
  66. const platformName = mapPlatformName(platform);
  67. if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) {
  68. console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`);
  69. continue;
  70. }
  71. for (const keyword of criteria.keywords) {
  72. console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}`);
  73. onProgress?.('search', `搜索 ${platformName} - ${keyword}...`);
  74. const results = await searchCreators({
  75. keyword,
  76. platform: platformName,
  77. minFans: criteria.fanRange.min,
  78. maxFans: criteria.fanRange.max,
  79. minPrice: criteria.budgetRange.min,
  80. maxPrice: criteria.budgetRange.max,
  81. gender: criteria.gender,
  82. location: criteria.region,
  83. pageSize: 50,
  84. });
  85. console.log(`[Recommend] ${platformName}/${keyword} 返回 ${results.length} 条结果`);
  86. allCandidates.push(...results);
  87. }
  88. }
  89. console.log(`[Recommend] 总召回: ${allCandidates.length} 位候选达人`);
  90. onProgress?.('search', `召回 ${allCandidates.length} 位候选达人`);
  91. // Step 2: 去重
  92. const uniqueMap = new Map<string, JustOneCreator>();
  93. for (const c of allCandidates) {
  94. if (!uniqueMap.has(c.userId)) {
  95. uniqueMap.set(c.userId, c);
  96. }
  97. }
  98. const uniqueCandidates = Array.from(uniqueMap.values());
  99. console.log(`[Recommend] 去重后: ${uniqueCandidates.length} 位候选`);
  100. onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`);
  101. // Step 3: 标准化并评分
  102. const normalized = uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index));
  103. if (normalized.length > 0) {
  104. console.log('[Recommend] 评分样例 (前3位):');
  105. normalized.slice(0, 3).forEach(c => {
  106. console.log(` - ${c.displayName} | 粉丝=${c.fansCount} | 报价=${c.minPrice} | 评分=${c.score} | 风格=${c.styleMatch}`);
  107. });
  108. }
  109. // Step 4: 内容采样验证(取 Top N 候选进行深度验证)
  110. const topCandidates = normalized
  111. .sort((a, b) => b.score - a.score)
  112. .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length));
  113. onProgress?.('processing', `对 Top ${Math.min(20, topCandidates.length)} 位候选进行内容采样验证...`);
  114. // 对前20个进行内容采样
  115. for (let i = 0; i < Math.min(20, topCandidates.length); i++) {
  116. const candidate = topCandidates[i];
  117. try {
  118. const samples = await getUserNotes(candidate.platformUserId, candidate.platform, 10);
  119. if (samples.length > 0) {
  120. candidate.contentSamples = samples;
  121. candidate.styleMatch = calculateStyleMatch(samples, criteria.keywords);
  122. }
  123. } catch {
  124. // Content sampling is optional, continue
  125. }
  126. }
  127. // Step 5: 最终排序和状态标记
  128. onProgress?.('processing', '计算最终推荐排序...');
  129. const finalCandidates = topCandidates.map((c) => assignRecommendStatus(c, criteria));
  130. // 按分数排序
  131. finalCandidates.sort((a, b) => b.score - a.score);
  132. const strong = finalCandidates.filter(c => c.recommendStatus === '强推荐').length;
  133. const backup = finalCandidates.filter(c => c.recommendStatus === '备选').length;
  134. const review = finalCandidates.filter(c => c.recommendStatus === '需复核').length;
  135. const removed = finalCandidates.filter(c => c.recommendStatus === '已剔除').length;
  136. console.log(`[Recommend] 最终结果: 总${finalCandidates.length}位 | 强推荐=${strong} | 备选=${backup} | 需复核=${review} | 已剔除=${removed}`);
  137. console.log('[Recommend] ========== 推荐流程结束 ==========');
  138. onProgress?.('processing', `完成!共 ${finalCandidates.length} 位候选达人`);
  139. return finalCandidates;
  140. }
  141. function normalizeCandidateFromJustOne(creator: JustOneCreator, criteria: SearchCriteria, index: number): NormalizedCandidate {
  142. const fanScore = scoreFanMatch(creator.fansCount, criteria.fanRange);
  143. // 取最优非零价格评分(Douyin 无报价时用 CPM 估算价,避免强制给 60 分)
  144. const bestPrice = creator.minPrice || creator.videoPrice || creator.imagePrice || 0;
  145. const priceScore = scorePriceMatch(bestPrice, criteria.budgetRange);
  146. if (index < 3) console.log(`[Score] #${index} ${creator.nickname} | contentTags=${JSON.stringify(creator.contentTags)} | keywords=${JSON.stringify(criteria.keywords)}`);
  147. const contentScore = scoreContentRelevance(creator.contentTags, criteria.keywords);
  148. const cooperationScore = creator.cooperationStatus ? 85 : 60;
  149. const activityScore = 75; // 默认中等,需要内容采样后更新
  150. const totalScore = Math.round(
  151. fanScore * (DEFAULT_WEIGHTS.fanMatch / 100) +
  152. priceScore * (DEFAULT_WEIGHTS.priceMatch / 100) +
  153. contentScore * (DEFAULT_WEIGHTS.contentRelevance / 100) +
  154. activityScore * (DEFAULT_WEIGHTS.activityLevel / 100) +
  155. cooperationScore * (DEFAULT_WEIGHTS.cooperationReady / 100)
  156. );
  157. const platform = creator.platform || guessPlatformFromCreator(creator);
  158. const profileUrl = platform === 'douyin'
  159. ? `https://www.douyin.com/user/${creator.userId}`
  160. : `https://www.xiaohongshu.com/user/profile/${creator.userId}`;
  161. return {
  162. id: `C-${String(index + 1).padStart(4, '0')}`,
  163. platform,
  164. platformUserId: creator.userId,
  165. displayName: creator.nickname,
  166. profileUrl,
  167. location: creator.location,
  168. fansCount: creator.fansCount,
  169. contentTags: creator.contentTags,
  170. personaTags: creator.personalTags,
  171. imagePrice: creator.imagePrice,
  172. videoPrice: creator.videoPrice,
  173. minPrice: creator.minPrice,
  174. cooperationStatus: creator.cooperationStatus,
  175. sourceProvider: 'justone',
  176. sourceConfidence: 80,
  177. score: totalScore,
  178. styleMatch: contentScore,
  179. recommendStatus: '需复核',
  180. recommendReason: '',
  181. riskNote: '',
  182. };
  183. }
  184. function assignRecommendStatus(candidate: NormalizedCandidate, criteria: SearchCriteria): NormalizedCandidate {
  185. let status: '强推荐' | '备选' | '需复核' | '已剔除' = '需复核';
  186. let reason = '';
  187. let risk = '';
  188. if (candidate.score >= 78 && candidate.styleMatch >= 70) {
  189. status = '强推荐';
  190. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,内容标签与Brief高度吻合。`;
  191. } else if (candidate.score >= 65) {
  192. status = '备选';
  193. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,满足基本要求。`;
  194. } else if (candidate.score < 55) {
  195. status = '已剔除';
  196. reason = '综合评分过低。';
  197. } else {
  198. status = '需复核';
  199. reason = '部分指标不确定,需要人工确认。';
  200. }
  201. // 风险检查
  202. if (candidate.fansCount === 0) {
  203. risk += '粉丝数据缺失;';
  204. }
  205. if (candidate.minPrice > criteria.budgetRange.max) {
  206. risk += '报价超出预算上限;';
  207. }
  208. if (!candidate.cooperationStatus) {
  209. risk += '合作状态未知;';
  210. }
  211. return {
  212. ...candidate,
  213. recommendStatus: status,
  214. recommendReason: reason,
  215. riskNote: risk || '暂无明显风险',
  216. };
  217. }
  218. function scoreFanMatch(fans: number, range: { min: number; max: number }): number {
  219. if (fans === 0) return 50;
  220. if (fans >= range.min && fans <= range.max) return 90;
  221. if (fans < range.min) {
  222. const ratio = fans / range.min;
  223. return Math.max(40, Math.round(90 * ratio));
  224. }
  225. // Slightly over max is still acceptable
  226. const overRatio = range.max / fans;
  227. return Math.max(50, Math.round(90 * overRatio));
  228. }
  229. function scorePriceMatch(price: number, range: { min: number; max: number }): number {
  230. if (price === 0) return 60; // 价格缺失
  231. if (price >= range.min && price <= range.max) return 90;
  232. if (price < range.min) return 75; // 低于预算更好
  233. const overRatio = range.max / price;
  234. return Math.max(30, Math.round(90 * overRatio));
  235. }
  236. function scoreContentRelevance(tags: string[], keywords: string[]): number {
  237. if (tags.length === 0 || keywords.length === 0) return 60;
  238. let matches = 0;
  239. for (const keyword of keywords) {
  240. for (const tag of tags) {
  241. if (tag.includes(keyword) || keyword.includes(tag)) {
  242. matches++;
  243. break;
  244. }
  245. }
  246. }
  247. const matchRatio = matches / keywords.length;
  248. return Math.min(95, Math.round(60 + matchRatio * 50));
  249. }
  250. function calculateStyleMatch(samples: ContentSample[], keywords: string[]): number {
  251. if (samples.length === 0 || keywords.length === 0) return 60;
  252. let matchingSamples = 0;
  253. let highEngagement = 0;
  254. for (const sample of samples) {
  255. const text = `${sample.title} ${sample.content}`.toLowerCase();
  256. const hasKeyword = keywords.some(kw => text.includes(kw.toLowerCase()));
  257. if (hasKeyword) matchingSamples++;
  258. if (sample.likeCount > 500 || sample.collectCount > 200) highEngagement++;
  259. }
  260. // 匹配率映射到 60–95,与 scoreContentRelevance 区间一致
  261. const matchRatio = matchingSamples / samples.length;
  262. const engagementRatio = highEngagement / samples.length;
  263. return Math.min(95, Math.round(60 + matchRatio * 45 + engagementRatio * 5));
  264. }
  265. function mapPlatformName(platform: string): string {
  266. const map: Record<string, string> = {
  267. xiaohongshu: 'xiaohongshu',
  268. '小红书': 'xiaohongshu',
  269. douyin: 'douyin',
  270. '抖音': 'douyin',
  271. bilibili: 'bilibili',
  272. 'B站': 'bilibili',
  273. weibo: 'weibo',
  274. '微博': 'weibo',
  275. weixin: 'weixin',
  276. '微信': 'weixin',
  277. };
  278. return map[platform] || platform;
  279. }
  280. function guessPlatformFromCreator(creator: JustOneCreator): string {
  281. if (creator.redId) return 'xiaohongshu';
  282. // 抖音星图 userId 是纯数字长 ID(约19位),无 redId
  283. if (/^\d{15,}$/.test(creator.userId)) return 'douyin';
  284. return 'xiaohongshu';
  285. }