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
+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: [] };
}
};