← back to All Designerwallcoverings

scripts/adapters/koroseal.js

236 lines

// adapters/koroseal.js — Koroseal live-stock/availability adapter (PUBLIC catalog + VAULT CREDS).
//
// WHAT KOROSEAL ACTUALLY IS (mined from the DW scraper engine —
//   Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/lib/scrapers/koroseal-spec-scraper.ts
//   + koroseal-general-scraper.ts, and colouranddesign-scraper-manager which "buys through Koroseal"):
//   koroseal.com is a COMMERCIAL/CONTRACT SPEC + SAMPLE catalog. The PDP exposes specs only —
//   Material, Backing, Fire Rating, Roll Width, Pattern Match, spec PDFs. There is NO cart, NO public
//   price, and NO stock/inventory field anywhere on the page (the DW koroseal microsite is flagged
//   `samples_only:true`). So Koroseal is NEITHER a live-price NOR a live-stock vendor in the Thibaut
//   sense. The only honest "live" signal the public site offers is PRODUCT LIVENESS: per the DW rule
//   "if a vendor product URL 301/404s or redirects to the category page → the product is
//   discontinued." So this adapter reports available (PDP resolves to a real colorway page) vs
//   discontinued (redirect-to-category / 404), and NEVER emits a price.
//
// CREDS: resolved by scripts/adapters/creds.js from the dw-price-stock vault (prefix KOROSEAL),
//   referenced IN PLACE — never copied here, never logged. koroseal.com scrapes anonymously, so login
//   is best-effort/defensive: attempted only if the vault supplies a KOROSEAL_LOGIN_URL. Availability
//   is read from the public PDP regardless of login.
//
// PUBLIC-SAFE: emits availability only. `price` is ALWAYS null — never emitted.
const { resolveCatalogUrl, releaseSession } = require('./resolve');
const creds = require('./creds');

const BASE = 'https://koroseal.com';

// ── SKU → product_url RECONSTRUCTION (the fix for Koroseal's 0% resolvability) ───────────────────
// Ported from koroseal-spec-scraper.ts. Koroseal grid SKUs are mfr codes (e.g. "C921-10", "AL21-15",
// "TAK-AA04-02", "B22-07"), but koroseal_catalog.product_url is a COLLECTION/COLOR PATH — the two do
// not join on a bare SKU string, which is exactly why only 9/2351 rows resolve today. We rebuild the
// URL from the SKU: prefix → collection slug, then match the exact colorway off the live collection
// page's thumb strip (.thumbscontain a.thumbprev; span.tptext = SKU, href = the real product_url).

// Regular wallcovering prefix → collection slug ( /products/wallcoverings/<collection>/<color> )
const SKU_TO_COLLECTION = {
  C921: 'chateau', C923: 'chateau', RA21: 'arco', SPR1: 'spire',
  AL21: 'adalyn-weave', AS21: 'astra',
  'DK-WT': 'digital', 'DK-ST': 'digital', 'DK-': 'digital',
  MB0: 'mandy-budan', 'SRD-': 'sarah-rowland',
};

// TAK Specialty prefix → collection ( /products/specialty/koroseal-specialty/<collection>/<sku> )
const TAK_COLLECTIONS = {
  'TAK-AA01': 'subtle-leaf', 'TAK-AA02': 'first-impression', 'TAK-AA04': 'opulent-leaf',
  'TAK-AA05': 'steeped-leaf', 'TAK-AA08': 'languid-leaf', 'TAK-AA09': 'whispering-patina',
  'TAK-BA01': 'ruche', 'TAK-BB01': 'galaxy-wash', 'TAK-BB02': 'galaxy-wash-(1)',
  'TAK-CA01': 'color-wash-cork', 'TAK-CA02': 'molten-cork', 'TAK-CA03': 'molten-cork-stria',
  'TAK-CA04': 'aged-molten-cork', 'TAK-CA05': 'aura-cork', 'TAK-CA06': 'native-cork',
  'TAK-CA07': 'quercus', 'TAK-CB01': 'beveled-native-cork', 'TAK-DA01': 'afterglow',
  'TAK-EA01': 'cyrus-weave', 'TAK-EA02': 'kilika', 'TAK-EA03': 'silk-noil',
  'TAK-EC01': 'portmanteau', 'TAK-ED01': 'dapper-weave', 'TAK-EE01': 'beveled-raffia',
  'TAK-EE03': 'native-raffia', 'TAK-EE04': 'native-sisal', 'TAK-EF01': 'pulp-and-plaid',
  'TAK-EG01': 'whitecap-woven', 'TAK-EH01': 'floating-gossamer', 'TAK-FA01': 'bark-rind',
  'TAK-GA01': 'woven-wood',
};

const isTak = (s) => s.toUpperCase().startsWith('TAK-');

