ks-render-v4.js 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const BASE_DIR = path.resolve(__dirname, '..');
  5. const RAW_DIR = path.join(BASE_DIR, 'raw', 'KS');
  6. const OUT_DIR = path.join(BASE_DIR, 'reports');
  7. const OUT_FILE = path.join(OUT_DIR, 'ks-voc-insight-report-v5.html');
  8. const GOALS = [
  9. '蓝领劳工在求职过程中,最担心的、最看重的是什么',
  10. '快手为什么能成为蓝领求职的重要渠道',
  11. '用户真正需要的情绪价值是什么',
  12. '最值得关注的关键词有哪些',
  13. '快聘为什么有接近招聘核心转化层的战略位势',
  14. ];
  15. const CLUSTERS = {
  16. concern: ['招聘骗局', '求职被骗', '打工陷阱', '黑中介', '黑厂', '被坑', '入职被坑', '押金不退', '克扣工资'],
  17. priority: ['工资靠谱', '真实薪资', '工资日结', '发工资', '加班费', '包吃包住', '当天入职'],
  18. channel: ['快聘', '直播带岗', '蓝领找工作', '当天入职', '包吃包住', '工资靠谱'],
  19. fastTrack: ['当天入职', '包吃包住', '快聘', '直播带岗'],
  20. };
  21. const TERMS = {
  22. concern: ['骗', '坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '虚假', '拖欠', '维权', '身份证'],
  23. priority: ['工资', '薪资', '发工资', '日结', '加班费', '包吃包住', '住宿', '吃住', '当天入职', '靠谱', '真实'],
  24. emotion: ['放心', '靠谱', '真实', '踏实', '终于', '尊重', '委屈', '难受', '压抑', '被骗', '被坑'],
  25. channel: ['快聘', '直播带岗', '找工作', '招工', '蓝领', '工厂', '岗位', '报名', '入职', '安排住宿'],
  26. attention: ['工资', '薪资', '发工资', '日结', '加班费', '包吃包住', '住宿', '当天入职', '靠谱', '真实', '快聘', '直播带岗', '找工作', '蓝领', '工厂', '岗位', '被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣工资', '套路', '陷阱'],
  27. };
  28. const POSITIVE_TERMS = ['放心', '靠谱', '真实', '踏实', '终于', '尊重', '包吃包住', '当天入职', '日结', '发工资'];
  29. const NEGATIVE_TERMS = ['被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '拖欠', '维权', '委屈', '难受', '压抑'];
  30. function esc(s) {
  31. return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  32. }
  33. function fmtNum(n) {
  34. return Number(n || 0).toLocaleString('zh-CN');
  35. }
  36. function fmtCompact(n) {
  37. const value = Number(n || 0);
  38. if (value >= 100000000) return `${(value / 100000000).toFixed(1).replace(/\.0$/, '')}亿`;
  39. if (value >= 10000) return `${(value / 10000).toFixed(1).replace(/\.0$/, '')}w`;
  40. if (value >= 1000) return `${(value / 1000).toFixed(1).replace(/\.0$/, '')}k`;
  41. return String(value);
  42. }
  43. function truncate(text, max = 140) {
  44. const clean = String(text || '').replace(/\s+/g, ' ').trim();
  45. return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
  46. }
  47. function ensureDir(dir) {
  48. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  49. }
  50. function readRawFiles() {
  51. return fs.readdirSync(RAW_DIR)
  52. .filter((name) => name.endsWith('.json'))
  53. .map((name) => JSON.parse(fs.readFileSync(path.join(RAW_DIR, name), 'utf8')));
  54. }
  55. function uniqueBy(items, getKey) {
  56. const seen = new Set();
  57. const out = [];
  58. for (const item of items) {
  59. const key = getKey(item);
  60. if (seen.has(key)) continue;
  61. seen.add(key);
  62. out.push(item);
  63. }
  64. return out;
  65. }
  66. function includesAny(text, terms) {
  67. return terms.some((term) => text.includes(term));
  68. }
  69. function collectDataset(rawFiles) {
  70. const records = [];
  71. const keywordStats = [];
  72. for (const raw of rawFiles) {
  73. const keyword = raw.keyword || '未知';
  74. const videos = raw.top_videos || [];
  75. const commentsMap = raw.comments || {};
  76. let comments = 0;
  77. let likes = 0;
  78. let views = 0;
  79. for (const video of videos) {
  80. const text = String(video.caption || '').replace(/\s+/g, ' ').trim();
  81. likes += Number(video.like_count || 0);
  82. views += Number(video.view_count || 0);
  83. if (!text) continue;
  84. records.push({
  85. kind: 'video',
  86. keyword,
  87. text,
  88. likes: Number(video.like_count || 0),
  89. comments: Number(video.comment_count || 0),
  90. views: Number(video.view_count || 0),
  91. score: Number(video.like_count || 0) + Number(video.comment_count || 0) * 3 + Math.floor(Number(video.view_count || 0) / 1000),
  92. sourceId: String(video.id || ''),
  93. });
  94. }
  95. for (const [videoId, list] of Object.entries(commentsMap)) {
  96. for (const comment of list || []) {
  97. const text = String(comment.content || '').replace(/\s+/g, ' ').trim();
  98. comments += 1;
  99. if (!text) continue;
  100. records.push({
  101. kind: 'comment',
  102. keyword,
  103. text,
  104. likes: Number(comment.like_count || 0),
  105. comments: 0,
  106. views: 0,
  107. score: Number(comment.like_count || 0) * 2 + (text.length >= 20 ? 3 : 0),
  108. sourceId: `${videoId}:${comment.id || ''}`,
  109. });
  110. }
  111. }
  112. keywordStats.push({
  113. keyword,
  114. videos: videos.length,
  115. comments,
  116. likes,
  117. views,
  118. });
  119. }
  120. return { records, keywordStats };
  121. }
  122. function pickCluster(records, keywords, terms) {
  123. const keywordSet = new Set(keywords);
  124. return uniqueBy(
  125. records.filter((item) => keywordSet.has(item.keyword) || includesAny(item.text, terms)),
  126. (item) => `${item.kind}:${item.keyword}:${item.sourceId}`
  127. );
  128. }
  129. function summarizeTerms(records, terms, limit = 12) {
  130. return terms.map((term) => {
  131. let count = 0;
  132. let score = 0;
  133. for (const item of records) {
  134. if (!item.text.includes(term)) continue;
  135. count += 1;
  136. score += item.score;
  137. }
  138. return { term, count, score };
  139. }).filter((item) => item.count > 0)
  140. .sort((a, b) => b.score - a.score || b.count - a.count)
  141. .slice(0, limit);
  142. }
  143. function topSamples(records, limit = 8) {
  144. return uniqueBy(
  145. [...records].sort((a, b) => b.score - a.score),
  146. (item) => item.text
  147. ).slice(0, limit);
  148. }
  149. function buildEmotionSamples(records, limit = 12) {
  150. const enriched = records
  151. .filter((item) => includesAny(item.text, [...POSITIVE_TERMS, ...NEGATIVE_TERMS]))
  152. .map((item) => {
  153. const positive = POSITIVE_TERMS.filter((term) => item.text.includes(term)).length;
  154. const negative = NEGATIVE_TERMS.filter((term) => item.text.includes(term)).length;
  155. let polarity = '中性';
  156. if (positive > negative) polarity = '正向';
  157. if (negative > positive) polarity = '负向';
  158. if (positive > 0 && negative > 0) polarity = '冲突';
  159. return { ...item, polarity, emotionScore: item.score + (positive + negative) * 10 };
  160. })
  161. .sort((a, b) => b.emotionScore - a.emotionScore);
  162. return uniqueBy(enriched, (item) => item.text).slice(0, limit);
  163. }
  164. function buildStackRows(keywordStats, keywords) {
  165. return keywordStats
  166. .filter((item) => keywords.includes(item.keyword))
  167. .sort((a, b) => b.views - a.views || b.comments - a.comments);
  168. }
  169. function buildBarChart(rows, accent) {
  170. const max = Math.max(...rows.map((row) => row.value), 1);
  171. const barH = 34;
  172. const gap = 12;
  173. const padL = 150;
  174. const padR = 95;
  175. const padV = 16;
  176. const width = 920;
  177. const totalH = rows.length * (barH + gap) + padV * 2;
  178. return `<div class="card chart-card"><svg viewBox="0 0 ${width} ${totalH}" style="width:100%;height:auto;" preserveAspectRatio="xMidYMid meet">
  179. ${rows.map((row, index) => {
  180. const y = padV + index * (barH + gap);
  181. const w = Math.max((row.value / max) * (width - padL - padR), 6);
  182. return `<text x="${padL - 10}" y="${y + barH / 2 + 5}" text-anchor="end" font-size="13" fill="var(--text-2)">${esc(row.label)}</text>
  183. <rect x="${padL}" y="${y}" width="${w}" height="${barH}" rx="5" fill="${row.color || accent}" opacity="0.88"></rect>
  184. <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>`;
  185. }).join('\n')}
  186. </svg></div>`;
  187. }
  188. function quoteCard(item, color) {
  189. return `<div class="card quote-card">
  190. <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
  191. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
  192. <span class="tag" style="background:${color}18;color:${color};border-color:${color}44;">${esc(item.keyword)}</span>
  193. <span class="text-xs">${item.kind === 'video' ? '视频文案' : '评论'}</span>
  194. </div>
  195. <div class="text-xs">赞 ${fmtCompact(item.likes)} · 热度 ${fmtCompact(item.score)}</div>
  196. </div>
  197. <div class="quote-text">${esc(truncate(item.text, 160))}</div>
  198. </div>`;
  199. }
  200. function metricCard(label, value, sub, color) {
  201. return `<div class="card metric">
  202. <div class="metric-label">${esc(label)}</div>
  203. <div class="metric-value" style="color:${color};">${esc(value)}</div>
  204. <div class="metric-sub">${esc(sub)}</div>
  205. </div>`;
  206. }
  207. const rawFiles = readRawFiles();
  208. const dataset = collectDataset(rawFiles);
  209. const records = dataset.records;
  210. const keywordStats = dataset.keywordStats;
  211. const totalKeywords = rawFiles.length;
  212. const totalVideos = keywordStats.reduce((sum, item) => sum + item.videos, 0);
  213. const totalComments = keywordStats.reduce((sum, item) => sum + item.comments, 0);
  214. const totalViews = keywordStats.reduce((sum, item) => sum + item.views, 0);
  215. const totalLikes = keywordStats.reduce((sum, item) => sum + item.likes, 0);
  216. const concernRecords = pickCluster(records, CLUSTERS.concern, TERMS.concern);
  217. const priorityRecords = pickCluster(records, CLUSTERS.priority, TERMS.priority);
  218. const channelRecords = pickCluster(records, CLUSTERS.channel, TERMS.channel);
  219. const fastTrackRecords = pickCluster(records, CLUSTERS.fastTrack, TERMS.channel);
  220. const concernTerms = summarizeTerms(concernRecords, TERMS.concern, 12);
  221. const priorityTerms = summarizeTerms(priorityRecords, TERMS.priority, 12);
  222. const attentionTerms = summarizeTerms(records, TERMS.attention, 18);
  223. const concernSamples = topSamples(concernRecords, 8);
  224. const prioritySamples = topSamples(priorityRecords, 8);
  225. const channelSamples = topSamples(channelRecords, 8);
  226. const emotionSamples = buildEmotionSamples(records, 12);
  227. const topRecords = topSamples(records, 12);
  228. const strategicRows = buildStackRows(keywordStats, CLUSTERS.channel).slice(0, 8);
  229. const topKeywordRows = [...keywordStats].sort((a, b) => b.comments - a.comments || b.views - a.views).slice(0, 10);
  230. const highestLike = records.reduce((max, item) => Math.max(max, item.likes || 0), 0);
  231. const highestScore = records.reduce((max, item) => Math.max(max, item.score || 0), 0);
  232. const html = `<!DOCTYPE html>
  233. <html lang="zh-CN">
  234. <head>
  235. <meta charset="UTF-8">
  236. <meta name="viewport" content="width=device-width,initial-scale=1.0">
  237. <title>快手渠道蓝领求职洞察报告 v5</title>
  238. <style>
  239. :root{
  240. --bg-0:#0A0A0A;--bg-1:#111111;--bg-2:#1A1A1A;--bg-3:#252525;
  241. --border:rgba(255,255,255,0.08);--border-strong:rgba(255,255,255,0.16);
  242. --text-1:#F5F5F5;--text-2:#A8A8A8;--text-3:#6B6B6B;
  243. --amber:#F5A623;--green:#00DC82;--rose:#FF4D8D;--blue:#3B82F6;--red:#EF4444;--purple:#8B5CF6;--orange:#F97316;--cyan:#22C7D6;
  244. --font-head:'Inter','Noto Sans SC','PingFang SC','Microsoft YaHei',sans-serif;
  245. --font-body:'Noto Sans SC','Inter','PingFang SC','Microsoft YaHei',sans-serif;
  246. --font-mono:'JetBrains Mono','SF Mono','Fira Code',monospace;
  247. --max-w:1280px;--radius-sm:8px;--radius-md:12px;--radius-lg:20px;
  248. }
  249. *{margin:0;padding:0;box-sizing:border-box}
  250. html{scroll-behavior:smooth;font-size:16px;scroll-snap-type:y proximity}
  251. 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}
  252. body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;background:
  253. radial-gradient(ellipse 920px 700px at 20% 22%,rgba(245,166,35,0.10),transparent 60%),
  254. radial-gradient(ellipse 860px 620px at 82% 78%,rgba(59,130,246,0.08),transparent 60%),
  255. radial-gradient(ellipse 640px 480px at 50% 50%,rgba(255,77,141,0.05),transparent 62%);
  256. transition:background 1.4s cubic-bezier(.2,.7,.2,1)}
  257. body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;background-image:
  258. linear-gradient(rgba(255,255,255,0.015) 1px,transparent 1px),
  259. linear-gradient(90deg,rgba(255,255,255,0.015) 1px,transparent 1px);
  260. background-size:76px 76px;
  261. mask-image:radial-gradient(ellipse 90% 95% at 50% 50%,black 0%,transparent 84%);
  262. -webkit-mask-image:radial-gradient(ellipse 90% 95% at 50% 50%,black 0%,transparent 84%)}
  263. main.deck{position:relative;z-index:1}
  264. ::selection{background:var(--amber);color:#000}
  265. .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)}
  266. .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)}
  267. .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)}
  268. .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)}
  269. .text-body{font-size:clamp(0.92rem,1.1vw,1rem);line-height:1.78;color:var(--text-2)}
  270. .text-sm{font-size:0.85rem;color:var(--text-2)}
  271. .text-xs{font-size:0.72rem;color:var(--text-3);letter-spacing:0.02em}
  272. 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}
  273. 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}
  274. section.report-section::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:-1;background:
  275. radial-gradient(ellipse 70% 60% at 85% 15%,var(--slide-glow-1,rgba(245,166,35,0.06)),transparent 55%),
  276. radial-gradient(ellipse 60% 50% at 15% 85%,var(--slide-glow-2,rgba(139,92,246,0.04)),transparent 55%);
  277. opacity:0;transition:opacity 1s cubic-bezier(.2,.7,.2,1)}
  278. section.report-section.in-view::after{opacity:1}
  279. section[data-tone="amber"]{--slide-glow-1:rgba(245,166,35,0.10);--slide-glow-2:rgba(245,166,35,0.04)}
  280. section[data-tone="rose"]{--slide-glow-1:rgba(255,77,141,0.09);--slide-glow-2:rgba(255,77,141,0.04)}
  281. section[data-tone="green"]{--slide-glow-1:rgba(0,220,130,0.08);--slide-glow-2:rgba(0,220,130,0.03)}
  282. section[data-tone="purple"]{--slide-glow-1:rgba(139,92,246,0.10);--slide-glow-2:rgba(139,92,246,0.04)}
  283. section[data-tone="blue"]{--slide-glow-1:rgba(59,130,246,0.08);--slide-glow-2:rgba(59,130,246,0.03)}
  284. section[data-tone="orange"]{--slide-glow-1:rgba(249,115,22,0.10);--slide-glow-2:rgba(249,115,22,0.04)}
  285. .section-inner{width:100%;max-width:var(--max-w);margin:0 auto;position:relative}
  286. .section-head{margin-bottom:clamp(32px,5vh,64px)}
  287. .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}
  288. .section-eyebrow::before{content:'';display:inline-block;width:24px;height:1px;background:currentColor}
  289. .section-sub{color:var(--text-2);font-size:1.04rem;max-width:780px;margin-top:14px;line-height:1.82}
  290. .grid{display:grid;gap:clamp(14px,1.6vw,22px)}
  291. .grid-2{grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
  292. .grid-3{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
  293. .grid-4{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
  294. .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}
  295. .card:hover{border-color:var(--border-strong);transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,0.28)}
  296. .metric{overflow:hidden}
  297. .metric::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--amber),transparent);opacity:0.7}
  298. .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}
  299. .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}
  300. .metric-sub{margin-top:12px;font-size:0.82rem;color:var(--text-2);line-height:1.5}
  301. .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)}
  302. .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%)}
  303. .cover-hero{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;padding:clamp(40px,8vh,100px) clamp(24px,6vw,80px)}
  304. .chart-card{padding:18px 20px}
  305. .quote-text{font-size:0.94rem;line-height:1.74;color:var(--text-1)}
  306. .insight-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}
  307. .insight-pill{padding:16px 18px;border-radius:12px;background:rgba(255,255,255,0.03);border:1px solid var(--border)}
  308. .stack-list{display:grid;gap:12px}
  309. .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)}
  310. .stack-dot{width:10px;height:10px;border-radius:50%;margin-top:8px;flex:0 0 auto}
  311. .table-card{overflow:auto}
  312. table.clean{width:100%;border-collapse:collapse;font-size:0.9rem}
  313. table.clean th,table.clean td{padding:12px 10px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
  314. 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}
  315. .nav-rail{position:fixed;left:clamp(16px,2vw,32px);top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;gap:10px}
  316. .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}
  317. .nav-dot.active{background:var(--amber);width:42px;box-shadow:0 0 10px rgba(245,166,35,0.4)}
  318. .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}
  319. .nav-dot:hover::after,.nav-dot.active::after{opacity:1}
  320. .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}
  321. .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)}
  322. .slide-counter .now{color:var(--amber);font-weight:700}
  323. .footer{text-align:center;padding:40px 24px;color:var(--text-3);font-size:0.8rem;border-top:1px solid var(--border)}
  324. @media(max-width:900px){.nav-rail,.slide-counter{display:none}}
  325. </style>
  326. </head>
  327. <body>
  328. <div class="progress-line" id="progressLine"></div>
  329. <nav class="nav-rail" id="navRail"></nav>
  330. <div class="slide-counter"><span class="now" id="slideNow">01</span><span>/</span><span id="slideTotal">10</span></div>
  331. <main class="deck">
  332. <section class="report-section" data-slide-num="COVER" data-tone="amber" style="position:relative;">
  333. <div class="cover-grid-bg"></div>
  334. <div class="cover-hero">
  335. <div class="section-eyebrow" style="margin-bottom:24px;">VOC 深度洞察报告</div>
  336. <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>
  337. <p class="text-body" style="font-size:1.16rem;max-width:720px;margin-bottom:28px;">
  338. 基于 <span style="color:var(--amber);font-weight:700;">${fmtNum(totalVideos)}</span> 条视频、
  339. <span style="color:var(--rose);font-weight:700;">${fmtNum(totalComments)}</span> 条评论、
  340. <span style="color:var(--blue);font-weight:700;">${fmtNum(totalViews)}</span> 次播放的主题化分析,
  341. 回答蓝领在快手求职时“最怕什么、最要什么、为什么会把快手和快聘当成高效率入口”。
  342. </p>
  343. <div class="insight-strip" style="max-width:980px;margin-bottom:30px;">
  344. <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>
  345. <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>
  346. <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>
  347. <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>
  348. </div>
  349. <div class="text-xs" style="color:var(--text-3);">${new Date().toLocaleDateString('zh-CN')} · 数据目录:docs/KS-Chanel_analysis/raw/KS</div>
  350. </div>
  351. </section>
  352. <section class="report-section" data-slide-num="01" data-tone="amber">
  353. <div class="section-inner">
  354. <div class="section-head">
  355. <div class="section-eyebrow">Chapter 01</div>
  356. <h2 class="text-h1">执行摘要</h2>
  357. <p class="section-sub">先把这份报告真正想回答的业务问题说清楚,再给出可以直接汇报的结论版。</p>
  358. </div>
  359. <div class="grid grid-2" style="margin-bottom:24px;">
  360. <div class="card" style="border-left:3px solid var(--rose);">
  361. <div class="text-h2" style="margin-bottom:10px;color:var(--rose);">最担心的不是低薪,而是被骗</div>
  362. <p class="text-body">从风险样本与高赞表达看,蓝领用户最敏感的是虚假招聘、黑中介、押金不退、工资克扣与入职后货不对板。风险焦虑是比价格比较更靠前的决策变量。</p>
  363. </div>
  364. <div class="card" style="border-left:3px solid var(--green);">
  365. <div class="text-h2" style="margin-bottom:10px;color:var(--green);">最看重的是“确定性条件”</div>
  366. <p class="text-body">工资真实、发薪稳定、包吃包住、当天入职、流程简单,这些词反复出现。它们本质上都是在降低求职成本、迁移成本和试错成本。</p>
  367. </div>
  368. <div class="card" style="border-left:3px solid var(--blue);">
  369. <div class="text-h2" style="margin-bottom:10px;color:var(--blue);">快手承接的是短链路求职决策</div>
  370. <p class="text-body">用户通过直播和短视频看环境、看宿舍、看工资口径,再通过评论和私信做真假判断。快手承担了入口、解释、信任建立和转化承接的复合角色。</p>
  371. </div>
  372. <div class="card" style="border-left:3px solid var(--orange);">
  373. <div class="text-h2" style="margin-bottom:10px;color:var(--orange);">快聘的战略位势来自离转化更近</div>
  374. <p class="text-body">如果内部判断“快聘找工作日活几十万简历,战略位势仅次于电商”,那这批 VOC 可以解释原因:需求高频、风险敏感、决策短、转化意图强。</p>
  375. </div>
  376. </div>
  377. <div class="card">
  378. <div class="section-eyebrow" style="margin-bottom:14px;">Business Questions</div>
  379. <div class="stack-list">
  380. ${GOALS.map((goal) => `<div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>${esc(goal)}</div></div>`).join('')}
  381. </div>
  382. </div>
  383. </div>
  384. </section>
  385. <section class="report-section" data-slide-num="02" data-tone="blue">
  386. <div class="section-inner">
  387. <div class="section-head">
  388. <div class="section-eyebrow" style="color:var(--blue);">Chapter 02</div>
  389. <h2 class="text-h1">数据范围与分析方法</h2>
  390. <p class="section-sub">这一页补足方法论说明,让整份 HTML 不只是结论页,也是一份可解释的数据汇报页。</p>
  391. </div>
  392. <div class="grid grid-4" style="margin-bottom:24px;">
  393. ${metricCard('关键词文件', fmtNum(totalKeywords), '覆盖快聘、直播带岗、工资、被骗、包吃包住等主题', '#F5A623')}
  394. ${metricCard('视频总量', fmtNum(totalVideos), '每个主题文件包含 top_videos 样本', '#3B82F6')}
  395. ${metricCard('评论总量', fmtNum(totalComments), '评论是本次用户需求判断的核心依据', '#FF4D8D')}
  396. ${metricCard('播放总量', fmtCompact(totalViews), '用于判断主题内容在快手端的触达与热度', '#00DC82')}
  397. </div>
  398. <div class="grid grid-2">
  399. <div class="card">
  400. <div class="section-eyebrow" style="color:var(--blue);margin-bottom:14px;">Analysis Logic</div>
  401. <div class="stack-list">
  402. <div class="stack-item"><span class="stack-dot" style="background:var(--blue);"></span><div>以原始 raw/KS/*.json 为准,重新汇总视频文案、评论文本和主题热度。</div></div>
  403. <div class="stack-item"><span class="stack-dot" style="background:var(--green);"></span><div>按“风险 / 需求 / 渠道 / 情绪 / 战略位势”做语义聚类,而不是只看单个关键词文件。</div></div>
  404. <div class="stack-item"><span class="stack-dot" style="background:var(--rose);"></span><div>通过“词项命中 × 文本热度”识别真正有汇报价值的主题词。</div></div>
  405. <div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>保留高赞视频文案和评论样本,方便直接放进管理层汇报或研究结论里。</div></div>
  406. </div>
  407. </div>
  408. <div class="card table-card">
  409. <div class="section-eyebrow" style="color:var(--cyan);margin-bottom:14px;">Data Snapshot</div>
  410. <table class="clean">
  411. <thead><tr><th>指标</th><th>数值</th><th>说明</th></tr></thead>
  412. <tbody>
  413. <tr><td>总点赞量</td><td>${fmtNum(totalLikes)}</td><td>用于估算主题互动强度</td></tr>
  414. <tr><td>最高单条热度</td><td>${fmtCompact(highestScore)}</td><td>高热视频与高赞评论共同构成综合热度</td></tr>
  415. <tr><td>风险样本</td><td>${fmtNum(concernRecords.length)}</td><td>“骗 / 坑 / 中介 / 押金 / 克扣”相关</td></tr>
  416. <tr><td>需求样本</td><td>${fmtNum(priorityRecords.length)}</td><td>“工资 / 吃住 / 发薪 / 入职”相关</td></tr>
  417. <tr><td>渠道样本</td><td>${fmtNum(channelRecords.length)}</td><td>“快聘 / 直播带岗 / 蓝领找工作”相关</td></tr>
  418. </tbody>
  419. </table>
  420. </div>
  421. </div>
  422. </div>
  423. </section>
  424. <section class="report-section" data-slide-num="03" data-tone="rose">
  425. <div class="section-inner">
  426. <div class="section-head">
  427. <div class="section-eyebrow" style="color:var(--rose);">Chapter 03</div>
  428. <h2 class="text-h1">蓝领求职最担心什么</h2>
  429. <p class="section-sub">风险不是单点事件,而是从“看到岗位”到“真正入职”全链路的不确定性。这也是快手求职场里最强烈的底层情绪。</p>
  430. </div>
  431. <div class="grid grid-3" style="margin-bottom:24px;">
  432. ${metricCard('风险样本', fmtNum(concernRecords.length), '覆盖招聘骗局、打工陷阱、被坑、黑中介、押金不退等', '#FF4D8D')}
  433. ${metricCard('首位风险词', concernTerms[0]?.term || '被骗', '按命中条数和热度综合排序', '#EF4444')}
  434. ${metricCard('高热风险表达', fmtCompact(concernSamples[0]?.score || 0), '高热内容通常直接揭露套路或踩坑经历', '#F97316')}
  435. </div>
  436. ${buildBarChart(concernTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#FF4D8D' })), '#FF4D8D')}
  437. <div class="grid grid-2" style="margin-top:22px;">
  438. ${concernSamples.map((item) => quoteCard(item, '#FF4D8D')).join('')}
  439. </div>
  440. <div class="card" style="margin-top:22px;border-left:3px solid var(--rose);">
  441. <div class="text-h2" style="margin-bottom:12px;color:var(--rose);">汇报可直接引用</div>
  442. <p class="text-body">蓝领求职最强焦虑不是“工资不够高”,而是“会不会被骗、会不会被中介抽走、会不会交押金、会不会干完拿不到钱”。风险感知先于薪资吸引,是快手求职内容里最值得重视的底层变量。</p>
  443. </div>
  444. </div>
  445. </section>
  446. <section class="report-section" data-slide-num="04" data-tone="green">
  447. <div class="section-inner">
  448. <div class="section-head">
  449. <div class="section-eyebrow" style="color:var(--green);">Chapter 04</div>
  450. <h2 class="text-h1">蓝领求职最看重什么</h2>
  451. <p class="section-sub">对蓝领来说,最重要的不是抽象品牌,而是“值不值得去、去了会不会亏、多久能上岗”这种极度现实的问题。</p>
  452. </div>
  453. <div class="grid grid-4" style="margin-bottom:24px;">
  454. <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">工资真实</div><p class="text-body">用户会反复对照薪资口径、到手收入和发薪规则。</p></div>
  455. <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">发薪稳定</div><p class="text-body">“发工资”“日结”“加班费”都在传递更强的安全感。</p></div>
  456. <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">包吃包住</div><p class="text-body">它直接降低异地求职的初始成本和决策门槛。</p></div>
  457. <div class="card"><div class="text-h2" style="margin-bottom:10px;color:var(--green);">当天入职</div><p class="text-body">蓝领决策短,能不能快速落地决定转化效率。</p></div>
  458. </div>
  459. ${buildBarChart(priorityTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#00DC82' })), '#00DC82')}
  460. <div class="grid grid-2" style="margin-top:22px;">
  461. ${prioritySamples.map((item) => quoteCard(item, '#00DC82')).join('')}
  462. </div>
  463. <div class="card" style="margin-top:22px;border-left:3px solid var(--green);">
  464. <div class="text-h2" style="margin-bottom:12px;color:var(--green);">汇报可直接引用</div>
  465. <p class="text-body">蓝领最看重的不是单一“高薪”,而是工资真实、发薪稳定、包吃包住、当天入职和流程简单。这些条件共同构成了他们对“确定性工作机会”的判断标准。</p>
  466. </div>
  467. </div>
  468. </section>
  469. <section class="report-section" data-slide-num="05" data-tone="purple">
  470. <div class="section-inner">
  471. <div class="section-head">
  472. <div class="section-eyebrow" style="color:var(--purple);">Chapter 05</div>
  473. <h2 class="text-h1">情绪价值强在哪里</h2>
  474. <p class="section-sub">真正有价值的情绪,不是空泛安慰,而是“让我少踩坑、让我看得懂、让我敢报名”的决策安全感。</p>
  475. </div>
  476. <div class="grid grid-3" style="margin-bottom:22px;">
  477. <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>
  478. <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>
  479. <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>
  480. </div>
  481. <div class="grid grid-2">
  482. ${emotionSamples.map((item) => quoteCard(item, item.polarity === '正向' ? '#00DC82' : item.polarity === '负向' ? '#FF4D8D' : '#F5A623')).join('')}
  483. </div>
  484. </div>
  485. </section>
  486. <section class="report-section" data-slide-num="06" data-tone="blue">
  487. <div class="section-inner">
  488. <div class="section-head">
  489. <div class="section-eyebrow" style="color:var(--blue);">Chapter 06</div>
  490. <h2 class="text-h1">为什么快手能成为蓝领求职的重要渠道</h2>
  491. <p class="section-sub">这里不是泛泛说“快手流量大”,而是解释为什么快手适合承接蓝领这种短链路、高时效、强风险判断的决策场景。</p>
  492. </div>
  493. <div class="grid grid-3" style="margin-bottom:24px;">
  494. <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>
  495. <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>
  496. <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>
  497. </div>
  498. <div class="grid grid-2">
  499. <div>
  500. ${buildBarChart(strategicRows.map((item) => ({ label: item.keyword, value: item.views, valueLabel: `${fmtNum(item.comments)}评 / ${fmtCompact(item.views)}`, color: '#3B82F6' })), '#3B82F6')}
  501. </div>
  502. <div class="card">
  503. <div class="section-eyebrow" style="color:var(--blue);margin-bottom:14px;">Management Take</div>
  504. <div class="stack-list">
  505. <div class="stack-item"><span class="stack-dot" style="background:var(--blue);"></span><div>快手不是“宣传渠道”那么简单,而是蓝领求职链路里的信息解释器和信任放大器。</div></div>
  506. <div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>“直播带岗”“包吃包住”“当天入职”这种内容组合,本质上是在降低用户决策成本。</div></div>
  507. <div class="stack-item"><span class="stack-dot" style="background:var(--green);"></span><div>按业务前提“10 个蓝领 9 个都用快手”,快手更像蓝领找工作时的内容主场,而不是边缘补充入口。</div></div>
  508. <div class="stack-item"><span class="stack-dot" style="background:var(--rose);"></span><div>因此快聘承接的不是普通流量,而是已经带有明确求职意图的高价值流量。</div></div>
  509. </div>
  510. </div>
  511. </div>
  512. </div>
  513. </section>
  514. <section class="report-section" data-slide-num="07" data-tone="orange">
  515. <div class="section-inner">
  516. <div class="section-head">
  517. <div class="section-eyebrow" style="color:var(--orange);">Chapter 07</div>
  518. <h2 class="text-h1">用户关注的关键字</h2>
  519. <p class="section-sub">这些词不是泛热词,而是会影响点击、咨询和转化的实际判断词,适合做后续内容、产品和业务汇报的重点词库。</p>
  520. </div>
  521. ${buildBarChart(attentionTerms.map((item) => ({ label: item.term, value: item.score, valueLabel: `${item.count} / ${fmtCompact(item.score)}`, color: '#F97316' })), '#F97316')}
  522. <div class="grid grid-4" style="margin-top:22px;">
  523. ${attentionTerms.slice(0, 12).map((item) => `<div class="card" style="padding:18px 18px;">
  524. <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>
  525. <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>
  526. <div class="text-sm">命中 ${fmtNum(item.count)} 条,适合做内容标题词、转化承诺词或研究结论词。</div>
  527. </div>`).join('')}
  528. </div>
  529. </div>
  530. </section>
  531. <section class="report-section" data-slide-num="08" data-tone="amber">
  532. <div class="section-inner">
  533. <div class="section-head">
  534. <div class="section-eyebrow">Chapter 08</div>
  535. <h2 class="text-h1">快聘的战略位势如何被需求支撑</h2>
  536. <p class="section-sub">如果内部已有“快聘找工作日活几十万简历,战略位势仅次于电商”的判断,这里给的是用户需求层的解释框架。</p>
  537. </div>
  538. <div class="grid grid-2" style="margin-bottom:24px;">
  539. <div class="card">
  540. <div class="section-eyebrow" style="margin-bottom:14px;">Demand Proof</div>
  541. <div class="stack-list">
  542. <div class="stack-item"><span class="stack-dot" style="background:var(--rose);"></span><div>需求高频:工资、吃住、发薪、入职效率反复出现,说明找工作不是低频浏览,而是持续高频需求。</div></div>
  543. <div class="stack-item"><span class="stack-dot" style="background:var(--blue);"></span><div>风险敏感:用户会用内容和评论先筛掉风险,再决定要不要报名,因此平台承接能力非常关键。</div></div>
  544. <div class="stack-item"><span class="stack-dot" style="background:var(--green);"></span><div>决策短链:从看内容到报名之间链路短,越贴近承接层,战略价值越高。</div></div>
  545. <div class="stack-item"><span class="stack-dot" style="background:var(--amber);"></span><div>转化诉求强:快聘承接的不是泛流量,而是已经准备迈入求职动作的高意图流量。</div></div>
  546. </div>
  547. </div>
  548. <div class="card table-card">
  549. <div class="section-eyebrow" style="margin-bottom:14px;">Strategic Topics</div>
  550. <table class="clean">
  551. <thead><tr><th>主题词</th><th>视频</th><th>评论</th><th>播放量</th></tr></thead>
  552. <tbody>
  553. ${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('')}
  554. </tbody>
  555. </table>
  556. </div>
  557. </div>
  558. <div class="card" style="border-left:3px solid var(--amber);">
  559. <div class="text-h2" style="margin-bottom:12px;">一句话管理层结论</div>
  560. <p class="text-body">快聘之所以有高战略位势,不只是因为求职流量大,而是因为快手内容场里已经天然沉淀了“高意图、强风险判断、短决策、重确定性”的蓝领求职需求。谁能承接这类需求,谁就更接近简历投递和入职转化的核心层。</p>
  561. </div>
  562. </div>
  563. </section>
  564. <section class="report-section" data-slide-num="09" data-tone="green">
  565. <div class="section-inner">
  566. <div class="section-head">
  567. <div class="section-eyebrow" style="color:var(--green);">Chapter 09</div>
  568. <h2 class="text-h1">内容与产品层启示</h2>
  569. <p class="section-sub">把前面的 VOC 洞察压缩成平台、内容和产品都能用的动作建议。</p>
  570. </div>
  571. <div class="grid grid-3">
  572. <div class="card" style="border-top:2px solid var(--green);">
  573. <div class="text-h2" style="margin-bottom:10px;color:var(--green);">内容层</div>
  574. <p class="text-body">多做“工资口径解释、现场环境实拍、包吃包住细节、入职流程拆解”这种解决不确定性的内容,而不是只做岗位播报。</p>
  575. </div>
  576. <div class="card" style="border-top:2px solid var(--blue);">
  577. <div class="text-h2" style="margin-bottom:10px;color:var(--blue);">产品层</div>
  578. <p class="text-body">强化“企业直招、无押金、工资发放规则、住宿安排、是否当天入职”等结构化字段,帮助用户快速判断。</p>
  579. </div>
  580. <div class="card" style="border-top:2px solid var(--rose);">
  581. <div class="text-h2" style="margin-bottom:10px;color:var(--rose);">信任层</div>
  582. <p class="text-body">蓝领情绪价值的核心是安全感。能提升信任校验、真实展示和评论问答质量,就能提高留资和转化效率。</p>
  583. </div>
  584. </div>
  585. <div class="grid grid-2" style="margin-top:22px;">
  586. ${channelSamples.slice(0, 4).map((item) => quoteCard(item, '#3B82F6')).join('')}
  587. </div>
  588. </div>
  589. </section>
  590. <section class="report-section" data-slide-num="10" data-tone="rose">
  591. <div class="section-inner">
  592. <div class="section-head">
  593. <div class="section-eyebrow" style="color:var(--rose);">Chapter 10</div>
  594. <h2 class="text-h1">高赞样本附录</h2>
  595. <p class="section-sub">这一页保留更原始的用户声音,方便在汇报里做案例补充或引用。</p>
  596. </div>
  597. <div class="grid grid-2">
  598. ${topRecords.map((item) => quoteCard(item, item.keyword && CLUSTERS.concern.includes(item.keyword) ? '#FF4D8D' : item.keyword && CLUSTERS.priority.includes(item.keyword) ? '#00DC82' : '#F5A623')).join('')}
  599. </div>
  600. </div>
  601. </section>
  602. </main>
  603. <footer class="footer">
  604. <div>快手渠道蓝领求职洞察报告 v5</div>
  605. <div style="margin-top:8px;">数据来源:docs/KS-Chanel_analysis/raw/KS · ${fmtNum(totalVideos)} 条视频 · ${fmtNum(totalComments)} 条评论 · ${fmtCompact(totalViews)} 播放</div>
  606. <div style="margin-top:4px;">生成时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</div>
  607. </footer>
  608. <script>
  609. (function(){
  610. const sections=[...document.querySelectorAll('section.report-section')];
  611. const progressLine=document.getElementById('progressLine');
  612. const navRail=document.getElementById('navRail');
  613. const slideNow=document.getElementById('slideNow');
  614. const slideTotal=document.getElementById('slideTotal');
  615. slideTotal.textContent=String(sections.length).padStart(2,'0');
  616. const dots=sections.map((sec,idx)=>{
  617. const dot=document.createElement('div');
  618. dot.className='nav-dot';
  619. dot.dataset.tip=sec.querySelector('.text-h1')?.textContent||('0'+(idx+1));
  620. dot.addEventListener('click',()=>sec.scrollIntoView({behavior:'smooth'}));
  621. navRail.appendChild(dot);
  622. return dot;
  623. });
  624. function update(idx){
  625. const pct=((idx+1)/sections.length)*100;
  626. progressLine.style.width=pct+'%';
  627. slideNow.textContent=String(idx+1).padStart(2,'0');
  628. dots.forEach((dot,i)=>dot.classList.toggle('active',i===idx));
  629. }
  630. const observer=new IntersectionObserver((entries)=>{
  631. entries.forEach((entry)=>{
  632. if(entry.isIntersecting){
  633. entry.target.classList.add('in-view');
  634. update(sections.indexOf(entry.target));
  635. }
  636. });
  637. },{threshold:0.45});
  638. sections.forEach((sec)=>observer.observe(sec));
  639. })();
  640. </script>
  641. </body>
  642. </html>`;
  643. ensureDir(OUT_DIR);
  644. fs.writeFileSync(OUT_FILE, html, 'utf8');
  645. console.log(`Generated: ${OUT_FILE}`);