collect-tiktok.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. #!/usr/bin/env node
  2. /**
  3. * TikTok 采集模块(TikHub)
  4. *
  5. * TikTok 是 Amazon 的内容端对标 —— 用英文关键词驱动:
  6. * - milk thistle / liver support → 肝纯片
  7. * - kids probiotics → 儿童乳酸菌
  8. * - lions mane mushroom → 猴菇饮
  9. *
  10. * 流程(每产品):
  11. * 1) 对每个 keyword_en 走 fetch_general_search_result,累计视频
  12. * 2) 去重后取 Top 8 高赞视频,每个抓前 20 条评论
  13. * 3) 合并所有视频的 author,按 follower_count 取 Top 5 作者,抓 profile + Top 10 作品
  14. * 4) 合并用户搜索 (/web/fetch_search_user) 拿到品类头部账号
  15. *
  16. * 不需要 cookie(TikTok 海外版)
  17. */
  18. const fs = require('fs');
  19. const path = require('path');
  20. const os = require('os');
  21. const https = require('https');
  22. const TIKHUB_TOKEN = JSON.parse(
  23. fs.readFileSync(
  24. path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json'),
  25. 'utf-8'
  26. )
  27. ).endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
  28. function get(apiPath, params = {}) {
  29. const qs = Object.entries(params)
  30. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  31. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  32. .join('&');
  33. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  34. return new Promise((resolve) => {
  35. const opts = {
  36. hostname: 'api.tikhub.io',
  37. path: fullPath,
  38. method: 'GET',
  39. headers: { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json' },
  40. };
  41. const req = https.request(opts, (res) => {
  42. const chunks = [];
  43. res.on('data', (c) => chunks.push(c));
  44. res.on('end', () => {
  45. const body = Buffer.concat(chunks).toString('utf-8');
  46. try { resolve({ status: res.statusCode, json: JSON.parse(body) }); }
  47. catch (e) { resolve({ status: res.statusCode, json: { _raw: body.slice(0, 200), _parseError: e.message } }); }
  48. });
  49. });
  50. req.on('error', (e) => resolve({ status: 0, json: { _error: e.message } }));
  51. req.setTimeout(60000, () => { req.destroy(); resolve({ status: 0, json: { _error: 'timeout' } }); });
  52. req.end();
  53. });
  54. }
  55. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  56. // ============================================================
  57. // 基础工具:解析搜索结果为扁平的视频数组
  58. // ============================================================
  59. function parseSearchVideos(json) {
  60. const d = json?.data;
  61. if (!d) return [];
  62. // TikTok search 返回 { data: { data: [...], cursor, ... } }
  63. let list = Array.isArray(d) ? d : (Array.isArray(d.data) ? d.data : (Array.isArray(d.aweme_list) ? d.aweme_list : []));
  64. return list
  65. .map((item) => item?.aweme_info || item)
  66. .filter((v) => v && v.aweme_id);
  67. }
  68. function slimVideo(v) {
  69. return {
  70. aweme_id: v.aweme_id,
  71. desc: v.desc,
  72. create_time: v.create_time,
  73. duration: v.duration,
  74. statistics: v.statistics ? {
  75. digg_count: v.statistics.digg_count || 0,
  76. comment_count: v.statistics.comment_count || 0,
  77. play_count: v.statistics.play_count || 0,
  78. share_count: v.statistics.share_count || 0,
  79. collect_count: v.statistics.collect_count || 0,
  80. download_count: v.statistics.download_count || 0,
  81. } : null,
  82. author: v.author ? {
  83. sec_uid: v.author.sec_uid,
  84. uid: v.author.uid,
  85. nickname: v.author.nickname,
  86. unique_id: v.author.unique_id,
  87. signature: v.author.signature,
  88. follower_count: v.author.follower_count,
  89. total_favorited: v.author.total_favorited,
  90. aweme_count: v.author.aweme_count,
  91. avatar: v.author.avatar_thumb?.url_list?.[0] || v.author.avatar_larger?.url_list?.[0],
  92. verification_type: v.author.verification_type,
  93. custom_verify: v.author.custom_verify,
  94. } : null,
  95. cover: v.video?.cover?.url_list?.[0] || v.video?.origin_cover?.url_list?.[0],
  96. hashtags: (v.text_extra || []).map((t) => t.hashtag_name).filter(Boolean),
  97. region: v.region,
  98. };
  99. }
  100. // ============================================================
  101. // 主入口:单产品采集
  102. // ============================================================
  103. async function collectTikTokForProduct(product) {
  104. console.log('');
  105. console.log(` [tt] 🎵 TikTok for ${product.name}`);
  106. const keywords = (product.keywords_en || []).slice(0, 4);
  107. if (!keywords.length) {
  108. return { _error: 'no keywords_en provided' };
  109. }
  110. const result = {
  111. product: product.name,
  112. keywords_used: keywords,
  113. by_keyword: {},
  114. merged_videos: [],
  115. top_authors: [],
  116. author_profiles: {},
  117. author_posts: {},
  118. top_comments_by_video: {},
  119. category_users: {},
  120. collected_at: new Date().toISOString(),
  121. };
  122. // === 1) 对每个关键词做 2 轮 search(拿 ~20 条) ===
  123. const allVideos = [];
  124. const seenAwemeIds = new Set();
  125. for (const kw of keywords) {
  126. result.by_keyword[kw] = { videos: [], pages: 0 };
  127. let cursor = 0;
  128. for (let page = 0; page < 2; page++) {
  129. console.log(` [tt] 🔍 search "${kw}" page=${page + 1} cursor=${cursor}`);
  130. const r = await get('/api/v1/tiktok/app/v3/fetch_general_search_result', {
  131. keyword: kw, cursor, count: 10,
  132. });
  133. await sleep(1000);
  134. const videos = parseSearchVideos(r.json);
  135. console.log(` → ${videos.length} videos`);
  136. videos.forEach((v) => {
  137. if (!seenAwemeIds.has(v.aweme_id)) {
  138. seenAwemeIds.add(v.aweme_id);
  139. const slim = slimVideo(v);
  140. slim._from_keyword = kw;
  141. allVideos.push(slim);
  142. result.by_keyword[kw].videos.push(slim);
  143. }
  144. });
  145. result.by_keyword[kw].pages = page + 1;
  146. cursor = r.json?.data?.cursor || r.json?.data?.next_cursor || 0;
  147. if (!r.json?.data?.has_more || videos.length === 0) break;
  148. }
  149. }
  150. result.merged_videos = allVideos;
  151. console.log(` [tt] ✓ merged ${allVideos.length} unique videos (keywords=${keywords.length})`);
  152. if (!allVideos.length) {
  153. console.log(` [tt] ⚠ 无视频数据,返回空结果`);
  154. return result;
  155. }
  156. // === 2) 按点赞取 Top 8 视频,每个抓前 20 评论 ===
  157. const topVideos = [...allVideos]
  158. .sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
  159. .slice(0, 8);
  160. for (const v of topVideos) {
  161. await sleep(900);
  162. console.log(` [tt] 💬 comments ${v.aweme_id} · ${(v.desc || '').slice(0, 40)}`);
  163. const rC = await get('/api/v1/tiktok/app/v3/fetch_video_comments', {
  164. aweme_id: v.aweme_id, cursor: 0, count: 20,
  165. });
  166. const raw = rC.json?.data?.comments || [];
  167. const comments = raw.map((c) => ({
  168. cid: c.cid,
  169. text: c.text,
  170. digg_count: c.digg_count || 0,
  171. create_time: c.create_time,
  172. reply_comment_total: c.reply_comment_total || 0,
  173. user: c.user ? {
  174. nickname: c.user.nickname,
  175. sec_uid: c.user.sec_uid,
  176. unique_id: c.user.unique_id,
  177. follower_count: c.user.follower_count,
  178. region: c.user.region,
  179. } : null,
  180. }));
  181. result.top_comments_by_video[v.aweme_id] = comments;
  182. console.log(` → ${comments.length} comments`);
  183. }
  184. // === 3) 合并作者 → Top 5 按粉丝,各抓 profile + Top 10 posts ===
  185. const authorMap = new Map();
  186. allVideos.forEach((v) => {
  187. if (v.author?.sec_uid && !authorMap.has(v.author.sec_uid)) {
  188. authorMap.set(v.author.sec_uid, v.author);
  189. }
  190. });
  191. const topAuthors = [...authorMap.values()]
  192. .sort((a, b) => (b.follower_count || 0) - (a.follower_count || 0))
  193. .slice(0, 5);
  194. result.top_authors = topAuthors.map((a) => ({
  195. sec_uid: a.sec_uid,
  196. nickname: a.nickname,
  197. unique_id: a.unique_id,
  198. follower_count: a.follower_count,
  199. total_favorited: a.total_favorited,
  200. aweme_count: a.aweme_count,
  201. custom_verify: a.custom_verify,
  202. avatar: a.avatar,
  203. }));
  204. for (const a of topAuthors) {
  205. await sleep(900);
  206. console.log(` [tt] 👤 profile @${a.unique_id || a.nickname} (${(a.follower_count || 0).toLocaleString()}粉)`);
  207. const rP = await get('/api/v1/tiktok/app/v3/handler_user_profile', {
  208. sec_user_id: a.sec_uid,
  209. });
  210. const u = rP.json?.data?.user;
  211. if (u) {
  212. result.author_profiles[a.sec_uid] = {
  213. nickname: u.nickname,
  214. unique_id: u.unique_id,
  215. sec_uid: u.sec_uid,
  216. signature: u.signature,
  217. follower_count: u.follower_count,
  218. following_count: u.following_count,
  219. total_favorited: u.total_favorited,
  220. aweme_count: u.aweme_count,
  221. custom_verify: u.custom_verify,
  222. verification_type: u.verification_type,
  223. region: u.region,
  224. avatar: u.avatar_larger?.url_list?.[0] || u.avatar_thumb?.url_list?.[0],
  225. };
  226. }
  227. await sleep(700);
  228. console.log(` [tt] 📹 posts @${a.unique_id || a.nickname}`);
  229. const rPost = await get('/api/v1/tiktok/app/v3/fetch_user_post_videos', {
  230. sec_user_id: a.sec_uid, max_cursor: 0, count: 10,
  231. });
  232. const awemeList = rPost.json?.data?.aweme_list || [];
  233. result.author_posts[a.sec_uid] = awemeList.map(slimVideo);
  234. console.log(` → ${result.author_posts[a.sec_uid].length} posts`);
  235. }
  236. // === 4) 品类用户搜索(可选,拿头部品牌/垂类号)===
  237. await sleep(1000);
  238. const primaryKw = keywords[0];
  239. console.log(` [tt] 🔎 user search "${primaryKw}"`);
  240. const rUser = await get('/api/v1/tiktok/web/fetch_search_user', {
  241. keyword: primaryKw, cursor: 0, count: 10,
  242. });
  243. const userList = rUser.json?.data;
  244. const userArr = Array.isArray(userList) ? userList : (Array.isArray(userList?.data) ? userList.data : (Array.isArray(userList?.user_list) ? userList.user_list : []));
  245. if (userArr.length) {
  246. result.category_users[primaryKw] = userArr.slice(0, 10).map((u) => {
  247. const ui = u.user_info || u;
  248. return {
  249. sec_uid: ui.sec_uid,
  250. unique_id: ui.unique_id,
  251. nickname: ui.nickname,
  252. follower_count: ui.follower_count,
  253. total_favorited: ui.total_favorited,
  254. aweme_count: ui.aweme_count,
  255. signature: ui.signature,
  256. custom_verify: ui.custom_verify,
  257. avatar: ui.avatar_thumb?.url_list?.[0] || ui.avatar_larger?.url_list?.[0],
  258. };
  259. });
  260. console.log(` → ${result.category_users[primaryKw].length} category users`);
  261. } else {
  262. console.log(` → 0 users (empty or error)`);
  263. }
  264. return result;
  265. }
  266. module.exports = { collectTikTokForProduct };