openvibers/openvibe.coupons Commits

Coupons: a code's own page carries one JSON-LD script, every node in one @graph, and a Product/Offer only where the page shows an amount off with a currency (SEO-10) (#135)

OP OpenVibers committed 15 days ago · 42e8fee706 · 6 files, +212 −7

* The price is the money the page's headline shows, as a number; a percentage, free shipping or a plain code is not a price
* The currency is the ISO 4217 code the code states, shown beside the amount on the code's own page, so the page claims no more than it shows
* Availability comes from the page's own state: in stock while the code is in date and in active results, sold out once the page says it expired
* A rating is never invented: a code's page shows no rating and no review count, so it carries none

Changes

ddiff --git a/server/http/public.js b/server/http/public.js
iindex 9bcb49f..b192687 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -25,8 +25,9 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
--const { renderPage } = require('../render/layout');
++const { renderPage, oneGraph } = require('../render/layout');
const pages = require('../render/pages');
++const offer = require('../render/offer');
const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError } = require('./errors');
@@@ -115,10 +116,16 @@ function createPublicRoutes(ctx) {
};
function jsonLd(m, v) {
-- return [
++ const nodes = [
seo.structuredData.breadcrumbs([{ name: 'Coupons', url: `${config.baseUrl}/` }, { name: m.name, url: publication.merchantUrl(m) }, ...(v ? [{ name: v.code, url: v.url }] : [])]),
seo.structuredData.webPage({ url: v ? v.url : publication.merchantUrl(m), name: v ? `${v.code} — ${v.title}` : `${m.name} coupon codes`, dateModified: v ? v.updated_at : null, inLanguage: 'en' }),
];
++ // A code's own page carries one JSON-LD script: every node in one @graph. The Product/Offer is
++ // there only where the page itself shows a price or an amount off with a currency.
++ if (!v) return nodes;
++ const product = offer.productOf(v, { merchant: { name: m.name, url: publication.merchantUrl(m) }, now: store.now() });
++ if (product) nodes.push(product);
++ return oneGraph(nodes);
}
// ── Home, about ─────────────────────────────────────────
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex aeef6ac..74d3c19 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -17,6 +17,15 @@ const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const ovServe = require('openvibe-shared/serve');
++/**
++ * The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.
++ * The nodes keep their own fields; only their own @context goes, because the graph carries it once.
++ */
++const oneGraph = (nodes) => {
++ const graph = nodes.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });
++ return [{ '@context': 'https://schema.org', '@graph': graph }];
++};
++
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Coupons';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@@ -115,4 +124,4 @@ function renderPage(o) {
});
}
--module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL };
++module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, oneGraph };
ddiff --git a/server/render/offer.js b/server/render/offer.js
nnew file mode 100644
iindex 0000000..be9994e
---- /dev/null
++++ b/server/render/offer.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++/**
++ * The Product and Offer a code's own page carries — only what the page shows.
++ *
++ * The owner rule: public structured data may claim only what the page itself shows. So a code's page
++ * carries a Product/Offer only when it shows a price or an amount off with a currency:
++ * - the price is the money the page's headline shows (an amount off, or a product's price), as a number;
++ * a percentage ("20% off"), free shipping or a plain code is not a price, and such a page carries
++ * no Product/Offer at all
++ * - the currency is the ISO 4217 code the code states, and the page shows it beside the amount
++ * - availability comes from the page's own state: in stock while the code is in date and in active
++ * results, sold out once the page says it expired
++ * - the url is the page's canonical
++ * A rating is never invented: a code's page shows no rating and no review count, so it claims none.
++ */
++const pages = require('./pages');
++
++const AVAILABILITY = { in_stock: 'https://schema.org/InStock', sold_out: 'https://schema.org/SoldOut' };
++
++/**
++ * The Product/Offer for a code's page, or null when the page shows no price with a currency.
++ * v: the code's public view; merchant: { name, url } (the shop the page shows); now: the site's clock.
++ */
++function productOf(v, { merchant = null, now = Date.now() } = {}) {
++ const money = pages.moneyOf(v);
++ const currency = pages.currencyOf(v, money);
++ if (!money || !currency) return null;
++ const expired = v.status === 'expired'
++ || (v.expiry && v.expiry.known && v.expiry.expires_at && Date.parse(v.expiry.expires_at) <= now);
++ const availability = expired ? AVAILABILITY.sold_out : (v.active ? AVAILABILITY.in_stock : null);
++ const offer = {
++ '@type': 'Offer',
++ price: money.amount,
++ priceCurrency: currency,
++ url: v.url,
++ ...(availability ? { availability } : {}),
++ // The expiry the page states, when it states one.
++ ...(v.expiry && v.expiry.known ? { validThrough: v.expiry.expires_at } : {}),
++ ...(merchant ? { seller: { '@type': 'Organization', name: merchant.name, url: merchant.url } } : {}),
++ };
++ return {
++ '@type': 'Product',
++ name: v.title,
++ url: v.url,
++ ...(v.description ? { description: v.description } : {}),
++ offers: [offer],
++ };
++}
++
++module.exports = { productOf };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 72164ba..390a88f 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -115,6 +115,40 @@ function headline(v) {
return 'Code';
}
++/**
++ * The money the page's headline shows: an amount off ("$15 off") or a product's price ("Air monitor for
++ * $35"). A percentage ("20% off"), free shipping or a plain code is not a price — a page that shows
++ * none of those carries no Product/Offer at all.
++ */
++function moneyOf(v) {
++ const head = headline(v);
++ let m = /^\$(\d[\d,]*(?:\.\d\d)?)\s*off$/.exec(head);
++ if (m) return { amount: Number(m[1].replace(/,/g, '')), symbol: '$', kind: 'off' };
++ m = /^\$(\d[\d,]*(?:\.\d\d)?)$/.exec(head);
++ if (m) return { amount: Number(m[1].replace(/,/g, '')), symbol: '$', kind: 'price' };
++ return null;
++}
++
++/** The symbol a currency is written with ("$" for USD, "€" for EUR). */
++function symbolOf(currency) {
++ try {
++ const parts = new Intl.NumberFormat('en', { style: 'currency', currency }).formatToParts(1);
++ return (parts.find((p) => p.type === 'currency') || {}).value || null;
++ } catch { return null; }
++}
++
++/**
++ * The ISO 4217 code the code states for its money: the one its minimum spend records, and only when
++ * that is the currency the page's money is written in (a "€20 off" code with a dollar minimum spend
++ * is not dollars). A code that states no currency states no currency.
++ */
++function currencyOf(v, money) {
++ if (!money) return null;
++ const min = (v.restrictions || []).find((r) => r.kind === 'min_spend' && r.currency);
++ if (!min) return null;
++ return symbolOf(min.currency) === money.symbol ? min.currency : null;
++}
++
/** The stated restrictions as short chips (none stated: no chips; Details says so in full). */
function restrictionChips(list) {
if (!list.length) return '';
@@@ -140,8 +174,11 @@ function quickReport(v, { viewer, csrf, back }) {
/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl } (page: its own page, where the title is the h1) */
function couponCard(v, opts = {}) {
const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;
++ // The currency the code states for its money, shown beside the amount on the code's own page: the page
++ // says which currency it is in, and the page's structured data claims nothing more than that.
++ const currency = opts.page ? currencyOf(v, moneyOf(v)) : null;
return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">
--<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}</div>
++<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}${currency ? h`<span class="cpn-scope">${currency}</span>` : ''}</div>
${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}
<div class="cpn-code"><code class="code" id="code-${v.id}">${v.code}</code>${k(kit.iconButton({ type: 'button', icon: 'copy', label: `Copy ${v.code}`, attrs: { 'data-ovk-copy': `#code-${v.id}`, hidden: true } }))}${opts.shopUrl ? k(kit.button({ href: opts.shopUrl, label: 'Shop', icon: 'open', kind: 'quiet', small: true, attrs: { rel: 'nofollow noopener', target: '_blank' } })) : ''}</div>
${statusLine(v)}
@@@ -449,4 +486,4 @@ function message({ heading, text, action }) {
}
module.exports = {
-- shopTab, isProductDeal, headline, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
++ shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 6c03597..40543a1 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -84,8 +84,11 @@ const { boot, check, done } = require('./helpers/boot');
const desc = (page.match(/<meta name="description" content="([^"]*)">/) || [])[1] || '';
assert.match(desc, /^Coupon code VEG5 for .+ at .+: (Validity unknown|Reported working|Reported not working), (expires \d{4}-\d{2}-\d{2}|no expiry stated)[^"]*\.$/, desc);
assert.ok(desc.length <= 160, desc);
-- const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
-- assert.deepStrictEqual(blocks.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);
++ // One JSON-LD script for the page, every node in one @graph (the estate's convention).
++ const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
++ assert.strictEqual(blocks.length, 1, 'exactly one JSON-LD script');
++ const graph = JSON.parse(blocks[0][1])['@graph'];
++ assert.deepStrictEqual(graph.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);
const json = (await t.get(`/c/${codeId}.json`)).json();
assert.strictEqual(json.coupon.code, 'VEG5');
assert.strictEqual(json.indexability.indexable, true);
ddiff --git a/test/structured-data.test.js b/test/structured-data.test.js
nnew file mode 100644
iindex 0000000..cc99ed5
---- /dev/null
++++ b/test/structured-data.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * A code's own page carries one JSON-LD script, every node in one @graph, and a Product/Offer only
++ * where the page shows a price or an amount off with a currency (the owner rule: structured data may
++ * claim only what the page itself shows).
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const scripts = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
++const nodes = (html) => scripts(html).flatMap((m) => {
++ const doc = JSON.parse(m[1]);
++ return Array.isArray(doc['@graph']) ? doc['@graph'] : [doc];
++});
++const product = (html) => nodes(html).find((n) => n['@type'] === 'Product');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });
++
++ // A code whose page shows "$15 off" and a minimum spend in US dollars.
++ const money = await t.submit(alice, {
++ merchant_id: shop.id, code: 'SAVE15', title: '$15 off orders of $75+',
++ restrictions: { min_spend: { amount: '75.00', currency: 'USD' } },
++ });
++ assert.strictEqual(money.status, 201, money.text.slice(0, 300));
++ const moneyId = money.json().coupon.id;
++
++ // A code whose page shows only a percentage.
++ const percent = await t.submit(alice, { merchant_id: shop.id, code: 'PCT20', title: '20% off sitewide' });
++ assert.strictEqual(percent.status, 201, percent.text.slice(0, 300));
++ const percentId = percent.json().coupon.id;
++
++ await check("a code's page carries one JSON-LD script, every node in one @graph", async () => {
++ const page = await t.get(`/c/${moneyId}`);
++ assert.strictEqual(page.status, 200);
++ assert.strictEqual(scripts(page.text).length, 1, 'exactly one JSON-LD script');
++ const doc = JSON.parse(scripts(page.text)[0][1]);
++ assert.strictEqual(doc['@context'], 'https://schema.org');
++ assert.ok(Array.isArray(doc['@graph']), 'the nodes are in one @graph');
++ const types = doc['@graph'].map((n) => n['@type']);
++ assert.ok(types.includes('BreadcrumbList') && types.includes('WebPage') && types.includes('Product'), JSON.stringify(types));
++ for (const n of doc['@graph']) assert.strictEqual(n['@context'], undefined, 'the graph carries the @context once');
++ });
++
++ await check('the offer claims the money the page shows, as a number', async () => {
++ const page = await t.get(`/c/${moneyId}`);
++ assert.match(page.text, /\$15 off/, 'the page shows the amount');
++ assert.match(page.text, /<span class="cpn-scope">USD<\/span>/, 'the page shows the currency it claims');
++ const p = product(page.text);
++ assert.ok(p, 'the page carries a Product');
++ assert.strictEqual(p.url, `https://openvibe.coupons/c/${moneyId}`);
++ assert.strictEqual(p.offers.length, 1);
++ const offer = p.offers[0];
++ assert.strictEqual(typeof offer.price, 'number', 'the price is a number');
++ assert.strictEqual(offer.price, 15);
++ assert.strictEqual(offer.priceCurrency, 'USD');
++ assert.strictEqual(offer.url, `https://openvibe.coupons/c/${moneyId}`);
++ assert.strictEqual(offer.availability, 'https://schema.org/InStock');
++ });
++
++ await check('a page that shows only a percentage carries no Product and no Offer', async () => {
++ const page = await t.get(`/c/${percentId}`);
++ assert.strictEqual(scripts(page.text).length, 1, 'exactly one JSON-LD script');
++ assert.ok(!product(page.text), 'no Product');
++ assert.ok(!/"price"/.test(JSON.stringify(nodes(page.text))), 'no price at all');
++ });
++
++ await check('a page shows no rating, so it claims none', async () => {
++ const page = await t.get(`/c/${moneyId}`);
++ assert.ok(!nodes(page.text).some((n) => n['@type'] === 'AggregateRating'), 'no invented rating');
++ });
++
++ await check('a shop page keeps the nodes it already had', async () => {
++ const page = await t.get(`/m/${shop.slug}`);
++ assert.strictEqual(page.status, 200);
++ const types = nodes(page.text).map((n) => n['@type']);
++ assert.ok(types.includes('BreadcrumbList') && types.includes('WebPage'), JSON.stringify(types));
++ assert.ok(!types.includes('Product'), 'no Offer for a code the shop page does not offer as a product');
++ });
++
++ await check('an expired code says sold out', async () => {
++ const r = await t.submit(alice, {
++ merchant_id: shop.id, code: 'OLD20', title: '$20 off', expires: '2026-09-23',
++ restrictions: { min_spend: { amount: '50.00', currency: 'USD' } },
++ });
++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
++ t.clock.advance(2 * 24 * 3600 * 1000); // past its expiry
++ const page = await t.get(`/c/${r.json().coupon.id}`);
++ assert.strictEqual(page.status, 200);
++ const p = product(page.text);
++ assert.ok(p, 'the page still carries the Product it shows');
++ assert.strictEqual(p.offers[0].availability, 'https://schema.org/SoldOut');
++ });
++
++ await done();
++})().catch((e) => { console.error(e); process.exit(1); });