132 lines
4.7 KiB
TypeScript
132 lines
4.7 KiB
TypeScript
export interface Book {
|
|
ebook: number;
|
|
title: string;
|
|
author: string;
|
|
language: string;
|
|
}
|
|
/** RFC 4180 records, including quoted commas, escaped quotes, and multiline titles. */
|
|
export function parseIndex(text: string): Book[] {
|
|
const rows: string[][] = [];
|
|
let row: string[] = [],
|
|
field = "",
|
|
quoted = false;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i]!;
|
|
if (ch === '"') {
|
|
if (quoted && text[i + 1] === '"') {
|
|
field += '"';
|
|
i++;
|
|
} else if (quoted || field === "") quoted = !quoted;
|
|
else throw Error("Unexpected CSV quote");
|
|
} else if (!quoted && (ch === "," || ch === "\n" || ch === "\r")) {
|
|
row.push(field);
|
|
field = "";
|
|
if (ch !== ",") {
|
|
rows.push(row);
|
|
row = [];
|
|
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
}
|
|
} else field += ch;
|
|
}
|
|
if (quoted) throw Error("Unclosed CSV quote");
|
|
if (field || row.length) {
|
|
row.push(field);
|
|
rows.push(row);
|
|
}
|
|
const header = rows.shift()!;
|
|
const columns = ["Text#", "Type", "Title", "Language", "Authors"].map((name) => header.indexOf(name));
|
|
if (columns.some((i) => i < 0)) throw Error("Unrecognized Gutenberg CSV header");
|
|
const books: Book[] = [],
|
|
seen = new Set<number>();
|
|
for (const row of rows) {
|
|
if (row.length === 1 && row[0] === "") continue;
|
|
if (row.length !== header.length) throw Error("Invalid Gutenberg CSV row");
|
|
if (row[columns[1]!] !== "Text") continue;
|
|
const ebook = Number(row[columns[0]!]!);
|
|
if (!Number.isSafeInteger(ebook) || ebook < 1 || seen.has(ebook)) throw Error("Invalid or repeated book ID");
|
|
seen.add(ebook);
|
|
books.push({
|
|
ebook,
|
|
title: row[columns[2]!]!.replace(/\s+/g, " ").trim(),
|
|
language: row[columns[3]!]!,
|
|
author: row[columns[4]!]!,
|
|
});
|
|
}
|
|
return books;
|
|
}
|
|
const normalized = (text: string) =>
|
|
text
|
|
.normalize("NFKD")
|
|
.replace(/\p{M}/gu, "")
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
.trim();
|
|
const grams = (text: string) =>
|
|
new Set(Array.from({ length: Math.max(0, text.length - 2) }, (_, i) => text.slice(i, i + 3)));
|
|
export function searchIndex(books: readonly Book[]) {
|
|
const index = books.map((book) => ({
|
|
book,
|
|
title: normalized(book.title),
|
|
words: normalized(book.title + " " + book.author),
|
|
grams: grams(normalized(book.title)),
|
|
}));
|
|
return (query: string): Book[] => {
|
|
const q = normalized(query).slice(0, 120);
|
|
if (q.length < 2) return [];
|
|
const tokens = q.split(" "),
|
|
qgrams = grams(q);
|
|
const matches: { book: Book; score: number }[] = [];
|
|
for (const entry of index) {
|
|
const overlap = [...qgrams].filter((g) => entry.grams.has(g)).length;
|
|
const similarity = (2 * overlap) / Math.max(1, qgrams.size + entry.grams.size);
|
|
const score =
|
|
(entry.title === q ? 10 : entry.title.includes(q) ? 5 : 0) +
|
|
(tokens.every((t) => entry.words.includes(t)) ? 2 : 0) +
|
|
similarity;
|
|
if (score >= 0.24) matches.push({ book: entry.book, score });
|
|
}
|
|
return matches
|
|
.sort((a, b) => b.score - a.score || a.book.ebook - b.book.ebook)
|
|
.slice(0, 20)
|
|
.map((entry) => entry.book);
|
|
};
|
|
}
|
|
export interface Excerpt {
|
|
text: string;
|
|
words: number;
|
|
}
|
|
/** The first paragraph boundary at or after roughly 1,000 words. Streaming
|
|
* callers wait for that boundary (or EOF), not the rest of the book. */
|
|
export function openingExcerpt(text: string, complete = false): Excerpt | null {
|
|
let body = text.replace(/^\uFEFF/, "").replaceAll("\r", "");
|
|
const start = /\*\*\*\s*START OF (?:THE|THIS) PROJECT GUTENBERG EBOOK[^\n]*\n/i.exec(body);
|
|
if (start) body = body.slice(start.index + start[0].length);
|
|
else if (!complete) return null;
|
|
const end = /\*\*\*\s*END OF (?:THE|THIS) PROJECT GUTENBERG EBOOK/i.exec(body);
|
|
if (end) {
|
|
body = body.slice(0, end.index);
|
|
complete = true;
|
|
}
|
|
const paragraphs = body.split(/\n[ \t]*\n+/);
|
|
if (!complete) paragraphs.pop();
|
|
const result: string[] = [];
|
|
let words = 0;
|
|
for (const paragraph of paragraphs) {
|
|
const part = paragraph.trim();
|
|
if (!part) continue;
|
|
result.push(part);
|
|
words += part.split(/\s+/u).length;
|
|
if (words >= 1000) break;
|
|
}
|
|
// An unusually large single paragraph must still fit the document contract.
|
|
// Prefer its last word boundary; ordinary excerpts always keep paragraphs whole.
|
|
if (result.join("\n\n").length > 56000 || (body.length >= 56000 && words < 1000)) {
|
|
const prefix = body.slice(0, 56000),
|
|
at = prefix.lastIndexOf(" ");
|
|
const bounded = (at > 0 ? prefix.slice(0, at) : prefix).replace(/[\uD800-\uDBFF]$/, "").trim();
|
|
return { text: bounded, words: bounded ? bounded.split(/\s+/u).length : 0 };
|
|
}
|
|
if (words < 1000 && !complete) return null;
|
|
return { text: result.join("\n\n"), words };
|
|
}
|