Just Eat 是欧洲最大的在线外卖配送市场之一,把数百万食客与本地餐厅连接起来。每个区域页面都是一份公开的、结构化的目录,列出谁在附近配送:餐厅名称、它做哪些菜系、它的星级评分、配送详情,以及一个直达其菜单的链接。对任何研究本地餐饮市场、追踪哪些菜系在某个邮编里占主导、对菜单定价做基准比较,或构建一个餐厅发现工具的人来说,那份数据都是一个清晰的信号。

本指南将向你展示如何用 Python 抓取 Just Eat 数据。你会构建一个小巧、可运行的抓取脚本,它通过 Crawling API 获取一个 Just Eat 区域页面,为每家餐厅解析出一条干净的记录,跟随一个餐厅链接去拉取它的菜单条目,处理该站点基于滚动的分页,并把结果导出为 JSON 和 CSV。整个演示始终限定在公开的列表数据范围内:任何人不登录就能在一个区域页或菜单页上看到的名称、菜系、评分、链接和菜单价格。

你将构建什么

一个 Python 脚本,它接收一个 Just Eat 区域 URL,通过 Crawling API 取回已渲染的页面,并为每家餐厅提取一条结构化记录。我们以伦敦桥区域页面作为贯穿示例,也就是旧版演示所用的同一个区域,并从每张餐厅卡片里提取以下字段:

  • 名称 列表卡片上显示的餐厅名称。
  • 菜系 菜系标签,例如“Pizza, Italian”。
  • 评分 星级评分和评论数,例如“4.5(26)”。
  • 链接 指向餐厅自己菜单页面的绝对 URL。
  • 菜单条目 逐道菜,来自餐厅菜单页面的类别、名称、价格和描述。

为什么普通请求在 Just Eat 上会失败

如果你把一个裸 HTTP 客户端指向一个 Just Eat 区域 URL,你很少能得到你想要的餐厅列表。有两件事在跟你作对。第一,Just Eat 在客户端渲染它的列表:服务器送来一个轻量外壳,卡片要随页面 JavaScript 运行、随你滚动才填充进来,所以初始 HTML 往往是一个空网格。第二,该站点会很快标记自动化流量。数据中心 IP 以及看起来不像真实浏览器的请求模式会遇到一个挑战页、一个 CAPTCHA,或被直接封禁。

因此,一个能用的 Just Eat 抓取器在一次请求里需要两样东西:一个渲染页面的浏览器,以及一个被站点视为真实访客的 IP。你可以用一个无头浏览器加一池轮换的住宅代理自己搭建这一切,但让这套技术栈保持健康才是大部分工作量所在。Crawling API 把两者收进单次调用:你把区域 URL 发给它,它在受信任的住宅 IP 背后渲染页面,处理轮换和 CAPTCHA 求解,并返回成品 HTML 供你解析。

前置条件

在写任何代码之前,你需要准备好几样东西。它们都不会花太久。

基本的 Python。 你应当能自如地编写并运行一个 Python 脚本,并用 pip 安装包。如果你刚接触这门语言,官方 Python 文档或任何入门课程都涵盖了本教程所假定的水平。

Python 3.8 或更高版本。python --version(或 python3 --version)确认你的版本。如果没有,请从 python.org 安装它,并确保 Python 在你的系统 PATH 上。

一个 Crawlbase 账户和 token。 注册一个免费账户,打开你的仪表板,并复制你的 token。Crawlbase 签发两个 token:一个用于静态站点的普通 token,以及一个用于像 Just Eat 这样 JS 渲染站点的 JavaScript token。免费套餐包含 1,000 次请求,无需绑卡。把 token 当作密码对待,别把它放进版本控制里。

搭建项目

创建一个虚拟环境以便让项目依赖保持隔离,然后安装抓取器所需的两个库。crawlbase 是 Crawling API 的官方客户端,而 beautifulsoup4 解析返回的 HTML,让你能按 CSS 选择器从餐厅卡片里取出每个字段。

bash
python --version

python -m venv just_eat_env
source just_eat_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,用 just_eat_env\Scripts\activate 而不是 source 那一行来激活环境。两个库都装好之后,创建本指南后续逐步搭建的脚本文件:

bash
touch just_eat_scraper.py

检查区域页面以找到选择器

