openvibers/openvibe.media Commits

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

OP OpenVibers committed 12 Oct 2026 · 08b743f7f9 · 4 files, +30 −1

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 c20db63..0edb008 100644
---- a/README.md
++++ b/README.md
@@@ -549,6 +549,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
| `GET /llms-full.txt` | the same map plus the full text of the index and the newest watch pages Media is the canonical home of (`openvibe-shared/seo.llmsFull`; rendering stops at 512 KB, and any further pages become its "truncated" line) |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
++Sitemap entries carry lastmod from the release commit for the index and each watch item's recorded time.
++
Watch pages (`/v/:id`, `/c/:id` on a browser navigation) offer a player only when the item's
[readiness](#readiness) is `playable`; otherwise they say why (still being recorded, still processing,
not available) and are `noindex`. They carry a schema.org `VideoObject` when the item is public and
ddiff --git a/server/public/crawl.js b/server/public/crawl.js
iindex b5a4fc4..47d7108 100644
---- a/server/public/crawl.js
++++ b/server/public/crawl.js
@@@ -14,6 +14,7 @@ const express = require('express');
const config = require('../config');
const db = require('../db/database');
const seo = require('openvibe-shared/seo');
++const releaseLastmod = require('../sitemap-lastmod');
const cache = require('openvibe-shared/cache-policy');
const pages = require('./pages');
const readiness = require('../objects/readiness');
@@@ -192,7 +193,7 @@ router.get('/llms-full.txt', async (req, res) => {
});
router.get('/sitemap.xml', async (req, res) => {
try {
-- send(res, 'application/xml', seo.sitemapXml([{ loc: `${config.publicUrl}/`, changefreq: 'hourly' }, ...await sitemapEntries()]));
++ send(res, 'application/xml', seo.sitemapXml([{ loc: `${config.publicUrl}/`, lastmod: releaseLastmod, changefreq: 'hourly' }, ...await sitemapEntries()]));
} catch (err) {
console.error('[Public] sitemap error:', err.message);
res.status(500).type('text/plain').send('sitemap unavailable');
ddiff --git a/server/sitemap-lastmod.js b/server/sitemap-lastmod.js
nnew file mode 100644
iindex 0000000..5320a0a
---- /dev/null
++++ b/server/sitemap-lastmod.js
@@@ -0,0 +1,12 @@
++'use strict';
++
++// The deployed code's commit time. RELEASE_AT is the release manifest's override.
++const path = require('path');
++const { execFileSync } = require('child_process');
++
++let value = process.env.RELEASE_AT;
++if (!value) {
++ try { value = execFileSync('git', ['log', '-1', '--format=%cI'], { cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 2000 }).trim(); }
++ catch { /* A source archive without release metadata has no defensible lastmod. */ }
++}
++module.exports = value && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : undefined;
ddiff --git a/test/crawl.test.js b/test/crawl.test.js
iindex 1e29b2f..ccd9a4d 100644
---- a/test/crawl.test.js
++++ b/test/crawl.test.js
@@@ -5,6 +5,17 @@
// private/unlisted/sandbox), and every listed page renders `index, follow`; /llms.txt maps the site;
// public watch pages carry a VideoObject; AI clips are noindex with no Person author, like on Live.
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 os = require('os');
const path = require('path');
@@@ -78,6 +89,9 @@ const express = require('express');
const locs = [...r.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
assert.deepStrictEqual(locs.filter(l => !l.includes('/t/')).sort(), ['https://media.test/', 'https://media.test/c/11', 'https://media.test/v/1'].sort());
assert.ok(r.body.includes('<image:loc>https://media.test/t/g1.jpg</image:loc>'), 'thumbnails as image entries');
++ assert.strictEqual(assertSitemapLastmods(r.body).length, 3);
++ assert.match(r.body, /<loc>https:\/\/media\.test\/v\/1<\/loc><lastmod>2026-09-20<\/lastmod>/);
++ assert.match(r.body, /<loc>https:\/\/media\.test\/c\/11<\/loc><lastmod>2026-09-22<\/lastmod>/);
for (const never of ['/v/2', '/v/3', '/v/4', '/v/5', '/v/6', '/v/7', '/v/8', '/c/12', '/c/13', '/c/14', '/c/15']) {
assert.ok(!locs.includes(`https://media.test${never}`), `${never} is not listed`);
}