从能跑到能稳定跑很久——爬虫的工程化跃迁

前几讲,已经能写出一个会发请求、会解析、会绕反爬的爬虫了。但它还只是个"脚本"——点一下运行,它跑完就结束。

而真实项目里的爬虫要做的事完全不一样:

  • 24 小时不停跑,有时候要连续跑几个月
  • 要抓几百家公司、几十万个职位
  • 中途网络断了、电脑重启了、被封了,得能自己恢复
  • 抓到的数据要进数据库、要去重、要追踪状态
  • 已经入库的数据过期了要清洗链接失效了要回写

这些事情不是写代码能力的问题,而是工程设计的问题

这一讲需要来回答最后一个核心问题:怎么把一个会跑的脚本,变成一个能稳定跑很久的系统?

学完这一讲,回头看猎聘项目里那个 1000 行的 spider_liepin_company.py,你会发现它其实没什么神秘的——它只是把这一讲讲的几件事,**每一件都做到了"豪华版"**而已。

一、从脚本到系统:核心矛盾在哪里?

把脚本变成系统,会遇到 5 个绕不开的问题。

一个一个解决。

问题1:配置硬编码 → 换个站点要改一堆代码    → 配置驱动
问题2:跑到一半崩了 → 重新跑一遍前面白做     → 状态系统
问题3:抓的数据越来越多 → 单进程跑不完       → 任务切片 + 多实例
问题4:数据怎么用?怎么去重? → 不能堆在硬盘  → 入库链路
问题5:已入库数据会过期、失效 → 数据库会脏   → 清洗回写

这 5 个问题对应着工程化爬虫的 5 个核心模块。逐个拆开讲。


二、问题 1:配置驱动 —— 把"会变的"和"不变的"分开

我们在第 4 讲写的爬虫,所有 URL、字段名、目录路径都直接写死在代码里:

URL = "https://realpython.github.io/fake-jobs/"
DATA_DIR = "data/jobs"

这个写法的问题:换一个站点要抓,整个代码都要改。换 10 个站点,意味着要写 10 份几乎一样的代码。

正确做法:把"会变的"抽出来放到配置文件里。

2.1 用 INI 配置文件

# config/spider_liepin.ini
[spider]
chromePath = /usr/bin/chromium
savePath = ./data/liepin
maxSpiderCount = 500
timeInterval = 86400

[api]
getProxy = http://proxy-pool.internal/get
jobUrl = http://api.internal/jobparse
comUrl = http://api.internal/comparse

代码里:

import configparser

cfg = configparser.ConfigParser()
cfg.read("config/spider_liepin.ini", encoding="utf-8")

chrome_path = cfg["spider"]["chromePath"]
save_path = cfg["spider"]["savePath"]
max_count = int(cfg["spider"]["maxSpiderCount"])
get_proxy_api = cfg["api"]["getProxy"]

这就是 spider.py 第一步做的事:spider.ini 装载所有"环境相关"的东西——浏览器路径、代理接口、入库接口、最大抓取数、去重时间窗口。

2.2 命令行参数

但有些东西,连配置文件都嫌死板——比如"今天我只想跑第 50 家公司"、"我要切到生产环境跑一下"。这种临时性指令用命令行参数:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-m", "--mode", default="ann", help="运行模式: ann/cjob/cp/...")
parser.add_argument("-f", "--shard", type=int, default=0, help="分片编号")
parser.add_argument("-d", "--env", default="dev", help="环境: dev/prod")
parser.add_argument("-c", "--company", help="单公司定向跑")
args = parser.parse_args()

if args.mode == "ann":
    run_ann_spider(shard=args.shard, env=args.env)
elif args.mode == "cjob":
    run_cjob_spider(shard=args.shard, env=args.env)

启动:

python main.py -m cjob -f 50 -d prod
python main.py -m cjob -c "腾讯" -d dev

这就是通用结构化系统里 main.py 那套 -m / -f / -d / -c 参数的全貌。一个脚本通过参数变成多种运行模式,省下了写 10 个脚本的功夫

2.3 配置层级:default + override

更进阶的做法是配置叠加:

setting_default.ini         ← 全局默认值
  ↓ 被覆盖
setting_sch_99.ini         ← 第 99 个分片的特定配置
  ↓ 被覆盖
命令行参数 -d prod          ← 临时覆盖

读取顺序:先读默认,再读特定分片,最后命令行覆盖。这样既能保持默认值统一,又能让每个分片有自己的个性化设置。通用结构化系统采用的就是这套层级。

三、问题 2:状态系统 —— 让爬虫"记得自己抓到哪了"

