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>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
-151
View File
@@ -1,151 +0,0 @@
// Anna's Archive 数据源:实时在线搜索
// 通过 annas-archive 镜像站的 HTML 搜索页抓取结果
const { fetchText, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const MIRRORS = [
'https://annas-archive.org',
'https://annas-archive.se',
'https://annas-archive.gs',
'https://annas-archive.li'
];
const PAGE_SIZE = 50;
function absUrl(base, href) {
if (!href) return '';
if (/^https?:\/\//.test(href)) return href;
if (href.startsWith('//')) return 'https:' + href;
if (href.startsWith('/')) return base + href;
return base + '/' + href;
}
function parseSearchHtml(html, base) {
const items = [];
// Anna's Archive 搜索结果在 <div class="record"> 或 <tr> 中
// 尝试匹配包含 md5 链接的卡片
const md5Re = /href="\/md5\/([a-f0-9]{32})"[^>]*>([\s\S]*?)<\/a>/g;
let m;
while ((m = md5Re.exec(html))) {
const md5 = m[1];
const inner = m[2];
const title = decodeEntities(inner.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
if (title) {
items.push({
postId: md5,
title,
cover: '',
date: '',
url: `${base}/md5/${md5}`,
subtitle: ''
});
}
}
// 如果没找到 md5 链接,尝试从 record 区块提取
if (!items.length) {
const recordRe = /<div[^>]+class="[^"]*record[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g;
let r;
while ((r = recordRe.exec(html))) {
const block = r[1];
const linkM = block.match(/href="([^"]*md5[^"]*)"/);
const titleM = block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/) || block.match(/<div[^>]+class="[^"]*title[^"]*"[^>]*>([\s\S]*?)<\/div>/);
const title = titleM ? decodeEntities(titleM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim() : '';
if (title && linkM) {
const md5M = linkM[1].match(/md5\/([a-f0-9]{32})/i);
const md5 = md5M ? md5M[1] : '';
if (md5) {
items.push({
postId: md5,
title,
cover: '',
date: '',
url: absUrl(base, linkM[1]),
subtitle: ''
});
}
}
}
}
return items;
}
function parseMaxPage(html) {
// Anna's Archive 分页信息在 "Page 1 of X" 或类似结构中
const m = html.match(/of\s+(\d+)\s+results/i) || html.match(/(\d+)\s+results/i);
if (m) return Math.max(1, Math.ceil(parseInt(m[1], 10) / PAGE_SIZE));
const pages = [];
const re = /page=(\d+)/g;
let mm;
while ((mm = re.exec(html))) pages.push(parseInt(mm[1], 10));
if (pages.length) return Math.max(...pages);
return 1;
}
async function searchMirror(base, keyword, page) {
const q = encodeURIComponent(keyword);
const url = `${base}/search?q=${q}&page=${page}`;
const html = await fetchText(url);
return { html, base };
}
module.exports = {
id: 'annas',
name: "Anna's Archive",
supportsSearch: true,
async list(page) {
return { items: [], maxPage: 1, page: 1 };
},
async search(keyword, page) {
page = clampPage(page);
const r = await tryMirrors('annas', MIRRORS, (m) => searchMirror(m, keyword, page));
const items = parseSearchHtml(r.html, r.base);
const maxPage = parseMaxPage(r.html);
return { items, maxPage, page };
},
async detail(postId) {
const r = await tryMirrors('annas', MIRRORS, async (m) => {
const url = `${m}/md5/${postId}`;
const html = await fetchText(url);
return { html, base: m, url };
});
const html = r.html;
// 从详情页解析元数据
let title = '', authors = [], year = '', cover = '', brief = '';
const titleM = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/) || html.match(/<title>([^<]+)<\/title>/i);
if (titleM) title = decodeEntities(titleM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
const authorM = html.match(/author[^>]*>([^<]+)</gi);
if (authorM) authors = authorM.map((a) => decodeEntities(a.replace(/<[^>]*>/g, '').trim())).filter(Boolean);
const yearM = html.match(/(?:year|published)[^\d]*(\d{4})/i);
if (yearM) year = yearM[1];
const descM = html.match(/description[^>]*>([\s\S]{10,800}?)<\/(?:div|td|p)>/i);
if (descM) brief = decodeEntities(descM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
const coverM = html.match(/(?:cover|image)[^>]*src="([^"]+)"/i);
if (coverM) cover = absUrl(r.base, coverM[1]);
const tags = [];
const extM = html.match(/extension[^>]*>([^<]+)</i);
if (extM) tags.push(`格式:${decodeEntities(extM[1]).trim()}`);
const sizeM = html.match(/size[^>]*>([^<]+)</i);
if (sizeM) tags.push(`大小:${decodeEntities(sizeM[1]).trim()}`);
return {
postId,
title: title || `MD5 ${postId.slice(0, 8)}`,
cover,
authors,
date: year,
tags,
brief,
url: r.url,
links: [{ name: "Anna's Archive 页", url: r.url }]
};
},
async download(postId) {
// Anna's Archive 下载需要到详情页点击,这里返回各镜像链接
const links = MIRRORS.map((m) => ({ name: `下载 (${m.replace('https://', '')})`, url: `${m}/md5/${postId}` }));
return { files: [], links };
}
};
+3 -2
View File
@@ -1,4 +1,4 @@
const { fetchJson, clampPage } = require('./http');
const { fetchJson, clampPage, isRetryable } = require('./http');
const PAGE_SIZE = 30;
const CATALOG_START = '2000-01-01';
@@ -29,7 +29,8 @@ async function fetchWindow(server, from, to, cursor, tries = 3) {
return { total, collection: j.collection || [] };
} catch (e) {
last = e;
if (!/504|502|503/.test(e.message)) throw e;
// 超时与网络抖动是这里最常见的瞬时故障,必须一并重试
if (!isRetryable(e) || i === tries - 1) throw e;
await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
}
}
+3 -2
View File
@@ -21,7 +21,8 @@ function toItem(r) {
const authors = (b.author || []).map((a) => a.name).slice(0, 3).join(', ');
const journal = (b.journal && b.journal.title) || '';
return {
postId: encodeURIComponent(r.id || idOf(b)),
// 存原始 id,编码交给用到的地方做,避免详情/下载再编码一次变成 %252F
postId: r.id || idOf(b),
title: b.title || '(无标题)',
cover: '',
date: b.year || '',
@@ -86,7 +87,7 @@ module.exports = {
links.push({ name: '全文页', url: l.url });
}
}
links.push({ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` });
links.push({ name: 'DOAJ 页', url: `https://doaj.org/article/${encodeURIComponent(postId)}` });
return { files, links };
}
};
+34 -14
View File
@@ -13,7 +13,7 @@ function setProxy(url) {
if (nextUrl) {
const parsed = new URL(nextUrl);
if (!/^https?:$/.test(parsed.protocol)) throw new Error('代理地址仅支持 http:// 或 https://');
nextDispatcher = new ProxyAgent({ uri: nextUrl });
nextDispatcher = new ProxyAgent({ uri: nextUrl, connectTimeout: 30000 });
}
if (dispatcher) {
dispatcher.close().catch(() => {});
@@ -28,6 +28,16 @@ function fetchWithProxy(url, options = {}) {
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options);
}
function fetchWithElectron(url, options = {}) {
try {
const electron = require('electron');
if (electron && electron.net && typeof electron.net.fetch === 'function') {
return electron.net.fetch(url, options);
}
} catch (e) { /* Node 测试环境没有 Electron 网络栈 */ }
return fetchWithProxy(url, options);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
const cookieJar = new Map();
@@ -55,10 +65,13 @@ function setCookies(url, setCookieHeaders) {
}
}
function clearCookies(urlPrefix) {
if (!urlPrefix) { cookieJar.clear(); return; }
for (const k of cookieJar.keys()) {
if (k.includes(urlPrefix)) cookieJar.delete(k);
// target 可以是完整 URL 或裸主机名;jar 以主机名为键,
// 传 URL 时要先取出 hostname,否则永远匹配不到。
function clearCookies(target) {
if (!target) { cookieJar.clear(); return; }
const host = domainOf(target) || String(target);
for (const k of [...cookieJar.keys()]) {
if (k === host || k.endsWith(`.${host}`)) cookieJar.delete(k);
}
}
@@ -74,24 +87,27 @@ async function fetchRaw(url, options = {}) {
};
if (cookie && !headers.Cookie) headers.Cookie = cookie;
const { timeout, ...rest } = options;
const { timeout, signal: outerSignal, useElectronNet, ...rest } = options;
const ms = timeout === undefined ? DEFAULT_TIMEOUT : timeout;
let signal = rest.signal;
let timer = null;
if (!signal && ms > 0) {
const ac = new AbortController();
signal = ac.signal;
timer = setTimeout(() => ac.abort(), ms);
// 调用方传入的 signal 不能顶替超时,否则外部取消一旦启用就再也没有超时保护
const ac = new AbortController();
const abort = () => ac.abort();
if (outerSignal) {
if (outerSignal.aborted) ac.abort();
else outerSignal.addEventListener('abort', abort, { once: true });
}
const timer = ms > 0 ? setTimeout(abort, ms) : null;
try {
const res = await fetchWithProxy(url, { redirect: 'follow', ...rest, headers, signal });
const request = useElectronNet ? fetchWithElectron : fetchWithProxy;
const res = await request(url, { redirect: 'follow', ...rest, headers, signal: ac.signal });
const setCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
setCookies(url, setCookie);
return res;
} catch (e) {
if (e && (e.name === 'AbortError' || /abort/i.test(e.message || ''))) {
if (outerSignal && outerSignal.aborted) throw new Error('请求已取消');
throw new Error('请求超时,站点无响应');
}
// undici / Chromium 的底层网络错误信息很不友好,统一换成可读文案
@@ -102,6 +118,7 @@ async function fetchRaw(url, options = {}) {
throw e;
} finally {
if (timer) clearTimeout(timer);
if (outerSignal) outerSignal.removeEventListener('abort', abort);
}
}
@@ -181,8 +198,11 @@ function tooShort(keyword, min = 3) {
}
// 可自愈的错误:超时、网络抖动、5xx、限流。4xx 属请求本身的问题,重试无意义。
// 主动取消(竞速败者、切换页面)不是故障,重试只会浪费一次请求。
function isRetryable(e) {
return /超时|网络连接失败|站点内部错误|站点网关|暂时不可用|过于频繁/.test((e && e.message) || '');
const msg = (e && e.message) || '';
if (/请求已取消/.test(msg)) return false;
return /超时|网络连接失败|站点内部错误|站点网关|暂时不可用|过于频繁/.test(msg);
}
// 带退避的重试包装。仅在错误可自愈时重试,避免为 4xx 白等几秒。
+24 -34
View File
@@ -6,11 +6,12 @@
// 搜索路由为 /s/<关键词>?page=N,结果为 schema.org 标注的 resItemBox 卡片,
// 条目链接形如 /book/<id>;下载需要该站自身账号,因此仅提供跳转链接。
//
// 策略:优先用新版站点搜索(当前唯一可用);经典镜像作为兜底,
// 一旦恢复即可自动参与(raceMirrors 有 5 分钟冷却重试机制)。
// 策略:用新版站点libgen.ac / libgen.mx)竞速搜索。
// 经典镜像(.li/.vg/.bz/.la/.gl)页面结构与新版完全不同,现有解析器无法处理,
// 因此不参与轮询;等它们恢复时需要另写解析分支才能接回来。
const { fetchText, decodeEntities, clampPage, tooShort } = require('./http');
const { raceMirrors } = require('./mirror');
const { raceMirrors, contentError } = require('./mirror');
// 新版站点(当前可用)
const WEB_MIRRORS = [
@@ -18,27 +19,19 @@ const WEB_MIRRORS = [
'https://libgen.mx'
];
// 经典镜像(当前 503,恢复后自动启用)
const LEGACY_MIRRORS = [
'https://libgen.li',
'https://libgen.vg',
'https://libgen.bz',
'https://libgen.la',
'https://libgen.gl'
];
// 已知 md5 时可用的下载入口
const DOWNLOAD_MIRRORS = [
'https://library.lol',
'https://libgen.li'
];
// 未登录时该站每页只返回 10 条
const PER_PAGE = 10;
const TIMEOUT = 12000;
// href 可能来自 JSON-LDschema.org 的 image 常是对象或数组而非字符串
function absUrl(base, href) {
if (!href) return '';
if (Array.isArray(href)) href = href[0];
if (href && typeof href === 'object') href = href.url || href.contentUrl || href['@id'] || '';
if (!href || typeof href !== 'string') return '';
if (/^https?:\/\//.test(href)) return href;
if (href.startsWith('//')) return 'https:' + href;
if (href.startsWith('/')) return base + href;
@@ -139,37 +132,34 @@ function parseWebResults(html, base) {
return items;
}
// 结果总数:<span class="totalCounter">(123)</span>
// 注意未登录时常显示 "(5+)" 这类模糊值,不可用于精确推算总页数。
function parseWebTotal(html) {
const m = html.match(/class="totalCounter"[^>]*>\s*\(?\s*([\d,]+)\s*\+?\s*\)?/i);
if (!m) return 0;
return parseInt(m[1].replace(/,/g, ''), 10) || 0;
}
// 从分页控件里取最大页码;没有分页控件说明只有一页。
// 必须限定在分页容器内扫描:全文扫 page= 会把页脚/侧栏的无关链接算进来,虚报页数。
function parseWebMaxPage(html, page, count) {
// 本页没有结果说明已经翻过头,回退到上一页
if (!count) return Math.max(1, page - 1);
const pager = html.match(/<(?:div|ul|nav)[^>]*class="[^"]*(?:paginat|pagination|pager)[^"]*"[^>]*>([\s\S]*?)<\/(?:div|ul|nav)>/i);
if (!pager) return page;
let max = 0;
const re = /[?&]page=(\d+)/g;
let m;
while ((m = re.exec(html))) {
while ((m = re.exec(pager[1]))) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
// 本页没有结果说明已经翻过头,回退到上一页
if (!count) return Math.max(1, page - 1);
if (max > page) return max;
return Math.max(page, max);
}
async function webSearch(keyword, page) {
const kw = encodeURIComponent(keyword);
return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => {
return raceMirrors('libgen-web', WEB_MIRRORS, async (base, signal) => {
const url = `${base}/s/${kw}${page > 1 ? `?page=${page}` : ''}`;
const html = await fetchText(url, { timeout: TIMEOUT, retries: 0 });
const html = await fetchText(url, { timeout: TIMEOUT, retries: 0, signal });
const items = parseWebResults(html, base);
if (!items.length && !/searchResultBox|resItemBox|Nothing found/i.test(html)) {
throw new Error('页面结构无法识别');
// 站点应答了,只是解析不出:换镜像同样解析不出,别把镜像拉黑
throw contentError('页面结构无法识别');
}
return { items, html, base };
});
@@ -184,8 +174,8 @@ function buildDownloadLinks(md5) {
}
async function fetchBookPage(id) {
return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => {
const html = await fetchText(`${base}/book/${id}`, { timeout: TIMEOUT, retries: 0 });
return raceMirrors('libgen-web', WEB_MIRRORS, async (base, signal) => {
const html = await fetchText(`${base}/book/${id}`, { timeout: TIMEOUT, retries: 0, signal });
return { html, base };
});
}
@@ -199,8 +189,8 @@ module.exports = {
page = clampPage(page);
// 新版站点有 /popular 榜单
try {
const r = await raceMirrors('libgen-web', WEB_MIRRORS, async (base) => {
const html = await fetchText(`${base}/popular`, { timeout: TIMEOUT, retries: 0 });
const r = await raceMirrors('libgen-web', WEB_MIRRORS, async (base, signal) => {
const html = await fetchText(`${base}/popular`, { timeout: TIMEOUT, retries: 0, signal });
return { items: parseWebResults(html, base), base };
});
return { items: r.items, maxPage: 1, page: 1 };
+50 -13
View File
@@ -37,10 +37,21 @@ function markGood(prefix, mirror) {
c.bad.delete(mirror);
}
function currentFor(prefix, mirrors) {
const c = stateOf(prefix);
if (c.current && mirrors.includes(c.current)) return c.current;
return mirrors[0];
// 内容级失败(找不到资源、凭据错误、页面解析不出)说明镜像本身是通的,
// 不能拉黑,否则一次密码输错或一次冷门查询就会废掉全部镜像。
function contentError(message) {
const err = new Error(message);
err.mirrorHealthy = true;
return err;
}
function isMirrorFault(e) {
return !(e && e.mirrorHealthy);
}
// 竞速败者是被我们自己中止的,不能据此判定镜像坏掉
function isCancelled(e) {
return !!e && (e.name === 'AbortError' || /请求已取消/.test(e.message || ''));
}
// 候选顺序:上次成功的优先,其余按原顺序,已拉黑的排到最后兜底
@@ -72,6 +83,11 @@ async function tryMirrors(prefix, mirrors, fn) {
return r;
} catch (e) {
lastErr = e;
// 镜像可达但内容不满足时,说明换镜像也是同样结果,直接返回
if (!isMirrorFault(e)) {
markGood(prefix, m);
throw e;
}
markBad(prefix, m);
}
}
@@ -82,33 +98,54 @@ async function tryMirrors(prefix, mirrors, fn) {
* 竞速尝试:同时向所有候选镜像发起请求,最先成功的胜出。
* 适用于镜像多且大量失效的场景(如 LibGen),避免串行等待累加。
*/
// 竞速时只用未拉黑的镜像;全被拉黑才退回完整列表重试一轮。
function raceCandidates(prefix, mirrors) {
const c = stateOf(prefix);
const fresh = candidates(prefix, mirrors).filter((m) => m === c.current || !isBad(c, m));
return fresh.length ? fresh : mirrors.slice();
}
async function raceMirrors(prefix, mirrors, fn) {
const list = candidates(prefix, mirrors);
const list = raceCandidates(prefix, mirrors);
if (!list.length) throw new Error('没有可用镜像');
// 胜出后主动中止其余在途请求,避免败者继续占用连接与代理带宽
const ac = new AbortController();
return new Promise((resolve, reject) => {
let pending = list.length;
let settled = false;
let lastErr;
const settle = (fn2, value) => {
if (settled) return;
settled = true;
ac.abort();
fn2(value);
};
for (const m of list) {
Promise.resolve()
.then(() => fn(m))
.then(() => fn(m, ac.signal))
.then((r) => {
if (settled) return;
settled = true;
markGood(prefix, m);
resolve(r);
settle(resolve, r);
})
.catch((e) => {
lastErr = e;
markBad(prefix, m);
if (--pending === 0 && !settled) {
reject(lastErr || new Error('所有镜像均不可用'));
// 输掉竞速被我们主动中止不算故障;但真实故障即使输了也要记进黑名单,
// 否则下次仍会去竞速一个已知坏掉的镜像。
if (!isCancelled(e) && isMirrorFault(e)) markBad(prefix, m);
if (settled) return;
if (!isMirrorFault(e)) {
markGood(prefix, m);
settle(reject, e);
return;
}
lastErr = e;
if (--pending === 0) settle(reject, lastErr || new Error('所有镜像均不可用'));
});
}
});
}
module.exports = { tryMirrors, raceMirrors, currentFor };
module.exports = { tryMirrors, raceMirrors, contentError };
+13 -9
View File
@@ -1,8 +1,8 @@
// Memory of the World 数据源:Calibre 书目服务,实时联网查询
// 端点:
// /books?page=N 浏览(分页)
// /search/titles/<kw>?page=N 按标题搜索
// /search/authors/<kw>?page=N 按作者搜索
// 端点(分页参数为 offset / limit
// /books?offset=N&limit=M 浏览(分页)
// /search/titles/<kw>?offset=N&limit=M 按标题搜索
// /search/authors/<kw>?offset=N&limit=M 按作者搜索
// 站点没有单条详情端点(/books/<id> 会回落到列表),因此详情与下载信息
// 从列表/搜索结果里缓存的原始记录中取。
@@ -47,6 +47,12 @@ function toItem(b) {
};
}
// 实测:该服务只认 offset / limit,传 page 会被忽略并一直返回第一页
// (响应里的 _meta.page 由 offset 推导得出)。
function pageQuery(page) {
return `offset=${(page - 1) * PAGE_SIZE}&limit=${PAGE_SIZE}`;
}
function pack(j, page) {
const items = j._items || [];
const total = (j._meta && j._meta.total) || 0;
@@ -76,8 +82,7 @@ module.exports = {
async list(page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchJson(`${BASE}/books?offset=${offset}&limit=${PAGE_SIZE}`);
const j = await fetchJson(`${BASE}/books?${pageQuery(page)}`);
return pack(j, page);
},
@@ -85,12 +90,11 @@ 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}?offset=${offset}&limit=${PAGE_SIZE}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?offset=${offset}&limit=${PAGE_SIZE}`).catch(() => null)
fetchJson(`${BASE}/search/titles/${kw}?${pageQuery(page)}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?${pageQuery(page)}`).catch(() => null)
]);
if (!byTitle && !byAuthor) throw new Error('搜索请求失败');
+44 -8
View File
@@ -25,6 +25,22 @@ function toItem(d) {
const FIELDS = 'key,title,author_name,first_publish_year,cover_i,ia,ocaid,editions';
// works 接口只给作者 key,姓名要按 key 逐个取;取不到就跳过而不是让详情整体失败
async function resolveAuthors(work) {
const keys = (work.authors || [])
.map((a) => (a && a.author && a.author.key) || (a && a.key) || '')
.filter(Boolean)
.slice(0, 5);
if (!keys.length) return [];
const names = await Promise.all(keys.map(async (k) => {
try {
const a = await fetchWithRetry(`${BASE}${k}.json`);
return a && a.name ? String(a.name) : '';
} catch (e) { return ''; }
}));
return names.filter(Boolean);
}
module.exports = {
id: 'openlibrary',
name: 'Open Library 图书',
@@ -55,28 +71,48 @@ module.exports = {
postId,
title: j.title || '(无标题)',
cover: j.covers && j.covers[0] ? `https://covers.openlibrary.org/b/id/${j.covers[0]}-M.jpg` : '',
authors: [],
authors: await resolveAuthors(j),
date: j.first_publish_date || '',
tags: (j.subjects || []).slice(0, 6).map((s) => `主题:${s}`),
brief: desc,
url: `${BASE}/works/${postId}`,
links: [{ name: 'Open Library 页', url: `${BASE}/works/${postId}` }]
url: `${BASE}/works/${encodeURIComponent(postId)}`,
links: [{ name: 'Open Library 页', url: `${BASE}/works/${encodeURIComponent(postId)}` }]
};
},
// archive.org 每个条目实际提供哪些格式要查 metadata,
// 直接拼 .pdf/.epub 会产生一半死链。
async download(postId) {
const ed = await fetchWithRetry(`${BASE}/works/${encodeURIComponent(postId)}/editions.json?limit=50`);
const files = [];
const ocaids = [];
const seen = new Set();
for (const e of (ed.entries || [])) {
const ocaid = e.ocaid || (e.ia && e.ia[0]);
if (!ocaid || seen.has(ocaid)) continue;
if (e.access_restricted === 'borrow') continue; // 借阅制,不直接下载
if (e.access_restricted === 'borrow' || e.access_restricted_item === true) continue;
seen.add(ocaid);
files.push({ name: `${ocaid}.pdf`, link: `https://archive.org/download/${ocaid}/${ocaid}.pdf`, format: 'PDF' });
files.push({ name: `${ocaid}.epub`, link: `https://archive.org/download/${ocaid}/${ocaid}.epub`, format: 'EPUB' });
ocaids.push(ocaid);
if (ocaids.length >= 3) break;
}
const WANTED = { 'Text PDF': 'PDF', 'Image Container PDF': 'PDF', 'EPUB': 'EPUB' };
const files = [];
for (const ocaid of ocaids) {
let meta;
try {
meta = await fetchWithRetry(`https://archive.org/metadata/${encodeURIComponent(ocaid)}`);
} catch (e) { continue; }
for (const f of (meta && meta.files) || []) {
const format = WANTED[f.format];
if (!format || !f.name) continue;
files.push({
name: f.name,
link: `https://archive.org/download/${encodeURIComponent(ocaid)}/${encodeURIComponent(f.name)}`,
format
});
}
if (files.length >= 6) break;
}
return { files, links: [{ name: 'Open Library 页', url: `${BASE}/works/${postId}` }] };
return { files: files.slice(0, 6), links: [{ name: 'Open Library 页', url: `${BASE}/works/${encodeURIComponent(postId)}` }] };
}
};
+28 -13
View File
@@ -4,21 +4,32 @@ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
const OA_DATA = 'https://pmc-oa-opendata.s3.amazonaws.com';
const PAGE_SIZE = 20;
// 对外 postId 一律是裸数字;拼 URL 时统一补 PMC 前缀,避免出现 PMCPMC123456
function bareId(postId) {
return String(postId == null ? '' : postId).trim().replace(/^PMC/i, '');
}
function articleUrl(postId) {
return `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${bareId(postId)}/`;
}
function toItem(r) {
const authors = (r.authors || []).map((a) => a.name).slice(0, 3).join(', ');
return {
postId: String(r.uid),
postId: bareId(r.uid),
title: r.title || '(无标题)',
cover: '',
date: (r.pubdate || '').slice(0, 4),
url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${r.uid}/`,
url: articleUrl(r.uid),
subtitle: [authors, r.fulljournalname || r.source].filter(Boolean).join(' · ')
};
}
async function esearch(term, start) {
const j = await fetchJson(`${EUTILS}/esearch.fcgi?db=pmc&term=${encodeURIComponent(term)}&retmode=json&retstart=${start}&retmax=${PAGE_SIZE}&sort=relevance`);
return { count: parseInt(j.esearchresult.count, 10) || 0, ids: j.esearchresult.idlist || [] };
const r = j && j.esearchresult;
if (!r) throw new Error('PMC 返回了无法识别的检索结果');
return { count: parseInt(r.count, 10) || 0, ids: r.idlist || [] };
}
async function esummary(ids) {
@@ -37,7 +48,10 @@ async function runList(term, page) {
}
async function resolvePdf(postId) {
const pmcid = `PMC${String(postId).replace(/^PMC/i, '')}`;
const id = bareId(postId);
// postId 来自 IPC,未校验就拼进 RegExp 会被元字符破坏甚至抛 SyntaxError
if (!/^\d+$/.test(id)) throw new Error('无效的 PMC ID');
const pmcid = `PMC${id}`;
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))
@@ -58,11 +72,12 @@ module.exports = {
search(keyword, page) { return runList(`${keyword} AND open access[filter] AND has_pdf[filter]`, page); },
async detail(postId) {
const result = await esummary([postId]);
const r = result[postId];
const id = bareId(postId);
const result = await esummary([id]);
const r = result[id];
if (!r) throw new Error('未找到该文献');
return {
postId: String(postId),
postId: id,
title: r.title || '(无标题)',
cover: '',
authors: (r.authors || []).map((a) => a.name),
@@ -72,17 +87,17 @@ module.exports = {
r.pubdate ? `发表:${r.pubdate}` : ''
].filter(Boolean),
brief: '',
url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`,
links: [{ name: 'PMC 全文页', url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/` }]
url: articleUrl(id),
links: [{ name: 'PMC 全文页', url: articleUrl(id) }]
};
},
async download(postId) {
const page = `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`;
const link = await resolvePdf(postId);
const id = bareId(postId);
const link = await resolvePdf(id);
return {
files: [{ name: `PMC${postId}.pdf`, link, format: 'PDF' }],
links: [{ name: 'PMC 全文页', url: page }]
files: [{ name: `PMC${id}.pdf`, link, format: 'PDF' }],
links: [{ name: 'PMC 全文页', url: articleUrl(id) }]
};
}
};
+14 -9
View File
@@ -3,7 +3,7 @@
// 模块会明确抛错并提示用户在浏览器中打开。
const { fetchText, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const { tryMirrors, contentError } = require('./mirror');
const MIRRORS = [
'https://sci-hub.se',
@@ -48,17 +48,21 @@ function extractTitle(html, doi) {
return doi;
}
// 每个 pattern 都要遍历全部匹配:首个 iframe 常是广告/统计框,
// 只看第一个会漏掉后面真正的 PDF。
// 用 matchAll 而不是 while(re.exec):后者在正则漏掉 g 标志时会死循环。
function extractPdf(html, base) {
const patterns = [
/<iframe[^>]+src\s*=\s*["']([^"']+)["']/i,
/<embed[^>]+src\s*=\s*["']([^"']+)["']/i,
/location\.href\s*=\s*['"]([^'"]+)['"]/i,
/<a[^>]+href\s*=\s*["']([^"']*\.pdf[^"']*)["']/i
/<iframe[^>]+src\s*=\s*["']([^"']+)["']/gi,
/<embed[^>]+src\s*=\s*["']([^"']+)["']/gi,
/location\.href\s*=\s*['"]([^'"]+)['"]/gi,
/<a[^>]+href\s*=\s*["']([^"']*\.pdf[^"']*)["']/gi
];
for (const re of patterns) {
const m = html.match(re);
if (m && m[1] && /\.pdf|\/downloads?\//i.test(m[1])) {
return absUrl(base, m[1].replace(/#.*$/, ''));
for (const m of html.matchAll(re)) {
if (m[1] && /\.pdf|\/downloads?\//i.test(m[1])) {
return absUrl(base, m[1].replace(/#.*$/, ''));
}
}
}
return '';
@@ -73,7 +77,8 @@ async function fetchSciHub(base, doi) {
const pdfUrl = extractPdf(html, base);
const title = extractTitle(html, doi);
const notFound = /article not found|не найдена|抱歉/i.test(html);
if (!pdfUrl && notFound) throw new Error('该 DOI 在 Sci-Hub 中不存在');
// 镜像明确答复"没有这篇":换镜像结果相同,不该拉黑镜像也不该继续串行等待
if (!pdfUrl && notFound) throw contentError('该 DOI 在 Sci-Hub 中不存在');
return { pdfUrl, title, url, base };
}
+6 -1
View File
@@ -26,10 +26,15 @@ function read() {
try {
const backup = `${filePath}.bak`;
if (!fs.existsSync(filePath) && fs.existsSync(backup)) fs.renameSync(backup, filePath);
// 文件不存在是"确实没配置",可以缓存;读取/解密失败可能是临时的
// (文件被占用、keyring 尚未就绪),缓存空值会让 key 在整个进程生命周期内失效
if (!fs.existsSync(filePath)) {
cachedKey = '';
return '';
}
cachedKey = safeStorage.decryptString(fs.readFileSync(filePath));
return cachedKey;
} catch (e) {
cachedKey = '';
return '';
}
}
+4 -2
View File
@@ -47,7 +47,9 @@ function catalogMaxPage(html, page) {
function publicationToItem(p) {
const metadata = p.metadata || {};
const slug = slugFromUrl(metadata.identifier);
const authors = Array.isArray(metadata.author) ? metadata.author : (metadata.author ? [metadata.author] : []);
// 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),
@@ -55,7 +57,7 @@ function publicationToItem(p) {
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(', ')
subtitle: authors.join(', ')
};
}
+113 -36
View File
@@ -1,65 +1,141 @@
// Z-Library 凭据与会话存储
// 注意:凭据以 base64 简单混淆存储于本地 userData 目录,不是真正的加密。
//
// 邮箱与密码用 Electron safeStorage 加密后落盘(Windows DPAPI / macOS Keychain /
// Linux libsecret),密文单独存 zlib-auth.cred。会话令牌等非敏感字段仍是明文 JSON。
// 系统不支持加密时不落盘密码,只在本进程内存里保留,重启后需要重新登录。
// 宁可让用户多登一次,也不把明文密码写到磁盘上。
const fs = require('fs');
const path = require('path');
let filePath = null;
let credPath = null;
let safeStorage = null;
let sessionCreds = null; // 无法加密时的内存兜底
function init(userDataDir) {
function init(userDataDir, storage) {
filePath = path.join(userDataDir, 'zlib-auth.json');
credPath = path.join(userDataDir, 'zlib-auth.cred');
safeStorage = storage || null;
sessionCreds = null;
}
function getFilePath() {
if (filePath) return filePath;
// 未初始化时回退到用户目录(便于独立 Node 脚本测试)
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'zlib-auth.json');
}
function read() {
const fp = getFilePath();
function getCredPath() {
if (credPath) return credPath;
return getFilePath().replace(/\.json$/, '.cred');
}
function encryptionAvailable() {
try {
const raw = fs.readFileSync(fp, 'utf8');
const j = JSON.parse(raw);
if (!j) return null;
return {
email: j.email ? Buffer.from(j.email, 'base64').toString('utf8') : '',
password: j.password ? Buffer.from(j.password, 'base64').toString('utf8') : '',
userId: j.userId || '',
userKey: j.userKey || '',
mirror: j.mirror || ''
};
return !!safeStorage && safeStorage.isEncryptionAvailable();
} catch (e) { return false; }
}
// 先写临时文件再原子改名:避免崩溃留下截断的 JSON 导致"静默登出"
function atomicWrite(dest, data) {
const temp = `${dest}.tmp`;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, data);
fs.renameSync(temp, dest);
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanupError) { /* ignore */ }
throw e;
}
}
function readSecrets() {
if (sessionCreds) return sessionCreds;
const fp = getCredPath();
if (!encryptionAvailable() || !fs.existsSync(fp)) return null;
try {
const j = JSON.parse(safeStorage.decryptString(fs.readFileSync(fp)));
return { email: j.email || '', password: j.password || '' };
} catch (e) { return null; }
}
function writeSecrets(email, password) {
if (!email && !password) {
sessionCreds = null;
try { fs.unlinkSync(getCredPath()); } catch (e) { /* ignore */ }
return;
}
if (!encryptionAvailable()) {
sessionCreds = { email, password };
return;
}
sessionCreds = null;
atomicWrite(getCredPath(), safeStorage.encryptString(JSON.stringify({ email, password })));
}
function readMeta() {
try {
const j = JSON.parse(fs.readFileSync(getFilePath(), 'utf8'));
return j && typeof j === 'object' ? j : null;
} catch (e) { return null; }
}
// 旧版本把 base64 混淆的凭据直接放在 json 里,读到就迁移进加密存储并抹掉明文
function migrateLegacy(meta) {
if (!meta || (!meta.email && !meta.password)) return null;
const decode = (v) => {
try { return v ? Buffer.from(v, 'base64').toString('utf8') : ''; } catch (e) { return ''; }
};
const creds = { email: decode(meta.email), password: decode(meta.password) };
try {
writeSecrets(creds.email, creds.password);
const { email, password, ...rest } = meta;
atomicWrite(getFilePath(), JSON.stringify(rest, null, 2));
} catch (e) { /* 迁移失败不影响本次使用 */ }
return creds;
}
function read() {
const meta = readMeta();
let secrets = readSecrets();
if (!secrets) secrets = migrateLegacy(meta);
if (!meta && !secrets) return null;
return {
email: (secrets && secrets.email) || '',
password: (secrets && secrets.password) || '',
userId: (meta && meta.userId) || '',
userKey: (meta && meta.userKey) || '',
mirror: (meta && meta.mirror) || '',
...((meta && Array.isArray(meta.customMirrors)) ? { customMirrors: meta.customMirrors } : {})
};
}
function write(creds) {
const fp = getFilePath();
try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch (e) { /* ignore */ }
const j = {
email: creds.email ? Buffer.from(creds.email, 'utf8').toString('base64') : '',
password: creds.password ? Buffer.from(creds.password, 'utf8').toString('base64') : '',
writeSecrets(creds.email || '', creds.password || '');
const meta = {
userId: creds.userId || '',
userKey: creds.userKey || '',
mirror: creds.mirror || ''
};
fs.writeFileSync(fp, JSON.stringify(j, null, 2), 'utf8');
if (Array.isArray(creds.customMirrors)) meta.customMirrors = creds.customMirrors;
atomicWrite(getFilePath(), JSON.stringify(meta, null, 2));
}
// 清除全部(含凭据)——用于"退出登录"
function clear() {
const fp = getFilePath();
try { fs.unlinkSync(fp); } catch (e) { /* ignore */ }
sessionCreds = null;
for (const fp of [getFilePath(), getCredPath(), `${getFilePath()}.tmp`, `${getCredPath()}.tmp`]) {
try { fs.unlinkSync(fp); } catch (e) { /* ignore */ }
}
}
// 只清除会话令牌,保留邮箱密码以便自动重新登录
function clearSession() {
const c = read();
if (!c) return;
c.userId = '';
c.userKey = '';
c.mirror = '';
write(c);
const meta = readMeta();
if (!meta) return;
const next = { ...meta, userId: '', userKey: '', mirror: '' };
atomicWrite(getFilePath(), JSON.stringify(next, null, 2));
}
function hasCreds() {
@@ -68,17 +144,18 @@ function hasCreds() {
}
function getSession() {
const c = read();
if (c && c.userId && c.userKey) return { userId: c.userId, userKey: c.userKey, mirror: c.mirror || '' };
const meta = readMeta();
if (meta && meta.userId && meta.userKey) {
return { userId: meta.userId, userKey: meta.userKey, mirror: meta.mirror || '' };
}
return null;
}
// 只动会话字段:密文不重写,凭据不会因为一次读取失败被清空
function setSession(userId, userKey, mirror) {
const c = read() || { email: '', password: '' };
c.userId = userId;
c.userKey = userKey;
c.mirror = mirror || '';
write(c);
const meta = readMeta() || {};
const next = { ...meta, userId, userKey, mirror: mirror || '' };
atomicWrite(getFilePath(), JSON.stringify(next, null, 2));
}
module.exports = { init, read, write, clear, clearSession, hasCreds, getSession, setSession };
+135 -33
View File
@@ -1,6 +1,6 @@
// Z-Library 数据源
// 关键约定(经实测确认):
// - 登录:POST /eapi/user/login (email, password) -> user.id / user.remix_userkey
// - 登录:POST /rpc.php,成功后从 remix_userid / remix_userkey Cookie 建立会话
// - 搜索:POST /eapi/book/search (message, limit, page, userId, userKey)
// * 必须是 POST;用 GET 会被当成取单本书并返回 "Requested book not found"
// * 分页信息在 pagination.total_items / total_pages
@@ -9,13 +9,13 @@
// - 下载:GET /eapi/book/{id}/{hash}/file -> file.downloadLink
// 镜像域名变动频繁,登录成功的镜像会被记录并优先复用。
const { fetchJson, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const { fetchRaw, clampPage, decodeEntities, clearCookies, getCookies } = require('./http');
const { tryMirrors, contentError } = require('./mirror');
const auth = require('./zlib-auth');
const DEFAULT_MIRRORS = [
'https://z-lib.fm',
'https://z-library.sk',
'https://z-lib.fm',
'https://z-lib.gs',
'https://1lib.sk',
'https://singlelogin.re'
@@ -23,6 +23,24 @@ const DEFAULT_MIRRORS = [
const PAGE_SIZE = 20;
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
let loginTransport = null;
function setLoginTransport(transport) {
if (transport != null && typeof transport !== 'function') {
throw new Error('Z-Library 登录传输层无效');
}
loginTransport = transport;
}
function requestHeaders(mirror, includeForm = false) {
const origin = new URL(mirror).origin;
return {
...(includeForm ? FORM : {}),
'X-Requested-With': 'XMLHttpRequest',
'Origin': origin,
'Referer': `${origin}/`
};
}
function getMirrors() {
const custom = (auth.read() || {}).customMirrors;
@@ -58,20 +76,73 @@ function errMessage(j) {
return typeof j.error === 'string' ? j.error : (j.error.message || '');
}
function cookieValue(header, name) {
const prefix = `${name}=`;
const part = String(header || '').split(';').map((item) => item.trim())
.find((item) => item.startsWith(prefix));
if (!part) return '';
const value = part.slice(prefix.length);
try { return decodeURIComponent(value); } catch (e) { return value; }
}
function rpcError(j) {
const response = j && j.response;
if (!response || typeof response !== 'object') return '';
if (!response.validationError && !response.error) return '';
return String(response.message || response.error || '登录失败');
}
async function doLogin() {
const creds = auth.read();
if (!creds || !creds.email || !creds.password) {
throw authRequired('Z-Library 需要登录,请先在设置中配置账号');
}
const r = await tryMirrors('zlib', getMirrors(), async (m) => {
const j = await fetchJson(apiUrl(m, '/eapi/user/login'), {
if (loginTransport) {
const result = await loginTransport(m, creds.email, creds.password);
if (result && result.error) throw contentError(String(result.error));
if (!result || !result.userId || !result.userKey) {
throw new Error('登录响应缺少会话信息');
}
return {
userId: String(result.userId),
userKey: String(result.userKey),
mirror: m
};
}
const url = apiUrl(m, '/rpc.php');
const res = await fetchRaw(url, {
method: 'POST',
headers: FORM,
retries: 0,
body: form({ email: creds.email, password: creds.password })
headers: requestHeaders(m, true),
timeout: 30000,
useElectronNet: true,
body: form({
isModal: true,
email: creds.email,
password: creds.password,
site_mode: 'books',
action: 'login',
isSingleLogin: 1,
redirectUrl: '',
gg_json_mode: 1
})
});
if (!j || !j.success || !j.user) throw new Error(errMessage(j) || '登录失败');
return { userId: String(j.user.id), userKey: j.user.remix_userkey, mirror: m };
const text = await res.text();
let j = null;
try { j = JSON.parse(text); } catch (e) { /* 非 JSON */ }
if (!j) {
if (/checking your browser|diamwall|cloudflare/i.test(text)) {
throw new Error('登录镜像触发了浏览器验证');
}
throw new Error(res.ok ? '登录镜像未返回 JSON' : `登录失败(HTTP ${res.status}`);
}
const message = rpcError(j);
if (message) throw contentError(message);
const cookies = getCookies(url);
const userId = cookieValue(cookies, 'remix_userid');
const userKey = cookieValue(cookies, 'remix_userkey');
if (!userId || !userKey) throw new Error('登录响应缺少会话信息');
return { userId, userKey, mirror: m };
});
auth.setSession(r.userId, r.userKey, r.mirror);
return r;
@@ -82,19 +153,27 @@ async function ensureLogin() {
}
// method: 'GET' | 'POST'。凭据 GET 走 queryPOST 走 body。
// 会话失效时 Z-Library 返回 4xx + JSON 体(实测 /file 给 400 "Please login"),
// 所以必须先读 body 再看状态码:否则真实原因被 HTTP 状态盖掉,
// 会话过期就无法被识别,自动重新登录也就不会触发。
async function callOn(mirror, path, params, session, method) {
const cred = { userId: session.userId, userKey: session.userKey };
let j;
if (method === 'POST') {
j = await fetchJson(apiUrl(mirror, path), {
method: 'POST',
headers: FORM,
retries: 0,
body: form({ ...params, ...cred })
});
} else {
j = await fetchJson(apiUrl(mirror, path, { ...params, ...cred }), { retries: 0 });
const url = method === 'POST'
? apiUrl(mirror, path)
: apiUrl(mirror, path, { ...params, ...cred });
const options = method === 'POST'
? { method: 'POST', headers: requestHeaders(mirror, true), body: form({ ...params, ...cred }) }
: { headers: requestHeaders(mirror) };
const res = await fetchRaw(url, { ...options, useElectronNet: true });
const text = await res.text();
let j = null;
try { j = JSON.parse(text); } catch (e) { /* 非 JSON,按状态码处理 */ }
if (!j) {
if (!res.ok) throw new Error(`请求失败(HTTP ${res.status}`);
throw new Error('该镜像返回了非预期内容');
}
const msg = errMessage(j);
if (msg) {
if (/userkey|unauthor|auth|login|token|expired/i.test(msg)) {
@@ -104,7 +183,7 @@ async function callOn(mirror, path, params, session, method) {
}
throw new Error(msg);
}
if (!j || j.success !== 1) throw new Error('该镜像不支持此接口');
if (j.success !== 1) throw new Error('该镜像不支持此接口');
return j;
}
@@ -133,19 +212,21 @@ async function apiCall(path, params = {}, method = 'GET') {
let r = await attempt(session, path, params, method);
if (r.ok) return r.data;
// 只有确认是会话失效才重新登录。纯网络不可达时重登也会失败,
// 反而会清掉有效会话并把原始错误换成登录错误。
if (!r.stale) throw r.error || new Error('Z-Library 所有镜像均不可用');
const creds = auth.read();
if (creds && creds.email && creds.password) {
auth.clearSession();
const fresh = await doLogin();
r = await attempt(fresh, path, params, method);
if (r.ok) return r.data;
if (!r.stale) throw r.error || new Error('Z-Library 所有镜像均不可用');
}
if (r.stale) {
auth.clearSession();
throw authRequired('Z-Library 会话已过期,请重新登录');
}
throw r.error || new Error('Z-Library 所有镜像均不可用');
auth.clearSession();
throw authRequired('Z-Library 会话已过期,请重新登录');
}
function splitAuthors(s) {
@@ -173,10 +254,16 @@ function toItem(b) {
};
}
// hash 可缺省:接口偶尔不返回 hash,此时仍可用 /eapi/book/<id> 取详情,
// 不能因为拼出 "123/" 就把整条结果判成无效 ID。
function parseId(postId) {
const m = String(postId).match(/^(\d+)\/([A-Za-z0-9]+)$/);
const m = String(postId).match(/^(\d+)(?:\/([A-Za-z0-9]*))?$/);
if (!m) throw new Error('无效的 Z-Library ID');
return { id: m[1], hash: m[2] };
return { id: m[1], hash: m[2] || '' };
}
function bookPath(id, hash, suffix = '') {
return `/eapi/book/${id}${hash ? `/${hash}` : ''}${suffix}`;
}
module.exports = {
@@ -211,7 +298,7 @@ module.exports = {
async detail(postId) {
const { id, hash } = parseId(postId);
const j = await apiCall(`/eapi/book/${id}/${hash}`);
const j = await apiCall(bookPath(id, hash));
const b = j.book;
if (!b) throw new Error('获取详情失败');
@@ -239,7 +326,7 @@ module.exports = {
async download(postId) {
const { id, hash } = parseId(postId);
const j = await apiCall(`/eapi/book/${id}/${hash}/file`);
const j = await apiCall(bookPath(id, hash, '/file'));
const f = j.file;
if (!f || !f.downloadLink) throw new Error('获取下载链接失败(可能已达每日下载上限)');
@@ -258,23 +345,38 @@ module.exports = {
};
},
// 校验通过后才落盘:登录失败不能毁掉之前可用的账号与会话
async login(email, password) {
auth.write({ email, password, userId: '', userKey: '', mirror: '' });
const previous = auth.read();
auth.write({
email,
password,
userId: '',
userKey: '',
mirror: '',
...((previous && Array.isArray(previous.customMirrors))
? { customMirrors: previous.customMirrors }
: {})
});
try {
await doLogin();
return { ok: true };
} catch (e) {
auth.clear();
if (previous) auth.write(previous); else auth.clear();
return { ok: false, error: e.message };
}
},
// 一并清掉各镜像的 cookie,否则"退出登录"后旧会话 cookie 仍会被自动带上
async logout() {
auth.clear();
for (const m of getMirrors()) clearCookies(m);
return { ok: true };
},
hasCreds() {
return auth.hasCreds();
}
},
setLoginTransport
};