openvibers/openvibe.report Commits

The Rundown canonicalises to OpenVibe.Live; its pages leave Report's sitemap (#124)

OP OpenVibers committed 6 days ago · 625abcd539 · 7 files, +58 −32

Changes

ddiff --git a/README.md b/README.md
iindex 02c9a57..29379bf 100644
---- a/README.md
++++ b/README.md
@@@ -77,10 +77,10 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
|---|---|
| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics |
| `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |
--| `/rundown` | on air, the lead, the latest streams and every topic |
--| `/rundown/t/:slug` | one topic, its takes and related topics |
--| `/rundown/e/:streamId` | one stream as an episode, with its segments |
--| `/rundown/s/:username` | one streamer's episodes and topics |
++| `/rundown` | on air, the lead, the latest streams and every topic; canonical is OpenVibe.Live's `/rundown` |
++| `/rundown/t/:slug` | one topic, its takes and related topics; canonical is Live's `/rundown/t/:slug` |
++| `/rundown/e/:streamId` | one stream as an episode, with its segments; canonical is Live's `/rundown/e/:streamId` |
++| `/rundown/s/:username` | one streamer's episodes and topics; canonical is Live's `/rundown/:username` |
| `/after-show/:streamId` | one after-show report |
| `/c/:username` | one channel's latest after-show reports |
| `/ai` | benchmark kinds grouped by room, with leaders and game counts |
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex ba5de64..cd3ba30 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -16,7 +16,6 @@ function createAgentPages({ publicPages, bench }) {
if (path === '/') page = await publicPages.home();
else if (path === '/developers') page = publicPages.developers();
else if (path === '/design') page = publicPages.design();
-- else if (path === '/rundown') page = await publicPages.rundown();
else if (path === '/stats') page = await publicPages.stats(normaliseDays(query && query.get('days')));
else if (path === '/ai') page = await publicPages.aiIndex();
else if (model) page = await publicPages.aiModel(decodeURIComponent(model[1]));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 135164a..57d3c02 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -7,8 +7,8 @@
* GET /sitemap.xml /, /stats, /design, /rundown and every /rundown/t/<slug> from the Rundown's topics
* GET /llms.txt a markdown map of the site and its machine-readable answers
*
-- * The sitemap reads topics through the same cached client as the pages, so a crawler is no heavier than a reader
-- * and a source that does not answer leaves the three static pages in place.
++ * The sitemap lists only the pages this site owns; The Rundown pages carry rel=canonical to openvibe.live and are left
++ * out, so the two sites never present the same page as two canonical URLs.
*/
const fs = require('fs');
const path = require('path');
@@@ -46,25 +46,20 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
});
r.get('/sitemap.xml', async (_req, res) => {
-- const [data, ai, reports] = await Promise.all([live.topics(), bench.kinds(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : []]);
-- const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
++ const [ai, reports] = await Promise.all([bench.kinds(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : []]);
const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const lastmod = siteUpdated();
++ // Only the pages this site owns. /rundown and its topic, episode and speaker pages carry rel=canonical to
++ // OpenVibe.Live's Rundown, so listing them here would advertise a duplicate.
const pages = [
{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },
{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/design'), changefreq: 'weekly', priority: 0.6 },
-- { loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),
...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
})),
-- ...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({
-- loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),
-- changefreq: 'weekly',
-- priority: 0.7,
-- })),
].map((p) => ({ ...p, ...(lastmod ? { lastmod } : {}) }));
res.type('application/xml').set('Cache-Control', CACHE).send(seo.sitemapXml(pages));
});
@@@ -81,7 +76,7 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
{ title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },
{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
{ title: 'Design health', url: abs('/design'), note: 'how closely each site follows the shared design kit, read from its code; per site at /design/<site>' },
-- { title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },
++ { title: 'The Rundown', url: 'https://openvibe.live/rundown', note: 'what streamers talked about, topic by topic (OpenVibe.Live is its canonical home)' },
{ title: 'AI boards', url: abs('/ai'), note: 'Bench ratings, quality and cost across rooms' },
] },
// A JSON API is what an agent came for, not something to skip (AGT-5 review).
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5b74ed1..5886da8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -87,6 +87,15 @@ function createPageRoutes(ctx) {
const { live, bench, sessionReports } = ctx;
const r = express.Router();
const publicPages = ctx.publicPages || createPublicPages(ctx);
++ // The Rundown's pages canonicalise to OpenVibe.Live, and so does its Markdown twin: the generic agent layer
++ // (server/agent-pages.js) would sign the twin with this site's own address. Answer it here instead, before the
++ // .md hand-off, with Live's canonical.
++ r.get('/rundown.md', async (_req, res) => {
++ const { markdownTwin } = require('openvibe-shared/agent-layer');
++ const page = await publicPages.rundown();
++ const body = markdownTwin({ html: page.html, canonical: 'https://openvibe.live/rundown' });
++ res.set('Content-Type', 'text/markdown; charset=utf-8').set('Cache-Control', CACHE).send(body);
++ });
// A path ending in .md is a Markdown twin (server/agent-pages.js), never a page parameter.
r.use((req, res, next) => (req.path.endsWith('.md') ? next('router') : next()));
ddiff --git a/server/views/index.js b/server/views/index.js
iindex ad98cdd..6e61546 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -23,6 +23,7 @@ const { asset } = require('../assets');
const NAME = 'OpenVibe.Report';
const NETWORK = 'https://openvibe.network';
++const LIVE = 'https://openvibe.live';
const BENCH = 'https://bench.openvibe.services';
const ROOM = rooms.get('report');
const esc = kit.esc;
@@@ -317,7 +318,7 @@ ${all.length ? `<section aria-labelledby="rp-t-h"><h2 id="rp-t-h">Topics</h2><ul
${voices.length ? `<section aria-labelledby="rp-v-h"><h2 id="rp-v-h">Voices</h2><ul class="rp-voices">${voices.map((v) => `<li><a href="/rundown/s/${encodeURIComponent((v.user || {}).username || '')}">${kit.mark({ name: who(v.user), src: v.user && v.user.avatar_url, size: 40 })}<span><b>${esc(who(v.user))}</b><small>${[n(v.topics) ? `${exact(v.topics)} topic${Number(v.topics) === 1 ? '' : 's'}` : '', n(v.segments) ? `${exact(v.segments)} take${Number(v.segments) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')}</small></span></a></li>`).join('')}</ul></section>` : ''}
${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}
${reports && reports.length ? reportsSection(reports) : ''}`;
-- return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown' });
++ return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown', canonical: `${LIVE}/rundown` });
}
@@@ -462,7 +463,7 @@ ${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<
${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}
${citesHTML}
${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;
-- return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });
++ return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}`, canonical: `${LIVE}/rundown/t/${encodeURIComponent(t.slug || '')}` });
}
/** A chapter's moment in the recording on OpenVibe.Video (the same link Live's own Rundown draws). */
@@@ -489,17 +490,17 @@ function episode({ episode: d, citations = [] } = {}) {
${chaptersHTML}
${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}
${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
-- return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });
++ return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}`, canonical: `${LIVE}/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });
}
function speaker({ speaker: d, username } = {}) {
const u = d.user || { username };
-- if (d.opted_out) return layout({ title: `${who(u)}: The Rundown`, robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>${esc(who(u))}</h1><p class="rp-lede">${esc(who(u))} has opted out of The Rundown: nothing they say on stream is written up here.</p></header>`, active: '/rundown' });
++ if (d.opted_out) return layout({ title: `${who(u)}: The Rundown`, robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>${esc(who(u))}</h1><p class="rp-lede">${esc(who(u))} has opted out of The Rundown: nothing they say on stream is written up here.</p></header>`, active: '/rundown', canonical: `${LIVE}/rundown/${encodeURIComponent(u.username || username || '')}` });
const body = `<header class="rp-page-h rp-person">${kit.mark({ name: who(u), src: u.avatar_url, size: 64 })}<div><p class="rp-kicker">The Rundown · voice</p><h1>${esc(who(u))}</h1><p class="rp-meta"><a href="https://openvibe.live/@${encodeURIComponent(u.username || username)}">Channel on OpenVibe.Live</a> · <a href="/c/${encodeURIComponent(u.username || username)}">After-show reports</a></p></div></header>
${(d.topics || []).length ? `<section aria-labelledby="rp-st-h"><h2 id="rp-st-h">Topics</h2><ul class="rp-topics">${d.topics.slice(0, 12).map(topicCard).join('')}</ul></section>` : ''}
${(d.lines || []).length ? `<section aria-labelledby="rp-sl-h"><h2 id="rp-sl-h">Takes</h2><ol class="rp-takes">${d.lines.slice(0, 20).map(takeRow).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}
${(d.episodes || []).length ? `<section aria-labelledby="rp-se-h"><h2 id="rp-se-h">Episodes</h2><ul class="rp-chips">${d.episodes.slice(0, 12).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
-- return layout({ title: `${who(u)}: The Rundown`, description: `What ${who(u)} talked about on stream, topic by topic.`, body, active: '/rundown', path: `/rundown/s/${encodeURIComponent(u.username || username || '')}` });
++ return layout({ title: `${who(u)}: The Rundown`, description: `What ${who(u)} talked about on stream, topic by topic.`, body, active: '/rundown', path: `/rundown/s/${encodeURIComponent(u.username || username || '')}`, canonical: `${LIVE}/rundown/${encodeURIComponent(u.username || username || '')}` });
}
function recap({ recap: d } = {}) {
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex 3113cd4..13c000d 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -1,9 +1,9 @@
'use strict';
/**
-- * The crawl artifacts: robots.txt allows the public pages and disallows the API; sitemap.xml is the home, /stats,
-- * /rundown and every topic from the Rundown; llms.txt names the site and its machine-readable answers. All three
-- * are public for an hour.
++ * The crawl artifacts: robots.txt allows the public pages and disallows the API; sitemap.xml is the pages this site
++ * owns (not The Rundown, which canonicalises to OpenVibe.Live); llms.txt names the site and its machine-readable
++ * answers. All three are public for an hour.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -26,14 +26,17 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST
for (const p of ['/stats', '/rundown']) assert.ok(!r.text.includes(`Disallow: ${p}`), `${p} must stay crawlable`);
});
-- await check('sitemap.xml: the home, /stats, /rundown and every topic, with lastmod from STATUS.json', async () => {
++ await check('sitemap.xml: the site\'s own pages, never The Rundown (Live is its canonical), with lastmod from STATUS.json', async () => {
const r = await t.get('/sitemap.xml');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type') || '', /^application\/xml/);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');
-- for (const p of ['/', '/stats', '/rundown', '/rundown/t/seattle', '/rundown/t/ai-progress']) {
++ for (const p of ['/', '/stats', '/design', '/ai']) {
assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), `no ${p} entry`);
}
++ for (const p of ['/rundown', '/rundown/t/seattle', '/rundown/t/ai-progress']) {
++ assert.ok(!r.text.includes(`<loc>https://openvibe.report${p}</loc>`), `${p} must not be listed: Live is its canonical`);
++ }
assert.ok(!r.text.includes('<loc>https://openvibe.report/api/'), 'the API must not be in the sitemap');
const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
assert.ok(lastmods.length > 0, 'no lastmod');
@@@ -49,19 +52,22 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST
for (const link of [
'https://openvibe.report/index.md',
'https://openvibe.report/stats.md',
-- 'https://openvibe.report/rundown.md',
++ 'https://openvibe.live/rundown',
'https://openvibe.report/design.md',
'https://openvibe.report/api/v1/stats?days=7',
'https://openvibe.report/sitemap.xml',
]) assert.ok(r.text.includes(`(${link})`), `missing link ${link}`);
assert.ok(r.text.includes('\n## Optional\n'), 'llms.txt has an Optional section');
-- // 11-03: /design and /rundown have the twins llms.txt links, and their pages name them.
++ // /design and /rundown have Markdown twins; The Rundown's twin carries Live's canonical, so the two
++ // representations agree about which URL is the page.
for (const p of ['/design', '/rundown']) {
const twin = await t.get(`${p}.md`);
assert.strictEqual(twin.status, 200, `${p}.md`);
assert.match(twin.headers.get('content-type') || '', /^text\/markdown/);
assert.match((await t.get(p)).headers.get('link') || '', /text\/markdown/, `${p} names its twin`);
}
++ const rundownTwin = await t.get('/rundown.md');
++ assert.ok(rundownTwin.text.includes('canonical: "https://openvibe.live/rundown"'), 'the twin canonicalises to Live');
});
await check('llms-full.txt (SEO-33): the site in full, every page llms.txt lists with what it holds', async () => {
@@@ -74,12 +80,12 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST
assert.match(r.text, /### Design health\n\nURL: https:\/\/openvibe\.report\/design\n\nhow closely each site follows/);
});
-- await check('a topics source that answers nothing still leaves the three static sitemap pages', async () => {
++ await check('a topics source that answers nothing still leaves the pages this site owns', async () => {
const empty = await boot({ data: { topics: null } });
try {
const r = await empty.get('/sitemap.xml');
assert.strictEqual(r.status, 200);
-- for (const p of ['/', '/stats', '/rundown']) assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), p);
++ for (const p of ['/', '/stats']) assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), p);
assert.ok(!r.text.includes('/rundown/t/'), 'no topic should be listed');
} finally { await empty.close(); }
});
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 84accfc..8bd6c54 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -32,6 +32,21 @@ const { boot, check, done, defaults } = require('./helpers/boot');
});
}
++ await check('every Rundown page canonicalises to OpenVibe.Live, and the rest to Report', async () => {
++ const canon = {
++ '/rundown': 'https://openvibe.live/rundown',
++ '/rundown/t/seattle': 'https://openvibe.live/rundown/t/seattle',
++ '/rundown/e/7': 'https://openvibe.live/rundown/e/7',
++ '/rundown/s/goosely': 'https://openvibe.live/rundown/goosely',
++ '/stats': 'https://openvibe.report/stats',
++ '/': 'https://openvibe.report/',
++ };
++ for (const [path, url] of Object.entries(canon)) {
++ const r = await t.get(path);
++ assert.ok(r.text.includes(`<link rel="canonical" href="${url}">`), `${path}: ${url}`);
++ }
++ });
++
await check('the CSP allows the Live and Community thumbnails on the pages that show them', async () => {
for (const path of ['/', '/rundown']) {
const r = await t.get(path);
@@@ -55,9 +70,10 @@ const { boot, check, done, defaults } = require('./helpers/boot');
assert.ok(org && Array.isArray(org.sameAs) && org.sameAs.includes('https://github.com/OpenVibers'), 'Organization');
});
-- await check('a page names its canonical and mounts the Frame for service report', async () => {
++ await check('The Rundown pages name OpenVibe.Live as canonical and mount the Frame for service report', async () => {
const r = await t.get('/rundown/t/seattle');
-- assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.report/rundown/t/seattle">'));
++ assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.live/rundown/t/seattle">'), 'the topic is Live\'s page');
++ assert.ok(!r.text.includes('<link rel="canonical" href="https://openvibe.report/rundown/t/seattle">'), 'Report must not claim it');
assert.ok(r.text.includes('<div id="navbar-mount"></div>'), 'no navbar mount');
assert.ok(r.text.includes('id="ov-footer"'), 'no Shared footer');
assert.ok(r.text.includes('"service":"report"'), 'the Frame is not initialised for report');