// Verse By Verse Ministry International (versebyverseministry.org): free verse-by-verse Bible teaching. The ministry publishes every study as a podcast feed // (/bible-studies//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:: 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(//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(/
  • ]*>([\s\S]*?)<\/h3>/.exec(card) || [])[1]), lessons: parseInt((/(\d+)\s+Lessons?/i.exec(card) || [])[1]) || 0, teacher: plain((/

    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 = (/ plain((new RegExp(']+(?:property|name)="' + n + '"[^>]+content="([^"]*)"', 'i').exec(h) || [])[1]); const lessons = [...h.matchAll(/

  • /g)].map(m => { const b = m[0], href = (/href="(\/lessons\/[\w-]+)"/.exec(b) || [])[1]; if (!href) return null; const num = plain((/([\s\S]*?)<\/span>/.exec(b) || [])[1]), sub = plain((/

    ${code === 404 ? 'This lesson has no handout' : 'The handout could not be loaded'}

    ${code === 404 ? 'Not every lesson comes with notes.' : 'Verse By Verse Ministry did not answer. Try again in a moment.'}

    Go back

    `); } return json(res, code, { error: e.message }); } } module.exports = { handle };