爬虫的基础积木——每个动作能得到什么效果

前两讲我们解决了"爬虫是什么"和"有哪些采集通道"的问题。

这一讲开始进入实战的第一步:把爬虫拆成一个个最小的"动作积木"。

只要理解每块积木"输入什么、输出什么、解决什么问题",后面再看真实项目里那些上千行的脚本,就会发现——它们不过是这些积木的反复组合和封装而已。

讲按照"发请求 → 解析内容 → 操作浏览器 → 监听接口 → 落盘存储 → 控制节奏 → 处理异常"这个顺序展开,因为这正是数据从网络流到硬盘上的真实路径。


一、最底层的积木:发一个 HTTP 请求

爬虫的本质就是"程序代替人去发请求"。所以第一块积木,就是"发请求"。

1.1 requests.get(url) —— 拿到网页源码或接口数据

import requests

resp = requests.get("https://example.com")
print(resp.status_code)   # 200 表示成功
print(resp.text)          # 网页 HTML 源码(字符串)
print(resp.json())        # 如果是 API,直接解析成 Python 字典

能得到什么:

  • 如果 URL 是网页,resp.text 就是 HTML 字符串,后面交给解析器处理。
  • 如果 URL 是 API 接口,resp.json() 直接得到结构化数据(字典/列表),不需要任何解析。

对应到项目里:

通用结构化系统中"API 直连通道"(auto_api/baidu_data_proc_api.py)用的就是这种方式——百度、京东、金蝶等公司的招聘接口直接返回 JSON,根本不需要打开浏览器,效率最高、最稳定。

1.2 给请求加"伪装" —— headers 和 cookies

光会 get 还不够。很多网站一看你是个"裸请求"(没有 User-Agent、没有 Referer),直接拒绝你。

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...",
    "Referer": "https://www.liepin.com/",
}
resp = requests.get(url, headers=headers, cookies={"sessionid": "xxx"})

能得到什么: 让服务器以为"这是个正常用户从浏览器点过来的",大幅提高成功率。

这是反爬的第一道门槛,也是项目里所有 HTTP 请求都会带上 headers 的原因。

1.3 加代理 —— 让请求换个"出口 IP"

proxies = {"http": "http://1.2.3.4:8080", "https": "http://1.2.3.4:8080"}
resp = requests.get(url, proxies=proxies)

能得到什么: 同一个 IP 抓太多次会被封,代理能让你"换张脸"继续抓。

对应到项目里: spider.py 基类里的 getProxy() / setProxyRecord() 就是把代理做成了一个"资源池"——不是一次性用,而是"领用 → 上报使用情况 → 触达上限就换",这是工程化爬虫和玩具爬虫最大的区别之一。


二、解析积木:从 HTML / JSON 里把数据"挑"出来

请求拿回来的东西要么是 HTML(一坨标签),要么是 JSON(一坨嵌套字典)。我们要把"职位标题、薪资、公司名"这些字段从里面挑出来。

2.1 BeautifulSoup —— 从 HTML 里提取内容

from bs4 import BeautifulSoup

soup = BeautifulSoup(resp.text, "lxml")

title = soup.select_one("h1.job-title").get_text(strip=True)
links = [a["href"] for a in soup.select("a.job-link")]

能得到什么: 用类似 CSS 选择器的语法,把 HTML 里的标题、链接、文本批量提出来。

适用场景: 静态页面、详情页(项目里猎聘抓职位描述、地址、标签就是用这种方式)。

2.2 json.loads() —— 解析接口返回

import json
data = json.loads(resp.text)
job_list = data["data"]["jobCardList"]
for job in job_list:
    print(job["title"], job["salary"])

能得到什么: 接口返回的数据天然就是结构化的,直接按 key 取就行,不用费力解析 HTML。

对应到项目里: 猎聘的 onResponse() 拿到 pc-search-job 接口返回后,直接读 ret['data']['data']['jobCardList']——这就是为什么它"列表页靠接口"比"列表页靠 DOM"稳定得多。

2.3 正则表达式 —— 从乱糟糟的文本里抠特定模式

