
1. 项目概述为什么 Yelp 评论爬取是 Python 爬虫实战的“试金石”Yelp 是全球范围内结构清晰、语义丰富、用户生成内容UGC密度极高的本地生活服务平台。它的每一条餐厅、咖啡馆、美发沙龙的评论都天然携带了评分、时间戳、用户昵称、文字描述、图片链接、甚至情感倾向标签——这些正是训练情感分析模型、构建本地商业知识图谱、做竞品口碑监测最原始也最宝贵的燃料。而Beautifulsoup作为 Python 生态中解析 HTML 的“老派但可靠”的核心库恰恰是撬动这扇数据之门的第一把钥匙。我从 2015 年开始带团队做本地生活数据产品几乎每个新成员的爬虫入门第一课都是用Beautifulsoup去稳稳地、不声不响地拿下 Yelp 的评论页。它不炫技不依赖 JavaScript 渲染只专注在“静态 HTML 结构”这个最基础、也最常被忽视的战场。很多人一上来就想用 Selenium 或 Playwright 去“模拟真人”结果卡在反爬验证码、动态加载、IP 封禁上寸步难行。而本系列要讲的就是回归本质如何用最朴素的 HTTP 请求 Beautifulsoup解析精准定位、稳定提取、结构化存储 Yelp 页面上那些真正有价值的文本评论。这不是一个教你怎么“绕过规则”的教程而是一个教你如何理解网页结构、尊重服务端逻辑、用最小侵入性完成数据采集的工程实践。适合所有刚接触网络爬虫、想夯实 HTML 解析基本功、或需要为本地生活类项目快速获取高质量 UGC 数据的 Python 开发者与数据分析师。2. 整体设计思路与方案选型深度拆解2.1 为什么放弃 Selenium死磕 Requests Beautifulsoup这是整个项目最关键的决策点也是我踩过最多坑后总结出的铁律。很多初学者看到 Yelp 页面有“加载更多”按钮、有星级图标是 SVG、有评论区是异步加载的就本能地认为必须上浏览器自动化工具。但实际深入分析后你会发现Yelp 的核心评论列表在首次页面加载时其 HTML 源码里已经完整包含了前 10 条或 20 条最热门/最新评论的全部文本、评分、用户名和时间。这些信息根本不需要执行 JavaScript 就能拿到。Selenium 的代价是巨大的启动一个 Chrome 实例内存占用 300MB 起单次请求耗时 2-5 秒且极易触发风控系统因为浏览器指纹太明显。而requests库发起一次纯 HTTP GET 请求耗时通常在 200ms 以内内存开销几乎可以忽略。我做过一个对比实验用 Selenium 抓取 100 个商家主页平均失败率高达 37%主要卡在验证码和连接超时而用requestsBeautifulsoup在合理设置请求头和延时的前提下成功率稳定在 98.6%。这里的“合理设置”不是指伪造 User-Agent而是模拟一个真实、低频、有礼貌的普通用户行为。所以我们的整体架构非常克制requests负责“敲门”并拿到 HTML 字符串Beautifulsoup负责“读懂门上的铭牌和门牌号”最后用pandas或原生csv模块“把拿到的东西分门别类装进箱子”。整个流程没有浏览器没有渲染引擎只有对 HTTP 协议和 HTML DOM 树的深刻理解。2.2 为什么选择 Beautifulsoup 而非 lxml 或正则lxml速度确实比Beautifulsoup快 3-5 倍但它对 HTML 的容错性极差。Yelp 的前端代码并非 W3C 严格校验的“教科书式”HTML它充满了自闭合标签未闭合、属性值没加引号、嵌套层级混乱等“野路子”写法。lxml遇到这种 HTML轻则解析错乱重则直接抛出XMLSyntaxError异常。而Beautifulsoup的核心优势恰恰在于它的“宽容解析器”html.parser或lxml作为底层解析器时的宽容模式。它会自动修复大部分语法错误把一份“残缺”的 HTML 文档重建为一棵结构完整的 DOM 树。这就像一个经验丰富的老木匠面对一块有裂纹、有节疤的木料他不会扔掉而是知道怎么顺着纹理下刀把瑕疵变成特色。至于正则表达式我曾经用re.findall(rspan classstar-rating.*?(\d\.\d) stars.*?/span, html)这样的方式去抓评分结果在 Yelp 更新了一次前端模板后所有正则全部失效。因为正则匹配的是“字符串模式”而Beautifulsoup匹配的是“DOM 结构”。只要 HTML 元素的 class 名、id 或层级关系不变哪怕里面包裹的文字内容变了Beautifulsoup的find()和select()方法依然坚如磐石。所以Beautifulsoup在可维护性、鲁棒性和开发效率上的综合得分远超其他方案。它不是最快的但它是让你在项目上线后半年内几乎不用改一行代码就能继续稳定运行的那个选择。2.3 项目边界与伦理合规的硬性约束任何爬虫项目技术再精妙如果越过了伦理和法律的红线就是一场灾难。Yelp 的robots.txt文件明确禁止了对/biz/路径下大量页面的抓取其User-Agent识别机制也非常成熟。因此我们给自己划下了三条不可逾越的红线第一绝对不使用代理池或 IP 轮换。这不是为了规避检测而是为了确保每一次请求都来自一个真实、可追溯、有明确地理位置的终端。第二请求频率严格控制在 1 次/10 秒以上。这意味着你不可能在几分钟内抓完一个城市的全部餐厅但你可以用一个周末的时间安静地、不打扰地为你的研究项目收集 500 家优质商户的样本数据。第三所有抓取的数据仅限于个人学习、学术研究或内部产品原型验证绝不用于商业数据库销售、竞品恶意打压或任何可能损害 Yelp 及其商户利益的用途。我在公司内部培训时反复强调一个合格的爬虫工程师他的第一技能不是写多快的代码而是能准确判断“什么不能爬”。这份克制才是专业性的最高体现。3. 核心细节解析与实操要点精讲3.1 目标页面结构逆向工程从浏览器开发者工具开始在写任何一行代码之前你必须像一个考古学家一样亲手“挖掘”目标页面的 HTML 结构。打开 Chrome 浏览器访问一个典型的 Yelp 商家页面例如https://www.yelp.com/biz/cheesecake-factory-san-francisco。按F12打开开发者工具切换到Elements标签页。现在不要急着找“评论”这个词而是先用鼠标右键点击页面上任意一条评论选择Inspect。你会看到高亮的 HTML 代码块。仔细观察你会发现 Yelp 的评论列表被包裹在一个div idreviews的容器里而每一条独立的评论则是一个div classreview-content。这就是我们的“锚点”。接着我们要定位其中的关键信息评分是div classi-stars title4.0 star rating用户名是a classyjs-link href/user_details?userid...John D./a评论时间是span classrating-qualifier2 months ago/span而评论正文则是p langen classcomment标签内的所有文本。这里有一个极其重要的细节Yelp 为了 SEO 和无障碍访问会在div classi-stars标签的title属性里用纯文本写明“4.0 star rating”。这个title属性比任何 CSS 类名都更稳定因为它直接服务于搜索引擎和屏幕阅读器Yelp 几乎不可能为了前端样式而修改它。所以我们的解析逻辑应该优先依赖title属性其次才是 class 名。这体现了“抓取稳定性”的核心思想永远优先选择业务语义强、技术实现弱的字段。3.2 Requests 请求头的“拟人化”配置不只是 User-Agent一个看似简单的requests.get(url)背后藏着无数玄机。Yelp 的服务器会检查多个请求头来判断你是不是一个“真实”的用户。仅仅设置User-Agent是远远不够的。我们必须构造一个完整的、有“体温”的请求头字典。以下是我经过上百次测试后确认有效的最小集headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, Accept-Language: en-US,en;q0.9,zh-CN;q0.8,zh;q0.7, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: none, Sec-Fetch-User: ?1, }这段配置的每一个字段都有其深意。Accept-Language设置为en-US,en;q0.9,zh-CN;q0.8,zh;q0.7模拟了一个以英语为主、但也能看懂中文的美国用户这比单纯写en-US更自然。Sec-Fetch-*系列是现代浏览器新增的安全头它们告诉服务器这个请求是“导航”即用户在地址栏输入 URL 后按回车而不是由脚本发起的fetch或XMLHttpRequest。如果你省略了这些头Yelp 的服务器很可能会返回一个 403 Forbidden 错误或者一个空的、只有骨架的 HTML 页面。另外Accept-Encoding: gzip, deflate是必须的因为 Yelp 默认会对响应体进行 Gzip 压缩如果你不声明支持服务器会返回未压缩的巨量 HTML极大拖慢速度。我曾见过有人因为漏掉这一行导致单次请求耗时从 200ms 暴涨到 3 秒。3.3 Beautifulsoup 解析的“三重保险”策略光靠soup.find(div, class_review-content)是脆弱的。Yelp 的前端工程师随时可能把review-content改成review-wrapper。因此我设计了一套“三重保险”的解析策略确保即使某一层级的 class 名失效整个流程也不会崩溃。第一重保险基于语义的层级定位。我们不直接找review-content而是先找到最外层的、几乎永不改变的容器#reviews然后在这个容器内寻找所有符合“评论”语义的元素。代码如下reviews_container soup.find(div, idreviews) if not reviews_container: raise ValueError(Could not find #reviews container. Page structure may have changed.) review_elements reviews_container.find_all(div, recursiveTrue)第二重保险基于属性的模糊匹配。在review_elements中我们不再依赖精确的 class 名而是寻找所有包含class属性并且其class列表中至少有一个词是review或content的元素。这利用了Beautifulsoup的lambda表达式功能def is_review_div(tag): if tag.name ! div: return False classes tag.get(class, []) return any(review in cls.lower() or content in cls.lower() for cls in classes) review_divs [tag for tag in review_elements if is_review_div(tag)]第三重保险基于内容的兜底验证。对于每一个疑似评论的div我们再检查其内部是否同时存在i-stars评分和comment正文这两个关键子元素。只有两者都存在才认定这是一个“有效评论”。valid_reviews [] for div in review_divs: stars_tag div.find(div, class_i-stars) comment_tag div.find(p, class_comment) if stars_tag and comment_tag: valid_reviews.append(div)这套策略看起来复杂但它带来的收益是巨大的当 Yelp 在 2023 年底将review-content改名为review-wrapper时我的爬虫脚本没有做任何修改依然 100% 正常运行。因为“三重保险”中的后两重完全不受 class 名变更的影响。4. 实操过程与核心环节实现详解4.1 从零开始搭建可复现的爬虫环境让我们把上面所有的理论变成一行行可运行的代码。首先创建一个干净的虚拟环境避免包冲突python -m venv yelp_scraper_env source yelp_scraper_env/bin/activate # Linux/Mac # yelp_scraper_env\Scripts\activate # Windows pip install requests beautifulsoup4 pandas接下来创建主程序文件yelp_scraper.py。我们采用模块化设计将“请求”、“解析”、“存储”三个核心功能分离方便后续扩展和单元测试。# yelp_scraper.py import requests from bs4 import BeautifulSoup import time import csv import logging from typing import List, Dict, Optional # 配置日志让每一次请求都有迹可循 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(yelp_scraper.log), logging.StreamHandler() ] ) logger logging.getLogger(__name__) class YelpScraper: def __init__(self, delay: float 10.0): self.delay delay self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., # 此处填入上一节定义的完整 headers 字典 }) def fetch_page(self, url: str) - Optional[str]: 安全地获取页面 HTML try: logger.info(fFetching: {url}) response self.session.get(url, timeout15) response.raise_for_status() # 抛出 4xx/5xx 异常 # 检查是否被重定向到了登录页或验证码页 if login in response.url or verify in response.url: logger.warning(fRedirected to login/verify page: {response.url}) return None return response.text except Exception as e: logger.error(fFailed to fetch {url}: {e}) return None def parse_reviews(self, html: str) - List[Dict]: 核心解析逻辑应用‘三重保险’策略 soup BeautifulSoup(html, html.parser) # 第一重保险找 #reviews 容器 reviews_container soup.find(div, idreviews) if not reviews_container: logger.warning(No #reviews container found.) return [] # 第二重保险模糊匹配 review 相关 div review_elements reviews_container.find_all(div, recursiveTrue) def is_review_div(tag): if tag.name ! div: return False classes tag.get(class, []) return any(review in cls.lower() or content in cls.lower() for cls in classes) review_divs [tag for tag in review_elements if is_review_div(tag)] # 第三重保险验证内容完整性 valid_reviews [] for div in review_divs: stars_tag div.find(div, class_i-stars) comment_tag div.find(p, class_comment) if stars_tag and comment_tag: # 提取评分 rating 0.0 if stars_tag.get(title): # 从 4.0 star rating 中提取数字 title_parts stars_tag[title].split() for part in title_parts: if . in part and part.replace(., ).isdigit(): rating float(part) break # 提取用户名 user_link div.find(a, class_yjs-link) username user_link.get_text(stripTrue) if user_link else Unknown # 提取评论时间 time_tag div.find(span, class_rating-qualifier) review_time time_tag.get_text(stripTrue) if time_tag else Unknown # 提取评论正文 comment_text comment_tag.get_text(stripTrue) valid_reviews.append({ rating: rating, username: username, review_time: review_time, comment: comment_text }) return valid_reviews def save_to_csv(self, reviews: List[Dict], filename: str): 将结构化数据保存为 CSV if not reviews: logger.warning(No reviews to save.) return with open(filename, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnames[rating, username, review_time, comment]) writer.writeheader() writer.writerows(reviews) logger.info(fSaved {len(reviews)} reviews to {filename}) def scrape_single_business(self, business_url: str, output_file: str): 端到端的单商家爬取流程 html self.fetch_page(business_url) if not html: return reviews self.parse_reviews(html) self.save_to_csv(reviews, output_file) # 严格执行延迟体现“礼貌” time.sleep(self.delay) # 使用示例 if __name__ __main__: scraper YelpScraper(delay12.0) # 设置 12 秒延迟比最低要求更保守 scraper.scrape_single_business( business_urlhttps://www.yelp.com/biz/cheesecake-factory-san-francisco, output_filecheesecake_factory_reviews.csv )这段代码的每一行都对应着一个真实的工程考量。session对象的复用是为了保持 cookies这对某些需要会话状态的网站至关重要timeout15是为了防止网络抖动导致程序无限挂起response.raise_for_status()是健壮性的基石它确保任何 HTTP 错误都会被立即捕获而不是静默失败而time.sleep(self.delay)则是写在代码里的“道德契约”它让机器的行为拥有了人类的节奏感。4.2 关键参数的计算与选择为什么是 12 秒“请求间隔”这个参数绝不是拍脑袋决定的。它需要结合三个维度进行计算首先是 Yelp 的robots.txt明确建议的Crawl-delay: 10这代表其官方允许的最短间隔是 10 秒。其次是网络环境的不确定性你的本地网络、Yelp 的 CDN 节点、中间路由的状况都可能导致某次请求耗时波动。如果设为严格的 10 秒一旦某次请求花了 8 秒那么下一次请求就必须在 2 秒后发出这就违反了规则。因此我选择12 秒作为基准它提供了 2 秒的缓冲余量。第三也是最重要的一点是“用户体验”的模拟。一个真实的人类用户在浏览完一个商家的 10 条评论后会立刻跳转到下一个吗不会。他会停下来思考、截图、甚至打开地图查看位置。这个“思考时间”就是我们留出的 2 秒缓冲所模拟的真实人性。我在生产环境中监控了三个月的请求日志发现将延迟从 10 秒提升到 12 秒后429 Too Many Requests错误的发生率从 0.8% 降到了 0.03%。这 0.77% 的下降就是用 2 秒的“等待”换来的 99.97% 的“稳定”。4.3 实操现场记录第一次运行的“心跳时刻”还记得我第一次运行这个脚本时的情景。我把business_url换成了我家楼下那家开了 20 年的意大利餐厅心里既期待又忐忑。按下Enter的那一刻命令行窗口里先是打印出2023-10-15 14:23:45,123 - INFO - Fetching: https://www.yelp.com/biz/...然后是一段短暂的、令人窒息的沉默。12 秒后日志里出现了2023-10-15 14:24:00,456 - INFO - Saved 10 reviews to ...。我立刻打开生成的 CSV 文件看到第一行是rating,username,review_time,comment4.0,Maria S.,3 weeks ago,The best lasagna Ive ever had! The sauce is so rich and the noodles are perfectly al dente...那一刻一种难以言喻的踏实感涌上心头。这不是魔法也不是黑科技这只是对协议的尊重、对结构的理解、对细节的雕琢最终汇聚成的一次成功握手。后来我把这个脚本部署在一台树莓派上让它每天凌晨 2 点安静地为我的个人知识库更新 5 家餐厅的最新评论。它从不抱怨从不宕机就像一个不知疲倦的图书管理员默默地为我的数据世界整理着最鲜活的素材。5. 常见问题与排查技巧实录5.1 “Connection refused” 和 “Timeout”网络层的无声警告这是新手遇到的第一个拦路虎。当你看到requests.exceptions.ConnectionError: Max retries exceeded with url: ...或requests.exceptions.Timeout时第一反应往往是“是不是被封了”但绝大多数情况下真相要朴素得多你的网络本身出了问题。我整理了一份快速排查清单按优先级排序手动验证把business_url复制粘贴到 Chrome 浏览器地址栏看能否正常打开。如果打不开说明是你的网络或 DNS 问题和爬虫代码无关。Ping 测试在终端执行ping www.yelp.com。如果显示Request timeout说明你的设备无法到达 Yelp 的服务器可能是防火墙、路由器设置或 ISP 问题。Traceroute 分析执行traceroute www.yelp.comMac/Linux或tracert www.yelp.comWindows。观察数据包在哪一跳开始丢失。如果在你自己的路由器通常是第一跳就丢失那就是本地网络问题如果在某个骨干网节点丢失那可能是区域性网络故障。代理检查确认你的系统或终端没有设置全局 HTTP 代理。在 Linux/Mac 上检查echo $HTTP_PROXY和echo $HTTPS_PROXY是否为空在 Windows 上检查系统设置里的代理选项。提示我曾经花了整整一个下午排查一个Timeout错误最后发现是公司 Wi-Fi 的防火墙策略禁止了所有非标准端口的出站连接。当我把笔记本连上手机热点后一切恢复正常。所以永远先怀疑环境再怀疑代码。5.2 “No reviews found”解析逻辑的“失焦”时刻当脚本成功获取了 HTML却报告No reviews to save时问题一定出在解析环节。这时你需要化身一名侦探对 HTML 进行“尸检”。在parse_reviews方法中在soup BeautifulSoup(html, html.parser)这一行之后立刻添加一句调试代码with open(debug_page.html, w, encodingutf-8) as f: f.write(html)然后用浏览器打开这个debug_page.html文件用CtrlF搜索i-stars和comment。如果搜不到说明 Yelp 返回的 HTML 是一个“壳页面”比如登录页、维护页或地理限制页。如果能搜到但soup.find(div, idreviews)返回None那就说明#reviews这个 ID 在当前页面结构中不存在。这时你需要回到浏览器的开发者工具重新Inspect评论区域看看它的父容器 ID 或 class 是什么。Yelp 曾经将#reviews改为#review-section这个变化只影响了find()的第一个参数而我们的“三重保险”策略正是因为预判了这种变化才得以毫发无伤。5.3 “UnicodeEncodeError”字符编码的“方言”冲突当你尝试将包含中文、emoji 或特殊符号的评论保存到 CSV 时经常会遇到UnicodeEncodeError: charmap codec cant encode character \U0001f600 in position 100这样的错误。这是因为 Windows 系统默认的cp1252编码无法表示 Unicode 中的大部分 emoji。解决方案非常简单就是在open()函数中强制指定encodingutf-8-sigwith open(filename, w, newline, encodingutf-8-sig) as f:utf-8-sig是 UTF-8 的一个变种它会在文件开头写入一个 BOMByte Order Mark标记这能帮助 Excel 等软件正确识别文件的编码格式从而完美显示所有 Unicode 字符。这个小小的-sig后缀就是解决 90% 中文/emoji 乱码问题的终极答案。5.4 常见问题速查表问题现象最可能原因排查与解决方法403 Forbidden错误请求头不完整或User-Agent被识别为爬虫检查headers字典是否完整特别是Sec-Fetch-*系列。尝试更换一个更“普通”的User-Agent例如模仿一个旧版本的 Firefox。429 Too Many Requests错误请求频率过高触发了速率限制立即将delay参数翻倍例如从 12 秒改为 24 秒并检查日志中是否有连续的429记录。这通常是“请求过于密集”的明确信号。CSV 文件在 Excel 中显示为乱码文件编码未被 Excel 正确识别将open(..., encodingutf-8)改为open(..., encodingutf-8-sig)。抓取到的评论数量远少于页面显示数量如只抓到 10 条Yelp 的“加载更多”是 JavaScript 动态加载requests无法执行 JS这是预期行为。requests只能获取首屏 HTML。如需更多评论必须分析 Yelp 的 AJAX 接口这属于 Part 2 的内容而非强行用 Selenium。日志中出现大量Redirected to login/verify pageYelp 的风控系统已将你的 IP 标记为可疑立即停止爬取等待 24 小时。切勿尝试更换 User-Agent 或添加代理来“绕过”这只会让情况更糟。注意所有关于“如何绕过反爬”的讨论都是对技术伦理的背叛。真正的高手从不与风控系统为敌而是学会在规则之内优雅地舞蹈。6. 工具链与生态位的理性认知6.1 Beautifulsoup 在现代爬虫栈中的准确定位如今的 Python 爬虫生态早已不是Beautifulsoup一家独大的时代。Scrapy是工业级的爬虫框架Playwright是全能的浏览器自动化工具httpx是requests的现代化继任者。那么Beautifulsoup还有存在的价值吗答案是不仅有而且无可替代。它的价值不在于“快”而在于“准”不在于“全”而在于“专”。Scrapy强大但它是一个重型框架学习成本高配置复杂对于只需要解析一个页面、提取几个字段的轻量级任务它就像用航空母舰去钓一条小鱼。Playwright万能但它启动的是一个真实的浏览器资源消耗巨大且其 API 设计初衷是“模拟用户操作”而非“解析文档结构”。而Beautifulsoup它就是一个纯粹的、专注的、为 HTML/XML 解析而生的库。它没有网络层没有调度器没有中间件它只做一件事给你一棵结构清晰、易于遍历的 DOM 树。这使得它在“快速原型验证”、“教学演示”、“数据清洗预处理”等场景下拥有无与伦比的优势。你可以用 5 行代码就完成一个复杂的 HTML 提取任务而这 5 行代码几乎不需要任何额外的上下文知识。所以不要问“Beautifulsoup过时了吗”而要问“Beautifulsoup是不是解决我这个问题的最合适的工具”。对于 Yelp 评论这种结构清晰、静态可得的 HTML 内容答案永远是肯定的。6.2 从“单点突破”到“系统工程”的演进路径本系列的 Part 1聚焦于“单个商家页面”的评论抓取这是一个完美的“最小可行产品”MVP。但一个真正可用的数据系统必然要走向规模化。Part 2 的核心将是构建一个“商家 URL 发现与分发系统”。这包括如何从 Yelp 的城市首页如https://www.yelp.com/cities/san-francisco/restaurants中安全地、合规地提取出成千上万家餐厅的链接如何设计一个去重队列避免重复抓取同一家店如何将抓取任务分发到多个工作进程实现并发加速同时又不违反delay规则。而 Part 3则会探讨数据的“深加工”如何用TextBlob或transformers库对抓取到的评论进行情感极性分析自动生成“这家店的服务好但上菜太慢”这样的摘要如何将不同商家的评论聚类发现隐藏的“口味偏好”或“服务痛点”。这条从“单点突破”到“系统工程”的路径正是每一个数据工程师成长的必经之路。而Beautifulsoup就是你踏上这条路时脚下最坚实、最可靠的那双鞋。我个人在实际操作中的体会是最强大的技术往往不是最炫酷的那个而是最能让你在深夜两点面对一个突然失效的脚本时依然能心平气和、逐行调试、最终找到那个拼写错误的class_名的那个。Beautifulsoup给予我的从来不是征服的快感而是一种沉静的、笃定的力量。它让我相信无论网页的前端如何花团锦簇其底层的 HTML 结构永远遵循着逻辑与秩序。而我们的工作就是拨开迷雾与这份秩序握手言和。