| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- 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<NormalizedCandidate[]> {
- 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<string, JustOneCreator>();
- 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<string, string> = {
- 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';
- }
|