search.js
1 import { CliError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 const GITEE_SEARCH_URL = 'https://gitee.com/search'; 4 const GITEE_SEARCH_WIDGET = 'wong1slagnlmzwvsu5ya'; 5 const GITEE_SEARCH_API = `https://so.gitee.com/v1/search/widget/${GITEE_SEARCH_WIDGET}`; 6 const MAX_LIMIT = 50; 7 function clampLimit(value) { 8 const parsed = Number(value); 9 if (Number.isNaN(parsed)) 10 return 10; 11 return Math.max(1, Math.min(parsed, MAX_LIMIT)); 12 } 13 function normalizeText(value) { 14 return value.replace(/\s+/g, ' ').trim(); 15 } 16 function normalizeStars(value) { 17 let raw = ''; 18 if (typeof value === 'number') 19 raw = String(value); 20 else if (typeof value === 'string') 21 raw = value; 22 else if (Array.isArray(value) && value.length > 0) 23 raw = String(value[0] ?? ''); 24 const compact = normalizeText(raw).replace(/\s+/g, ''); 25 if (!compact) 26 return '-'; 27 const match = compact.match(/\d+(?:[.,]\d+)?(?:[kKmMwW]|\u4E07)?/); 28 return match ? match[0] : '-'; 29 } 30 function getFirstText(value) { 31 if (typeof value === 'string') 32 return value; 33 if (typeof value === 'number') 34 return String(value); 35 if (Array.isArray(value)) { 36 for (const item of value) { 37 if (typeof item === 'string' || typeof item === 'number') { 38 return String(item); 39 } 40 } 41 } 42 return ''; 43 } 44 function asRecord(value) { 45 if (!value || typeof value !== 'object' || Array.isArray(value)) 46 return null; 47 return value; 48 } 49 function normalizeUrl(value) { 50 try { 51 const parsed = new URL(value, 'https://gitee.com'); 52 const host = parsed.hostname.toLowerCase(); 53 if (host !== 'gitee.com' && host !== 'www.gitee.com') 54 return null; 55 const parts = parsed.pathname.split('/').filter(Boolean); 56 if (parts.length !== 2) 57 return null; 58 return `https://gitee.com/${parts[0]}/${parts[1]}`; 59 } 60 catch { 61 return null; 62 } 63 } 64 cli({ 65 site: 'gitee', 66 name: 'search', 67 description: 'Search repositories on Gitee', 68 domain: 'gitee.com', 69 strategy: Strategy.PUBLIC, 70 browser: true, 71 args: [ 72 { name: 'keyword', positional: true, required: true, help: 'Search keyword' }, 73 { name: 'limit', type: 'int', default: 10, help: 'Number of results (max 50)' }, 74 ], 75 columns: ['rank', 'name', 'language', 'stars', 'description', 'url'], 76 func: async (page, args) => { 77 const keyword = String(args.keyword ?? '').trim(); 78 if (!keyword) { 79 throw new CliError('INVALID_ARGUMENT', 'Keyword is required', 'Provide a search keyword'); 80 } 81 const limit = clampLimit(args.limit); 82 const encodedKeyword = encodeURIComponent(keyword); 83 const searchUrl = `${GITEE_SEARCH_URL}?q=${encodedKeyword}&type=repository`; 84 const fetchSize = Math.max(10, limit); 85 const apiUrl = new URL(GITEE_SEARCH_API); 86 apiUrl.searchParams.set('q', keyword); 87 apiUrl.searchParams.set('from', '0'); 88 apiUrl.searchParams.set('size', String(fetchSize)); 89 await page.goto(searchUrl); 90 await page.wait(2); 91 const response = await fetch(apiUrl.toString(), { 92 headers: { 93 Accept: 'application/json', 94 'User-Agent': 'Mozilla/5.0', 95 Referer: searchUrl, 96 }, 97 }); 98 if (!response.ok) { 99 throw new CliError('REQUEST_FAILED', `Failed to request Gitee search API: ${response.status}`, 'Try again later or verify network access to so.gitee.com'); 100 } 101 const payload = await response.json(); 102 const payloadRecord = asRecord(payload); 103 const hitsRecord = asRecord(payloadRecord?.hits); 104 const rawRows = Array.isArray(hitsRecord?.hits) ? hitsRecord.hits : []; 105 if (rawRows.length === 0) { 106 throw new CliError('NOT_FOUND', 'No Gitee repository search results found', 'Try a different keyword or check whether Gitee search API changed'); 107 } 108 const seen = new Set(); 109 const rows = []; 110 for (let i = 0; i < rawRows.length && rows.length < limit; i++) { 111 const row = asRecord(rawRows[i]); 112 const fields = asRecord(row?.fields); 113 if (!fields) 114 continue; 115 const name = normalizeText(getFirstText(fields.title)); 116 const repoUrl = normalizeUrl(getFirstText(fields.url)); 117 if (!name || !repoUrl) 118 continue; 119 if (seen.has(repoUrl)) 120 continue; 121 seen.add(repoUrl); 122 rows.push({ 123 rank: rows.length + 1, 124 name, 125 language: normalizeText(getFirstText(fields.langs)) || '-', 126 description: normalizeText(getFirstText(fields.description)) || '-', 127 stars: normalizeStars(fields['count.star']), 128 url: repoUrl, 129 }); 130 } 131 if (rows.length === 0) { 132 throw new CliError('NOT_FOUND', 'No valid Gitee repository results parsed', 'Try a different keyword or check whether Gitee search API changed'); 133 } 134 return rows; 135 }, 136 });