recommendation.service.ts 40 KB

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