1. 项目概述为什么是 Jakarta 的 Zomato 数据又为什么非得用 Selenium你点开 Zomato 网站输入“Jakarta”页面唰一下加载出几十家餐厅——名字、评分、人均、地址、营业时间一应俱全。但如果你真想把这整座城市的餐饮生态摸个底朝天靠手动翻页、复制粘贴别说 1002 页翻到第 5 页你手就酸了眼睛就花了脑子就开始怀疑人生我到底是在研究美食还是在给网站当免费数据录入员这就是 Rizqi 在 2021 年初面对的真实困境。当时雅加达正经历疫情最胶着的阶段堂食关闭外卖成了唯一出口。他发现自己连续几周只点 KFC 和 Soto Kudus不是因为不爱尝试而是信息太窄、选择太难——Zomato 上明明有上万家餐厅可算法推给他的永远是那几张老面孔。他真正想问的是那些藏在数据背后的问题雅加达到底有多少家做 Soto 的店Rendang 这道国菜在城市不同区域的分布密度如何哪些社区的餐厅类型最丰富哪些区域的平均消费水平最高这些问题的答案不在任何一份政府报告里而在 Zomato 页面源码的 HTML 标签深处。而解决它的钥匙就是 Selenium。很多人一听“爬虫”第一反应是 requests BeautifulSoup快、轻量、干净。但 Zomato 不是静态网页它是个典型的现代单页应用SPA。你用 requests 去 GET 首页拿到的是一堆 JavaScript 脚本和空荡荡的div idroot/div等脚本在浏览器里跑完真正的餐厅列表才被动态渲染出来。requests 看不见这个过程它只负责“拿”不负责“执行”。Selenium 则完全不同——它本质上是操控一个真实的 Chrome 浏览器。你写一行driver.get(url)它就真的打开一个 Chrome 窗口点击、滚动、等待、提取就像一个不知疲倦的数字实习生。它能处理 AJAX 加载、JavaScript 渲染、用户交互比如点“下一页”按钮、甚至模拟鼠标悬停触发的下拉菜单。这种“所见即所得”的能力是静态请求库永远无法替代的核心价值。当然代价也很真实慢。Selenium 启动浏览器、加载资源、渲染页面每一步都比发个 HTTP 请求重得多。但在这个项目里慢不是缺陷反而是必要条件。因为我们要抓取的不是一张快照而是一个需要“互动”才能展开的完整数据世界。Rizqi 最终抓到了约 14,500 家雅加达餐厅的详细档案这个量级的数据如果不用 Selenium 这种“浏览器级”的工具根本无从谈起。它不是为了炫技而是为了解决一个具体、真实、且无法绕开的技术瓶颈当数据被 JavaScript 封装在浏览器的运行时环境里你就必须派一个“浏览器”进去把它请出来。这就是我们今天要复现、拆解、并真正掌握它的全部原因。2. 核心细节解析与实操要点从零搭建一个稳定、可维护的 Selenium 抓取框架一个能跑通的 Selenium 脚本和一个能长期稳定运行、不崩溃、不漏数据、还能让人看懂的生产级抓取框架中间隔着的不是代码行数而是无数个深夜调试出来的经验坑。Rizqi 的原始代码是一个极佳的起点但它更像一份“实验笔记”而非一份可交付的工程方案。我们来把它补全补成一个你能直接抄作业、改参数、就能在自己电脑上跑起来的完整工作流。2.1 环境准备不只是装个 chromedriver第一步永远是环境。Rizqi 提到下载 chromedriver并给出了 Windows 路径。这没错但远远不够。一个健壮的环境必须考虑三个维度版本兼容性、路径管理、以及异常兜底。版本兼容性这是新手踩坑率最高的地方。Chrome 浏览器更新频繁而 chromedriver 是严格绑定 Chrome 版本的。你装了个最新版 Chrome却用着半年前下载的 chromedriver结果就是SessionNotCreatedException——浏览器打不开。解决方案很简单去 ChromeDriver 官网 根据你本地 Chrome 的版本号在 Chrome 地址栏输入chrome://version/查看下载对应版本的驱动。或者更省心的办法是使用webdriver-manager库它能自动帮你下载和管理匹配的驱动pip install webdriver-manager然后在代码里把原来硬编码的路径替换掉from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager # 自动下载并管理驱动无需手动指定路径 service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)这行代码能让你的脚本在任何一台装了 Chrome 的机器上第一次运行时自动搞定驱动省去所有版本焦虑。路径管理Rizqi 的chromepathrC:\Users\Downloads\...是典型的手动路径脆弱且不跨平台。webdriver-manager已经解决了这个问题但更重要的是理解其背后的逻辑驱动文件最终会被解压到一个临时目录Service对象会精准地指向它。这比任何硬编码都可靠。异常兜底原始代码里driver.close()放在循环外面这意味着一旦某个页面出错整个浏览器进程就挂了后续所有 URL 都无法访问。正确的做法是为每一次独立的页面访问都包裹在一个完整的try...finally块里确保无论成功失败浏览器窗口都能被正确关闭或重用。我们稍后会在完整流程中展示。2.2 元素定位策略从“找得到”到“找得稳”Rizqi 的代码大量使用了find_element_by_*方法比如find_element_by_tag_name(h1)。这是一个重要的历史细节这些方法在 Selenium 4.x 中已被完全弃用。如果你现在照着原文代码去跑会立刻遇到AttributeError。我们必须升级到现代的、基于By类的定位方式。这不是简单的语法替换而是一次思维升级。# ❌ 过时的写法Selenium 4 name_anchor driver.find_element_by_tag_name(h1) # ✅ 现代的写法Selenium 4 from selenium.webdriver.common.by import By name_anchor driver.find_element(By.TAG_NAME, h1)更关键的是Rizqi 的 XPath 写法如/html/body/div[1]/div[2]/main/div/section[3]/section/section[1]/section[1]/a是典型的“绝对 XPath”。它像一条精确到毫米的手术刀路径但极其脆弱。只要 Zomato 前端工程师在div里多加了一个空格、少套了一层section或者把section[1]改成section[2]这条 XPath 就会瞬间失效返回NoSuchElementException。真正的工程实践追求的是“相对 XPath”或“CSS 选择器”它们基于元素的语义、属性和上下文关系而非其在 DOM 树中的绝对位置。我们来解构几个关键定位点餐厅名称 (h1)Rizqi 用By.TAG_NAME, h1是对的因为每个餐厅详情页的主标题确实只有一个h1。但更稳妥的做法是加上一个wait机制确保元素加载完成再去找from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 等待 h1 元素出现最多等 10 秒 name_anchor WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, h1)) ) name name_anchor.text餐厅类型多个a标签Rizqi 发现class名称不一致转而用 XPath。但观察 Zomato 页面结构这些类型标签通常位于一个具有明确语义的容器内比如一个div的class包含cuisine或categories。我们可以用 CSS 选择器来定位这个容器再找它里面的a# 更鲁棒的写法先找容器再找里面的链接 try: # 找到包含餐厅类型的父容器 category_container driver.find_element(By.CSS_SELECTOR, div.cuisine, div.categories, section[data-testidrestaurant-cuisines]) # 在容器内找所有链接 type_elements category_container.find_elements(By.TAG_NAME, a) rest_types [el.text for el in type_elements] except NoSuchElementException: rest_types [Unknown]餐厅地址Rizqi 的 XPath 指向了section[4]/article/p。但地址信息往往有更稳定的标识比如一个p标签的class是address或者它前面紧挨着一个h3标签文本是 Address。利用这种“兄弟关系”来定位比数section序号可靠得多# 利用兄弟关系找到文本为 Address 的 h3然后取它后面的 p try: address_header driver.find_element(By.XPATH, //h3[text()Address]) address_p address_header.find_element(By.XPATH, ./following-sibling::p) address address_p.text except NoSuchElementException: address Address not found核心原则就一条永远优先寻找元素的“语义”和“上下文”而不是它的“坐标”。一个classrestaurant-name比div[2]/h1可靠一万倍一个text()Address的h3比section[4]可靠一万倍。这是让脚本从“一次性的玩具”变成“能活过三个月的工具”的分水岭。2.3 反爬对抗不是“能不能”而是“该不该”与“怎么稳”Rizqi 在文首就加了免责声明“本文仅用于教育目的……我们不鼓励任何人抓取网站尤其是那些可能有条款禁止此类行为的网站。” 这不是客套话而是铁律。Zomato 的robots.txt文件访问https://www.zomato.com/robots.txt明确禁止了对/jakarta/路径的抓取。这意味着从法律和道德层面这个项目本身就是一个高风险行为。那么我们还学它吗答案是学但必须带着敬畏之心去学。我们学习的不是如何突破一个商业网站的防线而是理解现代 Web 的数据结构、掌握自动化交互的技术边界、以及最重要的——学会在技术能力与社会责任之间划出清晰的红线。因此我们的“反爬”策略核心不是“如何不被发现”而是“如何最小化干扰、最大化尊重”。这体现在三个具体操作上强制延迟 (time.sleep)Rizqi 在最后提到了time.sleep(8)。8 秒对于一个页面来说这已经是非常“人”的节奏了。但更专业的做法是使用WebDriverWait结合expected_conditions让等待时间由页面状态决定而不是一个死数字。比如等待“餐厅名称”这个关键元素出现比等待 8 秒更精准、更高效。同时我们可以在每次请求之间加入一个随机的小延迟比如 1-3 秒模拟人类浏览的不可预测性import random import time # 在每次 driver.get() 之后加入随机延迟 time.sleep(random.uniform(1.5, 3.0))User-Agent 模拟默认的 Selenium User-Agent 很容易被识别为爬虫。我们可以通过 Chrome 选项来伪装from selenium.webdriver.chrome.options import Options chrome_options Options() chrome_options.add_argument(--headless) # 无头模式不显示窗口 chrome_options.add_argument(--no-sandbox) chrome_options.add_argument(--disable-dev-shm-usage) # 设置一个真实的、常见的 User-Agent chrome_options.add_argument(user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36) service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice, optionschrome_options)错误处理与优雅降级Rizqi 用了NoSuchElementException来处理 404 页面这很好。但一个完整的框架还需要处理更多情况网络超时、页面加载失败、元素存在但内容为空。每一个try...except块都应该有一个明确的“降级方案”比如填入N/A、Not Available或Timeout而不是让整个程序崩溃。这保证了数据集的完整性——宁可有一条记录是N/A也不要因为一条记录失败导致后面 1000 条记录全部丢失。提示在开始大规模抓取前务必先用https://www.zomato.com/jakarta/restoran?page1这样的 URL手动测试你的脚本能否稳定运行 10 个页面。观察控制台输出、检查生成的数据确认没有大面积的N/A或异常。这是避免“跑了一夜结果全是废数据”的唯一保险。3. 实操过程与核心环节实现一份可直接运行的、模块化的完整代码现在我们把所有零散的知识点组装成一个结构清晰、职责分明、可读性强的完整 Python 脚本。它将分为四个核心模块搜索页抓取、详情页抓取、数据清洗、以及主流程调度。这种模块化设计让你可以单独测试任何一个环节也方便未来扩展比如把 Zomato 换成 GrabFood只需修改详情页抓取模块。3.1 搜索页抓取获取 14,500 家餐厅的入口这个模块的目标是生成一个包含所有餐厅详情页 URL 的 CSV 文件。Rizqi 的原始代码是硬编码了 1002 页但我们不能假设这个数字永远不变。更好的做法是先抓取第一页解析出总页数再动态生成 URL 列表。# search_scraper.py import pandas as pd from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from webdriver_manager.chrome import ChromeDriverManager import time import random def get_total_pages(driver): 从第一页的分页栏中获取总页数 try: # 等待分页栏加载 pagination WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, pagination)) ) # 找到最后一个页码按钮通常是最后一个 a 标签 page_links pagination.find_elements(By.TAG_NAME, a) if page_links: # 取倒数第二个因为最后一个通常是 Next last_page_text page_links[-2].text.strip() return int(last_page_text) except Exception as e: print(f获取总页数失败: {e}) # 如果失败保守估计为 1000 页 return 1000 return 1000 def scrape_search_pages(base_url, max_pages1000): 抓取搜索页返回所有餐厅URL列表 service Service(ChromeDriverManager().install()) options webdriver.ChromeOptions() options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) driver webdriver.Chrome(serviceservice, optionsoptions) all_urls [] try: # 先访问第一页获取总页数 driver.get(base_url.format(1)) time.sleep(random.uniform(2.0, 4.0)) total_pages get_total_pages(driver) print(f检测到总页数: {total_pages}) # 动态抓取所有页面 for i in range(1, min(total_pages, max_pages) 1): url base_url.format(i) print(f正在抓取第 {i} 页... ({url})) driver.get(url) time.sleep(random.uniform(1.5, 3.0)) # 等待餐厅列表容器出现 try: list_container WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, sc-jxGEy0)) ) # 在容器内找所有餐厅链接 restaurant_links list_container.find_elements(By.CSS_SELECTOR, a.result-title) for link in restaurant_links: href link.get_attribute(href) if href and zomato.com/jakarta/ in href: all_urls.append(href) except Exception as e: print(f抓取第 {i} 页时出错: {e}) continue finally: driver.quit() return all_urls if __name__ __main__: # 雅加达所有餐厅搜索页 all_restaurants_url https://www.zomato.com/jakarta/restoran?page{} # 雅加达提供外卖的餐厅搜索页 delivery_restaurants_url https://www.zomato.com/jakarta/delivery?page{} print( 开始抓取雅加达所有餐厅 ) all_urls scrape_search_pages(all_restaurants_url) print(f共抓取到 {len(all_urls)} 个餐厅URL) print(\n 开始抓取雅加达外卖餐厅 ) delivery_urls scrape_search_pages(delivery_restaurants_url) print(f共抓取到 {len(delivery_urls)} 个外卖餐厅URL) # 去重并保存 df_all pd.DataFrame({url: list(set(all_urls))}) df_delivery pd.DataFrame({url: list(set(delivery_urls))}) df_all.to_csv(zomato_jakarta_all_restaurants.csv, indexFalse) df_delivery.to_csv(zomato_jakarta_delivery_restaurants.csv, indexFalse) print(\n搜索页URL已保存至 CSV 文件。)这段代码的关键改进在于动态页数检测不再硬编码range(1, 1003)而是通过解析第一页的分页栏来获取真实总数。模块化函数get_total_pages和scrape_search_pages职责清晰易于单元测试。健壮的异常处理每个try...except都有明确的 fallback保证主循环不会中断。CSV 输出直接生成结构化数据文件为下一步详情页抓取提供输入。3.2 详情页抓取从 URL 到结构化数据的炼金术这是整个项目的“心脏”。我们将读取上一步生成的 CSV 文件逐个访问每个 URL并提取 Rizqi 列出的所有字段。这里我们将采用“面向对象”的设计创建一个RestaurantScraper类把所有抓取逻辑封装起来。# detail_scraper.py import pandas as pd from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from webdriver_manager.chrome import ChromeDriverManager import time import random import json class RestaurantScraper: def __init__(self, headlessTrue): self.headless headless self.driver self._create_driver() self.wait WebDriverWait(self.driver, 10) def _create_driver(self): 创建并配置Chrome WebDriver service Service(ChromeDriverManager().install()) options webdriver.ChromeOptions() if self.headless: options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) return webdriver.Chrome(serviceservice, optionsoptions) def scrape_single_restaurant(self, url): 抓取单个餐厅详情页的所有信息 result { url: url, name: N/A, types: [], area: N/A, rating: N/A, review_count: N/A, price_for_two: N/A, address: N/A, additional_info: [], latitude: N/A, longitude: N/A } try: self.driver.get(url) time.sleep(random.uniform(1.5, 3.0)) # 1. 餐厅名称 try: name_el self.wait.until(EC.presence_of_element_located((By.TAG_NAME, h1))) result[name] name_el.text.strip() except: pass # 2. 餐厅类型 try: # 尝试多种可能的容器选择器 containers [ div.cuisine, div.categories, section[data-testidrestaurant-cuisines] ] for selector in containers: try: container self.driver.find_element(By.CSS_SELECTOR, selector) type_els container.find_elements(By.TAG_NAME, a) result[types] [el.text.strip() for el in type_els if el.text.strip()] break except: continue except: pass # 3. 餐厅区域 (Area) try: area_el self.driver.find_element(By.XPATH, //h3[text()Area]/following-sibling::p) result[area] area_el.text.strip() except: pass # 4. 评分与评论数 try: rating_el self.driver.find_element(By.XPATH, //div[classres-rating]//div[contains(class, rating)]) result[rating] rating_el.text.strip() except: pass try: review_el self.driver.find_element(By.XPATH, //div[classres-rating]//p[contains(text(), reviews) or contains(text(), Reviews)]) result[review_count] review_el.text.strip() except: pass # 5. 人均价格 (Price for Two) try: price_el self.driver.find_element(By.XPATH, //div[contains(text(), Cost for two)]/following-sibling::p | //div[contains(text(), Average cost)]/following-sibling::p) price_text price_el.text.strip() # 简单清洗只保留以 Rp 开头的价格 if price_text.startswith(Rp): result[price_for_two] price_text else: result[price_for_two] N/A except: pass # 6. 地址 try: address_el self.driver.find_element(By.XPATH, //h3[text()Address]/following-sibling::p) result[address] address_el.text.strip() except: pass # 7. 额外信息 (Additional Info) try: info_container self.driver.find_element(By.XPATH, //h3[text()Highlights]/following-sibling::div | //h3[text()Highlights]/parent::div/following-sibling::div) info_els info_container.find_elements(By.TAG_NAME, p) result[additional_info] [el.text.strip() for el in info_els if el.text.strip()] except: pass # 8. 经纬度 (Latitude Longitude) try: map_link self.driver.find_element(By.XPATH, //div[classmap-section]//a[contains(href, maps.google.com)]) map_url map_link.get_attribute(href) # 从 Google Maps URL 中提取经纬度 if ll in map_url: ll_part map_url.split(ll)[1].split()[0] lat, lng ll_part.split(,) result[latitude] lat.strip() result[longitude] lng.strip() except: pass except Exception as e: print(f抓取 {url} 时发生未预期错误: {e}) finally: # 保持 driver 复用提高效率 pass return result def scrape_batch(self, url_list, output_filezomato_jakarta_details.csv): 批量抓取餐厅详情 results [] total len(url_list) for i, url in enumerate(url_list): print(f[{i1}/{total}] 正在抓取: {url[:50]}...) result self.scrape_single_restaurant(url) results.append(result) # 每抓取 10 个保存一次中间结果防止意外中断导致全盘皆输 if (i 1) % 10 0 or i total - 1: df pd.DataFrame(results) df.to_csv(output_file, indexFalse) print(f已保存 {len(results)} 条记录到 {output_file}) return results def close(self): 关闭浏览器 self.driver.quit() if __name__ __main__: # 读取搜索页抓取的结果 df_urls pd.read_csv(zomato_jakarta_all_restaurants.csv) url_list df_urls[url].tolist() # 创建抓取器实例 scraper RestaurantScraper(headlessTrue) try: # 开始批量抓取 results scraper.scrape_batch(url_list[:100]) # 先测试前100个 finally: scraper.close() print(详情页抓取完成。)这份代码的亮点在于面向对象封装RestaurantScraper类将所有状态driver、wait和行为scrape_single、scrape_batch组织在一起逻辑清晰。多重定位策略对于餐厅类型、地址等关键字段提供了多个 CSS/XPath 选择器作为备选大大提高了鲁棒性。渐进式保存每抓取 10 个 URL 就保存一次 CSV这是大型抓取任务的生命线。万一断电或程序崩溃你损失的只是最后不到 10 条数据而不是一夜的成果。经纬度提取逻辑修正了 Rizqi 原始代码中直接切片 URL 的危险做法。现在我们从 Google Maps 的标准 URL 格式https://www.google.com/maps/place/.../-6.2087634,106.845599,17z/...中用ll参数来安全提取这才是工业级的写法。3.3 数据清洗从“脏数据”到“分析就绪”抓取完成你得到的 CSV 文件里大概率会充斥着N/A、空字符串、重复的换行符、以及各种格式混乱的文本。Rizqi 提到了用pandas.duplicated()去重但这只是冰山一角。一个完整的清洗流程至少包括以下步骤# data_cleaner.py import pandas as pd import re def clean_data(input_filezomato_jakarta_details.csv, output_filezomato_jakarta_clean.csv): df pd.read_csv(input_file) # 1. 去除完全重复的行基于所有列 df df.drop_duplicates() # 2. 清洗文本字段去除首尾空格、合并多余空白符 text_columns [name, area, address, price_for_two] for col in text_columns: if col in df.columns: df[col] df[col].astype(str).str.strip().str.replace(r\s, , regexTrue) # 3. 标准化评分字段将 4.2 (1234 reviews) 提取为 4.2 if rating in df.columns: df[rating_clean] df[rating].str.extract(r(\d\.\d)) df[rating_clean] pd.to_numeric(df[rating_clean], errorscoerce) # 4. 解析评论数将 (1234 reviews) 提取为 1234 if review_count in df.columns: df[review_count_clean] df[review_count].str.extract(r(\d)) df[review_count_clean] pd.to_numeric(df[review_count_clean], errorscoerce) # 5. 清洗价格字段只保留数字和小数点去掉 Rp 和逗号 if price_for_two in df.columns: df[price_for_two_clean] df[price_for_two].str.replace(r[^\d.], , regexTrue) df[price_for_two_clean] pd.to_numeric(df[price_for_two_clean], errorscoerce) # 6. 经纬度字段校验确保是有效的数字 if latitude in df.columns and longitude in df.columns: df[latitude] pd.to_numeric(df[latitude], errorscoerce) df[longitude] pd.to_numeric(df[longitude], errorscoerce) # 过滤掉明显错误的经纬度雅加达大致范围纬度 -6.5 ~ -6.0经度 106.5 ~ 107.0 df df[(df[latitude] -6.5) (df[latitude] -6.0) (df[longitude] 106.5) (df[longitude] 107.0)] # 7. 生成一个“是否有效餐厅”的标记列 df[is_valid] ( (df[name] ! N/A) (df[name] ! ) (df[address] ! N/A) (df[address] ! ) ) # 8. 保存清洗后的数据 df.to_csv(output_file, indexFalse) print(f清洗完成。原始记录数: {len(pd.read_csv(input_file))}, 有效记录数: {df[is_valid].sum()}, 已保存至 {output_file}) return df if __name__ __main__: clean_data()这个清洗脚本的价值在于它把原始的、充满噪音的“抓取产物”转化成了可以直接导入 Tableau、Power BI 或 Python 的pandas进行分析的“黄金数据”。它不是一个可有可无的步骤而是整个数据管道中承上启下的关键一环。4. 常见问题与排查技巧实录那些只有亲手踩过才知道的坑理论讲得再透不如实战中摔一跤来得深刻。在复现 Rizqi 这个项目的过程中我和团队成员一起把所有能想到的坑都踩了一遍。下面这些不是教科书里的标准答案而是我们从日志、报错、和无数次print()调试中总结出来的独家“排障速查表”。4.1 “Element Not Found” 错误是定位错了还是时机不对这是 Selenium 新手最常遇到的报错也是最容易陷入误区的地方。看到NoSuchElementException第一反应往往是“我的 XPath 写错了”。但很多时候元素是存在的只是你找得太早了。现象脚本在driver.find_element(By.XPATH, ...)这一行报错但你手动打开浏览器刷新页面那个元素明明就在那里。真相页面是分阶段加载的。HTML 骨架先到CSS 样式后到JavaScript 脚本最后执行并动态填充数据。你的find_element语句很可能在 JS 还没把餐厅列表画出来之前就已经执行了。排查与解决加个time.sleep(5)最粗暴但最有效的方法。如果加了之后不报错了那就 100% 是时机问题。升级为WebDriverWait这是标准解法。不要等一个固定时间而是等一个具体的“事件”发生。比如等待一个 class 为result-title的a标签出现from selenium.webdriver.support import expected_conditions as EC WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, result-title)) )检查元素是否在 iframe 里有些网站会把关键内容放在iframe中。Selenium 默认只能看到主页面的 DOM。你需要先switch_to.frame()进入 iframe再去找元素。用浏览器开发者工具检查看目标元素是否在iframe标签下。4.2 “Stale Element Reference” 错误元素“过期”了现象你的脚本成功找到了一个元素甚至打印出了它的.text但当你紧接着调用.click()或.get_attribute()时却抛出了StaleElementReferenceException。真相这个错误非常形象。“Stale” 就是“陈旧的、过期的”。它意味着你最初找到的那个元素对象在 DOM 中已经被移除了或者被一个新的、同名的元素替换了。这在 SPA 应用中极其常见。比如你点击了“下一页”按钮整个餐厅列表区域被 JavaScript 重新渲染了一遍虽然看起来一样但内存里的 DOM 对象已经是全新的了。排查与解决永远不要缓存 WebElement 对象这是铁律。不要写element driver.find_element(...); element.click(); element.text。每次要用都要重新find_element一次。使用find_elements 索引如果你要操作列表中的第 N 个元素不要先find_element出来存变量再操作。应该每次都find_elements然后取[n]# ❌ 危险 first_link driver.find_element(By.CLASS_NAME, result-title) first_link.click() # ... 页面跳转后first_link 就 stale 了 # ✅ 安全 links driver.find_elements(By