ks-collect.js 29 KB

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