hongcheng-collect-xhs.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. #!/usr/bin/env node
  2. /**
  3. * 洪城到家 · 小红书(XHS)数据采集脚本
  4. *
  5. * 使用 TikHub API (via server.fmode.cn)
  6. * 参考 fmode-voc-projects/scripts/tools/lactic-collect.js
  7. */
  8. const fs = require('fs');
  9. const path = require('path');
  10. const os = require('os');
  11. const https = require('https');
  12. const ROOT = path.resolve(__dirname, '..', '..');
  13. const RAW_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw');
  14. const XHS_DIR = path.join(RAW_DIR, 'xhs');
  15. const AUDIT_LOG = path.join(RAW_DIR, 'audit.log');
  16. const API_CONFIG = (() => {
  17. const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  18. if (!fs.existsSync(p)) return null;
  19. try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
  20. })();
  21. const TIKHUB_TOKEN = API_CONFIG?.currentToken
  22. || API_CONFIG?.endpoint?.headers?.Authorization?.replace(/^Bearer\s+/, '')
  23. || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
  24. const API_HOST = 'server.fmode.cn';
  25. [RAW_DIR, XHS_DIR].forEach((d) => {
  26. if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
  27. });
  28. const BATCHES = {
  29. 1: {
  30. name: 'P0 · 品牌关键词',
  31. xhs: [
  32. { kw: '洪城到家', notes: 20, commentPages: 2, hypotheses: ['H1', 'H4', 'H6'] },
  33. { kw: '月嫂', notes: 20, commentPages: 2, hypotheses: ['H1', 'H2', 'H4'] },
  34. { kw: '多喜娃', notes: 20, commentPages: 2, hypotheses: ['H1', 'H5', 'H7'] },
  35. { kw: '天鹅到家', notes: 20, commentPages: 2, hypotheses: ['H1', 'H5', 'H7'] },
  36. { kw: '好孕妈妈', notes: 20, commentPages: 2, hypotheses: ['H1', 'H5', 'H7'] },
  37. ],
  38. },
  39. };
  40. const HYPOTHESIS_KEYWORDS = {
  41. H1: ['医院', '产检', '待产', '生孩子', '妇幼', '生产', '月嫂怎么找'],
  42. H2: ['价格', '多少钱', '收费', '报价', '性价比', '便宜', '贵'],
  43. H3: ['短剧', '抖音', '视频', '小红书', '看到'],
  44. H4: ['专业', '资质', '证书', '星级', '靠谱', '放心', '正规'],
  45. H5: ['社区', '小店', '私人', '对比', '选择'],
  46. H6: ['朋友', '推荐', '介绍', '转介绍', '口碑', '好评'],
  47. H7: ['美团', '大众点评', '搜索', '排名', '评价'],
  48. H8: ['换', '退', '不满意', '保障', '售后', '风险'],
  49. };
  50. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  51. function sanitizeFilename(kw) {
  52. return kw.replace(/[\/\\:*?"<>|\s]+/g, '-');
  53. }
  54. function auditLog(line) {
  55. const ts = new Date().toISOString();
  56. try { fs.appendFileSync(AUDIT_LOG, `[${ts}] ${line}\n`); } catch {}
  57. }
  58. function fileExistsNonEmpty(p) {
  59. if (!fs.existsSync(p)) return false;
  60. return fs.statSync(p).size > 100;
  61. }
  62. function tikhubGet(apiPath, params = {}) {
  63. const qs = Object.entries(params)
  64. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  65. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  66. .join('&');
  67. const url = qs ? `${apiPath}?${qs}` : apiPath;
  68. return new Promise((resolve) => {
  69. const opts = {
  70. hostname: API_HOST,
  71. path: url,
  72. method: 'GET',
  73. headers: {
  74. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  75. Accept: 'application/json',
  76. },
  77. };
  78. const req = https.request(opts, (res) => {
  79. const chunks = [];
  80. res.on('data', (c) => chunks.push(c));
  81. res.on('end', () => {
  82. const body = Buffer.concat(chunks).toString('utf-8');
  83. try {
  84. const j = JSON.parse(body);
  85. resolve({ data: j, status: res.statusCode });
  86. } catch (e) {
  87. resolve({ data: { _parse_error: e.message, _raw: body.slice(0, 500) }, status: res.statusCode });
  88. }
  89. });
  90. });
  91. req.on('error', (e) => resolve({ data: { _error: e.message }, status: 0 }));
  92. req.setTimeout(45000, () => { req.destroy(); resolve({ data: { _error: 'timeout' }, status: 0 }); });
  93. req.end();
  94. });
  95. }
  96. async function collectXhs(spec, { force = false } = {}) {
  97. const outPath = path.join(XHS_DIR, `${sanitizeFilename(spec.kw)}.json`);
  98. if (!force && fileExistsNonEmpty(outPath)) {
  99. console.log(` [xhs] ⏭️ ${spec.kw} (已存在, skip)`);
  100. auditLog(`xhs SKIP ${spec.kw} (exists)`);
  101. return { skipped: true, path: outPath };
  102. }
  103. console.log(` [xhs] 🔍 搜索 "${spec.kw}" · notes=${spec.notes} × commentPages=${spec.commentPages}`);
  104. const startTs = Date.now();
  105. const searchRes1 = await tikhubGet('/thapi/v1/xiaohongshu/app/search_notes', { keyword: spec.kw, page: 1, sort: 'general' });
  106. await sleep(600);
  107. const searchRes2 = await tikhubGet('/thapi/v1/xiaohongshu/app/search_notes', { keyword: spec.kw, page: 1, sort: 'popularity_descending' });
  108. const items1 = searchRes1?.data?.data?.items || [];
  109. const items2 = searchRes2?.data?.data?.items || [];
  110. const noteMap = new Map();
  111. for (const it of [...items1, ...items2]) {
  112. const n = it?.note;
  113. if (n?.id && !noteMap.has(n.id)) noteMap.set(n.id, n);
  114. }
  115. const allNotes = Array.from(noteMap.values());
  116. console.log(` [xhs] 去重后 ${allNotes.length} 条笔记 (综合 ${items1.length} + 最热 ${items2.length})`);
  117. if (allNotes.length === 0) {
  118. const errOut = {
  119. platform: 'xiaohongshu',
  120. keyword: spec.kw,
  121. _error: searchRes1?.data?._error || searchRes2?.data?._error || searchRes1?.data?.detail || searchRes2?.data?.detail || 'empty',
  122. collected_at: new Date().toISOString(),
  123. };
  124. fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8');
  125. auditLog(`xhs FAIL ${spec.kw} empty/err=${JSON.stringify(errOut._error).slice(0, 80)}`);
  126. return { skipped: false, notes: 0, comments: 0, path: outPath };
  127. }
  128. const topNotes = [...allNotes]
  129. .sort((a, b) => ((b.comments_count || 0) * 2 + (b.liked_count || 0)) - ((a.comments_count || 0) * 2 + (a.liked_count || 0)))
  130. .slice(0, spec.notes);
  131. const commentsByNoteId = {};
  132. let totalComments = 0;
  133. for (const note of topNotes) {
  134. if (!note?.id) continue;
  135. commentsByNoteId[note.id] = [];
  136. let cursor = '';
  137. for (let p = 0; p < (spec.commentPages || 2); p++) {
  138. await sleep(700);
  139. const cRes = await tikhubGet('/thapi/v1/xiaohongshu/app/get_note_comments', { note_id: note.id, cursor });
  140. const cmts = cRes?.data?.data?.comments || [];
  141. for (const c of cmts) {
  142. commentsByNoteId[note.id].push({
  143. id: c.id,
  144. content: c.content,
  145. create_time: c.create_time,
  146. like_count: c.like_count,
  147. sub_comment_count: c.sub_comment_count,
  148. ip_location: c.ip_location,
  149. user: c.user_info ? { user_id: c.user_info.user_id, nickname: c.user_info.nickname } : null,
  150. sub_comments: (c.sub_comments || []).slice(0, 3).map((s) => ({
  151. content: s.content,
  152. like_count: s.like_count,
  153. nickname: s.user_info?.nickname,
  154. })),
  155. });
  156. }
  157. totalComments += cmts.length;
  158. const hasMore = cRes?.data?.data?.has_more;
  159. cursor = cRes?.data?.data?.cursor || '';
  160. if (!hasMore || !cursor) break;
  161. }
  162. }
  163. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  164. console.log(` [xhs] ✓ ${spec.kw}: ${topNotes.length} 笔记 / ${totalComments} 评论 (${elapsed}s)`);
  165. auditLog(`xhs OK ${spec.kw} notes=${topNotes.length} comments=${totalComments} (${elapsed}s)`);
  166. const out = {
  167. platform: 'xiaohongshu',
  168. keyword: spec.kw,
  169. hypotheses: spec.hypotheses,
  170. collected_at: new Date().toISOString(),
  171. elapsed_seconds: Number(elapsed),
  172. total_notes_found: allNotes.length,
  173. top_notes: topNotes.map((n) => ({
  174. id: n.id,
  175. type: n.type,
  176. title: n.title,
  177. desc: n.desc,
  178. timestamp: n.timestamp,
  179. liked_count: n.liked_count,
  180. comments_count: n.comments_count,
  181. collected_count: n.collected_count,
  182. shared_count: n.shared_count,
  183. cover: n.images_list?.[0]?.url,
  184. user: n.user ? {
  185. userid: n.user.userid,
  186. nickname: n.user.nickname,
  187. red_id: n.user.red_id,
  188. verified: n.user.red_official_verified,
  189. } : null,
  190. })),
  191. comments: commentsByNoteId,
  192. };
  193. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  194. return { skipped: false, notes: topNotes.length, comments: totalComments, path: outPath };
  195. }
  196. function inferHypotheses(text, keywordHypotheses) {
  197. const results = new Set(keywordHypotheses || []);
  198. const t = String(text || '').toLowerCase();
  199. for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) {
  200. for (const kw of kws) {
  201. if (t.includes(kw.toLowerCase())) { results.add(h); break; }
  202. }
  203. }
  204. return Array.from(results);
  205. }
  206. const TAG_RULES = [
  207. { tag: '价格敏感', re: /价格|多少钱|贵|便宜|性价比|收费/ },
  208. { tag: '专业度关注', re: /专业|资质|证书|星级|培训/ },
  209. { tag: '安全保障', re: /放心|靠谱|安全|保障|正规/ },
  210. { tag: '医院渠道', re: /医院|产检|妇幼|待产|生孩子/ },
  211. { tag: '熟人推荐', re: /朋友推荐|介绍|口碑|好评|亲戚/ },
  212. { tag: '线上搜索', re: /美团|小红书|抖音|搜索/ },
  213. { tag: '服务担忧', re: /换|退|不满意|售后|保障/ },
  214. { tag: '婆媳关系', re: /婆婆|奶奶|家里|老人/ },
  215. { tag: '职场妈妈', re: /上班|工作|复工|职场/ },
  216. { tag: '新手爸妈', re: /新手|第一次|第一次当妈妈/ },
  217. ];
  218. function inferTags(text) {
  219. const tags = [];
  220. for (const r of TAG_RULES) {
  221. if (r.re.test(text || '')) tags.push(r.tag);
  222. }
  223. return tags;
  224. }
  225. const SENTIMENT_POS = /好|推荐|满意|专业|靠谱|放心|值得|不错|棒|优秀|喜欢|感谢/;
  226. const SENTIMENT_NEG = /差|坑|骗|贵|不专业|不满意|后悔|吐槽|垃圾|失望|糟糕|骗人/;
  227. const SENTIMENT_CONFLICT = /但是|可是|纠结|担心|犹豫|想又怕/;
  228. function inferSentiment(text) {
  229. const t = String(text || '');
  230. const pos = SENTIMENT_POS.test(t);
  231. const neg = SENTIMENT_NEG.test(t);
  232. const conf = SENTIMENT_CONFLICT.test(t);
  233. if (conf && (pos || neg)) return 'conflicted';
  234. if (pos && !neg) return 'positive';
  235. if (neg && !pos) return 'negative';
  236. return 'neutral';
  237. }
  238. function buildItem({ id, platform, product, keyword, type, nickname, ip, content, likes, rating, kwHypos }) {
  239. if (!content || content.length < 3) return null;
  240. const hypotheses = inferHypotheses(content, kwHypos);
  241. const tags = inferTags(content);
  242. const sentiment = inferSentiment(content);
  243. return { id, platform, product, keyword, type, nickname, ip, content, likes, rating, hypothesis: hypotheses, tags, sentiment, source: 'real-collected' };
  244. }
  245. function mergeAll() {
  246. const out = {
  247. meta: {
  248. collectedAt: new Date().toISOString(),
  249. platforms: {},
  250. products: {},
  251. hypotheses: {},
  252. keywords: {},
  253. stage: 'batch-real',
  254. sourceTier: 'real-collected',
  255. },
  256. items: [],
  257. };
  258. const flat = [];
  259. if (fs.existsSync(XHS_DIR)) {
  260. for (const f of fs.readdirSync(XHS_DIR)) {
  261. if (!f.endsWith('.json')) continue;
  262. const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, f), 'utf8'));
  263. if (raw._error || !raw.comments) continue;
  264. const kw = raw.keyword;
  265. const kwHypos = raw.hypotheses || [];
  266. for (const [noteId, cmts] of Object.entries(raw.comments)) {
  267. for (const c of cmts) {
  268. if (!c.content) continue;
  269. const item = buildItem({
  270. id: `xhs_${noteId}_${c.id}`,
  271. platform: 'xhs',
  272. product: kw,
  273. keyword: kw,
  274. type: 'comment',
  275. nickname: c.user?.nickname || '匿名',
  276. ip: c.ip_location || '',
  277. content: c.content.slice(0, 500),
  278. likes: c.like_count || 0,
  279. rating: null,
  280. kwHypos,
  281. });
  282. if (item) { out.items.push(item); flat.push(item); }
  283. }
  284. }
  285. out.meta.keywords[kw] = (out.meta.keywords[kw] || 0) + (raw.total_notes_found || 0);
  286. }
  287. }
  288. out.meta.platforms['xhs'] = out.items.length;
  289. out.meta.productsCount = Object.keys(out.meta.keywords).length;
  290. out.meta.keywordsCount = Object.keys(out.meta.keywords).length;
  291. out.meta.comments = out.items.length;
  292. for (const it of out.items) {
  293. for (const h of (it.hypothesis || [])) {
  294. out.meta.hypotheses[h] = (out.meta.hypotheses[h] || 0) + 1;
  295. }
  296. }
  297. fs.writeFileSync(path.join(RAW_DIR, '_merged.json'), JSON.stringify(out, null, 2), 'utf8');
  298. fs.writeFileSync(path.join(RAW_DIR, 'comments-flat.jsonl'), flat.map((it) => JSON.stringify(it)).join('\n'), 'utf8');
  299. console.log(` ✅ merged: ${out.items.length} items | ${out.meta.productsCount} keywords`);
  300. return out;
  301. }
  302. async function runBatch(batchNum, opts) {
  303. const batch = BATCHES[batchNum];
  304. if (!batch) throw new Error(`unknown batch: ${batchNum}`);
  305. console.log(`\n▶ Batch ${batchNum}: ${batch.name}`);
  306. let totalNotes = 0;
  307. let totalComments = 0;
  308. for (const task of batch.xhs || []) {
  309. try {
  310. const r = await collectXhs(task, opts);
  311. totalNotes += r.notes || 0;
  312. totalComments += r.comments || 0;
  313. } catch (e) {
  314. console.log(` ✗ xhs:${task.kw}: ${e.message}`);
  315. auditLog(`xhs EXC ${task.kw}: ${e.message}`);
  316. }
  317. await sleep(500);
  318. }
  319. console.log(` 📊 Batch ${batchNum} 合计: ${totalNotes} 笔记 / ${totalComments} 评论`);
  320. }
  321. async function main() {
  322. const argv = process.argv.slice(2);
  323. const opts = { force: argv.includes('--force') };
  324. const batchArg = argv.find((a) => a.startsWith('--batch='));
  325. const isMerge = argv.includes('--merge');
  326. console.log('\n╔═══════════════════════════════════════════════════════════╗');
  327. console.log('║ 洪城到家 · 小红书(XHS)数据采集 ║');
  328. console.log('╚═══════════════════════════════════════════════════════════╝');
  329. console.log(` TikHub Token: ${TIKHUB_TOKEN ? '✓' : '✗'}`);
  330. if (batchArg) {
  331. const bn = batchArg.split('=')[1];
  332. if (bn === 'all') {
  333. for (const k of Object.keys(BATCHES)) await runBatch(k, opts);
  334. } else {
  335. await runBatch(bn, opts);
  336. }
  337. }
  338. if (isMerge || batchArg) {
  339. console.log('\n▶ Merging...');
  340. mergeAll();
  341. }
  342. if (!batchArg && !isMerge) {
  343. console.log('\nUsage:');
  344. console.log(' --batch=1 执行小红书采集');
  345. console.log(' --merge 合并数据');
  346. console.log(' --force 强制重抓');
  347. console.log('\n关键词: 洪城到家、月嫂、多喜娃、天鹅到家、好孕妈妈');
  348. }
  349. }
  350. if (require.main === module) {
  351. main().catch((e) => { console.error('fatal:', e); process.exit(1); });
  352. }
  353. module.exports = { BATCHES, mergeAll, collectXhs };