ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

pywebcopy离线归档与整站镜像实战:参数、避坑与校验

pywebcopy离线归档与整站镜像实战:参数、避坑与校验

简介:pywebcopy 是一个用 Python 编写的网页抓取与归档库,面向需要保存完整网页或整站镜像的开发者、爬虫学习者和资料归档人员,解决页面样式、脚本、图片等静态资产分散、离线阅读不便的问题。压缩包共 32 个文件,约 65KB,以 19 个 py 源码模块为核心,辅以 8 个 md 说明文档、2 个 yml 配置文件及 requirements、许可证等辅助文件,便于阅读实现、运行示例和二次开发。已有 570 人学习或下载。它支持一次调用保存包含 CSS、JS 和图像的网页,也可克隆完整网站并重新映射资产与链接;代码中涵盖爬虫、解析、API、页面结构与配置等模块,并强调遵循 robots.txt,提供直接 API 与子类扩展方式。对镜像过程中的资源下载、链接重写和配置管理有较清晰的组织,适合用来理解站点镜像流程、搭建离线归档脚本或进行定制化抓取实践。

1. 一个离线归档需求,把 pywebcopy 推到了台前

上周有位做前端的朋友找我:一套已经下线的活动页要原样搬到内网,让审核同事在没网的环境里点开就能看。浏览器右键「另存为」最省事,但一个页面三十多张图,存下来的 HTML 里引用全被改成了本地绝对路径,换个目录就裂;碰上带查询串的 CDN 图片,文件名还会互相覆盖,最后只剩一张。这类活儿的正解是写几行 Python,而 pywebcopy 就是专门干这个的库——它把镜像网页和网站拆成「抓 HTML、找依赖、下载资源、改写引用」四步,最后落盘成一个双击就能打开的目录。它适合做内容归档、离线演示、设计参考站快照的从业者,不适合直接拿来对付需要登录态或者纯 JS 渲染的站点,这条边界后面会反复提到。

2. 最小闭环:从 pip 到一份能双击打开的离线页面

先把单页跑通,再谈整站。很多人上来就save_website一个域名,结果跑了两小时抓回来一堆日历页和搜索结果页,磁盘涨了十几个 G,真正想要的正文目录反而没抓全。单页验证能让你在十分钟内看清产物结构,知道资源落在哪、引用被改成什么样,后面出问题才有对照。

2.1 它凭什么能把页面拆开再装回去

pywebcopy 的内部流程大致分三层。第一层是请求层,用一个会话对象去拿 HTML,带上常规的 User-Agent 和超时;第二层是解析层,把 HTML 丢给解析器,遍历link[rel=stylesheet]、script[src]、img[src]、source[srcset]这些节点,把所有外部引用收集成一张资源清单,CSS 文件还会再进去扫一遍url(...)和@import;第三层是改写层,资源逐个下载到本地,同时把 HTML 里的绝对 URL 替换成相对路径。

理解这个三层结构很关键,因为它决定了失败长什么样。如果是请求层挂了,你会看到ConnectionError或者 403;如果是解析层漏了,表现是「页面能打开但某类资源没有」,比如字体文件全丢;如果是改写层出错,表现是文件明明下载下来了,但 HTML 里指向的还是外网地址,离线打开一片空白。排查时先定位在哪一层,比盲目加参数有用得多。

还有一个容易忽略的点:它改写的是「引用」,不是「内容」。CSS 文件里如果嵌套了三层@import,或者 JS 里动态拼了一个图片路径,这些都不在静态解析的射程内。这不是缺陷,是静态抓取的天然边界,后面第 4 章会给出补刀方案。

2.2 装好环境,跑通第一次 save_webpage

建议单独开一个虚拟环境,因为 pywebcopy 会拉进 requests、beautifulsoup4、lxml 这一串依赖,混在系统环境里容易和别的项目打架。

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install pywebcopy python -c "import pywebcopy; print(pywebcopy.__file__)"

最后一行能打印出安装路径,说明导入没问题。接下来是最小调用,把目标地址、产物根目录、任务名三个东西给齐就行:

