From 024d9908b36470097bd515d0a738f6d87e18575e Mon Sep 17 00:00:00 2001 From: K1tyoo <55703606+k1tyoodev@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:23:06 +0800 Subject: [PATCH] feat(hf): add top command for hf papers (daily, weekly, monthly) (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hf): add top command for hf papers (daily, weekly, monthly) * feat(footer): add footerExtra support and derive dates from API response Add footerExtra callback to CliCommand for custom table footer content. For weekly/monthly periods, derive date range from API response publishedAt field with local clock fallback. * fix: truncate long paper titles * refactor(hf): remove comments column for consistent output * feat(hf): add --all flag to return all papers * feat(hf): add paper id column to output * fix: restore main.ts as bootstrap, sync footerExtra + CDPBridge + domain pre-nav to cli.ts - main.ts should remain a lightweight entry point delegating to cli.ts - Preserve CDPBridge fallback (OPENCLI_CDP_ENDPOINT) — PR had hardcoded BrowserBridge only - Add domain pre-navigation for cookie/header strategies to cli.ts - footerExtra feature from PR is properly integrated --------- Co-authored-by: jackwener --- src/cli.ts | 9 ++- src/clis/hf/top.ts | 141 +++++++++++++++++++++++++++++++++++++++++++++ src/output.ts | 2 + src/registry.ts | 2 + 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/clis/hf/top.ts diff --git a/src/cli.ts b/src/cli.ts index 2f9467b..89b7fe8 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'; @@ -162,13 +162,18 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { if (shouldUseBrowserSession(cmd)) { 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) }); }, { workspace: `site:${cmd.site}` }); } 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);