Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions vtex/loaders/intelligentSearch/productListingPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions vtex/utils/fetchVTEX.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const fetchSafe = (
input: string | Request | URL,
init?: DecoRequestInit,
) => {
console.log("fetchSafe", input);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove debug logging before merging.

This console.log will fire on every VTEX API request in production, generating significant log noise and potentially exposing sensitive URL parameters. Given this is a WIP PR, this appears to be debug code that should be removed before merging to main.

Suggested fix
 export const fetchSafe = (
   input: string | Request | URL,
   init?: DecoRequestInit,
 ) => {
-  console.log("fetchSafe", input);
   return _fetchSafe(getSanitizedInput(input), init);
 };
🤖 Prompt for AI Agents
In @vtex/utils/fetchVTEX.ts at line 70, Remove the debug console.log in
fetchVTEX.ts that prints "fetchSafe" and the input; locate the call inside the
fetchSafe (or fetchVTEX) implementation and delete the console.log("fetchSafe",
input) line so the function no longer emits request details to stdout in
production (optionally replace with a guarded debug/logger call if you need
non-production debugging).

return _fetchSafe(getSanitizedInput(input), init);
};

Expand Down
144 changes: 144 additions & 0 deletions vtex/utils/similars.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Product[]> => {
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<string, string[]>();
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<Product[]> =>
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<string, Product>();
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,
};
});
};
Loading