recommendation.service.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961
  1. import { config } from '../config.ts';
  2. import { searchCreators, type JustOneCreator } from './justone.service.ts';
  3. import { getUserNotes, searchDouyinUsers, type ContentSample } from './tikhub.service.ts';
  4. import { recordRetrievalEvent, searchLocalCreators, updateProviderCreatorProfileUrl } 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. coreUserId?: string;
  22. secUid?: string;
  23. uniqueId?: string;
  24. cooperationMethod?: string;
  25. imagePrice: number;
  26. videoPrice: number;
  27. minPrice: number;
  28. cooperationStatus: string;
  29. sourceProvider: string;
  30. sourceConfidence: number;
  31. score: number;
  32. styleMatch: number;
  33. recommendStatus: '强推荐' | '备选' | '需复核' | '已剔除';
  34. recommendReason: string;
  35. riskNote: string;
  36. contentSamples?: ContentSample[];
  37. evaluationNeeds?: EvaluationNeeds;
  38. evaluation?: CandidateEvaluation;
  39. }
  40. interface SearchCriteria {
  41. platforms: string[];
  42. keywords: string[];
  43. fanRange: { min: number; max: number };
  44. budgetRange: { min: number; max: number };
  45. region?: string | string[];
  46. gender?: string;
  47. contentTags?: string[];
  48. excludeTags?: string[];
  49. targetCount?: number;
  50. evaluationNeeds?: EvaluationNeeds;
  51. }
  52. export interface EvaluationNeeds {
  53. cpmCpe?: boolean;
  54. commercialStability?: boolean;
  55. recentPerformance?: boolean;
  56. updateFrequency?: boolean;
  57. commentQuality?: boolean;
  58. publicSentiment?: boolean;
  59. audienceGender?: boolean;
  60. reasons?: string[];
  61. }
  62. export interface CandidateEvaluation {
  63. cpmCpe?: {
  64. pictureCpm?: number;
  65. videoCpm?: number;
  66. pictureCpe?: number;
  67. videoCpe?: number;
  68. verdict: string;
  69. };
  70. commercialStability?: {
  71. businessNoteCount?: number;
  72. coopNoteNum30d?: number;
  73. readMidCoop30?: number;
  74. interMidCoop30?: number;
  75. impressionMedian30d?: number;
  76. verdict: string;
  77. };
  78. recentPerformance?: {
  79. sampleSize: number;
  80. medianInteraction?: number;
  81. recentMedianInteraction?: number;
  82. earlierMedianInteraction?: number;
  83. trendRatio?: number;
  84. volatility?: number;
  85. verdict: string;
  86. };
  87. updateFrequency?: {
  88. lowActive?: boolean;
  89. sampleSize?: number;
  90. recent30DayNotes?: number;
  91. daysCovered?: number;
  92. verdict: string;
  93. };
  94. commentQuality?: {
  95. sampleSize: number;
  96. commentMedian?: number;
  97. verdict: string;
  98. };
  99. publicSentiment?: {
  100. riskKeywordHits: string[];
  101. verdict: string;
  102. };
  103. audienceGender?: {
  104. femaleRatio?: number;
  105. maleRatio?: number;
  106. source?: string;
  107. verdict: string;
  108. };
  109. notes: string[];
  110. }
  111. interface ScoringWeights {
  112. fanMatch: number;
  113. priceMatch: number;
  114. contentRelevance: number;
  115. activityLevel: number;
  116. cooperationReady: number;
  117. }
  118. const DEFAULT_WEIGHTS: ScoringWeights = {
  119. fanMatch: 20,
  120. priceMatch: 20,
  121. contentRelevance: 30,
  122. activityLevel: 15,
  123. cooperationReady: 15,
  124. };
  125. const MAX_KEYWORDS_PER_PLATFORM = 1;
  126. const PAGES_PER_PLATFORM = 3;
  127. const MAX_PAGES_PER_PLATFORM = 12;
  128. const ESTIMATED_RESULTS_PER_PAGE = 20;
  129. const DEFAULT_MIN_CANDIDATE_POOL = 30;
  130. const PUBLIC_PROFILE_ENRICH_LIMIT = 20;
  131. const EVALUATION_ENRICH_LIMIT = 30;
  132. /**
  133. * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序
  134. */
  135. export async function generateRecommendations(
  136. criteria: SearchCriteria,
  137. onProgress?: (stage: string, detail: string) => void
  138. ): Promise<NormalizedCandidate[]> {
  139. criteria = normalizeSearchCriteria(criteria);
  140. console.log('[Recommend] ========== 开始推荐流程 ==========');
  141. console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2));
  142. const targetCandidatePool = resolveTargetCandidatePool(criteria);
  143. const requestedPlatforms = uniquePlatforms(criteria.platforms);
  144. const targetPerPlatform = Math.max(1, Math.ceil(targetCandidatePool / Math.max(1, requestedPlatforms.length)));
  145. const pageLimit = resolvePageLimit(targetPerPlatform);
  146. onProgress?.('search', `正在检索候选达人,目标候选池 ${targetCandidatePool} 位...`);
  147. // Step 1: 优先从本地/自有达人库召回候选
  148. const allCandidates: JustOneCreator[] = [];
  149. const localCandidates: JustOneCreator[] = [];
  150. for (const platform of requestedPlatforms) {
  151. const platformLocalCandidates = await searchLocalCreators(
  152. { ...criteria, platforms: [platform] },
  153. Math.min(config.recommendation.maxCandidates, targetPerPlatform),
  154. );
  155. localCandidates.push(...platformLocalCandidates);
  156. allCandidates.push(...platformLocalCandidates);
  157. console.log(`[Recommend] 本地达人库 ${platform} 命中: ${platformLocalCandidates.length}/${targetPerPlatform} 位`);
  158. }
  159. if (localCandidates.length > 0) {
  160. console.log(`[Recommend] 本地达人库命中: ${localCandidates.length} 位`);
  161. onProgress?.('search', `本地达人库命中 ${localCandidates.length}/${targetCandidatePool} 位候选达人`);
  162. }
  163. // JustOne API 仅支持小红书和抖音
  164. const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音'];
  165. let providerCandidateCount = 0;
  166. if (platformsNeedSupplement(allCandidates, requestedPlatforms, targetPerPlatform)) {
  167. const shortage = targetCandidatePool - allCandidates.length;
  168. console.log(`[Recommend] 本地候选不足,继续调用 JustOne 补足: 缺口 ${shortage} 位`);
  169. onProgress?.('search', `本地候选不足,继续调用 JustOne 补足 ${shortage} 位...`);
  170. const searchKeywords = buildCompactSearchKeywords(criteria);
  171. for (const platform of requestedPlatforms) {
  172. const platformName = mapPlatformName(platform);
  173. if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) {
  174. console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`);
  175. continue;
  176. }
  177. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) {
  178. console.log(`[Recommend] ${platformName} 已满足平台候选目标 ${targetPerPlatform} 位,跳过补量`);
  179. continue;
  180. }
  181. for (const keyword of searchKeywords) {
  182. for (let page = 1; page <= pageLimit; page++) {
  183. console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}, 页=${page}`);
  184. onProgress?.('search', `搜索 ${platformName} - ${keyword} 第 ${page}/${pageLimit} 页...`);
  185. const results = await searchCreators({
  186. keyword,
  187. platform: platformName,
  188. minFans: criteria.fanRange.min,
  189. maxFans: criteria.fanRange.max,
  190. minPrice: criteria.budgetRange.min,
  191. maxPrice: criteria.budgetRange.max,
  192. gender: criteria.gender,
  193. location: Array.isArray(criteria.region) ? criteria.region.join(',') : criteria.region,
  194. page,
  195. pageSize: 50,
  196. });
  197. console.log(`[Recommend] ${platformName}/${keyword}/page-${page} 返回 ${results.length} 条结果`);
  198. allCandidates.push(...results);
  199. providerCandidateCount += results.length;
  200. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break;
  201. if (results.length === 0) break;
  202. }
  203. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break;
  204. }
  205. }
  206. } else {
  207. console.log(`[Recommend] 本地候选已满足目标候选池 ${targetCandidatePool} 位,跳过 JustOne`);
  208. }
  209. console.log(`[Recommend] 总召回: ${allCandidates.length} 位候选达人`);
  210. onProgress?.('search', `召回 ${allCandidates.length} 位候选达人`);
  211. // Step 2: 去重
  212. const uniqueMap = new Map<string, JustOneCreator>();
  213. for (const c of allCandidates) {
  214. const key = `${mapPlatformName(c.platform)}:${c.userId}`;
  215. if (!uniqueMap.has(key)) {
  216. uniqueMap.set(key, c);
  217. }
  218. }
  219. const uniqueCandidates = Array.from(uniqueMap.values());
  220. await recordRetrievalEvent({
  221. queryText: buildCompactSearchKeywords(criteria).join(' '),
  222. criteria,
  223. localHitCount: localCandidates.length,
  224. providerHitCount: providerCandidateCount,
  225. finalCount: uniqueCandidates.length,
  226. }).catch((error) => console.error('[Recommend] 记录检索事件失败:', error));
  227. console.log(`[Recommend] 去重后: ${uniqueCandidates.length} 位候选`);
  228. onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`);
  229. // Step 3: 标准化并评分
  230. const normalized = uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index));
  231. if (normalized.length > 0) {
  232. console.log('[Recommend] 评分样例 (前3位):');
  233. normalized.slice(0, 3).forEach(c => {
  234. console.log(` - ${c.displayName} | 粉丝=${c.fansCount} | 报价=${c.minPrice} | 评分=${c.score} | 风格=${c.styleMatch}`);
  235. });
  236. }
  237. // Step 4: 使用 JustOne 搜索结果内置的标签、报价和近期内容进行验证
  238. const topCandidates = normalized
  239. .sort((a, b) => b.score - a.score)
  240. .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length));
  241. onProgress?.('processing', `已用 JustOne 内置字段完成 Top ${topCandidates.length} 位候选评分...`);
  242. // Step 5: 最终排序和状态标记
  243. onProgress?.('processing', '计算最终推荐排序...');
  244. await applyRequestedEvaluations(topCandidates, criteria, onProgress);
  245. const finalCandidates = topCandidates.map((c) => assignRecommendStatus(c, criteria));
  246. // 按分数排序
  247. finalCandidates.sort((a, b) => b.score - a.score);
  248. await enrichTopDouyinPublicProfileUrls(finalCandidates, onProgress);
  249. const strong = finalCandidates.filter(c => c.recommendStatus === '强推荐').length;
  250. const backup = finalCandidates.filter(c => c.recommendStatus === '备选').length;
  251. const review = finalCandidates.filter(c => c.recommendStatus === '需复核').length;
  252. const removed = finalCandidates.filter(c => c.recommendStatus === '已剔除').length;
  253. console.log(`[Recommend] 最终结果: 总${finalCandidates.length}位 | 强推荐=${strong} | 备选=${backup} | 需复核=${review} | 已剔除=${removed}`);
  254. console.log('[Recommend] ========== 推荐流程结束 ==========');
  255. onProgress?.('processing', `完成!共 ${finalCandidates.length} 位候选达人`);
  256. return finalCandidates;
  257. }
  258. function normalizeCandidateFromJustOne(creator: JustOneCreator, criteria: SearchCriteria, index: number): NormalizedCandidate {
  259. const fanScore = scoreFanMatch(creator.fansCount, criteria.fanRange);
  260. // 取最优非零价格评分(Douyin 无报价时用 CPM 估算价,避免强制给 60 分)
  261. const bestPrice = creator.minPrice || creator.videoPrice || creator.imagePrice || 0;
  262. const priceScore = scorePriceMatch(bestPrice, criteria.budgetRange);
  263. if (index < 3) console.log(`[Score] #${index} ${creator.nickname} | contentTags=${JSON.stringify(creator.contentTags)} | keywords=${JSON.stringify(criteria.keywords)}`);
  264. const contentScore = scoreContentRelevance(creator.contentTags, criteria.keywords);
  265. const cooperationScore = creator.cooperationStatus ? 85 : 60;
  266. const activityScore = 75; // 默认中等,需要内容采样后更新
  267. const totalScore = Math.round(
  268. fanScore * (DEFAULT_WEIGHTS.fanMatch / 100) +
  269. priceScore * (DEFAULT_WEIGHTS.priceMatch / 100) +
  270. contentScore * (DEFAULT_WEIGHTS.contentRelevance / 100) +
  271. activityScore * (DEFAULT_WEIGHTS.activityLevel / 100) +
  272. cooperationScore * (DEFAULT_WEIGHTS.cooperationReady / 100)
  273. );
  274. const platform = creator.platform || guessPlatformFromCreator(creator);
  275. const profileUrl = creator.profileUrl
  276. || creator.xiaohongshuUrl
  277. || (platform === 'xiaohongshu' ? `https://www.xiaohongshu.com/user/profile/${creator.userId}` : '');
  278. return {
  279. id: `C-${String(index + 1).padStart(4, '0')}`,
  280. platform,
  281. platformUserId: creator.userId,
  282. displayName: creator.nickname,
  283. profileUrl,
  284. location: creator.location,
  285. fansCount: creator.fansCount,
  286. contentTags: creator.contentTags,
  287. personaTags: creator.personalTags,
  288. gender: creator.gender,
  289. likedCollectCount: creator.likedCollectCount,
  290. contentType: creator.contentType || creator.contentTags.join('、'),
  291. city: creator.city || creator.location,
  292. geoLocation: creator.geoLocation || creator.location,
  293. xiaohongshuUrl: creator.xiaohongshuUrl || (platform === 'xiaohongshu' ? profileUrl : ''),
  294. coreUserId: creator.coreUserId,
  295. secUid: creator.secUid,
  296. uniqueId: creator.uniqueId,
  297. cooperationMethod: creator.cooperationMethod,
  298. imagePrice: creator.imagePrice,
  299. videoPrice: creator.videoPrice,
  300. minPrice: creator.minPrice,
  301. cooperationStatus: creator.cooperationStatus,
  302. sourceProvider: creator.sourceProvider || 'justone',
  303. sourceConfidence: 80,
  304. score: totalScore,
  305. styleMatch: contentScore,
  306. recommendStatus: '需复核',
  307. recommendReason: '',
  308. riskNote: '',
  309. contentSamples: creator.contentSamples,
  310. evaluationNeeds: criteria.evaluationNeeds,
  311. evaluation: buildProviderEvaluation(creator, criteria.evaluationNeeds),
  312. };
  313. }
  314. function buildProviderEvaluation(creator: JustOneCreator, needs?: EvaluationNeeds): CandidateEvaluation | undefined {
  315. if (!hasAnyEvaluationNeed(needs)) return undefined;
  316. const evaluation: CandidateEvaluation = { notes: [] };
  317. const commercial = creator.commercialMetrics;
  318. const providerSamples = [
  319. ...(creator.contentSamples || []),
  320. ...commercialNoteSamplesToContent(commercial?.noteList || []),
  321. ];
  322. if (needs?.cpmCpe) {
  323. evaluation.cpmCpe = evaluateCpmCpe(commercial);
  324. evaluation.notes.push(`CPM/CPE:${evaluation.cpmCpe.verdict}`);
  325. }
  326. if (needs?.commercialStability) {
  327. evaluation.commercialStability = evaluateCommercialStability(commercial);
  328. evaluation.notes.push(`商单:${evaluation.commercialStability.verdict}`);
  329. }
  330. if (needs?.recentPerformance) {
  331. evaluation.recentPerformance = evaluateRecentPerformance(providerSamples);
  332. evaluation.notes.push(`近期数据:${evaluation.recentPerformance.verdict}`);
  333. }
  334. if (needs?.updateFrequency) {
  335. evaluation.updateFrequency = evaluateUpdateFrequency(providerSamples, commercial?.lowActive);
  336. evaluation.notes.push(`更新率:${evaluation.updateFrequency.verdict}`);
  337. }
  338. if (needs?.commentQuality) {
  339. evaluation.commentQuality = evaluateCommentQuality(providerSamples);
  340. evaluation.notes.push(`评论区:${evaluation.commentQuality.verdict}`);
  341. }
  342. if (needs?.publicSentiment) {
  343. evaluation.publicSentiment = evaluatePublicSentiment(creator, providerSamples);
  344. evaluation.notes.push(`舆情:${evaluation.publicSentiment.verdict}`);
  345. }
  346. if (needs?.audienceGender) {
  347. evaluation.audienceGender = evaluateAudienceGender(creator);
  348. evaluation.notes.push(`粉丝性别:${evaluation.audienceGender.verdict}`);
  349. }
  350. return evaluation;
  351. }
  352. async function applyRequestedEvaluations(
  353. candidates: NormalizedCandidate[],
  354. criteria: SearchCriteria,
  355. onProgress?: (stage: string, detail: string) => void,
  356. ): Promise<void> {
  357. const needs = criteria.evaluationNeeds;
  358. if (!hasAnyEvaluationNeed(needs)) return;
  359. const needsPublicSamples = Boolean(needs?.recentPerformance || needs?.updateFrequency || needs?.commentQuality || needs?.publicSentiment);
  360. if (!needsPublicSamples) return;
  361. const targets = candidates
  362. .filter((candidate) => !candidate.contentSamples || candidate.contentSamples.length < 10)
  363. .slice(0, EVALUATION_ENRICH_LIMIT);
  364. if (targets.length === 0) return;
  365. onProgress?.('processing', `按 Brief 要求补充 Top ${targets.length} 位达人近期内容样本...`);
  366. for (const candidate of targets) {
  367. const samples = await getUserNotes(candidate.platformUserId, candidate.platform, 30);
  368. if (samples.length === 0) continue;
  369. candidate.contentSamples = samples;
  370. candidate.evaluation = mergeEvaluation(candidate.evaluation, buildPublicSampleEvaluation(candidate, needs, samples));
  371. }
  372. }
  373. function buildPublicSampleEvaluation(
  374. candidate: NormalizedCandidate,
  375. needs: EvaluationNeeds | undefined,
  376. samples: ContentSample[],
  377. ): CandidateEvaluation {
  378. const evaluation: CandidateEvaluation = { notes: [] };
  379. if (needs?.recentPerformance) {
  380. evaluation.recentPerformance = evaluateRecentPerformance(samples);
  381. evaluation.notes.push(`近期数据:${evaluation.recentPerformance.verdict}`);
  382. }
  383. if (needs?.updateFrequency) {
  384. evaluation.updateFrequency = evaluateUpdateFrequency(samples, candidate.evaluation?.updateFrequency?.lowActive);
  385. evaluation.notes.push(`更新率:${evaluation.updateFrequency.verdict}`);
  386. }
  387. if (needs?.commentQuality) {
  388. evaluation.commentQuality = evaluateCommentQuality(samples);
  389. evaluation.notes.push(`评论区:${evaluation.commentQuality.verdict}`);
  390. }
  391. if (needs?.publicSentiment) {
  392. evaluation.publicSentiment = evaluatePublicSentiment(candidate, samples);
  393. evaluation.notes.push(`舆情:${evaluation.publicSentiment.verdict}`);
  394. }
  395. return evaluation;
  396. }
  397. function mergeEvaluation(current: CandidateEvaluation | undefined, incoming: CandidateEvaluation): CandidateEvaluation {
  398. return {
  399. notes: [...(current?.notes || []), ...(incoming.notes || [])],
  400. cpmCpe: incoming.cpmCpe || current?.cpmCpe,
  401. commercialStability: incoming.commercialStability || current?.commercialStability,
  402. recentPerformance: incoming.recentPerformance || current?.recentPerformance,
  403. updateFrequency: incoming.updateFrequency || current?.updateFrequency,
  404. commentQuality: incoming.commentQuality || current?.commentQuality,
  405. publicSentiment: incoming.publicSentiment || current?.publicSentiment,
  406. audienceGender: incoming.audienceGender || current?.audienceGender,
  407. };
  408. }
  409. function hasAnyEvaluationNeed(needs?: EvaluationNeeds): boolean {
  410. return Boolean(
  411. needs?.cpmCpe
  412. || needs?.commercialStability
  413. || needs?.recentPerformance
  414. || needs?.updateFrequency
  415. || needs?.commentQuality
  416. || needs?.publicSentiment
  417. || needs?.audienceGender
  418. );
  419. }
  420. function evaluateCpmCpe(metrics?: JustOneCreator['commercialMetrics']): NonNullable<CandidateEvaluation['cpmCpe']> {
  421. const pictureCpm = positiveNumber(metrics?.estimatePictureCpm);
  422. const videoCpm = positiveNumber(metrics?.estimateVideoCpm);
  423. const pictureCpe = positiveNumber(metrics?.estimatePictureEngageCost);
  424. const videoCpe = positiveNumber(metrics?.estimateVideoEngageCost);
  425. const cpmOk = [pictureCpm, videoCpm].some((value) => value !== undefined && value <= 50);
  426. const cpeOk = [pictureCpe, videoCpe].some((value) => value !== undefined && value <= 3);
  427. const hasData = [pictureCpm, videoCpm, pictureCpe, videoCpe].some((value) => value !== undefined);
  428. return {
  429. pictureCpm,
  430. videoCpm,
  431. pictureCpe,
  432. videoCpe,
  433. verdict: !hasData ? '暂无蒲公英成本数据'
  434. : cpmOk && cpeOk ? '达标'
  435. : cpmOk || cpeOk ? '部分达标'
  436. : '未达标/需复核',
  437. };
  438. }
  439. function evaluateCommercialStability(metrics?: JustOneCreator['commercialMetrics']): NonNullable<CandidateEvaluation['commercialStability']> {
  440. const businessNoteCount = positiveNumber(metrics?.businessNoteCount);
  441. const coopNoteNum30d = positiveNumber(metrics?.coopNoteNum30d);
  442. const readMidCoop30 = positiveNumber(metrics?.readMidCoop30);
  443. const interMidCoop30 = positiveNumber(metrics?.interMidCoop30);
  444. const impressionMedian30d = positiveNumber(metrics?.accumCoopImpMedinNum30d);
  445. const hasCoopData = [businessNoteCount, coopNoteNum30d, readMidCoop30, interMidCoop30, impressionMedian30d]
  446. .some((value) => value !== undefined && value > 0);
  447. const stable = Boolean((coopNoteNum30d && coopNoteNum30d > 0) && ((readMidCoop30 || 0) > 0 || (interMidCoop30 || 0) > 0));
  448. return {
  449. businessNoteCount,
  450. coopNoteNum30d,
  451. readMidCoop30,
  452. interMidCoop30,
  453. impressionMedian30d,
  454. verdict: !hasCoopData ? '暂无商单数据'
  455. : stable ? '近30天有商单且中位数据可用'
  456. : '有历史商单,稳定性需复核',
  457. };
  458. }
  459. function evaluateRecentPerformance(samples: ContentSample[]): NonNullable<CandidateEvaluation['recentPerformance']> {
  460. const interactions = samples
  461. .map((sample) => sample.likeCount + sample.commentCount + sample.collectCount + sample.shareCount)
  462. .filter((value) => value > 0);
  463. const recent = interactions.slice(0, 10);
  464. const earlier = interactions.slice(10, 30);
  465. const recentMedian = median(recent);
  466. const earlierMedian = median(earlier);
  467. const overallMedian = median(interactions);
  468. const trendRatio = earlierMedian && recentMedian !== undefined ? round(recentMedian / earlierMedian, 2) : undefined;
  469. const volatility = overallMedian && overallMedian > 0 ? round((Math.max(...interactions) || 0) / overallMedian, 2) : undefined;
  470. return {
  471. sampleSize: samples.length,
  472. medianInteraction: overallMedian,
  473. recentMedianInteraction: recentMedian,
  474. earlierMedianInteraction: earlierMedian,
  475. trendRatio,
  476. volatility,
  477. verdict: samples.length === 0 ? '暂无近期样本'
  478. : trendRatio !== undefined && trendRatio < 0.6 ? '近期互动下降'
  479. : volatility !== undefined && volatility > 8 ? '互动波动偏大'
  480. : '近期数据相对稳定',
  481. };
  482. }
  483. function evaluateUpdateFrequency(
  484. samples: ContentSample[],
  485. lowActive?: boolean,
  486. ): NonNullable<CandidateEvaluation['updateFrequency']> {
  487. const publishTimes = samples.map((sample) => parsePublishTime(sample.publishTime)).filter((time): time is number => time !== undefined);
  488. const now = Date.now();
  489. const recent30DayNotes = publishTimes.filter((time) => now - time <= 30 * 24 * 60 * 60 * 1000).length;
  490. const daysCovered = publishTimes.length > 1
  491. ? Math.ceil((Math.max(...publishTimes) - Math.min(...publishTimes)) / (24 * 60 * 60 * 1000))
  492. : undefined;
  493. return {
  494. lowActive,
  495. sampleSize: samples.length,
  496. recent30DayNotes,
  497. daysCovered,
  498. verdict: lowActive ? '平台标记低活跃'
  499. : samples.length === 0 ? '暂无更新样本'
  500. : recent30DayNotes >= 4 ? '更新频率正常'
  501. : '更新偏低/需复核',
  502. };
  503. }
  504. function evaluateCommentQuality(samples: ContentSample[]): NonNullable<CandidateEvaluation['commentQuality']> {
  505. const commentCounts = samples.map((sample) => sample.commentCount).filter((value) => value > 0);
  506. const commentMedian = median(commentCounts);
  507. return {
  508. sampleSize: samples.length,
  509. commentMedian,
  510. verdict: samples.length === 0 ? '暂无评论样本'
  511. : commentMedian === undefined ? '评论量偏少,需人工看评论正文'
  512. : commentMedian >= 5 ? '评论活跃度可用,正文需抽检'
  513. : '评论互动偏弱/需复核',
  514. };
  515. }
  516. function evaluatePublicSentiment(
  517. creator: Pick<NormalizedCandidate, 'displayName' | 'contentTags' | 'personaTags' | 'contentType'> | JustOneCreator,
  518. samples: ContentSample[],
  519. ): NonNullable<CandidateEvaluation['publicSentiment']> {
  520. const text = [
  521. 'displayName' in creator ? creator.displayName : creator.nickname,
  522. 'contentType' in creator ? creator.contentType : creator.contentType,
  523. ...(creator.contentTags || []),
  524. ...(creator.personaTags || ('personalTags' in creator ? creator.personalTags : []) || []),
  525. ...samples.flatMap((sample) => [sample.title, sample.content]),
  526. ].join(' ');
  527. const riskWords = ['负面', '争议', '翻车', '避雷', '塌房', '黑料', '投诉', '虚假', '诈骗', '辱骂'];
  528. const hits = riskWords.filter((word) => text.includes(word));
  529. return {
  530. riskKeywordHits: hits,
  531. verdict: hits.length > 0 ? `命中风险词:${hits.join('、')}` : '未命中明显风险词',
  532. };
  533. }
  534. function evaluateAudienceGender(creator: JustOneCreator): NonNullable<CandidateEvaluation['audienceGender']> {
  535. const femaleRatio = creator.audienceMetrics?.femaleRatio;
  536. const maleRatio = creator.audienceMetrics?.maleRatio;
  537. return {
  538. femaleRatio,
  539. maleRatio,
  540. source: creator.audienceMetrics?.source,
  541. verdict: femaleRatio === undefined ? '暂无粉丝性别占比数据'
  542. : femaleRatio >= 0.6 ? '女性粉丝占比较高'
  543. : femaleRatio >= 0.5 ? '女性粉丝略高'
  544. : '女性粉丝占比不高/需复核',
  545. };
  546. }
  547. function commercialNoteSamplesToContent(samples: NonNullable<JustOneCreator['commercialMetrics']>['noteList']): ContentSample[] {
  548. return (samples || []).map((sample) => ({
  549. noteId: sample.noteId || '',
  550. title: sample.title || '',
  551. content: sample.title || '',
  552. publishTime: sample.publishTime || '',
  553. likeCount: sample.likeCount || 0,
  554. commentCount: sample.commentCount || 0,
  555. collectCount: sample.collectCount || 0,
  556. shareCount: 0,
  557. type: 'image',
  558. }));
  559. }
  560. function positiveNumber(value: unknown): number | undefined {
  561. const parsed = Number(value || 0);
  562. return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
  563. }
  564. function median(values: number[]): number | undefined {
  565. const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
  566. if (sorted.length === 0) return undefined;
  567. const mid = Math.floor(sorted.length / 2);
  568. return sorted.length % 2 === 0 ? round((sorted[mid - 1] + sorted[mid]) / 2, 2) : sorted[mid];
  569. }
  570. function round(value: number, digits = 2): number {
  571. const factor = 10 ** digits;
  572. return Math.round(value * factor) / factor;
  573. }
  574. function parsePublishTime(value: string): number | undefined {
  575. if (!value) return undefined;
  576. const numeric = Number(value);
  577. if (Number.isFinite(numeric) && numeric > 0) {
  578. return numeric > 1_000_000_000_000 ? numeric : numeric * 1000;
  579. }
  580. const parsed = Date.parse(value);
  581. return Number.isFinite(parsed) ? parsed : undefined;
  582. }
  583. async function enrichTopDouyinPublicProfileUrls(
  584. candidates: NormalizedCandidate[],
  585. onProgress?: (stage: string, detail: string) => void,
  586. ): Promise<void> {
  587. const douyinCount = candidates.filter((candidate) => candidate.platform === 'douyin').length;
  588. const targets = candidates
  589. .filter((candidate) => candidate.platform === 'douyin' && !candidate.profileUrl)
  590. .slice(0, PUBLIC_PROFILE_ENRICH_LIMIT);
  591. if (targets.length === 0) return;
  592. onProgress?.('processing', `正在为 Top ${targets.length} 位抖音候选补充真实主页链接...`);
  593. console.log(`[Recommend] 补充抖音真实主页链接: ${targets.length}/${douyinCount}`);
  594. for (const candidate of targets) {
  595. const matches = await searchDouyinUsers(candidate.displayName);
  596. const match = chooseDouyinUserMatch(candidate, matches);
  597. if (!match) {
  598. candidate.riskNote = appendRisk(candidate.riskNote, '未匹配到抖音公开主页链接');
  599. continue;
  600. }
  601. const profileUrl = `https://www.douyin.com/user/${match.secUid}?from_tab_name=main`;
  602. candidate.profileUrl = profileUrl;
  603. candidate.secUid = match.secUid;
  604. candidate.uniqueId = match.uniqueId;
  605. await updateProviderCreatorProfileUrl({
  606. provider: 'justone',
  607. platform: 'douyin',
  608. platformUserId: candidate.platformUserId,
  609. profileUrl,
  610. secUid: match.secUid,
  611. uniqueId: match.uniqueId,
  612. }).catch((error) => console.error('[Recommend] 写回抖音主页链接缓存失败:', error));
  613. }
  614. }
  615. function chooseDouyinUserMatch(
  616. candidate: NormalizedCandidate,
  617. matches: Awaited<ReturnType<typeof searchDouyinUsers>>,
  618. ) {
  619. const exactCoreId = matches.find((match) => candidate.coreUserId && match.uid === candidate.coreUserId);
  620. if (exactCoreId) return exactCoreId;
  621. const exactName = matches.find((match) => normalizeName(match.nickname) === normalizeName(candidate.displayName));
  622. if (exactName) return exactName;
  623. const candidateName = normalizeName(candidate.displayName);
  624. return matches
  625. .filter((match) => {
  626. const matchName = normalizeName(match.nickname);
  627. return matchName.includes(candidateName) || candidateName.includes(matchName);
  628. })
  629. .sort((a, b) => Math.abs(a.followerCount - candidate.fansCount) - Math.abs(b.followerCount - candidate.fansCount))[0] || null;
  630. }
  631. function normalizeName(value: string): string {
  632. return value.replace(/\s+/g, '').toLowerCase();
  633. }
  634. function appendRisk(current: string, note: string): string {
  635. if (!current || current === '暂无明显风险') return note;
  636. return current.includes(note) ? current : `${current};${note}`;
  637. }
  638. function buildEvaluationRiskNote(evaluation?: CandidateEvaluation): string {
  639. if (!evaluation) return '';
  640. const risks: string[] = [];
  641. const cpmVerdict = evaluation.cpmCpe?.verdict || '';
  642. const commercialVerdict = evaluation.commercialStability?.verdict || '';
  643. const recentVerdict = evaluation.recentPerformance?.verdict || '';
  644. const updateVerdict = evaluation.updateFrequency?.verdict || '';
  645. const commentVerdict = evaluation.commentQuality?.verdict || '';
  646. const sentimentVerdict = evaluation.publicSentiment?.verdict || '';
  647. const genderVerdict = evaluation.audienceGender?.verdict || '';
  648. if (cpmVerdict.includes('未达标')) risks.push(`CPM/CPE${cpmVerdict}`);
  649. if (commercialVerdict.includes('暂无') || commercialVerdict.includes('需复核')) risks.push(`商单${commercialVerdict}`);
  650. if (recentVerdict.includes('下降') || recentVerdict.includes('波动')) risks.push(recentVerdict);
  651. if (updateVerdict.includes('低') || updateVerdict.includes('低活跃')) risks.push(updateVerdict);
  652. if (commentVerdict.includes('偏弱') || commentVerdict.includes('需')) risks.push(commentVerdict);
  653. if (sentimentVerdict.startsWith('命中风险词')) risks.push(sentimentVerdict);
  654. if (genderVerdict.includes('暂无') || genderVerdict.includes('不高')) risks.push(`粉丝性别${genderVerdict}`);
  655. return risks.length > 0 ? `${risks.join(';')};` : '';
  656. }
  657. function assignRecommendStatus(candidate: NormalizedCandidate, criteria: SearchCriteria): NormalizedCandidate {
  658. let status: '强推荐' | '备选' | '需复核' | '已剔除' = '需复核';
  659. let reason = '';
  660. let risk = '';
  661. if (candidate.score >= 78 && candidate.styleMatch >= 70) {
  662. status = '强推荐';
  663. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,内容标签与Brief高度吻合。`;
  664. } else if (candidate.score >= 65) {
  665. status = '备选';
  666. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,满足基本要求。`;
  667. } else if (candidate.score < 55) {
  668. status = '已剔除';
  669. reason = '综合评分过低。';
  670. } else {
  671. status = '需复核';
  672. reason = '部分指标不确定,需要人工确认。';
  673. }
  674. // 风险检查
  675. if (candidate.fansCount === 0) {
  676. risk += '粉丝数据缺失;';
  677. }
  678. if (candidate.minPrice > criteria.budgetRange.max) {
  679. risk += '报价超出预算上限;';
  680. }
  681. if (!candidate.cooperationStatus) {
  682. risk += '合作状态未知;';
  683. }
  684. const evaluationRisk = buildEvaluationRiskNote(candidate.evaluation);
  685. if (evaluationRisk) {
  686. risk += evaluationRisk;
  687. }
  688. return {
  689. ...candidate,
  690. recommendStatus: status,
  691. recommendReason: reason,
  692. riskNote: risk || '暂无明显风险',
  693. };
  694. }
  695. function scoreFanMatch(fans: number, range: { min: number; max: number }): number {
  696. if (fans === 0) return 50;
  697. if (fans >= range.min && fans <= range.max) return 90;
  698. if (fans < range.min) {
  699. const ratio = fans / range.min;
  700. return Math.max(40, Math.round(90 * ratio));
  701. }
  702. // Slightly over max is still acceptable
  703. const overRatio = range.max / fans;
  704. return Math.max(50, Math.round(90 * overRatio));
  705. }
  706. function scorePriceMatch(price: number, range: { min: number; max: number }): number {
  707. if (price === 0) return 60; // 价格缺失
  708. if (price >= range.min && price <= range.max) return 90;
  709. if (price < range.min) return 75; // 低于预算更好
  710. const overRatio = range.max / price;
  711. return Math.max(30, Math.round(90 * overRatio));
  712. }
  713. function scoreContentRelevance(tags: string[], keywords: string[]): number {
  714. if (tags.length === 0 || keywords.length === 0) return 60;
  715. let matches = 0;
  716. for (const keyword of keywords) {
  717. const keywordTerms = extractMatchTerms(keyword);
  718. for (const tag of tags) {
  719. const tagTerms = extractMatchTerms(tag);
  720. if (
  721. tag.includes(keyword) ||
  722. keyword.includes(tag) ||
  723. keywordTerms.some((term) => tag.includes(term)) ||
  724. tagTerms.some((term) => keyword.includes(term))
  725. ) {
  726. matches++;
  727. break;
  728. }
  729. }
  730. }
  731. const matchRatio = matches / keywords.length;
  732. return Math.min(95, Math.round(60 + matchRatio * 50));
  733. }
  734. function extractMatchTerms(text: string): string[] {
  735. const clean = text.trim();
  736. const terms: string[] = [clean];
  737. // 常见领域子词,用于从复合关键词中提取有意义的短词
  738. // 例如 "大象胶原蛋白系列" → 提取 "胶原蛋白","修丽可胶原蛋白精华" → 提取 "胶原蛋白"、"精华"
  739. const domainTerms = [
  740. '家居', '家装', '探店', '生活', '精致', '美食', '出行', '旅游', '母婴', '记录',
  741. '护肤', '美妆', '测评', '种草', '好物', '情侣', '精华', '胶原蛋白', '面膜',
  742. '防晒', '美白', '抗老', '补水', '保湿', '祛痘', '敏感肌', '修护', '清洁',
  743. '彩妆', '口红', '粉底', '眼影', '穿搭', '健身', '减肥', '养生', '保健',
  744. '数码', '科技', '游戏', '娱乐', '影视', '音乐', '教育', '职场', '金融',
  745. '好物种草', '个人护理', '身体护理', '抗衰', '紧致', '淡斑', '祛皱',
  746. ];
  747. for (const term of domainTerms) {
  748. if (clean.includes(term)) terms.push(term);
  749. }
  750. return [...new Set(terms)];
  751. }
  752. function calculateStyleMatch(samples: ContentSample[], keywords: string[]): number {
  753. if (samples.length === 0 || keywords.length === 0) return 60;
  754. let matchingSamples = 0;
  755. let highEngagement = 0;
  756. const allMatchTerms = keywords.flatMap(extractMatchTerms);
  757. for (const sample of samples) {
  758. const text = `${sample.title} ${sample.content}`.toLowerCase();
  759. const hasKeyword = allMatchTerms.some(term => text.includes(term.toLowerCase()));
  760. if (hasKeyword) matchingSamples++;
  761. if (sample.likeCount > 500 || sample.collectCount > 200) highEngagement++;
  762. }
  763. // 匹配率映射到 60–95,与 scoreContentRelevance 区间一致
  764. const matchRatio = matchingSamples / samples.length;
  765. const engagementRatio = highEngagement / samples.length;
  766. return Math.min(95, Math.round(60 + matchRatio * 45 + engagementRatio * 5));
  767. }
  768. function buildCompactSearchKeywords(criteria: SearchCriteria): string[] {
  769. const candidates = [...criteria.keywords, ...(criteria.contentTags || [])]
  770. .map(cleanSearchKeyword)
  771. .filter(Boolean);
  772. const unique = [...new Set(candidates)];
  773. return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0
  774. ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM)
  775. : ['生活方式'];
  776. }
  777. function cleanSearchKeyword(keyword: string): string {
  778. return keyword
  779. .trim()
  780. .replace(/类达人|达人|账号|粉丝号|垂类/g, '')
  781. .replace(/[【】]/g, '')
  782. .trim();
  783. }
  784. function normalizeSearchCriteria(criteria: SearchCriteria): SearchCriteria {
  785. return {
  786. ...criteria,
  787. fanRange: normalizeFanRange(criteria.fanRange),
  788. budgetRange: normalizeBudgetRange(criteria.budgetRange),
  789. platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu', 'douyin'],
  790. keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'],
  791. targetCount: normalizeTargetCount(criteria.targetCount),
  792. };
  793. }
  794. function resolveTargetCandidatePool(criteria: SearchCriteria): number {
  795. const targetCount = normalizeTargetCount(criteria.targetCount);
  796. if (targetCount > 0) {
  797. return Math.min(config.recommendation.maxCandidates, Math.max(DEFAULT_MIN_CANDIDATE_POOL, targetCount * config.recommendation.multiplier));
  798. }
  799. return Math.min(config.recommendation.maxCandidates, DEFAULT_MIN_CANDIDATE_POOL);
  800. }
  801. function resolvePageLimit(targetPerPlatform: number): number {
  802. const pagesNeeded = Math.ceil(targetPerPlatform / ESTIMATED_RESULTS_PER_PAGE);
  803. return Math.min(MAX_PAGES_PER_PLATFORM, Math.max(PAGES_PER_PLATFORM, pagesNeeded));
  804. }
  805. function normalizeTargetCount(value?: number): number | undefined {
  806. const count = Number(value || 0);
  807. return count > 0 ? Math.ceil(count) : undefined;
  808. }
  809. function normalizeFanRange(range: { min: number; max: number }): { min: number; max: number } {
  810. const min = Number(range?.min || 0);
  811. const max = Number(range?.max || 0);
  812. // LLM 容易把“1万-15万”解析成 1-15;统一换算成真实粉丝数。
  813. if (max > 0 && max <= 1000) {
  814. return { min: Math.max(0, min * 10000), max: max * 10000 };
  815. }
  816. return { min, max };
  817. }
  818. function normalizeBudgetRange(range: { min: number; max: number }): { min: number; max: number } {
  819. return {
  820. min: Number(range?.min || 0),
  821. max: Number(range?.max || 0),
  822. };
  823. }
  824. function uniquePlatforms(platforms: string[]): string[] {
  825. const normalized = platforms.map(mapPlatformName).filter(Boolean);
  826. const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin');
  827. return [...new Set(supported.length > 0 ? supported : ['xiaohongshu', 'douyin'])];
  828. }
  829. function platformsNeedSupplement(candidates: JustOneCreator[], platforms: string[], targetPerPlatform: number): boolean {
  830. return platforms.some((platform) => countCreatorsByPlatform(candidates, platform) < targetPerPlatform);
  831. }
  832. function countCreatorsByPlatform(candidates: JustOneCreator[], platform: string): number {
  833. const normalizedPlatform = mapPlatformName(platform);
  834. const uniqueIds = new Set<string>();
  835. for (const candidate of candidates) {
  836. if (mapPlatformName(candidate.platform) === normalizedPlatform && candidate.userId) {
  837. uniqueIds.add(candidate.userId);
  838. }
  839. }
  840. return uniqueIds.size;
  841. }
  842. function mapPlatformName(platform: string): string {
  843. const map: Record<string, string> = {
  844. xiaohongshu: 'xiaohongshu',
  845. '小红书': 'xiaohongshu',
  846. douyin: 'douyin',
  847. '抖音': 'douyin',
  848. bilibili: 'bilibili',
  849. 'B站': 'bilibili',
  850. weibo: 'weibo',
  851. '微博': 'weibo',
  852. weixin: 'weixin',
  853. '微信': 'weixin',
  854. };
  855. return map[platform] || platform;
  856. }
  857. function guessPlatformFromCreator(creator: JustOneCreator): string {
  858. if (creator.redId) return 'xiaohongshu';
  859. // 抖音星图 userId 是纯数字长 ID(约19位),无 redId
  860. if (/^\d{15,}$/.test(creator.userId)) return 'douyin';
  861. return 'xiaohongshu';
  862. }