houguyin-collect.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. #!/usr/bin/env node
  2. /**
  3. * 江中猴菇饮 OTC · VOC 多平台采集(真实采集执行版)
  4. *
  5. * 对齐 docs/jiangzhong-houguyin/2.采集矩阵.md 定义的关键词矩阵,
  6. * 采用 collect-jiangzhong-liver-deep.js 已验证的 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. * - 分批次执行 (--batch=1|2|3|all)
  12. * - 每关键词独立 JSON 落盘 docs/jiangzhong-houguyin/raw/{platform}/{kw}.json
  13. * - 断点续跑:已存在文件跳过,--force 强制重跑
  14. * - 3 次重试 + 指数退避
  15. * - 审计日志 docs/jiangzhong-houguyin/raw/audit.log
  16. * - --merge 合并 → _merged.json + comments-flat.jsonl(供分析器消费)
  17. *
  18. * 前置凭据:
  19. * - ~/.openclaw/skills/xiaohongshu-search-notes/api-config.json (TikHub token)
  20. * - ~/.openclaw/voc-credentials.json (VOC token for 抖音)
  21. *
  22. * 用法:
  23. * node scripts/tools/houguyin-collect.js --batch=1
  24. * node scripts/tools/houguyin-collect.js --batch=all
  25. * node scripts/tools/houguyin-collect.js --merge
  26. */
  27. const fs = require('fs');
  28. const path = require('path');
  29. const os = require('os');
  30. const https = require('https');
  31. // ============================================================
  32. // 路径
  33. // ============================================================
  34. const ROOT = path.resolve(__dirname, '..', '..');
  35. const RAW_DIR = path.join(ROOT, 'docs', 'jiangzhong-houguyin', 'raw');
  36. const XHS_DIR = path.join(RAW_DIR, 'xhs');
  37. const DY_DIR = path.join(RAW_DIR, 'douyin');
  38. const AUDIT_LOG = path.join(RAW_DIR, 'audit.log');
  39. [RAW_DIR, XHS_DIR, DY_DIR].forEach((d) => {
  40. if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
  41. });
  42. // ============================================================
  43. // 凭据
  44. // ============================================================
  45. const TIKHUB_TOKEN = (() => {
  46. const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  47. if (!fs.existsSync(p)) return null;
  48. try {
  49. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  50. return (c.endpoint?.headers?.Authorization || '').replace(/^Bearer\s+/, '');
  51. } catch { return null; }
  52. })();
  53. const VOC_TOKEN = (() => {
  54. const p = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  55. if (!fs.existsSync(p)) return '';
  56. try {
  57. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  58. return c.vocToken || c.sessionToken || '';
  59. } catch { return ''; }
  60. })();
  61. // ============================================================
  62. // 关键词矩阵(对标 2.采集矩阵.md · 聚焦 xhs + douyin 两大主力平台)
  63. // ============================================================
  64. const BATCHES = {
  65. 1: {
  66. name: 'P0 · 内部参照 + 药品主流',
  67. xhs: [
  68. { kw: '江中猴姑米稀', notes: 6, commentPages: 3, hypotheses: ['H1', 'H5'] },
  69. { kw: '猴头菇米稀', notes: 5, commentPages: 2, hypotheses: ['H1'] },
  70. { kw: '养胃米糊', notes: 5, commentPages: 2, hypotheses: ['H1', 'H5'] },
  71. { kw: '江中猴姑饼干', notes: 5, commentPages: 2, hypotheses: ['H2'] },
  72. { kw: '三九胃泰', notes: 5, commentPages: 2, hypotheses: ['H3'] },
  73. { kw: '奥美拉唑 副作用', notes: 5, commentPages: 2, hypotheses: ['H3', 'H4'] },
  74. { kw: '雷贝拉唑', notes: 4, commentPages: 2, hypotheses: ['H3', 'H4'] },
  75. ],
  76. douyin: [
  77. { kw: '猴姑米稀', videos: 3, commentPages: 2, hypotheses: ['H1'] },
  78. { kw: '三九胃泰', videos: 3, commentPages: 2, hypotheses: ['H3'] },
  79. { kw: '拉唑 长期吃', videos: 3, commentPages: 2, hypotheses: ['H3', 'H4'] },
  80. ],
  81. },
  82. 2: {
  83. name: 'P1 · 同名对标 + 场景 + 长尾药品',
  84. xhs: [
  85. { kw: '猴菇饮', notes: 5, commentPages: 2, hypotheses: ['H5'] },
  86. { kw: '猴头菇饮料', notes: 4, commentPages: 2, hypotheses: ['H5'] },
  87. { kw: '康复新液', notes: 4, commentPages: 2, hypotheses: ['H3'] },
  88. { kw: '年轻人 胃病', notes: 5, commentPages: 2, hypotheses: ['H5', 'H6'] },
  89. { kw: '熬夜 胃不舒服', notes: 5, commentPages: 2, hypotheses: ['H5'] },
  90. { kw: '应酬 胃难受', notes: 4, commentPages: 2, hypotheses: ['H5'] },
  91. { kw: '办公室 养胃', notes: 4, commentPages: 2, hypotheses: ['H5'] },
  92. ],
  93. douyin: [
  94. { kw: '胃病 年轻人', videos: 2, commentPages: 2, hypotheses: ['H5'] },
  95. { kw: '养胃', videos: 3, commentPages: 2, hypotheses: ['H5', 'H6'] },
  96. ],
  97. },
  98. 3: {
  99. name: 'P2 · 细分人群 + 礼品类比',
  100. xhs: [
  101. { kw: '药店 胃药推荐', notes: 4, commentPages: 2, hypotheses: ['H6'] },
  102. { kw: '糖尿病 养胃', notes: 4, commentPages: 2, hypotheses: ['H7'] },
  103. { kw: '糖友 胃不好', notes: 4, commentPages: 2, hypotheses: ['H7'] },
  104. { kw: '送父母 胃药', notes: 4, commentPages: 2, hypotheses: ['H8'] },
  105. { kw: '东阿阿胶 送礼', notes: 3, commentPages: 2, hypotheses: ['H8'] },
  106. { kw: '长期吃 拉唑', notes: 5, commentPages: 2, hypotheses: ['H3', 'H4'] },
  107. { kw: '拉唑 停药反弹', notes: 4, commentPages: 2, hypotheses: ['H4'] },
  108. ],
  109. douyin: [
  110. { kw: '送父母礼品', videos: 2, commentPages: 2, hypotheses: ['H8'] },
  111. ],
  112. },
  113. };
  114. // ============================================================
  115. // HTTP 工具(对齐 collect-jiangzhong-liver-deep.js 的已验证实现)
  116. // ============================================================
  117. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  118. function sanitizeFilename(kw) {
  119. return kw.replace(/[\/\\:*?"<>|\s]+/g, '-');
  120. }
  121. function auditLog(line) {
  122. const ts = new Date().toISOString();
  123. try {
  124. fs.appendFileSync(AUDIT_LOG, `[${ts}] ${line}\n`);
  125. } catch (e) {
  126. // audit.log 被锁时不要中断采集(VSCode 打开会锁)
  127. if (e.code === 'EBUSY' || e.code === 'EACCES' || e.code === 'EPERM') {
  128. try {
  129. fs.appendFileSync(AUDIT_LOG + '.alt', `[${ts}] ${line}\n`);
  130. } catch { /* 忽略 */ }
  131. }
  132. }
  133. }
  134. function fileExistsNonEmpty(p) {
  135. if (!fs.existsSync(p)) return false;
  136. return fs.statSync(p).size > 50;
  137. }
  138. function httpRequest(options, bodyData = null, maxAttempts = 3) {
  139. return new Promise(async (resolve, reject) => {
  140. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  141. try {
  142. const result = await new Promise((res, rej) => {
  143. const req = https.request(options, (response) => {
  144. const chunks = [];
  145. response.on('data', (c) => chunks.push(c));
  146. response.on('end', () => res({
  147. status: response.statusCode,
  148. body: Buffer.concat(chunks).toString('utf-8'),
  149. }));
  150. });
  151. req.on('error', rej);
  152. req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
  153. if (bodyData) req.write(bodyData);
  154. req.end();
  155. });
  156. if (result.status === 200) return resolve(result);
  157. if ([400, 429, 500, 502, 503, 504].includes(result.status) && attempt < maxAttempts) {
  158. await sleep(1500 * Math.pow(1.8, attempt - 1));
  159. continue;
  160. }
  161. return resolve(result);
  162. } catch (e) {
  163. if (attempt === maxAttempts) return reject(e);
  164. await sleep(2000 * attempt);
  165. }
  166. }
  167. });
  168. }
  169. async function tikhubGet(apiPath, paramsObj = {}) {
  170. if (!TIKHUB_TOKEN) return { _error: 'no TikHub token' };
  171. const qs = Object.entries(paramsObj)
  172. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  173. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  174. .join('&');
  175. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  176. const { status, body } = await httpRequest({
  177. hostname: 'server.fmode.cn',
  178. path: fullPath,
  179. method: 'GET',
  180. headers: {
  181. Accept: 'application/json',
  182. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  183. },
  184. timeout: 60000,
  185. });
  186. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  187. try { return JSON.parse(body); }
  188. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  189. }
  190. async function dyPostV2(apiPath, bodyObj = {}) {
  191. if (!VOC_TOKEN) return { _error: 'no VOC token' };
  192. const bodyStr = JSON.stringify(bodyObj);
  193. const { status, body } = await httpRequest({
  194. hostname: 'server.fmode.cn',
  195. path: apiPath,
  196. method: 'POST',
  197. headers: {
  198. Accept: 'application/json',
  199. 'Content-Type': 'application/json',
  200. 'Content-Length': Buffer.byteLength(bodyStr),
  201. Authorization: `Bearer ${VOC_TOKEN}`,
  202. },
  203. timeout: 60000,
  204. }, bodyStr);
  205. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  206. try { return JSON.parse(body); }
  207. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  208. }
  209. async function dyGetV3(apiPath, paramsObj = {}) {
  210. if (!VOC_TOKEN) return { _error: 'no VOC token' };
  211. const qs = Object.entries(paramsObj)
  212. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  213. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  214. .join('&');
  215. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  216. const { status, body } = await httpRequest({
  217. hostname: 'server.fmode.cn',
  218. path: fullPath,
  219. method: 'GET',
  220. headers: {
  221. Accept: 'application/json',
  222. Authorization: `Bearer ${VOC_TOKEN}`,
  223. },
  224. timeout: 60000,
  225. });
  226. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  227. try { return JSON.parse(body); }
  228. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  229. }
  230. // ============================================================
  231. // 小红书采集(单关键词深度)
  232. // ============================================================
  233. async function collectXhs(spec, { force = false } = {}) {
  234. const outPath = path.join(XHS_DIR, `${sanitizeFilename(spec.kw)}.json`);
  235. if (!force && fileExistsNonEmpty(outPath)) {
  236. console.log(` [xhs] ⏭️ ${spec.kw} (已存在, skip)`);
  237. auditLog(`xhs SKIP ${spec.kw} (exists)`);
  238. return { skipped: true, path: outPath };
  239. }
  240. console.log(` [xhs] 🔍 搜索 "${spec.kw}" · notes=${spec.notes} × commentPages=${spec.commentPages}`);
  241. const startTs = Date.now();
  242. // 综合 + 最热两种排序,取并集
  243. const searchRes1 = await tikhubGet('/thapi/v1/xiaohongshu/app/search_notes', { keyword: spec.kw, page: 1, sort: 'general' });
  244. await sleep(600);
  245. const searchRes2 = await tikhubGet('/thapi/v1/xiaohongshu/app/search_notes', { keyword: spec.kw, page: 1, sort: 'popularity_descending' });
  246. const items1 = searchRes1?.data?.data?.items || [];
  247. const items2 = searchRes2?.data?.data?.items || [];
  248. const noteMap = new Map();
  249. for (const it of [...items1, ...items2]) {
  250. const n = it?.note;
  251. if (n?.id && !noteMap.has(n.id)) noteMap.set(n.id, n);
  252. }
  253. const allNotes = Array.from(noteMap.values());
  254. console.log(` [xhs] 去重后 ${allNotes.length} 条笔记 (综合 ${items1.length} + 最热 ${items2.length})`);
  255. if (allNotes.length === 0) {
  256. const errOut = {
  257. platform: 'xiaohongshu', keyword: spec.kw,
  258. _error: searchRes1?._error || searchRes2?._error || 'empty',
  259. collected_at: new Date().toISOString(),
  260. };
  261. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  262. auditLog(`xhs FAIL ${spec.kw} empty/err=${JSON.stringify(errOut._error).slice(0, 80)}`);
  263. return { skipped: false, notes: 0, comments: 0, path: outPath };
  264. }
  265. const topNotes = [...allNotes]
  266. .sort((a, b) => ((b.comments_count || 0) * 2 + (b.liked_count || 0)) - ((a.comments_count || 0) * 2 + (a.liked_count || 0)))
  267. .slice(0, spec.notes);
  268. const commentsByNoteId = {};
  269. let totalComments = 0;
  270. for (const note of topNotes) {
  271. if (!note?.id) continue;
  272. commentsByNoteId[note.id] = [];
  273. let cursor = '';
  274. for (let p = 0; p < (spec.commentPages || 2); p++) {
  275. await sleep(700);
  276. const cRes = await tikhubGet('/thapi/v1/xiaohongshu/app/get_note_comments', { note_id: note.id, cursor });
  277. const cmts = cRes?.data?.data?.comments || [];
  278. for (const c of cmts) {
  279. commentsByNoteId[note.id].push({
  280. id: c.id,
  281. content: c.content,
  282. create_time: c.create_time,
  283. like_count: c.like_count,
  284. sub_comment_count: c.sub_comment_count,
  285. ip_location: c.ip_location,
  286. user: c.user_info ? { user_id: c.user_info.user_id, nickname: c.user_info.nickname } : null,
  287. sub_comments: (c.sub_comments || []).slice(0, 3).map((s) => ({
  288. content: s.content,
  289. like_count: s.like_count,
  290. nickname: s.user_info?.nickname,
  291. })),
  292. });
  293. }
  294. totalComments += cmts.length;
  295. const hasMore = cRes?.data?.data?.has_more;
  296. cursor = cRes?.data?.data?.cursor || '';
  297. if (!hasMore || !cursor) break;
  298. }
  299. }
  300. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  301. console.log(` [xhs] ✓ ${spec.kw}: ${topNotes.length} 笔记 / ${totalComments} 评论 (${elapsed}s)`);
  302. auditLog(`xhs OK ${spec.kw} notes=${topNotes.length} comments=${totalComments} (${elapsed}s)`);
  303. const out = {
  304. platform: 'xiaohongshu',
  305. keyword: spec.kw,
  306. hypotheses: spec.hypotheses,
  307. collected_at: new Date().toISOString(),
  308. elapsed_seconds: Number(elapsed),
  309. total_notes_found: allNotes.length,
  310. top_notes: topNotes.map((n) => ({
  311. id: n.id,
  312. type: n.type,
  313. title: n.title,
  314. desc: n.desc,
  315. timestamp: n.timestamp,
  316. liked_count: n.liked_count,
  317. comments_count: n.comments_count,
  318. collected_count: n.collected_count,
  319. shared_count: n.shared_count,
  320. cover: n.images_list?.[0]?.url,
  321. user: n.user ? {
  322. userid: n.user.userid,
  323. nickname: n.user.nickname,
  324. red_id: n.user.red_id,
  325. verified: n.user.red_official_verified,
  326. } : null,
  327. })),
  328. comments_by_note_id: commentsByNoteId,
  329. total_comments: totalComments,
  330. };
  331. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  332. return { skipped: false, notes: topNotes.length, comments: totalComments, path: outPath };
  333. }
  334. // ============================================================
  335. // 抖音采集(单关键词 · Top 视频 + 评论)
  336. // ============================================================
  337. async function collectDouyin(spec, { force = false } = {}) {
  338. const outPath = path.join(DY_DIR, `${sanitizeFilename(spec.kw)}.json`);
  339. if (!force && fileExistsNonEmpty(outPath)) {
  340. console.log(` [dy] ⏭️ ${spec.kw} (已存在, skip)`);
  341. auditLog(`dy SKIP ${spec.kw} (exists)`);
  342. return { skipped: true, path: outPath };
  343. }
  344. console.log(` [dy] 🔍 搜索 "${spec.kw}" · videos=${spec.videos} × commentPages=${spec.commentPages}`);
  345. const startTs = Date.now();
  346. const searchRes = await dyPostV2('/api/voc-social/douyin/search/fetch_general_search_v2', {
  347. keyword: spec.kw,
  348. cursor: 0,
  349. sort_type: '1',
  350. publish_time: '0',
  351. content_type: '1',
  352. filter_duration: '0',
  353. search_id: '',
  354. backtrace: '',
  355. });
  356. const businessData = searchRes?.data?.business_data || [];
  357. const validVideos = businessData
  358. .map((wrap) => wrap?.data?.aweme_info || wrap?.aweme_info)
  359. .filter((v) => v?.aweme_id);
  360. console.log(` [dy] 搜索到 ${validVideos.length} 条视频 (wrappers=${businessData.length})`);
  361. if (validVideos.length === 0) {
  362. const errOut = {
  363. platform: 'douyin', keyword: spec.kw,
  364. _search_error: searchRes?._error || searchRes?.mess || 'empty',
  365. _search_code: searchRes?.code,
  366. _search_sample: JSON.stringify(searchRes).slice(0, 400),
  367. collected_at: new Date().toISOString(),
  368. };
  369. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  370. auditLog(`dy FAIL ${spec.kw} search_empty code=${searchRes?.code}`);
  371. return { skipped: false, videos: 0, comments: 0, path: outPath };
  372. }
  373. const topVideos = [...validVideos]
  374. .sort((a, b) => (b.statistics?.comment_count || 0) - (a.statistics?.comment_count || 0))
  375. .slice(0, spec.videos);
  376. const commentsByAweme = {};
  377. let totalComments = 0;
  378. for (const v of topVideos) {
  379. commentsByAweme[v.aweme_id] = [];
  380. let cursor = 0;
  381. for (let p = 0; p < (spec.commentPages || 2); p++) {
  382. await sleep(700);
  383. const cRes = await dyGetV3('/api/voc-social/douyin/app/v3/fetch_video_comments', {
  384. aweme_id: v.aweme_id,
  385. cursor,
  386. count: 20,
  387. });
  388. const cmts = cRes?.data?.comments || cRes?.comments || [];
  389. for (const c of cmts) {
  390. commentsByAweme[v.aweme_id].push({
  391. cid: c.cid,
  392. text: c.text,
  393. digg_count: c.digg_count,
  394. create_time: c.create_time,
  395. ip_label: c.ip_label,
  396. reply_comment_total: c.reply_comment_total,
  397. user: c.user ? { nickname: c.user.nickname, uid: c.user.uid } : null,
  398. });
  399. }
  400. totalComments += cmts.length;
  401. const hasMore = (cRes?.data?.has_more ?? cRes?.has_more) === 1;
  402. cursor = cRes?.data?.cursor ?? cRes?.cursor ?? 0;
  403. if (!hasMore) break;
  404. }
  405. }
  406. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  407. console.log(` [dy] ✓ ${spec.kw}: ${topVideos.length} 视频 / ${totalComments} 评论 (${elapsed}s)`);
  408. auditLog(`dy OK ${spec.kw} videos=${topVideos.length} comments=${totalComments} (${elapsed}s)`);
  409. const out = {
  410. platform: 'douyin',
  411. keyword: spec.kw,
  412. hypotheses: spec.hypotheses,
  413. collected_at: new Date().toISOString(),
  414. elapsed_seconds: Number(elapsed),
  415. total_videos_found: validVideos.length,
  416. top_videos: topVideos.map((v) => ({
  417. aweme_id: v.aweme_id,
  418. desc: v.desc,
  419. create_time: v.create_time,
  420. statistics: v.statistics,
  421. author: v.author ? {
  422. nickname: v.author.nickname,
  423. sec_uid: v.author.sec_uid,
  424. uid: v.author.uid,
  425. follower_count: v.author.follower_count,
  426. } : null,
  427. cover: v.video?.cover?.url_list?.[0],
  428. text_extra: (v.text_extra || []).map((t) => t.hashtag_name).filter(Boolean),
  429. })),
  430. comments_by_aweme_id: commentsByAweme,
  431. total_comments: totalComments,
  432. };
  433. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  434. return { skipped: false, videos: topVideos.length, comments: totalComments, path: outPath };
  435. }
  436. // ============================================================
  437. // 假设 + tag 推理(纯规则,基于关键词 / 文本内容)
  438. // ============================================================
  439. const HYPOTHESIS_KEYWORDS = {
  440. H1: ['米稀', '猴头菇', '养胃', '糯', '暖胃', '早餐', '粥', '糊'],
  441. H2: ['饼干', '硬', '油', '反胃', '甜', '卫健委', '噱头', '加工'],
  442. H3: ['胃泰', '拉唑', '奥美', '雷贝', '艾司', '康复新', '中成药', '颗粒', '苦', '起效', '疗程'],
  443. H4: ['停药', '反弹', '依赖', '戒毒', '减量', 'PPI', '骨密度', '副作用', '长期吃'],
  444. H5: ['应酬', '熬夜', '办公', '工位', '差旅', '出差', '饭局', '酒后', '咖啡', '便携', '即饮', '应急'],
  445. H6: ['药店', '店员', '柜台', '推荐', '挂牌', '医保', 'OTC'],
  446. H7: ['糖尿', '糖友', '血糖', '无糖', '木糖醇'],
  447. H8: ['送礼', '送父母', '送长辈', '阿胶', '燕窝', '黄芪', '礼盒', '节日'],
  448. };
  449. function inferHypotheses(text, keywordHypotheses) {
  450. const results = new Set(keywordHypotheses || []);
  451. const t = String(text || '').toLowerCase();
  452. for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) {
  453. for (const kw of kws) {
  454. if (t.includes(kw.toLowerCase())) { results.add(h); break; }
  455. }
  456. }
  457. return Array.from(results);
  458. }
  459. const TAG_RULES = [
  460. { tag: '米稀好评', re: /好喝|糯|舒服|推荐|正品|复购/i },
  461. { tag: '米稀局限性', re: /太甜|腻|结块|冲泡|贵/i },
  462. { tag: '饼干失败', re: /饼干.*(硬|油|反|胃酸|噱头)/i },
  463. { tag: '胃泰口感差', re: /(胃泰|颗粒).*苦|苦.*(胃泰|颗粒)|难喝/i },
  464. { tag: 'PPI 依赖焦虑', re: /(拉唑|ppi).*(依赖|停|戒|反弹|骨)/i },
  465. { tag: '应酬场景', re: /应酬|饭局|酒后|白酒|喝多/i },
  466. { tag: '熬夜场景', re: /熬夜|加班|通宵|凌晨/i },
  467. { tag: '办公室场景', re: /办公室|工位|午饭|午餐|加班餐/i },
  468. { tag: '差旅场景', re: /出差|差旅|高铁|飞机|酒店/i },
  469. { tag: '药店推荐', re: /药店|店员|柜台|挂牌/i },
  470. { tag: '糖尿病', re: /糖友|糖尿|血糖|无糖|木糖醇/i },
  471. { tag: '送礼', re: /送父母|送长辈|送礼|阿胶|燕窝/i },
  472. { tag: '温和', re: /温和|不刺激|不苦|顺滑/i },
  473. { tag: '便携', re: /便携|包里|小支|随身|铝膜/i },
  474. ];
  475. function inferTags(text) {
  476. const tags = [];
  477. for (const r of TAG_RULES) {
  478. if (r.re.test(text || '')) tags.push(r.tag);
  479. }
  480. return tags;
  481. }
  482. const SENTIMENT_POS = /好喝|推荐|值得|有效|舒服|喜欢|好用|回购|复购|相信|靠谱|安心/;
  483. const SENTIMENT_NEG = /难喝|不推荐|智商税|贵|骗|假|副作用|反弹|依赖|呕|反胃|腹泻|差评|失望/;
  484. const SENTIMENT_CONFLICT = /想停|但是|可是|纠结|矛盾|害怕|担心|又.*又/;
  485. function inferSentiment(text) {
  486. const t = String(text || '');
  487. const pos = SENTIMENT_POS.test(t);
  488. const neg = SENTIMENT_NEG.test(t);
  489. const conf = SENTIMENT_CONFLICT.test(t);
  490. if (conf && (pos || neg)) return 'conflicted';
  491. if (pos && !neg) return 'positive';
  492. if (neg && !pos) return 'negative';
  493. return 'neutral';
  494. }
  495. // ============================================================
  496. // 合并 → _merged.json + comments-flat.jsonl
  497. // ============================================================
  498. function mergeAll() {
  499. const out = {
  500. meta: {
  501. collectedAt: new Date().toISOString(),
  502. platforms: {},
  503. products: {},
  504. hypotheses: {},
  505. keywords: {},
  506. stage: 'batch-2-real',
  507. sourceTier: 'real-collected',
  508. },
  509. items: [],
  510. };
  511. const flat = [];
  512. // 小红书
  513. if (fs.existsSync(XHS_DIR)) {
  514. for (const f of fs.readdirSync(XHS_DIR)) {
  515. if (!f.endsWith('.json')) continue;
  516. const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, f), 'utf8'));
  517. if (raw._error) continue;
  518. const kw = raw.keyword;
  519. const kwHypos = raw.hypotheses || [];
  520. for (const note of (raw.top_notes || [])) {
  521. const noteId = note.id;
  522. const cmts = (raw.comments_by_note_id || {})[noteId] || [];
  523. // 笔记本身也作为一条 item
  524. if (note.desc || note.title) {
  525. const noteContent = [note.title, note.desc].filter(Boolean).join(' · ').slice(0, 400);
  526. const item = buildItem({
  527. id: `xhs_${noteId}`,
  528. platform: 'xhs',
  529. product: kw,
  530. keyword: kw,
  531. type: 'note',
  532. nickname: note.user?.nickname || '匿名',
  533. ip: '',
  534. content: noteContent,
  535. likes: note.liked_count || 0,
  536. rating: null,
  537. noteId,
  538. kwHypos,
  539. });
  540. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  541. }
  542. for (const c of cmts) {
  543. if (!c.content) continue;
  544. const item = buildItem({
  545. id: `xhs_${noteId}_${c.id}`,
  546. platform: 'xhs',
  547. product: kw,
  548. keyword: kw,
  549. type: 'comment',
  550. nickname: c.user?.nickname || '匿名',
  551. ip: c.ip_location || '',
  552. content: c.content.slice(0, 500),
  553. likes: c.like_count || 0,
  554. rating: null,
  555. noteId,
  556. kwHypos,
  557. });
  558. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  559. }
  560. }
  561. }
  562. }
  563. // 抖音
  564. if (fs.existsSync(DY_DIR)) {
  565. for (const f of fs.readdirSync(DY_DIR)) {
  566. if (!f.endsWith('.json')) continue;
  567. const raw = JSON.parse(fs.readFileSync(path.join(DY_DIR, f), 'utf8'));
  568. if (raw._error || raw._search_error) continue;
  569. const kw = raw.keyword;
  570. const kwHypos = raw.hypotheses || [];
  571. for (const v of (raw.top_videos || [])) {
  572. const awemeId = v.aweme_id;
  573. const cmts = (raw.comments_by_aweme_id || {})[awemeId] || [];
  574. if (v.desc) {
  575. const item = buildItem({
  576. id: `dy_${awemeId}`,
  577. platform: 'douyin',
  578. product: kw,
  579. keyword: kw,
  580. type: 'video',
  581. nickname: v.author?.nickname || '匿名',
  582. ip: '',
  583. content: v.desc.slice(0, 400),
  584. likes: v.statistics?.digg_count || 0,
  585. rating: null,
  586. awemeId,
  587. kwHypos,
  588. });
  589. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  590. }
  591. for (const c of cmts) {
  592. if (!c.text) continue;
  593. const item = buildItem({
  594. id: `dy_${awemeId}_${c.cid}`,
  595. platform: 'douyin',
  596. product: kw,
  597. keyword: kw,
  598. type: 'comment',
  599. nickname: c.user?.nickname || '匿名',
  600. ip: c.ip_label || '',
  601. content: c.text.slice(0, 500),
  602. likes: c.digg_count || 0,
  603. rating: null,
  604. awemeId,
  605. kwHypos,
  606. });
  607. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  608. }
  609. }
  610. }
  611. }
  612. // 与 seed 合并(如果 seed 存在,以真实为优先)
  613. const seedPath = path.join(RAW_DIR, '_seed.json');
  614. if (fs.existsSync(seedPath)) {
  615. const seed = JSON.parse(fs.readFileSync(seedPath, 'utf8'));
  616. for (const it of (seed.items || [])) {
  617. if (!out.items.find((x) => x.id === it.id)) {
  618. out.items.push(it);
  619. flat.push(it);
  620. countMeta(out, it);
  621. }
  622. }
  623. out.meta.includesSeedFallback = true;
  624. }
  625. out.meta.productsCount = Object.keys(out.meta.products).length;
  626. out.meta.keywordsCount = Object.keys(out.meta.keywords).length;
  627. out.meta.comments = out.items.length;
  628. fs.writeFileSync(path.join(RAW_DIR, '_merged.json'), JSON.stringify(out, null, 2), 'utf8');
  629. fs.writeFileSync(path.join(RAW_DIR, 'comments-flat.jsonl'), flat.map((it) => JSON.stringify(it)).join('\n'), 'utf8');
  630. console.log(` ✅ merged: ${out.items.length} items | ${out.meta.productsCount} products | ${Object.keys(out.meta.platforms).length} platforms`);
  631. return out;
  632. }
  633. function buildItem({ id, platform, product, keyword, type, nickname, ip, content, likes, rating, kwHypos }) {
  634. if (!content || content.length < 3) return null;
  635. const hypotheses = inferHypotheses(content, kwHypos);
  636. const tags = inferTags(content);
  637. const sentiment = inferSentiment(content);
  638. return {
  639. id, platform, product, keyword, type,
  640. nickname, ip, content, likes, rating,
  641. hypothesis: hypotheses,
  642. tags,
  643. sentiment,
  644. source: 'real-collected',
  645. };
  646. }
  647. function countMeta(out, it) {
  648. out.meta.platforms[it.platform] = (out.meta.platforms[it.platform] || 0) + 1;
  649. out.meta.products[it.product] = (out.meta.products[it.product] || 0) + 1;
  650. out.meta.keywords[it.keyword] = (out.meta.keywords[it.keyword] || 0) + 1;
  651. for (const h of (it.hypothesis || [])) {
  652. out.meta.hypotheses[h] = (out.meta.hypotheses[h] || 0) + 1;
  653. }
  654. }
  655. // ============================================================
  656. // 批次执行
  657. // ============================================================
  658. async function runBatch(batchNum, opts) {
  659. const batch = BATCHES[batchNum];
  660. if (!batch) throw new Error(`unknown batch: ${batchNum}`);
  661. console.log(`\n▶ Batch ${batchNum}: ${batch.name}`);
  662. auditLog(`BATCH-START ${batchNum} ${batch.name}`);
  663. for (const task of batch.xhs || []) {
  664. try { await collectXhs(task, opts); }
  665. catch (e) { console.log(` [xhs] ✗ ${task.kw}: ${e.message}`); auditLog(`xhs EXC ${task.kw} ${e.message.slice(0, 80)}`); }
  666. await sleep(800);
  667. }
  668. for (const task of batch.douyin || []) {
  669. try { await collectDouyin(task, opts); }
  670. catch (e) { console.log(` [dy] ✗ ${task.kw}: ${e.message}`); auditLog(`dy EXC ${task.kw} ${e.message.slice(0, 80)}`); }
  671. await sleep(600);
  672. }
  673. auditLog(`BATCH-END ${batchNum}`);
  674. }
  675. // ============================================================
  676. // CLI
  677. // ============================================================
  678. async function main() {
  679. const argv = process.argv.slice(2);
  680. const opts = { force: argv.includes('--force') };
  681. const batchArg = argv.find((a) => a.startsWith('--batch='));
  682. const isMerge = argv.includes('--merge');
  683. const isTest = argv.includes('--test');
  684. console.log('╔═══════════════════════════════════════════════════════════╗');
  685. console.log('║ 江中猴菇饮 OTC · VOC 多平台真实采集 ║');
  686. console.log('╚═══════════════════════════════════════════════════════════╝');
  687. console.log(` TikHub token: ${TIKHUB_TOKEN ? '✓ ' + TIKHUB_TOKEN.slice(0, 8) + '...' : '✗'}`);
  688. console.log(` VOC token: ${VOC_TOKEN ? '✓ ' + VOC_TOKEN.slice(0, 8) + '...' : '✗'}`);
  689. if (isTest) {
  690. console.log('\n▶ Test mode · 跑通单关键词 xhs["江中猴姑米稀"]');
  691. await collectXhs({ kw: '江中猴姑米稀', notes: 3, commentPages: 1, hypotheses: ['H1', 'H5'] }, { force: true });
  692. return;
  693. }
  694. if (batchArg) {
  695. const bn = batchArg.split('=')[1];
  696. if (bn === 'all') {
  697. for (const k of Object.keys(BATCHES)) await runBatch(k, opts);
  698. } else {
  699. await runBatch(bn, opts);
  700. }
  701. }
  702. if (isMerge || batchArg) {
  703. console.log('\n▶ Merging all collected raw data...');
  704. mergeAll();
  705. }
  706. if (!batchArg && !isMerge && !isTest) {
  707. console.log('\nUsage:');
  708. console.log(' --test 跑通单关键词测试 API 链路');
  709. console.log(' --batch=1|2|3|all 执行采集批次');
  710. console.log(' --merge 合并 raw/*/*.json → _merged.json');
  711. console.log(' --force 强制重抓(忽略已存在文件)');
  712. }
  713. }
  714. if (require.main === module) {
  715. main().catch((e) => { console.error('fatal:', e); process.exit(1); });
  716. }
  717. module.exports = { BATCHES, mergeAll, collectXhs, collectDouyin };