xiaohongshu-trend-collector.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const os = require('os');
  5. const https = require('https');
  6. const { URL } = require('url');
  7. const {
  8. buildSampleDataset,
  9. keywordMatrixFromProfile
  10. } = require('./industry-trend-report');
  11. const DEFAULT_RESULT_PREFIX = 'XIAOHONGSHU_TREND_COLLECTOR_RESULT';
  12. const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
  13. const XHS_PROJECT_DIR = path.join(PROJECT_ROOT, 'xiaohongshu');
  14. const OPENCLAW_SKILLS_DIR = path.join(os.homedir(), '.openclaw', 'skills');
  15. function parseArgs(argv) {
  16. const args = {};
  17. for (let i = 0; i < argv.length; i++) {
  18. const token = argv[i];
  19. if (!token.startsWith('--')) continue;
  20. const eq = token.indexOf('=');
  21. if (eq >= 0) {
  22. args[token.slice(2, eq)] = token.slice(eq + 1);
  23. } else {
  24. const key = token.slice(2);
  25. const next = argv[i + 1];
  26. if (next && !next.startsWith('--')) {
  27. args[key] = next;
  28. i++;
  29. } else {
  30. args[key] = true;
  31. }
  32. }
  33. }
  34. return args;
  35. }
  36. function readJson(filePath) {
  37. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  38. }
  39. function writeJson(filePath, data) {
  40. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  41. fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
  42. }
  43. function toBool(value, fallback = false) {
  44. if (typeof value === 'boolean') return value;
  45. if (value === undefined || value === null || value === '') return fallback;
  46. return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase());
  47. }
  48. function toInt(value, fallback) {
  49. const parsed = Number.parseInt(String(value ?? ''), 10);
  50. return Number.isFinite(parsed) ? parsed : fallback;
  51. }
  52. function sleep(ms) {
  53. return new Promise(resolve => setTimeout(resolve, ms));
  54. }
  55. function uniqueBy(items, keyFn) {
  56. const seen = new Set();
  57. const out = [];
  58. items.forEach(item => {
  59. const key = keyFn(item);
  60. if (!key || seen.has(key)) return;
  61. seen.add(key);
  62. out.push(item);
  63. });
  64. return out;
  65. }
  66. function normalizeCount(value) {
  67. if (value === undefined || value === null || value === '') return 0;
  68. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  69. const text = String(value).trim().toLowerCase();
  70. if (!text) return 0;
  71. const match = text.match(/([\d.]+)/);
  72. if (!match) return 0;
  73. const base = Number.parseFloat(match[1]);
  74. if (!Number.isFinite(base)) return 0;
  75. if (text.includes('w') || text.includes('万')) return Math.round(base * 10000);
  76. if (text.includes('k') || text.includes('千')) return Math.round(base * 1000);
  77. return Math.round(base);
  78. }
  79. function noteScore(note) {
  80. return normalizeCount(note.liked_count || note.likeCount) +
  81. normalizeCount(note.collected_count || note.collectCount) * 1.5 +
  82. normalizeCount(note.comments_count || note.commentCount) * 3 +
  83. normalizeCount(note.shared_count || note.shareCount) * 2;
  84. }
  85. function loadSkillConfig(name) {
  86. const installedPath = path.join(OPENCLAW_SKILLS_DIR, name, 'api-config.json');
  87. const projectPath = path.join(XHS_PROJECT_DIR, name, 'api-config.json');
  88. const configPath = fs.existsSync(installedPath) ? installedPath : projectPath;
  89. if (!fs.existsSync(configPath)) {
  90. throw new Error(`Missing Xiaohongshu skill config: ${name}`);
  91. }
  92. return { config: readJson(configPath), source: configPath };
  93. }
  94. function buildUrl(skillConfig, params) {
  95. const url = new URL(skillConfig.endpoint.url);
  96. Object.entries(params).forEach(([key, value]) => {
  97. if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
  98. });
  99. return url.toString();
  100. }
  101. async function requestJson(url, headers, options = {}) {
  102. const maxAttempts = options.maxAttempts || 3;
  103. const retryOn = new Set([400, 408, 429, 500, 502, 503, 504]);
  104. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  105. try {
  106. const result = await new Promise((resolve, reject) => {
  107. const u = new URL(url);
  108. const req = https.request(
  109. {
  110. hostname: u.hostname,
  111. path: u.pathname + u.search,
  112. method: 'GET',
  113. headers,
  114. timeout: options.timeoutMs || 60000
  115. },
  116. res => {
  117. let body = '';
  118. res.on('data', chunk => {
  119. body += chunk;
  120. });
  121. res.on('end', () => resolve({ status: res.statusCode, body }));
  122. }
  123. );
  124. req.on('error', reject);
  125. req.on('timeout', () => {
  126. req.destroy();
  127. reject(new Error('request timeout'));
  128. });
  129. req.end();
  130. });
  131. let json;
  132. try {
  133. json = JSON.parse(result.body);
  134. } catch {
  135. json = undefined;
  136. }
  137. if (result.status === 200) {
  138. return { status: result.status, body: result.body, json };
  139. }
  140. if (retryOn.has(result.status) && attempt < maxAttempts) {
  141. await sleep((options.retryDelayMs || 1200) * attempt);
  142. continue;
  143. }
  144. return { status: result.status, body: result.body, json };
  145. } catch (error) {
  146. if (attempt >= maxAttempts) throw error;
  147. await sleep((options.retryDelayMs || 1200) * attempt);
  148. }
  149. }
  150. throw new Error('max retries exceeded');
  151. }
  152. function unwrapData(json) {
  153. if (!json) return {};
  154. return json.data?.data || json.data || json;
  155. }
  156. function pickTags(note) {
  157. const rawTags = note.tag_info || note.tagList || note.tags || [];
  158. if (!Array.isArray(rawTags)) return [];
  159. return rawTags.map(tag => tag.name || tag.tag_name || tag.title || tag).filter(Boolean).map(String);
  160. }
  161. function pickImages(note) {
  162. const images = note.images_list || note.image_list || note.images || [];
  163. if (!Array.isArray(images)) return [];
  164. return images
  165. .map(image => image.url || image.url_default || image.info_list?.[0]?.url || image)
  166. .filter(Boolean)
  167. .map(String);
  168. }
  169. function normalizeNote(raw, keyword, detail) {
  170. const note = detail && detail.id ? detail : raw;
  171. const id = note.id || raw.id || raw.note_id;
  172. const author = note.user || raw.user || {};
  173. return {
  174. id,
  175. platform: 'xiaohongshu',
  176. keyword,
  177. title: note.title || raw.title || '',
  178. content: note.desc || raw.desc || note.content || '',
  179. author: author.nickname || author.name || '',
  180. authorId: author.userid || author.user_id || '',
  181. type: note.type || raw.type || '',
  182. likeCount: normalizeCount(note.liked_count ?? raw.liked_count),
  183. collectCount: normalizeCount(note.collected_count ?? raw.collected_count),
  184. commentCount: normalizeCount(note.comments_count ?? raw.comments_count),
  185. shareCount: normalizeCount(note.shared_count ?? raw.shared_count),
  186. timestamp: note.timestamp || raw.timestamp || '',
  187. ipLocation: note.ip_location || raw.ip_location || '',
  188. url: id ? `https://www.xiaohongshu.com/explore/${id}` : '',
  189. tags: pickTags(note).length ? pickTags(note) : pickTags(raw),
  190. images: pickImages(note).length ? pickImages(note) : pickImages(raw),
  191. raw
  192. };
  193. }
  194. function normalizeComment(raw, noteId, keyword) {
  195. const user = raw.user_info || raw.user || {};
  196. return {
  197. id: raw.id || raw.comment_id || `${noteId}-${Math.random().toString(36).slice(2, 8)}`,
  198. noteId,
  199. platform: 'xiaohongshu',
  200. keyword,
  201. text: raw.content || raw.text || '',
  202. likeCount: normalizeCount(raw.like_count || raw.liked_count || raw.likeCount),
  203. author: user.nickname || user.name || '',
  204. authorId: user.user_id || user.userid || '',
  205. ipLocation: raw.ip_location || '',
  206. createTime: raw.create_time || '',
  207. subCommentCount: normalizeCount(raw.sub_comment_count || raw.subCommentCount),
  208. raw
  209. };
  210. }
  211. function keywordRows(keywordMatrix, keywordLimit) {
  212. return (keywordMatrix || [])
  213. .map(row => (typeof row === 'string' ? { keyword: row } : row))
  214. .filter(row => row && row.keyword)
  215. .slice(0, keywordLimit);
  216. }
  217. async function collectXiaohongshuDataset(options = {}) {
  218. const profile = options.profile || {};
  219. const keywordMatrix = options.keywordMatrix || keywordMatrixFromProfile(profile);
  220. const mode = options.mode || 'sample';
  221. const output = options.output ? path.resolve(options.output) : undefined;
  222. const fallbackSample = options.fallbackSample !== false;
  223. if (mode !== 'live') {
  224. const dataset = buildSampleDataset(profile, keywordMatrix);
  225. if (output) writeJson(output, dataset);
  226. return {
  227. status: 'ok',
  228. mode: 'sample',
  229. dataset,
  230. output,
  231. noteCount: dataset.notes.length,
  232. commentCount: dataset.comments.length,
  233. warnings: ['当前使用 P0 样例数据;需要真实采集时设置 collectionMode=live。']
  234. };
  235. }
  236. const errors = [];
  237. const warnings = [];
  238. const notes = [];
  239. const comments = [];
  240. const keywordLimit = Math.max(1, toInt(options.keywordLimit, 3));
  241. const searchPages = Math.max(1, toInt(options.searchPages, 1));
  242. const notesPerKeyword = Math.max(1, toInt(options.notesPerKeyword, 3));
  243. const maxCommentPages = Math.max(0, toInt(options.maxCommentPages, 1));
  244. const delayMs = Math.max(0, toInt(options.delayMs, 900));
  245. const sort = options.sort || 'popularity_descending';
  246. const noteType = options.noteType || '_0';
  247. let searchSkill;
  248. let detailSkill;
  249. let commentsSkill;
  250. try {
  251. searchSkill = loadSkillConfig('xiaohongshu-search-notes');
  252. detailSkill = loadSkillConfig('xiaohongshu-note-detail');
  253. commentsSkill = loadSkillConfig('xiaohongshu-note-comments');
  254. } catch (error) {
  255. if (!fallbackSample) throw error;
  256. const dataset = buildSampleDataset(profile, keywordMatrix);
  257. if (output) writeJson(output, dataset);
  258. return {
  259. status: 'fallback_sample',
  260. mode: 'sample',
  261. dataset,
  262. output,
  263. noteCount: dataset.notes.length,
  264. commentCount: dataset.comments.length,
  265. warnings: [`真实小红书采集配置不可用,已退回样例数据:${error.message}`],
  266. errors: [{ stage: 'load_config', message: error.message }]
  267. };
  268. }
  269. const rows = keywordRows(keywordMatrix, keywordLimit);
  270. for (const row of rows) {
  271. const keyword = row.keyword;
  272. const searched = [];
  273. for (let page = 1; page <= searchPages; page++) {
  274. const url = buildUrl(searchSkill.config, { keyword, page, sort, note_type: noteType });
  275. try {
  276. const response = await requestJson(url, searchSkill.config.endpoint.headers, searchSkill.config.retry || {});
  277. if (response.status !== 200) {
  278. errors.push({
  279. stage: 'search_notes',
  280. keyword,
  281. page,
  282. status: response.status,
  283. message: response.body.slice(0, 240)
  284. });
  285. continue;
  286. }
  287. const inner = unwrapData(response.json);
  288. const items = Array.isArray(inner.items) ? inner.items : [];
  289. searched.push(...items.map(item => item.note || item).filter(Boolean));
  290. } catch (error) {
  291. errors.push({ stage: 'search_notes', keyword, page, message: error.message });
  292. }
  293. if (delayMs) await sleep(delayMs);
  294. }
  295. const selected = uniqueBy(searched, item => item.id || item.note_id)
  296. .sort((a, b) => noteScore(b) - noteScore(a))
  297. .slice(0, notesPerKeyword);
  298. for (const rawNote of selected) {
  299. const noteId = rawNote.id || rawNote.note_id;
  300. if (!noteId) continue;
  301. let detailNote;
  302. try {
  303. const detailUrl = buildUrl(detailSkill.config, { note_id: noteId });
  304. const detailResponse = await requestJson(detailUrl, detailSkill.config.endpoint.headers, detailSkill.config.retry || {});
  305. if (detailResponse.status === 200) {
  306. const inner = unwrapData(detailResponse.json);
  307. detailNote = inner.note_list?.[0] || inner.note || undefined;
  308. const embeddedComments = Array.isArray(inner.comment_list) ? inner.comment_list : [];
  309. comments.push(...embeddedComments.map(comment => normalizeComment(comment, noteId, keyword)));
  310. } else {
  311. warnings.push(`笔记详情失败:${noteId} / HTTP ${detailResponse.status}`);
  312. }
  313. } catch (error) {
  314. warnings.push(`笔记详情失败:${noteId} / ${error.message}`);
  315. }
  316. notes.push(normalizeNote(rawNote, keyword, detailNote));
  317. if (delayMs) await sleep(delayMs);
  318. let cursor = '';
  319. for (let page = 1; page <= maxCommentPages; page++) {
  320. try {
  321. const commentsUrl = buildUrl(commentsSkill.config, { note_id: noteId, cursor });
  322. const commentsResponse = await requestJson(commentsUrl, commentsSkill.config.endpoint.headers, commentsSkill.config.retry || {});
  323. if (commentsResponse.status !== 200) {
  324. warnings.push(`评论采集失败:${noteId} / HTTP ${commentsResponse.status}`);
  325. break;
  326. }
  327. const inner = unwrapData(commentsResponse.json);
  328. const pageComments = Array.isArray(inner.comments) ? inner.comments : [];
  329. comments.push(...pageComments.map(comment => normalizeComment(comment, noteId, keyword)));
  330. if (!inner.has_more || !inner.cursor) break;
  331. cursor = inner.cursor;
  332. } catch (error) {
  333. warnings.push(`评论采集失败:${noteId} / ${error.message}`);
  334. break;
  335. }
  336. if (delayMs) await sleep(delayMs);
  337. }
  338. }
  339. }
  340. const dataset = {
  341. metadata: {
  342. platform: 'xiaohongshu',
  343. dataNature: 'live xiaohongshu trend collection',
  344. generatedAt: new Date().toISOString(),
  345. keywordLimit,
  346. searchPages,
  347. notesPerKeyword,
  348. maxCommentPages
  349. },
  350. notes: uniqueBy(notes, note => note.id),
  351. comments: uniqueBy(comments, comment => comment.id)
  352. };
  353. if (!dataset.notes.length && fallbackSample) {
  354. const sample = buildSampleDataset(profile, keywordMatrix);
  355. if (output) writeJson(output, sample);
  356. return {
  357. status: 'fallback_sample',
  358. mode: 'sample',
  359. dataset: sample,
  360. output,
  361. noteCount: sample.notes.length,
  362. commentCount: sample.comments.length,
  363. warnings: ['真实小红书采集未拿到有效笔记,已退回样例数据。', ...warnings],
  364. errors
  365. };
  366. }
  367. if (output) writeJson(output, dataset);
  368. return {
  369. status: errors.length ? 'partial' : 'ok',
  370. mode: 'live',
  371. dataset,
  372. output,
  373. noteCount: dataset.notes.length,
  374. commentCount: dataset.comments.length,
  375. warnings,
  376. errors
  377. };
  378. }
  379. async function main() {
  380. const args = parseArgs(process.argv.slice(2));
  381. const output = path.resolve(args.output || 'outputs/industry-trend-intelligence/xiaohongshu-sample/raw-input.json');
  382. const profile = args.profile ? readJson(path.resolve(args.profile)) : {};
  383. const keywordMatrix = args['keyword-matrix'] ? readJson(path.resolve(args['keyword-matrix'])) : keywordMatrixFromProfile(profile);
  384. const mode = args.mode || args['collection-mode'] || args.collectionMode || (toBool(args.live) ? 'live' : 'sample');
  385. const result = await collectXiaohongshuDataset({
  386. profile,
  387. keywordMatrix,
  388. mode,
  389. output,
  390. fallbackSample: !toBool(args['no-fallback-sample']),
  391. keywordLimit: args['keyword-limit'] || args.keywordLimit,
  392. searchPages: args['search-pages'] || args.searchPages,
  393. notesPerKeyword: args['notes-per-keyword'] || args.notesPerKeyword,
  394. maxCommentPages: args['max-comment-pages'] || args.maxCommentPages,
  395. delayMs: args['delay-ms'] || args.delayMs,
  396. sort: args.sort,
  397. noteType: args['note-type'] || args.noteType
  398. });
  399. const prefix = args['result-prefix'] || args.resultPrefix || DEFAULT_RESULT_PREFIX;
  400. console.log(`${prefix}=${JSON.stringify({
  401. status: result.status,
  402. mode: result.mode,
  403. output: result.output,
  404. noteCount: result.noteCount,
  405. commentCount: result.commentCount,
  406. warningCount: (result.warnings || []).length,
  407. errorCount: (result.errors || []).length,
  408. warnings: result.warnings || [],
  409. errors: result.errors || []
  410. })}`);
  411. }
  412. if (require.main === module) {
  413. main().catch(error => {
  414. console.error(error && error.stack ? error.stack : String(error));
  415. process.exit(1);
  416. });
  417. }
  418. module.exports = {
  419. collectXiaohongshuDataset
  420. };