diff --git a/src/cli.ts b/src/cli.ts index 92ee5fd..9cc6f8d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { executeCommand } from './engine.js'; -import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; +import { Strategy, type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; import { render as renderOutput } from './output.js'; import { BrowserBridge, CDPBridge } from './browser/index.js'; import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js'; @@ -160,13 +160,18 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { if (cmd.browser) { const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; result = await browserSession(BrowserFactory as any, async (page) => { + // Cookie/header strategies require same-origin context for credentialed fetch. + if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) { + try { await page.goto(`https://${cmd.domain}`); await page.wait(2); } catch {} + } return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }); }); } else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); } if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`)); } - renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) }); + const resolved = getRegistry().get(fullName(cmd)) ?? cmd; + renderOutput(result, { fmt: actionOpts.format, columns: resolved.columns, title: `${resolved.site}/${resolved.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(resolved), footerExtra: resolved.footerExtra?.(kwargs) }); } catch (err: any) { if (err instanceof CliError) { console.error(chalk.red(`Error [${err.code}]: ${err.message}`)); diff --git a/src/clis/hf/top.ts b/src/clis/hf/top.ts new file mode 100644 index 0000000..b912b41 --- /dev/null +++ b/src/clis/hf/top.ts @@ -0,0 +1,141 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; + +interface PaperAuthor { + name: string; +} + +interface DailyPaper { + paper: { + id: string; + upvotes: number; + authors: PaperAuthor[]; + }; + title: string; + numComments: number; +} + +interface PeriodPaper { + id: string; + title: string; + upvotes: number; + publishedAt: string; + authors: PaperAuthor[]; +} + +function truncate(str: string, max = 60): string { + return str.length > max ? str.slice(0, max - 3) + '...' : str; +} + +function formatAuthors(authors: PaperAuthor[], max = 3): string { + const names = authors.map((a) => a.name); + if (names.length <= max) return names.join(', '); + return names.slice(0, max).join(', ') + ' et al.'; +} + +const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function getMonthRange(): string { + const now = new Date(); + return `${MONTH_ABBR[now.getUTCMonth()]} ${now.getUTCFullYear()}`; +} + +function getWeekRange(): string { + const now = new Date(); + const day = now.getUTCDay(); // 0=Sun, 6=Sat + const daysToSat = day === 6 ? 0 : 6 - day; + const end = new Date(now); + end.setUTCDate(now.getUTCDate() + daysToSat); + const start = new Date(end); + start.setUTCDate(end.getUTCDate() - 6); + + const sm = MONTH_ABBR[start.getUTCMonth()]; + const em = MONTH_ABBR[end.getUTCMonth()]; + const sd = start.getUTCDate(); + const ed = end.getUTCDate(); + return sm === em ? `${sm} ${sd}-${ed}` : `${sm} ${sd}-${em} ${ed}`; +} + +cli({ + site: 'hf', + name: 'top', + description: 'Top upvoted Hugging Face papers', + domain: 'huggingface.co', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of papers' }, + { name: 'all', type: 'bool', default: false, help: 'Return all papers (ignore limit)' }, + { name: 'date', type: 'str', required: false, help: 'Date (YYYY-MM-DD), defaults to most recent' }, + { name: 'period', type: 'str', default: 'daily', choices: ['daily', 'weekly', 'monthly'], help: 'Time period: daily, weekly, or monthly' }, + ], + footerExtra: (kwargs) => { + if (kwargs._footerDate) return kwargs._footerDate; + if (kwargs.period === 'monthly') return getMonthRange(); + if (kwargs.period === 'weekly') return getWeekRange(); + return kwargs.date ?? new Date().toISOString().slice(0, 10); + }, + func: async (_page, kwargs) => { + const period = String(kwargs.period ?? 'daily'); + const all = Boolean(kwargs.all); + const endpoint = process.env.HF_ENDPOINT?.replace(/\/+$/, '') || 'https://huggingface.co'; + + if (period === 'weekly' || period === 'monthly') { + if (kwargs.date) { + throw new CliError('INVALID_ARG', `--date is not supported for ${period} period`, `Omit --date when using --period ${period}`); + } + const url = `${endpoint}/api/papers?period=${period}`; + const res = await fetch(url); + if (!res.ok) throw new CliError('FETCH_ERROR', `HF API error: ${res.status} ${res.statusText}`, 'Check HF_ENDPOINT or try again later'); + const body = await res.json(); + if (!Array.isArray(body)) throw new CliError('FETCH_ERROR', 'Unexpected HF API response', 'Check endpoint'); + const data: PeriodPaper[] = body; + const dates = data.map((d) => d.publishedAt).filter(Boolean).sort(); + if (dates.length > 0) { + if (period === 'monthly') { + const d = new Date(dates[0]); + kwargs._footerDate = `${MONTH_ABBR[d.getUTCMonth()]} ${d.getUTCFullYear()}`; + } else { + const start = new Date(dates[0]); + const end = new Date(dates[dates.length - 1]); + const sm = MONTH_ABBR[start.getUTCMonth()]; + const em = MONTH_ABBR[end.getUTCMonth()]; + const sd = start.getUTCDate(); + const ed = end.getUTCDate(); + kwargs._footerDate = sm === em ? `${sm} ${sd}-${ed}` : `${sm} ${sd}-${em} ${ed}`; + } + } + const sorted = [...data].sort((a, b) => (b.upvotes ?? 0) - (a.upvotes ?? 0)); + const items = all ? sorted : sorted.slice(0, Number(kwargs.limit)); + return items.map((item, i) => ({ + rank: i + 1, + id: item.id ?? '', + title: truncate(item.title ?? ''), + upvotes: item.upvotes ?? 0, + authors: formatAuthors(item.authors ?? []), + })); + } + + // daily + if (kwargs.date && !/^\d{4}-\d{2}-\d{2}$/.test(String(kwargs.date))) { + throw new CliError('INVALID_ARG', `Invalid date format: ${kwargs.date}`, 'Use YYYY-MM-DD'); + } + const url = kwargs.date + ? `${endpoint}/api/daily_papers?date=${kwargs.date}` + : `${endpoint}/api/daily_papers`; + const res = await fetch(url); + if (!res.ok) throw new CliError('FETCH_ERROR', `HF API error: ${res.status} ${res.statusText}`, 'Check HF_ENDPOINT or try again later'); + const body = await res.json(); + if (!Array.isArray(body)) throw new CliError('FETCH_ERROR', 'Unexpected HF API response', 'Check date format or endpoint'); + const data: DailyPaper[] = body; + const sorted = [...data].sort((a, b) => (b.paper?.upvotes ?? 0) - (a.paper?.upvotes ?? 0)); + const items = all ? sorted : sorted.slice(0, Number(kwargs.limit)); + return items.map((item, i) => ({ + rank: i + 1, + id: item.paper?.id ?? '', + title: truncate(item.title ?? ''), + upvotes: item.paper?.upvotes ?? 0, + authors: formatAuthors(item.paper?.authors ?? []), + })); + }, +}); diff --git a/src/output.ts b/src/output.ts index d2cea78..2daf74c 100644 --- a/src/output.ts +++ b/src/output.ts @@ -12,6 +12,7 @@ export interface RenderOptions { title?: string; elapsed?: number; source?: string; + footerExtra?: string; } export function render(data: unknown, opts: RenderOptions = {}): void { @@ -56,6 +57,7 @@ function renderTable(data: unknown, opts: RenderOptions): void { footer.push(`${rows.length} items`); if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`); if (opts.source) footer.push(opts.source); + if (opts.footerExtra) footer.push(opts.footerExtra); console.log(chalk.dim(footer.join(' ยท '))); } diff --git a/src/registry.ts b/src/registry.ts index 894119d..ed50778 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -35,6 +35,7 @@ export interface CliCommand { pipeline?: Record[]; timeoutSeconds?: number; source?: string; + footerExtra?: (kwargs: Record) => string | undefined; } /** Internal extension for lazy-loaded TS modules (not exposed in public API) */ @@ -63,6 +64,7 @@ export function cli(opts: CliOptions): CliCommand { func: opts.func, pipeline: opts.pipeline, timeoutSeconds: opts.timeoutSeconds, + footerExtra: opts.footerExtra, }; const key = fullName(cmd);