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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
1a1288ce18
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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 }] };
|
||||
}
|
||||
};
|
||||
@@ -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}` }]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
|
||||
.replace(/&/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 };
|
||||
@@ -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 };
|
||||
@@ -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-LD(Book 类型)
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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 }]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 走 query,POST 走 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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user