This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
amazon-po-parser/scrape.mjs

293 lines
8.8 KiB
JavaScript
Raw Permalink Normal View History

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 <po-list.json>");
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(/<br\s*\/?>/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();