---
title: "04-用积木拼一个最小职位爬虫"
created: 2026-05-06
tags:
- 项目
aliases:
- 第 4 讲:用积木拼一个最小职位爬虫
---
# 用积木拼一个最小职位爬虫
前三讲我们已经把"原理"和"积木"准备好了——知道爬虫是什么、浏览器和服务器怎么对话、四种采集通道分别在干什么、每个基础动作能产生什么效果。
这一讲要做一件非常重要的事:**把这些零散积木第一次拼起来**,做出一个真正能跑、能拿到数据、能保存下来的"最小可用职位爬虫"。
这里会刻意把它做得很小,因为只有先体会"麻雀虽小、五脏俱全"的整体感,这样在学习两个项目时才不会被淹没。
能独立写出这个最小版本,再回头看 `spider_liepin.py`,会发现它其实就是这个最小版本被反复增强、加固、扩展之后的样子。
## **这一讲的目标**
要拼出一个能完成下面这个完整闭环的小爬虫:
```text
打开列表页 → 拿到一批职位卡片 → 解析出标题和链接
→ 逐个打开详情页 → 提取描述、薪资、地点
→ 写成 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 里直接写着职位卡片。先用最朴素的方式把它抓下来:
```python
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 选择器集中管理"。
## **第三步:进入详情页,把字段补全**
列表页通常只给标题和链接,真正的职位描述、薪资、地址要进详情页才能拿到。
这正是猎聘那套实现里反复强调的——**列表页拿基础结构化字段,详情页补长文本字段**。
```python
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`——职位描述里通常有大量 `
` 和 `
`,用 `\n` 当分隔符能保留段落结构,方便后续做语义抽取。这是个非常小的细节,但《系统技术方案详解》里"内容标准化层"做的事,本质就是把这种细节放大一千倍。
## **第四步:把"积木"拼成完整循环**
现在把列表抓取、详情抓取、保存文件、去重四件事串起来:
```python
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_