diff --git a/cli-manifest.json b/cli-manifest.json index c5344bfff..d2a4461aa 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -13729,6 +13729,51 @@ "siteSession": "persistent", "defaultWindowMode": "foreground" }, + { + "site": "github", + "name": "trending", + "description": "GitHub Trending repositories (public, no login). Filter by --language and --since.", + "access": "read", + "domain": "github.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "since", + "type": "string", + "default": "daily", + "required": false, + "help": "Time range: daily / weekly / monthly" + }, + { + "name": "language", + "type": "string", + "default": "", + "required": false, + "help": "Filter by programming language slug, e.g. python, rust, \"c++\"" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of repositories to return (max 25)" + } + ], + "columns": [ + "rank", + "repo", + "description", + "language", + "stars", + "forks", + "starsSince", + "url" + ], + "type": "js", + "modulePath": "github/trending.js", + "sourceFile": "github/trending.js" + }, { "site": "github", "name": "whoami", diff --git a/clis/github/trending.js b/clis/github/trending.js new file mode 100644 index 000000000..50fd4c249 --- /dev/null +++ b/clis/github/trending.js @@ -0,0 +1,139 @@ +// github trending — repositories from https://github.com/trending (public HTML, no auth). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +const SINCE = { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', +}; + +function decodeHtmlEntities(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/'/gi, "'") + .replace(/ /g, ' '); +} + +function stripTags(value) { + return String(value ?? '').replace(/<[^>]*>/g, ''); +} + +function parseCount(value) { + if (value == null) return null; + const digits = String(value).replace(/[,\s]/g, ''); + if (!/^\d+$/.test(digits)) return null; + return Number(digits); +} + +function parseTrendingHtml(html, limit) { + const blocks = html.split('
').slice(1); + const rows = []; + for (const raw of blocks) { + const block = raw.split('
')[0]; + + const nameMatch = block.match(/([\s\S]*?)<\/p>/); + const description = descMatch + ? decodeHtmlEntities(stripTags(descMatch[1]).replace(/\s+/g, ' ')).trim() + : ''; + + const langMatch = block.match(/([\s\S]*?)<\/span>/); + const language = langMatch ? decodeHtmlEntities(stripTags(langMatch[1])).trim() : null; + + const starsMatch = block.match(/\/stargazers"[\s\S]*?>\s*([\d,]+)\s*<\/a>/); + const forksMatch = block.match(/\/forks"[\s\S]*?>\s*([\d,]+)\s*<\/a>/); + const sinceMatch = block.match(/([\d,]+)\s+stars\s+(?:today|this week|this month)/i); + + rows.push({ + repo, + description, + language, + stars: parseCount(starsMatch?.[1]), + forks: parseCount(forksMatch?.[1]), + starsSince: parseCount(sinceMatch?.[1]), + url: `https://github.com/${repo}`, + }); + if (rows.length >= limit) break; + } + return rows; +} + +cli({ + site: 'github', + name: 'trending', + access: 'read', + description: 'GitHub Trending repositories (public, no login). Filter by --language and --since.', + domain: 'github.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'since', type: 'string', default: 'daily', help: 'Time range: daily / weekly / monthly' }, + { name: 'language', type: 'string', default: '', help: 'Filter by programming language slug, e.g. python, rust, "c++"' }, + { name: 'limit', type: 'int', default: 25, help: 'Number of repositories to return (max 25)' }, + ], + columns: ['rank', 'repo', 'description', 'language', 'stars', 'forks', 'starsSince', 'url'], + func: async (args) => { + const sinceKey = String(args.since ?? 'daily').toLowerCase(); + const since = SINCE[sinceKey]; + if (!since) { + throw new ArgumentError(`Unknown --since "${sinceKey}". Valid: ${Object.keys(SINCE).join(', ')}`); + } + + const n = Number(args.limit ?? 25); + if (!Number.isInteger(n) || n <= 0) { + throw new ArgumentError('--limit must be a positive integer'); + } + if (n > 25) { + throw new ArgumentError('--limit must be <= 25 (GitHub Trending lists at most 25 repositories)'); + } + const limit = n; + + const language = String(args.language ?? '').trim(); + const path = language ? `/trending/${encodeURIComponent(language)}` : '/trending'; + const url = new URL(`https://github.com${path}`); + url.searchParams.set('since', since); + + let resp; + try { + resp = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; opencli/github-trending)', + Accept: 'text/html', + }, + }); + } catch (error) { + throw new CommandExecutionError(`github trending request failed: ${error?.message || error}`); + } + if (!resp.ok) { + throw new CommandExecutionError(`github trending request failed: HTTP ${resp.status}`); + } + + const html = await resp.text(); + const rows = parseTrendingHtml(html, limit); + if (rows.length === 0) { + throw new EmptyResultError('github trending', language + ? `no trending repositories for language "${language}" (${since})` + : `no trending repositories (${since})`); + } + + return rows.map((row, index) => ({ + rank: index + 1, + repo: row.repo, + description: row.description, + language: row.language, + stars: row.stars, + forks: row.forks, + starsSince: row.starsSince, + url: row.url, + })); + }, +}); diff --git a/clis/github/trending.test.js b/clis/github/trending.test.js new file mode 100644 index 000000000..c5411581b --- /dev/null +++ b/clis/github/trending.test.js @@ -0,0 +1,118 @@ +import { getRegistry } from '@jackwener/opencli/registry'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import './trending.js'; + +function loadCommand() { + const cmd = getRegistry().get('github/trending'); + if (!cmd?.func) throw new Error('github/trending not found or has no func'); + return cmd; +} + +function article({ repo, desc, lang, stars, forks, since }) { + const langSpan = lang ? `${lang}` : ''; + const descP = desc == null ? '' : `

\n ${desc}\n

`; + return ``; +} + +function pageHtml(articles) { + return `
${articles.join('\n')}
`; +} + +function mockHtmlOnce(html, { ok = true, status = 200 } = {}) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok, + status, + statusText: 'OK', + text: vi.fn().mockResolvedValue(html), + })); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('github/trending', () => { + it('parses repos, stars, forks, language, description and url', async () => { + const html = pageHtml([ + article({ repo: 'owner-a/repo-a', desc: 'Tools & toys for the web', lang: 'Rust', stars: '1,234', forks: '56', since: '78' }), + article({ repo: 'owner-b/repo-b', desc: null, lang: null, stars: '9', forks: '0', since: '3' }), + ]); + mockHtmlOnce(html); + + const rows = await loadCommand().func({ since: 'daily', language: '', limit: 25 }); + + expect(rows).toEqual([ + { rank: 1, repo: 'owner-a/repo-a', description: 'Tools & toys for the web', language: 'Rust', stars: 1234, forks: 56, starsSince: 78, url: 'https://github.com/owner-a/repo-a' }, + { rank: 2, repo: 'owner-b/repo-b', description: '', language: null, stars: 9, forks: 0, starsSince: 3, url: 'https://github.com/owner-b/repo-b' }, + ]); + }); + + it('honors --limit by truncating the result set', async () => { + const html = pageHtml([ + article({ repo: 'a/a', desc: 'a', lang: 'Go', stars: '1', forks: '1', since: '1' }), + article({ repo: 'b/b', desc: 'b', lang: 'Go', stars: '2', forks: '2', since: '2' }), + article({ repo: 'c/c', desc: 'c', lang: 'Go', stars: '3', forks: '3', since: '3' }), + ]); + mockHtmlOnce(html); + + const rows = await loadCommand().func({ since: 'daily', language: '', limit: 2 }); + expect(rows.map((r) => r.repo)).toEqual(['a/a', 'b/b']); + }); + + it('builds the language + since URL', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(pageHtml([ + article({ repo: 'x/y', desc: 'z', lang: 'Rust', stars: '1', forks: '1', since: '1' }), + ])), + }); + vi.stubGlobal('fetch', fetchFn); + + await loadCommand().func({ since: 'weekly', language: 'rust', limit: 25 }); + + const calledUrl = String(fetchFn.mock.calls[0][0]); + expect(calledUrl).toBe('https://github.com/trending/rust?since=weekly'); + }); + + it('rejects an unknown --since', async () => { + await expect(loadCommand().func({ since: 'yearly', language: '', limit: 25 })) + .rejects.toThrow(/since/i); + }); + + it('rejects a non-positive --limit', async () => { + await expect(loadCommand().func({ since: 'daily', language: '', limit: 0 })) + .rejects.toThrow(/limit/i); + }); + + it('rejects --limit above 25', async () => { + await expect(loadCommand().func({ since: 'daily', language: '', limit: 30 })) + .rejects.toThrow(/limit/i); + }); + + it('throws EmptyResultError when no repositories are present', async () => { + mockHtmlOnce(pageHtml([])); + await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 })) + .rejects.toThrow(/no data|empty/i); + }); + + it('throws on a non-ok HTTP response', async () => { + mockHtmlOnce('', { ok: false, status: 503 }); + await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 })) + .rejects.toThrow(/HTTP 503/); + }); +});