用积木拼一个最小职位爬虫
前三讲我们已经把"原理"和"积木"准备好了——知道爬虫是什么、浏览器和服务器怎么对话、四种采集通道分别在干什么、每个基础动作能产生什么效果。
这一讲要做一件非常重要的事:把这些零散积木第一次拼起来,做出一个真正能跑、能拿到数据、能保存下来的"最小可用职位爬虫"。
这里会刻意把它做得很小,因为只有先体会"麻雀虽小、五脏俱全"的整体感,这样在学习两个项目时才不会被淹没。
能独立写出这个最小版本,再回头看 spider_liepin.py,会发现它其实就是这个最小版本被反复增强、加固、扩展之后的样子。
这一讲的目标
要拼出一个能完成下面这个完整闭环的小爬虫:
打开列表页 → 拿到一批职位卡片 → 解析出标题和链接
→ 逐个打开详情页 → 提取描述、薪资、地点
→ 写成 JSON 文件 → 顺便做最简单的去重
这个流程看起来朴素,但它就是那两个项目里最核心的那条主链路。猎聘也好、前程无忧也好、鱼泡也好,本质都是这条线,只是在每个环节上加了"加固件"而已。
第一步:先想清楚"抓什么",再写一行代码
新手最容易犯的错是打开编辑器就开始 requests.get。但真正写过几年爬虫的人都会先做一件事——手动用浏览器走一遍流程,弄清楚三件事:
第一件,列表页长什么样、URL 规律是什么、翻页是改 URL 参数还是点按钮。
第二件,列表里的每条数据,是写在 HTML 里的(看源代码能直接搜到职位标题),还是 JS 渲染出来的(源代码里搜不到,但页面能看到)。
第三件,详情页是独立 URL 还是弹窗,URL 里的 ID 从哪儿来。
这三件事直接决定用第二讲里的哪种采集通道。
如果列表页源代码里能搜到职位标题,静态抓取就够了;
如果搜不到但 F12 里 XHR 标签能看到一个返回 JSON 的接口,那就走 API 直连或 on_response 监听;
如果接口有签名很难复现,那就只能 Playwright 动态抓取。
猎聘项目里之所以选"页面操作 + 监听接口",就是因为它的搜索接口带签名参数,自己拼 URL 很麻烦,但页面操作能自然触发接口返回——这是观察之后做出的工程取舍,不是凭空决定的。
第二步:写一个最小静态版(用 requests + BeautifulSoup)
我们假设有一个虚构的职位站点 https://example-jobs.com/list?page=1,列表页的 HTML 里直接写着职位卡片。先用最朴素的方式把它抓下来:
import requests
from bs4 import BeautifulSoup
import json
import os
import hashlib
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
}
def fetch_list(page: int):
url = f"https://example-jobs.com/list?page={page}"
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
return resp.text
def parse_list(html: str):
soup = BeautifulSoup(html, "lxml")
jobs = []
for card in soup.select(".job-card"):
title = card.select_one(".job-title").get_text(strip=True)
link = card.select_one("a")["href"]
jobs.append({"title": title, "link": link})
return jobs
这里有几个细节需要记住,因为它们后面会被反复"升级":
HEADERS 里的 User-Agent 是在伪装成浏览器,不加的话很多站点直接返回 403。timeout=10 是为了不让一个卡死的请求拖垮整个程序。select 用 CSS 选择器定位,比 find_all 更直观。这三件小事,到了猎聘项目里就分别演化成了"反自动化注入""异常重试""DOM 选择器集中管理"。
第三步:进入详情页,把字段补全
列表页通常只给标题和链接,真正的职位描述、薪资、地址要进详情页才能拿到。
这正是猎聘那套实现里反复强调的——列表页拿基础结构化字段,详情页补长文本字段。
def fetch_detail(url: str):
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "lxml")
return {
"salary": soup.select_one(".salary").get_text(strip=True),
"location": soup.select_one(".location").get_text(strip=True),
"description": soup.select_one(".job-desc").get_text("\n", strip=True),
}
注意这里用 get_text("\n", strip=True) 而不是直接 .text——职位描述里通常有大量 <br> 和 <p>,用 \n 当分隔符能保留段落结构,方便后续做语义抽取。这是个非常小的细节,但《系统技术方案详解》里"内容标准化层"做的事,本质就是把这种细节放大一千倍。
第四步:把"积木"拼成完整循环
现在把列表抓取、详情抓取、保存文件、去重四件事串起来:
SAVE_DIR = "./data/jobs"
os.makedirs(SAVE_DIR, exist_ok=True)
def job_id(link: str) -> str:
return hashlib.md5(link.encode()).hexdigest()[:12]
def already_spidered(jid: str) -> bool:
return os.path.exists(os.path.join(SAVE_DIR, f"{jid}.json"))
def save_job(jid: str, data: dict):
path = os.path.join(SAVE_DIR, f"{jid}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def run(max_page: int = 3):
for page in range(1, max_page + 1):
html = fetch_list(page)
jobs = parse_list(html)
for job in jobs:
jid = job_id(job["link"])
if already_spidered(jid):
print(f"跳过已抓: {job['title']}")
continue
try:
detail = fetch_detail(job["link"])
job.update(detail)
save_job(jid, job)
print(f"已保存: {job['title']}")
except Exception as e:
print(f"抓取失败 {job['link']}: {e}")
if __name__ == "__main__":
run()
到这里,一个真正能跑的最小职位爬虫就完成了。它已经具备五个关键能力:分页遍历、列表解析、详情补抓、本地落盘、基础去重。
第五步:对照真实项目,看每个"积木"是怎么被升级的
现在最有意思的部分来了。刚写的这 60 行代码,几乎每一行都能在那两个项目里找到"加强版",而所谓"工程化",就是把每一个朴素动作变得更稳、更准、更可恢复。
用一张对照表建立映射:
| 最小爬虫里的动作 | 项目里的加强版 | 加强的原因 |
|---|---|---|
requests.get |
Playwright 启动真实 Chromium | 应对 JS 渲染和反爬指纹检测 |
User-Agent 一行伪装 |
注入 navigator.webdriver=undefined、屏蔽图片字体 |
反自动化检测越来越严 |
BeautifulSoup 解析 HTML |
监听 pc-search-job 接口直接拿 JSON |
列表 DOM 不稳定,接口字段更结构化 |
详情页 requests.get |
Playwright 新开 popup 抓详情 | 详情页很多内容是 JS 渲染的 |
hashlib.md5 做去重 |
isSpiderToday + jobExist + 黑名单三层去重 |
单层去重不够,要兼顾本地、业务库、黑名单 |
os.path.exists 跳过 |
spider_status_<ComId>.json 状态文件 |
要支持断点续抓和条件组合标记 |
try/except 打印错误 |
success/failed/expired 三目录归档 + 重试次数 |
失败要可追溯、可重跑 |
直接 requests.get |
代理池 getProxy + 配额 limitSpiderCount |
单 IP 抓多了会被封 |
| 一次跑完 | 凌晨 8 点前不跑 + 9 点 17 点定时 | 长期任务要避开高风控时段 |
| 没有入库 | 落地 JSON 后调 jobparse HTTP 接口 |
采集和解析解耦,便于失败重试 |
每一项升级都不是"为了高级而高级",而是为了解决最小版本暴露出来的具体问题。
这也是为什么反复强调要先写最小版本——只有亲手撞到"被封 IP""HTML 改版""跑一半中断"这些坑,才能真正读懂猎聘项目里那些看起来"过度设计"的东西。
第六步:动手作业
这一讲不是看完就算了。我建议你做下面三件事,做完再进入第 5 讲会顺畅很多:
第一件,挑一个对你友好的真实站点(比如某个学校的就业信息网,或者你那两个项目里 auto_gen 目录里已经做过的某个站点),把上面这 60 行代码改成能真正跑通的版本。不要追求多完美,能保存出 10 条 JSON 就算成功。 练手
第二件,故意制造三种失败,观察程序行为:把 User-Agent 删掉看看会不会被拒、把网线断一下看看 timeout 触发什么、把某个 CSS 选择器改错看看 parse_list 会怎么炸。这三种失败,就是后面"工程化加固"要解决的三类问题。
第三件,打开 spider_liepin.py,找到它的 __spider 方法,看看它是怎么完成"列表 → 详情 → 落盘"这条主链路的——你会惊讶地发现,主干跟你写的这 60 行几乎一模一样,只是每个环节都被加固了。
组内导航:⬅️ 爬虫的基础积木——每个动作能得到什么效果 | 🏠 00-爬虫课程六讲 | ➡️ 当 requests 不够用时——进入动态网页与浏览器自动化
💬 评论