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/toast-modifiers.js
John Mizerek dd2a508680 Add playwright scripts to git
Previously only lived on servers at /opt/playwright/. Now tracked in repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:22:34 -07:00

308 lines
10 KiB
JavaScript

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