尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零实现增量爬虫:去重与断点续爬全解析

从零实现增量爬虫:去重与断点续爬全解析 我一直觉得爬虫写多了之后真正拉开差距的往往不是你会不会用requests、会不会解析XPath而是你能不能把一个已经跑通的爬虫从“能用”升级成“好用”。很多新手朋友一开始写爬虫都是对着一个网站从头到尾抓一遍数据存下来就完事了。等第二天再跑一次脚本发现数据库里多了几千条一模一样的记录整个人都不好了。这其实就是增量采集要解决的问题只抓新增的数据只处理更新的内容已经抓过的旧数据直接跳过。今天这篇就拿实际场景一步步拆从思路到代码把增量、去重、断点续爬这三个东西讲透。目标是让零基础的朋友看完也能自己动手改出一个带增量能力的爬虫。1. 先搞清楚为什么你的爬虫越跑越“笨”1.1 重复抓取带来的三笔“糊涂账”很多教程在讲爬虫的时候默认你只需要抓一次就完事了。但真实项目里爬虫是要反复跑的——新闻网站每隔几分钟就出新文章电商网站的价格每小时都在变论坛的帖子源源不断。如果你的爬虫每次都是全量抓取很快就会遇到三个让人头疼的问题。第一是存储膨胀。同样的数据抓了三次数据库里存了三份查询的时候还要花时间做去重纯粹是花钱买罪受。第二是效率变低。抓取全站的耗时和请求次数持续累积网站服务器会越来越不耐烦轻则给你返回验证码重则直接封掉你的IP。第三是数据失真。当你需要统计“最近24小时新增了多少条数据”时数据库里根本分不清哪条是新的、哪条是旧的。我自己最早写爬虫的时候就犯过这个毛病。当时抓一个商品信息站点每天定时跑一次全量抓取任务结果第三天数据库已经到了几万条重复记录磁盘占用翻了三倍晚上定时任务跑完都要快一个小时。后来导师给我提了一句“增量你注意一下”我才真正开始研究这玩意儿。1.2 增量采集的本质用“状态”替代“蛮力”增量采集的核心思想其实特别简单——不要每次都重新认识全世界只关心世界发生了什么变化。放到爬虫场景里就是你不需要每次都把所有页面抓一遍只需要识别出“哪些链接是新的”“哪些页面内容变了”然后把这部分数据拿回来。这样做的好处非常直接请求次数大幅减少存储不会膨胀程序响应更快对目标网站的服务器也更友好。说到底一个成熟的爬虫应该是“细水长流”的增量机器而不是“每次推倒重来”的搬运工。理解到这一层你就明白增量采集并不是什么高深莫测的黑科技它只是一种编程思维做任何事情之前先想你手里已经有什么缺的到底是什么然后只处理缺的那部分。2. 增量方案一时间戳比对最简单也最常用2.1 拿到时间戳的三种途径实现增量采集第一个绕不开的判断依据就是“时间”。因为绝大多数网站的内容都是按时间先后顺序产生的新发布的页面、新修改的数据都会带着一个比之前更晚的时间戳。在动手写代码之前我们得先解决一个基础问题时间戳从哪里来根据我自己的实战经验主要有三个途径。第一种页面正文里的发布时间。新闻稿件、博客文章、论坛帖子这类页面一般都会在页面上显示“发布时间”有的直接显示在标题下面有的藏在正文开头或结尾。这种时间最容易拿到直接解析HTML就可以。第二种HTML头部meta标签里的时间。有些网站会在meta标签里塞一个published_time或者date字段专门给搜索引擎用的这种时间一般格式很规范解析起来非常省事。第三种HTTP响应头里的 Last-Modified。这种方式比较隐蔽requests发一次请求后响应头里可能带了Last-Modified字段它表示服务器上这个页面的最后修改时间。但说实话不是所有服务器都会正确返回这个字段所以它只能当成补充手段。我整理了一个表格把三种途径的特点放在一起对比一下时间来源获取难度准确度适用场景页面正文发布时间简单高但格式五花八门新闻、博客、文章类页面meta标签时间简单较高格式较规范大多数内容站点、支持SEO的站响应头Last-Modified容易一般很多站不返回静态资源、更新不频繁的页面实战中我一般优先用meta标签里的时间格式统一、好解析没有的话再退回页面正文里找。2.2 时间戳比对的完整逻辑拿到时间戳以后增量判断的逻辑就非常清晰了核心只有三步。第一步本地数据库里存一个last_updated字段记录每条数据“上次抓取时的时间”。第二步每次抓取页面的时候解析出页面的最新时间。第三步把页面最新时间与本地时间比较如果页面的时间比本地记录的时间新说明这条数据是新增或更新过的需要重新抓取否则就直接跳过。举一个很简单的例子你昨天上午抓了一条新闻标题叫《AI大模型又出新版本了》当时页面显示的发布时间是昨天上午9点你把它存到了数据库里。今天上午10点你再跑爬虫发现这个页面的发布时间还是昨天上午9点此时有两种情况一种情况是这条新闻确实没更新过页面时间没变直接跳过另一种情况是内容更新了但发布时间没变这时候时间戳比对就会漏掉。所以时间戳比对适合解决大部分“明显的新增”场景但它不是万能的——它只能告诉你“发布时间晚于你上一次抓取”的数据需要处理无法告诉你“发布时间没变但内容悄悄改了”的数据需要更新。2.3 时间戳方案里那些坑新手第一次写时间戳增量最容易踩三个坑我挨个说。第一个坑是时区问题。有的网站页面时间是UTC你本地数据库存的是北京时间两者相差8小时。如果你直接拿字符串比大小看着好像没问题但实际对比结果可能完全反了。我的做法是无论从哪个来源拿到时间先统一转换成时间戳比如time.mktime或datetime.timestamp()再换算成本地时间的datetime对象存储。第二个坑是格式问题。同一个网站不同页面发布时间格式可能不一样有的是2025-06-15有的是2025/06/15还有的是2025年06月15日。如果直接按字符串解析解析失败程序就会崩。我的经验是写一个专门的parse_time函数把常见的格式都兼容一遍解析不了就返回None然后走“强制抓取”的逻辑不因为一个时间解析失败就漏掉数据。第三个坑是部分网站时间字段会变。有些CMS系统会把“最后更新时间”显示在页面上但这个时间可能是编辑最近一次误触保存产生的实际上内容并没有实质变化。如果只依赖时间戳会导致频繁触发更新。这时候就要引入内容哈希比对了这也是下一节要讲的方案。3. 增量方案二内容哈希比对专治“改了但没改时间”3.1 给页面算一个“内容指纹”时间戳有一个明显缺陷它判断的是“时间新不新”而不是“内容变没变”。在真实业务里有些数据内容被修改了但页面上的发布时间一直维持原样这时候时间戳方案就会漏掉更新。这时候就要请出第二个方案——内容哈希比对。它的原理非常容易理解就像给每个人取指纹一样我们把页面的核心内容取出来计算一段固定长度的哈希值比如MD5或SHA256这个哈希值可以看成是这个页面的“内容指纹”。页面内容不变指纹就不变页面内容一旦有任何变动哪怕只改了一个字指纹就会完全改变。实操里我一般会先提取页面里的正文文本比如article标签里的内容把HTML标签、脚本、样式全部去掉只保留纯文本然后对这段文本计算MD5或SHA256。3.2 哈希比对的手动实现过程我自己最早手动实现哈希比对的时候代码逻辑其实非常简单总共就四步第一步请求页面解析出正文文本。第二步对正文文本计算哈希值比如用hashlib.md5(正文.encode()).hexdigest()。第三步拿这个哈希值去数据库里查一下看这个URL对应的记录是否已经存在以及它存储的哈希值是多少。第四步如果哈希值不存在说明是新增数据插入记录如果哈希值存在但和之前不一样说明内容改了更新记录如果哈希值存在且完全一样说明内容没变直接跳过。这样的好处是判断精准不会因为页面上的广告、推荐位信息变化而误报更新因为这些噪声在提取正文时就已经被过滤掉了。3.3 哈希比对的实际适用范围内容哈希的最大价值在于“精准判重”但它的使用成本比时间戳高——因为你要把每个页面的正文都抓下来才能计算哈希值这本身就消耗了大量的网络请求和解析时间。所以我的习惯是把两种方案组合使用先用时间戳做粗筛再用哈希做细判。比如列表页显示发布时间超过一天没变的详情页时间戳直接跳过它根本不发起请求只有那些时间较新的页面才进入详情页抓取环节然后计算哈希值做进一步判断看看内容是否真正发生了变化。这个组合思路在业界其实很常见相当于先花很小的代价判断“值不值得看”再花较大的代价判断“到底变了没有”既能保证不漏数据又能把对服务器的请求量压到最低。4. 实战方案列表页与详情页的分工配合4.1 列表页负责“找新”详情页负责“判变”单独聊完时间戳和哈希两种方案的原理现在把它们放进一个真实的爬虫场景里看看怎么落地。以抓取一个资讯站为例一般有两种页面一种是列表页页面里排列着很多条新闻标题和链接另一种是详情页就是点进去以后完整的正文内容。增量爬虫的分工思路非常明确——列表页只负责“找新”详情页才负责“判变”。每次跑任务时先请求列表页把页面里所有新闻链接解析出来。然后对每一条链接去数据库里查一下这个链接是否已经抓过。如果是全新的链接说明是新增内容回过头来抓详情页入库如果是已经抓过的链接就要看详情页内容有没有变这时再走时间戳或哈希判断。这样一来列表页的抓取非常轻量每次只需要拉取几个页面成本低详情页也不是每条都抓只有新增或疑似变化的才抓整体请求量大幅下降。4.2 设计一张够用的状态表要把增量逻辑跑起来数据库表不能只存业务字段还得顺便把你抓过的状态记录下来。我经常用的表结构大概是这样的字段名类型说明idINTEGER 主键自增内部流水号urlTEXT 唯一详情页链接这个字段会建立唯一索引titleTEXT页面标题content_hashTEXT正文哈希值用于内容变更判断last_checkedDATETIME最近一次检查该链接的时间categoryTEXT分类信息可按需扩展这个表设计的关键在于url字段上加唯一索引这样数据库层面就能帮我们挡住重复插入的URL。哈希值单独存一列方便后续比对。last_checked字段可以让我们知道哪些链接已经很久没检查了必要时做定期巡检。4.3 增量抓取的主流程伪代码代码落地之前我们先看看主流程长什么样1. 请求列表页解析出所有详情页链接 2. 遍历链接 2.1 用链接去数据库查记录 2.2 如果记录不存在请求详情页解析正文计算哈希插入新记录 2.3 如果记录存在 判断页面时间是否比记录的last_checked新 判断正文哈希是否与content_hash一致 时间更新或哈希不一致 - 更新记录 时间未更新且哈希一致 - 跳过 3. 记录本次任务结束时间留作下一次的时间起点这套流程跑起来以后你会发现程序的日志里大部分链接都是“跳过”只有少数链接会触发“新增”或“更新”。对你来说这才是正常的爬虫健康状态。5. 去重的落地从内存Set到数据库唯一约束5.1 最原始的去重内存里的Set聊增量采集去重这关一定躲不过去。很多新手第一次想到的去重方案就是把所有抓过的URL放进一个Python Set里程序启动时Set是空的每抓一个URL就加进去下次看到一样的不再请求。这种方案的好处是代码极其简单一个urls_seen变量就能解决适合临时脚本和演示Demo。但它的致命缺陷是程序一旦重启内存里的Set数据就全部丢了下次又得从头开始积累。而且如果数据量达到几百万上千万内存占用也会变得非常可观。所以内存Set只能当成“单次运行期间”的去重工具不能作为长期增量爬虫的存储方案。真正靠谱的去重必须把状态持久化到磁盘、数据库或者其他外部存储中。5.2 数据库唯一索引永远可靠的选择在所有去重方案里我自己最推荐的是数据库唯一索引。以SQLite为例只需要在表定义时给url字段加上UNIQUE约束然后再配合INSERT OR IGNORE语句插入数据就能做到“插入时自动判断去重”。比如你想插入一条新抓到的链接执行INSERT OR IGNORE INTO articles (url, title, content_hash, last_checked) VALUES (http://example.com/news/123, 标题, 哈希值, 2025-06-15 10:00:00);如果URL已经存在这条语句不会报错也不会插入重复数据只是返回一个影响行数为0的结果。你只需要在代码里判断一下cursor.rowcount是否为0如果为0说明这条URL之前已经抓过了可以进入“是否更新”的判断如果为1说明是新插入的属于全新数据。这个方案的优点是简单、稳定、不用写多余的去重代码数据库本身的约束帮你兜底。缺点是仅适用于单机爬虫多台机器同时写同一个数据库时会遇到性能瓶颈。但面向新手教学和中小规模数据它已经完全足够了。5.3 布隆过滤器几百万URL也不慌的进阶方案再往深处说一点。如果你的爬虫规模持续变大URL数量已经达到千万级别用数据库逐条查重也不是不行但每次请求都要多一次数据库查询耗时也会上升。这时候业界常用的方案是布隆过滤器Bloom Filter。布隆过滤器的原理可以理解成你不再存完整的URL而是用几个哈希函数把URL映射到一个巨大的二进制位数组上。判断一个URL是否抓过时只需要看它对应位置的几个位是不是全部为1即可。位数组占用内存极小几千万个URL可能只需要几十MB的内存而且每次判断是O(1)级别的接近。Bloom Filter有一个特点宁可错杀不可放过。也就是说它可能把没抓过的URL误判成“抓过”但绝不会把抓过的URL误判成“没抓过”。所以在爬虫里使用它时会造成稍微漏掉少量数据这是它牺牲精确度换来的空间优势。适合那些“漏一两条无关紧要”的场景。Python里可以用pybloom_live或bloom_filter库来实现。不过说实话新手阶段真没必要一上来就碰布隆过滤器先把数据库唯一索引用明白遇到真正的性能瓶颈再去研究它也不迟。6. 断点续爬程序挂了进度不能丢6.1 断点续爬的本质把进度“写下来”增量爬虫还有一个非常实用的能力叫断点续爬。什么是断点续爬简单说就是你的爬虫跑到一半因为网络抖动、被反爬、电脑断电而中断了重新启动的时候它不需要从头开始跑而是从上次中断的位置继续往下跑。要实现这个能力核心就只有一句话——把当前爬到哪里的进度记录下来而且要记录到持久化存储里不能只放在内存里。进程一崩内存里面的进度就没了而落到文件或数据库里的进度才能保留下来。6.2 游标法保存“最后一个成功的位置”在爬取列表页的场景里断点续爬最简单的实现方式就是游标法。你可以把列表页分成很多页每页对应页码或偏移量处理到第几页记录一下当前进度。在爬虫脚本里我最常用的做法是维护一个小的JSON文件或者状态表。每成功抓完一页列表页就把当前的页码写进状态文件然后在下一次启动时读取这个状态文件从页码的下一位开始继续爬。import json import os STATE_FILE crawler_state.json def save_state(page): with open(STATE_FILE, w, encodingutf-8) as f: json.dump({last_page: page}, f) def load_state(): if not os.path.exists(STATE_FILE): return 0 with open(STATE_FILE, r, encodingutf-8) as f: data json.load(f) return data.get(last_page, 0)这个方案虽然看起来简陋但在实战中非常管用。哪怕你爬到第50页时被网站封了IP等你换完代理重启脚本它也能直接从第51页继续而不是傻乎乎地重爬前50页。6.3 失败重试队列断点续爬的进阶姿势游标法能解决“批量翻页”的中断恢复但真实场景里爬虫中断的另一个重要原因是单条请求失败。比如你已经爬到第20页了从中解析出了50个详情页链接其中第10个详情页请求超时此时你不想重爬整页列表只想把这条失败链接重新请求一遍。这种情况下比较成熟的做法是维护一个失败重试队列。每一条请求失败就把这条链接丢进专门的失败队列里继续往后面爬。等到整轮任务结束脚本统一读取失败队列里的链接集中重试几次。重试仍然失败的记录到错误日志里方便下次人工介入。业界常见的做法是使用Redis的List或Set结构来充当这个失败队列因为Redis支持持久化重启也不怕丢。但在新手入门阶段我建议先养好“记录失败”这个习惯用文件或者SQLite存一张failed_urls表也算是同时学会了断点续爬和失败恢复。7. 完整示例一个带增量逻辑的资讯爬虫7.1 项目初始化和依赖前面讲的都是“心法”真正要落到手里的还是“招式”。这里我写一个完整的示例目标网站就假设为一个标准的资讯列表站列表页结构类似https://example.com/news/1这样的页码形式详情页链接在列表页的a标签里。环境依赖只需要三个库requests负责发请求beautifulsoup4负责解析HTMLhashlib用来算哈希值这个标准库自带的不用另外安装。你可以直接在终端执行pip install requests beautifulsoup4然后在代码文件头部导入这些库import requests import hashlib import sqlite3 import time from bs4 import BeautifulSoup7.2 核心代码实现下面给你一个完整的参考代码保存为incremental_crawler.py直接可以跑通整个流程。import requests import hashlib import sqlite3 import time from bs4 import BeautifulSoup BASE_URL https://example.com/news/{page} DB_NAME news.db STATE_FILE crawler_state.json HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def init_db(): conn sqlite3.connect(DB_NAME) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, title TEXT, content_hash TEXT, last_checked TEXT, category TEXT ) ) conn.commit() conn.close() def get_page_html(url): resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text def parse_list_page(html): soup BeautifulSoup(html, html.parser) links [] for a in soup.select(a): href a.get(href, ) if href.startswith(/news/): full_url https://example.com href links.append(full_url) return list(set(links)) def compute_hash(text): return hashlib.md5(text.encode(utf-8)).hexdigest() def extract_article(html): soup BeautifulSoup(html, html.parser) title soup.find(h1) title title.get_text(stripTrue) if title else article soup.find(article) or soup.find(div, class_content) content article.get_text(stripTrue) if article else return title, content def process_detail(url): try: html get_page_html(url) title, content extract_article(html) content_hash compute_hash(content) now time.strftime(%Y-%m-%d %H:%M:%S) conn sqlite3.connect(DB_NAME) cursor conn.cursor() cursor.execute(SELECT id, content_hash FROM articles WHERE url ?, (url,)) row cursor.fetchone() if row is None: cursor.execute( INSERT OR IGNORE INTO articles (url, title, content_hash, last_checked) VALUES (?, ?, ?, ?) , (url, title, content_hash, now)) print(f[新增] {url}) else: if row[1] ! content_hash: cursor.execute( UPDATE articles SET title ?, content_hash ?, last_checked ? WHERE url ? , (title, content_hash, now, url)) print(f[更新] {url}) else: print(f[跳过] {url}) conn.commit() conn.close() except Exception as e: print(f[错误] {url} - {e}) def crawl_first_n_pages(n10): init_db() for page in range(1, n 1): list_url BASE_URL.format(pagepage) print(f正在抓取列表页: {list_url}) try: list_html get_page_html(list_url) detail_links parse_list_page(list_html) for link in detail_links: process_detail(link) time.sleep(0.5) except Exception as e: print(f列表页失败: {list_url} - {e}) break if __name__ __main__: crawl_first_n_pages(10)补充一点这个示例中的链接结构、标签选择器都是假设的真正使用的时候你需要按照目标网站的实际HTML结构去调整parse_list_page里的CSS选择器以及extract_article里的正文提取逻辑。但整体框架是通用的直接套用这套结构就行。7.3 看它的增量效果跑第一次时数据库是空的所有链接全部走“新增”分支。跑第二次时只要网站内容没有新增或修改所有链接都会走“跳过”分支——程序只是查了一下数据库哈希值没有任何实际抓取详情页动作。跑几次以后你再看看数据库记录数不会爆炸式增长程序输出日志里充满[跳过]这个效果本质上就是增量采集在你手里落地了。8. 爬虫实战里的几个“坑”和优化方向8.1 别把所有更新都压在“内容哈希”上哈希虽然精准但它有一个代价每次判断更新都必须把详情页完整下载下来才能算哈希。这在数据量大的时候会白白增加很多不必要的网络请求。更好的做法是三级过滤第一级看列表页的发布时间第二级看详情页的发布时间或响应头Last-Modified第三级才走到计算内容哈希。能用低成本字段过滤掉的数据绝不把它推入高成本环节这样整个爬虫的请求量才能压下来。8.2 学会给网站留喘息时间增量爬虫已经比全量爬虫温和很多了但如果你把频率设置得太快比如每秒发10个请求还是会触发网站的反爬机制。我的经验是请求之间至少加time.sleep(0.3)到1秒遇到连续失败就指数退避让爬虫自己慢下来。爬虫被拒绝、IP被封修复成本远高于多等几秒。8.3 关于合规使用的提醒爬虫是工具工具本身没有问题但使用工具的人得清楚边界。写爬虫之前先看目标网站的robots协议和用户协议只爬取允许访问的数据不恶意高频请求不抓取涉及个人隐私、版权保护的内容这是每个从业者都应该有的底线。增量采集本身的目的就是让爬虫更温和、更规范千万不要拿它去变相轰炸任何站点。8.4 从“会跑”到“跑得健康”如果你严格按照前面的示例跑通了一个增量爬虫我建议你接下来做三件小事把last_checked字段用起来定期巡检那些很长时间没有更新过的链接给失败链接建一张待重试表让程序自己消化临时故障再把列表页和详情页的抓取任务拆成两个独立模块方便以后改成分布式架构。这三步做完你的爬虫就已经从“会跑”进化到“跑得健康”了。我自己在增量采集这条路上踩过的最大一个坑就是早期把所有希望都寄托在“抓全”上结果每一次抓取都像是在做全量备份。后来真正把增量、去重、断点续爬这三板斧用起来之后爬虫的请求量降了90%以上数据质量反而更好了。这就是增量采集的价值所在——不是节省那点带宽而是让整个数据链路变得可持续。
返回列表