9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
291 lines
11 KiB
JavaScript
291 lines
11 KiB
JavaScript
/**
|
|
* DBLP adapter utilities.
|
|
*
|
|
* dblp serves a public, unauthenticated API:
|
|
* - Publication search (JSON):
|
|
* `https://dblp.org/search/publ/api?q=<query>&format=json&h=<limit>`
|
|
* - Per-record metadata (XML):
|
|
* `https://dblp.org/rec/<key>.xml`
|
|
*
|
|
* The search response is well-shaped JSON, but the per-record endpoint is
|
|
* XML. We parse it with conservative regexes (same pattern as the arxiv
|
|
* adapter) to avoid pulling in an XML lib for this single endpoint.
|
|
*/
|
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
|
|
export const DBLP_ORIGIN = 'https://dblp.org';
|
|
|
|
/**
|
|
* dblp record keys look like `<type>/<venue>/<short>` (e.g.
|
|
* `conf/nips/VaswaniSPUJGKP17`, `journals/corr/abs-2509-05821`,
|
|
* `phd/Smith20`). Allow lowercase letter prefixes plus 1+ slash-segments
|
|
* containing letters / digits / `_` / `.` / `-`.
|
|
*/
|
|
const KEY_PATTERN = /^[a-z]+(?:\/[A-Za-z0-9_.-]+)+$/;
|
|
|
|
/**
|
|
* Wraps `fetch` with typed errors. We always set a UA per dblp's
|
|
* polite-fetch guidance (https://dblp.org/faq/How+to+use+the+dblp+search+API.html).
|
|
*/
|
|
async function dblpFetch(url, label, accept) {
|
|
let res;
|
|
try {
|
|
res = await fetch(url, {
|
|
headers: {
|
|
accept,
|
|
'user-agent': 'opencli-dblp/1.0 (+https://github.com/jackwener/opencli)',
|
|
},
|
|
});
|
|
}
|
|
catch (err) {
|
|
throw new CommandExecutionError(`${label} request failed: ${err?.message ?? err}`, 'Check that dblp.org is reachable from this network.');
|
|
}
|
|
if (!res.ok) {
|
|
if (res.status === 429) {
|
|
throw new CommandExecutionError(`${label} returned HTTP 429 (rate limited)`, 'dblp throttles clients that fetch too aggressively. Wait a few seconds and retry, or lower --limit.');
|
|
}
|
|
if (res.status === 404) {
|
|
throw new EmptyResultError(label, 'dblp returned 404 — the requested record may not exist.');
|
|
}
|
|
throw new CommandExecutionError(`${label} returned HTTP ${res.status}`, 'Inspect the response in a browser at the same URL for more context.');
|
|
}
|
|
return res;
|
|
}
|
|
|
|
export async function dblpFetchJson(path, label) {
|
|
const res = await dblpFetch(`${DBLP_ORIGIN}${path}`, label, 'application/json');
|
|
let body;
|
|
try {
|
|
body = await res.json();
|
|
}
|
|
catch (err) {
|
|
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
}
|
|
const statusCode = String(body?.result?.status?.['@code'] ?? '').trim();
|
|
if (!statusCode) {
|
|
throw new CommandExecutionError(
|
|
`${label} returned JSON without result.status.@code`,
|
|
'dblp changed its JSON envelope or returned a partial error payload; inspect the raw response in a browser.',
|
|
);
|
|
}
|
|
if (statusCode !== '200') {
|
|
const statusText = String(body?.result?.status?.text ?? '').trim();
|
|
throw new CommandExecutionError(
|
|
`${label} returned API status ${statusCode}${statusText ? ` (${statusText})` : ''}`,
|
|
'dblp accepted the HTTP request but reported an API-level failure. Retry later or inspect the same query in a browser.',
|
|
);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
export async function dblpFetchXml(path, label) {
|
|
const res = await dblpFetch(`${DBLP_ORIGIN}${path}`, label, 'application/xml');
|
|
return res.text();
|
|
}
|
|
|
|
export function coerceInt(value) {
|
|
if (value === undefined || value === null || value === '') return NaN;
|
|
const n = typeof value === 'number' ? value : Number(value);
|
|
return Number.isFinite(n) && Number.isInteger(n) ? n : NaN;
|
|
}
|
|
|
|
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
const raw = value ?? defaultValue;
|
|
const n = coerceInt(raw);
|
|
if (!Number.isInteger(n) || n <= 0) {
|
|
throw new ArgumentError(`dblp ${label} must be a positive integer`);
|
|
}
|
|
if (n > maxValue) {
|
|
throw new ArgumentError(`dblp ${label} must be <= ${maxValue}`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export function requireQuery(value, label = 'query') {
|
|
const q = String(value ?? '').trim();
|
|
if (!q) {
|
|
throw new ArgumentError(`dblp ${label} cannot be empty`);
|
|
}
|
|
return q;
|
|
}
|
|
|
|
export function requireRecordKey(value) {
|
|
const key = String(value ?? '').trim();
|
|
if (!key) {
|
|
throw new ArgumentError('dblp paper key is required');
|
|
}
|
|
if (!KEY_PATTERN.test(key)) {
|
|
throw new ArgumentError(`dblp paper key "${value}" is not a valid record key`, 'Expected something like "conf/nips/VaswaniSPUJGKP17" — copy the `key` column from `dblp search`.');
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/** Decode the small set of XML entities dblp emits in record bodies. */
|
|
export function decodeXmlEntities(text) {
|
|
if (!text) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/'/g, "'")
|
|
.replace(/"/g, '"')
|
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)));
|
|
}
|
|
|
|
/** Strip dblp's per-author homonym suffixes (`Smith 0001`) → `Smith`. */
|
|
function trimAuthorHomonym(name) {
|
|
return String(name || '').replace(/\s+\d{4,}$/, '').trim();
|
|
}
|
|
|
|
/**
|
|
* Normalize the `info.authors.author` field from a dblp search hit. dblp
|
|
* collapses single authors into one object instead of a 1-element array.
|
|
*/
|
|
export function normalizeAuthors(authorsField) {
|
|
if (!authorsField) return [];
|
|
const raw = authorsField?.author;
|
|
if (!raw) return [];
|
|
const list = Array.isArray(raw) ? raw : [raw];
|
|
return list.map((a) => {
|
|
if (a && typeof a === 'object') return trimAuthorHomonym(a.text || a['#text'] || '');
|
|
return trimAuthorHomonym(String(a));
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Project a dblp publication search hit into one row. Drops only the most
|
|
* volatile fields (e.g. abstract, which dblp does not expose) and keeps
|
|
* the canonical key as the round-trip handle.
|
|
*/
|
|
export function searchHitToRow(hit, rank) {
|
|
const info = hit?.info ?? {};
|
|
const authors = normalizeAuthors(info.authors);
|
|
const key = String(info.key ?? '').trim();
|
|
return {
|
|
rank,
|
|
key,
|
|
title: stripTrailingDot(decodeXmlEntities(info.title ?? '')).trim(),
|
|
authors: authors.join(', '),
|
|
venue: decodeXmlEntities(info.venue ?? ''),
|
|
year: String(info.year ?? '').trim(),
|
|
type: simplifyHitType(info.type),
|
|
doi: String(info.doi ?? '').trim(),
|
|
url: String(info.ee ?? info.url ?? '').trim(),
|
|
};
|
|
}
|
|
|
|
/** Title strings from dblp end with a period — strip it for cleaner display. */
|
|
function stripTrailingDot(s) {
|
|
return String(s || '').replace(/\.\s*$/, '');
|
|
}
|
|
|
|
/** Compress dblp's verbose `type` strings into single-token tags. */
|
|
function simplifyHitType(type) {
|
|
if (!type) return '';
|
|
const t = String(type);
|
|
if (/Conference and Workshop/i.test(t)) return 'conf';
|
|
if (/Journal Articles/i.test(t)) return 'journal';
|
|
if (/Books and Theses/i.test(t)) return 'book';
|
|
if (/Editorship/i.test(t)) return 'editorship';
|
|
if (/Reference Works/i.test(t)) return 'reference';
|
|
if (/Informal/i.test(t)) return 'preprint';
|
|
return t.toLowerCase().split(/\s+/)[0];
|
|
}
|
|
|
|
/**
|
|
* Extract a single tag from a dblp record XML blob (regex-based, same
|
|
* approach as arxiv/utils.js — keeps the dependency surface flat).
|
|
*/
|
|
export function extractFirst(xml, tag) {
|
|
const m = String(xml || '').match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`));
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
export function extractAll(xml, tag) {
|
|
const out = [];
|
|
const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
|
let m;
|
|
while ((m = re.exec(String(xml || ''))) !== null) out.push(m[1]);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Pick the first `<ee type="oa">` (open-access link) if present, otherwise
|
|
* fall back to the first `<ee>`.
|
|
*/
|
|
export function extractOpenAccessLink(xml) {
|
|
const oa = String(xml || '').match(/<ee\b[^>]*type=["']oa["'][^>]*>([\s\S]*?)<\/ee>/);
|
|
if (oa) return oa[1].trim();
|
|
const any = String(xml || '').match(/<ee\b[^>]*>([\s\S]*?)<\/ee>/);
|
|
return any ? any[1].trim() : '';
|
|
}
|
|
|
|
/** Pull the `key` attribute off the wrapper element regardless of record type. */
|
|
export function extractRecordKey(xml) {
|
|
const m = String(xml || '').match(/<(?:article|inproceedings|incollection|proceedings|book|phdthesis|mastersthesis)\b[^>]*\bkey="([^"]+)"/);
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
/**
|
|
* Identify the record type from the wrapper element name. Maps to the
|
|
* same simplified tag set the search rows use.
|
|
*/
|
|
export function extractRecordType(xml) {
|
|
const m = String(xml || '').match(/<(article|inproceedings|incollection|proceedings|book|phdthesis|mastersthesis)\b/);
|
|
if (!m) return '';
|
|
switch (m[1]) {
|
|
case 'inproceedings': return 'conf';
|
|
case 'article': return 'journal';
|
|
case 'incollection': return 'incollection';
|
|
case 'proceedings': return 'editorship';
|
|
case 'book': return 'book';
|
|
case 'phdthesis': return 'phdthesis';
|
|
case 'mastersthesis': return 'mastersthesis';
|
|
default: return m[1];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a single dblp record XML into the row shape used by `dblp paper`.
|
|
* Treats every field as optional; throws upstream when the record body
|
|
* is missing entirely (404 already handled at fetch level).
|
|
*/
|
|
export function recordXmlToRow(xml) {
|
|
const key = extractRecordKey(xml);
|
|
const type = extractRecordType(xml);
|
|
const title = stripTrailingDot(decodeXmlEntities(extractFirst(xml, 'title')));
|
|
const authors = extractAll(xml, 'author').map((a) => trimAuthorHomonym(decodeXmlEntities(a)));
|
|
const year = decodeXmlEntities(extractFirst(xml, 'year'));
|
|
const pages = decodeXmlEntities(extractFirst(xml, 'pages'));
|
|
const venueRaw = type === 'conf'
|
|
? decodeXmlEntities(extractFirst(xml, 'booktitle'))
|
|
: decodeXmlEntities(extractFirst(xml, 'journal'));
|
|
const doi = (() => {
|
|
const m = String(xml || '').match(/<ee\b[^>]*>([^<]*?(?:doi\.org\/|10\.[^"<]+))<\/ee>/i);
|
|
if (!m) return '';
|
|
const v = m[1].replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '');
|
|
return v.startsWith('10.') ? v : '';
|
|
})();
|
|
return {
|
|
key,
|
|
type,
|
|
title,
|
|
authors: authors.join(', '),
|
|
venue: venueRaw,
|
|
year,
|
|
pages,
|
|
doi,
|
|
open_access_url: extractOpenAccessLink(xml),
|
|
dblp_url: key ? `${DBLP_ORIGIN}/rec/${key}.html` : '',
|
|
};
|
|
}
|
|
|
|
export const SEARCH_COLUMNS = [
|
|
'rank', 'key', 'title', 'authors', 'venue', 'year', 'type', 'doi', 'url',
|
|
];
|
|
|
|
export const PAPER_COLUMNS = [
|
|
'key', 'type', 'title', 'authors', 'venue', 'year', 'pages', 'doi', 'open_access_url', 'dblp_url',
|
|
];
|