← back to All Designerwallcoverings
scripts/adapters/designtex.js
162 lines
// adapters/designtex.js — Designtex PUBLIC live-stock adapter (LOGIN-FREE, schema-public style).
//
// Designtex (designtex.com) is a Steelcase-owned commercial/contract textile + wallcovering house on
// a Magento storefront. Prior fleet audit tagged it schema-public-eligible: its ANONYMOUS product
// page emits schema.org Product/Offer availability + Magento stock markup, so live stock is readable
// WITHOUT a trade login. The trade portal (customer/account/create) gates CHECKOUT/pricing, not the
// public availability signal — so NO credential vault is needed for this adapter (unlike thibaut.js).
//
// This is functionally the same read as scripts/adapters/schema-public.js — Designtex is already in
// build-coverage.js's SCHEMA_PUBLIC set and routes there today. The ONLY reason it dims is the
// resolvability gate: designtex_catalog's product_url values don't align with the grid's display SKUs
// (0% resolvable in the 2026-07-07 audit). This adapter fixes that IN THE ADAPTER by, when the direct
// catalog join misses, LEARNING the Designtex URL template from a real catalog row and substituting
// the grid SKU's mfr_sku — the SKU→product_url logic mined from the Designtex scraper (which keys
// designtex_catalog by mfr_sku like "3833-301" and stores the Magento product_url per row).
//
// PUBLIC-SAFE: availability only. Designtex publishes a to-the-trade price, which is NOT our retail,
// so `price` is ALWAYS null — never emitted (same contract as schema-public / shopify-public).
const { resolveCatalogUrl, NAV_RE, releaseSession } = require('./resolve');
const CATALOG = 'designtex_catalog';
// Designtex PDP host — env-overridable so a template correction never needs a code change.
const PDP_HOST = process.env.DESIGNTEX_PDP_HOST || 'https://www.designtex.com';
// SKU→product_url resolver with a Designtex-specific fallback.
// 1. Direct: resolve.js bridges the grid SKU → dw_sku/mfr_sku → designtex_catalog.product_url.
// 2. Fallback (the 0%-resolvability fix): recover the grid row's mfr_sku from shopify_products,
// LEARN the URL template from any real designtex_catalog row that carries both mfr_sku +
// product_url, and substitute this SKU's mfr_sku into that template. This resolves SKUs whose
// own catalog row has a stale/empty product_url, using the vendor's own URL shape — no guessing.
async function resolveDesigntexUrl(pool, sku, log) {
const direct = await resolveCatalogUrl(pool, CATALOG, sku);
if (!direct.error && direct.product_url) return { url: direct.product_url, how: 'catalog' };
// Recover the display row's mfr_sku (the Designtex pattern-color code, e.g. "3833-301").
let mfr = null;
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) mfr = String(rows[0].mfr_sku).trim();
} catch { /* shopify_products lookup optional */ }
if (!mfr) return { error: direct.error || 'no mfr_sku for this Designtex SKU' };
// Learn the URL template from a real catalog row (any row whose product_url embeds its mfr_sku).
try {
const { rows } = await pool.query(
`SELECT mfr_sku, product_url FROM "${CATALOG}"
WHERE product_url IS NOT NULL AND product_url <> '' AND mfr_sku IS NOT NULL AND mfr_sku <> ''
LIMIT 200`);
for (const r of rows) {
const code = String(r.mfr_sku).trim();
if (code && r.product_url.includes(code)) {
// Substitute this SKU's code into the learned template.
const url = r.product_url.split(code).join(mfr);
if (log) log('synthesized designtex url via learned template');
return { url, how: 'synthesized' };
}
}
} catch { /* catalog sample optional */ }
// Last resort — the canonical Designtex Magento product path (env-overridable host).
return { url: `${PDP_HOST}/products/${encodeURIComponent(mfr)}`, how: 'canonical-path' };
}
const readAvail = (s) => {
if (/OutOfStock|SoldOut|Discontinued|BackOrder/i.test(s)) return false;
if (/InStock|LimitedAvailability|PreOrder|OnlineOnly|InStoreOnly/i.test(s)) return true;
return null;
};
async function run({ sku, pool, newSession, log }) {
let sessionOpened = false, browser = null, session = null, bb = null;
try {
// 1. Resolve the grid SKU → its Designtex PDP (catalog join, else learned-template synthesis).
const r = await resolveDesigntexUrl(pool, sku, log);
if (r.error) return { available: false, reason: r.error };
const url = r.url;
// 2. Metered Browserbase session — NO login (public availability signal).
const sess = await newSession();
({ browser, session, bb } = sess); const page = sess.page;
sessionOpened = true;
log('bb session', session && session.id, '→', url, `(${r.how})`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(5000); // let Magento's client-rendered stock state settle
// 3. schema.org JSON-LD — offers.availability is authoritative when present.
const ldRaw = await page.$$eval('script[type="application/ld+json"]', (els) => els.map((e) => e.textContent).join('\n')).catch(() => '');
let in_stock = null, ldAvail = null;
if (ldRaw) {
const m = ldRaw.match(/"availability"\s*:\s*"([^"]+)"/i);
if (m) { ldAvail = m[1]; in_stock = readAvail(ldAvail); }
}
// 4. product-scoped DOM / Magento stock markup (link[itemprop=availability] href, .stock_status …).
let domTxt = '';
if (in_stock === null) {
domTxt = await page.$$eval(
'[itemprop=availability], link[itemprop=availability], meta[itemprop=availability], .product [class*=stock], .product-info [class*=stock], .stock_status, [class*=stock_status], [class*=stock-status], [class*=availab], .stock',
(els) => els.map((e) => (e.getAttribute('href') || e.getAttribute('content') || e.getAttribute('title') || e.textContent || '').trim()).filter(Boolean).join(' | ')
).catch(() => '');
const t = String(domTxt).replace(NAV_RE, '');
const tok = readAvail(t);
if (tok !== null) in_stock = tok;
else if (/out\s*of\s*stock|sold\s*out|unavailable|no longer available/i.test(t)) in_stock = false;
else if (/in\s*stock|in-?stock|available/i.test(t)) in_stock = true;
}
// 5. structured whole-document availability vote (schema.org tokens / JSON availability keys).
let voteLabel = null;
if (in_stock === null) {
const vote = await page.evaluate(() => {
const html = document.documentElement.innerHTML || '';
const n = (re) => (html.match(re) || []).length;
const inTok = n(/schema\.org\/(?:InStock|LimitedAvailability|PreOrder)/gi)
+ n(/[Aa]vailability\\?"?\s*:\s*\\?"?(?:InStock|LimitedAvailability|PreOrder)/g);
const outTok = n(/schema\.org\/(?:OutOfStock|SoldOut|Discontinued|BackOrder)/gi)
+ n(/[Aa]vailability\\?"?\s*:\s*\\?"?(?:OutOfStock|SoldOut|Discontinued|BackOrder)/g);
return { inTok, outTok };
}).catch(() => ({ inTok: 0, outTok: 0 }));
if (vote.inTok || vote.outTok) {
if (vote.outTok > 0 && vote.inTok === 0) in_stock = false;
else if (vote.inTok > 0) in_stock = true;
voteLabel = `${in_stock ? 'in stock' : 'out of stock'} (${vote.inTok} in / ${vote.outTok} out signals)`;
}
}
// 6. add-to-cart / order-sample CTA state (last resort — enabled CTA ⇒ purchasable).
if (in_stock === null) {
const cta = await page.$$eval(
'button[name=add], button[type=submit], [class*=add-to], [class*=addto], [class*=tocart], a[href*=cart]',
(els) => els.filter((e) => /add to (cart|basket|bag|sample)|add sample|order (a )?sample|request (a )?sample/i.test(
[e.textContent, e.getAttribute('aria-label'), e.getAttribute('title')].filter(Boolean).join(' ')))
.map((e) => (e.disabled || /disabled/i.test(e.className) ? 'disabled' : 'enabled')).join(',')
).catch(() => '');
if (/enabled/.test(cta)) in_stock = true;
else if (cta && !/enabled/.test(cta)) in_stock = false;
}
const label = voteLabel || (in_stock === true ? 'in stock' : in_stock === false ? 'out of stock'
: (ldAvail || (domTxt ? domTxt.slice(0, 60) : 'unknown')));
return {
available: true, session_opened: true, vendor: 'Designtex',
in_stock, lead_time: null,
price: null, // to-the-trade price is NOT our retail → never emitted
raw_stock_label: label,
as_of: new Date().toISOString(),
source: 'designtex-live',
};
} catch (e) {
return { available: false, session_opened: sessionOpened, reason: 'live scrape error: ' + (e.message || e) };
} finally {
await releaseSession({ browser, bb, session });
}
}
module.exports = { run };