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:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+135 -33
View File
@@ -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 走 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 };
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
};