ks-report-insights.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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 OUTPUT_PATH = path.join(BASE_DIR, 'raw', '解析后的数据.md');
  7. const REPORT_GOALS = [
  8. '蓝领劳工在求职过程中,最担心的、最看重的是什么',
  9. '快手渠道对蓝领求职的触达与使用场景',
  10. '情绪价值强的表达与诉求',
  11. '关注的关键字',
  12. '快聘的岗位供给与战略位势信号',
  13. ];
  14. const TOPIC_CONFIG = {
  15. concern: {
  16. title: '最担心的问题',
  17. keywords: ['招聘骗局', '求职被骗', '打工陷阱', '黑中介', '黑厂', '被坑', '入职被坑', '押金不退', '克扣工资'],
  18. matcher: ['骗', '坑', '黑中介', '黑厂', '押金', '不退', '克扣', '套路', '陷阱', '虚假', '拖欠', '维权', '身份证', '中介费'],
  19. },
  20. priority: {
  21. title: '最看重的因素',
  22. keywords: ['工资靠谱', '真实薪资', '工资日结', '发工资', '加班费', '包吃包住', '当天入职'],
  23. matcher: ['工资', '薪资', '日结', '发工资', '包吃包住', '住宿', '吃住', '当天入职', '入职快', '加班费', '靠谱', '五险', '结算'],
  24. },
  25. emotion: {
  26. title: '情绪价值强的内容',
  27. keywords: ['直播带岗', '蓝领找工作', '快聘', '工资靠谱', '包吃包住', '被坑', '求职被骗', '黑中介'],
  28. matcher: ['放心', '靠谱', '真实', '尊重', '委屈', '崩溃', '气死', '恶心', '压抑', '难受', '终于', '踏实', '被骗', '被坑'],
  29. },
  30. strategic: {
  31. title: '快手与快聘战略信号',
  32. keywords: ['快聘', '直播带岗', '蓝领找工作', '当天入职', '包吃包住', '工资靠谱'],
  33. matcher: ['快聘', '直播带岗', '找工作', '招工', '入职', '蓝领', '工厂', '岗位', '简历', '报名', '安排住宿'],
  34. },
  35. };
  36. const ATTENTION_TERMS = [
  37. '工资', '薪资', '日结', '发工资', '加班费', '包吃包住', '住宿', '食堂', '当天入职',
  38. '靠谱', '真实', '找工作', '快聘', '直播带岗', '蓝领', '工厂', '岗位',
  39. '被骗', '被坑', '黑中介', '黑厂', '押金', '不退', '克扣工资', '套路', '陷阱', '维权'
  40. ];
  41. const NEGATIVE_WORDS = ['被骗', '被坑', '坑', '黑中介', '黑厂', '套路', '陷阱', '押金', '不退', '克扣', '拖欠', '维权', '身份证'];
  42. const POSITIVE_WORDS = ['靠谱', '真实', '放心', '包吃包住', '当天入职', '日结', '发工资', '安排住宿', '工资高', '好找工作'];
  43. const EMOTION_WORDS = ['委屈', '难受', '压抑', '崩溃', '恶心', '气死', '后悔', '放心', '踏实', '尊重', '值得', '终于', '被骗', '被坑'];
  44. function readJson(filePath) {
  45. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  46. }
  47. function listJsonFiles(dir) {
  48. if (!fs.existsSync(dir)) return [];
  49. return fs.readdirSync(dir)
  50. .filter((name) => name.endsWith('.json'))
  51. .map((name) => path.join(dir, name));
  52. }
  53. function normalizeText(value) {
  54. return String(value || '')
  55. .replace(/\r/g, '')
  56. .replace(/\n+/g, ' ')
  57. .replace(/\s+/g, ' ')
  58. .trim();
  59. }
  60. function clip(text, max = 110) {
  61. const clean = normalizeText(text);
  62. return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
  63. }
  64. function mdSafe(text) {
  65. return normalizeText(text).replace(/\|/g, '\\|');
  66. }
  67. function uniqueBy(items, keyFn) {
  68. const seen = new Set();
  69. const out = [];
  70. for (const item of items) {
  71. const key = keyFn(item);
  72. if (seen.has(key)) continue;
  73. seen.add(key);
  74. out.push(item);
  75. }
  76. return out;
  77. }
  78. function includesAny(text, terms) {
  79. return terms.some((term) => text.includes(term));
  80. }
  81. function collectRecords(raw) {
  82. const keyword = raw.keyword || '';
  83. const records = [];
  84. for (const video of raw.top_videos || []) {
  85. const text = normalizeText(video.caption);
  86. if (!text) continue;
  87. records.push({
  88. type: 'video',
  89. keyword,
  90. text,
  91. likes: Number(video.like_count || 0),
  92. comments: Number(video.comment_count || 0),
  93. views: Number(video.view_count || 0),
  94. score: Number(video.like_count || 0) + Number(video.comment_count || 0) * 3 + Math.floor(Number(video.view_count || 0) / 1000),
  95. sourceId: String(video.id || ''),
  96. });
  97. }
  98. for (const [videoId, comments] of Object.entries(raw.comments || {})) {
  99. for (const comment of comments || []) {
  100. const text = normalizeText(comment.content);
  101. if (!text) continue;
  102. records.push({
  103. type: 'comment',
  104. keyword,
  105. text,
  106. likes: Number(comment.like_count || 0),
  107. comments: 0,
  108. views: 0,
  109. score: Number(comment.like_count || 0) * 2 + (text.length >= 20 ? 3 : 0),
  110. sourceId: `${videoId}:${comment.id || ''}`,
  111. });
  112. }
  113. }
  114. return records;
  115. }
  116. function analyzeFiles() {
  117. const files = listJsonFiles(RAW_DIR);
  118. const perKeyword = [];
  119. const allRecords = [];
  120. for (const filePath of files) {
  121. const raw = readJson(filePath);
  122. const records = collectRecords(raw);
  123. const topVideos = raw.top_videos || [];
  124. const commentCount = Object.values(raw.comments || {}).reduce((sum, list) => sum + (Array.isArray(list) ? list.length : 0), 0);
  125. const likeSum = topVideos.reduce((sum, item) => sum + Number(item.like_count || 0), 0);
  126. const viewSum = topVideos.reduce((sum, item) => sum + Number(item.view_count || 0), 0);
  127. const summary = {
  128. keyword: raw.keyword || path.basename(filePath, '.json'),
  129. filePath,
  130. videoCount: topVideos.length,
  131. commentCount,
  132. likeSum,
  133. viewSum,
  134. records,
  135. };
  136. perKeyword.push(summary);
  137. allRecords.push(...records);
  138. }
  139. return { files: perKeyword, allRecords };
  140. }
  141. function buildThemeStats(dataset, config) {
  142. const keywordSet = new Set(config.keywords);
  143. const matchedFiles = dataset.files.filter((file) => keywordSet.has(file.keyword));
  144. const matchedRecords = uniqueBy(
  145. matchedFiles.flatMap((file) => file.records).filter((record) => includesAny(record.text, config.matcher) || keywordSet.has(record.keyword)),
  146. (record) => `${record.type}:${record.keyword}:${record.sourceId}:${record.text}`
  147. );
  148. const signalMap = new Map();
  149. for (const record of matchedRecords) {
  150. for (const term of config.matcher) {
  151. if (!record.text.includes(term)) continue;
  152. const prev = signalMap.get(term) || { term, count: 0, score: 0 };
  153. prev.count += 1;
  154. prev.score += record.score;
  155. signalMap.set(term, prev);
  156. }
  157. }
  158. const samples = matchedRecords
  159. .sort((a, b) => b.score - a.score)
  160. .slice(0, 18);
  161. return {
  162. matchedFiles,
  163. matchedRecords,
  164. signals: Array.from(signalMap.values()).sort((a, b) => b.score - a.score || b.count - a.count).slice(0, 12),
  165. samples,
  166. };
  167. }
  168. function buildAttentionTerms(dataset) {
  169. const stats = ATTENTION_TERMS.map((term) => {
  170. let count = 0;
  171. let score = 0;
  172. for (const record of dataset.allRecords) {
  173. if (!record.text.includes(term)) continue;
  174. count += 1;
  175. score += record.score;
  176. }
  177. return { term, count, score };
  178. }).filter((item) => item.count > 0);
  179. return stats.sort((a, b) => b.score - a.score || b.count - a.count).slice(0, 20);
  180. }
  181. function buildEmotionSamples(dataset) {
  182. const items = dataset.allRecords
  183. .filter((record) => includesAny(record.text, EMOTION_WORDS))
  184. .map((record) => {
  185. const positiveHits = POSITIVE_WORDS.filter((term) => record.text.includes(term)).length;
  186. const negativeHits = NEGATIVE_WORDS.filter((term) => record.text.includes(term)).length;
  187. const emotionHits = EMOTION_WORDS.filter((term) => record.text.includes(term)).length;
  188. let polarity = '中性';
  189. if (negativeHits > positiveHits) polarity = '负向';
  190. if (positiveHits > negativeHits) polarity = '正向';
  191. if (positiveHits > 0 && negativeHits > 0) polarity = '冲突';
  192. return {
  193. ...record,
  194. polarity,
  195. emotionStrength: emotionHits * 10 + record.score,
  196. };
  197. });
  198. return uniqueBy(
  199. items.sort((a, b) => b.emotionStrength - a.emotionStrength),
  200. (item) => item.text
  201. ).slice(0, 20);
  202. }
  203. function buildStrategicObservations(dataset) {
  204. const strategicKeywords = new Set(TOPIC_CONFIG.strategic.keywords);
  205. const selected = dataset.files.filter((file) => strategicKeywords.has(file.keyword));
  206. const observations = [];
  207. for (const file of selected) {
  208. observations.push({
  209. keyword: file.keyword,
  210. videos: file.videoCount,
  211. comments: file.commentCount,
  212. likes: file.likeSum,
  213. views: file.viewSum,
  214. });
  215. }
  216. return observations.sort((a, b) => b.views - a.views || b.likes - a.likes);
  217. }
  218. function renderTable(rows, headers) {
  219. const head = `| ${headers.join(' | ')} |`;
  220. const divider = `| ${headers.map(() => '---').join(' | ')} |`;
  221. const body = rows.map((row) => `| ${row.join(' | ')} |`);
  222. return [head, divider, ...body].join('\n');
  223. }
  224. function buildMarkdown(dataset) {
  225. const concern = buildThemeStats(dataset, TOPIC_CONFIG.concern);
  226. const priority = buildThemeStats(dataset, TOPIC_CONFIG.priority);
  227. const emotion = buildThemeStats(dataset, TOPIC_CONFIG.emotion);
  228. const attention = buildAttentionTerms(dataset);
  229. const emotionSamples = buildEmotionSamples(dataset);
  230. const strategic = buildStrategicObservations(dataset);
  231. const totalKeywords = dataset.files.length;
  232. const totalVideos = dataset.files.reduce((sum, file) => sum + file.videoCount, 0);
  233. const totalComments = dataset.files.reduce((sum, file) => sum + file.commentCount, 0);
  234. const totalViews = dataset.files.reduce((sum, file) => sum + file.viewSum, 0);
  235. const lines = [];
  236. lines.push('# 快手渠道报告定向解析数据');
  237. lines.push('');
  238. lines.push(`生成时间:${new Date().toLocaleString('zh-CN', { hour12: false })}`);
  239. lines.push('');
  240. lines.push('## 解析目标');
  241. lines.push(...REPORT_GOALS.map((item) => `- ${item}`));
  242. lines.push('');
  243. lines.push('## 数据范围');
  244. lines.push(`- 数据目录:\`${RAW_DIR}\``);
  245. lines.push(`- 关键词文件数:${totalKeywords}`);
  246. lines.push(`- 抓取视频数:${totalVideos}`);
  247. lines.push(`- 抓取评论数:${totalComments}`);
  248. lines.push(`- 视频总播放量(基于 top_videos 汇总):${totalViews.toLocaleString('zh-CN')}`);
  249. lines.push('');
  250. lines.push('## 一、蓝领求职时最担心什么');
  251. lines.push('从“被骗/被坑/黑中介/押金不退/克扣工资/打工陷阱”相关文件和文本信号看,风险焦虑是最稳定、最强烈的底层情绪。');
  252. lines.push('');
  253. lines.push(renderTable(
  254. concern.signals.map((item) => [mdSafe(item.term), String(item.count), String(item.score)]),
  255. ['风险信号', '命中条数', '综合热度']
  256. ));
  257. lines.push('');
  258. lines.push('高代表性样本:');
  259. lines.push(...concern.samples.slice(0, 8).map((item, index) => `${index + 1}. [${item.keyword}][${item.type}] 热度=${item.score}:${clip(item.text)}`));
  260. lines.push('');
  261. lines.push('## 二、蓝领求职时最看重什么');
  262. lines.push('高频关注集中在“工资能否按时发、薪资是否真实、是否包吃包住、能否快速入职”这些能直接降低求职成本和试错成本的因素。');
  263. lines.push('');
  264. lines.push(renderTable(
  265. priority.signals.map((item) => [mdSafe(item.term), String(item.count), String(item.score)]),
  266. ['看重因素', '命中条数', '综合热度']
  267. ));
  268. lines.push('');
  269. lines.push('高代表性样本:');
  270. lines.push(...priority.samples.slice(0, 8).map((item, index) => `${index + 1}. [${item.keyword}][${item.type}] 热度=${item.score}:${clip(item.text)}`));
  271. lines.push('');
  272. lines.push('## 三、情绪价值强的表达');
  273. lines.push('情绪价值并不只来自“高薪”,更来自“靠谱、真实、被尊重、少踩坑、能快速安顿下来”。负向情绪主要由被骗、被坑、黑中介等风险触发;正向情绪则常与靠谱、包吃包住、当天入职等确定性表达绑定。');
  274. lines.push('');
  275. lines.push(renderTable(
  276. emotionSamples.slice(0, 12).map((item) => [
  277. mdSafe(item.keyword),
  278. item.polarity,
  279. String(item.likes),
  280. mdSafe(clip(item.text, 70))
  281. ]),
  282. ['关键词', '情绪方向', '点赞', '代表表达']
  283. ));
  284. lines.push('');
  285. lines.push('## 四、用户关注的关键字');
  286. lines.push('以下词项是跨视频与评论反复出现、且与报告主题直接相关的关注焦点,可作为后续洞察和报告章节标题的基础词库。');
  287. lines.push('');
  288. lines.push(renderTable(
  289. attention.map((item) => [mdSafe(item.term), String(item.count), String(item.score)]),
  290. ['关键词', '命中条数', '综合热度']
  291. ));
  292. lines.push('');
  293. lines.push('## 五、快手渠道与“快聘”战略位势信号');
  294. lines.push('从“快聘 / 直播带岗 / 蓝领找工作 / 当天入职 / 包吃包住 / 工资靠谱”等主题文件看,快手在蓝领求职场景里同时承担了流量入口、岗位展示、信任建立和快速转化四种角色。');
  295. lines.push('');
  296. lines.push(renderTable(
  297. strategic.map((item) => [
  298. mdSafe(item.keyword),
  299. String(item.videos),
  300. String(item.comments),
  301. item.likes.toLocaleString('zh-CN'),
  302. item.views.toLocaleString('zh-CN')
  303. ]),
  304. ['主题词', '视频数', '评论数', '点赞汇总', '播放量汇总']
  305. ));
  306. lines.push('');
  307. lines.push('可直接用于报告的结论整理:');
  308. lines.push('- 快手不是单纯的内容平台,在蓝领求职链路里已经具备“种草 + 筛选 + 询单 + 转化”的复合职能。');
  309. lines.push('- “直播带岗”“当天入职”“包吃包住”说明用户并不只想看岗位信息,而是希望快速判断机会真假、成本高低和落地效率。');
  310. lines.push('- “快聘”相关内容能承接这种高频、强时效的求职需求,因此更容易被用户当成高效率的找工作入口。');
  311. lines.push('- 如果内部已知“快聘找工作日活几十万简历,战略位势仅次于电商”,那么这批快手端 VOC 数据能提供用户需求面的支撑:需求高频、风险敏感、决策链短、转化诉求强。');
  312. lines.push('');
  313. lines.push('## 六、可直接引用的洞察');
  314. lines.push('- 最强焦虑不是“工资低”,而是“信息不真实、被骗、被坑、押金不退、工资被克扣”。');
  315. lines.push('- 最强购买点不是抽象品牌心智,而是“工资真实、发薪稳定、包吃包住、当天入职、流程简单”。');
  316. lines.push('- 情绪价值的核心不是娱乐,而是“确定性”:靠谱、真实、放心、少踩坑、能尽快安顿。');
  317. lines.push('- 快手在蓝领求职场景中的优势,来自内容触达、直播解释、即时互动和高频决策场景的天然匹配。');
  318. lines.push('');
  319. lines.push('## 七、附录:样本关键词覆盖');
  320. lines.push(...dataset.files
  321. .sort((a, b) => b.commentCount - a.commentCount || b.viewSum - a.viewSum)
  322. .map((file) => `- ${file.keyword}:视频 ${file.videoCount} 条,评论 ${file.commentCount} 条,播放量 ${file.viewSum.toLocaleString('zh-CN')}`));
  323. lines.push('');
  324. return `${lines.join('\n')}\n`;
  325. }
  326. function main() {
  327. if (!fs.existsSync(RAW_DIR)) {
  328. throw new Error(`raw directory not found: ${RAW_DIR}`);
  329. }
  330. const dataset = analyzeFiles();
  331. const markdown = buildMarkdown(dataset);
  332. fs.writeFileSync(OUTPUT_PATH, markdown, 'utf8');
  333. console.log(`Generated: ${OUTPUT_PATH}`);
  334. }
  335. if (require.main === module) {
  336. main();
  337. }