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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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-LD,schema.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
@@ -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
@@ -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('搜索请求失败');
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 走 query,POST 走 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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user