justone.service.ts 21 KB

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