| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- #!/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();
- }
|