/ clis / gitee / search.js
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  });