tikhub.service.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { config } from '../config.ts';
  2. export interface ContentSample {
  3. noteId: string;
  4. title: string;
  5. content: string;
  6. publishTime: string;
  7. likeCount: number;
  8. commentCount: number;
  9. collectCount: number;
  10. shareCount: number;
  11. type: 'image' | 'video';
  12. }
  13. export interface UserProfile {
  14. userId: string;
  15. nickname: string;
  16. avatar: string;
  17. fansCount: number;
  18. notesCount: number;
  19. likeAndCollect: number;
  20. description: string;
  21. location: string;
  22. tags: string[];
  23. }
  24. export interface DouyinUserSearchResult {
  25. uid: string;
  26. secUid: string;
  27. uniqueId: string;
  28. nickname: string;
  29. followerCount: number;
  30. avatar: string;
  31. }
  32. /**
  33. * TikHub API 内容采样验证服务
  34. * 文档: https://docs.tikhub.io/4579297m0
  35. * 用于近10条内容采样、风格判断、活跃度校验
  36. */
  37. export async function searchNotes(keyword: string, platform: string = 'xiaohongshu', page: number = 1): Promise<ContentSample[]> {
  38. if (!config.tikHub.apiKey) {
  39. console.warn('[TikHub] No API key configured, returning empty results');
  40. return [];
  41. }
  42. try {
  43. const endpoint = getPlatformSearchEndpoint(platform);
  44. const response = await fetch(`${config.tikHub.baseUrl}${endpoint}`, {
  45. method: 'GET',
  46. headers: {
  47. Authorization: `Bearer ${config.tikHub.apiKey}`,
  48. 'Content-Type': 'application/json',
  49. },
  50. // @ts-ignore - URL search params
  51. ...buildSearchParams(keyword, page, platform),
  52. });
  53. if (!response.ok) {
  54. const errorText = await response.text();
  55. console.error(`[TikHub] Search error: ${response.status} - ${errorText}`);
  56. return [];
  57. }
  58. const data = await response.json();
  59. return normalizeContentSamples(data, platform);
  60. } catch (error) {
  61. console.error('[TikHub] Search failed:', error);
  62. return [];
  63. }
  64. }
  65. /**
  66. * 获取用户近期内容列表(用于内容风格判断)
  67. */
  68. export async function getUserNotes(userId: string, platform: string = 'xiaohongshu', count: number = 10): Promise<ContentSample[]> {
  69. if (!config.tikHub.apiKey) {
  70. return [];
  71. }
  72. try {
  73. const endpoint = getPlatformUserNotesEndpoint(platform);
  74. const queryParams = new URLSearchParams();
  75. queryParams.set('user_id', userId);
  76. queryParams.set('count', String(count));
  77. const response = await fetch(`${config.tikHub.baseUrl}${endpoint}?${queryParams.toString()}`, {
  78. method: 'GET',
  79. headers: {
  80. Authorization: `Bearer ${config.tikHub.apiKey}`,
  81. 'Content-Type': 'application/json',
  82. },
  83. });
  84. if (!response.ok) {
  85. return [];
  86. }
  87. const data = await response.json();
  88. return normalizeContentSamples(data, platform);
  89. } catch (error) {
  90. console.error('[TikHub] User notes fetch failed:', error);
  91. return [];
  92. }
  93. }
  94. /**
  95. * 获取用户个人资料
  96. */
  97. export async function getUserProfile(userId: string, platform: string = 'xiaohongshu'): Promise<UserProfile | null> {
  98. if (!config.tikHub.apiKey) {
  99. return null;
  100. }
  101. try {
  102. const endpoint = getPlatformUserProfileEndpoint(platform);
  103. const queryParams = new URLSearchParams();
  104. queryParams.set('user_id', userId);
  105. const response = await fetch(`${config.tikHub.baseUrl}${endpoint}?${queryParams.toString()}`, {
  106. method: 'GET',
  107. headers: {
  108. Authorization: `Bearer ${config.tikHub.apiKey}`,
  109. 'Content-Type': 'application/json',
  110. },
  111. });
  112. if (!response.ok) {
  113. return null;
  114. }
  115. const data = await response.json();
  116. return normalizeUserProfile(data, platform);
  117. } catch (error) {
  118. console.error('[TikHub] User profile fetch failed:', error);
  119. return null;
  120. }
  121. }
  122. export async function searchDouyinUsers(keyword: string): Promise<DouyinUserSearchResult[]> {
  123. if (!config.tikHub.apiKey || !keyword.trim()) return [];
  124. try {
  125. const response = await fetch(`${config.tikHub.baseUrl}/api/v1/douyin/search/fetch_user_search`, {
  126. method: 'POST',
  127. headers: {
  128. Authorization: `Bearer ${config.tikHub.apiKey}`,
  129. 'Content-Type': 'application/json',
  130. },
  131. body: JSON.stringify({ keyword, cursor: 0 }),
  132. });
  133. if (!response.ok) {
  134. const errorText = await response.text();
  135. console.error(`[TikHub] Douyin user search error: ${response.status} - ${errorText}`);
  136. return [];
  137. }
  138. const data = await response.json();
  139. return normalizeDouyinUserSearchResults(data as Record<string, unknown>);
  140. } catch (error) {
  141. console.error('[TikHub] Douyin user search failed:', error);
  142. return [];
  143. }
  144. }
  145. function getPlatformSearchEndpoint(platform: string): string {
  146. switch (platform) {
  147. case 'xiaohongshu':
  148. return '/api/v1/xiaohongshu/web/search_notes';
  149. case 'douyin':
  150. return '/api/v1/douyin/web/search_videos';
  151. case 'bilibili':
  152. return '/api/v1/bilibili/web/search';
  153. default:
  154. return '/api/v1/xiaohongshu/web/search_notes';
  155. }
  156. }
  157. function getPlatformUserNotesEndpoint(platform: string): string {
  158. switch (platform) {
  159. case 'xiaohongshu':
  160. return '/api/v1/xiaohongshu/web/get_user_notes';
  161. case 'douyin':
  162. return '/api/v1/douyin/web/get_user_posts';
  163. case 'bilibili':
  164. return '/api/v1/bilibili/web/get_user_videos';
  165. default:
  166. return '/api/v1/xiaohongshu/web/get_user_notes';
  167. }
  168. }
  169. function getPlatformUserProfileEndpoint(platform: string): string {
  170. switch (platform) {
  171. case 'xiaohongshu':
  172. return '/api/v1/xiaohongshu/web/get_user_info';
  173. case 'douyin':
  174. return '/api/v1/douyin/web/get_user_info';
  175. case 'bilibili':
  176. return '/api/v1/bilibili/web/get_user_info';
  177. default:
  178. return '/api/v1/xiaohongshu/web/get_user_info';
  179. }
  180. }
  181. function buildSearchParams(keyword: string, page: number, platform: string): { body?: string } {
  182. // TikHub uses query params in URL, build accordingly
  183. const params = new URLSearchParams();
  184. params.set('keyword', keyword);
  185. params.set('page', String(page));
  186. if (platform === 'xiaohongshu') {
  187. params.set('sort', 'general');
  188. params.set('note_type', '0');
  189. }
  190. // Append to the URL instead of body
  191. return {};
  192. }
  193. function normalizeContentSamples(data: Record<string, unknown>, platform: string): ContentSample[] {
  194. const items = (data.data as Record<string, unknown>[] | undefined) || [];
  195. if (!Array.isArray(items)) return [];
  196. return items.map((item: Record<string, unknown>) => {
  197. if (platform === 'xiaohongshu') {
  198. return {
  199. noteId: String(item.note_id || item.id || ''),
  200. title: String(item.title || item.display_title || ''),
  201. content: String(item.desc || item.content || ''),
  202. publishTime: String(item.time || item.create_time || ''),
  203. likeCount: Number(item.liked_count || item.like_count || 0),
  204. commentCount: Number(item.comment_count || 0),
  205. collectCount: Number(item.collected_count || item.collect_count || 0),
  206. shareCount: Number(item.share_count || 0),
  207. type: (item.type === 'video' || item.note_type === 'video') ? 'video' : 'image',
  208. };
  209. }
  210. // Generic fallback for douyin/bilibili
  211. return {
  212. noteId: String(item.id || item.aweme_id || item.bvid || ''),
  213. title: String(item.title || item.desc || ''),
  214. content: String(item.content || item.desc || ''),
  215. publishTime: String(item.create_time || item.pubdate || ''),
  216. likeCount: Number(item.like_count || item.digg_count || item.like || 0),
  217. commentCount: Number(item.comment_count || item.reply || 0),
  218. collectCount: Number(item.collect_count || item.favorite || 0),
  219. shareCount: Number(item.share_count || 0),
  220. type: 'video',
  221. };
  222. });
  223. }
  224. function normalizeUserProfile(data: Record<string, unknown>, _platform: string): UserProfile | null {
  225. const user = (data.data as Record<string, unknown>) || data;
  226. if (!user) return null;
  227. return {
  228. userId: String(user.user_id || user.uid || ''),
  229. nickname: String(user.nickname || user.name || ''),
  230. avatar: String(user.avatar || user.avatar_url || ''),
  231. fansCount: Number(user.fans_count || user.follower_count || 0),
  232. notesCount: Number(user.notes_count || user.aweme_count || 0),
  233. likeAndCollect: Number(user.liked_and_collected || user.total_favorited || 0),
  234. description: String(user.description || user.desc || user.signature || ''),
  235. location: String(user.location || user.ip_location || ''),
  236. tags: Array.isArray(user.tags) ? user.tags as string[] : [],
  237. };
  238. }
  239. function normalizeDouyinUserSearchResults(data: Record<string, unknown>): DouyinUserSearchResult[] {
  240. const root = data.data as Record<string, unknown> | undefined;
  241. const userList = root?.user_list;
  242. if (!Array.isArray(userList)) return [];
  243. const results: DouyinUserSearchResult[] = [];
  244. for (const item of userList) {
  245. if (typeof item !== 'object' || item === null) continue;
  246. const rawData = ((item as Record<string, unknown>).dynamic_patch as Record<string, unknown> | undefined)?.raw_data;
  247. if (typeof rawData !== 'string') continue;
  248. try {
  249. const parsed = JSON.parse(rawData) as Record<string, unknown>;
  250. const userInfo = parsed.user_info as Record<string, unknown> | undefined;
  251. if (!userInfo) continue;
  252. results.push({
  253. uid: String(userInfo.uid || ''),
  254. secUid: String(userInfo.sec_uid || ''),
  255. uniqueId: String(userInfo.unique_id || ''),
  256. nickname: String(userInfo.nickname || ''),
  257. followerCount: Number(userInfo.follower_count || 0),
  258. avatar: firstUrl((userInfo.avatar_larger as Record<string, unknown> | undefined)?.url_list),
  259. });
  260. } catch {
  261. // Ignore malformed raw_data entries from search results.
  262. }
  263. }
  264. return results.filter((user) => user.uid && user.secUid && user.nickname);
  265. }
  266. function firstUrl(value: unknown): string {
  267. return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : '';
  268. }