From 76858f480bd826cdfebcbab3dff348e03787e8ae Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 11 Nov 2024 10:57:43 -0800 Subject: [PATCH] feat: add ratings to json-ld (#44) * feat: add ratings to json-ld * fix: tweak rating object * fix: cleanup * chore: fix test --- src/content/adobe-commerce.js | 2 +- src/content/handler.js | 2 +- src/content/queries/cs-product.js | 20 +++++++-- src/content/queries/cs-variants.js | 20 ++++++--- src/templates/json/JSONTemplate.js | 68 ++++++++++++++++++------------ src/types.d.ts | 40 ++++++++++++++---- src/utils/product.js | 66 ++++++++++++++++------------- test/content/handler.test.js | 2 + test/fixtures/product.js | 4 ++ test/fixtures/variant.js | 4 ++ 10 files changed, 151 insertions(+), 77 deletions(-) diff --git a/src/content/adobe-commerce.js b/src/content/adobe-commerce.js index 8b88319..439f119 100644 --- a/src/content/adobe-commerce.js +++ b/src/content/adobe-commerce.js @@ -54,7 +54,7 @@ async function fetchProduct(sku, config) { if (!productData) { throw errorWithResponse(404, 'could not find product', json.errors); } - const product = productAdapter(productData); + const product = productAdapter(config, productData); return product; } catch (e) { console.error('failed to parse product: ', e); diff --git a/src/content/handler.js b/src/content/handler.js index 9dacd9d..c9bc60e 100644 --- a/src/content/handler.js +++ b/src/content/handler.js @@ -29,7 +29,7 @@ export default async function contentHandler(ctx) { if (!config.pageType) { return errorResponse(400, 'invalid config for tenant site (missing pageType)'); } - console.log('config: ', JSON.stringify(config, null, 2)); + ctx.log.debug('config: ', JSON.stringify(config, null, 2)); if (config.catalogSource === 'helix') { return handleHelixCommerce(ctx); diff --git a/src/content/queries/cs-product.js b/src/content/queries/cs-product.js index 8468b01..b4d1164 100644 --- a/src/content/queries/cs-product.js +++ b/src/content/queries/cs-product.js @@ -11,13 +11,14 @@ */ import { forceImagesHTTPS } from '../../utils/http.js'; -import { gql, parseSpecialToDate } from '../../utils/product.js'; +import { gql, parseRating, parseSpecialToDate } from '../../utils/product.js'; /** + * @param {Config} config * @param {any} productData * @returns {Product} */ -export const adapter = (productData) => { +export const adapter = (config, productData) => { let minPrice = productData.priceRange?.minimum ?? productData.price; let maxPrice = productData.priceRange?.maximum ?? productData.price; @@ -42,6 +43,8 @@ export const adapter = (productData) => { externalId: productData.externalId, images: forceImagesHTTPS(productData.images) ?? [], attributes: productData.attributes ?? [], + attributeMap: Object.fromEntries((productData.attributes ?? []) + .map(({ name, value }) => [name, value])), options: (productData.options ?? []).map((option) => ({ id: option.id, label: option.title, @@ -76,7 +79,6 @@ export const adapter = (productData) => { currency: minPrice.regular.amount.currency, maximumAmount: maxPrice.regular.amount.value, minimumAmount: minPrice.regular.amount.value, - // TODO: add variant? }, final: { // TODO: determine whether to use min or max @@ -84,17 +86,27 @@ export const adapter = (productData) => { currency: minPrice.final.amount.currency, maximumAmount: maxPrice.final.amount.value, minimumAmount: minPrice.final.amount.value, - // TODO: add variant? }, visible: minPrice.roles?.includes('visible'), } : null, }; + if (config.attributeOverrides?.product) { + Object.entries(config.attributeOverrides.product).forEach(([key, value]) => { + product[key] = product.attributeMap[value] ?? product[key]; + }); + } + const specialToDate = parseSpecialToDate(product); if (specialToDate) { product.specialToDate = specialToDate; } + const rating = parseRating(product); + if (rating) { + product.rating = rating; + } + return product; }; diff --git a/src/content/queries/cs-variants.js b/src/content/queries/cs-variants.js index 4314043..6f5d10b 100644 --- a/src/content/queries/cs-variants.js +++ b/src/content/queries/cs-variants.js @@ -11,9 +11,10 @@ */ import { forceImagesHTTPS } from '../../utils/http.js'; -import { gql, parseSpecialToDate } from '../../utils/product.js'; +import { gql, parseRating, parseSpecialToDate } from '../../utils/product.js'; /** + * @param {Config} config * @param {any} variants * @returns {Variant[]} */ @@ -30,6 +31,8 @@ export const adapter = (config, variants) => variants.map(({ selections, product inStock: product.inStock, images: forceImagesHTTPS(product.images) ?? [], attributes: product.attributes ?? [], + attributeMap: Object.fromEntries((product.attributes ?? []) + .map(({ name, value }) => [name, value])), externalId: product.externalId, prices: { regular: { @@ -50,15 +53,20 @@ export const adapter = (config, variants) => variants.map(({ selections, product selections: (selections ?? []).sort(), }; - const specialToDate = parseSpecialToDate(product); + if (config.attributeOverrides?.variant) { + Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => { + variant[key] = variant.attributeMap[value] ?? variant[key]; + }); + } + + const specialToDate = parseSpecialToDate(variant); if (specialToDate) { variant.specialToDate = specialToDate; } - if (config.attributeOverrides?.variant) { - Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => { - variant[key] = product.attributes?.find((attr) => attr.name === value)?.value; - }); + const rating = parseRating(variant); + if (rating) { + variant.rating = rating; } return variant; diff --git a/src/templates/json/JSONTemplate.js b/src/templates/json/JSONTemplate.js index 18cd3fe..8b0ab21 100644 --- a/src/templates/json/JSONTemplate.js +++ b/src/templates/json/JSONTemplate.js @@ -72,14 +72,17 @@ export class JSONTemplate { * @param {Variant} [variant] */ constructMPN(variant) { + const { attributeMap: productAttrs } = this.product; + const { attributeMap: variantAttrs } = variant || {}; + return variant - ? variant.attributes.find((attr) => attr.name.toLowerCase() === 'mpn')?.value ?? this.constructMPN() - : this.product.attributes.find((attr) => attr.name.toLowerCase() === 'mpn')?.value ?? undefined; + ? variantAttrs.mpn ?? this.constructMPN() + : productAttrs.mpn ?? undefined; } renderBrand() { - const { attributes } = this.product; - const brandName = attributes?.find((attr) => attr.name === 'brand')?.value; + const { attributeMap: attrs } = this.product; + const brandName = attrs.brand; if (!brandName) { return undefined; } @@ -91,6 +94,32 @@ export class JSONTemplate { }; } + /** + * @param {Variant} [variant] + */ + renderRating(variant) { + const { rating } = variant || this.product; + if (!rating) { + return undefined; + } + + const { + count, + reviews, + value, + best, + worst, + } = rating; + return pruneUndefined({ + '@type': 'AggregateRating', + ratingValue: value, + ratingCount: count, + reviewCount: reviews, + bestRating: best, + worstRating: worst, + }); + } + renderOffers() { const image = this.product.images?.[0]?.url ?? findProductImage(this.product, this.variants)?.url; @@ -114,30 +143,26 @@ export class JSONTemplate { availability: v.inStock ? 'InStock' : 'OutOfStock', price: finalPrice, priceCurrency: variantPrices.final?.currency, + gtin: v.gtin, + priceValidUntil: v.specialToDate, + aggregateRating: this.renderRating(v), }; if (finalPrice < regularPrice) { offer.priceSpecification = this.renderOffersPriceSpecification(v); } - if (v.gtin) { - offer.gtin = v.gtin; - } - - if (v.specialToDate) { - offer.priceValidUntil = v.specialToDate; - } - return pruneUndefined(offer); }).filter(Boolean), ], }; } + /** + * @param {Variant} variant + */ renderOffersPriceSpecification(variant) { - const { prices } = variant; - const { regular } = prices; - const { amount, currency } = regular; + const { prices: { regular: { amount, currency } } } = variant; return { '@type': 'UnitPriceSpecification', priceType: 'https://schema.org/ListPrice', @@ -152,8 +177,6 @@ export class JSONTemplate { name, metaDescription, images, - reviewCount, - ratingValue, } = this.product; const productUrl = this.constructProductURL(); @@ -172,17 +195,6 @@ export class JSONTemplate { productID: sku, ...this.renderOffers(), ...(this.renderBrand() ?? {}), - ...(typeof reviewCount === 'number' - && typeof ratingValue === 'number' - && reviewCount > 0 - ? { - aggregateRating: { - '@type': 'AggregateRating', - ratingValue, - reviewCount, - }, - } - : {}), }), undefined, 2); } } diff --git a/src/types.d.ts b/src/types.d.ts index 314de8a..20b8c28 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -10,8 +10,13 @@ declare global { export interface AttributeOverrides { variant: { + // expected attribute name => actual attribute name [key: string]: string; }; + product: { + // expected attribute name => actual attribute name + [key: string]: string; + } } /** @@ -35,14 +40,14 @@ declare global { sku?: string; matchedPatterns: string[]; imageRoles?: string[]; - - confMap: ConfigMap; + host: string; params: Record; headers: Record; - host: string; - offerVariantURLTemplate: string; - attributeOverrides: AttributeOverrides; - siteOverrides: Record>; + offerVariantURLTemplate?: string; + attributeOverrides?: AttributeOverrides; + siteOverrides?: Record>; + + confMap: ConfigMap; } export interface Env { @@ -90,13 +95,15 @@ declare global { externalId?: string; variants?: Variant[]; // variants exist on products in helix commerce but not on magento specialToDate?: string; + rating?: Rating // not handled currently: externalParentId?: string; variantSku?: string; - reviewCount?: number; - ratingValue?: number; optionUIDs?: string[]; + + // internal use: + attributeMap: Record; } export interface Variant { @@ -112,6 +119,23 @@ declare global { externalId: string; specialToDate?: string; gtin?: string; + rating?: Rating; + + // internal use: + attributeMap: Record; + } + + interface Rating { + // number of ratings + count?: number; + // number of reviews + reviews?: number; + // rating value + value: number | string; + // range of ratings, highest + best?: number | string; + // range of ratings, lowest + worst?: number | string; } interface Image { diff --git a/src/utils/product.js b/src/utils/product.js index c0c9cf0..670064e 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -10,6 +10,10 @@ * governing permissions and limitations under the License. */ +/** + * @param {string} str + * @returns {boolean} + */ export const hasUppercase = (str) => /[A-Z]/.test(str); /** @@ -32,10 +36,12 @@ export function gql(strs, ...params) { /** * This function removes all undefined values from an object. - * @param {Record} obj - The object to prune. - * @returns {Record} - The pruned object. + * @template {Record} T + * @param {T} obj - The object to prune. + * @returns {Partial} - The pruned object. */ export function pruneUndefined(obj) { + // @ts-ignore return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); } @@ -45,7 +51,7 @@ export function pruneUndefined(obj) { * If no in-stock variant, returns first variant image * * @param {Product} product - The product object. - * @param {Variant[]} [variants] - The variants array. + * @param {Variant[]} [variants=[]] - The variants array. * @returns {Product['images'][number]} - The product image. */ export function findProductImage(product, variants = []) { @@ -70,34 +76,10 @@ export function assertValidProduct(product) { } /** - * @param {Config} config - * @param {string} path - * @returns {string} matched path key + * @param {Product|Variant} product */ -export function matchConfigPath(config, path) { - // Filter out any keys that are not paths - const pathEntries = Object.entries(config.confMap).filter(([key]) => key !== 'base'); - - for (const [key] of pathEntries) { - // Replace `{{urlkey}}` and `{{sku}}` with regex patterns - const pattern = key - .replace('{{urlkey}}', '([^]+)') - .replace('{{sku}}', '([^]+)'); - - // Convert to regex and test against the path - const regex = new RegExp(`^${pattern}$`); - const match = path.match(regex); - - if (match) { - return key; - } - } - console.warn('No match found for path:', path); - return null; -} - export function parseSpecialToDate(product) { - const specialToDate = product.attributes?.find((attr) => attr.name === 'special_to_date')?.value; + const specialToDate = product.attributeMap.special_to_date; if (specialToDate) { const today = new Date(); const specialPriceToDate = new Date(specialToDate); @@ -108,3 +90,29 @@ export function parseSpecialToDate(product) { } return undefined; } + +/** + * @param {Product|Variant} product + * @returns {Rating | undefined} + */ +export function parseRating(product) { + const { attributeMap: attrs } = product; + /** @type {Rating} */ + // @ts-ignore + const rating = pruneUndefined({ + count: Number.parseInt(attrs['rating-count'], 10), + reviews: Number.parseInt(attrs['review-count'], 10), + value: attrs['rating-value'], + best: attrs['best-rating'], + worst: attrs['worst-rating'], + }); + + // at least one of count, reviews, or value must exist + if (rating.value != null + || ['count', 'reviews'].some( + (key) => rating[key] != null && !Number.isNaN(rating[key]), + )) { + return rating; + } + return undefined; +} diff --git a/test/content/handler.test.js b/test/content/handler.test.js index d5d5c96..71ddb04 100644 --- a/test/content/handler.test.js +++ b/test/content/handler.test.js @@ -58,6 +58,7 @@ describe('contentHandler', () => { it('calls handleHelixCommerce if catalogSource is helix', async () => { const ctx = { + log: { debug: () => {} }, info: { method: 'GET' }, url: { pathname: '/content/product/us/p/product-urlkey' }, config: { @@ -76,6 +77,7 @@ describe('contentHandler', () => { it('calls handleAdobeCommerce', async () => { const ctx = { + log: { debug: () => {} }, info: { method: 'GET' }, url: { pathname: '/content/product/us/p/product-urlkey' }, config: { diff --git a/test/fixtures/product.js b/test/fixtures/product.js index c494ab2..55ffa49 100644 --- a/test/fixtures/product.js +++ b/test/fixtures/product.js @@ -98,6 +98,10 @@ export function createProductFixture(overrides = {}) { ...overrides, }; + product.attributeMap = Object.fromEntries( + product.attributes.map(({ name, value }) => [name, value]), + ); + // Deep merge defaults with overrides return product; } diff --git a/test/fixtures/variant.js b/test/fixtures/variant.js index cf6df00..641fa5b 100644 --- a/test/fixtures/variant.js +++ b/test/fixtures/variant.js @@ -59,6 +59,10 @@ export function createProductVariationFixture(overrides = {}) { ...overrides, }; + variation.attributeMap = Object.fromEntries( + variation.attributes.map(({ name, value }) => [name, value]), + ); + return variation; }