| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- #!/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 `<div class="card" style="padding:18px 20px;">
- <div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:flex-start;margin-bottom:10px;">
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
- <span class="tag" style="background:${color}18;color:${color};border-color:${color}44;">${esc(item.keyword)}</span>
- <span class="text-xs">${item.kind === 'video' ? '视频文案' : '评论'}</span>
- </div>
- <div class="text-xs">赞 ${fmtCompact(item.likes)} · 热度 ${fmtCompact(item.score)}</div>
- </div>
- <div style="font-size:0.92rem;line-height:1.72;color:var(--text-1);">${esc(truncate(item.text, 160))}</div>
- </div>`;
- }
- 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 `
- <section class="report-section" data-slide-num="11" data-tone="blue">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--blue);">Chapter 11</div>
- <h2 class="text-h1">深挖:快手求职链路的核心矛盾</h2>
- <p class="section-sub">这一章把“担心、看重、渠道、战略位势”放到同一张问题地图上,便于做管理层汇报时一页讲透。</p>
- </div>
- <div class="grid grid-4" style="margin-bottom:24px;">
- <div class="card metric"><div class="metric-label">风险样本</div><div class="metric-value" style="color:var(--rose);">${fmtNum(concernRecords.length)}</div><div class="metric-sub">围绕被骗、黑中介、押金不退、克扣工资</div></div>
- <div class="card metric"><div class="metric-label">需求样本</div><div class="metric-value" style="color:var(--green);">${fmtNum(priorityRecords.length)}</div><div class="metric-sub">围绕工资真实、吃住、发薪、快速入职</div></div>
- <div class="card metric"><div class="metric-label">渠道样本</div><div class="metric-value" style="color:var(--blue);">${fmtNum(channelRecords.length)}</div><div class="metric-sub">围绕快聘、直播带岗、蓝领找工作</div></div>
- <div class="card metric"><div class="metric-label">总播放</div><div class="metric-value" style="color:var(--amber);">${fmtCompact(totalViews)}</div><div class="metric-sub">说明这些主题在快手端具备真实触达和传播基础</div></div>
- </div>
- <div class="grid grid-2">
- <div class="card">
- <div class="section-eyebrow" style="color:var(--rose);margin-bottom:14px;">Risk Thesis</div>
- <div class="stack-list">
- <div class="stack-item"><span class="stack-dot" style="background:var(--rose);"></span><div>蓝领最怕的不是岗位少,而是看上去是机会、实际上是风险。</div></div>
- <div class="stack-item"><span class="stack-dot" style="background:var(--red);"></span><div>从快手内容场看,诈骗、中介抽成、押金不退、工资被扣构成连续风险链路。</div></div>
- <div class="stack-item"><span class="stack-dot" style="background:var(--orange);"></span><div>因此任何更高效的承接产品,首先要解决的不是曝光,而是信任和确定性。</div></div>
- </div>
- </div>
- <div class="card">
- <div class="section-eyebrow" style="color:var(--green);margin-bottom:14px;">Need Thesis</div>
- <div class="stack-list">
- <div class="stack-item"><span class="stack-dot" style="background:var(--green);"></span><div>用户最看重的是工资真实、发薪稳定、包吃包住、当天入职。</div></div>
- <div class="stack-item"><span class="stack-dot" style="background:var(--blue);"></span><div>这些词背后对应的是“降低迁移成本、降低试错成本、缩短入职等待期”。</div></div>
- <div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>所以蓝领要的情绪价值,本质上是安全感和可执行感,而不是轻飘飘的高薪叙事。</div></div>
- </div>
- </div>
- </div>
- </div>
- </section>`;
- }
- function buildMatrixSection(stats) {
- const { concernTerms, priorityTerms, attentionTerms, strategicRows } = stats;
- return `
- <section class="report-section" data-slide-num="12" data-tone="orange">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--orange);">Chapter 12</div>
- <h2 class="text-h1">证据矩阵:风险、需求、关键词、战略主题</h2>
- <p class="section-sub">把报告里最关键的词项和主题放到一个证据矩阵里,方便后续做汇报、摘要和内容策略复用。</p>
- </div>
- <div class="grid grid-2" style="margin-bottom:22px;">
- <div class="card table-card">
- <div class="section-eyebrow" style="color:var(--rose);margin-bottom:14px;">Risk Terms</div>
- <table class="clean">
- <thead><tr><th>风险词</th><th>命中</th><th>热度</th></tr></thead>
- <tbody>${tableRows(concernTerms, (item) => `<tr><td>${esc(item.term)}</td><td>${fmtNum(item.count)}</td><td>${fmtCompact(item.score)}</td></tr>`)}</tbody>
- </table>
- </div>
- <div class="card table-card">
- <div class="section-eyebrow" style="color:var(--green);margin-bottom:14px;">Need Terms</div>
- <table class="clean">
- <thead><tr><th>需求词</th><th>命中</th><th>热度</th></tr></thead>
- <tbody>${tableRows(priorityTerms, (item) => `<tr><td>${esc(item.term)}</td><td>${fmtNum(item.count)}</td><td>${fmtCompact(item.score)}</td></tr>`)}</tbody>
- </table>
- </div>
- </div>
- <div class="grid grid-2">
- <div class="card table-card">
- <div class="section-eyebrow" style="color:var(--orange);margin-bottom:14px;">Attention Keywords</div>
- <table class="clean">
- <thead><tr><th>关键词</th><th>命中</th><th>热度</th></tr></thead>
- <tbody>${tableRows(attentionTerms.slice(0, 12), (item) => `<tr><td>${esc(item.term)}</td><td>${fmtNum(item.count)}</td><td>${fmtCompact(item.score)}</td></tr>`)}</tbody>
- </table>
- </div>
- <div class="card table-card">
- <div class="section-eyebrow" style="color:var(--blue);margin-bottom:14px;">Strategic Topics</div>
- <table class="clean">
- <thead><tr><th>主题词</th><th>视频</th><th>评论</th><th>播放</th></tr></thead>
- <tbody>${tableRows(strategicRows, (item) => `<tr><td>${esc(item.keyword)}</td><td>${fmtNum(item.videos)}</td><td>${fmtNum(item.comments)}</td><td>${fmtCompact(item.views)}</td></tr>`)}</tbody>
- </table>
- </div>
- </div>
- </div>
- </section>`;
- }
- function buildAppendixSection(stats) {
- const { concernSamples, prioritySamples, channelSamples } = stats;
- return `
- <section class="report-section" data-slide-num="13" data-tone="rose">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--rose);">Chapter 13</div>
- <h2 class="text-h1">补充样本:可直接引用的原始声音</h2>
- <p class="section-sub">这一页不再抽象总结,而是直接保留高热样本,方便把“担心、看重、渠道”讲得更具体。</p>
- </div>
- <div class="grid grid-3">
- <div>
- <div class="section-eyebrow" style="color:var(--rose);margin-bottom:14px;">Concern</div>
- ${concernSamples.slice(0, 3).map((item) => quoteCard(item, '#FF4D8D')).join('')}
- </div>
- <div>
- <div class="section-eyebrow" style="color:var(--green);margin-bottom:14px;">Priority</div>
- ${prioritySamples.slice(0, 3).map((item) => quoteCard(item, '#00DC82')).join('')}
- </div>
- <div>
- <div class="section-eyebrow" style="color:var(--blue);margin-bottom:14px;">Channel</div>
- ${channelSamples.slice(0, 3).map((item) => quoteCard(item, '#3B82F6')).join('')}
- </div>
- </div>
- </div>
- </section>`;
- }
- 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('</main>', `${extraSections}\n</main>`);
- 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 };
|