Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

161 lines
5.8 KiB
JavaScript

// Shared helpers for the OpenAlex (`api.openalex.org`) adapter.
//
// OpenAlex is a free, open scholarly works database. The REST API is
// unauthenticated; passing an email via `mailto=` opts into the polite pool
// (faster). Work IDs are `W` followed by digits (`W2741809807`) and
// round-trip via `https://api.openalex.org/works/<id>` or
// `https://openalex.org/W…`.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const OPENALEX_BASE = 'https://api.openalex.org';
const UA = 'opencli-openalex-adapter (+https://github.com/jackwener/opencli)';
// OpenAlex stable IDs: a single-letter prefix (`W` works, `A` authors, `S`
// sources, `I` institutions…) + at least 4 digits. We accept just `W` here.
const WORK_ID = /^W\d{4,}$/;
// DOIs are loose — accept anything starting with "10." after the optional
// `doi.org/` prefix; OpenAlex itself does the normalization.
const DOI_BARE = /^10\.\S+$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`openalex ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`openalex ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`openalex ${label} must be <= ${maxValue}`);
}
return n;
}
/**
* Resolve a user-supplied work identifier to OpenAlex's canonical path
* segment. Accepts `W…` IDs, `doi:10.…`, raw DOIs, or full
* `https://doi.org/…` / `https://openalex.org/W…` URLs.
*/
export function requireWorkRef(value) {
const raw = String(value ?? '').trim();
if (!raw) {
throw new ArgumentError('openalex work id is required (e.g. "W2741809807", "10.7717/peerj.4375")');
}
// 1) full openalex URL
const oaUrl = raw.match(/^https?:\/\/(?:api\.)?openalex\.org\/(?:works\/)?([WAaSCFwIPwT]\d+)/i);
if (oaUrl) {
const id = oaUrl[1].toUpperCase();
if (id[0] !== 'W') {
throw new ArgumentError(`openalex work id "${value}" must be a Work (W…) ID, got "${id[0]}…"`);
}
return id;
}
// 2) bare W… id
if (WORK_ID.test(raw.toUpperCase())) {
return raw.toUpperCase();
}
// 3) doi:… prefix
if (/^doi:/i.test(raw)) {
const doi = raw.replace(/^doi:/i, '').trim();
if (DOI_BARE.test(doi)) return `doi:${doi}`;
}
// 4) full doi URL
const doiUrl = raw.match(/^https?:\/\/(?:dx\.)?doi\.org\/(.+)$/i);
if (doiUrl && DOI_BARE.test(doiUrl[1])) {
return `doi:${doiUrl[1]}`;
}
// 5) bare 10.xxxx/yyy DOI
if (DOI_BARE.test(raw)) {
return `doi:${raw}`;
}
throw new ArgumentError(
`openalex work id "${value}" is not recognised`,
'Use a Work id ("W2741809807"), a DOI ("10.7717/peerj.4375"), or a full openalex.org / doi.org URL.',
);
}
export async function openalexFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that api.openalex.org is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `OpenAlex returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'OpenAlex throttles unauthenticated traffic; wait a few seconds and retry, or set OPENALEX_MAILTO.',
);
}
if (!resp.ok) {
let detail = '';
try {
const text = await resp.text();
const match = text.match(/"message"\s*:\s*"([^"]+)"/);
if (match) detail = ` (${match[1]})`;
}
catch { /* ignore */ }
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}${detail}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
/** Strip the `https://openalex.org/` prefix if present so columns surface just the bare id. */
export function bareId(value) {
const s = String(value ?? '').trim();
if (!s) return '';
return s.replace(/^https?:\/\/(?:api\.)?openalex\.org\//i, '').replace(/^works\//i, '');
}
/** Strip the `https://doi.org/` prefix so DOIs render as plain `10.…/…` strings. */
export function bareDoi(value) {
const s = String(value ?? '').trim();
if (!s) return '';
return s.replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '');
}
/**
* Reconstruct a plain-text abstract from OpenAlex's
* `abstract_inverted_index` (token → [positions]). OpenAlex returns the
* abstract this way for licensing reasons.
*/
export function reconstructAbstract(invertedIndex) {
if (!invertedIndex || typeof invertedIndex !== 'object') return '';
const positions = [];
for (const [token, idxs] of Object.entries(invertedIndex)) {
if (!Array.isArray(idxs)) continue;
for (const i of idxs) {
if (Number.isInteger(i) && i >= 0 && i < 100000) {
positions[i] = token;
}
}
}
return positions.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
/** Append the polite-pool `mailto` query param if the env var is set. */
export function appendMailto(url) {
const mailto = process.env.OPENALEX_MAILTO?.trim();
if (!mailto) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}mailto=${encodeURIComponent(mailto)}`;
}