← back to Vendor Discount Agent
discount-checker.js
315 lines
const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs');
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots');
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
// Vendor-specific login and price extraction strategies
const VENDOR_STRATEGIES = {
brewster_york: {
async login(page, vendor) {
await page.goto('https://www.yorkwall.com/Login/SignIn', { waitUntil: 'networkidle2', timeout: 30000 });
await page.type('#CustomerNumber', vendor.trade_username);
await page.type('#Password', vendor.trade_password);
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 30000 }),
page.click('.signInLink, input[type="submit"]')
]);
},
async findPrices(page) {
// Navigate to a product page and look for list vs net pricing
await page.goto('https://www.yorkwall.com/Search?q=wallpaper&pageSize=12', { waitUntil: 'networkidle2', timeout: 30000 });
const prices = await page.evaluate(() => {
const items = document.querySelectorAll('.product-card, .product-item, [class*="product"]');
const results = [];
for (const item of Array.from(items).slice(0, 5)) {
const name = item.querySelector('h2, h3, .product-name, [class*="name"]')?.textContent?.trim();
const priceEls = item.querySelectorAll('[class*="price"], .price, .amount');
const priceTexts = Array.from(priceEls).map(el => el.textContent.trim());
if (name) results.push({ name, prices: priceTexts });
}
return results;
});
return prices;
}
},
york_contract: {
async login(page, vendor) {
return VENDOR_STRATEGIES.brewster_york.login(page, vendor);
},
async findPrices(page) {
return VENDOR_STRATEGIES.brewster_york.findPrices(page);
}
},
wallquest: {
async login(page, vendor) {
// WallQuest is NopCommerce — login page is at /login
await page.goto('https://wallquest.com/login', { waitUntil: 'networkidle2', timeout: 30000 });
// Try NopCommerce selectors
const emailSel = await page.$('#Email') || await page.$('input[name="Email"]') || await page.$('input[type="email"]');
const passSel = await page.$('#Password') || await page.$('input[name="Password"]') || await page.$('input[type="password"]');
if (emailSel) await emailSel.type(vendor.trade_username);
if (passSel) await passSel.type(vendor.trade_password);
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {}),
page.click('button[type="submit"], input[type="submit"], .login-button, .button-1').catch(() => {})
]);
},
async findPrices(page) {
await page.goto('https://wallquest.com/wallcovering', { waitUntil: 'networkidle2', timeout: 30000 });
const prices = await page.evaluate(() => {
const items = document.querySelectorAll('.product-item, .item-box');
const results = [];
for (const item of Array.from(items).slice(0, 5)) {
const name = item.querySelector('.product-title a, h2 a')?.textContent?.trim();
const priceEls = item.querySelectorAll('.price, .actual-price, .old-price');
const priceTexts = Array.from(priceEls).map(el => el.textContent.trim());
if (name) results.push({ name, prices: priceTexts });
}
return results;
});
return prices;
}
},
malibu: {
async login(page, vendor) {
await page.goto(vendor.login_url || 'https://malibuwallpaper.com/account/login', { waitUntil: 'networkidle2', timeout: 30000 });
await page.type('#customer_email, input[name="customer[email]"]', vendor.trade_username);
await page.type('#customer_password, input[name="customer[password]"]', vendor.trade_password);
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 30000 }),
page.click('button[type="submit"], input[type="submit"]')
]);
},
async findPrices(page) {
await page.goto('https://malibuwallpaper.com/collections/all', { waitUntil: 'networkidle2', timeout: 30000 });
const prices = await page.evaluate(() => {
const items = document.querySelectorAll('.product-card, .grid-item, [class*="product"]');
const results = [];
for (const item of Array.from(items).slice(0, 5)) {
const name = item.querySelector('h2, h3, .product-title, [class*="title"]')?.textContent?.trim();
const priceEls = item.querySelectorAll('[class*="price"], .price, .money');
const priceTexts = Array.from(priceEls).map(el => el.textContent.trim());
if (name) results.push({ name, prices: priceTexts });
}
return results;
});
return prices;
}
},
// Generic strategy for unknown vendors
generic: {
async login(page, vendor) {
await page.goto(vendor.login_url, { waitUntil: 'networkidle2', timeout: 30000 });
// Try common login form selectors
const emailSelectors = [
'input[name="email"]', 'input[name="username"]', 'input[type="email"]',
'#email', '#username', '#login-email', 'input[name="login"]',
'input[name="customer[email]"]', '#Username'
];
const passSelectors = [
'input[name="password"]', 'input[type="password"]',
'#password', '#pass', '#login-password',
'input[name="customer[password]"]', '#Password'
];
for (const sel of emailSelectors) {
try {
const el = await page.$(sel);
if (el) { await el.type(vendor.trade_username); break; }
} catch (e) { /* try next */ }
}
for (const sel of passSelectors) {
try {
const el = await page.$(sel);
if (el) { await el.type(vendor.trade_password); break; }
} catch (e) { /* try next */ }
}
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {}),
page.click('button[type="submit"], input[type="submit"]').catch(() => {})
]);
},
async findPrices(page) {
const prices = await page.evaluate(() => {
const priceEls = document.querySelectorAll('[class*="price"], .price, .amount, .money');
return Array.from(priceEls).slice(0, 10).map(el => ({
text: el.textContent.trim(),
classes: el.className
}));
});
return prices;
}
}
};
async function checkVendorDiscount(vendor, pool) {
const strategy = VENDOR_STRATEGIES[vendor.vendor_code] || VENDOR_STRATEGIES.generic;
let browser;
const checkRecord = {
vendor_code: vendor.vendor_code,
vendor_name: vendor.vendor_name,
status: 'running',
login_success: false,
notes: ''
};
// Insert initial check record
const insertResult = await pool.query(`
INSERT INTO vendor_discount_checks (vendor_code, vendor_name, status)
VALUES ($1, $2, $3) RETURNING id
`, [checkRecord.vendor_code, checkRecord.vendor_name, checkRecord.status]);
const checkId = insertResult.rows[0].id;
try {
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
timeout: 60000
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
// Step 1: Login
console.log(` Logging into ${vendor.vendor_name}...`);
await strategy.login(page, vendor);
// Screenshot after login
const loginScreenshot = path.join(SCREENSHOT_DIR, `${vendor.vendor_code}-login.png`);
await page.screenshot({ path: loginScreenshot, fullPage: false });
// Check if login succeeded (look for common failure indicators)
const pageText = await page.evaluate(() => document.body?.innerText?.substring(0, 2000) || '');
const loginFailed = /invalid|incorrect|failed|error|wrong password|try again/i.test(pageText) &&
!/welcome|account|dashboard|my account|logout|sign out/i.test(pageText);
if (loginFailed) {
checkRecord.login_success = false;
checkRecord.status = 'login_failed';
checkRecord.notes = 'Login appears to have failed';
} else {
checkRecord.login_success = true;
// Step 2: Find prices
console.log(` Finding prices for ${vendor.vendor_name}...`);
const prices = await strategy.findPrices(page);
// Screenshot of price page
const priceScreenshot = path.join(SCREENSHOT_DIR, `${vendor.vendor_code}-prices.png`);
await page.screenshot({ path: priceScreenshot, fullPage: false });
checkRecord.screenshot_path = priceScreenshot;
if (prices && prices.length > 0) {
checkRecord.notes = JSON.stringify(prices.slice(0, 5));
checkRecord.sample_product = prices[0].name || prices[0].text || 'Unknown';
checkRecord.status = 'prices_found';
// Try to parse and calculate discount
const parsed = parsePrices(prices);
if (parsed) {
checkRecord.sample_list_price = parsed.listPrice;
checkRecord.sample_net_price = parsed.netPrice;
checkRecord.calculated_discount = parsed.discount;
checkRecord.discount_found = parsed.discount;
checkRecord.price_type = parsed.priceType;
checkRecord.status = 'discount_calculated';
// Update vendor_registry with found discount
if (parsed.discount > 0 && parsed.discount < 100) {
await pool.query(`
UPDATE vendor_registry
SET vendor_discount_pct = $1,
pricing_notes = COALESCE(pricing_notes, '') || ' | Auto-checked ' || NOW()::date,
updated_at = NOW()
WHERE vendor_code = $2 AND vendor_discount_pct IS NULL
`, [parsed.discount, vendor.vendor_code]);
}
}
} else {
checkRecord.status = 'no_prices_found';
checkRecord.notes = 'Could not locate price elements on page';
}
}
} catch (err) {
checkRecord.status = 'error';
checkRecord.error = err.message;
console.error(` Error checking ${vendor.vendor_name}:`, err.message);
} finally {
if (browser) await browser.close().catch(() => {});
}
// Update check record
await pool.query(`
UPDATE vendor_discount_checks SET
status = $1, login_success = $2, discount_found = $3,
price_type = $4, sample_product = $5, sample_list_price = $6,
sample_net_price = $7, calculated_discount = $8,
screenshot_path = $9, notes = $10, error = $11,
checked_at = NOW()
WHERE id = $12
`, [
checkRecord.status, checkRecord.login_success, checkRecord.discount_found,
checkRecord.price_type, checkRecord.sample_product, checkRecord.sample_list_price,
checkRecord.sample_net_price, checkRecord.calculated_discount,
checkRecord.screenshot_path, checkRecord.notes, checkRecord.error,
checkId
]);
return checkRecord;
}
function parsePrices(priceData) {
// Try to find list price vs net/trade price patterns
for (const item of priceData) {
const texts = item.prices || [item.text];
const numbers = [];
for (const t of texts) {
const matches = t.match(/\$?([\d,]+\.?\d*)/g);
if (matches) {
for (const m of matches) {
const num = parseFloat(m.replace(/[$,]/g, ''));
if (num > 0 && num < 10000) numbers.push(num);
}
}
}
// If we have 2 different prices, the higher is likely list, lower is net
if (numbers.length >= 2) {
const sorted = [...new Set(numbers)].sort((a, b) => b - a);
if (sorted.length >= 2) {
const listPrice = sorted[0];
const netPrice = sorted[1];
const discount = ((listPrice - netPrice) / listPrice * 100).toFixed(2);
return {
listPrice,
netPrice,
discount: parseFloat(discount),
priceType: 'list_vs_net'
};
}
}
// Single price — can't calculate discount without a reference
if (numbers.length === 1) {
return {
listPrice: null,
netPrice: numbers[0],
discount: null,
priceType: 'net_only'
};
}
}
return null;
}
module.exports = { checkVendorDiscount };