feat(hf): add top command for hf papers (daily, weekly, monthly) (#110)
* 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 <jakevingoo@gmail.com>
This commit is contained in:
1 parent
943e286815
commit
024d9908b3
4 files changed
+152
-2
No files matched your search
+7
-2
@@ -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}`));
|
||||
|
||||
@@ -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 ?? []),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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(' · ')));
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface CliCommand {
|
||||
pipeline?: Record<string, unknown>[];
|
||||
timeoutSeconds?: number;
|
||||
source?: string;
|
||||
footerExtra?: (kwargs: Record<string, any>) => 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);
|
||||
|
||||
Reference in new issue
Block a user