构建与发布 / 单测与集成测试 (push) Has been cancelled
构建与发布 / 发布 GitHub Release (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
用户配置自定义 Z-Library 镜像后,旧会话记录的内置镜像仍被排在前面, 与界面“自定义优先”的约定不一致。调整请求顺序,让自定义镜像严格优先; 全部镜像失败时保留首个自定义镜像的错误,避免被后续兜底错误覆盖。 版本更新到 2.1.6,并补充优先级与错误归因回归测试。
444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
// 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 走 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 };
|
||
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 custom = auth.getCustomMirrors();
|
||
const mirrors = getMirrors();
|
||
// 用户显式配置镜像后必须严格优先于旧会话镜像,否则界面写着“自定义优先”,
|
||
// 实际却仍先等待上次成功但现在已失效的默认域名。
|
||
const ordered = custom.length
|
||
? mirrors
|
||
: session.mirror
|
||
? [session.mirror, ...mirrors.filter((m) => m !== session.mirror)]
|
||
: mirrors;
|
||
|
||
const errors = [];
|
||
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 };
|
||
errors.push(e);
|
||
}
|
||
}
|
||
if (errors.length <= 1) {
|
||
return { ok: false, stale: false, error: errors[0] };
|
||
}
|
||
const first = errors[0];
|
||
const label = custom.length ? '自定义镜像失败' : '首选镜像失败';
|
||
const summary = new Error(
|
||
`${label}:${(first && first.message) || String(first)};其余 ${errors.length - 1} 个镜像也未成功`
|
||
);
|
||
return { ok: false, stale: false, error: summary };
|
||
}
|
||
|
||
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
|
||
};
|