const test = require('node:test'); const assert = require('node:assert'); const h = require('./helpers'); h.installFetchStub(); const sources = require('../sources'); test('注册表:每个源都实现完整接口', () => { const list = sources.listSources(); assert.ok(list.length >= 16); assert.strictEqual(new Set(list.map((source) => source.id)).size, list.length, '数据源 ID 不能重复'); for (const s of list) { const m = sources.getSource(s.id); for (const fn of ['list', 'search', 'detail', 'download']) { assert.strictEqual(typeof m[fn], 'function', `${s.id}.${fn} 缺失`); } assert.ok(s.name, `${s.id} 缺 name`); } }); test('注册表:开放教材与中英文维基文库已启用', () => { const ids = sources.listSources().map((source) => source.id); for (const id of ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']) { assert.ok(ids.includes(id), `缺少新数据源: ${id}`); } }); test('注册表:未知 id 抛错', () => { assert.throws(() => sources.getSource('nope'), /未知数据源/); }); // --- PMC --- test('pmc: esearch 响应异常时给出可读错误而不是 TypeError', async () => { h.setHandler(h.routes([['esearch.fcgi', { body: { error: 'down' } }]])); await assert.rejects(sources.getSource('pmc').search('x', 1), /无法识别的检索结果/); }); test('pmc: postId 不重复拼 PMC 前缀', async () => { const seen = []; h.setHandler(h.routes([ ['esummary.fcgi', (u) => { seen.push(u); return h.makeResponse({ body: { result: { 123: { uid: '123', title: 'T', authors: [] } } } }); }] ])); const d = await sources.getSource('pmc').detail('PMC123'); assert.strictEqual(d.postId, '123'); assert.strictEqual(d.url, 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC123/'); assert.ok(!d.url.includes('PMCPMC'), 'URL 里出现了 PMCPMC'); assert.ok(seen[0].includes('id=123'), 'esummary 用了带前缀的 id'); }); test('pmc: 畸形 id 不会把正则搞崩', async () => { h.setHandler(() => h.makeResponse({ body: '' })); await assert.rejects(sources.getSource('pmc').download('12(3'), /无效的 PMC ID/); await assert.rejects(sources.getSource('pmc').download('.*'), /无效的 PMC ID/); }); test('pmc: 列表按 uid 归一化 postId', async () => { h.setHandler(h.routes([ ['esearch.fcgi', { body: { esearchresult: { count: '40', idlist: ['777'] } } }], ['esummary.fcgi', { body: { result: { 777: { uid: '777', title: 'A', authors: [{ name: 'X' }], pubdate: '2020 Jan' } } } }] ])); const r = await sources.getSource('pmc').search('kw', 1); assert.strictEqual(r.items[0].postId, '777'); assert.strictEqual(r.maxPage, 2); }); // --- DOAJ --- test('doaj: postId 不被二次编码', async () => { const urls = []; h.setHandler(h.routes([ ['search/articles', { body: { total: 1, results: [{ id: '10.1234/abc', bibjson: { title: 'T', author: [], link: [] } }] } }], ['api/v2/articles/', (u) => { urls.push(u); return h.makeResponse({ body: { bibjson: { title: 'T', author: [], link: [] } } }); }] ])); const doaj = sources.getSource('doaj'); const r = await doaj.search('kw', 1); assert.strictEqual(r.items[0].postId, '10.1234/abc', 'postId 不该预先编码'); await doaj.detail(r.items[0].postId); assert.ok(urls[0].includes('10.1234%2Fabc'), '详情 URL 编码错误: ' + urls[0]); assert.ok(!urls[0].includes('%252F'), '出现二次编码: ' + urls[0]); }); test('doaj: DOAJ 页链接正确编码', async () => { h.setHandler(h.routes([['api/v2/articles/', { body: { bibjson: { link: [] } } }]])); const d = await sources.getSource('doaj').download('10.1234/abc'); const page = d.links.find((l) => l.name === 'DOAJ 页'); assert.strictEqual(page.url, 'https://doaj.org/article/10.1234%2Fabc'); }); // --- Sci-Hub --- test('scihub: 跳过广告 iframe 找到真正的 PDF', async () => { h.setHandler(() => h.makeResponse({ body: '' })); const d = await sources.getSource('scihub').download('10.1038/nature12373'); assert.strictEqual(d.files[0].link, 'https://sci-hub.se/downloads/2020/x.pdf'); }); test('scihub: DOI 不存在时只请求一个镜像', async () => { const hits = []; h.setHandler((u) => { hits.push(u); return h.makeResponse({ body: 'article not found' }); }); await assert.rejects(sources.getSource('scihub').detail('10.1/x'), /不存在/); assert.strictEqual(hits.length, 1, `不该轮询全部镜像,实际请求 ${hits.length} 次`); }); test('scihub: 非 DOI 关键词返回空而不抛错', async () => { const r = await sources.getSource('scihub').search('随便搜点什么', 1); assert.deepStrictEqual(r.items, []); assert.ok(r.note); }); // --- LibGen --- test('libgen: maxPage 只看分页控件,忽略页脚干扰链接', async () => { const card = '
'; const footer = ''; const pager = ''; h.setHandler(() => h.makeResponse({ body: card + footer + pager })); const r = await sources.getSource('libgen').search('godel escher', 1); assert.strictEqual(r.items.length, 1); assert.strictEqual(r.maxPage, 3, '页脚的 page=999 被误算进来了'); }); test('libgen: 无分页控件时不虚报页数', async () => { const card = ''; h.setHandler(() => h.makeResponse({ body: card + 'junk' })); const r = await sources.getSource('libgen').search('solo book', 1); assert.strictEqual(r.maxPage, 1); }); test('libgen: JSON-LD image 为对象时详情不崩溃', async () => { const ld = JSON.stringify({ '@type': 'Book', name: 'B', image: { '@type': 'ImageObject', url: '/c.jpg' } }); h.setHandler(() => h.makeResponse({ body: `Open calculus textbook.
', license_name: 'Creative Commons Attribution-NonCommercial-ShareAlike License', license_version: '4.0', digital_isbn_13: '978-1-947172-16-6' } }]])); const detail = await openstax.detail('76'); assert.deepStrictEqual(detail.authors, ['Gilbert Strang', 'Second Author']); assert.strictEqual(detail.date, '2016-03-30'); assert.strictEqual(detail.brief, 'Open calculus textbook.'); assert.ok(detail.tags.includes('主题:Math')); assert.ok(detail.tags.some((tag) => tag.includes('4.0'))); }); test('openstax: PDF 去重且非法 id 不发请求', async () => { const openstax = h.freshRequire('sources/openstax.js'); h.resetCalls(); h.setHandler(h.routes([['/apps/cms/api/v2/pages/76/', { body: { id: 76, meta: { slug: 'calculus-volume-3' }, title: 'Calculus: Volume 3', pdf_url: 'https://assets.openstax.org/calculus.pdf', high_resolution_pdf_url: 'https://assets.openstax.org/calculus.pdf', license_url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/' } }]])); const download = await openstax.download('76'); assert.strictEqual(download.files.length, 1); assert.strictEqual(download.files[0].name, 'Calculus_ Volume 3.pdf'); await assert.rejects(openstax.detail('../76'), /无效的 OpenStax ID/); assert.strictEqual(h.getCalls().length, 1); }); // --- Open Textbook Library --- test('opentextbook: 搜索结果解析作者和服务端分页', async () => { const opentextbook = h.freshRequire('sources/opentextbook.js'); h.setHandler(h.routes([['textbooks.json?q=calculus&page=2', { body: { data: [{ id: 10, title: 'Calculus', copyright_year: 2023, contributors: [ { first_name: 'Gilbert', last_name: 'Strang' }, { corporate: true, title: 'Open Education Team' } ], url: 'https://open.umn.edu/opentextbooks/textbooks/calculus' }], links: { total_pages: 10, total_count: 98 } } }]])); const result = await opentextbook.search('calculus', 2); assert.strictEqual(result.page, 2); assert.strictEqual(result.maxPage, 10); assert.strictEqual(result.items[0].subtitle, 'Gilbert Strang, Open Education Team'); assert.strictEqual(result.items[0].date, '2023'); }); test('opentextbook: 详情展开 data 并保留单书许可', async () => { const opentextbook = h.freshRequire('sources/opentextbook.js'); h.setHandler(h.routes([['textbooks/10.json', { body: { data: { id: 10, title: 'Calculus', edition_statement: 'Third Edition', copyright_year: 1991, license: 'Attribution-NonCommercial-ShareAlike', language: 'eng', description: 'Free calculus textbook.
', contributors: [{ first_name: 'Gilbert', last_name: 'Strang' }], subjects: [{ name: 'Mathematics' }], url: 'https://open.umn.edu/opentextbooks/textbooks/calculus' } } }]])); const detail = await opentextbook.detail('10'); assert.deepStrictEqual(detail.authors, ['Gilbert Strang']); assert.strictEqual(detail.brief, 'Free calculus textbook.'); assert.ok(detail.tags.includes('版本:Third Edition')); assert.ok(detail.tags.includes('许可:Attribution-NonCommercial-ShareAlike')); }); test('opentextbook: 只有真实文件 URL 才进入下载列表', async () => { const opentextbook = h.freshRequire('sources/opentextbook.js'); h.resetCalls(); h.setHandler(h.routes([['textbooks/10.json', { body: { data: { id: 10, title: 'Calculus: Third Edition', url: 'https://open.umn.edu/opentextbooks/textbooks/calculus', formats: [ { type: 'PDF', url: 'https://ocw.mit.edu/courses/calculus/open-textbook/' }, { type: 'PDF', url: 'https://cdn.example/calculus.pdf?download=1' }, { type: 'EPUB', url: 'https://cdn.example/calculus.epub' }, { type: 'EPUB', url: 'https://cdn.example/calculus.epub' } ] } } }]])); const download = await opentextbook.download('10'); assert.deepStrictEqual(download.files.map((file) => file.format), ['PDF', 'EPUB']); assert.strictEqual(download.links[1].name, 'PDF 获取页'); assert.strictEqual(download.files[0].name, 'Calculus_ Third Edition.pdf'); await assert.rejects(opentextbook.download('10/../../x'), /无效的开放教材 ID/); assert.strictEqual(h.getCalls().length, 1); }); // --- Wikisource --- test('wikisource: 搜索使用整数偏移并携带可识别 User-Agent', async () => { const wikisource = h.freshRequire('sources/wikisource-zh.js'); let request = null; h.setHandler((url, options) => { request = { url, options }; return h.makeResponse({ body: { query: { searchinfo: { totalhits: 24753 }, search: [{ pageid: 6, title: '論語' }] } } }); }); const result = await wikisource.search('論語', 2); assert.strictEqual(result.items[0].postId, '6'); assert.strictEqual(result.items[0].subtitle, '中文'); assert.strictEqual(result.maxPage, 500, 'MediaWiki 搜索最多允许偏移到 10000 条'); assert.ok(request.url.includes('sroffset=20'), request.url); assert.match(request.options.headers['User-Agent'], /PeopleLib\/2\.1\.1/); }); test('wikisource: 浏览按 continuation 令牌翻页', async () => { const wikisource = h.freshRequire('sources/wikisource-en.js'); const urls = []; h.setHandler((url) => { urls.push(url); if (url.includes('apcontinue=')) { return h.makeResponse({ body: { query: { allpages: [{ pageid: 2, title: 'Second Book' }] } } }); } return h.makeResponse({ body: { continue: { apcontinue: 'Second Book', continue: '-||' }, query: { allpages: [{ pageid: 1, title: 'First Book' }] } } }); }); const first = await wikisource.list(1); const second = await wikisource.list(2); assert.strictEqual(first.maxPage, 2); assert.strictEqual(second.items[0].postId, '2'); assert.ok(urls[1].includes('apcontinue=Second+Book'), urls[1]); }); test('wikisource: 详情和导出链接由服务端标题生成', async () => { const wikisource = h.freshRequire('sources/wikisource-zh.js'); h.resetCalls(); h.setHandler(h.routes([['pageids=6', { body: { query: { pages: [{ pageid: 6, title: '論語/學而第一', extract: '學而時習之。
', fullurl: 'https://zh.wikisource.org/wiki/%E8%AB%96%E8%AA%9E', thumbnail: { source: 'https://upload.wikimedia.org/cover.jpg' } }] } } }]])); const detail = await wikisource.detail('6'); assert.strictEqual(detail.brief, '學而時習之。'); assert.strictEqual(detail.cover, 'https://upload.wikimedia.org/cover.jpg'); const download = await wikisource.download('6'); assert.deepStrictEqual(download.files.map((file) => file.format), ['EPUB', 'PDF']); assert.ok(download.files[0].link.includes('lang=zh')); assert.ok(download.files[0].link.includes('page=%E8%AB%96%E8%AA%9E%2F%E5%AD%B8%E8%80%8C%E7%AC%AC%E4%B8%80')); assert.strictEqual(download.files[0].name, '論語_學而第一.epub'); await assert.rejects(wikisource.detail('../6'), /无效的中文维基文库 ID/); assert.strictEqual(h.getCalls().length, 2); }); // --- Z-Library --- test('zlib: postId 缺 hash 时详情仍可用', async () => { const zlib = h.freshRequire('sources/zlib.js'); const auth = require('../sources/zlib-auth'); const origSession = auth.getSession; const origRead = auth.read; auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' }); auth.read = () => ({ email: 'e', password: 'p', userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' }); try { const urls = []; h.setHandler((u) => { urls.push(u); return h.makeResponse({ body: { success: 1, book: { title: 'B', author: 'X' } } }); }); const d = await zlib.detail('123/'); assert.strictEqual(d.title, 'B'); assert.ok(urls[0].includes('/eapi/book/123'), urls[0]); assert.ok(!urls[0].includes('/eapi/book/123/?'), '缺 hash 时不该留下尾斜杠: ' + urls[0]); } finally { auth.getSession = origSession; auth.read = origRead; } }); test('zlib: 完全无效的 id 仍然拒绝', async () => { const zlib = h.freshRequire('sources/zlib.js'); await assert.rejects(zlib.detail('not-an-id'), /无效的 Z-Library ID/); }); // 实测:会话过期时 /file 返回 400 + {"success":0,"error":"Please login"}。 // 若按 HTTP 状态码短路,真实原因会被吞掉,自动重登也不会触发。 test('zlib: 4xx+JSON 的会话过期能被识别并自动重新登录', async () => { const zlib = h.freshRequire('sources/zlib.js'); const auth = require('../sources/zlib-auth'); const orig = { read: auth.read, getSession: auth.getSession, setSession: auth.setSession, clearSession: auth.clearSession }; let session = { userId: 'old', userKey: 'stale', mirror: 'https://z-lib.fm' }; auth.read = () => ({ email: 'e@x.com', password: 'p', ...session }); auth.getSession = () => (session.userKey ? session : null); auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; }; auth.clearSession = () => { session = { userId: '', userKey: '', mirror: '' }; }; try { let loggedIn = false; h.setHandler((url) => { if (url.includes('/rpc.php')) { loggedIn = true; return h.makeResponse({ headers: { 'set-cookie': [ 'remix_userid=42; Path=/; Secure; HttpOnly', 'remix_userkey=fresh; Path=/; Secure; HttpOnly' ] }, body: { errors: [], response: { redirect: '/' } } }); } if (url.includes('userKey=fresh')) { return h.makeResponse({ body: { success: 1, file: { downloadLink: 'https://cdn/x.pdf', extension: 'pdf' } } }); } return h.makeResponse({ status: 400, body: { success: 0, error: 'Please login' } }); }); const d = await zlib.download('123/abc'); assert.ok(loggedIn, '过期会话没有触发重新登录'); assert.strictEqual(d.files[0].link, 'https://cdn/x.pdf'); } finally { Object.assign(auth, orig); } }); test('zlib: 凭据错误时报出服务端原因而不是 HTTP 状态码', async () => { const zlib = h.freshRequire('sources/zlib.js'); const auth = require('../sources/zlib-auth'); const orig = { read: auth.read, getSession: auth.getSession, write: auth.write, clear: auth.clear }; // login() 先写盘,doLogin() 再读回来,所以 stub 要如实模拟这个往返 let stored = null; auth.read = () => stored; auth.getSession = () => null; auth.write = (c) => { stored = { ...c }; }; auth.clear = () => { stored = null; }; try { let request = null; h.setHandler((url, options) => { request = { url, body: options.body }; return h.makeResponse({ body: { errors: [], response: { validationError: true, fields: ['email', 'password'], message: 'Incorrect email or password' } } }); }); const r = await zlib.login('e@x.com', 'wrong'); assert.strictEqual(r.ok, false); assert.match(r.error, /Incorrect email or password/, '真实原因被 HTTP 状态码盖掉了'); assert.ok(request.url.endsWith('/rpc.php')); assert.match(request.body, /action=login/); assert.match(request.body, /gg_json_mode=1/); } finally { Object.assign(auth, orig); } }); test('zlib: RPC 登录从安全 Cookie 建立会话', async () => { const zlib = h.freshRequire('sources/zlib.js'); const auth = require('../sources/zlib-auth'); const orig = { read: auth.read, getSession: auth.getSession, write: auth.write, setSession: auth.setSession, clear: auth.clear }; let stored = null; let session = null; auth.read = () => stored; auth.getSession = () => session; auth.write = (c) => { stored = { ...c }; }; auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; }; auth.clear = () => { stored = null; session = null; }; try { h.setHandler(() => h.makeResponse({ headers: { 'set-cookie': [ 'remix_userid=42; Path=/; Secure; HttpOnly', 'remix_userkey=key%2Bvalue; Path=/; Secure; HttpOnly' ] }, body: { errors: [], response: { redirect: '/' } } })); const r = await zlib.login('e@x.com', 'correct'); assert.strictEqual(r.ok, true); assert.strictEqual(session.userId, '42'); assert.strictEqual(session.userKey, 'key+value'); assert.match(session.mirror, /^https:\/\//); } finally { Object.assign(auth, orig); } }); test('zlib: 可注入同源浏览器登录传输并持久化会话', async () => { const zlib = h.freshRequire('sources/zlib.js'); const auth = require('../sources/zlib-auth'); const orig = { read: auth.read, getSession: auth.getSession, write: auth.write, setSession: auth.setSession, clear: auth.clear }; let stored = null; let session = null; auth.read = () => stored; auth.getSession = () => session; auth.write = (c) => { stored = { ...c }; }; auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; }; auth.clear = () => { stored = null; session = null; }; zlib.setLoginTransport(async (mirror, email, password) => { assert.match(mirror, /^https:\/\//); assert.strictEqual(email, 'e@x.com'); assert.strictEqual(password, 'correct'); return { userId: 'browser-user', userKey: 'browser-key' }; }); try { const result = await zlib.login('e@x.com', 'correct'); assert.strictEqual(result.ok, true); assert.strictEqual(session.userId, 'browser-user'); assert.strictEqual(session.userKey, 'browser-key'); } finally { zlib.setLoginTransport(null); Object.assign(auth, orig); } }); // --- Gutenberg --- test('gutenberg: 解析格式与封面', async () => { h.setHandler(h.routes([['gutendex.com/books', { body: { count: 64, results: [{ id: 11, title: 'Alice', authors: [{ name: 'Carroll' }], formats: { 'application/epub+zip': 'https://x/a.epub', 'image/jpeg': 'https://x/c.jpg' } }] } }]])); const r = await sources.getSource('gutenberg').search('alice', 1); assert.strictEqual(r.items[0].postId, '11'); assert.strictEqual(r.items[0].cover, 'https://x/c.jpg'); assert.strictEqual(r.maxPage, 2); });