第 4 讲我们用 progress.txt 记录"抓到第几条"。但真实项目里,事情远比这复杂:

  • 一个公司有上千个职位,要按"地区 × 经验 × 薪资"切成上百个组合,每个组合都得记录是否抓完
  • 程序中途崩了,状态文件不能损坏(否则全完了)
  • 老格式的状态文件要平滑升级到新格式,不能丢数据

这就是猎聘公司版那套 spider_status_<ComId>.json 解决的问题。

3.1 状态文件的本质:就是个 JSON

{
  "ComId": "12345",
  "ComName": "腾讯",
  "crawled_conditions": [
    "010|java|3-5|15-25",
    "010|java|5-10|15-25",
    "010|python|3-5|15-25"
  ],
  "total_conditions": 200,
  "last_update": "2026-05-06T14:30:00"
}

读起来是一个数组,记录了"哪些条件组合已经抓完了"。

3.2 关键操作:判断 / 标记 / 完成率

import json, os

def load_status(com_id):
    path = f"status/spider_status_{com_id}.json"
    if not os.path.exists(path):
        return {"crawled_conditions": []}
    with open(path, encoding="utf-8") as f:
        return json.load(f)

def is_crawled(status, condition_key):
    return condition_key in status["crawled_conditions"]

def mark_crawled(status, condition_key):
    if condition_key not in status["crawled_conditions"]:
        status["crawled_conditions"].append(condition_key)

def is_company_done(status, total):
    return len(status["crawled_conditions"]) >= total

主流程:

status = load_status(com_id)
all_conditions = generate_conditions(com_id)   # 200 个组合

for cond in all_conditions:
    cond_key = f"{cond['city']}|{cond['type']}|{cond['exp']}|{cond['salary']}"

    if is_crawled(status, cond_key):
        continue   # 这个组合已经抓过了,跳过

    crawl_one_condition(cond)
    mark_crawled(status, cond_key)
    save_status(com_id, status)   # 每抓完一个组合就保存

if is_company_done(status, len(all_conditions)):
    print(f"公司 {com_id} 已全部抓完,进入增量模式")

这就是 __is_condition_crawled() / __mark_condition_crawled() 的核心逻辑

3.3 为什么要"原子写入"?

普通的 open(path, "w") + write() 有个隐患:写到一半程序崩了,文件就坏了。下次启动读不出来,所有状态丢失。

工程化做法:先写临时文件,再原子重命名

import json, os, tempfile

def save_status_safe(path, data):
    # 1. 备份现有文件
    if os.path.exists(path):
        os.replace(path, path + ".backup")

    # 2. 写到临时文件
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.flush()
        os.fsync(f.fileno())   # 强制写到磁盘,不在系统缓冲区里

    # 3. 验证临时文件能正常读
    with open(tmp, encoding="utf-8") as f:
        json.load(f)   # 能解析就说明完整

    # 4. 原子重命名(操作系统保证这一步要么成功要么不发生)
    os.replace(tmp, path)

这套流程是从"血的教训"里来的——猎聘公司版项目代码里专门写明了:遇到过状态文件损坏问题,才把写入流程加固到这个程度。

3.4 格式兼容与迁移

项目跑久了,会想升级状态文件格式(比如从字典换成数组以提高性能)。但线上几千个老文件不能丢

def load_and_migrate(path):
    with open(path, encoding="utf-8") as f:
        raw = json.load(f)

    # 旧格式: {"crawled": {"key1": True, "key2": True}}
    if isinstance(raw.get("crawled"), dict):
        new_data = {
            "crawled_conditions": list(raw["crawled"].keys()),
            "version": "v2",
        }
        save_status_safe(path, new_data)
        return new_data

    # 新格式直接返回
    return raw

这就是 convert_existing_status_files() 干的事——读老文件 → 自动转新格式 → 落盘。线上系统最值钱的不是代码,是历史数据,这种平滑迁移能力是工程化系统的标志。


四、问题 3:任务切片 —— 一个爬虫干不动,就开多个

4.1 为什么要切片?

一家大公司可能有 5000 个职位。一次性请求列表接口,只能拿到前 100 条(深翻页都受限制)。怎么办?

把"大查询"切成"小查询"——按地区、经验、薪资、职类做笛卡尔积:

cities = ["北京", "上海", "深圳", "杭州"]
exps = ["1-3年", "3-5年", "5-10年"]
salaries = ["10-20k", "20-40k", "40k+"]

conditions = [
    {"city": c, "exp": e, "salary": s}
    for c in cities for e in exps for s in salaries
]
# 4 × 3 × 3 = 36 个条件组合

