#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const BASE_DIR = path.resolve(__dirname, '..'); const RAW_DIR = path.join(BASE_DIR, 'raw', 'KS'); const OUTPUT_PATH = path.join(BASE_DIR, 'raw', '解析后的数据.md'); const REPORT_GOALS = [ '蓝领劳工在求职过程中,最担心的、最看重的是什么', '快手渠道对蓝领求职的触达与使用场景', '情绪价值强的表达与诉求', '关注的关键字', '快聘的岗位供给与战略位势信号', ]; const TOPIC_CONFIG = { concern: { title: '最担心的问题', keywords: ['招聘骗局', '求职被骗', '打工陷阱', '黑中介', '黑厂', '被坑', '入职被坑', '押金不退', '克扣工资'], matcher: ['骗', '坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '虚假', '拖欠', '维权', '身份证', '中介费'], }, priority: { title: '最看重的因素', keywords: ['工资靠谱', '真实薪资', '工资日结', '发工资', '加班费', '包吃包住', '当天入职'], matcher: ['工资', '薪资', '日结', '发工资', '包吃包住', '住宿', '吃住', '当天入职', '入职快', '加班费', '靠谱', '五险', '结算'], }, emotion: { title: '情绪价值强的内容', keywords: ['直播带岗', '蓝领找工作', '快聘', '工资靠谱', '包吃包住', '被坑', '求职被骗', '黑中介'], matcher: ['放心', '靠谱', '真实', '尊重', '委屈', '崩溃', '气死', '恶心', '压抑', '难受', '终于', '踏实', '被骗', '被坑'], }, strategic: { title: '快手与快聘战略信号', keywords: ['快聘', '直播带岗', '蓝领找工作', '当天入职', '包吃包住', '工资靠谱'], matcher: ['快聘', '直播带岗', '找工作', '招工', '入职', '蓝领', '工厂', '岗位', '简历', '报名', '安排住宿'], }, }; const ATTENTION_TERMS = [ '工资', '薪资', '日结', '发工资', '加班费', '包吃包住', '住宿', '食堂', '当天入职', '靠谱', '真实', '找工作', '快聘', '直播带岗', '蓝领', '工厂', '岗位', '被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣工资', '套路', '陷阱', '维权' ]; const NEGATIVE_WORDS = ['被骗', '被坑', '坑', '黑中介', '黑厂', '套路', '陷阱', '押金', '不退', '克扣', '拖欠', '维权', '身份证']; const POSITIVE_WORDS = ['靠谱', '真实', '放心', '包吃包住', '当天入职', '日结', '发工资', '安排住宿', '工资高', '好找工作']; const EMOTION_WORDS = ['委屈', '难受', '压抑', '崩溃', '恶心', '气死', '后悔', '放心', '踏实', '尊重', '值得', '终于', '被骗', '被坑']; function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function listJsonFiles(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => path.join(dir, name)); } function normalizeText(value) { return String(value || '') .replace(/\r/g, '') .replace(/\n+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function clip(text, max = 110) { const clean = normalizeText(text); return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; } function mdSafe(text) { return normalizeText(text).replace(/\|/g, '\\|'); } function uniqueBy(items, keyFn) { const seen = new Set(); const out = []; for (const item of items) { const key = keyFn(item); if (seen.has(key)) continue; seen.add(key); out.push(item); } return out; } function includesAny(text, terms) { return terms.some((term) => text.includes(term)); } function collectRecords(raw) { const keyword = raw.keyword || ''; const records = []; for (const video of raw.top_videos || []) { const text = normalizeText(video.caption); if (!text) continue; records.push({ type: 'video', keyword, text, likes: Number(video.like_count || 0), comments: Number(video.comment_count || 0), views: Number(video.view_count || 0), score: Number(video.like_count || 0) + Number(video.comment_count || 0) * 3 + Math.floor(Number(video.view_count || 0) / 1000), sourceId: String(video.id || ''), }); } for (const [videoId, comments] of Object.entries(raw.comments || {})) { for (const comment of comments || []) { const text = normalizeText(comment.content); if (!text) continue; records.push({ type: 'comment', keyword, text, likes: Number(comment.like_count || 0), comments: 0, views: 0, score: Number(comment.like_count || 0) * 2 + (text.length >= 20 ? 3 : 0), sourceId: `${videoId}:${comment.id || ''}`, }); } } return records; } function analyzeFiles() { const files = listJsonFiles(RAW_DIR); const perKeyword = []; const allRecords = []; for (const filePath of files) { const raw = readJson(filePath); const records = collectRecords(raw); const topVideos = raw.top_videos || []; const commentCount = Object.values(raw.comments || {}).reduce((sum, list) => sum + (Array.isArray(list) ? list.length : 0), 0); const likeSum = topVideos.reduce((sum, item) => sum + Number(item.like_count || 0), 0); const viewSum = topVideos.reduce((sum, item) => sum + Number(item.view_count || 0), 0); const summary = { keyword: raw.keyword || path.basename(filePath, '.json'), filePath, videoCount: topVideos.length, commentCount, likeSum, viewSum, records, }; perKeyword.push(summary); allRecords.push(...records); } return { files: perKeyword, allRecords }; } function buildThemeStats(dataset, config) { const keywordSet = new Set(config.keywords); const matchedFiles = dataset.files.filter((file) => keywordSet.has(file.keyword)); const matchedRecords = uniqueBy( matchedFiles.flatMap((file) => file.records).filter((record) => includesAny(record.text, config.matcher) || keywordSet.has(record.keyword)), (record) => `${record.type}:${record.keyword}:${record.sourceId}:${record.text}` ); const signalMap = new Map(); for (const record of matchedRecords) { for (const term of config.matcher) { if (!record.text.includes(term)) continue; const prev = signalMap.get(term) || { term, count: 0, score: 0 }; prev.count += 1; prev.score += record.score; signalMap.set(term, prev); } } const samples = matchedRecords .sort((a, b) => b.score - a.score) .slice(0, 18); return { matchedFiles, matchedRecords, signals: Array.from(signalMap.values()).sort((a, b) => b.score - a.score || b.count - a.count).slice(0, 12), samples, }; } function buildAttentionTerms(dataset) { const stats = ATTENTION_TERMS.map((term) => { let count = 0; let score = 0; for (const record of dataset.allRecords) { if (!record.text.includes(term)) continue; count += 1; score += record.score; } return { term, count, score }; }).filter((item) => item.count > 0); return stats.sort((a, b) => b.score - a.score || b.count - a.count).slice(0, 20); } function buildEmotionSamples(dataset) { const items = dataset.allRecords .filter((record) => includesAny(record.text, EMOTION_WORDS)) .map((record) => { const positiveHits = POSITIVE_WORDS.filter((term) => record.text.includes(term)).length; const negativeHits = NEGATIVE_WORDS.filter((term) => record.text.includes(term)).length; const emotionHits = EMOTION_WORDS.filter((term) => record.text.includes(term)).length; let polarity = '中性'; if (negativeHits > positiveHits) polarity = '负向'; if (positiveHits > negativeHits) polarity = '正向'; if (positiveHits > 0 && negativeHits > 0) polarity = '冲突'; return { ...record, polarity, emotionStrength: emotionHits * 10 + record.score, }; }); return uniqueBy( items.sort((a, b) => b.emotionStrength - a.emotionStrength), (item) => item.text ).slice(0, 20); } function buildStrategicObservations(dataset) { const strategicKeywords = new Set(TOPIC_CONFIG.strategic.keywords); const selected = dataset.files.filter((file) => strategicKeywords.has(file.keyword)); const observations = []; for (const file of selected) { observations.push({ keyword: file.keyword, videos: file.videoCount, comments: file.commentCount, likes: file.likeSum, views: file.viewSum, }); } return observations.sort((a, b) => b.views - a.views || b.likes - a.likes); } function renderTable(rows, headers) { const head = `| ${headers.join(' | ')} |`; const divider = `| ${headers.map(() => '---').join(' | ')} |`; const body = rows.map((row) => `| ${row.join(' | ')} |`); return [head, divider, ...body].join('\n'); } function buildMarkdown(dataset) { const concern = buildThemeStats(dataset, TOPIC_CONFIG.concern); const priority = buildThemeStats(dataset, TOPIC_CONFIG.priority); const emotion = buildThemeStats(dataset, TOPIC_CONFIG.emotion); const attention = buildAttentionTerms(dataset); const emotionSamples = buildEmotionSamples(dataset); const strategic = buildStrategicObservations(dataset); const totalKeywords = dataset.files.length; const totalVideos = dataset.files.reduce((sum, file) => sum + file.videoCount, 0); const totalComments = dataset.files.reduce((sum, file) => sum + file.commentCount, 0); const totalViews = dataset.files.reduce((sum, file) => sum + file.viewSum, 0); const lines = []; lines.push('# 快手渠道报告定向解析数据'); lines.push(''); lines.push(`生成时间:${new Date().toLocaleString('zh-CN', { hour12: false })}`); lines.push(''); lines.push('## 解析目标'); lines.push(...REPORT_GOALS.map((item) => `- ${item}`)); lines.push(''); lines.push('## 数据范围'); lines.push(`- 数据目录:\`${RAW_DIR}\``); lines.push(`- 关键词文件数:${totalKeywords}`); lines.push(`- 抓取视频数:${totalVideos}`); lines.push(`- 抓取评论数:${totalComments}`); lines.push(`- 视频总播放量(基于 top_videos 汇总):${totalViews.toLocaleString('zh-CN')}`); lines.push(''); lines.push('## 一、蓝领求职时最担心什么'); lines.push('从“被骗/被坑/黑中介/押金不退/克扣工资/打工陷阱”相关文件和文本信号看,风险焦虑是最稳定、最强烈的底层情绪。'); lines.push(''); lines.push(renderTable( concern.signals.map((item) => [mdSafe(item.term), String(item.count), String(item.score)]), ['风险信号', '命中条数', '综合热度'] )); lines.push(''); lines.push('高代表性样本:'); lines.push(...concern.samples.slice(0, 8).map((item, index) => `${index + 1}. [${item.keyword}][${item.type}] 热度=${item.score}:${clip(item.text)}`)); lines.push(''); lines.push('## 二、蓝领求职时最看重什么'); lines.push('高频关注集中在“工资能否按时发、薪资是否真实、是否包吃包住、能否快速入职”这些能直接降低求职成本和试错成本的因素。'); lines.push(''); lines.push(renderTable( priority.signals.map((item) => [mdSafe(item.term), String(item.count), String(item.score)]), ['看重因素', '命中条数', '综合热度'] )); lines.push(''); lines.push('高代表性样本:'); lines.push(...priority.samples.slice(0, 8).map((item, index) => `${index + 1}. [${item.keyword}][${item.type}] 热度=${item.score}:${clip(item.text)}`)); lines.push(''); lines.push('## 三、情绪价值强的表达'); lines.push('情绪价值并不只来自“高薪”,更来自“靠谱、真实、被尊重、少踩坑、能快速安顿下来”。负向情绪主要由被骗、被坑、黑中介等风险触发;正向情绪则常与靠谱、包吃包住、当天入职等确定性表达绑定。'); lines.push(''); lines.push(renderTable( emotionSamples.slice(0, 12).map((item) => [ mdSafe(item.keyword), item.polarity, String(item.likes), mdSafe(clip(item.text, 70)) ]), ['关键词', '情绪方向', '点赞', '代表表达'] )); lines.push(''); lines.push('## 四、用户关注的关键字'); lines.push('以下词项是跨视频与评论反复出现、且与报告主题直接相关的关注焦点,可作为后续洞察和报告章节标题的基础词库。'); lines.push(''); lines.push(renderTable( attention.map((item) => [mdSafe(item.term), String(item.count), String(item.score)]), ['关键词', '命中条数', '综合热度'] )); lines.push(''); lines.push('## 五、快手渠道与“快聘”战略位势信号'); lines.push('从“快聘 / 直播带岗 / 蓝领找工作 / 当天入职 / 包吃包住 / 工资靠谱”等主题文件看,快手在蓝领求职场景里同时承担了流量入口、岗位展示、信任建立和快速转化四种角色。'); lines.push(''); lines.push(renderTable( strategic.map((item) => [ mdSafe(item.keyword), String(item.videos), String(item.comments), item.likes.toLocaleString('zh-CN'), item.views.toLocaleString('zh-CN') ]), ['主题词', '视频数', '评论数', '点赞汇总', '播放量汇总'] )); lines.push(''); lines.push('可直接用于报告的结论整理:'); lines.push('- 快手不是单纯的内容平台,在蓝领求职链路里已经具备“种草 + 筛选 + 询单 + 转化”的复合职能。'); lines.push('- “直播带岗”“当天入职”“包吃包住”说明用户并不只想看岗位信息,而是希望快速判断机会真假、成本高低和落地效率。'); lines.push('- “快聘”相关内容能承接这种高频、强时效的求职需求,因此更容易被用户当成高效率的找工作入口。'); lines.push('- 如果内部已知“快聘找工作日活几十万简历,战略位势仅次于电商”,那么这批快手端 VOC 数据能提供用户需求面的支撑:需求高频、风险敏感、决策链短、转化诉求强。'); lines.push(''); lines.push('## 六、可直接引用的洞察'); lines.push('- 最强焦虑不是“工资低”,而是“信息不真实、被骗、被坑、押金不退、工资被克扣”。'); lines.push('- 最强购买点不是抽象品牌心智,而是“工资真实、发薪稳定、包吃包住、当天入职、流程简单”。'); lines.push('- 情绪价值的核心不是娱乐,而是“确定性”:靠谱、真实、放心、少踩坑、能尽快安顿。'); lines.push('- 快手在蓝领求职场景中的优势,来自内容触达、直播解释、即时互动和高频决策场景的天然匹配。'); lines.push(''); lines.push('## 七、附录:样本关键词覆盖'); lines.push(...dataset.files .sort((a, b) => b.commentCount - a.commentCount || b.viewSum - a.viewSum) .map((file) => `- ${file.keyword}:视频 ${file.videoCount} 条,评论 ${file.commentCount} 条,播放量 ${file.viewSum.toLocaleString('zh-CN')}`)); lines.push(''); return `${lines.join('\n')}\n`; } function main() { if (!fs.existsSync(RAW_DIR)) { throw new Error(`raw directory not found: ${RAW_DIR}`); } const dataset = analyzeFiles(); const markdown = buildMarkdown(dataset); fs.writeFileSync(OUTPUT_PATH, markdown, 'utf8'); console.log(`Generated: ${OUTPUT_PATH}`); } if (require.main === module) { main(); }