openvibers/openvibe.host Commits
Host: hosting offers from LowEndTalk and LowEndBox, with the price each headline states and that price a month (#89)
Changes
@@@ -24,6 +24,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-mrobots, so that Sources seed stays disabled.- Staff entries, which must name their source URL.- Nothing is estimated. A value the source does not state is shown as Unknown.++- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndTalk's Offers board and LowEndBox's offer posts, from their public feeds through Sources (`lowendtalk-offers`, `lowendbox-posts`; `server/catalog/offers.js`). Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- Hosting offers (plan T20 step 3, "a modern LowEndBox"): the offers providers post on LowEndTalk's Offers board and++-- LowEndBox's offer posts, read from their public feeds through OpenVibe.Sources (category hosting,++-- server/catalog/offers.js). Host keeps the headline, who posted it, the post's date and link, and the price the++-- headline (or the post's opening) states, exactly as stated; never the post's text.++CREATE TABLE catalog_offers (++ id text COLLATE "C" PRIMARY KEY,++ source_key text COLLATE "C" NOT NULL,++ external_id text COLLATE "C" NOT NULL,++ source_item_id text COLLATE "C",++ title text NOT NULL,++ poster text,++ url text NOT NULL,++ published_at bigint NOT NULL,++ price_text text,++ price_from text COLLATE "C" CHECK (price_from IN ('title','post')),++ price_amount numeric(12,2),++ price_currency text COLLATE "C",++ price_period text COLLATE "C" CHECK (price_period IN ('month','year')),++ price_monthly numeric(12,4),++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','hidden')),++ first_seen_at bigint NOT NULL,++ checked_at bigint NOT NULL,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (source_key, external_id),++ CHECK ((price_text IS NULL) = (price_monthly IS NULL))++);++CREATE INDEX catalog_offers_recent ON catalog_offers (status, published_at DESC, id);++CREATE INDEX catalog_offers_price ON catalog_offers (status, price_currency, price_monthly, id);@@@ -78,6 +78,18 @@.hc-promo-meta { font-size: 13px; color: var(--hs-muted); }.hc-code code { padding: 2px 8px; border-radius: 6px; background: var(--hs-bg); border: 1px dashed var(--hs-line); font-size: 14px; user-select: all; }++/* Offers. */++.hc-offers { list-style: none; margin: 0; padding: 0; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }++.hc-offer { display: grid; grid-template-columns: 9.5rem 1fr; gap: .25rem 1rem; padding: .8rem 1rem; overflow-wrap: anywhere; }++.hc-offer + .hc-offer { border-top: 1px solid var(--hs-line); }++.hc-offer p { margin: 0; font-size: 13px; color: var(--hs-muted); }++.hc-offer-price { display: flex; flex-direction: column; }++.hc-offer strong { font-size: 1.05rem; color: var(--hs-fg); }++.hc-offer h3 { margin: 0 0 .3rem; font-size: 1rem; }++.hc-offer h3 a:not(:hover) { color: var(--hs-fg); text-decoration: none; }++.hc-filters .hc-check { flex-direction: row; align-items: center; }++.hc-filters .hc-check input { width: auto; }++/* A plan. */.hc-plan { display: grid; grid-template-columns: minmax(16rem, 22rem) 1fr 1fr; gap: 1.25rem; align-items: start; }.hc-plan h2 { margin-top: 0; font-size: 1rem; }@@@ -134,6 +146,8 @@.hc-table td[data-label="Provider"]::before { display: none; }.hc-table td[data-label="Checked"] { grid-column: 1 / -1; white-space: normal; }.hc-filters { grid-template-columns: 1fr 1fr; }++ .hc-offer { grid-template-columns: 1fr; }++ .hc-offer-price { flex-flow: row wrap; gap: 0 .6rem; }.hc-filter-actions { grid-column: 1 / -1; }.hc-spec { grid-template-columns: 1fr 1fr; }}@@@ -156,8 +156,8 @@ async function createApp(opts = {}) {app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {try {-- const [result, providers, promos, stats] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats()]);-- res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));++ const [result, providers, promos, stats, recent] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({})]);++ res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));} catch (err) { next(err); }});app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));@@@ -2,6 +2,7 @@const ingest = require('openvibe-publishing/ingest');const { SOURCES } = require('./mapping');++const offers = require('./offers');function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {if (!config.catalog.ingest || !config.catalog.sourcesUrl) return { enabled: false, pull: async () => ({ disabled: true }), consume: async () => ({ status: 503, code: 'ingest.disabled' }) };const sources = source || ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: config.catalog.sourcesUrl, category: 'hosting' } }, fetchImpl });@@@ -13,7 +14,7 @@ function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {let unknown = 0;const result = await ingest.pullChanges({ db: store.db, cursor, source: sources, name: 'sources.hosting.after', apply: async (item, t) => {const result = await catalog.applyItem(item, t);-- if (result === 'hold' && !SOURCES[item.source_key]) unknown++;++ if (result === 'hold' && !SOURCES[item.source_key] && !offers.SOURCES[item.source_key]) unknown++;return result;} });return { ...result, unknown };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++++/**++ * Hosting offers (plan T20 step 3, "a modern LowEndBox with honest data"): what providers post on LowEndTalk's Offers++ * board and LowEndBox's offer posts, from their public RSS feeds through OpenVibe.Sources (category hosting, plain++ * `rss` items: title, link, date, author, a short plain-text summary).++ *++ * Host keeps the headline, the poster, the date and the link, and reads one price out of the headline (or, when the++ * headline states none, out of the post's opening): the cheapest "<amount> per <month|year>" it states, kept as the++ * exact text with its monthly equivalent (a yearly price divided by 12). Nothing else is inferred: a price stated++ * without a period is no price here, a currency is only $, US$, €, EUR, £ or GBP, and an offer whose post states no++ * price shows "Price in the post".++ */++const SOURCES = Object.freeze({++ 'lowendtalk-offers': { label: 'LowEndTalk', board: 'Offers', home: 'https://lowendtalk.com/categories/offers' },++ 'lowendbox-posts': { label: 'LowEndBox', board: null, home: 'https://lowendbox.com/' },++});++// LowEndBox's feed mixes editorials and giveaways with offers: an offer post is filed under "Low End …", or its++// headline states a price and it is not a giveaway.++const LEB_OFFER_CATEGORY = /^Low End /i;++const LEB_GIVEAWAY = /^Giveaways?$/i;++const MAX_AGE_DAYS = 120;++++const CURRENCY = { '$': 'USD', 'us$': 'USD', usd: 'USD', '€': 'EUR', eur: 'EUR', '£': 'GBP', gbp: 'GBP' };++const PERIOD = { mo: 'month', mon: 'month', mth: 'month', month: 'month', monthly: 'month', yr: 'year', year: 'year', yearly: 'year', annually: 'year', annum: 'year' };++const AMOUNT = '(\\d{1,5}(?:[.,]\\d{1,2})?)';++const SYMBOL = '(US\\$|\\$|€|£|USD|EUR|GBP)';++const PER = '\\s*(?:/|per|a|an|each)\\s*(mo|mon|mth|month|monthly|yr|year|yearly|annually|annum)\\b';++// "$2.25/mo", "From $9.95/yr", "US$ 14.80 per Year", "2.60€/month", "€3.49 / Month", "4.17 EUR/mo".++const BEFORE = new RegExp(`${SYMBOL}\\s?${AMOUNT}${PER}`, 'gi');++const AFTER = new RegExp(`${AMOUNT}\\s?${SYMBOL}${PER}`, 'gi');++++/** Every "<amount> per <period>" in `text` → [{ text, amount, currency, period, monthly }], cheapest monthly first. */++function statedPrices(text) {++ const s = String(text || '').replace(/\s+/g, ' ');++ const found = [];++ const add = (whole, symbol, amount, period) => {++ const currency = CURRENCY[symbol.toLowerCase()];++ const per = PERIOD[period.toLowerCase()];++ const n = Number(amount.replace(',', '.'));++ if (!currency || !per || !Number.isFinite(n) || n <= 0) return;++ found.push({ text: whole.trim(), amount: n, currency, period: per, monthly: Math.round((per === 'year' ? n / 12 : n) * 10000) / 10000 });++ };++ for (const m of s.matchAll(BEFORE)) add(m[0], m[1], m[2], m[3]);++ for (const m of s.matchAll(AFTER)) add(m[0], m[2], m[1], m[3]);++ return found.sort((a, b) => a.monthly - b.monthly);++}++++/** A Sources rss item from an offer feed → the offer row's values, or null when it is not an offer (or too old). */++function mapOffer(item, now = Date.now()) {++ const source = SOURCES[item && item.source_key];++ if (!source) return null;++ if (item.category !== 'hosting' || !item.provenance || !item.provenance.retrieved_at || !item.provenance.first_seen_at) throw new TypeError('incomplete hosting item');++ const title = typeof item.title === 'string' ? item.title.replace(/\s+/g, ' ').trim().slice(0, 300) : '';++ const url = typeof item.canonical_url === 'string' ? item.canonical_url : '';++ const id = typeof item.identity === 'string' ? item.identity.trim() : '';++ const published = Date.parse(item.published_at || '');++ if (!title || !id || id.length > 2048 || !/^https:\/\//.test(url) || !Number.isFinite(published)) throw new TypeError('offer needs a title, link, id and date');++ if (published < now - MAX_AGE_DAYS * 86400000) return null;++ const inTitle = statedPrices(title);++ if (item.source_key === 'lowendbox-posts') {++ const categories = Array.isArray(item.fields && item.fields.categories) ? item.fields.categories : [];++ const filed = (re) => categories.some((c) => re.test(String(c)));++ if (!filed(LEB_OFFER_CATEGORY) && (!inTitle.length || filed(LEB_GIVEAWAY))) return null;++ }++ const inPost = inTitle.length ? [] : statedPrices(item.summary);++ const price = inTitle[0] || inPost[0] || null;++ const poster = Array.isArray(item.authors) && typeof item.authors[0] === 'string' ? item.authors[0].trim().slice(0, 100) || null : null;++ return {++ source_key: item.source_key, external_id: id, source_item_id: item.id || null, title, poster, url, published_at: published,++ price_text: price ? price.text.slice(0, 80) : null, price_from: price ? (inTitle.length ? 'title' : 'post') : null,++ price_amount: price ? price.amount : null, price_currency: price ? price.currency : null,++ price_period: price ? price.period : null, price_monthly: price ? price.monthly : null,++ checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),++ };++}++++module.exports = { SOURCES, MAX_AGE_DAYS, statedPrices, mapOffer };@@@ -3,6 +3,7 @@const { newId } = require('../ids');const { ApiError } = require('../http/errors');const { mapItem } = require('./mapping');++const offerFeed = require('./offers');const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };const fields = {@@@ -102,6 +103,7 @@ function createCatalog({ store, community = null, log = console }) {return await get(type, newKey);});async function applyItem(item, t = db) {++ if (item && offerFeed.SOURCES[item.source_key]) return await applyOffer(item, t);if (item && item.removed) return 'hold';const v = mapItem(item);if (!v) return 'hold';@@@ -126,6 +128,58 @@ function createCatalog({ store, community = null, log = console }) {}return 'applied';}++ // An offer post from a feed (server/catalog/offers.js): inserted once, its headline and stated price updated when++ // the source revises it; a post the source removed is hidden.++ async function applyOffer(item, t) {++ const now = store.now();++ if (item.removed) {++ const r = await t.prepare("UPDATE catalog_offers SET status = 'hidden', updated_at = ? WHERE source_key = ? AND external_id = ? AND status = 'active'").run(now, item.source_key, String(item.identity || ''));++ return r.changes ? 'removed' : 'hold';++ }++ const v = offerFeed.mapOffer(item, now);++ if (!v) return 'hold';++ const cols = ['source_item_id', 'title', 'poster', 'url', 'published_at', 'price_text', 'price_from', 'price_amount', 'price_currency', 'price_period', 'price_monthly', 'checked_at'];++ const old = await t.prepare('SELECT id, checked_at FROM catalog_offers WHERE source_key = ? AND external_id = ?').get(v.source_key, v.external_id);++ if (old) {++ if (v.checked_at < Number(old.checked_at)) return 'hold';++ await t.prepare(`UPDATE catalog_offers SET ${cols.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`).run(...cols.map((k) => v[k]), now, old.id);++ } else {++ const all = { id: newId('catalogOffer', now), source_key: v.source_key, external_id: v.external_id, ...Object.fromEntries(cols.map((k) => [k, v[k]])), status: 'active', first_seen_at: v.first_seen_at, created_at: now, updated_at: now };++ await t.prepare(`INSERT INTO catalog_offers (${Object.keys(all).join(', ')}) VALUES (${Object.keys(all).map(() => '?').join(', ')})`).run(...Object.values(all));++ }++ return 'applied';++ }++ // Offers posted in the last MAX_AGE_DAYS: newest first, or by the stated monthly price within one currency.++ const OFFER_SORTS = { new: 'published_at DESC, id DESC', price: 'price_monthly ASC, published_at DESC, id DESC' };++ const offerOut = (o) => ({ ...o, source: offerFeed.SOURCES[o.source_key] ? offerFeed.SOURCES[o.source_key].label : o.source_key, published_at: new Date(Number(o.published_at)).toISOString(), checked_at: new Date(Number(o.checked_at)).toISOString(),++ price_amount: o.price_amount == null ? null : Number(o.price_amount), price_monthly: o.price_monthly == null ? null : Number(o.price_monthly) });++ async function offers(query = {}) {++ const allowed = new Set(['sort', 'currency', 'max_monthly', 'source', 'priced', 'cursor']);++ for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);++ const sort = query.sort || 'new';++ if (!OFFER_SORTS[sort]) invalid('Invalid sort');++ if (sort === 'price' && !query.currency) invalid('Sorting by price needs a currency: prices are never converted');++ const args = [store.now() - offerFeed.MAX_AGE_DAYS * 86400000];++ let where = "status = 'active' AND published_at >= ?";++ if (query.currency) {++ if (!['USD', 'EUR', 'GBP'].includes(query.currency)) invalid('Invalid currency');++ where += ' AND price_currency = ?'; args.push(query.currency);++ }++ if (query.source) {++ if (!offerFeed.SOURCES[query.source]) invalid('Invalid source');++ where += ' AND source_key = ?'; args.push(query.source);++ }++ if (query.priced === '1') where += ' AND price_monthly IS NOT NULL';++ if (query.max_monthly != null && query.max_monthly !== '') {++ const n = Number(query.max_monthly);++ if (!Number.isFinite(n) || n < 0) invalid('Invalid max_monthly');++ where += ' AND price_monthly <= ?'; args.push(n);++ }++ const offset = query.cursor == null ? 0 : Number(query.cursor);++ if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');++ const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);++ return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };++ }const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });async function plans(query = {}) {const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);@@@ -178,6 +232,6 @@ function createCatalog({ store, community = null, log = console }) {}// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }-- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, plans, plan, providers, promos, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };++ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };}module.exports = { createCatalog };@@@ -9,7 +9,7 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');--const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];++const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_offers', 'catalog_ingest_cursor'];/*** The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database@@@ -22,6 +22,7 @@ function createCatalogApi({ catalog, viewers, actorLimits }) {router.get('/providers', run(async () => ({ providers: await catalog.providers() })));router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));router.get('/promos', run(async () => ({ promos: await catalog.promos() })));++ router.get('/offers', run(async (req) => await catalog.offers(req.query)));for (const type of ['providers', 'plans', 'promos']) {router.post(`/${type}`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));router.patch(`/${type}/:id`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));@@@ -85,6 +86,14 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,router.get('/promos', view(async (req, res) => {page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');}));++ router.get('/offers', view(async (req, res) => {++ // Sorting by price stays within one currency (nothing is converted): the page picks USD when none is chosen.++ const query = { ...req.query };++ if (query.sort === 'price' && !query.currency) query.currency = 'USD';++ const result = await catalog.offers(query);++ page(req, res, 'Hosting offers', render.offers({ result, query }), !Object.keys(req.query).length,++ 'Hosting offers from LowEndTalk and LowEndBox, newest first or cheapest a month, each with the price its headline states and the day it was posted.');++ }));router.get('/manage', view(async (req, res) => {if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');const csrf = csrfToken(config, req.viewer);@@@ -2,7 +2,7 @@/** Catalog identifiers: <prefix>_<ULID>. */const { ids } = require('openvibe-contracts');--const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr' };++const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr', catalogOffer: 'hof' };const ULID = '[0-9A-HJKMNP-TV-Z]{26}';const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));@@@ -8,6 +8,7 @@* provider(p) /catalog/providers/:slug* plan(p) /catalog/plans/:id* promos(list) /catalog/promos++ * offers({ result, query }) /catalog/offers: what providers post on LowEndTalk and LowEndBox** Every price shows when it was checked and links where it came from; an unknown value reads "Unknown", never 0;* affiliate links carry rel="sponsored nofollow noopener" with the provider's own disclosure beside them, and the@@@ -16,6 +17,7 @@const showcase = require('openvibe-shared/showcase');const seo = require('openvibe-shared/seo');const { esc } = require('./layout');++const { MAX_AGE_DAYS } = require('../catalog/offers');const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];@@@ -227,6 +229,51 @@ ${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;}++// Offers (server/catalog/offers.js): headlines from LowEndTalk's Offers board and LowEndBox, each with the cheapest++// price per month or year its headline (or the post's opening) states, and that price a month.++const OFFER_SORTS = [['new', 'Newest'], ['price', 'Cheapest a month']];++const OFFER_CURRENCIES = [['', 'Any currency'], ['USD', 'USD ($)'], ['EUR', 'EUR (€)'], ['GBP', 'GBP (£)']];++const OFFER_SOURCES = [['', 'Both boards'], ['lowendtalk-offers', 'LowEndTalk Offers'], ['lowendbox-posts', 'LowEndBox']];++const daysAgo = (iso, now) => Math.floor((now - Date.parse(iso)) / 86400000);++function offerItem(o, now) {++ const age = daysAgo(o.published_at, now);++ const monthly = o.price_period === 'year' ? money(o.price_monthly, o.price_currency) : null;++ const price = o.price_text++ ? `<p class="hc-offer-price"><strong>${esc(o.price_text)}</strong>${monthly ? `<span>${esc(monthly)} a month</span>` : ''}${o.price_from === 'post' ? '<span>stated in the post</span>' : ''}</p>`++ : '<p class="hc-offer-price">Price in the post</p>';++ const when = `<time datetime="${esc(o.published_at)}">${esc(day(o.published_at))}</time>`;++ return `<li class="hc-offer">${price}++<div class="hc-offer-body"><h3><a href="${esc(o.url)}" rel="nofollow ugc noopener">${esc(o.title)}</a></h3>++<p class="hc-offer-meta">${esc(o.source)}${o.poster ? ` · posted by ${esc(o.poster)}` : ''} · ${when}${age > 30 ? ` <span class="hc-chip" title="Offers often end; the post says whether this one still runs">${age} days old</span>` : ''}</p></div></li>`;++}++function offers({ result, query = {}, now = Date.now() }) {++ const q = query || {};++ const list = result.offers;++ const filtered = ['currency', 'max_monthly', 'source', 'priced'].some((k) => q[k]);++ const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog/offers?${new URLSearchParams({ ...q, cursor: result.next_cursor })}`, 'Next 50 offers →', 'button hc-button-quiet')}</p>` : '';++ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · hosting compared</p><h1>Hosting offers</h1>++<p class="hc-lede">What providers post on LowEndTalk's Offers board and LowEndBox, with the price each headline states and that price a month. Sort by the monthly price within one currency; nothing is converted.</p></header>++<form class="hc-filters" method="get" action="/catalog/offers" role="search" aria-label="Filter hosting offers">++${select('sort', 'Sort by', OFFER_SORTS, q.sort || 'new')}++${select('currency', 'Currency', OFFER_CURRENCIES, q.currency)}++${input('max_monthly', 'Max a month', q.max_monthly, 'type="number" min="0" step="0.01" inputmode="decimal" placeholder="Any"')}++${select('source', 'Board', OFFER_SOURCES, q.source)}++<label class="hc-check"><input type="checkbox" name="priced" value="1"${q.priced === '1' ? ' checked' : ''}> <span>Only with a stated price</span></label>++<div class="hc-filter-actions"><button type="submit">Show offers</button> <a href="/catalog/offers" class="hc-reset">Reset</a></div>++</form>++${list.length ? `<ol class="hc-offers" aria-label="${list.length}${result.next_cursor ? '+' : ''} offers">${list.map((o) => offerItem(o, now)).join('')}</ol>`++ : filtered ? `<div class="hc-empty"><p><strong>No offer matches these filters.</strong></p><p>${link('/catalog/offers', 'Clear the filters')} or widen one of them.</p></div>`++ : '<div class="hc-empty"><p><strong>No offer has arrived yet.</strong></p><p>Offers arrive as LowEndTalk\'s and LowEndBox\'s feeds are read, every hour.</p></div>'}++${next}++<section class="hc-method" aria-labelledby="hc-offers-method-h"><h2 id="hc-offers-method-h">How offers are read</h2><ul>++<li><strong>From the boards' own feeds.</strong> LowEndTalk's Offers board, where providers post their offers, and LowEndBox's offer posts, through OpenVibe.Sources. Each headline links to its post, which has the details and the final word.</li>++<li><strong>The price as stated.</strong> The cheapest "per month" or "per year" price the headline states, or the post's opening when the headline states none. A yearly price is shown a month too (divided by 12). A price without a period is not read.</li>++<li><strong>Dated.</strong> Every offer shows the day it was posted; one older than 30 days is marked, and offers older than ${MAX_AGE_DAYS} days drop off.</li>++<li><strong>Not for sale.</strong> The order comes only from the sort you pick. No provider pays to be listed here.</li>++</ul></section>++<p class="hc-links">${link('/catalog', '← Compare plans')} · ${link('/catalog/promos', 'Current promotions')}</p>`;++}++/** JSON-LD for the catalog pages (only indexable pages carry it; renderPage drops it otherwise). */const planUrl = (base, p) => `${base}/catalog/plans/${encodeURIComponent(p.id)}`;const jsonLd = {@@@ -254,7 +301,7 @@ const jsonLd = {};/** The front page of the hosting comparison. */--function front({ plans, providers, promos: live, stats }) {++function front({ plans, providers, promos: live, stats, offers: recent = [], now = Date.now() }) {const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now"><p class="hc-aside-h">Lowest monthly prices right now</p><ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>@@@ -265,7 +312,7 @@ function front({ plans, providers, promos: live, stats }) {title: 'Hosting, compared honestly.',accent: 'Every price dated and sourced.',lede: 'Cloud, VPS and dedicated plans from providers\' official APIs, side by side: price per month, per vCPU and per GB, the day each price was checked and a link to where it came from. Sorted by what you ask for, never by who pays.',-- actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Current promotions', href: '/catalog/promos' }],++ actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Latest offers', href: '/catalog/offers' }],note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',aside: aside ? { html: aside } : undefined,}) + `<section class="sc-sec">${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}@@@ -279,8 +326,9 @@ function front({ plans, providers, promos: live, stats }) {{ icon: 'ov:search', title: 'Compare what matters', text: 'Sort by monthly price, or by price per vCPU, per GB of RAM or per GB of disk. Unknown stays unknown.' },{ icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Affiliate links are marked, disclosed beside the link, and never change the order.' },],-- }) + (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')++ }) + (recent.length ? `<section class="sc-sec"><h2>Latest hosting offers</h2><ol class="hc-offers">${recent.slice(0, 6).map((o) => offerItem(o, now)).join('')}</ol><p class="hc-more">${link('/catalog/offers', 'Every offer, by price or date →')}</p></section>` : '')++ + (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;}--module.exports = { front, catalog, providerList, provider, plan, promos, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };++module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };@@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');const shell = require('openvibe-shared/shell');const NETWORK_URL = 'https://openvibe.network';--const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Promotions', href: '/catalog/promos' }];++const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';const SITE_NAME = 'OpenVibe.Host';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++/**++ * Hosting offers (server/catalog/offers.js): LowEndTalk's Offers board and LowEndBox posts from Sources rss items. The++ * price is the cheapest "per month|year" the headline (or the post's opening) states, kept as stated with its monthly++ * equivalent; LowEndBox editorials and giveaways are not offers; a removed post is hidden; price sorting needs one++ * currency; the page and the front page list them, newest first.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { statedPrices, mapOffer } = require('../server/catalog/offers');++++const NOW = Date.parse('2026-09-22T12:00:00Z');++let n = 0;++const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null, authors = ['SomeHost'], categories = [], removed = null, retrieved = '2026-09-22T11:00:00Z', identity = null } = {}) => ({++ id: `itm_${String(++n).padStart(26, '0')}`, source_key: key, category: 'hosting', kind: 'article', identity: identity || `https://lowendtalk.com/discussion/${n}`,++ canonical_url: identity || `https://lowendtalk.com/discussion/${n}/offer`, title, summary, authors, published_at: published, source_updated_at: null,++ fields: { categories }, revision: 1, removed,++ provenance: { retrieved_at: retrieved, first_seen_at: '2026-09-22T11:00:00Z', content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'feed@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },++});++++(async () => {++ await check('stated prices: symbol before or after, month or year, cheapest a month first; no period is no price', async () => {++ const p = statedPrices('KVM VPS – From €6.99/year (~$8.03) – Dedicated $45/mo | 9.99€/yr | US$ 14.80 per Year | 2,60€/month');++ assert.deepStrictEqual(p.map((x) => [x.text, x.currency, x.period, x.monthly]), [++ ['€6.99/year', 'EUR', 'year', 0.5825], ['9.99€/yr', 'EUR', 'year', 0.8325], ['US$ 14.80 per Year', 'USD', 'year', 1.2333], ['2,60€/month', 'EUR', 'month', 2.6], ['$45/mo', 'USD', 'month', 45],++ ]);++ assert.deepStrictEqual(statedPrices('Up to 55% off | $500 Halloween Giveaway | 4GB for $5'), []);++ assert.deepStrictEqual(statedPrices('£6 / Month')[0].currency, 'GBP');++ });++ await check('mapping: the headline price, else the post\'s; LowEndBox editorials and giveaways are not offers; too old is dropped', async () => {++ const a = mapOffer(item('lowendtalk-offers', 'Linveo - KVM VPS from $2.85/month - Ohio'), NOW);++ assert.deepStrictEqual([a.price_text, a.price_from, a.price_monthly, a.poster], ['$2.85/month', 'title', 2.85, 'SomeHost']);++ const b = mapOffer(item('lowendtalk-offers', 'DeluxHost | VPS Restock | NL', { summary: 'Plans: 1 GB for 6€/yr, 2 GB for 10€/yr.' }), NOW);++ assert.deepStrictEqual([b.price_text, b.price_from, b.price_monthly], ['6€/yr', 'post', 0.5]);++ const c = mapOffer(item('lowendtalk-offers', 'Blackbox: anonymous VPS', { summary: 'Ask us.' }), NOW);++ assert.deepStrictEqual([c.price_text, c.price_monthly], [null, null]);++ assert.strictEqual(mapOffer(item('lowendbox-posts', 'How Much VPS Do You Actually Need?', { categories: ['Editorial & News'] }), NOW), null);++ assert.strictEqual(mapOffer(item('lowendbox-posts', 'Last day! ServerHost $16.99/YR giveaway', { categories: ['Giveaways'] }), NOW), null);++ assert.ok(mapOffer(item('lowendbox-posts', 'VPSPioneer: France from $2.49/Month!', { categories: ['Editorial & News'] }), NOW));++ assert.ok(mapOffer(item('lowendbox-posts', 'Velohost returns', { categories: ['Low End Virtual'] }), NOW));++ assert.strictEqual(mapOffer(item('lowendtalk-offers', 'Old $1/mo', { published: '2026-01-01T00:00:00Z' }), NOW), null);++ assert.strictEqual(mapOffer({ source_key: 'vultr-plans' }, NOW), null, 'a plan source is not an offer source');++ assert.throws(() => mapOffer(item('lowendtalk-offers', ''), NOW), /title/);++ });++++ const t = await boot();++ const catalog = t.ctx.catalog;++ try {++ await check('apply: inserted once, revised in place, a removed post hidden; plans untouched', async () => {++ const first = item('lowendtalk-offers', 'Arct Cloud | EPYC VPS from $3.89/mo', { identity: 'https://lowendtalk.com/discussion/9001/arct' });++ assert.strictEqual(await catalog.applyItem(first), 'applied');++ assert.strictEqual(await catalog.applyItem({ ...first, title: 'Arct Cloud | EPYC VPS from $3.49/mo', provenance: { ...first.provenance, retrieved_at: '2026-09-22T11:30:00Z' } }), 'applied');++ let rows = (await catalog.offers({})).offers;++ assert.strictEqual(rows.length, 1);++ assert.deepStrictEqual([rows[0].title, rows[0].price_text, rows[0].price_monthly, rows[0].source], ['Arct Cloud | EPYC VPS from $3.49/mo', '$3.49/mo', 3.49, 'LowEndTalk']);++ assert.match(rows[0].id, /^hof_/);++ assert.strictEqual(await catalog.applyItem({ ...first, removed: { at: '2026-09-22T11:45:00Z', reason: 'gone' } }), 'removed');++ rows = (await catalog.offers({})).offers;++ assert.strictEqual(rows.length, 0);++ assert.strictEqual(await catalog.applyItem(item('lowendbox-posts', 'Reverse DNS: what is it good for?', { categories: ['Editorial & News'] })), 'hold');++ assert.strictEqual((await catalog.plans({})).plans.length, 0);++ });++ await check('the list: newest first; cheapest a month within one currency; filters', async () => {++ await catalog.applyItem(item('lowendtalk-offers', 'Yearly KVM $12/year', { published: '2026-09-21T09:00:00Z' }));++ await catalog.applyItem(item('lowendtalk-offers', 'EU VPS €3.99/mo', { published: '2026-09-21T10:00:00Z' }));++ await catalog.applyItem(item('lowendbox-posts', 'Godlike: 2GB for $1.83/Month', { published: '2026-09-21T11:00:00Z', categories: ['Low End Virtual'], authors: ['raindog308'] }));++ await catalog.applyItem(item('lowendtalk-offers', 'Dedicated servers, ask for a quote', { published: '2026-09-21T12:00:00Z' }));++ const newest = (await t.api('GET', '/api/v1/catalog/offers')).json().offers;++ assert.deepStrictEqual(newest.map((o) => o.title), ['Dedicated servers, ask for a quote', 'Godlike: 2GB for $1.83/Month', 'EU VPS €3.99/mo', 'Yearly KVM $12/year']);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?sort=price')).status, 422, 'a price sort without a currency converts nothing');++ const usd = (await t.api('GET', '/api/v1/catalog/offers?sort=price¤cy=USD')).json().offers;++ assert.deepStrictEqual(usd.map((o) => [o.price_text, o.price_monthly]), [['$12/year', 1], ['$1.83/Month', 1.83]]);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?priced=1')).json().offers.length, 3);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?source=lowendbox-posts')).json().offers.length, 1);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?currency=USD&max_monthly=1.5')).json().offers.length, 1);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?source=elsewhere')).status, 422);++ });++ await check('the page and the front page show them; a price sort without a currency uses USD on the page', async () => {++ const page = await t.api('GET', '/catalog/offers');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<h1>Hosting offers<\/h1>/);++ assert.match(page.text, /rel="nofollow ugc noopener">Godlike: 2GB for \$1\.83\/Month<\/a>/);++ assert.match(page.text, /\$12\/year<\/strong><span>\$1\.00 a month<\/span>/);++ assert.match(page.text, /Price in the post/);++ assert.match(page.text, /LowEndBox · posted by raindog308/);++ const sorted = await t.api('GET', '/catalog/offers?sort=price');++ assert.strictEqual(sorted.status, 200);++ assert.ok(sorted.text.indexOf('$12/year') < sorted.text.indexOf('$1.83/Month'));++ assert.doesNotMatch(sorted.text, /€3\.99/);++ const front = await t.api('GET', '/');++ assert.match(front.text, /Latest hosting offers/);++ assert.match(front.text, /href="\/catalog\/offers"/);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -24,6 +24,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -24,6 +24,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
robots, so that Sources seed stays disabled.
robots, so that Sources seed stays disabled.
- Staff entries, which must name their source URL.
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- Nothing is estimated. A value the source does not state is shown as Unknown.
+- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndTalk's Offers board and LowEndBox's offer posts, from their public feeds through Sources (`lowendtalk-offers`, `lowendbox-posts`; `server/catalog/offers.js`). Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- Hosting offers (plan T20 step 3, "a modern LowEndBox"): the offers providers post on LowEndTalk's Offers board and
+-- LowEndBox's offer posts, read from their public feeds through OpenVibe.Sources (category hosting,
+-- server/catalog/offers.js). Host keeps the headline, who posted it, the post's date and link, and the price the
+-- headline (or the post's opening) states, exactly as stated; never the post's text.
+CREATE TABLE catalog_offers (
+ id text COLLATE "C" PRIMARY KEY,
+ source_key text COLLATE "C" NOT NULL,
+ external_id text COLLATE "C" NOT NULL,
+ source_item_id text COLLATE "C",
+ title text NOT NULL,
+ poster text,
+ url text NOT NULL,
+ published_at bigint NOT NULL,
+ price_text text,
+ price_from text COLLATE "C" CHECK (price_from IN ('title','post')),
+ price_amount numeric(12,2),
+ price_currency text COLLATE "C",
+ price_period text COLLATE "C" CHECK (price_period IN ('month','year')),
+ price_monthly numeric(12,4),
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','hidden')),
+ first_seen_at bigint NOT NULL,
+ checked_at bigint NOT NULL,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (source_key, external_id),
+ CHECK ((price_text IS NULL) = (price_monthly IS NULL))
+);
+CREATE INDEX catalog_offers_recent ON catalog_offers (status, published_at DESC, id);
+CREATE INDEX catalog_offers_price ON catalog_offers (status, price_currency, price_monthly, id);
@@ -78,6 +78,18 @@
@@ -78,6 +78,18 @@
.hc-promo-meta { font-size: 13px; color: var(--hs-muted); }
.hc-promo-meta { font-size: 13px; color: var(--hs-muted); }
.hc-code code { padding: 2px 8px; border-radius: 6px; background: var(--hs-bg); border: 1px dashed var(--hs-line); font-size: 14px; user-select: all; }
.hc-code code { padding: 2px 8px; border-radius: 6px; background: var(--hs-bg); border: 1px dashed var(--hs-line); font-size: 14px; user-select: all; }
+/* Offers. */
+.hc-offers { list-style: none; margin: 0; padding: 0; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
+.hc-offer { display: grid; grid-template-columns: 9.5rem 1fr; gap: .25rem 1rem; padding: .8rem 1rem; overflow-wrap: anywhere; }
+.hc-offer + .hc-offer { border-top: 1px solid var(--hs-line); }
+.hc-offer p { margin: 0; font-size: 13px; color: var(--hs-muted); }
+.hc-offer-price { display: flex; flex-direction: column; }
+.hc-offer strong { font-size: 1.05rem; color: var(--hs-fg); }
+.hc-offer h3 { margin: 0 0 .3rem; font-size: 1rem; }
+.hc-offer h3 a:not(:hover) { color: var(--hs-fg); text-decoration: none; }
+.hc-filters .hc-check { flex-direction: row; align-items: center; }
+.hc-filters .hc-check input { width: auto; }
+
/* A plan. */
/* A plan. */
.hc-plan { display: grid; grid-template-columns: minmax(16rem, 22rem) 1fr 1fr; gap: 1.25rem; align-items: start; }
.hc-plan { display: grid; grid-template-columns: minmax(16rem, 22rem) 1fr 1fr; gap: 1.25rem; align-items: start; }
.hc-plan h2 { margin-top: 0; font-size: 1rem; }
.hc-plan h2 { margin-top: 0; font-size: 1rem; }
@@ -134,6 +146,8 @@
@@ -134,6 +146,8 @@
.hc-table td[data-label="Provider"]::before { display: none; }
.hc-table td[data-label="Provider"]::before { display: none; }
.hc-table td[data-label="Checked"] { grid-column: 1 / -1; white-space: normal; }
.hc-table td[data-label="Checked"] { grid-column: 1 / -1; white-space: normal; }
.hc-filters { grid-template-columns: 1fr 1fr; }
.hc-filters { grid-template-columns: 1fr 1fr; }
+ .hc-offer { grid-template-columns: 1fr; }
+ .hc-offer-price { flex-flow: row wrap; gap: 0 .6rem; }
.hc-filter-actions { grid-column: 1 / -1; }
.hc-filter-actions { grid-column: 1 / -1; }
.hc-spec { grid-template-columns: 1fr 1fr; }
.hc-spec { grid-template-columns: 1fr 1fr; }
}
}
@@ -156,8 +156,8 @@ async function createApp(opts = {}) {
@@ -156,8 +156,8 @@ async function createApp(opts = {}) {
app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));
app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
try {
try {
- const [result, providers, promos, stats] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats()]);
- res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));
+ const [result, providers, promos, stats, recent] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({})]);
+ res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));
} catch (err) { next(err); }
} catch (err) { next(err); }
});
});
app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));
app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
const ingest = require('openvibe-publishing/ingest');
const ingest = require('openvibe-publishing/ingest');
const { SOURCES } = require('./mapping');
const { SOURCES } = require('./mapping');
+const offers = require('./offers');
function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
if (!config.catalog.ingest || !config.catalog.sourcesUrl) return { enabled: false, pull: async () => ({ disabled: true }), consume: async () => ({ status: 503, code: 'ingest.disabled' }) };
if (!config.catalog.ingest || !config.catalog.sourcesUrl) return { enabled: false, pull: async () => ({ disabled: true }), consume: async () => ({ status: 503, code: 'ingest.disabled' }) };
const sources = source || ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: config.catalog.sourcesUrl, category: 'hosting' } }, fetchImpl });
const sources = source || ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: config.catalog.sourcesUrl, category: 'hosting' } }, fetchImpl });
@@ -13,7 +14,7 @@ function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
@@ -13,7 +14,7 @@ function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
let unknown = 0;
let unknown = 0;
const result = await ingest.pullChanges({ db: store.db, cursor, source: sources, name: 'sources.hosting.after', apply: async (item, t) => {
const result = await ingest.pullChanges({ db: store.db, cursor, source: sources, name: 'sources.hosting.after', apply: async (item, t) => {
const result = await catalog.applyItem(item, t);
const result = await catalog.applyItem(item, t);
- if (result === 'hold' && !SOURCES[item.source_key]) unknown++;
+ if (result === 'hold' && !SOURCES[item.source_key] && !offers.SOURCES[item.source_key]) unknown++;
return result;
return result;
} });
} });
return { ...result, unknown };
return { ...result, unknown };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * Hosting offers (plan T20 step 3, "a modern LowEndBox with honest data"): what providers post on LowEndTalk's Offers
+ * board and LowEndBox's offer posts, from their public RSS feeds through OpenVibe.Sources (category hosting, plain
+ * `rss` items: title, link, date, author, a short plain-text summary).
+ *
+ * Host keeps the headline, the poster, the date and the link, and reads one price out of the headline (or, when the
+ * headline states none, out of the post's opening): the cheapest "<amount> per <month|year>" it states, kept as the
+ * exact text with its monthly equivalent (a yearly price divided by 12). Nothing else is inferred: a price stated
+ * without a period is no price here, a currency is only $, US$, €, EUR, £ or GBP, and an offer whose post states no
+ * price shows "Price in the post".
+ */
+const SOURCES = Object.freeze({
+ 'lowendtalk-offers': { label: 'LowEndTalk', board: 'Offers', home: 'https://lowendtalk.com/categories/offers' },
+ 'lowendbox-posts': { label: 'LowEndBox', board: null, home: 'https://lowendbox.com/' },
+});
+// LowEndBox's feed mixes editorials and giveaways with offers: an offer post is filed under "Low End …", or its
+// headline states a price and it is not a giveaway.
+const LEB_OFFER_CATEGORY = /^Low End /i;
+const LEB_GIVEAWAY = /^Giveaways?$/i;
+const MAX_AGE_DAYS = 120;
+
+const CURRENCY = { '$': 'USD', 'us$': 'USD', usd: 'USD', '€': 'EUR', eur: 'EUR', '£': 'GBP', gbp: 'GBP' };
+const PERIOD = { mo: 'month', mon: 'month', mth: 'month', month: 'month', monthly: 'month', yr: 'year', year: 'year', yearly: 'year', annually: 'year', annum: 'year' };
+const AMOUNT = '(\\d{1,5}(?:[.,]\\d{1,2})?)';
+const SYMBOL = '(US\\$|\\$|€|£|USD|EUR|GBP)';
+const PER = '\\s*(?:/|per|a|an|each)\\s*(mo|mon|mth|month|monthly|yr|year|yearly|annually|annum)\\b';
+// "$2.25/mo", "From $9.95/yr", "US$ 14.80 per Year", "2.60€/month", "€3.49 / Month", "4.17 EUR/mo".
+const BEFORE = new RegExp(`${SYMBOL}\\s?${AMOUNT}${PER}`, 'gi');
+const AFTER = new RegExp(`${AMOUNT}\\s?${SYMBOL}${PER}`, 'gi');
+
+/** Every "<amount> per <period>" in `text` → [{ text, amount, currency, period, monthly }], cheapest monthly first. */
+function statedPrices(text) {
+ const s = String(text || '').replace(/\s+/g, ' ');
+ const found = [];
+ const add = (whole, symbol, amount, period) => {
+ const currency = CURRENCY[symbol.toLowerCase()];
+ const per = PERIOD[period.toLowerCase()];
+ const n = Number(amount.replace(',', '.'));
+ if (!currency || !per || !Number.isFinite(n) || n <= 0) return;
+ found.push({ text: whole.trim(), amount: n, currency, period: per, monthly: Math.round((per === 'year' ? n / 12 : n) * 10000) / 10000 });
+ };
+ for (const m of s.matchAll(BEFORE)) add(m[0], m[1], m[2], m[3]);
+ for (const m of s.matchAll(AFTER)) add(m[0], m[2], m[1], m[3]);
+ return found.sort((a, b) => a.monthly - b.monthly);
+}
+
+/** A Sources rss item from an offer feed → the offer row's values, or null when it is not an offer (or too old). */
+function mapOffer(item, now = Date.now()) {
+ const source = SOURCES[item && item.source_key];
+ if (!source) return null;
+ if (item.category !== 'hosting' || !item.provenance || !item.provenance.retrieved_at || !item.provenance.first_seen_at) throw new TypeError('incomplete hosting item');
+ const title = typeof item.title === 'string' ? item.title.replace(/\s+/g, ' ').trim().slice(0, 300) : '';
+ const url = typeof item.canonical_url === 'string' ? item.canonical_url : '';
+ const id = typeof item.identity === 'string' ? item.identity.trim() : '';
+ const published = Date.parse(item.published_at || '');
+ if (!title || !id || id.length > 2048 || !/^https:\/\//.test(url) || !Number.isFinite(published)) throw new TypeError('offer needs a title, link, id and date');
+ if (published < now - MAX_AGE_DAYS * 86400000) return null;
+ const inTitle = statedPrices(title);
+ if (item.source_key === 'lowendbox-posts') {
+ const categories = Array.isArray(item.fields && item.fields.categories) ? item.fields.categories : [];
+ const filed = (re) => categories.some((c) => re.test(String(c)));
+ if (!filed(LEB_OFFER_CATEGORY) && (!inTitle.length || filed(LEB_GIVEAWAY))) return null;
+ }
+ const inPost = inTitle.length ? [] : statedPrices(item.summary);
+ const price = inTitle[0] || inPost[0] || null;
+ const poster = Array.isArray(item.authors) && typeof item.authors[0] === 'string' ? item.authors[0].trim().slice(0, 100) || null : null;
+ return {
+ source_key: item.source_key, external_id: id, source_item_id: item.id || null, title, poster, url, published_at: published,
+ price_text: price ? price.text.slice(0, 80) : null, price_from: price ? (inTitle.length ? 'title' : 'post') : null,
+ price_amount: price ? price.amount : null, price_currency: price ? price.currency : null,
+ price_period: price ? price.period : null, price_monthly: price ? price.monthly : null,
+ checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
+ };
+}
+
+module.exports = { SOURCES, MAX_AGE_DAYS, statedPrices, mapOffer };
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
const { newId } = require('../ids');
const { newId } = require('../ids');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const { mapItem } = require('./mapping');
const { mapItem } = require('./mapping');
+const offerFeed = require('./offers');
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const fields = {
const fields = {
@@ -102,6 +103,7 @@ function createCatalog({ store, community = null, log = console }) {
@@ -102,6 +103,7 @@ function createCatalog({ store, community = null, log = console }) {
return await get(type, newKey);
return await get(type, newKey);
});
});
async function applyItem(item, t = db) {
async function applyItem(item, t = db) {
+ if (item && offerFeed.SOURCES[item.source_key]) return await applyOffer(item, t);
if (item && item.removed) return 'hold';
if (item && item.removed) return 'hold';
const v = mapItem(item);
const v = mapItem(item);
if (!v) return 'hold';
if (!v) return 'hold';
@@ -126,6 +128,58 @@ function createCatalog({ store, community = null, log = console }) {
@@ -126,6 +128,58 @@ function createCatalog({ store, community = null, log = console }) {
}
}
return 'applied';
return 'applied';
}
}
+ // An offer post from a feed (server/catalog/offers.js): inserted once, its headline and stated price updated when
+ // the source revises it; a post the source removed is hidden.
+ async function applyOffer(item, t) {
+ const now = store.now();
+ if (item.removed) {
+ const r = await t.prepare("UPDATE catalog_offers SET status = 'hidden', updated_at = ? WHERE source_key = ? AND external_id = ? AND status = 'active'").run(now, item.source_key, String(item.identity || ''));
+ return r.changes ? 'removed' : 'hold';
+ }
+ const v = offerFeed.mapOffer(item, now);
+ if (!v) return 'hold';
+ const cols = ['source_item_id', 'title', 'poster', 'url', 'published_at', 'price_text', 'price_from', 'price_amount', 'price_currency', 'price_period', 'price_monthly', 'checked_at'];
+ const old = await t.prepare('SELECT id, checked_at FROM catalog_offers WHERE source_key = ? AND external_id = ?').get(v.source_key, v.external_id);
+ if (old) {
+ if (v.checked_at < Number(old.checked_at)) return 'hold';
+ await t.prepare(`UPDATE catalog_offers SET ${cols.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`).run(...cols.map((k) => v[k]), now, old.id);
+ } else {
+ const all = { id: newId('catalogOffer', now), source_key: v.source_key, external_id: v.external_id, ...Object.fromEntries(cols.map((k) => [k, v[k]])), status: 'active', first_seen_at: v.first_seen_at, created_at: now, updated_at: now };
+ await t.prepare(`INSERT INTO catalog_offers (${Object.keys(all).join(', ')}) VALUES (${Object.keys(all).map(() => '?').join(', ')})`).run(...Object.values(all));
+ }
+ return 'applied';
+ }
+ // Offers posted in the last MAX_AGE_DAYS: newest first, or by the stated monthly price within one currency.
+ const OFFER_SORTS = { new: 'published_at DESC, id DESC', price: 'price_monthly ASC, published_at DESC, id DESC' };
+ const offerOut = (o) => ({ ...o, source: offerFeed.SOURCES[o.source_key] ? offerFeed.SOURCES[o.source_key].label : o.source_key, published_at: new Date(Number(o.published_at)).toISOString(), checked_at: new Date(Number(o.checked_at)).toISOString(),
+ price_amount: o.price_amount == null ? null : Number(o.price_amount), price_monthly: o.price_monthly == null ? null : Number(o.price_monthly) });
+ async function offers(query = {}) {
+ const allowed = new Set(['sort', 'currency', 'max_monthly', 'source', 'priced', 'cursor']);
+ for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
+ const sort = query.sort || 'new';
+ if (!OFFER_SORTS[sort]) invalid('Invalid sort');
+ if (sort === 'price' && !query.currency) invalid('Sorting by price needs a currency: prices are never converted');
+ const args = [store.now() - offerFeed.MAX_AGE_DAYS * 86400000];
+ let where = "status = 'active' AND published_at >= ?";
+ if (query.currency) {
+ if (!['USD', 'EUR', 'GBP'].includes(query.currency)) invalid('Invalid currency');
+ where += ' AND price_currency = ?'; args.push(query.currency);
+ }
+ if (query.source) {
+ if (!offerFeed.SOURCES[query.source]) invalid('Invalid source');
+ where += ' AND source_key = ?'; args.push(query.source);
+ }
+ if (query.priced === '1') where += ' AND price_monthly IS NOT NULL';
+ if (query.max_monthly != null && query.max_monthly !== '') {
+ const n = Number(query.max_monthly);
+ if (!Number.isFinite(n) || n < 0) invalid('Invalid max_monthly');
+ where += ' AND price_monthly <= ?'; args.push(n);
+ }
+ const offset = query.cursor == null ? 0 : Number(query.cursor);
+ if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
+ const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
+ return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
+ }
const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
async function plans(query = {}) {
async function plans(query = {}) {
const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);
const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);
@@ -178,6 +232,6 @@ function createCatalog({ store, community = null, log = console }) {
@@ -178,6 +232,6 @@ function createCatalog({ store, community = null, log = console }) {
}
}
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, plans, plan, providers, promos, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
+ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
}
module.exports = { createCatalog };
module.exports = { createCatalog };
@@ -9,7 +9,7 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');
@@ -9,7 +9,7 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
-const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
+const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_offers', 'catalog_ingest_cursor'];
/**
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
@@ -22,6 +22,7 @@ function createCatalogApi({ catalog, viewers, actorLimits }) {
@@ -22,6 +22,7 @@ function createCatalogApi({ catalog, viewers, actorLimits }) {
router.get('/providers', run(async () => ({ providers: await catalog.providers() })));
router.get('/providers', run(async () => ({ providers: await catalog.providers() })));
router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));
router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));
router.get('/promos', run(async () => ({ promos: await catalog.promos() })));
router.get('/promos', run(async () => ({ promos: await catalog.promos() })));
+ router.get('/offers', run(async (req) => await catalog.offers(req.query)));
for (const type of ['providers', 'plans', 'promos']) {
for (const type of ['providers', 'plans', 'promos']) {
router.post(`/${type}`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));
router.post(`/${type}`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));
router.patch(`/${type}/:id`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));
router.patch(`/${type}/:id`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));
@@ -85,6 +86,14 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
@@ -85,6 +86,14 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
router.get('/promos', view(async (req, res) => {
router.get('/promos', view(async (req, res) => {
page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
}));
}));
+ router.get('/offers', view(async (req, res) => {
+ // Sorting by price stays within one currency (nothing is converted): the page picks USD when none is chosen.
+ const query = { ...req.query };
+ if (query.sort === 'price' && !query.currency) query.currency = 'USD';
+ const result = await catalog.offers(query);
+ page(req, res, 'Hosting offers', render.offers({ result, query }), !Object.keys(req.query).length,
+ 'Hosting offers from LowEndTalk and LowEndBox, newest first or cheapest a month, each with the price its headline states and the day it was posted.');
+ }));
router.get('/manage', view(async (req, res) => {
router.get('/manage', view(async (req, res) => {
if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
const csrf = csrfToken(config, req.viewer);
const csrf = csrfToken(config, req.viewer);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/** Catalog identifiers: <prefix>_<ULID>. */
/** Catalog identifiers: <prefix>_<ULID>. */
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr' };
+const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr', catalogOffer: 'hof' };
const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* provider(p) /catalog/providers/:slug
* provider(p) /catalog/providers/:slug
* plan(p) /catalog/plans/:id
* plan(p) /catalog/plans/:id
* promos(list) /catalog/promos
* promos(list) /catalog/promos
+ * offers({ result, query }) /catalog/offers: what providers post on LowEndTalk and LowEndBox
*
*
* Every price shows when it was checked and links where it came from; an unknown value reads "Unknown", never 0;
* Every price shows when it was checked and links where it came from; an unknown value reads "Unknown", never 0;
* affiliate links carry rel="sponsored nofollow noopener" with the provider's own disclosure beside them, and the
* affiliate links carry rel="sponsored nofollow noopener" with the provider's own disclosure beside them, and the
@@ -16,6 +17,7 @@
@@ -16,6 +17,7 @@
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { esc } = require('./layout');
const { esc } = require('./layout');
+const { MAX_AGE_DAYS } = require('../catalog/offers');
const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';
const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';
const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];
const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];
@@ -227,6 +229,51 @@ ${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
@@ -227,6 +229,51 @@ ${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
}
}
+// Offers (server/catalog/offers.js): headlines from LowEndTalk's Offers board and LowEndBox, each with the cheapest
+// price per month or year its headline (or the post's opening) states, and that price a month.
+const OFFER_SORTS = [['new', 'Newest'], ['price', 'Cheapest a month']];
+const OFFER_CURRENCIES = [['', 'Any currency'], ['USD', 'USD ($)'], ['EUR', 'EUR (€)'], ['GBP', 'GBP (£)']];
+const OFFER_SOURCES = [['', 'Both boards'], ['lowendtalk-offers', 'LowEndTalk Offers'], ['lowendbox-posts', 'LowEndBox']];
+const daysAgo = (iso, now) => Math.floor((now - Date.parse(iso)) / 86400000);
+function offerItem(o, now) {
+ const age = daysAgo(o.published_at, now);
+ const monthly = o.price_period === 'year' ? money(o.price_monthly, o.price_currency) : null;
+ const price = o.price_text
+ ? `<p class="hc-offer-price"><strong>${esc(o.price_text)}</strong>${monthly ? `<span>${esc(monthly)} a month</span>` : ''}${o.price_from === 'post' ? '<span>stated in the post</span>' : ''}</p>`
+ : '<p class="hc-offer-price">Price in the post</p>';
+ const when = `<time datetime="${esc(o.published_at)}">${esc(day(o.published_at))}</time>`;
+ return `<li class="hc-offer">${price}
+<div class="hc-offer-body"><h3><a href="${esc(o.url)}" rel="nofollow ugc noopener">${esc(o.title)}</a></h3>
+<p class="hc-offer-meta">${esc(o.source)}${o.poster ? ` · posted by ${esc(o.poster)}` : ''} · ${when}${age > 30 ? ` <span class="hc-chip" title="Offers often end; the post says whether this one still runs">${age} days old</span>` : ''}</p></div></li>`;
+}
+function offers({ result, query = {}, now = Date.now() }) {
+ const q = query || {};
+ const list = result.offers;
+ const filtered = ['currency', 'max_monthly', 'source', 'priced'].some((k) => q[k]);
+ const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog/offers?${new URLSearchParams({ ...q, cursor: result.next_cursor })}`, 'Next 50 offers →', 'button hc-button-quiet')}</p>` : '';
+ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · hosting compared</p><h1>Hosting offers</h1>
+<p class="hc-lede">What providers post on LowEndTalk's Offers board and LowEndBox, with the price each headline states and that price a month. Sort by the monthly price within one currency; nothing is converted.</p></header>
+<form class="hc-filters" method="get" action="/catalog/offers" role="search" aria-label="Filter hosting offers">
+${select('sort', 'Sort by', OFFER_SORTS, q.sort || 'new')}
+${select('currency', 'Currency', OFFER_CURRENCIES, q.currency)}
+${input('max_monthly', 'Max a month', q.max_monthly, 'type="number" min="0" step="0.01" inputmode="decimal" placeholder="Any"')}
+${select('source', 'Board', OFFER_SOURCES, q.source)}
+<label class="hc-check"><input type="checkbox" name="priced" value="1"${q.priced === '1' ? ' checked' : ''}> <span>Only with a stated price</span></label>
+<div class="hc-filter-actions"><button type="submit">Show offers</button> <a href="/catalog/offers" class="hc-reset">Reset</a></div>
+</form>
+${list.length ? `<ol class="hc-offers" aria-label="${list.length}${result.next_cursor ? '+' : ''} offers">${list.map((o) => offerItem(o, now)).join('')}</ol>`
+ : filtered ? `<div class="hc-empty"><p><strong>No offer matches these filters.</strong></p><p>${link('/catalog/offers', 'Clear the filters')} or widen one of them.</p></div>`
+ : '<div class="hc-empty"><p><strong>No offer has arrived yet.</strong></p><p>Offers arrive as LowEndTalk\'s and LowEndBox\'s feeds are read, every hour.</p></div>'}
+${next}
+<section class="hc-method" aria-labelledby="hc-offers-method-h"><h2 id="hc-offers-method-h">How offers are read</h2><ul>
+<li><strong>From the boards' own feeds.</strong> LowEndTalk's Offers board, where providers post their offers, and LowEndBox's offer posts, through OpenVibe.Sources. Each headline links to its post, which has the details and the final word.</li>
+<li><strong>The price as stated.</strong> The cheapest "per month" or "per year" price the headline states, or the post's opening when the headline states none. A yearly price is shown a month too (divided by 12). A price without a period is not read.</li>
+<li><strong>Dated.</strong> Every offer shows the day it was posted; one older than 30 days is marked, and offers older than ${MAX_AGE_DAYS} days drop off.</li>
+<li><strong>Not for sale.</strong> The order comes only from the sort you pick. No provider pays to be listed here.</li>
+</ul></section>
+<p class="hc-links">${link('/catalog', '← Compare plans')} · ${link('/catalog/promos', 'Current promotions')}</p>`;
+}
+
/** JSON-LD for the catalog pages (only indexable pages carry it; renderPage drops it otherwise). */
/** JSON-LD for the catalog pages (only indexable pages carry it; renderPage drops it otherwise). */
const planUrl = (base, p) => `${base}/catalog/plans/${encodeURIComponent(p.id)}`;
const planUrl = (base, p) => `${base}/catalog/plans/${encodeURIComponent(p.id)}`;
const jsonLd = {
const jsonLd = {
@@ -254,7 +301,7 @@ const jsonLd = {
@@ -254,7 +301,7 @@ const jsonLd = {
};
};
/** The front page of the hosting comparison. */
/** The front page of the hosting comparison. */
-function front({ plans, providers, promos: live, stats }) {
+function front({ plans, providers, promos: live, stats, offers: recent = [], now = Date.now() }) {
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Lowest monthly prices right now</p>
<p class="hc-aside-h">Lowest monthly prices right now</p>
<ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
<ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
@@ -265,7 +312,7 @@ function front({ plans, providers, promos: live, stats }) {
@@ -265,7 +312,7 @@ function front({ plans, providers, promos: live, stats }) {
title: 'Hosting, compared honestly.',
title: 'Hosting, compared honestly.',
accent: 'Every price dated and sourced.',
accent: 'Every price dated and sourced.',
lede: 'Cloud, VPS and dedicated plans from providers\' official APIs, side by side: price per month, per vCPU and per GB, the day each price was checked and a link to where it came from. Sorted by what you ask for, never by who pays.',
lede: 'Cloud, VPS and dedicated plans from providers\' official APIs, side by side: price per month, per vCPU and per GB, the day each price was checked and a link to where it came from. Sorted by what you ask for, never by who pays.',
- actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Current promotions', href: '/catalog/promos' }],
+ actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Latest offers', href: '/catalog/offers' }],
note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
aside: aside ? { html: aside } : undefined,
aside: aside ? { html: aside } : undefined,
}) + `<section class="sc-sec">${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
}) + `<section class="sc-sec">${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
@@ -279,8 +326,9 @@ function front({ plans, providers, promos: live, stats }) {
@@ -279,8 +326,9 @@ function front({ plans, providers, promos: live, stats }) {
{ icon: 'ov:search', title: 'Compare what matters', text: 'Sort by monthly price, or by price per vCPU, per GB of RAM or per GB of disk. Unknown stays unknown.' },
{ icon: 'ov:search', title: 'Compare what matters', text: 'Sort by monthly price, or by price per vCPU, per GB of RAM or per GB of disk. Unknown stays unknown.' },
{ icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Affiliate links are marked, disclosed beside the link, and never change the order.' },
{ icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Affiliate links are marked, disclosed beside the link, and never change the order.' },
],
],
- }) + (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')
+ }) + (recent.length ? `<section class="sc-sec"><h2>Latest hosting offers</h2><ol class="hc-offers">${recent.slice(0, 6).map((o) => offerItem(o, now)).join('')}</ol><p class="hc-more">${link('/catalog/offers', 'Every offer, by price or date →')}</p></section>` : '')
+ + (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')
+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
}
}
-module.exports = { front, catalog, providerList, provider, plan, promos, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };
+module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };
@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');
@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Promotions', href: '/catalog/promos' }];
+const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const SITE_NAME = 'OpenVibe.Host';
const SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+/**
+ * Hosting offers (server/catalog/offers.js): LowEndTalk's Offers board and LowEndBox posts from Sources rss items. The
+ * price is the cheapest "per month|year" the headline (or the post's opening) states, kept as stated with its monthly
+ * equivalent; LowEndBox editorials and giveaways are not offers; a removed post is hidden; price sorting needs one
+ * currency; the page and the front page list them, newest first.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { statedPrices, mapOffer } = require('../server/catalog/offers');
+
+const NOW = Date.parse('2026-09-22T12:00:00Z');
+let n = 0;
+const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null, authors = ['SomeHost'], categories = [], removed = null, retrieved = '2026-09-22T11:00:00Z', identity = null } = {}) => ({
+ id: `itm_${String(++n).padStart(26, '0')}`, source_key: key, category: 'hosting', kind: 'article', identity: identity || `https://lowendtalk.com/discussion/${n}`,
+ canonical_url: identity || `https://lowendtalk.com/discussion/${n}/offer`, title, summary, authors, published_at: published, source_updated_at: null,
+ fields: { categories }, revision: 1, removed,
+ provenance: { retrieved_at: retrieved, first_seen_at: '2026-09-22T11:00:00Z', content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'feed@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },
+});
+
+(async () => {
+ await check('stated prices: symbol before or after, month or year, cheapest a month first; no period is no price', async () => {
+ const p = statedPrices('KVM VPS – From €6.99/year (~$8.03) – Dedicated $45/mo | 9.99€/yr | US$ 14.80 per Year | 2,60€/month');
+ assert.deepStrictEqual(p.map((x) => [x.text, x.currency, x.period, x.monthly]), [
+ ['€6.99/year', 'EUR', 'year', 0.5825], ['9.99€/yr', 'EUR', 'year', 0.8325], ['US$ 14.80 per Year', 'USD', 'year', 1.2333], ['2,60€/month', 'EUR', 'month', 2.6], ['$45/mo', 'USD', 'month', 45],
+ ]);
+ assert.deepStrictEqual(statedPrices('Up to 55% off | $500 Halloween Giveaway | 4GB for $5'), []);
+ assert.deepStrictEqual(statedPrices('£6 / Month')[0].currency, 'GBP');
+ });
+ await check('mapping: the headline price, else the post\'s; LowEndBox editorials and giveaways are not offers; too old is dropped', async () => {
+ const a = mapOffer(item('lowendtalk-offers', 'Linveo - KVM VPS from $2.85/month - Ohio'), NOW);
+ assert.deepStrictEqual([a.price_text, a.price_from, a.price_monthly, a.poster], ['$2.85/month', 'title', 2.85, 'SomeHost']);
+ const b = mapOffer(item('lowendtalk-offers', 'DeluxHost | VPS Restock | NL', { summary: 'Plans: 1 GB for 6€/yr, 2 GB for 10€/yr.' }), NOW);
+ assert.deepStrictEqual([b.price_text, b.price_from, b.price_monthly], ['6€/yr', 'post', 0.5]);
+ const c = mapOffer(item('lowendtalk-offers', 'Blackbox: anonymous VPS', { summary: 'Ask us.' }), NOW);
+ assert.deepStrictEqual([c.price_text, c.price_monthly], [null, null]);
+ assert.strictEqual(mapOffer(item('lowendbox-posts', 'How Much VPS Do You Actually Need?', { categories: ['Editorial & News'] }), NOW), null);
+ assert.strictEqual(mapOffer(item('lowendbox-posts', 'Last day! ServerHost $16.99/YR giveaway', { categories: ['Giveaways'] }), NOW), null);
+ assert.ok(mapOffer(item('lowendbox-posts', 'VPSPioneer: France from $2.49/Month!', { categories: ['Editorial & News'] }), NOW));
+ assert.ok(mapOffer(item('lowendbox-posts', 'Velohost returns', { categories: ['Low End Virtual'] }), NOW));
+ assert.strictEqual(mapOffer(item('lowendtalk-offers', 'Old $1/mo', { published: '2026-01-01T00:00:00Z' }), NOW), null);
+ assert.strictEqual(mapOffer({ source_key: 'vultr-plans' }, NOW), null, 'a plan source is not an offer source');
+ assert.throws(() => mapOffer(item('lowendtalk-offers', ''), NOW), /title/);
+ });
+
+ const t = await boot();
+ const catalog = t.ctx.catalog;
+ try {
+ await check('apply: inserted once, revised in place, a removed post hidden; plans untouched', async () => {
+ const first = item('lowendtalk-offers', 'Arct Cloud | EPYC VPS from $3.89/mo', { identity: 'https://lowendtalk.com/discussion/9001/arct' });
+ assert.strictEqual(await catalog.applyItem(first), 'applied');
+ assert.strictEqual(await catalog.applyItem({ ...first, title: 'Arct Cloud | EPYC VPS from $3.49/mo', provenance: { ...first.provenance, retrieved_at: '2026-09-22T11:30:00Z' } }), 'applied');
+ let rows = (await catalog.offers({})).offers;
+ assert.strictEqual(rows.length, 1);
+ assert.deepStrictEqual([rows[0].title, rows[0].price_text, rows[0].price_monthly, rows[0].source], ['Arct Cloud | EPYC VPS from $3.49/mo', '$3.49/mo', 3.49, 'LowEndTalk']);
+ assert.match(rows[0].id, /^hof_/);
+ assert.strictEqual(await catalog.applyItem({ ...first, removed: { at: '2026-09-22T11:45:00Z', reason: 'gone' } }), 'removed');
+ rows = (await catalog.offers({})).offers;
+ assert.strictEqual(rows.length, 0);
+ assert.strictEqual(await catalog.applyItem(item('lowendbox-posts', 'Reverse DNS: what is it good for?', { categories: ['Editorial & News'] })), 'hold');
+ assert.strictEqual((await catalog.plans({})).plans.length, 0);
+ });
+ await check('the list: newest first; cheapest a month within one currency; filters', async () => {
+ await catalog.applyItem(item('lowendtalk-offers', 'Yearly KVM $12/year', { published: '2026-09-21T09:00:00Z' }));
+ await catalog.applyItem(item('lowendtalk-offers', 'EU VPS €3.99/mo', { published: '2026-09-21T10:00:00Z' }));
+ await catalog.applyItem(item('lowendbox-posts', 'Godlike: 2GB for $1.83/Month', { published: '2026-09-21T11:00:00Z', categories: ['Low End Virtual'], authors: ['raindog308'] }));
+ await catalog.applyItem(item('lowendtalk-offers', 'Dedicated servers, ask for a quote', { published: '2026-09-21T12:00:00Z' }));
+ const newest = (await t.api('GET', '/api/v1/catalog/offers')).json().offers;
+ assert.deepStrictEqual(newest.map((o) => o.title), ['Dedicated servers, ask for a quote', 'Godlike: 2GB for $1.83/Month', 'EU VPS €3.99/mo', 'Yearly KVM $12/year']);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?sort=price')).status, 422, 'a price sort without a currency converts nothing');
+ const usd = (await t.api('GET', '/api/v1/catalog/offers?sort=price¤cy=USD')).json().offers;
+ assert.deepStrictEqual(usd.map((o) => [o.price_text, o.price_monthly]), [['$12/year', 1], ['$1.83/Month', 1.83]]);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?priced=1')).json().offers.length, 3);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?source=lowendbox-posts')).json().offers.length, 1);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?currency=USD&max_monthly=1.5')).json().offers.length, 1);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/offers?source=elsewhere')).status, 422);
+ });
+ await check('the page and the front page show them; a price sort without a currency uses USD on the page', async () => {
+ const page = await t.api('GET', '/catalog/offers');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<h1>Hosting offers<\/h1>/);
+ assert.match(page.text, /rel="nofollow ugc noopener">Godlike: 2GB for \$1\.83\/Month<\/a>/);
+ assert.match(page.text, /\$12\/year<\/strong><span>\$1\.00 a month<\/span>/);
+ assert.match(page.text, /Price in the post/);
+ assert.match(page.text, /LowEndBox · posted by raindog308/);
+ const sorted = await t.api('GET', '/catalog/offers?sort=price');
+ assert.strictEqual(sorted.status, 200);
+ assert.ok(sorted.text.indexOf('$12/year') < sorted.text.indexOf('$1.83/Month'));
+ assert.doesNotMatch(sorted.text, /€3\.99/);
+ const front = await t.api('GET', '/');
+ assert.match(front.text, /Latest hosting offers/);
+ assert.match(front.text, /href="\/catalog\/offers"/);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });