用积木拼一个最小职位爬虫

前三讲我们已经把"原理"和"积木"准备好了——知道爬虫是什么、浏览器和服务器怎么对话、四种采集通道分别在干什么、每个基础动作能产生什么效果。

这一讲要做一件非常重要的事:把这些零散积木第一次拼起来,做出一个真正能跑、能拿到数据、能保存下来的"最小可用职位爬虫"。

这里会刻意把它做得很小,因为只有先体会"麻雀虽小、五脏俱全"的整体感,这样在学习两个项目时才不会被淹没。

能独立写出这个最小版本,再回头看 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 不够用时——进入动态网页与浏览器自动化