collect-douyin.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. #!/usr/bin/env node
  2. /**
  3. * 抖音采集模块(TikHub via /thapi/v1/ 代理)
  4. *
  5. * 策略:
  6. * 1) 如果存在 data/douyin-cookie.txt,加载用户 cookie → 注入到每个 TikHub 请求的 ?cookie= 参数
  7. * 2) 对每个产品 share_url:
  8. * a. fetch_one_video_by_share_url → 拿 aweme_id / sec_uid / 视频元数据
  9. * b. fetch_video_comments → 评论
  10. * c. fetch_user_post_videos → 作者其他作品
  11. * d. handler_user_profile → 作者画像
  12. * 3) 如果 cookie 可用,再对每个中文关键词做一次 fetch_general_search_result
  13. *
  14. * 无 cookie 时:记录 _pending_cookie: true 优雅降级
  15. */
  16. const fs = require('fs');
  17. const path = require('path');
  18. const os = require('os');
  19. const https = require('https');
  20. const TIKHUB_TOKEN = JSON.parse(fs.readFileSync(
  21. path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json'),
  22. 'utf-8'
  23. )).endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
  24. // Cookie file:用户登录 www.douyin.com 后从 DevTools 复制整条 Cookie header
  25. const COOKIE_PATH = path.resolve(__dirname, '..', '..', 'data', 'douyin-cookie.txt');
  26. function loadCookie() {
  27. if (!fs.existsSync(COOKIE_PATH)) return '';
  28. const raw = fs.readFileSync(COOKIE_PATH, 'utf-8').trim();
  29. // 过滤掉注释行(#开头)
  30. return raw.split('\n').filter((l) => !l.startsWith('#') && l.trim()).join('; ').trim();
  31. }
  32. function buildUrl(base, params) {
  33. const qs = Object.entries(params)
  34. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  35. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  36. .join('&');
  37. return qs ? `${base}?${qs}` : base;
  38. }
  39. async function tikhubGet(apiPath, params = {}, cookie = '') {
  40. if (cookie) params.cookie = cookie;
  41. const url = buildUrl(apiPath, params);
  42. return new Promise((resolve) => {
  43. const opts = {
  44. hostname: 'api.tikhub.io', path: url, method: 'GET',
  45. headers: { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json' },
  46. };
  47. const req = https.request(opts, (res) => {
  48. const chunks = [];
  49. res.on('data', (c) => chunks.push(c));
  50. res.on('end', () => {
  51. const body = Buffer.concat(chunks).toString('utf-8');
  52. try {
  53. const j = JSON.parse(body);
  54. resolve({ status: res.statusCode, json: j });
  55. } catch (e) {
  56. resolve({ status: res.statusCode, json: { _parse_error: e.message, _raw: body.slice(0, 300) } });
  57. }
  58. });
  59. });
  60. req.on('error', (e) => resolve({ status: 0, json: { _error: e.message } }));
  61. req.setTimeout(45000, () => { req.destroy(); resolve({ status: 0, json: { _error: 'timeout' } }); });
  62. req.end();
  63. });
  64. }
  65. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  66. function extractVideoInfo(j) {
  67. if (!j || j.code !== 200 || !j.data) return null;
  68. const info = j.data.aweme_detail || j.data.aweme_info || j.data;
  69. if (!info?.aweme_id) return null;
  70. return info;
  71. }
  72. // 手动解析 v.douyin.com 短链 → 支持两种类型:
  73. // type=video → 拿 aweme_id(视频分享链)
  74. // type=user → 拿 sec_uid(作者主页分享链)
  75. function resolveShareUrlHead(shareUrl) {
  76. return new Promise((resolve) => {
  77. try {
  78. const u = new URL(shareUrl);
  79. const opts = {
  80. hostname: u.hostname,
  81. path: u.pathname + u.search,
  82. method: 'GET',
  83. headers: {
  84. 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
  85. Accept: 'text/html,*/*',
  86. },
  87. };
  88. const req = https.request(opts, (res) => {
  89. const loc = res.headers.location || '';
  90. // 视频分享链:/share/video/{aweme_id}/
  91. const mVideo = loc.match(/\/share\/video\/(\d+)/) || loc.match(/\/video\/(\d+)/);
  92. // 作者分享链:/share/user/{sec_uid}/ or ?sec_uid={sec_uid}
  93. const mUser = loc.match(/\/share\/user\/([A-Za-z0-9_\-]+)/);
  94. const mSecUid = loc.match(/[?&]sec_uid=([A-Za-z0-9_\-]+)/);
  95. if (mVideo) {
  96. res.resume();
  97. resolve({ type: 'video', aweme_id: mVideo[1], location: loc, status: res.statusCode });
  98. return;
  99. }
  100. if (mSecUid || mUser) {
  101. res.resume();
  102. resolve({ type: 'user', sec_uid: (mSecUid && mSecUid[1]) || mUser[1], location: loc, status: res.statusCode });
  103. return;
  104. }
  105. // 兜底:读 body 找线索
  106. const chunks = [];
  107. res.on('data', (c) => chunks.push(c));
  108. res.on('end', () => {
  109. const body = Buffer.concat(chunks).toString('utf-8');
  110. const b1 = body.match(/"aweme_id"\s*:\s*"(\d+)"/) || body.match(/\/video\/(\d{15,})/);
  111. const b2 = body.match(/"sec_uid"\s*:\s*"([A-Za-z0-9_\-]+)"/);
  112. if (b1) resolve({ type: 'video', aweme_id: b1[1], location: loc, status: res.statusCode });
  113. else if (b2) resolve({ type: 'user', sec_uid: b2[1], location: loc, status: res.statusCode });
  114. else resolve({ type: 'unknown', location: loc, status: res.statusCode, body_len: body.length });
  115. });
  116. });
  117. req.on('error', (e) => resolve({ _error: e.message }));
  118. req.setTimeout(20000, () => { req.destroy(); resolve({ _error: 'timeout' }); });
  119. req.end();
  120. } catch (e) {
  121. resolve({ _error: e.message });
  122. }
  123. });
  124. }
  125. // ============================================================
  126. // 规范化账号 specs:支持 douyin_share_urls (array) / douyin_share_url (string)
  127. // ============================================================
  128. function normalizeAccountSpecs(product) {
  129. // 优先用 douyin_share_urls 数组
  130. if (Array.isArray(product.douyin_share_urls) && product.douyin_share_urls.length) {
  131. return product.douyin_share_urls.map((item, idx) => {
  132. if (typeof item === 'string') {
  133. return { share_url: item, role: idx === 0 ? '官方号' : '补充号', label: idx === 0 ? '官方号' : `账号 ${idx + 1}` };
  134. }
  135. return {
  136. share_url: item.share_url || item.url,
  137. role: item.role || (idx === 0 ? '官方号' : '补充号'),
  138. label: item.label || item.name || (idx === 0 ? '官方号' : `账号 ${idx + 1}`),
  139. };
  140. }).filter((s) => s.share_url);
  141. }
  142. // 向下兼容单个 share_url
  143. if (product.douyin_share_url) {
  144. return [{ share_url: product.douyin_share_url, role: '官方号', label: '官方号' }];
  145. }
  146. return [];
  147. }
  148. // ============================================================
  149. // 单个账号采集(原 collectDouyinForProduct 主体逻辑抽出来)
  150. // ============================================================
  151. async function collectSingleAccount(spec, product, cookie, { runSearches = false } = {}) {
  152. const result = {
  153. share_url: spec.share_url,
  154. role: spec.role,
  155. label: spec.label,
  156. share_type: null,
  157. video: null,
  158. comments: [],
  159. user_posts: [],
  160. user_profile: null,
  161. searches: {},
  162. };
  163. // 1) 手动解析短链(TikHub 的 resolver 不稳定,直接跟重定向)
  164. console.log(` [dy·${spec.label}] 🎬 解析 ${spec.share_url}`);
  165. const parsed = await resolveShareUrlHead(spec.share_url);
  166. if (parsed._error || parsed.type === 'unknown') {
  167. result._resolve_error = parsed._error || `unknown type, location=${parsed.location}`;
  168. console.log(` ❌ 解析失败: ${String(result._resolve_error).slice(0, 160)}`);
  169. return result;
  170. }
  171. console.log(` ✓ type=${parsed.type} | ${parsed.aweme_id || parsed.sec_uid}`);
  172. result.share_type = parsed.type;
  173. let aweme_id = parsed.aweme_id || '';
  174. let sec_uid = parsed.sec_uid || '';
  175. await sleep(500);
  176. // 2) 若是视频分享链 → 先抓视频详情拿到 aweme_detail + author.sec_uid
  177. if (parsed.type === 'video' && aweme_id) {
  178. const rV = await tikhubGet(
  179. '/api/v1/douyin/app/v3/fetch_one_video',
  180. { aweme_id }, cookie
  181. );
  182. const video = extractVideoInfo(rV.json);
  183. if (video) {
  184. result.video = {
  185. aweme_id: video.aweme_id,
  186. desc: video.desc,
  187. create_time: video.create_time,
  188. duration: video.duration,
  189. statistics: video.statistics,
  190. author: video.author ? {
  191. nickname: video.author.nickname,
  192. sec_uid: video.author.sec_uid,
  193. uid: video.author.uid,
  194. avatar: video.author.avatar_thumb?.url_list?.[0] || video.author.avatar_larger?.url_list?.[0],
  195. signature: video.author.signature,
  196. follower_count: video.author.follower_count,
  197. aweme_count: video.author.aweme_count,
  198. } : null,
  199. cover: video.video?.cover?.url_list?.[0] || video.video?.origin_cover?.url_list?.[0],
  200. text_extra: video.text_extra?.map((t) => t.hashtag_name).filter(Boolean) || [],
  201. };
  202. sec_uid = sec_uid || video.author?.sec_uid || '';
  203. console.log(` [video] ${video.author?.nickname} · digg=${video.statistics?.digg_count} comment=${video.statistics?.comment_count}`);
  204. } else {
  205. console.log(` [video] ❌ detail failed: ${JSON.stringify(rV.json).slice(0, 120)}`);
  206. }
  207. await sleep(700);
  208. // 3) 视频评论(3 页累计最多 60)
  209. let cursor = 0;
  210. for (let page = 0; page < 3; page++) {
  211. const r2 = await tikhubGet(
  212. '/api/v1/douyin/app/v3/fetch_video_comments',
  213. { aweme_id, cursor, count: 20 }, cookie
  214. );
  215. if (r2.json?.code === 200 && r2.json?.data) {
  216. const cmts = r2.json.data.comments || [];
  217. cmts.forEach((c) => {
  218. result.comments.push({
  219. cid: c.cid,
  220. text: c.text,
  221. digg_count: c.digg_count,
  222. create_time: c.create_time,
  223. ip_label: c.ip_label,
  224. user: c.user ? {
  225. nickname: c.user.nickname,
  226. sec_uid: c.user.sec_uid,
  227. follower_count: c.user.follower_count,
  228. } : null,
  229. });
  230. });
  231. console.log(` [comments p${page + 1}] +${cmts.length} total=${result.comments.length}`);
  232. if (!r2.json.data.has_more) break;
  233. cursor = r2.json.data.cursor || 0;
  234. } else {
  235. console.log(` [comments p${page + 1}] ERR ${JSON.stringify(r2.json).slice(0, 150)}`);
  236. break;
  237. }
  238. await sleep(700);
  239. }
  240. }
  241. if (!sec_uid) {
  242. console.log(` ⚠ 无 sec_uid,跳过作者板块`);
  243. return result;
  244. }
  245. await sleep(500);
  246. // 4) 作者主页作品
  247. const r3 = await tikhubGet(
  248. '/api/v1/douyin/app/v3/fetch_user_post_videos',
  249. { sec_user_id: sec_uid, max_cursor: 0, count: 20 }, cookie
  250. );
  251. if (r3.json?.code === 200 && r3.json?.data) {
  252. const vids = r3.json.data.aweme_list || [];
  253. result.user_posts = vids.map((v) => ({
  254. aweme_id: v.aweme_id,
  255. desc: v.desc,
  256. create_time: v.create_time,
  257. statistics: v.statistics,
  258. cover: v.video?.cover?.url_list?.[0] || v.video?.origin_cover?.url_list?.[0],
  259. duration: v.duration,
  260. }));
  261. console.log(` [user_posts] ${result.user_posts.length} 条`);
  262. } else {
  263. console.log(` [user_posts] ERR ${JSON.stringify(r3.json).slice(0, 150)}`);
  264. }
  265. await sleep(500);
  266. // 5) 作者画像
  267. const r4 = await tikhubGet(
  268. '/api/v1/douyin/app/v3/handler_user_profile',
  269. { sec_user_id: sec_uid }, cookie
  270. );
  271. if (r4.json?.code === 200 && r4.json?.data) {
  272. const u = r4.json.data.user || r4.json.data;
  273. result.user_profile = {
  274. nickname: u.nickname,
  275. signature: u.signature,
  276. follower_count: u.follower_count,
  277. following_count: u.following_count,
  278. aweme_count: u.aweme_count,
  279. total_favorited: u.total_favorited,
  280. avatar: u.avatar_larger?.url_list?.[0] || u.avatar_thumb?.url_list?.[0],
  281. ip_location: u.ip_location,
  282. verification_type: u.verification_type,
  283. custom_verify: u.custom_verify,
  284. };
  285. console.log(` [profile] ${u.nickname} · 粉丝 ${(u.follower_count || 0).toLocaleString()} · 获赞 ${(u.total_favorited || 0).toLocaleString()}`);
  286. } else {
  287. console.log(` [profile] ERR ${JSON.stringify(r4.json).slice(0, 150)}`);
  288. }
  289. await sleep(500);
  290. // 6) 若是作者分享链 → 对作者的 Top 3 热门作品抓评论
  291. if (parsed.type === 'user' && result.user_posts.length) {
  292. const topPosts = [...result.user_posts]
  293. .sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
  294. .slice(0, 3);
  295. for (const p of topPosts) {
  296. await sleep(600);
  297. const rC = await tikhubGet(
  298. '/api/v1/douyin/app/v3/fetch_video_comments',
  299. { aweme_id: p.aweme_id, cursor: 0, count: 20 }, cookie
  300. );
  301. if (rC.json?.code === 200 && rC.json?.data) {
  302. const cmts = rC.json.data.comments || [];
  303. cmts.forEach((c) => {
  304. result.comments.push({
  305. cid: c.cid,
  306. aweme_id: p.aweme_id,
  307. text: c.text,
  308. digg_count: c.digg_count,
  309. create_time: c.create_time,
  310. ip_label: c.ip_label,
  311. user: c.user ? {
  312. nickname: c.user.nickname,
  313. sec_uid: c.user.sec_uid,
  314. follower_count: c.user.follower_count,
  315. } : null,
  316. });
  317. });
  318. console.log(` [comments on ${p.aweme_id}] +${cmts.length}`);
  319. }
  320. }
  321. }
  322. // 5) 如果启用(通常只在主号跑),再做关键词搜索覆盖品类全景
  323. // 注意:抖音 web search 需要带设备指纹的 cookie,多数情况下返回"用户未登录"。
  324. // 保留代码作为 best-effort,失败不影响其他数据。
  325. if (runSearches && cookie && product.keywords_cn) {
  326. for (const kw of product.keywords_cn.slice(0, 2)) {
  327. await sleep(800);
  328. console.log(` [search "${kw}"]`);
  329. const r5 = await tikhubGet(
  330. '/api/v1/douyin/app/v3/fetch_general_search_result',
  331. { keyword: kw, offset: 0, count: 20, sort_type: 1, publish_time: 180 }, cookie
  332. );
  333. if (r5.json?.code === 200 && r5.json?.data) {
  334. const data = r5.json.data.data || r5.json.data;
  335. const videos = Array.isArray(data) ? data.filter((x) => x.aweme_info).map((x) => x.aweme_info) : [];
  336. result.searches[kw] = videos.map((v) => ({
  337. aweme_id: v.aweme_id,
  338. desc: v.desc,
  339. create_time: v.create_time,
  340. statistics: v.statistics,
  341. author: v.author ? {
  342. nickname: v.author.nickname,
  343. sec_uid: v.author.sec_uid,
  344. follower_count: v.author.follower_count,
  345. } : null,
  346. cover: v.video?.cover?.url_list?.[0],
  347. }));
  348. console.log(` → ${result.searches[kw].length} 条视频`);
  349. } else {
  350. const st = r5.json?.data?.[0]?.status_msg || r5.json?.message_zh || 'unknown';
  351. console.log(` ❌ ${String(st).slice(0, 80)}`);
  352. result.searches[kw] = { _error: st };
  353. }
  354. }
  355. }
  356. return result;
  357. }
  358. // ============================================================
  359. // 多账号采集编排(对外主入口)
  360. // ============================================================
  361. async function collectDouyinForProduct(product, cookie) {
  362. const specs = normalizeAccountSpecs(product);
  363. const result = {
  364. product: product.name,
  365. cookie_loaded: !!cookie,
  366. primary_share_url: specs[0]?.share_url || null,
  367. accounts_total: specs.length,
  368. accounts: [],
  369. };
  370. if (specs.length === 0) {
  371. result._error = 'no share_url/share_urls provided';
  372. return result;
  373. }
  374. console.log(` [dy] 📦 ${specs.length} 个账号待采集: ${specs.map((s) => s.label).join(' · ')}`);
  375. for (let i = 0; i < specs.length; i++) {
  376. const spec = specs[i];
  377. try {
  378. const acc = await collectSingleAccount(spec, product, cookie, {
  379. runSearches: i === 0, // 只在首个账号跑搜索
  380. });
  381. result.accounts.push(acc);
  382. } catch (e) {
  383. console.log(` [dy·${spec.label}] ❌ ${e.message}`);
  384. result.accounts.push({ ...spec, _error: e.message });
  385. }
  386. if (i < specs.length - 1) await sleep(1500);
  387. }
  388. // 向下兼容:把首个账号的关键字段打平到根,老 render/analyze 还能用
  389. const primary = result.accounts[0];
  390. if (primary) {
  391. result.share_url = primary.share_url;
  392. result.share_type = primary.share_type;
  393. result.video = primary.video;
  394. result.comments = primary.comments;
  395. result.user_posts = primary.user_posts;
  396. result.user_profile = primary.user_profile;
  397. result.searches = primary.searches || {};
  398. }
  399. return result;
  400. }
  401. module.exports = { collectDouyinForProduct, collectSingleAccount, normalizeAccountSpecs, loadCookie, COOKIE_PATH };