const { chromium } = require("playwright"); (async () => { const url = process.argv[2]; if (!url) { console.log(JSON.stringify({ error: "URL required" })); process.exit(1); } const log = (msg) => process.stderr.write("[toast-mod] " + msg + "\n"); 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" }); const page = await context.newPage(); // Set up GraphQL response interceptor BEFORE navigation to catch everything let latestResponse = null; let responseCount = 0; page.on("response", async (response) => { try { const responseUrl = response.url(); if ((responseUrl.includes("graphql") || responseUrl.includes("federated-gateway"))) { const ct = response.headers()["content-type"] || ""; if (ct.includes("json")) { const rawBody = await response.json(); responseCount++; const responses = Array.isArray(rawBody) ? rawBody : [rawBody]; for (const body of responses) { if (!body || !body.data) continue; if (body.data.menuItemDetails) { const details = body.data.menuItemDetails; if (details.modifierGroups && Array.isArray(details.modifierGroups) && details.modifierGroups.length > 0) { latestResponse = details; } } } } } } catch (e) {} }); log("Navigating to " + url); await page.goto(url, { waitUntil: "load", timeout: 60000 }); await page.waitForTimeout(5000); const title = await page.title(); log("Page title: " + title); let hasOoState = await page.evaluate(() => !!window.__OO_STATE__); if (!hasOoState) { log("No __OO_STATE__ yet, waiting 10 more seconds..."); await page.waitForTimeout(10000); hasOoState = await page.evaluate(() => !!window.__OO_STATE__); if (!hasOoState) { console.log(JSON.stringify({ error: "No __OO_STATE__ found", items: [], modifiers: [], itemModifierMap: {} })); await browser.close(); process.exit(0); } } // Extract items const ooData = await page.evaluate(() => { const state = window.__OO_STATE__ || {}; const items = []; for (const key of Object.keys(state)) { if (!key.startsWith("Menu:")) continue; const menu = state[key]; if (!menu.groups || !Array.isArray(menu.groups)) continue; for (const group of menu.groups) { const groupName = group.name || "Menu"; if (group.items && Array.isArray(group.items)) { for (const item of group.items) { if (item.name) { items.push({ name: item.name.trim(), guid: item.guid || "", itemGroupGuid: item.itemGroupGuid || "", hasModifiers: !!item.hasModifiers, category: groupName }); } } } const subs = group.subgroups || group.children || group.childGroups || []; for (const sub of subs) { if (sub.items && Array.isArray(sub.items)) { for (const item of sub.items) { if (item.name) { items.push({ name: item.name.trim(), guid: item.guid || "", itemGroupGuid: item.itemGroupGuid || "", hasModifiers: !!item.hasModifiers, category: sub.name || groupName }); } } } } } } return items; }); log("Found " + ooData.length + " items, " + ooData.filter(i => i.hasModifiers).length + " with modifiers"); const modifierItems = ooData.filter(i => i.hasModifiers); if (modifierItems.length === 0) { console.log(JSON.stringify({ items: ooData, modifiers: [], itemModifierMap: {} })); await browser.close(); process.exit(0); } // OPTIMIZATION: Deduplicate by itemGroupGuid - only click one representative per group const guidToItems = new Map(); // itemGroupGuid -> [items] const noGuidItems = []; // items without itemGroupGuid for (const item of modifierItems) { if (item.itemGroupGuid) { if (!guidToItems.has(item.itemGroupGuid)) { guidToItems.set(item.itemGroupGuid, []); } guidToItems.get(item.itemGroupGuid).push(item); } else { noGuidItems.push(item); } } // Build click list: one item per unique itemGroupGuid + all items without a guid const clickList = []; for (const [guid, items] of guidToItems) { clickList.push(items[0]); // representative } for (const item of noGuidItems) { clickList.push(item); } log("Deduplicated: " + modifierItems.length + " modifier items -> " + clickList.length + " unique groups to click (" + guidToItems.size + " guids + " + noGuidItems.length + " ungrouped)"); // Click items to extract modifier data const allModifierGroups = new Map(); const itemModifierMap = {}; let clickedCount = 0; let failedClicks = 0; function processModGroups(groups, prefix) { if (!Array.isArray(groups)) return []; const modNames = []; for (const mg of groups) { const fullName = prefix ? prefix + " > " + mg.name : mg.name; const guid = mg.guid || fullName; if (!allModifierGroups.has(guid)) { const options = []; if (mg.modifiers && Array.isArray(mg.modifiers)) { for (const mod of mg.modifiers) { const price = typeof mod.price === "number" ? mod.price : 0; options.push({ name: mod.name || "", price: price }); if (mod.modifierGroups && Array.isArray(mod.modifierGroups) && mod.modifierGroups.length > 0) { const nestedNames = processModGroups(mod.modifierGroups, fullName); modNames.push(...nestedNames); } } } allModifierGroups.set(guid, { guid: guid, name: mg.name || "", required: (mg.minSelections || 0) > 0, minSelections: mg.minSelections || 0, maxSelections: mg.maxSelections || 0, options: options }); } modNames.push(allModifierGroups.get(guid).name); } return modNames; } // Only click the deduplicated clickList for (const item of clickList) { try { latestResponse = null; const countBefore = responseCount; // Find and click the item const headerLocator = page.locator(".headerText").filter({ hasText: new RegExp("^" + item.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$") }).first(); if (await headerLocator.count() === 0) { failedClicks++; log("Not found on page: " + item.name); continue; } const clickable = headerLocator.locator("xpath=ancestor::*[contains(@class,'clickable')]").first(); if (await clickable.count() > 0) { await clickable.scrollIntoViewIfNeeded(); await clickable.click({ timeout: 3000 }); } else { await headerLocator.scrollIntoViewIfNeeded(); await headerLocator.click({ timeout: 3000 }); } clickedCount++; // Wait for GraphQL response (up to 6s) const startTime = Date.now(); while (!latestResponse && Date.now() - startTime < 6000) { await page.waitForTimeout(200); } if (latestResponse && latestResponse.modifierGroups) { const names = processModGroups(latestResponse.modifierGroups, ""); // Map the clicked item itemModifierMap[item.name] = names; // OPTIMIZATION: Immediately map all siblings with same itemGroupGuid if (item.itemGroupGuid && guidToItems.has(item.itemGroupGuid)) { for (const sibling of guidToItems.get(item.itemGroupGuid)) { if (sibling.name !== item.name) { itemModifierMap[sibling.name] = names; } } } } // Close modal await page.keyboard.press("Escape"); await page.waitForTimeout(400); } catch (e) { log("Error clicking " + item.name + ": " + e.message); try { await page.keyboard.press("Escape"); } catch (e2) {} await page.waitForTimeout(300); } } const directMapped = Object.keys(itemModifierMap).length; log("Clicked: " + clickedCount + "/" + clickList.length + ", Mapped: " + directMapped + "/" + modifierItems.length); // Final fallback: any remaining unmapped items, try to infer from category siblings let inferredCount = 0; for (const item of modifierItems) { if (itemModifierMap[item.name]) continue; if (!item.itemGroupGuid) continue; for (const mappedName of Object.keys(itemModifierMap)) { const mappedItem = modifierItems.find(i => i.name === mappedName); if (mappedItem && mappedItem.itemGroupGuid === item.itemGroupGuid) { itemModifierMap[item.name] = itemModifierMap[mappedName]; inferredCount++; break; } } } if (inferredCount > 0) { log("Inferred modifiers for " + inferredCount + " additional items via itemGroupGuid fallback"); } log("Final: " + Object.keys(itemModifierMap).length + "/" + modifierItems.length + " mapped, " + allModifierGroups.size + " unique modifier groups"); const modifiers = Array.from(allModifierGroups.values()).map(mg => ({ name: mg.name, required: mg.required, minSelections: mg.minSelections, maxSelections: mg.maxSelections, options: mg.options })); console.log(JSON.stringify({ items: ooData, modifiers: modifiers, itemModifierMap: itemModifierMap, stats: { totalItems: ooData.length, itemsWithModifiers: modifierItems.length, modifiersExtracted: Object.keys(itemModifierMap).length, uniqueModifierGroups: modifiers.length, clickedCount: clickedCount, failedClicks: failedClicks, uniqueGroups: guidToItems.size, inferredCount: inferredCount } })); } catch (e) { log("Fatal error: " + e.message); console.log(JSON.stringify({ error: e.message, items: [], modifiers: [], itemModifierMap: {} })); } if (browser) await browser.close(); })();