构建与发布 / 单测与集成测试 (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
181 lines
5.2 KiB
JavaScript
181 lines
5.2 KiB
JavaScript
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 };
|
||
}
|
||
};
|