#!/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 TEMPLATE_FILE = path.join(BASE_DIR, 'reports', 'ks-voc-insight-report-v5.html'); const OUT_FILE = path.join(BASE_DIR, 'reports', 'ks-voc-insight-report-v6.html'); const GROUPS = { concern: ['招聘骗局', '求职被骗', '打工陷阱', '黑中介', '黑厂', '被坑', '入职被坑', '押金不退', '克扣工资'], priority: ['工资靠谱', '真实薪资', '工资日结', '发工资', '加班费', '包吃包住', '当天入职'], channel: ['快聘', '直播带岗', '蓝领找工作', '当天入职', '包吃包住', '工资靠谱'], }; const TERMS = { concern: ['骗', '坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '虚假', '拖欠', '维权'], priority: ['工资', '薪资', '发工资', '日结', '加班费', '包吃包住', '住宿', '吃住', '当天入职', '靠谱', '真实'], emotion: ['放心', '靠谱', '真实', '踏实', '终于', '尊重', '委屈', '难受', '压抑', '被骗', '被坑'], channel: ['快聘', '直播带岗', '找工作', '招工', '蓝领', '工厂', '岗位', '报名', '入职', '安排住宿'], attention: ['工资', '薪资', '发工资', '日结', '加班费', '包吃包住', '住宿', '当天入职', '靠谱', '真实', '快聘', '直播带岗', '找工作', '蓝领', '工厂', '岗位', '被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣工资', '套路', '陷阱'], }; 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 = 150) { const clean = String(text || '').replace(/\s+/g, ' ').trim(); return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; } 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 = 10) { 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 = 6) { return uniqueBy( [...records].sort((a, b) => b.score - a.score), (item) => item.text ).slice(0, limit); } function quoteCard(item, color) { return `
${esc(item.keyword)} ${item.kind === 'video' ? '视频文案' : '评论'}
赞 ${fmtCompact(item.likes)} · 热度 ${fmtCompact(item.score)}
${esc(truncate(item.text, 160))}
`; } function tableRows(rows, mapper) { return rows.map(mapper).join(''); } function buildDeepDiveSection(stats) { const { concernRecords, priorityRecords, channelRecords, concernTerms, priorityTerms, attentionTerms, strategicRows, concernSamples, prioritySamples, channelSamples, totalViews } = stats; return `
Chapter 11

深挖:快手求职链路的核心矛盾

这一章把“担心、看重、渠道、战略位势”放到同一张问题地图上,便于做管理层汇报时一页讲透。

风险样本
${fmtNum(concernRecords.length)}
围绕被骗、黑中介、押金不退、克扣工资
需求样本
${fmtNum(priorityRecords.length)}
围绕工资真实、吃住、发薪、快速入职
渠道样本
${fmtNum(channelRecords.length)}
围绕快聘、直播带岗、蓝领找工作
总播放
${fmtCompact(totalViews)}
说明这些主题在快手端具备真实触达和传播基础
Risk Thesis
蓝领最怕的不是岗位少,而是看上去是机会、实际上是风险。
从快手内容场看,诈骗、中介抽成、押金不退、工资被扣构成连续风险链路。
因此任何更高效的承接产品,首先要解决的不是曝光,而是信任和确定性。
Need Thesis
用户最看重的是工资真实、发薪稳定、包吃包住、当天入职。
这些词背后对应的是“降低迁移成本、降低试错成本、缩短入职等待期”。
所以蓝领要的情绪价值,本质上是安全感和可执行感,而不是轻飘飘的高薪叙事。
`; } function buildMatrixSection(stats) { const { concernTerms, priorityTerms, attentionTerms, strategicRows } = stats; return `
Chapter 12

证据矩阵:风险、需求、关键词、战略主题

把报告里最关键的词项和主题放到一个证据矩阵里,方便后续做汇报、摘要和内容策略复用。

Risk Terms
${tableRows(concernTerms, (item) => ``)}
风险词命中热度
${esc(item.term)}${fmtNum(item.count)}${fmtCompact(item.score)}
Need Terms
${tableRows(priorityTerms, (item) => ``)}
需求词命中热度
${esc(item.term)}${fmtNum(item.count)}${fmtCompact(item.score)}
Attention Keywords
${tableRows(attentionTerms.slice(0, 12), (item) => ``)}
关键词命中热度
${esc(item.term)}${fmtNum(item.count)}${fmtCompact(item.score)}
Strategic Topics
${tableRows(strategicRows, (item) => ``)}
主题词视频评论播放
${esc(item.keyword)}${fmtNum(item.videos)}${fmtNum(item.comments)}${fmtCompact(item.views)}
`; } function buildAppendixSection(stats) { const { concernSamples, prioritySamples, channelSamples } = stats; return `
Chapter 13

补充样本:可直接引用的原始声音

这一页不再抽象总结,而是直接保留高热样本,方便把“担心、看重、渠道”讲得更具体。

Concern
${concernSamples.slice(0, 3).map((item) => quoteCard(item, '#FF4D8D')).join('')}
Priority
${prioritySamples.slice(0, 3).map((item) => quoteCard(item, '#00DC82')).join('')}
Channel
${channelSamples.slice(0, 3).map((item) => quoteCard(item, '#3B82F6')).join('')}
`; } function analyzeDataset() { const rawFiles = readRawFiles(); const dataset = collectDataset(rawFiles); const records = dataset.records; const keywordStats = dataset.keywordStats; const totalViews = keywordStats.reduce((sum, item) => sum + item.views, 0); const concernRecords = pickCluster(records, GROUPS.concern, TERMS.concern); const priorityRecords = pickCluster(records, GROUPS.priority, TERMS.priority); const channelRecords = pickCluster(records, GROUPS.channel, TERMS.channel); return { concernRecords, priorityRecords, channelRecords, concernTerms: summarizeTerms(concernRecords, TERMS.concern, 10), priorityTerms: summarizeTerms(priorityRecords, TERMS.priority, 10), attentionTerms: summarizeTerms(records, TERMS.attention, 12), strategicRows: keywordStats.filter((item) => GROUPS.channel.includes(item.keyword)).sort((a, b) => b.views - a.views || b.comments - a.comments), concernSamples: topSamples(concernRecords, 6), prioritySamples: topSamples(priorityRecords, 6), channelSamples: topSamples(channelRecords, 6), totalViews, }; } function main() { const stats = analyzeDataset(); let html = fs.readFileSync(TEMPLATE_FILE, 'utf8'); const extraSections = [ buildDeepDiveSection(stats), buildMatrixSection(stats), buildAppendixSection(stats), ].join('\n'); html = html.replace('', `${extraSections}\n`); html = html.replace(/快手渠道蓝领求职洞察报告 v5/g, '快手渠道蓝领求职洞察报告 v6'); html = html.replace(/ks-voc-insight-report-v5/g, 'ks-voc-insight-report-v6'); fs.writeFileSync(OUT_FILE, html, 'utf8'); console.log(`Generated: ${OUT_FILE}`); } if (require.main === module) { try { main(); } catch (error) { console.error('fatal:', error); process.exit(1); } } module.exports = { analyzeDataset };