import { config } from '../config.ts'; import type { ContentSample } from './tikhub.service.ts'; import { archiveProviderResponse, cacheProviderCreators } from './local-creator-db.service.ts'; export interface JustOneCreator { userId: string; platform: string; // 'xiaohongshu' | 'douyin' nickname: string; redId?: string; location: string; fansCount: number; imagePrice: number; videoPrice: number; minPrice: number; cooperationStatus: string; personalTags: string[]; contentTags: string[]; sourceProvider?: string; gender?: string; likedCollectCount?: number; contentType?: string; city?: string; geoLocation?: string; xiaohongshuUrl?: string; profileUrl?: string; coreUserId?: string; secUid?: string; uniqueId?: string; cooperationMethod?: string; contentSamples?: ContentSample[]; commercialMetrics?: CommercialMetrics; audienceMetrics?: AudienceMetrics; } export interface CommercialMetrics { businessNoteCount?: number; totalNoteCount?: number; coopNoteNum30d?: number; readMidCoop30?: number; interMidCoop30?: number; accumCoopImpMedinNum30d?: number; estimatePictureCpm?: number; estimateVideoCpm?: number; estimatePictureEngageCost?: number; estimateVideoEngageCost?: number; lowActive?: boolean; fans30GrowthRate?: number; fans30GrowthNum?: number; inviteReply48hNumRatio?: number; noteList?: CommercialNoteSample[]; } export interface AudienceMetrics { femaleRatio?: number; maleRatio?: number; source?: string; } export interface CommercialNoteSample { noteId?: string; title?: string; publishTime?: string; likeCount?: number; commentCount?: number; collectCount?: number; } interface SearchParams { keyword: string; platform?: string; minFans?: number; maxFans?: number; minPrice?: number; maxPrice?: number; gender?: string; location?: string; page?: number; pageSize?: number; } /** * JustOne API 商业达人搜索服务 * 文档: https://docs.justoneapi.com/zh/usage * 用于蒲公英/星图类商业达人数据聚合搜索 */ export async function searchCreators(params: SearchParams): Promise { if (!config.justOneApi.apiKey) { console.warn('[JustOne] No API key configured, returning empty results'); return []; } try { const queryParams = new URLSearchParams(); queryParams.set('token', config.justOneApi.apiKey); queryParams.set('keyword', params.keyword); queryParams.set('page', String(params.page ?? 1)); // 根据平台选择不同的接口路径和参数格式 const endpoint = getPlatformEndpoint(params.platform); const isDouyin = params.platform === 'douyin' || params.platform === '抖音'; if (isDouyin) { queryParams.set('searchType', 'CONTENT'); if (params.minFans || params.maxFans) { const min = Math.floor((params.minFans || 0) / 10000); const max = params.maxFans ? Math.floor(params.maxFans / 10000) : 10000; const safeMax = Math.min(10000, Math.max(max, min + 1)); if (min > 0 || safeMax < 10000) { queryParams.set('followerRange', `${min}-${safeMax}`); } } // 不传 kolPriceRange,避免单价过滤导致空结果 } else { queryParams.set('searchType', 'NOTE'); if (params.minFans) queryParams.set('fansNumberLower', String(params.minFans)); if (params.maxFans) queryParams.set('fansNumberUpper', String(params.maxFans)); if (params.gender) { const genderMap: Record = { '女': 'FEMALE', '男': 'MALE', female: 'FEMALE', male: 'MALE' }; queryParams.set('gender', genderMap[params.gender] || 'ALL'); } } const requestUrl = `${config.justOneApi.baseUrl}${endpoint}?${queryParams.toString()}`; console.log('[JustOne] 请求 URL:', requestUrl.replace(config.justOneApi.apiKey, '***')); const response = await fetch(requestUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const errorText = await response.text(); console.error(`[JustOne] API error: ${response.status} - ${errorText}`); return []; } const data = await response.json(); await archiveProviderResponse({ provider: 'justone', endpoint, requestParams: Object.fromEntries(queryParams.entries()), responseBody: data, }); console.log('[JustOne] 原始响应 code:', data.code, 'message:', data.message); // code 301 = FAILED, RETRY——重试一次 if (data.code === 301) { console.log('[JustOne] 收到 301 RETRY,3秒后重试...'); await new Promise(r => setTimeout(r, 3000)); const retryResp = await fetch(requestUrl, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!retryResp.ok) { console.error(`[JustOne] 重试失败: ${retryResp.status}`); return []; } const retryData = await retryResp.json(); await archiveProviderResponse({ provider: 'justone', endpoint, requestParams: { ...Object.fromEntries(queryParams.entries()), retry: true }, responseBody: retryData, }); console.log('[JustOne] 重试响应 code:', retryData.code, 'message:', retryData.message); if (retryData.code !== 0) { console.error('[JustOne] 重试仍失败, code:', retryData.code); return []; } const creators = parseCreatorList(retryData); await cacheProviderCreators({ provider: 'justone', endpoint, requestParams: { ...Object.fromEntries(queryParams.entries()), retry: true }, creators, }); return creators; } if (data.code !== 0 && data.code !== undefined) { console.error('[JustOne] 业务错误 code:', data.code, data.message); return []; } const creators = parseCreatorList(data); await cacheProviderCreators({ provider: 'justone', endpoint, requestParams: Object.fromEntries(queryParams.entries()), creators, }); return creators; } catch (error) { console.error('[JustOne] Search failed:', error); return []; } } /** * 从 JustOne API 响应中解析创作者列表 * - 小红书蒲公英: data.data.kols,字段直接在条目上 * - 抖音星图: data.data.authors,字段全在 attribute_datas 字符串字典里 */ function parseCreatorList(data: Record): JustOneCreator[] { const d = data.data as Record | undefined; if (!d) { console.log('[JustOne] data.data 为空'); return []; } console.log('[JustOne] data.data keys:', JSON.stringify(Object.keys(d))); // 小红书蒲公英: kols 字段 if (Array.isArray(d.kols) && d.kols.length >= 0) { const list = d.kols as Record[]; console.log(`[JustOne] XHS kols 数量: ${list.length}`); if (list.length > 0) console.log('[JustOne] XHS 第一条 keys:', JSON.stringify(Object.keys(list[0]))); const creators = list.map(parseXhsCreator); console.log('[JustOne] XHS 解析完成,数量:', creators.length); if (creators.length > 0) console.log('[JustOne] XHS 第一条解析结果:', JSON.stringify(creators[0])); return creators; } // 抖音星图: authors 字段 if (Array.isArray(d.authors) && d.authors.length >= 0) { const list = d.authors as Record[]; console.log(`[JustOne] Douyin authors 数量: ${list.length}`); if (list.length > 0) console.log('[JustOne] Douyin 第一条 keys:', JSON.stringify(Object.keys(list[0]))); const creators = list.map(parseDouyinXingtuCreator); console.log('[JustOne] Douyin 解析完成,数量:', creators.length); if (creators.length > 0) console.log('[JustOne] Douyin 第一条解析结果:', JSON.stringify(creators[0])); return creators; } // 通用回退(按小红书处理) const rawList = d.bloggerList || d.list || d.items || d.result || []; const listArr = Array.isArray(rawList) ? rawList as Record[] : []; console.log(`[JustOne] 通用字段列表数量: ${listArr.length}`); return listArr.map(parseXhsCreator); } /** 解析小红书蒲公英创作者(字段直接在条目上) */ function parseXhsCreator(item: Record): JustOneCreator { // contentTags 是对象数组 [{taxonomy1Tag: '护肤', taxonomy2Tags: [...]}],提取一级标签 const rawContentTags = item.contentTags; const contentTags: string[] = Array.isArray(rawContentTags) ? rawContentTags .map((t: unknown) => { if (typeof t === 'string') return t; if (typeof t === 'object' && t !== null) { const obj = t as Record; return typeof obj.taxonomy1Tag === 'string' ? obj.taxonomy1Tag : null; } return null; }) .filter((s): s is string => s !== null) : []; // featureTags 是字符串数组,直接作为 personalTags const featureTags = toStringArray(item.featureTags || item.personalTags || item.personal_tags || []); // fansCount:fansCount 始终为 0,实际应用 fansNum const fansCount = Number(item.fansNum || item.fansCount || item.fans_count || item.followerCount || 0); // 价格:图文 picturePrice,视频 videoPrice,最低 lowerPrice const imagePrice = Number(item.picturePrice || item.imagePrice || 0); const videoPrice = Number(item.videoPrice || item.video_price || 0); const minPrice = Number(item.lowerPrice || item.picturePrice || item.imagePrice || 0); // 合作状态:cooperateState=1 表示可合作 const cooperateState = Number(item.cooperateState || 0); const cooperationStatus = cooperateState === 1 ? 'active' : String(item.cooperationStatus || ''); const genderRaw = String(item.gender || ''); const genderMap: Record = { '女': 'female', '男': 'male', 'FEMALE': 'female', 'MALE': 'male' }; return { userId: String(item.userId || item.user_id || ''), platform: 'xiaohongshu', nickname: String(item.name || item.nickname || item.nickName || ''), redId: String(item.redId || item.red_id || ''), location: String(item.location || item.city || ''), fansCount, imagePrice, videoPrice, minPrice, cooperationStatus, personalTags: featureTags, contentTags, gender: genderMap[genderRaw] || genderRaw, likedCollectCount: Number(item.likedCollectCount || item.likeCollectCount || item.interactionCount || 0), contentType: contentTags.join('、'), city: String(item.city || item.location || ''), geoLocation: String(item.location || item.city || ''), xiaohongshuUrl: item.userId ? `https://www.xiaohongshu.com/user/profile/${String(item.userId || item.user_id)}` : '', cooperationMethod: '', commercialMetrics: { businessNoteCount: toNumber(item.businessNoteCount), totalNoteCount: toNumber(item.totalNoteCount), coopNoteNum30d: toNumber(item.coopNoteNum30d), readMidCoop30: toNumber(item.readMidCoop30), interMidCoop30: toNumber(item.interMidCoop30), accumCoopImpMedinNum30d: toNumber(item.accumCoopImpMedinNum30d), estimatePictureCpm: toNumber(item.estimatePictureCpm), estimateVideoCpm: toNumber(item.estimateVideoCpm), estimatePictureEngageCost: toNumber(item.estimatePictureEngageCost), estimateVideoEngageCost: toNumber(item.estimateVideoEngageCost), lowActive: item.lowActive === true || String(item.lowActive).toLowerCase() === 'true', fans30GrowthRate: toNumber(item.fans30GrowthRate), fans30GrowthNum: toNumber(item.fans30GrowthNum), inviteReply48hNumRatio: toNumber(item.inviteReply48hNumRatio), noteList: parseXhsNoteList(item.noteList), }, audienceMetrics: parseAudienceMetrics(item), }; } /** 解析抖音星图创作者(数据全在 attribute_datas 字符串字典里) */ function parseDouyinXingtuCreator(item: Record): JustOneCreator { const attr = (item.attribute_datas || {}) as Record; const fansCount = Number(attr.follower || attr.fans_count || 0); const prices = parseDouyinTaskPrices(item.task_infos); const genderCode = attr.gender || '0'; const genderMap: Record = { '1': 'male', '2': 'female', '0': '' }; // content_theme_labels_180d 是 JSON 字符串数组(实际响应中常为空 "[]") // 若为空则退而从 last_10_items 视频标题中提取 hashtag 作为 contentTags let contentTags: string[] = []; try { const raw = attr.content_theme_labels_180d || '[]'; const parsed = JSON.parse(raw) as unknown[]; if (Array.isArray(parsed) && parsed.length > 0) { contentTags = parsed.map(String); } } catch { /* ignore */ } if (contentTags.length === 0) { try { const items = JSON.parse(attr.last_10_items || '[]') as Record[]; const tags: string[] = []; for (const it of items.slice(0, 5)) { const title = String(it.item_title || ''); for (const word of title.split(/\s+/)) { if (word.startsWith('#')) tags.push(word.slice(1)); } } // 去重,最多保留 10 个 contentTags = [...new Set(tags)].slice(0, 10); } catch { /* ignore */ } } contentTags = [...new Set([...contentTags, ...parseDouyinTagsRelation(attr.tags_relation)])].slice(0, 12); // author_thin_mid_word_association_index 是 JSON 对象 {词: 权重},取 key 作为标签 let personalTags: string[] = []; try { const raw = attr.author_thin_mid_word_association_index || '{}'; const parsed = JSON.parse(raw) as Record; personalTags = Object.keys(parsed); } catch { /* ignore */ } return { userId: String(item.star_id || attr.id || attr.core_user_id || ''), platform: 'douyin', nickname: String(attr.nick_name || attr.nickname || attr.name || ''), location: String(attr.city || attr.province || ''), fansCount, imagePrice: prices.imagePrice, videoPrice: prices.videoPrice, minPrice: prices.minPrice, cooperationStatus: attr.author_status === '1' ? 'active' : '', personalTags, contentTags, gender: genderMap[genderCode] || '', likedCollectCount: Number(attr.total_favorited || attr.total_favorite || 0), contentType: contentTags.join('、'), city: String(attr.city || ''), geoLocation: String(attr.province || attr.city || ''), coreUserId: String(attr.core_user_id || ''), cooperationMethod: prices.videoPrice > 0 ? '报备视频' : '', contentSamples: parseDouyinRecentItems(attr.last_10_items), commercialMetrics: { totalNoteCount: Number(attr.item_count || attr.aweme_count || 0), fans30GrowthRate: Number(attr.fans_growth_rate_30d || attr.follower_growth_rate_30d || 0), fans30GrowthNum: Number(attr.fans_growth_num_30d || attr.follower_growth_30d || 0), }, }; } function parseDouyinTaskPrices(rawTaskInfos: unknown): { imagePrice: number; videoPrice: number; minPrice: number } { const priceInfos: number[] = []; if (Array.isArray(rawTaskInfos)) { for (const task of rawTaskInfos) { if (typeof task !== 'object' || task === null) continue; const infos = (task as Record).price_infos; if (!Array.isArray(infos)) continue; for (const info of infos) { if (typeof info !== 'object' || info === null) continue; const price = Number((info as Record).price || 0); const videoTypeStatus = Number((info as Record).video_type_status ?? 1); if (price > 0 && videoTypeStatus === 1) priceInfos.push(price); } } } const sortedPrices = [...new Set(priceInfos)].sort((a, b) => a - b); const minPrice = sortedPrices[0] || 0; const videoPrice = sortedPrices.find((price) => price >= 1000) || minPrice; return { imagePrice: 0, videoPrice, minPrice, }; } function parseDouyinRecentItems(rawItems?: string): ContentSample[] { try { const items = JSON.parse(rawItems || '[]') as Record[]; if (!Array.isArray(items)) return []; return items.slice(0, 10).map((item) => ({ noteId: String(item.item_id || ''), title: String(item.item_title || ''), content: String(item.item_title || ''), publishTime: Number(item.item_publish_time || item.item_create_time || 0) > 0 ? new Date(Number(item.item_publish_time || item.item_create_time) * 1000).toISOString() : '', likeCount: Number(item.like_cnt || 0), commentCount: Number(item.comment_cnt || 0), collectCount: 0, shareCount: Number(item.share_cnt || 0), type: 'video', })); } catch { return []; } } function parseDouyinTagsRelation(raw?: string): string[] { try { const parsed = JSON.parse(raw || '{}') as Record; const tags: string[] = []; for (const [category, children] of Object.entries(parsed)) { tags.push(category); if (Array.isArray(children)) tags.push(...children.map(String)); } return tags.filter(Boolean); } catch { return []; } } function parseXhsNoteList(raw: unknown): CommercialNoteSample[] { if (!Array.isArray(raw)) return []; return raw.slice(0, 30) .filter((item): item is Record => typeof item === 'object' && item !== null) .map((item) => ({ noteId: String(item.noteId || item.note_id || item.id || ''), title: String(item.title || item.displayTitle || item.display_title || item.name || ''), publishTime: String(item.publishTime || item.publish_time || item.time || item.createTime || ''), likeCount: toNumber(item.likeCount || item.likedCount || item.liked_count || item.likeNum), commentCount: toNumber(item.commentCount || item.comment_count || item.commentNum), collectCount: toNumber(item.collectCount || item.collectedCount || item.collect_count || item.collectNum), })); } function parseAudienceMetrics(item: Record): AudienceMetrics | undefined { const femaleRatio = firstNumber( item.femaleRatio, item.female_ratio, item.fansFemaleRatio, item.fans_female_ratio, item.femaleFansRatio, ); const maleRatio = firstNumber( item.maleRatio, item.male_ratio, item.fansMaleRatio, item.fans_male_ratio, item.maleFansRatio, ); if (femaleRatio === undefined && maleRatio === undefined) return undefined; return { femaleRatio: normalizeRatio(femaleRatio), maleRatio: normalizeRatio(maleRatio), source: 'justone-search', }; } function firstNumber(...values: unknown[]): number | undefined { for (const value of values) { const parsed = toNumber(value); if (parsed > 0) return parsed; } return undefined; } function normalizeRatio(value: number | undefined): number | undefined { if (value === undefined) return undefined; return value > 1 ? Number((value / 100).toFixed(4)) : value; } function toNumber(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; const text = String(value ?? '').replace(/[%¥¥,]/g, '').trim(); if (!text || text === '-' || text === 'null' || text === 'undefined') return 0; return Number(text) || 0; } function toStringArray(val: unknown): string[] { if (!Array.isArray(val)) return []; return val .map(v => { if (typeof v === 'string') return v; if (typeof v !== 'object' || v === null) return null; const obj = v as Record; const text = obj.name || obj.tagName || obj.label || obj.value || obj.text || obj.title; if (text && typeof text === 'string') return text; for (const k of Object.keys(obj)) { if (typeof obj[k] === 'string' && (obj[k] as string).length > 0) return obj[k] as string; } return null; }) .filter((s): s is string => s !== null && s.length > 0); } /** * 获取创作者详情(粉丝画像、互动数据等) */ export async function getCreatorProfile(userId: string, platform?: string): Promise | null> { if (!config.justOneApi.apiKey) { return null; } try { const queryParams = new URLSearchParams(); queryParams.set('token', config.justOneApi.apiKey); queryParams.set('userId', userId); const profileEndpoint = getProfileEndpoint(platform); const response = await fetch( `${config.justOneApi.baseUrl}${profileEndpoint}?${queryParams.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, } ); if (!response.ok) { return null; } const data = await response.json(); return data.data || data; } catch (error) { console.error('[JustOne] Profile fetch failed:', error); return null; } } /** * 根据平台返回对应的搜索接口路径 * 文档: https://docs.justoneapi.com/zh/usage */ function getPlatformEndpoint(platform?: string): string { switch (platform) { case 'xiaohongshu': case '小红书': return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/v2/v1'; case 'douyin': case '抖音': return '/api/douyin-xingtu/gw/api/gsearch/search_for_author_square/v1'; default: return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/v2/v1'; } } function getProfileEndpoint(platform?: string): string { switch (platform) { case 'xiaohongshu': case '小红书': return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/profile/v1'; case 'douyin': case '抖音': return '/api/douyin-xingtu/gw/api/gsearch/search_for_author_square/v1'; default: return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/profile/v1'; } }