Compare commits

6 Commits
Author SHA1 Message Date
ztimson 943280f326 Bump 0.4.5
Publish Library / Build NPM Project (push) Successful in 3m44s
Publish Library / Tag Version (push) Successful in 10s
2026-09-21 00:38:53 -04:00
ztimson dc24f48a36 Better catalog search
Publish Library / Build NPM Project (push) Successful in 3m6s
Publish Library / Tag Version (push) Canceled after 9s
2026-09-20 23:27:16 -04:00
ztimson 812e72a8cd Better catalog search
Publish Library / Build NPM Project (push) Successful in 4m2s
Publish Library / Tag Version (push) Failing after 19s
2026-09-20 23:26:57 -04:00
ztimson a45d7bad8c Search fix
Publish Library / Build NPM Project (push) Successful in 4m13s
Publish Library / Tag Version (push) Successful in 16s
2026-09-20 20:57:46 -04:00
ztimson 0a7e87f6b7 Patched search results
Publish Library / Build NPM Project (push) Successful in 3m16s
Publish Library / Tag Version (push) Successful in 19s
2026-09-20 20:29:28 -04:00
ztimson e12c3e3cc6 Fixed typo
Publish Library / Build NPM Project (push) Successful in 4m39s
Publish Library / Tag Version (push) Successful in 8s
2026-09-20 19:22:10 -04:00
5 changed files with 124 additions and 51 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.4.0", "version": "0.4.5",
"description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js", "description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
+102 -22
View File
@@ -2,19 +2,29 @@ import {fuzzyMatch} from './utils.js';
import {decodeHtml} from '@ztimson/utils'; import {decodeHtml} from '@ztimson/utils';
export const CATALOG_URL = 'https://library.kiwix.org'; export const CATALOG_URL = 'https://library.kiwix.org';
export const VIEWER_URL = 'https://browse.library.kiwix.org';
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
export function parseEntries(xml, catalog = CATALOG_URL) { export function parseEntries(xml, catalog = CATALOG_URL) {
const blocks = xml.match(/<entry>[\s\S]*?<\/entry>/g) || []; const blocks = xml.match(/<entry>[\s\S]*?<\/entry>/g) || [];
return blocks.map(b => { return blocks.map(b => {
const grab = re => (b.match(re) || [])[1] || ''; const grab = re => (b.match(re) || [])[1] || '';
const linkMatch = b.match(/<link[^>]*type=["']application\/x-zim[^"']*["'][^>]*href=["']([^"']+)["']/); const linkMatch = b.match(/<link[^>]*type=["']application\/x-zim[^"']*["'][^>]*href=["']([^"']+)["']/);
const iconMatch = b.match(/<link[^>]*rel=["']http:\/\/opds-spec\.org\/image\/thumbnail["'][^>]*href=["']([^"']+)["']/) const iconMatch = b.match(/<link[^>]*rel=["']http:\/\/opds-spec\.org\/image\/thumbnail["'][^>]*href=["']([^"']+)["']/)
|| b.match(/<link[^>]*type=["']image\/[^"']*["'][^>]*href=["']([^"']+)["']/); || b.match(/<link[^>]*type=["']image\/[^"']*["'][^>]*href=["']([^"']+)["']/);
let tags = grab(/<tags>([^<]*)<\/tags>/); let tags = grab(/<tags>([^<]*)<\/tags>/);
if(tags) tags = tags.split(';'); if (tags) tags = tags.split(';');
const name = grab(/<name>([^<]*)<\/name>/); const name = grab(/<name>([^<]*)<\/name>/);
const href = linkMatch ? linkMatch[1] : null;
const filename = href
? decodeURIComponent(new URL(href).pathname.split('/').pop()).replace(/\.meta4$/i, '')
: name;
const viewerName = filename.replace(/\.zim$/i, '');
return { return {
id: grab(/<id>([^<]*)<\/id>/), id: grab(/<id>([^<]*)<\/id>/),
title: decodeHtml(grab(/<title>([^<]*)<\/title>/)), title: decodeHtml(grab(/<title>([^<]*)<\/title>/)),
@@ -22,25 +32,52 @@ export function parseEntries(xml, catalog = CATALOG_URL) {
summary: decodeHtml(grab(/<summary>([^<]*)<\/summary>/)), summary: decodeHtml(grab(/<summary>([^<]*)<\/summary>/)),
language: grab(/<language>([^<]*)<\/language>/), language: grab(/<language>([^<]*)<\/language>/),
name, name,
flavour: grab(/<flavour>([^<]*)<\/flavour>/),
category: grab(/<category>([^<]*)<\/category>/), category: grab(/<category>([^<]*)<\/category>/),
tags, tags,
mediaCount: Number(grab(/<mediaCount>([^<]*)<\/mediaCount>/)) || 0, mediaCount: Number(grab(/<mediaCount>([^<]*)<\/mediaCount>/)) || 0,
author: grab(/<author>\s*<name>([^<]*)<\/name>\s*<\/author>/m), author: grab(/<author>\s*<name>([^<]*)<\/name>\s*<\/author>/m),
publisher: grab(/<publisher>\s*<name>([^<]*)<\/name>\s*<\/publisher>/m), publisher: grab(/<publisher>\s*<name>([^<]*)<\/name>\s*<\/publisher>/m),
articleCount: Number(grab(/<articleCount>([^<]*)<\/articleCount>/)) || 0, articleCount: Number(grab(/<articleCount>([^<]*)<\/articleCount>/)) || 0,
sizeMb: linkMatch ? +(Number((b.match(/length=["'](\d+)["']/) || [])[1] || 0) / 1024 / 1024).toFixed(1) : '?', sizeMb: href
href: linkMatch ? linkMatch[1] : null, ? +(Number((b.match(/length=["'](\d+)["']/) || [])[1] || 0) / 1024 / 1024).toFixed(1)
: '?',
href,
icon: iconMatch ? new URL(iconMatch[1], catalog).href : null, icon: iconMatch ? new URL(iconMatch[1], catalog).href : null,
viewer: name ? new URL(`viewer#${name}`, catalog).href : null, viewer: viewerName ? `${VIEWER_URL}/viewer#${viewerName}` : null,
}; };
}); });
} }
async function fetchEntries(term, lang, url = CATALOG_URL) { async function fetchEntries(term, lang, url = CATALOG_URL) {
const params = new URLSearchParams({q: term, count: String(PAGE_SIZE), lang: lang || 'eng'}); const entries = [];
let start = 0;
let total = Infinity;
while (start < total) {
const params = new URLSearchParams({
q: term,
count: String(PAGE_SIZE),
start: String(start),
lang: lang || 'eng',
});
const res = await fetch(`${url}/catalog/v2/entries?${params}`); const res = await fetch(`${url}/catalog/v2/entries?${params}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return parseEntries(await res.text());
const xml = await res.text();
const page = parseEntries(xml, url);
const totalResults = Number((xml.match(/<totalResults>(\d+)<\/totalResults>/) || [])[1]);
entries.push(...page);
if (Number.isFinite(totalResults)) total = totalResults;
if (!page.length || start + page.length >= total) break;
start += page.length;
}
return entries;
} }
/** Looks up a single catalog entry by exact `name` (used to check for available updates). */ /** Looks up a single catalog entry by exact `name` (used to check for available updates). */
@@ -48,30 +85,73 @@ export async function zimCatalogInfo(name, url = CATALOG_URL) {
const params = new URLSearchParams({name, count: '5'}); const params = new URLSearchParams({name, count: '5'});
const res = await fetch(`${url}/catalog/v2/entries?${params}`); const res = await fetch(`${url}/catalog/v2/entries?${params}`);
if (!res.ok) return null; if (!res.ok) return null;
return parseEntries(await res.text()).find(e => e.name === name) || null;
return parseEntries(await res.text(), url).find(e => e.name === name) || null;
} }
/** Searches the Kiwix catalog for ZIMs matching `terms` within `category`, ranked by term coverage then fuzzy similarity. */ /** Searches the Kiwix catalog for ZIMs matching `terms`, ranked by relevance. */
export async function zimCatalog(terms, opts = {lang: 'eng', count: 20, url: CATALOG_URL}) { export async function zimCatalog(terms, opts = {lang: 'eng', count: 20, url: CATALOG_URL}) {
opts = Object.assign({lang: 'eng', count: 20, url: CATALOG_URL}, opts) opts = Object.assign({lang: 'eng', count: 20, url: CATALOG_URL}, opts);
const termList = [...String(terms).split(',')].filter(Boolean).map(t => t.trim().toLowerCase());
const results = await Promise.allSettled(termList.map(t => fetchEntries(t, opts.lang, opts.url))); const termList = [...String(terms).split(',')]
const byName = new Map(); .filter(Boolean)
.map(t => t.trim().toLowerCase());
const results = await Promise.allSettled(
termList.map(t => fetchEntries(t, opts.lang, opts.url))
);
const byZim = new Map();
results.forEach((r, i) => { results.forEach((r, i) => {
if (r.status !== 'fulfilled') return; if (r.status !== 'fulfilled') return;
const term = termList[i]; const term = termList[i];
for (const entry of r.value) { for (const entry of r.value) {
if (!entry.name) continue; const zimId = entry.href || entry.id || entry.name;
if (!byName.has(entry.name)) byName.set(entry.name, {entry, hitTerms: new Set()}); if (!zimId) continue;
byName.get(entry.name).hitTerms.add(term);
if (!byZim.has(zimId)) {
byZim.set(zimId, {entry, hitTerms: new Set()});
}
byZim.get(zimId).hitTerms.add(term);
} }
}); });
if (!byName.size) return [];
return [...byName.values()].map(({entry, hitTerms}) => { if (!byZim.size) return [];
const text = `${entry.title} ${entry.summary}`.trim();
return {entry, hits: hitTerms.size, fuzzy: fuzzyMatch(text, ...termList).max}; return [...byZim.values()]
}).toSorted((a, b) => .map(({entry, hitTerms}) => {
b.hits - a.hits || b.fuzzy - a.fuzzy || b.entry.articleCount - a.entry.articleCount const title = entry.title.toLowerCase();
).slice(0, opts.count).map(r => r.entry); const summary = entry.summary.toLowerCase();
const titleWords = title.split(/\W+/).filter(Boolean);
const exactTitle = termList.some(term => title === term);
const exactWord = termList.some(term => titleWords.includes(term));
const titleMatch = termList.some(term => title.includes(term));
const titleFuzzy = fuzzyMatch(title, ...termList).max;
const summaryFuzzy = fuzzyMatch(summary, ...termList).max;
return {
entry,
hits: hitTerms.size,
exactTitle,
exactWord,
titleMatch,
titleFuzzy,
summaryFuzzy,
};
})
.toSorted((a, b) =>
Number(b.exactTitle) - Number(a.exactTitle)
|| Number(b.exactWord) - Number(a.exactWord)
|| Number(b.titleMatch) - Number(a.titleMatch)
|| b.titleFuzzy - a.titleFuzzy
|| b.summaryFuzzy - a.summaryFuzzy
|| b.hits - a.hits
|| new Date(b.entry.updated) - new Date(a.entry.updated)
)
.map(r => r.entry);
} }
+1 -1
View File
@@ -107,7 +107,7 @@ export class ZimManager {
const file = match.href + (match.href.endsWith('.zim') ? '' : '.zim'); const file = match.href + (match.href.endsWith('.zim') ? '' : '.zim');
await fs.promises.rm(path.join(this.#dir, file), {force: true}); await fs.promises.rm(path.join(this.#dir, file), {force: true});
await this.#ensureServer(); await this.#ensureServer();
await this.#waitUntilVicsible(file, {expect: false}); await this.#waitUntilVisible(file, {expect: false});
return {file: match.file, status: 'deleted'}; return {file: match.file, status: 'deleted'};
} }
+1 -1
View File
@@ -101,7 +101,7 @@ export function rerank(hits, termList) {
const title = fieldScore(hit.title, termList, {fuzzy: true}); const title = fieldScore(hit.title, termList, {fuzzy: true});
const summary = fieldScore(hit.summary, termList); const summary = fieldScore(hit.summary, termList);
const titleLower = (hit.title || '').toLowerCase(); const titleLower = (hit.title || '').toLowerCase();
const summaryLower = (hit.summary || '').toLowerCase(); const summaryLower = (hit.summary?._text || '').toLowerCase();
const phrase = termList.join(' ').toLowerCase(); const phrase = termList.join(' ').toLowerCase();
const titleExactPhrase = titleLower.includes(phrase) ? 1 : 0; const titleExactPhrase = titleLower.includes(phrase) ? 1 : 0;
+17 -24
View File
@@ -212,7 +212,7 @@ export class KiwixServer {
return makeArray(entries?.library?.book || []).map(e => { return makeArray(entries?.library?.book || []).map(e => {
const tags = e.tags.split(';'); const tags = e.tags.split(';');
const name = e.path.replace('.zim', ''); const name = e.path.replace(/\.zim$/i, '');
return { return {
id: e.id, id: e.id,
@@ -262,53 +262,43 @@ export class KiwixServer {
} }
async #rawSearch(termList, scoped, bookMap, limit) { async #rawSearch(termList, scoped, bookMap, limit) {
const groups = new Map(); const perBook = await Promise.all(scoped.map(async book => {
for(const book of scoped) {
const lang = book.language || '';
(groups.get(lang) ?? groups.set(lang, []).get(lang)).push(book);
}
const perGroup = await Promise.all([...groups.values()].map(async group => {
const params = new URLSearchParams({ const params = new URLSearchParams({
pattern: termList.join(' '), pattern: termList.join(' '),
format: 'xml', format: 'xml',
pageLength: String(limit), pageLength: String(limit),
'books.name': book.href,
}); });
for(const book of group)
params.append('books.name', book.name);
const res = await fetch(`${this.baseUrl}/search?${params}`); const res = await fetch(`${this.baseUrl}/search?${params}`);
if(!res.ok) return []; if(!res.ok) return [];
const found = fromXml(await res.text())?.rss?.channel?.item || []; const found = fromXml(await res.text())?.rss?.channel?.item || [];
return found.map(hit => { return makeArray(found).map(hit => {
const book = bookMap.get(hit.book.title); const resultBook = bookMap.get(hit.book?.title) || book;
if(!book) return null;
const prefix = `/content/${book.href}/`; const prefix = `/content/${resultBook.href}/`;
const page = hit.link.startsWith(prefix) const page = hit.link.startsWith(prefix)
? hit.link.slice(prefix.length) ? hit.link.slice(prefix.length)
: hit.link.replace(/^\/+/, ''); : hit.link.replace(/^\/+/, '');
return { return {
id: book.id, id: resultBook.id,
title: hit.title, title: hit.title,
page, page,
name: book.name, name: resultBook.name,
publisher: book.publisher, publisher: resultBook.publisher,
href: `${book.href}/${page}`, href: `${resultBook.href}/${page}`,
icon: book.icon, icon: resultBook.icon,
viewer: this.baseUrl + hit.link, viewer: this.baseUrl + hit.link,
summary: hit.description, summary: hit.description,
xapianScore: +hit.score || 0, xapianScore: +hit.score || 0,
}; };
}).filter(Boolean).sort((a, b) => b.xapianScore - a.xapianScore); }).filter(Boolean);
})); }));
return rrfMerge(perGroup); return rrfMerge(perBook);
} }
/** Fulltext search across local ZIMs with ranking, diversification and spelling correction. */ /** Fulltext search across local ZIMs with ranking, diversification and spelling correction. */
@@ -320,6 +310,7 @@ export class KiwixServer {
const books = await this.list(); const books = await this.list();
const bookMap = new Map(books.map(b => [b.title, b])); const bookMap = new Map(books.map(b => [b.title, b]));
const scoped = sources?.length const scoped = sources?.length
? books.filter(b => sources.includes(b.name) || sources.includes(b.href)) ? books.filter(b => sources.includes(b.name) || sources.includes(b.href))
: books; : books;
@@ -360,8 +351,10 @@ export class KiwixServer {
} }
} }
const ranked = rerank(hits, activeTerms);
return { return {
results: diversify(rerank(hits, activeTerms), limit), results: diversify(ranked, limit),
spellcheck, spellcheck,
}; };
} }