collect-majiyong.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. #!/usr/bin/env node
  2. /**
  3. * 马记永 · 四月小红书 KOL/KOC 宣发 VOC 数据采集
  4. *
  5. * 输入: ../【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx
  6. * Sheets: KOL合作表(13) + KOC合作表(282)
  7. * 流程:
  8. * 1. 解析两张 sheet 合并成统一行
  9. * 2. 直连 URL → 直接提取 noteId / 短链 → 跳转解析
  10. * 3. TikHub /thapi/v1/xiaohongshu/app/get_note_comments 拉评论 (3 页/笔记)
  11. * 4. 落盘 data/raw-merged.json
  12. */
  13. const fs = require('fs');
  14. const path = require('path');
  15. const https = require('https');
  16. const http = require('http');
  17. const xlsx = require('xlsx');
  18. const ROOT = path.resolve(__dirname, '..');
  19. const XLSX_PATH = path.resolve(ROOT, '..', '【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx');
  20. const DATA_DIR = path.join(ROOT, 'data');
  21. const NOTES_DIR = path.join(DATA_DIR, 'notes');
  22. const COMMENTS_DIR = path.join(DATA_DIR, 'comments');
  23. const AUDIT_LOG = path.join(DATA_DIR, 'audit.log');
  24. [DATA_DIR, NOTES_DIR, COMMENTS_DIR].forEach((d) => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); });
  25. const TIKHUB_TOKEN = process.env.TIKHUB_TOKEN
  26. || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
  27. const API_HOST = 'server.fmode.cn';
  28. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  29. function log(msg) {
  30. const line = `[${new Date().toISOString()}] ${msg}`;
  31. console.log(line);
  32. try { fs.appendFileSync(AUDIT_LOG, line + '\n'); } catch {}
  33. }
  34. function excelDateToISO(serial) {
  35. if (typeof serial !== 'number') return String(serial || '');
  36. return new Date(Math.round((serial - 25569) * 86400 * 1000)).toISOString().slice(0, 10);
  37. }
  38. function tikhubGet(apiPath, params = {}) {
  39. const qs = Object.entries(params).filter(([, v]) => v !== '' && v !== undefined && v !== null)
  40. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
  41. const url = qs ? `${apiPath}?${qs}` : apiPath;
  42. return new Promise((resolve) => {
  43. const req = https.request({
  44. hostname: API_HOST, path: url, method: 'GET',
  45. headers: { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json' },
  46. }, (res) => {
  47. const chunks = [];
  48. res.on('data', (c) => chunks.push(c));
  49. res.on('end', () => {
  50. const body = Buffer.concat(chunks).toString('utf-8');
  51. try { resolve({ data: JSON.parse(body), status: res.statusCode }); }
  52. catch (e) { resolve({ data: { _parse_error: e.message, _raw: body.slice(0, 200) }, status: res.statusCode }); }
  53. });
  54. });
  55. req.on('error', (e) => resolve({ data: { _error: e.message }, status: 0 }));
  56. req.setTimeout(45000, () => { req.destroy(); resolve({ data: { _error: 'timeout' }, status: 0 }); });
  57. req.end();
  58. });
  59. }
  60. function extractNoteIdFromUrl(u) {
  61. if (!u) return null;
  62. const m = u.match(/(?:discovery\/item|explore|item)\/([0-9a-f]{16,32})/);
  63. return m ? m[1] : null;
  64. }
  65. function resolveShortLink(shortUrl, depth = 0) {
  66. // 直连优先,免抓取
  67. const direct = extractNoteIdFromUrl(shortUrl);
  68. if (direct) return Promise.resolve({ finalUrl: shortUrl, noteId: direct, status: 0 });
  69. if (depth > 6) return Promise.resolve({ finalUrl: shortUrl, noteId: null });
  70. return new Promise((resolve) => {
  71. let u; try { u = new URL(shortUrl); } catch { return resolve({ finalUrl: shortUrl, noteId: null, _error: 'bad-url' }); }
  72. const mod = u.protocol === 'https:' ? https : http;
  73. const req = mod.request({
  74. method: 'GET', hostname: u.hostname, path: u.pathname + (u.search || ''),
  75. headers: {
  76. 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
  77. Accept: 'text/html,*/*',
  78. },
  79. }, (res) => {
  80. const loc = res.headers.location;
  81. if ([301, 302, 303, 307, 308].includes(res.statusCode) && loc) {
  82. res.resume();
  83. const next = loc.startsWith('http') ? loc : new URL(loc, shortUrl).toString();
  84. return resolve(resolveShortLink(next, depth + 1));
  85. }
  86. const chunks = [];
  87. res.on('data', (c) => chunks.push(c));
  88. res.on('end', () => {
  89. const html = Buffer.concat(chunks).toString('utf-8');
  90. let noteId = extractNoteIdFromUrl(shortUrl);
  91. if (!noteId) {
  92. const m = html.match(/"note_?id"\s*:\s*"([0-9a-f]{16,32})"/i) || html.match(/\/(?:discovery\/item|explore)\/([0-9a-f]{16,32})/);
  93. if (m) noteId = m[1];
  94. }
  95. resolve({ finalUrl: shortUrl, noteId, status: res.statusCode });
  96. });
  97. });
  98. req.on('error', (e) => resolve({ finalUrl: shortUrl, noteId: null, _error: e.message }));
  99. req.setTimeout(15000, () => { req.destroy(); resolve({ finalUrl: shortUrl, noteId: null, _error: 'timeout' }); });
  100. req.end();
  101. });
  102. }
  103. function readRows() {
  104. const wb = xlsx.readFile(XLSX_PATH);
  105. const all = [];
  106. for (const sheet of ['KOL合作表', 'KOC合作表']) {
  107. const rows = xlsx.utils.sheet_to_json(wb.Sheets[sheet], { defval: '' });
  108. for (const r of rows) {
  109. if (!r['发布链接']) continue;
  110. all.push({
  111. sheet,
  112. seq: r['序号'],
  113. tier: r['量级'] || '-',
  114. contentType: r['类型'] || '-',
  115. name: r['小红书昵称'],
  116. profileUrl: r['主页链接'],
  117. xhsId: r['小红书id'],
  118. followersWan: r['粉丝数/w'],
  119. region: r['地区'] || '-',
  120. cooperation: r['合作形式'] || '-',
  121. priceOrder: r['下单价格'] || '',
  122. authorized: r['是否授权'] || '',
  123. publishDate: typeof r['发布时间'] === 'number' ? excelDateToISO(r['发布时间']) : (r['发布时间'] || ''),
  124. postUrl: r['发布链接'],
  125. likes: r['点赞'],
  126. collects: r['收藏'],
  127. comments: r['评论'],
  128. engagement: r['总互动'],
  129. impressions: r['曝光量'],
  130. syncEngagement: r['同步互动量'] || r['同步互动量总计'] || 0,
  131. syncImpressions: r['同步曝光量'] || r['同步曝光量总计'] || 0,
  132. engagementSubtotal: r['互动量小计'],
  133. impressionsSubtotal: r['曝光量小计'],
  134. });
  135. }
  136. }
  137. return all;
  138. }
  139. async function main() {
  140. const argv = process.argv.slice(2);
  141. const force = argv.includes('--force');
  142. const limit = (() => { const a = argv.find((x) => x.startsWith('--limit=')); return a ? parseInt(a.split('=')[1], 10) : 0; })();
  143. const skipResolve = argv.includes('--skip-resolve');
  144. const onlyResolve = argv.includes('--only-resolve');
  145. log(`▶ 启动马记永采集 · TIKHUB_TOKEN=${TIKHUB_TOKEN ? '✓' : '✗'} · force=${force} · limit=${limit || 'all'}`);
  146. const rows = readRows();
  147. log(` · 解析 xlsx(2 sheets) 合并 ${rows.length} 条 KOL/KOC 记录`);
  148. // 1. resolve
  149. const resolved = [];
  150. let directHit = 0, redirectCount = 0, failCount = 0;
  151. for (let i = 0; i < rows.length; i++) {
  152. if (limit && i >= limit) break;
  153. const row = rows[i];
  154. const cacheKey = `${row.sheet === 'KOL合作表' ? 'L' : 'C'}-${row.seq}`;
  155. const cachePath = path.join(NOTES_DIR, `${cacheKey}-resolve.json`);
  156. let r;
  157. if (!force && fs.existsSync(cachePath)) {
  158. r = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
  159. } else {
  160. r = await resolveShortLink(row.postUrl);
  161. fs.writeFileSync(cachePath, JSON.stringify(r, null, 2));
  162. if (!extractNoteIdFromUrl(row.postUrl)) await sleep(250);
  163. }
  164. if (r.noteId) {
  165. if (extractNoteIdFromUrl(row.postUrl)) directHit++; else redirectCount++;
  166. } else { failCount++; }
  167. if ((i + 1) % 25 === 0 || i === 0) log(` [${cacheKey}] ${row.name?.slice(0, 12)} → ${r.noteId ? r.noteId : 'NULL'} · 进度 ${i + 1}/${rows.length}`);
  168. resolved.push({ ...row, noteId: r.noteId, finalUrl: r.finalUrl, resolveError: r._error || null });
  169. }
  170. log(`▶ 解析完成: 直连 ${directHit} · 跳转 ${redirectCount} · 失败 ${failCount}`);
  171. if (onlyResolve) {
  172. fs.writeFileSync(path.join(DATA_DIR, 'resolved.json'), JSON.stringify(resolved, null, 2));
  173. log(' · only-resolve mode 退出');
  174. return;
  175. }
  176. // 2. fetch comments
  177. let totalCmt = 0, withCmt = 0;
  178. for (let i = 0; i < resolved.length; i++) {
  179. const row = resolved[i];
  180. if (!row.noteId) { row._comments = []; continue; }
  181. if (skipResolve && Number(row.comments || 0) === 0) { row._comments = []; continue; }
  182. const cacheKey = `${row.sheet === 'KOL合作表' ? 'L' : 'C'}-${row.seq}`;
  183. const cachePath = path.join(COMMENTS_DIR, `${cacheKey}-${row.noteId}.json`);
  184. if (!force && fs.existsSync(cachePath)) {
  185. row._comments = JSON.parse(fs.readFileSync(cachePath, 'utf8')).comments || [];
  186. if (row._comments.length) { totalCmt += row._comments.length; withCmt++; }
  187. continue;
  188. }
  189. const all = [];
  190. let cursor = '';
  191. const maxPages = Number(row.comments || 0) > 30 ? 5 : 3;
  192. for (let p = 0; p < maxPages; p++) {
  193. await sleep(700);
  194. const res = await tikhubGet('/thapi/v1/xiaohongshu/app/get_note_comments', { note_id: row.noteId, cursor });
  195. const inner = res?.data?.data?.data || res?.data?.data || {};
  196. const cmts = inner?.comments || [];
  197. for (const c of cmts) {
  198. all.push({
  199. id: c.id, content: c.content, time: c.time || c.create_time,
  200. like_count: c.like_count, sub_comment_count: c.sub_comment_count,
  201. ip_location: c.ip_location,
  202. nickname: c.user?.nickname || c.user_info?.nickname || '匿名',
  203. user_id: c.user?.userid || c.user_info?.user_id,
  204. sub_comments: (c.sub_comments || []).slice(0, 5).map((s) => ({
  205. content: s.content, like_count: s.like_count,
  206. nickname: s.user?.nickname || s.user_info?.nickname,
  207. })),
  208. });
  209. }
  210. const hasMore = inner?.has_more;
  211. cursor = inner?.cursor || '';
  212. if (!hasMore || !cursor) break;
  213. }
  214. row._comments = all;
  215. fs.writeFileSync(cachePath, JSON.stringify({ noteId: row.noteId, fetched_at: new Date().toISOString(), comments: all }, null, 2));
  216. if (all.length) { totalCmt += all.length; withCmt++; }
  217. if ((i + 1) % 20 === 0 || (all.length > 0)) {
  218. log(` [${cacheKey}] 💬 ${row.name?.slice(0, 14)} → ${all.length} 条 (xlsx ${row.comments}) · ${i + 1}/${resolved.length}`);
  219. }
  220. }
  221. const out = {
  222. meta: {
  223. source: '【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx',
  224. generated_at: new Date().toISOString(),
  225. total_posts: resolved.length,
  226. resolved_count: resolved.filter((x) => x.noteId).length,
  227. with_api_comments: withCmt,
  228. total_api_comments: totalCmt,
  229. total_xlsx_comments: resolved.reduce((a, b) => a + Number(b.comments || 0), 0),
  230. },
  231. items: resolved,
  232. };
  233. const outPath = path.join(DATA_DIR, 'raw-merged.json');
  234. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
  235. log(`\n✓ 完成: ${out.meta.resolved_count}/${out.meta.total_posts} 解析 · ${withCmt} 笔记有评论 · 抓取 ${totalCmt} 条 (xlsx 报备 ${out.meta.total_xlsx_comments}) → ${outPath}`);
  236. }
  237. if (require.main === module) {
  238. main().catch((e) => { console.error('fatal:', e); process.exit(1); });
  239. }