const { chromium } = require("playwright-extra"); const stealth = require("puppeteer-extra-plugin-stealth"); const fs = require("fs"); chromium.use(stealth()); (async () => { const url = process.argv[2]; const itemNamesFile = process.argv[3]; // Optional: JSON file with array of item names if (!url) { console.log(JSON.stringify({ error: "URL required", modifiers: [], itemModifierMap: {} })); process.exit(1); } const log = (msg) => process.stderr.write("[dd-mod] " + msg + "\n"); // Load item names if provided (from CFML fast-path) let knownItemNames = []; if (itemNamesFile && fs.existsSync(itemNamesFile)) { try { knownItemNames = JSON.parse(fs.readFileSync(itemNamesFile, "utf8")); log("Loaded " + knownItemNames.length + " item names from file"); } catch (e) { log("Failed to load item names file: " + e.message); } } let browser; try { browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); // Intercept itemPage GraphQL responses let latestItemPage = null; let responseCount = 0; page.on("response", async (response) => { try { const responseUrl = response.url(); if (responseUrl.includes("graphql") || responseUrl.includes("api/v2")) { const ct = response.headers()["content-type"] || ""; if (ct.includes("json")) { const body = await response.json(); if (body && body.data && body.data.itemPage) { latestItemPage = body.data.itemPage; responseCount++; } if (Array.isArray(body)) { for (const entry of body) { if (entry && entry.data && entry.data.itemPage) { latestItemPage = entry.data.itemPage; responseCount++; } } } } } } catch (e) {} }); log("Navigating to " + url); await page.goto(url, { waitUntil: "load", timeout: 60000 }); await page.waitForTimeout(5000); // Aggressive scroll to force DoorDash to render all items log("Scrolling to load all items..."); let lastHeight = 0; for (let round = 0; round < 3; round++) { const scrollHeight = await page.evaluate(() => document.body.scrollHeight); const viewportHeight = await page.evaluate(() => window.innerHeight); const scrollSteps = Math.ceil(scrollHeight / viewportHeight); for (let i = 0; i <= scrollSteps; i++) { await page.evaluate((y) => window.scrollTo(0, y), i * viewportHeight); await page.waitForTimeout(250); } await page.waitForTimeout(500); const newHeight = await page.evaluate(() => document.body.scrollHeight); if (newHeight === lastHeight) break; lastHeight = newHeight; } await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForTimeout(500); // If we have known item names, use them to find and click elements // Otherwise fall back to DOM auto-detection let itemsToClick = []; if (knownItemNames.length > 0) { // Find each known item in the DOM by text content log("Searching DOM for " + knownItemNames.length + " known items..."); itemsToClick = await page.evaluate((names) => { const found = []; const allElements = document.querySelectorAll('span, h3, h4, p, div'); // Build a map of text -> element for fast lookup const textMap = new Map(); for (const el of allElements) { // Only use leaf-ish elements (avoid containers that contain the whole menu) if (el.children.length > 5) continue; const text = el.textContent.trim(); if (text.length > 1 && text.length < 200 && !textMap.has(text)) { textMap.set(text, el); } } for (const name of names) { const el = textMap.get(name); if (el) { // Find the clickable parent (the item card) const clickable = el.closest('a, button, [role="button"], [tabindex="0"], [data-anchor-id]') || el.parentElement?.closest('a, button, [role="button"], [tabindex="0"], [data-anchor-id]') || el.parentElement; if (clickable) { const rect = clickable.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { found.push({ name, y: rect.y + window.scrollY }); } } } } return found; }, knownItemNames); log("Found " + itemsToClick.length + "/" + knownItemNames.length + " items in DOM"); } else { // Auto-detect from DOM (fallback) itemsToClick = await page.evaluate(() => { const items = []; const seen = new Set(); document.querySelectorAll('[data-anchor-id*="MenuItem"], button, [role="button"]').forEach(el => { const text = el.textContent || ""; if (text.match(/\$\d+\.\d{2}/) && text.length < 500) { const lines = text.split("\n").map(l => l.trim()).filter(l => l.length > 0); const name = lines[0]; if (name && !seen.has(name) && name.length > 1 && name.length < 200 && !name.startsWith("$")) { seen.add(name); const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { items.push({ name, y: rect.y + window.scrollY }); } } } }); return items; }); log("Auto-detected " + itemsToClick.length + " clickable items"); } if (itemsToClick.length === 0) { console.log(JSON.stringify({ error: "No clickable items found", modifiers: [], itemModifierMap: {} })); await browser.close(); process.exit(0); } // Click each item, capture modifier data const allModifierGroups = new Map(); const itemModifierMap = {}; let clickedCount = 0; let modItemCount = 0; let noModCount = 0; const maxClicks = Math.min(itemsToClick.length, 250); for (let i = 0; i < maxClicks; i++) { const item = itemsToClick[i]; try { latestItemPage = null; // Scroll to item position await page.evaluate((y) => window.scrollTo(0, Math.max(0, y - 300)), item.y); await page.waitForTimeout(200); // Find the item element by name and click it const clicked = await page.evaluate((itemName) => { // Find the text node with this exact name const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { if (walker.currentNode.textContent.trim() === itemName) { const el = walker.currentNode.parentElement; const clickable = el.closest('a, button, [role="button"], [tabindex="0"], [data-anchor-id]') || el.parentElement?.closest('a, button, [role="button"], [tabindex="0"], [data-anchor-id]') || el; const rect = clickable.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { clickable.click(); return true; } } } return false; }, item.name); if (!clicked) continue; clickedCount++; // Wait for GraphQL response (up to 5s) const startTime = Date.now(); while (!latestItemPage && Date.now() - startTime < 5000) { await page.waitForTimeout(150); } if (latestItemPage && latestItemPage.optionLists && Array.isArray(latestItemPage.optionLists)) { const optionLists = latestItemPage.optionLists; if (optionLists.length > 0) { const modNames = []; for (const ol of optionLists) { const olName = ol.name || "Options"; if (!allModifierGroups.has(olName)) { const options = []; if (ol.options && Array.isArray(ol.options)) { for (const opt of ol.options) { // DoorDash prices: sometimes cents (int), sometimes dollars (string like "$1.50") let price = 0; if (opt.price) { if (typeof opt.price === "number") { price = opt.price > 100 ? opt.price / 100 : opt.price; } else if (typeof opt.price === "string") { price = parseFloat(opt.price.replace(/[^0-9.]/g, "")) || 0; } } if (opt.displayPrice) { price = parseFloat(String(opt.displayPrice).replace(/[^0-9.]/g, "")) || price; } options.push({ name: opt.name || "", price: price }); } } allModifierGroups.set(olName, { name: olName, required: ol.isRequired || false, minSelections: ol.minNumOptions || 0, maxSelections: ol.maxNumOptions || 0, options: options }); } modNames.push(olName); } itemModifierMap[item.name] = modNames; modItemCount++; } else { noModCount++; } } else { noModCount++; } // Close modal await page.keyboard.press("Escape"); await page.waitForTimeout(350); // Double-check modal is closed const stillOpen = await page.evaluate(() => { const overlay = document.querySelector('[data-testid="modal-overlay"], [class*="ModalOverlay"], [class*="Overlay"]'); if (overlay) { overlay.click(); return true; } return false; }); if (stillOpen) await page.waitForTimeout(300); } catch (e) { log("Error clicking " + item.name + ": " + e.message); try { await page.keyboard.press("Escape"); } catch (e2) {} await page.waitForTimeout(300); } if ((i + 1) % 25 === 0) { log("Progress: " + (i + 1) + "/" + maxClicks + " | " + modItemCount + " with mods, " + noModCount + " without"); } } log("Done: " + clickedCount + "/" + maxClicks + " clicked, " + modItemCount + " with modifiers, " + allModifierGroups.size + " unique groups"); const modifiers = Array.from(allModifierGroups.values()); console.log(JSON.stringify({ modifiers: modifiers, itemModifierMap: itemModifierMap, stats: { totalItems: itemsToClick.length, clickedCount: clickedCount, itemsWithModifiers: modItemCount, itemsWithoutModifiers: noModCount, uniqueModifierGroups: modifiers.length } })); } catch (e) { log("Fatal error: " + e.message); console.log(JSON.stringify({ error: e.message, modifiers: [], itemModifierMap: {} })); } if (browser) await browser.close(); })();