All files / src/suppliers SupplierLoudwolf.ts

50.59% Statements 85/168
75% Branches 3/4
42.85% Functions 3/7
50.59% Lines 85/168

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 3451x 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 2x 2x 2x 2x 2x   2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x   2x         2x   2x 2x 2x 2x   2x 2x   1x                                             1x 1x 2x 2x 2x   2x 2x   2x 2x 2x   1x                                                   1x 1x 2x                                                                           2x   1x                                                   1x 1x                                                                                                       1x                                   1x 1x               1x  
import { parsePrice } from '@/helpers/currency';
import { parseQuantity } from '@/helpers/quantity';
import { mapDefined } from '@/helpers/utils';
import { ProductBuilder } from '@/utils/ProductBuilder';
import chunk from 'lodash/chunk';
import { SupplierBase } from './SupplierBase';
/**
 * Supplier implementation for Loudwolf chemical supplier.
 * Extends the base supplier class and provides Loudwolf-specific implementation
 * for product searching and data extraction.
 *
 * @typeParam S - The supplier-specific product type (Partial<Product>)
 * @typeParam T - The common Product type that all suppliers map to
 *
 * @example
 * ```typescript
 * const supplier = new SupplierLoudwolf("sodium chloride", 10, new AbortController());
 * for await (const product of supplier) {
 *   console.log("Found product:", product.title, product.price);
 * }
 * ```
 * @source
 */
export class SupplierLoudwolf extends SupplierBase<Partial<Product>, Product> implements ISupplier {
  // Display name of the supplier used for UI and logging
  public static readonly supplierName: string = 'Loudwolf';
 
  // Base URL for all API and web requests to Loudwolf
  public static readonly baseURL: string = 'https://www.loudwolf.com';
 
  // Shipping scope for Loudwolf
  public static readonly shipping: ShippingRange = 'worldwide';
 
  // The payment methods accepted by the supplier.
  public static readonly paymentMethods: PaymentMethod[] = ['mastercard', 'visa'];
 
  // The country code of the supplier.
  // This is used to determine the currency and other country-specific information.
  public static readonly country: CountryCode = 'US';
 
  // Cached search results from the last query execution
  protected queryResults: Array<Partial<Product>> = [];
 
  // Maximum number of HTTP requests allowed per search query
  // Used to prevent excessive requests to supplier
  protected httpRequestHardLimit: number = 50;
 
  // Counter for HTTP requests made during current query execution
  protected httpRequstCount: number = 0;
 
  // Number of requests to process in parallel when fetching product details
  protected maxConcurrentRequests: number = 5;
 
  /**
   * Sets up the supplier by setting the display to list.
   * @returns A promise that resolves when the setup is complete.
   * @source
   */
  protected async setup(): Promise<void> {}
 
  /**
   * Derives the unique product key from a Loudwolf search-result element: the
   * `product_id` query param in its product link, the same value
   * `initProductBuilders` passes to `.setID`. Falls back to the raw href when
   * no id is present, so the key is always a non-empty string.
   * @param data - The raw Loudwolf product-listing Element
   * @returns The product's product_id, or its href when no id is present
   * @example
   * ```typescript
   * this.getUniqueProductKey(element); // "1234"
   * ```
   * @source
   */
  protected getUniqueProductKey(data: Element): string {
    const href = data.querySelector('div.caption h4 a')?.getAttribute('href') ?? '';
    const url = new URL(href, this.baseURL);
    return url.searchParams.get('product_id') ?? this.href(href);
  }
 
