feat: add arxiv and wikipedia adapters (#132)

Add arXiv (search, paper) and Wikipedia (search, summary) public API adapters.

- arxiv/search: search papers by keyword
- arxiv/paper: get paper details by ID  
- wikipedia/search: search articles with lang support
- wikipedia/summary: get article summary

Type safety fixes applied: wikiFetch returns unknown, typed search results.

Co-authored-by: BruceLoveDecimal <39156883+BruceLoveDecimal@users.noreply.github.com>
This commit is contained in:
BruceLoveDecimal authored and GitHub committed 2026-03-20 17:08:17 +08:00
1 parent 4f74b45963
commit 3cda14a2ab
8 files changed
+190

No files matched your search

+2
View File
@@ -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 |
+2
View File
@@ -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` | 浏览器 |
+21
View File
@@ -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;
},
});
+24
View File
@@ -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 }));
},
});
+63
View File
@@ -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<string> {
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 = /<entry>([\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;
}
+30
View File
@@ -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, '_'))}`,
}));
},
});
+28
View File
@@ -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 <keyword>');
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}`,
}];
},
});
+20
View File
@@ -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<any> {
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();
}