基于Scrapy与Playwright的自动化网页快照归档方案

基于Scrapy与Playwright的自动化网页快照归档方案 前阵子要回查一个三年前的产品文档页面结果网站已经改版老地址直接404历史版本更是无处可寻。吃了一次亏之后我把网页快照归档这件事彻底工具化了不再手动CtrlS而是用Scrapy搭了一套自动化归档流程跑完之后每个重点页面都有完整HTML、标题、抓取时间、状态码和整页截图索引查得到、数据丢不了。这套方案的核心链路是Scrapy负责站点遍历和HTML快照Scrapy Playwright负责动态渲染和自动截图数据通过Item Pipeline写进SQLite同时用Feed Exports导出CSV和JSONL做交换。前后跑了几十个站点累计归档了上万个页面稳定性和检索体验都经住了考验。如果你也在做网页备份、离线阅读、竞品页面留档或者数据走查这篇文章应该能帮你省掉不少弯路。1. 网页归档对爬虫的真实要求为什么最终落在Scrapy1.1 归档场景和普通数据爬虫的差异很多人以为网页归档就是把页面抓下来存个文本实际上差别很大。普通爬虫关心的是结构化字段标题、价格、评论数然后用JSON或者Excel交付页面本身看完就扔。归档爬虫关心的是现场还原这一天这个页面长什么样、源码里有哪些关键信息、之后某天能不能翻出当时的版本。这意味着归档项目至少要满足四件事。第一保留完整的原始HTML不只是抽取后的文本字段因为未来的查询需求你现在不一定想得到。第二每条记录都要有足够丰富的元信息URL、标题、状态码、深度、时间戳这样才能支撑后续的检索和对比。第三爬取要可控、可增量不是一次性把几万页拉下来就结束而是能隔周、隔月重复跑重复的页面要能去重。第四输出要双轨既要人工可读的导出文件也要能应付结构化查询的数据库。用这个标准去看requests BeautifulSoup手写当然也能实现但并发调度、去重、爬取边界控制、下载中间件、数据管线这些全要自己造轮子。一旦站点数量超过三五个、页面数量上千手写方案就会在各种边界情况里耗掉大量时间。1.2 Scrapy内置能力正好覆盖归档需求Scrapy对这些问题的回应几乎是量身定做的。先说去重和调度。Scrapy默认就在请求层做了指纹去重同一个URL不会重复下载这对定时归档非常关键二次运行不会把已经抓过的页面全量重捞一遍。再说链路控制。通过CrawlSpider的Rule和LinkExtractor可以精确圈定哪个目录下的链接才会被继续跟踪配合allowed_domains限制域、DEPTH_LIMIT限制层级归档边界非常清晰不会一不留意爬出站点。然后是数据管线。Item Pipeline让所有字段在落地前有一个统一出口你可以在这里做清洗、补字段、写数据库Feed Exports则负责把最终数据导成CSV、JSONL等格式不用手动处理输出问题。最后是下载中间件。UA伪装、重试策略、超时控制、代理配置都在中间件层解决和业务代码解耦。后面接Playwright的时候也只需要替换下载处理器Spider逻辑不用伤筋动骨。1.3 方案全景五个模块的分工这套方案的实际运行流程可以理解成一条流水线Spider层负责入口URL递归提取站内链接生成归档Item。下载层默认下载器抓取原始HTML需要动态渲染的URL则通过meta标记交给Playwright处理。截图层Playwright在页面加载后执行整页截图图片路径回填到Item。数据层Item Pipeline把记录写入SQLite同时Feed Exports导出JSONL/CSV。控制层Settings里的并发数、下载延迟、去重开关、遵守robots配置等决定整体行为。后面所有代码都围绕这五层展开一步步把它变成能直接跑的东西。2. Spider层构建带元信息的HTML快照2.1 归档记录长什么样Item字段设计设计Item字段时我的原则是宁可多存不要后补。归档数据一旦补跑时间戳就变了历史版本就失真了。所以字段尽量一次存齐。字段类型说明urlstring页面完整URL也作为去重和主键关联titlestring页面title检索时最常用htmlstring原始HTML源码归档的核心资产statusintegerHTTP状态码记录页面当时能否正常访问depthinteger抓取深度方便分析归档边界screenshot_pathstring截图文件的相对路径crawled_atstringISO格式时间戳标记快照时间对应的Item定义长这样import scrapy class ArchiveItem(scrapy.Item): url scrapy.Field() title scrapy.Field() html scrapy.Field() status scrapy.Field() depth scrapy.Field() screenshot_path scrapy.Field() crawled_at scrapy.Field()注意这里没有设html为serializer因为归档数据里换行、引号、标签都是正常内容序列化时保持原样就行不需要做任何转义之外的加工。2.2 抓取逻辑从首页出发控制归档边界Spider的写法取决于站点结构。如果目标是一个结构清晰的文档站我建议直接用CrawlSpider Rule代码最简洁边界也好控制。import scrapy from scrapy.spiders import CrawlSpider, Rule from scrapy.linkextractors import LinkExtractor from archiver.items import ArchiveItem from datetime import datetime class ArchiveSpider(CrawlSpider): name archiver allowed_domains [example.com] start_urls [https://example.com/docs/] rules ( Rule( LinkExtractor( allowr/docs/, deny(r/tag/, r/author/, r\?page), ), callbackparse_page, followTrue, ), ) def parse_page(self, response): item ArchiveItem() item[url] response.url item[title] response.xpath(//title/text()).get(default).strip() item[html] response.text item[status] response.status item[depth] response.meta.get(depth, 0) item[crawled_at] datetime.utcnow().isoformat() item[screenshot_path] return item这段代码里有三个细节值得说明。LinkExtractor的deny参数最常见的作用就是拦截分页参数、标签聚合页、作者页这类边界外页面归档时这些页面通常没有长期价值还会让数据里混入大量动态查询参数拉低检索质量。followTrue表示继续从符合条件的页面里提取链接实现递归遍历。response.meta.get(depth, 0)是把Scrapy内部维护的抓取深度记录到Item里后续可以用来分析归档范围有没有超出预期。response.text在Scrapy中返回的是解码后的HTML字符串存的就是页面当时的源码。如果目标页面是服务端渲染的那么这一步拿到的就是完整的HTML快照。2.3 保留现场状态码、重定向和编码问题归档场景中HTTP状态码本身就很有价值。一个页面今天返回200下周变成404这就是它生命周期里的真实事件。所以我在Item里存status字段而不是只筛选200的页面。实际操作中可以让Spider对404页面也生成一条记录只是html里存占位符或者空字符串。这样时间维度上的页面消失就有了依据。重定向方面归档建议REDIRECT_ENABLED False也就是保留原始请求URL作为记录主键否则你看到的是redirect之后的URL并不一定是当初要归档的那个地址。当然对于官网入口页这类确需跟随跳转的场景也可以全局打开重定向然后在Spider里通过response.meta.get(redirect_urls)判断是否记录来源。编码是最容易被忽视的坑。不少老站点头部声明charsetutf-8实际内容却是GBK或者反过来。Scrapy的response.text依赖响应头和meta声明推断编码一旦推断错误HTML里全是乱码归档就废了。稳妥做法是在Spider里检查并覆盖编码if response.encoding and response.encoding.lower() not in (utf-8, utf8): try: body response.body.decode(response.encoding, errorsreplace) except LookupError: body response.body.decode(utf-8, errorsreplace) else: body response.text我后来把这段逻辑抽成了一个工具函数decode_response(response)所有Spider共用避免每个站点重复踩坑。2.4 控制抓取节奏别把对方服务器跑挂归档是重读操作频率又往往集中在短时间内对目标服务器的压力比普通爬虫大。我的做法是在Settings里显式控制节奏DOWNLOAD_DELAY 0.5 CONCURRENT_REQUESTS_PER_DOMAIN 8 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1.0 AUTOTHROTTLE_MAX_DELAY 5.0 ROBOTSTXT_OBEY TrueAUTOTHROTTLE_ENABLED打开后Scrapy会根据目标站点的响应延迟动态调整请求间隔比固定DOWNLOAD_DELAY要聪明。归档这种对实时性要求不高的任务宁慢勿快。这里也顺便强调一句只对你拥有授权、或者站点明确允许爬虫抓取的页面做归档。不要拿这套工具去高频抓取不欢迎爬虫的站点也不要尝试绕过登录、验证码或访问控制。保存别人网站内容时注意版权和使用条款边界。爬虫应该是一个克制的工具不是攻击手段。3. 自动截图在Scrapy异步链路里接上Playwright3.1 三种截图方案的取舍自动截图是很多人卡壳的地方。早期我试过两条路一是直接用Selenium写独立脚本抓完再另外截图绕开Scrapy二是用Selenium替换Scrapy的下载器全站无头渲染。两条路都不理想。第一种方案的问题是流程割裂。Scrapy抓完一轮Selenium再跑一遍两遍遍历的时间和请求量直接翻倍而且两边URL集合容易不一致很难保证有HTML的都有截图。第二种方案的问题是性能和复杂度上来了。所有页面都走Selenium即使很多页面根本没有动态内容白白增加了渲染等待时间内存和CPU占用也明显偏高。最终我选择了scrapy-playwright这个中间件。它把Playwright嵌进Scrapy的下载处理器里按需启用默认页面走普通下载动态页面通过Request的meta加标记只有被标记的请求才会调起Chromium渲染并截图。这样静态为主、少量动态的站点整体开销比全量Selenium低一个量级。3.2 环境接线中间件、浏览器内核、Settings配置安装依赖pip install scrapy-playwright playwright install chromium注意第二行别漏了。只装Python包不装浏览器内核运行时才会去调到时候错误信息容易让人误判是代理问题。Settings里需要做三件事DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor PLAYWRIGHT_BROWSER_TYPE chromiumTWISTED_REACTOR是必须切到asyncio的reactorscrapy-playwright底层依赖asyncio事件循环。这个设置必须在爬虫启动前生效所以写死在settings.py里最省事。PLAYWRIGHT_BROWSER_TYPE默认就是chromium显式写出来只是为了让配置更清楚。3.3 在Spider里截图的完整写法启用Playwright的关键是Request的meta。Spider在生成下一次请求时给需要渲染的URL加上标记yield scrapy.Request( url, callbackself.parse_page, meta{ playwright: True, playwright_include_page: True, playwright_page_goto_kwargs: { wait_until: networkidle, timeout: 30000, }, }, )playwright_include_page很重要。加上它Scrapy就会把浏览器page对象塞进response.meta[playwright_page]你才能调用screenshot方法。不加的话请求虽然走了浏览器但页面对象拿不到截图无从谈起。回调函数里只要检测到page对象就执行截图async def parse_page(self, response): item self.build_item(response) page response.meta.get(playwright_page) if page: await self.capture_screenshot(page, item) yield item async def capture_screenshot(self, page, item): try: await page.wait_for_timeout(800) path fscreenshots/{self.make_slug(item[url])}.png await page.screenshot(pathpath, full_pageTrue) item[screenshot_path] path except Exception as exc: self.logger.warning(screenshot failed for %s: %s, item[url], exc) item[screenshot_path] 这里有两个参数值得单独说。wait_for_timeout(800)是我踩坑后加的。很多页面在networkidle之后还有图片懒加载、字体加载、JS异步填充内容不等一下截出来的图就会缺块。等0.8到1.5秒视觉上几乎感知不到但截图完整性提升非常明显。full_pageTrue相当于是整页截图不是只截首屏。对归档场景来说用户看的是这个页面的完整内容首屏截图往往丢了后半部分信息。如果目标是纯封面图可以改成full_pageFalse速度快很多但这个方案的定位是内容归档所以整页优先。3.4 截图失败的兜底策略截图不是归档的核心资产HTML才是。所以截图逻辑必须做到失败不影响主流程。我在capture_screenshot里做了三件事外层try/except捕获所有异常、失败只写警告日志、并且把screenshot_path置为空字符串而不是让Item抛错。这样就算Playwright自身崩溃爬虫也不会中断整个站点还能正常归档只是部分页面缺图。另一个兜底是重试。如果截图失败不要立即灰心很多是页面渲染超时或临时网络抖动在errback里对这类请求做一次重试即可。我通常只在截图步骤里对当前URL做一次重试不对整个Request链路重放避免把已经成功入库的数据重复处理一遍。3.5 性能取舍不是所有页面都值得走浏览器渲染实战中最容易犯的错误是把所有URL统一加上playwright: True。我非常理解因为全站走浏览器写起来最省事。但代价也很明显每个请求都要启动Chromium上下文内存占用暴涨渲染耗时是普通下载的5到10倍页面生命周期变长触发意外超时的概率变大我的策略是分层归档。第一步用默认下载器抓一遍检查HTML里是否包含正文关键节点。可以用XPath快速判断def page_has_content(self, response): # 文章正文常见选择器按站点结构调整 body_text response.xpath(//div[contains(class, article-content)]//text()).get() return bool(body_text and len(body_text.strip()) 50)如果原始响应缺少内容就把这个URL记录下来第二轮专门用playwrightTrue补抓。这样既保证了动态页面也能归档又不会让全站页面都付出渲染成本。4. 数据落地CSV/JSONL导出与SQLite存储的分工4.1 导出层用FEEDS为什么我推荐JSONL而不是CSVScrapy的Feed Exports提供了最省事的数据导出方案在Settings里配一下就行FEEDS { data/snapshots.jsonl: { format: jsonlines, encoding: utf-8, overwrite: False, }, }标题里带了CSV导出但我一路写下来更推荐用JSONL做主导出格式CSV可以作为补充。原因很直接CSV把每一行记录用逗号和引号封装而html字段本身就有大量逗号、双引号、换行、标签符就算csv模块能正确序列化一旦你在Excel里打开表格几乎必然错列几个页面就乱成一锅粥。JSONL则每一行一条JSON记录html字段天然转义随便用什么编辑器都能读程序处理起来也直接。如果你确实需要CSV配合csv导出格式可以正常生成文件但不能指望Excel直接打开浏览HTML字段。CSV更适合导出不含html的摘要信息比如url、title、status、crawled_at四个字段做报表我给这个场景单独配了一个只含元数据的Item结构。4.2 SQLite持久化Pipeline里的入库细节SQLite成为归档存储首选有三个原因零配置、单文件、支持SQL查询。相比MySQL不需要账号权限相比直接存一堆文件检索方便得多。一个站点一个.db文件拷贝和备份都简单。建表和入库逻辑我放在Item Pipeline里这样Spider只管产出记录落地细节全部收敛到数据层。import sqlite3 class SQLitePipeline: def __init__(self, db_path): self.db_path db_path self.conn None classmethod def from_crawler(cls, crawler): return cls(db_pathcrawler.settings.get(SQLITE_DB, archive.db)) def open_spider(self, spider): self.conn sqlite3.connect(self.db_path, check_same_threadFalse) self.conn.execute(PRAGMA journal_modeWAL;) self.conn.execute( CREATE TABLE IF NOT EXISTS snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, title TEXT, html TEXT, status INTEGER, depth INTEGER, screenshot_path TEXT, crawled_at TEXT ); ) def close_spider(self, spider): if self.conn: self.conn.commit() self.conn.close() def process_item(self, item, spider): self.conn.execute( INSERT OR REPLACE INTO snapshots (url, title, html, status, depth, screenshot_path, crawled_at) VALUES (?, ?, ?, ?, ?, ?, ?) , ( item.get(url, ), item.get(title, ), item.get(html, ), item.get(status, 0), item.get(depth, 0), item.get(screenshot_path, ), item.get(crawled_at, ), ), ) self.conn.commit() return item这个Pipeline有两个细节值得留意。check_same_threadFalse几乎是必需的。Scrapy的Pipeline处理是跑在Twisted线程里的不同请求可能在不同线程调process_item如果不开这个参数SQLite会抛出SQLite objects created in a thread can only be used in that same thread的异常。PRAGMA journal_modeWAL;用来开启WAL模式。归档场景里读多写少WAL模式允许多个读事务和一个写事务并行显著减少database is locked报错。这一点在并发写入时尤其关键。4.3 写入策略去重、更新和历史版本归档场景里最需要想清楚的策略是同一个URL再次抓到时是覆盖还是保留两条记录INSERT OR REPLACE的效果是覆盖更新。适合做当前状态快照也就是始终保存最新版本。如果你只需要追溯最近一次抓取时这个页面长什么样这个方案完全够用。如果你要保留的是历史演进那就不能用REPLACE因为旧数据会被新数据顶掉。更合理的做法是去掉url的唯一约束或者增加一个snapshot_version字段每跑一轮版本号1CREATE TABLE IF NOT EXISTS snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, title TEXT, html TEXT, status INTEGER, depth INTEGER, screenshot_path TEXT, crawled_at TEXT, snapshot_version INTEGER DEFAULT 1 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_url_version ON snapshots(url, snapshot_version);这样每次运行都插入新版本查询时可以通过MAX(snapshot_version)取最新通过WHERE url ?查看全部历史。我个人做法是对绝大多数归档用途INSERT OR REPLACE已经够了只有当你明确要做页面变更对比、内容溯源时才需要版本表。不要一开始就把模型设计得太重等需要历史版本的时候再迁移也不迟。4.4 SQLite并发写入的两个坑爬虫跑到一半偶尔会看到database is locked。这个报错在SQLite里很常见原因通常是两个连接同时写同一个库文件。Scrapy的pipeline理论上只有一个实例但process_item是并发调用的多个Item同时提交时写锁可能互相冲突。我的处理办法有两个层面。第一层在Pipeline里增加一个threading.Lock让写操作串行化import threading class SQLitePipeline: def __init__(self, db_path): self.db_path db_path self.conn None self.lock threading.Lock() def process_item(self, item, spider): with self.lock: self.conn.execute(...) self.conn.commit() return item第二层如果归档量非常大可以考虑关闭自动提交攒一批再统一commit()减少锁竞争。比如每50条提交一次吞吐量能提升不少。不过这会带来数据丢失风险爬虫中途崩了最后一批没提交的数据就丢了。对我来说归档数据完整性优先所以我一直保持每次提交。5. 实测环节几个绕不开的坑和处理方式5.1 动态加载页面存下来的HTML没有正文第一次跑归档的时候我以为只要把response.text存下来就万事大吉结果打开SQLite一看一部分页面的html字段只有几十KB明显是个空壳正文全没了。这类页面通常是前端框架渲染的服务端返回的只有骨架。解决方式就是前面提到的分层策略先普通请求用XPath判断关键正文节点是否存在如果内容缺失就把URL加入待重抓队列第二轮用playwrightTrue请求拿到渲染后的完整DOM再存库。注意走Playwright后拿到的response.text已经包含JS渲染后的内容这正好是归档需要的用户实际看到的样子。5.2 中文乱码charset声明与实际不一致乱码问题在前面提过这里再补一个实测细节。有些站点会在HTML里放一个几乎不可能出错的自适应编码脚本但响应头却没跟上。用response.encoding判断时Scrapy可能从HTTP头拿到一个错误值。我的兜底逻辑是先用scrapy.utils.response.get_encoding_from_headers拿到声明编码再手动用chardet或者charset-normalizer检测实际编码两者不一致时以检测结果为准但只对HTML文本字段做解码不改变响应对象本身。这套逻辑虽然多花一点CPU但对归档数据的可靠性提升是实打实的。5.3 截图只截了首屏或者模糊截图模糊和截不全是Playwright截图最常见的两类问题。截不全通常是因为页面在滚动加载懒加载图片还没触发就截完了。解决办法是在截图前模拟快速滚动到底再回顶部强制触发懒加载await page.evaluate( window.scrollTo(0, document.body.scrollHeight); ) await page.wait_for_timeout(500) await page.evaluate( window.scrollTo(0, 0); )截图模糊则和设备像素比有关。默认viewport在普通屏上是够用的但高DPI屏幕截出来会有轻微发虚。想要更清晰的截图可以在Request的meta里设playwright_context_kwargs: { viewport: {width: 1440, height: 900}, device_scale_factor: 2, },device_scale_factor: 2截出来的图清晰度更高代价是图片体积变大。归档场景我倾向于开反正磁盘比看不清便宜。5.4 递归爬虫越爬越远归档边界失控CrawlSpider的Rule一旦写宽了很容易把用户中心、购物车、搜索页、翻页参数、日历控件全爬进来。比如某个文档站搜索结果URL长这样/search?qabc归档它毫无意义还污染数据。我的经验是每题一个站点都要单独审视Rule里的allow和deny正则。宁可先保守一点只匹配正文目录下的URL比如/docs/和/guide/也不要贪多。万一归档范围确实不够后面加正则再跑一轮就行如果一开始就爬宽了清洗数据反而更费劲。另外强烈建议在Spider里加一个custom_settings来限制最大页面数custom_settings { CLOSESPIDER_PAGECOUNT: 500, CLOSESPIDER_ITEMCOUNT: 450, }这个限制不是给长期归档用的而是调试规则时防止写错正则导致无限递归。跑测试时我一般再加个DEPTH_LIMIT 2几秒钟就能验证规则对不对。5.5 请求频率过高导致服务端拒绝即使ROBOTSTXT_OBEY True并不代表访问频率就是安全的。有的站点robots允许抓取但访问太频繁会临时封IP。AUTOTHROTTLE能自动调节延时但它的前提是目标服务器反馈出响应时间变化。如果站点是CDN响应时间可能始终稳定真正的限流在边缘节点发生Autothrottle感知不到。所以我做归档时会在Settings里叠加一个保守的DOWNLOAD_DELAYDOWNLOAD_DELAY 1.0这个值对几千页的归档项目来说多花不了多少时间但对服务器友好程度完全不同。归档本来就是低频任务没必要把自己的IP置于险境。6. 后续扩展从能存到好用6.1 目录结构与截图管理跑几十个站点之后截图文件一多散落在根目录就乱了。我的最终目录布局是data/ archive.db snapshots/ jsonl/ screenshots/ html/其中screenshots里按站点建子目录模板可以是screenshots/example.com/2025-03-10/xxx.png。这样每次归档是一个独立时间目录配合SQLite里的crawled_at时间维度天然清晰。html/目录其实可要可不要因为HTML已经存进数据库了只有当你需要直接打开本地文件做对比时才导出来。6.2 增量归档同一站点反复跑怎么办归档不是一次性任务定期重跑才是常态。增量策略非常简单因为url有唯一索引INSERT OR REPLACE天然保证了有则更新、无则插入。重跑一次只新增了新页面更新了老页面数据不会重复。定时调度方面Linux上直接用crontab就行0 2 * * 1 cd /path/to/project /usr/bin/python -m scrapy crawl archiver logs/archive.log 21每周一凌晨两点跑一次然后把data/archive.db同步到备份盘或者对象存储。我自己的习惯是每次跑完手动再打一个tar包因为数据库文件如果正在写入时被复制容易损坏。先压缩再备份稳妥。6.3 用SQLite生成检索索引页SQLite的价值在归档后体现得最充分。想查哪个页面标题里包含安装最近一次抓取是否正常一条SQL就出来了SELECT url, title, status, crawled_at FROM snapshots WHERE title LIKE %安装% ORDER BY crawled_at DESC LIMIT 20;我还写过一个简单脚本把当天新增的归档记录渲染成一个纯HTML索引页按目录分组链接指向本地HTML文件和截图路径。打开这个索引页整个归档就像一个小型离线站点。虽然实现不复杂但每次需要回看历史版本时体验比直接翻数据库好太多。6.4 按站点拆分数据库如果归档范围扩大到了多个独立站点我建议不要全部塞进一个SQLite库。每个站点一个独立的.db文件文件按站点名命名例如example.com.db。好处有三点备份灵活、单库体量可控、查询互不干扰。SQLite对单库大小没有硬性限制但查询性能和数据密度有关单库塞了太多无关数据检索时反而更难定位。7. 一些实际操作中的体会跑到后来我最大的感受是归档项目最怕的不是技术方案多复杂而是抓完就当成完了。HTML存进SQLite只是起点真正有用的部分在于定期重跑、增量更新以及结合截图的快速回看。这套方案的架构其实不新鲜就是Scrapy Playwright SQLite的常规组合难的是把每一环的细节抠到位哪些页面该走渲染、编码怎么兜底、写库并发怎么解、截图如何避免残缺、边界怎么圈定这些坑踩一遍就知道价值了。如果你只是备份几十个静态页面手写requests完全足够。但当你需要持续跟踪几百个网页、隔周做快照、随时回查任意一天的版本这套流程的性价比就会越来越明显。工具选型从来不是越高级越好而是匹配你的真实需求。归档如此做技术项目也一样。