Read a DoorDash store's full menu (categories, items, prices) plus store info by store id.
{"storeId":"string (DoorDash store id, digits only, e.g. '986955'; the `id` returned by search-stores)"}{"store":"{ id, name, cuisine, priceRange, rating, numRatings, deliveryTime, deliveryFee, dashpass, offersDelivery, offersPickup, address, phone, website, currency, status }","categoryCount":"int","count":"int (total items)","categories":"array of { name, description, itemCount, items:[{ id, name, description, price, priceAmount, strikethroughPrice, rating, callout, image }] }"}Read the full menu of a DoorDash store by its numeric store id. Instead of navigating the store page and waiting for it to hydrate (~8s), this fetch()es the store HTML in-session and parses the menu straight out of it (~1.5-3s). The full menu is server-rendered into the page as an RSC (Next.js) flight payload - every category and item, no scrolling - so there is no separate menu API to replay; the Extractor decodes the flight chunks and reads the StoreHeader + MenuPageItemList/MenuPageItem objects. Param: storeId (digits only, e.g. '986955'; this is the id field returned by the doordash.com/search-stores skill). Returns {store, categoryCount, count, categories:[{name, description, itemCount, items:[{id, name, description, price, priceAmount, strikethroughPrice, rating, callout, image}]}]}. price is the display string (e.g. "$8.28"), priceAmount the numeric value, rating the item's like-rating string (e.g. "78% (920)"), callout a badge like "#1 Best seller". store.priceRange is the number of $ signs. Prices/currency reflect the delivery location DoorDash has for the session. Read-only, it never adds to cart or orders.
fetch must run from a doordash.com page so it carries the session cookies and is same-origin (this is what clears Cloudflare and scopes results to the session's address). Have a doordash.com tab open - navigate one to https://www.doordash.com/ once if needed - then run the Extractor there. A flagged automation profile still gets Cloudflare-challenged; use the user's real browser profile.doordash.com tab exists (navigate to https://www.doordash.com/ once if the group has none) - the fetch inherits that tab's session/origin.params (needs params.storeId) on that tab. It fetches /store/{storeId}/ in-session, decodes the flight payload, and returns the JSON in returns. Multiple stores can run as parallel evaluates on the same tab.( async (root, params) => { const storeId = String((params && params.storeId) || ''); const origin = 'https://www.doordash.com'; const res = await fetch(origin + '/store/' + storeId + '/', { credentials: 'include' }); const html = await res.text(); // The page data is a Next.js RSC flight payload: a series of self.__next_f.push([1,"<escaped json>"]). // Decode each chunk with JSON.parse (correct un-escaping) and concatenate to get the raw data text. let text = ''; const chunkRe = /self.__next_f.push([1,("(?:[^"\]|\.)*")])/g; let cm; while ((cm = chunkRe.exec(html))) { try { text += JSON.parse(cm[1]); } catch (e) {} } if (!text) return { error: 'flight_not_found', store: null, categoryCount: 0, count: 0, categories: [] }; // String-aware brace matcher: returns the balanced {...} object starting at openIdx, ignoring braces inside strings. const balancedFrom = (str, openIdx) => { let depth = 0, inStr = false; for (let i = openIdx; i < str.length; i++) { const ch = str[i]; if (inStr) { if (ch === '\') { i++; continue; } if (ch === '"') inStr = false; continue; } if (ch === '"') { inStr = true; continue; } if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) return str.slice(openIdx, i + 1); } } return null; }; // Find the first object of a given __typename that JSON-parses and satisfies pred. const objFor = (tn, pred) => { const re = new RegExp('"__typename":"' + tn + '"', 'g'); let m; while ((m = re.exec(text))) { const raw = balancedFrom(text, text.lastIndexOf('{', m.index)); if (!raw) continue; let o; try { o = JSON.parse(raw); } catch (e) { continue; } if (!pred || pred(o)) return o; } return null; }; // RSC escapes a leading '$' as '$$' (the '$' prefix marks references), so display strings come as "$$8.28" - undo just the leading double. const fix = (s) => ((s || '').replace(/^$$/, '$')) || null;
const h = objFor('StoreHeader', (o) => o.name && o.id) || {}; const rt = h.ratings || {}, dtl = h.deliveryTimeLayout || {}, dfl = h.deliveryFeeLayout || {}, addr = h.address || {}; // Merchant contact info lives in a separate MxInfoData object. Read phone/website/status from there - // do NOT global-match "phoneNumber" across the flight: that field holds the signed-in USER's phone, not the store's. const mx = objFor('MxInfoData', (o) => ('phoneno' in o) || ('website' in o)) || {}; const opInfo = mx.operationInfo && mx.operationInfo.operationStatusInfo; const store = { id: h.id || storeId || null, name: h.name || null, cuisine: h.description || null, priceRange: (typeof h.priceRange === 'number') ? h.priceRange : null, rating: (typeof rt.averageRating === 'number') ? rt.averageRating : null, numRatings: (typeof rt.numRatings === 'number') ? rt.numRatings : null, deliveryTime: dtl.title || null, deliveryFee: fix(dfl.displayDeliveryFee), dashpass: !!h.isDashpassPartner, offersDelivery: !!h.offersDelivery, offersPickup: !!h.offersPickup, address: addr.displayAddress || (mx.address && mx.address.displayAddress) || null, phone: mx.phoneno || null, website: mx.website || null, currency: h.currency || null, status: (opInfo && opInfo.description) || null }; // Each category is a MenuPageItemList holding an inline items[] of MenuPageItem. Dedupe by category id. const categories = []; const seen = new Set(); let count = 0; const listRe = /"__typename":"MenuPageItemList"/g; let lm; while ((lm = listRe.exec(text))) { const raw = balancedFrom(text, text.lastIndexOf('{', lm.index)); if (!raw) continue; let o; try { o = JSON.parse(raw); } catch (e) { continue; } if (!o.id || seen.has(o.id) || !Array.isArray(o.items)) continue; seen.add(o.id); const items = o.items.filter((it) => it && it.__typename === 'MenuPageItem').map((it) => { count++; const p = (it.quickAddContext && it.quickAddContext.price) || {}; return { id: it.id || null, name: it.name || null, description: it.description || null, price: fix(it.displayPrice), priceAmount: (typeof p.unitAmount === 'number') ? p.unitAmount / Math.pow(10, (p.decimalPlaces || 2)) : null, strikethroughPrice: it.displayStrikethroughPrice || null, rating: it.ratingDisplayString || null, callout: it.calloutDisplayString || null, image: it.imageUrl || null }; }); if (items.length) categories.push({ name: o.name || null, description: o.description || null, itemCount: items.length, items }); } if (!categories.length) return { error: 'menu_not_found', store, categoryCount: 0, count: 0, categories: [] }; return { store, categoryCount: categories.length, count, categories }; } )
{
"expr": "typeof result === 'object' && Array.isArray(result.categories) && result.count > 0",
"type": "value"
}