diff --git a/vtex/loaders/intelligentSearch/productListingPage.ts b/vtex/loaders/intelligentSearch/productListingPage.ts index 709301ff7..09cb1c72b 100644 --- a/vtex/loaders/intelligentSearch/productListingPage.ts +++ b/vtex/loaders/intelligentSearch/productListingPage.ts @@ -16,7 +16,7 @@ import { } from "../../utils/legacy.ts"; import { getSegmentFromBag, withSegmentCookie } from "../../utils/segment.ts"; import { pageTypesFromUrl } from "../../utils/intelligentSearch.ts"; -import { withIsSimilarTo } from "../../utils/similars.ts"; +import { withIsSimilarToBatched } from "../../utils/similars.ts"; import { slugify } from "../../utils/slugify.ts"; import { filtersFromURL, @@ -374,20 +374,20 @@ const loader = async ( // Transform VTEX product format into schema.org's compatible format // If a property is missing from the final `products` array you can add // it in here - const products = await Promise.all( - vtexProducts - .map((p) => - toProduct(p, p.items.find(getFirstItemAvailable) || p.items[0], 0, { - baseUrl: baseUrl, - priceCurrency: segment?.payload?.currencyCode ?? "BRL", - includeOriginalAttributes: props.advancedConfigs - ?.includeOriginalAttributes, - }) - ) - .map((product) => - props.similars ? withIsSimilarTo(req, ctx, product) : product - ), + const transformedProducts = vtexProducts.map((p) => + toProduct(p, p.items.find(getFirstItemAvailable) || p.items[0], 0, { + baseUrl: baseUrl, + priceCurrency: segment?.payload?.currencyCode ?? "BRL", + includeOriginalAttributes: props.advancedConfigs + ?.includeOriginalAttributes, + }) ); + + // Use batched version to reduce API calls when fetching similar products + // Instead of N calls with ~3 IDs each, this makes 1 batched call with all IDs + const products = props.similars + ? await withIsSimilarToBatched(req, ctx, transformedProducts) + : transformedProducts; const paramsToPersist = new URLSearchParams(); searchArgs.query && paramsToPersist.set("q", searchArgs.query); searchArgs.sort && paramsToPersist.set("sort", searchArgs.sort); diff --git a/vtex/utils/fetchVTEX.ts b/vtex/utils/fetchVTEX.ts index 44d2bc048..952cc3a53 100644 --- a/vtex/utils/fetchVTEX.ts +++ b/vtex/utils/fetchVTEX.ts @@ -67,6 +67,7 @@ export const fetchSafe = ( input: string | Request | URL, init?: DecoRequestInit, ) => { + console.log("fetchSafe", input); return _fetchSafe(getSanitizedInput(input), init); }; diff --git a/vtex/utils/similars.ts b/vtex/utils/similars.ts index 1ba304260..2e8f41eed 100644 --- a/vtex/utils/similars.ts +++ b/vtex/utils/similars.ts @@ -1,6 +1,12 @@ import type { Product } from "../../commerce/types.ts"; +import { STALE } from "../../utils/fetch.ts"; import relatedProductsLoader from "../loaders/legacy/relatedProductsLoader.ts"; +import productList from "../loaders/legacy/productList.ts"; import { AppContext } from "../mod.ts"; +import { batch } from "./batch.ts"; +import { getSegmentFromBag, withSegmentCookie } from "./segment.ts"; +import { pickSku } from "./transform.ts"; +import { toSegmentParams } from "./legacy.ts"; export const withIsSimilarTo = async ( req: Request, @@ -27,3 +33,141 @@ export const withIsSimilarTo = async ( isSimilarTo: isSimilarTo ?? undefined, }; }; + +interface CrossSellingResult { + productId: string; + skuIds: string[]; +} + +/** + * Batched version of withIsSimilarTo that reduces productList API calls. + * + * Instead of calling productList once per product (N calls with ~3 IDs each), + * this function: + * 1. Makes all crossselling API calls in parallel + * 2. Collects all unique SKU IDs from all results + * 3. Makes a single batched productList call with all IDs + * 4. Distributes the results back to each product + * + * This reduces ~N productList calls to just 1 (or a few if > 50 IDs). + */ +export const withIsSimilarToBatched = async ( + req: Request, + ctx: AppContext, + products: Product[], +): Promise => { + const { vcsDeprecated } = ctx; + const segment = getSegmentFromBag(ctx); + const params = toSegmentParams(segment); + + // Filter products that have a valid productGroupID + const productsWithIds = products.filter( + (p) => p.isVariantOf?.productGroupID && p.inProductGroupWithID, + ); + + if (productsWithIds.length === 0) { + return products; + } + + // Step 1: Fetch all crossselling results in parallel + const crossSellingPromises = productsWithIds.map(async (product) => { + const productId = product.inProductGroupWithID!; + try { + const crossSellingProducts = await vcsDeprecated + ["GET /api/catalog_system/pub/products/crossselling/:type/:productId"]({ + type: "similars", + productId, + ...params, + }, { ...STALE, headers: withSegmentCookie(segment) }) + .then((res) => res.json()); + + if (!Array.isArray(crossSellingProducts)) { + return { productId, skuIds: [] } as CrossSellingResult; + } + + const skuIds = crossSellingProducts.map((p) => pickSku(p).itemId); + return { productId, skuIds } as CrossSellingResult; + } catch { + return { productId, skuIds: [] } as CrossSellingResult; + } + }); + + const crossSellingResults = await Promise.all(crossSellingPromises); + + // Create a map of productId -> skuIds for later distribution + const productToSimilarSkus = new Map(); + for (const result of crossSellingResults) { + productToSimilarSkus.set(result.productId, result.skuIds); + } + + // Step 2: Collect all unique SKU IDs + const allSkuIds = [ + ...new Set(crossSellingResults.flatMap((r) => r.skuIds)), + ]; + + if (allSkuIds.length === 0) { + return products; + } + + // Step 3: Fetch all similar products in batched productList calls (max 50 per call) + const batchedIds = batch(allSkuIds, 50); + const productListResults = await Promise.allSettled( + batchedIds.map((ids) => + productList({ props: { similars: false, ids } }, req, ctx) + ), + ); + + const allSimilarProducts = productListResults + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ) + .flatMap((result) => result.value) + .filter((x): x is Product => Boolean(x)); + + productListResults + .filter((result) => result.status === "rejected") + .forEach((result, index) => { + console.error( + `Error loading similar products for batch ${index}:`, + (result as PromiseRejectedResult).reason, + ); + }); + + // Step 4: Create a map of SKU ID -> Product for fast lookup + const skuToProduct = new Map(); + for (const product of allSimilarProducts) { + // Map by SKU ID + const skuId = product.sku; + if (skuId) { + skuToProduct.set(skuId, product); + } + // Also map by product ID for fallback + const productId = product.inProductGroupWithID; + if (productId) { + skuToProduct.set(`product:${productId}`, product); + } + } + + // Step 5: Distribute similar products back to each original product + return products.map((product) => { + const productId = product.inProductGroupWithID; + if (!productId) { + return product; + } + + const similarSkuIds = productToSimilarSkus.get(productId); + if (!similarSkuIds || similarSkuIds.length === 0) { + return product; + } + + const isSimilarTo = similarSkuIds + .map((skuId) => skuToProduct.get(skuId)) + .filter((p): p is Product => Boolean(p)); + + return { + ...product, + isSimilarTo: isSimilarTo.length > 0 ? isSimilarTo : undefined, + }; + }); +};