说实话,接到这门作业的时候,我连 Playwright 这个名字都念不完整。题目却能让人一瞬间看懂:用 Python、用 Playwright、做一个“智能招聘数据爬虫”。真正动手才发现,要搞定的不是某一行代码,而是“我怎么能让浏览器按我的节奏打开网页、输入关键词、点击搜索、翻页,把几十条岗位信息带回来,再整整齐齐写进 Excel”。这篇文章就是我从零折腾到能交作业的完整复盘,主题是 Python 零基础实战和智能招聘数据爬虫,用到的核心工具是 Playwright。它的玩法概括起来很简单:把你在浏览器里手动搜索招聘岗位的过程自动化,再按固定字段把结果抽取并存储下来。这份复盘适合作业党参考,也适合其他想用浏览器自动化解决重复点击问题的零基础新手,跟着走完全程大概只需要一个下午。
这个项目最容易踩的坑不是“不会写代码”,而是“一上来就想写一套完美框架”。我把整个项目拆成了四块来讲:先解读作业和方案选型,再讲稳定运行的关键细节,接着给出一套完整实操代码,最后整理我踩过的坑和排查方法。看完之后,你拿到的不是一段抄过来就跑不通的代码,而是能自己改、自己调试、遇到问题不慌的能力。
1. 作业需求拆解与整体设计思路,先把题读透再说写代码的事
1.1 题目里藏着哪些隐藏考点
课程作业嘛,标题一般都不会字字无用。我用最短的时间把题目拆成了四个关键词:Python 零基础、Playwright、智能、招聘数据爬虫。
“Python 零基础”意味着老师默认你会点语法,但不会默认你会框架。所以代码里尽量不要出现黑魔法级别的写法,每个函数、每个步骤要让人一眼看得懂,注释该写就写。“Playwright”是明确指定的技术栈,不是让你用 Selenium,也不是让你用 Scrapy,说明老师希望看到新兴自动化工具的应用。“智能”两个字别想太多,它不是要你上深度学习,而是要你的爬虫具备基本的自动搜索、自动筛选、字段解析、异常处理这些能对外称得上“智能”的能力。最后“招聘数据爬虫”才是核心交付,数据必须是真实页面上抓下来的岗位信息,不是写死的一堆假数据。
把这些考点对到评分标准上,老师的关注点其实高度集中在四件事上:第一,程序能稳定跑完整个流程,不闪退、不死循环;第二,数据准确,字段对得上,岗位数量和组织上的 Excel 能打开、能筛选;第三,代码结构清晰,有注释,有日志;第四,有一点容错意识,不会因为某一条数据缺失就整体崩掉。说白了,这门课要的不是一个“高级爬虫”,而是一个“能说明白、能演示、能和同学讲清楚”的完整小项目。
1.2 为什么选 Playwright 而不是 Requests 或 Selenium
我在动手之前确实纠结过技术选型。很多旧教程还在用 Requests 加 BeautifulSoup,这套方案遇到的大部分招聘站点会很尴尬,因为岗位列表基本是前端框架渲染出来的,直接请求地址回来的只有一堆 JavaScript 代码,什么数据都看不到。另一套老牌方案是 Selenium,它能驱动真实浏览器,但是 API 设计偏老,等待条件写起来又长又绕,环境配置还经常和浏览器驱动版本打架。
Playwright 的好处在于它把“等待页面就绪”这件事内置成了默认行为。你定位一个元素,如果元素还没出现,它会自动等到出现再执行下一步;如果元素被遮挡,它会自动滚动到可见区域。这一点解决了我初学时最常见的“时而能跑时而不行”的玄学问题。另外 Playwright 的命令行工具能直接帮你装浏览器驱动,不用手动找版本,零基础同学不需要理解驱动原理,一条命令就能准备好环境。后面我会演示这套环境怎么搭。
1.3 先画一条主链路,再往里面填代码
整个项目就是一条清晰的单向链路:启动浏览器、打开招聘平台的公开搜索页、填入关键词和城市、触发搜索、等待结果列表渲染、一条条提取岗位字段、判断是否存在下一页并翻页循环、最后把全部数据清洗去重写入 Excel。
我把这条链路分成三段来理解:开始段负责“把浏览器带到战场”,包括启动浏览器、设置身份信息、打开目标页面;循环段负责“重复提取直到翻完所有页”,这是爬虫最核心的循环逻辑,要处理等待、解析、翻页、异常记录;收尾段负责“把成果落到磁盘”,包括去重、排序、写表格、关闭浏览器释放资源。零基础新手只要能把这三段拎清楚,后面写代码就不会乱。目录也可以是三个函数:prepare 负责准备,fetch_page 负责抓取单页,save_data 负责落盘。哪怕一开始实现得粗糙,结构对了,后面优化就是往函数里补细节的问题。
2. 核心细节解析:稳定运行的几个关键点在动手前必须先压住
2.1 环境准备别图省事:虚拟环境和浏览器驱动一次装齐
很多零基础读者第一步就栽在环境上。我建议用虚拟环境把项目依赖隔离起来,避免电脑里其他项目的包冲突。命令行里执行:
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install playwright pandas openpyxl playwright install chromium这几条命令分别做了四件事:创建名为 venv 的虚拟环境、激活环境、安装运行时依赖、下载 Chromium 浏览器内核。第一次运行 playwright install 的时候会下载一两百兆的浏览器文件,网速慢的话要等一会儿,但是一次性装好之后就能稳定用。为什么用 Chromium 而不是 Chrome?因为 Chromium 是开箱即用的测试内核,不依赖你电脑上是否安装了对应版本的 Chrome,环境更可控。
还需要说明的是 pandas 和 openpyxl 是我写 Excel 用的,openpyxl 本身是 pandas 写 xlsx 格式的后端引擎。如果只想输出 CSV,可以只装 pandas,但课程作业展示 Excel 会比 CSV 看起来更正式,所以我保留了 openpyxl。环境准备没有太多技巧,唯一的建议是:严格按照顺序装,不要跳过 playwright install chromium,否则代码一跑就会报浏览器可执行文件找不到。
2.2 元素定位的三种姿势,用最不容易被页面改版波及的方式
Playwright 里定位页面元素有几十种方法,但零基础只要掌握三种就够应付招聘页面:CSS 选择器、XPath、文本定位。
CSS 选择器最适合定位有稳定 class 或 id 的元素,比如“搜索框”。如果页面上这个输入框有 id 是 keyword,我一般写 page.locator("#keyword")。XPath 适合按层级关系定位,比如“某个列表项内部的职位标题”,但我不推荐直接复制浏览器 DevTools 里右键生成的绝对 XPath,那种路径长、脆、页面稍微改动就失效。文本定位是 Playwright 的特色,比如页面上有个可见按钮的文字是“搜 索”,我可以写 page.get_by_text("搜 索"),注意文本中间可能带空格,实际写的时候可以按可见文本处理。
还有一个非常容易踩的细节:尽量和单个 locator 打交道。你在页面上一看觉得没问题,写 page.locator(".job-item") 可能有多个匹配,直接对它做操作会报 strict mode violation。所以在处理列表的时候,要拿到 locator 之后再调用 count() 获取数量,再用 nth(i) 逐个取第 i 个元素。如果你在一个有五个岗位的列表上用 inner_text(),Playwright 会提示你匹配到了多个元素,让你明确具体要哪一个。这个报错不是坏事,它逼着你写出更严谨的代码。
调试定位器最快的方法就是开一个独立的浏览器页面,在 DevTools 的 Console 里手动执行 document.querySelector 检查所选路径是否能命中目标。你可以用这个办法快速验证带空格的文本和动态 class。定位器写不对,后面所有逻辑都是白搭。
2.3 等待机制:别再到处塞 time.sleep 了
初学者最容易把程序写成一堆 time.sleep(3) 拼起来,页面快的时候白白等三秒,页面慢的时候三秒根本不够。Playwright 有一个核心机制叫 auto-wait:所有操作元素的方法都会等元素进入可操作状态,默认超时是 30 秒。所以定位一个按钮然后 click(),它会自己等待按钮出现、可见、不遮挡。
但是我建议你还是显式地等待关键节点,尤其是“搜索结果列表渲染完成”这种决定后续所有步骤的节点。我习惯用下面这种写法:
from playwright.sync_api import expect expect(page.locator(".job-list-item").first).to_be_visible(timeout=10000)这句话的意思是:等待第一个岗位条目在 10 秒内变成可见状态。如果列表始终不出现,说明搜索没有成功,这时候与其让后续代码因找不到元素而崩溃,不如在这里直接报一个定位超时,日志里能清楚看到是哪一步卡住。
time.sleep 并不是完全没有用。它的正确位置是在翻页之间模拟人的停顿节奏,以及触发某些懒加载页面的滚动后缓冲。为了不给目标服务器造成压力,翻页后随机停 0.8 到 1.8 秒是合理的行为。但结论要记住:能用显式等待解决的问题,不要用 sleep 去赌,sleep 永远只是“锦上添花”,显式等待才是“雪中送炭”。
2.4 反爬应对与合规底线:做课程作业也要懂边界
招聘网站的数据现在是很多分析项目眼里的香饽饽,站在网站的视角,它们当然不愿意被自动化程序整页整页地拉取。所以我在代码里做了三件低调的事情。
第一,设置一个常见浏览器的 User-Agent。默认的自动化浏览器 User-Agent 会直接暴露程序身份,新闻上常听说某某公司识别爬虫,识别原理很大一部分就是看这种请求头。第二,开启一个正常的浏览器上下文环境,设置语言为 zh-CN。这样页面返回的内容会比较接近真实用户看到的版本,也减少出现语言错乱的情况。第三,限制抓取速度和总页数。比如最多只翻 5 页,每页之间随机停顿,不追求把全站数据一网打尽。课程作业验证的是技术链路,不是数据量,没有必要把人家的压力测试也做了。
必须强调一点底线:只抓取公开可见的招聘岗位信息,比如岗位名称、公司名称、薪资范围、工作地点、岗位链接。对于需要登录才能看到的内容、个人联系方式、简历内容这些敏感信息,一律不碰。作业演示完就删除数据,不传播、不商用。这是我从这次实操里最想写进笔记的一段话,因为爬虫能力本身是中性的,边界是靠使用者自己划的。
3. 实操过程:从零搭建一个能跑通全文的招聘数据爬虫
3.1 先搭一个最小骨架:把浏览器打开就成功了一半
我写代码的习惯是先把骨架搭起来,保证“浏览器能打开、页面能访问、关闭能完成”,再往中间填充业务逻辑。这样每个阶段都有可验证的成果,心理上也舒服很多。
import random import time import datetime import logging import pandas as pd from playwright.sync_api import sync_playwright, expect KEYWORD = "Python" CITY = "目标城市" MAX_PAGES = 5 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger("job_crawler") def run(): with sync_playwright() as p: browser = p.chromium.launch(headless=False, slow_mo=200) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", locale="zh-CN", ) page = context.new_page() page.set_default_timeout(15000) page.goto("https://example-job-site.com/", wait_until="domcontentloaded") logger.info("页面标题: %s", page.title()) browser.close() if __name__ == "__main__": run()这段代码里有两个参数值得零基础同学理解。第一个是 headless=False,表示显示浏览器窗口,方便观察每一步到底发生了什么。交作业演示的时候就开着窗口,非常有说服力;如果只是想快速抓数据,可以改成 headless=True。第二个是 slow_mo=200,表示每一步操作之间放慢 200 毫秒,等于给操作加了慢镜头,调试的时候能看清页面变化。我调试页面卡顿问题时几乎都是靠它观察动作节奏。
set_default_timeout(15000) 设置了全局操作超时为 15 秒。这个值不是越大越好,大了会让人等得焦急,小了在网络波动时会误伤。15 秒在课程演示场景下比较合适。初始化完成后,先把浏览器能正常打开网页这个结果跑出来,整个项目就有了一份确定的起步基础。
3.2 模拟搜索与筛选:填充输入框、点击按钮、等待结果渲染
接下来进入主干逻辑。假设目标招聘平台主页上有岗位关键词输入框、城市输入框和搜索按钮。实际页面里的选择器我没办法替你固定,因为不同站点结构差异很大,我这里用带注释的示例选择器,你把它们换成你自己目标站点上的真实选择器就行。
def search_jobs(page): keyword_input = page.locator("#job_search_keyword") keyword_input.fill(KEYWORD) city_input = page.locator("#job_search_city") city_input.fill(CITY) search_btn = page.locator("button.btn-search") search_btn.click() expect(page.locator(".job-list-item").first).to_be_visible(timeout=10000) items = page.locator(".job-list-item") total = items.count() logger.info("本页抓取到岗位数量: %d", total)fill 方法会模拟真实输入,先聚焦再注入文本。这一步对动态页面来说比粗暴地设置 value 属性更可靠。点击按钮之后赶紧加一句 expect 等待结果列表出现,是因为页面搜索是异步的,可能请求要两秒、渲染要两秒,你不等它就会出现空列表。等待就绪后再 count() 统计数量才是真正有效的数量。
有的网站搜索后会自动在地址栏带上查询参数,比如 https://example-job-site.com/jobs?keyword=Python&city=目标城市。如果遇到这种 RESTful 风格的站点,更稳的方式其实是直接构造带参数的 URL,连点击搜索按钮都不需要。但作为课程作业,我还是建议保留“填表单、点按钮、等渲染”的流程,因为这样更能体现你对自动化交互的理解。
3.3 提取目标字段:从一页列表里把结构化数据带出来
拿到岗位列表后,我们要从每个条目里提取职位名称、公司名称、薪资范围、详情链接这些字段。Playwright 的推荐做法是先拿一个 locator 指向所有列表项,再通过 nth(i) 逐个取元素。这是最稳妥的方式,不会因为页面多渲染了一条广告被误伤。
def parse_page(page): jobs = [] items = page.locator(".job-list-item") for i in range(items.count()): item = items.nth(i) try: title = item.locator(".job-title").inner_text().strip() company = item.locator(".company-name").inner_text().strip() salary = item.locator(".salary").inner_text().strip() link = item.locator("a.job-title-link").get_attribute("href") jobs.append({ "职位": title, "公司": company, "薪资": salary, "详情链接": link, "采集页": page.url, }) except Exception as exc: logger.warning("第 %d 条数据解析失败: %s", i + 1, exc) continue return jobs对每一条都做 try 处理是因为页面数据总有脏数据,某条没有薪资,某条链接是空的,如果不对单条容错,一条坏数据会让整个循环崩溃。这其实就是“智能爬虫”在工程层面最朴素的含义:即使环境有异常,程序依然能继续跑。
提取之后还要对 salary 这类文本做轻量清洗。招聘站点上的薪资经常写成“8千-1.5万”或者“20-30K·14薪”,直接入库问题不大,但如果想让 Excel 更整洁,可以写一个简单的转换函数。用正则把数字和单位拆出来,再把“千”“万”换算成标准数字。这一小步很容易让爬虫在答辩时显得有亮点。
3.4 翻页与动态加载:循环必须有边界,必须能优雅退出
抓完第一页之后要判断有没有下一页。常规做法是找“下一页”按钮,点击前先判断它是否可见、是否禁用。同时要设置最大页数 MAX_PAGES,防止网站出现“下一页永远可点”的极端情况,让程序死循环跑到天荒地老。
def crawl(): all_jobs = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False, slow_mo=200) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", locale="zh-CN", ) page = context.new_page() page.set_default_timeout(15000) page.goto("https://example-job-site.com/", wait_until="domcontentloaded") search_jobs(page) for page_no in range(1, MAX_PAGES + 1): logger.info("开始抓取第 %d 页", page_no) page_jobs = parse_page(page) all_jobs.extend(page_jobs) next_btn = page.locator("button.next-page") if next_btn.count() == 0 or not next_btn.is_visible() or next_btn.is_disabled(): logger.info("没有下一页,停止翻页") break next_btn.click() page.wait_for_load_state("networkidle") time.sleep(random.uniform(0.8, 1.8)) browser.close() return all_jobs点击完“下一页”之后,我用 wait_for_load_state("networkidle") 等待网络空闲。这里有个值得注意的细节:networkidle 表示页面短时间内没有新的网络请求,但并不是所有站点的列表都是靠新请求渲染的,有的站点是前端本地过滤数据,这时候 networkidle 会一直等不到理想状态。所以也可以把“下一页点击后等待”写成 expect 结果列表的第一个元素重新可见,用元素状态代替网络状态更保险。
翻页循环还牵涉一个常见问题:有些“下一页”按钮点击后,页面 URL 会变化,但列表数据更新比较慢,导致重复抓取上一页的数据。我的建议是把详情链接作为去重依据之一,放到数据清洗环节统一处理,即使翻了重复页,最终结果也是干净的。
3.5 数据清洗与写入 Excel:交付物要经得起老师双击打开
翻页结束后,数据已经攒在 all_jobs 列表里。接下来就是课程作业最加分的环节:把它整理成一个字段清晰、去重后的 Excel 文件。
def save_to_excel(jobs): df = pd.DataFrame(jobs) if df.empty: logger.warning("没有抓到任何数据,跳过保存") return df.drop_duplicates(subset=["职位", "公司", "详情链接"], inplace=True) df["采集日期"] = datetime.date.today().isoformat() filename = "招聘数据_" + datetime.date.today().isoformat() + ".xlsx" df.to_excel(filename, index=False) logger.info("已保存 %d 条数据到 %s", len(df), filename)去重的时候我把详情链接也放进 subset 里,是因为同一个公司在同一页面上可能用同一个职位名称发多个岗位,光凭“职位+公司”去重会把一些真实职位误删,加上链接就能保证唯一。采集日期字段用于标注数据时间点,这也是课程答辩时体现专业性的小细节。
为什么用 pandas 而不是直接操作 openpyxl?因为 pandas 的 DataFrame 处理数据清洗特别顺手,去重、排序、改列名都只要一行代码,写完之后一行 to_excel 直接落盘。零基础同学不用理解 Excel 内部 API,把 DataFrame 当成一张“带表头的表格”来理解就够了。如果在某些电脑上打开 xlsx 出现格式兼容问题,可以改成 df.to_csv("data.csv", index=False, encoding="utf-8-sig"),这个编码参数在 Windows 上打开 CSV 时能避免中文乱码。
4. 常见问题与排查技巧实录,全是实操中真踩过的坑
4.1 高频报错速查:一张表快速定位问题
我把自己在这次作业过程中遇到的所有报错整理成了速查表,建议把它贴在代码旁边作为排查手册:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 浏览器打开后一直卡在等待 | 默认超时太短或页面有资源加载慢 | 调大 set_default_timeout,改用 wait_until="domcontentloaded" |
| 定位元素报 strict mode violation | 选择器匹配到多个元素 | 用 .first、.nth(i),或者换更精确的选择器 |
| 列表数量为 0 | 结果可能在 iframe 或 shadow DOM 内 | 用 frame_locator 进入 iframe,或改用更靠近数据的 selector |
| 点击“下一页”没反应 | 按钮被弹窗遮挡或尚未渲染 | 先滚动到按钮,再用 expect(...).to_be_enabled() |
| 控制台中文乱码 | 终端编码不是 UTF-8 | Windows 终端运行 chcp 65001,或给代码文件加 utf-8 声明 |
| 导入 pandas 时报错 | 虚拟环境未激活或包没装齐 | 检查 venv 是否激活,按前文命令补齐依赖 |
| 数据里有重复岗位 | 翻页或搜索时重复采集 | 在 DataFrame 成语去重,用链接做唯一键 |
这张表不是替代思考,而是让你知道“看到报错先别慌,它对号入座的概率很高”。如果用表里的办法解决了,再去深入理解原因,学习曲线会平滑很多。
4.2 我的调试三板斧:截图、录像、暂停观察
遇到页面上的疑难杂症,我最推荐的方法是截图。Playwright 里一行 page.screenshot(path="debug.png") 就能把当前页面状态保存下来,很多时候你猜不到页面到底长什么样,截图一看就明白了。“下一页按钮到底有没有出现”“弹窗是不是把列表挡住了”,这类问题截图比日志描述直接得多。
第二个好用的方法是 context.tracing 录制完整操作轨迹。在启动浏览器后加上 tracing.start(screenshots=True, snapshots=True),结束前用 tracing.stop(path="trace.zip") 保存,就能得到一份包含每一步操作、截图和网络请求的轨迹包。遇到那种“第三次翻页才失败”的间歇性问题,轨迹回放就像看着录像找事故现场,排查效率比盲猜高很多。
第三个方法完全反直觉,就是放慢。把 slow_mo 调成 500,浏览器每一步操作都慢吞吞,你能看清搜索按钮是不是真的被点上了、页面是不是真的刷新了。我还配合 logger 打印每页的当前 URL 和页面标题,一旦异常就能定位是“页面跳错了”还是“选择器写错了”。这些工具不复杂,但比闷头改代码要高效得多。
4.3 课程作业交付时的小建议:演示前一定要跑一遍全流程
只有到了交作业的时候,你才会意识到“能跑”和“能当着老师的面跑通”是两回事。我在演示前一天晚上临时加了一个字段,觉得改动很小,结果运行到第三页的时候因为选择器没更新直接中断,场面一度尴尬。所以我的第一条建议是:最终版本提交前,从头到尾完整跑一遍,并且打开生成的 Excel 确认数据确实存在,不是只在终端里打印了几行就交差。
第二条建议是代码里预留一个“演示模式”。比如设置 MAX_PAGES=2,演示时快速跑完两页就能展示完整链路,避免让现场所有人盯着屏幕等五分钟翻页。headless=False 也建议保留成可配置参数,因为正式演示时打开浏览器窗口更有视觉冲击力,老师能直观看到程序在真实浏览器里操作页面,这比只看到一个黑框终端更能说明你的工作量。
第三条建议是提前准备一分钟讲稿:先说目标页面是什么,再说爬虫流程分几步,最后展示 Excel 结构化结果。这个讲法正好对应代码里的搜索、解析、落盘三段逻辑,老师顺着你的思路走,很容易给出高分。
结尾:做完这次作业后,我留下的三个真实体会
第一个体会是环境问题的优先级远比想象中的高。我大概花了三分之一的时间处理安装、虚拟环境和浏览器驱动问题,真正写业务逻辑只用了一个晚上。很多新手把时间全花在琢磨选择器上,结果连“Playwright 浏览器内核没装”这种起步问题都没跨过去。所以环境安装这件事,宁愿前面慢一点、稳一点,也别跳着走。
第二个体会是“最小可跑版本”比“完美版本”重要得多。你说要支持多关键词、多城市、断点续传、邮件通知,听起来很厉害,但零基础版本如果连第一页都跑不通,一切都是零。先跑通一个最简单的搜索页,然后再加翻页,再加清洗,再加日志,每加一个功能都重新验证一遍,进度反而快。
第三个体会是爬虫项目真正锻炼的不是某个框架的 API,而是“定位问题拆解问题”的思维方式。你会慢慢习惯把一个复杂任务拆成“打开页面、填入数据、等待结果、提取信息、循环下一页、保存文件”这些可验证的小步骤。这个能力不只在课程作业里有用,以后做数据分析、做自动化测试、做一点业务流程小工具,都会反复用到。这次作业能用 Playwright 把招聘数据完整拿下来,最大的收获,其实就是这份“拆解之后逐个击破”的踏实感。