要抓取数据,你首先需要理解 Just Eat 区域页面是如何构建的。在你的浏览器里打开一个区域页面,例如伦敦桥区域的 https://www.just-eat.co.uk/area/ec4r3tn 页面,右键点击一张餐厅卡片,选择“检查”。Just Eat 用稳定的 data-qa 属性标记它的关键元素,这些属性远比它生成的工具类名更耐用。这些就是你要瞄准的元素:

  • 餐厅卡片: 一个带 data-qa="restaurant-card"<div> 包裹着每条列表。
  • 餐厅名称: 一个带 data-qa="restaurant-info-name"<div>
  • 菜系类型: 一个带 data-qa="restaurant-cuisine"<div>
  • 评分: 一个带 data-qa="restaurant-ratings"<div>
  • 餐厅链接: 卡片内 <a> 标签上的 href,它是相对的,所以给它加上 https://www.just-eat.co.uk 前缀。

第 1 步:获取已渲染的区域页面

先取到成品页面。导入 CrawlingAPI 类,用你的 token 初始化它,设置区域 URL,并请求它。Just Eat 的内容是异步加载的,所以传入 ajax_wait 以等待动态内容,并传入 page_wait 以在加载后保持几秒。在解析之前检查状态码能让失败响亮地暴露出来,而不是悄无声息。

python
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")

这两个等待选项对一个加载后才填充的网格很重要。ajax_wait 告诉 API 等待异步内容完成,而 page_wait 保持固定的毫秒数,让那些迟渲染的卡片在页面被捕获之前出现。运行脚本,你应当看到真实的列表标记,而不是一个空壳或一个挑战页。这在你写下一个选择器之前就确认了渲染是有效的。

Crawlbase Crawling API

那个区域网格只在 JavaScript 运行之后才填充,而且 Just Eat 会拦截看起来不像真实浏览器的流量。Crawling API 接收你的 token,在一个真实浏览器里运行页面,在服务端通过住宅 IP 轮换,并处理 CAPTCHA 求解,然后把成品 HTML 交给你。你无需自己运行一支无头浏览器舰队和一个代理池。先在免费的 1,000 次请求套餐上把它指向一个区域页面。

第 2 步:用 BeautifulSoup 解析餐厅卡片

手里有了已渲染的 HTML,就把它加载进 BeautifulSoup,找出每张餐厅卡片,并按其 data-qa 选择器取出每个字段。每张卡片都带有名称、菜系和评分,外加一个锚点,其相对 href 你要拼接到站点的基础 URL 上。一个小巧的 text_of 辅助函数会在字段缺失时返回空字符串,而不是在对着空值的 .text 调用上抛错。

python
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

data-qa="restaurant-card" 选择器找出列表容器,而 select_one 读取卡片内的每个字段。评分字段以一个像 "4.5(26)" 这样的组合字符串呈现,星级分数后面跟着括号里的评论数;这里保留原样,如果你需要把两个值分开,就在下游拆分它。链接在页面上是相对的,所以给它加上 BASE 前缀,会给你一个可直达菜单的绝对 URL。把每张卡片包在一个 try/except 里,意味着一条畸形的列表不会让整次运行崩溃。

选择器会漂移

Just Eat 的 data-qa 属性是为站点自己的测试而设的,这让它们比生成的类名更稳定,但它们不是一份契约。把上面的选择器当作一个起始模板。当某个字段在每张卡片上都返回为空时,在你的浏览器开发者工具里重新检查实时区域页面,并更新选择器。对任何生产级抓取器来说,定期的选择器维护都属正常。

第 3 步:处理基于滚动的分页

Just Eat 不用带编号的页面分页。它用无限滚动:当你向底部滚动时,更多餐厅会加载进来。Crawling API 可以替你驱动那次滚动,所以你不必手动管理它。把等待选项换成 scroll 和一个 scroll_interval,后者告诉 API 在捕获页面之前持续滚动并加载多少秒。你不需要在它旁边再设 page_wait;滚动间隔已经覆盖了等待。

python
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

这里 scroll_interval 设为 20,所以 API 在捕获之前滚动 20 秒,足够在一个繁忙区域加载大多数餐厅。对更密集的区域调高它,对清闲的区域调低它;更长的滚动会让每次请求耗时更多,所以按页面来调它。有了这个,parse_restaurants 看到的就是完整的网格,而不只是第一屏。

第 4 步:组装列表脚本并导出 JSON 和 CSV

现在把抓取和解析接成一个可运行的脚本,然后把记录同时写入 JSON 和 CSV,这样你就能把它们加载进一个笔记本或一个电子表格。一个共享的 FIELDS 列表让 CSV 的列顺序与字典的键保持一致,使两份导出永不彼此漂移。

