lactic-collect.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793
  1. #!/usr/bin/env node
  2. /**
  3. * 江中乳酸菌素片儿童版 · VOC 多平台采集(真实采集)
  4. *
  5. * 对齐 docs/乳酸菌/3.采集矩阵.md 定义的关键词矩阵,
  6. * 采用 collect-jiangzhong-liver-deep / houguyin-collect 已验证的 fmode.cn 接入模式:
  7. * - 小红书:TikHub · https://server.fmode.cn/thapi/v1/xiaohongshu/app/...
  8. * - 抖音:VOC · https://server.fmode.cn/api/voc-social/douyin/...
  9. *
  10. * 用法:
  11. * node scripts/tools/lactic-collect.js --batch=1
  12. * node scripts/tools/lactic-collect.js --batch=all
  13. * node scripts/tools/lactic-collect.js --merge
  14. */
  15. const fs = require('fs');
  16. const path = require('path');
  17. const os = require('os');
  18. const https = require('https');
  19. // ============================================================
  20. // 路径
  21. // ============================================================
  22. const ROOT = path.resolve(__dirname, '..', '..');
  23. const RAW_DIR = path.join(ROOT, 'docs', '乳酸菌', 'raw');
  24. const XHS_DIR = path.join(RAW_DIR, 'xhs');
  25. const DY_DIR = path.join(RAW_DIR, 'douyin');
  26. const AUDIT_LOG = path.join(RAW_DIR, 'audit.log');
  27. [RAW_DIR, XHS_DIR, DY_DIR].forEach((d) => {
  28. if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
  29. });
  30. // ============================================================
  31. // 凭据
  32. // ============================================================
  33. const TIKHUB_TOKEN = (() => {
  34. const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  35. if (!fs.existsSync(p)) return null;
  36. try {
  37. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  38. return (c.endpoint?.headers?.Authorization || '').replace(/^Bearer\s+/, '');
  39. } catch { return null; }
  40. })();
  41. const VOC_TOKEN = (() => {
  42. const p = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  43. if (!fs.existsSync(p)) return '';
  44. try {
  45. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  46. return c.vocToken || c.sessionToken || '';
  47. } catch { return ''; }
  48. })();
  49. // ============================================================
  50. // 关键词矩阵(对标 3.采集矩阵.md · 聚焦 xhs + douyin 两大主力平台)
  51. // ============================================================
  52. const BATCHES = {
  53. 1: {
  54. name: 'P0 · 本品 + 药品益生菌 + 食品益生菌头部',
  55. xhs: [
  56. // 本品 · H1/H3
  57. { kw: '乳酸菌素片', notes: 6, commentPages: 3, hypotheses: ['H1', 'H3'] },
  58. { kw: '乳酸菌素片儿童', notes: 5, commentPages: 3, hypotheses: ['H1', 'H3', 'H5'] },
  59. { kw: '江中乳酸菌素片', notes: 5, commentPages: 2, hypotheses: ['H1'] },
  60. // 药品益生菌竞品 · H1
  61. { kw: '妈咪爱', notes: 6, commentPages: 3, hypotheses: ['H1'] },
  62. { kw: '妈咪爱 宝宝', notes: 5, commentPages: 2, hypotheses: ['H1', 'H3'] },
  63. { kw: '亿活 儿童', notes: 5, commentPages: 2, hypotheses: ['H1'] },
  64. { kw: '金双岐', notes: 4, commentPages: 2, hypotheses: ['H1'] },
  65. // 食品益生菌头部 · H2
  66. { kw: '合生元益生菌', notes: 6, commentPages: 3, hypotheses: ['H2'] },
  67. { kw: '万益蓝益生菌', notes: 5, commentPages: 3, hypotheses: ['H2'] },
  68. { kw: '拜奥益生菌', notes: 5, commentPages: 2, hypotheses: ['H2'] },
  69. { kw: '儿童益生菌推荐', notes: 6, commentPages: 3, hypotheses: ['H2', 'H3'] },
  70. ],
  71. douyin: [
  72. { kw: '妈咪爱', videos: 4, commentPages: 2, hypotheses: ['H1'] },
  73. { kw: '乳酸菌素片', videos: 4, commentPages: 2, hypotheses: ['H1', 'H3'] },
  74. { kw: '儿童益生菌', videos: 4, commentPages: 2, hypotheses: ['H2', 'H3'] },
  75. { kw: '合生元益生菌', videos: 3, commentPages: 2, hypotheses: ['H2'] },
  76. { kw: '万益蓝益生菌', videos: 3, commentPages: 2, hypotheses: ['H2'] },
  77. ],
  78. },
  79. 2: {
  80. name: 'P1 · 食品长尾 + 儿童肠道刚需',
  81. xhs: [
  82. // 食品益生菌长尾 · H2
  83. { kw: 'inne 噗噗宝', notes: 4, commentPages: 2, hypotheses: ['H2', 'H3'] },
  84. { kw: '小胖瓶益生菌', notes: 4, commentPages: 2, hypotheses: ['H2'] },
  85. { kw: '康萃乐益生菌', notes: 4, commentPages: 2, hypotheses: ['H2'] },
  86. { kw: 'lifespace 儿童', notes: 4, commentPages: 2, hypotheses: ['H2'] },
  87. { kw: '宝乐安', notes: 4, commentPages: 2, hypotheses: ['H1'] },
  88. // 儿童肠道刚需 · H3
  89. { kw: '宝宝便秘', notes: 6, commentPages: 3, hypotheses: ['H3'] },
  90. { kw: '宝宝积食', notes: 6, commentPages: 3, hypotheses: ['H3'] },
  91. { kw: '宝宝腹泻', notes: 5, commentPages: 2, hypotheses: ['H3'] },
  92. { kw: '儿童挑食', notes: 5, commentPages: 2, hypotheses: ['H3'] },
  93. { kw: '抗生素 益生菌', notes: 5, commentPages: 2, hypotheses: ['H4'] },
  94. ],
  95. douyin: [
  96. { kw: '宝宝便秘', videos: 3, commentPages: 2, hypotheses: ['H3'] },
  97. { kw: '宝宝积食', videos: 3, commentPages: 2, hypotheses: ['H3'] },
  98. { kw: '抗生素 益生菌', videos: 3, commentPages: 2, hypotheses: ['H4'] },
  99. ],
  100. },
  101. 3: {
  102. name: 'P2 · 场景 + 决策 + 营养吸收',
  103. xhs: [
  104. // 场景 · H4
  105. { kw: '宝宝入园生病', notes: 5, commentPages: 2, hypotheses: ['H4'] },
  106. { kw: '宝宝换季腹泻', notes: 4, commentPages: 2, hypotheses: ['H4'] },
  107. // 喂药依从性 · H5/H6
  108. { kw: '喂药难', notes: 5, commentPages: 2, hypotheses: ['H5'] },
  109. { kw: '儿童咀嚼片', notes: 4, commentPages: 2, hypotheses: ['H5', 'H8'] },
  110. // 营养吸收 · H7
  111. { kw: '儿童营养软糖', notes: 6, commentPages: 3, hypotheses: ['H7', 'H8'] },
  112. { kw: '儿童DHA', notes: 5, commentPages: 2, hypotheses: ['H7'] },
  113. { kw: '瓶瓶罐罐', notes: 4, commentPages: 2, hypotheses: ['H7'] },
  114. { kw: '儿童补钙', notes: 5, commentPages: 2, hypotheses: ['H7'] },
  115. // 糖分焦虑 · H8
  116. { kw: '儿童无糖', notes: 4, commentPages: 2, hypotheses: ['H8'] },
  117. { kw: '宝宝专用', notes: 4, commentPages: 2, hypotheses: ['H6'] },
  118. ],
  119. douyin: [
  120. { kw: '儿童营养软糖', videos: 3, commentPages: 2, hypotheses: ['H7', 'H8'] },
  121. { kw: '瓶瓶罐罐', videos: 3, commentPages: 2, hypotheses: ['H7'] },
  122. ],
  123. },
  124. };
  125. // ============================================================
  126. // HTTP 工具(对齐 houguyin-collect 的已验证实现)
  127. // ============================================================
  128. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  129. function sanitizeFilename(kw) {
  130. return kw.replace(/[\/\\:*?"<>|\s]+/g, '-');
  131. }
  132. function auditLog(line) {
  133. const ts = new Date().toISOString();
  134. try {
  135. fs.appendFileSync(AUDIT_LOG, `[${ts}] ${line}\n`);
  136. } catch (e) {
  137. if (e.code === 'EBUSY' || e.code === 'EACCES' || e.code === 'EPERM') {
  138. try {
  139. fs.appendFileSync(AUDIT_LOG + '.alt', `[${ts}] ${line}\n`);
  140. } catch { /* ignore */ }
  141. }
  142. }
  143. }
  144. function fileExistsNonEmpty(p) {
  145. if (!fs.existsSync(p)) return false;
  146. return fs.statSync(p).size > 50;
  147. }
  148. function httpRequest(options, bodyData = null, maxAttempts = 3) {
  149. return new Promise(async (resolve, reject) => {
  150. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  151. try {
  152. const result = await new Promise((res, rej) => {
  153. const req = https.request(options, (response) => {
  154. const chunks = [];
  155. response.on('data', (c) => chunks.push(c));
  156. response.on('end', () => res({
  157. status: response.statusCode,
  158. body: Buffer.concat(chunks).toString('utf-8'),
  159. }));
  160. });
  161. req.on('error', rej);
  162. req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
  163. if (bodyData) req.write(bodyData);
  164. req.end();
  165. });
  166. if (result.status === 200) return resolve(result);
  167. if ([400, 429, 500, 502, 503, 504].includes(result.status) && attempt < maxAttempts) {
  168. await sleep(1500 * Math.pow(1.8, attempt - 1));
  169. continue;
  170. }
  171. return resolve(result);
  172. } catch (e) {
  173. if (attempt === maxAttempts) return reject(e);
  174. await sleep(2000 * attempt);
  175. }
  176. }
  177. });
  178. }
  179. async function tikhubGet(apiPath, paramsObj = {}) {
  180. if (!TIKHUB_TOKEN) return { _error: 'no TikHub token' };
  181. const qs = Object.entries(paramsObj)
  182. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  183. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  184. .join('&');
  185. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  186. const { status, body } = await httpRequest({
  187. hostname: 'server.fmode.cn',
  188. path: fullPath,
  189. method: 'GET',
  190. headers: {
  191. Accept: 'application/json',
  192. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  193. },
  194. timeout: 60000,
  195. });
  196. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  197. try { return JSON.parse(body); }
  198. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  199. }
  200. async function dyPostV2(apiPath, bodyObj = {}) {
  201. if (!VOC_TOKEN) return { _error: 'no VOC token' };
  202. const bodyStr = JSON.stringify(bodyObj);
  203. const { status, body } = await httpRequest({
  204. hostname: 'server.fmode.cn',
  205. path: apiPath,
  206. method: 'POST',
  207. headers: {
  208. Accept: 'application/json',
  209. 'Content-Type': 'application/json',
  210. 'Content-Length': Buffer.byteLength(bodyStr),
  211. Authorization: `Bearer ${VOC_TOKEN}`,
  212. },
  213. timeout: 60000,
  214. }, bodyStr);
  215. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  216. try { return JSON.parse(body); }
  217. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  218. }
  219. async function dyGetV3(apiPath, paramsObj = {}) {
  220. if (!VOC_TOKEN) return { _error: 'no VOC token' };
  221. const qs = Object.entries(paramsObj)
  222. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  223. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  224. .join('&');
  225. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  226. const { status, body } = await httpRequest({
  227. hostname: 'server.fmode.cn',
  228. path: fullPath,
  229. method: 'GET',
  230. headers: {
  231. Accept: 'application/json',
  232. Authorization: `Bearer ${VOC_TOKEN}`,
  233. },
  234. timeout: 60000,
  235. });
  236. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  237. try { return JSON.parse(body); }
  238. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  239. }
  240. // ============================================================
  241. // 小红书采集
  242. // ============================================================
  243. async function collectXhs(spec, { force = false } = {}) {
  244. const outPath = path.join(XHS_DIR, `${sanitizeFilename(spec.kw)}.json`);
  245. if (!force && fileExistsNonEmpty(outPath)) {
  246. console.log(` [xhs] ⏭️ ${spec.kw} (已存在, skip)`);
  247. auditLog(`xhs SKIP ${spec.kw} (exists)`);
  248. return { skipped: true, path: outPath };
  249. }
  250. console.log(` [xhs] 🔍 搜索 "${spec.kw}" · notes=${spec.notes} × commentPages=${spec.commentPages}`);
  251. const startTs = Date.now();
  252. const searchRes1 = await tikhubGet('/thapi/v1/xiaohongshu/app/search_notes', { keyword: spec.kw, page: 1, sort: 'general' });
  253. await sleep(600);
  254. const searchRes2 = await tikhubGet('/thapi/v1/xiaohongshu/app/search_notes', { keyword: spec.kw, page: 1, sort: 'popularity_descending' });
  255. const items1 = searchRes1?.data?.data?.items || [];
  256. const items2 = searchRes2?.data?.data?.items || [];
  257. const noteMap = new Map();
  258. for (const it of [...items1, ...items2]) {
  259. const n = it?.note;
  260. if (n?.id && !noteMap.has(n.id)) noteMap.set(n.id, n);
  261. }
  262. const allNotes = Array.from(noteMap.values());
  263. console.log(` [xhs] 去重后 ${allNotes.length} 条笔记 (综合 ${items1.length} + 最热 ${items2.length})`);
  264. if (allNotes.length === 0) {
  265. const errOut = {
  266. platform: 'xiaohongshu', keyword: spec.kw,
  267. _error: searchRes1?._error || searchRes2?._error || 'empty',
  268. collected_at: new Date().toISOString(),
  269. };
  270. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  271. auditLog(`xhs FAIL ${spec.kw} empty/err=${JSON.stringify(errOut._error).slice(0, 80)}`);
  272. return { skipped: false, notes: 0, comments: 0, path: outPath };
  273. }
  274. const topNotes = [...allNotes]
  275. .sort((a, b) => ((b.comments_count || 0) * 2 + (b.liked_count || 0)) - ((a.comments_count || 0) * 2 + (a.liked_count || 0)))
  276. .slice(0, spec.notes);
  277. const commentsByNoteId = {};
  278. let totalComments = 0;
  279. for (const note of topNotes) {
  280. if (!note?.id) continue;
  281. commentsByNoteId[note.id] = [];
  282. let cursor = '';
  283. for (let p = 0; p < (spec.commentPages || 2); p++) {
  284. await sleep(700);
  285. const cRes = await tikhubGet('/thapi/v1/xiaohongshu/app/get_note_comments', { note_id: note.id, cursor });
  286. const cmts = cRes?.data?.data?.comments || [];
  287. for (const c of cmts) {
  288. commentsByNoteId[note.id].push({
  289. id: c.id,
  290. content: c.content,
  291. create_time: c.create_time,
  292. like_count: c.like_count,
  293. sub_comment_count: c.sub_comment_count,
  294. ip_location: c.ip_location,
  295. user: c.user_info ? { user_id: c.user_info.user_id, nickname: c.user_info.nickname } : null,
  296. sub_comments: (c.sub_comments || []).slice(0, 3).map((s) => ({
  297. content: s.content,
  298. like_count: s.like_count,
  299. nickname: s.user_info?.nickname,
  300. })),
  301. });
  302. }
  303. totalComments += cmts.length;
  304. const hasMore = cRes?.data?.data?.has_more;
  305. cursor = cRes?.data?.data?.cursor || '';
  306. if (!hasMore || !cursor) break;
  307. }
  308. }
  309. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  310. console.log(` [xhs] ✓ ${spec.kw}: ${topNotes.length} 笔记 / ${totalComments} 评论 (${elapsed}s)`);
  311. auditLog(`xhs OK ${spec.kw} notes=${topNotes.length} comments=${totalComments} (${elapsed}s)`);
  312. const out = {
  313. platform: 'xiaohongshu',
  314. keyword: spec.kw,
  315. hypotheses: spec.hypotheses,
  316. collected_at: new Date().toISOString(),
  317. elapsed_seconds: Number(elapsed),
  318. total_notes_found: allNotes.length,
  319. top_notes: topNotes.map((n) => ({
  320. id: n.id,
  321. type: n.type,
  322. title: n.title,
  323. desc: n.desc,
  324. timestamp: n.timestamp,
  325. liked_count: n.liked_count,
  326. comments_count: n.comments_count,
  327. collected_count: n.collected_count,
  328. shared_count: n.shared_count,
  329. cover: n.images_list?.[0]?.url,
  330. user: n.user ? {
  331. userid: n.user.userid,
  332. nickname: n.user.nickname,
  333. red_id: n.user.red_id,
  334. verified: n.user.red_official_verified,
  335. } : null,
  336. })),
  337. comments_by_note_id: commentsByNoteId,
  338. total_comments: totalComments,
  339. };
  340. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  341. return { skipped: false, notes: topNotes.length, comments: totalComments, path: outPath };
  342. }
  343. // ============================================================
  344. // 抖音采集
  345. // ============================================================
  346. async function collectDouyin(spec, { force = false } = {}) {
  347. const outPath = path.join(DY_DIR, `${sanitizeFilename(spec.kw)}.json`);
  348. if (!force && fileExistsNonEmpty(outPath)) {
  349. console.log(` [dy] ⏭️ ${spec.kw} (已存在, skip)`);
  350. auditLog(`dy SKIP ${spec.kw} (exists)`);
  351. return { skipped: true, path: outPath };
  352. }
  353. console.log(` [dy] 🔍 搜索 "${spec.kw}" · videos=${spec.videos} × commentPages=${spec.commentPages}`);
  354. const startTs = Date.now();
  355. const searchRes = await dyPostV2('/api/voc-social/douyin/search/fetch_general_search_v2', {
  356. keyword: spec.kw,
  357. cursor: 0,
  358. sort_type: '1',
  359. publish_time: '0',
  360. content_type: '1',
  361. filter_duration: '0',
  362. search_id: '',
  363. backtrace: '',
  364. });
  365. const businessData = searchRes?.data?.business_data || [];
  366. const validVideos = businessData
  367. .map((wrap) => wrap?.data?.aweme_info || wrap?.aweme_info)
  368. .filter((v) => v?.aweme_id);
  369. console.log(` [dy] 搜索到 ${validVideos.length} 条视频 (wrappers=${businessData.length})`);
  370. if (validVideos.length === 0) {
  371. const errOut = {
  372. platform: 'douyin', keyword: spec.kw,
  373. _search_error: searchRes?._error || searchRes?.mess || 'empty',
  374. _search_code: searchRes?.code,
  375. _search_sample: JSON.stringify(searchRes).slice(0, 400),
  376. collected_at: new Date().toISOString(),
  377. };
  378. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  379. auditLog(`dy FAIL ${spec.kw} search_empty code=${searchRes?.code}`);
  380. return { skipped: false, videos: 0, comments: 0, path: outPath };
  381. }
  382. const topVideos = [...validVideos]
  383. .sort((a, b) => (b.statistics?.comment_count || 0) - (a.statistics?.comment_count || 0))
  384. .slice(0, spec.videos);
  385. const commentsByAweme = {};
  386. let totalComments = 0;
  387. for (const v of topVideos) {
  388. commentsByAweme[v.aweme_id] = [];
  389. let cursor = 0;
  390. for (let p = 0; p < (spec.commentPages || 2); p++) {
  391. await sleep(700);
  392. const cRes = await dyGetV3('/api/voc-social/douyin/app/v3/fetch_video_comments', {
  393. aweme_id: v.aweme_id,
  394. cursor,
  395. count: 20,
  396. });
  397. const cmts = cRes?.data?.comments || cRes?.comments || [];
  398. for (const c of cmts) {
  399. commentsByAweme[v.aweme_id].push({
  400. cid: c.cid,
  401. text: c.text,
  402. digg_count: c.digg_count,
  403. create_time: c.create_time,
  404. ip_label: c.ip_label,
  405. reply_comment_total: c.reply_comment_total,
  406. user: c.user ? { nickname: c.user.nickname, uid: c.user.uid } : null,
  407. });
  408. }
  409. totalComments += cmts.length;
  410. const hasMore = (cRes?.data?.has_more ?? cRes?.has_more) === 1;
  411. cursor = cRes?.data?.cursor ?? cRes?.cursor ?? 0;
  412. if (!hasMore) break;
  413. }
  414. }
  415. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  416. console.log(` [dy] ✓ ${spec.kw}: ${topVideos.length} 视频 / ${totalComments} 评论 (${elapsed}s)`);
  417. auditLog(`dy OK ${spec.kw} videos=${topVideos.length} comments=${totalComments} (${elapsed}s)`);
  418. const out = {
  419. platform: 'douyin',
  420. keyword: spec.kw,
  421. hypotheses: spec.hypotheses,
  422. collected_at: new Date().toISOString(),
  423. elapsed_seconds: Number(elapsed),
  424. total_videos_found: validVideos.length,
  425. top_videos: topVideos.map((v) => ({
  426. aweme_id: v.aweme_id,
  427. desc: v.desc,
  428. create_time: v.create_time,
  429. statistics: v.statistics,
  430. author: v.author ? {
  431. nickname: v.author.nickname,
  432. sec_uid: v.author.sec_uid,
  433. uid: v.author.uid,
  434. follower_count: v.author.follower_count,
  435. } : null,
  436. cover: v.video?.cover?.url_list?.[0],
  437. text_extra: (v.text_extra || []).map((t) => t.hashtag_name).filter(Boolean),
  438. })),
  439. comments_by_aweme_id: commentsByAweme,
  440. total_comments: totalComments,
  441. };
  442. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  443. return { skipped: false, videos: topVideos.length, comments: totalComments, path: outPath };
  444. }
  445. // ============================================================
  446. // 假设 + tag + 情感推理(儿童乳酸菌素片专属规则)
  447. // ============================================================
  448. const HYPOTHESIS_KEYWORDS = {
  449. H1: ['妈咪爱', '亿活', '金双岐', '宝乐安', '枯草杆菌', '活菌', '冷链', '冷藏', '处方', '医生开'],
  450. H2: ['合生元', '万益蓝', '拜奥', 'inne', '噗噗宝', '小胖瓶', '康萃乐', 'lifespace', '食品益生菌', '保健食品', '没效果', '智商税'],
  451. H3: ['便秘', '腹泻', '积食', '挑食', '厌食', '消化不良', '肚子', '肠胃', '拉肚子', '不爱吃饭', '大便'],
  452. H4: ['入园', '幼儿园', '换季', '抗生素', '感冒', '发烧', '开学', '集体生活', '交叉感染', '开园'],
  453. H5: ['喂药', '不吃', '抗拒', '咀嚼', '独立包装', '像打仗', '哄着', '加在', '偷偷放', '吐出来', '难喂'],
  454. H6: ['包装', '卡通', '颜值', '分龄', 'ip', '可爱', '娃娃头', '专用', '辨识度', '分年龄'],
  455. H7: ['DHA', '叶黄素', '补钙', '钙片', '维生素', '营养软糖', '营养包', '瓶瓶罐罐', '吸收', '吃了不吸收', '补了白补'],
  456. H8: ['无糖', '木糖醇', '含糖', '糖分', '龋齿', '蛀牙', '防腐剂', '香精', '色素', '甜度'],
  457. };
  458. function inferHypotheses(text, keywordHypotheses) {
  459. const results = new Set(keywordHypotheses || []);
  460. const t = String(text || '').toLowerCase();
  461. for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) {
  462. for (const kw of kws) {
  463. if (t.includes(kw.toLowerCase())) { results.add(h); break; }
  464. }
  465. }
  466. return Array.from(results);
  467. }
  468. const TAG_RULES = [
  469. // 药品益生菌痛点
  470. { tag: '冷链焦虑', re: /冷藏|冷链|2-8度|常温|失活|温度/ },
  471. { tag: '处方门槛', re: /处方|医生开|医嘱|儿科开|挂号/ },
  472. { tag: '活菌依赖', re: /停了|停药|反弹|长期吃|依赖/ },
  473. { tag: '妈咪爱好评', re: /(妈咪爱|亿活).*(好|有效|对症|管用|推荐)/ },
  474. { tag: '妈咪爱吐槽', re: /(妈咪爱|亿活).*(贵|苦|难喂|不好|没用)/ },
  475. // 食品益生菌
  476. { tag: '食品级怀疑', re: /(合生元|万益蓝|拜奥|inne).*(没效果|智商税|安慰剂|噱头|浪费)/ },
  477. { tag: '食品级推荐', re: /(合生元|万益蓝|拜奥|inne).*(好吃|爱吃|小朋友喜欢|果味)/ },
  478. // 儿童肠道刚需
  479. { tag: '便秘痛点', re: /便秘|便干|排便困难|几天不拉|拉不出/ },
  480. { tag: '腹泻痛点', re: /腹泻|拉稀|拉肚子|水样便|反复拉/ },
  481. { tag: '积食痛点', re: /积食|不消化|舌苔厚|口气|肚子胀/ },
  482. { tag: '挑食厌食', re: /挑食|厌食|不爱吃饭|不好好吃|吃得少/ },
  483. // 场景
  484. { tag: '入园生病', re: /入园|幼儿园.*(生病|感冒|腹泻)/ },
  485. { tag: '换季肠胃', re: /换季|春秋|季节.*(腹泻|拉肚)/ },
  486. { tag: '抗生素后', re: /抗生素|消炎药|头孢|阿莫西林|输液/ },
  487. // 决策 / 依从
  488. { tag: '喂药困难', re: /喂药|不吃|抗拒|吐出|哄着|偷偷/ },
  489. { tag: '咀嚼片认可', re: /咀嚼|嚼|小朋友爱吃|主动要吃|像糖/ },
  490. { tag: '颜值决策', re: /包装|卡通|ip|颜值|可爱|好看/ },
  491. // 营养吸收
  492. { tag: '瓶瓶罐罐焦虑', re: /瓶瓶罐罐|一堆|各种营养|十几种|追着喂/ },
  493. { tag: '吸收焦虑', re: /吸收|补了.*(白补|没效果|缺营养)|不吸收/ },
  494. // 糖分焦虑
  495. { tag: '糖分抵触', re: /无糖|糖分|龋齿|蛀牙|含糖量/ },
  496. { tag: '添加剂焦虑', re: /防腐剂|香精|色素|添加剂|配料表/ },
  497. ];
  498. function inferTags(text) {
  499. const tags = [];
  500. for (const r of TAG_RULES) {
  501. if (r.re.test(text || '')) tags.push(r.tag);
  502. }
  503. return tags;
  504. }
  505. const SENTIMENT_POS = /好喝|推荐|有效|舒服|喜欢|好用|回购|复购|靠谱|安心|爱吃|管用|对症|好转|有改善/;
  506. const SENTIMENT_NEG = /难喝|不推荐|智商税|贵|骗|假|副作用|反弹|没用|没效果|失望|难喂|抗拒|吐出/;
  507. const SENTIMENT_CONFLICT = /想停|但是|可是|纠结|矛盾|害怕|担心|又.*又|不知道.*好/;
  508. function inferSentiment(text) {
  509. const t = String(text || '');
  510. const pos = SENTIMENT_POS.test(t);
  511. const neg = SENTIMENT_NEG.test(t);
  512. const conf = SENTIMENT_CONFLICT.test(t);
  513. if (conf && (pos || neg)) return 'conflicted';
  514. if (pos && !neg) return 'positive';
  515. if (neg && !pos) return 'negative';
  516. return 'neutral';
  517. }
  518. // ============================================================
  519. // 合并 → _merged.json + comments-flat.jsonl
  520. // ============================================================
  521. function mergeAll() {
  522. const out = {
  523. meta: {
  524. collectedAt: new Date().toISOString(),
  525. platforms: {},
  526. products: {},
  527. hypotheses: {},
  528. keywords: {},
  529. stage: 'batch-real',
  530. sourceTier: 'real-collected',
  531. },
  532. items: [],
  533. };
  534. const flat = [];
  535. // 小红书
  536. if (fs.existsSync(XHS_DIR)) {
  537. for (const f of fs.readdirSync(XHS_DIR)) {
  538. if (!f.endsWith('.json')) continue;
  539. const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, f), 'utf8'));
  540. if (raw._error) continue;
  541. const kw = raw.keyword;
  542. const kwHypos = raw.hypotheses || [];
  543. for (const note of (raw.top_notes || [])) {
  544. const noteId = note.id;
  545. const cmts = (raw.comments_by_note_id || {})[noteId] || [];
  546. if (note.desc || note.title) {
  547. const noteContent = [note.title, note.desc].filter(Boolean).join(' · ').slice(0, 400);
  548. const item = buildItem({
  549. id: `xhs_${noteId}`,
  550. platform: 'xhs',
  551. product: kw,
  552. keyword: kw,
  553. type: 'note',
  554. nickname: note.user?.nickname || '匿名',
  555. ip: '',
  556. content: noteContent,
  557. likes: note.liked_count || 0,
  558. rating: null,
  559. kwHypos,
  560. });
  561. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  562. }
  563. for (const c of cmts) {
  564. if (!c.content) continue;
  565. const item = buildItem({
  566. id: `xhs_${noteId}_${c.id}`,
  567. platform: 'xhs',
  568. product: kw,
  569. keyword: kw,
  570. type: 'comment',
  571. nickname: c.user?.nickname || '匿名',
  572. ip: c.ip_location || '',
  573. content: c.content.slice(0, 500),
  574. likes: c.like_count || 0,
  575. rating: null,
  576. kwHypos,
  577. });
  578. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  579. }
  580. }
  581. }
  582. }
  583. // 抖音
  584. if (fs.existsSync(DY_DIR)) {
  585. for (const f of fs.readdirSync(DY_DIR)) {
  586. if (!f.endsWith('.json')) continue;
  587. const raw = JSON.parse(fs.readFileSync(path.join(DY_DIR, f), 'utf8'));
  588. if (raw._error || raw._search_error) continue;
  589. const kw = raw.keyword;
  590. const kwHypos = raw.hypotheses || [];
  591. for (const v of (raw.top_videos || [])) {
  592. const awemeId = v.aweme_id;
  593. const cmts = (raw.comments_by_aweme_id || {})[awemeId] || [];
  594. if (v.desc) {
  595. const item = buildItem({
  596. id: `dy_${awemeId}`,
  597. platform: 'douyin',
  598. product: kw,
  599. keyword: kw,
  600. type: 'video',
  601. nickname: v.author?.nickname || '匿名',
  602. ip: '',
  603. content: v.desc.slice(0, 400),
  604. likes: v.statistics?.digg_count || 0,
  605. rating: null,
  606. kwHypos,
  607. });
  608. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  609. }
  610. for (const c of cmts) {
  611. if (!c.text) continue;
  612. const item = buildItem({
  613. id: `dy_${awemeId}_${c.cid}`,
  614. platform: 'douyin',
  615. product: kw,
  616. keyword: kw,
  617. type: 'comment',
  618. nickname: c.user?.nickname || '匿名',
  619. ip: c.ip_label || '',
  620. content: c.text.slice(0, 500),
  621. likes: c.digg_count || 0,
  622. rating: null,
  623. kwHypos,
  624. });
  625. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  626. }
  627. }
  628. }
  629. }
  630. // cid/id 级去重
  631. const seen = new Set();
  632. const dedupeItems = [];
  633. const dedupeFlat = [];
  634. for (const it of out.items) {
  635. if (seen.has(it.id)) continue;
  636. seen.add(it.id);
  637. dedupeItems.push(it);
  638. }
  639. for (const it of flat) {
  640. if (dedupeFlat.find((x) => x.id === it.id)) continue;
  641. dedupeFlat.push(it);
  642. }
  643. out.items = dedupeItems;
  644. out.meta.productsCount = Object.keys(out.meta.products).length;
  645. out.meta.keywordsCount = Object.keys(out.meta.keywords).length;
  646. out.meta.comments = out.items.length;
  647. fs.writeFileSync(path.join(RAW_DIR, '_merged.json'), JSON.stringify(out, null, 2), 'utf8');
  648. fs.writeFileSync(path.join(RAW_DIR, 'comments-flat.jsonl'), dedupeFlat.map((it) => JSON.stringify(it)).join('\n'), 'utf8');
  649. console.log(` ✅ merged: ${out.items.length} items | ${out.meta.productsCount} products | ${Object.keys(out.meta.platforms).length} platforms`);
  650. console.log(` 假设覆盖: ${Object.entries(out.meta.hypotheses).map(([k, v]) => `${k}:${v}`).join(' / ')}`);
  651. return out;
  652. }
  653. function buildItem({ id, platform, product, keyword, type, nickname, ip, content, likes, rating, kwHypos }) {
  654. if (!content || content.length < 3) return null;
  655. const hypotheses = inferHypotheses(content, kwHypos);
  656. const tags = inferTags(content);
  657. const sentiment = inferSentiment(content);
  658. return {
  659. id, platform, product, keyword, type,
  660. nickname, ip, content, likes, rating,
  661. hypothesis: hypotheses,
  662. tags,
  663. sentiment,
  664. source: 'real-collected',
  665. };
  666. }
  667. function countMeta(out, it) {
  668. out.meta.platforms[it.platform] = (out.meta.platforms[it.platform] || 0) + 1;
  669. out.meta.products[it.product] = (out.meta.products[it.product] || 0) + 1;
  670. out.meta.keywords[it.keyword] = (out.meta.keywords[it.keyword] || 0) + 1;
  671. for (const h of (it.hypothesis || [])) {
  672. out.meta.hypotheses[h] = (out.meta.hypotheses[h] || 0) + 1;
  673. }
  674. }
  675. // ============================================================
  676. // 批次执行
  677. // ============================================================
  678. async function runBatch(batchNum, opts) {
  679. const batch = BATCHES[batchNum];
  680. if (!batch) throw new Error(`unknown batch: ${batchNum}`);
  681. console.log(`\n▶ Batch ${batchNum}: ${batch.name}`);
  682. auditLog(`BATCH-START ${batchNum} ${batch.name}`);
  683. for (const task of batch.xhs || []) {
  684. try { await collectXhs(task, opts); }
  685. catch (e) { console.log(` [xhs] ✗ ${task.kw}: ${e.message}`); auditLog(`xhs EXC ${task.kw} ${e.message.slice(0, 80)}`); }
  686. await sleep(800);
  687. }
  688. for (const task of batch.douyin || []) {
  689. try { await collectDouyin(task, opts); }
  690. catch (e) { console.log(` [dy] ✗ ${task.kw}: ${e.message}`); auditLog(`dy EXC ${task.kw} ${e.message.slice(0, 80)}`); }
  691. await sleep(600);
  692. }
  693. auditLog(`BATCH-END ${batchNum}`);
  694. }
  695. // ============================================================
  696. // CLI
  697. // ============================================================
  698. async function main() {
  699. const argv = process.argv.slice(2);
  700. const opts = { force: argv.includes('--force') };
  701. const batchArg = argv.find((a) => a.startsWith('--batch='));
  702. const isMerge = argv.includes('--merge');
  703. const isTest = argv.includes('--test');
  704. console.log('╔═══════════════════════════════════════════════════════════╗');
  705. console.log('║ 江中乳酸菌素片儿童版 · VOC 多平台真实采集 ║');
  706. console.log('╚═══════════════════════════════════════════════════════════╝');
  707. console.log(` TikHub token: ${TIKHUB_TOKEN ? '✓ ' + TIKHUB_TOKEN.slice(0, 8) + '...' : '✗'}`);
  708. console.log(` VOC token: ${VOC_TOKEN ? '✓ ' + VOC_TOKEN.slice(0, 8) + '...' : '✗'}`);
  709. if (isTest) {
  710. console.log('\n▶ Test mode · 跑通单关键词 xhs["乳酸菌素片"]');
  711. await collectXhs({ kw: '乳酸菌素片', notes: 3, commentPages: 1, hypotheses: ['H1', 'H3'] }, { force: true });
  712. return;
  713. }
  714. if (batchArg) {
  715. const bn = batchArg.split('=')[1];
  716. if (bn === 'all') {
  717. for (const k of Object.keys(BATCHES)) await runBatch(k, opts);
  718. } else {
  719. await runBatch(bn, opts);
  720. }
  721. }
  722. if (isMerge || batchArg) {
  723. console.log('\n▶ Merging all collected raw data...');
  724. mergeAll();
  725. }
  726. if (!batchArg && !isMerge && !isTest) {
  727. console.log('\nUsage:');
  728. console.log(' --test 跑通单关键词测试 API 链路');
  729. console.log(' --batch=1|2|3|all 执行采集批次');
  730. console.log(' --merge 合并 raw/*/*.json → _merged.json');
  731. console.log(' --force 强制重抓(忽略已存在文件)');
  732. }
  733. }
  734. if (require.main === module) {
  735. main().catch((e) => { console.error('fatal:', e); process.exit(1); });
  736. }
  737. module.exports = { BATCHES, mergeAll, collectXhs, collectDouyin };