feat: PeopleLib 开放文献客户端,集成 Z-Library 与 LibGen 等多源检索

Electron 桌面客户端,聚合多个开放获取文献源的搜索、详情与下载。

新增数据源:
- Z-Library:邮箱登录(凭据本地存储),会话失效自动重登
- LibGen:适配新版 libgen.ac 前端(旧版 search.php 镜像已全部下线)
- Memory of the World、Sci-Hub、Anna's Archive

基础设施:
- mirror.js:镜像故障转移,支持串行优先与并发竞速两种策略,
  失效镜像 5 分钟冷却后自动重试,避免站点恢复后被永久跳过
- http.js:统一 15 秒请求超时,防止单个卡死镜像拖垮整次搜索
- settings.js:全局代理配置持久化,经 Electron net.fetch 生效于所有请求

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-25 14:51:10 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 1a1288ce18
33 changed files with 4640 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
const { app } = require('electron');
const fs = require('fs');
const path = require('path');
const { fetchWithProxy } = require('../sources/http');
const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const FILE = () => path.join(app.getPath('userData'), 'library.json');
const COVER_DIR = () => path.join(app.getPath('userData'), 'covers');
let items = null;
let changeListener = null;
function isRemoteCover(c) { return typeof c === 'string' && /^https?:\/\//i.test(c); }
function coverExt(url) {
const m = String(url).split('?')[0].match(/\.(png|jpe?g|webp|gif|bmp)$/i);
return m ? m[0].toLowerCase() : '.img';
}
async function cacheCover(id, url) {
try {
const res = await fetchWithProxy(url, { headers: { 'User-Agent': DL_UA, 'Referer': new URL(url).origin } });
if (!res.ok) return '';
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return '';
fs.mkdirSync(COVER_DIR(), { recursive: true });
const dest = path.join(COVER_DIR(), id + coverExt(url));
fs.writeFileSync(dest, buf);
return dest;
} catch (e) { return ''; }
}
async function ensureCoverCached(id) {
const it = get(id);
if (!it || !isRemoteCover(it.cover)) return;
const local = await cacheCover(id, it.cover);
if (local && get(id)) { update(id, { cover: local }); notifyChange(); }
}
function removeCoverFile(id) {
try {
const dir = COVER_DIR();
if (!fs.existsSync(dir)) return;
for (const f of fs.readdirSync(dir)) {
if (f === id || f.startsWith(id + '.')) { try { fs.unlinkSync(path.join(dir, f)); } catch (e) { /* ignore */ } }
}
} catch (e) { /* ignore */ }
}
function setChangeListener(fn) { changeListener = typeof fn === 'function' ? fn : null; }
function notifyChange() { if (changeListener) { try { changeListener(); } catch (e) { /* ignore */ } } }
function load() {
if (items) return items;
try {
items = JSON.parse(fs.readFileSync(FILE(), 'utf-8'));
if (!Array.isArray(items)) items = [];
} catch (e) { items = []; }
return items;
}
function persist() {
fs.mkdirSync(path.dirname(FILE()), { recursive: true });
fs.writeFileSync(FILE(), JSON.stringify(items, null, 2), 'utf-8');
}
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
function list() {
return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
}
function get(id) { return load().find((x) => x.id === id) || null; }
function findBySource(sourceId, sourcePostId) {
return load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId)) || null;
}
function add(item) {
load();
const it = {
id: genId(),
title: item.title || '未命名',
authors: item.authors || [],
cover: item.cover || '',
date: item.date || '',
brief: item.brief || '',
url: item.url || '',
sourceId: item.sourceId || null,
sourcePostId: item.sourcePostId != null ? String(item.sourcePostId) : null,
files: item.files || [], // [{ path, name, format }]
addedAt: Date.now()
};
items.push(it);
persist();
if (isRemoteCover(it.cover)) ensureCoverCached(it.id);
return it;
}
function update(id, patch) {
load();
const it = items.find((x) => x.id === id);
if (!it) throw new Error('条目不存在');
Object.assign(it, patch);
persist();
return it;
}
function attachFile(id, filePath) {
const it = get(id);
if (!it) return;
const files = (it.files || []).filter((f) => f.path !== filePath);
files.push({ path: filePath, name: path.basename(filePath), format: (path.extname(filePath) || '').slice(1).toUpperCase() });
update(id, { files });
notifyChange();
}
function remove(id, deleteFiles) {
load();
const it = get(id);
if (deleteFiles && it && it.files) {
for (const f of it.files) { try { if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch (e) { /* ignore */ } }
}
items = items.filter((x) => x.id !== id);
persist();
removeCoverFile(id);
return { removed: true };
}
module.exports = { list, get, findBySource, add, update, remove, attachFile, setChangeListener };
+45
View File
@@ -0,0 +1,45 @@
// 应用设置持久化(代理等)
const fs = require('fs');
const path = require('path');
let filePath = null;
let cache = null;
function init(userDataDir) {
filePath = path.join(userDataDir, 'settings.json');
}
function getFilePath() {
if (filePath) return filePath;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'settings.json');
}
function load() {
if (cache) return cache;
try {
cache = JSON.parse(fs.readFileSync(getFilePath(), 'utf8')) || {};
} catch (e) { cache = {}; }
return cache;
}
function save() {
try {
fs.mkdirSync(path.dirname(getFilePath()), { recursive: true });
fs.writeFileSync(getFilePath(), JSON.stringify(cache, null, 2), 'utf8');
} catch (e) { /* ignore */ }
}
function get(key, def) {
const c = load();
return c[key] !== undefined ? c[key] : def;
}
function set(key, value) {
load();
cache[key] = value;
save();
}
module.exports = { init, get, set };
+151
View File
@@ -0,0 +1,151 @@
// Anna's Archive 数据源:实时在线搜索
// 通过 annas-archive 镜像站的 HTML 搜索页抓取结果
const { fetchText, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const MIRRORS = [
'https://annas-archive.org',
'https://annas-archive.se',
'https://annas-archive.gs',
'https://annas-archive.li'
];
const PAGE_SIZE = 50;
function absUrl(base, href) {
if (!href) return '';
if (/^https?:\/\//.test(href)) return href;
if (href.startsWith('//')) return 'https:' + href;
if (href.startsWith('/')) return base + href;
return base + '/' + href;
}
function parseSearchHtml(html, base) {
const items = [];
// Anna's Archive 搜索结果在 <div class="record"> 或 <tr> 中
// 尝试匹配包含 md5 链接的卡片
const md5Re = /href="\/md5\/([a-f0-9]{32})"[^>]*>([\s\S]*?)<\/a>/g;
let m;
while ((m = md5Re.exec(html))) {
const md5 = m[1];
const inner = m[2];
const title = decodeEntities(inner.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
if (title) {
items.push({
postId: md5,
title,
cover: '',
date: '',
url: `${base}/md5/${md5}`,
subtitle: ''
});
}
}
// 如果没找到 md5 链接,尝试从 record 区块提取
if (!items.length) {
const recordRe = /<div[^>]+class="[^"]*record[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g;
let r;
while ((r = recordRe.exec(html))) {
const block = r[1];
const linkM = block.match(/href="([^"]*md5[^"]*)"/);
const titleM = block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/) || block.match(/<div[^>]+class="[^"]*title[^"]*"[^>]*>([\s\S]*?)<\/div>/);
const title = titleM ? decodeEntities(titleM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim() : '';
if (title && linkM) {
const md5M = linkM[1].match(/md5\/([a-f0-9]{32})/i);
const md5 = md5M ? md5M[1] : '';
if (md5) {
items.push({
postId: md5,
title,
cover: '',
date: '',
url: absUrl(base, linkM[1]),
subtitle: ''
});
}
}
}
}
return items;
}
function parseMaxPage(html) {
// Anna's Archive 分页信息在 "Page 1 of X" 或类似结构中
const m = html.match(/of\s+(\d+)\s+results/i) || html.match(/(\d+)\s+results/i);
if (m) return Math.max(1, Math.ceil(parseInt(m[1], 10) / PAGE_SIZE));
const pages = [];
const re = /page=(\d+)/g;
let mm;
while ((mm = re.exec(html))) pages.push(parseInt(mm[1], 10));
if (pages.length) return Math.max(...pages);
return 1;
}
async function searchMirror(base, keyword, page) {
const q = encodeURIComponent(keyword);
const url = `${base}/search?q=${q}&page=${page}`;
const html = await fetchText(url);
return { html, base };
}
module.exports = {
id: 'annas',
name: "Anna's Archive",
supportsSearch: true,
async list(page) {
return { items: [], maxPage: 1, page: 1 };
},
async search(keyword, page) {
page = clampPage(page);
const r = await tryMirrors('annas', MIRRORS, (m) => searchMirror(m, keyword, page));
const items = parseSearchHtml(r.html, r.base);
const maxPage = parseMaxPage(r.html);
return { items, maxPage, page };
},
async detail(postId) {
const r = await tryMirrors('annas', MIRRORS, async (m) => {
const url = `${m}/md5/${postId}`;
const html = await fetchText(url);
return { html, base: m, url };
});
const html = r.html;
// 从详情页解析元数据
let title = '', authors = [], year = '', cover = '', brief = '';
const titleM = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/) || html.match(/<title>([^<]+)<\/title>/i);
if (titleM) title = decodeEntities(titleM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
const authorM = html.match(/author[^>]*>([^<]+)</gi);
if (authorM) authors = authorM.map((a) => decodeEntities(a.replace(/<[^>]*>/g, '').trim())).filter(Boolean);
const yearM = html.match(/(?:year|published)[^\d]*(\d{4})/i);
if (yearM) year = yearM[1];
const descM = html.match(/description[^>]*>([\s\S]{10,800}?)<\/(?:div|td|p)>/i);
if (descM) brief = decodeEntities(descM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
const coverM = html.match(/(?:cover|image)[^>]*src="([^"]+)"/i);
if (coverM) cover = absUrl(r.base, coverM[1]);
const tags = [];
const extM = html.match(/extension[^>]*>([^<]+)</i);
if (extM) tags.push(`格式:${decodeEntities(extM[1]).trim()}`);
const sizeM = html.match(/size[^>]*>([^<]+)</i);
if (sizeM) tags.push(`大小:${decodeEntities(sizeM[1]).trim()}`);
return {
postId,
title: title || `MD5 ${postId.slice(0, 8)}`,
cover,
authors,
date: year,
tags,
brief,
url: r.url,
links: [{ name: "Anna's Archive 页", url: r.url }]
};
},
async download(postId) {
// Anna's Archive 下载需要到详情页点击,这里返回各镜像链接
const links = MIRRORS.map((m) => ({ name: `下载 (${m.replace('https://', '')})`, url: `${m}/md5/${postId}` }));
return { files: [], links };
}
};
+105
View File
@@ -0,0 +1,105 @@
const { fetchText, decodeEntities, clampPage } = require('./http');
const BASE = 'https://export.arxiv.org/api/query';
const PAGE_SIZE = 20;
function parseFeed(xml) {
const totalM = xml.match(/<opensearch:totalResults[^>]*>(\d+)<\/opensearch:totalResults>/);
const total = totalM ? parseInt(totalM[1], 10) : 0;
const entries = [];
const re = /<entry>([\s\S]*?)<\/entry>/g;
let m;
while ((m = re.exec(xml))) {
const e = m[1];
const id = decodeEntities((e.match(/<id>([\s\S]*?)<\/id>/) || [])[1] || '').trim();
const title = decodeEntities((e.match(/<title>([\s\S]*?)<\/title>/) || [])[1] || '').replace(/\s+/g, ' ').trim();
const summary = decodeEntities((e.match(/<summary>([\s\S]*?)<\/summary>/) || [])[1] || '').replace(/\s+/g, ' ').trim();
const published = ((e.match(/<published>([\s\S]*?)<\/published>/) || [])[1] || '').slice(0, 10);
const authors = [];
const are = /<author>\s*<name>([\s\S]*?)<\/name>\s*<\/author>/g;
let a;
while ((a = are.exec(e))) authors.push(decodeEntities(a[1]).trim());
let pdf = '';
const lre = /<link[^>]*>/g;
let l;
while ((l = lre.exec(e))) {
if (/title="pdf"/.test(l[0])) {
const hm = l[0].match(/href="([^"]+)"/);
if (hm) pdf = hm[1];
}
}
const catM = e.match(/<category[^>]*term="([^"]+)"/);
entries.push({
arxivId: id.replace(/^https?:\/\/arxiv\.org\/abs\//, ''),
title, summary, published, authors, pdf, category: catM ? catM[1] : '', url: id
});
}
return { total, entries };
}
function toItem(e) {
return {
postId: e.arxivId,
title: e.title,
cover: '',
date: e.published,
url: e.url,
subtitle: e.authors.slice(0, 3).join(', ') + (e.authors.length > 3 ? ' 等' : '')
};
}
async function query(params) {
const xml = await fetchText(`${BASE}?${params}`);
return parseFeed(xml);
}
module.exports = {
id: 'arxiv',
name: 'arXiv 论文',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const start = (page - 1) * PAGE_SIZE;
const { total, entries } = await query(
`search_query=cat:cs.*&start=${start}&max_results=${PAGE_SIZE}&sortBy=submittedDate&sortOrder=descending`
);
return { items: entries.map(toItem), maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page };
},
async search(keyword, page) {
page = clampPage(page);
const start = (page - 1) * PAGE_SIZE;
const q = `all:"${String(keyword).replace(/"/g, '')}"`;
const { total, entries } = await query(
`search_query=${encodeURIComponent(q)}&start=${start}&max_results=${PAGE_SIZE}&sortBy=relevance&sortOrder=descending`
);
return { items: entries.map(toItem), maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page };
},
async detail(postId) {
const { entries } = await query(`id_list=${encodeURIComponent(postId)}&max_results=1`);
const e = entries[0];
if (!e) throw new Error('未找到该论文');
return {
postId: e.arxivId,
title: e.title,
cover: '',
authors: e.authors,
date: e.published,
tags: e.category ? [`分类:${e.category}`] : [],
brief: e.summary,
url: e.url,
links: [{ name: '摘要页', url: e.url }]
};
},
async download(postId) {
const { entries } = await query(`id_list=${encodeURIComponent(postId)}&max_results=1`);
const e = entries[0];
if (!e) throw new Error('未找到该论文');
const files = [];
if (e.pdf) files.push({ name: `${e.arxivId.replace(/\//g, '_')}.pdf`, link: e.pdf, format: 'PDF' });
return { files, links: [{ name: '摘要页', url: e.url }] };
}
};
+84
View File
@@ -0,0 +1,84 @@
const { fetchJson, clampPage } = require('./http');
const PAGE_SIZE = 30;
function toItem(r, server) {
const authors = String(r.authors || '').split(';').map((s) => s.trim()).filter(Boolean).slice(0, 3).join(', ');
return {
postId: r.doi,
title: r.title || '(无标题)',
cover: '',
date: r.date || '',
url: `https://www.${server}.org/content/${r.doi}v${r.version || 1}`,
subtitle: [authors, r.category].filter(Boolean).join(' · '),
_server: server
};
}
function dateStr(d) { return d.toISOString().slice(0, 10); }
async function fetchWindow(server, from, to, cursor, tries = 3) {
let last;
for (let i = 0; i < tries; i++) {
try {
const j = await fetchJson(`https://api.biorxiv.org/details/${server}/${from}/${to}/${cursor}`);
const total = parseInt(j.messages && j.messages[0] && j.messages[0].total, 10) || 0;
return { total, collection: j.collection || [] };
} catch (e) {
last = e;
if (!/504|502|503/.test(e.message)) throw e;
await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
}
}
throw last;
}
module.exports = {
id: 'biorxiv',
name: 'bioRxiv 预印本',
supportsSearch: false,
async list(page) {
page = clampPage(page);
const server = 'biorxiv';
const end = new Date();
const daysPerPage = 3;
const startIdx = (page - 1) * daysPerPage;
const from = new Date(end.getTime() - (startIdx + daysPerPage) * 864e5);
const to = new Date(end.getTime() - startIdx * 864e5);
const { collection } = await fetchWindow(server, dateStr(from), dateStr(to), 0);
const items = collection.slice(0, PAGE_SIZE).map((r) => toItem(r, server));
return { items, maxPage: 1000, page };
},
async search() {
throw new Error('bioRxiv 暂不支持搜索,请翻页浏览');
},
async detail(postId) {
const j = await fetchJson(`https://api.biorxiv.org/details/biorxiv/${postId}`);
const c = (j.collection || [])[0];
if (!c) throw new Error('未找到该预印本');
return {
postId,
title: c.title,
cover: '',
authors: String(c.authors || '').split(';').map((s) => s.trim()).filter(Boolean),
date: c.date || '',
tags: [c.category ? `分类:${c.category}` : '', c.license ? `许可:${c.license}` : ''].filter(Boolean),
brief: c.abstract || '',
url: `https://www.biorxiv.org/content/${c.doi}v${c.version || 1}`,
links: [{ name: 'bioRxiv 页', url: `https://www.biorxiv.org/content/${c.doi}v${c.version || 1}` }]
};
},
async download(postId) {
const j = await fetchJson(`https://api.biorxiv.org/details/biorxiv/${postId}`);
const c = (j.collection || [])[0];
const v = (c && c.version) || 1;
return {
files: [{ name: `${String(postId).replace(/\//g, '_')}.pdf`, link: `https://www.biorxiv.org/content/${postId}v${v}.full.pdf`, format: 'PDF' }],
links: [{ name: 'bioRxiv 页', url: `https://www.biorxiv.org/content/${postId}v${v}` }]
};
}
};
+84
View File
@@ -0,0 +1,84 @@
const { fetchJson, clampPage, stripTags } = require('./http');
const BASE = 'https://doaj.org/api/v2/search/articles';
const PAGE_SIZE = 20;
function idOf(bibjson) {
const ids = bibjson.identifier || [];
const doi = ids.find((x) => x.type === 'doi');
return doi ? doi.id : (ids[0] && ids[0].id) || '';
}
function fulltextOf(bibjson) {
const links = bibjson.link || [];
const pdf = links.find((l) => /pdf/i.test(l.content_type || ''));
const any = links.find((l) => l.url);
return (pdf && pdf.url) || (any && any.url) || '';
}
function toItem(r) {
const b = r.bibjson || {};
const authors = (b.author || []).map((a) => a.name).slice(0, 3).join(', ');
const journal = (b.journal && b.journal.title) || '';
return {
postId: encodeURIComponent(r.id || idOf(b)),
title: b.title || '(无标题)',
cover: '',
date: b.year || '',
url: fulltextOf(b),
subtitle: [authors, journal].filter(Boolean).join(' · ')
};
}
async function run(path) {
const j = await fetchJson(path);
const total = j.total || 0;
return { j, total, maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)) };
}
module.exports = {
id: 'doaj',
name: 'DOAJ 开放期刊',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const { j, maxPage } = await run(`${BASE}/*?page=${page}&pageSize=${PAGE_SIZE}`);
return { items: (j.results || []).map(toItem), maxPage, page };
},
async search(keyword, page) {
page = clampPage(page);
const { j, maxPage } = await run(`${BASE}/${encodeURIComponent(keyword)}?page=${page}&pageSize=${PAGE_SIZE}`);
return { items: (j.results || []).map(toItem), maxPage, page };
},
async detail(postId) {
const j = await fetchJson(`https://doaj.org/api/v2/articles/${encodeURIComponent(postId)}`);
const b = j.bibjson || {};
return {
postId,
title: b.title || '(无标题)',
cover: '',
authors: (b.author || []).map((a) => a.name),
date: b.year || '',
tags: [
b.journal && b.journal.title ? `期刊:${b.journal.title}` : '',
...(b.keywords || []).slice(0, 5).map((k) => `关键词:${k}`)
].filter(Boolean),
brief: stripTags(b.abstract || ''),
url: fulltextOf(b),
links: [{ name: '全文页', url: fulltextOf(b) }]
};
},
async download(postId) {
const j = await fetchJson(`https://doaj.org/api/v2/articles/${encodeURIComponent(postId)}`);
const b = j.bibjson || {};
const files = [];
for (const l of b.link || []) {
if (l.url) files.push({ name: /pdf/i.test(l.content_type || '') ? 'PDF 全文' : (l.content_type || '全文'), link: l.url, format: l.content_type || '' });
}
return { files, links: [{ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` }] };
}
};
+90
View File
@@ -0,0 +1,90 @@
const { fetchJson, clampPage } = require('./http');
const BASE = 'https://gutendex.com/books';
function coverOf(formats) {
if (!formats) return '';
return formats['image/jpeg'] || formats['image/png'] || '';
}
const EXT = { EPUB: 'epub', Kindle: 'mobi', TXT: 'txt', HTML: 'html', PDF: 'pdf' };
function bookFiles(formats) {
const out = [];
if (!formats) return out;
const map = [
['application/epub+zip', 'EPUB'],
['application/x-mobipocket-ebook', 'Kindle'],
['text/plain; charset=utf-8', 'TXT'],
['text/plain', 'TXT'],
['application/pdf', 'PDF']
];
const seen = new Set();
for (const [key, label] of map) {
const url = formats[key];
if (url && !seen.has(label)) {
seen.add(label);
out.push({ name: label, link: url, format: label });
}
}
return out;
}
function toItem(b) {
const authors = (b.authors || []).map((a) => a.name).join(', ');
return {
postId: String(b.id),
title: b.title,
cover: coverOf(b.formats),
date: '',
url: `https://www.gutenberg.org/ebooks/${b.id}`,
subtitle: authors
};
}
module.exports = {
id: 'gutenberg',
name: 'Gutenberg 公版书',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const j = await fetchJson(`${BASE}?page=${page}`);
const total = j.count || 0;
const maxPage = Math.max(1, Math.ceil(total / 32));
return { items: (j.results || []).map(toItem), maxPage, page };
},
async search(keyword, page) {
page = clampPage(page);
const j = await fetchJson(`${BASE}?search=${encodeURIComponent(keyword)}&page=${page}`);
const total = j.count || 0;
const maxPage = Math.max(1, Math.ceil(total / 32));
return { items: (j.results || []).map(toItem), maxPage, page };
},
async detail(postId) {
const b = await fetchJson(`${BASE}/${encodeURIComponent(postId)}`);
return {
postId: String(b.id),
title: b.title,
cover: coverOf(b.formats),
authors: (b.authors || []).map((a) => a.name),
date: '',
tags: (b.subjects || []).slice(0, 6).map((s) => `主题:${s}`),
brief: (b.summaries || [])[0] || '',
url: `https://www.gutenberg.org/ebooks/${b.id}`,
links: [{ name: 'Gutenberg 页', url: `https://www.gutenberg.org/ebooks/${b.id}` }]
};
},
async download(postId) {
const b = await fetchJson(`${BASE}/${encodeURIComponent(postId)}`);
const files = bookFiles(b.formats).map((f) => ({
name: `${String(b.title).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.${EXT[f.format] || 'bin'}`,
link: f.link,
format: f.format
}));
return { files, links: [{ name: 'Gutenberg 页', url: `https://www.gutenberg.org/ebooks/${b.id}` }] };
}
};
+148
View File
@@ -0,0 +1,148 @@
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
// 持久化存储在 userData/settings.json 的 "proxy" 字段。
let proxyUrl = '';
let dispatcher = null;
function setProxy(url) {
proxyUrl = String(url || '').trim();
dispatcher = null;
if (!proxyUrl) return;
// Electron 下由 session.setProxy 统一接管代理,不需要 undici dispatcher。
// 仅在纯 Node 环境(脚本/测试)才构建 ProxyAgent。
if (process.versions.electron) return;
try {
const { ProxyAgent } = require('undici');
dispatcher = new ProxyAgent({
uri: proxyUrl,
requestTls: { rejectUnauthorized: false }
});
} catch (e) {
console.warn('代理初始化失败:', e.message);
}
}
function getProxy() { return proxyUrl; }
function fetchWithProxy(url, options = {}) {
// 在 Electron 主进程中优先使用 net.fetch(走 Chromium 网络栈,由 session.setProxy 控制代理)
if (process.versions.electron) {
try {
const { net } = require('electron');
return net.fetch(url, options);
} catch (e) { /* fallback */ }
}
if (dispatcher) return fetch(url, { ...options, dispatcher });
return fetch(url, options);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
const cookieJar = new Map();
function domainOf(url) {
try { return new URL(url).hostname; } catch (e) { return ''; }
}
function getCookies(url) {
const d = domainOf(url);
const m = cookieJar.get(d);
if (!m) return '';
return Array.from(m.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
}
function setCookies(url, setCookieHeaders) {
if (!setCookieHeaders || !setCookieHeaders.length) return;
const d = domainOf(url);
if (!d) return;
let m = cookieJar.get(d);
if (!m) { m = new Map(); cookieJar.set(d, m); }
for (const sc of setCookieHeaders) {
const pair = String(sc).split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) m.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
function clearCookies(urlPrefix) {
if (!urlPrefix) { cookieJar.clear(); return; }
for (const k of cookieJar.keys()) {
if (k.includes(urlPrefix)) cookieJar.delete(k);
}
}
// 默认请求超时(毫秒)。没有超时时,单个卡死的镜像会拖死整次搜索。
const DEFAULT_TIMEOUT = 15000;
async function fetchRaw(url, options = {}) {
const cookie = getCookies(url);
const headers = {
'User-Agent': UA,
'Accept': 'application/json, application/atom+xml, application/xml, text/xml, text/html, */*',
...(options.headers || {})
};
if (cookie && !headers.Cookie) headers.Cookie = cookie;
const { timeout, ...rest } = options;
const ms = timeout === undefined ? DEFAULT_TIMEOUT : timeout;
let signal = rest.signal;
let timer = null;
if (!signal && ms > 0) {
const ac = new AbortController();
signal = ac.signal;
timer = setTimeout(() => ac.abort(), ms);
}
try {
const res = await fetchWithProxy(url, { redirect: 'follow', ...rest, headers, signal });
const setCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
setCookies(url, setCookie);
return res;
} catch (e) {
if (e && (e.name === 'AbortError' || /abort/i.test(e.message || ''))) {
throw new Error(`请求超时: ${url}`);
}
throw e;
} finally {
if (timer) clearTimeout(timer);
}
}
async function fetchText(url, options = {}) {
const res = await fetchRaw(url, options);
if (!res.ok) throw new Error(`请求失败: ${res.status} ${url}`);
return res.text();
}
async function fetchJson(url, options = {}) {
const text = await fetchText(url, options);
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`JSON 解析失败: ${url}`);
}
}
function decodeEntities(s) {
if (s == null) return '';
return String(s)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
.replace(/&amp;/g, '&');
}
function stripTags(s) {
return decodeEntities(String(s == null ? '' : s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
}
function clampPage(n, min) {
n = parseInt(n, 10);
if (!Number.isFinite(n) || n < (min || 1)) return min || 1;
return n;
}
module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy };
+27
View File
@@ -0,0 +1,27 @@
const arxiv = require('./arxiv');
const gutenberg = require('./gutenberg');
const openlibrary = require('./openlibrary');
const doaj = require('./doaj');
const pmc = require('./pmc');
const biorxiv = require('./biorxiv');
const standardebooks = require('./standardebooks');
const semanticscholar = require('./semanticscholar');
const libgen = require('./libgen');
const zlib = require('./zlib');
const scihub = require('./scihub');
const motw = require('./motw');
const sources = [arxiv, gutenberg, openlibrary, doaj, pmc, biorxiv, standardebooks, semanticscholar, libgen, zlib, scihub, motw];
const byId = new Map(sources.map((s) => [s.id, s]));
function listSources() {
return sources.map((s) => ({ id: s.id, name: s.name, supportsSearch: s.supportsSearch !== false }));
}
function getSource(id) {
const s = byId.get(id);
if (!s) throw new Error(`未知数据源: ${id}`);
return s;
}
module.exports = { listSources, getSource };
+345
View File
@@ -0,0 +1,345 @@
// LibGen 数据源
//
// 现状(实测):
// - 经典镜像 libgen.is/.rs/.st 域名已失效;.li/.vg/.bz/.la/.gl 全部返回 503
// - libgen.ac(别名 libgen.mx)在线可用,是重写过的新版前端(Z-Library 引擎),
// 搜索路由为 /s/<关键词>?page=N,结果为 schema.org 标注的 resItemBox 卡片,
// 条目链接形如 /book/<id>;下载需要该站自身账号,因此仅提供跳转链接。
//
// 策略:优先用新版站点搜索(当前唯一可用);经典镜像作为兜底,
// 一旦恢复即可自动参与(raceMirrors 有 5 分钟冷却重试机制)。
const { fetchText, decodeEntities, clampPage } = require('./http');
const { raceMirrors } = require('./mirror');
// 新版站点(当前可用)
const WEB_MIRRORS = [
'https://libgen.ac',
'https://libgen.mx'
];
// 经典镜像(当前 503,恢复后自动启用)
const LEGACY_MIRRORS = [
'https://libgen.li',
'https://libgen.vg',
'https://libgen.bz',
'https://libgen.la',
'https://libgen.gl'
];
// 已知 md5 时可用的下载入口
const DOWNLOAD_MIRRORS = [
'https://library.lol',
'https://libgen.li'
];
// 未登录时该站每页只返回 10 条
const PER_PAGE = 10;
const TIMEOUT = 12000;
function absUrl(base, href) {
if (!href) return '';
if (/^https?:\/\//.test(href)) return href;
if (href.startsWith('//')) return 'https:' + href;
if (href.startsWith('/')) return base + href;
return base + '/' + href;
}
function stripTags(s) {
return decodeEntities(String(s || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
}
// 取页面内嵌的 schema.org JSON-LDBook 类型)
function parseJsonLd(html) {
const re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html))) {
try {
const j = JSON.parse(m[1].trim());
const node = Array.isArray(j) ? j.find((x) => x && x['@type'] === 'Book') : j;
if (node && node['@type'] === 'Book') return node;
} catch (e) {
// 忽略格式不合法的块
}
}
return {};
}
// 从 resItemBox 卡片中取某个 bookProperty 的值
function propOf(block, label) {
const re = new RegExp(
`<div class="property_label"[^>]*>\\s*${label}\\s*:?\\s*</div>\\s*<div class="property_value[^"]*"[^>]*>([\\s\\S]*?)</div>`,
'i'
);
const m = block.match(re);
return m ? stripTags(m[1]) : '';
}
// 解析新版站点搜索结果
function parseWebResults(html, base) {
const items = [];
const seen = new Set();
// 每个结果卡片以 resItemBox 开始,data-book_id 是稳定标识
const blocks = html.split(/<div class="resItemBox/).slice(1);
for (const raw of blocks) {
const block = '<div class="resItemBox' + raw;
const idM = block.match(/data-book_id="(\d+)"/);
if (!idM) continue;
const id = idM[1];
// 同一卡片内 data-book_id 会出现多次,按 id 去重
if (seen.has(id)) continue;
seen.add(id);
// 标题:<h3 itemprop="name"><a ...>标题</a>
let title = '';
const tM = block.match(/<h3[^>]*itemprop="name"[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/i);
if (tM) title = stripTags(tM[1]);
if (!title) {
const alt = block.match(/<img[^>]*alt="([^"]+)"/i);
if (alt) title = decodeEntities(alt[1]).trim();
}
if (!title) continue;
// 作者:.authors 区块内的 itemprop="author"
const authors = [];
const authBlock = block.match(/<div class="authors">([\s\S]*?)<\/div>/i);
if (authBlock) {
const aRe = /<a[^>]*itemprop="author"[^>]*>([\s\S]*?)<\/a>/gi;
let a;
while ((a = aRe.exec(authBlock[1]))) {
const name = stripTags(a[1]);
if (name && !authors.includes(name)) authors.push(name);
}
}
// 封面:懒加载在 data-src
let cover = '';
const cM = block.match(/<img[^>]*class="[^"]*cover[^"]*"[^>]*data-src="([^"]+)"/i)
|| block.match(/<img[^>]*data-src="([^"]+)"/i);
if (cM) cover = absUrl(base, cM[1]);
const year = propOf(block, 'Year');
const ext = propOf(block, 'File');
const language = propOf(block, 'Language');
const publisher = (block.match(/itemprop="publisher"[\s\S]{0,200}?<span itemprop="name">([\s\S]*?)<\/span>/i) || [])[1];
items.push({
postId: `web:${id}`,
title,
cover,
date: year,
url: `${base}/book/${id}`,
subtitle: authors.join(', '),
ext,
language,
publisher: publisher ? stripTags(publisher) : ''
});
}
return items;
}
// 结果总数:<span class="totalCounter">(123)</span>
// 注意未登录时常显示 "(5+)" 这类模糊值,不可用于精确推算总页数。
function parseWebTotal(html) {
const m = html.match(/class="totalCounter"[^>]*>\s*\(?\s*([\d,]+)\s*\+?\s*\)?/i);
if (!m) return 0;
return parseInt(m[1].replace(/,/g, ''), 10) || 0;
}
// 从分页控件里取最大页码;没有分页控件说明只有一页。
function parseWebMaxPage(html, page, count) {
let max = 0;
const re = /[?&]page=(\d+)/g;
let m;
while ((m = re.exec(html))) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
// 本页没有结果说明已经翻过头,回退到上一页
if (!count) return Math.max(1, page - 1);
if (max > page) return max;
return Math.max(page, max);
}
async function webSearch(keyword, page) {
const kw = encodeURIComponent(keyword);
return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => {
const url = `${base}/s/${kw}${page > 1 ? `?page=${page}` : ''}`;
const html = await fetchText(url, { timeout: TIMEOUT });
const items = parseWebResults(html, base);
if (!items.length && !/searchResultBox|resItemBox|Nothing found/i.test(html)) {
throw new Error('页面结构无法识别');
}
return { items, html, base };
});
}
function buildDownloadLinks(md5) {
if (!md5) return [];
return DOWNLOAD_MIRRORS.map((dm) => ({
name: `下载 (${dm.replace(/^https?:\/\//, '')})`,
url: `${dm}/${dm.includes('library.lol') ? 'book/index.php?md5=' : 'ads.php?md5='}${md5}`
}));
}
async function fetchBookPage(id) {
return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => {
const html = await fetchText(`${base}/book/${id}`, { timeout: TIMEOUT });
return { html, base };
});
}
module.exports = {
id: 'libgen',
name: 'Library Genesis',
supportsSearch: true,
async list(page) {
page = clampPage(page);
// 新版站点有 /popular 榜单
try {
const r = await raceMirrors('libgen-web', WEB_MIRRORS, async (base) => {
const html = await fetchText(`${base}/popular`, { timeout: TIMEOUT });
return { items: parseWebResults(html, base), base };
});
return { items: r.items, maxPage: 1, page: 1 };
} catch (e) {
return { items: [], maxPage: 1, page: 1 };
}
},
async search(keyword, page) {
page = clampPage(page);
const q = String(keyword || '').trim();
if (!q) return { items: [], maxPage: 1, page };
let r;
try {
r = await webSearch(q, page);
} catch (e) {
throw new Error(`LibGen 暂不可用:${e.message}`);
}
// 未登录时该站只返回第一页(约 10 条),第二页为空,
// 因此不虚报页数:只有当页面本身给出更多分页链接时才认为可翻页。
const maxPage = parseWebMaxPage(r.html, page, r.items.length);
return { items: r.items, maxPage, page };
},
async detail(postId) {
const s = String(postId);
const webM = s.match(/^web:(\d+)$/);
if (!webM) throw new Error('无效的 LibGen ID');
const id = webM[1];
const { html, base } = await fetchBookPage(id);
// 页面内嵌 schema.org JSON-LD,是最可靠的元数据来源
const ld = parseJsonLd(html);
let title = '';
const tM = html.match(/<h1[^>]*itemprop="name"[^>]*>([\s\S]*?)<\/h1>/i)
|| html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (tM) title = stripTags(tM[1]);
if (!title && ld.name) title = decodeEntities(ld.name);
if (!title) {
const mt = html.match(/<meta name="title" content="([^"]+)"/i);
if (mt) title = decodeEntities(mt[1]).replace(/\s*\|\s*Libgen\s*$/i, '').trim();
}
const authors = [];
if (Array.isArray(ld.author)) {
for (const p of ld.author) {
const n = decodeEntities(String((p && p.name) || '')).trim();
if (n && !authors.includes(n)) authors.push(n);
}
}
if (!authors.length) {
const aRe = /<a[^>]*itemprop="author"[^>]*>([\s\S]*?)<\/a>/gi;
let a;
while ((a = aRe.exec(html))) {
const n = stripTags(a[1]);
if (n && !authors.includes(n)) authors.push(n);
}
}
let cover = ld.image ? absUrl(base, ld.image) : '';
if (!cover) {
const cM = html.match(/<img[^>]*itemprop="image"[^>]*(?:data-src|src)="([^"]+)"/i)
|| html.match(/<img[^>]*(?:data-src|src)="([^"]+)"[^>]*alt="[^"]*cover"/i);
if (cM) cover = absUrl(base, cM[1]);
}
const tags = [];
for (const label of ['Year', 'Publisher', 'Language', 'File', 'Pages', 'ISBN', 'Series', 'Edition']) {
const v = propOf(html, label);
if (v) tags.push(`${label}${v}`);
}
if (!tags.length && ld.inLanguage) tags.push(`Language${ld.inLanguage}`);
let brief = '';
const dM = html.match(/<div[^>]*id="bookDescriptionBox"[^>]*>([\s\S]*?)<\/div>/i);
if (dM) brief = stripTags(dM[1]).slice(0, 2000);
if (!brief) {
const md = html.match(/<meta name="description" content="([^"]*)"/i);
const v = md ? decodeEntities(md[1]).trim() : '';
// 过滤 "Download ... for free from Libgen" 之类的模板文案
if (v && !/for free from Libgen|free E-Books Library/i.test(v)) brief = v;
}
// 详情页里的 termsHash 即经典 LibGen 的 md5,可用于兜底下载
const md5 = (html.match(/"termsHash"\s*:\s*"([a-f0-9]{32})"/i) || [])[1] || '';
const links = [{ name: 'LibGen 页面', url: `${base}/book/${id}` }].concat(buildDownloadLinks(md5));
return {
postId,
title: title || `LibGen ${id}`,
cover,
authors,
date: propOf(html, 'Year'),
tags,
brief,
url: `${base}/book/${id}`,
links
};
},
async download(postId) {
const s = String(postId);
const webM = s.match(/^web:(\d+)$/);
if (!webM) throw new Error('无效的 LibGen ID');
const id = webM[1];
const { html, base } = await fetchBookPage(id);
// 站点已登录时会渲染真实下载链接,否则只有 /login 按钮。
// 只接受站内 /dl/ 路径或直接指向文件扩展名的链接,避免误抓页脚社交链接。
const files = [];
const dlRe = /<a[^>]*class="[^"]*dlButton[^"]*"[^>]*href="([^"]+)"/gi;
let m;
while ((m = dlRe.exec(html))) {
const href = m[1];
const isFile = /\/dl\/|\/download\/|\.(pdf|epub|mobi|djvu|azw3|fb2|txt|zip|rar)(\?|#|$)/i.test(href);
if (!isFile) continue;
const link = absUrl(base, href);
const extM = link.match(/\.([a-z0-9]{2,5})(?:\?|#|$)/i);
files.push({
name: `libgen-${id}${extM ? '.' + extM[1] : ''}`,
link,
format: extM ? extM[1].toUpperCase() : ''
});
}
const md5 = (html.match(/"termsHash"\s*:\s*"([a-f0-9]{32})"/i) || [])[1] || '';
const links = [{ name: 'LibGen 页面', url: `${base}/book/${id}` }].concat(buildDownloadLinks(md5));
if (!files.length) {
// 没有直链时不报错,交给用户走外部链接(该站下载需登录)
return { files: [], links };
}
return { files, links };
}
};
+114
View File
@@ -0,0 +1,114 @@
// 通用镜像管理与故障转移
// 每个 source 提供候选镜像列表,运行时记住"当前可用"镜像;
// 失败的镜像会被临时拉黑(带过期时间),避免站点恢复后永远不再尝试。
const BAD_TTL = 5 * 60 * 1000; // 失败镜像的冷却时间
const stateCache = new Map(); // prefix -> { current, bad: Map<mirror, ts> }
function stateOf(prefix) {
let c = stateCache.get(prefix);
if (!c) {
c = { current: null, bad: new Map() };
stateCache.set(prefix, c);
}
return c;
}
function isBad(c, mirror) {
const ts = c.bad.get(mirror);
if (!ts) return false;
if (Date.now() - ts > BAD_TTL) {
c.bad.delete(mirror);
return false;
}
return true;
}
function markBad(prefix, mirror) {
const c = stateOf(prefix);
c.bad.set(mirror, Date.now());
if (c.current === mirror) c.current = null;
}
function markGood(prefix, mirror) {
const c = stateOf(prefix);
c.current = mirror;
c.bad.delete(mirror);
}
function currentFor(prefix, mirrors) {
const c = stateOf(prefix);
if (c.current && mirrors.includes(c.current)) return c.current;
return mirrors[0];
}
// 候选顺序:上次成功的优先,其余按原顺序,已拉黑的排到最后兜底
function candidates(prefix, mirrors) {
const c = stateOf(prefix);
const fresh = [];
const stale = [];
for (const m of mirrors) {
if (m === c.current) continue;
(isBad(c, m) ? stale : fresh).push(m);
}
const out = [];
if (c.current && mirrors.includes(c.current)) out.push(c.current);
out.push(...fresh, ...stale);
return out.length ? out : mirrors.slice();
}
/**
* 串行尝试:按优先级逐个调用 fn(mirror),第一个成功即返回。
* 适用于镜像少、需要严格优先级的场景。
*/
async function tryMirrors(prefix, mirrors, fn) {
const list = candidates(prefix, mirrors);
let lastErr;
for (const m of list) {
try {
const r = await fn(m);
markGood(prefix, m);
return r;
} catch (e) {
lastErr = e;
markBad(prefix, m);
}
}
throw lastErr || new Error('所有镜像均不可用');
}
/**
* 竞速尝试:同时向所有候选镜像发起请求,最先成功的胜出。
* 适用于镜像多且大量失效的场景(如 LibGen),避免串行等待累加。
*/
async function raceMirrors(prefix, mirrors, fn) {
const list = candidates(prefix, mirrors);
if (!list.length) throw new Error('没有可用镜像');
return new Promise((resolve, reject) => {
let pending = list.length;
let settled = false;
let lastErr;
for (const m of list) {
Promise.resolve()
.then(() => fn(m))
.then((r) => {
if (settled) return;
settled = true;
markGood(prefix, m);
resolve(r);
})
.catch((e) => {
lastErr = e;
markBad(prefix, m);
if (--pending === 0 && !settled) {
reject(lastErr || new Error('所有镜像均不可用'));
}
});
}
});
}
module.exports = { tryMirrors, raceMirrors, currentFor };
+143
View File
@@ -0,0 +1,143 @@
// Memory of the World 数据源:Calibre 书目服务,实时联网查询
// 端点:
// /books?page=N 浏览(分页)
// /search/titles/<kw>?page=N 按标题搜索
// /search/authors/<kw>?page=N 按作者搜索
// 站点没有单条详情端点(/books/<id> 会回落到列表),因此详情与下载信息
// 从列表/搜索结果里缓存的原始记录中取。
const { fetchJson, clampPage, decodeEntities } = require('./http');
const BASE = 'https://library.memoryoftheworld.org';
const PAGE_SIZE = 48;
// postId -> 原始记录,供 detail/download 复用(LRU 上限,避免无限增长)
const recordCache = new Map();
const CACHE_LIMIT = 2000;
function remember(b) {
if (!b || !b._id) return;
if (recordCache.has(b._id)) recordCache.delete(b._id);
recordCache.set(b._id, b);
while (recordCache.size > CACHE_LIMIT) {
recordCache.delete(recordCache.keys().next().value);
}
}
function stripHtml(s) {
return decodeEntities(String(s || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
}
function fileUrl(b, rel) {
// library_url 形如 /files/hortense/rel 为库内相对路径
const lib = String(b.library_url || '').replace(/\/+$/, '');
const parts = String(rel || '').split('/').map(encodeURIComponent).join('/');
return `${BASE}${lib}/${parts}`;
}
function toItem(b) {
remember(b);
return {
postId: b._id,
title: b.title || '',
cover: b.cover_url ? fileUrl(b, b.cover_url) : '',
date: b.pubdate && b.pubdate.slice(0, 4) !== '0101' ? b.pubdate.slice(0, 4) : '',
url: `${BASE}/#/book/${b._id}`,
subtitle: (b.authors || []).join(', ')
};
}
function pack(j, page) {
const items = j._items || [];
const total = (j._meta && j._meta.total) || 0;
const size = (j._meta && j._meta.max_results) || PAGE_SIZE;
return {
items: items.map(toItem),
maxPage: Math.max(1, Math.ceil(total / size)),
page
};
}
// 搜索关键词出现在路径段中,需要编码;斜杠会破坏路由,统一替换为空格
function safeKeyword(kw) {
return encodeURIComponent(String(kw || '').replace(/\//g, ' ').trim());
}
function getRecord(postId) {
const b = recordCache.get(postId);
if (!b) throw new Error('详情已过期,请返回列表重新进入');
return b;
}
module.exports = {
id: 'motw',
name: 'Memory of the World',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const j = await fetchJson(`${BASE}/books?page=${page}`);
return pack(j, page);
},
async search(keyword, page) {
page = clampPage(page);
const kw = safeKeyword(keyword);
if (!kw) return { items: [], maxPage: 1, page };
// 标题与作者两路合并,按 _id 去重
const [byTitle, byAuthor] = await Promise.all([
fetchJson(`${BASE}/search/titles/${kw}?page=${page}`).catch(() => null),
fetchJson(`${BASE}/search/authors/${kw}?page=${page}`).catch(() => null)
]);
if (!byTitle && !byAuthor) throw new Error('搜索请求失败');
const seen = new Set();
const items = [];
for (const j of [byTitle, byAuthor]) {
for (const b of (j && j._items) || []) {
if (!b || !b._id || seen.has(b._id)) continue;
seen.add(b._id);
items.push(toItem(b));
}
}
const totals = [byTitle, byAuthor]
.map((j) => (j && j._meta && j._meta.total) || 0);
const total = Math.max(...totals, 0);
return {
items,
maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)),
page
};
},
async detail(postId) {
const b = getRecord(postId);
const tags = (b.tags || []).map((t) => `标签:${t}`);
if (b.publisher) tags.push(`出版:${b.publisher}`);
if (b.languages && b.languages.length) tags.push(`语言:${b.languages.join(', ')}`);
if (b.librarian) tags.push(`馆藏者:${b.librarian}`);
return {
postId,
title: b.title || '',
cover: b.cover_url ? fileUrl(b, b.cover_url) : '',
authors: b.authors || [],
date: b.pubdate && b.pubdate.slice(0, 4) !== '0101' ? b.pubdate.slice(0, 4) : '',
tags,
brief: stripHtml(b.abstract),
url: `${BASE}/#/book/${b._id}`,
links: [{ name: '详情页', url: `${BASE}/#/book/${b._id}` }]
};
},
async download(postId) {
const b = getRecord(postId);
const files = (b.formats || []).map((f) => ({
name: f.file_name || `${b.title}.${f.format}`,
link: fileUrl(b, `${f.dir_path || ''}${f.file_name || ''}`),
format: (f.format || '').toUpperCase()
}));
return { files, links: [{ name: '详情页', url: `${BASE}/#/book/${b._id}` }] };
}
};
+84
View File
@@ -0,0 +1,84 @@
const { fetchJson, clampPage } = require('./http');
const BASE = 'https://openlibrary.org';
const PAGE_SIZE = 20;
async function fetchWithRetry(url, tries = 2) {
let last;
for (let i = 0; i < tries; i++) {
try { return await fetchJson(url); } catch (e) { last = e; await new Promise((r) => setTimeout(r, 1200)); }
}
throw last;
}
function coverOf(doc) {
return doc.cover_i ? `https://covers.openlibrary.org/b/id/${doc.cover_i}-M.jpg` : '';
}
function toItem(d) {
const workKey = String(d.key || '').replace(/^\/works\//, '');
return {
postId: workKey,
title: d.title || '(无标题)',
cover: coverOf(d),
date: d.first_publish_year ? String(d.first_publish_year) : '',
url: `${BASE}/works/${workKey}`,
subtitle: (d.author_name || []).slice(0, 3).join(', ')
};
}
const FIELDS = 'key,title,author_name,first_publish_year,cover_i,ia,ocaid,editions';
module.exports = {
id: 'openlibrary',
name: 'Open Library 图书',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/search.json?q=${encodeURIComponent('subject:fiction')}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.numFound || 0) / PAGE_SIZE));
return { items: (j.docs || []).map(toItem), maxPage, page };
},
async search(keyword, page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/search.json?q=${encodeURIComponent(keyword)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.numFound || 0) / PAGE_SIZE));
return { items: (j.docs || []).map(toItem), maxPage, page };
},
async detail(postId) {
const j = await fetchWithRetry(`${BASE}/works/${encodeURIComponent(postId)}.json`);
const desc = typeof j.description === 'string' ? j.description : (j.description && j.description.value) || '';
return {
postId,
title: j.title || '(无标题)',
cover: j.covers && j.covers[0] ? `https://covers.openlibrary.org/b/id/${j.covers[0]}-M.jpg` : '',
authors: [],
date: j.first_publish_date || '',
tags: (j.subjects || []).slice(0, 6).map((s) => `主题:${s}`),
brief: desc,
url: `${BASE}/works/${postId}`,
links: [{ name: 'Open Library 页', url: `${BASE}/works/${postId}` }]
};
},
async download(postId) {
const ed = await fetchWithRetry(`${BASE}/works/${encodeURIComponent(postId)}/editions.json?limit=50`);
const files = [];
const seen = new Set();
for (const e of (ed.entries || [])) {
const ocaid = e.ocaid || (e.ia && e.ia[0]);
if (!ocaid || seen.has(ocaid)) continue;
if (e.access_restricted === 'borrow') continue; // 借阅制,不直接下载
seen.add(ocaid);
files.push({ name: `${ocaid}.pdf`, link: `https://archive.org/download/${ocaid}/${ocaid}.pdf`, format: 'PDF' });
files.push({ name: `${ocaid}.epub`, link: `https://archive.org/download/${ocaid}/${ocaid}.epub`, format: 'EPUB' });
if (files.length >= 6) break;
}
return { files, links: [{ name: 'Open Library 页', url: `${BASE}/works/${postId}` }] };
}
};
+73
View File
@@ -0,0 +1,73 @@
const { fetchJson, clampPage } = require('./http');
const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
const PAGE_SIZE = 20;
function toItem(r) {
const authors = (r.authors || []).map((a) => a.name).slice(0, 3).join(', ');
return {
postId: String(r.uid),
title: r.title || '(无标题)',
cover: '',
date: (r.pubdate || '').slice(0, 4),
url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${r.uid}/`,
subtitle: [authors, r.fulljournalname || r.source].filter(Boolean).join(' · ')
};
}
async function esearch(term, start) {
const j = await fetchJson(`${EUTILS}/esearch.fcgi?db=pmc&term=${encodeURIComponent(term)}&retmode=json&retstart=${start}&retmax=${PAGE_SIZE}&sort=relevance`);
return { count: parseInt(j.esearchresult.count, 10) || 0, ids: j.esearchresult.idlist || [] };
}
async function esummary(ids) {
if (!ids.length) return {};
const j = await fetchJson(`${EUTILS}/esummary.fcgi?db=pmc&id=${ids.join(',')}&retmode=json`);
return j.result || {};
}
async function runList(term, page) {
page = clampPage(page);
const start = (page - 1) * PAGE_SIZE;
const { count, ids } = await esearch(term, start);
const result = await esummary(ids);
const items = ids.map((id) => result[id]).filter(Boolean).map(toItem);
return { items, maxPage: Math.max(1, Math.ceil(count / PAGE_SIZE)), page };
}
module.exports = {
id: 'pmc',
name: 'PMC 生物医学',
supportsSearch: true,
list(page) { return runList('open access[filter]', page); },
search(keyword, page) { return runList(`${keyword} AND open access[filter]`, page); },
async detail(postId) {
const result = await esummary([postId]);
const r = result[postId];
if (!r) throw new Error('未找到该文献');
return {
postId: String(postId),
title: r.title || '(无标题)',
cover: '',
authors: (r.authors || []).map((a) => a.name),
date: (r.pubdate || '').slice(0, 10),
tags: [
r.fulljournalname ? `期刊:${r.fulljournalname}` : '',
r.pubdate ? `发表:${r.pubdate}` : ''
].filter(Boolean),
brief: '',
url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`,
links: [{ name: 'PMC 全文页', url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/` }]
};
},
async download(postId) {
const page = `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`;
return {
files: [{ name: `PMC${postId}.pdf`, link: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/pdf/`, format: 'PDF' }],
links: [{ name: 'PMC 全文页', url: page }]
};
}
};
+160
View File
@@ -0,0 +1,160 @@
// Sci-Hub 数据源:按 DOI 精确获取论文 PDF
// 注意:Sci-Hub 各镜像会不定期启用人机验证(ALTCHA),此时无法通过纯 HTTP 抓取,
// 模块会明确抛错并提示用户在浏览器中打开。
const { fetchText, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const MIRRORS = [
'https://sci-hub.se',
'https://sci-hub.st',
'https://sci-hub.ru'
];
const DOI_RE = /^10\.\d{4,9}\/\S+$/;
function normalizeDoi(input) {
let s = String(input || '').trim();
s = s.replace(/^doi:\s*/i, '');
s = s.replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '');
return s;
}
function absUrl(base, href) {
if (!href) return '';
if (/^https?:\/\//.test(href)) return href;
if (href.startsWith('//')) return 'https:' + href;
if (href.startsWith('/')) return base + href;
return base + '/' + href;
}
function isChallenge(html) {
return /altcha|你是机器人|are you a robot|captcha/i.test(html);
}
function extractTitle(html, doi) {
// 优先用引文区块(含完整论文标题)
const cite = html.match(/id\s*=\s*["']citation["'][^>]*>([\s\S]{0,600}?)<\/(?:div|i|p)>/i);
if (cite) {
const t = decodeEntities(cite[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
if (t) return t;
}
const titleM = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleM) {
let t = decodeEntities(titleM[1]).replace(/\s+/g, ' ').trim();
t = t.replace(/^Sci-Hub\s*[:]\s*/i, '').replace(/\s*[-|]\s*Sci-Hub.*$/i, '').trim();
if (t) return t;
}
return doi;
}
function extractPdf(html, base) {
const patterns = [
/<iframe[^>]+src\s*=\s*["']([^"']+)["']/i,
/<embed[^>]+src\s*=\s*["']([^"']+)["']/i,
/location\.href\s*=\s*['"]([^'"]+)['"]/i,
/<a[^>]+href\s*=\s*["']([^"']*\.pdf[^"']*)["']/i
];
for (const re of patterns) {
const m = html.match(re);
if (m && m[1] && /\.pdf|\/downloads?\//i.test(m[1])) {
return absUrl(base, m[1].replace(/#.*$/, ''));
}
}
return '';
}
async function fetchSciHub(base, doi) {
const url = `${base}/${doi}`;
const html = await fetchText(url);
if (isChallenge(html)) {
throw new Error(`${base} 启用了人机验证`);
}
const pdfUrl = extractPdf(html, base);
const title = extractTitle(html, doi);
const notFound = /article not found|не найдена|抱歉/i.test(html);
if (!pdfUrl && notFound) throw new Error('该 DOI 在 Sci-Hub 中不存在');
return { pdfUrl, title, url, base };
}
async function resolve(doi) {
try {
return await tryMirrors('scihub', MIRRORS, (m) => fetchSciHub(m, doi));
} catch (e) {
if (/人机验证/.test(e.message)) {
throw new Error('Sci-Hub 当前要求人机验证,请在浏览器中打开该 DOI 页面');
}
throw e;
}
}
module.exports = {
id: 'scihub',
name: 'Sci-Hub(按 DOI',
supportsSearch: true,
async list() {
return { items: [], maxPage: 1, page: 1 };
},
async search(keyword, page) {
page = clampPage(page);
const doi = normalizeDoi(keyword);
if (!doi) return { items: [], maxPage: 1, page: 1 };
if (!DOI_RE.test(doi)) {
throw new Error('Sci-Hub 仅支持 DOI 查询,例如 10.1038/nature12373');
}
const r = await resolve(doi);
return {
items: [{
postId: doi,
title: r.title,
cover: '',
date: '',
url: r.url,
subtitle: doi
}],
maxPage: 1,
page: 1
};
},
async detail(postId) {
const doi = normalizeDoi(postId);
const r = await resolve(doi);
return {
postId: doi,
title: r.title,
cover: '',
authors: [],
date: '',
tags: [`DOI${doi}`],
brief: '',
url: r.url,
links: [
{ name: 'Sci-Hub 页', url: r.url },
{ name: 'DOI 原文', url: `https://doi.org/${doi}` }
]
};
},
async download(postId) {
const doi = normalizeDoi(postId);
const r = await resolve(doi);
const files = [];
if (r.pdfUrl) {
files.push({
name: `${doi.replace(/[\\/:*?"<>|]/g, '_')}.pdf`,
link: r.pdfUrl,
format: 'PDF'
});
}
return {
files,
links: [
{ name: 'Sci-Hub 页', url: r.url },
{ name: 'DOI 原文', url: `https://doi.org/${doi}` }
]
};
}
};
+80
View File
@@ -0,0 +1,80 @@
const { fetchJson, clampPage } = require('./http');
const BASE = 'https://api.semanticscholar.org/graph/v1';
const PAGE_SIZE = 25;
const FIELDS = 'title,authors,year,abstract,openAccessPdf,externalIds,url,venue';
async function fetchWithRetry(url, tries = 3) {
let last;
for (let i = 0; i < tries; i++) {
try { return await fetchJson(url); } catch (e) {
last = e;
if (!/429/.test(e.message)) throw e;
await new Promise((r) => setTimeout(r, 2500 * (i + 1)));
}
}
throw last;
}
function toItem(p) {
return {
postId: p.paperId || (p.externalIds && (p.externalIds.DOI || p.externalIds.ArXiv)) || p.title,
title: p.title || '(无标题)',
cover: '',
date: p.year ? String(p.year) : '',
url: p.url || '',
subtitle: [(p.authors || []).map((a) => a.name).slice(0, 3).join(', '), p.venue].filter(Boolean).join(' · ')
};
}
module.exports = {
id: 'semanticscholar',
name: 'Semantic Scholar',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent('a')}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page };
},
async search(keyword, page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent(keyword)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`);
const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE));
return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page };
},
async detail(postId) {
const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=${FIELDS}`);
return {
postId,
title: p.title || '(无标题)',
cover: '',
authors: (p.authors || []).map((a) => a.name),
date: p.year ? String(p.year) : '',
tags: [
p.venue ? `来源:${p.venue}` : '',
p.externalIds && p.externalIds.DOI ? `DOI${p.externalIds.DOI}` : ''
].filter(Boolean),
brief: p.abstract || '',
url: p.url || '',
links: p.url ? [{ name: 'Semantic Scholar 页', url: p.url }] : []
};
},
async download(postId) {
const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=title,openAccessPdf,url,externalIds`);
const files = [];
if (p.openAccessPdf && p.openAccessPdf.url) {
files.push({ name: `${String(p.title || postId).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.pdf`, link: p.openAccessPdf.url, format: 'PDF' });
}
const links = [];
if (p.url) links.push({ name: 'Semantic Scholar 页', url: p.url });
if (p.externalIds && p.externalIds.DOI) links.push({ name: 'DOI', url: `https://doi.org/${p.externalIds.DOI}` });
return { files, links };
}
};
+91
View File
@@ -0,0 +1,91 @@
const { fetchText, clampPage, decodeEntities } = require('./http');
const BASE = 'https://standardebooks.org';
const PAGE_SIZE = 24;
async function fetchOpds(path) {
return fetchText(`${BASE}${path}`, { headers: { 'Accept': 'application/atom+xml, text/xml, */*' } });
}
function parseEntries(xml) {
const entries = [];
const re = /<entry>([\s\S]*?)<\/entry>/g;
let m;
while ((m = re.exec(xml))) {
const e = m[1];
const title = decodeEntities((e.match(/<title>([\s\S]*?)<\/title>/) || [])[1] || '').trim();
const id = decodeEntities((e.match(/<id>([\s\S]*?)<\/id>/) || [])[1] || '').trim();
const author = decodeEntities((e.match(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/) || [])[1] || '').trim();
const summary = decodeEntities((e.match(/<summary>([\s\S]*?)<\/summary>/) || [])[1] || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
let epub = '', cover = '', pageUrl = '';
const lre = /<link[^>]*\/>/g;
let l;
while ((l = lre.exec(e))) {
const tag = l[0];
const href = (tag.match(/href="([^"]+)"/) || [])[1] || '';
const rel = (tag.match(/rel="([^"]+)"/) || [])[1] || '';
if (/epub/i.test(tag) && !epub) epub = href;
else if (/image/.test(tag) && !cover) cover = href;
else if (rel === 'alternate' && !pageUrl) pageUrl = href;
}
const slug = id.replace(/^urn:uuid:|^https?:\/\/standardebooks\.org\/ebooks\//, '').replace(/\//g, '_') || title;
entries.push({ slug, title, author, summary, epub, cover, pageUrl });
}
return entries;
}
function toItem(e) {
return {
postId: encodeURIComponent(e.slug),
title: e.title,
cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '',
date: '',
url: e.pageUrl ? (e.pageUrl.startsWith('http') ? e.pageUrl : BASE + e.pageUrl) : '',
subtitle: e.author
};
}
module.exports = {
id: 'standardebooks',
name: 'Standard Ebooks',
supportsSearch: true,
async list(page) {
page = clampPage(page);
const xml = await fetchOpds(`/feeds/opds/all?page=${page}`);
return { items: parseEntries(xml).map(toItem), maxPage: 40, page };
},
async search(keyword, page) {
page = clampPage(page);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(keyword)}&page=${page}`);
return { items: parseEntries(xml).map(toItem), maxPage: 40, page };
},
async detail(postId) {
const slug = decodeURIComponent(postId);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`);
const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0];
if (!e) throw new Error('未找到该图书');
return {
postId,
title: e.title,
cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '',
authors: e.author ? [e.author] : [],
date: '',
tags: [],
brief: e.summary,
url: e.pageUrl,
links: e.pageUrl ? [{ name: 'Standard Ebooks 页', url: e.pageUrl }] : []
};
},
async download(postId) {
const slug = decodeURIComponent(postId);
const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`);
const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0];
if (!e || !e.epub) throw new Error('未找到 EPUB 下载');
const url = e.epub.startsWith('http') ? e.epub : BASE + e.epub;
return { files: [{ name: `${e.title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.epub`, link: url, format: 'EPUB' }], links: [] };
}
};
+84
View File
@@ -0,0 +1,84 @@
// Z-Library 凭据与会话存储
// 注意:凭据以 base64 简单混淆存储于本地 userData 目录,不是真正的加密。
const fs = require('fs');
const path = require('path');
let filePath = null;
function init(userDataDir) {
filePath = path.join(userDataDir, 'zlib-auth.json');
}
function getFilePath() {
if (filePath) return filePath;
// 未初始化时回退到用户目录(便于独立 Node 脚本测试)
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'zlib-auth.json');
}
function read() {
const fp = getFilePath();
try {
const raw = fs.readFileSync(fp, 'utf8');
const j = JSON.parse(raw);
if (!j) return null;
return {
email: j.email ? Buffer.from(j.email, 'base64').toString('utf8') : '',
password: j.password ? Buffer.from(j.password, 'base64').toString('utf8') : '',
userId: j.userId || '',
userKey: j.userKey || '',
mirror: j.mirror || ''
};
} catch (e) { return null; }
}
function write(creds) {
const fp = getFilePath();
try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch (e) { /* ignore */ }
const j = {
email: creds.email ? Buffer.from(creds.email, 'utf8').toString('base64') : '',
password: creds.password ? Buffer.from(creds.password, 'utf8').toString('base64') : '',
userId: creds.userId || '',
userKey: creds.userKey || '',
mirror: creds.mirror || ''
};
fs.writeFileSync(fp, JSON.stringify(j, null, 2), 'utf8');
}
// 清除全部(含凭据)——用于"退出登录"
function clear() {
const fp = getFilePath();
try { fs.unlinkSync(fp); } catch (e) { /* ignore */ }
}
// 只清除会话令牌,保留邮箱密码以便自动重新登录
function clearSession() {
const c = read();
if (!c) return;
c.userId = '';
c.userKey = '';
c.mirror = '';
write(c);
}
function hasCreds() {
const c = read();
return !!(c && c.email && c.password);
}
function getSession() {
const c = read();
if (c && c.userId && c.userKey) return { userId: c.userId, userKey: c.userKey, mirror: c.mirror || '' };
return null;
}
function setSession(userId, userKey, mirror) {
const c = read() || { email: '', password: '' };
c.userId = userId;
c.userKey = userKey;
c.mirror = mirror || '';
write(c);
}
module.exports = { init, read, write, clear, clearSession, hasCreds, getSession, setSession };
+278
View File
@@ -0,0 +1,278 @@
// Z-Library 数据源
// 关键约定(经实测确认):
// - 登录:POST /eapi/user/login (email, password) -> user.id / user.remix_userkey
// - 搜索: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 { fetchJson, clampPage, decodeEntities } = require('./http');
const { tryMirrors } = require('./mirror');
const auth = require('./zlib-auth');
const DEFAULT_MIRRORS = [
'https://z-lib.fm',
'https://z-library.sk',
'https://z-lib.gs',
'https://1lib.sk',
'https://singlelogin.re'
];
const PAGE_SIZE = 20;
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
function getMirrors() {
const custom = (auth.read() || {}).customMirrors;
if (Array.isArray(custom) && custom.length) {
return custom.concat(DEFAULT_MIRRORS.filter((m) => !custom.includes(m)));
}
return DEFAULT_MIRRORS.slice();
}
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 || '');
}
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'), {
method: 'POST',
headers: FORM,
body: form({ email: creds.email, password: creds.password })
});
if (!j || !j.success || !j.user) throw new Error(errMessage(j) || '登录失败');
return { userId: String(j.user.id), userKey: j.user.remix_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。
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,
body: form({ ...params, ...cred })
});
} else {
j = await fetchJson(apiUrl(mirror, path, { ...params, ...cred }));
}
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 || 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;
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) {
auth.clearSession();
throw authRequired('Z-Library 会话已过期,请重新登录');
}
throw r.error || new Error('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 || '')
};
}
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] };
}
module.exports = {
id: 'zlib',
name: 'Z-Library',
supportsSearch: 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(`/eapi/book/${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(`/eapi/book/${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) {
auth.write({ email, password, userId: '', userKey: '', mirror: '' });
try {
await doLogin();
return { ok: true };
} catch (e) {
auth.clear();
return { ok: false, error: e.message };
}
},
async logout() {
auth.clear();
return { ok: true };
},
hasCreds() {
return auth.hasCreds();
}
};
+102
View File
@@ -0,0 +1,102 @@
$('minBtn').onclick = () => window.api.minimize();
$('maxBtn').onclick = () => window.api.maximize();
$('closeBtn').onclick = () => window.api.close();
let currentTab = 'library';
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
$('libraryTab').classList.toggle('hidden', tab !== 'library');
$('browseTab').classList.toggle('hidden', tab !== 'browse');
$('settingsTab').classList.toggle('hidden', tab !== 'settings');
if (tab === 'library') Library.refresh(true);
}
document.querySelectorAll('.tab').forEach((t) => {
t.onclick = () => switchTab(t.dataset.tab);
});
Browse.init();
Library.init();
const sortSelect = $('sortSelect');
sortSelect.value = Library.getSortMode();
sortSelect.onchange = () => Library.setSortMode(sortSelect.value);
async function initSourceManager() {
const listEl = $('sourceList');
const res = await window.api.sources.list();
const all = res.ok ? res.data : [];
const enabled = getEnabledSources();
listEl.innerHTML = all.map((s) => {
const checked = enabled ? enabled.includes(s.id) : true;
return `
<label class="source-row">
<input type="checkbox" data-id="${escapeHtml(s.id)}" ${checked ? 'checked' : ''} />
<span>${escapeHtml(s.name)}</span>
</label>`;
}).join('');
listEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.onchange = () => {
const ids = Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked'))
.map((el) => el.dataset.id);
setEnabledSources(ids);
Browse.reloadSources();
};
});
}
initSourceManager();
async function refreshZlibStatus() {
const r = await window.api.zlib.hasCreds();
const logged = r.ok && r.data;
$('zlibStatus').textContent = logged ? '已配置(凭据保存在本地)' : '未登录';
$('zlibLoginBtn').textContent = logged ? '重新登录' : '登录';
$('zlibLogoutBtn').classList.toggle('hidden', !logged);
}
$('zlibLoginBtn').onclick = async () => {
const r = await openModal('Z-Library 登录', `
<p style="margin-bottom:8px;">使用 Z-Library 账号登录(保存在本地 userData 目录)</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<input id="zlibEmail" type="email" placeholder="邮箱" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<input id="zlibPassword" type="password" placeholder="密码" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<div id="zlibErr" style="color:#f66;font-size:12px;min-height:16px;"></div>
</div>
`, async () => {
const email = $('zlibEmail').value.trim();
const password = $('zlibPassword').value;
if (!email || !password) { $('zlibErr').textContent = '请输入邮箱和密码'; return false; }
$('zlibErr').textContent = '登录中...';
const res = await window.api.zlib.login(email, password);
if (!res.ok) { $('zlibErr').textContent = res.error || '登录失败'; return false; }
if (res.data && res.data.ok === false) { $('zlibErr').textContent = res.data.error || '登录失败'; return false; }
return true;
});
if (r) refreshZlibStatus();
};
$('zlibLogoutBtn').onclick = async () => {
const ok = await confirmModal('退出 Z-Library', '确定要清除本地保存的 Z-Library 凭据吗?');
if (ok) { await window.api.zlib.logout(); refreshZlibStatus(); }
};
refreshZlibStatus();
async function refreshProxy() {
const r = await window.api.proxy.get();
if (r.ok) $('proxyInput').value = r.data || '';
}
$('proxySaveBtn').onclick = async () => {
await window.api.proxy.set($('proxyInput').value.trim());
$('proxySaveBtn').textContent = '已保存 ✓';
setTimeout(() => { $('proxySaveBtn').textContent = '保存'; }, 1500);
};
refreshProxy();
window.api.getVersion().then((r) => {
if (r && r.ok) $('appVersion').textContent = 'v' + r.data;
});
switchTab('library');
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https: http: data: file:; style-src 'self' 'unsafe-inline';" />
<title>PeopleLib 文献库</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="titlebar">
<div class="titlebar-left">
<span class="brand">PeopleLib <span class="brand-sub">开放文献库</span></span>
</div>
<nav class="tabs">
<button class="tab active" data-tab="library">我的书库</button>
<button class="tab" data-tab="browse">检索</button>
</nav>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<button class="win-btn tab" data-tab="settings" title="设置">&#9881;</button>
<button id="minBtn" class="win-btn" title="最小化">&#9472;</button>
<button id="maxBtn" class="win-btn" title="最大化">&#9633;</button>
<button id="closeBtn" class="win-btn win-close" title="关闭">&#10005;</button>
</div>
</div>
<main id="main">
<!-- 我的书库 -->
<section id="libraryTab" class="tab-panel">
<div class="toolbar">
<span id="libStatus" class="status-bar"></span>
<div class="spacer"></div>
<button id="addLocalBtn" class="tb-btn">+ 添加本地文件</button>
</div>
<div id="libGrid" class="grid"></div>
</section>
<!-- 检索 -->
<section id="browseTab" class="tab-panel hidden">
<div id="browseGridView">
<div class="browse-head">
<div class="toolbar">
<select id="sourceSelect" class="source-select"></select>
<div class="search-inline">
<input id="searchInput" type="text" placeholder="搜索标题 / 作者 / 关键词..." />
<button id="searchBtn" class="tb-btn">搜索</button>
<button id="clearSearchBtn" class="tb-btn ghost hidden">✕ 清除</button>
</div>
</div>
<div id="statusBar" class="status-bar"></div>
</div>
<div id="grid" class="grid"></div>
<div id="pager" class="pager hidden">
<button id="prevBtn" class="page-btn">← 上一页</button>
<span id="pageInfo" class="page-info"></span>
<button id="nextBtn" class="page-btn">下一页 →</button>
<span class="page-jump">
跳转到
<input id="jumpInput" type="number" min="1" class="jump-input" />
<button id="jumpBtn" class="page-btn">跳转</button>
</span>
</div>
</div>
<div id="detailView" class="hidden">
<button id="backBtn" class="back-btn" aria-label="返回">← 返回</button>
<div id="detailContent"></div>
</div>
</section>
<!-- 设置 -->
<section id="settingsTab" class="tab-panel hidden">
<div class="settings-page">
<h2 class="settings-title">设置</h2>
<div class="settings-group">
<div class="settings-item settings-item-block">
<div class="settings-item-info">
<div class="settings-item-label">数据源管理</div>
<div class="settings-item-desc">选择在检索页显示的数据源</div>
</div>
<div id="sourceList" class="source-list"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">书库默认排序</div>
<div class="settings-item-desc">控制书库条目的排列顺序</div>
</div>
<select id="sortSelect" class="source-select">
<option value="added">添加时间</option>
<option value="title">标题</option>
<option value="author">作者</option>
</select>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">网络代理</div>
<div class="settings-item-desc">对所有数据源与下载统一生效;访问 LibGen / Z-Library 通常需要代理(留空表示直连)</div>
</div>
<input id="proxyInput" type="text" placeholder="留空表示直连,例如 http://localhost:7897" style="width:220px;padding:6px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<button id="proxySaveBtn" class="tb-btn">保存</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">Z-Library 账号</div>
<div class="settings-item-desc" id="zlibStatus">未登录</div>
</div>
<button id="zlibLoginBtn" class="tb-btn">登录</button>
<button id="zlibLogoutBtn" class="tb-btn ghost hidden">退出</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">关于</div>
<div class="settings-item-desc">PeopleLib <span id="appVersion">-</span> · 开放获取文献与公版图书客户端</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- 通用弹窗 -->
<div id="modal" class="modal hidden">
<div class="modal-box">
<div id="modalTitle" class="modal-title"></div>
<div id="modalBody" class="modal-body"></div>
<div class="modal-actions">
<button id="modalCancel" class="page-btn">取消</button>
<button id="modalOk" class="tb-btn">确定</button>
</div>
</div>
</div>
<script src="util.js"></script>
<script src="views/browse.js"></script>
<script src="views/library.js"></script>
<script src="app.js"></script>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
:root {
--bg: #14161a;
--bg-soft: #181b20;
--bg-card: #1c2027;
--line: #2a2f38;
--accent: #6ea8fe;
--accent-bright: #9cc2ff;
--text: #dfe4ec;
--text-dim: #8b94a3;
--green: #3fb96f;
--danger: #d9534f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.hidden { display: none !important; }
/* 标题栏 */
.titlebar {
height: 44px;
background: linear-gradient(135deg, #171b26, #12141c);
display: flex; align-items: center;
padding: 0 8px 0 16px;
-webkit-app-region: drag;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.titlebar-left { flex-shrink: 0; margin-right: 24px; }
.brand { font-size: 15px; font-weight: 700; color: var(--accent-bright); letter-spacing: 0.5px; }
.brand-sub { color: var(--text-dim); font-weight: 400; font-size: 12px; }
.tabs { display: flex; gap: 4px; -webkit-app-region: no-drag; }
.tab {
height: 30px; padding: 0 18px;
background: transparent; border: none; color: var(--text-dim);
font-size: 14px; cursor: pointer; border-radius: 8px;
}
.tab:hover { color: var(--text); background: rgba(255,255,255,0.05); }
.tab.active { color: #0d1420; background: var(--accent); font-weight: 600; }
.titlebar-spacer { flex: 1; }
.titlebar-controls { display: flex; gap: 2px; -webkit-app-region: no-drag; flex-shrink: 0; }
.win-btn {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 30px;
background: transparent; border: none; border-radius: 6px;
color: var(--text-dim); font-size: 14px; cursor: pointer;
}
.win-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); }
.win-close:hover { background: var(--danger); color: #fff; }
/* 主区 */
#main { flex: 1; overflow-y: auto; padding: 20px; }
.tab-panel { min-height: 100%; }
/* 工具栏 */
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar .status-bar { margin: 0; flex: 1; }
.spacer { flex: 1; }
.tb-btn {
height: 30px; padding: 0 16px;
background: var(--accent); color: #0d1420; border: none; border-radius: 8px;
font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap;
}
.tb-btn:hover { background: var(--accent-bright); }
.tb-btn.ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--line); }
.tb-btn.ghost:hover { color: var(--text); border-color: var(--accent); }
.tb-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.tb-btn.in-lib { background: #2a2f38; color: var(--text-dim); }
.tb-btn.sm { height: 24px; padding: 0 10px; font-size: 12px; }
.tb-btn.danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
.tb-btn.danger:hover { background: var(--danger); color: #fff; }
.status-bar { color: var(--text-dim); font-size: 13px; min-height: 18px; }
/* 浏览页头部常驻 */
.browse-head {
position: sticky; top: -20px; z-index: 6;
margin: -20px -20px 0; padding: 20px 20px 14px;
background: linear-gradient(to bottom, var(--bg) 62%, transparent);
}
.browse-head .toolbar { margin-bottom: 0; }
.browse-head .status-bar { margin: 0; min-height: 0; }
.browse-head .status-bar:not(:empty) { margin-top: 8px; }
.source-select {
height: 30px; padding: 0 12px;
background: var(--bg-soft); color: var(--text);
border: 1px solid var(--line); border-radius: 8px; font-size: 13px; cursor: pointer; outline: none;
}
.search-inline { display: flex; align-items: center; gap: 8px; }
#searchInput {
width: 300px; height: 30px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--line); border-radius: 8px; padding: 0 14px;
color: var(--text); font-size: 13px; outline: none;
}
#searchInput:focus { border-color: var(--accent); }
/* 卡片网格 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}
.card { cursor: pointer; }
.card-cover {
width: 100%; aspect-ratio: 3/4;
background: var(--bg-card) center/cover no-repeat;
border: 1px solid var(--line); border-radius: 10px;
display: flex; align-items: center; justify-content: center;
padding: 10px; text-align: center;
transition: transform 0.15s, border-color 0.15s;
}
.card:hover .card-cover { transform: translateY(-3px); border-color: var(--accent); }
.card-cover .ph { color: var(--text-dim); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.card-title {
margin-top: 8px; font-size: 13px; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card-sub { margin-top: 2px; font-size: 11px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-date { margin-top: 2px; font-size: 11px; color: var(--text-dim); }
.card-badge {
display: inline-block; margin-top: 4px; padding: 1px 8px;
font-size: 11px; border-radius: 10px;
background: rgba(63,185,111,0.15); color: var(--green);
}
.card-badge.miss { background: rgba(217,83,79,0.15); color: var(--danger); }
.empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; }
/* 分页 */
.pager {
position: sticky; bottom: -20px; z-index: 6;
margin: 16px -20px -20px; padding: 12px 20px 16px;
background: linear-gradient(to top, var(--bg) 62%, transparent);
display: flex; align-items: center; justify-content: center; gap: 12px;
}
.page-btn {
height: 28px; padding: 0 14px;
background: var(--bg-soft); color: var(--text);
border: 1px solid var(--line); border-radius: 8px; font-size: 12px; cursor: pointer;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); }
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.page-info { color: var(--text-dim); font-size: 13px; }
.page-jump { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12px; }
.jump-input {
width: 60px; height: 28px; text-align: center;
background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px;
color: var(--text); font-size: 12px; outline: none;
}
/* 详情 */
.back-btn {
margin-bottom: 14px; height: 30px; padding: 0 14px;
background: transparent; color: var(--text-dim);
border: 1px solid var(--line); border-radius: 8px; font-size: 13px; cursor: pointer;
}
.back-btn:hover { color: var(--text); border-color: var(--accent); }
.detail-head { display: flex; gap: 20px; margin-bottom: 20px; }
.detail-cover {
width: 150px; height: 200px; flex-shrink: 0;
background: var(--bg-card) center/cover no-repeat;
border: 1px solid var(--line); border-radius: 10px;
display: flex; align-items: center; justify-content: center; padding: 12px; text-align: center;
}
.detail-cover .ph { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
.detail-meta { flex: 1; min-width: 0; }
.detail-title { font-size: 20px; font-weight: 700; line-height: 1.4; margin-bottom: 8px; }
.detail-authors { color: var(--accent-bright); font-size: 13px; margin-bottom: 10px; }
.meta-row { font-size: 13px; color: var(--text-dim); margin-bottom: 6px; }
.meta-row b { color: var(--text); font-weight: 600; margin-right: 8px; }
.meta-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
.meta-row a:hover { text-decoration: underline; }
.add-lib-btn { margin-top: 10px; }
.add-lib-hint { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.section-title { font-size: 14px; font-weight: 700; color: var(--accent-bright); margin: 18px 0 10px; }
.brief-panel {
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 10px;
padding: 14px; font-size: 13px; line-height: 1.7; color: var(--text-dim);
max-height: 220px; overflow-y: auto;
}
/* 下载区 */
.download-box { display: flex; flex-direction: column; gap: 10px; }
.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; }
.dl-error { color: var(--danger); }
.retry-btn { margin-left: 10px; background: none; border: none; color: var(--accent); cursor: pointer; font-size: 13px; }
.dl-files { display: flex; flex-direction: column; gap: 6px; }
.dl-panel-name { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
.dl-file-row {
display: flex; align-items: center; gap: 10px;
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px;
}
.dl-file-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-fmt { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 6px; padding: 0 6px; flex-shrink: 0; }
.copy-btn, .dl-btn {
height: 24px; padding: 0 10px; flex-shrink: 0;
background: transparent; color: var(--text-dim);
border: 1px solid var(--line); border-radius: 6px; font-size: 12px; cursor: pointer;
}
.copy-btn:hover, .dl-btn:hover { color: var(--text); border-color: var(--accent); }
.dl-btn { background: var(--accent); color: #0d1420; border: none; }
.dl-btn:hover { background: var(--accent-bright); color: #0d1420; }
.dl-btn.copied, .copy-btn.copied { color: var(--green); border-color: var(--green); }
.dl-link-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 4px 0; }
.dl-link-row a { color: var(--accent); text-decoration: none; }
.dl-link-row a:hover { text-decoration: underline; }
/* 书库 */
.lib-card-actions { display: flex; gap: 6px; margin-top: 8px; }
.lib-card-actions button {
flex: 1; height: 26px; font-size: 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--line); background: transparent; color: var(--text-dim);
}
.lib-card-actions button:hover { color: var(--text); border-color: var(--accent); }
.lib-card-actions .open-btn { background: var(--accent); color: #0d1420; border: none; font-weight: 600; }
.lib-card-actions .open-btn:hover { background: var(--accent-bright); }
.lib-card-actions .open-btn:disabled { background: #2a2f38; color: var(--text-dim); cursor: not-allowed; }
/* 设置 */
.settings-page { max-width: 720px; }
.settings-title { font-size: 20px; margin-bottom: 20px; }
.settings-group {
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 12px;
padding: 6px 18px; margin-bottom: 16px;
}
.settings-item { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--line); }
.settings-item:last-child { border-bottom: none; }
.settings-item-block { flex-direction: column; align-items: stretch; }
.settings-item-label { font-size: 14px; font-weight: 600; }
.settings-item-desc { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
.source-list { display: flex; flex-direction: column; gap: 2px; padding: 10px 0 14px; }
.source-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; cursor: pointer; }
.source-row input { accent-color: var(--accent); }
/* 弹窗 */
.modal {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
}
.modal-box {
width: 420px; max-width: 90vw;
background: var(--bg-card); border: 1px solid var(--line); border-radius: 14px; padding: 20px;
}
.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
.modal-body { font-size: 13px; color: var(--text-dim); line-height: 1.6; margin-bottom: 18px; }
.modal-body input[type="text"] {
width: 100%; height: 32px; margin-top: 8px;
background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px;
padding: 0 12px; color: var(--text); font-size: 13px; outline: none;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* 滚动条 */
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2f38; border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: #3a4150; }
+61
View File
@@ -0,0 +1,61 @@
window.$ = (id) => document.getElementById(id);
window.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
window.coverStyle = (cover) => {
if (!cover) return '';
const url = /^(https?:|data:)/.test(cover) ? cover : 'file:///' + String(cover).replace(/\\/g, '/');
return `background-image:url('${url.replace(/'/g, "\\'")}')`;
};
window.copyText = async (btn, text) => {
await window.api.copy(text);
const orig = btn.textContent;
btn.textContent = '已复制 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1500);
};
window.formatDate = (ts) => {
if (!ts) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
window.getEnabledSources = () => {
let ids = null;
try {
const raw = localStorage.getItem('enabledSources');
if (raw) ids = JSON.parse(raw);
} catch (e) { /* ignore */ }
return ids;
};
window.setEnabledSources = (ids) => {
localStorage.setItem('enabledSources', JSON.stringify(ids));
};
// 通用弹窗: 返回 Promise<{ok, values}|null>
window.openModal = (title, bodyHtml, onOk) => {
const modal = $('modal');
$('modalTitle').textContent = title;
$('modalBody').innerHTML = bodyHtml;
modal.classList.remove('hidden');
return new Promise((resolve) => {
const close = (result) => {
modal.classList.add('hidden');
$('modalOk').onclick = null;
$('modalCancel').onclick = null;
resolve(result);
};
$('modalCancel').onclick = () => close(null);
$('modalOk').onclick = async () => {
const r = onOk ? await onOk() : true;
if (r !== false) close(r);
};
});
};
window.confirmModal = (title, text) => window.openModal(title, `<p>${escapeHtml(text)}</p>`);
+313
View File
@@ -0,0 +1,313 @@
const Browse = (() => {
const state = {
sourceId: null,
supportsSearch: true,
mode: 'list',
keyword: '',
page: 1,
maxPage: 1,
scrollY: 0,
currentPostId: null,
currentDetail: null
};
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
function init() {
grid = $('grid');
statusBar = $('statusBar');
pager = $('pager');
gridView = $('browseGridView');
detailView = $('detailView');
detailContent = $('detailContent');
mainEl = $('main');
sourceSelect = $('sourceSelect');
searchInput = $('searchInput');
$('searchBtn').onclick = doSearch;
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
$('clearSearchBtn').onclick = clearSearch;
$('prevBtn').onclick = () => { if (state.page > 1) { state.page--; loadGrid(); } };
$('nextBtn').onclick = () => { if (state.page < state.maxPage) { state.page++; loadGrid(); } };
$('jumpBtn').onclick = jumpToPage;
$('jumpInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') jumpToPage(); });
$('backBtn').onclick = showGrid;
sourceSelect.onchange = () => {
state.sourceId = sourceSelect.value;
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
updateSearchUi();
state.mode = 'list';
state.page = 1;
clearSearchUi();
loadGrid();
};
loadSources();
}
function updateSearchUi() {
searchInput.disabled = !state.supportsSearch;
$('searchBtn').disabled = !state.supportsSearch;
searchInput.placeholder = state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览';
}
async function loadSources() {
const res = await window.api.sources.list();
const all = res.ok ? res.data : [];
const enabled = getEnabledSources();
const list = enabled ? all.filter((s) => enabled.includes(s.id)) : all;
sourceSelect.innerHTML = list.map((s) =>
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
if (!list.length) {
state.sourceId = null;
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
pager.classList.add('hidden');
statusBar.textContent = '';
return;
}
if (!list.some((s) => s.id === state.sourceId)) state.sourceId = list[0].id;
sourceSelect.value = state.sourceId;
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
updateSearchUi();
loadGrid();
}
function doSearch() {
if (!state.supportsSearch) return;
const kw = searchInput.value.trim();
if (!kw) return;
state.mode = 'search';
state.keyword = kw;
state.page = 1;
$('clearSearchBtn').classList.remove('hidden');
loadGrid();
}
function clearSearchUi() {
searchInput.value = '';
$('clearSearchBtn').classList.add('hidden');
}
function clearSearch() {
state.mode = 'list';
state.keyword = '';
state.page = 1;
clearSearchUi();
loadGrid();
}
async function loadGrid() {
showGrid();
statusBar.textContent = '加载中...';
grid.innerHTML = '';
pager.classList.add('hidden');
mainEl.scrollTop = 0;
const res = state.mode === 'search'
? await window.api.sources.search(state.sourceId, state.keyword, state.page)
: await window.api.sources.browse(state.sourceId, state.page);
if (!res.ok) {
const isAuth = state.sourceId === 'zlib' && /登录|登录|AUTH/i.test(res.error || '');
statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} <button class="retry-btn" id="gridRetry">重试</button>`;
if (isAuth) {
grid.innerHTML = '<div class="empty">Z-Library 需要登录,请到"设置"页配置账号</div>';
} else {
grid.innerHTML = '<div class="empty">该数据源暂时不可用,可切换其它源</div>';
}
$('gridRetry').onclick = loadGrid;
return;
}
const { items, maxPage } = res.data;
state.maxPage = maxPage || 1;
if (!items.length) {
grid.innerHTML = '<div class="empty">未找到相关结果</div>';
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}` : '';
return;
}
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : '';
grid.innerHTML = items.map((it) => `
<div class="card" data-id="${escapeHtml(it.postId)}">
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
<div class="card-title">${escapeHtml(it.title)}</div>
${it.subtitle ? `<div class="card-sub">${escapeHtml(it.subtitle)}</div>` : ''}
${it.date ? `<div class="card-date">${escapeHtml(it.date)}</div>` : ''}
</div>`).join('');
grid.querySelectorAll('.card').forEach((el) => {
el.onclick = () => openDetail(el.dataset.id);
});
$('pageInfo').textContent = `${state.page} / ${state.maxPage}`;
$('prevBtn').disabled = state.page <= 1;
$('nextBtn').disabled = state.page >= state.maxPage;
const jump = $('jumpInput');
jump.max = state.maxPage;
jump.value = '';
jump.placeholder = state.page;
pager.classList.remove('hidden');
}
function jumpToPage() {
const input = $('jumpInput');
let n = parseInt(input.value, 10);
if (!n || n < 1) return;
if (n > state.maxPage) n = state.maxPage;
if (n === state.page) return;
state.page = n;
loadGrid();
}
function showGrid() {
detailView.classList.add('hidden');
gridView.classList.remove('hidden');
if (state.scrollY) mainEl.scrollTop = state.scrollY;
}
async function openDetail(postId) {
state.scrollY = mainEl.scrollTop;
state.currentPostId = postId;
gridView.classList.add('hidden');
detailView.classList.remove('hidden');
mainEl.scrollTop = 0;
detailContent.innerHTML = '<div class="dl-loading">加载中...</div>';
const res = await window.api.sources.detail(state.sourceId, postId);
if (!res.ok) {
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
$('detailRetry').onclick = () => openDetail(postId);
return;
}
state.currentDetail = res.data;
renderDetail(res.data);
loadDownload(postId);
refreshAddButton();
}
function renderDetail(d) {
const tagsHtml = (d.tags || []).map((t) => {
const idx = t.indexOf('');
if (idx > 0) return `<div class="meta-row"><b>${escapeHtml(t.slice(0, idx))}</b>${escapeHtml(t.slice(idx + 1))}</div>`;
return `<div class="meta-row">${escapeHtml(t)}</div>`;
}).join('');
const authorsHtml = (d.authors && d.authors.length)
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
const briefHtml = d.brief ? `<div class="section-title">简介 / 摘要</div><div class="brief-panel">${escapeHtml(d.brief)}</div>` : '';
detailContent.innerHTML = `
<div class="detail-head">
<div class="detail-cover" style="${coverStyle(d.cover)}">${d.cover ? '' : `<div class="ph">${escapeHtml(d.title)}</div>`}</div>
<div class="detail-meta">
<div class="detail-title">${escapeHtml(d.title)}</div>
${authorsHtml}
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
${tagsHtml}
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
</div>
</div>
<div class="section-title">下载 / 全文</div>
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
${briefHtml}
`;
$('addLibBtn').onclick = addToLibrary;
const urlLink = $('detailUrlLink');
if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); };
}
async function refreshAddButton() {
const btn = $('addLibBtn');
if (!btn) return;
const res = await window.api.library.findBySource(state.sourceId, state.currentPostId);
if (res.ok && res.data) {
btn.textContent = '已在书库 ✓';
btn.disabled = true;
btn.classList.add('in-lib');
}
}
async function addToLibrary() {
const d = state.currentDetail;
if (!d) return;
const res = await window.api.library.add({
title: d.title,
authors: d.authors || [],
cover: d.cover,
date: d.date || '',
brief: d.brief || '',
url: d.url || '',
sourceId: state.sourceId,
sourcePostId: state.currentPostId
});
if (res.ok) {
const btn = $('addLibBtn');
btn.textContent = '已加入书库 ✓';
btn.disabled = true;
btn.classList.add('in-lib');
if (window.Library) window.Library.markDirty();
}
}
async function loadDownload(postId) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = '<div class="dl-loading">下载信息获取中...</div>';
const res = await window.api.sources.download(state.sourceId, postId);
if (!box.isConnected) return;
if (!res.ok) {
box.innerHTML = `<div class="dl-error">获取失败:${escapeHtml(res.error)}<button class="retry-btn" id="dlRetry">重试</button></div>`;
$('dlRetry').onclick = () => loadDownload(postId);
return;
}
const d = res.data;
let html = '';
const files = d.files || [];
if (files.length) {
html += `<div class="dl-files">${files.map((f) => `<div class="dl-file-row">
<span class="dl-file-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
${f.format ? `<span class="dl-fmt">${escapeHtml(f.format)}</span>` : ''}
<button class="copy-btn" data-copy="${escapeHtml(f.link)}">复制</button>
<button class="dl-btn" data-file="${escapeHtml(f.link)}" data-name="${escapeHtml(f.name)}">下载</button>
</div>`).join('')}</div>`;
}
const links = d.links || [];
if (links.length) {
html += links.map((l) => `<div class="dl-link-row">🔗 <a href="#" data-open="${escapeHtml(l.url)}">${escapeHtml(l.name)}</a></div>`).join('');
}
box.innerHTML = html || '<div class="dl-error">未解析到下载信息</div>';
box.querySelectorAll('.copy-btn').forEach((btn) => { btn.onclick = () => copyText(btn, btn.dataset.copy); });
box.querySelectorAll('.dl-btn[data-file]').forEach((btn) => { btn.onclick = () => downloadFile(btn, btn.dataset.file); });
box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; });
}
async function downloadFile(btn, url) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '下载中...';
const lib = await window.api.library.findBySource(state.sourceId, state.currentPostId);
const entryId = (lib.ok && lib.data) ? lib.data.id : undefined;
const res = await window.api.downloadFile(url, btn.dataset.name || '', entryId);
if (res.ok && res.data && res.data.canceled) {
btn.textContent = orig; btn.disabled = false;
return;
}
if (res.ok) {
btn.textContent = '已保存 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); btn.disabled = false; }, 2000);
} else {
btn.textContent = '失败';
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000);
}
}
return { init, reloadSources: loadSources };
})();
window.Browse = Browse;
+114
View File
@@ -0,0 +1,114 @@
const Library = (() => {
let dirty = true;
let sortMode = localStorage.getItem('libSortMode') || 'added';
let grid, statusEl;
const SORTERS = {
added: (a, b) => (b.addedAt || 0) - (a.addedAt || 0),
title: (a, b) => String(a.title).localeCompare(String(b.title), 'zh'),
author: (a, b) => String((a.authors || [])[0] || '').localeCompare(String((b.authors || [])[0] || ''), 'zh')
};
function init() {
grid = $('libGrid');
statusEl = $('libStatus');
$('addLocalBtn').onclick = addLocal;
window.api.library.onChanged(() => { dirty = true; refresh(true); });
}
function getSortMode() { return sortMode; }
function setSortMode(m) {
sortMode = m;
localStorage.setItem('libSortMode', m);
dirty = true;
refresh(true);
}
async function refresh(force) {
if (!force && !dirty) return;
const res = await window.api.library.list();
dirty = false;
if (!res.ok) { statusEl.textContent = '加载失败:' + res.error; return; }
const items = res.data.slice().sort(SORTERS[sortMode] || SORTERS.added);
statusEl.textContent = `${items.length}`;
if (!items.length) {
grid.innerHTML = '<div class="empty">书库为空,去「检索」页添加文献 / 图书吧</div>';
return;
}
grid.innerHTML = items.map((it) => {
const hasFile = (it.files || []).some((f) => f.path);
const badge = hasFile
? '<span class="card-badge">已下载</span>'
: '<span class="card-badge miss">未下载</span>';
return `
<div class="card" data-id="${escapeHtml(it.id)}">
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
<div class="card-title">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
<div class="lib-card-actions">
<button class="open-btn" data-act="open" ${hasFile ? '' : 'disabled'}>打开</button>
${it.url ? '<button data-act="page">页面</button>' : ''}
<button data-act="remove">移除</button>
</div>
</div>`;
}).join('');
grid.querySelectorAll('.card').forEach((el) => {
const id = el.dataset.id;
el.querySelectorAll('button').forEach((btn) => {
btn.onclick = (e) => { e.stopPropagation(); onAction(id, btn.dataset.act); };
});
});
}
async function onAction(id, act) {
const res = await window.api.library.get(id);
if (!res.ok || !res.data) return;
const it = res.data;
if (act === 'open') {
const f = (it.files || []).find((x) => x.path);
if (f) window.api.openPath(f.path);
} else if (act === 'page') {
if (it.url) window.api.openExternal(it.url);
} else if (act === 'remove') {
const hasFile = (it.files || []).some((f) => f.path);
const r = await openModal('移除条目', `
<p>确定移除「${escapeHtml(it.title)}」吗?</p>
${hasFile ? '<p style="margin-top:8px"><label><input type="checkbox" id="delFiles" /> 同时删除已下载的文件</label></p>' : ''}
`, () => ({ del: !!(document.getElementById('delFiles') || {}).checked }));
if (!r) return;
await window.api.library.remove(id, r.del);
dirty = true;
refresh(true);
}
}
async function addLocal() {
const r = await window.api.pickFile();
if (!r.ok || !r.data) return;
const { path: p, name } = r.data;
const res = await openModal('添加本地文件', `
<p>文件:${escapeHtml(p)}</p>
<input type="text" id="localTitle" placeholder="标题" value="${escapeHtml(name)}" />
<input type="text" id="localAuthor" placeholder="作者(可选)" />
`, () => ({
title: (document.getElementById('localTitle').value || name).trim(),
author: (document.getElementById('localAuthor').value || '').trim()
}));
if (!res) return;
await window.api.library.add({
title: res.title,
authors: res.author ? [res.author] : [],
files: [{ path: p, name: p.split(/[\\/]/).pop(), format: (p.split('.').pop() || '').toUpperCase() }]
});
dirty = true;
refresh(true);
}
function markDirty() { dirty = true; }
return { init, refresh, markDirty, getSortMode, setSortMode };
})();
window.Library = Library;