Files
peoplelib/src/sources/standardebooks.js
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 3ccd044527 feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 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>
2026-08-03 12:13:02 +08:00

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: []
};
}
};