直接拿requests去拉蔚来社区的活动页十有八九你会得到一堆空荡荡的div标签核心的活动数据一个都看不见。这是我在做Python爬虫抓取蔚来社区全国活动数据时踩到的第一个坑也是我决定用Playwright来驾驭这个页面的原因。这篇文章就围绕“怎么用Playwright把蔚来社区全国活动数据一键抓下来”这件事展开会覆盖环境搭建、活动卡片定位、无限滚动加载、数据清理落库、风控应对这几个环节。不管你是刚接触自动化爬虫的新手还是被动态渲染页面折磨过的老手按着下面的思路走基本都能把活动列表跑通。我会把踩过的坑和排查链路也一并写出来免得你再走一遍弯路。1. 蔚来社区活动页的真实形态为什么requests拿不到数据1.1 服务端返回的只是个空壳子蔚来社区的页面是用前端框架渲染的典型单页应用。你打开页面后看到的“近期活动”“城市活动”这些卡片并不是服务器在HTML里写好的而是浏览器执行JavaScript之后从后端接口拉取JSON数据再动态渲染到DOM里。我用requests请求首页时返回的HTML里确实有活动相关的DOM节点但这些节点内部是空的。页面源码里能看到div classactivity-card但里面没有标题、没有时间、没有地点。如果按着传统爬虫的思路去解析静态HTML拿到的就是一堆残缺的结构。这类页面在爬虫里被叫做“动态渲染页面”也是最近几年社区类站点最常见的页面形态。对付它的手段无非两条一条是抓接口分析这个页面加载时发的XHR请求另一条是上自动化浏览器等页面渲染完再抓DOM。Playwright选的就是第二条路。1.2 滚动加载活动数据不是一次到位的蔚来社区的活动列表还有一个特点——它做的是滚动加载或者说“瀑布流分页”。往下滑页面滑到底部附近页面会自动发请求把下一页的活动数据追加到列表后面。整个过程没有传统的“下一页”按钮没有URL页码参数。你要是在静态页面上找分页链接找到天亮都找不到。这种设计的好处对用户来说是体验流畅对爬虫来说就麻烦一点你不仅要等页面渲染还必须触发滚动事件并且要判断滚动到什么时候才算结束。requests这种一次性请求的模式在这套机制面前完全没有施展空间。1.3 Playwright的角色一个可以写代码控制的真浏览器Playwright本质上就是一个浏览器自动化工具支持Chromium、Firefox、WebKit三种内核。它做的事情就是启动一个真实的浏览器实例然后你可以通过代码让它打开页面、点击按钮、滚动鼠标、填写表单、读取DOM。页面里跑的所有JavaScript都是真实执行的前端怎么渲染它就怎么渲染。所以用它来抓蔚来社区的活动数据逻辑就很简单打开活动页等人家的前端代码把活动卡片渲染出来再滚动到页面底部诱发加载新数据等所有数据都加载完成后把DOM里的标题、时间、地点、报名信息提取出来存入本地。整个过程就像有一个隐形人在帮你操作浏览器而你只是在旁边记录“他在屏幕上看到了什么”。这也是我把这套爬虫命名为“驾驭”的原因——不是硬拆接口而是顺着页面本身的交互方式走。2. 环境搭建与第一版脚本先把Chromium跑起来2.1 Python环境与Playwright安装我的环境是Python 3.10Windows 11实际上Python 3.8以上都行。开始之前建议先建一个虚拟环境避免把依赖装到全局把系统搞乱mkdir nio_events_spider cd nio_events_spider python -m venv venv # Windows激活 venv\Scripts\activate # Linux/macOS激活 source venv/bin/activate然后安装Playwrightpip install playwright playwright install chromium第二步是下载Chromium内核。这一步国内网络有时候会卡住如果下载失败可以先设置镜像源再装或者用playwright install --with-deps chromiumLinux环境需要系统依赖Windows一般不需要。装完之后建议立刻跑一个最小样例验证环境不要一上来就写大段代码from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://www.nio.cn/, timeout60000) page.wait_for_timeout(3000) print(page.title()) browser.close()如果能看到浏览器窗口打开并且打印出页面标题那说明Playwright已经能正常驱动Chromium了。接下来所有代码都是在这个最小样例基础上扩展的。2.2 两种模式有头与无头headlessFalse是有头模式也就是你能看到一个真实的浏览器窗口在桌面上操作headlessTrue是无头模式浏览器在后台运行不显示界面。调试阶段强烈建议用有头模式。因为你要肉眼看清楚页面渲染成什么样、卡片结构长什么样、滚动到底部之后有没有加载出新内容。等代码稳定了再切成无头模式放到服务器或者定时任务里去跑。有头模式有个小问题鼠标动了浏览器窗口可能会干扰操作建议在调试时把窗口拖到一个固定位置不要中途去动它。另外无头模式下有些站点会识别HeadlessChrome的标志如果有风控拦截后续我会在踩坑部分展开聊。2.3 初始化context参数给浏览器一个“正常人”的配置启动浏览器时有几个参数我会每次都带上browser p.chromium.launch(headlessFalse, args[ --disable-blink-featuresAutomationControlled, ]) context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36, viewport{width: 1366, height: 768}, localezh-CN, ) page context.new_page()--disable-blink-featuresAutomationControlled这个参数是去掉Chromium里navigator.webdriver标记的常见做法。加上它之后脚本模拟出来的浏览器会少一些“自动化工具”的味道后续在风控环节会省很多事。3. 活动卡片定位三种定位方式我只留了两种3.1 按文本定位最直观但最容易误伤第一次尝试我用了page.get_by_text()比如页面里有“北京站”“上海站”这样的文本我就想直接按文本把卡片找出来。这个思路很直接但实际跑下来发现一个问题活动标题千奇百怪有“蔚来用户见面会·杭州”也有“NIO Summer 2024 湾区专场”按文本匹配很容易把页面上其他区域里出现的同名字段也匹配上。文本定位不是不能用只是不适合做卡片级别的容器定位。它更适合用来定位“确认页面加载成功”这种场景比如等“近期活动”这四个字出现可以认为页面已经渲染到了活动列表区域。3.2 CSS选择器定位页面结构变化时容易碎第二种思路是用CSS选择器。打开浏览器开发者工具选中一张活动卡片看到的class名通常是这样的div.activity-list-item.content-card。用.activity-list-item就能定位到所有活动卡片。CSS选择器的问题是前端重构时class名经常变。今天叫.activity-list-item明天可能改成.event-card一旦改了你的定位器就全部失效。所以我把CSS选择器定位作为“快速验证版本”而不是最终方案。3.3 XPath定位这次项目里最稳的选择最终我采用的是XPath定位卡片容器。定位方式是先找到活动列表的父容器再往下找子节点。cards page.locator( xpath//div[contains(class, activity-list)]//div[contains(class, activity-card)] ) count cards.count() print(f当前页面加载出 {count} 张活动卡片)在这套写法里我做了两层结构限定外层先定位到activity-list这个列表容器内层再定位activity-card这个卡片节点。这样就算页面上有其他区域出现activity-card字样也不会误匹配。XPath里的contains(class, xxx)比直接写classxxx好用。因为前端的class往往不止一个而是以空格分隔的多值classcontains只要包含目标词就能匹配上兼容性更好。三种定位方式的对比我整理如下定位方式优点缺点适用场景文本定位对结构不敏感直观容易误匹配无法精确定位容器等待页面出现某个关键词CSS选择器语法简洁定位速度快class改名后容易失效页面结构稳定的快速原型XPath可以写复杂层级关系容错强语法稍复杂动态页面的卡片容器定位定位到卡片集合之后还要做一件事循环遍历每张卡片提取字段。这一步我用的是嵌套定位for i in range(cards.count()): card cards.nth(i) title card.locator(xpath.//div[contains(class, title)]).inner_text() city card.locator(xpath.//div[contains(class, city)]).inner_text() time_text card.locator(xpath.//div[contains(class, time)]).inner_text() link card.locator(xpath.//a).get_attribute(href) print(title, city, time_text, link)注意card拿到的是一个Locator对象对它继续做.locator()时xpath前面要加.表示从当前节点往下找。不加点的话会从整个文档重新搜索结果可能跑到卡片外面去。这个是新手最容易踩的雷我一开始在这里废了不少时间。3.4 空节点兜底定位不到时别让脚本崩掉页面结构不总是完整的。有的活动卡片可能没有报名人数有的可能没写地点。inner_text()对不存在的节点会抛异常。所以我对每个字段都做了一层兜底def safe_text(locator): try: text locator.inner_text() return text.strip() except Exception: return 这个函数看起来简单但在实际跑批时价值非常大。全国几十个活动的列表只要有一张卡片结构异常脚本整个崩掉你前面的重试和等待全部白费。安全提取函数就是给脚本穿上防弹衣。4. 滚动加载实战别让while True裸奔4.1 观察滚动机制到底怎么样才算“加载了新数据”在写滚动代码之前我用有头模式手动操作了一遍页面——打开开发者工具盯着Network面板慢慢往下滚动。我观察到每次滚动到页面底部的大概三分之一区域时页面会发起一个XHR请求请求参数里有page和pageSize返回的是JSON格式的活动数据。前端拿到数据后再渲染出新的活动卡片。这就是一个典型的滚动分页加载循环。知道这个机制之后写自动化就心里有数了我要做的就是模拟用户滚动然后等待请求返回再继续滚动。4.2 基于高度判断的滚动实现最朴素的滚动方案是通过page.evaluate直接操作JS把滚动条拉到底部page.evaluate(window.scrollTo(0, document.body.scrollHeight))这个操作在Python爬虫里等价于你手动按了一下“End”键。滚到页面底部之后页面开始加载新数据新数据渲染完成后document.body.scrollHeight会变大。所以你要反复执行这个滚动操作直到滚动前后的高度不再变化说明没有新内容加载了。这里有个关键点每次滚动之后不能立刻判断高度有没有变化要给页面一点时间去发请求和渲染。我用的等待方式是page.wait_for_timeout(1500)每次滚动后固定等1.5秒。4.3 判断停止条件的三个信号滚动循环最终代码如下last_height page.evaluate(document.body.scrollHeight) stable_count 0 while True: page.evaluate(window.scrollTo(0, document.body.scrollHeight)) page.wait_for_timeout(1500) new_height page.evaluate(document.body.scrollHeight) if new_height last_height: stable_count 1 if stable_count 3: break else: stable_count 0 last_height new_height我加了一个stable_count变量。为什么连续3次高度不变才退出因为你滚动到页面底部后可能第一次没有触发加载第二次才触发。如果只判断一次相同就直接退出很可能会漏掉后半部分活动数据。连续3次高度完全相同基本可以认定数据已经全部加载完。这个方法对绝大多数滚动加载页面都通用不只是蔚来社区。以后你抓小红书、知乎话题页、汽车之家论坛都可以复用。4.4 监听网络响应来判断加载完成进阶版高度判断法简单可靠但有缺陷滚动后高度没变化可能是数据正在加载但还没渲染完你提前结束了也可能是网络请求失败了页面根本没有新数据。所以我后来把判断逻辑升级了一下用page.on(response)监听分页接口的响应。page.on(response, lambda response: print(response.url))这个监听器会把页面发起的每个请求URL打印出来。你可以在控制台里看到活动分页接口的URL规律比如里面包含/api/activity/list?page2这样的路径。如果能直接监听到pageN就可以把“最后一次请求页码”作为滚动过程中数据的落点。不过这个方案调试成本高一点。如果只是想快速把全国活动数据拿到手高度判断法足够了。我用最终版代码实测滚动结束后拿到的卡片数量和我在开发者工具里手动翻完整个列表的数量是一致的。5. 数据清理与落库从杂乱的DOM文本到规整的数据表格5.1 文本字段的清洗策略DOM里提取出来的文本通常带着各种杂质。比如时间字段可能是“2024年06月15日 14:00-17:00”前面还有“时间”两个字地点字段可能是“蔚来中心 | 上海静安嘉里中心”这种带竖线分隔的形式。我列了一张清洗规则表方便对照处理字段DOM原始示例清洗后处理方式活动标题NIO Summer 2024 · 北京站NIO Summer 2024·北京站去除首尾空格替换多个空格为一个城市城市上海上海去掉“城市”前缀地点蔚来中心丨杭州西湖杭州西湖按竖线分割取后半段开始时间2024-06-15 14:00开始2024-06-15 14:00:00用正则提取日期时间报名状态已报满 / 报名中已报满直接取值洗数据的时候我推荐尽量用正则而不是replace叠罗汉。一个re.sub能解决的问题不要写五个replace。我清洗时间的代码是这样的import re def parse_time(raw: str): m re.search(r(\d{4})[-年/](\d{1,2})[-月/](\d{1,2})日?\s*(\d{1,2}:\d{2})?, raw) if m: return f{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d} (f {m.group(4)} if m.group(4) else ) return 日期里的月和日可能有前导零也可能没有“2024-6-15”和“2024-06-15”都存在。我上面用int()转一次再补零能保证输出格式统一后续存数据库或做排序都方便。5.2 城市字段的归一化把活动归到城市维度标题里说的是“全国活动数据”那就不能只存文本得把城市维度提出来。毕竟后面分析时你可能要看“哪个城市活动最多”“哪个月份活动密集”这些分析都需要单独的城市字段。城市提取我没有用复杂的NLP方案就是手动维护一个城市关键词表city_keywords [北京, 上海, 广州, 深圳, 杭州, 成都, 武汉, 西安, 南京, 苏州, 宁波, 合肥, 重庆, 长沙, 青岛, 厦门, 天津, 无锡, 常州, 福州, 郑州, 济南] def extract_city(title, location): for city in city_keywords: if city in title or city in location: return city return 未知这个办法看起来很土但非常实用。蔚来社区的活动标题和地点文本里一定包含城市名而城市名的数量其实有限全国主要城市也就二三十个。比起训练一个模型手工维护表更可控、更快。5.3 用SQLAlchemy存储到SQLite轻量但规整热搜词里提到了SQLAlchemy存储爬虫数据我这次也确实是用它来落库的。SQLite不需要额外启动数据库服务适合做个人爬虫的本地存储。先定义模型from sqlalchemy import create_engine, Column, String, Integer from sqlalchemy.orm import declarative_base, sessionmaker Base declarative_base() class Activity(Base): __tablename__ nio_activities id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(200)) city Column(String(50)) location Column(String(200)) start_time Column(String(50)) status Column(String(20)) link Column(String(500))然后建表和插入engine create_engine(sqlite:///nio_activities.db, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() # activities 是清洗后的字典列表 for item in activities: act Activity(**item) session.add(act) session.commit()这里有个经验插入数据时最好先查一遍标题和城市是否已存在避免重复爬取导致数据翻倍。去重键我用的是(title, city, start_time)这个三元组因为不同城市可能办同标题的活动光靠标题去重会误删。5.4 顺手导出CSV一键查看不用进数据库数据库虽然规整但大多数人还是习惯打开Excel看数据。所以我在脚本最后加了一段CSV导出import csv with open(nio_activities.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, city, location, start_time, status, link]) writer.writeheader() for item in activities: writer.writerow(item)编码用了utf-8-sig不加这个的话Excel打开CSV会乱码。这也是一个细节点。6. 风控与翻车实录被当机器人的那几次6.1 第一次被拦截页面返回了环境校验我第一版脚本写完后开开心心跑起来结果跑了大概十几页之后页面突然不再加载活动卡片了。打开浏览器截图一看页面变成了一个环境校验页面提示“系统检测到异常访问请完成验证”。这就是爬虫社区常说的风控触发。蔚来社区用的防护体系会跟踪浏览行为如果同一浏览器短时间内滚动次数异常频繁、请求间隔太短就会触发人工校验。我的第一反应不是去逆向这个防护而是反思自己的行为特征哪里不像真人。排查后的第一个问题是滚动间隔太固定每1.5秒一次像机器节拍器一样精准。真人滚动是有快有慢的所以我加了随机延时import random time.sleep(random.uniform(1.2, 2.5))别小看这个随机延时它是我这轮优化里回报最大的一行代码。6.2 指纹暴露navigator.webdriver标记问题第二次遇到风控是在我切到无头模式之后。脚本在本地有头模式跑得好好的一上服务器无头模式跑没过多久就被拦。我对比了一下有头模式和无头模式下浏览器的差异发现navigator.webdriver这个属性在无头模式下是true在有头模式下是false。很多网站的风控脚本就是检测这个标记来决定要不要给你弹验证码的。解决的方案有两个一个是在启动参数里去掉自动化标记另一个是禁用navigator.webdriver这个属性本身。我用了前者browser p.chromium.launch( headlessTrue, args[ --disable-blink-featuresAutomationControlled, --user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... ] )注意UA也要设置。无头模式下默认UA字符串里可能是HeadlessChrome这是明显的破绽。6.3 用page.on定位隐藏接口读取数据的新思路风控问题折腾到后面我发现与其一直跟DOM渲染纠缠不如换一个思路直接监听页面发出的分页接口。Playwright的page.on(response)可以让你看到页面所有网络请求的URL和响应体。我把活动列表分页的接口URL打印出来后发现这个接口返回的就是活动JSON。这就意味着我可以减少对DOM的依赖——只要浏览器正常滚动触发请求我直接从响应里拿数据不需要再解析卡片DOM。def on_response(response): if /api/activity/list in response.url: json_body response.json() print(json_body[data][total]) page.on(response, on_response)这个策略的优点是数据直接从JSON里来字段结构干净没有DOM解析的麻烦缺点是你需要手动分析一次页面的网络请求确定哪个接口是分页接口。不过这个分析过程只用做一次之后可以长期复用。6.4 合理限速爬虫也要有边界意识这次实战之后我对“风控”有了新的理解。风控不是为了拦截所有自动化而是拦截“会给系统造成异常压力的请求”。所以应对风控最有性价比的手段不是堆代理池、不是搞特征伪装而是控制自己的爬取节奏。我从这次项目里总结出的限速经验是单页数据爬取间隔不少于1秒单个浏览器会话最长运行时间控制在10分钟以内一次爬取结束后至少间隔十几分钟再跑第二次只在公开页面抓取公开可见的数据不碰需要登录才能访问的报名接口这个边界定下来之后脚本在这类社区页面上跑起来稳定多了不再频繁触发风控而且数据量也完全没有变少。写在最后一套自动化脚本值钱的不是代理池而是对页面状态机的理解这次蔚来社区活动数据爬虫前前后后我改了三版从纯requests失败到Playwright无脑滚动再到滚动加载加监听分页接口最后稳定落地成一套可以“一键运行提取全国活动数据”的脚本。最大的体会是爬虫脚本的价值不在于用了多高级的工具而在于你对页面加载逻辑的理解有多深。像蔚来社区这种动态渲染加滚动加载的页面页面状态就是“初始加载—卡片渲染—滚动触发—分页请求—新卡片追加—滚动到底”爬虫要做的其实是把这个状态机完整复现一遍。如果你也想拿Playwright做其他社区的动态数据爬取我建议先花半小时用有头模式把页面交互走一遍打开开发者工具把Network和DOM结构都看清楚再动手写代码。这个准备时间往往比后面调试省下的时间要多得多。最后再提醒一句任何爬虫项目都要克制使用频率只访问公开页面遵守目标网站的服务协议。这套代码和方法适合你自己做数据分析、活动调研这类个人用途不要去构建那种高频轰炸式的采集服务——那样做对谁都没有好处。