← back to All Designerwallcoverings
scripts/adapters/fabricut.js
105 lines
// adapters/fabricut.js — Fabricut trade-portal live-stock adapter (PORTAL + VAULT CREDS).
//
// Fabricut (fabricut.com) is a trade portal that also fronts Stroheim, S. Harris, Vervain, etc. under
// fabricut.com/<brand>. The DW Fabricut scraper (fabricut-scraper-manager skill + the ImportNewSku
// `fabricut-*` scrapers) scrapes the storefront ANONYMOUSLY via Browserbase/Puppeteer — it mines
// pattern/color/sku/image from the PDP URL (`/<brand>/wallcovering/<sku>/<pattern>/<color>`) and does
// NOT read any stock field; trade price is login-gated. The skill documents a trade login
// (`https://fabricut.com/sign-in`) whose creds live ONLY in the dw-price-stock vault (prefix
// FABRICUT). This adapter reuses that: it resolves creds by path via creds.js (never copied here),
// logs in for ONE session, resolves the grid SKU → its Fabricut PDP via resolve.js, and reads
// whatever availability the logged-in PDP exposes.
//
// PRICE-ORIENTED PORTAL CAVEAT: the mined scraper exposes NO live-stock field — Fabricut's value
// behind the login is trade PRICE, not a reliable inventory count. So this adapter is best-effort on
// availability (returns in_stock:null / label 'unknown' when the PDP shows no stock state) and, per
// the PUBLIC-SAFE contract, ALWAYS emits price:null — the logged-in price is trade/net and never
// leaves here.
const { resolveCatalogUrl, releaseSession } = require('./resolve');
const creds = require('./creds');
const DEFAULT_LOGIN = 'https://fabricut.com/sign-in';
async function run({ sku, pool, newSession, catalogTable, log }) {
let sessionOpened = false, browser = null, session = null, bb = null;
try {
// 1. Resolve the grid SKU → its Fabricut PDP (bridged via shopify_products → fabricut_catalog).
const r = await resolveCatalogUrl(pool, catalogTable || 'fabricut_catalog', sku);
if (r.error) return { available: false, reason: r.error };
const url = r.product_url;
// 2. Vault creds (prefix FABRICUT) — referenced in place; registry fallback. NEVER logged.
const c = await creds.resolve({ prefix: 'FABRICUT', pool, vendorLike: 'fabricut' });
if (!c || !c.pass) return { available: false, reason: 'fabricut trade credentials not available (vault)' };
const loginUrl = c.loginUrl || DEFAULT_LOGIN;
// 3. Metered Browserbase session + Fabricut trade login (email/username + password).
const sess = await newSession();
({ browser, session, bb } = sess); const page = sess.page;
sessionOpened = true;
log('bb session', session && session.id);
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(2500);
// dismiss any cookie/consent gate that could overlay the form
for (const sel of ['button:has-text("Accept")', 'button:has-text("ACCEPT")', '#onetrust-accept-btn-handler']) {
const el = await page.$(sel).catch(() => null);
if (el) { await el.click().catch(() => {}); await page.waitForTimeout(600); break; }
}
const userEl = await page.$('#email, input[name=email], input[type=email], input[name=username], #username').catch(() => null);
if (userEl) {
await page.fill('#email, input[name=email], input[type=email], input[name=username], #username', c.user || '').catch(() => {});
await page.fill('#password, input[name=password], input[type=password]', c.pass).catch(() => {});
await page.waitForTimeout(400);
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(() => {}); });
// wait for the login page to fall away (redirect off /sign-in)
for (let i = 0; i < 18; i++) { await page.waitForTimeout(1000); if (!/sign-?in|log-?in|\/login/i.test(page.url())) break; }
}
const loggedIn = !/sign-?in|log-?in|\/login/i.test(page.url());
// 4. PDP → wait for the trade-gated content to hydrate, then read whatever availability shows.
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(7000);
const html = await page.content().catch(() => '');
// Any embedded stock flags Fabricut might expose in inline JSON (best-effort; not observed in the
// anonymous scraper, so these commonly do not match — that's fine, they only ADD a signal).
const availForSale = /"availableForSale"\s*:\s*true/i.test(html) ? true
: /"availableForSale"\s*:\s*false/i.test(html) ? false : null;
const stockM = html.match(/"(?:stock|inventory|quantity)(?:_?on_?hand)?"\s*:\s*(\d+)/i);
const stockNum = stockM ? parseInt(stockM[1], 10) : null;
const backorder = /back\s*-?\s*order/i.test(html);
// product-scoped visible status text (avoid global nav chrome)
const availTxt = await page.$$eval(
'[itemprop=availability], main [class*=stock], main [class*=availab], main [class*=inventory], button:has-text("Add"), button:has-text("Sample")',
(els) => els.map((e) => (e.getAttribute('content') || e.getAttribute('aria-label') || e.textContent || '').trim()).filter(Boolean).join(' | ')
).catch(() => '');
let in_stock = null;
if (availForSale === false || /out\s*of\s*stock|sold\s*out|discontinued|unavailable/i.test(availTxt)) in_stock = false;
else if (availForSale === true || stockNum > 0 || /in\s*stock|in-?stock|add to (cart|order|sample)/i.test(availTxt)) in_stock = true;
else if (stockNum === 0) in_stock = false;
const label = in_stock === true ? (backorder ? 'in stock (some backorder)' : 'in stock')
: in_stock === false ? 'out of stock'
: (availTxt ? availTxt.slice(0, 60) : 'unknown (Fabricut portal is price-oriented; no live stock field exposed)');
return {
available: true, session_opened: true, vendor: 'Fabricut',
in_stock, lead_time: backorder ? 'backorder possible' : null,
price: null, // Fabricut logged-in price is trade/net → never emitted
raw_stock_label: label,
as_of: new Date().toISOString(),
source: loggedIn ? 'fabricut-live' : 'fabricut-live (anon fallback)',
};
} catch (e) {
return { available: false, session_opened: sessionOpened, reason: 'live scrape error: ' + (e.message || e) };
} finally {
await releaseSession({ browser, bb, session });
}
}
module.exports = { run };