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
+17
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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