Python 爬虫断点续爬与增量采集——爬崩了也不怕

爬虫爬到一半网络断了、电脑重启了、IP 被封了——如果从头再来,前面的进度就白费了。断点续爬就是让爬虫记住爬到了哪里,中断后自动从断点继续。

一、为什么需要断点续爬

你没有断点续爬:
  爬了200页 → 网络中断 → 重新从第1页开始 → 浪费大量时间

你有断点续爬:
  爬了200页 → 网络中断 → 重新启动 → 从第201页继续 → 不浪费

不能续爬的爬虫只适合小规模(几百页),大规模采集(几万页以上)必须有断点续爬能力。

二、实现方式一:文件记录进度

最简单的方式,爬到哪一页就写到文件里。

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

class ResumeCrawler:
    """支持断点续爬的爬虫"""

    def __init__(self, start_page=1, end_page=100):
        self.state_file = "crawl_state.json"  # 状态文件
        self.data_file = "crawled_data.jsonl" # 数据文件
        self.end_page = end_page
        self.current_page = self._load_state() or start_page
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"
        })

    def _load_state(self):
        """加载上次的进度"""
        if os.path.exists(self.state_file):
            with open(self.state_file, "r") as f:
                state = json.load(f)
                print(f"📂 发现上次进度:第 {state['current_page']} 页")
                return state["current_page"]
        return None

    def _save_state(self):
        """保存当前进度"""
        with open(self.state_file, "w") as f:
            json.dump({"current_page": self.current_page}, f)

    def _save_data(self, items):
        """保存爬到的数据"""
        with open(self.data_file, "a", encoding="utf-8") as f:
            for item in items:
                f.write(json.dumps(item, ensure_ascii=False) + "\n")

    def crawl_page(self, page):
        """爬取单页"""
        url = f"https://example.com/products?page={page}"
        resp = self.session.get(url)
        soup = BeautifulSoup(resp.text, "html.parser")

        items = []
        for product in soup.select(".product"):
            items.append({
                "title": product.select_one(".title").text.strip(),
                "price": product.select_one(".price").text.strip(),
                "page": page,
                "crawl_time": time.time(),
            })

        return items

    def run(self):
        """主运行逻辑"""
        print(f"开始爬取,从第 {self.current_page} 页到第 {self.end_page} 页")

        while self.current_page <= self.end_page:
            try:
                print(f"正在爬取第 {self.current_page} 页...")
                items = self.crawl_page(self.current_page)

                if items:
                    self._save_data(items)
                    print(f"  第 {self.current_page} 页完成,获取 {len(items)} 条")

                # 保存进度
                self._save_state()
                self.current_page += 1

                time.sleep(1)  # 礼貌延时

            except Exception as e:
                print(f"❌ 第 {self.current_page} 页出错: {e}")
                print(f"已保存进度到第 {self.current_page} 页,等待 30 秒后重试...")
                self._save_state()
                time.sleep(30)  # 等一会再重试

        print(f"✅ 全部完成!共爬取到第 {self.end_page} 页")

# 使用
crawler = ResumeCrawler(start_page=1, end_page=500)
crawler.run()

# 如果中途中断了,重新运行会自动从断点继续

三、实现方式二:Redis 记录进度(分布式环境)

多台机器同时爬时,文件方式就不行了——文件在 A 机器上,B 机器看不到。用 Redis 存进度,所有机器共享:

import redis
import json

class RedisResumeCrawler:
    """基于 Redis 的断点续爬"""

    def __init__(self, name="crawler", start_page=1):
        self.name = name
        self.redis_client = redis.Redis(host="localhost", port=6379, db=0)
        self.progress_key = f"{name}:progress"
        self.data_key = f"{name}:data"

        self.current_page = self._load_progress() or start_page

    def _load_progress(self):
        """从 Redis 加载进度"""
        progress = self.redis_client.get(self.progress_key)
        if progress:
            page = int(progress)
            print(f"📂 上次爬到第 {page} 页,继续...")
            return page
        return None

    def _save_progress(self, page):
        """保存进度到 Redis"""
        self.redis_client.set(self.progress_key, page)

    def _save_data(self, items):
        """数据存到 Redis 列表"""
        for item in items:
            self.redis_client.rpush(
                self.data_key,
                json.dumps(item, ensure_ascii=False)
            )

    def run(self, end_page=1000):
        """运行爬虫"""
        while self.current_page <= end_page:
            # ... 爬取逻辑同上 ...
            self._save_progress(self.current_page)
            self.current_page += 1

四、增量采集——只爬新数据

断点续爬解决的是"中断后重爬",增量采集解决的是"已经爬过的不再爬"。

1. 基于 URL 去重

import hashlib
import redis

class IncrementalCrawler:
    """增量爬虫:只爬新 URL"""

    def __init__(self):
        self.redis = redis.Redis(host="localhost", port=6379, db=0)
        self.seen_key = "crawler:seen_urls"

    def _url_hash(self, url):
        """URL 指纹"""
        return hashlib.md5(url.encode()).hexdigest()

    def is_crawled(self, url):
        """检查 URL 是否已经爬过"""
        return self.redis.sismember(self.seen_key, self._url_hash(url))

    def mark_crawled(self, url):
        """标记 URL 已爬"""
        self.redis.sadd(self.seen_key, self._url_hash(url))

    def extract_new_urls(self, page_url, new_urls):
        """从新页面中筛选出未爬过的 URL"""
        fresh_urls = []
        for url in new_urls:
            if not self.is_crawled(url):
                fresh_urls.append(url)
        return fresh_urls

