#!/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 `
`;
}
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) => `
`).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) => `| ${esc(item.keyword)} | ${fmtNum(item.videos)} | ${fmtNum(item.comments)} | ${fmtCompact(item.views)} |
`).join('')}
一句话管理层结论
快聘之所以有高战略位势,不只是因为求职流量大,而是因为快手内容场里已经天然沉淀了“高意图、强风险判断、短决策、重确定性”的蓝领求职需求。谁能承接这类需求,谁就更接近简历投递和入职转化的核心层。
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}`);