justone.service.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import { config } from '../config.ts';
  2. export interface JustOneCreator {
  3. userId: string;
  4. platform: string; // 'xiaohongshu' | 'douyin'
  5. nickname: string;
  6. redId?: string;
  7. location: string;
  8. fansCount: number;
  9. imagePrice: number;
  10. videoPrice: number;
  11. minPrice: number;
  12. cooperationStatus: string;
  13. personalTags: string[];
  14. contentTags: string[];
  15. gender?: string;
  16. }
  17. interface SearchParams {
  18. keyword: string;
  19. platform?: string;
  20. minFans?: number;
  21. maxFans?: number;
  22. minPrice?: number;
  23. maxPrice?: number;
  24. gender?: string;
  25. location?: string;
  26. page?: number;
  27. pageSize?: number;
  28. }
  29. /**
  30. * JustOne API 商业达人搜索服务
  31. * 文档: https://docs.justoneapi.com/zh/usage
  32. * 用于蒲公英/星图类商业达人数据聚合搜索
  33. */
  34. export async function searchCreators(params: SearchParams): Promise<JustOneCreator[]> {
  35. if (!config.justOneApi.apiKey) {
  36. console.warn('[JustOne] No API key configured, returning empty results');
  37. return [];
  38. }
  39. try {
  40. const queryParams = new URLSearchParams();
  41. queryParams.set('token', config.justOneApi.apiKey);
  42. queryParams.set('keyword', params.keyword);
  43. queryParams.set('page', String(params.page ?? 1));
  44. // 根据平台选择不同的接口路径和参数格式
  45. const endpoint = getPlatformEndpoint(params.platform);
  46. const isDouyin = params.platform === 'douyin' || params.platform === '抖音';
  47. if (isDouyin) {
  48. queryParams.set('searchType', 'CONTENT');
  49. if (params.minFans || params.maxFans) {
  50. const min = Math.floor((params.minFans || 0) / 10000);
  51. const max = Math.floor((params.maxFans || 99999) / 10000);
  52. if (min > 0 || max > 0) {
  53. queryParams.set('followerRange', `${min}-${max}`);
  54. }
  55. }
  56. // 不传 kolPriceRange,避免单价过滤导致空结果
  57. } else {
  58. queryParams.set('searchType', 'NOTE');
  59. if (params.minFans) queryParams.set('fansNumberLower', String(params.minFans));
  60. if (params.maxFans) queryParams.set('fansNumberUpper', String(params.maxFans));
  61. if (params.gender) {
  62. const genderMap: Record<string, string> = { '女': 'FEMALE', '男': 'MALE', female: 'FEMALE', male: 'MALE' };
  63. queryParams.set('gender', genderMap[params.gender] || 'ALL');
  64. }
  65. }
  66. const requestUrl = `${config.justOneApi.baseUrl}${endpoint}?${queryParams.toString()}`;
  67. console.log('[JustOne] 请求 URL:', requestUrl.replace(config.justOneApi.apiKey, '***'));
  68. const response = await fetch(requestUrl, {
  69. method: 'GET',
  70. headers: {
  71. 'Content-Type': 'application/json',
  72. },
  73. });
  74. if (!response.ok) {
  75. const errorText = await response.text();
  76. console.error(`[JustOne] API error: ${response.status} - ${errorText}`);
  77. return [];
  78. }
  79. const data = await response.json();
  80. console.log('[JustOne] 原始响应 code:', data.code, 'message:', data.message);
  81. // code 301 = FAILED, RETRY——重试一次
  82. if (data.code === 301) {
  83. console.log('[JustOne] 收到 301 RETRY,3秒后重试...');
  84. await new Promise(r => setTimeout(r, 3000));
  85. const retryResp = await fetch(requestUrl, { method: 'GET', headers: { 'Content-Type': 'application/json' } });
  86. if (!retryResp.ok) {
  87. console.error(`[JustOne] 重试失败: ${retryResp.status}`);
  88. return [];
  89. }
  90. const retryData = await retryResp.json();
  91. console.log('[JustOne] 重试响应 code:', retryData.code, 'message:', retryData.message);
  92. if (retryData.code !== 0) {
  93. console.error('[JustOne] 重试仍失败, code:', retryData.code);
  94. return [];
  95. }
  96. return parseCreatorList(retryData);
  97. }
  98. if (data.code !== 0 && data.code !== undefined) {
  99. console.error('[JustOne] 业务错误 code:', data.code, data.message);
  100. return [];
  101. }
  102. return parseCreatorList(data);
  103. } catch (error) {
  104. console.error('[JustOne] Search failed:', error);
  105. return [];
  106. }
  107. }
  108. /**
  109. * 从 JustOne API 响应中解析创作者列表
  110. * - 小红书蒲公英: data.data.kols,字段直接在条目上
  111. * - 抖音星图: data.data.authors,字段全在 attribute_datas 字符串字典里
  112. */
  113. function parseCreatorList(data: Record<string, unknown>): JustOneCreator[] {
  114. const d = data.data as Record<string, unknown> | undefined;
  115. if (!d) {
  116. console.log('[JustOne] data.data 为空');
  117. return [];
  118. }
  119. console.log('[JustOne] data.data keys:', JSON.stringify(Object.keys(d)));
  120. // 小红书蒲公英: kols 字段
  121. if (Array.isArray(d.kols) && d.kols.length >= 0) {
  122. const list = d.kols as Record<string, unknown>[];
  123. console.log(`[JustOne] XHS kols 数量: ${list.length}`);
  124. if (list.length > 0) console.log('[JustOne] XHS 第一条 keys:', JSON.stringify(Object.keys(list[0])));
  125. const creators = list.map(parseXhsCreator);
  126. console.log('[JustOne] XHS 解析完成,数量:', creators.length);
  127. if (creators.length > 0) console.log('[JustOne] XHS 第一条解析结果:', JSON.stringify(creators[0]));
  128. return creators;
  129. }
  130. // 抖音星图: authors 字段
  131. if (Array.isArray(d.authors) && d.authors.length >= 0) {
  132. const list = d.authors as Record<string, unknown>[];
  133. console.log(`[JustOne] Douyin authors 数量: ${list.length}`);
  134. if (list.length > 0) console.log('[JustOne] Douyin 第一条 keys:', JSON.stringify(Object.keys(list[0])));
  135. const creators = list.map(parseDouyinXingtuCreator);
  136. console.log('[JustOne] Douyin 解析完成,数量:', creators.length);
  137. if (creators.length > 0) console.log('[JustOne] Douyin 第一条解析结果:', JSON.stringify(creators[0]));
  138. return creators;
  139. }
  140. // 通用回退(按小红书处理)
  141. const rawList = d.bloggerList || d.list || d.items || d.result || [];
  142. const listArr = Array.isArray(rawList) ? rawList as Record<string, unknown>[] : [];
  143. console.log(`[JustOne] 通用字段列表数量: ${listArr.length}`);
  144. return listArr.map(parseXhsCreator);
  145. }
  146. /** 解析小红书蒲公英创作者(字段直接在条目上) */
  147. function parseXhsCreator(item: Record<string, unknown>): JustOneCreator {
  148. // contentTags 是对象数组 [{taxonomy1Tag: '护肤', taxonomy2Tags: [...]}],提取一级标签
  149. const rawContentTags = item.contentTags;
  150. const contentTags: string[] = Array.isArray(rawContentTags)
  151. ? rawContentTags
  152. .map((t: unknown) => {
  153. if (typeof t === 'string') return t;
  154. if (typeof t === 'object' && t !== null) {
  155. const obj = t as Record<string, unknown>;
  156. return typeof obj.taxonomy1Tag === 'string' ? obj.taxonomy1Tag : null;
  157. }
  158. return null;
  159. })
  160. .filter((s): s is string => s !== null)
  161. : [];
  162. // featureTags 是字符串数组,直接作为 personalTags
  163. const featureTags = toStringArray(item.featureTags || item.personalTags || item.personal_tags || []);
  164. // fansCount:fansCount 始终为 0,实际应用 fansNum
  165. const fansCount = Number(item.fansNum || item.fansCount || item.fans_count || item.followerCount || 0);
  166. // 价格:图文 picturePrice,视频 videoPrice,最低 lowerPrice
  167. const imagePrice = Number(item.picturePrice || item.imagePrice || 0);
  168. const videoPrice = Number(item.videoPrice || item.video_price || 0);
  169. const minPrice = Number(item.lowerPrice || item.picturePrice || item.imagePrice || 0);
  170. // 合作状态:cooperateState=1 表示可合作
  171. const cooperateState = Number(item.cooperateState || 0);
  172. const cooperationStatus = cooperateState === 1 ? 'active' : String(item.cooperationStatus || '');
  173. const genderRaw = String(item.gender || '');
  174. const genderMap: Record<string, string> = { '女': 'female', '男': 'male', 'FEMALE': 'female', 'MALE': 'male' };
  175. return {
  176. userId: String(item.userId || item.user_id || ''),
  177. platform: 'xiaohongshu',
  178. nickname: String(item.name || item.nickname || item.nickName || ''),
  179. redId: String(item.redId || item.red_id || ''),
  180. location: String(item.location || item.city || ''),
  181. fansCount,
  182. imagePrice,
  183. videoPrice,
  184. minPrice,
  185. cooperationStatus,
  186. personalTags: featureTags,
  187. contentTags,
  188. gender: genderMap[genderRaw] || genderRaw,
  189. };
  190. }
  191. /** 解析抖音星图创作者(数据全在 attribute_datas 字符串字典里) */
  192. function parseDouyinXingtuCreator(item: Record<string, unknown>): JustOneCreator {
  193. const attr = (item.attribute_datas || {}) as Record<string, string>;
  194. const fansCount = Number(attr.follower || attr.fans_count || 0);
  195. // assign_cpm_suggest_price 是 CPM 建议价(元/千次),不是合作报价,暂设为0
  196. const imagePrice = 0;
  197. const videoPrice = 0;
  198. const minPrice = 0;
  199. const genderCode = attr.gender || '0';
  200. const genderMap: Record<string, string> = { '1': 'male', '2': 'female', '0': '' };
  201. // content_theme_labels_180d 是 JSON 字符串数组(实际响应中常为空 "[]")
  202. // 若为空则退而从 last_10_items 视频标题中提取 hashtag 作为 contentTags
  203. let contentTags: string[] = [];
  204. try {
  205. const raw = attr.content_theme_labels_180d || '[]';
  206. const parsed = JSON.parse(raw) as unknown[];
  207. if (Array.isArray(parsed) && parsed.length > 0) {
  208. contentTags = parsed.map(String);
  209. }
  210. } catch { /* ignore */ }
  211. if (contentTags.length === 0) {
  212. try {
  213. const items = JSON.parse(attr.last_10_items || '[]') as Record<string, unknown>[];
  214. const tags: string[] = [];
  215. for (const it of items.slice(0, 5)) {
  216. const title = String(it.item_title || '');
  217. for (const word of title.split(/\s+/)) {
  218. if (word.startsWith('#')) tags.push(word.slice(1));
  219. }
  220. }
  221. // 去重,最多保留 10 个
  222. contentTags = [...new Set(tags)].slice(0, 10);
  223. } catch { /* ignore */ }
  224. }
  225. // author_thin_mid_word_association_index 是 JSON 对象 {词: 权重},取 key 作为标签
  226. let personalTags: string[] = [];
  227. try {
  228. const raw = attr.author_thin_mid_word_association_index || '{}';
  229. const parsed = JSON.parse(raw) as Record<string, unknown>;
  230. personalTags = Object.keys(parsed);
  231. } catch { /* ignore */ }
  232. return {
  233. userId: String(item.star_id || attr.id || attr.core_user_id || ''),
  234. platform: 'douyin',
  235. nickname: String(attr.nickname || attr.name || ''),
  236. location: String(attr.city || attr.province || ''),
  237. fansCount,
  238. imagePrice,
  239. videoPrice,
  240. minPrice,
  241. cooperationStatus: attr.author_status === '1' ? 'active' : '',
  242. personalTags,
  243. contentTags,
  244. gender: genderMap[genderCode] || '',
  245. };
  246. }
  247. function toStringArray(val: unknown): string[] {
  248. if (!Array.isArray(val)) return [];
  249. return val
  250. .map(v => {
  251. if (typeof v === 'string') return v;
  252. if (typeof v !== 'object' || v === null) return null;
  253. const obj = v as Record<string, unknown>;
  254. const text = obj.name || obj.tagName || obj.label || obj.value || obj.text || obj.title;
  255. if (text && typeof text === 'string') return text;
  256. for (const k of Object.keys(obj)) {
  257. if (typeof obj[k] === 'string' && (obj[k] as string).length > 0) return obj[k] as string;
  258. }
  259. return null;
  260. })
  261. .filter((s): s is string => s !== null && s.length > 0);
  262. }
  263. /**
  264. * 获取创作者详情(粉丝画像、互动数据等)
  265. */
  266. export async function getCreatorProfile(userId: string, platform?: string): Promise<Record<string, unknown> | null> {
  267. if (!config.justOneApi.apiKey) {
  268. return null;
  269. }
  270. try {
  271. const queryParams = new URLSearchParams();
  272. queryParams.set('token', config.justOneApi.apiKey);
  273. queryParams.set('userId', userId);
  274. const profileEndpoint = getProfileEndpoint(platform);
  275. const response = await fetch(
  276. `${config.justOneApi.baseUrl}${profileEndpoint}?${queryParams.toString()}`,
  277. {
  278. method: 'GET',
  279. headers: {
  280. 'Content-Type': 'application/json',
  281. },
  282. }
  283. );
  284. if (!response.ok) {
  285. return null;
  286. }
  287. const data = await response.json();
  288. return data.data || data;
  289. } catch (error) {
  290. console.error('[JustOne] Profile fetch failed:', error);
  291. return null;
  292. }
  293. }
  294. /**
  295. * 根据平台返回对应的搜索接口路径
  296. * 文档: https://docs.justoneapi.com/zh/usage
  297. */
  298. function getPlatformEndpoint(platform?: string): string {
  299. switch (platform) {
  300. case 'xiaohongshu':
  301. case '小红书':
  302. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/v2/v1';
  303. case 'douyin':
  304. case '抖音':
  305. return '/api/douyin-xingtu/gw/api/gsearch/search_for_author_square/v1';
  306. default:
  307. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/v2/v1';
  308. }
  309. }
  310. function getProfileEndpoint(platform?: string): string {
  311. switch (platform) {
  312. case 'xiaohongshu':
  313. case '小红书':
  314. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/profile/v1';
  315. case 'douyin':
  316. case '抖音':
  317. return '/api/douyin-xingtu/gw/api/gsearch/search_for_author_square/v1';
  318. default:
  319. return '/api/xiaohongshu-pgy/api/solar/cooperator/blogger/profile/v1';
  320. }
  321. }