openvibers/openvibe.mediahub Commits

/llms-full.txt on openvibe.pics and openvibe.video (SEO-33) (#275)

OP OpenVibers committed 10 days ago · 4309708862 · 4 files, +48 −5

Both answered 404 (the drive host has one). Pics renders its llms.txt descriptor in full;
Video's lists the latest recordings and clips with each one's own description, from
the same feed llms.txt reads (llms.txt itself stays a list of links).

Changes

ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 2274309..d4e9494 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -623,7 +623,8 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
.send(seo.sitemapXml(['/', '/updates', ...legal.PATHS, ...POLICY_PATHS].map((p) => ({ loc: `${base}${p}` })))));
// RFC 9116, canonical to this host (openvibe.pics).
r.get('/.well-known/security.txt', securityTxt.handler(base));
-- r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
++ // The site as llms.txt describes it, shared by /llms.txt and /llms-full.txt (SEO-33).
++ const llmsSite = () => ({
name: SITE_NAME,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',
@@@ -635,7 +636,19 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
{ title: 'Pictures and AI', url: `${base}/ai-training` },
] },
],
-- })));
++ });
++ r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt(llmsSite())));
++ // The same, in full (SEO-33): what the site is, then every page llms.txt lists with what it holds.
++ r.get('/llms-full.txt', (_req, res) => {
++ const d = llmsSite();
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsFull({
++ site: d.name, summary: d.summary, base: `${base}/`,
++ sections: [
++ { title: 'About', pages: [{ title: d.name, url: `${base}/`, text: [d.summary, Array.isArray(d.details) ? d.details.join('\n') : d.details].filter(Boolean).join('\n\n') }] },
++ ...(d.sections || []).map((x) => ({ title: x.title, pages: (x.links || []).map((l) => ({ title: l.title, url: l.url, text: l.note || '' })) })),
++ ],
++ }));
++ });
r.get('/updates', (req, res) => page(req, res, {
index: true, title: 'What shipped on OpenVibe.Pics', description: 'Every change deployed to OpenVibe.Pics, newest first.',
body: raw(frame.updatesBody({ service: 'media-hub', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 4fb2507..f259753 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -1852,15 +1852,16 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
// RFC 9116, canonical to this host (openvibe.video), not the drive's.
r.get('/.well-known/security.txt', securityTxt.handler(base));
-- r.get('/llms.txt', async (req, res) => {
++ // The site as llms.txt describes it, with the latest recordings and clips (shared by /llms-full.txt, SEO-33).
++ async function llmsSite() {
const [vods, clips] = await Promise.all([
live.feed({ type: 'vods', limit: SITEMAP_LIMIT }),
live.feed({ type: 'clips', limit: SITEMAP_LIMIT }),
]);
const vodItems = (vods && vods.items) || [];
const clipItems = (clips && clips.items) || [];
-- const links = (items, kind) => items.map((it) => ({ title: it.title || (kind === 'vod' ? 'Recording' : 'Clip'), url: `${base}/${kind === 'vod' ? 'v' : 'c'}/${it.id}` }));
-- return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
++ const links = (items, kind) => items.map((it) => ({ title: it.title || (kind === 'vod' ? 'Recording' : 'Clip'), url: `${base}/${kind === 'vod' ? 'v' : 'c'}/${it.id}`, note: String(it.description || it.ai_summary || it.summary || '').replace(/\s+/g, ' ').trim().slice(0, 600) }));
++ return {
name: SITE_NAME,
summary: 'Videos people upload or record in the browser, and stream recordings and clips from OpenVibe.Live, each with a watch page.',
details: 'AI captions, chapters and a summary are made for uploads (OpenVibe.AI, on OpenVibe\'s own servers) and rendered on the watch pages, as are the AI chapters and captions of stream recordings. The AI cut makes an edited copy of an upload: the pauses cut out, or a highlights reel. Uploading needs a signed-in OpenVibe account; /upload and /record.',
@@@ -1869,6 +1870,24 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
{ title: 'Latest recordings', links: links(vodItems, 'vod') },
{ title: 'Latest clips', links: links(clipItems, 'clip') },
],
++ };
++ }
++ r.get('/llms.txt', async (req, res) => {
++ const d = await llmsSite();
++ // llms.txt stays a list of links: the notes are for /llms-full.txt.
++ const brief = { ...d, sections: d.sections.map((x) => ({ ...x, links: x.links.map(({ note, ...l }) => l) })) };
++ return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt(brief));
++ });
++ // The same, in full (SEO-33): what the site is, then every recording and clip with what it holds.
++ r.get('/llms-full.txt', async (req, res) => {
++ const d = await llmsSite();
++ return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsFull({
++ site: d.name, summary: d.summary, base: `${base}/`,
++ sections: [
++ { title: 'About', pages: [{ title: d.name, url: `${base}/`, text: [d.summary, d.details].filter(Boolean).join('\n\n') }] },
++ ...d.sections.map((x) => ({ title: x.title, pages: x.links.map((l) => ({ title: l.title, url: l.url, text: l.note || '' })) })),
++ ],
++ maxBytes: 200000,
}));
});
ddiff --git a/test/pics-policy.test.js b/test/pics-policy.test.js
iindex 1a99c1f..456bcf4 100644
---- a/test/pics-policy.test.js
++++ b/test/pics-policy.test.js
@@@ -103,6 +103,11 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
for (const path of PAGES) assert.ok(map.includes(`<loc>https://openvibe.pics${path}</loc>`), `the sitemap lists ${path}`);
const llms = (await t.get('/llms.txt', { headers: PICS })).text;
for (const path of PAGES) assert.ok(llms.includes(`https://openvibe.pics${path}`), `llms.txt names ${path}`);
++ // SEO-33: llms-full.txt answers on the pics host, from the same descriptor.
++ const full = await t.get('/llms-full.txt', { headers: PICS });
++ assert.strictEqual(full.status, 200);
++ assert.match(full.text, /## About\n\n### /);
++ for (const path of PAGES) assert.ok(full.text.includes(`URL: https://openvibe.pics${path}`), `llms-full.txt names ${path}`);
const robots = (await t.get('/robots.txt', { headers: PICS })).text;
for (const path of PAGES) assert.ok(!robots.includes(`Disallow: ${path}`), `robots.txt does not block ${path}`);
});
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex f67a3ac..70f8404 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -606,6 +606,12 @@ function countOccurrences(haystack, needle) {
const llms = await getVideo('/llms.txt');
assert.strictEqual(llms.status, 200);
assert.ok(llms.text.includes('# OpenVibe.Video'));
++ // SEO-33: the same, in full.
++ const full = await getVideo('/llms-full.txt');
++ assert.strictEqual(full.status, 200);
++ assert.ok(full.text.startsWith('# OpenVibe.Video'));
++ assert.match(full.text, /## About\n\n### OpenVibe\.Video\n/);
++ assert.ok(full.text.length > 500, `${full.text.length} characters`);
});
await check('legal pages on the video host name openvibe.video', async () => {