Verse By Verse: handouts open as a plain link (no pop-up blocking), friendly page when a lesson has none; studies with an empty or partial feed are read from their page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
4 files changed
+38
-7
No files matched your search
@@ -39,13 +39,29 @@ const studiesAll = () => cached('studies', 3600e3, async () => {
|
||||
});
|
||||
const feed = slug => cached('feed:' + slug, 1800e3, async () => {
|
||||
if (!/^[\w-]{1,80}$/.test(slug)) throw fail(400, 'Bad study');
|
||||
const x = await page(`${BASE()}/bible-studies/${slug}/podcast.xml`, 5e6), ch = x.replace(/<item>[\s\S]*$/, '');
|
||||
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 () => {
|
||||
@@ -65,7 +81,8 @@ async function handle(req, res) {
|
||||
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 l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson');
|
||||
res.writeHead(302, { location: l.audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||
const audio = l.audio || (l.link && allowed(l.link) ? await lessonAudio(l.link) : ''); if (!audio) throw fail(404, 'This lesson has no audio');
|
||||
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 l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson');
|
||||
@@ -83,7 +100,14 @@ async function handle(req, res) {
|
||||
if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req)));
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
|
||||
} 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 };
|
||||
Reference in new issue
Block a user