openvibers/openvibe.deals Commits
Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#44)
Changes
@@@ -70,6 +70,8 @@ records" obligation; a deviation from the nine-table list, see the final section| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | operations (`/metrics` loopback only; nginx never proxies it) || `/auth/*` | Network SSO (the same session layer as Community/Blog) |++Sitemap entries carry lastmod from each offer or product page's latest recorded change.++### API (`/api/v1`, problem+json errors)Service tokens (audience `openvibe.deals`) are checked for **one capability per route** and act for@@@ -38,7 +38,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {async function productEntries() {return Promise.all((await listings.products()).map(async (p) => {const pv = await publication.productView(p);-- return { loc: abs(publication.productPath(p)), lastmod: pv.freshestObservedAt || undefined, decision: pv.decision };++ return { loc: abs(publication.productPath(p)), lastmod: Math.max(p.updated_at, ...pv.offers.map((v) => Math.max(v.root.updated_at, v.latest ? v.latest.observed_at : 0))), decision: pv.decision };}));}@@@ -94,7 +94,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {});router.get('/sitemap.xml', async (_req, res) => {-- const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod);++ const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);const p = (await productEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));@@@ -4,6 +4,17 @@* sitemaps and Search; discovery artifacts; caching; operations endpoints. No seed deals.*/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 { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');(async () => {@@@ -198,6 +209,18 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');const offers = await t.get('/sitemaps/offers.xml');const fresh = (await t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get()).slug;assert.ok(offers.text.includes(fresh));++ assertSitemapLastmods(offers.text);++ const offerRow = await t.ctx.store.db.prepare('SELECT id, updated_at FROM deal_offers WHERE slug = ?').get(fresh);++ const observation = await t.ctx.store.db.prepare('SELECT MAX(observed_at) AS time FROM deal_price_observations WHERE offer_id = ?').get(offerRow.id);++ const expected = new Date(Math.max(offerRow.updated_at, observation.time || 0)).toISOString().slice(0, 10);++ assert.ok(offers.text.includes(`<lastmod>${expected}</lastmod>`));++ const products = (await t.get('/sitemaps/products.xml')).text;++ assertSitemapLastmods(products);++ // An index entry carries its map's newest lastmod; an empty map has no honest date and none is invented.++ const offersEntry = /<sitemap><loc>[^<]*\/sitemaps\/offers\.xml<\/loc>(<lastmod>[^<]+<\/lastmod>)?<\/sitemap>/.exec(idx.text);++ assert.ok(offersEntry && offersEntry[1], idx.text);++ if (!products.includes('<url>')) assert.ok(/<loc>[^<]*\/sitemaps\/products\.xml<\/loc><\/sitemap>/.test(idx.text), 'no lastmod for an empty map');++ else assertSitemapLastmods(idx.text, 'sitemap');assert.ok(!offers.text.includes(timed.slug), 'expired');assert.ok(!offers.text.includes(deal.slug), 'expired');});
@@ -70,6 +70,8 @@ records" obligation; a deviation from the nine-table list, see the final section
@@ -70,6 +70,8 @@ records" obligation; a deviation from the nine-table list, see the final section
| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | operations (`/metrics` loopback only; nginx never proxies it) |
| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | operations (`/metrics` loopback only; nginx never proxies it) |
| `/auth/*` | Network SSO (the same session layer as Community/Blog) |
| `/auth/*` | Network SSO (the same session layer as Community/Blog) |
+Sitemap entries carry lastmod from each offer or product page's latest recorded change.
+
### API (`/api/v1`, problem+json errors)
### API (`/api/v1`, problem+json errors)
Service tokens (audience `openvibe.deals`) are checked for **one capability per route** and act for
Service tokens (audience `openvibe.deals`) are checked for **one capability per route** and act for
@@ -38,7 +38,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -38,7 +38,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
async function productEntries() {
async function productEntries() {
return Promise.all((await listings.products()).map(async (p) => {
return Promise.all((await listings.products()).map(async (p) => {
const pv = await publication.productView(p);
const pv = await publication.productView(p);
- return { loc: abs(publication.productPath(p)), lastmod: pv.freshestObservedAt || undefined, decision: pv.decision };
+ return { loc: abs(publication.productPath(p)), lastmod: Math.max(p.updated_at, ...pv.offers.map((v) => Math.max(v.root.updated_at, v.latest ? v.latest.observed_at : 0))), decision: pv.decision };
}));
}));
}
}
@@ -94,7 +94,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -94,7 +94,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
});
});
router.get('/sitemap.xml', async (_req, res) => {
router.get('/sitemap.xml', async (_req, res) => {
- const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod);
+ const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
const p = (await productEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
const p = (await productEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});
const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
@@ -4,6 +4,17 @@
@@ -4,6 +4,17 @@
* sitemaps and Search; discovery artifacts; caching; operations endpoints. No seed deals.
* sitemaps and Search; discovery artifacts; caching; operations endpoints. No seed deals.
*/
*/
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 { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
(async () => {
(async () => {
@@ -198,6 +209,18 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -198,6 +209,18 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
const offers = await t.get('/sitemaps/offers.xml');
const offers = await t.get('/sitemaps/offers.xml');
const fresh = (await t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get()).slug;
const fresh = (await t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get()).slug;
assert.ok(offers.text.includes(fresh));
assert.ok(offers.text.includes(fresh));
+ assertSitemapLastmods(offers.text);
+ const offerRow = await t.ctx.store.db.prepare('SELECT id, updated_at FROM deal_offers WHERE slug = ?').get(fresh);
+ const observation = await t.ctx.store.db.prepare('SELECT MAX(observed_at) AS time FROM deal_price_observations WHERE offer_id = ?').get(offerRow.id);
+ const expected = new Date(Math.max(offerRow.updated_at, observation.time || 0)).toISOString().slice(0, 10);
+ assert.ok(offers.text.includes(`<lastmod>${expected}</lastmod>`));
+ const products = (await t.get('/sitemaps/products.xml')).text;
+ assertSitemapLastmods(products);
+ // An index entry carries its map's newest lastmod; an empty map has no honest date and none is invented.
+ const offersEntry = /<sitemap><loc>[^<]*\/sitemaps\/offers\.xml<\/loc>(<lastmod>[^<]+<\/lastmod>)?<\/sitemap>/.exec(idx.text);
+ assert.ok(offersEntry && offersEntry[1], idx.text);
+ if (!products.includes('<url>')) assert.ok(/<loc>[^<]*\/sitemaps\/products\.xml<\/loc><\/sitemap>/.test(idx.text), 'no lastmod for an empty map');
+ else assertSitemapLastmods(idx.text, 'sitemap');
assert.ok(!offers.text.includes(timed.slug), 'expired');
assert.ok(!offers.text.includes(timed.slug), 'expired');
assert.ok(!offers.text.includes(deal.slug), 'expired');
assert.ok(!offers.text.includes(deal.slug), 'expired');
});
});