openvibers/openvibe.trade Commits

Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#46)

OP OpenVibers committed 12 Oct 2026 · 466bac0126 · 3 files, +37 −4

Plan T11's SEO kit calls for "sitemaps with lastmod". A survey of the live sites found this one's sitemap entries without `<lastmod>`, which search engines use to decide what to recrawl. Every entry now carries an honest one:

- **Item pages:** the item's own last change, from its row (its updated, published or observed time). Never "now", and never invented.
- **Static pages** (home, about, guides, legal): the deployed release's commit time (`released_at` from `openvibe-shared/release`, or the commit date).
- **Sitemap index entries:** the newest lastmod of their map. An empty map gets none, rather than an invented date.

The README says where lastmod comes from. The sitemap test asserts that every `<url>` has a valid date, and that an item's date equals its row's.

Part of a sweep over twelve sites, built by a Codex job and reviewed. During review I fixed two regex literals the job had double-escaped in test files (Media, Reviews), and made Deals' index test accept no date for an empty map. Each repo's changed test file was run locally and passes; CI runs the full suite.

Changes

ddiff --git a/README.md b/README.md
iindex c8ae132..6353de4 100644
---- a/README.md
++++ b/README.md
@@@ -138,13 +138,15 @@ deployment has no instruments (editors add them), no documents until the source
| `/api/health`, `/api/ready`, `/release.json`, `POST /release-metrics`, `/metrics` | health, readiness, release, open tabs' update reports (counted in `/metrics`), metrics (loopback only) |
| `POST /internal/events` | signed OpenVibe.Events deliveries (`sources.*`, and the two ADR-033 account events), host-local |
++Sitemap entries carry lastmod from each instrument's latest recorded change.
++
### Discoverability (roadmap §32)
- Instrument pages go through the `openvibe-publishing/seo` gate. Financial information is a
**sensitive** category: a page is `noindex` (`unreviewed_sensitive`) until a person-reviewed
context is published, `noindex` when its newest monetary observation is older than
`TRADE_PRICE_MAX_AGE_SEC` (`stale_price`), and `thin` below `TRADE_GATE_MIN_WORDS` of context.
--- The sitemap lists indexable instrument pages only, `lastmod` = the context's real publication time.
++- The sitemap lists indexable instrument pages only, `lastmod` = the instrument's latest recorded change.
- JSON-LD: `WebPage` (+ `Corporation` with `tickerSymbol` for equities) and breadcrumbs, from real
fields only. No price, offer, rating or invented date.
- Every instrument page has a JSON twin with the same data (`/i/:symbol.json`).
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 5e6e61a..937652d 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -10,7 +10,7 @@
* GET /sitemap.xml sitemap index
* GET /sitemaps/instruments.xml INDEXABLE instrument pages only (the gate decides:
* person-reviewed context, no stale price); lastmod = the
-- * context's real publication time
++ * latest instrument, observation or document change
* GET /feed.xml | /atom.xml | /feed.json recent public documents across instruments
* GET /i/:symbol/documents.xml|.atom|.json one instrument's documents
*
@@@ -103,18 +103,29 @@ function createDiscovery(ctx) {
}));
});
++ async function pageChanges() {
++ const rows = await store.db.prepare(`SELECT instrument_id, MAX(changed_at) AS changed_at FROM (
++ SELECT instrument_id, recorded_at AS changed_at FROM trade_market_observations
++ UNION ALL SELECT instrument_id, updated_at AS changed_at FROM trade_source_documents
++ ) changes GROUP BY instrument_id`).all();
++ return new Map(rows.map((r) => [r.instrument_id, r.changed_at]));
++ }
++ const pageLastmod = (instrument, changes) => Math.max(instrument.updated_at || 0, instrument.context_published_at || 0, changes.get(instrument.id) || 0);
++
define(router, 'get', '/sitemap.xml', 'sitemapIndex', async (_req, res) => {
const active = await instruments.active();
const decisions = await Promise.all(active.map(async (i) => await indexing.decide(i)));
-- const times = active.filter((i, n) => decisions[n].indexable && i.context_published_at).map((i) => i.context_published_at);
++ const changes = await pageChanges();
++ const times = active.filter((i, n) => decisions[n].indexable).map((i) => pageLastmod(i, changes)).filter(Boolean);
const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));
});
define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {
++ const changes = await pageChanges();
const entries = (await Promise.all((await instruments.active()).map(async (i) => ({
loc: urls.instrument(i),
-- ...(i.context_published_at ? { lastmod: new Date(i.context_published_at).toISOString() } : {}),
++ ...(pageLastmod(i, changes) ? { lastmod: new Date(pageLastmod(i, changes)).toISOString() } : {}),
decision: await indexing.decide(i),
}))));
xml(res, seo.sitemap(entries).files[0]);
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 59528fd..f62c318 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -5,6 +5,17 @@
* endpoints answer; the historical marketplace branch is recorded as an open question, not built.
*/
const assert = require('assert');
++
++function assertSitemapLastmods(xml, tag = 'url') {
++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
++ for (const [, body] of entries) {
++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
++ assert.ok(match, body);
++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
++ }
++ return entries;
++}
++
const fs = require('fs');
const path = require('path');
const { boot, check, done } = require('./helpers/boot');
@@@ -72,6 +83,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
assert.ok(/automated-consumer policy/.test(robots));
assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.trade/sitemaps/instruments.xml'));
assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('<loc>'), 'no indexable instrument yet');
++ assertSitemapLastmods((await t.get('/sitemaps/instruments.xml')).text);
assert.ok((await t.get('/llms.txt')).text.startsWith('# OpenVibe.Trade'));
});
@@@ -122,6 +134,14 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
await t.instrument({ symbol: 'OTHR', name: 'Other Co' });
const pub = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme's price is stated by the source. ${WORDS(70)}`, cite: `observation:${observation.id}`, publish: '1' } });
assert.ok(/n=context_published/.test(pub.headers.get('location')), 'the context published');
++ const map = (await t.get('/sitemaps/instruments.xml')).text;
++ assert.strictEqual(assertSitemapLastmods(map).length, 1);
++ const instrument = await t.ctx.store.db.prepare("SELECT id, updated_at, context_published_at FROM trade_instruments WHERE symbol = 'ACME'").get();
++ const observationTime = await t.ctx.store.db.prepare('SELECT MAX(recorded_at) AS at FROM trade_market_observations WHERE instrument_id = ?').get(instrument.id);
++ const documentTime = await t.ctx.store.db.prepare('SELECT MAX(updated_at) AS at FROM trade_source_documents WHERE instrument_id = ?').get(instrument.id);
++ const expected = new Date(Math.max(instrument.updated_at, instrument.context_published_at || 0, observationTime.at || 0, documentTime.at || 0)).toISOString().slice(0, 10);
++ assert.ok(map.includes(`<loc>https://openvibe.trade/i/ACME</loc><lastmod>${expected}</lastmod>`));
++ assertSitemapLastmods((await t.get('/sitemap.xml')).text, 'sitemap');
const after = await t.get('/llms-full.txt');
assert.ok(after.text.includes('https://openvibe.trade/i/ACME'));
assert.ok(after.text.includes('### ACME — Acme Corp'));