This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/playwright/doordash-modifiers.js
John Mizerek f5974a5fa2 Improve DoorDash modifier extraction: pass item names to Playwright
- Pass extracted item names via temp JSON file so Playwright knows exactly
  what to click instead of guessing from DOM selectors (7 → 171 items)
- Use TreeWalker for exact text matching and aggressive scrolling
- Better price parsing: handle cents (int), dollars (string), displayPrice
- Improved modal dismissal with overlay click fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:02:27 -07:00

296 lines
11 KiB
JavaScript

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();
})();