from pywebcopy import save_webpage # 单页归档:抓目标页本身和它引用的 css / js / 图片 save_webpage( url='https://demo-site.test/blog/2024/activity.html', project_folder='./mirror', # 产物根目录,不存在会自动创建 project_name='activity_2024', # 本次任务的子目录名,相当于任务 ID overwrite=True, # 同名任务目录已存在时整个覆盖 delay=0.3, # 每次请求之间的间隔,单位秒 )

这段代码里,url必须带完整 scheme,只写域名会直接抛异常;project_folder和project_name拼起来才是最终落在磁盘上的任务目录,所以project_name别随便起,建议用「日期 + 页面 slug」,方便日后回溯;overwrite=True在调试期很省心,但它会删掉整个旧目录重来,一旦你把它设成某个已经归档好的历史任务名,之前的产物就没了。归档场景下我一般设成False,看到目录已存在的报错反而说明没误伤。

跑完之后先别急着验收,用tree看一眼结构,比任何文档都直观:

tree -L 3 ./mirror/activity_2024 | head -40

2.3 产物目录里到底落了什么

不同版本在子目录命名上会有些差异,但骨架是一致的:HTML 按 URL 的 path 映射成目录层级,资源集中落在页面同级或子级的资源目录里。第一次跑完,建议对照下面这张表逐个确认,缺哪一类就说明哪一层的解析漏了。

路径 / 文件内容验收要点
<project_folder>/<project_name>/任务根目录每次调用生成一个,命名要可回溯
与 URL path 对应的.html页面主体打开后正文应完整,不依赖网络
资源子目录css / js / 字体 / 图片目录名随版本略有差异,以实际产物为准
HTML 内的引用属性已改写成相对路径用编辑器搜索https://应基本清空
原始 HTML 备份(部分版本)改写前的副本有的话留着,出问题能对比

验收动作可以很简单:把网线拔了,双击那个 HTML,看三件事——正文在不在、样式对不对、图片裂没裂。三样都过,单页这条链路就算通了。有一样不过,回到 2.1 的三层模型去定位。

2.4 三个必须先定下来的参数

单页场景真正需要调的参数不多,但下面这几个一旦定错,后面整站镜像会放大成灾难。

参数默认值作用我一般怎么定
project_folder当前目录产物根目录挂到独立数据盘,别放代码仓库里
project_name按 URL 生成本次任务子目录日期_slug,归档场景绝不复用
overwriteFalse同名任务是否覆盖调试True,正式归档False
delay0请求间隔(秒)目标站有频控就调到 0.5~1.0
threadedFalse是否多线程下载单页无所谓,整站建议关
zip_projectFalse抓完是否打包成 zip需要交付单个文件时开

delay这个参数值得单独说一句。它控制的是每次请求之间的等待,看起来不起眼,但在整站镜像里是决定你会不会被目标站限流的关键。很多人第一次跑整站就把delay设成 0、threaded开成 True,结果前两百个请求很顺,第三百个开始全变 429,后面抓回来的全是错误页,还得清空重来。

3. 整站镜像:max_depth 与域名边界决定你抓到的是站点还是垃圾场

单页跑通之后,save_website的诱惑很大——一行调用抓一整个站。但整站镜像的本质是「从一个入口出发做广度优先遍历」,你不画边界,它就会顺着分页、日历、标签、搜索参数一路爬下去,最后抓回来的东西和你想归档的内容可能只重合三成。这一章讲的就是怎么画这个边界。

3.1 save_website 和 save_webpage 的差别不在抓多抓少

两者真正的差别在于「有没有 URL 队列」。save_webpage是一次性的:解析当前页、下载依赖、改写、结束,不关心页面里指向的其他 HTML。save_website维护一个待抓队列,每抓到一个 HTML 就从中提取站内链接,判断是否在白名单内、是否超过深度、是否命中黑名单,通过了才入队。

这个机制带来两个直接后果。第一,深度是相对入口页计算的,入口页算第 0 层,它链出去的算第 1 层,max_depth=3意味着最多跟三层,再深的页面不会被抓。第二,去重完全依赖 URL 字符串,同一个页面如果有?from=home和?from=list两个入口,会被当成两个不同页面各抓一遍,产物里出现两份内容相同、路径不同的 HTML。这一点在 CMS 站点上特别常见。

