hongcheng-collect-douy.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. #!/usr/bin/env node
  2. /**
  3. * 洪城到家 · VOC 多平台采集(抖音为主)
  4. *
  5. * 覆盖竞品:天鹅到家、好孕妈妈、宜尔宝、妈咪无忧、爱侬家政、
  6. * 叮当找阿姨、优护佳、大爱天使、多喜娃、爱的果实
  7. *
  8. * 用法:
  9. * node scripts/tools/hongcheng-collect.js --batch=1
  10. * node scripts/tools/hongcheng-collect.js --batch=all
  11. * node scripts/tools/hongcheng-collect.js --merge
  12. */
  13. const fs = require('fs');
  14. const path = require('path');
  15. const os = require('os');
  16. const https = require('https');
  17. const ROOT = path.resolve(__dirname, '..', '..');
  18. const RAW_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw');
  19. const XHS_DIR = path.join(RAW_DIR, 'xhs');
  20. const DY_DIR = path.join(RAW_DIR, 'douyin');
  21. const MEITUAN_DIR = path.join(RAW_DIR, 'meituan');
  22. const AUDIT_LOG = path.join(RAW_DIR, 'audit.log');
  23. [RAW_DIR, XHS_DIR, DY_DIR, MEITUAN_DIR].forEach((d) => {
  24. if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
  25. });
  26. const VOC_TOKEN = (() => {
  27. const p = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  28. if (!fs.existsSync(p)) return '';
  29. try {
  30. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  31. return c.vocToken || c.sessionToken || '';
  32. } catch { return ''; }
  33. })();
  34. const TIKHUB_TOKEN = (() => {
  35. const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  36. if (!fs.existsSync(p)) return '';
  37. try {
  38. const c = JSON.parse(fs.readFileSync(p, 'utf8'));
  39. return c.currentToken || '';
  40. } catch { return ''; }
  41. })();
  42. console.log(` VOC Token: ${VOC_TOKEN ? VOC_TOKEN.slice(0,8)+'...' : '❌ missing'}`);
  43. console.log(` TikHub Token: ${TIKHUB_TOKEN ? TIKHUB_TOKEN.slice(0,8)+'...' : '❌ missing'}`);
  44. const BATCHES = {
  45. 1: {
  46. name: 'P0 · 本品 + 全国头部竞品',
  47. douyin: [
  48. { kw: '洪诚到家月嫂', videos: 8, commentPages: 30, hypotheses: ['H1', 'H4', 'H6'] },
  49. { kw: '天鹅到家月嫂', videos: 8, commentPages: 30, hypotheses: ['H1', 'H5', 'H7'] },
  50. { kw: '好孕妈妈月嫂', videos: 8, commentPages: 30, hypotheses: ['H1', 'H5', 'H7'] },
  51. { kw: '多喜娃月嫂', videos: 8, commentPages: 30, hypotheses: ['H1', 'H5', 'H7'] },
  52. { kw: '妈咪无忧月嫂', videos: 8, commentPages: 30, hypotheses: ['H1', 'H5', 'H7'] },
  53. { kw: '江西月嫂', videos: 6, commentPages: 25, hypotheses: ['H1'] },
  54. { kw: '南昌月嫂哪家好', videos: 6, commentPages: 25, hypotheses: ['H5', 'H7'] },
  55. ],
  56. },
  57. 2: {
  58. name: 'P1 · 场景痛点 + 更多竞品',
  59. douyin: [
  60. { kw: '新手爸妈找月嫂', videos: 6, commentPages: 25, hypotheses: ['H1', 'H4'] },
  61. { kw: '坐月子注意事项', videos: 6, commentPages: 25, hypotheses: ['H2', 'H4'] },
  62. { kw: '月嫂多少钱一个月', videos: 6, commentPages: 25, hypotheses: ['H2'] },
  63. { kw: '职场妈妈月子', videos: 5, commentPages: 20, hypotheses: ['H3'] },
  64. { kw: '新手爸妈', videos: 5, commentPages: 20, hypotheses: ['H1', 'H4'] },
  65. { kw: '宜尔宝月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H5'] },
  66. { kw: '爱侬家政月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H5'] },
  67. { kw: '优护佳月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H5'] },
  68. ],
  69. },
  70. 3: {
  71. name: 'P2 · 长尾 + 竞品对比 + 短剧',
  72. douyin: [
  73. { kw: '社区月嫂', videos: 5, commentPages: 20, hypotheses: ['H5'] },
  74. { kw: '南昌月嫂对比', videos: 5, commentPages: 20, hypotheses: ['H5', 'H7'] },
  75. { kw: '月嫂怎么找靠谱', videos: 5, commentPages: 20, hypotheses: ['H4', 'H8'] },
  76. { kw: '月嫂不满意可以换吗', videos: 4, commentPages: 15, hypotheses: ['H8'] },
  77. { kw: '朋友推荐月嫂', videos: 4, commentPages: 15, hypotheses: ['H6'] },
  78. { kw: '南昌短剧', videos: 5, commentPages: 20, hypotheses: ['H3'] },
  79. { kw: '婆媳关系坐月子', videos: 5, commentPages: 20, hypotheses: ['H3'] },
  80. { kw: '叮当找阿姨月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H5'] },
  81. { kw: '大爱天使月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H5'] },
  82. { kw: '爱的果实月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H5'] },
  83. ],
  84. },
  85. 4: {
  86. name: 'P3 · 价格/决策 + 更多长尾',
  87. douyin: [
  88. { kw: '南昌月嫂价格', videos: 5, commentPages: 20, hypotheses: ['H2'] },
  89. { kw: '月嫂服务', videos: 4, commentPages: 15, hypotheses: ['H4'] },
  90. { kw: '婆婆不让请月嫂', videos: 4, commentPages: 15, hypotheses: ['H1'] },
  91. { kw: '月嫂和保姆的区别', videos: 4, commentPages: 15, hypotheses: ['H4'] },
  92. { kw: '坐月子请月嫂', videos: 5, commentPages: 20, hypotheses: ['H1', 'H2'] },
  93. { kw: '月嫂真实评价', videos: 4, commentPages: 15, hypotheses: ['H4', 'H6'] },
  94. { kw: '月嫂一天工作流程', videos: 4, commentPages: 15, hypotheses: ['H4'] },
  95. { kw: '新生儿护理', videos: 5, commentPages: 20, hypotheses: ['H4'] },
  96. { kw: '产妇护理', videos: 4, commentPages: 15, hypotheses: ['H4'] },
  97. { kw: '月子餐做法', videos: 4, commentPages: 15, hypotheses: ['H4'] },
  98. ],
  99. },
  100. };
  101. const XHS_BATCHES = {
  102. 1: {
  103. name: 'P0 · 本品 + 头部竞品',
  104. xhs: [
  105. { kw: '洪诚到家月嫂', maxNotes: 15, maxComments: 10, hypotheses: ['H1', 'H4', 'H6'] },
  106. { kw: '天鹅到家月嫂', maxNotes: 15, maxComments: 10, hypotheses: ['H1', 'H5', 'H7'] },
  107. { kw: '好孕妈妈月嫂', maxNotes: 15, maxComments: 10, hypotheses: ['H1', 'H5', 'H7'] },
  108. { kw: '多喜娃月嫂', maxNotes: 12, maxComments: 8, hypotheses: ['H1', 'H5', 'H7'] },
  109. { kw: '妈咪无忧月嫂', maxNotes: 12, maxComments: 8, hypotheses: ['H1', 'H5', 'H7'] },
  110. { kw: '南昌月嫂哪家好', maxNotes: 12, maxComments: 8, hypotheses: ['H5', 'H7'] },
  111. ],
  112. },
  113. 2: {
  114. name: 'P1 · 场景痛点',
  115. xhs: [
  116. { kw: '新手爸妈找月嫂', maxNotes: 12, maxComments: 8, hypotheses: ['H1', 'H4'] },
  117. { kw: '坐月子注意事项', maxNotes: 12, maxComments: 8, hypotheses: ['H2', 'H4'] },
  118. { kw: '月嫂多少钱一个月', maxNotes: 12, maxComments: 8, hypotheses: ['H2'] },
  119. { kw: '月嫂怎么找靠谱', maxNotes: 10, maxComments: 6, hypotheses: ['H4', 'H8'] },
  120. { kw: '婆婆不让请月嫂', maxNotes: 10, maxComments: 6, hypotheses: ['H1'] },
  121. ],
  122. },
  123. 3: {
  124. name: 'P2 · 长尾 + 补全',
  125. xhs: [
  126. { kw: '社区月嫂', maxNotes: 10, maxComments: 6, hypotheses: ['H5'] },
  127. { kw: '南昌月嫂对比', maxNotes: 10, maxComments: 6, hypotheses: ['H5', 'H7'] },
  128. { kw: '月嫂不满意可以换吗', maxNotes: 8, maxComments: 5, hypotheses: ['H8'] },
  129. { kw: '朋友推荐月嫂', maxNotes: 8, maxComments: 5, hypotheses: ['H6'] },
  130. { kw: '江西月嫂', maxNotes: 10, maxComments: 6, hypotheses: ['H1'] },
  131. ],
  132. },
  133. };
  134. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  135. function sanitizeFilename(kw) {
  136. return kw.replace(/[\/\\:*?"<>|\s]+/g, '-');
  137. }
  138. function auditLog(line) {
  139. const ts = new Date().toISOString();
  140. try {
  141. fs.appendFileSync(AUDIT_LOG, `[${ts}] ${line}\n`);
  142. } catch {}
  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. req.setTimeout(60000);
  164. if (bodyData) req.write(bodyData);
  165. req.end();
  166. });
  167. if (result.status === 200) return resolve(result);
  168. if ([400, 429, 500, 502, 503, 504].includes(result.status) && attempt < maxAttempts) {
  169. await sleep(1500 * Math.pow(1.8, attempt - 1));
  170. continue;
  171. }
  172. return resolve(result);
  173. } catch (e) {
  174. if (attempt === maxAttempts) return reject(e);
  175. await sleep(2000 * attempt);
  176. }
  177. }
  178. });
  179. }
  180. async function dyPostV2(apiPath, bodyObj = {}) {
  181. if (!VOC_TOKEN) return { _error: 'no VOC token' };
  182. const bodyStr = JSON.stringify(bodyObj);
  183. const { status, body } = await httpRequest({
  184. hostname: 'server.fmode.cn',
  185. path: apiPath,
  186. method: 'POST',
  187. headers: {
  188. Accept: 'application/json',
  189. 'Content-Type': 'application/json',
  190. 'Content-Length': Buffer.byteLength(bodyStr),
  191. Authorization: `Bearer ${VOC_TOKEN}`,
  192. },
  193. }, bodyStr);
  194. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  195. try { return JSON.parse(body); }
  196. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  197. }
  198. async function dyGetV3(apiPath, paramsObj = {}) {
  199. if (!VOC_TOKEN) return { _error: 'no VOC token' };
  200. const qs = Object.entries(paramsObj)
  201. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  202. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  203. .join('&');
  204. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  205. const { status, body } = await httpRequest({
  206. hostname: 'server.fmode.cn',
  207. path: fullPath,
  208. method: 'GET',
  209. headers: {
  210. Accept: 'application/json',
  211. Authorization: `Bearer ${VOC_TOKEN}`,
  212. },
  213. });
  214. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  215. try { return JSON.parse(body); }
  216. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  217. }
  218. async function xhsSearchNotes(keyword, maxNotes = 15) {
  219. if (!TIKHUB_TOKEN) return { _error: 'no TikHub token' };
  220. const bodyStr = JSON.stringify({
  221. keyword,
  222. sort_type: 'general',
  223. note_type: 'normal',
  224. total_number: maxNotes,
  225. });
  226. const { status, body } = await httpRequest({
  227. hostname: 'api.tikhub.io',
  228. path: '/api/v1/xiaohongshu/web_v1/search_notes',
  229. method: 'POST',
  230. headers: {
  231. Accept: 'application/json',
  232. 'Content-Type': 'application/json',
  233. 'Content-Length': Buffer.byteLength(bodyStr),
  234. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  235. },
  236. }, bodyStr);
  237. if (status !== 200) return { _error: { status, body: body.slice(0, 200) } };
  238. try { return JSON.parse(body); }
  239. catch (e) { return { _error: { parse: e.message, body: body.slice(0, 200) } }; }
  240. }
  241. async function xhsNoteComments(noteId, maxComments = 10) {
  242. if (!TIKHUB_TOKEN) return { _error: 'no TikHub token' };
  243. let cursor = '';
  244. const allComments = [];
  245. let hasMore = true;
  246. let attempts = 0;
  247. while (hasMore && allComments.length < maxComments && attempts < 5) {
  248. const bodyStr = JSON.stringify({ note_id: noteId, cursor });
  249. const { status, body } = await httpRequest({
  250. hostname: 'api.tikhub.io',
  251. path: '/api/v1/xiaohongshu/web_v1/note_comments',
  252. method: 'POST',
  253. headers: {
  254. Accept: 'application/json',
  255. 'Content-Type': 'application/json',
  256. 'Content-Length': Buffer.byteLength(bodyStr),
  257. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  258. },
  259. }, bodyStr);
  260. if (status !== 200 || !body) break;
  261. try {
  262. const res = JSON.parse(body);
  263. const comments = res?.data?.comments || res?.comments || [];
  264. allComments.push(...comments.slice(0, maxComments - allComments.length));
  265. cursor = res?.data?.cursor || res?.cursor || '';
  266. hasMore = !!(res?.data?.has_more || res?.has_more);
  267. } catch { break; }
  268. attempts++;
  269. await sleep(300);
  270. }
  271. return allComments;
  272. }
  273. async function collectDouyin(spec, { force = false } = {}) {
  274. const outPath = path.join(DY_DIR, `${sanitizeFilename(spec.kw)}.json`);
  275. if (!force && fileExistsNonEmpty(outPath)) {
  276. console.log(` ⏭️ ${spec.kw} (已存在, skip)`);
  277. auditLog(`dy SKIP ${spec.kw} (exists)`);
  278. return { skipped: true, path: outPath };
  279. }
  280. console.log(` 🔍 "${spec.kw}" · videos=${spec.videos} × commentPages=${spec.commentPages}`);
  281. const startTs = Date.now();
  282. const searchRes = await dyPostV2('/api/voc-social/douyin/search/fetch_general_search_v2', {
  283. keyword: spec.kw,
  284. cursor: 0,
  285. sort_type: '1',
  286. publish_time: '0',
  287. content_type: '1',
  288. filter_duration: '0',
  289. search_id: '',
  290. backtrace: '',
  291. });
  292. const businessData = searchRes?.data?.business_data || [];
  293. const validVideos = businessData
  294. .map((wrap) => wrap?.data?.aweme_info || wrap?.aweme_info)
  295. .filter((v) => v?.aweme_id);
  296. console.log(` 搜索到 ${validVideos.length} 条视频`);
  297. if (validVideos.length === 0) {
  298. const errOut = {
  299. platform: 'douyin', keyword: spec.kw,
  300. _search_error: searchRes?._error || searchRes?.msg || 'empty',
  301. collected_at: new Date().toISOString(),
  302. };
  303. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  304. auditLog(`dy FAIL ${spec.kw} search_empty`);
  305. return { skipped: false, videos: 0, comments: 0, path: outPath };
  306. }
  307. const topVideos = [...validVideos]
  308. .sort((a, b) => (b.statistics?.comment_count || 0) - (a.statistics?.comment_count || 0))
  309. .slice(0, spec.videos);
  310. const commentsByAweme = {};
  311. let totalComments = 0;
  312. for (const v of topVideos) {
  313. commentsByAweme[v.aweme_id] = [];
  314. let cursor = 0;
  315. for (let p = 0; p < (spec.commentPages || 2); p++) {
  316. await sleep(700);
  317. const cRes = await dyGetV3('/api/voc-social/douyin/app/v3/fetch_video_comments', {
  318. aweme_id: v.aweme_id,
  319. cursor,
  320. count: 20,
  321. });
  322. const cmts = cRes?.data?.comments || cRes?.comments || [];
  323. for (const c of cmts) {
  324. commentsByAweme[v.aweme_id].push({
  325. cid: c.cid,
  326. text: c.text,
  327. digg_count: c.digg_count,
  328. create_time: c.create_time,
  329. ip_label: c.ip_label,
  330. reply_comment_total: c.reply_comment_total,
  331. user: c.user ? { nickname: c.user.nickname, uid: c.user.uid } : null,
  332. });
  333. }
  334. totalComments += cmts.length;
  335. const hasMore = (cRes?.data?.has_more ?? cRes?.has_more) === 1;
  336. cursor = cRes?.data?.cursor ?? cRes?.cursor ?? 0;
  337. if (!hasMore) break;
  338. }
  339. }
  340. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  341. console.log(` ✓ ${spec.kw}: ${topVideos.length} 视频 / ${totalComments} 评论 (${elapsed}s)`);
  342. auditLog(`dy OK ${spec.kw} videos=${topVideos.length} comments=${totalComments}`);
  343. const out = {
  344. platform: 'douyin',
  345. keyword: spec.kw,
  346. hypotheses: spec.hypotheses,
  347. collected_at: new Date().toISOString(),
  348. elapsed_seconds: Number(elapsed),
  349. total_videos_found: validVideos.length,
  350. top_videos: topVideos.map((v) => ({
  351. aweme_id: v.aweme_id,
  352. desc: v.desc,
  353. create_time: v.create_time,
  354. statistics: v.statistics,
  355. author: v.author ? { nickname: v.author.nickname, sec_uid: v.author.sec_uid, uid: v.author.uid } : null,
  356. cover: v.video?.cover?.url_list?.[0],
  357. })),
  358. comments_by_aweme_id: commentsByAweme,
  359. total_comments: totalComments,
  360. };
  361. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  362. return { skipped: false, videos: topVideos.length, comments: totalComments, path: outPath };
  363. }
  364. async function collectXhs(spec, { force = false } = {}) {
  365. const outPath = path.join(XHS_DIR, `${sanitizeFilename(spec.kw)}.json`);
  366. if (!force && fileExistsNonEmpty(outPath)) {
  367. console.log(` ⏭️ xhs:${spec.kw} (已存在, skip)`);
  368. auditLog(`xhs SKIP ${spec.kw} (exists)`);
  369. return { skipped: true, path: outPath };
  370. }
  371. console.log(` 🔍 xhs:"${spec.kw}" · notes=${spec.maxNotes} × comments=${spec.maxComments}`);
  372. const startTs = Date.now();
  373. const searchRes = await xhsSearchNotes(spec.kw, spec.maxNotes);
  374. if (searchRes._error) {
  375. console.log(` ✗ 搜索失败: ${searchRes._error.status || searchRes._error.message}`);
  376. auditLog(`xhs FAIL ${spec.kw} search_error`);
  377. fs.writeFileSync(outPath, JSON.stringify({ platform: 'xhs', keyword: spec.kw, _error: searchRes._error, collected_at: new Date().toISOString() }, null, 2));
  378. return { skipped: false, notes: 0, comments: 0 };
  379. }
  380. const notes = searchRes?.data?.notes || searchRes?.notes || [];
  381. console.log(` 搜索到 ${notes.length} 篇笔记`);
  382. if (notes.length === 0) {
  383. fs.writeFileSync(outPath, JSON.stringify({ platform: 'xhs', keyword: spec.kw, _empty: true, collected_at: new Date().toISOString() }, null, 2));
  384. auditLog(`xhs EMPTY ${spec.kw}`);
  385. return { skipped: false, notes: 0, comments: 0 };
  386. }
  387. const items = [];
  388. for (const note of notes.slice(0, spec.maxNotes)) {
  389. await sleep(250);
  390. const comments = await xhsNoteComments(note.id, spec.maxComments);
  391. for (const c of comments) {
  392. items.push({
  393. platform: 'xhs',
  394. keyword: spec.kw,
  395. hypotheses: spec.hypotheses,
  396. note_id: note.id,
  397. note_title: note.title || note.desc || '',
  398. note_likes: note.liked_count || 0,
  399. comment_id: c.id || c.comment_id || '',
  400. content: c.content || c.text || '',
  401. user_nickname: c.user_info?.nickname || c.user_nickname || '匿名',
  402. ip_location: c.ip_location || c.ip_label || '',
  403. comment_likes: c.like_count || c.liked_count || 0,
  404. collected_at: new Date().toISOString(),
  405. });
  406. }
  407. }
  408. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  409. console.log(` ✓ xhs:${spec.kw}: ${notes.length} 笔记 / ${items.length} 评论 (${elapsed}s)`);
  410. auditLog(`xhs OK ${spec.kw} notes=${notes.length} comments=${items.length}`);
  411. const out = { platform: 'xhs', keyword: spec.kw, hypotheses: spec.hypotheses, collected_at: new Date().toISOString(), elapsed_seconds: Number(elapsed), notes_found: notes.length, notes_processed: Math.min(notes.length, spec.maxNotes), comments: items };
  412. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  413. return { skipped: false, notes: notes.length, comments: items.length, path: outPath };
  414. }
  415. const HYPOTHESIS_KEYWORDS = {
  416. H1: ['医院', '产检', '待产', '生孩子', '妇幼', '生产', '月嫂怎么找'],
  417. H2: ['价格', '多少钱', '收费', '报价', '性价比', '便宜', '贵'],
  418. H3: ['短剧', '抖音', '视频', '小红书', '看到'],
  419. H4: ['专业', '资质', '证书', '星级', '靠谱', '放心', '正规'],
  420. H5: ['社区', '小店', '私人', '对比', '选择'],
  421. H6: ['朋友', '推荐', '介绍', '转介绍', '口碑', '好评'],
  422. H7: ['美团', '大众点评', '搜索', '排名', '评价'],
  423. H8: ['换', '退', '不满意', '保障', '售后', '风险'],
  424. };
  425. function inferHypotheses(text, keywordHypotheses) {
  426. const results = new Set(keywordHypotheses || []);
  427. const t = String(text || '').toLowerCase();
  428. for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) {
  429. for (const kw of kws) {
  430. if (t.includes(kw.toLowerCase())) { results.add(h); break; }
  431. }
  432. }
  433. return Array.from(results);
  434. }
  435. const TAG_RULES = [
  436. { tag: '价格敏感', re: /价格|多少钱|贵|便宜|性价比|收费/ },
  437. { tag: '专业度关注', re: /专业|资质|证书|星级|培训/ },
  438. { tag: '安全保障', re: /放心|靠谱|安全|保障|正规/ },
  439. { tag: '医院渠道', re: /医院|产检|妇幼|待产|生孩子/ },
  440. { tag: '熟人推荐', re: /朋友推荐|介绍|口碑|好评|亲戚/ },
  441. { tag: '线上搜索', re: /美团|小红书|抖音|搜索/ },
  442. { tag: '服务担忧', re: /换|退|不满意|售后|保障/ },
  443. { tag: '婆媳关系', re: /婆婆|奶奶|家里|老人/ },
  444. { tag: '职场妈妈', re: /上班|工作|复工|职场/ },
  445. { tag: '新手爸妈', re: /新手|第一次|第一次当妈妈/ },
  446. ];
  447. function inferTags(text) {
  448. const tags = [];
  449. for (const r of TAG_RULES) {
  450. if (r.re.test(text || '')) tags.push(r.tag);
  451. }
  452. return tags;
  453. }
  454. const SENTIMENT_POS = /好|推荐|满意|专业|靠谱|放心|值得|不错|棒|优秀|喜欢|感谢/;
  455. const SENTIMENT_NEG = /差|坑|骗|贵|不专业|不满意|后悔|吐槽|垃圾|失望|糟糕|骗人/;
  456. const SENTIMENT_CONFLICT = /但是|可是|纠结|担心|犹豫|想又怕/;
  457. function inferSentiment(text) {
  458. const t = String(text || '');
  459. const pos = SENTIMENT_POS.test(t);
  460. const neg = SENTIMENT_NEG.test(t);
  461. const conf = SENTIMENT_CONFLICT.test(t);
  462. if (conf && (pos || neg)) return 'conflicted';
  463. if (pos && !neg) return 'positive';
  464. if (neg && !pos) return 'negative';
  465. return 'neutral';
  466. }
  467. function mergeAll() {
  468. const out = {
  469. meta: {
  470. collectedAt: new Date().toISOString(),
  471. platforms: {},
  472. products: {},
  473. hypotheses: {},
  474. keywords: {},
  475. stage: 'batch-real',
  476. sourceTier: 'real-collected',
  477. },
  478. items: [],
  479. };
  480. const flat = [];
  481. if (fs.existsSync(DY_DIR)) {
  482. for (const f of fs.readdirSync(DY_DIR)) {
  483. if (!f.endsWith('.json')) continue;
  484. const raw = JSON.parse(fs.readFileSync(path.join(DY_DIR, f), 'utf8'));
  485. if (raw._error || raw._search_error) continue;
  486. const kw = raw.keyword;
  487. const kwHypos = raw.hypotheses || [];
  488. for (const v of (raw.top_videos || [])) {
  489. const awemeId = v.aweme_id;
  490. const cmts = (raw.comments_by_aweme_id || {})[awemeId] || [];
  491. if (v.desc) {
  492. const item = buildItem({ id: `dy_${awemeId}`, platform: 'douyin', product: kw, keyword: kw, type: 'video', nickname: v.author?.nickname || '匿名', ip: '', content: v.desc.slice(0, 400), likes: v.statistics?.digg_count || 0, rating: null, kwHypos });
  493. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  494. }
  495. for (const c of cmts) {
  496. if (!c.text) continue;
  497. const item = buildItem({ id: `dy_${awemeId}_${c.cid}`, platform: 'douyin', product: kw, keyword: kw, type: 'comment', nickname: c.user?.nickname || '匿名', ip: c.ip_label || '', content: c.text.slice(0, 500), likes: c.digg_count || 0, rating: null, kwHypos });
  498. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  499. }
  500. }
  501. }
  502. }
  503. if (fs.existsSync(XHS_DIR)) {
  504. for (const f of fs.readdirSync(XHS_DIR)) {
  505. if (!f.endsWith('.json')) continue;
  506. const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, f), 'utf8'));
  507. if (raw._error || raw._empty) continue;
  508. const kw = raw.keyword;
  509. const kwHypos = raw.hypotheses || [];
  510. const comments = raw.comments || [];
  511. for (const c of comments) {
  512. if (!c.content) continue;
  513. const item = buildItem({
  514. id: `xhs_${c.note_id}_${c.comment_id}`,
  515. platform: 'xhs',
  516. product: kw,
  517. keyword: kw,
  518. type: 'comment',
  519. nickname: c.user_nickname || '匿名',
  520. ip: c.ip_location || '',
  521. content: c.content.slice(0, 500),
  522. likes: c.comment_likes || 0,
  523. rating: null,
  524. kwHypos,
  525. note_title: c.note_title || '',
  526. note_likes: c.note_likes || 0,
  527. });
  528. if (item) { out.items.push(item); flat.push(item); countMeta(out, item); }
  529. }
  530. }
  531. }
  532. const seen = new Set();
  533. out.items = out.items.filter((it) => { if (seen.has(it.id)) return false; seen.add(it.id); return true; });
  534. out.meta.productsCount = Object.keys(out.meta.products).length;
  535. out.meta.keywordsCount = Object.keys(out.meta.keywords).length;
  536. out.meta.comments = out.items.length;
  537. fs.writeFileSync(path.join(RAW_DIR, '_merged.json'), JSON.stringify(out, null, 2), 'utf8');
  538. fs.writeFileSync(path.join(RAW_DIR, 'comments-flat.jsonl'), flat.map((it) => JSON.stringify(it)).join('\n'), 'utf8');
  539. console.log(` ✅ merged: ${out.items.length} items | ${out.meta.productsCount} products`);
  540. return out;
  541. }
  542. function buildItem({ id, platform, product, keyword, type, nickname, ip, content, likes, rating, kwHypos }) {
  543. if (!content || content.length < 3) return null;
  544. const hypotheses = inferHypotheses(content, kwHypos);
  545. const tags = inferTags(content);
  546. const sentiment = inferSentiment(content);
  547. return { id, platform, product, keyword, type, nickname, ip, content, likes, rating, hypothesis: hypotheses, tags, sentiment, source: 'real-collected' };
  548. }
  549. function countMeta(out, it) {
  550. out.meta.platforms[it.platform] = (out.meta.platforms[it.platform] || 0) + 1;
  551. out.meta.products[it.product] = (out.meta.products[it.product] || 0) + 1;
  552. out.meta.keywords[it.keyword] = (out.meta.keywords[it.keyword] || 0) + 1;
  553. for (const h of (it.hypothesis || [])) {
  554. out.meta.hypotheses[h] = (out.meta.hypotheses[h] || 0) + 1;
  555. }
  556. }
  557. async function runBatch(batchNum, opts) {
  558. const batch = BATCHES[batchNum];
  559. if (!batch) throw new Error(`unknown batch: ${batchNum}`);
  560. console.log(`\n▶ Batch ${batchNum}: ${batch.name}`);
  561. for (const task of batch.douyin || []) {
  562. try { await collectDouyin(task, opts); } catch (e) { console.log(` ✗ ${task.kw}: ${e.message}`); auditLog(`dy EXC ${task.kw}`); }
  563. await sleep(800);
  564. }
  565. }
  566. async function runXhsBatch(batchNum, opts) {
  567. const batch = XHS_BATCHES[batchNum];
  568. if (!batch) throw new Error(`unknown xhs batch: ${batchNum}`);
  569. console.log(`\n▶ XHS Batch ${batchNum}: ${batch.name}`);
  570. for (const task of batch.xhs || []) {
  571. try { await collectXhs(task, opts); } catch (e) { console.log(` ✗ xhs:${task.kw}: ${e.message}`); auditLog(`xhs EXC ${task.kw}`); }
  572. await sleep(350);
  573. }
  574. }
  575. async function main() {
  576. const argv = process.argv.slice(2);
  577. const opts = { force: argv.includes('--force') };
  578. const batchArg = argv.find((a) => a.startsWith('--batch='));
  579. const xhsBatchArg = argv.find((a) => a.startsWith('--xhs='));
  580. const isMerge = argv.includes('--merge');
  581. console.log('╔═══════════════════════════════════════════════════════════╗');
  582. console.log('║ 洪城到家 · VOC 多平台采集(抖音+小红书) ║');
  583. console.log('╚═══════════════════════════════════════════════════════════╝');
  584. console.log(` VOC Token: ${VOC_TOKEN ? '✓' : '✗'}`);
  585. console.log(` TikHub Token: ${TIKHUB_TOKEN ? '✓' : '✗'}`);
  586. if (batchArg) {
  587. const bn = batchArg.split('=')[1];
  588. if (bn === 'all') {
  589. for (const k of Object.keys(BATCHES)) await runBatch(k, opts);
  590. } else {
  591. await runBatch(bn, opts);
  592. }
  593. }
  594. if (xhsBatchArg) {
  595. const bn = xhsBatchArg.split('=')[1];
  596. if (bn === 'all') {
  597. for (const k of Object.keys(XHS_BATCHES)) await runXhsBatch(k, opts);
  598. } else {
  599. await runXhsBatch(bn, opts);
  600. }
  601. }
  602. if (isMerge || batchArg || xhsBatchArg) {
  603. console.log('\n▶ Merging...');
  604. mergeAll();
  605. }
  606. if (!batchArg && !xhsBatchArg && !isMerge) {
  607. console.log('\nUsage:');
  608. console.log(' --batch=1|2|3|4|all 执行抖音采集批次');
  609. console.log(' --xhs=1|2|3|all 执行小红书采集批次');
  610. console.log(' --merge 合并数据');
  611. console.log(' --force 强制重抓');
  612. console.log('\n抖音关键词覆盖:');
  613. console.log(' - 全国龙头: 天鹅到家、好孕妈妈');
  614. console.log(' - 区域强者: 多喜娃、妈咪无忧、宜尔宝、爱侬家政、优护佳');
  615. console.log(' - 长尾品牌: 叮当找阿姨、大爱天使、爱的果实');
  616. console.log(' - 场景词: 新手爸妈、坐月子、月子餐、产妇护理');
  617. console.log('\n小红书关键词覆盖:');
  618. console.log(' - 本品: 洪诚到家月嫂');
  619. console.log(' - 竞品: 天鹅到家、好孕妈妈、多喜娃、妈咪无忧');
  620. console.log(' - 场景: 新手爸妈找月嫂、月嫂多少钱一个月、月嫂怎么找靠谱');
  621. }
  622. }
  623. if (require.main === module) {
  624. main().catch((e) => { console.error('fatal:', e); process.exit(1); });
  625. }
  626. module.exports = { BATCHES, XHS_BATCHES, mergeAll, collectDouyin, collectXhs };