openvibers/openvibe.host Commits
Offers come from LowEndSpirit's Offers board (lowendspirit-offers): LowEndTalk's and LowEndBox's feeds answer 403 to the production host, so the page no longer promises them; one board, no board filter (#132)
Changes
@@@ -24,7 +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`.++- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. 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`.- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.- **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`.@@@ -1,9 +1,10 @@'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).++ * Hosting offers (plan T20 step 3, "a modern LowEndBox with honest data"): what providers post on LowEndSpirit's Offers++ * board, from its public RSS feed through OpenVibe.Sources (category hosting, plain `rss` items: title, link, date,++ * author, a short plain-text summary). LowEndTalk's and LowEndBox's feeds answer 403 to the production host's network++ * (2026-10-18), so their sources are off; their entries stay here for any item already stored and if they answer again.** 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@@@ -12,6 +13,7 @@* price shows "Price in the post".*/const SOURCES = Object.freeze({++ 'lowendspirit-offers': { label: 'LowEndSpirit', board: 'Offers', home: 'https://lowendspirit.com/categories/offers' },'lowendtalk-offers': { label: 'LowEndTalk', board: 'Offers', home: 'https://lowendtalk.com/categories/offers' },'lowendbox-posts': { label: 'LowEndBox', board: null, home: 'https://lowendbox.com/' },});@@@ -99,7 +99,7 @@ function createCatalogPages({ catalog, domainCheck, community, config, viewers,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.');++ 'Hosting offers from LowEndSpirit\'s Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.');}));router.get('/domains', view(async (req, res) => {const [result, stats] = await Promise.all([catalog.domains.list(req.query), catalog.domains.stats()]);@@@ -8,7 +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++ * offers({ result, query }) /catalog/offers: what providers post on LowEndSpirit's Offers board** 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@@@ -229,11 +229,10 @@ ${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++// Offers (server/catalog/offers.js): headlines from LowEndSpirit's Offers board, 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);@@@ -252,21 +251,20 @@ function offers({ result, query = {}, now = Date.now() }) {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>++<p class="hc-lede">What providers post on LowEndSpirit's Offers board, 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>'}++ : '<div class="hc-empty"><p><strong>No offer in the last 120 days.</strong></p><p>LowEndSpirit\'s Offers board is 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>From the board's own feed.</strong> LowEndSpirit's Offers board, where providers post their offers, 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>@@@ -39,6 +39,9 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = 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);++ // LowEndSpirit's Offers board, the one the production host can read (LowEndTalk and LowEndBox answer it 403).++ const les = mapOffer(item('lowendspirit-offers', '1.99€/2TB Per Month Storage Box! The Eternal Väinämöinen', { identity: 'https://lowendspirit.com/discussion/10922' }), NOW);++ assert.ok(les && les.source_key === 'lowendspirit-offers', 'a LowEndSpirit offer maps');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/);});
@@ -24,7 +24,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -24,7 +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`.
+- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. 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`.
- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.
- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.
- **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`.
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
'use strict';
'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).
+ * Hosting offers (plan T20 step 3, "a modern LowEndBox with honest data"): what providers post on LowEndSpirit's Offers
+ * board, from its public RSS feed through OpenVibe.Sources (category hosting, plain `rss` items: title, link, date,
+ * author, a short plain-text summary). LowEndTalk's and LowEndBox's feeds answer 403 to the production host's network
+ * (2026-10-18), so their sources are off; their entries stay here for any item already stored and if they answer again.
*
*
* Host keeps the headline, the poster, the date and the link, and reads one price out of the headline (or, when the
* 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
* headline states none, out of the post's opening): the cheapest "<amount> per <month|year>" it states, kept as the
@@ -12,6 +13,7 @@
@@ -12,6 +13,7 @@
* price shows "Price in the post".
* price shows "Price in the post".
*/
*/
const SOURCES = Object.freeze({
const SOURCES = Object.freeze({
+ 'lowendspirit-offers': { label: 'LowEndSpirit', board: 'Offers', home: 'https://lowendspirit.com/categories/offers' },
'lowendtalk-offers': { label: 'LowEndTalk', board: 'Offers', home: 'https://lowendtalk.com/categories/offers' },
'lowendtalk-offers': { label: 'LowEndTalk', board: 'Offers', home: 'https://lowendtalk.com/categories/offers' },
'lowendbox-posts': { label: 'LowEndBox', board: null, home: 'https://lowendbox.com/' },
'lowendbox-posts': { label: 'LowEndBox', board: null, home: 'https://lowendbox.com/' },
});
});
@@ -99,7 +99,7 @@ function createCatalogPages({ catalog, domainCheck, community, config, viewers,
@@ -99,7 +99,7 @@ function createCatalogPages({ catalog, domainCheck, community, config, viewers,
if (query.sort === 'price' && !query.currency) query.currency = 'USD';
if (query.sort === 'price' && !query.currency) query.currency = 'USD';
const result = await catalog.offers(query);
const result = await catalog.offers(query);
page(req, res, 'Hosting offers', render.offers({ result, query }), !Object.keys(req.query).length,
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.');
+ 'Hosting offers from LowEndSpirit\'s Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.');
}));
}));
router.get('/domains', view(async (req, res) => {
router.get('/domains', view(async (req, res) => {
const [result, stats] = await Promise.all([catalog.domains.list(req.query), catalog.domains.stats()]);
const [result, stats] = await Promise.all([catalog.domains.list(req.query), catalog.domains.stats()]);
@@ -8,7 +8,7 @@
@@ -8,7 +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
+ * offers({ result, query }) /catalog/offers: what providers post on LowEndSpirit's Offers board
*
*
* 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
@@ -229,11 +229,10 @@ ${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
@@ -229,11 +229,10 @@ ${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
+// Offers (server/catalog/offers.js): headlines from LowEndSpirit's Offers board, each with the cheapest
// price per month or year its headline (or the post's opening) states, and that price a month.
// 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_SORTS = [['new', 'Newest'], ['price', 'Cheapest a month']];
const OFFER_CURRENCIES = [['', 'Any currency'], ['USD', 'USD ($)'], ['EUR', 'EUR (€)'], ['GBP', 'GBP (£)']];
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);
const daysAgo = (iso, now) => Math.floor((now - Date.parse(iso)) / 86400000);
function offerItem(o, now) {
function offerItem(o, now) {
const age = daysAgo(o.published_at, now);
const age = daysAgo(o.published_at, now);
@@ -252,21 +251,20 @@ function offers({ result, query = {}, now = Date.now() }) {
@@ -252,21 +251,20 @@ function offers({ result, query = {}, now = Date.now() }) {
const filtered = ['currency', 'max_monthly', 'source', 'priced'].some((k) => q[k]);
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>` : '';
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>
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>
+<p class="hc-lede">What providers post on LowEndSpirit's Offers board, 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">
<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('sort', 'Sort by', OFFER_SORTS, q.sort || 'new')}
${select('currency', 'Currency', OFFER_CURRENCIES, q.currency)}
${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"')}
${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>
<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>
<div class="hc-filter-actions"><button type="submit">Show offers</button> <a href="/catalog/offers" class="hc-reset">Reset</a></div>
</form>
</form>
${list.length ? `<ol class="hc-offers" aria-label="${list.length}${result.next_cursor ? '+' : ''} offers">${list.map((o) => offerItem(o, now)).join('')}</ol>`
${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>`
: 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>'}
+ : '<div class="hc-empty"><p><strong>No offer in the last 120 days.</strong></p><p>LowEndSpirit\'s Offers board is read every hour.</p></div>'}
${next}
${next}
<section class="hc-method" aria-labelledby="hc-offers-method-h"><h2 id="hc-offers-method-h">How offers are read</h2><ul>
<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>From the board's own feed.</strong> LowEndSpirit's Offers board, where providers post their offers, 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>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>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>
<li><strong>Not for sale.</strong> The order comes only from the sort you pick. No provider pays to be listed here.</li>
@@ -39,6 +39,9 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null,
@@ -39,6 +39,9 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null,
assert.ok(mapOffer(item('lowendbox-posts', 'VPSPioneer: France from $2.49/Month!', { categories: ['Editorial & News'] }), NOW));
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.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(item('lowendtalk-offers', 'Old $1/mo', { published: '2026-01-01T00:00:00Z' }), NOW), null);
+ // LowEndSpirit's Offers board, the one the production host can read (LowEndTalk and LowEndBox answer it 403).
+ const les = mapOffer(item('lowendspirit-offers', '1.99€/2TB Per Month Storage Box! The Eternal Väinämöinen', { identity: 'https://lowendspirit.com/discussion/10922' }), NOW);
+ assert.ok(les && les.source_key === 'lowendspirit-offers', 'a LowEndSpirit offer maps');
assert.strictEqual(mapOffer({ source_key: 'vultr-plans' }, NOW), null, 'a plan source is not an offer source');
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/);
assert.throws(() => mapOffer(item('lowendtalk-offers', ''), NOW), /title/);
});
});