diff --git a/README.md b/README.md index 27c7653..6eb5e96 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ Run `opencli list` for the live registry. | **bbc** | `news` | Public | | **ctrip** | `search` | Browser | | **github** | `search` | Public | +| **arxiv** | `search` `paper` | Public | +| **wikipedia** | `search` `summary` | Public | | **hackernews** | `top` | Public | | **linkedin** | `search` | Browser | | **reuters** | `search` | Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index e9a383a..e14d0dc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,6 +138,8 @@ npm install -g @jackwener/opencli@latest | **bbc** | `news` | 公共 API | | **ctrip** | `search` | 浏览器 | | **github** | `search` | 公共 API | +| **arxiv** | `search` `paper` | 公开 | +| **wikipedia** | `search` `summary` | 公开 | | **hackernews** | `top` | 公共 API | | **linkedin** | `search` | 浏览器 | | **reuters** | `search` | 浏览器 | diff --git a/src/clis/arxiv/paper.ts b/src/clis/arxiv/paper.ts new file mode 100644 index 0000000..5d80648 --- /dev/null +++ b/src/clis/arxiv/paper.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { arxivFetch, parseEntries } from './utils.js'; + +cli({ + site: 'arxiv', + name: 'paper', + description: 'Get arXiv paper details by ID', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' }, + ], + columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'], + func: async (_page, args) => { + const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`); + const entries = parseEntries(xml); + if (!entries.length) throw new CliError('NOT_FOUND', `Paper ${args.id} not found`, 'Check the arXiv ID format, e.g. 1706.03762'); + return entries; + }, +}); diff --git a/src/clis/arxiv/search.ts b/src/clis/arxiv/search.ts new file mode 100644 index 0000000..e26caa4 --- /dev/null +++ b/src/clis/arxiv/search.ts @@ -0,0 +1,24 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { arxivFetch, parseEntries } from './utils.js'; + +cli({ + site: 'arxiv', + name: 'search', + description: 'Search arXiv papers', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'keyword', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' }, + { name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' }, + ], + columns: ['id', 'title', 'authors', 'published'], + func: async (_page, args) => { + const limit = Math.max(1, Math.min(Number(args.limit), 25)); + const query = encodeURIComponent(`all:${args.keyword}`); + const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`); + const entries = parseEntries(xml); + if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword'); + return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published })); + }, +}); diff --git a/src/clis/arxiv/utils.ts b/src/clis/arxiv/utils.ts new file mode 100644 index 0000000..b6265c0 --- /dev/null +++ b/src/clis/arxiv/utils.ts @@ -0,0 +1,63 @@ +/** + * arXiv adapter utilities. + * + * arXiv exposes a public Atom/XML API — no key required. + * https://info.arxiv.org/help/api/index.html + */ + +import { CliError } from '../../errors.js'; + +export const ARXIV_BASE = 'https://export.arxiv.org/api/query'; + +export async function arxivFetch(params: string): Promise { + const resp = await fetch(`${ARXIV_BASE}?${params}`); + if (!resp.ok) { + throw new CliError('FETCH_ERROR', `arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID'); + } + return resp.text(); +} + +/** Extract the text content of the first matching XML tag. */ +function extract(xml: string, tag: string): string { + const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`)); + return m ? m[1].trim() : ''; +} + +/** Extract all text contents of a repeated XML tag. */ +function extractAll(xml: string, tag: string): string[] { + const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const results: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) results.push(m[1].trim()); + return results; +} + +export interface ArxivEntry { + id: string; + title: string; + authors: string; + abstract: string; + published: string; + url: string; +} + +/** Parse Atom XML feed into structured entries. */ +export function parseEntries(xml: string): ArxivEntry[] { + const entryRe = /([\s\S]*?)<\/entry>/g; + const entries: ArxivEntry[] = []; + let m: RegExpExecArray | null; + while ((m = entryRe.exec(xml)) !== null) { + const e = m[1]; + const rawId = extract(e, 'id'); + const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, ''); + entries.push({ + id: arxivId, + title: extract(e, 'title').replace(/\s+/g, ' '), + authors: extractAll(e, 'name').slice(0, 3).join(', '), + abstract: extract(e, 'summary').replace(/\s+/g, ' ').slice(0, 200) + '...', + published: extract(e, 'published').slice(0, 10), + url: `https://arxiv.org/abs/${arxivId}`, + }); + } + return entries; +} diff --git a/src/clis/wikipedia/search.ts b/src/clis/wikipedia/search.ts new file mode 100644 index 0000000..bc0b875 --- /dev/null +++ b/src/clis/wikipedia/search.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { wikiFetch } from './utils.js'; + +cli({ + site: 'wikipedia', + name: 'search', + description: 'Search Wikipedia articles', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'keyword', positional: true, required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 10, help: 'Max results' }, + { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' }, + ], + columns: ['title', 'snippet', 'url'], + func: async (_page, args) => { + const limit = Math.max(1, Math.min(Number(args.limit), 50)); + const lang = args.lang || 'en'; + const q = encodeURIComponent(args.keyword); + const data = await wikiFetch(lang, `/w/api.php?action=query&list=search&srsearch=${q}&srlimit=${limit}&format=json&utf8=1`); + const results = data?.query?.search; + if (!results?.length) throw new CliError('NOT_FOUND', 'No articles found', 'Try a different keyword'); + return results.map((r: any) => ({ + title: r.title, + snippet: r.snippet.replace(/<[^>]+>/g, '').slice(0, 120), + url: `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(r.title.replace(/ /g, '_'))}`, + })); + }, +}); diff --git a/src/clis/wikipedia/summary.ts b/src/clis/wikipedia/summary.ts new file mode 100644 index 0000000..a1119d2 --- /dev/null +++ b/src/clis/wikipedia/summary.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { wikiFetch } from './utils.js'; + +cli({ + site: 'wikipedia', + name: 'summary', + description: 'Get Wikipedia article summary', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'title', positional: true, required: true, help: 'Article title (e.g. "Transformer (machine learning model)")' }, + { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' }, + ], + columns: ['title', 'description', 'extract', 'url'], + func: async (_page, args) => { + const lang = args.lang || 'en'; + const title = encodeURIComponent(args.title.replace(/ /g, '_')); + const data = await wikiFetch(lang, `/api/rest_v1/page/summary/${title}`); + if (!data?.title) throw new CliError('NOT_FOUND', `Article "${args.title}" not found`, 'Try searching first: opencli wikipedia search '); + return [{ + title: data.title, + description: data.description ?? '-', + extract: (data.extract ?? '').slice(0, 300), + url: data.content_urls?.desktop?.page ?? `https://${lang}.wikipedia.org/wiki/${title}`, + }]; + }, +}); diff --git a/src/clis/wikipedia/utils.ts b/src/clis/wikipedia/utils.ts new file mode 100644 index 0000000..04fa5a7 --- /dev/null +++ b/src/clis/wikipedia/utils.ts @@ -0,0 +1,20 @@ +/** + * Wikipedia adapter utilities. + * + * Uses the public MediaWiki REST API and Action API — no key required. + * REST API: https://en.wikipedia.org/api/rest_v1/ + * Action API: https://en.wikipedia.org/w/api.php + */ + +import { CliError } from '../../errors.js'; + +export async function wikiFetch(lang: string, path: string): Promise { + const url = `https://${lang}.wikipedia.org${path}`; + const resp = await fetch(url, { + headers: { 'User-Agent': 'opencli/1.0 (https://github.com/jackwener/opencli)' }, + }); + if (!resp.ok) { + throw new CliError('FETCH_ERROR', `Wikipedia API HTTP ${resp.status}`, `Check your title or search term`); + } + return resp.json(); +}