| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949 |
- 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[];
- gender?: string;
- contentTags?: string[];
- excludeTags?: string[];
- targetCount?: number;
- evaluationNeeds?: EvaluationNeeds;
- }
- 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<NormalizedCandidate[]> {
- 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<string, JustOneCreator>();
- 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', '计算最终推荐排序...');
- 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<void> {
- 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<CandidateEvaluation['cpmCpe']> {
- 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<CandidateEvaluation['commercialStability']> {
- 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<CandidateEvaluation['recentPerformance']> {
- 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<CandidateEvaluation['updateFrequency']> {
- 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<CandidateEvaluation['commentQuality']> {
- 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<NormalizedCandidate, 'displayName' | 'contentTags' | 'personaTags' | 'contentType'> | JustOneCreator,
- samples: ContentSample[],
- ): NonNullable<CandidateEvaluation['publicSentiment']> {
- 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<CandidateEvaluation['audienceGender']> {
- 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<JustOneCreator['commercialMetrics']>['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<void> {
- 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<ReturnType<typeof searchDouyinUsers>>,
- ) {
- 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 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 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 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<string>();
- 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<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';
- }
|