Files
peoplelib/src/sources/zlib.js
T
lofyer 488de94289 feat: 支持自定义 Z-Library 镜像并发布 v2.1.5
在 Z-Library 账户设置内增加镜像站点编辑入口,一行一个 HTTPS 地址,
自定义地址经 origin 规范化后优先于内置列表。镜像配置与凭据分开处理,
退出登录后仍保留;移除当前会话依赖的镜像时清理旧会话和 Cookie。

版本更新到 2.1.5,并补充存储、校验、IPC、界面与真实 Electron 保存测试。
2026-08-09 15:25:32 +08:00

431 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Z-Library 数据源
// 关键约定(经实测确认):
// - 登录: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
// * 作者字段是 author(单数字符串),不是 authors
// - 详情:GET /eapi/book/{id}/{hash}
// - 下载:GET /eapi/book/{id}/{hash}/file -> file.downloadLink
// 镜像域名变动频繁,登录成功的镜像会被记录并优先复用。
const { fetchRaw, clampPage, decodeEntities, clearCookies, getCookies } = require('./http');
const { tryMirrors, contentError } = require('./mirror');
const auth = require('./zlib-auth');
const DEFAULT_MIRRORS = [
'https://z-library.sk',
'https://z-lib.fm',
'https://z-lib.gs',
'https://1lib.sk',
'https://singlelogin.re'
];
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.getCustomMirrors();
if (custom.length) {
return custom.concat(DEFAULT_MIRRORS.filter((m) => !custom.includes(m)));
}
return DEFAULT_MIRRORS.slice();
}
const MAX_CUSTOM_MIRRORS = 20;
// 镜像地址来自用户输入,会被直接拼进请求 URL,因此必须按 origin 归一:
// 带路径、查询串或凭据的地址会让后续 apiUrl() 拼出错误甚至泄露凭据的 URL。
function normalizeMirror(value) {
const raw = String(value || '').trim();
if (!raw) return '';
let url;
try { url = new URL(raw); } catch (e) { throw new Error(`镜像地址无效:${raw}`); }
if (url.protocol !== 'https:') throw new Error(`镜像必须使用 HTTPS${raw}`);
if (url.username || url.password) throw new Error(`镜像地址不能包含账号信息:${raw}`);
return url.origin;
}
function normalizeMirrors(list) {
if (!Array.isArray(list)) throw new Error('镜像列表格式无效');
if (list.length > MAX_CUSTOM_MIRRORS) {
throw new Error(`最多只能保存 ${MAX_CUSTOM_MIRRORS} 个镜像`);
}
const out = [];
for (const item of list) {
const origin = normalizeMirror(item);
if (origin && !out.includes(origin)) out.push(origin);
}
return out;
}
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 || '');
}
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) => {
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: 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
})
});
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;
}
async function ensureLogin() {
return auth.getSession() || doLogin();
}
// 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 };
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)) {
const e = new Error(msg);
e.code = 'AUTH_STALE';
throw e;
}
throw new Error(msg);
}
if (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;
// 只有确认是会话失效才重新登录。纯网络不可达时重登也会失败,
// 反而会清掉有效会话并把原始错误换成登录错误。
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 所有镜像均不可用');
}
auth.clearSession();
throw authRequired('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 || '')
};
}
// hash 可缺省:接口偶尔不返回 hash,此时仍可用 /eapi/book/<id> 取详情,
// 不能因为拼出 "123/" 就把整条结果判成无效 ID。
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] || '' };
}
function bookPath(id, hash, suffix = '') {
return `/eapi/book/${id}${hash ? `/${hash}` : ''}${suffix}`;
}
module.exports = {
id: 'zlib',
name: 'Z-Library',
supportsSearch: true,
downloadOnDemand: 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(bookPath(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(bookPath(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) {
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) {
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();
},
// 默认域名变动频繁且会整批失效,用户需要能自己补上可用地址而不必等新版本
getMirrorConfig() {
return { custom: auth.getCustomMirrors(), defaults: DEFAULT_MIRRORS.slice() };
},
setMirrors(list) {
const next = normalizeMirrors(list);
const previous = auth.getCustomMirrors();
auth.setCustomMirrors(next);
// 换镜像后旧会话绑定的域名可能已不在列表里,留着会一直优先命中失效地址
const session = auth.getSession();
if (session && session.mirror && !getMirrors().includes(session.mirror)) {
auth.clearSession();
}
for (const mirror of previous) {
if (!next.includes(mirror)) clearCookies(mirror);
}
return { custom: next, defaults: DEFAULT_MIRRORS.slice() };
},
setLoginTransport
};