feat: 完善本地书库与发布更新流程

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-28 21:55:16 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent de3e1d8a44
commit b8c8d24107
22 changed files with 1579 additions and 359 deletions
+111 -51
View File
@@ -1,50 +1,100 @@
const { fetchText, clampPage, decodeEntities } = require('./http');
const { fetchText, fetchJson, clampPage, decodeEntities, stripTags } = require('./http');
const BASE = 'https://standardebooks.org';
const PAGE_SIZE = 24;
const DETAIL_TTL = 5 * 60 * 1000;
const detailCache = new Map();
async function fetchOpds(path) {
return fetchText(`${BASE}${path}`, { headers: { 'Accept': 'application/atom+xml, text/xml, */*' } });
function absolute(url) {
return url ? new URL(url, BASE).toString() : '';
}
function parseEntries(xml) {
const entries = [];
const re = /<entry>([\s\S]*?)<\/entry>/g;
function slugFromUrl(url) {
return String(url || '').replace(/^https?:\/\/standardebooks\.org\/ebooks\//, '').replace(/^\/?ebooks\//, '').replace(/^\/+|\/+$/g, '');
}
function postId(slug) { return encodeURIComponent(slug); }
function parseCatalog(html) {
const items = [];
const re = /<li\s+typeof="schema:Book"\s+about="([^"]+)"([\s\S]*?)(?=<li\s+typeof="schema:Book"|<\/ol>)/g;
let m;
while ((m = re.exec(xml))) {
const e = m[1];
const title = decodeEntities((e.match(/<title>([\s\S]*?)<\/title>/) || [])[1] || '').trim();
const id = decodeEntities((e.match(/<id>([\s\S]*?)<\/id>/) || [])[1] || '').trim();
const author = decodeEntities((e.match(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/) || [])[1] || '').trim();
const summary = decodeEntities((e.match(/<summary>([\s\S]*?)<\/summary>/) || [])[1] || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
let epub = '', cover = '', pageUrl = '';
const lre = /<link[^>]*\/>/g;
let l;
while ((l = lre.exec(e))) {
const tag = l[0];
const href = (tag.match(/href="([^"]+)"/) || [])[1] || '';
const rel = (tag.match(/rel="([^"]+)"/) || [])[1] || '';
if (/epub/i.test(tag) && !epub) epub = href;
else if (/image/.test(tag) && !cover) cover = href;
else if (rel === 'alternate' && !pageUrl) pageUrl = href;
}
const slug = id.replace(/^urn:uuid:|^https?:\/\/standardebooks\.org\/ebooks\//, '').replace(/\//g, '_') || title;
entries.push({ slug, title, author, summary, epub, cover, pageUrl });
while ((m = re.exec(html))) {
const slug = slugFromUrl(m[1]);
const block = m[2];
const title = stripTags((block.match(/property="schema:name">([\s\S]*?)<\/span>/) || [])[1] || '');
const author = stripTags((block.match(/class="author"[^>]*>([\s\S]*?)<\/p>/) || [])[1] || '');
const cover = (block.match(/<img[^>]*property="schema:image"[^>]*src="([^"]+)"/) || [])[1]
|| (block.match(/<img[^>]*src="([^"]+)"[^>]*property="schema:image"/) || [])[1] || '';
if (!slug || !title) continue;
items.push({
postId: postId(slug),
title,
cover: absolute(decodeEntities(cover)),
date: '',
url: `${BASE}/ebooks/${slug}`,
subtitle: author
});
}
return entries;
return items;
}
function toItem(e) {
function catalogMaxPage(html, page) {
const pages = Array.from(html.matchAll(/[?&]page=(\d+)/g)).map((m) => parseInt(m[1], 10));
return Math.max(page, ...pages.filter(Number.isFinite));
}
function publicationToItem(p) {
const metadata = p.metadata || {};
const slug = slugFromUrl(metadata.identifier);
const authors = Array.isArray(metadata.author) ? metadata.author : (metadata.author ? [metadata.author] : []);
const image = (p.images || []).find((x) => x && x.href);
return {
postId: encodeURIComponent(e.slug),
title: e.title,
cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '',
date: '',
url: e.pageUrl ? (e.pageUrl.startsWith('http') ? e.pageUrl : BASE + e.pageUrl) : '',
subtitle: e.author
postId: postId(slug),
title: metadata.title || '(无标题)',
cover: image ? absolute(image.href) : '',
date: String(metadata.published || '').slice(0, 10),
url: `${BASE}/ebooks/${slug}`,
subtitle: authors.map((a) => a.name || '').filter(Boolean).join(', ')
};
}
function parseDetail(html, slug) {
const title = stripTags((html.match(/<h1[^>]*property="schema:name"[^>]*>([\s\S]*?)<\/h1>/) || [])[1] || '');
const authorBlock = (html.match(/<a[^>]*property="schema:author"[^>]*>([\s\S]*?)<\/a>/) || [])[1] || '';
const author = stripTags((authorBlock.match(/property="schema:name"[^>]*>([\s\S]*?)<\/span>/) || [])[1] || authorBlock);
const brief = decodeEntities((html.match(/<meta[^>]*property="schema:description"[^>]*content="([^"]*)"/) || [])[1] || '');
const cover = (html.match(/<meta[^>]*property="schema:image"[^>]*content="([^"]+)"/) || [])[1] || '';
const date = (html.match(/<meta[^>]*property="schema:datePublished"[^>]*content="([^"]+)"/) || [])[1] || '';
const epub = (html.match(/<a[^>]*property="schema:contentUrl"[^>]*href="([^"]+)"[^>]*class="epub"/) || [])[1] || '';
return { slug, title, author, brief, cover: absolute(cover), date, epub: absolute(epub) };
}
function validateSlug(post) {
const slug = decodeURIComponent(post);
if (!/^[a-z0-9-]+(?:\/[a-z0-9-]+)+$/i.test(slug)) throw new Error('无效的 Standard Ebooks ID');
return slug;
}
async function loadDetail(post) {
const slug = validateSlug(post);
const cached = detailCache.get(slug);
if (cached && cached.expiresAt > Date.now()) return cached.promise;
const promise = fetchText(`${BASE}/ebooks/${slug}`).then((html) => {
const detail = parseDetail(html, slug);
if (!detail.title) throw new Error('未找到该图书');
return detail;
});
detailCache.set(slug, { promise, expiresAt: Date.now() + DETAIL_TTL });
while (detailCache.size > 50) detailCache.delete(detailCache.keys().next().value);
try {
return await promise;
} catch (e) {
detailCache.delete(slug);
throw e;
}
}
module.exports = {
id: 'standardebooks',
name: 'Standard Ebooks',
@@ -52,40 +102,50 @@ module.exports = {
async list(page) {
page = clampPage(page);
const xml = await fetchOpds(`/feeds/opds/all?page=${page}`);
return { items: parseEntries(xml).map(toItem), maxPage: 40, page };
const html = await fetchText(`${BASE}/ebooks?page=${page}&per-page=${PAGE_SIZE}&view=list`);
return { items: parseCatalog(html), maxPage: catalogMaxPage(html, page), page };
},
async search(keyword, page) {
page = clampPage(page);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(keyword)}&page=${page}`);
return { items: parseEntries(xml).map(toItem), maxPage: 40, page };
const j = await fetchJson(`${BASE}/feeds/opds/all?query=${encodeURIComponent(keyword)}&per-page=${PAGE_SIZE}&page=${page}`, {
headers: { 'Accept': 'application/opds+json' }
});
const publications = j.publications || [];
return {
items: publications.map(publicationToItem).filter((x) => decodeURIComponent(x.postId)),
maxPage: publications.length === PAGE_SIZE ? page + 1 : page,
page
};
},
async detail(postId) {
const slug = decodeURIComponent(postId);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`);
const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0];
if (!e) throw new Error('未找到该图书');
const e = await loadDetail(postId);
return {
postId,
title: e.title,
cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '',
cover: e.cover,
authors: e.author ? [e.author] : [],
date: '',
date: e.date,
tags: [],
brief: e.summary,
url: e.pageUrl,
links: e.pageUrl ? [{ name: 'Standard Ebooks 页', url: e.pageUrl }] : []
brief: e.brief,
url: `${BASE}/ebooks/${e.slug}`,
links: [{ name: 'Standard Ebooks 页', url: `${BASE}/ebooks/${e.slug}` }]
};
},
async download(postId) {
const slug = decodeURIComponent(postId);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`);
const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0];
const e = await loadDetail(postId);
if (!e || !e.epub) throw new Error('未找到 EPUB 下载');
const url = e.epub.startsWith('http') ? e.epub : BASE + e.epub;
return { files: [{ name: `${e.title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.epub`, link: url, format: 'EPUB' }], links: [] };
const url = new URL(e.epub);
url.searchParams.set('source', 'download');
return {
files: [{
name: `${e.title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.epub`,
link: url.toString(),
format: 'EPUB'
}],
links: []
};
}
};