live-collector.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. const { XiaohongshuApi } = require('../../providers/xiaohongshu-api');
  2. function normalizeCount(value) {
  3. if (value === undefined || value === null || value === '') return 0;
  4. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  5. const text = String(value).trim().toLowerCase();
  6. const match = text.match(/([\d.]+)/);
  7. if (!match) return 0;
  8. const base = Number.parseFloat(match[1]);
  9. if (!Number.isFinite(base)) return 0;
  10. if (text.includes('w') || text.includes('万')) return Math.round(base * 10000);
  11. if (text.includes('k') || text.includes('千')) return Math.round(base * 1000);
  12. return Math.round(base);
  13. }
  14. function noteScore(note) {
  15. return normalizeCount(note.likeCount) +
  16. normalizeCount(note.collectCount) * 1.5 +
  17. normalizeCount(note.commentCount) * 3 +
  18. normalizeCount(note.shareCount) * 2;
  19. }
  20. function pickTags(note) {
  21. const tags = note.tag_info || note.tagList || note.tags || [];
  22. if (!Array.isArray(tags)) return [];
  23. return tags
  24. .map(tag => tag.name || tag.tag_name || tag.title || tag)
  25. .filter(Boolean)
  26. .map(String);
  27. }
  28. function pickFirstString(...values) {
  29. return values
  30. .flat()
  31. .filter(value => typeof value === 'string')
  32. .map(value => value.trim())
  33. .find(Boolean) || '';
  34. }
  35. function pickFirstVerifiedUrl(...values) {
  36. return values
  37. .flat()
  38. .filter(value => typeof value === 'string')
  39. .map(value => value.trim())
  40. .find(value => value && hasXsecToken(value)) || '';
  41. }
  42. function nestedValue(object, path) {
  43. return path.split('.').reduce((current, key) => (
  44. current && typeof current === 'object' ? current[key] : undefined
  45. ), object);
  46. }
  47. function hasXsecToken(url) {
  48. return /[?&]xsec_token=/i.test(String(url || ''));
  49. }
  50. function pickWebUrl(note = {}, raw = {}) {
  51. const candidates = [
  52. 'mini_program_info.webpage_url',
  53. 'note_card.mini_program_info.webpage_url',
  54. 'share_info.link',
  55. 'note_card.share_info.link',
  56. 'shareLink',
  57. 'share_url',
  58. 'shareUrl',
  59. 'link',
  60. 'webpage_url',
  61. 'webUrl',
  62. 'url'
  63. ].flatMap(field => [nestedValue(note, field), nestedValue(raw, field)]);
  64. const withXsecToken = candidates.find(hasXsecToken);
  65. return withXsecToken || pickFirstString(candidates);
  66. }
  67. function looksLikeImageUrl(value) {
  68. return /^https?:\/\//i.test(String(value || ''));
  69. }
  70. function collectImageUrlsFromValue(value, result = []) {
  71. if (!value) return result;
  72. if (typeof value === 'string') {
  73. if (looksLikeImageUrl(value) && !result.includes(value)) result.push(value);
  74. return result;
  75. }
  76. if (Array.isArray(value)) {
  77. value.forEach(item => collectImageUrlsFromValue(item, result));
  78. return result;
  79. }
  80. if (typeof value !== 'object') return result;
  81. [
  82. 'url',
  83. 'url_default',
  84. 'url_pre',
  85. 'original_url',
  86. 'original',
  87. 'large',
  88. 'large_url',
  89. 'middle',
  90. 'middle_url',
  91. 'thumbnail',
  92. 'image_url',
  93. 'imageUrl',
  94. 'cover',
  95. 'cover_url',
  96. 'coverUrl',
  97. 'poster',
  98. 'poster_url',
  99. 'posterUrl',
  100. 'master_url'
  101. ].forEach(key => collectImageUrlsFromValue(value[key], result));
  102. [
  103. 'url_list',
  104. 'urlList',
  105. 'urlListHigh',
  106. 'info_list',
  107. 'infoList',
  108. 'images',
  109. 'image_list',
  110. 'imageList',
  111. 'image_info_list',
  112. 'imageInfoList',
  113. 'image_infos',
  114. 'imageInfos',
  115. 'display_image_info',
  116. 'displayImageInfo',
  117. 'image',
  118. 'image_info',
  119. 'imageInfo',
  120. 'multiMedia',
  121. 'multimedia'
  122. ].forEach(key => collectImageUrlsFromValue(value[key], result));
  123. return result;
  124. }
  125. function extractImageUrls(note = {}, raw = {}) {
  126. const result = [];
  127. [
  128. note.cover,
  129. raw.cover,
  130. note.note_card && note.note_card.cover,
  131. raw.note_card && raw.note_card.cover,
  132. note.note_card && note.note_card.display_image_info,
  133. raw.note_card && raw.note_card.display_image_info,
  134. note.note_card && note.note_card.image_info_list,
  135. raw.note_card && raw.note_card.image_info_list,
  136. note.image_list,
  137. raw.image_list,
  138. note.image_info_list,
  139. raw.image_info_list,
  140. note.display_image_info,
  141. raw.display_image_info,
  142. note.cover_info,
  143. raw.cover_info,
  144. note.images_list,
  145. raw.images_list,
  146. note.imageList,
  147. raw.imageList,
  148. note.note_card && note.note_card.image_list,
  149. raw.note_card && raw.note_card.image_list
  150. ].forEach(value => collectImageUrlsFromValue(value, result));
  151. return result;
  152. }
  153. function contentExcerpt(text, limit = 120) {
  154. const value = String(text || '').replace(/\s+/g, ' ').trim();
  155. if (value.length <= limit) return value;
  156. return `${value.slice(0, limit - 1)}...`;
  157. }
  158. function normalizeNote(raw, keyword, detail) {
  159. const note = detail && (detail.id || detail.note_id) ? detail : raw;
  160. const id = note.id || note.note_id || raw.id || raw.note_id;
  161. const user = note.user || raw.user || note.user_info || raw.user_info || {};
  162. const accessibleUrl = pickWebUrl(note, raw);
  163. const verifiedUrl = pickFirstVerifiedUrl(accessibleUrl);
  164. const fallbackUrl = id ? `https://www.xiaohongshu.com/discovery/item/${id}` : '';
  165. const imageUrls = extractImageUrls(note, raw);
  166. const content = note.desc || raw.desc || note.content || raw.content || '';
  167. return {
  168. id,
  169. platform: 'xiaohongshu',
  170. keyword,
  171. title: note.title || raw.title || '',
  172. content,
  173. contentExcerpt: contentExcerpt(content),
  174. rawExcerpt: contentExcerpt(content),
  175. author: user.nickname || user.name || '',
  176. authorId: user.userid || user.user_id || '',
  177. type: note.type || raw.type || '',
  178. likeCount: normalizeCount(note.liked_count ?? raw.liked_count ?? note.likeCount ?? raw.likeCount),
  179. collectCount: normalizeCount(note.collected_count ?? raw.collected_count ?? note.collectCount ?? raw.collectCount),
  180. commentCount: normalizeCount(note.comments_count ?? raw.comments_count ?? note.commentCount ?? raw.commentCount),
  181. shareCount: normalizeCount(note.shared_count ?? raw.shared_count ?? note.shareCount ?? raw.shareCount),
  182. timestamp: note.timestamp || raw.timestamp || '',
  183. url: verifiedUrl,
  184. webUrl: verifiedUrl,
  185. shareUrl: pickFirstString(
  186. nestedValue(note, 'share_info.link'),
  187. nestedValue(raw, 'share_info.link'),
  188. nestedValue(note, 'note_card.share_info.link'),
  189. nestedValue(raw, 'note_card.share_info.link')
  190. ),
  191. sourceUrl: verifiedUrl,
  192. sourceUrlVerified: Boolean(verifiedUrl),
  193. candidateUrl: accessibleUrl || fallbackUrl,
  194. fallbackUrl,
  195. coverUrl: imageUrls[0] || '',
  196. imageUrls,
  197. tags: pickTags(note).length ? pickTags(note) : pickTags(raw)
  198. };
  199. }
  200. function normalizeComment(raw, noteId, keyword) {
  201. const user = raw.user_info || raw.user || {};
  202. return {
  203. id: raw.id || raw.comment_id || `${noteId}-${Math.random().toString(36).slice(2, 8)}`,
  204. noteId,
  205. platform: 'xiaohongshu',
  206. keyword,
  207. text: raw.content || raw.text || '',
  208. likeCount: normalizeCount(raw.like_count || raw.liked_count || raw.likeCount),
  209. author: user.nickname || user.name || '',
  210. authorId: user.user_id || user.userid || '',
  211. createTime: raw.create_time || '',
  212. ipLocation: raw.ip_location || '',
  213. subCommentCount: normalizeCount(raw.sub_comment_count || raw.subCommentCount)
  214. };
  215. }
  216. function extractSearchNotes(data) {
  217. const items = data.items || data.note_list || data.notes || [];
  218. if (!Array.isArray(items)) return [];
  219. return items
  220. .map(item => item.note || item)
  221. .filter(note => note && (note.id || note.note_id));
  222. }
  223. function extractDetailNote(data) {
  224. const list = data.note_list || data.notes || [];
  225. return Array.isArray(list) && list[0] ? list[0] : data.note || data;
  226. }
  227. function extractComments(data) {
  228. const comments = data.comments || data.comment_list || [];
  229. return Array.isArray(comments) ? comments : [];
  230. }
  231. async function collectLiveDataset({ profile, token, options = {} }) {
  232. const api = new XiaohongshuApi({ token, baseUrl: options.baseUrl });
  233. const keywordLimit = Math.max(1, Number(options.keywordLimit || 1));
  234. const notesPerKeyword = Math.max(1, Number(options.notesPerKeyword || 1));
  235. const maxCommentPages = Math.max(0, Number(options.maxCommentPages ?? 1));
  236. const keywords = (profile.keywords || []).slice(0, keywordLimit);
  237. const notes = [];
  238. const comments = [];
  239. const warnings = [];
  240. const errors = [];
  241. for (const keyword of keywords) {
  242. try {
  243. const search = await api.searchNotes({
  244. keyword,
  245. page: 1,
  246. sort: options.sort || 'popularity_descending',
  247. noteType: options.noteType || '_0'
  248. });
  249. const rawNotes = extractSearchNotes(search.data)
  250. .map(raw => normalizeNote(raw, keyword))
  251. .sort((a, b) => noteScore(b) - noteScore(a))
  252. .slice(0, notesPerKeyword);
  253. for (const rawNote of rawNotes) {
  254. let note = rawNote;
  255. try {
  256. const detail = await api.getNoteDetail({ noteId: rawNote.id });
  257. note = normalizeNote(rawNote, keyword, extractDetailNote(detail.data));
  258. } catch (error) {
  259. warnings.push({
  260. stage: 'note_detail',
  261. keyword,
  262. noteId: rawNote.id,
  263. message: error.message,
  264. httpStatus: error.httpStatus
  265. });
  266. }
  267. notes.push(note);
  268. let cursor = '';
  269. for (let page = 0; page < maxCommentPages; page++) {
  270. try {
  271. const commentResult = await api.getNoteComments({ noteId: note.id, cursor });
  272. extractComments(commentResult.data).forEach(rawComment => {
  273. comments.push(normalizeComment(rawComment, note.id, keyword));
  274. });
  275. cursor = commentResult.data.cursor || '';
  276. if (!commentResult.data.has_more || !cursor) break;
  277. } catch (error) {
  278. warnings.push({
  279. stage: 'note_comments',
  280. keyword,
  281. noteId: note.id,
  282. message: error.message,
  283. httpStatus: error.httpStatus
  284. });
  285. break;
  286. }
  287. }
  288. }
  289. } catch (error) {
  290. errors.push({
  291. stage: 'search_notes',
  292. keyword,
  293. message: error.message,
  294. httpStatus: error.httpStatus
  295. });
  296. }
  297. }
  298. return {
  299. notes,
  300. comments,
  301. warnings,
  302. errors
  303. };
  304. }
  305. module.exports = {
  306. collectLiveDataset,
  307. normalizeNote,
  308. pickWebUrl,
  309. extractImageUrls,
  310. hasXsecToken
  311. };