python
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()

python just_eat_scraper.py 运行完整脚本。它会获取已渲染、已滚动的区域页面,每家餐厅解析一行,并写出 just_eat_restaurants.jsonjust_eat_restaurants.csv 两个文件。每一行上的 link 字段就是你喂进下一节菜单抓取器里的那个确切 URL。

列表输出长什么样

你会得到一份干净的餐厅记录列表,按列表顺序排列,可以写入 JSON、CSV 或一个数据库。

json
[
  {
    "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"
  }
]

第 5 步:抓取一家餐厅的菜单

列表链接直接指向一家餐厅的菜单页面,那里有更深的细节:各道菜、它们的价格和它们的描述,按类别分组。菜单页面同样是 JavaScript 渲染并按滚动分页的,所以抓取逻辑镜像了列表抓取。以同样的方式检查一个菜单页面,你会找到这些元素:

  • 类别: 一个带 data-qa="item-category"<section>;它的名称位于带 data-qa="heading"<h2> 里。
  • 菜名: 在条目带 data-qa="heading"<h2> 里面。
  • 菜价: 在一个 class 以 formatted-currency-style 开头的 <span> 里面。
  • 菜品描述: 在一个 class 以 new-item-style_item-description 开头的 <div> 里面。

因为价格和描述的 class 是用一个稳定前缀生成的,解析器用 [class^="..."] 属性选择器按前缀匹配,而不是按那个完整、易变的 class 名。一个小巧的 re.sub 调用会折叠 Just Eat 在长描述里留下的连续空白。

python
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()

菜单页面也会滚动,所以 fetch_menu_page 使用同样的 scroll 选项,配上一个更短的 scroll_interval,为 15 秒,因为大多数菜单比一个繁忙区域的餐厅网格更小。parse_menu 遍历每个 data-qa="item-category" 区块,读取一次类别标题,然后循环其内部的条目并记录菜名、价格和清理过的描述。要扩大范围,把你列表导出里的 link 值喂给它,并在各家餐厅之间用一个短暂延迟给请求设定节奏,方式和你跨区域页面时一样。

菜单输出长什么样

每个菜单条目变成一条带有其类别标记的扁平记录,所以这份导出能干净地加载进一个电子表格或一个价格比较管线。

json
[
  {
    "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."
  }
]

跨区域扩展并保持不被封锁

一个区域页只是演示;一项真实的研究任务会跨许多邮编运行,然后钻进每家餐厅的菜单。Just Eat 在它自己的 /area/ URL 上为每个邮编暴露一个区域页,所以你保留一份邮编列表,抓取每个区域,然后跟随每家餐厅上的 link 进入菜单抓取器。几个习惯能让那次更宽的运行保持健康,它们适用于任何难啃的商业目标。

  • 给你的请求设定节奏。 在各区域页之间以及各菜单获取之间放一个延迟,而不要一次性把所有请求都打出去。把更重的任务安排在非高峰时段,以减轻站点服务器的负载。
  • 依靠轮换。 一池住宅 IP 会把请求分散到许多真实用户地址上,这样就没有任何单个地址会触发速率限制。Crawling API 替你处理这件事;如果你自己搭建技术栈,这就是要做好的那一部分。
  • 调好滚动。scroll_interval 设得与每个页面的密集程度相匹配,这样你就能加载到每张卡片,而不必为一个短列表上的空转滚动付费。
  • 只保留你需要的。 存储你项目所用的列表和菜单字段,丢弃其余的。定期重新检查你的 data-qa 选择器,让抓取器跟得上标记的变化。

关于避免被封的更宽泛策略手册,参阅如何在不被封锁的情况下抓取网站;关于为什么这里渲染很重要的更多内容,参阅如何爬取 JavaScript 网站。如果你是初次接触 Python 抓取,用 Python 抓取一个网站涵盖了基础知识;而要把菜单价格变成一个比较数据源,将网页抓取用于价格情报展示了这些数据通向何处。

抓取 Just Eat 合法吗?

抓取 Just Eat 是否被允许,取决于 Just Eat 的条款与条件、你所在的司法辖区,以及你拿数据做什么。Just Eat 的条款限制自动化访问,所以无论你的工具有多谨慎,抓取都可能违背这些条款。这里的任何代码都改变不了这一点;它只是把技术部分做通而已。请阅读 Just Eat 的条款与条件及其 robots.txt,并把两者都当作你采集内容的边界。对于商业或竞争性用途,法律图景会变得更复杂,就你的具体情况咨询一位法律专家是明智之举。

