diff --git a/CHANGELOG.md b/CHANGELOG.md index 97eb247..35b1bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Fetch sponsored products. + ## [0.0.3] - 2024-11-05 ### Feature diff --git a/node/clients/index.ts b/node/clients/index.ts index 881a117..3af5775 100644 --- a/node/clients/index.ts +++ b/node/clients/index.ts @@ -1,7 +1,7 @@ import { IOClients } from '@vtex/api' import Synerise from './synerise' -import { IntelligentSearch } from './intelligent-search' +import { IntelligentSearch } from './intelligentSearch' // Extend the default IOClients implementation with our own custom clients. export class Clients extends IOClients { diff --git a/node/clients/intelligent-search.ts b/node/clients/intelligent-search.ts deleted file mode 100644 index 35d953d..0000000 --- a/node/clients/intelligent-search.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { InstanceOptions, IOContext } from '@vtex/api' -import { ExternalClient } from '@vtex/api' - -export const parseState = (state?: string): { [key: string]: string } => { - if (!state) { - return {} - } - - try { - const parsed = JSON.parse(decodeURI(state)) - - if (typeof parsed === 'object') { - return parsed - } - } catch (err) { - /* ignore parsing errors */ - } - - return {} -} - -export class IntelligentSearch extends ExternalClient { - private locale: string | undefined - - constructor(context: IOContext, options?: InstanceOptions) { - super( - `http://${context.workspace}--${context.account}.myvtex.com/_v/api/intelligent-search`, - context, - { - ...options, - headers: { - ...options?.headers, - }, - } - ) - - const { locale, tenant } = context - - this.locale = locale ?? tenant?.locale - } - - public async productById(id: string) { - const result = await this.http.get(`/product_search/`, { - params: { - query: `product:${id}`, - locale: this.locale, - }, - metric: 'synerise-product-by-id', - }) - - return result.products[0] - } -} diff --git a/node/clients/intelligentSearch/index.ts b/node/clients/intelligentSearch/index.ts new file mode 100644 index 0000000..c63505c --- /dev/null +++ b/node/clients/intelligentSearch/index.ts @@ -0,0 +1,115 @@ +import type { InstanceOptions, IOContext } from '@vtex/api' +import { ExternalClient } from '@vtex/api' + +import type { + SearchResultArgs, + SearchProductsResponse, + SponsoredProductsResponse, + IIntelligentSearchClient, +} from './types' + +export const parseState = (state?: string): { [key: string]: string } => { + if (!state) { + return {} + } + + try { + const parsed = JSON.parse(decodeURI(state)) + + if (typeof parsed === 'object') { + return parsed + } + } catch (err) { + /* ignore parsing errors */ + } + + return {} +} + +const isPathTraversal = (str: string) => str.indexOf('..') >= 0 + +const decodeQuery = (query: string) => { + try { + return decodeURIComponent(query) + } catch (e) { + return query + } +} + +export class IntelligentSearch + extends ExternalClient + implements IIntelligentSearchClient { + private locale: string | undefined + + constructor(context: IOContext, options?: InstanceOptions) { + super( + `http://${context.workspace}--${context.account}.myvtex.com/_v/api/intelligent-search`, + context, + { + ...options, + headers: { + ...options?.headers, + }, + } + ) + + const { locale, tenant } = context + + this.locale = locale ?? tenant?.locale + } + + public async searchProducts( + params: SearchResultArgs, + path: string, + shippingHeader?: string[] + ) { + const { query, leap, searchState } = params + + if (isPathTraversal(path)) { + throw new Error('Malformed URL') + } + + return this.http.get(`/product_search/${path}`, { + params: { + query: query && decodeQuery(query), + locale: this.locale, + bgy_leap: leap ? true : undefined, + ...parseState(searchState), + ...params, + }, + metric: 'product-search', + headers: { + 'x-vtex-shipping-options': shippingHeader ?? '', + }, + }) + } + + public async searchSponsoredProducts( + params: SearchResultArgs, + path: string, + shippingHeader?: string[] + ) { + const { query, leap, searchState } = params + + if (isPathTraversal(path)) { + throw new Error('Malformed URL') + } + + return this.http.get( + `/sponsored_products/${path}`, + { + params: { + query: query && decodeQuery(query), + locale: this.locale, + bgy_leap: leap ? true : undefined, + ...parseState(searchState), + ...params, + }, + metric: 'product-search', + headers: { + 'x-vtex-shipping-options': shippingHeader ?? '', + }, + } + ) + } +} diff --git a/node/clients/intelligentSearch/types.ts b/node/clients/intelligentSearch/types.ts new file mode 100644 index 0000000..8542a37 --- /dev/null +++ b/node/clients/intelligentSearch/types.ts @@ -0,0 +1,81 @@ +type SegmentData = { + channel: number + utm_campaign: string + regionId?: string + utm_source: string + utmi_campaign: string + currencyCode: string + currencySymbol: string + countryCode: string + cultureInfo: string +} + +type IndexingType = 'API' | 'XML' + +type RegionSeller = { + id: string + name: string +} + +type Options = { + allowRedirect?: boolean +} + +type AdvertisementOptions = { + showSponsored?: boolean + sponsoredCount?: number + repeatSponsoredProducts?: boolean + advertisementPlacement?: string +} + +export type SearchResultArgs = { + attributePath?: string + query?: string + page?: number + count?: number + sort?: string + operator?: string + fuzzy?: string + leap?: boolean + tradePolicy?: number + segment?: SegmentData + indexingType?: IndexingType + searchState?: string + sellers?: RegionSeller[] + hideUnavailableItems?: boolean | null + removeHiddenFacets?: boolean | null + options?: Options + initialAttributes?: string + workspaceSearchParams?: Record + regionId?: string | null + from?: number | null + to?: number | null + showSponsored?: boolean +} & AdvertisementOptions + +// We have more fields but they are not relevant for this codebase. + +export type Product = { + productId: string +} + +export type SearchProductsResponse = { + products: Product[] +} + +export type SponsoredProductsResponse = Product[] // Same product but with ADS info... + +// + +export interface IIntelligentSearchClient { + searchProducts( + params: SearchResultArgs, + path: string, + shippingHeader?: string[] + ): Promise + searchSponsoredProducts( + params: SearchResultArgs, + path: string, + shippingHeader?: string[] + ): Promise +} diff --git a/node/middlewares/campaign.ts b/node/middlewares/campaign.ts index 8fdb439..e889ea9 100644 --- a/node/middlewares/campaign.ts +++ b/node/middlewares/campaign.ts @@ -1,16 +1,91 @@ -const convertProducts = (products: SynProductData[], ctx: Context) => { +import type { Product } from '../clients/intelligentSearch/types' + +export async function fetchHydratedProductsWithAds( + ctx: Context, + products: SynProductData[] +) { const { clients: { intelligentSearch }, } = ctx - return products.map((product) => + if (products.length === 0) { + return [] + } + + const query = products.reduce((acc, product) => { + return `${acc}${product.itemId};` + }, 'product:') + + const [sponsoredProducts, searchProducts] = await Promise.all([ intelligentSearch - .productById(product.itemId) - .then((result) => result) - .catch(() => { - return undefined + .searchSponsoredProducts( + { + query, + }, + '' + ) + .catch((e) => { + ctx.vtex.logger.error(e) + + return [] as Product[] + }), + intelligentSearch + .searchProducts( + { + query, + }, + '' + ) + .then((res) => { + // This is a type error, but we can't fix it because we don't have the type definition. + // We can't import the type definition because it's not exported. + return res.products }) - ) + .catch((e) => { + ctx.vtex.logger.error(e) + + return [] as Product[] + }), + ]) + + const setOfProducts = new Map() + const productsOrdered: string[] = [] + + sponsoredProducts.forEach((product) => { + setOfProducts.set(product.productId, product) + productsOrdered.push(product.productId) + }) + + searchProducts.forEach((product) => { + if (!setOfProducts.has(product.productId)) { + setOfProducts.set(product.productId, product) + productsOrdered.push(product.productId) + } + }) + + products.forEach((product) => { + const hydratedProduct = setOfProducts.get(product.itemId) + + if (!hydratedProduct) { + ctx.vtex.logger.error(`Product not found: ${product.itemId}`) + } + }) + + const result = productsOrdered + .map((productId) => { + const hydratedProduct = setOfProducts.get(productId) + + if (!hydratedProduct) { + ctx.vtex.logger.error(`Product not found: ${productId}`) + + return null + } + + return hydratedProduct + }) + .filter((product) => product !== null) + + return result } export async function campaign(ctx: Context, next: () => Promise) { @@ -34,7 +109,7 @@ export async function campaign(ctx: Context, next: () => Promise) { return } - const products = await Promise.all(convertProducts(recs.data, ctx)) + const products = await fetchHydratedProductsWithAds(ctx, recs.data) ctx.body = { products: products.filter((product) => !!product), jwt: userJWT } diff --git a/node/yarn.lock b/node/yarn.lock index 3013be1..7be3985 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -5616,7 +5616,7 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"stats-lite@github:vtex/node-stats-lite#dist": +stats-lite@vtex/node-stats-lite#dist: version "2.2.0" resolved "https://codeload.github.com/vtex/node-stats-lite/tar.gz/1b0d39cc41ef7aaecfd541191f877887a2044797" dependencies: