新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件, 批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、 图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文, 页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。 便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化, 避免升级后 data/ 被遗留在旧目录。 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
154 lines
5.6 KiB
JavaScript
154 lines
5.6 KiB
JavaScript
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();
|
|
|
|
function absolute(url) {
|
|
return url ? new URL(url, BASE).toString() : '';
|
|
}
|
|
|
|
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(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 items;
|
|
}
|
|
|
|
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);
|
|
// OPDS 的 author 可能是字符串、对象或两者混排的数组
|
|
const raw = Array.isArray(metadata.author) ? metadata.author : (metadata.author ? [metadata.author] : []);
|
|
const authors = raw.map((a) => (typeof a === 'string' ? a : (a && a.name) || '')).filter(Boolean);
|
|
const image = (p.images || []).find((x) => x && x.href);
|
|
return {
|
|
postId: postId(slug),
|
|
title: metadata.title || '(无标题)',
|
|
cover: image ? absolute(image.href) : '',
|
|
date: String(metadata.published || '').slice(0, 10),
|
|
url: `${BASE}/ebooks/${slug}`,
|
|
subtitle: authors.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',
|
|
supportsSearch: true,
|
|
|
|
async list(page) {
|
|
page = clampPage(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 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 e = await loadDetail(postId);
|
|
return {
|
|
postId,
|
|
title: e.title,
|
|
cover: e.cover,
|
|
authors: e.author ? [e.author] : [],
|
|
date: e.date,
|
|
tags: [],
|
|
brief: e.brief,
|
|
url: `${BASE}/ebooks/${e.slug}`,
|
|
links: [{ name: 'Standard Ebooks 页', url: `${BASE}/ebooks/${e.slug}` }]
|
|
};
|
|
},
|
|
|
|
async download(postId) {
|
|
const e = await loadDetail(postId);
|
|
if (!e || !e.epub) throw new Error('未找到 EPUB 下载');
|
|
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: []
|
|
};
|
|
}
|
|
};
|