xiaohongshu-api.js 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. const VOC_SOCIAL_API_ROOT = 'https://server.fmode.cn/api/voc-social';
  2. const XIAOHONGSHU_APP_BASE_URL = `${VOC_SOCIAL_API_ROOT}/xiaohongshu/app`;
  3. function buildUrl(baseUrl, path, query = {}) {
  4. const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
  5. Object.entries(query).forEach(([key, value]) => {
  6. if (value !== undefined && value !== null && value !== '') {
  7. url.searchParams.set(key, String(value));
  8. }
  9. });
  10. return url.toString();
  11. }
  12. function unwrapData(json) {
  13. if (!json || typeof json !== 'object') return {};
  14. return json.data?.data || json.data || json;
  15. }
  16. function pickBusinessStatus(json) {
  17. const topCode = Number(json?.code);
  18. if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
  19. return topCode;
  20. }
  21. const nestedCode = Number(json?.data?.status_code);
  22. if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
  23. return nestedCode;
  24. }
  25. return 0;
  26. }
  27. function redactSecret(value) {
  28. return String(value || '')
  29. .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
  30. .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
  31. .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
  32. .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
  33. .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
  34. }
  35. async function requestJson(url, token) {
  36. const response = await fetch(url, {
  37. method: 'GET',
  38. headers: {
  39. Authorization: `Bearer ${token}`,
  40. Accept: 'application/json'
  41. }
  42. });
  43. const text = await response.text();
  44. let json;
  45. try {
  46. json = JSON.parse(text);
  47. } catch {
  48. json = undefined;
  49. }
  50. const businessStatus = pickBusinessStatus(json);
  51. if (!response.ok || businessStatus) {
  52. const message = json?.message || json?.msg || json?.data?.msg || text || `HTTP ${response.status}`;
  53. const error = new Error(redactSecret(message));
  54. error.httpStatus = businessStatus || response.status || 500;
  55. error.responseBody = redactSecret(text);
  56. throw error;
  57. }
  58. return { json, data: unwrapData(json), rawText: text };
  59. }
  60. class XiaohongshuApi {
  61. constructor(options = {}) {
  62. this.baseUrl = options.baseUrl || process.env.XIAOHONGSHU_API_BASE_URL || XIAOHONGSHU_APP_BASE_URL;
  63. this.token = options.token;
  64. }
  65. async searchNotes({ keyword, page = 1, sort = 'popularity_descending', noteType = '_0' }) {
  66. const url = buildUrl(this.baseUrl, 'search_notes', {
  67. keyword,
  68. page,
  69. sort,
  70. note_type: noteType
  71. });
  72. return requestJson(url, this.token);
  73. }
  74. async getNoteDetail({ noteId }) {
  75. const url = buildUrl(this.baseUrl, 'get_note_info', {
  76. note_id: noteId
  77. });
  78. return requestJson(url, this.token);
  79. }
  80. async getNoteComments({ noteId, cursor = '' }) {
  81. const url = buildUrl(this.baseUrl, 'get_note_comments', {
  82. note_id: noteId,
  83. cursor
  84. });
  85. return requestJson(url, this.token);
  86. }
  87. }
  88. module.exports = {
  89. VOC_SOCIAL_API_ROOT,
  90. XIAOHONGSHU_APP_BASE_URL,
  91. XiaohongshuApi
  92. };