import re
salary = re.search(r"(\d+)k-(\d+)k", text)

能得到什么: 当数据混在长文本里(比如职位描述里夹着"3-5年经验"),用正则一抓一个准。项目里"届次推断"、"URL 归一化"都用正则。


三、浏览器操作积木:让 Playwright 像人一样动

当网站用 JavaScript 动态渲染、需要登录、需要点击展开时,requests 就不够用了——你必须真的"开一个浏览器"。

3.1 启动浏览器并打开页面

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)   # False 表示能看到浏览器窗口
    page = browser.new_page()
    page.goto("https://www.liepin.com/zhaopin/")
    page.wait_for_load_state("networkidle")       # 等所有网络请求结束
    html = page.content()                          # 拿到完整渲染后的 HTML

能得到什么: 拿到的是 JS 执行完毕后的最终 HTML,而不是原始的"骨架"HTML。这是抓动态网站的关键。

3.2 模拟用户操作

page.click("button.search-btn") # 点击按钮
page.fill("input.keyword", "Python 工程师")  # 输入文字
page.select_option("select.city", "北京") # 下拉选择
page.mouse.wheel(0, 3000) # 滚动 3000 像素

能得到什么: 触发筛选、翻页、加载更多——就像真人在用网站。

对应到项目里: 猎聘 __selectItem() 通过点击控件选公司性质、规模、经验,鱼泡爬虫还要"模拟滑块拖动"过验证——本质都是这几个 API 的组合。

3.3 反自动化伪装

page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")

能得到什么: 隐藏掉"我是个自动化工具"的标志,让网站不容易识破。

3.4 拦截资源,加速 + 省流量

def block_resource(route):
    if route.request.resource_type in ("image", "font", "media"):
        route.abort()
    else:
        route.continue_()
context.route("**/*", block_resource)

能得到什么: 不下载图片和字体,页面打开速度翻倍,代理流量也省一大半。猎聘和鱼泡爬虫全都用了这招。


四、监听积木:page.on("response") —— 偷听浏览器和服务器的对话

这是一个非常巧妙的技术,也是前面提到的"第四种采集通道"的核心。

def on_response(response):
    if "pc-search-job" in response.url:
        data = response.json()
        print("拦截到职位列表数据:", data)

page.on("response", on_response)
page.goto("https://www.liepin.com/zhaopin/")

能得到什么: 我们让浏览器正常打开页面、正常和服务器对话,但我们在旁边"偷听",把浏览器收到的接口数据截下来自己用。

为什么这招好用?

  • 不用自己研究接口签名(很多接口有复杂的 token、加密参数,自己构造很麻烦)
  • 不用解析复杂的 HTML
  • 拿到的就是干净的 JSON

对应到项目里: 猎聘所有列表数据都是这么拿的——这就是"页面操作驱动 + 接口监听获取"混合方案的精髓。


五、存储积木:把抓下来的东西存好

工程化爬虫一定不是"抓完直接入库",而是"先落盘,再入库"。

5.1 写 JSON 文件

import json
with open("job_123.json", "w", encoding="utf-8") as f:
    json.dump(job_data, f, ensure_ascii=False, indent=2)

能得到什么: 抓到的原始数据先存成文件,任何一步出错都能从文件重跑,不用重新抓。

5.2 文件状态机:.ok / .err / .expired

import os
os.rename("job_123.json", "success/job_123.json.ok") # 入库成功
os.rename("job_123.json", "failed/job_123.json.err") # 入库失败

能得到什么: 一眼看出每个任务的状态,失败的可以批量重试,成功的可以归档。

对应到项目里: 爬虫逻辑分析里反复强调的"先文件、后接口"——com/job/success/failed/progress/clean/ 这套目录就是一个手工版的"任务状态机"。

5.3 进度文件 / 状态文件:断点续跑的灵魂

# 每抓完一批就写一次
with open("progress.txt", "w") as f:
    f.write(str(last_id))

# 启动时读
last_id = int(open("progress.txt").read()) if os.path.exists("progress.txt") else 0

