Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
| **1688** | `search` `item` `assets` `download` `store` |
| **gitee** | `trending` `search` `user` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # εΏ«ι€Ÿε‘½δ»€ε‚
| **google** | `news` `search` `suggest` `trends` | ε…¬εΌ€ |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | ζ΅θ§ˆε™¨ |
| **1688** | `search` `item` `assets` `download` `store` | ζ΅θ§ˆε™¨ |
| **gitee** | `trending` `search` `user` | ε…¬εΌ€ / ζ΅θ§ˆε™¨ |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | ζ΅θ§ˆε™¨ |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | ζ΅θ§ˆε™¨ |
Expand Down
3 changes: 3 additions & 0 deletions clis/gitee/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import './trending.js';
import './search.js';
import './user.js';
163 changes: 163 additions & 0 deletions clis/gitee/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { CliError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';

interface GiteeSearchResult {
rank: number;
name: string;
language: string;
description: string;
stars: string;
url: string;
}

const GITEE_SEARCH_URL = 'https://gitee.com/search';
const GITEE_SEARCH_WIDGET = 'wong1slagnlmzwvsu5ya';
const GITEE_SEARCH_API = `https://so.gitee.com/v1/search/widget/${GITEE_SEARCH_WIDGET}`;
const MAX_LIMIT = 50;

function clampLimit(value: unknown): number {
const parsed = Number(value);
if (Number.isNaN(parsed)) return 10;
return Math.max(1, Math.min(parsed, MAX_LIMIT));
}

function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}

function normalizeStars(value: unknown): string {
let raw = '';
if (typeof value === 'number') raw = String(value);
else if (typeof value === 'string') raw = value;
else if (Array.isArray(value) && value.length > 0) raw = String(value[0] ?? '');

const compact = normalizeText(raw).replace(/\s+/g, '');
if (!compact) return '-';
const match = compact.match(/\d+(?:[.,]\d+)?(?:[kKmMwW]|\u4E07)?/);
return match ? match[0] : '-';
}

function getFirstText(value: unknown): string {
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === 'string' || typeof item === 'number') {
return String(item);
}
}
}
return '';
}

function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}

function normalizeUrl(value: string): string | null {
try {
const parsed = new URL(value, 'https://gitee.com');
const host = parsed.hostname.toLowerCase();
if (host !== 'gitee.com' && host !== 'www.gitee.com') return null;

const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length !== 2) return null;
return `https://gitee.com/${parts[0]}/${parts[1]}`;
} catch {
return null;
}
}

cli({
site: 'gitee',
name: 'search',
description: 'Search repositories on Gitee',
domain: 'gitee.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'keyword', positional: true, required: true, help: 'Search keyword' },
{ name: 'limit', type: 'int', default: 10, help: 'Number of results (max 50)' },
],
columns: ['rank', 'name', 'language', 'stars', 'description', 'url'],
func: async (page, args) => {
const keyword = String(args.keyword ?? '').trim();
if (!keyword) {
throw new CliError('INVALID_ARGUMENT', 'Keyword is required', 'Provide a search keyword');
}

const limit = clampLimit(args.limit);
const encodedKeyword = encodeURIComponent(keyword);
const searchUrl = `${GITEE_SEARCH_URL}?q=${encodedKeyword}&type=repository`;
const fetchSize = Math.max(10, limit);
const apiUrl = new URL(GITEE_SEARCH_API);
apiUrl.searchParams.set('q', keyword);
apiUrl.searchParams.set('from', '0');
apiUrl.searchParams.set('size', String(fetchSize));

await page.goto(searchUrl);
await page.wait(2);

const response = await fetch(apiUrl.toString(), {
headers: {
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0',
Referer: searchUrl,
},
});
if (!response.ok) {
throw new CliError(
'REQUEST_FAILED',
`Failed to request Gitee search API: ${response.status}`,
'Try again later or verify network access to so.gitee.com',
);
}

const payload = await response.json() as unknown;
const payloadRecord = asRecord(payload);
const hitsRecord = asRecord(payloadRecord?.hits);
const rawRows = Array.isArray(hitsRecord?.hits) ? hitsRecord.hits : [];

if (rawRows.length === 0) {
throw new CliError(
'NOT_FOUND',
'No Gitee repository search results found',
'Try a different keyword or check whether Gitee search API changed',
);
}

const seen = new Set<string>();
const rows: GiteeSearchResult[] = [];
for (let i = 0; i < rawRows.length && rows.length < limit; i++) {
const row = asRecord(rawRows[i]);
const fields = asRecord(row?.fields);
if (!fields) continue;

const name = normalizeText(getFirstText(fields.title));
const repoUrl = normalizeUrl(getFirstText(fields.url));
if (!name || !repoUrl) continue;
if (seen.has(repoUrl)) continue;
seen.add(repoUrl);

rows.push({
rank: rows.length + 1,
name,
language: normalizeText(getFirstText(fields.langs)) || '-',
description: normalizeText(getFirstText(fields.description)) || '-',
stars: normalizeStars(fields['count.star']),
url: repoUrl,
});
}

if (rows.length === 0) {
throw new CliError(
'NOT_FOUND',
'No valid Gitee repository results parsed',
'Try a different keyword or check whether Gitee search API changed',
);
}

return rows;
},
});
Loading