每个组合大约返回几十到几百条职位,加起来覆盖全公司,每个组合也都不会触发深翻页限制。这就是猎聘公司版"四维条件组合"的本质——[dq, jobtitle, workyear, salary]

4.2 阈值优化:小结果集不切

如果一个公司只有 50 个职位,你切 36 个组合就是浪费——每个组合都要发请求、都要落盘。做个阈值判断:

total = get_total_count(company)

if total <= 120:
    # 直接抓一轮
    crawl_simple(company)
else:
    # 切片
    for cond in generate_conditions(company):
        crawl_one(cond)

这就是猎聘代码里那个 "120 阈值" 的由来——基于实际数据观察出的拐点。

4.3 多实例分片:把任务分给多台机器

更大的体量,要靠多机并行:

# 机器 1
python main.py -f 1   # 跑分片 1(公司 ID 模 10 == 1)

# 机器 2
python main.py -f 2

# 机器 10
python main.py -f 10

代码里:

def get_companies_for_shard(shard, total_shards=10):
    all_companies = fetch_all_companies()
    return [c for c in all_companies if int(c["id"]) % total_shards == shard]

就这么简单pyexe.py 那个生成 .bat 文件的脚本,做的就是把 python main.py -f 1-f 2-f 3...批量生成多个启动批处理,部署到多台机器上。

4.4 时间调度:不要 24 小时不停打

import datetime, time

def wait_until(hour):
    now = datetime.datetime.now()
    target = now.replace(hour=hour, minute=0, second=0)
    if target <= now:
        target += datetime.timedelta(days=1)
    seconds = (target - now).total_seconds()
    print(f"休眠 {seconds/3600:.1f} 小时,等到 {hour} 点")
    time.sleep(seconds)

while True:
    if datetime.datetime.now().hour < 8:
        wait_until(8)   # 凌晨不抓

    run_spider()

    wait_until(9)   # 跑完等到下次 9 点
    run_spider()
    wait_until(17)  # 再等到下次 17 点

这就是 spider_liepin_conditions.py 那套"凌晨 8 点前不抓、9 点和 17 点各跑一次"的实现

为什么不 24 小时跑?

  1. 凌晨流量小,你抓得猛容易被识别
  2. 抓久了代理消耗大,适当休息能让代理池补货
  3. 业务有节奏——招聘网站本身上班时间发布的新职位多,夜里没多少新数据,抓也是浪费

五、问题 4:入库链路 —— "先文件、后接口"

抓到数据之后,不要直接往数据库写。这是工程化爬虫最重要的一条原则。

5.1 为什么不直接入库?

直接入库:
爬虫  数据库
    
   这一步出错,数据就没了

先文件后接口:
爬虫  本地 JSON  API  数据库
                  
       数据保住了   API 失败可以重试

好处:

  1. 可追溯:出问题了,能看到每条数据当时长什么样
  2. 可重跑:解析逻辑改了,直接重跑本地 JSON,不用重新抓网络
  3. 解耦:抓取和入库是两件事,各自可以独立扩展、独立调试
  4. 批量重试:某个 API 挂了 1 小时,这 1 小时的文件等 API 恢复了批量重传

5.2 完整链路:落盘 → 调接口 → 归档

import os, json, requests, shutil

def save_raw(job_data, save_path):
    """步骤1: 抓到的数据落盘"""
    fname = f"{job_data['id']}.json"
    path = os.path.join(save_path, "job", fname)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(job_data, f, ensure_ascii=False)
    return path

def call_parse_api(file_path, api_url):
    """步骤2: 调用解析入库接口"""
    with open(file_path, encoding="utf-8") as f:
        content = f.read()

    resp = requests.post(api_url, data={
        "comFrom": "liepin",
        "fileName": os.path.basename(file_path),
        "content": content,
        "retry": 0,
    }, timeout=30)
    return resp.json()

def archive(file_path, success):
    """步骤3: 根据结果归档"""
    today = datetime.date.today().isoformat()
    target_dir = f"success/{today}" if success else f"failed/{today}"
    os.makedirs(target_dir, exist_ok=True)
    shutil.move(file_path, target_dir)

# 主流程
def enter_database(job_data):
    file_path = save_raw(job_data, save_path)

    try:
        result = call_parse_api(file_path, "http://api.internal/jobparse")
        if result.get("code") == 200:
            archive(file_path, success=True)
            return True
        else:
            archive(file_path, success=False)
            return False
    except Exception as e:
        print(f"入库失败: {e}")
        # 不归档,留在 job/ 目录,下次启动会重试
        return False

5.3 目录就是状态机

