recommendation.service.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. import { config } from '../config.ts';
  2. import { searchCreators, type JustOneCreator } from './justone.service.ts';
  3. import { 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. }
  38. interface SearchCriteria {
  39. platforms: string[];
  40. keywords: string[];
  41. fanRange: { min: number; max: number };
  42. budgetRange: { min: number; max: number };
  43. region?: string | string[];
  44. gender?: string;
  45. contentTags?: string[];
  46. excludeTags?: string[];
  47. targetCount?: number;
  48. }
  49. interface ScoringWeights {
  50. fanMatch: number;
  51. priceMatch: number;
  52. contentRelevance: number;
  53. activityLevel: number;
  54. cooperationReady: number;
  55. }
  56. const DEFAULT_WEIGHTS: ScoringWeights = {
  57. fanMatch: 20,
  58. priceMatch: 20,
  59. contentRelevance: 30,
  60. activityLevel: 15,
  61. cooperationReady: 15,
  62. };
  63. const MAX_KEYWORDS_PER_PLATFORM = 1;
  64. const PAGES_PER_PLATFORM = 3;
  65. const DEFAULT_MIN_CANDIDATE_POOL = 30;
  66. const PUBLIC_PROFILE_ENRICH_LIMIT = 20;
  67. /**
  68. * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序
  69. */
  70. export async function generateRecommendations(
  71. criteria: SearchCriteria,
  72. onProgress?: (stage: string, detail: string) => void
  73. ): Promise<NormalizedCandidate[]> {
  74. criteria = normalizeSearchCriteria(criteria);
  75. console.log('[Recommend] ========== 开始推荐流程 ==========');
  76. console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2));
  77. const targetCandidatePool = resolveTargetCandidatePool(criteria);
  78. const requestedPlatforms = uniquePlatforms(criteria.platforms);
  79. const targetPerPlatform = Math.max(1, Math.ceil(targetCandidatePool / Math.max(1, requestedPlatforms.length)));
  80. onProgress?.('search', `正在检索候选达人,目标候选池 ${targetCandidatePool} 位...`);
  81. // Step 1: 优先从本地/自有达人库召回候选
  82. const allCandidates: JustOneCreator[] = [];
  83. const localCandidates: JustOneCreator[] = [];
  84. for (const platform of requestedPlatforms) {
  85. const platformLocalCandidates = await searchLocalCreators(
  86. { ...criteria, platforms: [platform] },
  87. Math.min(config.recommendation.maxCandidates, targetPerPlatform),
  88. );
  89. localCandidates.push(...platformLocalCandidates);
  90. allCandidates.push(...platformLocalCandidates);
  91. console.log(`[Recommend] 本地达人库 ${platform} 命中: ${platformLocalCandidates.length}/${targetPerPlatform} 位`);
  92. }
  93. if (localCandidates.length > 0) {
  94. console.log(`[Recommend] 本地达人库命中: ${localCandidates.length} 位`);
  95. onProgress?.('search', `本地达人库命中 ${localCandidates.length}/${targetCandidatePool} 位候选达人`);
  96. }
  97. // JustOne API 仅支持小红书和抖音
  98. const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音'];
  99. let providerCandidateCount = 0;
  100. if (platformsNeedSupplement(allCandidates, requestedPlatforms, targetPerPlatform)) {
  101. const shortage = targetCandidatePool - allCandidates.length;
  102. console.log(`[Recommend] 本地候选不足,继续调用 JustOne 补足: 缺口 ${shortage} 位`);
  103. onProgress?.('search', `本地候选不足,继续调用 JustOne 补足 ${shortage} 位...`);
  104. const searchKeywords = buildCompactSearchKeywords(criteria);
  105. for (const platform of requestedPlatforms) {
  106. const platformName = mapPlatformName(platform);
  107. if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) {
  108. console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`);
  109. continue;
  110. }
  111. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) {
  112. console.log(`[Recommend] ${platformName} 已满足平台候选目标 ${targetPerPlatform} 位,跳过补量`);
  113. continue;
  114. }
  115. for (const keyword of searchKeywords) {
  116. for (let page = 1; page <= PAGES_PER_PLATFORM; page++) {
  117. console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}, 页=${page}`);
  118. onProgress?.('search', `搜索 ${platformName} - ${keyword} 第 ${page}/${PAGES_PER_PLATFORM} 页...`);
  119. const results = await searchCreators({
  120. keyword,
  121. platform: platformName,
  122. minFans: criteria.fanRange.min,
  123. maxFans: criteria.fanRange.max,
  124. minPrice: criteria.budgetRange.min,
  125. maxPrice: criteria.budgetRange.max,
  126. gender: criteria.gender,
  127. location: Array.isArray(criteria.region) ? criteria.region.join(',') : criteria.region,
  128. page,
  129. pageSize: 50,
  130. });
  131. console.log(`[Recommend] ${platformName}/${keyword}/page-${page} 返回 ${results.length} 条结果`);
  132. allCandidates.push(...results);
  133. providerCandidateCount += results.length;
  134. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break;
  135. if (results.length === 0) break;
  136. }
  137. if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break;
  138. }
  139. }
  140. } else {
  141. console.log(`[Recommend] 本地候选已满足目标候选池 ${targetCandidatePool} 位,跳过 JustOne`);
  142. }
  143. console.log(`[Recommend] 总召回: ${allCandidates.length} 位候选达人`);
  144. onProgress?.('search', `召回 ${allCandidates.length} 位候选达人`);
  145. // Step 2: 去重
  146. const uniqueMap = new Map<string, JustOneCreator>();
  147. for (const c of allCandidates) {
  148. const key = `${mapPlatformName(c.platform)}:${c.userId}`;
  149. if (!uniqueMap.has(key)) {
  150. uniqueMap.set(key, c);
  151. }
  152. }
  153. const uniqueCandidates = Array.from(uniqueMap.values());
  154. await recordRetrievalEvent({
  155. queryText: buildCompactSearchKeywords(criteria).join(' '),
  156. criteria,
  157. localHitCount: localCandidates.length,
  158. providerHitCount: providerCandidateCount,
  159. finalCount: uniqueCandidates.length,
  160. }).catch((error) => console.error('[Recommend] 记录检索事件失败:', error));
  161. console.log(`[Recommend] 去重后: ${uniqueCandidates.length} 位候选`);
  162. onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`);
  163. // Step 3: 标准化并评分
  164. const normalized = uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index));
  165. if (normalized.length > 0) {
  166. console.log('[Recommend] 评分样例 (前3位):');
  167. normalized.slice(0, 3).forEach(c => {
  168. console.log(` - ${c.displayName} | 粉丝=${c.fansCount} | 报价=${c.minPrice} | 评分=${c.score} | 风格=${c.styleMatch}`);
  169. });
  170. }
  171. // Step 4: 使用 JustOne 搜索结果内置的标签、报价和近期内容进行验证
  172. const topCandidates = normalized
  173. .sort((a, b) => b.score - a.score)
  174. .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length));
  175. onProgress?.('processing', `已用 JustOne 内置字段完成 Top ${topCandidates.length} 位候选评分...`);
  176. // Step 5: 最终排序和状态标记
  177. onProgress?.('processing', '计算最终推荐排序...');
  178. const finalCandidates = topCandidates.map((c) => assignRecommendStatus(c, criteria));
  179. // 按分数排序
  180. finalCandidates.sort((a, b) => b.score - a.score);
  181. await enrichTopDouyinPublicProfileUrls(finalCandidates, onProgress);
  182. const strong = finalCandidates.filter(c => c.recommendStatus === '强推荐').length;
  183. const backup = finalCandidates.filter(c => c.recommendStatus === '备选').length;
  184. const review = finalCandidates.filter(c => c.recommendStatus === '需复核').length;
  185. const removed = finalCandidates.filter(c => c.recommendStatus === '已剔除').length;
  186. console.log(`[Recommend] 最终结果: 总${finalCandidates.length}位 | 强推荐=${strong} | 备选=${backup} | 需复核=${review} | 已剔除=${removed}`);
  187. console.log('[Recommend] ========== 推荐流程结束 ==========');
  188. onProgress?.('processing', `完成!共 ${finalCandidates.length} 位候选达人`);
  189. return finalCandidates;
  190. }
  191. function normalizeCandidateFromJustOne(creator: JustOneCreator, criteria: SearchCriteria, index: number): NormalizedCandidate {
  192. const fanScore = scoreFanMatch(creator.fansCount, criteria.fanRange);
  193. // 取最优非零价格评分(Douyin 无报价时用 CPM 估算价,避免强制给 60 分)
  194. const bestPrice = creator.minPrice || creator.videoPrice || creator.imagePrice || 0;
  195. const priceScore = scorePriceMatch(bestPrice, criteria.budgetRange);
  196. if (index < 3) console.log(`[Score] #${index} ${creator.nickname} | contentTags=${JSON.stringify(creator.contentTags)} | keywords=${JSON.stringify(criteria.keywords)}`);
  197. const contentScore = scoreContentRelevance(creator.contentTags, criteria.keywords);
  198. const cooperationScore = creator.cooperationStatus ? 85 : 60;
  199. const activityScore = 75; // 默认中等,需要内容采样后更新
  200. const totalScore = Math.round(
  201. fanScore * (DEFAULT_WEIGHTS.fanMatch / 100) +
  202. priceScore * (DEFAULT_WEIGHTS.priceMatch / 100) +
  203. contentScore * (DEFAULT_WEIGHTS.contentRelevance / 100) +
  204. activityScore * (DEFAULT_WEIGHTS.activityLevel / 100) +
  205. cooperationScore * (DEFAULT_WEIGHTS.cooperationReady / 100)
  206. );
  207. const platform = creator.platform || guessPlatformFromCreator(creator);
  208. const profileUrl = creator.profileUrl
  209. || creator.xiaohongshuUrl
  210. || (platform === 'xiaohongshu' ? `https://www.xiaohongshu.com/user/profile/${creator.userId}` : '');
  211. return {
  212. id: `C-${String(index + 1).padStart(4, '0')}`,
  213. platform,
  214. platformUserId: creator.userId,
  215. displayName: creator.nickname,
  216. profileUrl,
  217. location: creator.location,
  218. fansCount: creator.fansCount,
  219. contentTags: creator.contentTags,
  220. personaTags: creator.personalTags,
  221. gender: creator.gender,
  222. likedCollectCount: creator.likedCollectCount,
  223. contentType: creator.contentType || creator.contentTags.join('、'),
  224. city: creator.city || creator.location,
  225. geoLocation: creator.geoLocation || creator.location,
  226. xiaohongshuUrl: creator.xiaohongshuUrl || (platform === 'xiaohongshu' ? profileUrl : ''),
  227. coreUserId: creator.coreUserId,
  228. secUid: creator.secUid,
  229. uniqueId: creator.uniqueId,
  230. cooperationMethod: creator.cooperationMethod,
  231. imagePrice: creator.imagePrice,
  232. videoPrice: creator.videoPrice,
  233. minPrice: creator.minPrice,
  234. cooperationStatus: creator.cooperationStatus,
  235. sourceProvider: creator.sourceProvider || 'justone',
  236. sourceConfidence: 80,
  237. score: totalScore,
  238. styleMatch: contentScore,
  239. recommendStatus: '需复核',
  240. recommendReason: '',
  241. riskNote: '',
  242. contentSamples: creator.contentSamples,
  243. };
  244. }
  245. async function enrichTopDouyinPublicProfileUrls(
  246. candidates: NormalizedCandidate[],
  247. onProgress?: (stage: string, detail: string) => void,
  248. ): Promise<void> {
  249. const douyinCount = candidates.filter((candidate) => candidate.platform === 'douyin').length;
  250. const targets = candidates
  251. .filter((candidate) => candidate.platform === 'douyin' && !candidate.profileUrl)
  252. .slice(0, PUBLIC_PROFILE_ENRICH_LIMIT);
  253. if (targets.length === 0) return;
  254. onProgress?.('processing', `正在为 Top ${targets.length} 位抖音候选补充真实主页链接...`);
  255. console.log(`[Recommend] 补充抖音真实主页链接: ${targets.length}/${douyinCount}`);
  256. for (const candidate of targets) {
  257. const matches = await searchDouyinUsers(candidate.displayName);
  258. const match = chooseDouyinUserMatch(candidate, matches);
  259. if (!match) {
  260. candidate.riskNote = appendRisk(candidate.riskNote, '未匹配到抖音公开主页链接');
  261. continue;
  262. }
  263. const profileUrl = `https://www.douyin.com/user/${match.secUid}?from_tab_name=main`;
  264. candidate.profileUrl = profileUrl;
  265. candidate.secUid = match.secUid;
  266. candidate.uniqueId = match.uniqueId;
  267. await updateProviderCreatorProfileUrl({
  268. provider: 'justone',
  269. platform: 'douyin',
  270. platformUserId: candidate.platformUserId,
  271. profileUrl,
  272. secUid: match.secUid,
  273. uniqueId: match.uniqueId,
  274. }).catch((error) => console.error('[Recommend] 写回抖音主页链接缓存失败:', error));
  275. }
  276. }
  277. function chooseDouyinUserMatch(
  278. candidate: NormalizedCandidate,
  279. matches: Awaited<ReturnType<typeof searchDouyinUsers>>,
  280. ) {
  281. const exactCoreId = matches.find((match) => candidate.coreUserId && match.uid === candidate.coreUserId);
  282. if (exactCoreId) return exactCoreId;
  283. const exactName = matches.find((match) => normalizeName(match.nickname) === normalizeName(candidate.displayName));
  284. if (exactName) return exactName;
  285. const candidateName = normalizeName(candidate.displayName);
  286. return matches
  287. .filter((match) => {
  288. const matchName = normalizeName(match.nickname);
  289. return matchName.includes(candidateName) || candidateName.includes(matchName);
  290. })
  291. .sort((a, b) => Math.abs(a.followerCount - candidate.fansCount) - Math.abs(b.followerCount - candidate.fansCount))[0] || null;
  292. }
  293. function normalizeName(value: string): string {
  294. return value.replace(/\s+/g, '').toLowerCase();
  295. }
  296. function appendRisk(current: string, note: string): string {
  297. if (!current || current === '暂无明显风险') return note;
  298. return current.includes(note) ? current : `${current};${note}`;
  299. }
  300. function assignRecommendStatus(candidate: NormalizedCandidate, criteria: SearchCriteria): NormalizedCandidate {
  301. let status: '强推荐' | '备选' | '需复核' | '已剔除' = '需复核';
  302. let reason = '';
  303. let risk = '';
  304. if (candidate.score >= 78 && candidate.styleMatch >= 70) {
  305. status = '强推荐';
  306. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,内容标签与Brief高度吻合。`;
  307. } else if (candidate.score >= 65) {
  308. status = '备选';
  309. reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,满足基本要求。`;
  310. } else if (candidate.score < 55) {
  311. status = '已剔除';
  312. reason = '综合评分过低。';
  313. } else {
  314. status = '需复核';
  315. reason = '部分指标不确定,需要人工确认。';
  316. }
  317. // 风险检查
  318. if (candidate.fansCount === 0) {
  319. risk += '粉丝数据缺失;';
  320. }
  321. if (candidate.minPrice > criteria.budgetRange.max) {
  322. risk += '报价超出预算上限;';
  323. }
  324. if (!candidate.cooperationStatus) {
  325. risk += '合作状态未知;';
  326. }
  327. return {
  328. ...candidate,
  329. recommendStatus: status,
  330. recommendReason: reason,
  331. riskNote: risk || '暂无明显风险',
  332. };
  333. }
  334. function scoreFanMatch(fans: number, range: { min: number; max: number }): number {
  335. if (fans === 0) return 50;
  336. if (fans >= range.min && fans <= range.max) return 90;
  337. if (fans < range.min) {
  338. const ratio = fans / range.min;
  339. return Math.max(40, Math.round(90 * ratio));
  340. }
  341. // Slightly over max is still acceptable
  342. const overRatio = range.max / fans;
  343. return Math.max(50, Math.round(90 * overRatio));
  344. }
  345. function scorePriceMatch(price: number, range: { min: number; max: number }): number {
  346. if (price === 0) return 60; // 价格缺失
  347. if (price >= range.min && price <= range.max) return 90;
  348. if (price < range.min) return 75; // 低于预算更好
  349. const overRatio = range.max / price;
  350. return Math.max(30, Math.round(90 * overRatio));
  351. }
  352. function scoreContentRelevance(tags: string[], keywords: string[]): number {
  353. if (tags.length === 0 || keywords.length === 0) return 60;
  354. let matches = 0;
  355. for (const keyword of keywords) {
  356. const keywordTerms = extractMatchTerms(keyword);
  357. for (const tag of tags) {
  358. const tagTerms = extractMatchTerms(tag);
  359. if (
  360. tag.includes(keyword) ||
  361. keyword.includes(tag) ||
  362. keywordTerms.some((term) => tag.includes(term)) ||
  363. tagTerms.some((term) => keyword.includes(term))
  364. ) {
  365. matches++;
  366. break;
  367. }
  368. }
  369. }
  370. const matchRatio = matches / keywords.length;
  371. return Math.min(95, Math.round(60 + matchRatio * 50));
  372. }
  373. function extractMatchTerms(text: string): string[] {
  374. const clean = text.trim();
  375. const domainTerms = ['家居', '家装', '探店', '生活', '精致', '美食', '出行', '旅游', '母婴', '记录'];
  376. const terms = domainTerms.filter((term) => clean.includes(term));
  377. if (clean.length >= 2) terms.push(clean);
  378. return [...new Set(terms)];
  379. }
  380. function calculateStyleMatch(samples: ContentSample[], keywords: string[]): number {
  381. if (samples.length === 0 || keywords.length === 0) return 60;
  382. let matchingSamples = 0;
  383. let highEngagement = 0;
  384. for (const sample of samples) {
  385. const text = `${sample.title} ${sample.content}`.toLowerCase();
  386. const hasKeyword = keywords.some(kw => text.includes(kw.toLowerCase()));
  387. if (hasKeyword) matchingSamples++;
  388. if (sample.likeCount > 500 || sample.collectCount > 200) highEngagement++;
  389. }
  390. // 匹配率映射到 60–95,与 scoreContentRelevance 区间一致
  391. const matchRatio = matchingSamples / samples.length;
  392. const engagementRatio = highEngagement / samples.length;
  393. return Math.min(95, Math.round(60 + matchRatio * 45 + engagementRatio * 5));
  394. }
  395. function buildCompactSearchKeywords(criteria: SearchCriteria): string[] {
  396. const candidates = [...criteria.keywords, ...(criteria.contentTags || [])]
  397. .map(cleanSearchKeyword)
  398. .filter(Boolean);
  399. const unique = [...new Set(candidates)];
  400. return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0
  401. ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM)
  402. : ['生活方式'];
  403. }
  404. function cleanSearchKeyword(keyword: string): string {
  405. return keyword
  406. .trim()
  407. .replace(/类达人|达人|账号|粉丝号|垂类/g, '')
  408. .replace(/[【】]/g, '')
  409. .trim();
  410. }
  411. function normalizeSearchCriteria(criteria: SearchCriteria): SearchCriteria {
  412. return {
  413. ...criteria,
  414. fanRange: normalizeFanRange(criteria.fanRange),
  415. budgetRange: normalizeBudgetRange(criteria.budgetRange),
  416. platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu'],
  417. keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'],
  418. targetCount: normalizeTargetCount(criteria.targetCount),
  419. };
  420. }
  421. function resolveTargetCandidatePool(criteria: SearchCriteria): number {
  422. const targetCount = normalizeTargetCount(criteria.targetCount);
  423. if (targetCount > 0) {
  424. return Math.min(config.recommendation.maxCandidates, Math.max(DEFAULT_MIN_CANDIDATE_POOL, targetCount * config.recommendation.multiplier));
  425. }
  426. return Math.min(config.recommendation.maxCandidates, DEFAULT_MIN_CANDIDATE_POOL);
  427. }
  428. function normalizeTargetCount(value?: number): number | undefined {
  429. const count = Number(value || 0);
  430. return count > 0 ? Math.ceil(count) : undefined;
  431. }
  432. function normalizeFanRange(range: { min: number; max: number }): { min: number; max: number } {
  433. const min = Number(range?.min || 0);
  434. const max = Number(range?.max || 0);
  435. // LLM 容易把“1万-15万”解析成 1-15;统一换算成真实粉丝数。
  436. if (max > 0 && max <= 1000) {
  437. return { min: Math.max(0, min * 10000), max: max * 10000 };
  438. }
  439. return { min, max };
  440. }
  441. function normalizeBudgetRange(range: { min: number; max: number }): { min: number; max: number } {
  442. return {
  443. min: Number(range?.min || 0),
  444. max: Number(range?.max || 0),
  445. };
  446. }
  447. function uniquePlatforms(platforms: string[]): string[] {
  448. const normalized = platforms.map(mapPlatformName).filter(Boolean);
  449. const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin');
  450. return [...new Set(supported.length > 0 ? supported : ['xiaohongshu'])];
  451. }
  452. function platformsNeedSupplement(candidates: JustOneCreator[], platforms: string[], targetPerPlatform: number): boolean {
  453. return platforms.some((platform) => countCreatorsByPlatform(candidates, platform) < targetPerPlatform);
  454. }
  455. function countCreatorsByPlatform(candidates: JustOneCreator[], platform: string): number {
  456. const normalizedPlatform = mapPlatformName(platform);
  457. const uniqueIds = new Set<string>();
  458. for (const candidate of candidates) {
  459. if (mapPlatformName(candidate.platform) === normalizedPlatform && candidate.userId) {
  460. uniqueIds.add(candidate.userId);
  461. }
  462. }
  463. return uniqueIds.size;
  464. }
  465. function mapPlatformName(platform: string): string {
  466. const map: Record<string, string> = {
  467. xiaohongshu: 'xiaohongshu',
  468. '小红书': 'xiaohongshu',
  469. douyin: 'douyin',
  470. '抖音': 'douyin',
  471. bilibili: 'bilibili',
  472. 'B站': 'bilibili',
  473. weibo: 'weibo',
  474. '微博': 'weibo',
  475. weixin: 'weixin',
  476. '微信': 'weixin',
  477. };
  478. return map[platform] || platform;
  479. }
  480. function guessPlatformFromCreator(creator: JustOneCreator): string {
  481. if (creator.redId) return 'xiaohongshu';
  482. // 抖音星图 userId 是纯数字长 ID(约19位),无 redId
  483. if (/^\d{15,}$/.test(creator.userId)) return 'douyin';
  484. return 'xiaohongshu';
  485. }