collect-jiangzhong-liver-deep.js 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. #!/usr/bin/env node
  2. /**
  3. * 江中肝纯片 VOC 深度评论采集(多平台×多假设×多关键词)
  4. *
  5. * 对标 docs/jiangzhong/2.数据采集矩阵.md 定义的 42 个关键词,
  6. * 在小红书 / 抖音 / Amazon 三平台跑到评论颗粒度。
  7. *
  8. * 特性:
  9. * - 分批次执行 (--batch=1/2/3/all), 每批内按平台并行
  10. * - 每关键词独立 JSON 落盘到 docs/jiangzhong/raw/{platform}/{kw}.json
  11. * - 断点续跑:已存在文件跳过,--force 强制重跑
  12. * - 3 次重试 + 指数退避,适应 TikHub 瞬时 400/超时
  13. * - 审计日志 docs/jiangzhong/raw/audit.log 每关键词一行
  14. * - --merge 合并所有关键词 → _merged.json + comments-flat.jsonl
  15. */
  16. const fs = require('fs');
  17. const path = require('path');
  18. const os = require('os');
  19. const https = require('https');
  20. // ============================================================
  21. // 配置
  22. // ============================================================
  23. const ROOT = path.resolve(__dirname, '..', '..');
  24. const RAW_DIR = path.join(ROOT, 'docs', 'jiangzhong', 'raw');
  25. const XHS_DIR = path.join(RAW_DIR, 'xhs');
  26. const DY_DIR = path.join(RAW_DIR, 'douyin');
  27. const AMZ_DIR = path.join(RAW_DIR, 'amazon');
  28. const AUDIT_LOG = path.join(RAW_DIR, 'audit.log');
  29. // 确保目录存在
  30. [RAW_DIR, XHS_DIR, DY_DIR, AMZ_DIR].forEach((d) => {
  31. if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
  32. });
  33. // TikHub token(与 xiaohongshu-search-notes skill 共用)
  34. const TIKHUB_TOKEN = (() => {
  35. const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  36. if (!fs.existsSync(p)) {
  37. console.error('❌ TikHub 凭据不存在: ' + p);
  38. console.error(' 请先部署 xiaohongshu-search-notes skill 到 ~/.openclaw/skills/');
  39. process.exit(2);
  40. }
  41. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  42. return c.endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
  43. })();
  44. // 抖音 cookie(可选,无则自动降级到仅 share_url 解析)
  45. const DY_COOKIE = (() => {
  46. const p = path.join(ROOT, 'data', 'douyin-cookie.txt');
  47. if (!fs.existsSync(p)) return '';
  48. return fs.readFileSync(p, 'utf-8').trim()
  49. .split('\n').filter((l) => l && !l.startsWith('#')).join('; ').trim();
  50. })();
  51. // VOC Token(抖音搜索/评论 skill 共用,走 server.fmode.cn/api/voc-social/)
  52. const VOC_TOKEN = (() => {
  53. const p = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  54. if (!fs.existsSync(p)) return '';
  55. try {
  56. const c = JSON.parse(fs.readFileSync(p, 'utf-8'));
  57. return c.vocToken || c.sessionToken || '';
  58. } catch (e) { return ''; }
  59. })();
  60. // ============================================================
  61. // 关键词矩阵(对标 2.数据采集矩阵.md)
  62. // ============================================================
  63. const BATCHES = {
  64. 1: {
  65. name: '本品 & 核心竞品',
  66. xhs: [
  67. { kw: '肝纯片', notes: 5, commentPages: 2, hypotheses: ['H1', 'H2'] },
  68. { kw: '江中肝纯片', notes: 5, commentPages: 2, hypotheses: ['H1'] },
  69. { kw: '护肝片', notes: 5, commentPages: 2, hypotheses: ['H3', 'H4'] },
  70. { kw: 'swisse护肝片', notes: 5, commentPages: 2, hypotheses: ['H5', 'H7'] },
  71. { kw: '易善复', notes: 5, commentPages: 2, hypotheses: ['H3', 'H5'] },
  72. { kw: '葵花护肝片', notes: 5, commentPages: 2, hypotheses: ['H3'] },
  73. { kw: '海王金樽', notes: 5, commentPages: 2, hypotheses: ['H2'] },
  74. { kw: '解酒神器', notes: 5, commentPages: 2, hypotheses: ['H2'] },
  75. ],
  76. douyin: [
  77. { kw: '肝纯片', videos: 2, commentPages: 2, hypotheses: ['H1', 'H2'] },
  78. { kw: '江中肝纯片', videos: 2, commentPages: 2, hypotheses: ['H1'] },
  79. { kw: '护肝片', videos: 2, commentPages: 2, hypotheses: ['H3', 'H4'] },
  80. { kw: '解酒神器', videos: 2, commentPages: 2, hypotheses: ['H2'] },
  81. { kw: '海王金樽', videos: 2, commentPages: 2, hypotheses: ['H2'] },
  82. ],
  83. amazon: [
  84. { kw: 'milk thistle', pages: 3, topDetail: 6, topReviews: 5, hypotheses: ['H5', 'H8'] },
  85. { kw: 'silymarin', pages: 3, topDetail: 6, topReviews: 5, hypotheses: ['H5', 'H8'] },
  86. { kw: 'liver support supplement', pages: 3, topDetail: 6, topReviews: 5, hypotheses: ['H8'] },
  87. { kw: 'dihydromyricetin', pages: 3, topDetail: 6, topReviews: 5, hypotheses: ['H1'] },
  88. ],
  89. },
  90. 2: {
  91. name: '场景 & 人群',
  92. xhs: [
  93. { kw: '脂肪肝', notes: 4, commentPages: 2, hypotheses: ['H4'] },
  94. { kw: '熬夜护肝', notes: 4, commentPages: 2, hypotheses: ['H4'] },
  95. { kw: '应酬解酒', notes: 4, commentPages: 2, hypotheses: ['H2'] },
  96. { kw: '送长辈保健品', notes: 4, commentPages: 2, hypotheses: ['H6', 'H7'] },
  97. { kw: '水飞蓟', notes: 4, commentPages: 2, hypotheses: ['H5', 'H3'] },
  98. { kw: '奶蓟草', notes: 4, commentPages: 2, hypotheses: ['H5'] },
  99. { kw: '片仔癀护肝', notes: 4, commentPages: 2, hypotheses: ['H5'] },
  100. { kw: '汤臣倍健护肝', notes: 4, commentPages: 2, hypotheses: ['H5'] },
  101. { kw: '酒局必备', notes: 4, commentPages: 2, hypotheses: ['H2'] },
  102. { kw: '藤茶', notes: 4, commentPages: 2, hypotheses: ['H1'] },
  103. ],
  104. douyin: [
  105. { kw: '脂肪肝', videos: 2, commentPages: 2, hypotheses: ['H4'] },
  106. { kw: '熬夜伤肝', videos: 2, commentPages: 2, hypotheses: ['H4'] },
  107. { kw: '应酬解酒', videos: 2, commentPages: 2, hypotheses: ['H2'] },
  108. ],
  109. amazon: [
  110. { kw: 'liver detox', pages: 3, topDetail: 5, topReviews: 5, hypotheses: ['H8'] },
  111. { kw: 'hangover pills', pages: 3, topDetail: 5, topReviews: 5, hypotheses: ['H2', 'H8'] },
  112. ],
  113. },
  114. 3: {
  115. name: '长尾',
  116. xhs: [
  117. { kw: '二氢杨梅素', notes: 3, commentPages: 1, hypotheses: ['H1'] },
  118. { kw: '熊胆粉', notes: 3, commentPages: 1, hypotheses: ['H5'] },
  119. { kw: '保健品礼盒', notes: 3, commentPages: 1, hypotheses: ['H7'] },
  120. { kw: '父母保健品', notes: 3, commentPages: 1, hypotheses: ['H6'] },
  121. { kw: '养肝', notes: 3, commentPages: 1, hypotheses: ['H4'] },
  122. { kw: '肝不好', notes: 3, commentPages: 1, hypotheses: ['H4'] },
  123. { kw: '酒后护肝', notes: 3, commentPages: 1, hypotheses: ['H2'] },
  124. { kw: '进口保健品礼盒', notes: 3, commentPages: 1, hypotheses: ['H7'] },
  125. { kw: '保肝片', notes: 3, commentPages: 1, hypotheses: ['H3'] },
  126. { kw: '肝损伤', notes: 3, commentPages: 1, hypotheses: ['H4'] },
  127. ],
  128. douyin: [],
  129. amazon: [],
  130. },
  131. 4: {
  132. name: '竞品深度补采',
  133. xhs: [
  134. // Swisse 护肝片 —— 需要更多正面/负面体验帖
  135. { kw: 'swisse护肝片 测评', notes: 8, commentPages: 3, hypotheses: ['H5'] },
  136. { kw: 'swisse护肝片 有用吗', notes: 8, commentPages: 3, hypotheses: ['H5'] },
  137. { kw: 'swisse奶蓟草 效果', notes: 6, commentPages: 3, hypotheses: ['H5'] },
  138. // 葵花护肝片 —— 国民品牌评价
  139. { kw: '葵花护肝片 效果', notes: 8, commentPages: 3, hypotheses: ['H3'] },
  140. { kw: '葵花护肝片 副作用', notes: 6, commentPages: 3, hypotheses: ['H3'] },
  141. // 海王金樽 —— 解酒品类一哥
  142. { kw: '海王金樽 有用吗', notes: 8, commentPages: 3, hypotheses: ['H2'] },
  143. { kw: '海王金樽 测评', notes: 6, commentPages: 3, hypotheses: ['H2'] },
  144. { kw: '解酒药 有用吗', notes: 6, commentPages: 3, hypotheses: ['H2'] },
  145. // 易善复 —— 处方药用户体验
  146. { kw: '易善复 效果', notes: 8, commentPages: 3, hypotheses: ['H3'] },
  147. { kw: '易善复 副作用', notes: 6, commentPages: 3, hypotheses: ['H3'] },
  148. { kw: '多烯磷脂酰胆碱 测评', notes: 5, commentPages: 2, hypotheses: ['H3'] },
  149. // 片仔癀 —— 高端护肝品牌
  150. { kw: '片仔癀 护肝 效果', notes: 8, commentPages: 3, hypotheses: ['H5'] },
  151. { kw: '片仔癀 值得买吗', notes: 6, commentPages: 3, hypotheses: ['H5'] },
  152. // 汤臣倍健 —— 保健品大牌
  153. { kw: '汤臣倍健护肝片 测评', notes: 6, commentPages: 2, hypotheses: ['H5'] },
  154. ],
  155. douyin: [
  156. { kw: 'swisse护肝片', videos: 3, commentPages: 3, hypotheses: ['H5'] },
  157. { kw: '葵花护肝片', videos: 3, commentPages: 3, hypotheses: ['H3'] },
  158. { kw: '海王金樽', videos: 3, commentPages: 3, hypotheses: ['H2'] },
  159. { kw: '易善复', videos: 3, commentPages: 3, hypotheses: ['H3'] },
  160. { kw: '片仔癀护肝', videos: 3, commentPages: 3, hypotheses: ['H5'] },
  161. ],
  162. amazon: [],
  163. },
  164. };
  165. // ============================================================
  166. // 通用工具
  167. // ============================================================
  168. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  169. function sanitizeFilename(kw) {
  170. return kw.replace(/[\/\\:*?"<>|\s]+/g, '-');
  171. }
  172. function auditLog(line) {
  173. const ts = new Date().toISOString();
  174. fs.appendFileSync(AUDIT_LOG, `[${ts}] ${line}\n`);
  175. }
  176. function fileExistsNonEmpty(p) {
  177. if (!fs.existsSync(p)) return false;
  178. const st = fs.statSync(p);
  179. return st.size > 50;
  180. }
  181. function httpRequest(options, bodyData = null, maxAttempts = 3) {
  182. return new Promise(async (resolve, reject) => {
  183. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  184. try {
  185. const result = await new Promise((res, rej) => {
  186. const req = https.request(options, (response) => {
  187. const chunks = [];
  188. response.on('data', (c) => chunks.push(c));
  189. response.on('end', () => res({
  190. status: response.statusCode,
  191. body: Buffer.concat(chunks).toString('utf-8'),
  192. }));
  193. });
  194. req.on('error', rej);
  195. req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
  196. if (bodyData) req.write(bodyData);
  197. req.end();
  198. });
  199. if (result.status === 200) return resolve(result);
  200. if ([400, 429, 500, 502, 503, 504].includes(result.status) && attempt < maxAttempts) {
  201. const wait = 1500 * Math.pow(1.8, attempt - 1);
  202. await sleep(wait);
  203. continue;
  204. }
  205. return resolve(result);
  206. } catch (e) {
  207. if (attempt === maxAttempts) return reject(e);
  208. await sleep(2000 * attempt);
  209. }
  210. }
  211. });
  212. }
  213. async function tikhubGet(apiPath, paramsObj = {}) {
  214. const qs = Object.entries(paramsObj)
  215. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  216. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  217. .join('&');
  218. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  219. const { status, body } = await httpRequest({
  220. hostname: 'server.fmode.cn',
  221. path: fullPath,
  222. method: 'GET',
  223. headers: {
  224. Accept: 'application/json',
  225. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  226. },
  227. timeout: 60000,
  228. });
  229. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  230. try { return JSON.parse(body); }
  231. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  232. }
  233. async function dyPostV2(apiPath, bodyObj = {}) {
  234. const bodyStr = JSON.stringify(bodyObj);
  235. const headers = {
  236. Accept: 'application/json',
  237. 'Content-Type': 'application/json',
  238. 'Content-Length': Buffer.byteLength(bodyStr),
  239. };
  240. if (VOC_TOKEN) headers.Authorization = `Bearer ${VOC_TOKEN}`;
  241. const { status, body } = await httpRequest({
  242. hostname: 'server.fmode.cn',
  243. path: apiPath,
  244. method: 'POST',
  245. headers,
  246. timeout: 60000,
  247. }, bodyStr);
  248. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  249. try { return JSON.parse(body); }
  250. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  251. }
  252. async function dyGetV3(apiPath, paramsObj = {}) {
  253. const qs = Object.entries(paramsObj)
  254. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  255. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  256. .join('&');
  257. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  258. const headers = { Accept: 'application/json' };
  259. if (VOC_TOKEN) headers.Authorization = `Bearer ${VOC_TOKEN}`;
  260. const { status, body } = await httpRequest({
  261. hostname: 'server.fmode.cn',
  262. path: fullPath,
  263. method: 'GET',
  264. headers,
  265. timeout: 60000,
  266. });
  267. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  268. try { return JSON.parse(body); }
  269. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  270. }
  271. async function sorftimeCall(apiPath, body = {}, domain = 1) {
  272. const bodyStr = JSON.stringify({ path: apiPath, method: 'POST', body, query: { domain } });
  273. const { status, body: respBody } = await httpRequest({
  274. hostname: 'server-msq.fmode.cn',
  275. path: '/api/sorftime/forward',
  276. method: 'POST',
  277. headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) },
  278. timeout: 60000,
  279. }, bodyStr);
  280. if (status !== 200) return { _error: { status, body: respBody.slice(0, 200) } };
  281. try {
  282. const j = JSON.parse(respBody);
  283. return j?.Data ?? j?.data ?? j;
  284. } catch (e) { return { _error: { parse: e.message } }; }
  285. }
  286. // ============================================================
  287. // 小红书采集(单关键词深度)
  288. // ============================================================
  289. async function collectXhsForKeyword(spec, { force = false } = {}) {
  290. const outPath = path.join(XHS_DIR, `${sanitizeFilename(spec.kw)}.json`);
  291. if (!force && fileExistsNonEmpty(outPath)) {
  292. console.log(` [xhs] ⏭️ ${spec.kw} (已存在, skip)`);
  293. auditLog(`xhs SKIP ${spec.kw} (exists)`);
  294. return { skipped: true, path: outPath };
  295. }
  296. console.log(` [xhs] 🔍 搜索 "${spec.kw}" · notes=${spec.notes} × commentPages=${spec.commentPages}`);
  297. const startTs = Date.now();
  298. // 1) 综合 + 最热 两种排序各抓一页,取并集提高笔记质量
  299. const searchRes1 = await tikhubGet(
  300. '/thapi/v1/xiaohongshu/app/search_notes',
  301. { keyword: spec.kw, page: 1, sort: 'general' },
  302. );
  303. await sleep(600);
  304. const searchRes2 = await tikhubGet(
  305. '/thapi/v1/xiaohongshu/app/search_notes',
  306. { keyword: spec.kw, page: 1, sort: 'popularity_descending' },
  307. );
  308. const items1 = searchRes1?.data?.data?.items || [];
  309. const items2 = searchRes2?.data?.data?.items || [];
  310. const noteMap = new Map();
  311. for (const it of [...items1, ...items2]) {
  312. const n = it?.note;
  313. if (n?.id && !noteMap.has(n.id)) noteMap.set(n.id, n);
  314. }
  315. const allNotes = Array.from(noteMap.values());
  316. console.log(` [xhs] 去重后 ${allNotes.length} 条笔记 (综合 ${items1.length} + 最热 ${items2.length})`);
  317. if (allNotes.length === 0) {
  318. const errOut = { keyword: spec.kw, _error: searchRes1?._error || searchRes2?._error || 'empty', collected_at: new Date().toISOString() };
  319. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  320. auditLog(`xhs FAIL ${spec.kw} empty/err=${JSON.stringify(errOut._error).slice(0, 80)}`);
  321. return { skipped: false, notes: 0, comments: 0, path: outPath };
  322. }
  323. // 2) 按互动数/评论数排序,取 Top K
  324. const topNotes = [...allNotes]
  325. .sort((a, b) => {
  326. const sa = (a.comments_count || 0) * 2 + (a.liked_count || 0);
  327. const sb = (b.comments_count || 0) * 2 + (b.liked_count || 0);
  328. return sb - sa;
  329. })
  330. .slice(0, spec.notes);
  331. // 3) 抓每条 Top 笔记的评论(多页)
  332. const commentsByNoteId = {};
  333. let totalComments = 0;
  334. for (const note of topNotes) {
  335. if (!note?.id) continue;
  336. const noteId = note.id;
  337. commentsByNoteId[noteId] = [];
  338. let cursor = '';
  339. for (let p = 0; p < spec.commentPages; p++) {
  340. await sleep(700);
  341. const cRes = await tikhubGet(
  342. '/thapi/v1/xiaohongshu/app/get_note_comments',
  343. { note_id: noteId, cursor },
  344. );
  345. const cmts = cRes?.data?.data?.comments || [];
  346. // 保存精简字段,供后续合并
  347. for (const c of cmts) {
  348. commentsByNoteId[noteId].push({
  349. id: c.id,
  350. content: c.content,
  351. create_time: c.create_time,
  352. like_count: c.like_count,
  353. sub_comment_count: c.sub_comment_count,
  354. ip_location: c.ip_location,
  355. user: c.user_info ? {
  356. user_id: c.user_info.user_id,
  357. nickname: c.user_info.nickname,
  358. } : null,
  359. sub_comments: (c.sub_comments || []).slice(0, 3).map((s) => ({
  360. content: s.content,
  361. like_count: s.like_count,
  362. nickname: s.user_info?.nickname,
  363. })),
  364. });
  365. }
  366. totalComments += cmts.length;
  367. const hasMore = cRes?.data?.data?.has_more;
  368. cursor = cRes?.data?.data?.cursor || '';
  369. if (!hasMore || !cursor) break;
  370. }
  371. }
  372. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  373. console.log(` [xhs] ✓ ${spec.kw}: ${topNotes.length} 笔记 / ${totalComments} 评论 (${elapsed}s)`);
  374. auditLog(`xhs OK ${spec.kw} notes=${topNotes.length} comments=${totalComments} (${elapsed}s)`);
  375. const out = {
  376. platform: 'xiaohongshu',
  377. keyword: spec.kw,
  378. hypotheses: spec.hypotheses,
  379. collected_at: new Date().toISOString(),
  380. elapsed_seconds: Number(elapsed),
  381. total_notes_found: allNotes.length,
  382. top_notes: topNotes.map((n) => ({
  383. id: n.id,
  384. type: n.type,
  385. title: n.title,
  386. desc: n.desc,
  387. timestamp: n.timestamp,
  388. liked_count: n.liked_count,
  389. comments_count: n.comments_count,
  390. collected_count: n.collected_count,
  391. shared_count: n.shared_count,
  392. cover: n.images_list?.[0]?.url,
  393. user: n.user ? {
  394. userid: n.user.userid,
  395. nickname: n.user.nickname,
  396. red_id: n.user.red_id,
  397. verified: n.user.red_official_verified,
  398. } : null,
  399. })),
  400. comments_by_note_id: commentsByNoteId,
  401. total_comments: totalComments,
  402. };
  403. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  404. return { skipped: false, notes: topNotes.length, comments: totalComments, path: outPath };
  405. }
  406. // ============================================================
  407. // 抖音采集(单关键词搜索 + Top 视频评论)
  408. // ============================================================
  409. async function collectDouyinForKeyword(spec, { force = false } = {}) {
  410. const outPath = path.join(DY_DIR, `${sanitizeFilename(spec.kw)}.json`);
  411. if (!force && fileExistsNonEmpty(outPath)) {
  412. console.log(` [dy] ⏭️ ${spec.kw} (已存在, skip)`);
  413. auditLog(`dy SKIP ${spec.kw} (exists)`);
  414. return { skipped: true, path: outPath };
  415. }
  416. console.log(` [dy] 🔍 搜索 "${spec.kw}" · videos=${spec.videos} × commentPages=${spec.commentPages}`);
  417. const startTs = Date.now();
  418. // 抖音搜索 v2:POST /api/voc-social/douyin/search/fetch_general_search_v2
  419. // 响应结构: { code, data: { business_data: [{data: {aweme_info: {...}}}], has_more, cursor } }
  420. const searchRes = await dyPostV2('/api/voc-social/douyin/search/fetch_general_search_v2', {
  421. keyword: spec.kw,
  422. cursor: 0,
  423. sort_type: '1', // 最多点赞
  424. publish_time: '0', // 不限
  425. content_type: '1', // 视频
  426. filter_duration: '0',
  427. search_id: '',
  428. backtrace: '',
  429. });
  430. // 从 business_data 中抽出 aweme_info
  431. const businessData = searchRes?.data?.business_data || [];
  432. const validVideos = businessData
  433. .map((wrap) => wrap?.data?.aweme_info || wrap?.aweme_info)
  434. .filter((v) => v?.aweme_id);
  435. console.log(` [dy] 搜索到 ${validVideos.length} 条视频 (business_data wrappers=${businessData.length})`);
  436. if (validVideos.length === 0) {
  437. const errOut = {
  438. platform: 'douyin',
  439. keyword: spec.kw,
  440. _search_error: searchRes?._error || searchRes?.mess || 'empty',
  441. _search_code: searchRes?.code,
  442. _search_sample: JSON.stringify(searchRes).slice(0, 500),
  443. collected_at: new Date().toISOString(),
  444. };
  445. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  446. auditLog(`dy FAIL ${spec.kw} search_empty code=${searchRes?.code}`);
  447. return { skipped: false, videos: 0, comments: 0, path: outPath };
  448. }
  449. // 按评论数排序取 Top K
  450. const topVideos = [...validVideos]
  451. .sort((a, b) => (b.statistics?.comment_count || 0) - (a.statistics?.comment_count || 0))
  452. .slice(0, spec.videos);
  453. // 对每条 Top 视频抓评论
  454. // 响应: { code, data: { comments: [...], cursor, has_more } }
  455. const commentsByAweme = {};
  456. let totalComments = 0;
  457. for (const v of topVideos) {
  458. const awemeId = v.aweme_id;
  459. commentsByAweme[awemeId] = [];
  460. let cursor = 0;
  461. for (let p = 0; p < spec.commentPages; p++) {
  462. await sleep(700);
  463. const cRes = await dyGetV3('/api/voc-social/douyin/app/v3/fetch_video_comments', {
  464. aweme_id: awemeId,
  465. cursor,
  466. count: 20,
  467. });
  468. const cmts = cRes?.data?.comments || cRes?.comments || [];
  469. for (const c of cmts) {
  470. commentsByAweme[awemeId].push({
  471. cid: c.cid,
  472. text: c.text,
  473. digg_count: c.digg_count,
  474. create_time: c.create_time,
  475. ip_label: c.ip_label,
  476. reply_comment_total: c.reply_comment_total,
  477. user: c.user ? {
  478. nickname: c.user.nickname,
  479. uid: c.user.uid,
  480. } : null,
  481. });
  482. }
  483. totalComments += cmts.length;
  484. const hasMore = (cRes?.data?.has_more ?? cRes?.has_more) === 1;
  485. cursor = cRes?.data?.cursor ?? cRes?.cursor ?? 0;
  486. if (!hasMore) break;
  487. }
  488. }
  489. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  490. console.log(` [dy] ✓ ${spec.kw}: ${topVideos.length} 视频 / ${totalComments} 评论 (${elapsed}s)`);
  491. auditLog(`dy OK ${spec.kw} videos=${topVideos.length} comments=${totalComments} (${elapsed}s)`);
  492. const out = {
  493. platform: 'douyin',
  494. keyword: spec.kw,
  495. hypotheses: spec.hypotheses,
  496. collected_at: new Date().toISOString(),
  497. elapsed_seconds: Number(elapsed),
  498. total_videos_found: validVideos.length,
  499. top_videos: topVideos.map((v) => ({
  500. aweme_id: v.aweme_id,
  501. desc: v.desc,
  502. create_time: v.create_time,
  503. statistics: v.statistics,
  504. author: v.author ? {
  505. nickname: v.author.nickname,
  506. sec_uid: v.author.sec_uid,
  507. uid: v.author.uid,
  508. follower_count: v.author.follower_count,
  509. } : null,
  510. cover: v.video?.cover?.url_list?.[0],
  511. text_extra: (v.text_extra || []).map((t) => t.hashtag_name).filter(Boolean),
  512. })),
  513. comments_by_aweme_id: commentsByAweme,
  514. total_comments: totalComments,
  515. };
  516. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  517. return { skipped: false, videos: topVideos.length, comments: totalComments, path: outPath };
  518. }
  519. // ============================================================
  520. // Amazon 采集(Sorftime:多页产品 + Top 评论)
  521. // ============================================================
  522. async function collectAmazonForKeyword(spec, { force = false } = {}) {
  523. const outPath = path.join(AMZ_DIR, `${sanitizeFilename(spec.kw)}.json`);
  524. if (!force && fileExistsNonEmpty(outPath)) {
  525. console.log(` [amz] ⏭️ ${spec.kw} (已存在, skip)`);
  526. auditLog(`amz SKIP ${spec.kw} (exists)`);
  527. return { skipped: true, path: outPath };
  528. }
  529. console.log(` [amz] 🔎 "${spec.kw}" · pages=${spec.pages} × topDetail=${spec.topDetail} × topReviews=${spec.topReviews}`);
  530. const startTs = Date.now();
  531. const allProducts = [];
  532. for (let page = 1; page <= spec.pages; page++) {
  533. const r = await sorftimeCall('/api/ProductQuery', {
  534. Page: page, Query: '1', QueryType: '7', Pattern: spec.kw,
  535. });
  536. if (r?._error) {
  537. console.log(` [amz] p${page} 失败: ${JSON.stringify(r._error).slice(0, 120)}`);
  538. break;
  539. }
  540. const prods = r?.Products || [];
  541. allProducts.push(...prods);
  542. console.log(` [amz] p${page} +${prods.length} · total=${r?.PageCount || '?'}`);
  543. if (!prods.length) break;
  544. await sleep(500);
  545. }
  546. // 去重(按 ASIN 取 SalesVolume 最大的那条)
  547. const asinMap = new Map();
  548. for (const p of allProducts) {
  549. if (!p.Asin) continue;
  550. const cur = asinMap.get(p.Asin);
  551. if (!cur || (p.ListingSalesVolumeOfMonth || 0) > (cur.ListingSalesVolumeOfMonth || 0)) {
  552. asinMap.set(p.Asin, p);
  553. }
  554. }
  555. const uniqueProducts = Array.from(asinMap.values())
  556. .sort((a, b) => (b.ListingSalesVolumeOfMonth || 0) - (a.ListingSalesVolumeOfMonth || 0));
  557. // Top N 抓详情
  558. const details = {};
  559. for (const p of uniqueProducts.slice(0, spec.topDetail)) {
  560. await sleep(400);
  561. const d = await sorftimeCall('/api/ProductRequest', {
  562. ASIN: p.Asin, Trend: 1, QueryTrendStartDt: '', QueryTrendEndDt: '',
  563. });
  564. if (!d?._error) details[p.Asin] = d;
  565. }
  566. // Top M 抓评论 (Sorftime 返回扁数组:[{ConsumerName, Star, Title, Content, Helpful, ReviewsDate, IsVP, Asin, ReviewsLink, ...}])
  567. const reviewsByAsin = {};
  568. let totalReviews = 0;
  569. for (const p of uniqueProducts.slice(0, spec.topReviews)) {
  570. await sleep(500);
  571. const r = await sorftimeCall('/api/ProductReviewsQuery', { ASIN: p.Asin });
  572. if (!r?._error) {
  573. // 响应有三种形态: 数组 / {Reviews:[...]} / {reviews:[...]}
  574. let arr = Array.isArray(r) ? r : (r?.Reviews || r?.reviews || []);
  575. // Sorftime 用数字 key 的对象(Object.values 可以还原)
  576. if (!Array.isArray(arr) && typeof r === 'object') {
  577. const vals = Object.values(r);
  578. if (vals.length && typeof vals[0] === 'object' && (vals[0].Title || vals[0].Content || vals[0].Star)) {
  579. arr = vals;
  580. }
  581. }
  582. reviewsByAsin[p.Asin] = arr;
  583. totalReviews += arr.length;
  584. }
  585. }
  586. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  587. console.log(` [amz] ✓ ${spec.kw}: ${uniqueProducts.length} 产品 / ${Object.keys(details).length} 详情 / ${totalReviews} 评论 (${elapsed}s)`);
  588. auditLog(`amz OK ${spec.kw} products=${uniqueProducts.length} reviews=${totalReviews} (${elapsed}s)`);
  589. const out = {
  590. platform: 'amazon',
  591. keyword: spec.kw,
  592. hypotheses: spec.hypotheses,
  593. collected_at: new Date().toISOString(),
  594. elapsed_seconds: Number(elapsed),
  595. total_products: uniqueProducts.length,
  596. top_products: uniqueProducts.slice(0, Math.max(spec.topDetail, spec.topReviews)),
  597. product_details: details,
  598. reviews_by_asin: reviewsByAsin,
  599. total_reviews: totalReviews,
  600. };
  601. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  602. return { skipped: false, products: uniqueProducts.length, reviews: totalReviews, path: outPath };
  603. }
  604. // ============================================================
  605. // Batch 执行器
  606. // ============================================================
  607. async function runBatch(batchId, opts = {}) {
  608. const batch = BATCHES[batchId];
  609. if (!batch) throw new Error(`Unknown batch: ${batchId}`);
  610. const only = opts.only; // 'xhs' | 'dy' | 'amz' | null
  611. const runXhs = !only || only === 'xhs';
  612. const runDy = !only || only === 'dy' || only === 'douyin';
  613. const runAmz = !only || only === 'amz' || only === 'amazon';
  614. console.log('\n' + '━'.repeat(60));
  615. console.log(`🦐 Batch ${batchId}: ${batch.name}` + (only ? ` · only=${only}` : ''));
  616. console.log(` XHS=${runXhs ? batch.xhs.length : 'skip'}kw · DY=${runDy ? batch.douyin.length : 'skip'}kw · AMZ=${runAmz ? batch.amazon.length : 'skip'}kw`);
  617. console.log('━'.repeat(60));
  618. auditLog(`=== BATCH ${batchId} START ${batch.name}${only ? ' only=' + only : ''} ===`);
  619. const batchStart = Date.now();
  620. const stats = { xhs: [], douyin: [], amazon: [] };
  621. // 平台间并行 (XHS / DY / AMZ);平台内串行
  622. await Promise.all([
  623. (async () => {
  624. if (!runXhs) return;
  625. for (const spec of batch.xhs) {
  626. try {
  627. const r = await collectXhsForKeyword(spec, opts);
  628. stats.xhs.push({ kw: spec.kw, ...r });
  629. } catch (e) {
  630. console.log(` [xhs] ❌ ${spec.kw}: ${e.message}`);
  631. auditLog(`xhs ERR ${spec.kw} ${e.message}`);
  632. stats.xhs.push({ kw: spec.kw, error: e.message });
  633. }
  634. await sleep(500);
  635. }
  636. })(),
  637. (async () => {
  638. if (!runDy) return;
  639. for (const spec of batch.douyin) {
  640. try {
  641. const r = await collectDouyinForKeyword(spec, opts);
  642. stats.douyin.push({ kw: spec.kw, ...r });
  643. } catch (e) {
  644. console.log(` [dy] ❌ ${spec.kw}: ${e.message}`);
  645. auditLog(`dy ERR ${spec.kw} ${e.message}`);
  646. stats.douyin.push({ kw: spec.kw, error: e.message });
  647. }
  648. await sleep(500);
  649. }
  650. })(),
  651. (async () => {
  652. if (!runAmz) return;
  653. for (const spec of batch.amazon) {
  654. try {
  655. const r = await collectAmazonForKeyword(spec, opts);
  656. stats.amazon.push({ kw: spec.kw, ...r });
  657. } catch (e) {
  658. console.log(` [amz] ❌ ${spec.kw}: ${e.message}`);
  659. auditLog(`amz ERR ${spec.kw} ${e.message}`);
  660. stats.amazon.push({ kw: spec.kw, error: e.message });
  661. }
  662. await sleep(500);
  663. }
  664. })(),
  665. ]);
  666. const elapsed = ((Date.now() - batchStart) / 1000).toFixed(1);
  667. console.log(`\n🦐 Batch ${batchId} 完成 (${elapsed}s)`);
  668. const xhsCmts = stats.xhs.reduce((s, x) => s + (x.comments || 0), 0);
  669. const dyCmts = stats.douyin.reduce((s, x) => s + (x.comments || 0), 0);
  670. const amzRev = stats.amazon.reduce((s, x) => s + (x.reviews || 0), 0);
  671. console.log(` xhs: ${stats.xhs.filter(x => !x.error).length}/${batch.xhs.length} kw · ${xhsCmts} 评论`);
  672. console.log(` dy : ${stats.douyin.filter(x => !x.error).length}/${batch.douyin.length} kw · ${dyCmts} 评论`);
  673. console.log(` amz: ${stats.amazon.filter(x => !x.error).length}/${batch.amazon.length} kw · ${amzRev} 评论`);
  674. auditLog(`=== BATCH ${batchId} END xhsCmts=${xhsCmts} dyCmts=${dyCmts} amzRev=${amzRev} (${elapsed}s) ===`);
  675. return stats;
  676. }
  677. // ============================================================
  678. // 合并
  679. // ============================================================
  680. function mergeAll() {
  681. console.log('\n📦 合并所有关键词 → _merged.json + comments-flat.jsonl');
  682. const merged = {
  683. product: '江中肝纯片',
  684. collected_at: new Date().toISOString(),
  685. xhs: {},
  686. douyin: {},
  687. amazon: {},
  688. stats: {},
  689. };
  690. const flatPath = path.join(RAW_DIR, 'comments-flat.jsonl');
  691. const flatStream = fs.createWriteStream(flatPath);
  692. let xhsCmts = 0, dyCmts = 0, amzCmts = 0;
  693. let xhsNotes = 0, dyVideos = 0, amzProducts = 0;
  694. // XHS
  695. for (const f of fs.readdirSync(XHS_DIR).filter((f) => f.endsWith('.json'))) {
  696. const d = JSON.parse(fs.readFileSync(path.join(XHS_DIR, f), 'utf-8'));
  697. merged.xhs[d.keyword] = d;
  698. xhsNotes += (d.top_notes?.length || 0);
  699. const cmtMap = d.comments_by_note_id || {};
  700. for (const [noteId, cmts] of Object.entries(cmtMap)) {
  701. for (const c of cmts) {
  702. xhsCmts++;
  703. flatStream.write(JSON.stringify({
  704. platform: 'xhs',
  705. keyword: d.keyword,
  706. hypotheses: d.hypotheses,
  707. note_id: noteId,
  708. cid: c.id,
  709. content: c.content,
  710. like: c.like_count,
  711. sub_count: c.sub_comment_count,
  712. ip: c.ip_location,
  713. user: c.user?.nickname,
  714. }) + '\n');
  715. }
  716. }
  717. }
  718. // Douyin
  719. for (const f of fs.readdirSync(DY_DIR).filter((f) => f.endsWith('.json'))) {
  720. const d = JSON.parse(fs.readFileSync(path.join(DY_DIR, f), 'utf-8'));
  721. merged.douyin[d.keyword] = d;
  722. dyVideos += (d.top_videos?.length || 0);
  723. const cmtMap = d.comments_by_aweme_id || {};
  724. for (const [awemeId, cmts] of Object.entries(cmtMap)) {
  725. for (const c of cmts) {
  726. dyCmts++;
  727. flatStream.write(JSON.stringify({
  728. platform: 'douyin',
  729. keyword: d.keyword,
  730. hypotheses: d.hypotheses,
  731. aweme_id: awemeId,
  732. cid: c.cid,
  733. content: c.text,
  734. like: c.digg_count,
  735. ip: c.ip_label,
  736. user: c.user?.nickname,
  737. }) + '\n');
  738. }
  739. }
  740. }
  741. // Amazon (Sorftime 返回的扁数组:{ConsumerName, Star, Title, Content, Helpful, ReviewsDate, IsVP, Asin, ReviewsLink, AsinProperty})
  742. for (const f of fs.readdirSync(AMZ_DIR).filter((f) => f.endsWith('.json'))) {
  743. const d = JSON.parse(fs.readFileSync(path.join(AMZ_DIR, f), 'utf-8'));
  744. merged.amazon[d.keyword] = d;
  745. amzProducts += (d.top_products?.length || 0);
  746. for (const [asin, rObj] of Object.entries(d.reviews_by_asin || {})) {
  747. // 支持 3 种格式:直接数组 / {Reviews:[]} / {'0':{...},'1':{...}}
  748. let reviews = [];
  749. if (Array.isArray(rObj)) {
  750. reviews = rObj;
  751. } else if (rObj?.Reviews || rObj?.reviews) {
  752. reviews = rObj.Reviews || rObj.reviews;
  753. } else if (typeof rObj === 'object') {
  754. const vals = Object.values(rObj);
  755. if (vals.length && typeof vals[0] === 'object' && (vals[0].Title || vals[0].Content || vals[0].Star)) {
  756. reviews = vals;
  757. }
  758. }
  759. for (const r of reviews) {
  760. amzCmts++;
  761. flatStream.write(JSON.stringify({
  762. platform: 'amazon',
  763. keyword: d.keyword,
  764. hypotheses: d.hypotheses,
  765. asin: r.Asin || asin,
  766. parent_asin: asin,
  767. title: r.Title,
  768. content: r.Content,
  769. rating: r.Star,
  770. verified: r.IsVP,
  771. helpful: r.Helpful,
  772. date: r.ReviewsDate,
  773. reviewer: r.ConsumerName,
  774. review_link: r.ReviewsLink,
  775. variant: r.AsinProperty,
  776. }) + '\n');
  777. }
  778. }
  779. }
  780. flatStream.end();
  781. merged.stats = {
  782. xhs: { keywords: Object.keys(merged.xhs).length, notes: xhsNotes, comments: xhsCmts },
  783. douyin: { keywords: Object.keys(merged.douyin).length, videos: dyVideos, comments: dyCmts },
  784. amazon: { keywords: Object.keys(merged.amazon).length, products: amzProducts, reviews: amzCmts },
  785. total_comments: xhsCmts + dyCmts + amzCmts,
  786. };
  787. const mergedPath = path.join(RAW_DIR, '_merged.json');
  788. fs.writeFileSync(mergedPath, JSON.stringify(merged, null, 2), 'utf-8');
  789. console.log(`\n✓ 合并完成:`);
  790. console.log(` XHS : ${merged.stats.xhs.keywords} kw · ${xhsNotes} 笔记 · ${xhsCmts} 评论`);
  791. console.log(` Douyin : ${merged.stats.douyin.keywords} kw · ${dyVideos} 视频 · ${dyCmts} 评论`);
  792. console.log(` Amazon : ${merged.stats.amazon.keywords} kw · ${amzProducts} 产品 · ${amzCmts} 评论`);
  793. console.log(` TOTAL : ${merged.stats.total_comments} 条评论\n`);
  794. console.log(` → ${mergedPath}`);
  795. console.log(` → ${flatPath}`);
  796. auditLog(`=== MERGE xhs=${xhsCmts} dy=${dyCmts} amz=${amzCmts} total=${merged.stats.total_comments} ===`);
  797. return merged;
  798. }
  799. // ============================================================
  800. // CLI
  801. // ============================================================
  802. function parseArgs() {
  803. const args = { batch: 'all', force: false, merge: false, only: null };
  804. for (const a of process.argv.slice(2)) {
  805. if (a.startsWith('--batch=')) args.batch = a.split('=')[1];
  806. else if (a === '--force') args.force = true;
  807. else if (a === '--merge') args.merge = true;
  808. else if (a.startsWith('--only=')) args.only = a.split('=')[1];
  809. }
  810. return args;
  811. }
  812. async function main() {
  813. const args = parseArgs();
  814. console.log('╔══════════════════════════════════════════════════════════╗');
  815. console.log('║ 江中肝纯片 VOC 深度评论采集 (多平台×42 关键词) ║');
  816. console.log('╚══════════════════════════════════════════════════════════╝');
  817. console.log(` 📅 ${new Date().toLocaleString()}`);
  818. console.log(` 📁 输出: ${RAW_DIR}`);
  819. console.log(` 🔑 TikHub: ${TIKHUB_TOKEN.slice(0, 8)}...`);
  820. console.log(` 🍪 抖音 cookie: ${DY_COOKIE ? `已加载 (${DY_COOKIE.length} chars)` : '未配置'}`);
  821. console.log(` ⚙️ batch=${args.batch} force=${args.force} merge=${args.merge}`);
  822. if (args.merge) {
  823. mergeAll();
  824. return;
  825. }
  826. const targets = args.batch === 'all' ? ['1', '2', '3', '4'] : [args.batch];
  827. for (const b of targets) {
  828. await runBatch(b, { force: args.force, only: args.only });
  829. }
  830. // 默认在 batch=all 或指定时自动合并一次
  831. if (args.batch === 'all') {
  832. mergeAll();
  833. } else {
  834. console.log(`\n💡 提示: 跑完所有批次后执行 \`node scripts/tools/collect-jiangzhong-liver-deep.js --merge\` 合并`);
  835. }
  836. }
  837. if (require.main === module) {
  838. main().catch((e) => {
  839. console.error('❌ 致命错误:', e);
  840. auditLog(`FATAL ${e.message} ${e.stack?.slice(0, 300)}`);
  841. process.exit(1);
  842. });
  843. }
  844. module.exports = { runBatch, mergeAll, BATCHES };