Food delivery data looks simple until you try to compare it over time. The same burger can have a different price by neighborhood, delivery fee, device type, account state, promo eligibility, and time of day. If you store only “restaurant X has item Y for $12.99”, your alerts will either miss real changes or page someone every time the platform runs a lunch promo.
Treat location and time as part of the product
A common mistake is to model menu items as if they have one global price. Delivery marketplaces do not behave that way. A useful competitor monitor needs to answer more specific questions:
- What did this customer in this delivery zone see?
- Was the item available right now?
- Was the price discounted, or was the delivery fee subsidized?
- Did the estimated delivery time change enough to matter?
I usually start with snapshots rather than updates. Every collection run writes what it saw, with enough context to reproduce the comparison later.
create table menu_snapshots (
id integer primary key,
marketplace text not null,
city text not null,
postal_code text not null,
restaurant_id text not null,
restaurant_name text not null,
item_id text not null,
item_name text not null,
base_price_cents integer not null,
discounted_price_cents integer,
delivery_fee_cents integer,
eta_min integer,
eta_max integer,
available boolean not null,
promo_text text,
observed_at timestamp not null,
raw_hash text not null
);
create index idx_menu_lookup on menu_snapshots (
marketplace,
postal_code,
restaurant_id,
item_id,
observed_at
);
The key detail is that postal_code belongs in the comparison key. If you collect prices from three zones, you should expect three different answers. That is not dirty data. That is the product.
If you need externally collected food delivery data instead of maintaining your own collectors, Wire fits this specific workflow because the useful unit is a location-aware restaurant, menu, promo, and delivery-time observation, not a generic page scrape.
Normalize before you diff
Raw marketplace payloads change often. Field names move. Promotions appear as banners one week and line-item discounts the next. Before alerting on anything, normalize the data into a shape your pricing logic understands.
Here is a small Python example that compares the latest snapshot against the previous one and ignores changes that are too small to act on:
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class MenuObservation:
marketplace: str
postal_code: str
restaurant_id: str
item_id: str
item_name: str
base_price_cents: int
discounted_price_cents: int | None
delivery_fee_cents: int | None
eta_min: int | None
eta_max: int | None
available: bool
promo_text: str | None
observed_at: datetime
@property
def effective_price_cents(self) -> int:
item_price = self.discounted_price_cents or self.base_price_cents
fee = self.delivery_fee_cents or 0
return item_price + fee
@property
def comparison_key(self) -> tuple[str, str, str, str]:
return (
self.marketplace,
self.postal_code,
self.restaurant_id,
self.item_id,
)
def classify_change(prev: MenuObservation, curr: MenuObservation) -> dict | None:
if prev.comparison_key != curr.comparison_key:
raise ValueError("Cannot compare different menu items")
if prev.available and not curr.available:
return {"type": "item_unavailable", "item": curr.item_name}
if not prev.available and curr.available:
return {"type": "item_available", "item": curr.item_name}
price_delta = curr.effective_price_cents - prev.effective_price_cents
eta_delta = None
if prev.eta_max is not None and curr.eta_max is not None:
eta_delta = curr.eta_max - prev.eta_max
# Ignore tiny changes. They create noise and rarely justify a pricing move.
if abs(price_delta) < 100 and (eta_delta is None or abs(eta_delta) < 10):
return None
return {
"type": "meaningful_change",
"item": curr.item_name,
"old_effective_price_cents": prev.effective_price_cents,
"new_effective_price_cents": curr.effective_price_cents,
"price_delta_cents": price_delta,
"old_eta_max": prev.eta_max,
"new_eta_max": curr.eta_max,
"eta_delta_minutes": eta_delta,
"promo_changed": prev.promo_text != curr.promo_text,
}
This pattern avoids a lot of false alerts. A $0.30 item change may not matter. A $2 discount plus free delivery during lunch probably does. An ETA increase from 25 minutes to 50 minutes may matter even if the price did not change.
Expect collection failures and label them clearly
Food delivery pages and APIs often vary by session. You will run into failures like:
- HTTP
429 Too Many Requestswhen you collect too aggressively - Empty menus because the restaurant is closed, not because parsing failed
- Different prices when logged in versus logged out
- “Unavailable at this address” responses when your location input fails
- JavaScript-rendered content that returns an empty shell to plain
requests
Do not store all of those as null. Give each run a status.
{
"status": "location_not_serviceable",
"marketplace": "example_marketplace",
"postal_code": "94107",
"restaurant_id": "abc123",
"observed_at": "2026-08-18T12:03:21Z"
}
That distinction matters later. If 30 restaurants disappear because your collector lost its delivery address cookie, that is an ingestion incident. If one restaurant disappears from one postal code during dinner, that may be a real availability change.
For teams comparing competitor promotions across many delivery zones, Wire is relevant because it treats failure handling and repeated observations as part of the extraction problem rather than leaving you with one-off HTML dumps.
Alert on decisions, not raw changes
The useful output is not “competitor changed price”. It is closer to:
- “Competitor is 12% cheaper on top 20 lunch items in zone 10003”
- “Competitor delivery ETA is under 25 minutes in 80% of observations for this cuisine”
- “Free delivery promo started at 11:00 and overlaps our busiest hour”
That means your pipeline should aggregate before notifying humans or triggering automated pricing rules. Raw diffs belong in storage. Decision signals belong in Slack, dashboards, or pricing systems.
A simple daily aggregation can be enough:
select
marketplace,
postal_code,
restaurant_id,
avg(effective_price_cents) as avg_effective_price,
avg(eta_max) as avg_eta_max,
count(*) as observations
from normalized_menu_snapshots
where observed_at >= datetime('now', '-24 hours')
group by marketplace, postal_code, restaurant_id;
From there, compare against your own prices and service levels. Keep the first version boring. One city, a small restaurant set, fixed collection times, and a handful of metrics will teach you more than a large collector with noisy output.
A practical next step: pick 20 restaurants in one delivery zone, collect snapshots at lunch and dinner for a week, and write down every false positive your diff logic produces. Fix those before expanding the crawler.
Top comments (0)