recommendation.service.ts 15 KB

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