From de3e1d8a44f68c0cb0d6411ad229a902ce66ceea Mon Sep 17 00:00:00 2001 From: lofyer Date: Sun, 26 Jul 2026 11:59:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A3=80=E7=B4=A2=E9=A1=B5=E8=81=9A?= =?UTF-8?q?=E5=90=88=E6=90=9C=E7=B4=A2=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=85=B3=E9=94=AE=E8=AF=8D=E5=AF=BC=E8=87=B4=E4=B8=BB?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次查询所有已启用数据源并按源分组展示,每组最多 5 条, 超出时提供「查看更多」跳转到该源的单源检索。 同时修复聚合并发暴露出的网络层问题: - 主进程崩溃:Memory of the World 会把搜索关键词原样回写进 ETag 响应头,中文关键词下 Electron net.fetch 在内部 emit 回调里抛 ByteString TypeError,await 无法捕获,主进程直接 崩溃且 Promise 永不 settle。现拦截该类异常并回退到 undici。 - OpenLibrary / LibGen 要求关键词至少 3 字符,否则返回 422。 改为前置校验,直接返回空结果加说明,耗时从 14s 降到 3ms。 - 重试只针对可自愈错误(超时/5xx/限流),不再为 4xx 白等; 重试时收紧超时,避免最坏耗时翻倍。镜像轮询类源关闭内层 重试,换镜像交给 tryMirrors/raceMirrors。 - 网络错误文案改为可读提示,不再把整条 URL 抛给用户。 - Sci-Hub 非 DOI 关键词返回空而非报错,避免聚合结果刷屏。 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 138 ++++++++++++++++++++++++++ src/sources/gutenberg.js | 11 ++- src/sources/http.js | 169 ++++++++++++++++++++++++++++--- src/sources/libgen.js | 10 +- src/sources/openlibrary.js | 12 +-- src/sources/scihub.js | 9 +- src/sources/semanticscholar.js | 13 +-- src/sources/zlib.js | 4 +- src/ui/style.css | 15 +++ src/ui/views/browse.js | 175 ++++++++++++++++++++++++++++----- 10 files changed, 484 insertions(+), 72 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..20aee76 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# PeopleLib + +开放获取文献与图书的桌面客户端(Electron)。在一个界面里检索多个公开文献源,查看详情,下载文件并归入本地书库。 + +## 功能 + +- **多源检索**:12 个数据源统一的搜索、详情、下载流程 +- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理 +- **全局代理**:一处配置,对所有数据源与封面请求生效 +- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用 +- **Z-Library 登录**:凭据本地保存,会话过期自动重新登录 + +## 数据源 + +| 源 | ID | 说明 | +|---|---|---| +| arXiv 论文 | `arxiv` | 预印本,支持全文检索 | +| Gutenberg 公版书 | `gutenberg` | 公共领域图书 | +| Open Library 图书 | `openlibrary` | 图书元数据与借阅入口 | +| DOAJ 开放期刊 | `doaj` | 开放获取期刊论文 | +| PMC 生物医学 | `pmc` | PubMed Central 全文 | +| bioRxiv 预印本 | `biorxiv` | 仅浏览最新列表,不支持关键词搜索 | +| Standard Ebooks | `standardebooks` | 精校排版的公版电子书 | +| Semantic Scholar | `semanticscholar` | 学术论文检索 | +| Memory of the World | `motw` | 公共图书馆藏书 | +| Library Genesis | `libgen` | 图书检索,下载需站点账号 | +| Z-Library | `zlib` | 需登录,可获取下载直链 | +| Sci-Hub(按 DOI) | `scihub` | 按 DOI 查询 | + +数据源的可用性取决于站点自身状态。部分站点(如 Sci-Hub)启用了人机验证,程序会给出明确提示而非静默失败。 + +## 环境要求 + +- Node.js 18+ +- Windows / macOS / Linux + +## 快速开始 + +```bash +npm install +npm start +``` + +## 打包 + +生成 Windows 免安装版到 `dist/`: + +```bash +npm run portable +``` + +## 配置 + +### 代理 + +在应用内「设置」填写代理地址,例如 `http://127.0.0.1:7897`。留空表示直连。配置会持久化,重启后仍生效,并应用于所有数据源请求与封面加载。 + +网络受限环境下,多数数据源需要代理才能访问。 + +### Z-Library 账号 + +「设置」中填入邮箱与密码即可登录。登录后可获取下载直链(免费账号有每日下载额度限制)。 + +> 凭据以 base64 混淆后保存在本地 `zlib-auth.json`,**这只是防止肉眼直读,不是加密**。请勿在不受信任的机器上使用。 + +## 数据位置 + +| 模式 | 路径 | +|---|---| +| 开发运行 | `%APPDATA%/PeopleLib`(Windows) | +| 打包运行 | 可执行文件同级的 `data/` 目录 | + +该目录包含: + +- `library.json` 书库索引 +- `settings.json` 应用设置(代理等) +- `zlib-auth.json` Z-Library 凭据与会话 +- `covers/` 封面缓存 + +## 项目结构 + +``` +main.js 主进程:窗口、IPC、代理与证书处理、文件下载 +preload.js 渲染进程 API 桥接 +src/ + settings.js 设置持久化 + library/store.js 书库存储 + sources/ + index.js 数据源注册表 + http.js 统一 HTTP 层:超时、Cookie、代理 + mirror.js 镜像故障转移 + zlib-auth.js Z-Library 凭据存储 + .js 各数据源实现 + ui/ 渲染进程界面 +``` + +### 数据源接口 + +每个数据源模块导出以下结构: + +```js +module.exports = { + id: 'example', + name: '示例源', + supportsSearch: true, + + async list(page) // 浏览:{ items, maxPage, page } + async search(keyword, page) // 搜索:{ items, maxPage, page } + async detail(postId) // 详情:{ title, authors, cover, tags, brief, links } + async download(postId) // 下载:{ files, links } +}; +``` + +新增数据源只需实现该接口,并在 `src/sources/index.js` 中注册。 + +### 网络层说明 + +- 主进程使用 Electron 的 `net.fetch`(Chromium 网络栈),代理通过 `session.setProxy` 生效 +- 纯 Node 环境回退到 undici `ProxyAgent`(Electron 下不启用,该组合存在兼容问题) +- 所有请求默认 15 秒超时,可按调用点覆盖 +- 失效镜像进入 5 分钟冷却,之后自动重试,避免站点恢复后被永久跳过 +- **应用启动时全局忽略 TLS 证书错误**。这是为了兼容大量使用自签名或过期证书的镜像站点,代价是失去对中间人攻击的防护,请仅在受信任的网络环境中使用。 + +## 测试 + +```bash +npx electron test-search.js --proxy http://127.0.0.1:7897 +``` + +对主要数据源依次执行搜索、详情、下载链路,输出每一步耗时与结果。 + +## 免责声明 + +本项目仅是对公开网络接口的客户端封装,不托管、不分发任何内容。部分数据源所提供作品的版权状态因司法辖区而异,使用者需自行确保其使用方式符合当地法律与各站点的服务条款。 + +## 许可 + +MIT diff --git a/src/sources/gutenberg.js b/src/sources/gutenberg.js index 3df28f2..782db55 100644 --- a/src/sources/gutenberg.js +++ b/src/sources/gutenberg.js @@ -2,6 +2,9 @@ const { fetchJson, clampPage } = require('./http'); const BASE = 'https://gutendex.com/books'; +// gutendex 偶发瞬时超时,fetchJson 默认已自动重试一次 +const getJson = fetchJson; + function coverOf(formats) { if (!formats) return ''; return formats['image/jpeg'] || formats['image/png'] || ''; @@ -49,7 +52,7 @@ module.exports = { async list(page) { page = clampPage(page); - const j = await fetchJson(`${BASE}?page=${page}`); + const j = await getJson(`${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 }; @@ -57,14 +60,14 @@ module.exports = { async search(keyword, page) { page = clampPage(page); - const j = await fetchJson(`${BASE}?search=${encodeURIComponent(keyword)}&page=${page}`); + const j = await getJson(`${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)}`); + const b = await getJson(`${BASE}/${encodeURIComponent(postId)}`); return { postId: String(b.id), title: b.title, @@ -79,7 +82,7 @@ module.exports = { }, async download(postId) { - const b = await fetchJson(`${BASE}/${encodeURIComponent(postId)}`); + const b = await getJson(`${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, diff --git a/src/sources/http.js b/src/sources/http.js index 8f69b21..d2f7461 100644 --- a/src/sources/http.js +++ b/src/sources/http.js @@ -9,9 +9,8 @@ function setProxy(url) { proxyUrl = String(url || '').trim(); dispatcher = null; if (!proxyUrl) return; - // Electron 下由 session.setProxy 统一接管代理,不需要 undici dispatcher。 - // 仅在纯 Node 环境(脚本/测试)才构建 ProxyAgent。 - if (process.versions.electron) return; + // Electron 下代理主要由 session.setProxy 接管;这里仍然构建 ProxyAgent, + // 供 net.fetch 不可用时的 undici 回退路径使用。 try { const { ProxyAgent } = require('undici'); dispatcher = new ProxyAgent({ @@ -25,18 +24,96 @@ function setProxy(url) { 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 */ } +// --- Electron net.fetch 响应头非 ASCII 崩溃的兜底 --- +// 部分站点(如 Memory of the World)会把搜索关键词原样回写进 ETag 等响应头。 +// 关键词含中文时,Electron 的 net.fetch 在其内部 emit 回调里抛出 +// "Cannot convert argument to a ByteString"。该异常无法被 await 捕获: +// 主进程直接崩溃,且对应的 fetch Promise 永远不会 settle。 +// +// 兜底策略:拦截这一类 uncaughtException(其余异常原样交还默认处理), +// 记录 origin 后让仍在挂起中的同源请求转为可捕获的失败,再用 undici 重试。 +// 加宽限期是因为异常与请求之间无法直接关联,宽限期内正常返回的请求不受影响。 +const HEADER_BUG_GRACE = 1200; + +// origin -> 该源已知会触发响应头崩溃(后续请求直接走 undici) +const headerBugOrigins = new Set(); +// 正在进行中的 net.fetch: origin -> Set +const pendingByOrigin = new Map(); + +function originOf(url) { + try { return new URL(url).origin; } catch (e) { return ''; } +} + +function isHeaderByteStringError(e) { + return !!e && (e instanceof TypeError || e.name === 'TypeError') && /ByteString/i.test(e.message || ''); +} + +let rethrowing = false; +function onUncaught(e) { + if (isHeaderByteStringError(e)) { + // 只有在宽限期后仍未返回的请求,才判定为被该异常卡死 + const victims = []; + for (const set of pendingByOrigin.values()) victims.push(...set); + setTimeout(() => { for (const fail of victims) fail(); }, HEADER_BUG_GRACE); + return; } + if (rethrowing) return; + rethrowing = true; + process.removeListener('uncaughtException', onUncaught); + setImmediate(() => { throw e; }); +} +process.on('uncaughtException', onUncaught); + +function undiciFetch(url, options) { if (dispatcher) return fetch(url, { ...options, dispatcher }); return fetch(url, options); } +function netFetch(url, options) { + const { net } = require('electron'); + const origin = originOf(url); + return new Promise((resolve, reject) => { + let settled = false; + let set = pendingByOrigin.get(origin); + if (!set) { set = new Set(); pendingByOrigin.set(origin, set); } + + const fail = () => { + if (settled) return; + settled = true; + cleanup(); + headerBugOrigins.add(origin); + const err = new Error('响应头包含非 ASCII 字符,Electron 网络栈无法处理'); + err.code = 'HEADER_BYTESTRING'; + reject(err); + }; + function cleanup() { + set.delete(fail); + if (!set.size) pendingByOrigin.delete(origin); + } + set.add(fail); + + net.fetch(url, options).then( + (r) => { if (settled) return; settled = true; cleanup(); resolve(r); }, + (e) => { if (settled) return; settled = true; cleanup(); reject(e); } + ); + }); +} + +async function fetchWithProxy(url, options = {}) { + // Electron 主进程优先用 net.fetch(走 Chromium 网络栈,由 session.setProxy 控制代理) + if (process.versions.electron) { + // 已知有问题的源直接走 undici,避免每次都重新触发一遍崩溃 + if (headerBugOrigins.has(originOf(url))) return undiciFetch(url, options); + try { + return await netFetch(url, options); + } catch (e) { + if (e && e.code === 'HEADER_BYTESTRING') return undiciFetch(url, options); + throw e; + } + } + return undiciFetch(url, options); +} + // 简易 cookie jar: Map> const cookieJar = new Map(); @@ -101,7 +178,12 @@ async function fetchRaw(url, options = {}) { return res; } catch (e) { if (e && (e.name === 'AbortError' || /abort/i.test(e.message || ''))) { - throw new Error(`请求超时: ${url}`); + throw new Error('请求超时,站点无响应'); + } + // undici / Chromium 的底层网络错误信息很不友好,统一换成可读文案 + const msg = (e && e.message) || String(e); + if (/fetch failed|ERR_|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|socket hang up/i.test(msg)) { + throw new Error('网络连接失败,请检查网络或代理设置'); } throw e; } finally { @@ -109,18 +191,49 @@ async function fetchRaw(url, options = {}) { } } -async function fetchText(url, options = {}) { +const STATUS_HINT = { + 403: '站点拒绝访问(403)', + 404: '资源不存在(404)', + 422: '查询条件不被接受(422)', + 429: '请求过于频繁,请稍后再试(429)', + 500: '站点内部错误(500)', + 502: '站点网关错误(502)', + 503: '站点暂时不可用(503)', + 504: '站点网关超时(504)' +}; + +async function fetchOnce(url, options) { const res = await fetchRaw(url, options); - if (!res.ok) throw new Error(`请求失败: ${res.status} ${url}`); + if (!res.ok) throw new Error(STATUS_HINT[res.status] || `请求失败(HTTP ${res.status})`); return res.text(); } +// 默认对瞬时故障(超时/网络抖动/5xx/限流)自动重试一次。 +// 镜像轮询类数据源(LibGen / Sci-Hub / Z-Library)应传 retries: 0, +// 由上层的 tryMirrors / raceMirrors 负责换镜像,避免重试叠加放大耗时。 +// +// 重试会把最坏耗时翻倍(超时 15s -> 30s),聚合搜索下体感很差。 +// 因此重试时缩短单次超时:宁可放弃这次重试,也不要让整组结果一直转圈。 +function fetchText(url, options = {}) { + const { retries, retryDelay, ...rest } = options; + const tries = (retries === undefined ? 1 : retries) + 1; + if (tries <= 1) return fetchOnce(url, rest); + + const baseTimeout = rest.timeout === undefined ? DEFAULT_TIMEOUT : rest.timeout; + return withRetry( + (attempt) => fetchOnce(url, attempt === 0 + ? rest + : { ...rest, timeout: Math.max(3000, Math.round(baseTimeout / 2)) }), + { tries, delay: retryDelay || 800 } + ); +} + async function fetchJson(url, options = {}) { const text = await fetchText(url, options); try { return JSON.parse(text); } catch (e) { - throw new Error(`JSON 解析失败: ${url}`); + throw new Error('返回内容不是有效 JSON'); } } @@ -145,4 +258,30 @@ function clampPage(n, min) { return n; } -module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy }; +// 部分站点(OpenLibrary / LibGen)对过短的关键词直接返回 422。 +// 与其把 HTTP 错误抛给用户,不如提前返回空结果并带上说明。 +function tooShort(keyword, min = 3) { + const kw = String(keyword || '').trim(); + if (kw.length >= min) return null; + return { items: [], maxPage: 1, page: 1, note: `关键词太短,该源要求至少 ${min} 个字符` }; +} + +// 可自愈的错误:超时、网络抖动、5xx、限流。4xx 属请求本身的问题,重试无意义。 +function isRetryable(e) { + return /超时|网络连接失败|站点内部错误|站点网关|暂时不可用|过于频繁/.test((e && e.message) || ''); +} + +// 带退避的重试包装。仅在错误可自愈时重试,避免为 4xx 白等几秒。 +// fn 会收到当前尝试序号(0 起),便于按次调整超时等参数。 +async function withRetry(fn, { tries = 2, delay = 1000 } = {}) { + for (let i = 0; ; i++) { + try { + return await fn(i); + } catch (e) { + if (i >= tries - 1 || !isRetryable(e)) throw e; + await new Promise((r) => setTimeout(r, delay * (i + 1))); + } + } +} + +module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy }; diff --git a/src/sources/libgen.js b/src/sources/libgen.js index ce00c37..48f38de 100644 --- a/src/sources/libgen.js +++ b/src/sources/libgen.js @@ -9,7 +9,7 @@ // 策略:优先用新版站点搜索(当前唯一可用);经典镜像作为兜底, // 一旦恢复即可自动参与(raceMirrors 有 5 分钟冷却重试机制)。 -const { fetchText, decodeEntities, clampPage } = require('./http'); +const { fetchText, decodeEntities, clampPage, tooShort } = require('./http'); const { raceMirrors } = require('./mirror'); // 新版站点(当前可用) @@ -166,7 +166,7 @@ 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 html = await fetchText(url, { timeout: TIMEOUT, retries: 0 }); const items = parseWebResults(html, base); if (!items.length && !/searchResultBox|resItemBox|Nothing found/i.test(html)) { throw new Error('页面结构无法识别'); @@ -185,7 +185,7 @@ function buildDownloadLinks(md5) { async function fetchBookPage(id) { return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => { - const html = await fetchText(`${base}/book/${id}`, { timeout: TIMEOUT }); + const html = await fetchText(`${base}/book/${id}`, { timeout: TIMEOUT, retries: 0 }); return { html, base }; }); } @@ -200,7 +200,7 @@ module.exports = { // 新版站点有 /popular 榜单 try { const r = await raceMirrors('libgen-web', WEB_MIRRORS, async (base) => { - const html = await fetchText(`${base}/popular`, { timeout: TIMEOUT }); + const html = await fetchText(`${base}/popular`, { timeout: TIMEOUT, retries: 0 }); return { items: parseWebResults(html, base), base }; }); return { items: r.items, maxPage: 1, page: 1 }; @@ -213,6 +213,8 @@ module.exports = { page = clampPage(page); const q = String(keyword || '').trim(); if (!q) return { items: [], maxPage: 1, page }; + const short = tooShort(q); + if (short) return { ...short, page }; let r; try { diff --git a/src/sources/openlibrary.js b/src/sources/openlibrary.js index b6f1918..a04f79e 100644 --- a/src/sources/openlibrary.js +++ b/src/sources/openlibrary.js @@ -1,14 +1,10 @@ -const { fetchJson, clampPage } = require('./http'); +const { fetchJson, clampPage, tooShort } = 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 fetchWithRetry(url) { + return fetchJson(url, { retryDelay: 1200 }); } function coverOf(doc) { @@ -44,6 +40,8 @@ module.exports = { async search(keyword, page) { page = clampPage(page); + const short = tooShort(keyword); + if (short) return { ...short, 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)); diff --git a/src/sources/scihub.js b/src/sources/scihub.js index ead7131..5c748a9 100644 --- a/src/sources/scihub.js +++ b/src/sources/scihub.js @@ -66,7 +66,7 @@ function extractPdf(html, base) { async function fetchSciHub(base, doi) { const url = `${base}/${doi}`; - const html = await fetchText(url); + const html = await fetchText(url, { retries: 0 }); if (isChallenge(html)) { throw new Error(`${base} 启用了人机验证`); } @@ -97,13 +97,12 @@ module.exports = { return { items: [], maxPage: 1, page: 1 }; }, + // 仅支持 DOI。非 DOI 关键词返回空而不是抛错, + // 这样在聚合搜索里不会被当成"失败的源"刷屏。 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'); - } + if (!doi || !DOI_RE.test(doi)) return { items: [], maxPage: 1, page: 1, note: '仅支持 DOI 查询,例如 10.1038/nature12373' }; const r = await resolve(doi); return { items: [{ diff --git a/src/sources/semanticscholar.js b/src/sources/semanticscholar.js index befc932..80b81c0 100644 --- a/src/sources/semanticscholar.js +++ b/src/sources/semanticscholar.js @@ -4,16 +4,9 @@ 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; +// 该 API 对匿名调用限流很严。只重试一次,避免在聚合搜索里长时间阻塞整组结果。 +function fetchWithRetry(url) { + return fetchJson(url, { retryDelay: 1500 }); } function toItem(p) { diff --git a/src/sources/zlib.js b/src/sources/zlib.js index bffbe51..d7a6626 100644 --- a/src/sources/zlib.js +++ b/src/sources/zlib.js @@ -67,6 +67,7 @@ async function doLogin() { const j = await fetchJson(apiUrl(m, '/eapi/user/login'), { method: 'POST', headers: FORM, + retries: 0, body: form({ email: creds.email, password: creds.password }) }); if (!j || !j.success || !j.user) throw new Error(errMessage(j) || '登录失败'); @@ -88,10 +89,11 @@ async function callOn(mirror, path, params, session, method) { j = await fetchJson(apiUrl(mirror, path), { method: 'POST', headers: FORM, + retries: 0, body: form({ ...params, ...cred }) }); } else { - j = await fetchJson(apiUrl(mirror, path, { ...params, ...cred })); + j = await fetchJson(apiUrl(mirror, path, { ...params, ...cred }), { retries: 0 }); } const msg = errMessage(j); if (msg) { diff --git a/src/ui/style.css b/src/ui/style.css index e1e7fd7..55d1dee 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -140,6 +140,21 @@ body { .empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; } +/* 聚合搜索:按源分组 */ +.agg-list { display: flex; flex-direction: column; gap: 22px; } +.agg-list .empty { grid-column: auto; } +.agg-group { border-top: 1px solid var(--line); padding-top: 12px; } +.agg-group:first-child { border-top: none; padding-top: 0; } +.agg-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.agg-name { font-size: 14px; font-weight: 600; color: var(--text); } +.agg-count { font-size: 12px; color: var(--text-dim); } +.agg-count.err { color: var(--danger); } +.agg-more { margin-left: auto; } +.agg-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +.agg-loading, .agg-msg { font-size: 12px; color: var(--text-dim); padding: 6px 0; } +.agg-err { color: var(--danger); } +.agg-group.agg-empty, .agg-group.agg-failed { opacity: 0.55; } + /* 分页 */ .pager { position: sticky; bottom: -20px; z-index: 6; diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js index f23a917..5ec3474 100644 --- a/src/ui/views/browse.js +++ b/src/ui/views/browse.js @@ -1,4 +1,7 @@ const Browse = (() => { + const AGG_ID = '__all__'; + const AGG_LIMIT = 5; + const state = { sourceId: null, supportsSearch: true, @@ -7,6 +10,9 @@ const Browse = (() => { page: 1, maxPage: 1, scrollY: 0, + sourceList: [], + aggToken: 0, + activeSourceId: null, currentPostId: null, currentDetail: null }; @@ -34,9 +40,7 @@ const Browse = (() => { $('backBtn').onclick = showGrid; sourceSelect.onchange = () => { - state.sourceId = sourceSelect.value; - state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0'; - updateSearchUi(); + applySourceSelection(sourceSelect.value); state.mode = 'list'; state.page = 1; clearSearchUi(); @@ -46,10 +50,21 @@ const Browse = (() => { loadSources(); } + function isAgg() { return state.sourceId === AGG_ID; } + + function applySourceSelection(id) { + state.sourceId = id; + const opt = sourceSelect.selectedOptions[0]; + state.supportsSearch = id === AGG_ID ? true : (opt ? opt.dataset.search !== '0' : true); + updateSearchUi(); + } + function updateSearchUi() { searchInput.disabled = !state.supportsSearch; $('searchBtn').disabled = !state.supportsSearch; - searchInput.placeholder = state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览'; + searchInput.placeholder = isAgg() + ? '聚合搜索:一次查询所有已启用数据源...' + : (state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览'); } async function loadSources() { @@ -57,7 +72,10 @@ const Browse = (() => { 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) => + state.sourceList = list; + const aggOpt = list.filter((s) => s.supportsSearch).length > 1 + ? `` : ''; + sourceSelect.innerHTML = aggOpt + list.map((s) => ``).join(''); if (!list.length) { state.sourceId = null; @@ -66,10 +84,21 @@ const Browse = (() => { statusBar.textContent = ''; return; } - if (!list.some((s) => s.id === state.sourceId)) state.sourceId = list[0].id; + const stillValid = state.sourceId === AGG_ID ? !!aggOpt : list.some((s) => s.id === state.sourceId); + if (!stillValid) state.sourceId = aggOpt ? AGG_ID : list[0].id; sourceSelect.value = state.sourceId; - state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0'; - updateSearchUi(); + applySourceSelection(state.sourceId); + loadGrid(); + } + + function gotoSource(sourceId, keyword) { + sourceSelect.value = sourceId; + applySourceSelection(sourceId); + state.mode = 'search'; + state.keyword = keyword; + state.page = 1; + searchInput.value = keyword; + $('clearSearchBtn').classList.remove('hidden'); loadGrid(); } @@ -98,16 +127,21 @@ const Browse = (() => { } async function loadGrid() { + state.aggToken++; showGrid(); statusBar.textContent = '加载中...'; grid.innerHTML = ''; pager.classList.add('hidden'); mainEl.scrollTop = 0; + if (isAgg()) return loadAggregate(); + 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); + grid.className = 'grid'; + if (!res.ok) { const isAuth = state.sourceId === 'zlib' && /登录|登录|AUTH/i.test(res.error || ''); statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} `; @@ -131,17 +165,8 @@ const Browse = (() => { statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : ''; - grid.innerHTML = items.map((it) => ` -
-
${it.cover ? '' : `
${escapeHtml(it.title)}
`}
-
${escapeHtml(it.title)}
- ${it.subtitle ? `
${escapeHtml(it.subtitle)}
` : ''} - ${it.date ? `
${escapeHtml(it.date)}
` : ''} -
`).join(''); - - grid.querySelectorAll('.card').forEach((el) => { - el.onclick = () => openDetail(el.dataset.id); - }); + grid.innerHTML = items.map(cardHtml).join(''); + bindCards(grid, state.sourceId); $('pageInfo').textContent = `第 ${state.page} / ${state.maxPage} 页`; $('prevBtn').disabled = state.page <= 1; @@ -153,6 +178,97 @@ const Browse = (() => { pager.classList.remove('hidden'); } + function cardHtml(it) { + return ` +
+
${it.cover ? '' : `
${escapeHtml(it.title)}
`}
+
${escapeHtml(it.title)}
+ ${it.subtitle ? `
${escapeHtml(it.subtitle)}
` : ''} + ${it.date ? `
${escapeHtml(it.date)}
` : ''} +
`; + } + + function bindCards(root, sourceId) { + root.querySelectorAll('.card').forEach((el) => { + el.onclick = () => openDetail(el.dataset.id, sourceId); + }); + } + + // 聚合搜索:并发查询所有支持搜索的已启用源,按源分组展示,每组最多 AGG_LIMIT 条 + async function loadAggregate() { + const targets = state.sourceList.filter((s) => s.supportsSearch); + grid.className = 'agg-list'; + + if (state.mode !== 'search' || !state.keyword) { + statusBar.textContent = ''; + grid.innerHTML = `
聚合搜索:输入关键词后将同时查询 ${targets.length} 个数据源
`; + return; + } + + const token = ++state.aggToken; + const keyword = state.keyword; + + statusBar.textContent = `聚合搜索「${keyword}」:0 / ${targets.length} 个源完成`; + grid.innerHTML = targets.map((s) => ` +
+
+ ${escapeHtml(s.name)} + 搜索中... +
+
加载中...
+
`).join(''); + + let done = 0; + let hit = 0; + let failed = 0; + + await Promise.all(targets.map(async (s) => { + const res = await window.api.sources.search(s.id, keyword, 1); + if (token !== state.aggToken || !grid.isConnected) return; + const sec = $(`agg-${s.id}`); + if (!sec) return; + const countEl = sec.querySelector('[data-role="count"]'); + const gridEl = sec.querySelector('[data-role="grid"]'); + const headEl = sec.querySelector('.agg-head'); + done++; + + if (!res.ok) { + failed++; + sec.classList.add('agg-failed'); + countEl.textContent = '失败'; + countEl.classList.add('err'); + gridEl.className = 'agg-msg'; + gridEl.innerHTML = `${escapeHtml(res.error)}`; + } else { + const items = (res.data && res.data.items) || []; + if (!items.length) { + sec.classList.add('agg-empty'); + countEl.textContent = '无结果'; + gridEl.className = 'agg-msg'; + gridEl.textContent = res.data.note || '未找到相关结果'; + } else { + hit++; + const shown = items.slice(0, AGG_LIMIT); + countEl.textContent = `${shown.length} 条`; + gridEl.className = 'grid agg-grid'; + gridEl.innerHTML = shown.map(cardHtml).join(''); + bindCards(gridEl, s.id); + if (items.length > AGG_LIMIT || (res.data.maxPage || 1) > 1) { + const more = document.createElement('button'); + more.className = 'tb-btn ghost sm agg-more'; + more.textContent = '查看更多 →'; + more.onclick = () => gotoSource(s.id, keyword); + headEl.appendChild(more); + } + } + } + statusBar.textContent = `聚合搜索「${keyword}」:${done} / ${targets.length} 个源完成`; + })); + + if (token !== state.aggToken) return; + statusBar.textContent = `聚合搜索「${keyword}」:${hit} 个源有结果${failed ? `,${failed} 个源失败` : ''}(共 ${targets.length} 个源)`; + } + function jumpToPage() { const input = $('jumpInput'); let n = parseInt(input.value, 10); @@ -169,18 +285,19 @@ const Browse = (() => { if (state.scrollY) mainEl.scrollTop = state.scrollY; } - async function openDetail(postId) { + async function openDetail(postId, sourceId) { state.scrollY = mainEl.scrollTop; state.currentPostId = postId; + state.activeSourceId = sourceId || state.sourceId; gridView.classList.add('hidden'); detailView.classList.remove('hidden'); mainEl.scrollTop = 0; detailContent.innerHTML = '
加载中...
'; - const res = await window.api.sources.detail(state.sourceId, postId); + const res = await window.api.sources.detail(state.activeSourceId, postId); if (!res.ok) { detailContent.innerHTML = `
加载失败:${escapeHtml(res.error)}
`; - $('detailRetry').onclick = () => openDetail(postId); + $('detailRetry').onclick = () => openDetail(postId, state.activeSourceId); return; } state.currentDetail = res.data; @@ -189,6 +306,11 @@ const Browse = (() => { refreshAddButton(); } + function sourceNameOf(id) { + const s = state.sourceList.find((x) => x.id === id); + return s ? s.name : id; + } + function renderDetail(d) { const tagsHtml = (d.tags || []).map((t) => { const idx = t.indexOf(':'); @@ -205,6 +327,7 @@ const Browse = (() => {
${escapeHtml(d.title)}
${authorsHtml} +
来源${escapeHtml(sourceNameOf(state.activeSourceId))}
${d.date ? `
日期${escapeHtml(d.date)}
` : ''} ${tagsHtml} ${d.url ? `` : ''} @@ -224,7 +347,7 @@ const Browse = (() => { async function refreshAddButton() { const btn = $('addLibBtn'); if (!btn) return; - const res = await window.api.library.findBySource(state.sourceId, state.currentPostId); + const res = await window.api.library.findBySource(state.activeSourceId, state.currentPostId); if (res.ok && res.data) { btn.textContent = '已在书库 ✓'; btn.disabled = true; @@ -242,7 +365,7 @@ const Browse = (() => { date: d.date || '', brief: d.brief || '', url: d.url || '', - sourceId: state.sourceId, + sourceId: state.activeSourceId, sourcePostId: state.currentPostId }); if (res.ok) { @@ -258,7 +381,7 @@ const Browse = (() => { const box = $('downloadBox'); if (!box) return; box.innerHTML = '
下载信息获取中...
'; - const res = await window.api.sources.download(state.sourceId, postId); + const res = await window.api.sources.download(state.activeSourceId, postId); if (!box.isConnected) return; if (!res.ok) { box.innerHTML = `
获取失败:${escapeHtml(res.error)}
`; @@ -290,7 +413,7 @@ const Browse = (() => { const orig = btn.textContent; btn.disabled = true; btn.textContent = '下载中...'; - const lib = await window.api.library.findBySource(state.sourceId, state.currentPostId); + const lib = await window.api.library.findBySource(state.activeSourceId, 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) {