  /**
   * Queries Loudwolf products based on a search string.
   * Makes a GET request to the Loudwolf search endpoint and parses the HTML response
   * to extract basic product information.
   *
   * @param query - The search term to query products for
   * @param limit - The maximum number of results to query for
   * @returns Promise resolving to an array of partial product objects or void if search fails
   *
   * @example
   * ```typescript
   * const supplier = new SupplierLoudwolf("acetone", 10, new AbortController());
   * const results = await supplier.queryProducts("acetone");
   * if (results) {
   *   console.log(`Found ${results.length} products`);
   *   console.log("First product:", results[0].title);
   * }
   * ```
   * @source
   */
  protected async queryProducts(
    query: string,
    limit: number = this.limit,
  ): Promise<ProductBuilder<Product>[] | void> {
    this.logger.log('Starting product search', { query, limit });
    localStorage.setItem('display', 'list');
 
    // Loudwolf's storefront only does keyword search and can't understand a CAS/formula/
    // SMILES identifier (all its supports* flags are off), so search by the resolved
    // chemical name; effectiveQuery is the raw query for a plain name.
    const searchResponse = await this.httpGetHtml({
      path: '/storefront/index.php',
      params: {
        search: encodeURIComponent(this.effectiveQuery),
        route: 'product/search',
        limit: 100,
      },
    });
 
    if (!searchResponse) {
      this.logger.error('No search response', { query, limit });
      return;
    }
 
    this.logger.debug('Received search response', { searchResponse });
 
    const $fuzzResults = this.fuzzHtmlResponse(query, searchResponse);
    this.logger.debug('Applied fuzzy filter to search results', {
      fuzzResults: Array.from($fuzzResults),
    });
 
    return this.initProductBuilders($fuzzResults.slice(0, limit));
  }
 
  /**
   * Parses HTML response and performs fuzzy filtering on product elements.
   * Creates a DOM from the HTML response, selects product elements, and applies
   * fuzzy filtering to find matches for the search query.
   *
   * @param query - The search term to filter products by
   * @param response - The HTML response string containing product listings
   * @returns Array of DOM Elements that match the fuzzy search criteria
   *
   * @example
   * ```typescript
   * const html = await this.httpGetHtml({ path: "/search", params: { q: "acetone" } });
   * if (html) {
   *   const matchingElements = this.fuzzHtmlResponse("acetone", html);
   *   console.log(`Found ${matchingElements.length} matching products`);
   *   // Elements can be used to extract product details
   *   for (const element of matchingElements) {
   *     const title = element.querySelector("h4 a")?.textContent;
   *     console.log("Product:", title);
   *   }
   * }
   * ```
   * @source
   */
  protected fuzzHtmlResponse(query: string, response: string): Element[] {
    // Create a new DOM to do the travesing/parsing
    const parser = new DOMParser();
    const parsedHTML = parser.parseFromString(response, 'text/html');
 
    // Select all products by a known selector path
    const products = parsedHTML.querySelectorAll('div.product-layout.product-list');
 
    // Do the fuzzy filtering using the element found when using this.titleSelector()
    return this.fuzzyFilterAst<Element>(Array.from(products));
  }
 
  /**
   * Initialize product builders from Loudwolf HTML search response data.
   * Transforms HTML product listings into ProductBuilder instances, handling:
   * - Basic product information (title, URL, supplier)
   * - Pricing information with currency details
   * - Product descriptions
   * - Product IDs and SKUs
   * - HTML parsing of product listings
   * - Price extraction from formatted strings
   * - URL and ID extraction from product links
   *
   * @param $elements - HTML string containing product listings
   * @returns Array of ProductBuilder instances initialized with 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 HTML
   *   for (const builder of builders) {
   *     const product = await builder.build();
   *     console.log(product.title, product.price, product.id);
   *   }
   * }
   * ```
   * @source
   */
  protected initProductBuilders($elements: Element[]): ProductBuilder<Product>[] {
    return mapDefined($elements, (element: Element) => {
      const builder = new ProductBuilder<Product>(this.baseURL);
 
      const priceElem = element.querySelector('div.caption > p.price');
      const price = parsePrice(priceElem?.textContent?.trim() || '');
 
      if (price === undefined) {
        this.logger.error('No price for product', { element });
        return;
      }
 
      const href = element.querySelector('div.caption h4 a')?.getAttribute('href');
 
      if (!href) {
        this.logger.error('No URL for product', { element });
        return;
      }
 
      const url = new URL(href, this.baseURL);
 
      const id = url.searchParams.get('product_id');
 
      if (id === null) {
        this.logger.error('No ID for product', { element, url });
        return;
      }
      const title = element.querySelector('div.caption h4 a')?.textContent?.trim() || '';
 
      return builder
        .setBasicInfo(title, String(url), this.supplierName)
        .setMatchPercentage(this.matchScoreOf(element))
        .setDescription(
          element.querySelector('div.caption > p:nth-child(2)')?.textContent?.trim() || '',
        )
        .setID(id)
        .setCacheKey(this.getUniqueProductKey(element))
        .setPricing(price.price, price.currencyCode, price.currencySymbol);
    });
  }
 
