import { config } from '../config.ts'; import { searchCreators, type JustOneCreator } from './justone.service.ts'; import { getUserNotes, type ContentSample } from './tikhub.service.ts'; export interface NormalizedCandidate { id: string; platform: string; platformUserId: string; displayName: string; profileUrl: string; location: string; fansCount: number; contentTags: string[]; personaTags: string[]; imagePrice: number; videoPrice: number; minPrice: number; cooperationStatus: string; sourceProvider: string; sourceConfidence: number; score: number; styleMatch: number; recommendStatus: '强推荐' | '备选' | '需复核' | '已剔除'; recommendReason: string; riskNote: string; contentSamples?: ContentSample[]; } interface SearchCriteria { platforms: string[]; keywords: string[]; fanRange: { min: number; max: number }; budgetRange: { min: number; max: number }; region?: string; gender?: string; contentTags?: string[]; excludeTags?: string[]; } interface ScoringWeights { fanMatch: number; priceMatch: number; contentRelevance: number; activityLevel: number; cooperationReady: number; } const DEFAULT_WEIGHTS: ScoringWeights = { fanMatch: 20, priceMatch: 20, contentRelevance: 30, activityLevel: 15, cooperationReady: 15, }; /** * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序 */ export async function generateRecommendations( criteria: SearchCriteria, onProgress?: (stage: string, detail: string) => void ): Promise { console.log('[Recommend] ========== 开始推荐流程 =========='); console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2)); onProgress?.('search', '正在从第三方平台召回候选达人...'); // Step 1: 从 JustOne API 召回候选 const allCandidates: JustOneCreator[] = []; // JustOne API 仅支持小红书和抖音 const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音']; for (const platform of criteria.platforms) { const platformName = mapPlatformName(platform); if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) { console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`); continue; } for (const keyword of criteria.keywords) { console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}`); onProgress?.('search', `搜索 ${platformName} - ${keyword}...`); const results = await searchCreators({ keyword, platform: platformName, minFans: criteria.fanRange.min, maxFans: criteria.fanRange.max, minPrice: criteria.budgetRange.min, maxPrice: criteria.budgetRange.max, gender: criteria.gender, location: criteria.region, pageSize: 50, }); console.log(`[Recommend] ${platformName}/${keyword} 返回 ${results.length} 条结果`); allCandidates.push(...results); } } console.log(`[Recommend] 总召回: ${allCandidates.length} 位候选达人`); onProgress?.('search', `召回 ${allCandidates.length} 位候选达人`); // Step 2: 去重 const uniqueMap = new Map(); for (const c of allCandidates) { if (!uniqueMap.has(c.userId)) { uniqueMap.set(c.userId, c); } } const uniqueCandidates = Array.from(uniqueMap.values()); console.log(`[Recommend] 去重后: ${uniqueCandidates.length} 位候选`); onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`); // Step 3: 标准化并评分 const normalized = uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index)); if (normalized.length > 0) { console.log('[Recommend] 评分样例 (前3位):'); normalized.slice(0, 3).forEach(c => { console.log(` - ${c.displayName} | 粉丝=${c.fansCount} | 报价=${c.minPrice} | 评分=${c.score} | 风格=${c.styleMatch}`); }); } // Step 4: 内容采样验证(取 Top N 候选进行深度验证) const topCandidates = normalized .sort((a, b) => b.score - a.score) .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length)); onProgress?.('processing', `对 Top ${Math.min(20, topCandidates.length)} 位候选进行内容采样验证...`); // 对前20个进行内容采样 for (let i = 0; i < Math.min(20, topCandidates.length); i++) { const candidate = topCandidates[i]; try { const samples = await getUserNotes(candidate.platformUserId, candidate.platform, 10); if (samples.length > 0) { candidate.contentSamples = samples; candidate.styleMatch = calculateStyleMatch(samples, criteria.keywords); } } catch { // Content sampling is optional, continue } } // Step 5: 最终排序和状态标记 onProgress?.('processing', '计算最终推荐排序...'); const finalCandidates = topCandidates.map((c) => assignRecommendStatus(c, criteria)); // 按分数排序 finalCandidates.sort((a, b) => b.score - a.score); const strong = finalCandidates.filter(c => c.recommendStatus === '强推荐').length; const backup = finalCandidates.filter(c => c.recommendStatus === '备选').length; const review = finalCandidates.filter(c => c.recommendStatus === '需复核').length; const removed = finalCandidates.filter(c => c.recommendStatus === '已剔除').length; console.log(`[Recommend] 最终结果: 总${finalCandidates.length}位 | 强推荐=${strong} | 备选=${backup} | 需复核=${review} | 已剔除=${removed}`); console.log('[Recommend] ========== 推荐流程结束 =========='); onProgress?.('processing', `完成!共 ${finalCandidates.length} 位候选达人`); return finalCandidates; } function normalizeCandidateFromJustOne(creator: JustOneCreator, criteria: SearchCriteria, index: number): NormalizedCandidate { const fanScore = scoreFanMatch(creator.fansCount, criteria.fanRange); // 取最优非零价格评分(Douyin 无报价时用 CPM 估算价,避免强制给 60 分) const bestPrice = creator.minPrice || creator.videoPrice || creator.imagePrice || 0; const priceScore = scorePriceMatch(bestPrice, criteria.budgetRange); if (index < 3) console.log(`[Score] #${index} ${creator.nickname} | contentTags=${JSON.stringify(creator.contentTags)} | keywords=${JSON.stringify(criteria.keywords)}`); const contentScore = scoreContentRelevance(creator.contentTags, criteria.keywords); const cooperationScore = creator.cooperationStatus ? 85 : 60; const activityScore = 75; // 默认中等,需要内容采样后更新 const totalScore = Math.round( fanScore * (DEFAULT_WEIGHTS.fanMatch / 100) + priceScore * (DEFAULT_WEIGHTS.priceMatch / 100) + contentScore * (DEFAULT_WEIGHTS.contentRelevance / 100) + activityScore * (DEFAULT_WEIGHTS.activityLevel / 100) + cooperationScore * (DEFAULT_WEIGHTS.cooperationReady / 100) ); const platform = creator.platform || guessPlatformFromCreator(creator); const profileUrl = platform === 'douyin' ? `https://www.douyin.com/user/${creator.userId}` : `https://www.xiaohongshu.com/user/profile/${creator.userId}`; return { id: `C-${String(index + 1).padStart(4, '0')}`, platform, platformUserId: creator.userId, displayName: creator.nickname, profileUrl, location: creator.location, fansCount: creator.fansCount, contentTags: creator.contentTags, personaTags: creator.personalTags, imagePrice: creator.imagePrice, videoPrice: creator.videoPrice, minPrice: creator.minPrice, cooperationStatus: creator.cooperationStatus, sourceProvider: 'justone', sourceConfidence: 80, score: totalScore, styleMatch: contentScore, recommendStatus: '需复核', recommendReason: '', riskNote: '', }; } function assignRecommendStatus(candidate: NormalizedCandidate, criteria: SearchCriteria): NormalizedCandidate { let status: '强推荐' | '备选' | '需复核' | '已剔除' = '需复核'; let reason = ''; let risk = ''; if (candidate.score >= 78 && candidate.styleMatch >= 70) { status = '强推荐'; reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,内容标签与Brief高度吻合。`; } else if (candidate.score >= 65) { status = '备选'; reason = `综合评分 ${candidate.score},风格匹配 ${candidate.styleMatch}%,满足基本要求。`; } else if (candidate.score < 55) { status = '已剔除'; reason = '综合评分过低。'; } else { status = '需复核'; reason = '部分指标不确定,需要人工确认。'; } // 风险检查 if (candidate.fansCount === 0) { risk += '粉丝数据缺失;'; } if (candidate.minPrice > criteria.budgetRange.max) { risk += '报价超出预算上限;'; } if (!candidate.cooperationStatus) { risk += '合作状态未知;'; } return { ...candidate, recommendStatus: status, recommendReason: reason, riskNote: risk || '暂无明显风险', }; } function scoreFanMatch(fans: number, range: { min: number; max: number }): number { if (fans === 0) return 50; if (fans >= range.min && fans <= range.max) return 90; if (fans < range.min) { const ratio = fans / range.min; return Math.max(40, Math.round(90 * ratio)); } // Slightly over max is still acceptable const overRatio = range.max / fans; return Math.max(50, Math.round(90 * overRatio)); } function scorePriceMatch(price: number, range: { min: number; max: number }): number { if (price === 0) return 60; // 价格缺失 if (price >= range.min && price <= range.max) return 90; if (price < range.min) return 75; // 低于预算更好 const overRatio = range.max / price; return Math.max(30, Math.round(90 * overRatio)); } function scoreContentRelevance(tags: string[], keywords: string[]): number { if (tags.length === 0 || keywords.length === 0) return 60; let matches = 0; for (const keyword of keywords) { for (const tag of tags) { if (tag.includes(keyword) || keyword.includes(tag)) { matches++; break; } } } const matchRatio = matches / keywords.length; return Math.min(95, Math.round(60 + matchRatio * 50)); } function calculateStyleMatch(samples: ContentSample[], keywords: string[]): number { if (samples.length === 0 || keywords.length === 0) return 60; let matchingSamples = 0; let highEngagement = 0; for (const sample of samples) { const text = `${sample.title} ${sample.content}`.toLowerCase(); const hasKeyword = keywords.some(kw => text.includes(kw.toLowerCase())); if (hasKeyword) matchingSamples++; if (sample.likeCount > 500 || sample.collectCount > 200) highEngagement++; } // 匹配率映射到 60–95,与 scoreContentRelevance 区间一致 const matchRatio = matchingSamples / samples.length; const engagementRatio = highEngagement / samples.length; return Math.min(95, Math.round(60 + matchRatio * 45 + engagementRatio * 5)); } function mapPlatformName(platform: string): string { const map: Record = { xiaohongshu: 'xiaohongshu', '小红书': 'xiaohongshu', douyin: 'douyin', '抖音': 'douyin', bilibili: 'bilibili', 'B站': 'bilibili', weibo: 'weibo', '微博': 'weibo', weixin: 'weixin', '微信': 'weixin', }; return map[platform] || platform; } function guessPlatformFromCreator(creator: JustOneCreator): string { if (creator.redId) return 'xiaohongshu'; // 抖音星图 userId 是纯数字长 ID(约19位),无 redId if (/^\d{15,}$/.test(creator.userId)) return 'douyin'; return 'xiaohongshu'; }