openvibers/openvibe.sources Commits
jsonld@2: an offer's list (strikethrough) price as stated, and a price stated only in its specification (#129)
Changes
@@@ -104,9 +104,9 @@ succeeded. Health status: `healthy | stale | failing | never_fetched | disabled|---|---|---|| `rss`, `atom` (`feed@3`) | RSS 2.0, RSS 1.0/RDF, Atom 1.0 | guid/id identity, else link; text-only summary capped at 1000 chars; a deal feed's own offer elements (DealNews' `dealnews:price` with its currency, `dealnews:retailer`, `dealnews:expires`) become `fields.price`, `currency`, `seller` and `valid_until` exactly as stated (the item stays an article; nothing is read out of the headline); its `dealnews:category` fills `fields.categories` when the item has no `<category>`, and `dealnews:staffPick` is `fields.staff_pick` (true/false, absent when not stated) || `sitemap` (`sitemap@1`) | `<urlset>` (+ Google News extension), `<sitemapindex>` | index children are listed, not followed |--| `jsonld` (`jsonld@1`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error` |++| `jsonld` (`jsonld@2`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error`; `list_price` from a ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification`, as stated || `api` (`api@1`) | JSON or XML official APIs via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`; epoch fields only where mapped as such |--| `crawl` (`jsonld@1`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata |++| `crawl` (`jsonld@2`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata || `manual` (`manual@1`) | items entered through the API with an evidence URL | no fetcher |XML with DTD entity declarations is refused; the parser expands no entities (values are decoded@@@ -5,8 +5,10 @@* Types read: Product, Offer, AggregateOffer, Review, NewsArticle, BlogPosting (and Article and* its news subtypes). Values are copied as the page states them: prices stay the strings the page* gave, ratings are the page's own numbers, availability is the schema.org term. Nothing is-- * computed, averaged or defaulted; a missing price is null. Embedded reviews keep author, rating-- * and date only (review text belongs to its author).++ * computed, averaged or defaulted; a missing price is null. An offer's `list_price` is the price++ * of its ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification` entry, copied exactly as the++ * page states it. Embedded reviews keep author, rating and date only (review text belongs to its++ * author).** A page without any readable JSON-LD block is a parse error: the source promised structured data* and did not deliver it, and the run must say so instead of producing nothing silently.@@@ -14,7 +16,7 @@const crypto = require('crypto');const { toText, toIsoDate, canonicalUrl, toId, asArray, ParseError } = require('./util');--const PARSER_VERSION = 'jsonld@1';++const PARSER_VERSION = 'jsonld@2';const ARTICLE_TYPES = new Set(['NewsArticle', 'BlogPosting', 'Article', 'ReportageNewsArticle', 'AnalysisNewsArticle', 'OpinionNewsArticle', 'BackgroundNewsArticle']);const KIND = { Product: 'product', Offer: 'offer', AggregateOffer: 'offer', Review: 'review' };@@@ -61,6 +63,23 @@ const priceOf = (v) => {return null;};const term = (v) => (typeof v === 'string' ? v.replace(/^https?:\/\/schema\.org\//, '').slice(0, 60) : null);++const LIST_PRICE_TYPES = new Set(['ListPrice', 'StrikethroughPrice', 'MSRP', 'SRP']);++++/**++ * The price of the first priceSpecification entry whose priceType `match`es (or, when++ * `allowUntyped`, that has no priceType), copied exactly as stated; null when there is no such++ * entry, or when the entry's priceCurrency and the offer's are both stated and differ.++ */++function specPrice(o, match, allowUntyped = false) {++ for (const spec of asArray(o.priceSpecification)) {++ if (!spec || typeof spec !== 'object') continue;++ const t = term(spec.priceType);++ if (!match(t) && !(allowUntyped && t === null)) continue;++ if (typeof spec.priceCurrency === 'string' && typeof o.priceCurrency === 'string' && spec.priceCurrency !== o.priceCurrency) return null;++ return priceOf(spec.price);++ }++ return null;++}const imageOf = (v, base) => {const first = asArray(v)[0];return canonicalUrl(first && typeof first === 'object' ? first.url || first.contentUrl : first, base);@@@ -68,13 +87,24 @@ const imageOf = (v, base) => {function offerFields(o, base) {if (!o || typeof o !== 'object') return null;++ let price = priceOf(o.price);++ let currency = typeof o.priceCurrency === 'string' && /^[A-Z]{3}$/.test(o.priceCurrency) ? o.priceCurrency : null;++ if (o.price == null) {++ price = specPrice(o, (t) => t === 'SalePrice', true);++ // A shop that states its price only in the specification often states the currency only there too.++ if (price != null && !currency) {++ const spec = asArray(o.priceSpecification).find(x => x && typeof x === 'object' && priceOf(x.price) === price);++ if (spec && typeof spec.priceCurrency === 'string' && /^[A-Z]{3}$/.test(spec.priceCurrency)) currency = spec.priceCurrency;++ }++ }return {type: typesOf(o)[0] || null,-- price: priceOf(o.price),++ price,low_price: priceOf(o.lowPrice),high_price: priceOf(o.highPrice),++ list_price: specPrice(o, (t) => LIST_PRICE_TYPES.has(t)),offer_count: numeric(o.offerCount),-- currency: typeof o.priceCurrency === 'string' && /^[A-Z]{3}$/.test(o.priceCurrency) ? o.priceCurrency : null,++ currency,availability: term(o.availability),condition: term(o.itemCondition),valid_until: toIsoDate(o.priceValidUntil),@@@ -132,16 +132,39 @@ t('JSON-LD Product: offers as stated, missing price stays null, rating copied noassert.strictEqual(p.fields.gtin, '0012345678905');assert.strictEqual(p.fields.brand, 'Example Co');assert.deepStrictEqual(p.fields.offers[0], {-- type: 'Offer', price: '19.99', low_price: null, high_price: null, offer_count: null, currency: 'USD', availability: 'InStock',++ type: 'Offer', price: '19.99', low_price: null, high_price: null, list_price: null, offer_count: null, currency: 'USD', availability: 'InStock',condition: null, valid_until: '2026-12-31T00:00:00.000Z', url: 'https://shop.example.com/p/widget-pro', seller: 'Example Shop',});assert.strictEqual(p.fields.offers[1].price, null, 'no price on the page = null, never 0 or a guess');++ assert.strictEqual(p.fields.offers[1].list_price, null, 'no priceSpecification = no list price');assert.strictEqual(p.fields.offers[1].availability, 'OutOfStock');assert.deepStrictEqual(p.fields.aggregate_rating, { value: 4.4, best: null, worst: null, count: null, review_count: 89 });assert.deepStrictEqual(p.fields.reviews, [{ author: 'Sam', rating: { value: 5, best: 5, worst: null, count: null, review_count: null }, date: '2026-08-01T00:00:00.000Z' }]);assert.ok(!JSON.stringify(p).includes('Long text'), 'embedded review bodies are not stored');});++t('JSON-LD priceSpecification: list price from ListPrice/StrikethroughPrice/MSRP/SRP as stated, only in the offer\'s currency; SalePrice or an untyped specification fills a missing price', () => {++ const r = jsonld.parse(fx('list-price.html'), { url: 'https://shop.example.com/p/deals' });++ assert.strictEqual(r.items.length, 1);++ const o = r.items[0].fields.offers;++ assert.strictEqual(o.length, 9);++ const [a, b, c, d, e, f, g, h, i] = o;++ assert.strictEqual(a.price, '99.99');++ assert.strictEqual(a.list_price, '129.99', 'the prefixed ListPrice specification, exactly as stated');++ assert.strictEqual(b.list_price, '79.95', 'an unprefixed StrikethroughPrice is read');++ assert.strictEqual(c.list_price, null, 'a list price in EUR on a USD offer is not comparable');++ assert.strictEqual(d.list_price, '99.99', 'MSRP');++ assert.strictEqual(e.list_price, '84.99', 'SRP');++ assert.strictEqual(f.price, '79.00', 'a SalePrice specification supplies the current price when the offer states none');++ assert.strictEqual(f.list_price, null, 'a SalePrice is not a list price');++ assert.strictEqual(g.price, null, 'a SalePrice in another currency does not supply the price');++ assert.strictEqual(h.price, '44.00', 'a specification without priceType supplies the current price');++ assert.strictEqual(i.price, '15.50');++ assert.strictEqual(i.currency, 'GBP', 'a price stated only in the specification brings its currency');++ assert.strictEqual(a.currency, 'USD');++ assert.strictEqual(jsonld.PARSER_VERSION, 'jsonld@2');++});++t('JSON-LD NewsArticle, Review and BlogPosting; an invalid block is skipped, not fatal', () => {const r = jsonld.parse(fx('article.html'), { url: 'https://times.example.org/2026/09/budget' });assert.deepStrictEqual(r.items.map(i => i.kind), ['article', 'review', 'article']);@@@ -121,7 +121,7 @@ t('a crawl reads the sitemaps robots.txt names and keeps the products their pageconst lamp = items.find(i => i.title === 'Desk lamp');assert.strictEqual(lamp.kind, 'product');assert.strictEqual(JSON.parse(lamp.fields).offers[0].price, '19.99');-- assert.strictEqual(lamp.parser_version, 'jsonld@1');++ assert.strictEqual(lamp.parser_version, 'jsonld@2');assert.strictEqual(lamp.last_fetch_run_id, by['/products/lamp'].last_ok_run_id);const runs = await runsOf('shop');assert.ok(runs.every(r => r.state !== 'parse_error'));nnew file mode 100644@@@ -0,0 +1,22 @@++<!doctype html>++<html><head><meta charset="utf-8"><title>List price examples</title>++<script type="application/ld+json">++{++ "@context": "https://schema.org",++ "@type": "Product",++ "@id": "https://shop.example.com/p/deals#product",++ "name": "Deals example",++ "offers": [++ { "@type": "Offer", "price": "99.99", "priceCurrency": "USD", "priceSpecification": [{ "@type": "UnitPriceSpecification", "priceType": "https://schema.org/ListPrice", "price": "129.99", "priceCurrency": "USD" }] },++ { "@type": "Offer", "price": "49.95", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "StrikethroughPrice", "price": "79.95", "priceCurrency": "USD" } },++ { "@type": "Offer", "price": "9.99", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "http://schema.org/ListPrice", "price": "14.99", "priceCurrency": "EUR" } },++ { "@type": "Offer", "price": "89.99", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "https://schema.org/MSRP", "price": "99.99", "priceCurrency": "USD" } },++ { "@type": "Offer", "price": "79.99", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "https://schema.org/SRP", "price": "84.99", "priceCurrency": "USD" } },++ { "@type": "Offer", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "https://schema.org/SalePrice", "price": "79.00", "priceCurrency": "USD" } },++ { "@type": "Offer", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "SalePrice", "price": "59.00", "priceCurrency": "EUR" } },++ { "@type": "Offer", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "price": "44.00", "priceCurrency": "USD" } },++ { "@type": "Offer", "priceSpecification": { "@type": "UnitPriceSpecification", "price": "15.50", "priceCurrency": "GBP" } }++ ]++}++</script>++</head><body>Prices</body></html>@@@ -323,7 +323,7 @@ t('JSON-LD and sitemap sources ingest end to end', async () => {const [prod] = await itemsOf(p);assert.strictEqual(prod.kind, 'product');assert.strictEqual(JSON.parse(prod.fields).offers[0].price, '19.99');-- assert.strictEqual(prod.parser_version, 'jsonld@1');++ assert.strictEqual(prod.parser_version, 'jsonld@2');const sm = await create(sourceDef({ type: 'sitemap', category: 'deals', endpoints: [`${s.origin}/sitemap.xml`] }));const out = await run(sm);assert.deepStrictEqual([out.runs[0].state, out.runs[0].items_created, out.runs[0].items_skipped], ['ok', 2, 1]);
@@ -104,9 +104,9 @@ succeeded. Health status: `healthy | stale | failing | never_fetched | disabled
@@ -104,9 +104,9 @@ succeeded. Health status: `healthy | stale | failing | never_fetched | disabled
|---|---|---|
|---|---|---|
| `rss`, `atom` (`feed@3`) | RSS 2.0, RSS 1.0/RDF, Atom 1.0 | guid/id identity, else link; text-only summary capped at 1000 chars; a deal feed's own offer elements (DealNews' `dealnews:price` with its currency, `dealnews:retailer`, `dealnews:expires`) become `fields.price`, `currency`, `seller` and `valid_until` exactly as stated (the item stays an article; nothing is read out of the headline); its `dealnews:category` fills `fields.categories` when the item has no `<category>`, and `dealnews:staffPick` is `fields.staff_pick` (true/false, absent when not stated) |
| `rss`, `atom` (`feed@3`) | RSS 2.0, RSS 1.0/RDF, Atom 1.0 | guid/id identity, else link; text-only summary capped at 1000 chars; a deal feed's own offer elements (DealNews' `dealnews:price` with its currency, `dealnews:retailer`, `dealnews:expires`) become `fields.price`, `currency`, `seller` and `valid_until` exactly as stated (the item stays an article; nothing is read out of the headline); its `dealnews:category` fills `fields.categories` when the item has no `<category>`, and `dealnews:staffPick` is `fields.staff_pick` (true/false, absent when not stated) |
| `sitemap` (`sitemap@1`) | `<urlset>` (+ Google News extension), `<sitemapindex>` | index children are listed, not followed |
| `sitemap` (`sitemap@1`) | `<urlset>` (+ Google News extension), `<sitemapindex>` | index children are listed, not followed |
-| `jsonld` (`jsonld@1`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error` |
+| `jsonld` (`jsonld@2`) | schema.org Product, Offer/AggregateOffer, Review, NewsArticle, BlogPosting (+ Article) | prices, ratings and availability exactly as the page states; missing = null; embedded review text not kept; a page without JSON-LD is a `parse_error`; `list_price` from a ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification`, as stated |
| `api` (`api@1`) | JSON or XML official APIs via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`; epoch fields only where mapped as such |
| `api` (`api@1`) | JSON or XML official APIs via a registry mapping | `items_path`, `items_shape` (`array` default, `map` for records keyed by id), `fields`, `extra`; epoch fields only where mapped as such |
-| `crawl` (`jsonld@1`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata |
+| `crawl` (`jsonld@2`, `page@1`) | a whole site from its seeds: its sitemaps and, if asked, its links (see below) | only pages that publish JSON-LD are kept, unless `require_structured: false` keeps a `page` item from the page's own search metadata |
| `manual` (`manual@1`) | items entered through the API with an evidence URL | no fetcher |
| `manual` (`manual@1`) | items entered through the API with an evidence URL | no fetcher |
XML with DTD entity declarations is refused; the parser expands no entities (values are decoded
XML with DTD entity declarations is refused; the parser expands no entities (values are decoded
@@ -5,8 +5,10 @@
@@ -5,8 +5,10 @@
* Types read: Product, Offer, AggregateOffer, Review, NewsArticle, BlogPosting (and Article and
* Types read: Product, Offer, AggregateOffer, Review, NewsArticle, BlogPosting (and Article and
* its news subtypes). Values are copied as the page states them: prices stay the strings the page
* its news subtypes). Values are copied as the page states them: prices stay the strings the page
* gave, ratings are the page's own numbers, availability is the schema.org term. Nothing is
* gave, ratings are the page's own numbers, availability is the schema.org term. Nothing is
- * computed, averaged or defaulted; a missing price is null. Embedded reviews keep author, rating
- * and date only (review text belongs to its author).
+ * computed, averaged or defaulted; a missing price is null. An offer's `list_price` is the price
+ * of its ListPrice/StrikethroughPrice/MSRP/SRP `priceSpecification` entry, copied exactly as the
+ * page states it. Embedded reviews keep author, rating and date only (review text belongs to its
+ * author).
*
*
* A page without any readable JSON-LD block is a parse error: the source promised structured data
* A page without any readable JSON-LD block is a parse error: the source promised structured data
* and did not deliver it, and the run must say so instead of producing nothing silently.
* and did not deliver it, and the run must say so instead of producing nothing silently.
@@ -14,7 +16,7 @@
@@ -14,7 +16,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const { toText, toIsoDate, canonicalUrl, toId, asArray, ParseError } = require('./util');
const { toText, toIsoDate, canonicalUrl, toId, asArray, ParseError } = require('./util');
-const PARSER_VERSION = 'jsonld@1';
+const PARSER_VERSION = 'jsonld@2';
const ARTICLE_TYPES = new Set(['NewsArticle', 'BlogPosting', 'Article', 'ReportageNewsArticle', 'AnalysisNewsArticle', 'OpinionNewsArticle', 'BackgroundNewsArticle']);
const ARTICLE_TYPES = new Set(['NewsArticle', 'BlogPosting', 'Article', 'ReportageNewsArticle', 'AnalysisNewsArticle', 'OpinionNewsArticle', 'BackgroundNewsArticle']);
const KIND = { Product: 'product', Offer: 'offer', AggregateOffer: 'offer', Review: 'review' };
const KIND = { Product: 'product', Offer: 'offer', AggregateOffer: 'offer', Review: 'review' };
@@ -61,6 +63,23 @@ const priceOf = (v) => {
@@ -61,6 +63,23 @@ const priceOf = (v) => {
return null;
return null;
};
};
const term = (v) => (typeof v === 'string' ? v.replace(/^https?:\/\/schema\.org\//, '').slice(0, 60) : null);
const term = (v) => (typeof v === 'string' ? v.replace(/^https?:\/\/schema\.org\//, '').slice(0, 60) : null);
+const LIST_PRICE_TYPES = new Set(['ListPrice', 'StrikethroughPrice', 'MSRP', 'SRP']);
+
+/**
+ * The price of the first priceSpecification entry whose priceType `match`es (or, when
+ * `allowUntyped`, that has no priceType), copied exactly as stated; null when there is no such
+ * entry, or when the entry's priceCurrency and the offer's are both stated and differ.
+ */
+function specPrice(o, match, allowUntyped = false) {
+ for (const spec of asArray(o.priceSpecification)) {
+ if (!spec || typeof spec !== 'object') continue;
+ const t = term(spec.priceType);
+ if (!match(t) && !(allowUntyped && t === null)) continue;
+ if (typeof spec.priceCurrency === 'string' && typeof o.priceCurrency === 'string' && spec.priceCurrency !== o.priceCurrency) return null;
+ return priceOf(spec.price);
+ }
+ return null;
+}
const imageOf = (v, base) => {
const imageOf = (v, base) => {
const first = asArray(v)[0];
const first = asArray(v)[0];
return canonicalUrl(first && typeof first === 'object' ? first.url || first.contentUrl : first, base);
return canonicalUrl(first && typeof first === 'object' ? first.url || first.contentUrl : first, base);
@@ -68,13 +87,24 @@ const imageOf = (v, base) => {
@@ -68,13 +87,24 @@ const imageOf = (v, base) => {
function offerFields(o, base) {
function offerFields(o, base) {
if (!o || typeof o !== 'object') return null;
if (!o || typeof o !== 'object') return null;
+ let price = priceOf(o.price);
+ let currency = typeof o.priceCurrency === 'string' && /^[A-Z]{3}$/.test(o.priceCurrency) ? o.priceCurrency : null;
+ if (o.price == null) {
+ price = specPrice(o, (t) => t === 'SalePrice', true);
+ // A shop that states its price only in the specification often states the currency only there too.
+ if (price != null && !currency) {
+ const spec = asArray(o.priceSpecification).find(x => x && typeof x === 'object' && priceOf(x.price) === price);
+ if (spec && typeof spec.priceCurrency === 'string' && /^[A-Z]{3}$/.test(spec.priceCurrency)) currency = spec.priceCurrency;
+ }
+ }
return {
return {
type: typesOf(o)[0] || null,
type: typesOf(o)[0] || null,
- price: priceOf(o.price),
+ price,
low_price: priceOf(o.lowPrice),
low_price: priceOf(o.lowPrice),
high_price: priceOf(o.highPrice),
high_price: priceOf(o.highPrice),
+ list_price: specPrice(o, (t) => LIST_PRICE_TYPES.has(t)),
offer_count: numeric(o.offerCount),
offer_count: numeric(o.offerCount),
- currency: typeof o.priceCurrency === 'string' && /^[A-Z]{3}$/.test(o.priceCurrency) ? o.priceCurrency : null,
+ currency,
availability: term(o.availability),
availability: term(o.availability),
condition: term(o.itemCondition),
condition: term(o.itemCondition),
valid_until: toIsoDate(o.priceValidUntil),
valid_until: toIsoDate(o.priceValidUntil),
@@ -132,16 +132,39 @@ t('JSON-LD Product: offers as stated, missing price stays null, rating copied no
@@ -132,16 +132,39 @@ t('JSON-LD Product: offers as stated, missing price stays null, rating copied no
assert.strictEqual(p.fields.gtin, '0012345678905');
assert.strictEqual(p.fields.gtin, '0012345678905');
assert.strictEqual(p.fields.brand, 'Example Co');
assert.strictEqual(p.fields.brand, 'Example Co');
assert.deepStrictEqual(p.fields.offers[0], {
assert.deepStrictEqual(p.fields.offers[0], {
- type: 'Offer', price: '19.99', low_price: null, high_price: null, offer_count: null, currency: 'USD', availability: 'InStock',
+ type: 'Offer', price: '19.99', low_price: null, high_price: null, list_price: null, offer_count: null, currency: 'USD', availability: 'InStock',
condition: null, valid_until: '2026-12-31T00:00:00.000Z', url: 'https://shop.example.com/p/widget-pro', seller: 'Example Shop',
condition: null, valid_until: '2026-12-31T00:00:00.000Z', url: 'https://shop.example.com/p/widget-pro', seller: 'Example Shop',
});
});
assert.strictEqual(p.fields.offers[1].price, null, 'no price on the page = null, never 0 or a guess');
assert.strictEqual(p.fields.offers[1].price, null, 'no price on the page = null, never 0 or a guess');
+ assert.strictEqual(p.fields.offers[1].list_price, null, 'no priceSpecification = no list price');
assert.strictEqual(p.fields.offers[1].availability, 'OutOfStock');
assert.strictEqual(p.fields.offers[1].availability, 'OutOfStock');
assert.deepStrictEqual(p.fields.aggregate_rating, { value: 4.4, best: null, worst: null, count: null, review_count: 89 });
assert.deepStrictEqual(p.fields.aggregate_rating, { value: 4.4, best: null, worst: null, count: null, review_count: 89 });
assert.deepStrictEqual(p.fields.reviews, [{ author: 'Sam', rating: { value: 5, best: 5, worst: null, count: null, review_count: null }, date: '2026-08-01T00:00:00.000Z' }]);
assert.deepStrictEqual(p.fields.reviews, [{ author: 'Sam', rating: { value: 5, best: 5, worst: null, count: null, review_count: null }, date: '2026-08-01T00:00:00.000Z' }]);
assert.ok(!JSON.stringify(p).includes('Long text'), 'embedded review bodies are not stored');
assert.ok(!JSON.stringify(p).includes('Long text'), 'embedded review bodies are not stored');
});
});
+t('JSON-LD priceSpecification: list price from ListPrice/StrikethroughPrice/MSRP/SRP as stated, only in the offer\'s currency; SalePrice or an untyped specification fills a missing price', () => {
+ const r = jsonld.parse(fx('list-price.html'), { url: 'https://shop.example.com/p/deals' });
+ assert.strictEqual(r.items.length, 1);
+ const o = r.items[0].fields.offers;
+ assert.strictEqual(o.length, 9);
+ const [a, b, c, d, e, f, g, h, i] = o;
+ assert.strictEqual(a.price, '99.99');
+ assert.strictEqual(a.list_price, '129.99', 'the prefixed ListPrice specification, exactly as stated');
+ assert.strictEqual(b.list_price, '79.95', 'an unprefixed StrikethroughPrice is read');
+ assert.strictEqual(c.list_price, null, 'a list price in EUR on a USD offer is not comparable');
+ assert.strictEqual(d.list_price, '99.99', 'MSRP');
+ assert.strictEqual(e.list_price, '84.99', 'SRP');
+ assert.strictEqual(f.price, '79.00', 'a SalePrice specification supplies the current price when the offer states none');
+ assert.strictEqual(f.list_price, null, 'a SalePrice is not a list price');
+ assert.strictEqual(g.price, null, 'a SalePrice in another currency does not supply the price');
+ assert.strictEqual(h.price, '44.00', 'a specification without priceType supplies the current price');
+ assert.strictEqual(i.price, '15.50');
+ assert.strictEqual(i.currency, 'GBP', 'a price stated only in the specification brings its currency');
+ assert.strictEqual(a.currency, 'USD');
+ assert.strictEqual(jsonld.PARSER_VERSION, 'jsonld@2');
+});
+
t('JSON-LD NewsArticle, Review and BlogPosting; an invalid block is skipped, not fatal', () => {
t('JSON-LD NewsArticle, Review and BlogPosting; an invalid block is skipped, not fatal', () => {
const r = jsonld.parse(fx('article.html'), { url: 'https://times.example.org/2026/09/budget' });
const r = jsonld.parse(fx('article.html'), { url: 'https://times.example.org/2026/09/budget' });
assert.deepStrictEqual(r.items.map(i => i.kind), ['article', 'review', 'article']);
assert.deepStrictEqual(r.items.map(i => i.kind), ['article', 'review', 'article']);
@@ -121,7 +121,7 @@ t('a crawl reads the sitemaps robots.txt names and keeps the products their page
@@ -121,7 +121,7 @@ t('a crawl reads the sitemaps robots.txt names and keeps the products their page
const lamp = items.find(i => i.title === 'Desk lamp');
const lamp = items.find(i => i.title === 'Desk lamp');
assert.strictEqual(lamp.kind, 'product');
assert.strictEqual(lamp.kind, 'product');
assert.strictEqual(JSON.parse(lamp.fields).offers[0].price, '19.99');
assert.strictEqual(JSON.parse(lamp.fields).offers[0].price, '19.99');
- assert.strictEqual(lamp.parser_version, 'jsonld@1');
+ assert.strictEqual(lamp.parser_version, 'jsonld@2');
assert.strictEqual(lamp.last_fetch_run_id, by['/products/lamp'].last_ok_run_id);
assert.strictEqual(lamp.last_fetch_run_id, by['/products/lamp'].last_ok_run_id);
const runs = await runsOf('shop');
const runs = await runsOf('shop');
assert.ok(runs.every(r => r.state !== 'parse_error'));
assert.ok(runs.every(r => r.state !== 'parse_error'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+<!doctype html>
+<html><head><meta charset="utf-8"><title>List price examples</title>
+<script type="application/ld+json">
+{
+ "@context": "https://schema.org",
+ "@type": "Product",
+ "@id": "https://shop.example.com/p/deals#product",
+ "name": "Deals example",
+ "offers": [
+ { "@type": "Offer", "price": "99.99", "priceCurrency": "USD", "priceSpecification": [{ "@type": "UnitPriceSpecification", "priceType": "https://schema.org/ListPrice", "price": "129.99", "priceCurrency": "USD" }] },
+ { "@type": "Offer", "price": "49.95", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "StrikethroughPrice", "price": "79.95", "priceCurrency": "USD" } },
+ { "@type": "Offer", "price": "9.99", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "http://schema.org/ListPrice", "price": "14.99", "priceCurrency": "EUR" } },
+ { "@type": "Offer", "price": "89.99", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "https://schema.org/MSRP", "price": "99.99", "priceCurrency": "USD" } },
+ { "@type": "Offer", "price": "79.99", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "https://schema.org/SRP", "price": "84.99", "priceCurrency": "USD" } },
+ { "@type": "Offer", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "https://schema.org/SalePrice", "price": "79.00", "priceCurrency": "USD" } },
+ { "@type": "Offer", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "priceType": "SalePrice", "price": "59.00", "priceCurrency": "EUR" } },
+ { "@type": "Offer", "priceCurrency": "USD", "priceSpecification": { "@type": "UnitPriceSpecification", "price": "44.00", "priceCurrency": "USD" } },
+ { "@type": "Offer", "priceSpecification": { "@type": "UnitPriceSpecification", "price": "15.50", "priceCurrency": "GBP" } }
+ ]
+}
+</script>
+</head><body>Prices</body></html>
@@ -323,7 +323,7 @@ t('JSON-LD and sitemap sources ingest end to end', async () => {
@@ -323,7 +323,7 @@ t('JSON-LD and sitemap sources ingest end to end', async () => {
const [prod] = await itemsOf(p);
const [prod] = await itemsOf(p);
assert.strictEqual(prod.kind, 'product');
assert.strictEqual(prod.kind, 'product');
assert.strictEqual(JSON.parse(prod.fields).offers[0].price, '19.99');
assert.strictEqual(JSON.parse(prod.fields).offers[0].price, '19.99');
- assert.strictEqual(prod.parser_version, 'jsonld@1');
+ assert.strictEqual(prod.parser_version, 'jsonld@2');
const sm = await create(sourceDef({ type: 'sitemap', category: 'deals', endpoints: [`${s.origin}/sitemap.xml`] }));
const sm = await create(sourceDef({ type: 'sitemap', category: 'deals', endpoints: [`${s.origin}/sitemap.xml`] }));
const out = await run(sm);
const out = await run(sm);
assert.deepStrictEqual([out.runs[0].state, out.runs[0].items_created, out.runs[0].items_skipped], ['ok', 2, 1]);
assert.deepStrictEqual([out.runs[0].state, out.runs[0].items_created, out.runs[0].items_skipped], ['ok', 2, 1]);