Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
114 lines
11 KiB
JavaScript
114 lines
11 KiB
JavaScript
// Verse By Verse Ministry International (versebyverseministry.org): free verse-by-verse Bible teaching. The ministry publishes every study as a podcast feed
|
||
// (/bible-studies/<study>/podcast.xml) with one MP3 per lesson, and a handout (PDF) on each lesson's page. The hub reads the study list from the ministry's own
|
||
// pages and the feeds, sends the browser straight to each lesson's recording (so the ministry's server does the streaming), and keeps each person's place.
|
||
const users = require('./users');
|
||
const prog = require('./progress')('vbv-progress.json'); // keys vbv:<study>:<lesson number>
|
||
|
||
const BASE = () => (process.env.VBV_URL || 'https://versebyverseministry.org').replace(/\/+$/, '');
|
||
const HOSTS = () => { const h = new URL(BASE()).hostname; return [h, 'www.' + h, 'media.' + h.replace(/^www\./, ''), 'versebyverseministry.org', 'media.versebyverseministry.org', 'www.versebyverseministry.org']; };
|
||
const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().includes(x.hostname); } catch { return false; } };
|
||
const UA = 'NE-Hub/1.0 (self-hosted; reads the ministry\'s public pages and feeds)';
|
||
const CATEGORIES = [['new-testament-books', 'New Testament'], ['old-testament-books', 'Old Testament'], ['topical-series', 'Topical'], ['single-teachings', 'Single teachings'], ['womens-teaching', "Women's"], ['youth-resources', 'Youth']];
|
||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||
const unent = s => String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|rsquo|lsquo|ldquo|rdquo|mdash|ndash|nbsp);/gi, (m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', nbsp: ' ' }[e.toLowerCase()] || (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : +e.slice(1)) : m)));
|
||
const plain = s => unent(String(s || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
||
|
||
async function page(url, max = 3e6) {
|
||
if (!allowed(url)) throw fail(502, 'Bad address');
|
||
let r; try { r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xml,*/*;q=0.5' }, redirect: 'follow', signal: AbortSignal.timeout(15000) }); } catch { throw fail(502, 'Could not reach Verse By Verse Ministry'); }
|
||
if (!allowed(r.url)) throw fail(502, 'Bad address'); if (r.status === 404) throw fail(404, 'Not found'); if (!r.ok) throw fail(502, 'Verse By Verse Ministry answered ' + r.status);
|
||
const t = await r.text(); if (t.length > max) throw fail(502, 'Too large'); return t;
|
||
}
|
||
const memo = new Map(); // key -> { t, v }
|
||
async function cached(key, ttl, fn) { const c = memo.get(key); if (c && Date.now() - c.t < ttl) return c.v; const v = await fn(); if (memo.size > 300) memo.clear(); memo.set(key, { t: Date.now(), v }); return v; }
|
||
|
||
const studiesAll = () => cached('studies', 3600e3, async () => {
|
||
const by = new Map();
|
||
await Promise.all(CATEGORIES.map(async ([c, name]) => {
|
||
let h; try { h = await page(`${BASE()}/bible-studies/category/${c}`); } catch { return; }
|
||
for (const m of h.matchAll(/<li class="study-item[\s\S]*?<\/li>/g)) {
|
||
const card = m[0], slug = (/href="\/bible-studies\/([\w-]+)"/.exec(card) || [])[1]; if (!slug) continue;
|
||
const imgs = (/srcset="([^"]+)"/.exec(card) || [])[1], cover = ((imgs || '').split(',').pop() || '').trim().split(' ')[0] || (/<img src="([^"]+)"/.exec(card) || [])[1] || '';
|
||
const s = by.get(slug) || { slug, title: plain((/<h3[^>]*>([\s\S]*?)<\/h3>/.exec(card) || [])[1]), lessons: parseInt((/(\d+)\s+Lessons?/i.exec(card) || [])[1]) || 0, teacher: plain((/<p class="body-text[^>]*>([\s\S]*?)<\/p>/.exec(card) || [])[1]), cover: allowed(cover) ? cover : '', categories: [] };
|
||
s.categories.push(c); by.set(slug, s);
|
||
}
|
||
}));
|
||
if (!by.size) throw fail(502, 'Could not read the list of studies');
|
||
return [...by.values()];
|
||
});
|
||
const feed = slug => cached('feed:' + slug, 1800e3, async () => {
|
||
if (!/^[\w-]{1,80}$/.test(slug)) throw fail(400, 'Bad study');
|
||
let x = ''; try { x = await page(`${BASE()}/bible-studies/${slug}/podcast.xml`, 5e6); } catch (e) { if (e.code !== 404) throw e; }
|
||
const ch = x.replace(/<item>[\s\S]*$/, '');
|
||
const tag = (s, t) => plain((new RegExp('<' + t + '>([\\s\\S]*?)</' + t + '>', 'i').exec(s) || [])[1]);
|
||
const items = [...x.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m, i) => { const it = m[1], enc = /<enclosure[^>]*url="([^"]+)"/.exec(it); return { n: i, title: tag(it, 'title').replace(/^\d+\s+/, '') || 'Lesson ' + (i + 1), description: tag(it, 'description'), summary: tag(it, 'itunes:subtitle'), date: tag(it, 'pubDate'), audio: enc && allowed(unent(enc[1])) ? unent(enc[1]) : '', link: tag(it, 'link') }; }).filter(i => i.audio);
|
||
if (items.length < 3) { // a few studies have an empty or partial feed; their lessons are all on the study's own page
|
||
let alt = null; try { alt = await fromPage(slug); } catch (e) { if (!items.length) throw e; }
|
||
if (alt && alt.lessons.length > items.length) return alt;
|
||
}
|
||
if (!items.length) throw fail(404, 'No lessons found');
|
||
const img = (/<itunes:image href="([^"]+)"/.exec(ch) || [])[1];
|
||
return { slug, title: tag(ch, 'title'), teacher: tag(ch, 'itunes:author'), description: tag(ch, 'itunes:summary') || tag(ch, 'description'), cover: img && allowed(img) ? img : '', lessons: items };
|
||
});
|
||
/** A study whose feed lists nothing: read its lesson list from its page; each lesson's recording is found when it is played. */
|
||
async function fromPage(slug) {
|
||
const h = await page(`${BASE()}/bible-studies/${slug}`), meta = n => plain((new RegExp('<meta[^>]+(?:property|name)="' + n + '"[^>]+content="([^"]*)"', 'i').exec(h) || [])[1]);
|
||
const lessons = [...h.matchAll(/<li class="lesson[^"]*"[\s\S]*?<\/li>/g)].map(m => { const b = m[0], href = (/href="(\/lessons\/[\w-]+)"/.exec(b) || [])[1]; if (!href) return null;
|
||
const num = plain((/<span class="lesson-number">([\s\S]*?)<\/span>/.exec(b) || [])[1]), sub = plain((/<p class="body-text[^>]*>([\s\S]*?)<\/p>/.exec(b) || [])[1]);
|
||
return { title: [num, sub].filter(Boolean).join(' \u2013 '), description: sub, summary: '', date: '', audio: '', link: BASE() + href }; }).filter(Boolean).map((l, n) => ({ ...l, n, title: l.title || 'Lesson ' + (n + 1) }));
|
||
if (!lessons.length) throw fail(404, 'No lessons found for this study');
|
||
const cover = meta('og:image');
|
||
return { slug, title: plain((/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(h) || [])[1]) || plain((/<title>([^<|]*)/.exec(h) || [])[1]), teacher: '', description: meta('og:description') || meta('description'), cover: allowed(cover) ? cover : '', lessons };
|
||
}
|
||
const lessonAudio = link => cached('audio:' + link, 6 * 3600e3, async () => { const h = await page(link); return [...h.matchAll(/https?:\/\/[^"'\s<>]+\.mp3/gi)].map(m => unent(m[0])).find(allowed) || ''; });
|
||
const coverUrl = u => u ? '/api/vbv/cover?u=' + encodeURIComponent(u) : '';
|
||
const publicStudy = s => ({ ...s, cover: coverUrl(s.cover), lessons: s.lessons.map(({ audio, link, ...l }) => l) });
|
||
const notesOf = (slug, n, lessonUrl) => cached(`notes:${slug}:${n}`, 6 * 3600e3, async () => {
|
||
const h = await page(lessonUrl); const pdfs = [...h.matchAll(/https?:\/\/[^"'\s<>]+\.pdf/gi)].map(m => unent(m[0])).filter(allowed); return pdfs[0] || '';
|
||
});
|
||
const audioUrl = async (slug, n) => { const l = (await feed(slug)).lessons[n]; if (!l) throw fail(404, 'No such lesson'); const a = l.audio || (l.link && allowed(l.link) ? await lessonAudio(l.link) : ''); if (!a) throw fail(404, 'This lesson has no audio'); return a; };
|
||
const notesUrl = async (slug, n) => { const l = (await feed(slug)).lessons[n]; if (!l) throw fail(404, 'No such lesson'); const pdf = l.link && allowed(l.link) ? await notesOf(slug, n, l.link) : ''; if (!pdf) throw fail(404, 'This lesson has no handout'); return pdf; };
|
||
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||
|
||
async function handle(req, res) {
|
||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/vbv'.length) || '/';
|
||
try {
|
||
let m;
|
||
if (p === '/studies' && req.method === 'GET') {
|
||
const q = (u.searchParams.get('q') || '').trim().toLowerCase(), cat = u.searchParams.get('category') || '';
|
||
const list = (await studiesAll()).filter(s => (!cat || s.categories.includes(cat)) && (!q || (s.title + ' ' + s.teacher).toLowerCase().includes(q)));
|
||
return json(res, 200, { categories: CATEGORIES.map(([id, name]) => ({ id, name })), studies: list.map(s => ({ ...s, cover: coverUrl(s.cover) })) });
|
||
}
|
||
if ((m = /^\/study\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicStudy(await feed(m[1])));
|
||
if ((m = /^\/audio\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the ministry's media server; the browser is sent straight there (it supports seeking)
|
||
const audio = await audioUrl(m[1], +m[2]);
|
||
res.writeHead(302, { location: audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||
}
|
||
if ((m = /^\/notes\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') {
|
||
const pdf = await notesUrl(m[1], +m[2]);
|
||
res.writeHead(302, { location: pdf, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||
}
|
||
if (p === '/cover' && req.method === 'GET') {
|
||
const src = u.searchParams.get('u') || ''; if (!allowed(src)) throw fail(404, 'No cover');
|
||
const r = await fetch(src, { headers: { 'user-agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(10000) }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||
if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) throw fail(404, 'No cover'); const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > 3e6) throw fail(502, 'Too large');
|
||
res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
|
||
}
|
||
if (p === '/progress') {
|
||
if (req.method === 'GET') return json(res, 200, prog.load());
|
||
if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req)));
|
||
}
|
||
return json(res, 404, { error: 'Not found' });
|
||
} catch (e) {
|
||
const code = e.code >= 400 && e.code < 600 ? e.code : 502;
|
||
if (/^\/notes\//.test(p) && /text\/html/.test(req.headers.accept || '')) { // opened in a new tab: say it in words, with a way back
|
||
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||
return res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lesson handout</title><body style="font:17px/1.5 system-ui;max-width:32em;margin:15vh auto;padding:0 20px;text-align:center"><h2>${code === 404 ? 'This lesson has no handout' : 'The handout could not be loaded'}</h2><p style="color:#666">${code === 404 ? 'Not every lesson comes with notes.' : 'Verse By Verse Ministry did not answer. Try again in a moment.'}</p><p><a href="javascript:history.length>1?history.back():window.close()">Go back</a></p>`);
|
||
}
|
||
return json(res, code, { error: e.message });
|
||
}
|
||
}
|
||
|
||
module.exports = { handle, studyInfo: feed, audioUrl, notesUrl, allowed, UA };
|