| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- #!/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);
- });
|