chore: import upstream snapshot with attribution
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 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 escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function assertCount(value, field, repo) {
|
||||
const count = parseCount(value);
|
||||
if (count == null) {
|
||||
throw new CommandExecutionError(`github-trending parser drift: missing ${field} for ${repo}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function hasExplicitEmptyTrending(html) {
|
||||
return /don.t have any trending repositories/i.test(stripTags(html))
|
||||
|| /no trending repositories/i.test(stripTags(html));
|
||||
}
|
||||
|
||||
function parseTrendingHtml(html, limit) {
|
||||
const blocks = Array.from(String(html ?? '').matchAll(/<article\b[^>]*class="[^"]*\bBox-row\b[^"]*"[^>]*>([\s\S]*?)<\/article>/g))
|
||||
.map((match) => match[1]);
|
||||
const rows = [];
|
||||
|
||||
if (blocks.length === 0) {
|
||||
if (hasExplicitEmptyTrending(html)) return rows;
|
||||
throw new CommandExecutionError('github-trending parser drift: no repository rows found');
|
||||
}
|
||||
|
||||
for (const raw of blocks) {
|
||||
const block = raw;
|
||||
|
||||
const nameMatch = block.match(/<h2\b[\s\S]*?href="\/([^"/?#]+\/[^"/?#]+)"/);
|
||||
if (!nameMatch) {
|
||||
throw new CommandExecutionError('github-trending parser drift: missing repository link');
|
||||
}
|
||||
const repo = decodeHtmlEntities(nameMatch[1]).trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
||||
throw new CommandExecutionError(`github-trending parser drift: invalid repository identity "${repo}"`);
|
||||
}
|
||||
|
||||
const descMatch = block.match(/<p class="col-9 color-fg-muted[^"]*">([\s\S]*?)<\/p>/);
|
||||
const description = descMatch
|
||||
? decodeHtmlEntities(stripTags(descMatch[1]).replace(/\s+/g, ' ')).trim()
|
||||
: '';
|
||||
|
||||
const langMatch = block.match(/<span itemprop="programmingLanguage">([\s\S]*?)<\/span>/);
|
||||
const language = langMatch ? decodeHtmlEntities(stripTags(langMatch[1])).trim() : null;
|
||||
|
||||
const escapedRepo = escapeRegExp(repo);
|
||||
const starsMatch = block.match(new RegExp(`<a\\b[^>]*href="/${escapedRepo}/stargazers"[^>]*>([\\s\\S]*?)</a>`));
|
||||
const forksMatch = block.match(new RegExp(`<a\\b[^>]*href="/${escapedRepo}/forks"[^>]*>([\\s\\S]*?)</a>`));
|
||||
const sinceMatch = block.match(/([\d,]+)\s+stars\s+(?:today|this week|this month)/i);
|
||||
|
||||
rows.push({
|
||||
repo,
|
||||
description,
|
||||
language,
|
||||
stars: assertCount(starsMatch ? stripTags(starsMatch[1]) : null, 'stars', repo),
|
||||
forks: assertCount(forksMatch ? stripTags(forksMatch[1]) : null, 'forks', repo),
|
||||
starsSince: assertCount(sinceMatch?.[1], 'period stars', repo),
|
||||
url: `https://github.com/${repo}`,
|
||||
});
|
||||
if (rows.length >= limit) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'github-trending',
|
||||
name: 'repos',
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import './repos.js';
|
||||
|
||||
function loadCommand() {
|
||||
const cmd = getRegistry().get('github-trending/repos');
|
||||
if (!cmd?.func) throw new Error('github-trending/repos not found or has no func');
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function article({ repo, desc, lang, stars, forks, since }) {
|
||||
const langSpan = lang ? `<span itemprop="programmingLanguage">${lang}</span>` : '';
|
||||
const descP = desc == null ? '' : `<p class="col-9 color-fg-muted my-1 tmp-pr-4">\n ${desc}\n </p>`;
|
||||
return `<article class="Box-row">
|
||||
<h2 class="h3 lh-condensed">
|
||||
<a href="/${repo}" data-view-component="true" class="Link">
|
||||
<span class="text-normal">${repo.split('/')[0]} /</span>
|
||||
${repo.split('/')[1]}</a>
|
||||
</h2>
|
||||
${descP}
|
||||
<div class="f6 color-fg-muted mt-2">
|
||||
${langSpan}
|
||||
<a href="/${repo}/stargazers" class="Link Link--muted d-inline-block"><svg></svg>\n ${stars}</a>
|
||||
<a href="/${repo}/forks" class="Link Link--muted d-inline-block"><svg></svg>\n ${forks}</a>
|
||||
<span class="d-inline-block float-sm-right"><svg></svg> ${since} stars today</span>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function pageHtml(articles) {
|
||||
return `<!DOCTYPE html><html><body><div class="Box">${articles.join('\n')}</div></body></html>`;
|
||||
}
|
||||
|
||||
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/repos', () => {
|
||||
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('<main><h2>It looks like we don’t have any trending repositories for your choices.</h2></main>');
|
||||
await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
|
||||
.rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('fails closed when GitHub row selectors drift without an explicit empty marker', async () => {
|
||||
mockHtmlOnce('<main><div class="Box">Trending repositories</div></main>');
|
||||
await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('fails closed when a repository row has no repo identity', async () => {
|
||||
mockHtmlOnce(pageHtml([article({ repo: 'owner-a/repo-a', desc: 'x', lang: 'Rust', stars: '1', forks: '2', since: '3' })
|
||||
.replace('href="/owner-a/repo-a"', 'href="https://evil.example/owner-a/repo-a"')]));
|
||||
|
||||
await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('fails closed when stars, forks, or period stars stop parsing', async () => {
|
||||
const valid = article({ repo: 'owner-a/repo-a', desc: 'x', lang: 'Rust', stars: '1', forks: '2', since: '3' });
|
||||
|
||||
mockHtmlOnce(pageHtml([valid.replace('1</a>', 'many</a>')]));
|
||||
await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
|
||||
mockHtmlOnce(pageHtml([valid.replace('2</a>', 'some</a>')]));
|
||||
await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
|
||||
mockHtmlOnce(pageHtml([valid.replace('3 stars today', 'several stars today')]));
|
||||
await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user