import { config } from '../config.ts'; import { searchCreators, type JustOneCreator } from './justone.service.ts'; import { getUserNotes, 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[]; evaluationNeeds?: EvaluationNeeds; evaluation?: CandidateEvaluation; } interface SearchCriteria { platforms: string[]; keywords: string[]; fanRange: { min: number; max: number }; budgetRange: { min: number; max: number }; region?: string | string[]; regionalBudgetRules?: RegionalBudgetRule[]; gender?: string; contentTags?: string[]; excludeTags?: string[]; targetCount?: number; evaluationNeeds?: EvaluationNeeds; } interface RegionalBudgetRule { regions: string[]; min: number; max: number; } export interface EvaluationNeeds { cpmCpe?: boolean; commercialStability?: boolean; recentPerformance?: boolean; updateFrequency?: boolean; commentQuality?: boolean; publicSentiment?: boolean; audienceGender?: boolean; reasons?: string[]; } export interface CandidateEvaluation { cpmCpe?: { pictureCpm?: number; videoCpm?: number; pictureCpe?: number; videoCpe?: number; verdict: string; }; commercialStability?: { businessNoteCount?: number; coopNoteNum30d?: number; readMidCoop30?: number; interMidCoop30?: number; impressionMedian30d?: number; verdict: string; }; recentPerformance?: { sampleSize: number; medianInteraction?: number; recentMedianInteraction?: number; earlierMedianInteraction?: number; trendRatio?: number; volatility?: number; verdict: string; }; updateFrequency?: { lowActive?: boolean; sampleSize?: number; recent30DayNotes?: number; daysCovered?: number; verdict: string; }; commentQuality?: { sampleSize: number; commentMedian?: number; verdict: string; }; publicSentiment?: { riskKeywordHits: string[]; verdict: string; }; audienceGender?: { femaleRatio?: number; maleRatio?: number; source?: string; verdict: string; }; notes: 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, }; const MAX_KEYWORDS_PER_PLATFORM = 1; const PAGES_PER_PLATFORM = 3; const MAX_PAGES_PER_PLATFORM = 12; const ESTIMATED_RESULTS_PER_PAGE = 20; const DEFAULT_MIN_CANDIDATE_POOL = 30; const PUBLIC_PROFILE_ENRICH_LIMIT = 20; const EVALUATION_ENRICH_LIMIT = 30; /** * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序 */ 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))); const pageLimit = resolvePageLimit(targetPerPlatform); 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 <= pageLimit; page++) { console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}, 页=${page}`); onProgress?.('search', `搜索 ${platformName} - ${keyword} 第 ${page}/${pageLimit} 页...`); 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 = applyRegionalBudgetHardFilter( uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index)), criteria, ); 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', '计算最终推荐排序...'); await applyRequestedEvaluations(topCandidates, criteria, onProgress); 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, evaluationNeeds: criteria.evaluationNeeds, evaluation: buildProviderEvaluation(creator, criteria.evaluationNeeds), }; } function buildProviderEvaluation(creator: JustOneCreator, needs?: EvaluationNeeds): CandidateEvaluation | undefined { if (!hasAnyEvaluationNeed(needs)) return undefined; const evaluation: CandidateEvaluation = { notes: [] }; const commercial = creator.commercialMetrics; const providerSamples = [ ...(creator.contentSamples || []), ...commercialNoteSamplesToContent(commercial?.noteList || []), ]; if (needs?.cpmCpe) { evaluation.cpmCpe = evaluateCpmCpe(commercial); evaluation.notes.push(`CPM/CPE:${evaluation.cpmCpe.verdict}`); } if (needs?.commercialStability) { evaluation.commercialStability = evaluateCommercialStability(commercial); evaluation.notes.push(`商单:${evaluation.commercialStability.verdict}`); } if (needs?.recentPerformance) { evaluation.recentPerformance = evaluateRecentPerformance(providerSamples); evaluation.notes.push(`近期数据:${evaluation.recentPerformance.verdict}`); } if (needs?.updateFrequency) { evaluation.updateFrequency = evaluateUpdateFrequency(providerSamples, commercial?.lowActive); evaluation.notes.push(`更新率:${evaluation.updateFrequency.verdict}`); } if (needs?.commentQuality) { evaluation.commentQuality = evaluateCommentQuality(providerSamples); evaluation.notes.push(`评论区:${evaluation.commentQuality.verdict}`); } if (needs?.publicSentiment) { evaluation.publicSentiment = evaluatePublicSentiment(creator, providerSamples); evaluation.notes.push(`舆情:${evaluation.publicSentiment.verdict}`); } if (needs?.audienceGender) { evaluation.audienceGender = evaluateAudienceGender(creator); evaluation.notes.push(`粉丝性别:${evaluation.audienceGender.verdict}`); } return evaluation; } async function applyRequestedEvaluations( candidates: NormalizedCandidate[], criteria: SearchCriteria, onProgress?: (stage: string, detail: string) => void, ): Promise { const needs = criteria.evaluationNeeds; if (!hasAnyEvaluationNeed(needs)) return; const needsPublicSamples = Boolean(needs?.recentPerformance || needs?.updateFrequency || needs?.commentQuality || needs?.publicSentiment); if (!needsPublicSamples) return; const targets = candidates .filter((candidate) => !candidate.contentSamples || candidate.contentSamples.length < 10) .slice(0, EVALUATION_ENRICH_LIMIT); if (targets.length === 0) return; onProgress?.('processing', `按 Brief 要求补充 Top ${targets.length} 位达人近期内容样本...`); for (const candidate of targets) { const samples = await getUserNotes(candidate.platformUserId, candidate.platform, 30); if (samples.length === 0) continue; candidate.contentSamples = samples; candidate.evaluation = mergeEvaluation(candidate.evaluation, buildPublicSampleEvaluation(candidate, needs, samples)); } } function buildPublicSampleEvaluation( candidate: NormalizedCandidate, needs: EvaluationNeeds | undefined, samples: ContentSample[], ): CandidateEvaluation { const evaluation: CandidateEvaluation = { notes: [] }; if (needs?.recentPerformance) { evaluation.recentPerformance = evaluateRecentPerformance(samples); evaluation.notes.push(`近期数据:${evaluation.recentPerformance.verdict}`); } if (needs?.updateFrequency) { evaluation.updateFrequency = evaluateUpdateFrequency(samples, candidate.evaluation?.updateFrequency?.lowActive); evaluation.notes.push(`更新率:${evaluation.updateFrequency.verdict}`); } if (needs?.commentQuality) { evaluation.commentQuality = evaluateCommentQuality(samples); evaluation.notes.push(`评论区:${evaluation.commentQuality.verdict}`); } if (needs?.publicSentiment) { evaluation.publicSentiment = evaluatePublicSentiment(candidate, samples); evaluation.notes.push(`舆情:${evaluation.publicSentiment.verdict}`); } return evaluation; } function mergeEvaluation(current: CandidateEvaluation | undefined, incoming: CandidateEvaluation): CandidateEvaluation { return { notes: [...(current?.notes || []), ...(incoming.notes || [])], cpmCpe: incoming.cpmCpe || current?.cpmCpe, commercialStability: incoming.commercialStability || current?.commercialStability, recentPerformance: incoming.recentPerformance || current?.recentPerformance, updateFrequency: incoming.updateFrequency || current?.updateFrequency, commentQuality: incoming.commentQuality || current?.commentQuality, publicSentiment: incoming.publicSentiment || current?.publicSentiment, audienceGender: incoming.audienceGender || current?.audienceGender, }; } function hasAnyEvaluationNeed(needs?: EvaluationNeeds): boolean { return Boolean( needs?.cpmCpe || needs?.commercialStability || needs?.recentPerformance || needs?.updateFrequency || needs?.commentQuality || needs?.publicSentiment || needs?.audienceGender ); } function evaluateCpmCpe(metrics?: JustOneCreator['commercialMetrics']): NonNullable { const pictureCpm = positiveNumber(metrics?.estimatePictureCpm); const videoCpm = positiveNumber(metrics?.estimateVideoCpm); const pictureCpe = positiveNumber(metrics?.estimatePictureEngageCost); const videoCpe = positiveNumber(metrics?.estimateVideoEngageCost); const cpmOk = [pictureCpm, videoCpm].some((value) => value !== undefined && value <= 50); const cpeOk = [pictureCpe, videoCpe].some((value) => value !== undefined && value <= 3); const hasData = [pictureCpm, videoCpm, pictureCpe, videoCpe].some((value) => value !== undefined); return { pictureCpm, videoCpm, pictureCpe, videoCpe, verdict: !hasData ? '暂无蒲公英成本数据' : cpmOk && cpeOk ? '达标' : cpmOk || cpeOk ? '部分达标' : '未达标/需复核', }; } function evaluateCommercialStability(metrics?: JustOneCreator['commercialMetrics']): NonNullable { const businessNoteCount = positiveNumber(metrics?.businessNoteCount); const coopNoteNum30d = positiveNumber(metrics?.coopNoteNum30d); const readMidCoop30 = positiveNumber(metrics?.readMidCoop30); const interMidCoop30 = positiveNumber(metrics?.interMidCoop30); const impressionMedian30d = positiveNumber(metrics?.accumCoopImpMedinNum30d); const hasCoopData = [businessNoteCount, coopNoteNum30d, readMidCoop30, interMidCoop30, impressionMedian30d] .some((value) => value !== undefined && value > 0); const stable = Boolean((coopNoteNum30d && coopNoteNum30d > 0) && ((readMidCoop30 || 0) > 0 || (interMidCoop30 || 0) > 0)); return { businessNoteCount, coopNoteNum30d, readMidCoop30, interMidCoop30, impressionMedian30d, verdict: !hasCoopData ? '暂无商单数据' : stable ? '近30天有商单且中位数据可用' : '有历史商单,稳定性需复核', }; } function evaluateRecentPerformance(samples: ContentSample[]): NonNullable { const interactions = samples .map((sample) => sample.likeCount + sample.commentCount + sample.collectCount + sample.shareCount) .filter((value) => value > 0); const recent = interactions.slice(0, 10); const earlier = interactions.slice(10, 30); const recentMedian = median(recent); const earlierMedian = median(earlier); const overallMedian = median(interactions); const trendRatio = earlierMedian && recentMedian !== undefined ? round(recentMedian / earlierMedian, 2) : undefined; const volatility = overallMedian && overallMedian > 0 ? round((Math.max(...interactions) || 0) / overallMedian, 2) : undefined; return { sampleSize: samples.length, medianInteraction: overallMedian, recentMedianInteraction: recentMedian, earlierMedianInteraction: earlierMedian, trendRatio, volatility, verdict: samples.length === 0 ? '暂无近期样本' : trendRatio !== undefined && trendRatio < 0.6 ? '近期互动下降' : volatility !== undefined && volatility > 8 ? '互动波动偏大' : '近期数据相对稳定', }; } function evaluateUpdateFrequency( samples: ContentSample[], lowActive?: boolean, ): NonNullable { const publishTimes = samples.map((sample) => parsePublishTime(sample.publishTime)).filter((time): time is number => time !== undefined); const now = Date.now(); const recent30DayNotes = publishTimes.filter((time) => now - time <= 30 * 24 * 60 * 60 * 1000).length; const daysCovered = publishTimes.length > 1 ? Math.ceil((Math.max(...publishTimes) - Math.min(...publishTimes)) / (24 * 60 * 60 * 1000)) : undefined; return { lowActive, sampleSize: samples.length, recent30DayNotes, daysCovered, verdict: lowActive ? '平台标记低活跃' : samples.length === 0 ? '暂无更新样本' : recent30DayNotes >= 4 ? '更新频率正常' : '更新偏低/需复核', }; } function evaluateCommentQuality(samples: ContentSample[]): NonNullable { const commentCounts = samples.map((sample) => sample.commentCount).filter((value) => value > 0); const commentMedian = median(commentCounts); return { sampleSize: samples.length, commentMedian, verdict: samples.length === 0 ? '暂无评论样本' : commentMedian === undefined ? '评论量偏少,需人工看评论正文' : commentMedian >= 5 ? '评论活跃度可用,正文需抽检' : '评论互动偏弱/需复核', }; } function evaluatePublicSentiment( creator: Pick | JustOneCreator, samples: ContentSample[], ): NonNullable { const text = [ 'displayName' in creator ? creator.displayName : creator.nickname, 'contentType' in creator ? creator.contentType : creator.contentType, ...(creator.contentTags || []), ...(creator.personaTags || ('personalTags' in creator ? creator.personalTags : []) || []), ...samples.flatMap((sample) => [sample.title, sample.content]), ].join(' '); const riskWords = ['负面', '争议', '翻车', '避雷', '塌房', '黑料', '投诉', '虚假', '诈骗', '辱骂']; const hits = riskWords.filter((word) => text.includes(word)); return { riskKeywordHits: hits, verdict: hits.length > 0 ? `命中风险词:${hits.join('、')}` : '未命中明显风险词', }; } function evaluateAudienceGender(creator: JustOneCreator): NonNullable { const femaleRatio = creator.audienceMetrics?.femaleRatio; const maleRatio = creator.audienceMetrics?.maleRatio; return { femaleRatio, maleRatio, source: creator.audienceMetrics?.source, verdict: femaleRatio === undefined ? '暂无粉丝性别占比数据' : femaleRatio >= 0.6 ? '女性粉丝占比较高' : femaleRatio >= 0.5 ? '女性粉丝略高' : '女性粉丝占比不高/需复核', }; } function commercialNoteSamplesToContent(samples: NonNullable['noteList']): ContentSample[] { return (samples || []).map((sample) => ({ noteId: sample.noteId || '', title: sample.title || '', content: sample.title || '', publishTime: sample.publishTime || '', likeCount: sample.likeCount || 0, commentCount: sample.commentCount || 0, collectCount: sample.collectCount || 0, shareCount: 0, type: 'image', })); } function positiveNumber(value: unknown): number | undefined { const parsed = Number(value || 0); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } function median(values: number[]): number | undefined { const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); if (sorted.length === 0) return undefined; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? round((sorted[mid - 1] + sorted[mid]) / 2, 2) : sorted[mid]; } function round(value: number, digits = 2): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; } function parsePublishTime(value: string): number | undefined { if (!value) return undefined; const numeric = Number(value); if (Number.isFinite(numeric) && numeric > 0) { return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; } const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : undefined; } 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 buildEvaluationRiskNote(evaluation?: CandidateEvaluation): string { if (!evaluation) return ''; const risks: string[] = []; const cpmVerdict = evaluation.cpmCpe?.verdict || ''; const commercialVerdict = evaluation.commercialStability?.verdict || ''; const recentVerdict = evaluation.recentPerformance?.verdict || ''; const updateVerdict = evaluation.updateFrequency?.verdict || ''; const commentVerdict = evaluation.commentQuality?.verdict || ''; const sentimentVerdict = evaluation.publicSentiment?.verdict || ''; const genderVerdict = evaluation.audienceGender?.verdict || ''; if (cpmVerdict.includes('未达标')) risks.push(`CPM/CPE${cpmVerdict}`); if (commercialVerdict.includes('暂无') || commercialVerdict.includes('需复核')) risks.push(`商单${commercialVerdict}`); if (recentVerdict.includes('下降') || recentVerdict.includes('波动')) risks.push(recentVerdict); if (updateVerdict.includes('低') || updateVerdict.includes('低活跃')) risks.push(updateVerdict); if (commentVerdict.includes('偏弱') || commentVerdict.includes('需')) risks.push(commentVerdict); if (sentimentVerdict.startsWith('命中风险词')) risks.push(sentimentVerdict); if (genderVerdict.includes('暂无') || genderVerdict.includes('不高')) risks.push(`粉丝性别${genderVerdict}`); return risks.length > 0 ? `${risks.join(';')};` : ''; } 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 += '合作状态未知;'; } const evaluationRisk = buildEvaluationRiskNote(candidate.evaluation); if (evaluationRisk) { risk += evaluationRisk; } 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 terms: string[] = [clean]; // 常见领域子词,用于从复合关键词中提取有意义的短词 // 例如 "大象胶原蛋白系列" → 提取 "胶原蛋白","修丽可胶原蛋白精华" → 提取 "胶原蛋白"、"精华" const domainTerms = [ '家居', '家装', '探店', '生活', '精致', '美食', '出行', '旅游', '母婴', '记录', '护肤', '美妆', '测评', '种草', '好物', '情侣', '精华', '胶原蛋白', '面膜', '防晒', '美白', '抗老', '补水', '保湿', '祛痘', '敏感肌', '修护', '清洁', '彩妆', '口红', '粉底', '眼影', '穿搭', '健身', '减肥', '养生', '保健', '数码', '科技', '游戏', '娱乐', '影视', '音乐', '教育', '职场', '金融', '好物种草', '个人护理', '身体护理', '抗衰', '紧致', '淡斑', '祛皱', ]; for (const term of domainTerms) { if (clean.includes(term)) terms.push(term); } 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; const allMatchTerms = keywords.flatMap(extractMatchTerms); for (const sample of samples) { const text = `${sample.title} ${sample.content}`.toLowerCase(); const hasKeyword = allMatchTerms.some(term => text.includes(term.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)]; if (criteria.regionalBudgetRules?.length) { const baseKeyword = unique[0] || '生活方式'; const regionalKeywords = criteria.regionalBudgetRules .flatMap((rule) => rule.regions.map((region) => `${region} ${baseKeyword}`)) .map(cleanSearchKeyword) .filter(Boolean); const regionalUnique = [...new Set(regionalKeywords)]; if (regionalUnique.length > 0) return regionalUnique.slice(0, Math.max(MAX_KEYWORDS_PER_PLATFORM, 8)); } 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), regionalBudgetRules: normalizeRegionalBudgetRules(criteria.regionalBudgetRules), platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu', 'douyin'], 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 resolvePageLimit(targetPerPlatform: number): number { const pagesNeeded = Math.ceil(targetPerPlatform / ESTIMATED_RESULTS_PER_PAGE); return Math.min(MAX_PAGES_PER_PLATFORM, Math.max(PAGES_PER_PLATFORM, pagesNeeded)); } 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 normalizeRegionalBudgetRules(rules?: RegionalBudgetRule[]): RegionalBudgetRule[] | undefined { if (!Array.isArray(rules)) return undefined; const normalized = rules .map((rule) => ({ regions: Array.isArray(rule.regions) ? [...new Set(rule.regions.map(String).map((item) => item.trim()).filter(Boolean))] : [], min: Number(rule.min || 0), max: Number(rule.max || 0), })) .filter((rule) => rule.regions.length > 0 && rule.max > 0) .map((rule) => ({ ...rule, min: Math.min(rule.min, rule.max), max: Math.max(rule.min, rule.max), })); return normalized.length > 0 ? normalized : undefined; } function applyRegionalBudgetHardFilter(candidates: NormalizedCandidate[], criteria: SearchCriteria): NormalizedCandidate[] { const rules = criteria.regionalBudgetRules || []; if (rules.length === 0) return candidates; const filtered = candidates.filter((candidate) => { const rule = matchRegionalBudgetRule(candidate, rules); return rule ? hasPriceWithinRegionalBudget(candidate, rule) : false; }); console.log(`[Recommend] Regional budget hard filter: ${filtered.length}/${candidates.length} candidates kept`); return filtered; } function matchRegionalBudgetRule(candidate: NormalizedCandidate, rules: RegionalBudgetRule[]): RegionalBudgetRule | undefined { const locationText = `${candidate.city || ''} ${candidate.location || ''} ${candidate.geoLocation || ''}`; return rules.find((rule) => rule.regions.some((region) => locationText.includes(region))); } function hasPriceWithinRegionalBudget(candidate: NormalizedCandidate, rule: RegionalBudgetRule): boolean { return [candidate.imagePrice, candidate.videoPrice, candidate.minPrice] .filter((price) => price > 0) .some((price) => price >= rule.min && price <= rule.max); } 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', 'douyin'])]; } 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'; }