2. 基于内容哈希去重(相同内容不再爬)

import hashlib

class ContentDedupCrawler:
    """基于内容去重的爬虫"""

    def __init__(self):
        self.content_hashes = set()

    def _content_hash(self, text):
        """计算内容的指纹"""
        # 去掉空白字符后再算哈希,避免微小的格式差异导致重复
        clean = "".join(text.split())
        return hashlib.md5(clean.encode()).hexdigest()

    def is_duplicate(self, text):
        """判断内容是否重复"""
        h = self._content_hash(text)
        if h in self.content_hashes:
            return True
        self.content_hashes.add(h)
        return False

3. 基于时间戳(只爬新闻/文章类)

from datetime import datetime, timedelta

class TimeBasedCrawler:
    """基于时间的增量爬虫"""

    def __init__(self, max_days=7):
        self.max_days = max_days
        self.cutoff_date = datetime.now() - timedelta(days=max_days)

    def should_crawl(self, article_date_str):
        """判断是否应该爬取(只爬最近 N 天的)"""
        try:
            article_date = datetime.strptime(article_date_str, "%Y-%m-%d")
            return article_date >= self.cutoff_date
        except:
            return True  # 日期解析失败就默认爬

    def run_daily(self):
        """每天定时执行"""
        today = datetime.now().strftime("%Y-%m-%d")
        print(f"开始增量采集:{today}")

        # 只爬当天的数据
        self.crawl_by_date(today)

五、完整示例:增量爬取新闻

import requests
from bs4 import BeautifulSoup
import json
import hashlib
import os
import time

class NewsIncrementalCrawler:
    """新闻增量爬虫"""

    def __init__(self, state_file="news_state.json"):
        self.state_file = state_file
        self.state = self._load_state()
        self.session = requests.Session()

    def _load_state(self):
        """加载状态"""
        if os.path.exists(self.state_file):
            with open(self.state_file, "r") as f:
                return json.load(f)
        return {"crawled_urls": [], "latest_crawl_time": ""}

    def _save_state(self):
        """保存状态"""
        with open(self.state_file, "w") as f:
            json.dump(self.state, f, ensure_ascii=False, indent=2)

    def _is_crawled(self, url):
        """是否已经爬过"""
        url_hash = hashlib.md5(url.encode()).hexdigest()
        return url_hash in self.state["crawled_urls"]

    def _mark_crawled(self, url):
        """标记已爬"""
        url_hash = hashlib.md5(url.encode()).hexdigest()
        self.state["crawled_urls"].append(url_hash)
        # 只保留最近 10000 条,防止状态文件越来越大
        if len(self.state["crawled_urls"]) > 10000:
            self.state["crawled_urls"] = self.state["crawled_urls"][-10000:]

    def crawl_news_list(self):
        """爬取新闻列表页"""
        url = "https://example.com/news"
        resp = self.session.get(url)
        soup = BeautifulSoup(resp.text, "html.parser")

        new_articles = []
        for item in soup.select(".news-item"):
            title = item.select_one(".title").text.strip()
            link = item.select_one("a").get("href")
            date = item.select_one(".date").text.strip()

            # 只处理新文章
            if not self._is_crawled(link):
                new_articles.append({
                    "title": title,
                    "url": link,
                    "date": date,
                })

        return new_articles

    def crawl_detail(self, url):
        """爬取新闻详情"""
        resp = self.session.get(url)
        soup = BeautifulSoup(resp.text, "html.parser")
        return {
            "content": soup.select_one(".article-content").text.strip(),
            "author": soup.select_one(".author").text.strip(),
        }

    def run(self):
        """执行增量采集"""
        print(f"开始增量采集,上次共爬取 {len(self.state['crawled_urls'])} 条")

        new_articles = self.crawl_news_list()
        print(f"本次发现 {len(new_articles)} 条新文章")

        for article in new_articles:
            try:
                detail = self.crawl_detail(article["url"])
                article.update(detail)

                # 保存到文件
                with open("news_data.jsonl", "a", encoding="utf-8") as f:
                    f.write(json.dumps(article, ensure_ascii=False) + "\n")

                # 标记已爬
                self._mark_crawled(article["url"])
                self._save_state()

                print(f"  ✅ {article['title']}")
                time.sleep(1)

            except Exception as e:
                print(f"  ❌ {article['title']}: {e}")

        print(f"增量采集完成,新增 {len(new_articles)} 篇")

# 每天定时执行
crawler = NewsIncrementalCrawler()
crawler.run()

六、数据去重的三种粒度

去重粒度方法适用场景
URL 去重记 URL 是否爬过通用,最常用
内容去重算内容的 MD5同一篇文章有不同 URL(转载)
字段去重按标题/ID去重数据库已有唯一约束

推荐组合: URL 去重为主 + 内容去重为辅。

七、断点续爬的最佳实践

① 每次爬完一页/一条就保存进度(不要等全部爬完才存)
② 状态文件和数据文件分开存
③ 定期清理 URL 去重集合(防止无限增长)
④ 爬虫启动时先恢复进度,而不是从头开始
⑤ 每页爬完加 1-3 秒延时,给后续重试留时间

最重要的原则: 宁可重复保存进度,也不要丢失进度。每爬一页存一次是安全的,每 10 页存一次就有丢失风险。


💡 觉得有用的话,点赞 + 关注【张老师技术栈】吧!每周更新 Java/Python/爬虫 实战干货,不让你白来。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值