能得到什么: 程序中断了再启动,从上次的位置继续,不用从头来一遍。

对应到项目里: 猎聘公司版的 spider_status_<ComId>.json 就是这个的豪华版——它不仅记录"抓到哪了",还记录"哪些条件组合已经抓完了",甚至做了临时文件 + 原子重命名 + 备份恢复这种数据库级别的可靠性保证。


六、节奏控制积木:别让网站发现你是机器

6.1 随机休眠

import random, time
time.sleep(random.uniform(1.5, 4.0))

能得到什么: 请求之间有随机间隔,看起来更像人类。猎聘、鱼泡都有 randomSleep()

6.2 去重判断

if job_id in already_spider_today:
    continue   # 今天抓过,跳过

能得到什么: 不重复抓,省时间省代理额度。项目里有四层去重:今日是否抓过 → 公司维度是否抓过 → 数据库是否已存在 → 是否在黑名单


七、异常处理积木:网络是不可靠的

7.1 try / except + 重试

for i in range(3):
    try:
        resp = requests.get(url, timeout=10)
        break
    except Exception as e:
        print(f"第 {i+1} 次失败: {e}")
        time.sleep(2 ** i)   # 指数退避:1秒、2秒、4秒

能得到什么: 网络抖动、临时被封、超时——都不会让程序崩掉,而是"等等再来"。

对应到项目里: spider_yupao_clean.py 用的就是"指数退避 + 多选择器兼容"组合,这是工程化爬虫的标配。

7.2 风控识别

if page.title() == "猎聘":   # 正常页应该是"职位搜索 - 猎聘"
    print("可能被拦截")
    change_proxy()

能得到什么: 主动识别"我被发现了",立刻换代理或休眠,避免被永久封禁。


把这些积木拼起来,就是一个最小爬虫

我们可以现在就把上面所有积木串成一个 30 行的小例子,看看一个"麻雀虽小五脏俱全"的爬虫长什么样:

import requests, json, time, random, os
from bs4 import BeautifulSoup

headers = {"User-Agent": "Mozilla/5.0 ..."}
visited = set()

def fetch_list(page_no):
    url = f"https://example.com/jobs?page={page_no}"
    resp = requests.get(url, headers=headers, timeout=10)
    soup = BeautifulSoup(resp.text, "lxml")
    return [(a.get_text(), a["href"]) for a in soup.select("a.job-link")]

def fetch_detail(url):
    resp = requests.get(url, headers=headers, timeout=10)
    soup = BeautifulSoup(resp.text, "lxml")
    return {
        "title": soup.select_one("h1").get_text(strip=True),
        "desc":  soup.select_one(".job-desc").get_text(strip=True),
    }

def save(job_id, data):
    os.makedirs("jobs", exist_ok=True)
    with open(f"jobs/{job_id}.json", "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

for page_no in range(1, 6):
    for title, link in fetch_list(page_no):
        job_id = link.split("/")[-1]
        if job_id in visited:
            continue
        try:
            detail = fetch_detail(link)
            save(job_id, {"title": title, "link": link, **detail})
            visited.add(job_id)
        except Exception as e:
            print(f"出错: {e}")
        time.sleep(random.uniform(1, 3))

这就是一个完整的、能跑的、有去重有异常处理的最小爬虫。它包含了:发请求 → 解析 → 翻页 → 详情页 → 落盘 → 去重 → 节奏控制 → 异常处理 这 8 块积木。

真实项目(比如猎聘那 1000 多行的 spider_liepin_company.py)做的事情其实没有本质上的不同,只是把每块积木做强、做大、做工程化了:

  • "发请求"被升级成了 Playwright + 代理池 + 反自动化
  • "解析"分成了"接口监听拿列表 + DOM 拿详情"
  • "去重"变成了四层去重
  • "落盘"变成了完整的状态机目录
  • "进度记录"变成了带备份恢复的状态文件系统
  • "异常处理"变成了风控识别 + 自动换代理 + 跨日恢复

组内导航:⬅️ 四重采集通道 | 🏠 00-爬虫课程六讲 | ➡️ 用积木拼一个最小职位爬虫