ks-collect-v2.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. #!/usr/bin/env node
  2. /**
  3. * KS-Chanel Analysis · 快手(Kuaishou)数据采集脚本 V2
  4. *
  5. * 针对报告目标优化(L1-6):
  6. * L1: 蓝领劳工最担心/最看重什么
  7. * L2: 快手(10个蓝领,九个都用快手)
  8. * L3: 情绪价值强的关键词
  9. * L4: 关注的关键字
  10. * L5: 快聘战略地位(日活几十万简历)
  11. * L6: 核心痛点深挖
  12. *
  13. * 使用 TikHub API (via api.tikhub.io / api.tikhub.dev)
  14. */
  15. const fs = require('fs');
  16. const path = require('path');
  17. const os = require('os');
  18. const https = require('https');
  19. const ROOT = path.resolve(__dirname, '..');
  20. const RAW_DIR = path.join(ROOT, 'raw');
  21. const KS_DIR = path.join(RAW_DIR, 'kuaishou');
  22. const AUDIT_LOG = path.join(RAW_DIR, 'audit.log');
  23. const API_CONFIG = (() => {
  24. const p = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  25. if (!fs.existsSync(p)) return null;
  26. try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
  27. })();
  28. const TIKHUB_TOKEN = API_CONFIG?.currentToken
  29. || API_CONFIG?.endpoint?.headers?.Authorization?.replace(/^Bearer\s+/, '')
  30. || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
  31. const API_HOST = process.env.TIKHUB_HOST || API_CONFIG?.endpoint?.host || 'api.tikhub.io';
  32. [RAW_DIR, KS_DIR].forEach((d) => {
  33. if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
  34. });
  35. /**
  36. * V2 假设体系(H1-H12)
  37. * H1: 快聘是蓝领求职主阵地
  38. * H2: 薪资透明度是核心关切(日结/月薪/五险一金)
  39. * H3: 入职便捷性(当天入职/手续简单)
  40. * H4: 食宿条件影响决策(包吃包住/宿舍)
  41. * H5: 平台信任与口碑(真实评价/朋友推荐)
  42. * H6: 被骗焦虑是普遍痛点 ← 重点
  43. * H7: 黑中介/克扣是核心风险 ← 重点
  44. * H8: 工资结算方式差异(日结/月结)
  45. * H9: 职业尊严与社会认同 ← 情绪价值
  46. * H10: 同事关系与工作氛围 ← 情绪价值
  47. * H11: 直播带岗降低信息不对称
  48. * H12: 平台战略地位验证(快聘)
  49. */
  50. /**
  51. * V2 关键词批次 —— 针对L1-6报告目标优化
  52. */
  53. const BATCHES = {
  54. 1: {
  55. name: 'P1 · 核心担忧:被骗与押金(高情绪价值)',
  56. hypotheses: ['H6', 'H7'],
  57. kuaishou: [
  58. { kw: '招聘骗局', videos: 20, commentPages: 3, hypotheses: ['H6'] },
  59. { kw: '打工陷阱', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  60. { kw: '黑厂', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  61. { kw: '克扣工资', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7', 'H8'] },
  62. { kw: '不退押金', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  63. { kw: '被坑', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  64. ],
  65. },
  66. 2: {
  67. name: 'P2 · 最看重:薪资与权益保障',
  68. hypotheses: ['H2', 'H3', 'H8'],
  69. kuaishou: [
  70. { kw: '五险一金', videos: 20, commentPages: 3, hypotheses: ['H2'] },
  71. { kw: '加班费', videos: 20, commentPages: 3, hypotheses: ['H2'] },
  72. { kw: '发工资', videos: 20, commentPages: 3, hypotheses: ['H2', 'H8'] },
  73. { kw: '不拖欠工资', videos: 20, commentPages: 3, hypotheses: ['H2', 'H8'] },
  74. { kw: '工资日结', videos: 20, commentPages: 3, hypotheses: ['H2', 'H8'] },
  75. { kw: '签合同', videos: 20, commentPages: 3, hypotheses: ['H2', 'H6'] },
  76. ],
  77. },
  78. 3: {
  79. name: 'P3 · 情绪价值:尊严与归属感',
  80. hypotheses: ['H9', 'H10'],
  81. kuaishou: [
  82. { kw: '被骂', videos: 20, commentPages: 3, hypotheses: ['H9'] },
  83. { kw: '受委屈', videos: 20, commentPages: 3, hypotheses: ['H9', 'H10'] },
  84. { kw: '同事好', videos: 20, commentPages: 3, hypotheses: ['H10'] },
  85. { kw: '领导好', videos: 20, commentPages: 3, hypotheses: ['H10'] },
  86. { kw: '氛围好', videos: 20, commentPages: 3, hypotheses: ['H10'] },
  87. { kw: '被尊重', videos: 20, commentPages: 3, hypotheses: ['H9'] },
  88. ],
  89. },
  90. 4: {
  91. name: 'P4 · 平台信任与口碑验证',
  92. hypotheses: ['H5', 'H11', 'H12'],
  93. kuaishou: [
  94. { kw: '靠谱工作', videos: 20, commentPages: 3, hypotheses: ['H5', 'H12'] },
  95. { kw: '好厂推荐', videos: 20, commentPages: 3, hypotheses: ['H5'] },
  96. { kw: '真实评价', videos: 20, commentPages: 3, hypotheses: ['H5', 'H11'] },
  97. { kw: '入职顺利', videos: 20, commentPages: 3, hypotheses: ['H3', 'H5'] },
  98. { kw: '没被骗', videos: 20, commentPages: 3, hypotheses: ['H5', 'H6'] },
  99. { kw: '朋友推荐', videos: 20, commentPages: 3, hypotheses: ['H5'] },
  100. ],
  101. },
  102. 5: {
  103. name: 'P5 · 快聘生态与直播求职',
  104. hypotheses: ['H1', 'H11', 'H12'],
  105. kuaishou: [
  106. { kw: '快聘', videos: 20, commentPages: 3, hypotheses: ['H1', 'H12'] },
  107. { kw: '直播带岗', videos: 20, commentPages: 3, hypotheses: ['H1', 'H11'] },
  108. { kw: '直播间找工作', videos: 20, commentPages: 3, hypotheses: ['H1', 'H11'] },
  109. { kw: '主播靠谱', videos: 20, commentPages: 3, hypotheses: ['H1', 'H5'] },
  110. { kw: '快手求职', videos: 20, commentPages: 3, hypotheses: ['H1', 'H2'] },
  111. { kw: '快手工作', videos: 20, commentPages: 3, hypotheses: ['H1'] },
  112. ],
  113. },
  114. 6: {
  115. name: 'P6 · 风险防范与自我保护',
  116. hypotheses: ['H6', 'H7'],
  117. kuaishou: [
  118. { kw: '防骗指南', videos: 20, commentPages: 3, hypotheses: ['H6'] },
  119. { kw: '维权', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  120. { kw: '劳动仲裁', videos: 20, commentPages: 3, hypotheses: ['H6'] },
  121. { kw: '黑中介', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  122. { kw: '身份证扣押', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  123. { kw: '不收押金', videos: 20, commentPages: 3, hypotheses: ['H6', 'H7'] },
  124. ],
  125. },
  126. };
  127. const HYPOTHESIS_KEYWORDS = {
  128. H1: ['快聘', '直播带岗', '蓝领', '求职', '找工作', '快手求职', '直播间找工作', '快手工作'],
  129. H2: ['工资', '日结', '月薪', '薪资', '真实薪资', '多少钱', '性价比', '高工资', '五险一金', '加班费', '发工资'],
  130. H3: ['当天入职', '包吃包住', '入职快', '随时入职', '手续简单', '拎包入住', '入职顺利'],
  131. H4: ['正规', '靠谱', '放心', '有保障', '安全', '不收押金', '不扣证件'],
  132. H5: ['朋友推荐', '口碑', '真实评价', '过来人', '亲身经历', '推荐', '好厂推荐', '靠谱工作'],
  133. H6: ['被骗', '黑中介', '被坑', '押金不退', '虚假招聘', '套路', '陷阱', '投诉', '维权', '克扣工资', '招聘骗局', '打工陷阱', '黑厂', '被骂', '受委屈'],
  134. H7: ['黑中介', '押金不退', '不退押金', '克扣', '身份证扣押', '中介费', '不退押金'],
  135. H8: ['工资日结', '日结', '小时工', '月结', '工资结算', '不拖欠工资'],
  136. H9: ['被骂', '受委屈', '尊严', '不尊重', '骂人', '受气', '被尊重', '尊重'],
  137. H10: ['同事好', '领导好', '氛围好', '相处好', '工友', '关系好'],
  138. H11: ['直播间找工作', '直播带岗', '主播靠谱', '真实评价'],
  139. H12: ['快聘', '快手求职', '靠谱工作'],
  140. };
  141. const HYPOTHESIS_COLORS = {
  142. H1: '#F5A623',
  143. H2: '#3B82F6',
  144. H3: '#00DC82',
  145. H4: '#8B5CF6',
  146. H5: '#F97316',
  147. H6: '#FF4D8D',
  148. H7: '#EF4444',
  149. H8: '#6B7280',
  150. H9: '#EC4899',
  151. H10: '#14B8A6',
  152. H11: '#22C55E',
  153. H12: '#06B6D4',
  154. };
  155. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  156. function sanitizeFilename(kw) {
  157. return kw.replace(/[\/\\:*?"<>|\s]+/g, '-');
  158. }
  159. function auditLog(line) {
  160. const ts = new Date().toISOString();
  161. try { fs.appendFileSync(AUDIT_LOG, `[${ts}] [ks-v2] ${line}\n`); } catch {}
  162. }
  163. function fileExistsNonEmpty(p) {
  164. if (!fs.existsSync(p)) return false;
  165. return fs.statSync(p).size > 100;
  166. }
  167. function tikhubGet(apiPath, params = {}) {
  168. const qs = Object.entries(params)
  169. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  170. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  171. .join('&');
  172. const url = qs ? `${apiPath}?${qs}` : apiPath;
  173. return new Promise((resolve) => {
  174. const opts = {
  175. hostname: API_HOST,
  176. path: url,
  177. method: 'GET',
  178. headers: {
  179. Authorization: `Bearer ${TIKHUB_TOKEN}`,
  180. Accept: 'application/json',
  181. },
  182. };
  183. const req = https.request(opts, (res) => {
  184. const chunks = [];
  185. res.on('data', (c) => chunks.push(c));
  186. res.on('end', () => {
  187. const body = Buffer.concat(chunks).toString('utf-8');
  188. try {
  189. const j = JSON.parse(preserveLargeIntegerFields(body));
  190. resolve({ data: j, status: res.statusCode });
  191. } catch (e) {
  192. resolve({ data: { _parse_error: e.message, _raw: body.slice(0, 500) }, status: res.statusCode });
  193. }
  194. });
  195. });
  196. req.on('error', (e) => resolve({ data: { _error: e.message }, status: 0 }));
  197. req.setTimeout(45000, () => { req.destroy(); resolve({ data: { _error: 'timeout' }, status: 0 }); });
  198. req.end();
  199. });
  200. }
  201. function preserveLargeIntegerFields(text) {
  202. return text.replace(
  203. /("(?:[A-Za-z_]*id|[A-Za-z_]*Id|photo_id|photoId|user_id|userId|comment_id|commentId|videoId)"\s*:\s*)(\d{16,})/g,
  204. '$1"$2"'
  205. );
  206. }
  207. function firstArray(...values) {
  208. for (const value of values) {
  209. if (Array.isArray(value)) return value;
  210. }
  211. return [];
  212. }
  213. function findFirstArray(value, keys = []) {
  214. if (!value || typeof value !== 'object') return [];
  215. for (const key of keys) {
  216. const arr = key.split('.').reduce((cur, part) => cur?.[part], value);
  217. if (Array.isArray(arr)) return arr;
  218. }
  219. const queue = [value];
  220. const seen = new Set();
  221. while (queue.length) {
  222. const cur = queue.shift();
  223. if (!cur || typeof cur !== 'object' || seen.has(cur)) continue;
  224. seen.add(cur);
  225. if (Array.isArray(cur)) {
  226. if (cur.length === 0) continue;
  227. if (cur.some((it) => it && typeof it === 'object')) return cur;
  228. continue;
  229. }
  230. for (const next of Object.values(cur)) queue.push(next);
  231. }
  232. return [];
  233. }
  234. function asCount(value) {
  235. if (typeof value === 'number') return value;
  236. if (typeof value === 'string') {
  237. const text = value.trim().toLowerCase();
  238. const num = Number(text.replace(/[,+]/g, '').replace(/w|万/g, ''));
  239. if (!Number.isFinite(num)) return 0;
  240. return /w|万/.test(text) ? Math.round(num * 10000) : num;
  241. }
  242. return 0;
  243. }
  244. function pickVideoId(v) {
  245. return String(
  246. v?.photo_id
  247. || v?.photoId
  248. || v?.id
  249. || v?.photo?.id
  250. || v?.photo?.photo_id
  251. || v?.photo?.photoId
  252. || v?.work?.id
  253. || ''
  254. );
  255. }
  256. function pickVideoText(v) {
  257. return v?.caption
  258. || v?.title
  259. || v?.desc
  260. || v?.description
  261. || v?.photo?.caption
  262. || v?.photo?.captionText
  263. || v?.photo?.title
  264. || v?.work?.caption
  265. || '';
  266. }
  267. function normalizeVideo(v, rank) {
  268. const photo = v?.photo || v?.work || v;
  269. const author = v?.author || v?.user || v?.userInfo || photo?.author || photo?.user || {};
  270. return {
  271. photo_id: pickVideoId(v),
  272. caption: pickVideoText(v),
  273. like_count: asCount(v?.like_count ?? v?.likeCount ?? v?.liked_count ?? v?.likedCount ?? photo?.like_count ?? photo?.likeCount),
  274. comment_count: asCount(v?.comment_count ?? v?.commentCount ?? v?.comments_count ?? v?.commentsCount ?? photo?.comment_count ?? photo?.commentCount),
  275. view_count: asCount(v?.view_count ?? v?.viewCount ?? v?.play_count ?? v?.playCount ?? photo?.view_count ?? photo?.viewCount),
  276. cover: v?.cover || v?.thumbnail || v?.coverUrl || photo?.cover || photo?.thumbnail || '',
  277. create_time: v?.create_time || v?.createTime || v?.timestamp || photo?.create_time || photo?.createTime || '',
  278. rank: v?.rank || v?.index || rank,
  279. hot_value: asCount(v?.hot_value ?? v?.hotValue ?? v?.score ?? v?.heat ?? v?.hot),
  280. user: author,
  281. };
  282. }
  283. function normalizeComment(c) {
  284. const user = c?.user_info || c?.userInfo || c?.author || c?.user || {};
  285. return {
  286. id: c?.id || c?.comment_id || c?.commentId || c?.cid || '',
  287. content: c?.content || c?.text || c?.comment || c?.body || '',
  288. create_time: c?.create_time || c?.createTime || c?.timestamp || '',
  289. like_count: asCount(c?.like_count ?? c?.liked_count ?? c?.likeCount ?? c?.likedCount),
  290. ip_location: c?.ip_location || c?.ipLocation || c?.ip || '',
  291. user: user ? {
  292. user_id: user.user_id || user.userId || user.id || '',
  293. nickname: user.nickname || user.name || user.user_name || user.userName || '',
  294. } : null,
  295. sub_comments: firstArray(c?.sub_comments, c?.subComments, c?.replies).slice(0, 3).map((s) => ({
  296. content: s?.content || s?.text || '',
  297. like_count: asCount(s?.like_count ?? s?.liked_count ?? s?.likeCount ?? s?.likedCount),
  298. nickname: s?.user_info?.nickname || s?.userInfo?.nickname || s?.user?.nickname || '',
  299. })),
  300. };
  301. }
  302. function getCursor(data) {
  303. return data?.data?.pcursor
  304. || data?.data?.cursor
  305. || data?.data?.nextCursor
  306. || data?.data?.next_cursor
  307. || data?.pcursor
  308. || data?.cursor
  309. || '';
  310. }
  311. function hasMoreComments(data, comments, cursor) {
  312. const raw = data?.data || data || {};
  313. if (typeof raw.has_more === 'boolean') return raw.has_more;
  314. if (typeof raw.hasMore === 'boolean') return raw.hasMore;
  315. return comments.length > 0 && Boolean(cursor);
  316. }
  317. async function collectKs(spec, { force = false } = {}) {
  318. const outPath = path.join(KS_DIR, `${sanitizeFilename(spec.kw)}.json`);
  319. if (!force && fileExistsNonEmpty(outPath)) {
  320. console.log(` [ks] ⏭️ ${spec.kw} (已存在, skip)`);
  321. auditLog(`ks SKIP ${spec.kw} (exists)`);
  322. return { skipped: true, path: outPath };
  323. }
  324. console.log(` [ks] 🔍 搜索视频 "${spec.kw}" · page=1 × videos=${spec.videos}`);
  325. const startTs = Date.now();
  326. const allVideos = [];
  327. const seenIds = new Set();
  328. for (let page = 1; page <= 3; page++) {
  329. await sleep(800);
  330. const searchRes = await tikhubGet('/api/v1/kuaishou/app/search_video_v2', { keyword: spec.kw, page });
  331. const rawVideos = findFirstArray(searchRes?.data, [
  332. 'data.visionSearchPhoto',
  333. 'data.videos',
  334. 'data.items',
  335. 'data.feeds',
  336. 'data.list',
  337. 'data.result',
  338. 'data.data',
  339. 'visionSearchPhoto',
  340. ]);
  341. if (rawVideos.length === 0) {
  342. console.log(` [ks] ⚠️ 第${page}页 API 响应:`, JSON.stringify(searchRes?.data).slice(0, 300));
  343. break;
  344. }
  345. for (const [idx, v] of rawVideos.entries()) {
  346. const nv = normalizeVideo(v, allVideos.length + idx + 1);
  347. const photoId = nv.photo_id;
  348. if (photoId && photoId !== '0' && !seenIds.has(photoId)) {
  349. seenIds.add(photoId);
  350. allVideos.push(nv);
  351. }
  352. }
  353. console.log(` [ks] 第${page}页: +${rawVideos.length} 视频,累计 ${allVideos.length}`);
  354. if (allVideos.length >= spec.videos) break;
  355. }
  356. const topVideos = allVideos.slice(0, spec.videos);
  357. console.log(` [ks] 去重后 ${topVideos.length} 个视频,开始抓评论...`);
  358. const commentsByVideoId = {};
  359. let totalComments = 0;
  360. for (const video of topVideos) {
  361. if (!video.photo_id) continue;
  362. commentsByVideoId[video.photo_id] = [];
  363. let pcursor = '';
  364. for (let p = 0; p < (spec.commentPages || 2); p++) {
  365. await sleep(700);
  366. const commentAttempts = [
  367. {
  368. path: '/api/v1/kuaishou/app/fetch_one_video_comment',
  369. params: { photo_id: video.photo_id, pcursor },
  370. },
  371. {
  372. path: '/api/v1/kuaishou/web/fetch_video_comments',
  373. params: { photo_id: video.photo_id, pcursor },
  374. },
  375. {
  376. path: '/api/v1/kuaishou/web/fetch_video_comments',
  377. params: { photo_id: video.photo_id, cursor: pcursor },
  378. },
  379. ];
  380. let cRes = null;
  381. let cmts = [];
  382. for (const attempt of commentAttempts) {
  383. cRes = await tikhubGet(attempt.path, attempt.params);
  384. cmts = findFirstArray(cRes?.data, [
  385. 'data.comments',
  386. 'data.rootComments',
  387. 'data.commentList',
  388. 'data.list',
  389. 'data.items',
  390. 'data.data',
  391. 'comments',
  392. 'rootComments',
  393. ]);
  394. if (cmts.length > 0 || cRes.status === 200) break;
  395. }
  396. if (cmts.length === 0 && p === 0) {
  397. console.log(` [ks] ⚠️ 评论响应[photo_id=${video.photo_id}]:`, JSON.stringify(cRes?.data).slice(0, 300));
  398. }
  399. for (const c of cmts) {
  400. const comment = normalizeComment(c);
  401. if (comment.content) commentsByVideoId[video.photo_id].push(comment);
  402. }
  403. totalComments += cmts.length;
  404. pcursor = getCursor(cRes?.data);
  405. if (!hasMoreComments(cRes?.data, cmts, pcursor)) break;
  406. }
  407. }
  408. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  409. console.log(` [ks] ✓ ${spec.kw}: ${topVideos.length} 视频 / ${totalComments} 评论 (${elapsed}s)`);
  410. auditLog(`ks OK ${spec.kw} videos=${topVideos.length} comments=${totalComments} (${elapsed}s)`);
  411. const out = {
  412. platform: 'kuaishou',
  413. keyword: spec.kw,
  414. hypotheses: spec.hypotheses,
  415. collected_at: new Date().toISOString(),
  416. elapsed_seconds: Number(elapsed),
  417. total_videos_found: allVideos.length,
  418. top_videos: topVideos.map((v) => ({
  419. id: v.photo_id,
  420. caption: v.caption,
  421. like_count: v.like_count,
  422. comment_count: v.comment_count,
  423. view_count: v.view_count,
  424. cover: v.cover,
  425. create_time: v.create_time,
  426. user: v.user ? {
  427. user_id: v.user.user_id || v.user.id || '',
  428. nickname: v.user.nickname || v.user.name || v.user.user_name || '',
  429. } : null,
  430. })),
  431. comments: commentsByVideoId,
  432. };
  433. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  434. return { skipped: false, notes: topVideos.length, comments: totalComments, path: outPath };
  435. }
  436. function inferHypotheses(text, keywordHypotheses) {
  437. const results = new Set(keywordHypotheses || []);
  438. const t = String(text || '').toLowerCase();
  439. for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) {
  440. for (const kw of kws) {
  441. if (t.includes(kw.toLowerCase())) { results.add(h); break; }
  442. }
  443. }
  444. return Array.from(results);
  445. }
  446. const TAG_RULES = [
  447. { tag: '薪资关注', re: /工资|日结|月薪|薪资|钱|待遇|发工资|扣钱|加班费/ },
  448. { tag: '入职速度', re: /当天入职|入职快|随时入职|明天|今天|马上|入职顺利/ },
  449. { tag: '包吃包住', re: /包吃包住|包住|宿舍|食堂|吃饭|住宿/ },
  450. { tag: '防骗意识', re: /被骗|黑中介|押金|套路|陷阱|被坑|虚假|防骗/ },
  451. { tag: '平台信任', re: /快聘|官方|平台|快手|正规|靠谱/ },
  452. { tag: '口碑参考', re: /朋友推荐|过来人|亲身|真实评价|口碑|推荐/ },
  453. { tag: '工作环境', re: /车间|工厂|加班|环境|宿舍|工作条件|氛围/ },
  454. { tag: '合同保障', re: /合同|签合同|保障|合法|劳动法|权益|五险一金/ },
  455. { tag: '中介排斥', re: /黑中介|中介|第三方|劳务/ },
  456. { tag: '求职焦虑', re: /担心|害怕|不放心|纠结|怕|焦虑|犹豫/ },
  457. { tag: '情绪价值', re: /被骂|受委屈|尊重|尊严|同事好|氛围好|领导好/ },
  458. { tag: '维权意识', re: /维权|劳动仲裁|投诉|举报/ },
  459. ];
  460. function inferTags(text) {
  461. const tags = [];
  462. for (const r of TAG_RULES) {
  463. if (r.re.test(text || '')) tags.push(r.tag);
  464. }
  465. return tags;
  466. }
  467. const SENTIMENT_POS = /好|推荐|靠谱|放心|真实|满意|不错|工资高|环境好|入职快|顺利|真实评价|良心|没被骗|入职顺利|朋友推荐/;
  468. const SENTIMENT_NEG = /骗|坑|黑中介|押金不退|虚假|套路|被坑|垃圾|失望|后悔|坑人|恶劣|克扣|拖延|威胁|被骂|受委屈/;
  469. const SENTIMENT_CONFLICT = /但是|可是|纠结|担心|想又怕|犹豫|说实话又|虽然|不过|可惜/;
  470. function inferSentiment(text) {
  471. const t = String(text || '');
  472. const pos = SENTIMENT_POS.test(t);
  473. const neg = SENTIMENT_NEG.test(t);
  474. const conf = SENTIMENT_CONFLICT.test(t);
  475. if (conf && (pos || neg)) return 'conflicted';
  476. if (pos && !neg) return 'positive';
  477. if (neg && !pos) return 'negative';
  478. return 'neutral';
  479. }
  480. function buildItem({ id, platform, product, keyword, type, nickname, ip, content, likes, rating, kwHypos }) {
  481. if (!content || content.length < 3) return null;
  482. const hypotheses = inferHypotheses(content, kwHypos);
  483. const tags = inferTags(content);
  484. const sentiment = inferSentiment(content);
  485. return { id, platform, product, keyword, type, nickname, ip, content, likes, rating, hypothesis: hypotheses, tags, sentiment, source: 'v2-collected' };
  486. }
  487. function mergeAll() {
  488. const out = {
  489. meta: {
  490. collectedAt: new Date().toISOString(),
  491. version: '2.0',
  492. platforms: {},
  493. products: {},
  494. hypotheses: {},
  495. keywords: {},
  496. stage: 'batch-v2',
  497. sourceTier: 'v2-collected',
  498. },
  499. items: [],
  500. hotlistItems: [],
  501. };
  502. const flat = [];
  503. if (fs.existsSync(KS_DIR)) {
  504. for (const f of fs.readdirSync(KS_DIR)) {
  505. if (!f.endsWith('.json')) continue;
  506. const raw = JSON.parse(fs.readFileSync(path.join(KS_DIR, f), 'utf8'));
  507. if (raw._error) continue;
  508. if (raw.type === 'hotlist' && raw.videos) {
  509. const catName = raw.name || raw.category;
  510. const hypos = raw.hypotheses || [];
  511. for (const v of raw.videos) {
  512. const caption = v.caption || '';
  513. if (caption.length < 3) continue;
  514. const item = buildItem({
  515. id: `hl_${v.photo_id}`,
  516. platform: 'kuaishou',
  517. product: catName,
  518. keyword: catName,
  519. type: 'hotlist_video',
  520. nickname: v.user?.nickname || v.user?.name || '匿名',
  521. ip: '',
  522. content: caption.slice(0, 500),
  523. likes: v.like_count || 0,
  524. rating: null,
  525. kwHypos: hypos,
  526. });
  527. if (item) {
  528. out.items.push(item);
  529. out.hotlistItems.push({ ...item, rank: v.rank, hot_value: v.hot_value });
  530. flat.push(item);
  531. }
  532. }
  533. out.meta.keywords[catName] = (out.meta.keywords[catName] || 0) + raw.videos.length;
  534. continue;
  535. }
  536. if (!raw.comments) continue;
  537. const kw = raw.keyword;
  538. const kwHypos = raw.hypotheses || [];
  539. for (const v of raw.top_videos || []) {
  540. const caption = v.caption || '';
  541. if (!caption || caption.length < 3) continue;
  542. const item = buildItem({
  543. id: `ks_video_${v.id}`,
  544. platform: 'kuaishou',
  545. product: kw,
  546. keyword: kw,
  547. type: 'video_caption',
  548. nickname: v.user?.nickname || '匿名',
  549. ip: '',
  550. content: caption.slice(0, 500),
  551. likes: v.like_count || 0,
  552. rating: null,
  553. kwHypos,
  554. });
  555. if (item) { out.items.push(item); flat.push(item); }
  556. }
  557. for (const [videoId, cmts] of Object.entries(raw.comments)) {
  558. for (const c of cmts) {
  559. if (!c.content) continue;
  560. const item = buildItem({
  561. id: `ks_${videoId}_${c.id}`,
  562. platform: 'kuaishou',
  563. product: kw,
  564. keyword: kw,
  565. type: 'comment',
  566. nickname: c.user?.nickname || '匿名',
  567. ip: c.ip_location || '',
  568. content: c.content.slice(0, 500),
  569. likes: c.like_count || 0,
  570. rating: null,
  571. kwHypos,
  572. });
  573. if (item) { out.items.push(item); flat.push(item); }
  574. }
  575. }
  576. out.meta.keywords[kw] = (out.meta.keywords[kw] || 0) + (raw.total_videos_found || 0);
  577. }
  578. }
  579. out.meta.platforms['kuaishou'] = out.items.length;
  580. out.meta.productsCount = Object.keys(out.meta.keywords).length;
  581. out.meta.keywordsCount = Object.keys(out.meta.keywords).length;
  582. out.meta.comments = out.items.length;
  583. for (const it of out.items) {
  584. for (const h of (it.hypothesis || [])) {
  585. out.meta.hypotheses[h] = (out.meta.hypotheses[h] || 0) + 1;
  586. }
  587. }
  588. fs.writeFileSync(path.join(RAW_DIR, '_merged-v2.json'), JSON.stringify(out, null, 2), 'utf8');
  589. fs.writeFileSync(path.join(RAW_DIR, 'comments-flat-v2.jsonl'), flat.map((it) => JSON.stringify(it)).join('\n'), 'utf8');
  590. console.log(` ✅ merged: ${out.items.length} items | ${out.meta.productsCount} keywords`);
  591. return out;
  592. }
  593. async function runBatch(batchNum, opts) {
  594. const batch = BATCHES[batchNum];
  595. if (!batch) throw new Error(`unknown batch: ${batchNum}`);
  596. console.log(`\n▶ Batch ${batchNum}: ${batch.name}`);
  597. let totalNotes = 0;
  598. let totalComments = 0;
  599. if (batch.kuaishou) {
  600. for (const task of batch.kuaishou) {
  601. try {
  602. const r = await collectKs(task, opts);
  603. totalNotes += r.notes || 0;
  604. totalComments += r.comments || 0;
  605. } catch (e) {
  606. console.log(` ✗ ks:${task.kw}: ${e.message}`);
  607. auditLog(`ks EXC ${task.kw}: ${e.message}`);
  608. }
  609. await sleep(500);
  610. }
  611. }
  612. console.log(` 📊 Batch ${batchNum} 合计: ${totalNotes} 视频 / ${totalComments} 评论`);
  613. }
  614. async function main() {
  615. const argv = process.argv.slice(2);
  616. const opts = { force: argv.includes('--force') };
  617. const batchArg = argv.find((a) => a.startsWith('--batch='));
  618. const isMerge = argv.includes('--merge');
  619. console.log('\n╔═══════════════════════════════════════════════════════════╗');
  620. console.log('║ KS-Chanel Analysis V2 · 快手(Kuaishou)数据采集 ║');
  621. console.log('║ 目标: L1-6 最担心/最看重 + 情绪价值关键词 ║');
  622. console.log('╚═══════════════════════════════════════════════════════════╝');
  623. console.log(` TikHub Token: ${TIKHUB_TOKEN ? '✓' : '✗'}`);
  624. console.log(` API Host: ${API_HOST}`);
  625. if (batchArg) {
  626. const bn = batchArg.split('=')[1];
  627. if (bn === 'all') {
  628. for (const k of Object.keys(BATCHES)) await runBatch(k, opts);
  629. } else {
  630. await runBatch(bn, opts);
  631. }
  632. }
  633. if (isMerge || batchArg) {
  634. console.log('\n▶ Merging V2 data...');
  635. mergeAll();
  636. }
  637. if (!batchArg && !isMerge) {
  638. console.log('\nUsage:');
  639. console.log(' --batch=1 P1 · 核心担忧:被骗与押金');
  640. console.log(' --batch=2 P2 · 最看重:薪资与权益保障');
  641. console.log(' --batch=3 P3 · 情绪价值:尊严与归属感');
  642. console.log(' --batch=4 P4 · 平台信任与口碑验证');
  643. console.log(' --batch=5 P5 · 快聘生态与直播求职');
  644. console.log(' --batch=6 P6 · 风险防范与自我保护');
  645. console.log(' --batch=all 执行所有批次');
  646. console.log(' --merge 合并数据');
  647. console.log(' --force 强制重抓');
  648. console.log('\n假设体系: H1-H12');
  649. console.log(' H1: 快聘是蓝领求职主阵地');
  650. console.log(' H2: 薪资透明度是核心关切');
  651. console.log(' H3: 入职便捷性');
  652. console.log(' H4: 食宿条件影响决策');
  653. console.log(' H5: 平台信任与口碑');
  654. console.log(' H6: 被骗焦虑是普遍痛点 ← 重点');
  655. console.log(' H7: 黑中介/克扣是核心风险 ← 重点');
  656. console.log(' H8: 工资结算方式差异');
  657. console.log(' H9: 职业尊严与社会认同 ← 情绪价值');
  658. console.log(' H10: 同事关系与工作氛围 ← 情绪价值');
  659. console.log(' H11: 直播带岗降低信息不对称');
  660. console.log(' H12: 快聘战略地位验证');
  661. }
  662. }
  663. if (require.main === module) {
  664. main().catch((e) => { console.error('fatal:', e); process.exit(1); });
  665. }
  666. module.exports = { BATCHES, HYPOTHESIS_KEYWORDS, HYPOTHESIS_COLORS, mergeAll, collectKs };