openvibers/openvibe.deals Commits

Deals: one JSON-LD graph per page, the price as a number, and the offer's own heading as its name (SEO-10) (#131)

OP OpenVibers committed 15 days ago · cb7c630db8 · 7 files, +41 −18

Changes

ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex 0c5e01e..dfc5463 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -175,7 +175,8 @@ function createPublication({ config, store, reads, catalog }) {
seller: v.root.seller ? { '@type': 'Organization', name: v.root.seller } : v.store ? { '@type': 'Organization', name: v.store.name || v.store.domain } : undefined,
};
if (fresh && o) {
-- if (o.price != null && o.currency) { ld.price = o.price; ld.priceCurrency = o.currency; }
++ // The price a buyer pays, as a number (the page shows the same number and its currency).
++ if (o.price != null && o.currency) { ld.price = o.price_num != null ? o.price_num : Number(o.price); ld.priceCurrency = o.currency; }
if (o.availability) ld.availability = `https://schema.org/${{ in_stock: 'InStock', out_of_stock: 'OutOfStock', preorder: 'PreOrder', discontinued: 'Discontinued', limited: 'LimitedAvailability', sold_out: 'SoldOut', online_only: 'OnlineOnly', in_store_only: 'InStoreOnly' }[o.availability]}`;
if (o.condition) ld.itemCondition = `https://schema.org/${{ new: 'NewCondition', used: 'UsedCondition', refurbished: 'RefurbishedCondition', damaged: 'DamagedCondition' }[o.condition]}`;
if (o.shipping != null && o.currency) ld.shippingDetails = { '@type': 'OfferShippingDetails', shippingRate: { '@type': 'MonetaryAmount', value: o.shipping, currency: o.currency } };
@@@ -184,12 +185,14 @@ function createPublication({ config, store, reads, catalog }) {
return ld;
}
++ /**
++ * The offer's own page: one Product node, named by the page's own heading (the offer's title).
++ * The page never names a brand, so no brand is claimed; the brand belongs to the product's own page.
++ */
function offerJsonLd(v) {
-- const name = v.product ? v.product.name : v.root.title;
return seo.compact({
-- '@context': 'https://schema.org', '@type': 'Product', name, url: abs(offerPath(v.root)),
++ '@context': 'https://schema.org', '@type': 'Product', name: v.root.title, url: abs(offerPath(v.root)),
description: v.root.description || undefined,
-- brand: v.product && v.product.brand ? { '@type': 'Brand', name: v.product.brand } : undefined,
offers: [offerLd(v)],
});
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ea709ed..c846dae 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -31,7 +31,8 @@ const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const cache = require('openvibe-shared/cache-policy');
const ssr = require('openvibe-publishing/ssr');
--const { renderPage, SITE_SUMMARY } = require('../render/layout');
++const { renderPage, SITE_SUMMARY, oneGraph } = require('../render/layout');
++const sharedSeo = require('openvibe-shared/seo');
const views = require('../render/views');
const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
@@@ -205,11 +206,14 @@ function createPages(ctx) {
decision: pageDecision('/', { empty: total === 0 }),
canonical: seo.canonicalUrl(config.baseUrl, '/'),
feeds: FEEDS,
-- jsonLd: [
-- { '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Deals', url: publication.abs('/'), potentialAction: { '@type': 'SearchAction', target: `${publication.abs('/search')}?q={q}`, 'query-input': 'required name=q' } },
-- ...(listed.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Deals on OpenVibe.Deals', itemListElement: listed.map((v, i) => ({ '@type': 'ListItem', position: i + 1, url: publication.abs(publication.offerPath(v.root)), name: v.root.title })) }] : []),
++ // One JSON-LD script for the page: every node in one @graph.
++ jsonLd: oneGraph([
++ { '@type': 'WebSite', name: 'OpenVibe.Deals', url: publication.abs('/'), potentialAction: { '@type': 'SearchAction', target: `${publication.abs('/search')}?q={q}`, 'query-input': 'required name=q' } },
++ ...(listed.length ? [{ '@type': 'ItemList', name: 'Deals on OpenVibe.Deals', itemListElement: listed.map((v, i) => ({ '@type': 'ListItem', position: i + 1, url: publication.abs(publication.offerPath(v.root)), name: v.root.title })) }] : []),
home.faqJsonLd(faq),
-- ],
++ // The publisher of the WebSite above, so its @id resolves on this page too.
++ sharedSeo.jsonLd.organization(),
++ ]),
bodyClass: 'home',
body: home.homePage({
empty: total === 0, freshnessHours: Math.round(config.freshnessMs / 3600000), faq,
@@@ -281,7 +285,7 @@ function createPages(ctx) {
decision: v.decision,
canonical,
type: 'website',
-- jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
++ jsonLd: oneGraph([publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])]),
body: views.offerPage({
v, dto: publication.offerDto(v), viewer, csrf: person ? csrfToken(config, viewer) : '', myVote: person ? await votes.mine(viewer.subject, r) : 0,
comments: c, mergedComments, canEdit: access.canEdit(viewer, r), isMod,
@@@ -388,7 +392,7 @@ function createPages(ctx) {
title: `${product.name} — price comparison`,
description: `Offers for ${product.name}, each with the time its price was observed.`,
decision: pv.decision, canonical,
-- jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],
++ jsonLd: oneGraph([publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])]),
body: views.productPage({ pv, urls }),
},
};
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a88fc1c..76eb31b 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -15,6 +15,15 @@ const path = require('path');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
++/**
++ * 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.Deals';
// One site summary, shared by /llms.txt, /llms-full.txt and the home page's ai-summary.
@@@ -113,4 +122,4 @@ function renderPage(o) {
});
}
--module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, SITE_SUMMARY, NETWORK_URL };
++module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, SITE_SUMMARY, NETWORK_URL, oneGraph };
ddiff --git a/test/freshness.test.js b/test/freshness.test.js
iindex 4475b75..13f7ac2 100644
---- a/test/freshness.test.js
++++ b/test/freshness.test.js
@@@ -20,7 +20,7 @@ const { sourceItem } = require('./helpers/mocks');
assert.match(page.text, /data-freshness="fresh"/);
assert.strictEqual(robotsOf(page.text), 'index, follow');
const ld = jsonLd(page.text).find((x) => x['@type'] === 'Product');
-- assert.strictEqual(ld.offers[0].price, '49.99');
++ assert.strictEqual(ld.offers[0].price, 49.99);
assert.strictEqual(ld.offers[0].priceCurrency, 'USD');
assert.strictEqual(ld.offers[0].availability, 'https://schema.org/InStock');
const json = await t.offerJson(offer.slug);
@@@ -82,7 +82,7 @@ const { sourceItem } = require('./helpers/mocks');
assert.strictEqual(json.observations[0].observed_by, bob.subject);
const page = await t.get(`/d/${offer.slug}`);
assert.strictEqual(robotsOf(page.text), 'index, follow');
-- assert.strictEqual(jsonLd(page.text).find((x) => x['@type'] === 'Product').offers[0].price, '44.00');
++ assert.strictEqual(jsonLd(page.text).find((x) => x['@type'] === 'Product').offers[0].price, 44);
});
await check('an imported observation is dated by when the source saw it: old retrieval → stale from the start', async () => {
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 18a8fcb..a178ba6 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -104,8 +104,15 @@ async function boot(opts = {}) {
}
/** JSON-LD blocks of an HTML page. */
++/** The JSON-LD of a page: every script's nodes, with a one-@graph script flattened into its nodes. */
function jsonLd(htmlText) {
-- return [...htmlText.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ return [...htmlText.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)]
++ .flatMap((m) => { const doc = JSON.parse(m[1]); return Array.isArray(doc['@graph']) ? doc['@graph'] : [doc]; });
++}
++
++/** How many JSON-LD scripts a page carries (the estate ships one per page). */
++function jsonLdScripts(htmlText) {
++ return [...htmlText.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].length;
}
const robotsOf = (htmlText) => (/<meta name="robots" content="([^"]+)"/.exec(htmlText) || [])[1];
@@@ -116,4 +123,4 @@ async function check(name, fn) {
}
function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
--module.exports = { boot, check, done, makeClock, jsonLd, robotsOf, HOUR };
++module.exports = { boot, check, done, makeClock, jsonLd, jsonLdScripts, robotsOf, HOUR };
ddiff --git a/test/missing-price.test.js b/test/missing-price.test.js
iindex 0fa16c8..52b3ffd 100644
---- a/test/missing-price.test.js
++++ b/test/missing-price.test.js
@@@ -60,7 +60,7 @@ const { sourceItem } = require('./helpers/mocks');
const ld = jsonLd(page.text).find((x) => x['@type'] === 'Product');
assert.strictEqual(ld.gtin, '0012345678905');
const prices = ld.offers.map((o) => o.price);
-- assert.deepStrictEqual(prices, ['30.00', undefined, undefined], 'only the fresh stated price reaches structured data');
++ assert.deepStrictEqual(prices, [30, undefined, undefined], 'only the fresh stated price reaches structured data');
});
await check('a price in an imported headline is not parsed into a price', async () => {
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex c709fe0..df82acc 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -65,7 +65,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
const types = jsonLd(r.text).map((x) => x['@type']);
assert.deepStrictEqual(types, ['Product', 'BreadcrumbList']);
const ld = jsonLd(r.text)[0].offers[0];
-- assert.deepStrictEqual([ld.price, ld.priceCurrency, ld.itemCondition, ld.seller.name], ['79.00', 'USD', 'https://schema.org/NewCondition', 'Gadgets Inc']);
++ assert.deepStrictEqual([ld.price, ld.priceCurrency, ld.itemCondition, ld.seller.name], [79, 'USD', 'https://schema.org/NewCondition', 'Gadgets Inc']);
assert.strictEqual(ld.shippingDetails.shippingRate.value, '4.99');
assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe.deals\/d\//);
const json = await t.get(`/d/${deal.slug}.json`);