function takCollection(sku) {
  const u = sku.toUpperCase();
  for (const [pfx, col] of Object.entries(TAK_COLLECTIONS)) if (u.startsWith(pfx)) return col;
  return null;
}
function regularCollection(sku) {
  const u = sku.toUpperCase();
  for (const [pfx, col] of Object.entries(SKU_TO_COLLECTION)) if (u.startsWith(pfx)) return col;
  return null;
}

// Candidate collection/search pages to mine a SKU's exact product_url from — most-specific first.
function korosealCollectionPages(sku) {
  const u = sku.toUpperCase();
  const pages = [];
  if (isTak(u)) {
    const col = takCollection(u);
    if (col) {
      // TAK PDP path already includes the sku — try it directly, then the collection page.
      pages.push(`${BASE}/products/specialty/koroseal-specialty/${col}/${u.toLowerCase()}`);
      pages.push(`${BASE}/products/specialty/koroseal-specialty/${col}`);
    }
  } else {
    const col = regularCollection(u);
    if (col) pages.push(`${BASE}/products/wallcoverings/${col}`);
  }
  // Prefix search fallback (per DW koroseal rule): SG13-02 → search "SG13". Recovers collections we
  // don't have a static prefix map for, so the reconstruction degrades gracefully instead of failing.
  const pfx = (u.match(/^[A-Z]+[0-9]*/) || [u])[0];
  pages.push(`${BASE}/search-results?searchtext=${encodeURIComponent(pfx)}`);
  return pages;
}

// Read the collection/search page's thumb strip and return the href whose SKU === the grid SKU.
async function matchThumbUrl(page, sku) {
  const target = String(sku).toUpperCase().trim();
  return page.$$eval('.thumbscontain a.thumbprev, a.thumbprev, a[href*="/products/"]', (els) =>
    els.map((el) => {
      const skuEl = el.querySelector('span.tptext');
      const skuText = (skuEl && skuEl.childNodes[0] ? skuEl.childNodes[0].textContent : (skuEl ? skuEl.textContent : '')) || '';
      return { sku: skuText.trim(), href: el.href || el.getAttribute('href') || '' };
    }).filter((t) => t.sku && t.href)
  ).then((thumbs) => {
    const hit = thumbs.find((t) => t.sku.toUpperCase() === target)
      || thumbs.find((t) => t.sku.toUpperCase().startsWith(target) || target.startsWith(t.sku.toUpperCase()));
    return hit ? hit.href : null;
  }).catch(() => null);
}

// Recover the SKU's Koroseal mfr code from shopify_products. The grid/server passes the DISPLAY sku
// (usually the dw_sku, e.g. "DWK-29052"), but the URL reconstruction below keys on the mfr code
// (e.g. "TAK-EA02-01" / "SE81-21") — the collection prefix maps + thumb-strip SKU text are mfr codes,
// not DWK dw_skus. So bridge dw_sku → mfr_sku before reconstructing; fall back to the raw sku if the
// grid row itself already carries the mfr code.
async function recoverMfrSku(pool, sku) {
  try {
    const { rows } = await pool.query(
      `SELECT mfr_sku FROM shopify_products
        WHERE upper(coalesce(dw_sku,''))=upper($1) OR upper(coalesce(variant_sku,''))=upper($1)
           OR upper(coalesce(sku,''))=upper($1) LIMIT 1`, [sku]);
    if (rows[0] && rows[0].mfr_sku) return String(rows[0].mfr_sku).trim();
  } catch { /* optional */ }
  return null;
}

// Resolve the grid SKU → its koroseal.com PDP. Catalog join first (works once koroseal_catalog is
// backfilled); on miss, live-reconstruct via the collection/thumb walk described above — keyed on the
// recovered MFR code (not the DWK dw_sku the server passes).
async function resolveKorosealUrl(page, pool, catalogTable, sku) {
  const r = await resolveCatalogUrl(pool, catalogTable || 'koroseal_catalog', sku).catch(() => ({ error: 'resolve failed' }));
  // Accept ONLY a REAL catalog product_url — never resolve.js's shape-general SYNTHESIZED template
  // guess. koroseal_catalog's populated URLs are pattern-slug digital-lab paths, so substituting a
  // spec/wallcovering mfr code into that template yields a bogus URL that 404s (reads as a false
  // "discontinued"). Koroseal has its own correct mfr-code reconstruction below, so prefer that.
  if (r && r.product_url && !r.synthesized) return { url: r.product_url, via: 'catalog' };

  // The reconstruction key is the mfr code — recover it from shopify_products, else use the raw sku.
  const mfr = (await recoverMfrSku(pool, sku)) || sku;

  // Fallback: reconstruct from the mfr code by mining the live collection/search page's thumbs.
  for (const cp of korosealCollectionPages(mfr)) {
    try {
      await page.goto(cp, { waitUntil: 'domcontentloaded', timeout: 45000 });
      await page.waitForTimeout(2500);
      // If we navigated straight to a TAK PDP that exists, use it as-is.
      if (/\/products\/specialty\/koroseal-specialty\/[^/]+\/[^/]+/.test(page.url()) && !/page-not-found|404/i.test(await page.content().catch(() => ''))) {
        return { url: page.url(), via: 'reconstruct-direct' };
      }
      const href = await matchThumbUrl(page, mfr);
      if (href) return { url: href.startsWith('http') ? href : `${BASE}${href.startsWith('/') ? '' : '/'}${href}`, via: 'reconstruct-thumb' };
    } catch { /* try next candidate */ }
  }
  return { error: (r && r.error) || 'SKU not resolvable to a Koroseal product_url' };
}

