| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- 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<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)));
- 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<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', '计算最终推荐排序...');
- 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<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 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<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';
- }
|