#!/usr/bin/env node /** * 小红书 Skill 端到端验证脚本 * * 验证:search_notes → get_note_info → get_note_comments → get_user_info 全链路 * 基于 ~/.openclaw/skills/xiaohongshu/ 下各 skill 的 api-config.json 读取 endpoint + header * 带自动重试(handles TikHub intermittent 400s) */ const fs = require('fs'); const path = require('path'); const os = require('os'); const https = require('https'); const { URL } = require('url'); const SKILLS_DIR = path.join(os.homedir(), '.openclaw', 'skills'); const PROJECT_DIR = path.resolve(__dirname, '..', '..'); const XHS_DIR = path.join(PROJECT_DIR, 'xiaohongshu'); // ============================================================ // HTTP helper with retry // ============================================================ async function request(url, headers, maxAttempts = 3) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const result = await new Promise((resolve, reject) => { const u = new URL(url); const req = https.request( { hostname: u.hostname, path: u.pathname + u.search, method: 'GET', headers, timeout: 60000, }, (res) => { let body = ''; res.on('data', (chunk) => (body += chunk)); res.on('end', () => resolve({ status: res.statusCode, body })); } ); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); req.end(); }); if (result.status === 200) return result; if (result.status === 400 || result.status === 429 || result.status >= 500) { const waitMs = 2000 * Math.pow(2, attempt - 1); console.log(` [attempt ${attempt}/${maxAttempts}] status=${result.status}, retrying in ${waitMs}ms...`); await new Promise((r) => setTimeout(r, waitMs)); continue; } return result; } catch (e) { console.log(` [attempt ${attempt}/${maxAttempts}] error: ${e.message}`); if (attempt === maxAttempts) throw e; await new Promise((r) => setTimeout(r, 2000 * Math.pow(2, attempt - 1))); } } throw new Error('max retries exceeded'); } // ============================================================ // Load skill config (prefer installed, fall back to project dir) // ============================================================ function loadSkill(name) { const installedPath = path.join(SKILLS_DIR, name, 'api-config.json'); const projectPath = path.join(XHS_DIR, name, 'api-config.json'); const configPath = fs.existsSync(installedPath) ? installedPath : projectPath; if (!fs.existsSync(configPath)) throw new Error(`skill not found: ${name}`); return { config: JSON.parse(fs.readFileSync(configPath, 'utf-8')), source: configPath }; } function buildUrl(skillConfig, params) { const base = skillConfig.endpoint.url; const query = new URLSearchParams(params).toString(); return `${base}?${query}`; } // ============================================================ // Main test flow // ============================================================ async function main() { const keyword = process.argv[2] || 'lactobacillus'; console.log(''); console.log(`╔══════════════════════════════════════════════════════════╗`); console.log(`║ Xiaohongshu Skill E2E Test — keyword: ${keyword.padEnd(18)} ║`); console.log(`╚══════════════════════════════════════════════════════════╝`); console.log(''); // ─── Step 1: search_notes ─── console.log('Step 1/4: xiaohongshu-search-notes'); const s1 = loadSkill('xiaohongshu-search-notes'); console.log(` source: ${s1.source}`); const url1 = buildUrl(s1.config, { keyword, page: 1 }); console.log(` GET ${url1}`); const r1 = await request(url1, s1.config.endpoint.headers); console.log(` → status=${r1.status}, len=${r1.body.length}`); if (r1.status !== 200) { console.log(` ❌ body: ${r1.body.slice(0, 300)}`); process.exit(1); } const j1 = JSON.parse(r1.body); // Gateway response is double-wrapped: {data: {data: {items: [...]}, code, success, ...}} const inner1 = j1.data?.data || j1.data || {}; const items = inner1.items || []; console.log(` ✅ items=${items.length}, inner code=${j1.data?.code}, msg=${j1.data?.msg}`); if (items.length === 0) { console.log(' ⚠️ no items returned, abort downstream tests'); console.log(' data keys:', Object.keys(j1.data || {}).join(', ')); console.log(' inner keys:', Object.keys(inner1).join(', ')); process.exit(1); } // pick a note with a userid + most comments // items[] wraps each note as { model_type: 'note', note: {...} } const candidates = items.map((w) => w.note || w).filter((n) => n && n.user?.userid); const note = candidates.sort((a, b) => (b.comments_count || 0) - (a.comments_count || 0))[0] || items[0]?.note || items[0]; const noteId = note.id; const userId = note.user?.userid; const xsecToken = note.xsec_token; console.log(` sampled note:`); console.log(` id=${noteId}`); console.log(` title=${(note.title || '').slice(0, 50)}`); console.log(` user.userid=${userId} (${note.user?.nickname})`); console.log(` stats: liked=${note.liked_count} comments=${note.comments_count} collected=${note.collected_count} shared=${note.shared_count}`); await new Promise((r) => setTimeout(r, 1500)); // ─── Step 2: get_note_info ─── console.log(''); console.log('Step 2/4: xiaohongshu-note-detail'); const s2 = loadSkill('xiaohongshu-note-detail'); const url2 = buildUrl(s2.config, { note_id: noteId }); console.log(` GET ${url2}`); const r2 = await request(url2, s2.config.endpoint.headers); console.log(` → status=${r2.status}, len=${r2.body.length}`); if (r2.status === 200) { const j2 = JSON.parse(r2.body); const inner2 = j2.data?.data || j2.data || {}; const n = inner2.note_list?.[0] || inner2.note || {}; const cmts = inner2.comment_list || []; console.log(` ✅ inner code=${j2.data?.code}, success=${j2.data?.success}`); console.log(` note.id=${n.id}, type=${n.type}, desc_len=${(n.desc || '').length}, ip=${n.ip_location}`); console.log(` embedded_comment_list: ${cmts.length} comments`); if (!n.id) console.log(` inner keys: ${Object.keys(inner2).join(', ')}`); } else { console.log(` ⚠️ body: ${r2.body.slice(0, 300)}`); } await new Promise((r) => setTimeout(r, 1500)); // ─── Step 3: get_note_comments ─── console.log(''); console.log('Step 3/4: xiaohongshu-note-comments'); const s3 = loadSkill('xiaohongshu-note-comments'); const url3 = buildUrl(s3.config, { note_id: noteId, cursor: '' }); console.log(` GET ${url3}`); const r3 = await request(url3, s3.config.endpoint.headers); console.log(` → status=${r3.status}, len=${r3.body.length}`); if (r3.status === 200) { const j3 = JSON.parse(r3.body); const inner3 = j3.data?.data || j3.data || {}; const comments = inner3.comments || []; console.log(` ✅ inner code=${j3.data?.code}, msg=${j3.data?.msg}`); console.log(` comments=${comments.length}, has_more=${inner3.has_more}, cursor=${inner3.cursor?.slice(0, 20)}`); if (comments.length > 0) { const c0 = comments[0]; console.log(` top comment: "${(c0.content || '').slice(0, 80)}" (likes=${c0.like_count})`); } else { console.log(` inner keys: ${Object.keys(inner3).join(', ')}`); } } else { console.log(` ⚠️ body: ${r3.body.slice(0, 300)}`); } await new Promise((r) => setTimeout(r, 1500)); // ─── Step 4: get_user_info ─── console.log(''); console.log('Step 4/4: xiaohongshu-user-info'); const s4 = loadSkill('xiaohongshu-user-info'); const url4 = buildUrl(s4.config, { user_id: userId }); console.log(` GET ${url4}`); const r4 = await request(url4, s4.config.endpoint.headers); console.log(` → status=${r4.status}, len=${r4.body.length}`); if (r4.status === 200) { const j4 = JSON.parse(r4.body); const u = j4.data?.data || j4.data?.user || j4.data || {}; const fans = (u.interactions || []).find((x) => x.type === 'fans')?.count || u.fans; const follows = (u.interactions || []).find((x) => x.type === 'follows')?.count || u.follows; const interaction = (u.interactions || []).find((x) => x.type === 'interaction')?.count; console.log(` ✅ inner code=${j4.data?.code}, success=${j4.data?.success}`); console.log(` userid=${u.userid}, nickname=${u.nickname}`); console.log(` fans=${fans}, follows=${follows}, interaction=${interaction}, gender=${u.gender}, ip=${u.ip_location}`); console.log(` collected_notes=${u.collected_notes_num}, verified=${u.red_official_verified} (type=${u.red_official_verify_type})`); if (u.desc) console.log(` desc: ${u.desc.slice(0, 80)}`); if (!u.nickname) console.log(` inner keys: ${Object.keys(u).join(', ')}`); } else { console.log(` ⚠️ body: ${r4.body.slice(0, 300)}`); } console.log(''); console.log('═══════════════════════════════════════════════════════════'); console.log(' Summary'); console.log('═══════════════════════════════════════════════════════════'); console.log(` 1/4 search_notes : ${r1.status === 200 ? '✅' : '❌'} ${r1.status}`); console.log(` 2/4 note-detail : ${r2.status === 200 ? '✅' : '⚠️'} ${r2.status}`); console.log(` 3/4 note-comments : ${r3.status === 200 ? '✅' : '⚠️'} ${r3.status}`); console.log(` 4/4 user-info : ${r4.status === 200 ? '✅' : '⚠️'} ${r4.status}`); console.log(''); } main().catch((err) => { console.error('❌ fatal:', err); process.exit(1); });