test-xiaohongshu-skills.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. #!/usr/bin/env node
  2. /**
  3. * 小红书 Skill 端到端验证脚本
  4. *
  5. * 验证:search_notes → get_note_info → get_note_comments → get_user_info 全链路
  6. * 基于 ~/.openclaw/skills/xiaohongshu/ 下各 skill 的 api-config.json 读取 endpoint + header
  7. * 带自动重试(handles TikHub intermittent 400s)
  8. */
  9. const fs = require('fs');
  10. const path = require('path');
  11. const os = require('os');
  12. const https = require('https');
  13. const { URL } = require('url');
  14. const SKILLS_DIR = path.join(os.homedir(), '.openclaw', 'skills');
  15. const PROJECT_DIR = path.resolve(__dirname, '..', '..');
  16. const XHS_DIR = path.join(PROJECT_DIR, 'xiaohongshu');
  17. // ============================================================
  18. // HTTP helper with retry
  19. // ============================================================
  20. async function request(url, headers, maxAttempts = 3) {
  21. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  22. try {
  23. const result = await new Promise((resolve, reject) => {
  24. const u = new URL(url);
  25. const req = https.request(
  26. {
  27. hostname: u.hostname,
  28. path: u.pathname + u.search,
  29. method: 'GET',
  30. headers,
  31. timeout: 60000,
  32. },
  33. (res) => {
  34. let body = '';
  35. res.on('data', (chunk) => (body += chunk));
  36. res.on('end', () => resolve({ status: res.statusCode, body }));
  37. }
  38. );
  39. req.on('error', reject);
  40. req.on('timeout', () => {
  41. req.destroy();
  42. reject(new Error('timeout'));
  43. });
  44. req.end();
  45. });
  46. if (result.status === 200) return result;
  47. if (result.status === 400 || result.status === 429 || result.status >= 500) {
  48. const waitMs = 2000 * Math.pow(2, attempt - 1);
  49. console.log(` [attempt ${attempt}/${maxAttempts}] status=${result.status}, retrying in ${waitMs}ms...`);
  50. await new Promise((r) => setTimeout(r, waitMs));
  51. continue;
  52. }
  53. return result;
  54. } catch (e) {
  55. console.log(` [attempt ${attempt}/${maxAttempts}] error: ${e.message}`);
  56. if (attempt === maxAttempts) throw e;
  57. await new Promise((r) => setTimeout(r, 2000 * Math.pow(2, attempt - 1)));
  58. }
  59. }
  60. throw new Error('max retries exceeded');
  61. }
  62. // ============================================================
  63. // Load skill config (prefer installed, fall back to project dir)
  64. // ============================================================
  65. function loadSkill(name) {
  66. const installedPath = path.join(SKILLS_DIR, name, 'api-config.json');
  67. const projectPath = path.join(XHS_DIR, name, 'api-config.json');
  68. const configPath = fs.existsSync(installedPath) ? installedPath : projectPath;
  69. if (!fs.existsSync(configPath)) throw new Error(`skill not found: ${name}`);
  70. return { config: JSON.parse(fs.readFileSync(configPath, 'utf-8')), source: configPath };
  71. }
  72. function buildUrl(skillConfig, params) {
  73. const base = skillConfig.endpoint.url;
  74. const query = new URLSearchParams(params).toString();
  75. return `${base}?${query}`;
  76. }
  77. // ============================================================
  78. // Main test flow
  79. // ============================================================
  80. async function main() {
  81. const keyword = process.argv[2] || 'lactobacillus';
  82. console.log('');
  83. console.log(`╔══════════════════════════════════════════════════════════╗`);
  84. console.log(`║ Xiaohongshu Skill E2E Test — keyword: ${keyword.padEnd(18)} ║`);
  85. console.log(`╚══════════════════════════════════════════════════════════╝`);
  86. console.log('');
  87. // ─── Step 1: search_notes ───
  88. console.log('Step 1/4: xiaohongshu-search-notes');
  89. const s1 = loadSkill('xiaohongshu-search-notes');
  90. console.log(` source: ${s1.source}`);
  91. const url1 = buildUrl(s1.config, { keyword, page: 1 });
  92. console.log(` GET ${url1}`);
  93. const r1 = await request(url1, s1.config.endpoint.headers);
  94. console.log(` → status=${r1.status}, len=${r1.body.length}`);
  95. if (r1.status !== 200) {
  96. console.log(` ❌ body: ${r1.body.slice(0, 300)}`);
  97. process.exit(1);
  98. }
  99. const j1 = JSON.parse(r1.body);
  100. // Gateway response is double-wrapped: {data: {data: {items: [...]}, code, success, ...}}
  101. const inner1 = j1.data?.data || j1.data || {};
  102. const items = inner1.items || [];
  103. console.log(` ✅ items=${items.length}, inner code=${j1.data?.code}, msg=${j1.data?.msg}`);
  104. if (items.length === 0) {
  105. console.log(' ⚠️ no items returned, abort downstream tests');
  106. console.log(' data keys:', Object.keys(j1.data || {}).join(', '));
  107. console.log(' inner keys:', Object.keys(inner1).join(', '));
  108. process.exit(1);
  109. }
  110. // pick a note with a userid + most comments
  111. // items[] wraps each note as { model_type: 'note', note: {...} }
  112. const candidates = items.map((w) => w.note || w).filter((n) => n && n.user?.userid);
  113. const note = candidates.sort((a, b) => (b.comments_count || 0) - (a.comments_count || 0))[0] || items[0]?.note || items[0];
  114. const noteId = note.id;
  115. const userId = note.user?.userid;
  116. const xsecToken = note.xsec_token;
  117. console.log(` sampled note:`);
  118. console.log(` id=${noteId}`);
  119. console.log(` title=${(note.title || '').slice(0, 50)}`);
  120. console.log(` user.userid=${userId} (${note.user?.nickname})`);
  121. console.log(` stats: liked=${note.liked_count} comments=${note.comments_count} collected=${note.collected_count} shared=${note.shared_count}`);
  122. await new Promise((r) => setTimeout(r, 1500));
  123. // ─── Step 2: get_note_info ───
  124. console.log('');
  125. console.log('Step 2/4: xiaohongshu-note-detail');
  126. const s2 = loadSkill('xiaohongshu-note-detail');
  127. const url2 = buildUrl(s2.config, { note_id: noteId });
  128. console.log(` GET ${url2}`);
  129. const r2 = await request(url2, s2.config.endpoint.headers);
  130. console.log(` → status=${r2.status}, len=${r2.body.length}`);
  131. if (r2.status === 200) {
  132. const j2 = JSON.parse(r2.body);
  133. const inner2 = j2.data?.data || j2.data || {};
  134. const n = inner2.note_list?.[0] || inner2.note || {};
  135. const cmts = inner2.comment_list || [];
  136. console.log(` ✅ inner code=${j2.data?.code}, success=${j2.data?.success}`);
  137. console.log(` note.id=${n.id}, type=${n.type}, desc_len=${(n.desc || '').length}, ip=${n.ip_location}`);
  138. console.log(` embedded_comment_list: ${cmts.length} comments`);
  139. if (!n.id) console.log(` inner keys: ${Object.keys(inner2).join(', ')}`);
  140. } else {
  141. console.log(` ⚠️ body: ${r2.body.slice(0, 300)}`);
  142. }
  143. await new Promise((r) => setTimeout(r, 1500));
  144. // ─── Step 3: get_note_comments ───
  145. console.log('');
  146. console.log('Step 3/4: xiaohongshu-note-comments');
  147. const s3 = loadSkill('xiaohongshu-note-comments');
  148. const url3 = buildUrl(s3.config, { note_id: noteId, cursor: '' });
  149. console.log(` GET ${url3}`);
  150. const r3 = await request(url3, s3.config.endpoint.headers);
  151. console.log(` → status=${r3.status}, len=${r3.body.length}`);
  152. if (r3.status === 200) {
  153. const j3 = JSON.parse(r3.body);
  154. const inner3 = j3.data?.data || j3.data || {};
  155. const comments = inner3.comments || [];
  156. console.log(` ✅ inner code=${j3.data?.code}, msg=${j3.data?.msg}`);
  157. console.log(` comments=${comments.length}, has_more=${inner3.has_more}, cursor=${inner3.cursor?.slice(0, 20)}`);
  158. if (comments.length > 0) {
  159. const c0 = comments[0];
  160. console.log(` top comment: "${(c0.content || '').slice(0, 80)}" (likes=${c0.like_count})`);
  161. } else {
  162. console.log(` inner keys: ${Object.keys(inner3).join(', ')}`);
  163. }
  164. } else {
  165. console.log(` ⚠️ body: ${r3.body.slice(0, 300)}`);
  166. }
  167. await new Promise((r) => setTimeout(r, 1500));
  168. // ─── Step 4: get_user_info ───
  169. console.log('');
  170. console.log('Step 4/4: xiaohongshu-user-info');
  171. const s4 = loadSkill('xiaohongshu-user-info');
  172. const url4 = buildUrl(s4.config, { user_id: userId });
  173. console.log(` GET ${url4}`);
  174. const r4 = await request(url4, s4.config.endpoint.headers);
  175. console.log(` → status=${r4.status}, len=${r4.body.length}`);
  176. if (r4.status === 200) {
  177. const j4 = JSON.parse(r4.body);
  178. const u = j4.data?.data || j4.data?.user || j4.data || {};
  179. const fans = (u.interactions || []).find((x) => x.type === 'fans')?.count || u.fans;
  180. const follows = (u.interactions || []).find((x) => x.type === 'follows')?.count || u.follows;
  181. const interaction = (u.interactions || []).find((x) => x.type === 'interaction')?.count;
  182. console.log(` ✅ inner code=${j4.data?.code}, success=${j4.data?.success}`);
  183. console.log(` userid=${u.userid}, nickname=${u.nickname}`);
  184. console.log(` fans=${fans}, follows=${follows}, interaction=${interaction}, gender=${u.gender}, ip=${u.ip_location}`);
  185. console.log(` collected_notes=${u.collected_notes_num}, verified=${u.red_official_verified} (type=${u.red_official_verify_type})`);
  186. if (u.desc) console.log(` desc: ${u.desc.slice(0, 80)}`);
  187. if (!u.nickname) console.log(` inner keys: ${Object.keys(u).join(', ')}`);
  188. } else {
  189. console.log(` ⚠️ body: ${r4.body.slice(0, 300)}`);
  190. }
  191. console.log('');
  192. console.log('═══════════════════════════════════════════════════════════');
  193. console.log(' Summary');
  194. console.log('═══════════════════════════════════════════════════════════');
  195. console.log(` 1/4 search_notes : ${r1.status === 200 ? '✅' : '❌'} ${r1.status}`);
  196. console.log(` 2/4 note-detail : ${r2.status === 200 ? '✅' : '⚠️'} ${r2.status}`);
  197. console.log(` 3/4 note-comments : ${r3.status === 200 ? '✅' : '⚠️'} ${r3.status}`);
  198. console.log(` 4/4 user-info : ${r4.status === 200 ? '✅' : '⚠️'} ${r4.status}`);
  199. console.log('');
  200. }
  201. main().catch((err) => {
  202. console.error('❌ fatal:', err);
  203. process.exit(1);
  204. });