#!/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 OUT_DIR = path.join(BASE_DIR, 'reports'); const OUT_FILE = path.join(OUT_DIR, 'ks-voc-insight-report-v5.html'); const GOALS = [ '蓝领劳工在求职过程中,最担心的、最看重的是什么', '快手为什么能成为蓝领求职的重要渠道', '用户真正需要的情绪价值是什么', '最值得关注的关键词有哪些', '快聘为什么有接近招聘核心转化层的战略位势', ]; const CLUSTERS = { concern: ['招聘骗局', '求职被骗', '打工陷阱', '黑中介', '黑厂', '被坑', '入职被坑', '押金不退', '克扣工资'], priority: ['工资靠谱', '真实薪资', '工资日结', '发工资', '加班费', '包吃包住', '当天入职'], channel: ['快聘', '直播带岗', '蓝领找工作', '当天入职', '包吃包住', '工资靠谱'], fastTrack: ['当天入职', '包吃包住', '快聘', '直播带岗'], }; const TERMS = { concern: ['骗', '坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '虚假', '拖欠', '维权', '身份证'], priority: ['工资', '薪资', '发工资', '日结', '加班费', '包吃包住', '住宿', '吃住', '当天入职', '靠谱', '真实'], emotion: ['放心', '靠谱', '真实', '踏实', '终于', '尊重', '委屈', '难受', '压抑', '被骗', '被坑'], channel: ['快聘', '直播带岗', '找工作', '招工', '蓝领', '工厂', '岗位', '报名', '入职', '安排住宿'], attention: ['工资', '薪资', '发工资', '日结', '加班费', '包吃包住', '住宿', '当天入职', '靠谱', '真实', '快聘', '直播带岗', '找工作', '蓝领', '工厂', '岗位', '被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣工资', '套路', '陷阱'], }; const POSITIVE_TERMS = ['放心', '靠谱', '真实', '踏实', '终于', '尊重', '包吃包住', '当天入职', '日结', '发工资']; const NEGATIVE_TERMS = ['被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '拖欠', '维权', '委屈', '难受', '压抑']; function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function fmtNum(n) { return Number(n || 0).toLocaleString('zh-CN'); } function fmtCompact(n) { const value = Number(n || 0); if (value >= 100000000) return `${(value / 100000000).toFixed(1).replace(/\.0$/, '')}亿`; if (value >= 10000) return `${(value / 10000).toFixed(1).replace(/\.0$/, '')}w`; if (value >= 1000) return `${(value / 1000).toFixed(1).replace(/\.0$/, '')}k`; return String(value); } function truncate(text, max = 140) { const clean = String(text || '').replace(/\s+/g, ' ').trim(); return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; } function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } function readRawFiles() { return fs.readdirSync(RAW_DIR) .filter((name) => name.endsWith('.json')) .map((name) => JSON.parse(fs.readFileSync(path.join(RAW_DIR, name), 'utf8'))); } function uniqueBy(items, getKey) { const seen = new Set(); const out = []; for (const item of items) { const key = getKey(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 collectDataset(rawFiles) { const records = []; const keywordStats = []; for (const raw of rawFiles) { const keyword = raw.keyword || '未知'; const videos = raw.top_videos || []; const commentsMap = raw.comments || {}; let comments = 0; let likes = 0; let views = 0; for (const video of videos) { const text = String(video.caption || '').replace(/\s+/g, ' ').trim(); likes += Number(video.like_count || 0); views += Number(video.view_count || 0); if (!text) continue; records.push({ kind: '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, list] of Object.entries(commentsMap)) { for (const comment of list || []) { const text = String(comment.content || '').replace(/\s+/g, ' ').trim(); comments += 1; if (!text) continue; records.push({ kind: '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 || ''}`, }); } } keywordStats.push({ keyword, videos: videos.length, comments, likes, views, }); } return { records, keywordStats }; } function pickCluster(records, keywords, terms) { const keywordSet = new Set(keywords); return uniqueBy( records.filter((item) => keywordSet.has(item.keyword) || includesAny(item.text, terms)), (item) => `${item.kind}:${item.keyword}:${item.sourceId}` ); } function summarizeTerms(records, terms, limit = 12) { return terms.map((term) => { let count = 0; let score = 0; for (const item of records) { if (!item.text.includes(term)) continue; count += 1; score += item.score; } return { term, count, score }; }).filter((item) => item.count > 0) .sort((a, b) => b.score - a.score || b.count - a.count) .slice(0, limit); } function topSamples(records, limit = 8) { return uniqueBy( [...records].sort((a, b) => b.score - a.score), (item) => item.text ).slice(0, limit); } function buildEmotionSamples(records, limit = 12) { const enriched = records .filter((item) => includesAny(item.text, [...POSITIVE_TERMS, ...NEGATIVE_TERMS])) .map((item) => { const positive = POSITIVE_TERMS.filter((term) => item.text.includes(term)).length; const negative = NEGATIVE_TERMS.filter((term) => item.text.includes(term)).length; let polarity = '中性'; if (positive > negative) polarity = '正向'; if (negative > positive) polarity = '负向'; if (positive > 0 && negative > 0) polarity = '冲突'; return { ...item, polarity, emotionScore: item.score + (positive + negative) * 10 }; }) .sort((a, b) => b.emotionScore - a.emotionScore); return uniqueBy(enriched, (item) => item.text).slice(0, limit); } function buildStackRows(keywordStats, keywords) { return keywordStats .filter((item) => keywords.includes(item.keyword)) .sort((a, b) => b.views - a.views || b.comments - a.comments); } function buildBarChart(rows, accent) { const max = Math.max(...rows.map((row) => row.value), 1); const barH = 34; const gap = 12; const padL = 150; const padR = 95; const padV = 16; const width = 920; const totalH = rows.length * (barH + gap) + padV * 2; return `
${rows.map((row, index) => { const y = padV + index * (barH + gap); const w = Math.max((row.value / max) * (width - padL - padR), 6); return `${esc(row.label)} ${esc(row.valueLabel || row.value)}`; }).join('\n')}
`; } function quoteCard(item, color) { return `
${esc(item.keyword)} ${item.kind === 'video' ? '视频文案' : '评论'}
赞 ${fmtCompact(item.likes)} · 热度 ${fmtCompact(item.score)}
${esc(truncate(item.text, 160))}
`; } function metricCard(label, value, sub, color) { return `
${esc(label)}
${esc(value)}
${esc(sub)}
`; } const rawFiles = readRawFiles(); const dataset = collectDataset(rawFiles); const records = dataset.records; const keywordStats = dataset.keywordStats; const totalKeywords = rawFiles.length; const totalVideos = keywordStats.reduce((sum, item) => sum + item.videos, 0); const totalComments = keywordStats.reduce((sum, item) => sum + item.comments, 0); const totalViews = keywordStats.reduce((sum, item) => sum + item.views, 0); const totalLikes = keywordStats.reduce((sum, item) => sum + item.likes, 0); const concernRecords = pickCluster(records, CLUSTERS.concern, TERMS.concern); const priorityRecords = pickCluster(records, CLUSTERS.priority, TERMS.priority); const channelRecords = pickCluster(records, CLUSTERS.channel, TERMS.channel); const fastTrackRecords = pickCluster(records, CLUSTERS.fastTrack, TERMS.channel); const concernTerms = summarizeTerms(concernRecords, TERMS.concern, 12); const priorityTerms = summarizeTerms(priorityRecords, TERMS.priority, 12); const attentionTerms = summarizeTerms(records, TERMS.attention, 18); const concernSamples = topSamples(concernRecords, 8); const prioritySamples = topSamples(priorityRecords, 8); const channelSamples = topSamples(channelRecords, 8); const emotionSamples = buildEmotionSamples(records, 12); const topRecords = topSamples(records, 12); const strategicRows = buildStackRows(keywordStats, CLUSTERS.channel).slice(0, 8); const topKeywordRows = [...keywordStats].sort((a, b) => b.comments - a.comments || b.views - a.views).slice(0, 10); const highestLike = records.reduce((max, item) => Math.max(max, item.likes || 0), 0); const highestScore = records.reduce((max, item) => Math.max(max, item.score || 0), 0); const html = ` 快手渠道蓝领求职洞察报告 v5
01/10
VOC 深度洞察报告

