diff --git a/README.md b/README.md index cd5052b2..f3924f9c 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ To load the source Browser Bridge extension: | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | | **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` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 52921132..27d0f20c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -218,6 +218,7 @@ npm link | **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` | 浏览器 | diff --git a/clis/gitee/index.ts b/clis/gitee/index.ts new file mode 100644 index 00000000..2184e79e --- /dev/null +++ b/clis/gitee/index.ts @@ -0,0 +1,3 @@ +import './trending.js'; +import './search.js'; +import './user.js'; diff --git a/clis/gitee/search.ts b/clis/gitee/search.ts new file mode 100644 index 00000000..0f36ac6f --- /dev/null +++ b/clis/gitee/search.ts @@ -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 | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +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(); + 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; + }, +}); diff --git a/clis/gitee/trending.ts b/clis/gitee/trending.ts new file mode 100644 index 00000000..91e1fd07 --- /dev/null +++ b/clis/gitee/trending.ts @@ -0,0 +1,611 @@ +import { CliError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { isRecord } from '@jackwener/opencli/utils'; + +interface GiteeProject { + name: string; + description: string; + stars: string; + url: string; +} + +interface CaptureProject { + name?: string; + description?: string; + stars?: string; + url: string; +} + +const GITEE_EXPLORE_URL = 'https://gitee.com/explore'; +const MAX_LIMIT = 50; +const MAX_DESCRIPTION_LENGTH = 48; +const GITEE_HOSTS = new Set(['gitee.com', 'www.gitee.com']); +const RESERVED_SEGMENTS = new Set([ + 'about', + 'account', + 'ai', + 'all', + 'api', + 'apps', + 'blog', + 'contact', + 'dashboard', + 'docs', + 'enterprise', + 'enterprises', + 'explore', + 'features', + 'help', + 'issues', + 'login', + 'marketplace', + 'organizations', + 'pricing', + 'pulls', + 'security', + 'settings', + 'signup', + 'sitemap', + 'stars', + 'support', + 'terms', + 'users', +]); + +const STAR_KEYS = [ + 'stars', + 'star', + 'stars_count', + 'star_count', + 'stargazers_count', + 'stargazer_count', + 'watch_count', + 'watchers_count', +] as const; + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function normalizeStars(value: string): string { + const compact = normalizeWhitespace(value).replace(/\s+/g, ''); + if (!compact) return '-'; + const match = compact.match(/\d+(?:[.,]\d+)?(?:[kKmMwW]|\u4E07)?/); + return match ? match[0] : compact; +} + +function compactDescription(value: string): string { + const normalized = normalizeWhitespace(value); + if (!normalized || normalized === '-') return '-'; + if (normalized.length <= MAX_DESCRIPTION_LENGTH) return normalized; + return `${normalized.slice(0, MAX_DESCRIPTION_LENGTH - 3)}...`; +} + +function clampLimit(value: unknown): number { + const parsed = Number(value); + if (Number.isNaN(parsed)) return 20; + return Math.max(1, Math.min(parsed, MAX_LIMIT)); +} + +function normalizeRepoUrl(value: string): string | null { + try { + const parsed = new URL(value, 'https://gitee.com'); + if (!GITEE_HOSTS.has(parsed.hostname.toLowerCase())) return null; + + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts.length !== 2) return null; + + const owner = parts[0]; + const repo = parts[1]; + if (RESERVED_SEGMENTS.has(owner.toLowerCase()) || RESERVED_SEGMENTS.has(repo.toLowerCase())) return null; + + return `https://gitee.com/${owner}/${repo}`; + } catch { + return null; + } +} + +function repoUrlFromPath(value: string): string | null { + const compact = value.trim().replace(/^\/+|\/+$/g, ''); + if (!compact.includes('/')) return null; + return normalizeRepoUrl(`https://gitee.com/${compact}`); +} + +function toCaptureProject(record: Record): CaptureProject | null { + const urlCandidates: string[] = []; + const pushUrlCandidate = (raw: unknown): void => { + if (typeof raw !== 'string') return; + const normalized = normalizeRepoUrl(raw) ?? repoUrlFromPath(raw); + if (normalized) urlCandidates.push(normalized); + }; + + pushUrlCandidate(record.url); + pushUrlCandidate(record.html_url); + pushUrlCandidate(record.project_url); + pushUrlCandidate(record.web_url); + pushUrlCandidate(record.path_with_namespace); + pushUrlCandidate(record.name_with_namespace); + pushUrlCandidate(record.full_name); + pushUrlCandidate(record.fullName); + pushUrlCandidate(record.path); + + const url = urlCandidates[0]; + if (!url) return null; + + let name = ''; + const nameCandidate = [ + record.name_with_namespace, + record.full_name, + record.fullName, + record.path_with_namespace, + record.name, + ].find((value) => typeof value === 'string' && value.trim()); + if (typeof nameCandidate === 'string') { + name = normalizeWhitespace(nameCandidate.replace(/\s*\/\s*/g, '/')); + } + + let description = ''; + const descCandidate = [ + record.description, + record.desc, + record.summary, + record.project_description, + record.intro, + record.tagline, + ].find((value) => typeof value === 'string' && value.trim()); + if (typeof descCandidate === 'string') { + description = normalizeWhitespace(descCandidate); + } + + let stars = ''; + for (const key of STAR_KEYS) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + stars = String(value); + break; + } + if (typeof value === 'string' && value.trim()) { + stars = value; + break; + } + } + + return { + url, + name: name || undefined, + description: description || undefined, + stars: stars ? normalizeStars(stars) : undefined, + }; +} + +function tryParseJson(raw: string): unknown | null { + const text = raw.trim(); + if (!text || (!text.startsWith('{') && !text.startsWith('['))) return null; + + try { + return JSON.parse(text); + } catch { + const lastBrace = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']')); + if (lastBrace <= 0) return null; + const clipped = text.slice(0, lastBrace + 1); + try { + return JSON.parse(clipped); + } catch { + return null; + } + } +} + +function parseCaptureBody(entry: unknown): unknown | null { + if (!isRecord(entry)) return null; + const preview = typeof entry.responsePreview === 'string' ? entry.responsePreview : ''; + if (!preview || preview.startsWith('base64:')) return null; + + const contentType = typeof entry.responseContentType === 'string' + ? entry.responseContentType.toLowerCase() + : ''; + if (contentType && !contentType.includes('json') && !contentType.includes('javascript') && !contentType.includes('text')) { + return null; + } + + return tryParseJson(preview); +} + +function choosePreferredText(current: string | undefined, incoming: string | undefined): string | undefined { + if (!incoming || incoming === '-') return current; + if (!current || current === '-') return incoming; + return incoming.length > current.length ? incoming : current; +} + +function collectProjectsFromUnknown( + value: unknown, + out: Map, + seen: Set, + depth: number, +): void { + if (depth > 8 || value === null || value === undefined || typeof value !== 'object') return; + if (seen.has(value)) return; + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + collectProjectsFromUnknown(item, out, seen, depth + 1); + } + return; + } + + const record = value as Record; + const candidate = toCaptureProject(record); + if (candidate) { + const previous = out.get(candidate.url); + if (!previous) { + out.set(candidate.url, candidate); + } else { + out.set(candidate.url, { + url: candidate.url, + name: choosePreferredText(previous.name, candidate.name), + description: choosePreferredText(previous.description, candidate.description), + stars: previous.stars && previous.stars !== '-' ? previous.stars : candidate.stars, + }); + } + } + + for (const child of Object.values(record)) { + collectProjectsFromUnknown(child, out, seen, depth + 1); + } +} + +function collectProjectsFromCapture(entries: unknown[]): Map { + const collected = new Map(); + const seen = new Set(); + + for (const entry of entries) { + const body = parseCaptureBody(entry); + if (body !== null) { + collectProjectsFromUnknown(body, collected, seen, 0); + } + } + return collected; +} + +function toProject(value: unknown): GiteeProject | null { + if (!value || typeof value !== 'object') return null; + + const row = value as Record; + const name = typeof row.name === 'string' ? normalizeWhitespace(row.name) : ''; + const urlRaw = typeof row.url === 'string' ? row.url.trim() : ''; + const url = normalizeRepoUrl(urlRaw); + if (!name || !url) return null; + + const description = typeof row.description === 'string' + ? normalizeWhitespace(row.description) + : ''; + const stars = typeof row.stars === 'string' ? normalizeStars(row.stars) : '-'; + + return { + name, + description: description || '-', + stars, + url, + }; +} + +function mergeCapturedProject(project: GiteeProject, captured: CaptureProject | undefined): GiteeProject { + if (!captured) return project; + const mergedName = captured.name ? normalizeWhitespace(captured.name) : ''; + const mergedDescription = captured.description ? normalizeWhitespace(captured.description) : ''; + const mergedStars = captured.stars ? normalizeStars(captured.stars) : '-'; + + return { + name: mergedName && mergedName.length <= 120 ? mergedName : project.name, + description: project.description !== '-' ? project.description : (mergedDescription || '-'), + stars: project.stars !== '-' ? project.stars : mergedStars, + url: project.url, + }; +} + +cli({ + site: 'gitee', + name: 'trending', + description: 'Recommended open-source projects on Gitee Explore', + domain: 'gitee.com', + strategy: Strategy.PUBLIC, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of projects (max 50)' }, + ], + columns: ['name', 'description', 'stars', 'url'], + func: async (page, args) => { + const limit = clampLimit(args.limit); + let projectsFromCapture = new Map(); + + if (page.startNetworkCapture) { + try { + await page.startNetworkCapture('gitee.com'); + } catch { + // best-effort enrichment path + } + } + + await page.goto(GITEE_EXPLORE_URL); + await page.wait(3); + + if (page.readNetworkCapture) { + try { + const captureEntries = await page.readNetworkCapture(); + projectsFromCapture = collectProjectsFromCapture(captureEntries); + } catch { + // best-effort enrichment path + } + } + + const rawProjects = await page.evaluate(` + (() => { + const RESERVED = new Set([ + 'about', + 'account', + 'ai', + 'all', + 'api', + 'apps', + 'blog', + 'contact', + 'dashboard', + 'docs', + 'enterprise', + 'enterprises', + 'explore', + 'features', + 'help', + 'issues', + 'login', + 'marketplace', + 'organizations', + 'pricing', + 'pulls', + 'security', + 'settings', + 'signup', + 'sitemap', + 'stars', + 'support', + 'terms', + 'users', + ]); + + const KEYWORDS = [ + '\\u63A8\\u8350\\u5F00\\u6E90\\u9879\\u76EE', + '\\u63A8\\u8350\\u9879\\u76EE', + '\\u63A8\\u8350\\u4ED3\\u5E93', + 'Recommended Projects', + ]; + const STAR_TOKEN = /(\\d+(?:[.,]\\d+)?(?:\\s*[kKmMwW\\u4E07])?)/; + + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + + const toRepoUrl = (href) => { + if (!href) return ''; + try { + const url = new URL(href, location.origin); + if (url.origin !== location.origin) return ''; + + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length !== 2) return ''; + + const owner = parts[0].toLowerCase(); + const repo = parts[1].toLowerCase(); + if (RESERVED.has(owner) || RESERVED.has(repo)) return ''; + + return url.origin + '/' + parts[0] + '/' + parts[1]; + } catch { + return ''; + } + }; + + const scoreCard = (node) => { + const text = normalize(node.textContent || ''); + const linkCount = node.querySelectorAll('a[href]').length; + if (!text || linkCount > 45) return -Infinity; + + const hasDesc = !!node.querySelector('.project-desc, .project-description, .description, .desc, [class*="desc"], [class*="intro"], [class*="summary"], p'); + const hasMetric = !!node.querySelector('a[href*="stargazers"], a[href*="stars"], [class*="star"], [class*="collect"], [class*="watch"], [aria-label*="star" i], [title*="star" i]'); + let score = Math.min(text.length, 1200) - linkCount * 6; + if (hasDesc) score += 260; + if (hasMetric) score += 360; + if (/(?:stars?|star|stargazers?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E|★|⭐)/i.test(text)) score += 220; + return score; + }; + + const pickCard = (link) => { + let best = link; + let bestScore = scoreCard(link); + let node = link; + for (let i = 0; i < 7 && node.parentElement; i++) { + node = node.parentElement; + const score = scoreCard(node); + if (score > bestScore) { + best = node; + bestScore = score; + } + } + return best; + }; + + const extractStars = (card) => { + const classToken = /(star|stargazer|collect|watch)/i; + const metricNodes = card.querySelectorAll( + 'a[href*="stargazers"], a[href*="stars"], [class*="star"], [class*="collect"], [class*="watch"], [aria-label*="star" i], [title*="star" i], span, strong, small, em, div' + ); + for (const node of metricNodes) { + const className = String(node.className || ''); + const raw = normalize([ + node.textContent || '', + node.getAttribute?.('title') || '', + node.getAttribute?.('aria-label') || '', + node.nextElementSibling?.textContent || '', + ].join(' ')); + if (!raw) continue; + if (!/(?:stars?|star|stargazers?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E|★|⭐)/i.test(raw) && !classToken.test(className)) continue; + const match = raw.match(STAR_TOKEN); + if (match) return match[1].replace(/\\s+/g, ''); + } + + const directStar = card.querySelector( + '.project-stars-count-box .stars-count, .stars-count, [class*="stars-count"], [class*="starsCount"]' + ); + const directStarText = normalize(directStar?.textContent || ''); + const directMatch = directStarText.match(STAR_TOKEN); + if (directMatch) return directMatch[1].replace(/\\s+/g, ''); + + const text = normalize(card.textContent || ''); + const patterns = [ + /(?:stars?|star|stargazers?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E)\\s*[::]?\\s*(\\d+(?:[.,]\\d+)?(?:\\s*[kKmMwW\\u4E07])?)/i, + /(\\d+(?:[.,]\\d+)?(?:\\s*[kKmMwW\\u4E07])?)\\s*(?:stars?|star|stargazers?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E)/i, + /[★⭐]\\s*(\\d+(?:[.,]\\d+)?(?:\\s*[kKmMwW\\u4E07])?)/, + ]; + + for (const pattern of patterns) { + const match = text.match(pattern); + if (match) return match[1].replace(/\\s+/g, ''); + } + + return ''; + }; + + const pickDescription = (card, name) => { + const directNodes = Array.from( + card.querySelectorAll('.project-desc, .project-description, .description, .desc, [class*="description"], [class*="desc"], [class*="intro"], [class*="summary"], p') + ); + + const seen = new Set(); + const candidates = []; + for (const node of directNodes) { + const textCandidates = [ + node.textContent || '', + node.getAttribute?.('title') || '', + node.getAttribute?.('aria-label') || '', + ].map((value) => normalize(value || '')).filter(Boolean); + + for (const text of textCandidates) { + if (!text || text === name || seen.has(text)) continue; + if (text.length < 6 || text.length > 320) continue; + if (/^(?:stars?|star|fork|issues?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E|\\d+(?:[.,]\\d+)?)$/i.test(text)) continue; + seen.add(text); + candidates.push(text); + } + } + if (candidates.length > 0) { + candidates.sort((left, right) => right.length - left.length); + return candidates[0]; + } + + const text = normalize(card.textContent || '') + .replace(/window\\.gon\\._errorText\\s*=\\s*\"[^\"]*\"/g, ''); + if (!text) return '-'; + const cleaned = text + .replace(name, '') + .replace(/(?:stars?|star|stargazers?|fork|issues?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E)\\s*[::]?\\s*\\d+(?:[.,]\\d+)?(?:\\s*[kKmMwW\\u4E07])?/ig, '') + .replace(/\\d+(?:[.,]\\d+)?(?:\\s*[kKmMwW\\u4E07])?\\s*(?:stars?|star|stargazers?|\\u6536\\u85CF|\\u5173\\u6CE8|\\u70B9\\u8D5E)/ig, '') + .replace(/\\|\\s*\\d+\\s*(?:\\u79D2\\u524D|\\u5206\\u949F\\u524D|\\u5C0F\\u65F6\\u524D|\\u5929\\u524D)/g, '') + .replace(/\\s+/g, ' ') + .trim(); + return cleaned ? cleaned.slice(0, 300) : '-'; + }; + + const countRepoLinks = (root) => { + const links = root.querySelectorAll('a[href]'); + let count = 0; + for (const link of links) { + if (toRepoUrl(link.getAttribute('href') || '')) count++; + } + return count; + }; + + let root = document; + const headingNodes = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, div, span')) + .filter((node) => { + const text = normalize(node.textContent || ''); + if (!text || text.length > 20) return false; + return KEYWORDS.some((keyword) => text.includes(keyword)); + }); + + let bestCount = 0; + for (const heading of headingNodes) { + let candidate = heading; + for (let i = 0; i < 4 && candidate.parentElement; i++) { + candidate = candidate.parentElement; + const count = countRepoLinks(candidate); + if (count > bestCount) { + bestCount = count; + root = candidate; + } + } + } + + const seen = new Set(); + const projects = []; + const collect = (scope) => { + const links = scope.querySelectorAll('a[href]'); + for (const link of links) { + const url = toRepoUrl(link.getAttribute('href') || ''); + if (!url || seen.has(url)) continue; + + const card = pickCard(link); + const titleAttr = normalize(link.getAttribute('title') || ''); + const nameText = normalize(link.textContent || ''); + const pathParts = new URL(url).pathname.split('/').filter(Boolean); + const fallbackName = pathParts.join('/'); + const nameCandidate = titleAttr || nameText; + const name = (nameCandidate && nameCandidate.length <= 120 + ? nameCandidate.replace(/\\s*\\/\\s*/g, '/') + : fallbackName) || fallbackName; + if (!name) continue; + + projects.push({ + name, + description: pickDescription(card, name), + stars: extractStars(card), + url, + }); + seen.add(url); + } + }; + + collect(root); + if (projects.length < 8 && root !== document) { + collect(document); + } + + return projects; + })() + `) as unknown; + + if (!Array.isArray(rawProjects)) { + throw new CliError( + 'FETCH_ERROR', + 'Failed to parse Gitee Explore page', + 'Gitee may have changed its page structure', + ); + } + + const projects = rawProjects + .map(toProject) + .filter((project): project is GiteeProject => project !== null) + .map((project) => mergeCapturedProject(project, projectsFromCapture.get(project.url))) + .map((project) => ({ + ...project, + description: compactDescription(project.description), + })) + .slice(0, limit); + + if (projects.length === 0) { + throw new CliError( + 'NOT_FOUND', + 'No recommended projects found on Gitee Explore', + 'Gitee may be blocking this request or the page structure changed', + ); + } + + return projects; + }, +}); diff --git a/clis/gitee/user.test.ts b/clis/gitee/user.test.ts new file mode 100644 index 00000000..af674dbd --- /dev/null +++ b/clis/gitee/user.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getRegistry } from '@jackwener/opencli/registry'; + +import './user.js'; + +const command = getRegistry().get('gitee/user'); + +function createPage(snapshot: unknown) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(snapshot), + } as any; +} + +describe('gitee user', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('registers the gitee user command', () => { + expect(command).toMatchObject({ + site: 'gitee', + name: 'user', + }); + }); + + it('does not mislabel contribution totals as Gitee Index when the real index is unavailable', async () => { + const page = createPage({ + notFound: false, + blocked: false, + nickname: 'Alice', + followers: '12', + publicRepos: '7', + giteeIndex: '', + contributionTotal: 321, + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + login: 'alice', + name: 'Alice', + followers: 12, + public_repos: 7, + }), { status: 200 }))); + + const rows = await command!.func!(page, { username: 'alice' }) as Array<{ field: string; value: string }>; + expect(rows).toContainEqual({ field: 'Gitee Index', value: '-' }); + }); + + it('uses an API-provided Gitee Index when available', async () => { + const page = createPage({ + notFound: false, + blocked: false, + nickname: '', + followers: '', + publicRepos: '', + giteeIndex: '', + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + login: 'alice', + followers: 9, + public_repos: 3, + gitee_index: 88, + }), { status: 200 }))); + + const rows = await command!.func!(page, { username: 'alice' }) as Array<{ field: string; value: string }>; + expect(rows).toContainEqual({ field: 'Gitee Index', value: '88' }); + }); +}); diff --git a/clis/gitee/user.ts b/clis/gitee/user.ts new file mode 100644 index 00000000..230fab06 --- /dev/null +++ b/clis/gitee/user.ts @@ -0,0 +1,251 @@ +import { CliError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; + +interface DomUserSnapshot { + notFound: boolean; + blocked: boolean; + nickname: string; + followers: string; + publicRepos: string; + giteeIndex: string; +} + +const GITEE_BASE_URL = 'https://gitee.com'; +const GITEE_USER_API = 'https://gitee.com/api/v5/users'; + +function normalizeText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function sanitizeUsername(value: string): string { + return value.trim().replace(/^@+/, '').replace(/^\/+|\/+$/g, ''); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function firstText(value: unknown): string { + if (typeof value === 'string') return normalizeText(value); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (Array.isArray(value)) { + for (const item of value) { + const text = firstText(item); + if (text) return text; + } + } + return ''; +} + +function normalizeCount(value: unknown): string { + const raw = firstText(value); + if (!raw) return ''; + const compact = raw.replace(/,/g, ''); + const match = compact.match(/\d+(?:[.]\d+)?(?:[kKmMwW]|\u4E07)?/); + if (match) return match[0]; + return ''; +} + +function pickFirst(...values: Array): string { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function apiGiteeIndex(user: Record | null): string { + if (!user) return ''; + const keys = [ + 'gitee_index', + 'giteeIndex', + 'index', + 'score', + 'contribution_score', + 'contributionScore', + 'contribution_index', + 'contributionIndex', + ] as const; + for (const key of keys) { + const value = normalizeCount(user[key]); + if (value) return value; + } + return ''; +} + +cli({ + site: 'gitee', + name: 'user', + description: 'Show a Gitee user profile panel', + domain: 'gitee.com', + strategy: Strategy.PUBLIC, + browser: true, + args: [ + { name: 'username', positional: true, required: true, help: 'Gitee username' }, + ], + columns: ['field', 'value'], + func: async (page, args) => { + const username = sanitizeUsername(String(args.username ?? '')); + if (!username) { + throw new CliError('INVALID_ARGUMENT', 'Username is required', 'Use: opencli gitee user '); + } + + const profileUrl = `${GITEE_BASE_URL}/${encodeURIComponent(username)}`; + await page.goto(profileUrl); + await page.wait(2); + + const rawDomSnapshot = await page.evaluate(` + (() => { + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const extractCount = (value) => { + const text = normalize(value).replace(/,/g, ''); + if (!text) return ''; + const match = text.match(/\\d+(?:[.]\\d+)?(?:\\s*[kKmMwW\\u4E07])?/); + return match ? match[0].replace(/\\s+/g, '') : ''; + }; + + const title = normalize(document.title || ''); + const bodyText = normalize(document.body?.innerText || ''); + const notFound = /404|页面不存在|资源不存在|page not found/i.test(title + ' ' + bodyText); + const blocked = /访问受限|没有访问权限|forbidden|denied/i.test(bodyText); + + const nicknameCandidates = Array.from( + document.querySelectorAll('.users__personal-name h2 span[title], .users__personal-name h2 [title], .users__personal-name h2 span, .users__personal-name h2, h1'), + ); + const nicknameNode = nicknameCandidates.find((node) => { + const titleAttr = node && node.getAttribute ? node.getAttribute('title') : ''; + const text = normalize((titleAttr || node.textContent || '').replace(/\\(\\s*备注名\\s*\\)/g, '')); + return !!text && !/备注名/.test(text); + }) || null; + const nicknameFromAttr = nicknameNode && nicknameNode.getAttribute ? nicknameNode.getAttribute('title') : ''; + const nickname = normalize((nicknameFromAttr || nicknameNode?.textContent || '').replace(/\\(\\s*备注名\\s*\\)/g, '')); + + let followers = extractCount(document.querySelector('#followers-number .social-count, #followers-number .follow-num')?.textContent || ''); + if (!followers) { + const card = Array.from(document.querySelectorAll('.users__personal-socials .four.wide.column, .users__personal-socials [class*="column"]')) + .find((el) => /followers/i.test(normalize(el.textContent || ''))); + if (card) followers = extractCount(card.textContent || ''); + } + + let publicRepos = ''; + const projectLink = Array.from(document.querySelectorAll('a[href]')) + .find((el) => /\\/[^/?#]+\\/projects(?:$|[/?#])/i.test(el.getAttribute('href') || '')); + if (projectLink) publicRepos = extractCount(projectLink.textContent || ''); + + let giteeIndex = ''; + const indexNodes = Array.from( + document.querySelectorAll('.users__personal-info *, .users__personal-container *, [class*="index" i], [id*="index" i], [class*="score" i], [id*="score" i]'), + ); + for (const node of indexNodes) { + const text = normalize(node.textContent || ''); + if (!/(码云指数|gitee\\s*index|gitee\\s*指数)/i.test(text)) continue; + + const direct = text.match(/(?:码云指数|gitee\\s*index|gitee\\s*指数)[::]?\\s*(\\d+(?:[.]\\d+)?(?:\\s*[kKmMwW\\u4E07])?)/i); + if (direct?.[1]) { + giteeIndex = direct[1].replace(/\\s+/g, ''); + break; + } + + const siblingText = normalize(node.nextElementSibling?.textContent || ''); + const parentText = normalize(node.parentElement?.textContent || ''); + const around = extractCount(siblingText + ' ' + parentText); + if (around) { + giteeIndex = around; + break; + } + } + + return { + notFound, + blocked, + nickname, + followers, + publicRepos, + giteeIndex, + }; + })() + `) as unknown; + + const domSnapshotRecord = asRecord(rawDomSnapshot); + const domSnapshot: DomUserSnapshot = { + notFound: domSnapshotRecord?.notFound === true, + blocked: domSnapshotRecord?.blocked === true, + nickname: firstText(domSnapshotRecord?.nickname), + followers: normalizeCount(domSnapshotRecord?.followers), + publicRepos: normalizeCount(domSnapshotRecord?.publicRepos), + giteeIndex: normalizeCount(domSnapshotRecord?.giteeIndex), + }; + + if (domSnapshot.notFound) { + throw new CliError( + 'NOT_FOUND', + `Gitee user "${username}" does not exist`, + 'Check the username and retry: opencli gitee user ', + ); + } + + if (domSnapshot.blocked) { + throw new CliError( + 'FORBIDDEN', + `Gitee user page "${username}" is not accessible`, + 'The profile may be private/restricted, or the account may be unavailable', + ); + } + + const apiUrl = `${GITEE_USER_API}/${encodeURIComponent(username)}`; + const apiResponse = await fetch(apiUrl, { + headers: { + Accept: 'application/json', + 'User-Agent': 'Mozilla/5.0', + Referer: profileUrl, + }, + }); + + if (apiResponse.status === 404) { + throw new CliError( + 'NOT_FOUND', + `Gitee user "${username}" does not exist`, + 'Check the username and retry: opencli gitee user ', + ); + } + + if (!apiResponse.ok) { + throw new CliError( + 'REQUEST_FAILED', + `Failed to read Gitee user profile API: ${apiResponse.status}`, + 'Try again later or verify network access to gitee.com', + ); + } + + const apiUser = asRecord(await apiResponse.json() as unknown); + const nickname = pickFirst( + domSnapshot.nickname, + firstText(apiUser?.name), + firstText(apiUser?.login), + username, + ); + const followers = pickFirst( + domSnapshot.followers, + normalizeCount(apiUser?.followers), + '-', + ); + const publicRepos = pickFirst( + domSnapshot.publicRepos, + normalizeCount(apiUser?.public_repos), + '-', + ); + const giteeIndex = pickFirst( + domSnapshot.giteeIndex, + apiGiteeIndex(apiUser), + '-', + ); + + return [ + { field: 'Nickname', value: nickname }, + { field: 'Followers', value: followers }, + { field: 'Public Repositories', value: publicRepos }, + { field: 'Gitee Index', value: giteeIndex }, + { field: 'URL', value: profileUrl }, + ]; + }, +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8d4ed75f..01df1294 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -75,6 +75,7 @@ export default defineConfig({ { text: 'Grok', link: '/adapters/browser/grok' }, { text: 'Amazon', link: '/adapters/browser/amazon' }, { text: '1688', link: '/adapters/browser/1688' }, + { text: 'Gitee', link: '/adapters/browser/gitee' }, { text: 'Gemini', link: '/adapters/browser/gemini' }, { text: 'Yuanbao', link: '/adapters/browser/yuanbao' }, { text: 'NotebookLM', link: '/adapters/browser/notebooklm' }, diff --git a/docs/adapters/browser/gitee.md b/docs/adapters/browser/gitee.md new file mode 100644 index 00000000..915ce1b3 --- /dev/null +++ b/docs/adapters/browser/gitee.md @@ -0,0 +1,34 @@ +# Gitee + +**Mode**: 🌐 Public (Browser) · **Domain**: `gitee.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli gitee trending` | Recommended open-source projects from Gitee Explore | +| `opencli gitee search` | Search Gitee repositories by keyword | +| `opencli gitee user` | Show user profile panel (nickname, followers, public repos, Gitee index) | + +## Usage Examples + +```bash +# Explore recommended projects +opencli gitee trending --limit 10 + +# Search repositories +opencli gitee search opencli --limit 10 + +# User profile panel +opencli gitee user fu-qingrong + +# JSON output +opencli gitee trending --limit 5 -f json +opencli gitee search "ai agent" --limit 5 -f json +opencli gitee user jackwener -f json +``` + +## Prerequisites + +- Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed +- No login required for these public commands diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 95a41de5..e3e0df8c 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -49,6 +49,38 @@ Run `opencli list` for the live registry. | **[jd](./browser/jd.md)** | `item` | 🔐 Browser | | **[amazon](./browser/amazon.md)** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | 🔐 Browser | | **[1688](./browser/1688.md)** | `search` `item` `assets` `download` `store` | 🔐 Browser | +| **[gitee](./browser/gitee.md)** | `trending` `search` `user` | 🌐 / 🔐 | +| **[web](./browser/web.md)** | `read` | 🔐 Browser | +| **[weixin](./browser/weixin.md)** | `download` | 🔐 Browser | +| **[36kr](./browser/36kr.md)** | `news` `hot` `search` `article` | 🌐 / 🔐 | +| **[producthunt](./browser/producthunt.md)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 | +| **[ones](./browser/ones.md)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` | +| **[band](./browser/band.md)** | `bands` `posts` `post` `mentions` | 🔐 Browser | +| **[zsxq](./browser/zsxq.md)** | `groups` `dynamics` `topics` `topic` `search` | 🔐 Browser | +| **[bluesky](./browser/bluesky.md)** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` | 🌐 Public | +| **[douyin](./browser/douyin.md)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser | +| **[xianyu](./browser/xianyu.md)** | `search` `item` `chat` | 🔐 Browser | +| **[quark](./browser/quark.md)** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 🔐 Browser | +| **[grok](./browser/grok)** | `ask` | 🔐 Browser | +| **[gemini](./browser/gemini)** | `new` `ask` `image` `deep-research` `deep-research-result` | 🔐 Browser | +| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser | +| **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `current` `get` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-get` `summary` | 🔐 Browser | +| **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser | +| **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser | +| **[douban](./browser/douban.md)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser | +| **[facebook](./browser/facebook.md)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser | +| **[imdb](./browser/imdb.md)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 | +| **[instagram](./browser/instagram.md)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser | +| **[medium](./browser/medium.md)** | `feed` `search` `user` | 🔐 Browser | +| **[sinablog](./browser/sinablog.md)** | `hot` `search` `article` `user` | 🔐 Browser | +| **[substack](./browser/substack.md)** | `feed` `search` `publication` | 🔐 Browser | +| **[pixiv](./browser/pixiv.md)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser | +| **[tiktok](./browser/tiktok.md)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser | +| **[google](./browser/google.md)** | `news` `search` `suggest` `trends` | 🌐 / 🔐 | +| **[jd](./browser/jd.md)** | `item` | 🔐 Browser | +| **[amazon](./browser/amazon.md)** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | 🔐 Browser | +| **[1688](./browser/1688.md)** | `search` `item` `assets` `download` `store` | 🔐 Browser | +| **[gitee](./browser/gitee.md)** | `trending` `search` `user` | 🌐 / 🔐 | | **[web](./browser/web.md)** | `read` | 🔐 Browser | | **[weixin](./browser/weixin.md)** | `download` | 🔐 Browser | | **[36kr](./browser/36kr.md)** | `news` `hot` `search` `article` | 🌐 / 🔐 |