// 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/ 取详情, // 不能因为拼出 "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 };