async function run({ sku, pool, newSession, catalogTable, log }) {
  let sessionOpened = false, browser = null, session = null, bb = null;
  try {
    // 1. Vault creds (prefix KOROSEAL) — referenced in place; registry fallback. NEVER logged.
    //    Login is optional for Koroseal (public catalog); we resolve creds so a future gated portal
    //    still works, and so build-coverage's vaultHas('KOROSEAL') gate stays truthful.
    const c = await creds.resolve({ prefix: 'KOROSEAL', pool, vendorLike: 'koroseal' });
    const loginUrl = c && c.loginUrl ? c.loginUrl : null;

    // 2. Metered Browserbase session.
    const sess = await newSession();
    ({ browser, session, bb } = sess); const page = sess.page;
    sessionOpened = true;
    log('bb session', session && session.id);

    // 3. Best-effort trade login (only if the vault gave us a login URL + a password).
    let loggedIn = false;
    if (loginUrl && c && c.pass) {
      try {
        await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
        await page.waitForTimeout(2000);
        const emailEl = await page.$('#email, input[name=email], input[type=email], #username, input[name=username]').catch(() => null);
        if (emailEl) {
          await page.fill('#email, input[name=email], input[type=email], #username, input[name=username]', c.user || '').catch(() => {});
          await page.fill('#password, input[name=password], input[type=password]', c.pass).catch(() => {});
          await page.waitForTimeout(300);
          await page.click('button[type=submit], form button:has-text("Sign"), form button:has-text("Log")')
            .catch(async () => { await page.press('#password, input[type=password]', 'Enter').catch(() => {}); });
          for (let i = 0; i < 12; i++) { await page.waitForTimeout(1000); if (!/login|sign-?in/i.test(page.url())) break; }
          loggedIn = !/login|sign-?in/i.test(page.url());
        }
      } catch { /* public catalog works without login */ }
    }

    // 4. Resolve the grid SKU → its koroseal.com PDP (catalog join, else live reconstruction).
    const res = await resolveKorosealUrl(page, pool, catalogTable, sku);
    if (res.error) return { available: false, session_opened: true, reason: res.error };
    const url = res.url;

    // 5. Load the PDP and read AVAILABILITY (liveness). No price/stock field exists to read.
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
    await page.waitForTimeout(4000);
    const finalUrl = page.url();
    const html = (await page.content().catch(() => '')) || '';
    const bodyText = await page.evaluate(() => document.body ? document.body.innerText : '').catch(() => '');

    // Discontinued signals: redirect off the colorway PDP to a bare category/list, or a 404/not-found.
    const notFound = /page[-\s]?not[-\s]?found|404|no results|not found|we could(?:n'?t| not) find/i.test(html);
    const redirectedToCategory =
      /\/products\/wallcoverings\/?$/.test(finalUrl) ||
      /\/products\/?$/.test(finalUrl) ||
      /\/search-results/i.test(finalUrl) ||
      /-list\/?$/.test(finalUrl);
    // Live-product signals: the spec block Koroseal renders on a real colorway page.
    const hasSpecs = /Material\s*:|Fire Rating\s*:|Roll Width|Pattern Match\s*:|Backing\s*:/i.test(bodyText)
      || /\bthumbprev\b|\btppic\b|\btptext\b/.test(html);

    let in_stock = null, availability = 'unknown (availability parse pending verification)';
    if (notFound || redirectedToCategory) {
      in_stock = false; availability = 'discontinued (PDP no longer resolves / redirected to category)';
    } else if (hasSpecs) {
      in_stock = true; availability = 'available (current in Koroseal catalog — spec/sample only, no live inventory count)';
    }

    return {
      available: true, session_opened: true, vendor: 'Koroseal',
      in_stock,
      availability,
      lead_time: null,
      price: null, // Koroseal PDP carries NO public price — never emitted
      raw_stock_label: availability,
      as_of: new Date().toISOString(),
      source: loggedIn ? 'koroseal-live' : 'koroseal-live',
      resolved_via: res.via || null,
    };
  } catch (e) {
    return { available: false, session_opened: sessionOpened, reason: 'live scrape error: ' + (e.message || e) };
  } finally {
    await releaseSession({ browser, bb, session });
  }
}

module.exports = { run };