← back to All Designerwallcoverings
scripts/adapters/schumacher.js
111 lines
// adapters/schumacher.js — Schumacher (F. Schumacher) trade-portal live-stock adapter (PORTAL + VAULT CREDS).
//
// F. Schumacher (schumacher.com / fschumacher.com) is a login-gated trade line. The PUBLIC product
// pages are quote-only — the DW Schumacher catalog carries no price and rolls read literally
// "** Please Call for Pricing on F. Schumacher Products **". Anonymous PDPs therefore expose NO
// live stock and NO retail; both live behind the trade login. Creds are resolved by
// scripts/adapters/creds.js from the dw-price-stock vault (prefix SCHUMACHER) and referenced IN
// PLACE — never copied here, never logged; they travel only in memory for this one login.
//
// STOCK EXPOSURE CAVEAT: the existing DWSW scraper (schumacher-dwsw-updater skill) only mines
// pattern/color/spec/fire-rating/CDN-image data — it never reads availability, and Schumacher's
// public pages are quote-only. Whether the trade PDP surfaces a machine-readable in-stock signal is
// UNVERIFIED (no live proof session run). So this adapter reads availability DEFENSIVELY and
// dims to in_stock:null ("unknown") rather than guessing when no stock signal is present.
//
// PUBLIC-SAFE: emits availability only. Schumacher's logged-in price is trade/net (and the line is
// quote-only publicly), so `price` is ALWAYS null — cost/net/wholesale NEVER leave here.
const { resolveCatalogUrl, releaseSession } = require('./resolve');
const creds = require('./creds');
// Prefer the vault's SCHUMACHER_LOGIN_URL (authoritative); this is only a last-resort default.
const DEFAULT_LOGIN = 'https://www.fschumacher.com/account/login';
async function run({ sku, pool, newSession, catalogTable, log }) {
let sessionOpened = false, browser = null, session = null, bb = null;
try {
// 1. Resolve the grid SKU → its Schumacher PDP (bridged via shopify_products → schumacher_catalog).
// Same 39%-resolvability path the coverage gate measures — SKUs with no catalog product_url
// simply return "not found" and the grid dims that row (the known SKU↔catalog alignment gap).
const r = await resolveCatalogUrl(pool, catalogTable || 'schumacher_catalog', sku);
if (r.error) return { available: false, reason: r.error };
const url = r.product_url;
// 2. Vault creds (prefix SCHUMACHER) — referenced in place; registry fallback. NEVER logged.
const c = await creds.resolve({ prefix: 'SCHUMACHER', pool, vendorLike: 'schumacher' });
if (!c || !c.pass) return { available: false, reason: 'schumacher trade credentials not available (vault)' };
const loginUrl = c.loginUrl || DEFAULT_LOGIN;
// 3. Metered Browserbase session + Schumacher 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 emailSel = '#email, input[name=email], input[type=email], #username, input[name=username]';
const passSel = '#password, input[name=password], input[type=password]';
const emailEl = await page.$(emailSel).catch(() => null);
if (emailEl) {
await page.fill(emailSel, c.user || '').catch(() => {});
await page.fill(passSel, c.pass).catch(() => {});
await page.waitForTimeout(400);
await page.click('button[type=submit], form button:has-text("Sign"), form button:has-text("Log"), form button:has-text("Continue")')
.catch(async () => { await page.press(passSel, 'Enter').catch(() => {}); });
// wait for the login page to fall away (redirect off /login | /account/login)
for (let i = 0; i < 18; i++) { await page.waitForTimeout(1000); if (!/\/(account\/)?login/i.test(page.url())) break; }
}
const loggedIn = !/\/(account\/)?login/i.test(page.url());
// 4. PDP → wait for any trade-gated inventory to hydrate, then read availability DEFENSIVELY.
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(7000);
const html = await page.content().catch(() => '');
// Opportunistic embedded stock flags (present on many trade storefronts' product JSON).
const availForSale = /"availableForSale"\s*:\s*true/i.test(html) ? true
: /"availableForSale"\s*:\s*false/i.test(html) ? false : null;
const stockedM = html.match(/"(?:stocked|qtyAvailable|quantityAvailable|inventory_quantity)"\s*:\s*(\d+)/i);
const stocked = stockedM ? parseInt(stockedM[1], 10) : null;
const backorder = /"backorder"\s*:\s*(1|true)/i.test(html) || /back\s*-?order/i.test(html);
// product-scoped visible status text (avoid global nav "Check Stock"/"Discontinued" chrome)
const availTxt = await page.$$eval(
'[itemprop=availability], main [class*=stock], main [class*=availab], main [class*=inventory], button:has-text("Add"), button:has-text("Order")',
(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 || stocked === 0 || /out\s*of\s*stock|sold\s*out|discontinued|unavailable|no longer available/i.test(availTxt)) in_stock = false;
else if (availForSale === true || (typeof stocked === 'number' && stocked > 0) || /in\s*stock|in-?stock|add to (cart|order|sample|bag)/i.test(availTxt)) in_stock = true;
// else: no stock signal on the trade PDP → in_stock stays null ("unknown"), row dims gracefully.
const label = in_stock === true ? (backorder ? 'in stock (some backorder)' : 'in stock')
: in_stock === false ? 'out of stock'
: (availTxt ? availTxt.slice(0, 60) : 'unknown (no stock signal on trade PDP — verification pending)');
return {
available: true, session_opened: true, vendor: 'Schumacher',
in_stock,
availability: label, // task-requested alias
lead_time: backorder ? 'backorder possible' : null,
price: null, // Schumacher is quote-only / trade-net → NEVER emitted
raw_stock_label: label, // registry-contract field (matches thibaut shape)
as_of: new Date().toISOString(),
source: loggedIn ? 'schumacher-live' : 'schumacher-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 };