openvibers/openvibe.news Commits
Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#44)
Changes
@@@ -159,6 +159,8 @@ Other tables in the same database:| `/internal/events` | the Events webhook (loopback; nginx does not proxy it) || `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, truthful readiness, release, metrics (`/metrics` loopback only) |++Sitemap entries carry lastmod from each story's latest publication or topic page's latest change.++### Discoverability (roadmap §32)- Robots meta, canonical and `X-Robots-Tag` come from the `openvibe-publishing/seo` gate with@@@ -10,7 +10,7 @@* GET /sitemap.xml sitemap index over the two sections below* GET /sitemaps/stories.xml published, INDEXABLE stories only (the gate decides: retracted,* unsupported, AI-unreviewed and noindex stories never appear);-- * lastmod = the published revision's real time++ * lastmod = the story's latest publication time* GET /sitemaps/topics.xml topic pages that have indexable stories** Built from the database on every request, never from the viewer.@@@ -91,35 +91,41 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {}));});-- router.get('/sitemap.xml', async (_req, res) => {-- const entries = (await storyEntries()).filter((e) => e.decision.indexable);-- const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;-- xml(res, seo.sitemapIndex([-- { loc: abs('/sitemaps/stories.xml'), ...(newest ? { lastmod: newest } : {}) },-- { loc: abs('/sitemaps/topics.xml'), ...(newest ? { lastmod: newest } : {}) },-- ]));-- });---- router.get('/sitemaps/stories.xml', async (_req, res) => {-- xml(res, seo.sitemap((await storyEntries()).map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.rev.createdAt, decision: e.decision }))).files[0]);-- });---- router.get('/sitemaps/topics.xml', async (_req, res) => {++ async function topicEntries(stories) {const byTopic = new Map();-- for (const e of await storyEntries()) {++ for (const e of stories) {if (!e.decision.indexable || !e.story.topic_id) continue;-- const t = e.rev.createdAt;++ const t = e.story.published_at;if (!byTopic.has(e.story.topic_id) || byTopic.get(e.story.topic_id) < t) byTopic.set(e.story.topic_id, t);}const entries = [];-- for (const [id, lastmod] of byTopic) {++ for (const [id, storyLastmod] of byTopic) {const topic = await topics.byId(id);if (!topic || topic.status !== 'active') continue;const path = publication.topicPath(topic);const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });-- entries.push({ loc: abs(path), lastmod, decision });++ entries.push({ loc: abs(path), lastmod: new Date(Math.max(storyLastmod, topic.updated_at)).toISOString(), decision });}-- xml(res, seo.sitemap(entries).files[0]);++ return entries;++ }++++ router.get('/sitemap.xml', async (_req, res) => {++ const stories = await storyEntries();++ const indexed = stories.filter((e) => e.decision.indexable);++ const topics = (await topicEntries(stories)).filter((e) => e.decision.indexable);++ const newest = (times) => times.length ? new Date(Math.max(...times)).toISOString() : undefined;++ xml(res, seo.sitemapIndex([++ { loc: abs('/sitemaps/stories.xml'), lastmod: newest(indexed.map((e) => e.story.published_at)) },++ { loc: abs('/sitemaps/topics.xml'), lastmod: newest(topics.map((e) => Date.parse(e.lastmod))) },++ ]));++ });++++ router.get('/sitemaps/stories.xml', async (_req, res) => {++ xml(res, seo.sitemap((await storyEntries()).map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.story.published_at, decision: e.decision }))).files[0]);++ });++++ router.get('/sitemaps/topics.xml', async (_req, res) => {++ xml(res, seo.sitemap(await topicEntries(await storyEntries())).files[0]);});return router;@@@ -6,6 +6,17 @@* published with a revision, and non-editors refused.*/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 contracts = require('openvibe-contracts');const { boot, check, done, launchReports } = require('./helpers/boot');@@@ -165,8 +176,13 @@ const BODY = [assert.strictEqual(other.json().items.length, 0);const sm = await t.get('/sitemaps/stories.xml');assert.match(sm.text, /<loc>https:\/\/openvibe.news\/stories\/europa-clipper-launches-for-jupiter<\/loc>/);++ assertSitemapLastmods(sm.text);++ const storyRow = await t.db().prepare('SELECT published_at FROM news_stories WHERE id = ?').get(story.id);++ assert.ok(sm.text.includes(`<lastmod>${new Date(storyRow.published_at).toISOString().slice(0, 10)}</lastmod>`));const tm = await t.get('/sitemaps/topics.xml');assert.match(tm.text, /\/topics\/space/);++ assertSitemapLastmods(tm.text);++ assertSitemapLastmods((await t.get('/sitemap.xml')).text, 'sitemap');});await check('a correction is published with the next revision and shows in the correction history', async () => {
@@ -159,6 +159,8 @@ Other tables in the same database:
@@ -159,6 +159,8 @@ Other tables in the same database:
| `/internal/events` | the Events webhook (loopback; nginx does not proxy it) |
| `/internal/events` | the Events webhook (loopback; nginx does not proxy it) |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, truthful readiness, release, metrics (`/metrics` loopback only) |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, truthful readiness, release, metrics (`/metrics` loopback only) |
+Sitemap entries carry lastmod from each story's latest publication or topic page's latest change.
+
### Discoverability (roadmap §32)
### Discoverability (roadmap §32)
- Robots meta, canonical and `X-Robots-Tag` come from the `openvibe-publishing/seo` gate with
- Robots meta, canonical and `X-Robots-Tag` come from the `openvibe-publishing/seo` gate with
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* GET /sitemap.xml sitemap index over the two sections below
* GET /sitemap.xml sitemap index over the two sections below
* GET /sitemaps/stories.xml published, INDEXABLE stories only (the gate decides: retracted,
* GET /sitemaps/stories.xml published, INDEXABLE stories only (the gate decides: retracted,
* unsupported, AI-unreviewed and noindex stories never appear);
* unsupported, AI-unreviewed and noindex stories never appear);
- * lastmod = the published revision's real time
+ * lastmod = the story's latest publication time
* GET /sitemaps/topics.xml topic pages that have indexable stories
* GET /sitemaps/topics.xml topic pages that have indexable stories
*
*
* Built from the database on every request, never from the viewer.
* Built from the database on every request, never from the viewer.
@@ -91,35 +91,41 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
@@ -91,35 +91,41 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
}));
}));
});
});
- router.get('/sitemap.xml', async (_req, res) => {
- const entries = (await storyEntries()).filter((e) => e.decision.indexable);
- const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
- xml(res, seo.sitemapIndex([
- { loc: abs('/sitemaps/stories.xml'), ...(newest ? { lastmod: newest } : {}) },
- { loc: abs('/sitemaps/topics.xml'), ...(newest ? { lastmod: newest } : {}) },
- ]));
- });
-
- router.get('/sitemaps/stories.xml', async (_req, res) => {
- xml(res, seo.sitemap((await storyEntries()).map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.rev.createdAt, decision: e.decision }))).files[0]);
- });
-
- router.get('/sitemaps/topics.xml', async (_req, res) => {
+ async function topicEntries(stories) {
const byTopic = new Map();
const byTopic = new Map();
- for (const e of await storyEntries()) {
+ for (const e of stories) {
if (!e.decision.indexable || !e.story.topic_id) continue;
if (!e.decision.indexable || !e.story.topic_id) continue;
- const t = e.rev.createdAt;
+ const t = e.story.published_at;
if (!byTopic.has(e.story.topic_id) || byTopic.get(e.story.topic_id) < t) byTopic.set(e.story.topic_id, t);
if (!byTopic.has(e.story.topic_id) || byTopic.get(e.story.topic_id) < t) byTopic.set(e.story.topic_id, t);
}
}
const entries = [];
const entries = [];
- for (const [id, lastmod] of byTopic) {
+ for (const [id, storyLastmod] of byTopic) {
const topic = await topics.byId(id);
const topic = await topics.byId(id);
if (!topic || topic.status !== 'active') continue;
if (!topic || topic.status !== 'active') continue;
const path = publication.topicPath(topic);
const path = publication.topicPath(topic);
const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });
const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });
- entries.push({ loc: abs(path), lastmod, decision });
+ entries.push({ loc: abs(path), lastmod: new Date(Math.max(storyLastmod, topic.updated_at)).toISOString(), decision });
}
}
- xml(res, seo.sitemap(entries).files[0]);
+ return entries;
+ }
+
+ router.get('/sitemap.xml', async (_req, res) => {
+ const stories = await storyEntries();
+ const indexed = stories.filter((e) => e.decision.indexable);
+ const topics = (await topicEntries(stories)).filter((e) => e.decision.indexable);
+ const newest = (times) => times.length ? new Date(Math.max(...times)).toISOString() : undefined;
+ xml(res, seo.sitemapIndex([
+ { loc: abs('/sitemaps/stories.xml'), lastmod: newest(indexed.map((e) => e.story.published_at)) },
+ { loc: abs('/sitemaps/topics.xml'), lastmod: newest(topics.map((e) => Date.parse(e.lastmod))) },
+ ]));
+ });
+
+ router.get('/sitemaps/stories.xml', async (_req, res) => {
+ xml(res, seo.sitemap((await storyEntries()).map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.story.published_at, decision: e.decision }))).files[0]);
+ });
+
+ router.get('/sitemaps/topics.xml', async (_req, res) => {
+ xml(res, seo.sitemap(await topicEntries(await storyEntries())).files[0]);
});
});
return router;
return router;
@@ -6,6 +6,17 @@
@@ -6,6 +6,17 @@
* published with a revision, and non-editors refused.
* published with a revision, and non-editors refused.
*/
*/
const assert = require('assert');
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 contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { boot, check, done, launchReports } = require('./helpers/boot');
const { boot, check, done, launchReports } = require('./helpers/boot');
@@ -165,8 +176,13 @@ const BODY = [
@@ -165,8 +176,13 @@ const BODY = [
assert.strictEqual(other.json().items.length, 0);
assert.strictEqual(other.json().items.length, 0);
const sm = await t.get('/sitemaps/stories.xml');
const sm = await t.get('/sitemaps/stories.xml');
assert.match(sm.text, /<loc>https:\/\/openvibe.news\/stories\/europa-clipper-launches-for-jupiter<\/loc>/);
assert.match(sm.text, /<loc>https:\/\/openvibe.news\/stories\/europa-clipper-launches-for-jupiter<\/loc>/);
+ assertSitemapLastmods(sm.text);
+ const storyRow = await t.db().prepare('SELECT published_at FROM news_stories WHERE id = ?').get(story.id);
+ assert.ok(sm.text.includes(`<lastmod>${new Date(storyRow.published_at).toISOString().slice(0, 10)}</lastmod>`));
const tm = await t.get('/sitemaps/topics.xml');
const tm = await t.get('/sitemaps/topics.xml');
assert.match(tm.text, /\/topics\/space/);
assert.match(tm.text, /\/topics\/space/);
+ assertSitemapLastmods(tm.text);
+ assertSitemapLastmods((await t.get('/sitemap.xml')).text, 'sitemap');
});
});
await check('a correction is published with the next revision and shows in the correction history', async () => {
await check('a correction is published with the next revision and shows in the correction history', async () => {