#!/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.term)} | ${fmtNum(item.count)} | ${fmtCompact(item.score)} |
| 需求词 | 命中 | 热度 |
|---|---|---|
| ${esc(item.term)} | ${fmtNum(item.count)} | ${fmtCompact(item.score)} |
| 关键词 | 命中 | 热度 |
|---|---|---|
| ${esc(item.term)} | ${fmtNum(item.count)} | ${fmtCompact(item.score)} |
| 主题词 | 视频 | 评论 | 播放 |
|---|---|---|---|
| ${esc(item.keyword)} | ${fmtNum(item.videos)} | ${fmtNum(item.comments)} | ${fmtCompact(item.views)} |
这一页不再抽象总结,而是直接保留高热样本,方便把“担心、看重、渠道”讲得更具体。