Catalogs & Documents
The works lane answers a query with catalog records: undated bibliographic entries with identifiers (ISBN, DOI, content hash), availability signals, and links. It sits beside the news, data, and events lanes with its own machinery, and connects to file download and dependency-free text extraction.
Search catalogs
import { internetArchiveSource, openLibrarySource, searchWorks, searchWorksAcross } from "@xbbg/xnews";
const { items, results } = await searchWorksAcross([openLibrarySource(), internetArchiveSource()], {
title: "The Intelligent Investor",
author: "Graham",
limit: 10,
});
for (const record of items) {
console.log(record.provider, record.title, record.publishedYear, record.identity.isbn13, record.availability);
}
for (const result of results) console.log(result.provider, result.status, result.recordCount);
// One catalog, with the same non-throwing envelope.
const single = await searchWorks(openLibrarySource(), { isbn: "9780060555665" });
console.log(single.status, single.items[0]?.title);WorksQuery takes at least one of query, title, author, isbn, or doi (an empty query is a config error), plus page (1-based), maxPages (default 1), limit, and the usual transport options. searchWorks returns a WorksResult (status, items, recordCount, page, warnings, requestUrls, error); searchWorksAcross queries catalogs concurrently and merges their records while keeping each catalog's envelope.
| Catalog | Factory | Origin | Policy notes |
|---|---|---|---|
open-library | openLibrarySource() | Official API | Send a User-Agent naming your app and a contact; identified callers get a higher allowance |
internet-archive | internetArchiveSource() | Official API | No published rate ceiling, but heavy anonymous traffic is throttled. Borrowable items are not redistributable |
libgen | libgenSource({ mirrors }) | Caller-supplied mirrors | Unofficial; no terms or uptime contract; queries need at least 3 characters |
annas-archive | annasArchiveSource({ mirrors }) | Caller-supplied mirrors | Unofficial; metadata search is keyless; fast downloads need a paid membership key |
WORKS_PROVIDER_POLICIES holds the same facts as data. You are responsible for complying with copyright and each catalog's terms; xnews grants no rights to any file.
Records, identity, and dedupe
A WorkRecord has provider, sourceId, title, subtitle?, authors, publisher?, publishedYear?, edition?, series?, language?, format? (file extension), pageCount?, sizeBytes?, identity, availability, url (a detail page, not a file), addedAt?, modifiedAt?, warnings, and provenance.
- Identity holds
isbn13,isbn10,doi,oclc,lccn,openLibraryId, andmd5, plusorigin("record"when the catalog stated them,"resolved"when inferred) andconfidence(always 1 for"record"). - Availability is the catalog's access signal:
open-access,public-domain,preview,borrow,metadata-only, orunknownwhen the catalog says nothing. - Dedupe.
mergeWorkRecords(used bysearchWorksAcross) merges on the strongest shared identifier (ISBN-13, then DOI, then content MD5) and falls back to a normalized title, author, year, and format key.
Scraped catalogs often state no identifiers. resolveWorkIdentity scores a record against an authoritative catalog and returns the best match only above a confidence floor:
import { openLibrarySource, resolveWorkIdentity, type WorkRecord } from "@xbbg/xnews";
async function enrich(record: WorkRecord): Promise<WorkRecord> {
const resolution = await resolveWorkIdentity(record, openLibrarySource(), { minConfidence: 0.9 });
if (resolution.status !== "ok" || !resolution.matched) return record; // no match, or the lookup failed
console.log(resolution.identity.origin, resolution.identity.confidence); // "resolved", 0.9..1
return { ...record, identity: resolution.identity };
}minConfidence defaults to 0.82 and maxCandidates to 10. candidates always lists the scored near misses, and status distinguishes "the catalog answered with no match" from "the catalog never answered" (error or disabled).
ISBN and DOI helpers are exported: normalizeIsbn, isbn10To13, extractIsbns (checksum-validated, from free text), isbnIdentity, normalizeDoi, and workMatchScore.
Mirror pools
libgen and annas-archive rotate domains faster than a release cycle, so every URL builder takes an origin you supply. Keep the list in a deployment-local file, mirrors.local.txt by default or the path in XNEWS_MIRRORS_FILE:
# mirrors.local.txt
[libgen]
https://first-mirror.example # a trailing comment becomes the label
second-mirror.example # a bare host is read as https://
[annas-archive]
https://another-mirror.exampleimport { libgenSource, loadMirrorList, mirrorBaseUrls, searchWorks } from "@xbbg/xnews";
const list = await loadMirrorList(); // or loadMirrorList("config/mirrors.txt")
console.log(list.warnings); // rejected entries, for example http:// origins
const result = await searchWorks(libgenSource({ mirrors: mirrorBaseUrls(list, "libgen") }), {
title: "Security Analysis",
});
console.log(result.status, result.warnings);- Mirrors are tried in order, and only a failed request advances to the next one. A mirror that answers with zero results ends the search, so one mirror's honest empty page is never replaced by another's results. Mirrors that failed first are named in
warnings. - An empty pool reports
status: "disabled"with aconfigerror, never an empty catalog. parseMirrorListis pure and rejects non-HTTPS origins at parse time.loadMirrorListthrows aconfigerror when the file cannot be read.withMirrorFailoverapplies the same failover rule to your own calls.
Download files
WorkRecord.url is a detail page. resolveWorkFiles walks the provider's own indirection to candidate files, downloadFile fetches one, and downloadWork does both and returns the first candidate that answers:
import { downloadWork, extractText, internetArchiveSource, searchWorks } from "@xbbg/xnews";
const result = await searchWorks(internetArchiveSource(), { title: "The Wealth of Nations", limit: 5 });
const record = result.items.find((item) => item.availability === "public-domain");
if (record) {
const file = await downloadWork(record);
console.log(file.fileName, file.contentType, file.sizeBytes);
const book = await extractText(file);
console.log(book.format, book.characterCount, book.sections.length);
}| Catalog | How a file is reached |
|---|---|
internet-archive | /metadata/<id> lists files; /download/<id>/<name> serves one. Restricted items answer 401 |
libgen | The mirror's ads.php page mints a single-use key for get.php, so that page is fetched first |
annas-archive | The documented fast_download JSON API; pass annasArchiveKey (a membership key) |
open-library | A catalog, not a file host: resolves to no files |
Downloads raise the response ceiling to DEFAULT_DOWNLOAD_MAX_BYTES (2 GiB) unless you set maxResponseBytes, and set allowCrossOriginRedirects, because catalogs mint a token on their own host and redirect to a CDN. A redirect into a private network address is still refused. Bytes are never decoded, so any file type passes through intact.
Extract text
extractText({ bytes, fileName }) decodes these formats with no runtime dependency:
| Format | How |
|---|---|
| EPUB | ZIP plus the OPF spine, so chapters come out in reading order and cover/navigation documents stay out of the body |
Native decoder: object scan, /ObjStm object streams, Flate/ASCIIHex/ASCII85/RunLength filters, PNG predictors, text operators, /ToUnicode CMaps | |
| DjVu | Native decoder for the hidden text layer (IFF, Z′-Coder, BZZ), written from the published DjVu v3 specification |
| MOBI / AZW / AZW3 | PalmDOC LZ77 inside the PDB container |
| CBZ | One empty section per page image, in natural order, plus a warning: comics have no text layer |
| TXT / HTML | Text is decoded as UTF-8; HTML has its tags stripped |
| CBR | Refused with a config error naming RAR as the missing decoder |
import { readFile } from "node:fs/promises";
import { extractText } from "@xbbg/xnews";
const bytes = new Uint8Array(await readFile("annual-report.pdf"));
const extracted = await extractText({ bytes, fileName: "annual-report.pdf" }, { maxCharacters: 2_000_000 });
console.log(extracted.format, extracted.characterCount, extracted.warnings);
for (const section of extracted.sections.slice(0, 3)) {
console.log(section.href, section.title, section.text.slice(0, 120));
}The format is inferred from the file name; pass format when the bytes came without one, and maxCharacters to stop early. The PDF decoder does not rasterize or guess: a PDF with no text layer (a scan) throws a config error that says OCR is needed, instead of returning an empty string, and so does a DjVu without hidden text. Oversized PDF streams are recorded in warnings rather than materialized. For lower-level access, extractPdfText, extractPdfImages, and extractDjvuText are exported too.
OCR for scanned PDFs
Scanned PDF pages are usually JPEG images (/DCTDecode), which xnews extracts directly without a PDF renderer and sends to any OpenAI-compatible /v1/chat/completions vision endpoint:
import { readFile } from "node:fs/promises";
import { extractText } from "@xbbg/xnews";
const bytes = new Uint8Array(await readFile("scanned-prospectus.pdf"));
const book = await extractText(
{ bytes, fileName: "scanned-prospectus.pdf" },
{ ocr: { baseUrl: "http://127.0.0.1:10000", model: "Unlimited-OCR", pagesPerRequest: 8 } },
);
console.log(book.text.slice(0, 1_000), book.warnings);Defaults follow Baidu's Unlimited-OCR recipe (temperature: 0, its image modes and prompts); serve it with that project's vLLM or SGLang instructions. On SGLang, customLogitProcessor enables its repeat suppression; vLLM rejects that field, so it is only sent when you set it. Any other OpenAI-compatible vision endpoint works: set baseUrl to OPENROUTER_BASE_URL and pass apiKey to route through OpenRouter. Pages are batched (pagesPerRequest, default 8) so a long book does not overrun a model's context, and a failed batch becomes a warning with its page range rather than a silently shorter text. ocrImages exposes the OCR step on its own.
Spreadsheets, archives, and CSV
The readers behind the data lane are public, in @xbbg/xnews/parsers and the root export:
import { readFile } from "node:fs/promises";
import { excelSerialDateToIso, parseCsvTable, readXlsx, readZipEntries } from "@xbbg/xnews/parsers";
const workbook = await readXlsx(new Uint8Array(await readFile("e16.xlsx")));
const firstSheet = workbook.sheets[0];
console.log(firstSheet?.name, firstSheet?.rows[3]?.[2]); // rows are dense: blank cells keep their column
const cell = firstSheet?.rows[1]?.[0];
if (typeof cell === "number") console.log(excelSerialDateToIso(cell)); // dates arrive as Excel serials
const entries = await readZipEntries(new Uint8Array(await readFile("bundle.zip")));
console.log(entries.map((entry) => `${entry.name} ${entry.bytes.length}`));
console.log(parseCsvTable('name,value\n"a, b",1\n'));readXlsxresolves sheets in workbook order, concatenates rich-text runs, honorsxml:space="preserve", and keeps blank cells positional. Formula cells yield their cached value; styles, charts, and formula evaluation are out of scope.readZipEntriesrejects multi-disk archives, supports ZIP64 sizes, requires each entry to inflate to exactly its declared size, and refuses any entry that declares more thanMAX_ZIP_UNCOMPRESSED_BYTES(512 MiB) before decompressing.parseCsvRecordsandparseCsvTablefollow RFC 4180 (quoted fields, escaped quotes, embedded newlines) and throw on text that ends inside a quoted field.
Related
- Research Papers:
pdfUrllinks you can download and read when the license allows. - Transport & Security: response ceilings and redirect rules.
- Provider Policies: works-catalog policies.
