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
45 changes: 45 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions clis/github/trending.js
Original file line number Diff line number Diff line change
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&nbsp;/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('<article class="Box-row">').slice(1);
const rows = [];
for (const raw of blocks) {
const block = raw.split('</article>')[0];

const nameMatch = block.match(/<h2\b[\s\S]*?href="\/([^"?#]+)"/);
if (!nameMatch) continue;
const repo = decodeHtmlEntities(nameMatch[1]).trim();
if (!repo.includes('/')) continue;

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 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,
}));
},
});
118 changes: 118 additions & 0 deletions clis/github/trending.test.js
Original file line number Diff line number Diff line change
@@ -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 ? `<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', () => {
it('parses repos, stars, forks, language, description and url', async () => {
const html = pageHtml([
article({ repo: 'owner-a/repo-a', desc: 'Tools &amp; 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/);
});
});