构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
706 lines
28 KiB
JavaScript
706 lines
28 KiB
JavaScript
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: '<iframe src="https://ads.example/b.html"></iframe><iframe src="/downloads/2020/x.pdf"></iframe>'
|
|
}));
|
|
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 = '<div class="resItemBox" data-book_id="1"><h3 itemprop="name"><a>Book One</a></h3></div>';
|
|
const footer = '<div class="footer"><a href="/x?page=999">junk</a></div>';
|
|
const pager = '<div class="paginator"><a href="?page=2">2</a><a href="?page=3">3</a></div>';
|
|
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 = '<div class="resItemBox" data-book_id="9"><h3 itemprop="name"><a>Solo</a></h3></div>';
|
|
h.setHandler(() => h.makeResponse({ body: card + '<a href="/y?page=42">junk</a>' }));
|
|
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: `<script type="application/ld+json">${ld}</script><h1 itemprop="name">B</h1>`
|
|
}));
|
|
const d = await sources.getSource('libgen').detail('web:5');
|
|
assert.strictEqual(d.title, 'B');
|
|
assert.ok(/\/c\.jpg$/.test(d.cover), 'cover 解析失败: ' + d.cover);
|
|
});
|
|
|
|
test('libgen: 关键词过短直接返回提示', async () => {
|
|
const r = await sources.getSource('libgen').search('ab', 1);
|
|
assert.deepStrictEqual(r.items, []);
|
|
assert.ok(r.note);
|
|
});
|
|
|
|
// --- Standard Ebooks ---
|
|
|
|
test('standardebooks: author 为字符串时不丢作者', async () => {
|
|
h.setHandler(h.routes([['feeds/opds/all', {
|
|
body: {
|
|
publications: [{
|
|
metadata: { identifier: 'https://standardebooks.org/ebooks/jane-austen/emma', title: 'Emma', author: 'Jane Austen' },
|
|
images: []
|
|
}]
|
|
}
|
|
}]]));
|
|
const r = await sources.getSource('standardebooks').search('emma', 1);
|
|
assert.strictEqual(r.items[0].subtitle, 'Jane Austen');
|
|
});
|
|
|
|
test('standardebooks: author 混排对象与字符串', async () => {
|
|
h.setHandler(h.routes([['feeds/opds/all', {
|
|
body: {
|
|
publications: [{
|
|
metadata: { identifier: 'https://standardebooks.org/ebooks/a/b', title: 'T', author: [{ name: 'A' }, 'B'] },
|
|
images: []
|
|
}]
|
|
}
|
|
}]]));
|
|
const r = await sources.getSource('standardebooks').search('t', 1);
|
|
assert.strictEqual(r.items[0].subtitle, 'A, B');
|
|
});
|
|
|
|
test('standardebooks: 非法 slug 被拒绝', async () => {
|
|
await assert.rejects(sources.getSource('standardebooks').detail('../../etc/passwd'), /无效的/);
|
|
});
|
|
|
|
// --- Open Library ---
|
|
|
|
test('openlibrary: 详情解析作者姓名', async () => {
|
|
h.setHandler(h.routes([
|
|
[/works\/OL1W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/OL1A' } }], subjects: [] } }],
|
|
[/authors\/OL1A\.json/, { body: { name: 'Ursula Le Guin' } }]
|
|
]));
|
|
const d = await sources.getSource('openlibrary').detail('OL1W');
|
|
assert.deepStrictEqual(d.authors, ['Ursula Le Guin']);
|
|
});
|
|
|
|
test('openlibrary: 单个作者取不到不影响整体', async () => {
|
|
h.setHandler(h.routes([
|
|
[/works\/OL2W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/BAD' } }, { author: { key: '/authors/OK' } }], subjects: [] } }],
|
|
// 404 不触发重试,避免这条用例白等两轮退避
|
|
[/authors\/BAD\.json/, { status: 404 }],
|
|
[/authors\/OK\.json/, { body: { name: 'Good' } }]
|
|
]));
|
|
const d = await sources.getSource('openlibrary').detail('OL2W');
|
|
assert.deepStrictEqual(d.authors, ['Good']);
|
|
});
|
|
|
|
test('openlibrary: 下载只给 archive.org 真实存在的文件', async () => {
|
|
h.setHandler(h.routes([
|
|
['editions.json', { body: { entries: [{ ocaid: 'someitem' }] } }],
|
|
['archive.org/metadata/', {
|
|
body: { files: [{ name: 'someitem.pdf', format: 'Text PDF' }, { name: 'thumb.jpg', format: 'JPEG' }] }
|
|
}]
|
|
]));
|
|
const d = await sources.getSource('openlibrary').download('OL3W');
|
|
assert.strictEqual(d.files.length, 1, '推了不存在的格式: ' + JSON.stringify(d.files));
|
|
assert.strictEqual(d.files[0].format, 'PDF');
|
|
assert.ok(d.files[0].link.includes('someitem.pdf'));
|
|
});
|
|
|
|
test('openlibrary: 借阅制条目被跳过', async () => {
|
|
h.setHandler(h.routes([
|
|
['editions.json', { body: { entries: [{ ocaid: 'lend', access_restricted: 'borrow' }] } }]
|
|
]));
|
|
const d = await sources.getSource('openlibrary').download('OL4W');
|
|
assert.deepStrictEqual(d.files, []);
|
|
});
|
|
|
|
// --- bioRxiv ---
|
|
|
|
test('biorxiv: 瞬时故障会重试而不是直接失败', async () => {
|
|
let n = 0;
|
|
h.setHandler(() => {
|
|
n++;
|
|
// 502 与超时走的是同一条 isRetryable 分支,用 502 避免真的等满超时
|
|
if (n <= 2) return h.makeResponse({ status: 502 });
|
|
return h.makeResponse({ body: { messages: [{ total: 100 }], collection: [] } });
|
|
});
|
|
const r = await sources.getSource('biorxiv').list(1);
|
|
assert.ok(n >= 3, `没有重试,只请求了 ${n} 次`);
|
|
assert.ok(r.maxPage >= 1);
|
|
});
|
|
|
|
test('biorxiv: 超时被判定为可重试(回归 504|502|503 正则漏判)', () => {
|
|
const { isRetryable } = require('../sources/http');
|
|
assert.strictEqual(isRetryable(new Error('请求超时,站点无响应')), true);
|
|
assert.strictEqual(isRetryable(new Error('网络连接失败,请检查网络或代理设置')), true);
|
|
const src = require('fs').readFileSync(require.resolve('../sources/biorxiv.js'), 'utf8');
|
|
assert.ok(!/504\|502\|503/.test(src), '旧的字符串匹配门仍在');
|
|
});
|
|
|
|
test('biorxiv: 不支持搜索时明确报错', async () => {
|
|
await assert.rejects(sources.getSource('biorxiv').search('x', 1), /不支持搜索/);
|
|
});
|
|
|
|
// --- MOTW ---
|
|
|
|
test('motw: 分页用 offset/limit 且随页码递增', async () => {
|
|
const urls = [];
|
|
h.setHandler((u) => {
|
|
urls.push(u);
|
|
return h.makeResponse({ body: { _items: [], _meta: { total: 1000, max_results: 48 } } });
|
|
});
|
|
const motw = sources.getSource('motw');
|
|
await motw.list(1);
|
|
await motw.list(3);
|
|
assert.ok(urls[0].includes('offset=0&limit=48'), urls[0]);
|
|
assert.ok(urls[1].includes('offset=96&limit=48'), urls[1]);
|
|
});
|
|
|
|
test('motw: 未缓存的详情给出可操作提示', async () => {
|
|
await assert.rejects(sources.getSource('motw').detail('unknown-id'), /重新进入/);
|
|
});
|
|
|
|
// --- arXiv ---
|
|
|
|
test('arxiv: 解析 atom feed 并取 pdf 链接', async () => {
|
|
const xml = `<feed><opensearch:totalResults>40</opensearch:totalResults>
|
|
<entry><id>http://arxiv.org/abs/2201.00978v1</id><title>Paper T</title>
|
|
<summary>S</summary><published>2022-01-03T00:00:00Z</published>
|
|
<author><name>A One</name></author>
|
|
<link title="pdf" href="https://arxiv.org/pdf/2201.00978v1"/>
|
|
<category term="cs.CV"/></entry></feed>`;
|
|
h.setHandler(() => h.makeResponse({ body: xml }));
|
|
const r = await sources.getSource('arxiv').search('transformer', 1);
|
|
assert.strictEqual(r.items[0].postId, '2201.00978v1');
|
|
assert.strictEqual(r.maxPage, 2);
|
|
const d = await sources.getSource('arxiv').download('2201.00978v1');
|
|
assert.strictEqual(d.files[0].link, 'https://arxiv.org/pdf/2201.00978v1');
|
|
});
|
|
|
|
// --- OpenStax ---
|
|
|
|
test('openstax: 只展示 live 教材并支持本地关键词分页', async () => {
|
|
const openstax = h.freshRequire('sources/openstax.js');
|
|
const books = Array.from({ length: 21 }, (_, i) => ({
|
|
id: i + 1,
|
|
slug: `books/book-${i + 1}`,
|
|
book_state: 'live',
|
|
title: i === 20 ? 'Advanced Calculus' : `Biology ${i + 1}`,
|
|
subjects: i === 20 ? ['Math'] : ['Science'],
|
|
subject_categories: []
|
|
}));
|
|
books.push({ id: 99, slug: 'books/draft', book_state: 'draft', title: 'Draft Calculus' });
|
|
h.setHandler(h.routes([['/apps/cms/api/books', { body: { books } }]]));
|
|
|
|
const page2 = await openstax.list(2);
|
|
assert.strictEqual(page2.items.length, 1);
|
|
assert.strictEqual(page2.maxPage, 2);
|
|
const found = await openstax.search('advanced math', 1);
|
|
assert.deepStrictEqual(found.items.map((item) => item.postId), ['21']);
|
|
assert.strictEqual(found.items[0].url, 'https://openstax.org/details/books/book-21');
|
|
});
|
|
|
|
test('openstax: 详情解析作者、许可和日期', async () => {
|
|
const openstax = h.freshRequire('sources/openstax.js');
|
|
h.setHandler(h.routes([['/apps/cms/api/v2/pages/76/', {
|
|
body: {
|
|
id: 76,
|
|
meta: { slug: 'calculus-volume-3', html_url: 'https://openstax.org/details/books/calculus-volume-3' },
|
|
title: 'Calculus Volume 3',
|
|
publish_date: '2016-03-30',
|
|
authors: [{ value: { name: 'Gilbert Strang' } }, { name: 'Second Author' }],
|
|
book_subjects: { subject_name: 'Math' },
|
|
book_categories: [{ subject_name: 'Calculus' }],
|
|
description: '<p>Open <b>calculus</b> textbook.</p>',
|
|
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: '<p>Free <b>calculus</b> textbook.</p>',
|
|
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: '<p>學而時習之。</p>',
|
|
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);
|
|
});
|