  /**
   * Transforms a partial product item into a complete Product object.
   * Fetches additional product details from the product page, extracts quantity, CAS number,
   * and other specifications, then builds a standardized Product object.
   *
   * @param product - Partial product object to transform
   * @returns Promise resolving to a complete Product object or void if transformation fails
   *
   * @example
   * ```typescript
   * const partialProduct = {
   *   title: "Sodium Chloride",
   *   url: "/product/123",
   *   price: 19.99
   * };
   * const fullProduct = await supplier.getProductData(partialProduct);
   * if (fullProduct) {
   *   console.log("Complete product:", {
   *     title: fullProduct.title,
   *     cas: fullProduct.cas,
   *     quantity: fullProduct.quantity,
   *     uom: fullProduct.uom
   *   });
   * }
   * ```
   * @source
   */
  protected async getProductData(
    product: ProductBuilder<Product>,
  ): Promise<ProductBuilder<Product> | void> {
    return this.getProductDataWithCache(product, async (builder) => {
      this.logger.debug('Querying data for partialproduct', { builder });
 
      if (typeof builder === 'undefined') {
        this.logger.error('No products to get data for');
        return;
      }
 
      const productResponse = await this.httpGetHtml({
        path: builder.get('url'),
      });
 
      if (!productResponse) {
        this.logger.warn('No product response', { builder });
        return;
      }
 
      this.logger.debug('Received product response', { productResponse });
 
      const parser = new DOMParser();
      const parsedHTML = parser.parseFromString(productResponse, 'text/html');
      const domContent = parsedHTML.querySelector('#content');
      const dataGrid = Array.from(
        domContent?.querySelectorAll('#content .tab-content .MsoTableGrid') || [],
      )
        .find((element) => element.textContent?.trim().match(/CAS/i))
        ?.querySelectorAll('p');
 
      const dataRows = Array.from(dataGrid || []).map((n) => n.textContent?.trim() ?? '');
 
      // Apply each spec row straight to the builder via its validating setter.
      // (setCAS extracts the number from the cell text itself.)
      for (const [key, value] of chunk(dataRows, 2)) {
        if (key.match(/CAS/i)) {
          builder.setCAS(value);
        } else if (key.match(/TOTAL [A-Z]+ OF PRODUCT/i)) {
          const qty = parseQuantity(value);
          if (qty) {
            builder.setQuantity(qty);
          }
        } else if (key.match(/GRADE/i)) {
          builder.setGrade(value);
        }
      }
 
      return builder;
    });
  }
 
  /**
   * Extracts the product title from a DOM Element.
   * Searches for the product title within the product listing element using
   * a specific CSS selector path. Returns an empty string if no title is found.
   *
   * @param data - The DOM Element containing the product listing
   * @returns The product title as a string, or empty string if not found
   *
   * @example
   * ```typescript
   * const element = document.querySelector(".product-layout");
   * if (element) {
   *   const title = this.titleSelector(element);
   *   console.log("Product title:", title);
   *   // Output: "Sodium Chloride, ACS Grade, 500g"
   * }
   * ```
   * @source
   */
  protected titleSelector(data: Element): string {
    const title = data.querySelector('div.caption h4 a');
    if (title === null) {
      this.logger.error('No title for product', { data });
      return '';
    }
    return title.textContent?.trim() || '';
  }
}