有几条值得守住的底线。只采集公开数据:任何人不用账户就能在一个区域页或菜单页上看到的餐厅名称、菜系、评分、列表链接和菜单条目。把你的请求量保持得足够低,以免给 Just Eat 的服务器造成压力,并避开个人数据,包括任何超出公开列出范围、与可识别的顾客、评论者或具名个人相关联的东西。菜单上的菜品描述和照片是餐厅自己的受版权保护的内容,所以不要把它们当作你自己的整体转载。

本指南刻意限定在公开的区域页和菜单页,因为那是让这项工作站得住脚的那条线。它不涵盖任何登录后面的东西、账户或订单历史、支付详情,或任何绕过认证或一个你无权通过的 CAPTCHA 的尝试。如果你的项目需要超出公开列表数据的内容,或需要有保障的结构和商业权利,正确的路径是与 Just Eat 建立官方合作关系或数据协议,而不是一个更聪明的抓取器。

回顾

核心要点

  • Just Eat 区域页是一份公开的餐厅目录。 每个 /area/ 页都列出谁在某个邮编里配送,附带名称、菜系、评分和一个链接,这正是它对本地餐饮市场研究有用的原因。
  • 你需要渲染和受信任的 IP 一起到位。 Just Eat 在客户端填充它的网格并拦截机器人流量,所以 Crawling API 一次调用就在一个住宅 IP 背后渲染页面。
  • 依靠 data-qa 选择器。 为列表遍历 data-qa="restaurant-card" 卡片,为菜单遍历 data-qa="item-category" 区块;这些测试属性比生成的类名更结实,但仍会漂移。
  • 用 API 驱动无限滚动。 传入 scrollscroll_interval 而不是自己管理滚动,并把间隔调到与每个页面的密集程度相匹配。
  • 坚守公开数据。 尊重 Just Eat 的条款和 robots.txt,避开账户、订单和个人信息,并且不要把受版权保护的菜单内容当作你自己的来转载。

常见问题

为什么普通请求从 Just Eat 返回不了任何餐厅?

Just Eat 在客户端渲染它的餐厅网格,并随你滚动加载更多卡片,所以一个原始请求往往得到一个空壳。除此之外,该站点还会挑战或拦截看起来不像真实浏览器的流量。通过 Crawling API 在一个受信任的 IP 背后渲染页面,并启用滚动选项,能同时解决这两点,这正是这里的抓取器把它的请求经由它路由的原因。

我该如何抓取 Just Eat 的某个特定区域?

每个 Just Eat 区域都有它自己稳定的、以邮编为键的 /area/ URL,例如伦敦桥区域的 /area/ec4r3tn。把抓取器指向你想要的那个区域 URL。要覆盖许多区域,保留一份邮编列表并遍历它们的 URL,在它们之间用一个短暂延迟给请求设定节奏。

我可以为特定餐厅提取菜单信息吗?

可以。每条列表的 link 字段直接指向餐厅的菜单页面。把那个 URL 喂进菜单抓取器,以按类别分组拉取菜名、价格和描述。菜单页面像区域页一样是 JavaScript 渲染并按滚动分页的,所以同一个 scroll 选项会在解析之前加载完整的菜单。

抓取器如何处理 Just Eat 的无限滚动?

Just Eat 使用基于滚动的分页,而不是带编号的页面。不要自己自动化滚动,而是把 scroll: "true" 和一个以秒为单位的 scroll_interval 传给 Crawling API,它会在服务端滚动页面直到间隔耗尽,然后返回完全加载好的 HTML。对更密集的区域调高间隔,对短菜单调低它。

为什么用 data-qa 选择器而不是类名?

Just Eat 送来的生成的工具类名会不经通知地变化,而它的 data-qa 属性为站点自己的自动化测试而存在,在各次发布之间保持得更稳定。瞄准 data-qa="restaurant-card"data-qa="item-category" 给你一个更结实的抓手。对于价格和描述,它们用带固定前缀的生成 class,解析器用一个 [class^="..."] 选择器按那个前缀匹配。

抓取 Just Eat 时我该如何避免被封?

把你的每 IP 请求速率保持得低,在区域获取和菜单获取之间加一个延迟,并通过轮换的住宅 IP 路由,这样就没有任何单个地址会触发速率限制。Crawling API 替你管理轮换、一个受信任的 IP 池和 CAPTCHA 处理;如果你搭建自己的技术栈,那就是要投入的那部分。留意状态码,并在你开始看到挑战时后退。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量