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 }; } };