feat: 发布 v2.1.0 开放书源扩展
构建与发布 / 单测与集成测试 (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
构建与发布 / 发布 GitHub Release (push) Has been cancelled

This commit is contained in:
lofyer
2026-08-06 00:36:20 +08:00
parent d406a0a508
commit c5b9072ffd
15 changed files with 795 additions and 14 deletions
+180
View File
@@ -0,0 +1,180 @@
const { fetchJson, clampPage, stripTags } = require('./http');
const BASE = 'https://openstax.org';
const CATALOG_URL = `${BASE}/apps/cms/api/books`;
const DETAIL_BASE = `${BASE}/apps/cms/api/v2/pages`;
const PAGE_SIZE = 20;
const CATALOG_TTL = 30 * 60 * 1000;
let catalogPromise = null;
let catalogAt = 0;
function getJson(url) {
return fetchJson(url);
}
async function loadCatalog() {
const now = Date.now();
if (catalogPromise && now - catalogAt < CATALOG_TTL) return catalogPromise;
const promise = getJson(CATALOG_URL).then((j) => {
if (!j || !Array.isArray(j.books)) throw new Error('OpenStax 返回了无法识别的书目');
return j.books.filter((b) => b && b.id && b.book_state === 'live');
});
catalogPromise = promise;
catalogAt = now;
try {
return await promise;
} catch (e) {
if (catalogPromise === promise) {
catalogPromise = null;
catalogAt = 0;
}
throw e;
}
}
function validateId(postId) {
const id = String(postId || '');
if (!/^\d+$/.test(id)) throw new Error('无效的 OpenStax ID');
return id;
}
function pageUrl(book) {
const slug = String(book && book.slug || '').replace(/^\/+/, '');
return slug ? `${BASE}/details/${slug}` : `${BASE}/subjects`;
}
function toItem(book) {
return {
postId: String(book.id),
title: book.title || '(无标题)',
cover: book.cover_url || '',
date: '',
url: pageUrl(book),
subtitle: (book.subjects || []).slice(0, 3).join(' · ')
};
}
function pack(books, page) {
const start = (page - 1) * PAGE_SIZE;
return {
items: books.slice(start, start + PAGE_SIZE).map(toItem),
maxPage: Math.max(1, Math.ceil(books.length / PAGE_SIZE)),
page
};
}
function matches(book, keyword) {
const tokens = String(keyword || '').trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (!tokens.length) return true;
const text = [
book.title,
...(book.subjects || []),
...(book.subject_categories || [])
].filter(Boolean).join(' ').toLocaleLowerCase();
return tokens.every((token) => text.includes(token));
}
function authorsOf(book) {
return (book.authors || []).map((author) => {
if (typeof author === 'string') return author;
return author && author.value && author.value.name
? author.value.name
: (author && author.name) || '';
}).filter(Boolean);
}
function subjectNames(value) {
const items = Array.isArray(value) ? value : (value ? [value] : []);
return items.map((item) => (
typeof item === 'string' ? item : (item && (item.subject_name || item.name)) || ''
)).filter(Boolean);
}
async function bookDetail(postId) {
const id = validateId(postId);
const book = await getJson(`${DETAIL_BASE}/${id}/`);
if (!book || !book.id) throw new Error('未找到该 OpenStax 教材');
return book;
}
function detailUrl(book) {
return (book.meta && book.meta.html_url) || `${BASE}/details/books/${book.meta && book.meta.slug || book.id}`;
}
function licenseLabel(book) {
return [book.license_name, book.license_version].filter(Boolean).join(' ');
}
function safeName(title) {
return String(title || 'OpenStax 教材').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
}
module.exports = {
id: 'openstax',
name: 'OpenStax 开放教材',
supportsSearch: true,
async list(page) {
page = clampPage(page);
return pack(await loadCatalog(), page);
},
async search(keyword, page) {
page = clampPage(page);
const books = (await loadCatalog()).filter((book) => matches(book, keyword));
return pack(books, page);
},
async detail(postId) {
const book = await bookDetail(postId);
const subjects = [
...subjectNames(book.book_subjects),
...subjectNames(book.book_categories)
];
return {
postId: String(book.id),
title: book.title || '(无标题)',
cover: book.cover_url || '',
authors: authorsOf(book),
date: String(book.publish_date || '').slice(0, 10),
tags: [
...subjects.slice(0, 4).map((subject) => `主题:${subject}`),
licenseLabel(book) ? `许可:${licenseLabel(book)}` : '',
book.digital_isbn_13 ? `ISBN${book.digital_isbn_13}` : ''
].filter(Boolean),
brief: stripTags(book.description || ''),
url: detailUrl(book),
links: [
{ name: 'OpenStax 页', url: detailUrl(book) },
...(book.webview_link || book.webview_rex_link
? [{ name: '在线阅读', url: book.webview_link || book.webview_rex_link }]
: [])
]
};
},
async download(postId) {
const book = await bookDetail(postId);
const files = [];
const seen = new Set();
for (const [url, label] of [
[book.pdf_url, 'PDF'],
[book.high_resolution_pdf_url, '高清 PDF']
]) {
if (!url || seen.has(url)) continue;
seen.add(url);
files.push({
name: `${safeName(book.title)}${label === '高清 PDF' ? '-高清' : ''}.pdf`,
link: url,
format: 'PDF'
});
}
const links = [{ name: 'OpenStax 页', url: detailUrl(book) }];
if (book.webview_link || book.webview_rex_link) {
links.push({ name: '在线阅读', url: book.webview_link || book.webview_rex_link });
}
if (book.license_url) links.push({ name: '许可说明', url: book.license_url });
return { files, links };
}
};