Previously only lived on servers at /opt/playwright/. Now tracked in repo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
308 lines
10 KiB
JavaScript
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();
|
|
})();
|