data/
├── job/              ← 待入库
│   ├── 12345.json
│   └── 12346.json
├── success/          ← 已成功
│   └── 2026-05-06/
│       ├── 11111.json
│       └── 11112.json
├── failed/           ← 失败,要排查
│   └── 2026-05-06/
│       └── 99999.json
├── expired/          ← 业务拒收(过期/重复)
└── progress/         ← 状态文件

目录结构本身就是任务的当前状态。看一眼 job/ 还有多少文件,就知道还有多少没入库;failed/ 有多少,就知道有多少要排查。这比写监控代码简单多了。

5.4 业务状态码的处理

API 不只返回"成功/失败",还可能返回业务码:

result = call_parse_api(...)
code = result.get("code")

if code == 200:
    archive(file_path, success=True)
elif code in (301, 302, 303, 304, 305):
    # 业务拒收:过期、重复、人工修改过等
    move_to_expired(file_path)   # 不算失败,不重试
elif code == 500:
    # 真正的失败,留在 job/ 等下次重试
    pass
else:
    archive(file_path, success=False)

这就是通用结构化系统".ok / .err / .expired"三态文件机制的本质


六、问题 5:清洗与回写 —— 数据库会"变质",得有人扫垃圾

6.1 为什么要清洗?

抓回来的职位入库之后,会过期:

  • 招聘网站把这条职位下线了
  • 招聘单位把岗位招满了
  • 链接 404 了

数据库里如果还显示这些"僵尸职位",用户点进去什么都看不到——用户体验崩了

所以要有一个专门的清洗爬虫,定期去验证已入库职位是否还有效。这就是 spider_yupao_clean.py 干的事。

6.2 清洗爬虫的特殊性

普通采集爬虫 清洗爬虫
目标:抓新数据 目标:验证老数据
字段:抓全 字段:不抓,只看页面状态
数据流:站点 → 数据库 数据流:数据库 → 站点 → 数据库(回写状态)
规模:百万级 规模:千万级(存量更大)

清洗爬虫的输入是数据库已有的链接列表,输出是过期标记。

def clean_job_links():
    while True:
        # 1. 从内部接口拉一批待验证的链接
        links = fetch_links_to_verify(batch_size=100)
        if not links:
            break

        for job_id, url in links:
            status = check_job_status(url)

            if status == "expired":
                # 2. 调用过期回写接口
                report_expired(job_id)

            time.sleep(random.uniform(0.5, 1.5))

        # 3. 写游标,下次从这里继续
        save_cursor(links[-1][0])

def check_job_status(url):
    page.goto(url)

    # 各种判断条件
    if "404" in page.title():
        return "expired"
    if page.query_selector(".job-ended"):
        return "expired"
    if page.query_selector(".verify-page"):
        return "blocked"
    return "valid"

def report_expired(job_id):
    requests.post("http://api.internal/job_to_expired", json={"id": job_id})

6.3 清洗爬虫的工程化升级

清洗任务规模大、频率高,所以对性能极度敏感spider_yupao_clean.py 加了一堆优化:

  1. 浏览器上下文复用:不为每个链接开新 context,而是复用同一个,节省启动开销
  2. LRU 缓存:同一批次里如果有重复 URL,直接用缓存结果
  3. 指数退避重试:网络抖动时重试,失败间隔逐步拉长
  4. 多选择器兼容:验证页可能有多种样式,用一组选择器轮询匹配
  5. 统一资源清理:用 with 和上下文管理器保证浏览器实例最终一定被关掉
class JobLinkCleaner:
    def __init__(self):
        self.cache = {}   # LRU cache
        self.browser = None
        self.context = None

    def __enter__(self):
        self.playwright = sync_playwright().start()
        self.browser = self.playwright.chromium.launch(headless=True)
        self.context = self.browser.new_context()
        return self

    def __exit__(self, *args):
        self.context.close()
        self.browser.close()
        self.playwright.stop()

    def check_with_retry(self, url, max_retry=3):
        if url in self.cache:
            return self.cache[url]

        for i in range(max_retry):
            try:
                result = self._check_once(url)
                self.cache[url] = result
                return result
            except Exception:
                time.sleep(2 ** i)
        return "unknown"

# 使用
with JobLinkCleaner() as cleaner:
    for url in urls:
        cleaner.check_with_retry(url)

这就是项目里"工程化程度最高的脚本"长什么样——它不是逻辑最复杂,而是对性能、稳定性、资源管理的考虑最周密


七、问题之外:基类抽象 —— 把"共性"集中起来

到这里你应该发现,无论是哪个站点的爬虫,都要做同样的事:读配置、获取代理、判断去重、调入库接口、归档文件、记录日志

