--- 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_.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 就算成功。 [[06-练手|练手]] 第二件,故意制造三种失败,观察程序行为:把 `User-Agent` 删掉看看会不会被拒、把网线断一下看看 `timeout` 触发什么、把某个 CSS 选择器改错看看 `parse_list` 会怎么炸。这三种失败,**就是后面"工程化加固"要解决的三类问题**。 第三件,打开 `spider_liepin.py`,找到它的 `__spider` 方法,看看它是怎么完成"列表 → 详情 → 落盘"这条主链路的——你会惊讶地发现,**主干跟你写的这 60 行几乎一模一样,只是每个环节都被加固了**。 --- **组内导航**:⬅️ [[03-爬虫的基础积木——每个动作能得到什么效果|爬虫的基础积木——每个动作能得到什么效果]] | 🏠 [[00-爬虫课程六讲|00-爬虫课程六讲]] | ➡️ [[05-当 requests 不够用时——进入动态网页与浏览器自动化|当 requests 不够用时——进入动态网页与浏览器自动化]]