快手蓝领求职

基于 ${fmtNum(totalVideos)} 条视频、 ${fmtNum(totalComments)} 条评论、 ${fmtNum(totalViews)} 次播放的主题化分析, 回答蓝领在快手求职时“最怕什么、最要什么、为什么会把快手和快聘当成高效率入口”。

${fmtNum(totalKeywords)}
主题关键词文件
${fmtNum(concernRecords.length)}
风险相关样本
${fmtNum(priorityRecords.length)}
需求相关样本
${fmtCompact(highestLike)}
最高点赞单条
${new Date().toLocaleDateString('zh-CN')} · 数据目录:docs/KS-Chanel_analysis/raw/KS
Chapter 01

执行摘要

先把这份报告真正想回答的业务问题说清楚,再给出可以直接汇报的结论版。

最担心的不是低薪,而是被骗

从风险样本与高赞表达看,蓝领用户最敏感的是虚假招聘、黑中介、押金不退、工资克扣与入职后货不对板。风险焦虑是比价格比较更靠前的决策变量。

最看重的是“确定性条件”

工资真实、发薪稳定、包吃包住、当天入职、流程简单,这些词反复出现。它们本质上都是在降低求职成本、迁移成本和试错成本。

快手承接的是短链路求职决策

用户通过直播和短视频看环境、看宿舍、看工资口径,再通过评论和私信做真假判断。快手承担了入口、解释、信任建立和转化承接的复合角色。

