recommendation.service.ts 17 KB

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