feat: PeopleLib 开放文献客户端,集成 Z-Library 与 LibGen 等多源检索

Electron 桌面客户端,聚合多个开放获取文献源的搜索、详情与下载。

新增数据源:
- Z-Library:邮箱登录(凭据本地存储),会话失效自动重登
- LibGen:适配新版 libgen.ac 前端(旧版 search.php 镜像已全部下线)
- Memory of the World、Sci-Hub、Anna's Archive

基础设施:
- mirror.js:镜像故障转移,支持串行优先与并发竞速两种策略,
  失效镜像 5 分钟冷却后自动重试,避免站点恢复后被永久跳过
- http.js:统一 15 秒请求超时,防止单个卡死镜像拖垮整次搜索
- settings.js:全局代理配置持久化,经 Electron net.fetch 生效于所有请求

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-25 14:51:10 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 1a1288ce18
33 changed files with 4640 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
// Z-Library 数据源
// 关键约定(经实测确认):
// - 登录:POST /eapi/user/login (email, password) -> user.id / user.remix_userkey
// - 搜索:POST /eapi/book/search (message, limit, page, userId, userKey)
// * 必须是 POST;用 GET 会被当成取单本书并返回 "Requested book not found"
// * 分页信息在 pagination.total_items / total_pages
// * 作者字段是 author(单数字符串),不是 authors
// - 详情:GET /eapi/book/{id}/{hash}
// - 下载:GET /eapi/book/{id}/{hash}/file -> file.downloadLink
// 镜像域名变动频繁,登录成功的镜像会被记录并优先复用。
const { fetchJson, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const auth = require('./zlib-auth');
const DEFAULT_MIRRORS = [
'https://z-lib.fm',
'https://z-library.sk',
'https://z-lib.gs',
'https://1lib.sk',
'https://singlelogin.re'
];
const PAGE_SIZE = 20;
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
function getMirrors() {
const custom = (auth.read() || {}).customMirrors;
if (Array.isArray(custom) && custom.length) {
return custom.concat(DEFAULT_MIRRORS.filter((m) => !custom.includes(m)));
}
return DEFAULT_MIRRORS.slice();
}
function apiUrl(base, path, params = {}) {
const u = new URL(base + path);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v);
}
return u.toString();
}
function form(params) {
return Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}
function authRequired(msg) {
const err = new Error(msg);
err.code = 'AUTH_REQUIRED';
return err;
}
function errMessage(j) {
if (!j || !j.error) return '';
return typeof j.error === 'string' ? j.error : (j.error.message || '');
}
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'), {
method: 'POST',
headers: FORM,
body: form({ email: creds.email, password: creds.password })
});
if (!j || !j.success || !j.user) throw new Error(errMessage(j) || '登录失败');
return { userId: String(j.user.id), userKey: j.user.remix_userkey, mirror: m };
});
auth.setSession(r.userId, r.userKey, r.mirror);
return r;
}
async function ensureLogin() {
return auth.getSession() || doLogin();
}
// method: 'GET' | 'POST'。凭据 GET 走 queryPOST 走 body。
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,
body: form({ ...params, ...cred })
});
} else {
j = await fetchJson(apiUrl(mirror, path, { ...params, ...cred }));
}
const msg = errMessage(j);
if (msg) {
if (/userkey|unauthor|auth|login|token|expired/i.test(msg)) {
const e = new Error(msg);
e.code = 'AUTH_STALE';
throw e;
}
throw new Error(msg);
}
if (!j || j.success !== 1) throw new Error('该镜像不支持此接口');
return j;
}
async function attempt(session, path, params, method) {
const mirrors = getMirrors();
const ordered = session.mirror
? [session.mirror, ...mirrors.filter((m) => m !== session.mirror)]
: mirrors;
let lastErr;
for (const m of ordered) {
try {
const j = await callOn(m, path, params, session, method);
if (session.mirror !== m) auth.setSession(session.userId, session.userKey, m);
return { ok: true, data: j };
} catch (e) {
if (e.code === 'AUTH_STALE') return { ok: false, stale: true, error: e };
lastErr = e;
}
}
return { ok: false, stale: false, error: lastErr };
}
async function apiCall(path, params = {}, method = 'GET') {
const session = await ensureLogin();
let r = await attempt(session, path, params, method);
if (r.ok) return r.data;
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) {
auth.clearSession();
throw authRequired('Z-Library 会话已过期,请重新登录');
}
throw r.error || new Error('Z-Library 所有镜像均不可用');
}
function splitAuthors(s) {
return String(s || '')
.split(/[,;]| and /i)
.map((a) => a.trim())
.filter(Boolean);
}
function bookUrl(b) {
const mirror = (auth.getSession() || {}).mirror || DEFAULT_MIRRORS[0];
if (b.href) return b.href.startsWith('http') ? b.href : mirror + b.href;
if (b.url) return b.url.startsWith('http') ? b.url : mirror + b.url;
return `${mirror}/book/${b.id}`;
}
function toItem(b) {
return {
postId: `${b.id}/${b.hash || ''}`,
title: decodeEntities(b.title || ''),
cover: b.cover || '',
date: b.year ? String(b.year) : '',
url: bookUrl(b),
subtitle: decodeEntities(b.author || '')
};
}
function parseId(postId) {
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] };
}
module.exports = {
id: 'zlib',
name: 'Z-Library',
supportsSearch: true,
// 无关键词时展示热门书目
async list(page) {
page = clampPage(page);
const j = await apiCall('/eapi/book/most-popular');
const books = j.books || [];
return { items: books.map(toItem), maxPage: 1, page: 1 };
},
async search(keyword, page) {
page = clampPage(page);
const j = await apiCall('/eapi/book/search', {
message: keyword,
limit: PAGE_SIZE,
page
}, 'POST');
const books = j.books || [];
const pg = j.pagination || {};
const maxPage = pg.total_pages
? Math.max(1, pg.total_pages)
: Math.max(1, Math.ceil((j.exactBooksCount || books.length) / PAGE_SIZE));
return { items: books.map(toItem), maxPage, page };
},
async detail(postId) {
const { id, hash } = parseId(postId);
const j = await apiCall(`/eapi/book/${id}/${hash}`);
const b = j.book;
if (!b) throw new Error('获取详情失败');
const tags = [];
if (b.language) tags.push(`语言:${b.language}`);
if (b.extension) tags.push(`格式:${String(b.extension).toUpperCase()}`);
if (b.filesizeString) tags.push(`大小:${b.filesizeString}`);
else if (b.filesize) tags.push(`大小:${(b.filesize / 1048576).toFixed(1)} MB`);
if (b.publisher) tags.push(`出版:${b.publisher}`);
if (b.pages) tags.push(`页数:${b.pages}`);
if (b.series) tags.push(`丛书:${b.series}`);
return {
postId,
title: decodeEntities(b.title || ''),
cover: b.cover || '',
authors: splitAuthors(b.author),
date: b.year ? String(b.year) : '',
tags,
brief: decodeEntities(String(b.description || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(),
url: bookUrl(b),
links: [{ name: 'Z-Library 页', url: bookUrl(b) }]
};
},
async download(postId) {
const { id, hash } = parseId(postId);
const j = await apiCall(`/eapi/book/${id}/${hash}/file`);
const f = j.file;
if (!f || !f.downloadLink) throw new Error('获取下载链接失败(可能已达每日下载上限)');
let name = f.description || f.name || '';
if (!name) name = `zlib-${id}`;
const ext = (f.extension || '').toLowerCase();
if (ext && !new RegExp(`\\.${ext}$`, 'i').test(name)) name += `.${ext}`;
return {
files: [{
name: name.replace(/[\\/:*?"<>|]/g, '_'),
link: f.downloadLink,
format: (f.extension || '').toUpperCase()
}],
links: []
};
},
async login(email, password) {
auth.write({ email, password, userId: '', userKey: '', mirror: '' });
try {
await doLogin();
return { ok: true };
} catch (e) {
auth.clear();
return { ok: false, error: e.message };
}
},
async logout() {
auth.clear();
return { ok: true };
},
hasCreds() {
return auth.hasCreds();
}
};