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
+17 -9
View File
@@ -1,6 +1,9 @@
const { fetchJson, clampPage } = require('./http');
const PAGE_SIZE = 30;
const CATALOG_START = '2000-01-01';
const SNAPSHOT_TTL = 5 * 60 * 1000;
let catalogSnapshot = null;
function toItem(r, server) {
const authors = String(r.authors || '').split(';').map((s) => s.trim()).filter(Boolean).slice(0, 3).join(', ');
@@ -21,7 +24,7 @@ async function fetchWindow(server, from, to, cursor, tries = 3) {
let last;
for (let i = 0; i < tries; i++) {
try {
const j = await fetchJson(`https://api.biorxiv.org/details/${server}/${from}/${to}/${cursor}`);
const j = await fetchJson(`https://api.biorxiv.org/details/${server}/${from}/${to}/${cursor}`, { retries: 0 });
const total = parseInt(j.messages && j.messages[0] && j.messages[0].total, 10) || 0;
return { total, collection: j.collection || [] };
} catch (e) {
@@ -41,14 +44,19 @@ module.exports = {
async list(page) {
page = clampPage(page);
const server = 'biorxiv';
const end = new Date();
const daysPerPage = 3;
const startIdx = (page - 1) * daysPerPage;
const from = new Date(end.getTime() - (startIdx + daysPerPage) * 864e5);
const to = new Date(end.getTime() - startIdx * 864e5);
const { collection } = await fetchWindow(server, dateStr(from), dateStr(to), 0);
const items = collection.slice(0, PAGE_SIZE).map((r) => toItem(r, server));
return { items, maxPage: 1000, page };
const to = dateStr(new Date());
if (!catalogSnapshot || catalogSnapshot.to !== to || catalogSnapshot.expiresAt <= Date.now()) {
const first = await fetchWindow(server, CATALOG_START, to, 0);
catalogSnapshot = { to, total: first.total, expiresAt: Date.now() + SNAPSHOT_TTL };
}
const total = catalogSnapshot.total;
const end = Math.max(0, total - (page - 1) * PAGE_SIZE);
const cursor = Math.max(0, end - PAGE_SIZE);
const count = end - cursor;
if (!count) return { items: [], maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page };
const { collection } = await fetchWindow(server, CATALOG_START, to, cursor);
const items = collection.slice(0, count).reverse().map((r) => toItem(r, server));
return { items, maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page };
},
async search() {
+10 -2
View File
@@ -76,9 +76,17 @@ module.exports = {
const j = await fetchJson(`https://doaj.org/api/v2/articles/${encodeURIComponent(postId)}`);
const b = j.bibjson || {};
const files = [];
const links = [];
for (const l of b.link || []) {
if (l.url) files.push({ name: /pdf/i.test(l.content_type || '') ? 'PDF 全文' : (l.content_type || '全文'), link: l.url, format: l.content_type || '' });
if (!l.url) continue;
const type = l.content_type || '';
if (/pdf/i.test(type) || /\.pdf(?:$|[?#])/i.test(l.url)) {
files.push({ name: 'PDF 全文', link: l.url, format: 'PDF' });
} else {
links.push({ name: '全文页', url: l.url });
}
}
return { files, links: [{ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` }] };
links.push({ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` });
return { files, links };
}
};
+15 -101
View File
@@ -1,4 +1,5 @@
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const { fetch: undiciFetch, ProxyAgent } = require('undici');
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
// 持久化存储在 userData/settings.json 的 "proxy" 字段。
@@ -6,112 +7,25 @@ let proxyUrl = '';
let dispatcher = null;
function setProxy(url) {
proxyUrl = String(url || '').trim();
dispatcher = null;
if (!proxyUrl) return;
// Electron 下代理主要由 session.setProxy 接管;这里仍然构建 ProxyAgent
// 供 net.fetch 不可用时的 undici 回退路径使用。
try {
const { ProxyAgent } = require('undici');
dispatcher = new ProxyAgent({
uri: proxyUrl,
requestTls: { rejectUnauthorized: false }
});
} catch (e) {
console.warn('代理初始化失败:', e.message);
const nextUrl = String(url || '').trim();
if (nextUrl === proxyUrl) return;
let nextDispatcher = null;
if (nextUrl) {
const parsed = new URL(nextUrl);
if (!/^https?:$/.test(parsed.protocol)) throw new Error('代理地址仅支持 http:// 或 https://');
nextDispatcher = new ProxyAgent({ uri: nextUrl });
}
if (dispatcher) {
dispatcher.close().catch(() => {});
}
proxyUrl = nextUrl;
dispatcher = nextDispatcher;
}
function getProxy() { return proxyUrl; }
// --- Electron net.fetch 响应头非 ASCII 崩溃的兜底 ---
// 部分站点(如 Memory of the World)会把搜索关键词原样回写进 ETag 等响应头。
// 关键词含中文时,Electron 的 net.fetch 在其内部 emit 回调里抛出
// "Cannot convert argument to a ByteString"。该异常无法被 await 捕获:
// 主进程直接崩溃,且对应的 fetch Promise 永远不会 settle。
//
// 兜底策略:拦截这一类 uncaughtException(其余异常原样交还默认处理),
// 记录 origin 后让仍在挂起中的同源请求转为可捕获的失败,再用 undici 重试。
// 加宽限期是因为异常与请求之间无法直接关联,宽限期内正常返回的请求不受影响。
const HEADER_BUG_GRACE = 1200;
// origin -> 该源已知会触发响应头崩溃(后续请求直接走 undici)
const headerBugOrigins = new Set();
// 正在进行中的 net.fetch: origin -> Set<abortFn>
const pendingByOrigin = new Map();
function originOf(url) {
try { return new URL(url).origin; } catch (e) { return ''; }
}
function isHeaderByteStringError(e) {
return !!e && (e instanceof TypeError || e.name === 'TypeError') && /ByteString/i.test(e.message || '');
}
let rethrowing = false;
function onUncaught(e) {
if (isHeaderByteStringError(e)) {
// 只有在宽限期后仍未返回的请求,才判定为被该异常卡死
const victims = [];
for (const set of pendingByOrigin.values()) victims.push(...set);
setTimeout(() => { for (const fail of victims) fail(); }, HEADER_BUG_GRACE);
return;
}
if (rethrowing) return;
rethrowing = true;
process.removeListener('uncaughtException', onUncaught);
setImmediate(() => { throw e; });
}
process.on('uncaughtException', onUncaught);
function undiciFetch(url, options) {
if (dispatcher) return fetch(url, { ...options, dispatcher });
return fetch(url, options);
}
function netFetch(url, options) {
const { net } = require('electron');
const origin = originOf(url);
return new Promise((resolve, reject) => {
let settled = false;
let set = pendingByOrigin.get(origin);
if (!set) { set = new Set(); pendingByOrigin.set(origin, set); }
const fail = () => {
if (settled) return;
settled = true;
cleanup();
headerBugOrigins.add(origin);
const err = new Error('响应头包含非 ASCII 字符,Electron 网络栈无法处理');
err.code = 'HEADER_BYTESTRING';
reject(err);
};
function cleanup() {
set.delete(fail);
if (!set.size) pendingByOrigin.delete(origin);
}
set.add(fail);
net.fetch(url, options).then(
(r) => { if (settled) return; settled = true; cleanup(); resolve(r); },
(e) => { if (settled) return; settled = true; cleanup(); reject(e); }
);
});
}
async function fetchWithProxy(url, options = {}) {
// Electron 主进程优先用 net.fetch(走 Chromium 网络栈,由 session.setProxy 控制代理)
if (process.versions.electron) {
// 已知有问题的源直接走 undici,避免每次都重新触发一遍崩溃
if (headerBugOrigins.has(originOf(url))) return undiciFetch(url, options);
try {
return await netFetch(url, options);
} catch (e) {
if (e && e.code === 'HEADER_BYTESTRING') return undiciFetch(url, options);
throw e;
}
}
return undiciFetch(url, options);
function fetchWithProxy(url, options = {}) {
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
+5 -3
View File
@@ -76,7 +76,8 @@ module.exports = {
async list(page) {
page = clampPage(page);
const j = await fetchJson(`${BASE}/books?page=${page}`);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchJson(`${BASE}/books?offset=${offset}&limit=${PAGE_SIZE}`);
return pack(j, page);
},
@@ -84,11 +85,12 @@ module.exports = {
page = clampPage(page);
const kw = safeKeyword(keyword);
if (!kw) return { items: [], maxPage: 1, page };
const offset = (page - 1) * PAGE_SIZE;
// 标题与作者两路合并,按 _id 去重
const [byTitle, byAuthor] = await Promise.all([
fetchJson(`${BASE}/search/titles/${kw}?page=${page}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?page=${page}`).catch(() => null)
fetchJson(`${BASE}/search/titles/${kw}?offset=${offset}&limit=${PAGE_SIZE}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?offset=${offset}&limit=${PAGE_SIZE}`).catch(() => null)
]);
if (!byTitle && !byAuthor) throw new Error('搜索请求失败');
+19 -4
View File
@@ -1,6 +1,7 @@
const { fetchJson, clampPage } = require('./http');
const { fetchJson, fetchText, clampPage } = require('./http');
const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
const OA_DATA = 'https://pmc-oa-opendata.s3.amazonaws.com';
const PAGE_SIZE = 20;
function toItem(r) {
@@ -35,13 +36,26 @@ async function runList(term, page) {
return { items, maxPage: Math.max(1, Math.ceil(count / PAGE_SIZE)), page };
}
async function resolvePdf(postId) {
const pmcid = `PMC${String(postId).replace(/^PMC/i, '')}`;
const listing = await fetchText(`${OA_DATA}/?list-type=2&prefix=${encodeURIComponent(`${pmcid}.`)}&delimiter=%2F`);
const versions = Array.from(listing.matchAll(new RegExp(`<Prefix>${pmcid}\\.(\\d+)/</Prefix>`, 'g')))
.map((m) => parseInt(m[1], 10))
.filter(Number.isFinite);
if (!versions.length) throw new Error('该文献不在 PMC 可下载数据集中');
const version = Math.max(...versions);
const meta = await fetchJson(`${OA_DATA}/metadata/${pmcid}.${version}.json`);
if (!meta.pdf_url) throw new Error('该文献未提供 PDF');
return meta.pdf_url.replace('s3://pmc-oa-opendata/', `${OA_DATA}/`);
}
module.exports = {
id: 'pmc',
name: 'PMC 生物医学',
supportsSearch: true,
list(page) { return runList('open access[filter]', page); },
search(keyword, page) { return runList(`${keyword} AND open access[filter]`, page); },
list(page) { return runList('open access[filter] AND has_pdf[filter]', page); },
search(keyword, page) { return runList(`${keyword} AND open access[filter] AND has_pdf[filter]`, page); },
async detail(postId) {
const result = await esummary([postId]);
@@ -65,8 +79,9 @@ module.exports = {
async download(postId) {
const page = `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`;
const link = await resolvePdf(postId);
return {
files: [{ name: `PMC${postId}.pdf`, link: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/pdf/`, format: 'PDF' }],
files: [{ name: `PMC${postId}.pdf`, link, format: 'PDF' }],
links: [{ name: 'PMC 全文页', url: page }]
};
}
+98
View File
@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
let filePath = null;
let safeStorage = null;
let sessionKey = '';
let cachedKey = null;
let keyRevision = 0;
function init(userDataDir, storage) {
filePath = path.join(userDataDir, 'semantic-scholar-key.bin');
safeStorage = storage;
sessionKey = '';
cachedKey = null;
keyRevision++;
}
function encryptionAvailable() {
return !!safeStorage && safeStorage.isEncryptionAvailable();
}
function read() {
if (sessionKey) return sessionKey;
if (cachedKey !== null) return cachedKey;
if (!filePath || !encryptionAvailable()) return '';
try {
const backup = `${filePath}.bak`;
if (!fs.existsSync(filePath) && fs.existsSync(backup)) fs.renameSync(backup, filePath);
cachedKey = safeStorage.decryptString(fs.readFileSync(filePath));
return cachedKey;
} catch (e) {
cachedKey = '';
return '';
}
}
function write(key) {
const value = String(key || '').trim();
if (!value) {
clear();
return { persistent: encryptionAvailable() };
}
if (!encryptionAvailable()) {
sessionKey = value;
cachedKey = value;
keyRevision++;
return { persistent: false };
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temp = `${filePath}.tmp`;
const backup = `${filePath}.bak`;
let backedUp = false;
try {
fs.writeFileSync(temp, safeStorage.encryptString(value));
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(filePath)) {
fs.renameSync(filePath, backup);
backedUp = true;
}
fs.renameSync(temp, filePath);
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* ignore */ }
}
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (e) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(filePath) && fs.existsSync(backup)) fs.renameSync(backup, filePath);
} catch (rollbackError) { /* 下次读取时恢复 */ }
throw e;
}
sessionKey = '';
cachedKey = value;
keyRevision++;
return { persistent: true };
}
function clear() {
sessionKey = '';
cachedKey = '';
keyRevision++;
if (!filePath) return;
for (const suffix of ['', '.bak', '.tmp']) {
try { fs.unlinkSync(`${filePath}${suffix}`); } catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
}
function status() {
return {
configured: !!read(),
persistent: encryptionAvailable()
};
}
function revision() { return keyRevision; }
module.exports = { init, read, write, clear, status, revision };
+131 -15
View File
@@ -1,12 +1,124 @@
const { fetchJson, clampPage } = require('./http');
const { fetchRaw, clampPage } = require('./http');
const apiKey = require('./semantic-key');
const BASE = 'https://api.semanticscholar.org/graph/v1';
const PAGE_SIZE = 25;
const FIELDS = 'title,authors,year,abstract,openAccessPdf,externalIds,url,venue';
const SEARCH_FIELDS = 'title,authors,year,url,venue';
const DETAIL_FIELDS = 'title,authors,year,abstract,openAccessPdf,externalIds,url,venue';
const REQUEST_INTERVAL = 1100;
const SEARCH_TTL = 10 * 60 * 1000;
const DETAIL_TTL = 24 * 60 * 60 * 1000;
const CACHE_LIMIT = 100;
// 该 API 对匿名调用限流很严。只重试一次,避免在聚合搜索里长时间阻塞整组结果。
function fetchWithRetry(url) {
return fetchJson(url, { retryDelay: 1500 });
let queue = Promise.resolve();
let nextRequestAt = 0;
let cooldownUntil = 0;
let consecutive429 = 0;
let lastKeyRevision = -1;
const cache = new Map();
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function syncKeyState() {
const revision = apiKey.revision();
if (revision === lastKeyRevision) return;
lastKeyRevision = revision;
cooldownUntil = 0;
consecutive429 = 0;
cache.clear();
}
function enqueue(fn) {
const result = queue.then(fn);
queue = result.catch(() => {});
return result;
}
async function waitForSlot() {
const delay = Math.max(0, nextRequestAt - Date.now());
if (delay) await sleep(delay);
nextRequestAt = Date.now() + REQUEST_INTERVAL;
}
function retryDelay(res) {
const value = res.headers.get('retry-after');
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, Math.min(seconds * 1000, 5 * 60 * 1000));
const at = Date.parse(value);
if (Number.isFinite(at)) return Math.max(0, Math.min(at - Date.now(), 5 * 60 * 1000));
}
return 2000 + Math.floor(Math.random() * 2001);
}
async function requestOnce(url) {
await waitForSlot();
const key = apiKey.read();
const headers = key ? { 'x-api-key': key } : {};
const res = await fetchRaw(url, { headers, timeout: 15000 });
if (res.ok) {
const text = await res.text();
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Semantic Scholar 返回内容不是有效 JSON');
}
}
const delay = res.status === 429 ? retryDelay(res) : 0;
try { await res.body?.cancel(); } catch (e) { /* ignore */ }
const err = new Error(res.status === 429
? 'Semantic Scholar 请求过于频繁(429'
: `Semantic Scholar 请求失败(HTTP ${res.status}`);
err.status = res.status;
err.retryDelay = delay;
throw err;
}
async function requestWithCooldown(url) {
syncKeyState();
if (Date.now() < cooldownUntil) {
const seconds = Math.max(1, Math.ceil((cooldownUntil - Date.now()) / 1000));
throw new Error(`Semantic Scholar 正在限流冷却,请约 ${seconds} 秒后重试`);
}
try {
const data = await requestOnce(url);
consecutive429 = 0;
return data;
} catch (e) {
if (e.status !== 429) throw e;
await sleep(e.retryDelay);
try {
const data = await requestOnce(url);
consecutive429 = 0;
return data;
} catch (retryError) {
if (retryError.status !== 429) throw retryError;
consecutive429++;
const seconds = Math.min(300, 30 * (2 ** (consecutive429 - 1)));
cooldownUntil = Date.now() + seconds * 1000;
throw new Error(`Semantic Scholar 持续限流,已暂停请求 ${seconds}`);
}
}
}
function cachedRequest(url, ttl) {
syncKeyState();
const now = Date.now();
const existing = cache.get(url);
if (existing && existing.expiresAt > now) {
cache.delete(url);
cache.set(url, existing);
return existing.promise;
}
if (existing) cache.delete(url);
const promise = enqueue(() => requestWithCooldown(url));
cache.set(url, { promise, expiresAt: now + ttl });
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
promise.catch(() => {
const current = cache.get(url);
if (current && current.promise === promise) cache.delete(url);
});
return promise;
}
function toItem(p) {
@@ -20,6 +132,10 @@ function toItem(p) {
};
}
function paperMetadata(postId) {
return cachedRequest(`${BASE}/paper/${encodeURIComponent(postId)}?fields=${DETAIL_FIELDS}`, DETAIL_TTL);
}
module.exports = {
id: 'semanticscholar',
name: 'Semantic Scholar',
@@ -27,22 +143,22 @@ module.exports = {
async list(page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent('a')}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page };
return { items: [], maxPage: 1, page, note: '请输入关键词搜索 Semantic Scholar' };
},
async search(keyword, page) {
page = clampPage(page);
page = Math.min(clampPage(page), 40);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent(keyword)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page };
const query = String(keyword || '').trim();
if (!query) return { items: [], maxPage: 1, page: 1 };
const url = `${BASE}/paper/search?query=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${SEARCH_FIELDS}`;
const j = await cachedRequest(url, SEARCH_TTL);
const maxPage = Math.max(1, Math.ceil(Math.min(j.total || 0, 1000) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage, page };
},
async detail(postId) {
const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=${FIELDS}`);
const p = await paperMetadata(postId);
return {
postId,
title: p.title || '(无标题)',
@@ -60,7 +176,7 @@ module.exports = {
},
async download(postId) {
const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=title,openAccessPdf,url,externalIds`);
const p = await paperMetadata(postId);
const files = [];
if (p.openAccessPdf && p.openAccessPdf.url) {
files.push({ name: `${String(p.title || postId).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.pdf`, link: p.openAccessPdf.url, format: 'PDF' });
+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: []
};
}
};