src / net / book-search.ts
src / net / book-search.ts
import { BookReference } from "../types";
import { fetchPage } from "./http";
import { buildCoreTopicQuery } from "../utils/topicQuery";
function names(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((v): v is string => typeof v === "string").slice(0, 4);
}
function catalogUrl(value: unknown, host: string): string | null {
if (typeof value !== "string") return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
if (url.hostname !== host && !url.hostname.endsWith(`.${host}`)) return null;
url.protocol = "https:";
return url.toString();
} catch { return null; }
}
export function parseOpenLibraryBooks(data: unknown): BookReference[] {
const docs = (data as { docs?: unknown })?.docs;
if (!Array.isArray(docs)) return [];
return docs.flatMap((doc): BookReference[] => {
if (typeof doc?.title !== "string" || typeof doc.key !== "string") return [];
const key = doc.key.match(/^(?:\/works\/)?(OL\d+W)$/i)?.[1];
if (!key) return [];
return [{ title: doc.title, authors: names(doc.author_name), year: typeof doc.first_publish_year === "number" ? String(doc.first_publish_year) : undefined, url: `https://openlibrary.org/works/${key}`, catalog: "Open Library" }];
});
}
export function parseLibraryOfCongressBooks(data: unknown): BookReference[] {
const results = (data as { results?: unknown })?.results;
if (!Array.isArray(results)) return [];
return results.flatMap((item): BookReference[] => {
const url = catalogUrl(item?.id, "loc.gov");
if (!url || typeof item.title !== "string") return [];
return [{ title: item.title, authors: names(item.contributor), year: typeof item.date === "string" ? item.date : undefined, url, catalog: "Library of Congress" }];
});
}
/** DSpace search endpoints return item records with an expanded metadata array. */
export function parseOpenAccessBooks(data: unknown, host: string, catalog: string): BookReference[] {
const items = Array.isArray(data) ? data : (data as { results?: unknown })?.results;
if (!Array.isArray(items)) return [];
return items.flatMap((item): BookReference[] => {
if (item?.type && item.type !== "item") return [];
if (typeof item?.handle !== "string" || !/^\d+(?:\.\d+)*\/\d+$/.test(item.handle)) return [];
const metadata = Array.isArray(item.metadata) ? item.metadata : [];
const values = (key: string): string[] => metadata
.filter((entry: { key?: unknown; value?: unknown }) => entry?.key === key && typeof entry.value === "string")
.map((entry: { value: string }) => entry.value);
const languages = [...values("dc.language.iso"), ...values("dc.language")];
if (!languages.some((value) => /^(en|eng|english)([-_][a-z]+)?$/i.test(value.trim()))) return [];
const title = values("dc.title")[0] ?? item.name;
if (typeof title !== "string" || !title.trim()) return [];
return [{ title, authors: values("dc.contributor.author").slice(0, 4), year: values("dc.date.issued")[0], url: `https://${host}/handle/${item.handle}`, catalog }];
});
}
export function parseGutenbergBooks(data: unknown): BookReference[] {
const results = (data as { results?: unknown })?.results;
if (!Array.isArray(results)) return [];
return results.flatMap((book): BookReference[] => {
if (!Number.isSafeInteger(book?.id) || book.id <= 0 || typeof book.title !== "string") return [];
if (!Array.isArray(book.languages) || !book.languages.includes("en")) return [];
const authors = Array.isArray(book.authors) ? book.authors.map((author: { name?: unknown }) => author?.name) : [];
return [{ title: book.title, authors: names(authors), url: `https://www.gutenberg.org/ebooks/${book.id}`, catalog: "Project Gutenberg (free ebook)" }];
});
}
/** One small, time-bounded search per public catalog. Failures never stop research. */
export async function searchBooks(topic: string, signal: AbortSignal, timeoutMs = 5000): Promise<BookReference[]> {
const query = buildCoreTopicQuery(topic, 4).trim();
if (!query || signal.aborted || timeoutMs <= 0) return [];
const q = encodeURIComponent(query);
const openAccessQuery = encodeURIComponent(`(${query}) AND (dc.language.iso:eng OR dc.language.iso:en)`);
const requests = [
{ url: `https://directory.doabooks.org/rest/search?query=${openAccessQuery}&expand=metadata&limit=12`, parse: (data: unknown) => parseOpenAccessBooks(data, "directory.doabooks.org", "DOAB (open access)") },
{ url: `https://library.oapen.org/rest/search?query=${openAccessQuery}&expand=metadata&limit=12`, parse: (data: unknown) => parseOpenAccessBooks(data, "library.oapen.org", "OAPEN (free to read)") },
{ url: `https://gutendex.com/books/?languages=en&search=${q}`, parse: parseGutenbergBooks },
{ url: `https://openlibrary.org/search.json?q=${q}%20language%3Aeng&limit=12&fields=key,title,author_name,first_publish_year`, parse: parseOpenLibraryBooks },
{ url: `https://www.loc.gov/books/?fo=json&c=12&fa=language%3Aenglish&q=${q}`, parse: parseLibraryOfCongressBooks },
];
const controller = new AbortController();
const abort = () => controller.abort();
signal.addEventListener("abort", abort, { once: true });
const timeout = setTimeout(abort, timeoutMs);
try {
const partial: BookReference[][] = requests.map(() => []);
const tasks = requests.map(async ({ url, parse }, index) => {
try {
const response = await fetchPage(url, controller.signal, { headers: { Accept: "application/json" } });
if (controller.signal.aborted || (response.statusCode && response.statusCode >= 400)) return [];
return (partial[index] = parse(JSON.parse(response.html)));
} catch { return []; }
});
const cancelled = new Promise<BookReference[][]>((resolve) => {
controller.signal.addEventListener("abort", () => resolve(partial), { once: true });
});
const groups = await Promise.race([Promise.all(tasks), cancelled]);
const results: BookReference[] = [];
for (let i = 0; i < 12; i++) {
for (const group of groups) if (group[i]) results.push(group[i]);
}
const seen = new Set<string>();
return results.filter((book) => {
const key = `${book.title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()}|${(book.authors[0] ?? "").toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 30);
} finally {
clearTimeout(timeout);
signal.removeEventListener("abort", abort);
}
}
import { BookReference } from "../types";
import { fetchPage } from "./http";
import { buildCoreTopicQuery } from "../utils/topicQuery";
function names(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((v): v is string => typeof v === "string").slice(0, 4);
}
function catalogUrl(value: unknown, host: string): string | null {
if (typeof value !== "string") return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
if (url.hostname !== host && !url.hostname.endsWith(`.${host}`)) return null;
url.protocol = "https:";
return url.toString();
} catch { return null; }
}
export function parseOpenLibraryBooks(data: unknown): BookReference[] {
const docs = (data as { docs?: unknown })?.docs;
if (!Array.isArray(docs)) return [];
return docs.flatMap((doc): BookReference[] => {
if (typeof doc?.title !== "string" || typeof doc.key !== "string") return [];
const key = doc.key.match(/^(?:\/works\/)?(OL\d+W)$/i)?.[1];
if (!key) return [];
return [{ title: doc.title, authors: names(doc.author_name), year: typeof doc.first_publish_year === "number" ? String(doc.first_publish_year) : undefined, url: `https://openlibrary.org/works/${key}`, catalog: "Open Library" }];
});
}
export function parseLibraryOfCongressBooks(data: unknown): BookReference[] {
const results = (data as { results?: unknown })?.results;
if (!Array.isArray(results)) return [];
return results.flatMap((item): BookReference[] => {
const url = catalogUrl(item?.id, "loc.gov");
if (!url || typeof item.title !== "string") return [];
return [{ title: item.title, authors: names(item.contributor), year: typeof item.date === "string" ? item.date : undefined, url, catalog: "Library of Congress" }];
});
}
/** DSpace search endpoints return item records with an expanded metadata array. */
export function parseOpenAccessBooks(data: unknown, host: string, catalog: string): BookReference[] {
const items = Array.isArray(data) ? data : (data as { results?: unknown })?.results;
if (!Array.isArray(items)) return [];
return items.flatMap((item): BookReference[] => {
if (item?.type && item.type !== "item") return [];
if (typeof item?.handle !== "string" || !/^\d+(?:\.\d+)*\/\d+$/.test(item.handle)) return [];
const metadata = Array.isArray(item.metadata) ? item.metadata : [];
const values = (key: string): string[] => metadata
.filter((entry: { key?: unknown; value?: unknown }) => entry?.key === key && typeof entry.value === "string")
.map((entry: { value: string }) => entry.value);
const languages = [...values("dc.language.iso"), ...values("dc.language")];
if (!languages.some((value) => /^(en|eng|english)([-_][a-z]+)?$/i.test(value.trim()))) return [];
const title = values("dc.title")[0] ?? item.name;
if (typeof title !== "string" || !title.trim()) return [];
return [{ title, authors: values("dc.contributor.author").slice(0, 4), year: values("dc.date.issued")[0], url: `https://${host}/handle/${item.handle}`, catalog }];
});
}
export function parseGutenbergBooks(data: unknown): BookReference[] {
const results = (data as { results?: unknown })?.results;
if (!Array.isArray(results)) return [];
return results.flatMap((book): BookReference[] => {
if (!Number.isSafeInteger(book?.id) || book.id <= 0 || typeof book.title !== "string") return [];
if (!Array.isArray(book.languages) || !book.languages.includes("en")) return [];
const authors = Array.isArray(book.authors) ? book.authors.map((author: { name?: unknown }) => author?.name) : [];
return [{ title: book.title, authors: names(authors), url: `https://www.gutenberg.org/ebooks/${book.id}`, catalog: "Project Gutenberg (free ebook)" }];
});
}
/** One small, time-bounded search per public catalog. Failures never stop research. */
export async function searchBooks(topic: string, signal: AbortSignal, timeoutMs = 5000): Promise<BookReference[]> {
const query = buildCoreTopicQuery(topic, 4).trim();
if (!query || signal.aborted || timeoutMs <= 0) return [];
const q = encodeURIComponent(query);
const openAccessQuery = encodeURIComponent(`(${query}) AND (dc.language.iso:eng OR dc.language.iso:en)`);
const requests = [
{ url: `https://directory.doabooks.org/rest/search?query=${openAccessQuery}&expand=metadata&limit=12`, parse: (data: unknown) => parseOpenAccessBooks(data, "directory.doabooks.org", "DOAB (open access)") },
{ url: `https://library.oapen.org/rest/search?query=${openAccessQuery}&expand=metadata&limit=12`, parse: (data: unknown) => parseOpenAccessBooks(data, "library.oapen.org", "OAPEN (free to read)") },
{ url: `https://gutendex.com/books/?languages=en&search=${q}`, parse: parseGutenbergBooks },
{ url: `https://openlibrary.org/search.json?q=${q}%20language%3Aeng&limit=12&fields=key,title,author_name,first_publish_year`, parse: parseOpenLibraryBooks },
{ url: `https://www.loc.gov/books/?fo=json&c=12&fa=language%3Aenglish&q=${q}`, parse: parseLibraryOfCongressBooks },
];
const controller = new AbortController();
const abort = () => controller.abort();
signal.addEventListener("abort", abort, { once: true });
const timeout = setTimeout(abort, timeoutMs);
try {
const partial: BookReference[][] = requests.map(() => []);
const tasks = requests.map(async ({ url, parse }, index) => {
try {
const response = await fetchPage(url, controller.signal, { headers: { Accept: "application/json" } });
if (controller.signal.aborted || (response.statusCode && response.statusCode >= 400)) return [];
return (partial[index] = parse(JSON.parse(response.html)));
} catch { return []; }
});
const cancelled = new Promise<BookReference[][]>((resolve) => {
controller.signal.addEventListener("abort", () => resolve(partial), { once: true });
});
const groups = await Promise.race([Promise.all(tasks), cancelled]);
const results: BookReference[] = [];
for (let i = 0; i < 12; i++) {
for (const group of groups) if (group[i]) results.push(group[i]);
}
const seen = new Set<string>();
return results.filter((book) => {
const key = `${book.title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()}|${(book.authors[0] ?? "").toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 30);
} finally {
clearTimeout(timeout);
signal.removeEventListener("abort", abort);
}
}