import { config } from '../config.ts'; import { searchCreators, type JustOneCreator } from './justone.service.ts'; import { searchDouyinUsers, type ContentSample } from './tikhub.service.ts'; import { recordRetrievalEvent, searchLocalCreators, updateProviderCreatorProfileUrl } from './local-creator-db.service.ts'; export interface NormalizedCandidate { id: string; platform: string; platformUserId: string; displayName: string; profileUrl: string; location: string; fansCount: number; contentTags: string[]; personaTags: string[]; gender?: string; likedCollectCount?: number; contentType?: string; city?: string; geoLocation?: string; xiaohongshuUrl?: string; coreUserId?: string; secUid?: string; uniqueId?: string; cooperationMethod?: 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 | string[]; gender?: string; contentTags?: string[]; excludeTags?: string[]; targetCount?: number; } 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, }; const MAX_KEYWORDS_PER_PLATFORM = 1; const PAGES_PER_PLATFORM = 3; const DEFAULT_MIN_CANDIDATE_POOL = 30; const PUBLIC_PROFILE_ENRICH_LIMIT = 20; /** * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序 */ export async function generateRecommendations( criteria: SearchCriteria, onProgress?: (stage: string, detail: string) => void ): Promise { criteria = normalizeSearchCriteria(criteria); console.log('[Recommend] ========== 开始推荐流程 =========='); console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2)); const targetCandidatePool = resolveTargetCandidatePool(criteria); const requestedPlatforms = uniquePlatforms(criteria.platforms); const targetPerPlatform = Math.max(1, Math.ceil(targetCandidatePool / Math.max(1, requestedPlatforms.length))); onProgress?.('search', `正在检索候选达人,目标候选池 ${targetCandidatePool} 位...`); // Step 1: 优先从本地/自有达人库召回候选 const allCandidates: JustOneCreator[] = []; const localCandidates: JustOneCreator[] = []; for (const platform of requestedPlatforms) { const platformLocalCandidates = await searchLocalCreators( { ...criteria, platforms: [platform] }, Math.min(config.recommendation.maxCandidates, targetPerPlatform), ); localCandidates.push(...platformLocalCandidates); allCandidates.push(...platformLocalCandidates); console.log(`[Recommend] 本地达人库 ${platform} 命中: ${platformLocalCandidates.length}/${targetPerPlatform} 位`); } if (localCandidates.length > 0) { console.log(`[Recommend] 本地达人库命中: ${localCandidates.length} 位`); onProgress?.('search', `本地达人库命中 ${localCandidates.length}/${targetCandidatePool} 位候选达人`); } // JustOne API 仅支持小红书和抖音 const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音']; let providerCandidateCount = 0; if (platformsNeedSupplement(allCandidates, requestedPlatforms, targetPerPlatform)) { const shortage = targetCandidatePool - allCandidates.length; console.log(`[Recommend] 本地候选不足,继续调用 JustOne 补足: 缺口 ${shortage} 位`); onProgress?.('search', `本地候选不足,继续调用 JustOne 补足 ${shortage} 位...`); const searchKeywords = buildCompactSearchKeywords(criteria); for (const platform of requestedPlatforms) { const platformName = mapPlatformName(platform); if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) { console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`); continue; } if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) { console.log(`[Recommend] ${platformName} 已满足平台候选目标 ${targetPerPlatform} 位,跳过补量`); continue; } for (const keyword of searchKeywords) { for (let page = 1; page <= PAGES_PER_PLATFORM; page++) { console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}, 页=${page}`); onProgress?.('search', `搜索 ${platformName} - ${keyword} 第 ${page}/${PAGES_PER_PLATFORM} 页...`); 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: Array.isArray(criteria.region) ? criteria.region.join(',') : criteria.region, page, pageSize: 50, }); console.log(`[Recommend] ${platformName}/${keyword}/page-${page} 返回 ${results.length} 条结果`); allCandidates.push(...results); providerCandidateCount += results.length; if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break; if (results.length === 0) break; } if (countCreatorsByPlatform(allCandidates, platformName) >= targetPerPlatform) break; } } } else { console.log(`[Recommend] 本地候选已满足目标候选池 ${targetCandidatePool} 位,跳过 JustOne`); } console.log(`[Recommend] 总召回: ${allCandidates.length} 位候选达人`); onProgress?.('search', `召回 ${allCandidates.length} 位候选达人`); // Step 2: 去重 const uniqueMap = new Map(); for (const c of allCandidates) { const key = `${mapPlatformName(c.platform)}:${c.userId}`; if (!uniqueMap.has(key)) { uniqueMap.set(key, c); } } const uniqueCandidates = Array.from(uniqueMap.values()); await recordRetrievalEvent({ queryText: buildCompactSearchKeywords(criteria).join(' '), criteria, localHitCount: localCandidates.length, providerHitCount: providerCandidateCount, finalCount: uniqueCandidates.length, }).catch((error) => console.error('[Recommend] 记录检索事件失败:', error)); 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: 使用 JustOne 搜索结果内置的标签、报价和近期内容进行验证 const topCandidates = normalized .sort((a, b) => b.score - a.score) .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length)); onProgress?.('processing', `已用 JustOne 内置字段完成 Top ${topCandidates.length} 位候选评分...`); // Step 5: 最终排序和状态标记 onProgress?.('processing', '计算最终推荐排序...'); const finalCandidates = topCandidates.map((c) => assignRecommendStatus(c, criteria)); // 按分数排序 finalCandidates.sort((a, b) => b.score - a.score); await enrichTopDouyinPublicProfileUrls(finalCandidates, onProgress); 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 = creator.profileUrl || creator.xiaohongshuUrl || (platform === 'xiaohongshu' ? `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, gender: creator.gender, likedCollectCount: creator.likedCollectCount, contentType: creator.contentType || creator.contentTags.join('、'), city: creator.city || creator.location, geoLocation: creator.geoLocation || creator.location, xiaohongshuUrl: creator.xiaohongshuUrl || (platform === 'xiaohongshu' ? profileUrl : ''), coreUserId: creator.coreUserId, secUid: creator.secUid, uniqueId: creator.uniqueId, cooperationMethod: creator.cooperationMethod, imagePrice: creator.imagePrice, videoPrice: creator.videoPrice, minPrice: creator.minPrice, cooperationStatus: creator.cooperationStatus, sourceProvider: creator.sourceProvider || 'justone', sourceConfidence: 80, score: totalScore, styleMatch: contentScore, recommendStatus: '需复核', recommendReason: '', riskNote: '', contentSamples: creator.contentSamples, }; } async function enrichTopDouyinPublicProfileUrls( candidates: NormalizedCandidate[], onProgress?: (stage: string, detail: string) => void, ): Promise { const douyinCount = candidates.filter((candidate) => candidate.platform === 'douyin').length; const targets = candidates .filter((candidate) => candidate.platform === 'douyin' && !candidate.profileUrl) .slice(0, PUBLIC_PROFILE_ENRICH_LIMIT); if (targets.length === 0) return; onProgress?.('processing', `正在为 Top ${targets.length} 位抖音候选补充真实主页链接...`); console.log(`[Recommend] 补充抖音真实主页链接: ${targets.length}/${douyinCount}`); for (const candidate of targets) { const matches = await searchDouyinUsers(candidate.displayName); const match = chooseDouyinUserMatch(candidate, matches); if (!match) { candidate.riskNote = appendRisk(candidate.riskNote, '未匹配到抖音公开主页链接'); continue; } const profileUrl = `https://www.douyin.com/user/${match.secUid}?from_tab_name=main`; candidate.profileUrl = profileUrl; candidate.secUid = match.secUid; candidate.uniqueId = match.uniqueId; await updateProviderCreatorProfileUrl({ provider: 'justone', platform: 'douyin', platformUserId: candidate.platformUserId, profileUrl, secUid: match.secUid, uniqueId: match.uniqueId, }).catch((error) => console.error('[Recommend] 写回抖音主页链接缓存失败:', error)); } } function chooseDouyinUserMatch( candidate: NormalizedCandidate, matches: Awaited>, ) { const exactCoreId = matches.find((match) => candidate.coreUserId && match.uid === candidate.coreUserId); if (exactCoreId) return exactCoreId; const exactName = matches.find((match) => normalizeName(match.nickname) === normalizeName(candidate.displayName)); if (exactName) return exactName; const candidateName = normalizeName(candidate.displayName); return matches .filter((match) => { const matchName = normalizeName(match.nickname); return matchName.includes(candidateName) || candidateName.includes(matchName); }) .sort((a, b) => Math.abs(a.followerCount - candidate.fansCount) - Math.abs(b.followerCount - candidate.fansCount))[0] || null; } function normalizeName(value: string): string { return value.replace(/\s+/g, '').toLowerCase(); } function appendRisk(current: string, note: string): string { if (!current || current === '暂无明显风险') return note; return current.includes(note) ? current : `${current};${note}`; } 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) { const keywordTerms = extractMatchTerms(keyword); for (const tag of tags) { const tagTerms = extractMatchTerms(tag); if ( tag.includes(keyword) || keyword.includes(tag) || keywordTerms.some((term) => tag.includes(term)) || tagTerms.some((term) => keyword.includes(term)) ) { matches++; break; } } } const matchRatio = matches / keywords.length; return Math.min(95, Math.round(60 + matchRatio * 50)); } function extractMatchTerms(text: string): string[] { const clean = text.trim(); const domainTerms = ['家居', '家装', '探店', '生活', '精致', '美食', '出行', '旅游', '母婴', '记录']; const terms = domainTerms.filter((term) => clean.includes(term)); if (clean.length >= 2) terms.push(clean); return [...new Set(terms)]; } 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 buildCompactSearchKeywords(criteria: SearchCriteria): string[] { const candidates = [...criteria.keywords, ...(criteria.contentTags || [])] .map(cleanSearchKeyword) .filter(Boolean); const unique = [...new Set(candidates)]; return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0 ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM) : ['生活方式']; } function cleanSearchKeyword(keyword: string): string { return keyword .trim() .replace(/类达人|达人|账号|粉丝号|垂类/g, '') .replace(/[【】]/g, '') .trim(); } function normalizeSearchCriteria(criteria: SearchCriteria): SearchCriteria { return { ...criteria, fanRange: normalizeFanRange(criteria.fanRange), budgetRange: normalizeBudgetRange(criteria.budgetRange), platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu'], keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'], targetCount: normalizeTargetCount(criteria.targetCount), }; } function resolveTargetCandidatePool(criteria: SearchCriteria): number { const targetCount = normalizeTargetCount(criteria.targetCount); if (targetCount > 0) { return Math.min(config.recommendation.maxCandidates, Math.max(DEFAULT_MIN_CANDIDATE_POOL, targetCount * config.recommendation.multiplier)); } return Math.min(config.recommendation.maxCandidates, DEFAULT_MIN_CANDIDATE_POOL); } function normalizeTargetCount(value?: number): number | undefined { const count = Number(value || 0); return count > 0 ? Math.ceil(count) : undefined; } function normalizeFanRange(range: { min: number; max: number }): { min: number; max: number } { const min = Number(range?.min || 0); const max = Number(range?.max || 0); // LLM 容易把“1万-15万”解析成 1-15;统一换算成真实粉丝数。 if (max > 0 && max <= 1000) { return { min: Math.max(0, min * 10000), max: max * 10000 }; } return { min, max }; } function normalizeBudgetRange(range: { min: number; max: number }): { min: number; max: number } { return { min: Number(range?.min || 0), max: Number(range?.max || 0), }; } function uniquePlatforms(platforms: string[]): string[] { const normalized = platforms.map(mapPlatformName).filter(Boolean); const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin'); return [...new Set(supported.length > 0 ? supported : ['xiaohongshu'])]; } function platformsNeedSupplement(candidates: JustOneCreator[], platforms: string[], targetPerPlatform: number): boolean { return platforms.some((platform) => countCreatorsByPlatform(candidates, platform) < targetPerPlatform); } function countCreatorsByPlatform(candidates: JustOneCreator[], platform: string): number { const normalizedPlatform = mapPlatformName(platform); const uniqueIds = new Set(); for (const candidate of candidates) { if (mapPlatformName(candidate.platform) === normalizedPlatform && candidate.userId) { uniqueIds.add(candidate.userId); } } return uniqueIds.size; } 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'; }