justone.service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { config } from '../config.ts';
  2. import type { ContentSample } from './tikhub.service.ts';
  3. import { archiveProviderResponse, cacheProviderCreators } from './local-creator-db.service.ts';
  4. export interface JustOneCreator {
  5. userId: string;
  6. platform: string; // 'xiaohongshu' | 'douyin'
  7. nickname: string;
  8. redId?: string;
  9. location: string;
  10. fansCount: number;
  11. imagePrice: number;
  12. videoPrice: number;
  13. minPrice: number;
  14. cooperationStatus: string;
  15. personalTags: string[];
  16. contentTags: string[];
  17. sourceProvider?: string;
  18. gender?: string;
  19. likedCollectCount?: number;
  20. contentType?: string;
  21. city?: string;
  22. geoLocation?: string;
  23. xiaohongshuUrl?: string;
  24. cooperationMethod?: string;
  25. contentSamples?: ContentSample[];
  26. }
  27. interface SearchParams {
  28. keyword: string;
  29. platform?: string;
  30. minFans?: number;
  31. maxFans?: number;
  32. minPrice?: number;
  33. maxPrice?: number;
  34. gender?: string;
  35. location?: string;
  36. page?: number;
  37. pageSize?: number;
  38. }
  39. /**
  40. * JustOne API 商业达人搜索服务
  41. * 文档: https://docs.justoneapi.com/zh/usage
  42. * 用于蒲公英/星图类商业达人数据聚合搜索
  43. */
  44. export async function searchCreators(params: SearchParams): Promise<JustOneCreator[]> {
  45. if (!config.justOneApi.apiKey) {
  46. console.warn('[JustOne] No API key configured, returning empty results');
  47. return [];
  48. }
  49. try {
  50. const queryParams = new URLSearchParams();
  51. queryParams.set('token', config.justOneApi.apiKey);
  52. queryParams.set('keyword', params.keyword);
  53. queryParams.set('page', String(params.page ?? 1));
  54. // 根据平台选择不同的接口路径和参数格式
  55. const endpoint = getPlatformEndpoint(params.platform);
  56. const isDouyin = params.platform === 'douyin' || params.platform === '抖音';
  57. if (isDouyin) {
  58. queryParams.set('searchType', 'CONTENT');
  59. if (params.minFans || params.maxFans) {
  60. const min = Math.floor((params.minFans || 0) / 10000);
  61. const max = Math.floor((params.maxFans || 99999) / 10000);
  62. if (min > 0 || max > 0) {
  63. queryParams.set('followerRange', `${min}-${max}`);
  64. }
  65. }
  66. // 不传 kolPriceRange,避免单价过滤导致空结果
  67. } else {
  68. queryParams.set('searchType', 'NOTE');
  69. if (params.minFans) queryParams.set('fansNumberLower', String(params.minFans));
  70. if (params.maxFans) queryParams.set('fansNumberUpper', String(params.maxFans));
  71. if (params.gender) {
  72. const genderMap: Record<string, string> = { '女': 'FEMALE', '男': 'MALE', female: 'FEMALE', male: 'MALE' };
  73. queryParams.set('gender', genderMap[params.gender] || 'ALL');
  74. }
  75. }
  76. const requestUrl = `${config.justOneApi.baseUrl}${endpoint}?${queryParams.toString()}`;
  77. console.log('[JustOne] 请求 URL:', requestUrl.replace(config.justOneApi.apiKey, '***'));
  78. const response = await fetch(requestUrl, {
  79. method: 'GET',
  80. headers: {
  81. 'Content-Type': 'application/json',
  82. },
  83. });
  84. if (!response.ok) {
  85. const errorText = await response.text();
  86. console.error(`[JustOne] API error: ${response.status} - ${errorText}`);
  87. return [];
  88. }
  89. const data = await response.json();
  90. await archiveProviderResponse({
  91. provider: 'justone',
  92. endpoint,
  93. requestParams: Object.fromEntries(queryParams.entries()),
  94. responseBody: data,
  95. });
  96. console.log('[JustOne] 原始响应 code:', data.code, 'message:', data.message);
  97. // code 301 = FAILED, RETRY——重试一次
  98. if (data.code === 301) {
  99. console.log('[JustOne] 收到 301 RETRY,3秒后重试...');
  100. await new Promise(r => setTimeout(r, 3000));
  101. const retryResp = await fetch(requestUrl, { method: 'GET', headers: { 'Content-Type': 'application/json' } });
  102. if (!retryResp.ok) {
  103. console.error(`[JustOne] 重试失败: ${retryResp.status}`);
  104. return [];
  105. }
  106. const retryData = await retryResp.json();
  107. await archiveProviderResponse({
  108. provider: 'justone',
  109. endpoint,
  110. requestParams: { ...Object.fromEntries(queryParams.entries()), retry: true },
  111. responseBody: retryData,
  112. });
  113. console.log('[JustOne] 重试响应 code:', retryData.code, 'message:', retryData.message);
  114. if (retryData.code !== 0) {
  115. console.error('[JustOne] 重试仍失败, code:', retryData.code);
  116. return [];
  117. }
  118. const creators = parseCreatorList(retryData);
  119. await cacheProviderCreators({
  120. provider: 'justone',
  121. endpoint,
  122. requestParams: { ...Object.fromEntries(queryParams.entries()), retry: true },
  123. creators,
  124. });
  125. return creators;
  126. }
  127. if (data.code !== 0 && data.code !== undefined) {
  128. console.error('[JustOne] 业务错误 code:', data.code, data.message);
  129. return [];
  130. }
  131. const creators = parseCreatorList(data);
  132. await cacheProviderCreators({
  133. provider: 'justone',
  134. endpoint,
  135. requestParams: Object.fromEntries(queryParams.entries()),
  136. creators,
  137. });
  138. return creators;
  139. } catch (error) {
  140. console.error('[JustOne] Search failed:', error);
  141. return [];
  142. }
  143. }
  144. /**
  145. * 从 JustOne API 响应中解析创作者列表
  146. * - 小红书蒲公英: data.data.kols,字段直接在条目上
  147. * - 抖音星图: data.data.authors,字段全在 attribute_datas 字符串字典里
  148. */
  149. function parseCreatorList(data: Record<string, unknown>): JustOneCreator[] {
  150. const d = data.data as Record<string, unknown> | undefined;
  151. if (!d) {
  152. console.log('[JustOne] data.data 为空');
  153. return [];
  154. }
  155. console.log('[JustOne] data.data keys:', JSON.stringify(Object.keys(d)));
  156. // 小红书蒲公英: kols 字段
  157. if (Array.isArray(d.kols) && d.kols.length >= 0) {
  158. const list = d.kols as Record<string, unknown>[];
  159. console.log(`[JustOne] XHS kols 数量: ${list.length}`);
  160. if (list.length > 0) console.log('[JustOne] XHS 第一条 keys:', JSON.stringify(Object.keys(list[0])));
  161. const creators = list.map(parseXhsCreator);
  162. console.log('[JustOne] XHS 解析完成,数量:', creators.length);
  163. if (creators.length > 0) console.log('[JustOne] XHS 第一条解析结果:', JSON.stringify(creators[0]));
  164. return creators;
  165. }
  166. // 抖音星图: authors 字段
  167. if (Array.isArray(d.authors) && d.authors.length >= 0) {
  168. const list = d.authors as Record<string, unknown>[];
  169. console.log(`[JustOne] Douyin authors 数量: ${list.length}`);
  170. if (list.length > 0) console.log('[JustOne] Douyin 第一条 keys:', JSON.stringify(Object.keys(list[0])));
  171. const creators = list.map(parseDouyinXingtuCreator);
  172. console.log('[JustOne] Douyin 解析完成,数量:', creators.length);
  173. if (creators.length > 0) console.log('[JustOne] Douyin 第一条解析结果:', JSON.stringify(creators[0]));
  174. return creators;
  175. }
  176. // 通用回退(按小红书处理)
  177. const rawList = d.bloggerList || d.list || d.items || d.result || [];
  178. const listArr = Array.isArray(rawList) ? rawList as Record<string, unknown>[] : [];
  179. console.log(`[JustOne] 通用字段列表数量: ${listArr.length}`);
  180. return listArr.map(parseXhsCreator);
  181. }
  182. /** 解析小红书蒲公英创作者(字段直接在条目上) */
  183. function parseXhsCreator(item: Record<string, unknown>): JustOneCreator {
  184. // contentTags 是对象数组 [{taxonomy1Tag: '护肤', taxonomy2Tags: [...]}],提取一级标签
  185. const rawContentTags = item.contentTags;
  186. const contentTags: string[] = Array.isArray(rawContentTags)
  187. ? rawContentTags
  188. .map((t: unknown) => {
  189. if (typeof t === 'string') return t;
  190. if (typeof t === 'object' && t !== null) {
  191. const obj = t as Record<string, unknown>;
  192. return typeof obj.taxonomy1Tag === 'string' ? obj.taxonomy1Tag : null;
  193. }
  194. return null;
  195. })
  196. .filter((s): s is string => s !== null)
  197. : [];
  198. // featureTags 是字符串数组,直接作为 personalTags
  199. const featureTags = toStringArray(item.featureTags || item.personalTags || item.personal_tags || []);
  200. // fansCount:fansCount 始终为 0,实际应用 fansNum
  201. const fansCount = Number(item.fansNum || item.fansCount || item.fans_count || item.followerCount || 0);
  202. // 价格:图文 picturePrice,视频 videoPrice,最低 lowerPrice
  203. const imagePrice = Number(item.picturePrice || item.imagePrice || 0);
  204. const videoPrice = Number(item.videoPrice || item.video_price || 0);
  205. const minPrice = Number(item.lowerPrice || item.picturePrice || item.imagePrice || 0);
  206. // 合作状态:cooperateState=1 表示可合作
  207. const cooperateState = Number(item.cooperateState || 0);
  208. const cooperationStatus = cooperateState === 1 ? 'active' : String(item.cooperationStatus || '');
  209. const genderRaw = String(item.gender || '');
  210. const genderMap: Record<string, string> = { '女': 'female', '男': 'male', 'FEMALE': 'female', 'MALE': 'male' };
  211. return {
  212. userId: String(item.userId || item.user_id || ''),
  213. platform: 'xiaohongshu',
  214. nickname: String(item.name || item.nickname || item.nickName || ''),
  215. redId: String(item.redId || item.red_id || ''),
  216. location: String(item.location || item.city || ''),
  217. fansCount,
  218. imagePrice,
  219. videoPrice,
  220. minPrice,
  221. cooperationStatus,
  222. personalTags: featureTags,
  223. contentTags,
  224. gender: genderMap[genderRaw] || genderRaw,
  225. likedCollectCount: Number(item.likedCollectCount || item.likeCollectCount || item.interactionCount || 0),
  226. contentType: contentTags.join('、'),
  227. city: String(item.city || item.location || ''),
  228. geoLocation: String(item.location || item.city || ''),
  229. xiaohongshuUrl: item.userId ? `https://www.xiaohongshu.com/user/profile/${String(item.userId || item.user_id)}` : '',
  230. cooperationMethod: '',
  231. };
  232. }
  233. /** 解析抖音星图创作者(数据全在 attribute_datas 字符串字典里) */
  234. function parseDouyinXingtuCreator(item: Record<string, unknown>): JustOneCreator {
  235. const attr = (item.attribute_datas || {}) as Record<string, string>;
  236. const fansCount = Number(attr.follower || attr.fans_count || 0);
  237. const prices = parseDouyinTaskPrices(item.task_infos);
  238. const genderCode = attr.gender || '0';
  239. const genderMap: Record<string, string> = { '1': 'male', '2': 'female', '0': '' };
  240. // content_theme_labels_180d 是 JSON 字符串数组(实际响应中常为空 "[]")
  241. // 若为空则退而从 last_10_items 视频标题中提取 hashtag 作为 contentTags
  242. let contentTags: string[] = [];
  243. try {
  244. const raw = attr.content_theme_labels_180d || '[]';
  245. const parsed = JSON.parse(raw) as unknown[];
  246. if (Array.isArray(parsed) && parsed.length > 0) {
  247. contentTags = parsed.map(String);
  248. }
  249. } catch { /* ignore */ }
  250. if (contentTags.length === 0) {
  251. try {
  252. const items = JSON.parse(attr.last_10_items || '[]') as Record<string, unknown>[];
  253. const tags: string[] = [];
  254. for (const it of items.slice(0, 5)) {
  255. const title = String(it.item_title || '');
  256. for (const word of title.split(/\s+/)) {
  257. if (word.startsWith('#')) tags.push(word.slice(1));
  258. }
  259. }
  260. // 去重,最多保留 10 个
  261. contentTags = [...new Set(tags)].slice(0, 10);
  262. } catch { /* ignore */ }
  263. }
  264. contentTags = [...new Set([...contentTags, ...parseDouyinTagsRelation(attr.tags_relation)])].slice(0, 12);
  265. // author_thin_mid_word_association_index 是 JSON 对象 {词: 权重},取 key 作为标签
  266. let personalTags: string[] = [];
  267. try {
  268. const raw = attr.author_thin_mid_word_association_index || '{}';
  269. const parsed = JSON.parse(raw) as Record<string, unknown>;
  270. personalTags = Object.keys(parsed);
  271. } catch { /* ignore */ }
  272. return {
  273. userId: String(item.star_id || attr.id || attr.core_user_id || ''),
  274. platform: 'douyin',
  275. nickname: String(attr.nick_name || attr.nickname || attr.name || ''),
  276. location: String(attr.city || attr.province || ''),
  277. fansCount,
  278. imagePrice: prices.imagePrice,
  279. videoPrice: prices.videoPrice,
  280. minPrice: prices.minPrice,
  281. cooperationStatus: attr.author_status === '1' ? 'active' : '',
  282. personalTags,
  283. contentTags,
  284. gender: genderMap[genderCode] || '',
  285. likedCollectCount: Number(attr.total_favorited || attr.total_favorite || 0),
  286. contentType: contentTags.join('、'),
  287. city: String(attr.city || ''),
  288. geoLocation: String(attr.province || attr.city || ''),
  289. cooperationMethod: prices.videoPrice > 0 ? '报备视频' : '',
  290. contentSamples: parseDouyinRecentItems(attr.last_10_items),
  291. };
  292. }
  293. function parseDouyinTaskPrices(rawTaskInfos: unknown): { imagePrice: number; videoPrice: number; minPrice: number } {
  294. const priceInfos: number[] = [];
  295. if (Array.isArray(rawTaskInfos)) {
  296. for (const task of rawTaskInfos) {
  297. if (typeof task !== 'object' || task === null) continue;
  298. const infos = (task as Record<string, unknown>).price_infos;
  299. if (!Array.isArray(infos)) continue;
  300. for (const info of infos) {
  301. if (typeof info !== 'object' || info === null) continue;
  302. const price = Number((info as Record<string, unknown>).price || 0);
  303. const videoTypeStatus = Number((info as Record<string, unknown>).video_type_status ?? 1);
  304. if (price > 0 && videoTypeStatus === 1) priceInfos.push(price);
  305. }
  306. }
  307. }
  308. const sortedPrices = [...new Set(priceInfos)].sort((a, b) => a - b);
  309. const minPrice = sortedPrices[0] || 0;
  310. const videoPrice = sortedPrices.find((price) => price >= 1000) || minPrice;
  311. return {
  312. imagePrice: 0,
  313. videoPrice,
  314. minPrice,
  315. };
  316. }
  317. function parseDouyinRecentItems(rawItems?: string): ContentSample[] {
  318. try {
  319. const items = JSON.parse(rawItems || '[]') as Record<string, unknown>[];
  320. if (!Array.isArray(items)) return [];
  321. return items.slice(0, 10).map((item) => ({
  322. noteId: String(item.item_id || ''),
  323. title: String(item.item_title || ''),
  324. content: String(item.item_title || ''),
  325. publishTime: Number(item.item_publish_time || item.item_create_time || 0) > 0
  326. ? new Date(Number(item.item_publish_time || item.item_create_time) * 1000).toISOString()
  327. : '',
  328. likeCount: Number(item.like_cnt || 0),
  329. commentCount: Number(item.comment_cnt || 0),
  330. collectCount: 0,
  331. shareCount: Number(item.share_cnt || 0),
  332. type: 'video',
  333. }));
  334. } catch {
  335. return [];
  336. }
  337. }
  338. function parseDouyinTagsRelation(raw?: string): string[] {
  339. try {
  340. const parsed = JSON.parse(raw || '{}') as Record<string, unknown>;
  341. const tags: string[] = [];
  342. for (const [category, children] of Object.entries(parsed)) {
  343. tags.push(category);
  344. if (Array.isArray(children)) tags.push(...children.map(String));
  345. }
  346. return tags.filter(Boolean);
  347. } catch {
  348. return [];
  349. }
  350. }
  351. function toStringArray(val: unknown): string[] {
  352. if (!Array.isArray(val)) return [];
  353. return val
  354. .map(v => {
  355. if (typeof v === 'string') return v;
  356. if (typeof v !== 'object' || v === null) return null;
  357. const obj = v as Record<string, unknown>;
  358. const text = obj.name || obj.tagName || obj.label || obj.value || obj.text || obj.title;
  359. if (text && typeof text === 'string') return text;
  360. for (const k of Object.keys(obj)) {
  361. if (typeof obj[k] === 'string' && (obj[k] as string).length > 0) return obj[k] as string;
  362. }
  363. return null;
  364. })
  365. .filter((s): s is string => s !== null && s.length > 0);
  366. }
  367. /**
  368. * 获取创作者详情(粉丝画像、互动数据等)
  369. */
  370. export async function getCreatorProfile(userId: string, platform?: string): Promise<Record<string, unknown> | null> {
  371. if (!config.justOneApi.apiKey) {
  372. return null;
  373. }
  374. try {
  375. const queryParams = new URLSearchParams();
  376. queryParams.set('token', config.justOneApi.apiKey);
  377. queryParams.set('userId', userId);
  378. const profileEndpoint = getProfileEndpoint(platform);
  379. const response = await fetch(
  380. `${config.justOneApi.baseUrl}${profileEndpoint}?${queryParams.toString()}`,
  381. {
  382. method: 'GET',
  383. headers: {
  384. 'Content-Type': 'application/json',
  385. },
  386. }
  387. );
  388. if (!response.ok) {
  389. return null;
  390. }
  391. const data = await response.json();
  392. return data.data || data;
  393. } catch (error) {
  394. console.error('[JustOne] Profile fetch failed:', error);
  395. return null;
  396. }
  397. }
  398. /**
  399. * 根据平台返回对应的搜索接口路径
  400. * 文档: https://docs.justoneapi.com/zh/usage
  401. */
  402. function getPlatformEndpoint(platform?: string): string {
  403. switch (platform) {
  404. case 'xiaohongshu':
  405. case '小红书':
  406. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/v2/v1';
  407. case 'douyin':
  408. case '抖音':
  409. return '/api/douyin-xingtu/gw/api/gsearch/search_for_author_square/v1';
  410. default:
  411. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/v2/v1';
  412. }
  413. }
  414. function getProfileEndpoint(platform?: string): string {
  415. switch (platform) {
  416. case 'xiaohongshu':
  417. case '小红书':
  418. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/profile/v1';
  419. case 'douyin':
  420. case '抖音':
  421. return '/api/douyin-xingtu/gw/api/gsearch/search_for_author_square/v1';
  422. default:
  423. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/profile/v1';
  424. }
  425. }