#!/usr/bin/env node /** * KS-Chanel Analysis · 快手(Kuaishou)数据采集脚本 * * 使用 TikHub API (via api.tikhub.io / api.tikhub.dev) * 快手关键词搜索视频 -> 获取评论 -> 合并成 VOC 分析数据 */ const fs = require('fs'); const path = require('path'); const os = require('os'); const https = require('https'); const ROOT = path.resolve(__dirname, '..', '..'); const RAW_DIR = path.join(ROOT, 'raw'); const KS_DIR = path.join(RAW_DIR, 'kuaishou'); const AUDIT_LOG = path.join(RAW_DIR, 'audit.log'); const API_CONFIG = (() => { const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json'); if (!fs.existsSync(p)) return null; try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } })(); const TIKHUB_TOKEN = API_CONFIG?.currentToken || API_CONFIG?.endpoint?.headers?.Authorization?.replace(/^Bearer\s+/, '') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ=='; const API_HOST = process.env.TIKHUB_HOST || API_CONFIG?.endpoint?.host || 'api.tikhub.io'; [RAW_DIR, KS_DIR].forEach((d) => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }); const HOTLIST_CATEGORIES = [ { id: 'hotShare', name: '热门', hypotheses: ['H1', 'H5', 'H8'] }, { id: 'job', name: '职场', hypotheses: ['H1', 'H2', 'H4', 'H6'] }, { id: 'recruit', name: '招聘', hypotheses: ['H1', 'H3', 'H4'] }, ]; const BATCHES = { 1: { name: 'P0 · 蓝领求职核心关键词', kuaishou: [ { kw: '快聘', videos: 20, commentPages: 2, hypotheses: ['H1'] }, { kw: '直播带岗', videos: 20, commentPages: 2, hypotheses: ['H1', 'H3'] }, { kw: '蓝领找工作', videos: 20, commentPages: 2, hypotheses: ['H1', 'H5'] }, { kw: '工资靠谱', videos: 20, commentPages: 2, hypotheses: ['H2', 'H4'] }, { kw: '包吃包住', videos: 20, commentPages: 2, hypotheses: ['H3', 'H7'] }, ], }, 2: { name: 'P1 · 求职担忧与风险', kuaishou: [ { kw: '求职被骗', videos: 20, commentPages: 2, hypotheses: ['H6', 'H8'] }, { kw: '黑中介', videos: 20, commentPages: 2, hypotheses: ['H6', 'H4'] }, { kw: '入职被坑', videos: 20, commentPages: 2, hypotheses: ['H6', 'H8'] }, { kw: '押金不退', videos: 20, commentPages: 2, hypotheses: ['H6', 'H8'] }, { kw: '工资日结', videos: 20, commentPages: 2, hypotheses: ['H2', 'H3'] }, ], }, 3: { name: 'P2 · 口碑与平台对比', kuaishou: [ { kw: '当天入职', videos: 20, commentPages: 2, hypotheses: ['H3', 'H4'] }, { kw: '真实薪资', videos: 20, commentPages: 2, hypotheses: ['H2', 'H5'] }, { kw: '工作环境真实', videos: 20, commentPages: 2, hypotheses: ['H7', 'H5'] }, { kw: '快手求职', videos: 20, commentPages: 2, hypotheses: ['H1', 'H8'] }, { kw: '工厂打工', videos: 20, commentPages: 2, hypotheses: ['H1', 'H3', 'H7'] }, ], }, hotlist: { name: '热榜数据', hotlist: HOTLIST_CATEGORIES, }, }; const HYPOTHESIS_KEYWORDS = { H1: ['快聘', '直播带岗', '蓝领', '求职', '找工作', '快手求职', '工厂打工'], H2: ['工资', '日结', '月薪', '薪资', '真实薪资', '多少钱', '性价比', '高工资'], H3: ['当天入职', '包吃包住', '入职快', '随时入职', '手续简单', '拎包入住'], H4: ['正规', '靠谱', '放心', '有保障', '安全', '不收押金', '不扣证件'], H5: ['朋友推荐', '口碑', '真实评价', '过来人', '亲身经历', '推荐'], H6: ['被骗', '黑中介', '被坑', '押金不退', '虚假招聘', '套路', '陷阱', '投诉', '维权'], H7: ['工作环境', '宿舍', '包住', '车间', '加班', '真实工作', '环境好不好'], H8: ['抖音', '美团', '对比', '哪个好', '选择', '快手', '平台', '排名'], }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function sanitizeFilename(kw) { return kw.replace(/[\/\\:*?"<>|\s]+/g, '-'); } function auditLog(line) { const ts = new Date().toISOString(); try { fs.appendFileSync(AUDIT_LOG, `[${ts}] ${line}\n`); } catch {} } function fileExistsNonEmpty(p) { if (!fs.existsSync(p)) return false; return fs.statSync(p).size > 100; } function tikhubGet(apiPath, params = {}) { const qs = Object.entries(params) .filter(([, v]) => v !== undefined && v !== null && v !== '') .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join('&'); const url = qs ? `${apiPath}?${qs}` : apiPath; return new Promise((resolve) => { const opts = { hostname: API_HOST, path: url, method: 'GET', headers: { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json', }, }; const req = https.request(opts, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const body = Buffer.concat(chunks).toString('utf-8'); try { const j = JSON.parse(preserveLargeIntegerFields(body)); resolve({ data: j, status: res.statusCode }); } catch (e) { resolve({ data: { _parse_error: e.message, _raw: body.slice(0, 500) }, 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 preserveLargeIntegerFields(text) { return text.replace( /("(?:[A-Za-z_]*id|[A-Za-z_]*Id|photo_id|photoId|user_id|userId|comment_id|commentId|videoId)"\s*:\s*)(\d{16,})/g, '$1"$2"' ); } function firstArray(...values) { for (const value of values) { if (Array.isArray(value)) return value; } return []; } function findFirstArray(value, keys = []) { if (!value || typeof value !== 'object') return []; for (const key of keys) { const arr = key.split('.').reduce((cur, part) => cur?.[part], value); if (Array.isArray(arr)) return arr; } const queue = [value]; const seen = new Set(); while (queue.length) { const cur = queue.shift(); if (!cur || typeof cur !== 'object' || seen.has(cur)) continue; seen.add(cur); if (Array.isArray(cur)) { if (cur.length === 0) continue; if (cur.some((it) => it && typeof it === 'object')) return cur; continue; } for (const next of Object.values(cur)) queue.push(next); } return []; } function asCount(value) { if (typeof value === 'number') return value; if (typeof value === 'string') { const text = value.trim().toLowerCase(); const num = Number(text.replace(/[,+]/g, '').replace(/w|万/g, '')); if (!Number.isFinite(num)) return 0; return /w|万/.test(text) ? Math.round(num * 10000) : num; } return 0; } function pickVideoId(v) { return String( v?.photo_id || v?.photoId || v?.id || v?.photo?.id || v?.photo?.photo_id || v?.photo?.photoId || v?.work?.id || '' ); } function pickVideoText(v) { return v?.caption || v?.title || v?.desc || v?.description || v?.photo?.caption || v?.photo?.captionText || v?.photo?.title || v?.work?.caption || ''; } function normalizeVideo(v, rank) { const photo = v?.photo || v?.work || v; const author = v?.author || v?.user || v?.userInfo || photo?.author || photo?.user || {}; return { photo_id: pickVideoId(v), caption: pickVideoText(v), like_count: asCount(v?.like_count ?? v?.likeCount ?? v?.liked_count ?? v?.likedCount ?? photo?.like_count ?? photo?.likeCount), comment_count: asCount(v?.comment_count ?? v?.commentCount ?? v?.comments_count ?? v?.commentsCount ?? photo?.comment_count ?? photo?.commentCount), view_count: asCount(v?.view_count ?? v?.viewCount ?? v?.play_count ?? v?.playCount ?? photo?.view_count ?? photo?.viewCount), cover: v?.cover || v?.thumbnail || v?.coverUrl || photo?.cover || photo?.thumbnail || '', create_time: v?.create_time || v?.createTime || v?.timestamp || photo?.create_time || photo?.createTime || '', rank: v?.rank || v?.index || rank, hot_value: asCount(v?.hot_value ?? v?.hotValue ?? v?.score ?? v?.heat ?? v?.hot), user: author, }; } function normalizeComment(c) { const user = c?.user_info || c?.userInfo || c?.author || c?.user || {}; return { id: c?.id || c?.comment_id || c?.commentId || c?.cid || '', content: c?.content || c?.text || c?.comment || c?.body || '', create_time: c?.create_time || c?.createTime || c?.timestamp || '', like_count: asCount(c?.like_count ?? c?.liked_count ?? c?.likeCount ?? c?.likedCount), ip_location: c?.ip_location || c?.ipLocation || c?.ip || '', user: user ? { user_id: user.user_id || user.userId || user.id || '', nickname: user.nickname || user.name || user.user_name || user.userName || '', } : null, sub_comments: firstArray(c?.sub_comments, c?.subComments, c?.replies).slice(0, 3).map((s) => ({ content: s?.content || s?.text || '', like_count: asCount(s?.like_count ?? s?.liked_count ?? s?.likeCount ?? s?.likedCount), nickname: s?.user_info?.nickname || s?.userInfo?.nickname || s?.user?.nickname || '', })), }; } function getCursor(data) { return data?.data?.pcursor || data?.data?.cursor || data?.data?.nextCursor || data?.data?.next_cursor || data?.pcursor || data?.cursor || ''; } function hasMoreComments(data, comments, cursor) { const raw = data?.data || data || {}; if (typeof raw.has_more === 'boolean') return raw.has_more; if (typeof raw.hasMore === 'boolean') return raw.hasMore; return comments.length > 0 && Boolean(cursor); } async function collectKs(spec, { force = false } = {}) { const outPath = path.join(KS_DIR, `${sanitizeFilename(spec.kw)}.json`); if (!force && fileExistsNonEmpty(outPath)) { console.log(` [ks] ⏭️ ${spec.kw} (已存在, skip)`); auditLog(`ks SKIP ${spec.kw} (exists)`); return { skipped: true, path: outPath }; } console.log(` [ks] 🔍 搜索视频 "${spec.kw}" · page=1 × videos=${spec.videos}`); const startTs = Date.now(); const allVideos = []; const seenIds = new Set(); for (let page = 1; page <= 3; page++) { await sleep(800); const searchRes = await tikhubGet('/api/v1/kuaishou/app/search_video_v2', { keyword: spec.kw, page }); const rawVideos = findFirstArray(searchRes?.data, [ 'data.visionSearchPhoto', 'data.videos', 'data.items', 'data.feeds', 'data.list', 'data.result', 'data.data', 'visionSearchPhoto', ]); if (rawVideos.length === 0) { console.log(` [ks] ⚠️ 第${page}页 API 响应:`, JSON.stringify(searchRes?.data).slice(0, 300)); break; } for (const [idx, v] of rawVideos.entries()) { const nv = normalizeVideo(v, allVideos.length + idx + 1); const photoId = nv.photo_id; if (photoId && photoId !== '0' && !seenIds.has(photoId)) { seenIds.add(photoId); allVideos.push(nv); } } console.log(` [ks] 第${page}页: +${rawVideos.length} 视频,累计 ${allVideos.length}`); if (allVideos.length >= spec.videos) break; } const topVideos = allVideos.slice(0, spec.videos); console.log(` [ks] 去重后 ${topVideos.length} 个视频,开始抓评论...`); const commentsByVideoId = {}; let totalComments = 0; for (const video of topVideos) { if (!video.photo_id) continue; commentsByVideoId[video.photo_id] = []; let pcursor = ''; for (let p = 0; p < (spec.commentPages || 2); p++) { await sleep(700); const commentAttempts = [ { path: '/api/v1/kuaishou/app/fetch_one_video_comment', params: { photo_id: video.photo_id, pcursor }, }, { path: '/api/v1/kuaishou/web/fetch_video_comments', params: { photo_id: video.photo_id, pcursor }, }, { path: '/api/v1/kuaishou/web/fetch_video_comments', params: { photo_id: video.photo_id, cursor: pcursor }, }, ]; let cRes = null; let cmts = []; for (const attempt of commentAttempts) { cRes = await tikhubGet(attempt.path, attempt.params); cmts = findFirstArray(cRes?.data, [ 'data.comments', 'data.rootComments', 'data.commentList', 'data.list', 'data.items', 'data.data', 'comments', 'rootComments', ]); if (cmts.length > 0 || cRes.status === 200) break; } if (cmts.length === 0 && p === 0) { console.log(` [ks] ⚠️ 评论响应[photo_id=${video.photo_id}]:`, JSON.stringify(cRes?.data).slice(0, 300)); } for (const c of cmts) { const comment = normalizeComment(c); if (comment.content) commentsByVideoId[video.photo_id].push(comment); } totalComments += cmts.length; pcursor = getCursor(cRes?.data); if (!hasMoreComments(cRes?.data, cmts, pcursor)) break; } } const elapsed = ((Date.now() - startTs) / 1000).toFixed(1); console.log(` [ks] ✓ ${spec.kw}: ${topVideos.length} 视频 / ${totalComments} 评论 (${elapsed}s)`); auditLog(`ks OK ${spec.kw} videos=${topVideos.length} comments=${totalComments} (${elapsed}s)`); const out = { platform: 'kuaishou', keyword: spec.kw, hypotheses: spec.hypotheses, collected_at: new Date().toISOString(), elapsed_seconds: Number(elapsed), total_videos_found: allVideos.length, top_videos: topVideos.map((v) => ({ id: v.photo_id, caption: v.caption, like_count: v.like_count, comment_count: v.comment_count, view_count: v.view_count, cover: v.cover, create_time: v.create_time, user: v.user ? { user_id: v.user.user_id || v.user.id || '', nickname: v.user.nickname || v.user.name || v.user.user_name || '', } : null, })), comments: commentsByVideoId, }; fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8'); return { skipped: false, notes: topVideos.length, comments: totalComments, path: outPath }; } async function collectHotList(spec, { force = false } = {}) { const outPath = path.join(KS_DIR, `hotlist_${sanitizeFilename(spec.name)}.json`); if (!force && fileExistsNonEmpty(outPath)) { console.log(` [hl] ⏭️ 热榜「${spec.name}」(已存在, skip)`); auditLog(`hl SKIP ${spec.name} (exists)`); return { skipped: true, path: outPath }; } console.log(` [hl] 🔥 获取热榜「${spec.name}」`); const startTs = Date.now(); const attempts = [ { path: '/api/v1/kuaishou/app/get_hot_list', params: { category_id: spec.id } }, { path: '/api/v1/kuaishou/web/fetch_kuaishou_hot_list_v2', params: { category_id: spec.id } }, { path: '/api/v1/kuaishou/web/fetch_kuaishou_hot_list_v1', params: { category_id: spec.id } }, { path: '/api/v1/kuaishou/web/get_trending_v2', params: { category_id: spec.id } }, { path: '/api/v1/kuaishou/web/get_trending', params: { category_id: spec.id } }, ]; let listRes = null; let videos = []; for (const attempt of attempts) { listRes = await tikhubGet(attempt.path, attempt.params); videos = findFirstArray(listRes?.data, [ 'data.visionSearchPhoto', 'data.hots', 'data.trending', 'data.list', 'data.items', 'data.data', 'hots', 'trending', ]); if (videos.length > 0 || listRes?.status === 200) break; } if (videos.length === 0) { console.log(` [hl] ⚠️ 热榜响应:`, JSON.stringify(listRes?.data).slice(0, 300)); const errOut = { platform: 'kuaishou', type: 'hotlist', category: spec.id, name: spec.name, _error: 'empty hotlist', collected_at: new Date().toISOString(), }; fs.writeFileSync(outPath, JSON.stringify(errOut, null, 2), 'utf-8'); auditLog(`hl FAIL ${spec.name}: empty`); return { skipped: false, notes: 0, comments: 0, path: outPath }; } const seenIds = new Set(); const uniqueVideos = []; for (const [idx, v] of videos.entries()) { const normalized = normalizeVideo(v, idx + 1); const photoId = normalized.photo_id; if (photoId && photoId !== '0' && !seenIds.has(photoId)) { seenIds.add(photoId); uniqueVideos.push(normalized); } } const elapsed = ((Date.now() - startTs) / 1000).toFixed(1); console.log(` [hl] ✓ 热榜「${spec.name}」: ${uniqueVideos.length} 条 (${elapsed}s)`); auditLog(`hl OK ${spec.name} videos=${uniqueVideos.length} (${elapsed}s)`); const out = { platform: 'kuaishou', type: 'hotlist', category: spec.id, name: spec.name, hypotheses: spec.hypotheses, collected_at: new Date().toISOString(), elapsed_seconds: Number(elapsed), total_videos_found: uniqueVideos.length, videos: uniqueVideos, }; fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8'); return { skipped: false, notes: uniqueVideos.length, comments: 0, path: outPath }; } function inferHypotheses(text, keywordHypotheses) { const results = new Set(keywordHypotheses || []); const t = String(text || '').toLowerCase(); for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) { for (const kw of kws) { if (t.includes(kw.toLowerCase())) { results.add(h); break; } } } return Array.from(results); } const TAG_RULES = [ { tag: '薪资关注', re: /工资|日结|月薪|薪资|钱|待遇|发工资|扣钱/ }, { tag: '入职速度', re: /当天入职|入职快|随时入职|明天|今天|马上/ }, { tag: '包吃包住', re: /包吃包住|包住|宿舍|食堂|吃饭|住宿/ }, { tag: '防骗意识', re: /被骗|黑中介|押金|套路|陷阱|被坑|虚假/ }, { tag: '平台信任', re: /快聘|官方|平台|快手|正规|靠谱/ }, { tag: '口碑参考', re: /朋友推荐|过来人|亲身|真实评价|口碑|推荐/ }, { tag: '工作环境', re: /车间|工厂|加班|环境|宿舍|工作条件/ }, { tag: '合同保障', re: /合同|签合同|保障|合法|劳动法|权益/ }, { tag: '中介排斥', re: /黑中介|中介|第三方|劳务/ }, { tag: '求职焦虑', re: /担心|害怕|不放心|纠结|怕|焦虑|犹豫/ }, ]; function inferTags(text) { const tags = []; for (const r of TAG_RULES) { if (r.re.test(text || '')) tags.push(r.tag); } return tags; } const SENTIMENT_POS = /好|推荐|靠谱|放心|真实|满意|不错|工资高|环境好|入职快|顺利|真实评价|良心/; const SENTIMENT_NEG = /骗|坑|黑中介|押金不退|虚假|套路|被坑|垃圾|失望|后悔|坑人|恶劣|克扣|拖延|威胁/; const SENTIMENT_CONFLICT = /但是|可是|纠结|担心|想又怕|犹豫|说实话又|虽然|不过|可惜/; function inferSentiment(text) { const t = String(text || ''); const pos = SENTIMENT_POS.test(t); const neg = SENTIMENT_NEG.test(t); const conf = SENTIMENT_CONFLICT.test(t); if (conf && (pos || neg)) return 'conflicted'; if (pos && !neg) return 'positive'; if (neg && !pos) return 'negative'; return 'neutral'; } function buildItem({ id, platform, product, keyword, type, nickname, ip, content, likes, rating, kwHypos }) { if (!content || content.length < 3) return null; const hypotheses = inferHypotheses(content, kwHypos); const tags = inferTags(content); const sentiment = inferSentiment(content); return { id, platform, product, keyword, type, nickname, ip, content, likes, rating, hypothesis: hypotheses, tags, sentiment, source: 'real-collected' }; } function mergeAll() { const out = { meta: { collectedAt: new Date().toISOString(), platforms: {}, products: {}, hypotheses: {}, keywords: {}, stage: 'batch-real', sourceTier: 'real-collected', }, items: [], hotlistItems: [], }; const flat = []; if (fs.existsSync(KS_DIR)) { for (const f of fs.readdirSync(KS_DIR)) { if (!f.endsWith('.json')) continue; const raw = JSON.parse(fs.readFileSync(path.join(KS_DIR, f), 'utf8')); if (raw._error) continue; if (raw.type === 'hotlist' && raw.videos) { const catName = raw.name || raw.category; const hypos = raw.hypotheses || []; for (const v of raw.videos) { const caption = v.caption || ''; if (caption.length < 3) continue; const item = buildItem({ id: `hl_${v.photo_id}`, platform: 'kuaishou', product: catName, keyword: catName, type: 'hotlist_video', nickname: v.user?.nickname || v.user?.name || '匿名', ip: '', content: caption.slice(0, 500), likes: v.like_count || 0, rating: null, kwHypos: hypos, }); if (item) { out.items.push(item); out.hotlistItems.push({ ...item, rank: v.rank, hot_value: v.hot_value }); flat.push(item); } } out.meta.keywords[catName] = (out.meta.keywords[catName] || 0) + raw.videos.length; continue; } if (!raw.comments) continue; const kw = raw.keyword; const kwHypos = raw.hypotheses || []; for (const v of raw.top_videos || []) { const caption = v.caption || ''; if (!caption || caption.length < 3) continue; const item = buildItem({ id: `ks_video_${v.id}`, platform: 'kuaishou', product: kw, keyword: kw, type: 'video_caption', nickname: v.user?.nickname || '匿名', ip: '', content: caption.slice(0, 500), likes: v.like_count || 0, rating: null, kwHypos, }); if (item) { out.items.push(item); flat.push(item); } } for (const [videoId, cmts] of Object.entries(raw.comments)) { for (const c of cmts) { if (!c.content) continue; const item = buildItem({ id: `ks_${videoId}_${c.id}`, platform: 'kuaishou', product: kw, keyword: kw, type: 'comment', nickname: c.user?.nickname || '匿名', ip: c.ip_location || '', content: c.content.slice(0, 500), likes: c.like_count || 0, rating: null, kwHypos, }); if (item) { out.items.push(item); flat.push(item); } } } out.meta.keywords[kw] = (out.meta.keywords[kw] || 0) + (raw.total_videos_found || 0); } } out.meta.platforms['kuaishou'] = out.items.length; out.meta.productsCount = Object.keys(out.meta.keywords).length; out.meta.keywordsCount = Object.keys(out.meta.keywords).length; out.meta.comments = out.items.length; for (const it of out.items) { for (const h of (it.hypothesis || [])) { out.meta.hypotheses[h] = (out.meta.hypotheses[h] || 0) + 1; } } fs.writeFileSync(path.join(RAW_DIR, '_merged.json'), JSON.stringify(out, null, 2), 'utf8'); fs.writeFileSync(path.join(RAW_DIR, 'comments-flat.jsonl'), flat.map((it) => JSON.stringify(it)).join('\n'), 'utf8'); console.log(` ✅ merged: ${out.items.length} items | ${out.meta.productsCount} keywords`); return out; } async function runBatch(batchNum, opts) { const batch = BATCHES[batchNum]; if (!batch) throw new Error(`unknown batch: ${batchNum}`); console.log(`\n▶ Batch ${batchNum}: ${batch.name}`); let totalNotes = 0; let totalComments = 0; if (batch.kuaishou) { for (const task of batch.kuaishou) { try { const r = await collectKs(task, opts); totalNotes += r.notes || 0; totalComments += r.comments || 0; } catch (e) { console.log(` ✗ ks:${task.kw}: ${e.message}`); auditLog(`ks EXC ${task.kw}: ${e.message}`); } await sleep(500); } } if (batch.hotlist) { for (const task of batch.hotlist) { try { const r = await collectHotList(task, opts); totalNotes += r.notes || 0; } catch (e) { console.log(` ✗ hl:${task.name}: ${e.message}`); auditLog(`hl EXC ${task.name}: ${e.message}`); } await sleep(500); } } console.log(` 📊 Batch ${batchNum} 合计: ${totalNotes} 视频 / ${totalComments} 评论`); } async function main() { const argv = process.argv.slice(2); const opts = { force: argv.includes('--force') }; const batchArg = argv.find((a) => a.startsWith('--batch=')); const isMerge = argv.includes('--merge'); const isHotlist = argv.includes('--hotlist'); console.log('\n╔═══════════════════════════════════════════════════════════╗'); console.log('║ KS-Chanel Analysis · 快手(Kuaishou)数据采集 ║'); console.log('╚═══════════════════════════════════════════════════════════╝'); console.log(` TikHub Token: ${TIKHUB_TOKEN ? '✓' : '✗'}`); console.log(` API Host: ${API_HOST}`); if (isHotlist) { console.log('\n▶ 热榜数据采集中...'); let total = 0; for (const cat of HOTLIST_CATEGORIES) { try { const r = await collectHotList(cat, opts); total += r.notes || 0; } catch (e) { console.log(` ✗ hl:${cat.name}: ${e.message}`); } await sleep(500); } console.log(` 📊 热榜合计: ${total} 条`); } else if (batchArg) { const bn = batchArg.split('=')[1]; if (bn === 'all') { for (const k of Object.keys(BATCHES)) await runBatch(k, opts); } else { await runBatch(bn, opts); } } if (isMerge || batchArg || isHotlist) { console.log('\n▶ Merging...'); mergeAll(); } if (!batchArg && !isMerge && !isHotlist) { console.log('\nUsage:'); console.log(' --batch=1 P0 · 蓝领求职核心关键词'); console.log(' --batch=2 P1 · 求职担忧与风险'); console.log(' --batch=3 P2 · 口碑与平台对比'); console.log(' --batch=hotlist 热榜数据(热门/职场/招聘)'); console.log(' --batch=all 执行所有批次(含热榜)'); console.log(' --merge 合并数据'); console.log(' --force 强制重抓'); console.log('\n关键词: 快聘、直播带岗、蓝领找工作、工资靠谱、包吃包住、求职被骗等'); } } if (require.main === module) { main().catch((e) => { console.error('fatal:', e); process.exit(1); }); } module.exports = { BATCHES, HOTLIST_CATEGORIES, mergeAll, collectKs, collectHotList };