如果每个站点脚本都重写一遍,维护成本爆炸。所以要抽出一个公共基类:

class Spider:
    """所有渠道爬虫的基类"""

    def __init__(self, config_path):
        self._load_config(config_path)
        self._init_directories()

    # ===== 通用能力 =====
    def get_proxy(self):
        """从代理池领代理"""
        ...

    def set_proxy_record(self, success_count, limited_type):
        """上报代理使用情况"""
        ...

    def is_spider_today(self, item_id):
        """今天是否抓过"""
        ...

    def job_exist(self, com_name, job_title, city):
        """数据库是否已存在"""
        ...

    def com_exist(self, com_name):
        """公司是否已存在 / 是否在黑名单"""
        ...

    def enter_database(self, file_path, parse_url):
        """通用入库链路:调接口 + 归档"""
        ...

    def random_sleep(self, min_s=1, max_s=3):
        """随机休眠"""
        ...

    # ===== 子类必须实现 =====
    def crawl(self):
        """每个站点自己实现具体抓取逻辑"""
        raise NotImplementedError

class LiepinSpider(Spider):
    """猎聘爬虫,只关注'怎么从猎聘抓到数据'"""

    def crawl(self):
        proxy = self.get_proxy()        # 用基类
        # ... 猎聘特定的抓取流程

        for job in jobs:
            if self.is_spider_today(job["id"]):    # 用基类
                continue

            file_path = self.save_raw(job)
            self.enter_database(file_path, self.job_url)  # 用基类

class YupaoSpider(Spider):
    """鱼泡爬虫"""

    def crawl(self):
        # 鱼泡是移动端,实现不一样
        # 但去重、入库、代理等共性都用基类
        ...

spider.py 那个公共基类,本质就是这件事:把"所有渠道都要做的事"集中,让子类只关注"这个渠道独有的事"。

最大的好处是改一处生效十处——比如代理池接口变了,只改基类的 get_proxy(),所有子类自动生效。这就是为什么真实项目里基类那么重要。


八、把所有模块串起来:工程化爬虫的全貌

讲到这里,我们可以把一个工程化爬虫的完整架构画出来了:

                  ┌──────────────────────────────┐
                  │     配置层 (.ini + CLI)       │
                  │  路径、接口、参数、模式       │
                  └────────────┬─────────────────┘
                               │
                  ┌────────────▼─────────────────┐
                  │     调度层 (main.py)          │
                  │  按模式分发、按分片切分       │
                  └────────────┬─────────────────┘
                               │
                  ┌────────────▼─────────────────┐
                  │     基类层 (spider.py)        │
                  │  代理 / 去重 / 入库 / 归档    │
                  └────┬──────────┬──────────┬───┘
                       │          │          │
              ┌────────▼──┐ ┌─────▼────┐ ┌───▼────┐
              │ LiepinSp. │ │ YupaoSp. │ │ 51JobSp│
              │  (子类)    │ │ (子类)    │ │ (子类) │
              └────────┬──┘ └─────┬────┘ └───┬────┘
                       │          │          │
                       └─────┬────┴──────────┘
                             ▼
                  ┌──────────────────────────────┐
                  │     落盘层 (data/)            │
                  │  job/ → success/ / failed/   │
                  └────────────┬─────────────────┘
                               │
                  ┌────────────▼─────────────────┐
                  │     入库层 (内部 API)         │
                  │  jobparse / comparse         │
                  └────────────┬─────────────────┘
                               │
                  ┌────────────▼─────────────────┐
                  │     清洗层                    │
                  │  yupao_clean / job_to_expired │
                  └──────────────────────────────┘

这就是猎聘 + 通用结构化系统的完整骨架。每一层都解决了一个特定问题,合起来就是"能稳定跑很久"。


九、本讲核心认知

"工程化"不是把代码写得复杂,而是把'每一种会出错的情况'都提前安排好

会出什么错 怎么预防
配置改了要重新发版 配置文件 + 命令行参数
跑一半崩了 进度文件 + 状态文件 + 原子写入
单机跑不动 分片 + 多实例
数据丢了 先文件后接口 + 三态归档
数据库变脏 清洗爬虫 + 回写过期
改一处要改十处 基类抽象
站点改版了 配置驱动 + 模板化抽取函数

回头去看 spider.py 的初始化代码,你会发现它不过是在做"读配置、建目录、初始化代理客户端、加载状态文件"——没有任何花哨的东西,只是把所有该做的事都做了


组内导航:⬅️ 练手网站 | 🏠 00-爬虫课程六讲 | ➡️ 对照项目源码拆解——从配置到入库的完整链路