Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 5x 5x 5x 5x 5x 1x 3x 3x 3x 3x 3x 3x 1x 5x 5x 5x 5x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 5x 5x 5x 5x 5x 5x 1x 3x 3x 1x 5x 5x 5x 2x 5x 5x 5x 5x 5x 5x 5x 5x 5x 4x 4x 3x 3x 3x 3x 3x 3x 3x 4x 5x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 5x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 1x | import { UOM } from '@/constants/common';
import { parsePrice } from '@/helpers/currency';
import { parseQuantity } from '@/helpers/quantity';
import { parseChemicalSpecs } from '@/helpers/science';
import { findPdfHref, htmlToAscii, mapDefined } from '@/helpers/utils';
import getFilteredProductsWithHasDiscount from '@/queries/wix-product-query.gql';
import { ProductBuilder } from '@/utils/ProductBuilder';
import { translateAstToWixFilter } from '@/utils/search-query/translators/translateAstToWixFilter';
import { isParsedPrice } from '@/utils/typeGuards/common';
import { isValidVariant } from '@/utils/typeGuards/productbuilder';
import { isProductItem, isProductSelection, isValidSearchResponse } from '@/utils/typeGuards/wix';
import { print } from 'graphql';
import { SupplierBase } from './SupplierBase';
/**
* SupplierBaseWix class that extends SupplierBase and implements AsyncIterable<Product>.
* @abstract
* @category Suppliers
* @source
*/
export abstract class SupplierBaseWix
extends SupplierBase<ProductObject, Product>
implements ISupplier
{
/** Access token for Wix API authentication */
protected accessToken: string = '';
/** Ecom app ID for Wix API authentication */
protected readonly ecomAppId: string = '1380b703-ce81-ff05-f115-39571d94dfcd';
/** Categories for Wix API */
protected readonly categories: Record<string, string> = {
all: '00000000-000000-000000-000000000001',
};
/** Default values for products */
protected productDefaults = {
uom: UOM.EA,
quantity: 1,
currencyCode: 'USD',
currencySymbol: '$',
};
protected readonly minMatchPercentage: number = 45;
// Wix's catalog filter supports and/or/not, so advanced queries are translated
// server-side instead of using the keyword-only fallback.
protected readonly supportsNativeAdvancedSearch: boolean = true;
/**
* Derives the unique product key from a Wix product object: its stable
* catalog product id (the same value passed to `.setID`).
* @param data - The raw Wix product object
* @returns The product's id
* @example
* ```typescript
* this.getUniqueProductKey(product); // "d1e2f3a4-..."
* ```
* @source
*/
protected getUniqueProductKey(data: ProductObject): string {
return String(data.id);
}
/**
* Sets up the Wix API access by retrieving and setting the access token.
* This method must be called before making any API requests.
* @returns Promise that resolves when the access token is set
* @example
* ```typescript
* await this.setup();
* // Now the access token is set and API requests can be made
* ```
* @source
*/
protected async setup(): Promise<void> {
const accessTokenResponse = await fetch(`${this.baseURL}/_api/v1/access-tokens`, {
credentials: 'include',
headers: {
accept: '*/*',
'accept-language': 'en-US,en;q=0.5',
'cache-control': 'no-cache',
pragma: 'no-cache',
priority: 'u=1, i',
referer: this.baseURL,
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
},
});
const data = await accessTokenResponse.json();
this.accessToken = data.apps[this.ecomAppId].accessToken;
this.headers = {
...this.headers,
Authorization: this.accessToken,
};
}
/**
* Get the GraphQL variables for the Wix API
*
* @param query - The query to search for
* @returns The GraphQL variables
* @source
*/
protected getGraphQLVariables(query: string): GraphQLQueryVariables {
const parsed = this.getAst();
const filters: WixFilterNode = parsed.isAdvanced
? translateAstToWixFilter(parsed.ast)
: { term: { field: 'name', op: 'CONTAINS', values: [`*${query}*`] } };
return {
mainCollectionId: this.categories.all,
offset: 0,
limit: 150,
sort: null,
filters,
} satisfies GraphQLQueryVariables;
}
/**
* Query products from the Wix API
*
* @param query - The query to search for
* @param limit - The limit of products to return
* @returns A promise that resolves when the products are queried
* @source
*/
protected async queryProducts(
query: string,
limit: number = this.limit,
): Promise<ProductBuilder<Product>[] | void> {
// The .gql import is a parsed DocumentNode (vite-plugin-graphql-loader);
// the Wix endpoint wants the raw query text in the `q` param, so print it.
const graphQLQuery = print(getFilteredProductsWithHasDiscount);
const graphQLVariables = this.getGraphQLVariables(query);
const searchRequest = await this.httpGetJson({
path: '_api/wix-ecommerce-storefront-web/api',
params: {
o: 'getFilteredProducts',
s: 'WixStoresWebClient',
q: graphQLQuery,
v: JSON.stringify(graphQLVariables),
},
});
if (isValidSearchResponse(searchRequest) === false) {
throw new Error(`Invalid or empty Wix query response for ${query}`, {
cause: {
searchRequest,
graphQLQuery,
graphQLVariables,
query,
supplier: this.supplierName,
},
});
}
const fuzzResults = this.fuzzyFilterAst<ProductObject>(
searchRequest.data.catalog.category.productsWithMetaData.list,
);
this.logger.debug('Applied fuzzy filter to product results', {
query,
productResults: searchRequest.data.catalog.category.productsWithMetaData.list,
fuzzResults,
});
return this.initProductBuilders(fuzzResults.slice(0, limit));
}
/**
* Initialize product builders from Wix search response data.
* Transforms Wix product objects into ProductBuilder instances, handling:
* - Basic product information (name, URL, supplier)
* - Pricing information with currency details
* - Product descriptions
* - Product IDs and SKUs
* - CAS number extraction from product text
* - Complex variant handling:
* - Merges product items with their price and quantity information
* - Processes product selections for variant options
* - Handles multiple variant attributes and their values
*
* @param results - Array of Wix product objects from search results
* @returns Array of ProductBuilder instances initialized with Wix product data
* @example
* ```typescript
* const results = await this.queryProducts("sodium chloride");
* if (results) {
* const builders = this.initProductBuilders(results);
* // Each builder contains parsed product data from Wix
* for (const builder of builders) {
* const product = await builder.build();
* console.log(product.title, product.price, product.variants);
* }
* }
* ```
* @source
*/
protected initProductBuilders(results: ProductObject[]): ProductBuilder<Product>[] {
return mapDefined(results, (product) => {
if (!product.price) {
this.logger.warn('Dropping product: no price', {
product,
});
return;
}
// Build selectionId -> parsed quantity map across ALL options.
const selectionIndex = new Map<number, ReturnType<typeof parseQuantity>>();
for (const option of product.options ?? []) {
for (const selection of option.selections ?? []) {
if (!isProductSelection(selection)) {
this.logger.warn('Invalid product selection', { selection });
continue;
}
const parsed = parseQuantity(selection.value);
if (parsed) {
selectionIndex.set(selection.id, parsed);
}
}
}
// Track which selection IDs are covered by explicit productItems.
const coveredSelectionIds = new Set<number>();
// Resolve explicit productItems first.
const productVariants = mapDefined(product.productItems, (item: ProductItem) => {
if (!isProductItem(item)) {
this.logger.warn('Invalid product item', { item });
return;
}
const parsedPrice = parsePrice(item.formattedPrice ?? product.formattedPrice);
if (!isParsedPrice(parsedPrice)) {
this.logger.warn('Dropping product item: unparseable formattedPrice', {
item,
product,
});
return;
}
const resolvedQuantities = mapDefined(item.optionsSelections ?? [], (selectionId) => {
coveredSelectionIds.add(selectionId);
return selectionIndex.get(selectionId);
});
const quantityInfo = resolvedQuantities.find((q) => q && 'uom' in q);
if (!quantityInfo) {
this.logger.warn('Dropping product item: no quantityInfo', {
item,
product,
});
return;
}
return {
...parsedPrice,
...quantityInfo,
id: item.id,
};
});
// Synthesize the implicit default variant from the parent price.
// Wix omits the default variant from productItems, so any selection
// that wasn't covered above represents the parent's price point.
const parentParsedPrice = parsePrice(product.formattedPrice);
if (isParsedPrice(parentParsedPrice)) {
for (const option of product.options ?? []) {
for (const selection of option.selections ?? []) {
if (coveredSelectionIds.has(selection.id)) continue;
const quantityInfo = selectionIndex.get(selection.id);
if (!quantityInfo || !('uom' in quantityInfo)) continue;
productVariants.push({
...parentParsedPrice,
...quantityInfo,
id: `${product.id}:${selection.id}`, // synthetic — no real Wix variant ID
});
coveredSelectionIds.add(selection.id);
}
}
}
// Sort variants by quantity within the same UOM, falling back to price.
productVariants.sort((a, b) => {
if (a.uom === b.uom) return a.quantity - b.quantity;
return a.price - b.price;
});
if (!isParsedPrice(parentParsedPrice)) {
this.logger.warn('Dropping product: unparseable formattedPrice', {
id: product.id,
name: product.name,
formattedPrice: product.formattedPrice,
product,
});
return;
}
// The parent's own quantity/uom should come from the variant whose
// price matches the parent price, not just the first variant.
let parentVariant =
productVariants.find((v) => v.price === parentParsedPrice.price) ?? productVariants[0];
// Wix omits options entirely for single-size products, leaving no variant to read the
// quantity off. Give the supplier a chance to recover it from elsewhere on the product
// (see getFallbackQuantity) before the listing is dropped.
if (!parentVariant) {
const fallbackQuantity = this.getFallbackQuantity(product);
if (fallbackQuantity && 'uom' in fallbackQuantity) {
parentVariant = {
...parentParsedPrice,
...fallbackQuantity,
id: String(product.id),
};
productVariants.push(parentVariant);
}
}
if (!parentVariant || !('quantity' in parentVariant) || !('uom' in parentVariant)) {
// No quantity/uom could be derived from Wix options or productItems. Suppliers
// that store the size only in the product name (e.g. "Adrafinil 100 grams") land
// here and are dropped, since this method never parses the name for quantity.
this.logger.warn('Dropping product: no quantity/uom from Wix options or productItems', {
id: product.id,
name: product.name,
optionCount: product.options?.length ?? 0,
productItemCount: product.productItems?.length ?? 0,
variantCount: productVariants.length,
product,
productVariants,
});
return;
}
const builder = new ProductBuilder<Product>(this.baseURL);
builder
.setBasicInfo(
product.name,
`${this.baseURL}/product-page/${product.urlPart}`,
this.supplierName,
)
.setMatchPercentage(this.matchScoreOf(product))
.setPricing(
parentParsedPrice.price,
parentParsedPrice.currencyCode,
parentParsedPrice.currencySymbol,
)
.setAvailability(product.isInStock)
.setQuantity(parentVariant.quantity, parentVariant.uom)
.setID(product.id)
.setSku(product.sku)
.setDescription(htmlToAscii(product.description))
.setVariants(productVariants.filter(isValidVariant))
.setCacheKey(this.getUniqueProductKey(product));
this.applyChemicalProperties(builder, product);
return builder;
});
}
/**
* Last-resort quantity lookup for a Wix product that carries no options and no productItems,
* called by {@link initProductBuilders} just before such a product would be dropped. The base
* implementation gives up; suppliers that encode the size somewhere else on the product (a SKU
* segment, the name, a spec line) override this to recover it.
* @param product - The raw Wix product object from the search response
* @returns The parsed quantity/uom, or nothing when none can be derived
* @example
* ```typescript
* this.getFallbackQuantity(product); // { quantity: 500, uom: "g" } | undefined
* ```
* @source
*/
protected getFallbackQuantity(product: ProductObject): ReturnType<typeof parseQuantity> {
this.logger.debug('No fallback quantity strategy for supplier', {
supplier: this.supplierName,
id: product.id,
});
return;
}
/**
* Populates a product builder with the chemical and media properties that live in a Wix
* product's free-form copy. Both Wix suppliers (FTF, BioFuran) scatter these across the
* `description` and `additionalInfo` accordions in loosely-structured HTML, so the values are
* parsed tolerantly via {@link parseChemicalSpecs} / {@link findPdfHref} and only applied when
* found. Image comes from the first photo in the media gallery; CAS is extracted from the
* product name and copy by {@link ProductBuilder.setCAS}.
*
* @param builder - The product builder to populate (mutated in place)
* @param product - The raw Wix product object from the search response
* @returns Nothing; the builder is mutated in place
* @example
* ```typescript
* const builder = new ProductBuilder<Product>(this.baseURL);
* this.applyChemicalProperties(builder, product);
* // builder now carries images, sdsUrl, purity, formula, moleweight, smiles and cas
* ```
* @source
*/
protected applyChemicalProperties(
builder: ProductBuilder<Product>,
product: ProductObject,
): void {
// Specs and SDS links live in the description and/or the additional-info accordions;
// parse both together so a supplier's layout (FTF vs BioFuran) doesn't matter.
const additionalInfoHtml = (product.additionalInfo ?? [])
.map((info) => info.description)
.join('\n');
const copy = `${product.description ?? ''}\n${additionalInfoHtml}`;
// The setters ignore undefined/invalid input, so the parser output can be passed straight in.
const photo = (product.media ?? []).find((item) => item.mediaType === 'PHOTO' && item.fullUrl);
const specs = parseChemicalSpecs(copy);
builder
.setImage(photo?.fullUrl, photo?.title ?? product.name)
.setSDSUrl(findPdfHref(copy))
.setPurity(specs.purity)
.setGrade(specs.grade)
.setFormula(specs.formula)
.setMoleweight(specs.molecularWeight)
.setSmiles(specs.smiles)
// CAS appears in the product name (BioFuran) and/or the copy (both); setCAS extracts it.
.setCAS(`${product.name}\n${copy}`);
}
/**
* Get the product data from the Wix API
*
* @param product - The product to get the data for
* @returns A promise that resolves to the product data or void if the product has no price
* @source
*/
protected async getProductData(
product: ProductBuilder<Product>,
): Promise<ProductBuilder<Product> | void> {
return this.getProductDataWithCache(product, async (builder) => builder);
}
/**
* Selects the title of a product from the search response
* @param data - Product object from search response
* @returns - The title of the product
* @source
*/
protected titleSelector(data: ProductObject): string {
return data.name;
}
}
|