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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
+135
-33
@@ -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 走 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 };
|
||||
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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user