openvibers/openvibe.host Commits

Host: hosting offers from LowEndTalk and LowEndBox, with the price each headline states and that price a month (#89)

OP OpenVibers committed 30 days ago · 59ab68d7fe · 13 files, +342 −11

Plan T20 step 3 ("a modern LowEndBox with honest data"): openvibe.host lists hosting offers.

- `/catalog/offers` (no-JS page, nav "Offers"), `GET /api/v1/catalog/offers`, and "Latest hosting offers" on the front page.
- Source: LowEndTalk's Offers board and LowEndBox's offer posts, from their public RSS feeds through OpenVibe.Sources (category `hosting`, keys `lowendtalk-offers` and `lowendbox-posts`; the seeds come in a Sources PR). Both robots.txt files allow the feeds.
- Host keeps only the headline, poster, date and link, plus the cheapest "per month" or "per year" price that the headline (or the post's opening) states. The price is stored as stated, with its monthly equivalent (yearly ÷ 12). A price with no period is not read.
- LowEndBox editorials and giveaways are not offers. A removed post is hidden. Offers older than 120 days drop off, and offers older than 30 days are marked.
- Sorting: newest first, or cheapest per month within one currency. The API refuses `sort=price` without `currency`; the page uses USD. Nothing is converted. Filters: currency, max a month, board, only with a stated price.
- Migration `0009_catalog_offers` (expand).
- The new styles fit the front page's 27 KB CSS budget.

Tests:
- `test/catalog-offers.test.js` covers price parsing, the mapping rules, apply/revise/remove, sorting and filters, the page and the front page.
- The full suite passes.
- Checked by rendering the real feeds (83 offers) at 390 and 1280 px: no overflow.

Changes

ddiff --git a/README.md b/README.md
iindex b940a44..edd0bf1 100644
---- a/README.md
++++ b/README.md
@@@ -24,6 +24,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
robots, 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`.
ddiff --git a/migrations/0009_catalog_offers.sql b/migrations/0009_catalog_offers.sql
nnew file mode 100644
iindex 0000000..ff9a6c9
---- /dev/null
++++ b/migrations/0009_catalog_offers.sql
@@@ -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);
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex 5e3b199..e88ccb4 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -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; }
}
ddiff --git a/server/app.js b/server/app.js
iindex b944aad..8db5084 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 })));
ddiff --git a/server/catalog/ingest.js b/server/catalog/ingest.js
iindex e1fb974..3f37f7e 100644
---- a/server/catalog/ingest.js
++++ b/server/catalog/ingest.js
@@@ -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 };
ddiff --git a/server/catalog/offers.js b/server/catalog/offers.js
nnew file mode 100644
iindex 0000000..e9821bc
---- /dev/null
++++ b/server/catalog/offers.js
@@@ -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 };
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex c35b541..6fa2cba 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
iindex d41d37c..e9a99d5 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 3465b57..db97838 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -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);
ddiff --git a/server/ids.js b/server/ids.js
iindex 5696610..2af24ab 100644
---- a/server/ids.js
++++ b/server/ids.js
@@@ -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}$`)]));
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex f2e0667..6f7c36b 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex f8e908b..62a1590 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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');
ddiff --git a/test/catalog-offers.test.js b/test/catalog-offers.test.js
nnew file mode 100644
iindex 0000000..47b78f4
---- /dev/null
++++ b/test/catalog-offers.test.js
@@@ -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&currency=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); });