Just Eat is one of the largest online food delivery marketplaces in Europe, connecting millions of diners to local restaurants. Each area page is a public, structured catalog of who is delivering nearby: the restaurant name, the cuisines it cooks, its star rating, delivery details, and a link straight to its menu. That data is a clean signal for anyone studying local food markets, tracking which cuisines dominate a postcode, benchmarking menu pricing, or building a restaurant-discovery tool.
This guide shows you how to scrape Just Eat data with Python. You build a small, runnable scraper that fetches a Just Eat area page through the Crawling API, parses a clean record for each restaurant, follows a restaurant link to pull its menu items, handles the site's scroll-based pagination, and exports the results to JSON and CSV. The whole walkthrough stays scoped to public listing data: the names, cuisines, ratings, links, and menu prices anyone can see on an area or menu page without logging in.
What you will build
A Python script that takes a Just Eat area URL, retrieves the rendered page through the Crawling API, and extracts a structured record per restaurant. We use the London Bridge area page as the running example, the same area the legacy walkthrough used, and pull these fields from each restaurant card:
- Name the restaurant name shown on the listing card.
- Cuisine the cuisine tags, for example "Pizza, Italian".
- Rating the star rating and review count, for example "4.5(26)".
- Link the absolute URL to the restaurant's own menu page.
- Menu items per dish, the category, name, price, and description from the restaurant's menu page.
Why a plain request fails on Just Eat
If you point a bare HTTP client at a Just Eat area URL, you rarely get the restaurant list you came for. Two things work against you. First, Just Eat renders its listings client-side: the server ships a lightweight shell, and the cards fill in as the page's JavaScript runs and as you scroll, so the initial HTML is often an empty grid. Second, the site flags automated traffic quickly. Datacenter IPs and request patterns that do not look like a real browser get met with a challenge page, a CAPTCHA, or an outright block.
So a working Just Eat scraper needs two things in one request: a browser that renders the page and an IP that the site reads as a real visitor. You can assemble that yourself with a headless browser and a pool of rotating residential proxies, but keeping that stack healthy is most of the work. The Crawling API folds both into a single call: you send it the area URL, it renders the page behind a trusted residential IP, handles the rotation and CAPTCHA solving, and returns finished HTML for you to parse.
Prerequisites
You need a few things in place before writing any code. None of them take long.
Basic Python. You should be comfortable writing and running a Python script and installing packages with pip. If you are new to the language, the official Python docs or any beginner course covers the level this tutorial assumes.
Python 3.8 or later. Confirm your version with python --version (or python3 --version). If you do not have it, install it from python.org and make sure Python is on your system PATH.
A Crawlbase account and token. Sign up for a free account, open your dashboard, and copy your token. Crawlbase issues two tokens: a normal token for static sites and a JavaScript token for JS-rendered sites like Just Eat. The free tier includes 1,000 requests with no card. Treat the token like a password and keep it out of version control.
Set up the project
Create a virtual environment so project dependencies stay isolated, then install the two libraries the scraper needs. crawlbase is the official client for the Crawling API, and beautifulsoup4 parses the returned HTML so you can pull each field out of the restaurant cards by CSS selector.
python --version python -m venv just_eat_env source just_eat_env/bin/activate pip install crawlbase beautifulsoup4
On Windows, activate the environment with just_eat_env\Scripts\activate instead of the source line. With both libraries installed, create the script file the rest of the guide builds up:
touch just_eat_scraper.py
Inspecting the area page to find selectors
To scrape data, you first need to understand how the Just Eat area page is structured. Open an area page in your browser, for example the https://www.just-eat.co.uk/area/ec4r3tn page for the London Bridge area, right-click a restaurant card, and choose Inspect. Just Eat marks its key elements with stable data-qa attributes, which are far more durable than its generated utility class names. These are the elements you target:
-
Restaurant card: a
<div>withdata-qa="restaurant-card"wraps each listing. -
Restaurant name: a
<div>withdata-qa="restaurant-info-name". -
Cuisine type: a
<div>withdata-qa="restaurant-cuisine". -
Rating: a
<div>withdata-qa="restaurant-ratings". -
Restaurant link: the
hrefon the<a>tag inside the card, which is relative, so prefix it withhttps://www.just-eat.co.uk.
Step 1: Fetch the rendered area page
Start by getting the finished page. Import the CrawlingAPI class, initialize it with your token, set the area URL, and request it. Just Eat content loads asynchronously, so pass ajax_wait to wait for the dynamic content and page_wait to hold for a few seconds after load. Checking the status code before you parse keeps failures loud instead of silent.
from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def fetch_listings(url): options = {"ajax_wait": "true", "page_wait": 3000} response = api.get(url, options) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Failed to fetch the page. Status: {response['status_code']}") return None if __name__ == "__main__": area_url = "https://www.just-eat.co.uk/area/ec4r3tn" html = fetch_listings(area_url) print(html[:500] if html else "No HTML returned")
The two wait options matter for a grid that fills in after load. ajax_wait tells the API to wait for the asynchronous content to finish, and page_wait holds for a fixed number of milliseconds so the late-rendering cards appear before the page is captured. Run the script and you should see real listing markup, not an empty shell or a challenge page. That confirms rendering works before you write a single selector.
That area grid only fills in once the JavaScript runs, and Just Eat blocks traffic that does not look like a real browser. The Crawling API takes your token, runs the page in a real browser, rotates through residential IPs server-side, and handles the CAPTCHA solving, then hands you finished HTML. You skip running a headless browser fleet and a proxy pool yourself. Point it at an area page on the free 1,000-request tier first.
Step 2: Parse the restaurant cards with BeautifulSoup
With rendered HTML in hand, load it into BeautifulSoup, find every restaurant card, and pull each field by its data-qa selector. Each card carries the name, cuisine, and rating, plus an anchor whose relative href you join onto the site's base URL. A small text_of helper returns an empty string when a field is missing instead of throwing on a .text call against nothing.
from bs4 import BeautifulSoup BASE = "https://www.just-eat.co.uk" def text_of(card, selector): el = card.select_one(selector) return el.get_text(strip=True) if el else "" def parse_restaurants(html): soup = BeautifulSoup(html, "html.parser") restaurants = [] cards = soup.select('div[data-qa="restaurant-card"]') for card in cards: try: anchor = card.select_one("a[href]") link = BASE + anchor["href"] if anchor else "" restaurants.append({ "name": text_of(card, 'div[data-qa="restaurant-info-name"]'), "cuisine": text_of(card, 'div[data-qa="restaurant-cuisine"]'), "rating": text_of(card, 'div[data-qa="restaurant-ratings"]'), "link": link, }) except Exception as e: print(f"Skipped a card: {e}") return restaurants
The data-qa="restaurant-card" selector finds the listing containers, and select_one reads each field inside a card. The rating field comes through as a combined string like "4.5(26)", the star score followed by the review count in parentheses; keep it raw here and split it downstream if you need the two values separately. The link is relative on the page, so prefixing it with BASE gives you an absolute URL you can follow straight to the menu. Wrapping each card in a try/except means one malformed listing does not crash the whole run.
Just Eat's data-qa attributes are meant for the site's own testing, which makes them more stable than generated class names, but they are not a contract. Treat the selectors above as a starting template. When a field comes back empty for every card, re-inspect the live area page in your browser's dev tools and update the selector. Periodic selector maintenance is normal for any production scraper.
Step 3: Handle scroll-based pagination
Just Eat does not paginate with numbered pages. It uses infinite scroll: more restaurants load as you scroll toward the bottom. The Crawling API can drive that scroll for you, so you do not have to manage it manually. Swap the wait options for scroll and a scroll_interval, which tells the API how many seconds to keep scrolling and loading before it captures the page. You do not need page_wait alongside it; the scroll interval covers the wait.
def fetch_listings(url): options = {"scroll": "true", "scroll_interval": "20"} response = api.get(url, options) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Failed to fetch the page. Status: {response['status_code']}") return None
Here scroll_interval is set to 20, so the API scrolls for 20 seconds before capturing, long enough to load most restaurants in a busy area. Raise it for denser areas and lower it for quiet ones; longer scrolls cost more time per request, so tune it to the page. With this in place, parse_restaurants sees the full grid rather than just the first screen.
Step 4: Assemble the listings script and export JSON and CSV
Now wire the fetch and the parse into one runnable script, then write the records to both JSON and CSV so you can load them into a notebook or a spreadsheet. A shared FIELDS list keeps the CSV column order in step with the dictionary keys so the two exports never drift apart.
import csv import json from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) BASE = "https://www.just-eat.co.uk" FIELDS = ["name", "cuisine", "rating", "link"] def fetch_listings(url): options = {"scroll": "true", "scroll_interval": "20"} response = api.get(url, options) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Failed to fetch the page. Status: {response['status_code']}") return None def text_of(card, selector): el = card.select_one(selector) return el.get_text(strip=True) if el else "" def parse_restaurants(html): soup = BeautifulSoup(html, "html.parser") restaurants = [] cards = soup.select('div[data-qa="restaurant-card"]') for card in cards: try: anchor = card.select_one("a[href]") link = BASE + anchor["href"] if anchor else "" restaurants.append({ "name": text_of(card, 'div[data-qa="restaurant-info-name"]'), "cuisine": text_of(card, 'div[data-qa="restaurant-cuisine"]'), "rating": text_of(card, 'div[data-qa="restaurant-ratings"]'), "link": link, }) except Exception as e: print(f"Skipped a card: {e}") return restaurants def export(rows, name="just_eat_restaurants"): with open(f"{name}.json", "w", encoding="utf-8") as f: json.dump(rows, f, indent=4, ensure_ascii=False) with open(f"{name}.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=FIELDS) writer.writeheader() writer.writerows(rows) print(f"Saved {len(rows)} restaurants to {name}.json and {name}.csv") def main(): url = "https://www.just-eat.co.uk/area/ec4r3tn" html = fetch_listings(url) if not html: return rows = parse_restaurants(html) export(rows) if __name__ == "__main__": main()
Run the full script with python just_eat_scraper.py. It fetches the rendered, scrolled area page, parses one row per restaurant, and writes both just_eat_restaurants.json and just_eat_restaurants.csv. The link field on each row is the exact URL you feed into the menu scraper in the next section.
What the listings output looks like
You get a clean list of restaurant records, in listing order, ready to write to JSON, CSV, or a database.
[ { "name": "Tower Mangal", "cuisine": "Turkish, Mediterranean", "rating": "4.5(26)", "link": "https://www.just-eat.co.uk/restaurants-tower-mangal-southwark/menu" }, { "name": "Sud Italia", "cuisine": "Pizza, Italian", "rating": "3(2)", "link": "https://www.just-eat.co.uk/restaurants-sud-italia-aldgate/menu" } ]
Step 5: Scrape a restaurant's menu
The listing link points straight at a restaurant's menu page, which holds the deeper detail: the dishes, their prices, and their descriptions, grouped by category. The menu page is also JavaScript-rendered and scroll-paginated, so the fetch logic mirrors the listings fetch. Inspect a menu page the same way, and you find these elements:
-
Category: a
<section>withdata-qa="item-category"; its name lives in the<h2>withdata-qa="heading". -
Dish name: inside the item's
<h2>withdata-qa="heading". -
Dish price: inside a
<span>whose class starts withformatted-currency-style. -
Dish description: inside a
<div>whose class starts withnew-item-style_item-description.
Because the price and description classes are generated with a stable prefix, the parser matches on the prefix with the [class^="..."] attribute selector rather than the full, volatile class name. A small re.sub call collapses the runs of whitespace that Just Eat leaves in long descriptions.
import csv import json import re from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) MENU_FIELDS = ["category", "name", "price", "description"] def fetch_menu_page(url): options = {"scroll": "true", "scroll_interval": "15"} response = api.get(url, options) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Failed to fetch the menu page. Status: {response['status_code']}") return None def text_of(node, selector, default=""): el = node.select_one(selector) return el.get_text(strip=True) if el else default def parse_menu(html): soup = BeautifulSoup(html, "html.parser") menu = [] categories = soup.select('section[data-qa="item-category"]') for category in categories: category_name = text_of(category, 'h2[data-qa="heading"]', "Uncategorized") items = category.select('div[data-qa="item-category-list"] div[data-qa="item"]') for item in items: description = text_of(item, 'div[class^="new-item-style_item-description"]') menu.append({ "category": category_name, "name": text_of(item, 'h2[data-qa="heading"]'), "price": text_of(item, 'span[class^="formatted-currency-style"]'), "description": re.sub(r"\s+", " ", description), }) return menu def export_menu(rows, name="just_eat_menu"): with open(f"{name}.json", "w", encoding="utf-8") as f: json.dump(rows, f, indent=4, ensure_ascii=False) with open(f"{name}.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=MENU_FIELDS) writer.writeheader() writer.writerows(rows) print(f"Saved {len(rows)} menu items to {name}.json and {name}.csv") def main(): menu_url = "https://www.just-eat.co.uk/restaurants-tower-mangal-southwark/menu" html = fetch_menu_page(menu_url) if not html: return rows = parse_menu(html) export_menu(rows) if __name__ == "__main__": main()
The menu page scrolls too, so fetch_menu_page uses the same scroll option with a shorter scroll_interval of 15 seconds, since most menus are smaller than a busy area's restaurant grid. parse_menu walks each data-qa="item-category" section, reads the category heading once, then loops the items inside it and records the dish name, price, and cleaned description. To go wider, feed it the link values from your listings export and pace the requests with a short delay between restaurants, the same way you would across area pages.
What the menu output looks like
Each menu item becomes one flat record tagged with its category, so the export loads cleanly into a spreadsheet or a price-comparison pipeline.
[ { "category": "What's New?", "name": "Terry's Chocolate Orange Pie", "price": "£2.49", "description": "Crispy chocolate pastry filled with a chocolate orange ganache." }, { "category": "What's New?", "name": "Large Grimace Shake", "price": "£3.99", "description": "Milkshake base blended with blueberry-flavour syrup." } ]
Scaling across areas and staying unblocked
One area page is a demo; a real research job runs across many postcodes and then drills into each restaurant's menu. Just Eat exposes an area page for every postcode at its own /area/ URL, so you keep a list of postcodes, scrape each area, then follow the link on each restaurant into the menu scraper. A few habits keep that wider run healthy, and they apply to any hard commercial target.
- Pace your requests. Put a delay between area pages and between menu fetches rather than firing everything at once. Schedule heavier jobs during off-peak hours to ease load on the site's servers.
- Lean on rotation. A pool of residential IPs spreads requests across many real-user addresses so no single one trips a rate limit. The Crawling API handles this for you; if you roll your own stack, this is the part to get right.
-
Tune the scroll. Set
scroll_intervalto match how dense each page is, so you load every card without paying for idle scrolling on a short list. -
Retain only what you need. Store the listing and menu fields your project uses and discard the rest. Re-check your
data-qaselectors periodically so the scraper keeps pace with markup changes.
For the broader playbook on avoiding blocks, see how to scrape websites without getting blocked, and for more on why rendering matters here, how to crawl JavaScript websites. If you are coming to Python scraping fresh, scrape a website with Python covers the fundamentals, and for turning menu prices into a comparison feed, web scraping for price intelligence shows where this data leads.
Is it legal to scrape Just Eat?
Whether scraping Just Eat is allowed depends on Just Eat's Terms and Conditions, your jurisdiction, and what you do with the data. Just Eat's terms restrict automated access, so scraping can run against those terms regardless of how careful your tooling is. None of the code here changes that; it just makes the technical part work. Read Just Eat's Terms and Conditions and its robots.txt, and treat both as the boundary for what you collect. For commercial or competitive use, the legal picture gets more complex, and consulting a legal expert about your specific case is the sensible move.
A few lines worth holding to. Collect only public data: the restaurant names, cuisines, ratings, listing links, and menu items that anyone can see on an area or menu page without an account. Keep your request volume low enough that you are not straining Just Eat's servers, and avoid personal data, including anything tied to identifiable customers, reviewers, or named individuals beyond what is publicly listed. The dish descriptions and photos on a menu are the restaurant's own copyrighted content, so do not republish them wholesale as if they were yours.
This guide is deliberately scoped to public area and menu pages because that is the line that keeps the work defensible. It does not cover anything behind a login, account or order history, payment details, or any attempt to bypass authentication or a CAPTCHA you are not entitled to pass. If your project needs more than public listing data, or guaranteed structure and commercial rights, an official partnership or data agreement with Just Eat is the correct path, not a cleverer scraper.
Key takeaways
-
Just Eat area pages are a public restaurant catalog. Each
/area/page lists who delivers in a postcode with name, cuisine, rating, and a link, which is why it is useful for local food-market research. - You need rendering and a trusted IP together. Just Eat fills its grid client-side and blocks bot traffic, so the Crawling API renders the page behind a residential IP in one call.
-
Lean on the
data-qaselectors. Loopdata-qa="restaurant-card"cards for listings anddata-qa="item-category"sections for menus; these test attributes are sturdier than generated class names but still drift. -
Drive infinite scroll with the API. Pass
scrollandscroll_intervalinstead of managing the scroll yourself, and tune the interval to how dense each page is. - Stay on public data. Respect Just Eat's Terms and robots.txt, avoid accounts, orders, and personal information, and do not republish copyrighted menu content as your own.
Frequently Asked Questions (FAQs)
Why does a plain request return no restaurants from Just Eat?
Just Eat renders its restaurant grid client-side and loads more cards as you scroll, so a raw request often gets an empty shell. On top of that, the site challenges or blocks traffic that does not look like a real browser. Rendering the page through the Crawling API behind a trusted IP, with the scroll option enabled, solves both, which is why the scraper here routes its request through it.
How do I scrape Just Eat for a specific area?
Every Just Eat area has its own stable /area/ URL keyed on the postcode, for example /area/ec4r3tn for the London Bridge area. Point the scraper at the area URL you want. To cover many areas, keep a list of postcodes and loop over their URLs, pacing the requests with a short delay between them.
Can I extract menu information for specific restaurants?
Yes. Each listing's link field points straight at the restaurant's menu page. Feed that URL into the menu scraper to pull the dish name, price, and description grouped by category. The menu page is JavaScript-rendered and scroll-paginated like the area page, so the same scroll option loads the full menu before parsing.
How does the scraper handle Just Eat's infinite scroll?
Just Eat uses scroll-based pagination rather than numbered pages. Instead of automating the scroll yourself, pass scroll: "true" and a scroll_interval in seconds to the Crawling API, and it scrolls the page server-side until the interval elapses, then returns the fully loaded HTML. Raise the interval for denser areas and lower it for short menus.
Why use data-qa selectors instead of class names?
Just Eat ships generated utility class names that change without notice, while its data-qa attributes exist for the site's own automated testing and stay more stable across releases. Targeting data-qa="restaurant-card" or data-qa="item-category" gives you a sturdier hook. For the price and description, which use generated classes with a fixed prefix, the parser matches on that prefix with a [class^="..."] selector.
How do I avoid getting blocked while scraping Just Eat?
Keep your per-IP request rate low, add a delay between area and menu fetches, and route through rotating residential IPs so no single address trips a rate limit. The Crawling API manages rotation, a trusted IP pool, and CAPTCHA handling for you; if you build your own stack, that is the part to invest in. Watch the status codes and back off when you start seeing challenges.
Crawl any site at scale, without fighting infrastructure.
Crawlbase handles proxies, fingerprints, and CAPTCHAs so your team ships data pipelines instead of maintaining crawl plumbing. 1,000 requests free, no card required.
