import { chromium } from "playwright"; import { readFileSync, writeFileSync, existsSync } from "fs"; const EMAIL = process.env.COUPA_EMAIL; const PASSWORD = process.env.COUPA_PASSWORD; if (!EMAIL || !PASSWORD) { console.error("Set COUPA_EMAIL and COUPA_PASSWORD env vars"); process.exit(1); } const SUPPLIER_ID = "895025"; const INSTANCE_URL = "https%3A%2F%2Famazon.coupahost.com%2F"; const OUTPUT_FILE = "output/scraped-pos.json"; const PROGRESS_FILE = "output/progress.json"; const DELAY_MS = 1500; const KEEPALIVE_EVERY = 75; const poListFile = process.argv[2]; if (!poListFile) { console.error("Usage: node scrape.mjs "); process.exit(1); } const allPOs = JSON.parse(readFileSync(poListFile, "utf-8")); console.log(`Loaded ${allPOs.length} POs to scrape`); let completed = {}; if (existsSync(PROGRESS_FILE)) { completed = JSON.parse(readFileSync(PROGRESS_FILE, "utf-8")); console.log(`Resuming: ${Object.keys(completed).length} already scraped`); } const remaining = allPOs.filter((po) => !completed[po]); console.log(`Remaining: ${remaining.length} POs\n`); if (remaining.length === 0) { console.log("All POs already scraped!"); process.exit(0); } function poUrl(po) { const numeric = po.split("-").slice(1).join("-"); return `https://supplier.coupahost.com/orders/${numeric}?instance_url=${INSTANCE_URL}&supplier_id=${SUPPLIER_ID}&`; } function isLoginPage(url) { return url.includes("sessions/new") || url.includes("login") || url.includes("sign_in"); } async function autoLogin(page) { console.log("\nšŸ” Session expired — auto-filling login form..."); // Step 1: Fill email and click Continue try { await page.waitForSelector("input#email", { state: "visible", timeout: 5000 }); await page.fill("input#email", EMAIL); await page.click("button.s-login").catch(() => {}); console.log(" Email filled, clicked Continue."); } catch { // Already past email step } // Step 2: CAPTCHA appears before password — wait for user to solve it // After CAPTCHA, password field will appear console.log(" ā³ Solve the CAPTCHA — password will auto-fill after..."); try { await page.waitForSelector("input#password", { state: "visible", timeout: 120000 }); await page.fill("input#password", PASSWORD); console.log(" Password filled, clicking Login..."); page.click("button.s-login").catch(() => {}); } catch { console.log(" Password field never appeared. Check the browser."); } // Wait for login to complete (may need another CAPTCHA or just redirects) console.log(" ā³ Waiting for login to complete..."); try { await page.waitForURL((url) => !isLoginPage(url.toString()), { timeout: 120000 }); console.log(" āœ… Login successful!\n"); return true; } catch { console.error(" āŒ Login timed out after 120s. Exiting."); return false; } } async function keepAlive(page) { try { await page.goto("https://supplier.coupahost.com/home/", { waitUntil: "domcontentloaded", timeout: 15000, }); if (isLoginPage(page.url())) return false; return true; } catch { return false; } } async function scrapePO(page, po) { await page.goto(poUrl(po), { waitUntil: "networkidle", timeout: 30000 }); if (isLoginPage(page.url())) { const ok = await autoLogin(page); if (!ok) return null; // Navigate to PO after re-login await page.goto(poUrl(po), { waitUntil: "networkidle", timeout: 30000 }); if (isLoginPage(page.url())) return null; } await page.waitForSelector("iframe#enterprise_frame", { timeout: 15000 }); await page.waitForTimeout(1500); const frame = page.frame({ name: "enterprise_frame" }) || page.frames().find((f) => f.url().includes("supplier_order_headers")); if (!frame) return "no_iframe"; await frame.waitForLoadState("networkidle"); const data = await frame.evaluate(() => { const text = (sel) => { const el = document.querySelector(sel); return el ? el.textContent.trim() : null; }; const lineItems = []; const rows = document.querySelectorAll(".coupa_datatable_row"); for (const row of rows) { const desc = row.querySelector(".s-description"); const price = row.querySelector(".s-price span[title]"); const total = row.querySelector(".s-total span[title]"); const needBy = row.querySelector("[id$='_need_by_date']"); lineItems.push({ description: desc ? desc.textContent.trim() : null, price: price ? price.textContent.trim() : null, total: total ? total.textContent.trim() : null, need_by: needBy ? needBy.textContent.trim() : null, }); } const addrEl = document.querySelector( "label[for='order_header_ship_to_address'] + span.address, span.address" ); let shipTo = null; if (addrEl) { const clone = addrEl.cloneNode(true); clone.querySelectorAll(".form_element").forEach((el) => el.remove()); shipTo = clone.innerHTML .replace(//gi, "\n") .replace(/<[^>]+>/g, "") .trim(); } return { status: text(".s-readable_status"), order_date: text(".s-local_header_created_at"), requester: text(".s-requester"), payment_term: text("#order_header_payment_term .data"), ship_to_raw: shipTo, line_items: lineItems, }; }); let site_code = null; if (data.ship_to_raw) { const m = data.ship_to_raw.match(/\(([A-Z0-9]{3,5})\)/); if (m) site_code = m[1]; if (!site_code) { const m2 = data.ship_to_raw.match(/Attn:\s*([A-Z0-9]{3,5})\b/); if (m2) site_code = m2[1]; } } return { po_number: po, site_code, ...data }; } // --- Main --- // Use a persistent Chrome profile so reCAPTCHA trusts the browser const userDataDir = new URL("./chrome-profile", import.meta.url).pathname; const context = await chromium.launchPersistentContext(userDataDir, { headless: false, channel: "chrome", args: ["--disable-blink-features=AutomationControlled"], ignoreDefaultArgs: ["--enable-automation"], }); const page = context.pages()[0] || (await context.newPage()); // Initial login console.log("Opening Coupa supplier portal..."); await page.goto(poUrl(remaining[0]), { waitUntil: "networkidle", timeout: 30000 }); if (isLoginPage(page.url())) { const ok = await autoLogin(page); if (!ok) { await context.close(); process.exit(1); } } let results = []; if (existsSync(OUTPUT_FILE)) { results = JSON.parse(readFileSync(OUTPUT_FILE, "utf-8")); } let scraped = 0; let errors = 0; let consecutiveErrors = 0; const startTime = Date.now(); for (const po of remaining) { // Session keep-alive if (scraped > 0 && scraped % KEEPALIVE_EVERY === 0) { process.stdout.write("\n Refreshing session..."); const alive = await keepAlive(page); if (!alive) { console.log(" session expired, re-logging in..."); await page.goto("https://supplier.coupahost.com/sessions/new", { waitUntil: "networkidle", }); const ok = await autoLogin(page); if (!ok) break; } else { process.stdout.write(" ok\n"); } } try { const result = await scrapePO(page, po); if (result === null) { console.error(`\nFailed to authenticate. Saving progress.`); break; } if (result === "no_iframe") { console.error(`\n [${po}] No iframe found, skipping`); errors++; consecutiveErrors++; if (consecutiveErrors > 10) { console.error("Too many consecutive errors, stopping."); break; } continue; } results.push(result); completed[po] = true; scraped++; consecutiveErrors = 0; const elapsed = (Date.now() - startTime) / 1000; const rate = scraped / elapsed; const eta = Math.round((remaining.length - scraped) / rate); const etaMin = Math.floor(eta / 60); const etaSec = eta % 60; const desc0 = result.line_items[0]?.description?.substring(0, 50) || "no lines"; process.stdout.write( `\r[${scraped}/${remaining.length}] ${po} | ${result.site_code || "?"} | ${desc0}... | ETA: ${etaMin}m${etaSec}s ` ); // Save every 10 POs (more frequent for monitor) if (scraped % 10 === 0) { writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2)); writeFileSync(PROGRESS_FILE, JSON.stringify(completed)); } } catch (err) { console.error(`\n [${po}] Error: ${err.message}`); errors++; consecutiveErrors++; if (consecutiveErrors > 10) { console.error("Too many consecutive errors, stopping."); break; } } await page.waitForTimeout(DELAY_MS); } // Final save writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2)); writeFileSync(PROGRESS_FILE, JSON.stringify(completed)); console.log(`\n\nDone! Scraped: ${scraped}, Errors: ${errors}`); console.log(`Total in output: ${results.length} POs`); console.log(`Results saved to ${OUTPUT_FILE}`); await context.close();