快聘的战略位势来自离转化更近

如果内部判断“快聘找工作日活几十万简历,战略位势仅次于电商”,那这批 VOC 可以解释原因:需求高频、风险敏感、决策短、转化意图强。

Business Questions
${GOALS.map((goal) => `
${esc(goal)}
`).join('')}
Chapter 02

数据范围与分析方法

这一页补足方法论说明,让整份 HTML 不只是结论页,也是一份可解释的数据汇报页。

${metricCard('关键词文件', fmtNum(totalKeywords), '覆盖快聘、直播带岗、工资、被骗、包吃包住等主题', '#F5A623')} ${metricCard('视频总量', fmtNum(totalVideos), '每个主题文件包含 top_videos 样本', '#3B82F6')} ${metricCard('评论总量', fmtNum(totalComments), '评论是本次用户需求判断的核心依据', '#FF4D8D')} ${metricCard('播放总量', fmtCompact(totalViews), '用于判断主题内容在快手端的触达与热度', '#00DC82')}
Analysis Logic
以原始 raw/KS/*.json 为准,重新汇总视频文案、评论文本和主题热度。
按“风险 / 需求 / 渠道 / 情绪 / 战略位势”做语义聚类,而不是只看单个关键词文件。
通过“词项命中 × 文本热度”识别真正有汇报价值的主题词。
保留高赞视频文案和评论样本,方便直接放进管理层汇报或研究结论里。
Data Snapshot
指标数值说明
总点赞量${fmtNum(totalLikes)}用于估算主题互动强度
最高单条热度${fmtCompact(highestScore)}高热视频与高赞评论共同构成综合热度
风险样本${fmtNum(concernRecords.length)}“骗 / 坑 / 中介 / 押金 / 克扣”相关
需求样本${fmtNum(priorityRecords.length)}“工资 / 吃住 / 发薪 / 入职”相关
渠道样本${fmtNum(channelRecords.length)}“快聘 / 直播带岗 / 蓝领找工作”相关
Chapter 03

蓝领求职最担心什么

风险不是单点事件,而是从“看到岗位”到“真正入职”全链路的不确定性。这也是快手求职场里最强烈的底层情绪。

${metricCard('风险样本', fmtNum(concernRecords.length), '覆盖招聘骗局、打工陷阱、被坑、黑中介、押金不退等', '#FF4D8D')} ${metricCard('首位风险词', concernTerms[0]?.term || '被骗', '按命中条数和热度综合排序', '#EF4444')} ${metricCard('高热风险表达', fmtCompact(concernSamples[0]?.score || 0), '高热内容通常直接揭露套路或踩坑经历', '#F97316')}
${buildBarChart(concernTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#FF4D8D' })), '#FF4D8D')}
${concernSamples.map((item) => quoteCard(item, '#FF4D8D')).join('')}
汇报可直接引用

蓝领求职最强焦虑不是“工资不够高”,而是“会不会被骗、会不会被中介抽走、会不会交押金、会不会干完拿不到钱”。风险感知先于薪资吸引,是快手求职内容里最值得重视的底层变量。

Chapter 04

蓝领求职最看重什么

对蓝领来说,最重要的不是抽象品牌,而是“值不值得去、去了会不会亏、多久能上岗”这种极度现实的问题。

工资真实

用户会反复对照薪资口径、到手收入和发薪规则。

发薪稳定

“发工资”“日结”“加班费”都在传递更强的安全感。

包吃包住

它直接降低异地求职的初始成本和决策门槛。

当天入职

蓝领决策短,能不能快速落地决定转化效率。

${buildBarChart(priorityTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#00DC82' })), '#00DC82')}
${prioritySamples.map((item) => quoteCard(item, '#00DC82')).join('')}
汇报可直接引用

蓝领最看重的不是单一“高薪”,而是工资真实、发薪稳定、包吃包住、当天入职和流程简单。这些条件共同构成了他们对“确定性工作机会”的判断标准。

Chapter 05

情绪价值强在哪里

真正有价值的情绪,不是空泛安慰,而是“让我少踩坑、让我看得懂、让我敢报名”的决策安全感。

负向情绪来源

被骗、被坑、黑中介、押金不退、工资克扣,会把委屈、愤怒和无力感放大。

正向情绪来源

靠谱、真实、放心、包吃包住、当天入职,本质上都在回应“能不能放心去”。

情绪价值本质

是帮用户建立确定性,而不是单纯制造高情绪张力或娱乐氛围。

${emotionSamples.map((item) => quoteCard(item, item.polarity === '正向' ? '#00DC82' : item.polarity === '负向' ? '#FF4D8D' : '#F5A623')).join('')}
Chapter 06

为什么快手能成为蓝领求职的重要渠道

这里不是泛泛说“快手流量大”,而是解释为什么快手适合承接蓝领这种短链路、高时效、强风险判断的决策场景。

内容可视化

车间、宿舍、食堂、工资口径、现场环境都可以被直接展示,比纯文字招聘更有说服力。

评论问答密度高

用户会直接追问“是不是中介、包不包住、工资怎么发、什么时候入职”,帮助做真假筛选。

转化链路短

从看内容到留电话、私信报名、到场面试,快手天然适配短决策和高转化意图。

${buildBarChart(strategicRows.map((item) => ({ label: item.keyword, value: item.views, valueLabel: `${fmtNum(item.comments)}评 / ${fmtCompact(item.views)}`, color: '#3B82F6' })), '#3B82F6')}
Management Take
快手不是“宣传渠道”那么简单,而是蓝领求职链路里的信息解释器和信任放大器。
“直播带岗”“包吃包住”“当天入职”这种内容组合,本质上是在降低用户决策成本。
按业务前提“10 个蓝领 9 个都用快手”,快手更像蓝领找工作时的内容主场,而不是边缘补充入口。
因此快聘承接的不是普通流量,而是已经带有明确求职意图的高价值流量。
Chapter 07

用户关注的关键字

这些词不是泛热词,而是会影响点击、咨询和转化的实际判断词,适合做后续内容、产品和业务汇报的重点词库。

${buildBarChart(attentionTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#F97316' })), '#F97316')}
${attentionTerms.slice(0, 12).map((item) => `
${esc(item.term)}
${fmtCompact(item.score)}
命中 ${fmtNum(item.count)} 条,适合做内容标题词、转化承诺词或研究结论词。
`).join('')}
Chapter 08

快聘的战略位势如何被需求支撑

如果内部已有“快聘找工作日活几十万简历,战略位势仅次于电商”的判断,这里给的是用户需求层的解释框架。

Demand Proof
需求高频:工资、吃住、发薪、入职效率反复出现,说明找工作不是低频浏览,而是持续高频需求。
风险敏感:用户会用内容和评论先筛掉风险,再决定要不要报名,因此平台承接能力非常关键。
决策短链:从看内容到报名之间链路短,越贴近承接层,战略价值越高。
转化诉求强:快聘承接的不是泛流量,而是已经准备迈入求职动作的高意图流量。
Strategic Topics
${strategicRows.map((item) => ``).join('')}
主题词视频评论播放量
${esc(item.keyword)}${fmtNum(item.videos)}${fmtNum(item.comments)}${fmtCompact(item.views)}
一句话管理层结论

快聘之所以有高战略位势,不只是因为求职流量大,而是因为快手内容场里已经天然沉淀了“高意图、强风险判断、短决策、重确定性”的蓝领求职需求。谁能承接这类需求,谁就更接近简历投递和入职转化的核心层。

Chapter 09

内容与产品层启示

把前面的 VOC 洞察压缩成平台、内容和产品都能用的动作建议。

内容层

多做“工资口径解释、现场环境实拍、包吃包住细节、入职流程拆解”这种解决不确定性的内容,而不是只做岗位播报。

产品层

强化“企业直招、无押金、工资发放规则、住宿安排、是否当天入职”等结构化字段,帮助用户快速判断。

信任层

蓝领情绪价值的核心是安全感。能提升信任校验、真实展示和评论问答质量,就能提高留资和转化效率。

${channelSamples.slice(0, 4).map((item) => quoteCard(item, '#3B82F6')).join('')}
Chapter 10

高赞样本附录

这一页保留更原始的用户声音,方便在汇报里做案例补充或引用。

${topRecords.map((item) => quoteCard(item, item.keyword && CLUSTERS.concern.includes(item.keyword) ? '#FF4D8D' : item.keyword && CLUSTERS.priority.includes(item.keyword) ? '#00DC82' : '#F5A623')).join('')}
`; ensureDir(OUT_DIR); fs.writeFileSync(OUT_FILE, html, 'utf8'); console.log(`Generated: ${OUT_FILE}`);