| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- #!/usr/bin/env node
- /**
- * 马记永 · 四月小红书 KOL/KOC 宣发 VOC 数据采集
- *
- * 输入: ../【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx
- * Sheets: KOL合作表(13) + KOC合作表(282)
- * 流程:
- * 1. 解析两张 sheet 合并成统一行
- * 2. 直连 URL → 直接提取 noteId / 短链 → 跳转解析
- * 3. TikHub /thapi/v1/xiaohongshu/app/get_note_comments 拉评论 (3 页/笔记)
- * 4. 落盘 data/raw-merged.json
- */
- const fs = require('fs');
- const path = require('path');
- const https = require('https');
- const http = require('http');
- const xlsx = require('xlsx');
- const ROOT = path.resolve(__dirname, '..');
- const XLSX_PATH = path.resolve(ROOT, '..', '【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx');
- const DATA_DIR = path.join(ROOT, 'data');
- const NOTES_DIR = path.join(DATA_DIR, 'notes');
- const COMMENTS_DIR = path.join(DATA_DIR, 'comments');
- const AUDIT_LOG = path.join(DATA_DIR, 'audit.log');
- [DATA_DIR, NOTES_DIR, COMMENTS_DIR].forEach((d) => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); });
- const TIKHUB_TOKEN = process.env.TIKHUB_TOKEN
- || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
- const API_HOST = 'server.fmode.cn';
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
- function log(msg) {
- const line = `[${new Date().toISOString()}] ${msg}`;
- console.log(line);
- try { fs.appendFileSync(AUDIT_LOG, line + '\n'); } catch {}
- }
- function excelDateToISO(serial) {
- if (typeof serial !== 'number') return String(serial || '');
- return new Date(Math.round((serial - 25569) * 86400 * 1000)).toISOString().slice(0, 10);
- }
- function tikhubGet(apiPath, params = {}) {
- const qs = Object.entries(params).filter(([, v]) => v !== '' && v !== undefined && v !== null)
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
- const url = qs ? `${apiPath}?${qs}` : apiPath;
- return new Promise((resolve) => {
- const req = https.request({
- hostname: API_HOST, path: url, method: 'GET',
- headers: { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json' },
- }, (res) => {
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- const body = Buffer.concat(chunks).toString('utf-8');
- try { resolve({ data: JSON.parse(body), status: res.statusCode }); }
- catch (e) { resolve({ data: { _parse_error: e.message, _raw: body.slice(0, 200) }, status: res.statusCode }); }
- });
- });
- req.on('error', (e) => resolve({ data: { _error: e.message }, status: 0 }));
- req.setTimeout(45000, () => { req.destroy(); resolve({ data: { _error: 'timeout' }, status: 0 }); });
- req.end();
- });
- }
- function extractNoteIdFromUrl(u) {
- if (!u) return null;
- const m = u.match(/(?:discovery\/item|explore|item)\/([0-9a-f]{16,32})/);
- return m ? m[1] : null;
- }
- function resolveShortLink(shortUrl, depth = 0) {
- // 直连优先,免抓取
- const direct = extractNoteIdFromUrl(shortUrl);
- if (direct) return Promise.resolve({ finalUrl: shortUrl, noteId: direct, status: 0 });
- if (depth > 6) return Promise.resolve({ finalUrl: shortUrl, noteId: null });
- return new Promise((resolve) => {
- let u; try { u = new URL(shortUrl); } catch { return resolve({ finalUrl: shortUrl, noteId: null, _error: 'bad-url' }); }
- const mod = u.protocol === 'https:' ? https : http;
- const req = mod.request({
- method: 'GET', hostname: u.hostname, path: u.pathname + (u.search || ''),
- headers: {
- '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',
- Accept: 'text/html,*/*',
- },
- }, (res) => {
- const loc = res.headers.location;
- if ([301, 302, 303, 307, 308].includes(res.statusCode) && loc) {
- res.resume();
- const next = loc.startsWith('http') ? loc : new URL(loc, shortUrl).toString();
- return resolve(resolveShortLink(next, depth + 1));
- }
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- const html = Buffer.concat(chunks).toString('utf-8');
- let noteId = extractNoteIdFromUrl(shortUrl);
- if (!noteId) {
- const m = html.match(/"note_?id"\s*:\s*"([0-9a-f]{16,32})"/i) || html.match(/\/(?:discovery\/item|explore)\/([0-9a-f]{16,32})/);
- if (m) noteId = m[1];
- }
- resolve({ finalUrl: shortUrl, noteId, status: res.statusCode });
- });
- });
- req.on('error', (e) => resolve({ finalUrl: shortUrl, noteId: null, _error: e.message }));
- req.setTimeout(15000, () => { req.destroy(); resolve({ finalUrl: shortUrl, noteId: null, _error: 'timeout' }); });
- req.end();
- });
- }
- function readRows() {
- const wb = xlsx.readFile(XLSX_PATH);
- const all = [];
- for (const sheet of ['KOL合作表', 'KOC合作表']) {
- const rows = xlsx.utils.sheet_to_json(wb.Sheets[sheet], { defval: '' });
- for (const r of rows) {
- if (!r['发布链接']) continue;
- all.push({
- sheet,
- seq: r['序号'],
- tier: r['量级'] || '-',
- contentType: r['类型'] || '-',
- name: r['小红书昵称'],
- profileUrl: r['主页链接'],
- xhsId: r['小红书id'],
- followersWan: r['粉丝数/w'],
- region: r['地区'] || '-',
- cooperation: r['合作形式'] || '-',
- priceOrder: r['下单价格'] || '',
- authorized: r['是否授权'] || '',
- publishDate: typeof r['发布时间'] === 'number' ? excelDateToISO(r['发布时间']) : (r['发布时间'] || ''),
- postUrl: r['发布链接'],
- likes: r['点赞'],
- collects: r['收藏'],
- comments: r['评论'],
- engagement: r['总互动'],
- impressions: r['曝光量'],
- syncEngagement: r['同步互动量'] || r['同步互动量总计'] || 0,
- syncImpressions: r['同步曝光量'] || r['同步曝光量总计'] || 0,
- engagementSubtotal: r['互动量小计'],
- impressionsSubtotal: r['曝光量小计'],
- });
- }
- }
- return all;
- }
- async function main() {
- const argv = process.argv.slice(2);
- const force = argv.includes('--force');
- const limit = (() => { const a = argv.find((x) => x.startsWith('--limit=')); return a ? parseInt(a.split('=')[1], 10) : 0; })();
- const skipResolve = argv.includes('--skip-resolve');
- const onlyResolve = argv.includes('--only-resolve');
- log(`▶ 启动马记永采集 · TIKHUB_TOKEN=${TIKHUB_TOKEN ? '✓' : '✗'} · force=${force} · limit=${limit || 'all'}`);
- const rows = readRows();
- log(` · 解析 xlsx(2 sheets) 合并 ${rows.length} 条 KOL/KOC 记录`);
- // 1. resolve
- const resolved = [];
- let directHit = 0, redirectCount = 0, failCount = 0;
- for (let i = 0; i < rows.length; i++) {
- if (limit && i >= limit) break;
- const row = rows[i];
- const cacheKey = `${row.sheet === 'KOL合作表' ? 'L' : 'C'}-${row.seq}`;
- const cachePath = path.join(NOTES_DIR, `${cacheKey}-resolve.json`);
- let r;
- if (!force && fs.existsSync(cachePath)) {
- r = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
- } else {
- r = await resolveShortLink(row.postUrl);
- fs.writeFileSync(cachePath, JSON.stringify(r, null, 2));
- if (!extractNoteIdFromUrl(row.postUrl)) await sleep(250);
- }
- if (r.noteId) {
- if (extractNoteIdFromUrl(row.postUrl)) directHit++; else redirectCount++;
- } else { failCount++; }
- if ((i + 1) % 25 === 0 || i === 0) log(` [${cacheKey}] ${row.name?.slice(0, 12)} → ${r.noteId ? r.noteId : 'NULL'} · 进度 ${i + 1}/${rows.length}`);
- resolved.push({ ...row, noteId: r.noteId, finalUrl: r.finalUrl, resolveError: r._error || null });
- }
- log(`▶ 解析完成: 直连 ${directHit} · 跳转 ${redirectCount} · 失败 ${failCount}`);
- if (onlyResolve) {
- fs.writeFileSync(path.join(DATA_DIR, 'resolved.json'), JSON.stringify(resolved, null, 2));
- log(' · only-resolve mode 退出');
- return;
- }
- // 2. fetch comments
- let totalCmt = 0, withCmt = 0;
- for (let i = 0; i < resolved.length; i++) {
- const row = resolved[i];
- if (!row.noteId) { row._comments = []; continue; }
- if (skipResolve && Number(row.comments || 0) === 0) { row._comments = []; continue; }
- const cacheKey = `${row.sheet === 'KOL合作表' ? 'L' : 'C'}-${row.seq}`;
- const cachePath = path.join(COMMENTS_DIR, `${cacheKey}-${row.noteId}.json`);
- if (!force && fs.existsSync(cachePath)) {
- row._comments = JSON.parse(fs.readFileSync(cachePath, 'utf8')).comments || [];
- if (row._comments.length) { totalCmt += row._comments.length; withCmt++; }
- continue;
- }
- const all = [];
- let cursor = '';
- const maxPages = Number(row.comments || 0) > 30 ? 5 : 3;
- for (let p = 0; p < maxPages; p++) {
- await sleep(700);
- const res = await tikhubGet('/thapi/v1/xiaohongshu/app/get_note_comments', { note_id: row.noteId, cursor });
- const inner = res?.data?.data?.data || res?.data?.data || {};
- const cmts = inner?.comments || [];
- for (const c of cmts) {
- all.push({
- id: c.id, content: c.content, time: c.time || c.create_time,
- like_count: c.like_count, sub_comment_count: c.sub_comment_count,
- ip_location: c.ip_location,
- nickname: c.user?.nickname || c.user_info?.nickname || '匿名',
- user_id: c.user?.userid || c.user_info?.user_id,
- sub_comments: (c.sub_comments || []).slice(0, 5).map((s) => ({
- content: s.content, like_count: s.like_count,
- nickname: s.user?.nickname || s.user_info?.nickname,
- })),
- });
- }
- const hasMore = inner?.has_more;
- cursor = inner?.cursor || '';
- if (!hasMore || !cursor) break;
- }
- row._comments = all;
- fs.writeFileSync(cachePath, JSON.stringify({ noteId: row.noteId, fetched_at: new Date().toISOString(), comments: all }, null, 2));
- if (all.length) { totalCmt += all.length; withCmt++; }
- if ((i + 1) % 20 === 0 || (all.length > 0)) {
- log(` [${cacheKey}] 💬 ${row.name?.slice(0, 14)} → ${all.length} 条 (xlsx ${row.comments}) · ${i + 1}/${resolved.length}`);
- }
- }
- const out = {
- meta: {
- source: '【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx',
- generated_at: new Date().toISOString(),
- total_posts: resolved.length,
- resolved_count: resolved.filter((x) => x.noteId).length,
- with_api_comments: withCmt,
- total_api_comments: totalCmt,
- total_xlsx_comments: resolved.reduce((a, b) => a + Number(b.comments || 0), 0),
- },
- items: resolved,
- };
- const outPath = path.join(DATA_DIR, 'raw-merged.json');
- fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
- log(`\n✓ 完成: ${out.meta.resolved_count}/${out.meta.total_posts} 解析 · ${withCmt} 笔记有评论 · 抓取 ${totalCmt} 条 (xlsx 报备 ${out.meta.total_xlsx_comments}) → ${outPath}`);
- }
- if (require.main === module) {
- main().catch((e) => { console.error('fatal:', e); process.exit(1); });
- }
|