feat: 完善本地书库与发布更新流程
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
de3e1d8a44
commit
b8c8d24107
+111
-51
@@ -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: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user