| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693 |
- #!/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 `<div class="card chart-card"><svg viewBox="0 0 ${width} ${totalH}" style="width:100%;height:auto;" preserveAspectRatio="xMidYMid meet">
- ${rows.map((row, index) => {
- const y = padV + index * (barH + gap);
- const w = Math.max((row.value / max) * (width - padL - padR), 6);
- return `<text x="${padL - 10}" y="${y + barH / 2 + 5}" text-anchor="end" font-size="13" fill="var(--text-2)">${esc(row.label)}</text>
- <rect x="${padL}" y="${y}" width="${w}" height="${barH}" rx="5" fill="${row.color || accent}" opacity="0.88"></rect>
- <text x="${padL + w + 10}" y="${y + barH / 2 + 5}" font-size="13" font-weight="700" fill="${row.color || accent}" font-family="var(--font-mono)">${esc(row.valueLabel || row.value)}</text>`;
- }).join('\n')}
- </svg></div>`;
- }
- function quoteCard(item, color) {
- return `<div class="card quote-card">
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;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 class="quote-text">${esc(truncate(item.text, 160))}</div>
- </div>`;
- }
- function metricCard(label, value, sub, color) {
- return `<div class="card metric">
- <div class="metric-label">${esc(label)}</div>
- <div class="metric-value" style="color:${color};">${esc(value)}</div>
- <div class="metric-sub">${esc(sub)}</div>
- </div>`;
- }
- 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 = `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width,initial-scale=1.0">
- <title>快手渠道蓝领求职洞察报告 v5</title>
- <style>
- :root{
- --bg-0:#0A0A0A;--bg-1:#111111;--bg-2:#1A1A1A;--bg-3:#252525;
- --border:rgba(255,255,255,0.08);--border-strong:rgba(255,255,255,0.16);
- --text-1:#F5F5F5;--text-2:#A8A8A8;--text-3:#6B6B6B;
- --amber:#F5A623;--green:#00DC82;--rose:#FF4D8D;--blue:#3B82F6;--red:#EF4444;--purple:#8B5CF6;--orange:#F97316;--cyan:#22C7D6;
- --font-head:'Inter','Noto Sans SC','PingFang SC','Microsoft YaHei',sans-serif;
- --font-body:'Noto Sans SC','Inter','PingFang SC','Microsoft YaHei',sans-serif;
- --font-mono:'JetBrains Mono','SF Mono','Fira Code',monospace;
- --max-w:1280px;--radius-sm:8px;--radius-md:12px;--radius-lg:20px;
- }
- *{margin:0;padding:0;box-sizing:border-box}
- html{scroll-behavior:smooth;font-size:16px;scroll-snap-type:y proximity}
- body{font-family:var(--font-body);background:var(--bg-0);color:var(--text-1);line-height:1.65;-webkit-font-smoothing:antialiased;overflow-x:hidden}
- body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;background:
- radial-gradient(ellipse 920px 700px at 20% 22%,rgba(245,166,35,0.10),transparent 60%),
- radial-gradient(ellipse 860px 620px at 82% 78%,rgba(59,130,246,0.08),transparent 60%),
- radial-gradient(ellipse 640px 480px at 50% 50%,rgba(255,77,141,0.05),transparent 62%);
- transition:background 1.4s cubic-bezier(.2,.7,.2,1)}
- body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;background-image:
- linear-gradient(rgba(255,255,255,0.015) 1px,transparent 1px),
- linear-gradient(90deg,rgba(255,255,255,0.015) 1px,transparent 1px);
- background-size:76px 76px;
- mask-image:radial-gradient(ellipse 90% 95% at 50% 50%,black 0%,transparent 84%);
- -webkit-mask-image:radial-gradient(ellipse 90% 95% at 50% 50%,black 0%,transparent 84%)}
- main.deck{position:relative;z-index:1}
- ::selection{background:var(--amber);color:#000}
- .text-hero{font-family:var(--font-head);font-weight:900;letter-spacing:-0.04em;line-height:0.92;font-size:clamp(3.8rem,10vw,9rem)}
- .text-poster{font-family:var(--font-head);font-weight:800;letter-spacing:-0.03em;line-height:1.02;font-size:clamp(2.5rem,6vw,5rem)}
- .text-h1{font-family:var(--font-head);font-weight:700;letter-spacing:-0.02em;line-height:1.08;font-size:clamp(2rem,4vw,3.5rem)}
- .text-h2{font-family:var(--font-head);font-weight:700;letter-spacing:-0.015em;line-height:1.16;font-size:clamp(1.35rem,2.4vw,2rem)}
- .text-body{font-size:clamp(0.92rem,1.1vw,1rem);line-height:1.78;color:var(--text-2)}
- .text-sm{font-size:0.85rem;color:var(--text-2)}
- .text-xs{font-size:0.72rem;color:var(--text-3);letter-spacing:0.02em}
- section.report-section{min-height:100vh;padding:clamp(60px,10vh,120px) clamp(24px,6vw,80px);position:relative;border-bottom:1px solid var(--border);display:flex;flex-direction:column;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden;isolation:isolate}
- section.report-section::before{content:attr(data-slide-num);position:absolute;top:clamp(20px,3vh,36px);right:clamp(24px,4vw,60px);font-family:var(--font-mono);font-weight:600;font-size:clamp(0.68rem,0.75vw,0.78rem);letter-spacing:0.25em;color:var(--text-3);z-index:4;pointer-events:none}
- section.report-section::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:-1;background:
- radial-gradient(ellipse 70% 60% at 85% 15%,var(--slide-glow-1,rgba(245,166,35,0.06)),transparent 55%),
- radial-gradient(ellipse 60% 50% at 15% 85%,var(--slide-glow-2,rgba(139,92,246,0.04)),transparent 55%);
- opacity:0;transition:opacity 1s cubic-bezier(.2,.7,.2,1)}
- section.report-section.in-view::after{opacity:1}
- section[data-tone="amber"]{--slide-glow-1:rgba(245,166,35,0.10);--slide-glow-2:rgba(245,166,35,0.04)}
- section[data-tone="rose"]{--slide-glow-1:rgba(255,77,141,0.09);--slide-glow-2:rgba(255,77,141,0.04)}
- section[data-tone="green"]{--slide-glow-1:rgba(0,220,130,0.08);--slide-glow-2:rgba(0,220,130,0.03)}
- section[data-tone="purple"]{--slide-glow-1:rgba(139,92,246,0.10);--slide-glow-2:rgba(139,92,246,0.04)}
- section[data-tone="blue"]{--slide-glow-1:rgba(59,130,246,0.08);--slide-glow-2:rgba(59,130,246,0.03)}
- section[data-tone="orange"]{--slide-glow-1:rgba(249,115,22,0.10);--slide-glow-2:rgba(249,115,22,0.04)}
- .section-inner{width:100%;max-width:var(--max-w);margin:0 auto;position:relative}
- .section-head{margin-bottom:clamp(32px,5vh,64px)}
- .section-eyebrow{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-mono);font-size:0.72rem;letter-spacing:0.18em;text-transform:uppercase;color:var(--amber);font-weight:500;margin-bottom:18px}
- .section-eyebrow::before{content:'';display:inline-block;width:24px;height:1px;background:currentColor}
- .section-sub{color:var(--text-2);font-size:1.04rem;max-width:780px;margin-top:14px;line-height:1.82}
- .grid{display:grid;gap:clamp(14px,1.6vw,22px)}
- .grid-2{grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
- .grid-3{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
- .grid-4{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
- .card{background:linear-gradient(180deg,rgba(255,255,255,0.025) 0%,rgba(255,255,255,0.008) 100%),var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-md);padding:clamp(20px,2.2vw,32px);position:relative;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);transition:transform .35s cubic-bezier(.2,.7,.2,1),border-color .3s ease,box-shadow .35s ease}
- .card:hover{border-color:var(--border-strong);transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,0.28)}
- .metric{overflow:hidden}
- .metric::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--amber),transparent);opacity:0.7}
- .metric-label{font-family:var(--font-mono);font-size:0.7rem;letter-spacing:0.15em;text-transform:uppercase;color:var(--text-3);margin-bottom:10px}
- .metric-value{font-family:var(--font-head);font-weight:800;font-size:clamp(2rem,3.8vw,3.5rem);letter-spacing:-0.03em;line-height:1;color:var(--text-1);margin-bottom:8px}
- .metric-sub{margin-top:12px;font-size:0.82rem;color:var(--text-2);line-height:1.5}
- .tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:4px;font-size:0.72rem;font-weight:500;letter-spacing:0.02em;font-family:var(--font-head);background:rgba(245,166,35,0.1);color:var(--amber);border:1px solid rgba(245,166,35,0.22)}
- .cover-grid-bg{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.025) 1px,transparent 1px);background-size:60px 60px;mask-image:radial-gradient(ellipse 80% 80% at 50% 50%,black 0%,transparent 70%);-webkit-mask-image:radial-gradient(ellipse 80% 80% at 50% 50%,black 0%,transparent 70%)}
- .cover-hero{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;padding:clamp(40px,8vh,100px) clamp(24px,6vw,80px)}
- .chart-card{padding:18px 20px}
- .quote-text{font-size:0.94rem;line-height:1.74;color:var(--text-1)}
- .insight-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}
- .insight-pill{padding:16px 18px;border-radius:12px;background:rgba(255,255,255,0.03);border:1px solid var(--border)}
- .stack-list{display:grid;gap:12px}
- .stack-item{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;border-radius:12px;background:rgba(255,255,255,0.02);border:1px solid var(--border)}
- .stack-dot{width:10px;height:10px;border-radius:50%;margin-top:8px;flex:0 0 auto}
- .table-card{overflow:auto}
- table.clean{width:100%;border-collapse:collapse;font-size:0.9rem}
- table.clean th,table.clean td{padding:12px 10px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
- table.clean th{font-family:var(--font-mono);font-size:0.72rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-3);font-weight:600}
- .nav-rail{position:fixed;left:clamp(16px,2vw,32px);top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;gap:10px}
- .nav-dot{width:28px;height:3px;border-radius:2px;background:rgba(255,255,255,0.14);cursor:pointer;transition:all 0.5s cubic-bezier(.2,.7,.2,1);position:relative}
- .nav-dot.active{background:var(--amber);width:42px;box-shadow:0 0 10px rgba(245,166,35,0.4)}
- .nav-dot::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:rgba(20,20,20,0.92);backdrop-filter:blur(8px);border:1px solid var(--border);padding:5px 10px;border-radius:6px;font-size:0.7rem;color:var(--text-1);white-space:nowrap;opacity:0;pointer-events:none;transition:opacity 0.2s}
- .nav-dot:hover::after,.nav-dot.active::after{opacity:1}
- .progress-line{position:fixed;top:0;left:0;height:2px;background:linear-gradient(90deg,var(--amber),var(--rose));z-index:1000;width:0%;transition:width .3s ease}
- .slide-counter{position:fixed;left:clamp(16px,2vw,28px);bottom:clamp(16px,2vh,28px);z-index:100;display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:rgba(20,20,20,0.85);backdrop-filter:blur(16px);border:1px solid var(--border-strong);font-family:var(--font-mono);font-size:0.74rem;letter-spacing:0.1em;color:var(--text-2)}
- .slide-counter .now{color:var(--amber);font-weight:700}
- .footer{text-align:center;padding:40px 24px;color:var(--text-3);font-size:0.8rem;border-top:1px solid var(--border)}
- @media(max-width:900px){.nav-rail,.slide-counter{display:none}}
- </style>
- </head>
- <body>
- <div class="progress-line" id="progressLine"></div>
- <nav class="nav-rail" id="navRail"></nav>
- <div class="slide-counter"><span class="now" id="slideNow">01</span><span>/</span><span id="slideTotal">10</span></div>
- <main class="deck">
- <section class="report-section" data-slide-num="COVER" data-tone="amber" style="position:relative;">
- <div class="cover-grid-bg"></div>
- <div class="cover-hero">
- <div class="section-eyebrow" style="margin-bottom:24px;">VOC 深度洞察报告</div>
- <h1 class="text-hero" style="margin-bottom:18px;background:linear-gradient(135deg,#fff 30%,rgba(245,166,35,0.92) 100%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;">快手蓝领求职</h1>
- <p class="text-body" style="font-size:1.16rem;max-width:720px;margin-bottom:28px;">
- 基于 <span style="color:var(--amber);font-weight:700;">${fmtNum(totalVideos)}</span> 条视频、
- <span style="color:var(--rose);font-weight:700;">${fmtNum(totalComments)}</span> 条评论、
- <span style="color:var(--blue);font-weight:700;">${fmtNum(totalViews)}</span> 次播放的主题化分析,
- 回答蓝领在快手求职时“最怕什么、最要什么、为什么会把快手和快聘当成高效率入口”。
- </p>
- <div class="insight-strip" style="max-width:980px;margin-bottom:30px;">
- <div class="insight-pill"><div style="font-size:1.9rem;font-weight:800;color:var(--amber);font-family:var(--font-head);">${fmtNum(totalKeywords)}</div><div class="text-xs" style="margin-top:6px;">主题关键词文件</div></div>
- <div class="insight-pill"><div style="font-size:1.9rem;font-weight:800;color:var(--rose);font-family:var(--font-head);">${fmtNum(concernRecords.length)}</div><div class="text-xs" style="margin-top:6px;">风险相关样本</div></div>
- <div class="insight-pill"><div style="font-size:1.9rem;font-weight:800;color:var(--green);font-family:var(--font-head);">${fmtNum(priorityRecords.length)}</div><div class="text-xs" style="margin-top:6px;">需求相关样本</div></div>
- <div class="insight-pill"><div style="font-size:1.9rem;font-weight:800;color:var(--blue);font-family:var(--font-head);">${fmtCompact(highestLike)}</div><div class="text-xs" style="margin-top:6px;">最高点赞单条</div></div>
- </div>
- <div class="text-xs" style="color:var(--text-3);">${new Date().toLocaleDateString('zh-CN')} · 数据目录:docs/KS-Chanel_analysis/raw/KS</div>
- </div>
- </section>
- <section class="report-section" data-slide-num="01" data-tone="amber">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow">Chapter 01</div>
- <h2 class="text-h1">执行摘要</h2>
- <p class="section-sub">先把这份报告真正想回答的业务问题说清楚,再给出可以直接汇报的结论版。</p>
- </div>
- <div class="grid grid-2" style="margin-bottom:24px;">
- <div class="card" style="border-left:3px solid var(--rose);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--rose);">最担心的不是低薪,而是被骗</div>
- <p class="text-body">从风险样本与高赞表达看,蓝领用户最敏感的是虚假招聘、黑中介、押金不退、工资克扣与入职后货不对板。风险焦虑是比价格比较更靠前的决策变量。</p>
- </div>
- <div class="card" style="border-left:3px solid var(--green);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--green);">最看重的是“确定性条件”</div>
- <p class="text-body">工资真实、发薪稳定、包吃包住、当天入职、流程简单,这些词反复出现。它们本质上都是在降低求职成本、迁移成本和试错成本。</p>
- </div>
- <div class="card" style="border-left:3px solid var(--blue);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--blue);">快手承接的是短链路求职决策</div>
- <p class="text-body">用户通过直播和短视频看环境、看宿舍、看工资口径,再通过评论和私信做真假判断。快手承担了入口、解释、信任建立和转化承接的复合角色。</p>
- </div>
- <div class="card" style="border-left:3px solid var(--orange);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--orange);">快聘的战略位势来自离转化更近</div>
- <p class="text-body">如果内部判断“快聘找工作日活几十万简历,战略位势仅次于电商”,那这批 VOC 可以解释原因:需求高频、风险敏感、决策短、转化意图强。</p>
- </div>
- </div>
- <div class="card">
- <div class="section-eyebrow" style="margin-bottom:14px;">Business Questions</div>
- <div class="stack-list">
- ${GOALS.map((goal) => `<div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>${esc(goal)}</div></div>`).join('')}
- </div>
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="02" data-tone="blue">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--blue);">Chapter 02</div>
- <h2 class="text-h1">数据范围与分析方法</h2>
- <p class="section-sub">这一页补足方法论说明,让整份 HTML 不只是结论页,也是一份可解释的数据汇报页。</p>
- </div>
- <div class="grid grid-4" style="margin-bottom:24px;">
- ${metricCard('关键词文件', fmtNum(totalKeywords), '覆盖快聘、直播带岗、工资、被骗、包吃包住等主题', '#F5A623')}
- ${metricCard('视频总量', fmtNum(totalVideos), '每个主题文件包含 top_videos 样本', '#3B82F6')}
- ${metricCard('评论总量', fmtNum(totalComments), '评论是本次用户需求判断的核心依据', '#FF4D8D')}
- ${metricCard('播放总量', fmtCompact(totalViews), '用于判断主题内容在快手端的触达与热度', '#00DC82')}
- </div>
- <div class="grid grid-2">
- <div class="card">
- <div class="section-eyebrow" style="color:var(--blue);margin-bottom:14px;">Analysis Logic</div>
- <div class="stack-list">
- <div class="stack-item"><span class="stack-dot" style="background:var(--blue);"></span><div>以原始 raw/KS/*.json 为准,重新汇总视频文案、评论文本和主题热度。</div></div>
- <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(--rose);"></span><div>通过“词项命中 × 文本热度”识别真正有汇报价值的主题词。</div></div>
- <div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>保留高赞视频文案和评论样本,方便直接放进管理层汇报或研究结论里。</div></div>
- </div>
- </div>
- <div class="card table-card">
- <div class="section-eyebrow" style="color:var(--cyan);margin-bottom:14px;">Data Snapshot</div>
- <table class="clean">
- <thead><tr><th>指标</th><th>数值</th><th>说明</th></tr></thead>
- <tbody>
- <tr><td>总点赞量</td><td>${fmtNum(totalLikes)}</td><td>用于估算主题互动强度</td></tr>
- <tr><td>最高单条热度</td><td>${fmtCompact(highestScore)}</td><td>高热视频与高赞评论共同构成综合热度</td></tr>
- <tr><td>风险样本</td><td>${fmtNum(concernRecords.length)}</td><td>“骗 / 坑 / 中介 / 押金 / 克扣”相关</td></tr>
- <tr><td>需求样本</td><td>${fmtNum(priorityRecords.length)}</td><td>“工资 / 吃住 / 发薪 / 入职”相关</td></tr>
- <tr><td>渠道样本</td><td>${fmtNum(channelRecords.length)}</td><td>“快聘 / 直播带岗 / 蓝领找工作”相关</td></tr>
- </tbody>
- </table>
- </div>
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="03" data-tone="rose">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--rose);">Chapter 03</div>
- <h2 class="text-h1">蓝领求职最担心什么</h2>
- <p class="section-sub">风险不是单点事件,而是从“看到岗位”到“真正入职”全链路的不确定性。这也是快手求职场里最强烈的底层情绪。</p>
- </div>
- <div class="grid grid-3" style="margin-bottom:24px;">
- ${metricCard('风险样本', fmtNum(concernRecords.length), '覆盖招聘骗局、打工陷阱、被坑、黑中介、押金不退等', '#FF4D8D')}
- ${metricCard('首位风险词', concernTerms[0]?.term || '被骗', '按命中条数和热度综合排序', '#EF4444')}
- ${metricCard('高热风险表达', fmtCompact(concernSamples[0]?.score || 0), '高热内容通常直接揭露套路或踩坑经历', '#F97316')}
- </div>
- ${buildBarChart(concernTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#FF4D8D' })), '#FF4D8D')}
- <div class="grid grid-2" style="margin-top:22px;">
- ${concernSamples.map((item) => quoteCard(item, '#FF4D8D')).join('')}
- </div>
- <div class="card" style="margin-top:22px;border-left:3px solid var(--rose);">
- <div class="text-h2" style="margin-bottom:12px;color:var(--rose);">汇报可直接引用</div>
- <p class="text-body">蓝领求职最强焦虑不是“工资不够高”,而是“会不会被骗、会不会被中介抽走、会不会交押金、会不会干完拿不到钱”。风险感知先于薪资吸引,是快手求职内容里最值得重视的底层变量。</p>
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="04" data-tone="green">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--green);">Chapter 04</div>
- <h2 class="text-h1">蓝领求职最看重什么</h2>
- <p class="section-sub">对蓝领来说,最重要的不是抽象品牌,而是“值不值得去、去了会不会亏、多久能上岗”这种极度现实的问题。</p>
- </div>
- <div class="grid grid-4" style="margin-bottom:24px;">
- <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">工资真实</div><p class="text-body">用户会反复对照薪资口径、到手收入和发薪规则。</p></div>
- <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">发薪稳定</div><p class="text-body">“发工资”“日结”“加班费”都在传递更强的安全感。</p></div>
- <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">包吃包住</div><p class="text-body">它直接降低异地求职的初始成本和决策门槛。</p></div>
- <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">当天入职</div><p class="text-body">蓝领决策短,能不能快速落地决定转化效率。</p></div>
- </div>
- ${buildBarChart(priorityTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#00DC82' })), '#00DC82')}
- <div class="grid grid-2" style="margin-top:22px;">
- ${prioritySamples.map((item) => quoteCard(item, '#00DC82')).join('')}
- </div>
- <div class="card" style="margin-top:22px;border-left:3px solid var(--green);">
- <div class="text-h2" style="margin-bottom:12px;color:var(--green);">汇报可直接引用</div>
- <p class="text-body">蓝领最看重的不是单一“高薪”,而是工资真实、发薪稳定、包吃包住、当天入职和流程简单。这些条件共同构成了他们对“确定性工作机会”的判断标准。</p>
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="05" data-tone="purple">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--purple);">Chapter 05</div>
- <h2 class="text-h1">情绪价值强在哪里</h2>
- <p class="section-sub">真正有价值的情绪,不是空泛安慰,而是“让我少踩坑、让我看得懂、让我敢报名”的决策安全感。</p>
- </div>
- <div class="grid grid-3" style="margin-bottom:22px;">
- <div class="card" style="border-left:3px solid var(--rose);"><div class="text-h2" style="margin-bottom:10px;color:var(--rose);">负向情绪来源</div><p class="text-body">被骗、被坑、黑中介、押金不退、工资克扣,会把委屈、愤怒和无力感放大。</p></div>
- <div class="card" style="border-left:3px solid var(--green);"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">正向情绪来源</div><p class="text-body">靠谱、真实、放心、包吃包住、当天入职,本质上都在回应“能不能放心去”。</p></div>
- <div class="card" style="border-left:3px solid var(--amber);"><div class="text-h2" style="margin-bottom:10px;color:var(--amber);">情绪价值本质</div><p class="text-body">是帮用户建立确定性,而不是单纯制造高情绪张力或娱乐氛围。</p></div>
- </div>
- <div class="grid grid-2">
- ${emotionSamples.map((item) => quoteCard(item, item.polarity === '正向' ? '#00DC82' : item.polarity === '负向' ? '#FF4D8D' : '#F5A623')).join('')}
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="06" data-tone="blue">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--blue);">Chapter 06</div>
- <h2 class="text-h1">为什么快手能成为蓝领求职的重要渠道</h2>
- <p class="section-sub">这里不是泛泛说“快手流量大”,而是解释为什么快手适合承接蓝领这种短链路、高时效、强风险判断的决策场景。</p>
- </div>
- <div class="grid grid-3" style="margin-bottom:24px;">
- <div class="card" style="border-top:2px solid var(--blue);"><div class="text-h2" style="margin-bottom:10px;color:var(--blue);">内容可视化</div><p class="text-body">车间、宿舍、食堂、工资口径、现场环境都可以被直接展示,比纯文字招聘更有说服力。</p></div>
- <div class="card" style="border-top:2px solid var(--cyan);"><div class="text-h2" style="margin-bottom:10px;color:var(--cyan);">评论问答密度高</div><p class="text-body">用户会直接追问“是不是中介、包不包住、工资怎么发、什么时候入职”,帮助做真假筛选。</p></div>
- <div class="card" style="border-top:2px solid var(--amber);"><div class="text-h2" style="margin-bottom:10px;color:var(--amber);">转化链路短</div><p class="text-body">从看内容到留电话、私信报名、到场面试,快手天然适配短决策和高转化意图。</p></div>
- </div>
- <div class="grid grid-2">
- <div>
- ${buildBarChart(strategicRows.map((item) => ({ label: item.keyword, value: item.views, valueLabel: `${fmtNum(item.comments)}评 / ${fmtCompact(item.views)}`, color: '#3B82F6' })), '#3B82F6')}
- </div>
- <div class="card">
- <div class="section-eyebrow" style="color:var(--blue);margin-bottom:14px;">Management Take</div>
- <div class="stack-list">
- <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 class="stack-item"><span class="stack-dot" style="background:var(--green);"></span><div>按业务前提“10 个蓝领 9 个都用快手”,快手更像蓝领找工作时的内容主场,而不是边缘补充入口。</div></div>
- <div class="stack-item"><span class="stack-dot" style="background:var(--rose);"></span><div>因此快聘承接的不是普通流量,而是已经带有明确求职意图的高价值流量。</div></div>
- </div>
- </div>
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="07" data-tone="orange">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--orange);">Chapter 07</div>
- <h2 class="text-h1">用户关注的关键字</h2>
- <p class="section-sub">这些词不是泛热词,而是会影响点击、咨询和转化的实际判断词,适合做后续内容、产品和业务汇报的重点词库。</p>
- </div>
- ${buildBarChart(attentionTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#F97316' })), '#F97316')}
- <div class="grid grid-4" style="margin-top:22px;">
- ${attentionTerms.slice(0, 12).map((item) => `<div class="card" style="padding:18px 18px;">
- <div class="tag" style="background:rgba(249,115,22,0.10);color:var(--orange);border-color:rgba(249,115,22,0.25);margin-bottom:10px;">${esc(item.term)}</div>
- <div style="font-family:var(--font-head);font-weight:800;font-size:1.5rem;color:var(--text-1);margin-bottom:6px;">${fmtCompact(item.score)}</div>
- <div class="text-sm">命中 ${fmtNum(item.count)} 条,适合做内容标题词、转化承诺词或研究结论词。</div>
- </div>`).join('')}
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="08" data-tone="amber">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow">Chapter 08</div>
- <h2 class="text-h1">快聘的战略位势如何被需求支撑</h2>
- <p class="section-sub">如果内部已有“快聘找工作日活几十万简历,战略位势仅次于电商”的判断,这里给的是用户需求层的解释框架。</p>
- </div>
- <div class="grid grid-2" style="margin-bottom:24px;">
- <div class="card">
- <div class="section-eyebrow" style="margin-bottom:14px;">Demand Proof</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(--blue);"></span><div>风险敏感:用户会用内容和评论先筛掉风险,再决定要不要报名,因此平台承接能力非常关键。</div></div>
- <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(--amber);"></span><div>转化诉求强:快聘承接的不是泛流量,而是已经准备迈入求职动作的高意图流量。</div></div>
- </div>
- </div>
- <div class="card table-card">
- <div class="section-eyebrow" style="margin-bottom:14px;">Strategic Topics</div>
- <table class="clean">
- <thead><tr><th>主题词</th><th>视频</th><th>评论</th><th>播放量</th></tr></thead>
- <tbody>
- ${strategicRows.map((item) => `<tr><td>${esc(item.keyword)}</td><td>${fmtNum(item.videos)}</td><td>${fmtNum(item.comments)}</td><td>${fmtCompact(item.views)}</td></tr>`).join('')}
- </tbody>
- </table>
- </div>
- </div>
- <div class="card" style="border-left:3px solid var(--amber);">
- <div class="text-h2" style="margin-bottom:12px;">一句话管理层结论</div>
- <p class="text-body">快聘之所以有高战略位势,不只是因为求职流量大,而是因为快手内容场里已经天然沉淀了“高意图、强风险判断、短决策、重确定性”的蓝领求职需求。谁能承接这类需求,谁就更接近简历投递和入职转化的核心层。</p>
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="09" data-tone="green">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--green);">Chapter 09</div>
- <h2 class="text-h1">内容与产品层启示</h2>
- <p class="section-sub">把前面的 VOC 洞察压缩成平台、内容和产品都能用的动作建议。</p>
- </div>
- <div class="grid grid-3">
- <div class="card" style="border-top:2px solid var(--green);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--green);">内容层</div>
- <p class="text-body">多做“工资口径解释、现场环境实拍、包吃包住细节、入职流程拆解”这种解决不确定性的内容,而不是只做岗位播报。</p>
- </div>
- <div class="card" style="border-top:2px solid var(--blue);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--blue);">产品层</div>
- <p class="text-body">强化“企业直招、无押金、工资发放规则、住宿安排、是否当天入职”等结构化字段,帮助用户快速判断。</p>
- </div>
- <div class="card" style="border-top:2px solid var(--rose);">
- <div class="text-h2" style="margin-bottom:10px;color:var(--rose);">信任层</div>
- <p class="text-body">蓝领情绪价值的核心是安全感。能提升信任校验、真实展示和评论问答质量,就能提高留资和转化效率。</p>
- </div>
- </div>
- <div class="grid grid-2" style="margin-top:22px;">
- ${channelSamples.slice(0, 4).map((item) => quoteCard(item, '#3B82F6')).join('')}
- </div>
- </div>
- </section>
- <section class="report-section" data-slide-num="10" data-tone="rose">
- <div class="section-inner">
- <div class="section-head">
- <div class="section-eyebrow" style="color:var(--rose);">Chapter 10</div>
- <h2 class="text-h1">高赞样本附录</h2>
- <p class="section-sub">这一页保留更原始的用户声音,方便在汇报里做案例补充或引用。</p>
- </div>
- <div class="grid grid-2">
- ${topRecords.map((item) => quoteCard(item, item.keyword && CLUSTERS.concern.includes(item.keyword) ? '#FF4D8D' : item.keyword && CLUSTERS.priority.includes(item.keyword) ? '#00DC82' : '#F5A623')).join('')}
- </div>
- </div>
- </section>
- </main>
- <footer class="footer">
- <div>快手渠道蓝领求职洞察报告 v5</div>
- <div style="margin-top:8px;">数据来源:docs/KS-Chanel_analysis/raw/KS · ${fmtNum(totalVideos)} 条视频 · ${fmtNum(totalComments)} 条评论 · ${fmtCompact(totalViews)} 播放</div>
- <div style="margin-top:4px;">生成时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</div>
- </footer>
- <script>
- (function(){
- const sections=[...document.querySelectorAll('section.report-section')];
- const progressLine=document.getElementById('progressLine');
- const navRail=document.getElementById('navRail');
- const slideNow=document.getElementById('slideNow');
- const slideTotal=document.getElementById('slideTotal');
- slideTotal.textContent=String(sections.length).padStart(2,'0');
- const dots=sections.map((sec,idx)=>{
- const dot=document.createElement('div');
- dot.className='nav-dot';
- dot.dataset.tip=sec.querySelector('.text-h1')?.textContent||('0'+(idx+1));
- dot.addEventListener('click',()=>sec.scrollIntoView({behavior:'smooth'}));
- navRail.appendChild(dot);
- return dot;
- });
- function update(idx){
- const pct=((idx+1)/sections.length)*100;
- progressLine.style.width=pct+'%';
- slideNow.textContent=String(idx+1).padStart(2,'0');
- dots.forEach((dot,i)=>dot.classList.toggle('active',i===idx));
- }
- const observer=new IntersectionObserver((entries)=>{
- entries.forEach((entry)=>{
- if(entry.isIntersecting){
- entry.target.classList.add('in-view');
- update(sections.indexOf(entry.target));
- }
- });
- },{threshold:0.45});
- sections.forEach((sec)=>observer.observe(sec));
- })();
- </script>
- </body>
- </html>`;
- ensureDir(OUT_DIR);
- fs.writeFileSync(OUT_FILE, html, 'utf8');
- console.log(`Generated: ${OUT_FILE}`);
|