#!/usr/bin/env node /** * KS-Chanel Analysis · 快手(Kuaishou)数据采集脚本 V2 * * 针对报告目标优化(L1-6): * L1: 蓝领劳工最担心/最看重什么 * L2: 快手(10个蓝领,九个都用快手) * L3: 情绪价值强的关键词 * L4: 关注的关键字 * L5: 快聘战略地位(日活几十万简历) * L6: 核心痛点深挖 * * 使用 TikHub API (via api.tikhub.io / api.tikhub.dev) */ 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 }); }); /** * V2 假设体系(H1-H12) * H1: 快聘是蓝领求职主阵地 * H2: 薪资透明度是核心关切(日结/月薪/五险一金) * H3: 入职便捷性(当天入职/手续简单) * H4: 食宿条件影响决策(包吃包住/宿舍) * H5: 平台信任与口碑(真实评价/朋友推荐) * H6: 被骗焦虑是普遍痛点 ← 重点 * H7: 黑中介/克扣是核心风险 ← 重点 * H8: 工资结算方式差异(日结/月结) * H9: 职业尊严与社会认同 ← 情绪价值 * H10: 同事关系与工作氛围 ← 情绪价值 * H11: 直播带岗降低信息不对称 * H12: 平台战略地位验证(快聘) */ /** * V2 关键词批次 —— 针对L1-6报告目标优化 */ const BATCHES = { 1: { name: 'P1 · 核心担忧:被骗与押金(高情绪价值)', hypotheses: ['H6', 'H7'], kuaishou: [ { kw: '招聘骗局', videos: 20, commentPages: 3, hypotheses: ['H6'] }, { kw: '打工陷阱', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, { kw: '黑厂', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, { kw: '克扣工资', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7', 'H8'] }, { kw: '不退押金', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, { kw: '被坑', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, ], }, 2: { name: 'P2 · 最看重:薪资与权益保障', hypotheses: ['H2', 'H3', 'H8'], kuaishou: [ { kw: '五险一金', videos: 20, commentPages: 3, hypotheses: ['H2'] }, { kw: '加班费', videos: 20, commentPages: 3, hypotheses: ['H2'] }, { kw: '发工资', videos: 20, commentPages: 3, hypotheses: ['H2', 'H8'] }, { kw: '不拖欠工资', videos: 20, commentPages: 3, hypotheses: ['H2', 'H8'] }, { kw: '工资日结', videos: 20, commentPages: 3, hypotheses: ['H2', 'H8'] }, { kw: '签合同', videos: 20, commentPages: 3, hypotheses: ['H2', 'H6'] }, ], }, 3: { name: 'P3 · 情绪价值:尊严与归属感', hypotheses: ['H9', 'H10'], kuaishou: [ { kw: '被骂', videos: 20, commentPages: 3, hypotheses: ['H9'] }, { kw: '受委屈', videos: 20, commentPages: 3, hypotheses: ['H9', 'H10'] }, { kw: '同事好', videos: 20, commentPages: 3, hypotheses: ['H10'] }, { kw: '领导好', videos: 20, commentPages: 3, hypotheses: ['H10'] }, { kw: '氛围好', videos: 20, commentPages: 3, hypotheses: ['H10'] }, { kw: '被尊重', videos: 20, commentPages: 3, hypotheses: ['H9'] }, ], }, 4: { name: 'P4 · 平台信任与口碑验证', hypotheses: ['H5', 'H11', 'H12'], kuaishou: [ { kw: '靠谱工作', videos: 20, commentPages: 3, hypotheses: ['H5', 'H12'] }, { kw: '好厂推荐', videos: 20, commentPages: 3, hypotheses: ['H5'] }, { kw: '真实评价', videos: 20, commentPages: 3, hypotheses: ['H5', 'H11'] }, { kw: '入职顺利', videos: 20, commentPages: 3, hypotheses: ['H3', 'H5'] }, { kw: '没被骗', videos: 20, commentPages: 3, hypotheses: ['H5', 'H6'] }, { kw: '朋友推荐', videos: 20, commentPages: 3, hypotheses: ['H5'] }, ], }, 5: { name: 'P5 · 快聘生态与直播求职', hypotheses: ['H1', 'H11', 'H12'], kuaishou: [ { kw: '快聘', videos: 20, commentPages: 3, hypotheses: ['H1', 'H12'] }, { kw: '直播带岗', videos: 20, commentPages: 3, hypotheses: ['H1', 'H11'] }, { kw: '直播间找工作', videos: 20, commentPages: 3, hypotheses: ['H1', 'H11'] }, { kw: '主播靠谱', videos: 20, commentPages: 3, hypotheses: ['H1', 'H5'] }, { kw: '快手求职', videos: 20, commentPages: 3, hypotheses: ['H1', 'H2'] }, { kw: '快手工作', videos: 20, commentPages: 3, hypotheses: ['H1'] }, ], }, 6: { name: 'P6 · 风险防范与自我保护', hypotheses: ['H6', 'H7'], kuaishou: [ { kw: '防骗指南', videos: 20, commentPages: 3, hypotheses: ['H6'] }, { kw: '维权', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, { kw: '劳动仲裁', videos: 20, commentPages: 3, hypotheses: ['H6'] }, { kw: '黑中介', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, { kw: '身份证扣押', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, { kw: '不收押金', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] }, ], }, }; const HYPOTHESIS_KEYWORDS = { H1: ['快聘', '直播带岗', '蓝领', '求职', '找工作', '快手求职', '直播间找工作', '快手工作'], H2: ['工资', '日结', '月薪', '薪资', '真实薪资', '多少钱', '性价比', '高工资', '五险一金', '加班费', '发工资'], H3: ['当天入职', '包吃包住', '入职快', '随时入职', '手续简单', '拎包入住', '入职顺利'], H4: ['正规', '靠谱', '放心', '有保障', '安全', '不收押金', '不扣证件'], H5: ['朋友推荐', '口碑', '真实评价', '过来人', '亲身经历', '推荐', '好厂推荐', '靠谱工作'], H6: ['被骗', '黑中介', '被坑', '押金不退', '虚假招聘', '套路', '陷阱', '投诉', '维权', '克扣工资', '招聘骗局', '打工陷阱', '黑厂', '被骂', '受委屈'], H7: ['黑中介', '押金不退', '不退押金', '克扣', '身份证扣押', '中介费', '不退押金'], H8: ['工资日结', '日结', '小时工', '月结', '工资结算', '不拖欠工资'], H9: ['被骂', '受委屈', '尊严', '不尊重', '骂人', '受气', '被尊重', '尊重'], H10: ['同事好', '领导好', '氛围好', '相处好', '工友', '关系好'], H11: ['直播间找工作', '直播带岗', '主播靠谱', '真实评价'], H12: ['快聘', '快手求职', '靠谱工作'], }; const HYPOTHESIS_COLORS = { H1: '#F5A623', H2: '#3B82F6', H3: '#00DC82', H4: '#8B5CF6', H5: '#F97316', H6: '#FF4D8D', H7: '#EF4444', H8: '#6B7280', H9: '#EC4899', H10: '#14B8A6', H11: '#22C55E', H12: '#06B6D4', }; 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}] [ks-v2] ${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 }; } 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: /担心|害怕|不放心|纠结|怕|焦虑|犹豫/ }, { 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: 'v2-collected' }; } function mergeAll() { const out = { meta: { collectedAt: new Date().toISOString(), version: '2.0', platforms: {}, products: {}, hypotheses: {}, keywords: {}, stage: 'batch-v2', sourceTier: 'v2-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-v2.json'), JSON.stringify(out, null, 2), 'utf8'); fs.writeFileSync(path.join(RAW_DIR, 'comments-flat-v2.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); } } 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'); console.log('\n╔═══════════════════════════════════════════════════════════╗'); console.log('║ KS-Chanel Analysis V2 · 快手(Kuaishou)数据采集 ║'); console.log('║ 目标: L1-6 最担心/最看重 + 情绪价值关键词 ║'); console.log('╚═══════════════════════════════════════════════════════════╝'); console.log(` TikHub Token: ${TIKHUB_TOKEN ? '✓' : '✗'}`); console.log(` API Host: ${API_HOST}`); 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) { console.log('\n▶ Merging V2 data...'); mergeAll(); } if (!batchArg && !isMerge) { console.log('\nUsage:'); console.log(' --batch=1 P1 · 核心担忧:被骗与押金'); console.log(' --batch=2 P2 · 最看重:薪资与权益保障'); console.log(' --batch=3 P3 · 情绪价值:尊严与归属感'); console.log(' --batch=4 P4 · 平台信任与口碑验证'); console.log(' --batch=5 P5 · 快聘生态与直播求职'); console.log(' --batch=6 P6 · 风险防范与自我保护'); console.log(' --batch=all 执行所有批次'); console.log(' --merge 合并数据'); console.log(' --force 强制重抓'); console.log('\n假设体系: H1-H12'); console.log(' H1: 快聘是蓝领求职主阵地'); console.log(' H2: 薪资透明度是核心关切'); console.log(' H3: 入职便捷性'); console.log(' H4: 食宿条件影响决策'); console.log(' H5: 平台信任与口碑'); console.log(' H6: 被骗焦虑是普遍痛点 ← 重点'); console.log(' H7: 黑中介/克扣是核心风险 ← 重点'); console.log(' H8: 工资结算方式差异'); console.log(' H9: 职业尊严与社会认同 ← 情绪价值'); console.log(' H10: 同事关系与工作氛围 ← 情绪价值'); console.log(' H11: 直播带岗降低信息不对称'); console.log(' H12: 快聘战略地位验证'); } } if (require.main === module) { main().catch((e) => { console.error('fatal:', e); process.exit(1); }); } module.exports = { BATCHES, HYPOTHESIS_KEYWORDS, HYPOTHESIS_COLORS, mergeAll, collectKs };