← 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 };