所以整站镜像的第一件事不是调深度,而是想清楚「哪些 URL 形态是同一个页面的不同马甲」,把它们塞进黑名单。

3.2 用 allowed_domains 和 blacklist_urls 画一个抓取围栏

下面这份调用是我在归档一个内容站时的常用配置,注释写得比较细,可以直接照着改:

from pywebcopy import save_website save_website( url='https://demo-site.test/', project_folder='./mirror', project_name='demo_site_full', # 从入口算起最多跟三层,再深基本是分页和日历,价值低且量大 max_depth=3, # 域名白名单,写域名不写 URL,子域要单独列出来 allowed_domains=['demo-site.test', 'static.demo-site.test'], # 正则黑名单,注意把点号转义,否则 . 会匹配任意字符 blacklist_urls=[ r'demo-site\.test/logout', r'demo-site\.test/search\?', r'demo-site\.test/calendar/', r'demo-site\.test/tag/', r'\.(zip|pdf|mp4|iso)$', # 大文件先排除,避免把磁盘打满 ], delay=1.0, # 一秒一个请求,礼貌且不容易被限流 threaded=False, # 大站关掉多线程,队列顺序更可控 overwrite=False, )

allowed_domains是一个常见翻车点。它的语义是「允许抓取的域名」,不包含主域之外的 CDN 域名时,页面里那些托管在 CDN 上的图片可能抓不到。稳妥做法是先把页面里的资源域名扫一遍,把静态资源域也加进去。至于资源抓取是否严格受这个白名单约束,不同版本行为有差异,所以最终还是要靠产物验收来判断,别把它当成铁律。

blacklist_urls用的是正则匹配,匹配对象是完整 URL。写的时候把/logout、/search?、/calendar/这几类先排掉,能省掉一大半无效请求。带查询串的 URL 记得把?也转义进去,否则只匹配到路径部分。

3.3 delay 和 threaded 的取舍:限速比并发重要

整站镜像最常见的误区是把「抓得快」当成目标。实际上你真正要的是「抓得全且不重来」,而重来的成本远高于慢一点的成本。

配置组合适用场景风险
threaded=False, delay=0.5~1.0绝大多数整站归档慢,但产物完整、可复现
threaded=False, delay=0本地起服务测试对真实站点不要这么干
threaded=True, delay=1.0大型静态站、内网环境队列顺序乱,日志难对账
threaded=True, delay=0不推荐极易触发限流,抓到大量错误页

我自己的习惯是:第一次跑永远用单线程加 1 秒间隔,先抓个二三十页看看产物质量和日志,确认没问题再决定要不要提速。提速的方式我倾向于「分批跑」而不是「调高并发」——把站点按栏目拆成多个任务,每个任务用不同的project_name,跑完再合并。这样任何一个批次失败,损失都是局部可重跑的。

还有一点容易被忽略:delay作用的是请求之间的间隔,但整站镜像里有一部分耗时是资源下载,那部分如果不走同一个限速器,实际压力可能比你预期的大。所以跑的时候盯着目标站的响应码看,一旦出现 429 或 503 的批量出现,立刻停,把delay翻倍再继续。

3.4 robots 与 bypass_robots 的边界

pywebcopy 有一个bypass_robots参数,含义是抓取时是否忽略目标站的 robots.txt 限制。这个参数的默认值是False,也就是默认遵守。

我的建议很明确:除了你自己拥有或者明确获得授权的站点,其他场景一律保持默认。robots.txt 表达的是站点运营方对爬虫的意愿,绕过它既可能违反站点条款,也会让你在对方日志里变成明显的异常流量。归档公开内容时,正确的做法是先看一眼目标站的 robots.txt(浏览器直接访问/robots.txt就能看),确认你要抓的路径不在禁止列表里,再按delay限速慢跑。

还有一层现实考虑:即便技术上绕过了,对方一旦识别出高频访问,封的是 IP 段,你的整个抓取任务会从某一刻起全部返回 403,而你已经抓下来的半成品还得手工清理。这笔账怎么算都不划算。

4. 避坑与排查:五条用真实故障换来的记录

这一章里的每一条,都是我在不同项目里实实在在撞过的。按「现象 → 原因 → 解决」写,方便你对照自己的报错直接定位。

4.1 页面能打开,但样式全丢、图片全裂

现象:HTML 双击能打开,正文文字也在,但整页没有排版,控制台里一堆 404,指向的路径形如./_internal/xxx/default.css,实际磁盘上没有这个文件。

原因:CSS 文件内部的url(...)引用是相对于 CSS 文件自身位置的,不是相对于 HTML。pywebcopy 在改写 CSS 内容时,如果@import形成了多层嵌套,或者 CSS 里用了url(../fonts/a.woff2)这种跨级相对路径,改写结果可能算错基准目录,导致资源被下载到了另一个位置,而引用路径指向空处。

解决:先手工确认基准。打开产物里的 CSS 文件,搜索url(,看改写后的路径,再对照磁盘上资源实际所在目录,两者一比对就知道偏移在哪。如果只是少量文件对不上,写个脚本按「CSS 文件所在目录 + 引用路径」把文件挪到正确位置,或者反向改写 CSS 里的路径:

import re from pathlib import Path def fix_css_urls(css_file: Path, assets_dir: Path): """把 CSS 里指向不存在文件的 url() 改成指向 assets_dir 下的同名文件""" text = css_file.read_text(encoding='utf-8', errors='ignore') def repl(m): ref = m.group(1).strip('\'"') if ref.startswith(('data:', 'http://', 'https://')): return m.group(0) name = Path(ref).name candidate = assets_dir / name if candidate.exists(): rel = candidate.relative_to(css_file.parent) return f'url("{rel.as_posix()}")' return m.group(0) css_file.write_text(re.sub(r'url\(([^)]+)\)', repl, text), encoding='utf-8')

这段逻辑的关键是relative_to,它算出的是从 CSS 文件所在目录到资源文件的真实相对路径,比手工拼字符串可靠。参数assets_dir要指向你实际发现资源所在的目录,跑之前先find一遍确认。

4.2 抓回来一片空白,HTML 里只有一个空的挂载点

现象:产物 HTML 打开是白屏,查看源码只有<div id="app"></div>和几个 script 标签,正文一个字都没有。

原因:目标是单页应用,正文由 JS 在浏览器里渲染出来,服务器返回的原始 HTML 本身就是空的。pywebcopy 拿到的是服务器响应,不是渲染后的 DOM,所以它抓到的就是那个空壳。

解决:在 pywebcopy 之前插一步预渲染,用无头浏览器把页面跑一遍、拿到渲染后的 HTML,再喂给后续流程。常见做法是:

from playwright.sync_api import sync_playwright def render_html(url: str, timeout: int = 30000) -> str: """用无头浏览器拿到 JS 执行完之后的 DOM 快照""" with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() # networkidle 表示等网络静默,比 load 更适合异步渲染的页面 page.goto(url, wait_until='networkidle', timeout=timeout) html = page.content() browser.close() return html

拿到html之后有两条路:一是写入本地临时文件,起一个python -m http.server让 pywebcopy 从这个本地地址抓,这样资源引用还能正常解析;二是自己解析这份 HTML 把资源抓全,完全绕开 pywebcopy 的抓取层。前者改动小,后者可控性强,我一般先用前者快速验证。

4.3 一批图片落地后只剩一张,文件名互相覆盖

现象:页面引用了logo.png?v=1、logo.png?v=2、logo.png?v=3三个版本,产物目录里只有一张logo.png,页面上三个位置显示的是同一张图。

原因:落盘时文件名通常取自 URL 的 path 部分,查询串被丢掉了。三个 URL 的 path 完全相同,自然写到同一个文件,后写的覆盖先写的。

解决:抓取前先自己把资源本地化,用「path + 查询串哈希」生成唯一文件名,改写 HTML 里的引用为相对路径,然后再让 pywebcopy 处理页面。这样 pywebcopy 看到的已经是本地相对引用,不会再重复下载,也不会撞名:

import hashlib, os from urllib.parse import urljoin, urlsplit import requests from bs4 import BeautifulSoup ASSET_ATTRS = {'img': 'src', 'script': 'src', 'link': 'href'} def localize_assets(page_url: str, html: str, out_dir: str = './assets') -> str: os.makedirs(out_dir, exist_ok=True) soup = BeautifulSoup(html, 'html.parser') for tag, attr in ASSET_ATTRS.items(): for node in soup.find_all(tag): raw = node.get(attr) if not raw or raw.startswith('data:'): continue abs_url = urljoin(page_url, raw) parts = urlsplit(abs_url) # 用查询串的短哈希做文件名后缀,保证同 path 不同 query 不撞 digest = hashlib.md5(parts.query.encode()).hexdigest()[:8] if parts.query else '' base = os.path.basename(parts.path) or 'index' if digest: stem, dot, ext = base.rpartition('.') base = f'{stem}.{digest}.{ext}' if dot else f'{base}.{digest}' target = os.path.join(out_dir, base) if not os.path.exists(target): resp = requests.get(abs_url, timeout=15) resp.raise_for_status() with open(target, 'wb') as f: f.write(resp.content) node[attr] = os.path.relpath(target, '.').replace(os.sep, '/') return str(soup)

digest只取 8 位,冲突概率在单站规模下可以忽略;os.path.relpath算出相对当前工作目录的路径,所以调用这个函数时的工作目录要和后续 pywebcopy 的project_folder保持同一基准,否则改写出来的相对路径会偏。

4.4 中文全是乱码,英文正常

现象:抓下来的页面中文变成䏿–‡这种字符,英文和数字完全正常。

原因:目标站响应头里没给charset,或者给了一个错误的默认值(比如ISO-8859-1),而页面实际是 GBK 或 UTF-8 编码。requests 在没有明确声明时会按 HTTP 规范回退到 ISO-8859-1,解出来就是乱码。

解决:抓之前先探一次编码,再决定怎么传。探测代码如下:

import requests resp = requests.get('https://demo-site.test/news/legacy.html', timeout=10) # 先看响应头声明,再看内容推测,两者不一致时以推测为准 print('declared:', resp.encoding) print('detected:', resp.apparent_encoding)

较新版本的 pywebcopy 支持传入编码相关的关键字参数,把探测到的值传进去即可。如果你的版本不认这个参数,退路是先按正确编码把内容写成临时 HTML 文件,本地起一个 http 服务,让 pywebcopy 从本地地址抓——本地服务的响应头你可以完全控制,编码问题就绕过去了。

4.5 整站跑到一半卡住,内存持续上涨

现象:任务跑到几百个页面之后速度明显变慢,进程内存一路涨到几个 G,最后要么被系统杀掉,要么一直挂在那里不动。

原因:递归队列没有做上限控制,加上threaded=True时多个线程同时往队列里塞 URL,去重表膨胀;再加上每个页面的 DOM 对象没有被及时释放,累积起来就是内存雪崩。站点越大、重复入口越多,这个问题越明显。

解决:三个动作一起做。第一,关掉多线程,threaded=False,把并发压力换成可预测的顺序执行。第二,把blacklist_urls补齐,尤其是带分页参数的?page=、带排序参数的?sort=,这些是队列膨胀的主要来源。第三,分批跑:按栏目或按字母区间拆成多个任务,每个任务用独立的project_name,跑完再合并目录。批量跑的时候在外面套一层循环,单任务失败不影响后续,日志里记清楚哪一批失败了好补跑。

注意:整站镜像前先估算体量。用max_depth=1试跑一次,看单页平均产物大小和页面数,乘以预估总页数,得出来的数字如果超过磁盘剩余空间的七成,就先拆批次。

5. 封装成能反复用的镜像工具:批处理、增量与产物校验

单次调用解决不了「每周归档一次」这种需求。真正要长期用,得往上包一层:命令行入口负责批量调度,状态库负责跳过没变的页面,校验脚本负责在交付前兜底。这三块加起来不到两百行,但能把返工率压下去一大截。

5.1 用 argparse 包一层命令行,一次传多个入口

核心诉求是「一条命令跑完一批 URL,单个失败不中断」。下面这版实现故意做得朴素,没有引入额外依赖:

import argparse import time from pathlib import Path from urllib.parse import urlsplit from pywebcopy import save_webpage, save_website def build_parser(): p = argparse.ArgumentParser(description='批量镜像网页或站点') p.add_argument('urls', nargs='+', help='入口 URL,可以传多个') p.add_argument('-o', '--out', default='./mirror', help='产物根目录') p.add_argument('-n', '--name', default=None, help='任务名,默认按时间戳生成') p.add_argument('-d', '--depth', type=int, default=1, help='1 表示只抓单页,大于 1 表示按深度递归') p.add_argument('--delay', type=float, default=0.8, help='请求间隔秒') p.add_argument('--overwrite', action='store_true') return p def main(): args = build_parser().parse_args() stamp = time.strftime('%Y%m%d_%H%M%S') for idx, url in enumerate(args.urls): name = args.name or f'job_{stamp}_{idx}' common = dict( project_folder=args.out, project_name=name, overwrite=args.overwrite, delay=args.delay, threaded=False, ) try: if args.depth <= 1: save_webpage(url=url, **common) else: # netloc 作为域名白名单,生产环境建议显式传入配置 domain = urlsplit(url).netloc save_website(url=url, max_depth=args.depth, allowed_domains=[domain], **common) print(f'[ok] {url} -> {Path(args.out) / name}') except Exception as exc: # 单个任务失败只记录,不中断整批 print(f'[fail] {url}: {exc}') if __name__ == '__main__': main()

几个设计取舍值得说明。depth=1走单页分支、大于 1 走整站分支,是为了让同一个命令既能做快速快照又能做深度归档,不用记两套参数。urlsplit(url).netloc直接把入口域名当白名单,省掉手填,但如果目标站的资源在独立 CDN 域上,这个默认值会漏抓,所以正式场景还是建议加一个--allow-domain参数手动补。异常捕获用宽泛的Exception,在批处理场景里是对的——你宁可吞掉一条错误日志,也不愿意因为第 3 个 URL 挂了导致后面 50 个没跑。

5.2 用 SQLite 记状态,跳过没变化的页面

每周归档一次同样的站点,如果每次都全量重抓,时间和带宽都浪费在没变的页面上。判断页面变没变,最可靠的是服务端给的ETag和Last-Modified,这两个头在 HEAD 请求里就能拿到,成本极低。

先建一张状态表,主键是 URL,记录两个校验值和时间戳:

import sqlite3 DB = 'mirror_state.db' def init_db(): conn = sqlite3.connect(DB) conn.execute(''' CREATE TABLE IF NOT EXISTS pages ( url TEXT PRIMARY KEY, etag TEXT, last_modified TEXT, fetched_at INTEGER, status INTEGER ) ''') conn.commit() return conn def need_refetch(conn, url, etag=None, last_modified=None): row = conn.execute( 'SELECT etag, last_modified FROM pages WHERE url = ?', (url,) ).fetchone() if row is None: return True # 没记录过,必须抓 if etag and etag != row[0]: return True # ETag 变了,内容有更新 if last_modified and last_modified != row[1]: return True return False

need_refetch的判断顺序有讲究:先看有没有记录,再看 ETag,最后看 Last-Modified。ETag 优先级更高是因为它通常是对内容做哈希算出来的,比时间戳精确;有些服务器只给其中一个,所以两个都要兼容。注意etag and ...这个写法,etag为空字符串时直接短路返回False,避免把「服务端没给 ETag」误判成「内容变了」而触发全量重抓。

配套的调度循环大致是:先对每个 URL 发 HEAD 请求拿两个头,查库判断要不要抓,要抓才调 pywebcopy,抓完把新值写回去。整站镜像场景下,这套逻辑要放在 URL 入队的那一层,而不是抓完再判断,否则省不下请求。

5.3 交付前的三道校验,别让残缺产物出门

抓完之后最怕的是「看起来抓完了,其实缺了一半」。写一个校验函数,把产物目录里所有 HTML 的相对引用扫一遍,检查目标文件是否存在:

import re from pathlib import Path from urllib.parse import unquote, urlsplit REF_RE = re.compile(r'(?:src|href)="([^"]+)"') def find_missing_refs(root: str, limit: int = 30): """扫描产物里所有本地引用,返回指向不存在文件的条目""" root_path = Path(root) missing = [] for html in root_path.rglob('*.html'): text = html.read_text(encoding='utf-8', errors='ignore') for ref in REF_RE.findall(text): if ref.startswith(('http://', 'https://', 'data:', '#', 'mailto:', 'javascript:')): continue # 去掉查询串和锚点,再解码百分号编码,才是真实的文件路径 target = (html.parent / unquote(urlsplit(ref).path)).resolve() if not target.exists(): missing.append((str(html.relative_to(root_path)), ref)) if len(missing) >= limit: return missing return missing

三个细节决定这个脚本准不准。第一,过滤协议前缀时要带上javascript:和mailto:,否则一堆内联脚本会被误报。第二,必须unquote,因为 HTML 里的中文路径通常是百分号编码的,不还原就会算出不存在的路径。第三,用limit截断,站点大的时候缺失项可能有几千条,全部打印出来反而看不到重点,先看前三十条足够判断是系统性问题还是零星遗漏。

校验结果怎么用:如果缺失集中在某几类资源上(比如所有字体都缺),说明是解析层的系统性问题,回到第 4 章对应条目排查;如果缺失是零散的、每次跑的都不一样,多半是网络超时导致的下载失败,把delay调大重跑一遍缺失的部分即可。

6. 进阶:把预渲染和特性补刀接进同一条流水线

走到这一步,pywebcopy 能覆盖的场景已经比较清楚了:静态渲染的内容站、文档站、活动页归档,几行代码就能出可用产物。剩下的短板集中在两类,一类是 JS 渲染,一类是 HTML 特性覆盖不全,这两块都可以在调用它之前用几十行代码补上。

先看一张对比表,把不同页面类型的推荐路径列清楚,省得每次都重新想:

页面类型直接 save_webpage预渲染后再抓说明
服务端渲染的内容页可用不必要首选方案,成本最低
静态文档 / 博客可用不必要注意 CSS 里的字体和 @import
单页应用产物为空壳必选先拿到渲染后 DOM 再抓
重图片的电商详情页部分可用建议srcset 多分辨率容易漏抓
需要登录的页面不可用不可用会话态不在这个工具的射程内

预渲染那一步在第 4 章已经给过代码,这里补一个把两者串起来的做法:先用无头浏览器拿到渲染后的 HTML,写到一个临时目录,起一个本地静态服务,再把本地地址交给 pywebcopy。这样 pywebcopy 的解析和改写能力仍然能用上,同时它面对的不再是空壳。落地时注意本地服务的端口要固定,别用随机端口,否则日志里的产物路径每次都不一样,排查时对不上号。

另一个高频遗漏是srcset。响应式图片会给多个分辨率候选,pywebcopy 对这类属性的处理在不同版本上不一致,稳妥做法是在抓之前把srcset压成一个src,只保最大的一张:

import re def flatten_srcset(html: str) -> str: """把 srcset 压成单个 src,取候选列表里最后(通常最大)的一张""" def repl(m): candidates = [c.strip().split()[0] for c in m.group(1).split(',') if c.strip()] if not candidates: return m.group(0) return f'src="{candidates[-1]}"' return re.sub(r'srcset="([^"]+)"', repl, html)

这个替换是粗暴的,它不管原来的img上有没有src,直接覆盖。生产环境里要先判断节点是否已有src,有的话只在srcset存在时才改,没有的话补一个。取最后一张是因为大多数站点的srcset按从小到大排列,最后一张分辨率最高,离线归档场景下清晰度比体积重要。

最后说个我自己的习惯:每次归档任务开始前,先在纸上写三行——这次要抓什么、抓到什么程度算完成、产物交给谁。这三行写不出来,说明需求还没想清楚,这时候调参数都是瞎调。镜像网页这件事,工具只占三成,剩下七成是想清楚边界。前面那些踩坑记录,有一半是因为我一开始没写这三行。

希望帮到你。

本文还有配套的精品资源,点击获取

返回列表