
前几天接了个有点“复古”的活儿。一个在高校做行政的朋友找到我说他们学院的旧网站要下线了但新闻栏目里存了好几百张历年活动照片领导要求全部归档留存。后台没有导出功能FTP账号早就被信息中心收回唯一的办法就是从已经渲染好的页面上把图片一张张捞下来。他问我有没有什么“高级办法”我说有的而且不一定多高级正则表达式就够了。这个需求的本质其实很朴素从一段有规律的HTML文本里把形如img src...的片段全部找出来提取其中的图片地址然后再批量下载。听起来简单但真在真实环境里做一次你会遇到懒加载、属性顺序乱、相对路径、编码乱码、防盗链等一系列问题。把这些都处理干净代码就没那么“一行搞定”了。我为什么不用现成的解析库BeautifulSoup、XPath 其实也能做而且处理规范HTML时更稳。但在这个场景里有几个现实考虑第一官网HTML很多时候是不规范的标签缺闭合、属性带着多余空格是常态正则对这种“脏文本”反而更直接第二我只需要提取一个字段杀鸡没必要用牛刀第三正则写法不依赖额外依赖包丢到任何一台有Python的机器上都能跑对非专业运维人员更友好。所以我选择用标准库 re 搭配 requests走完整个提取、过滤、下载流程。需要说明的是我并不是在否定解析库。如果你后面想写复杂的爬虫要维护整个页面结构那 BeautifulSoup 一定更合适。但单次、轻量、提取逻辑简单时正则绝对是性价比最高的起点。这篇博文我就按实际处理的顺序把每一步的写法和踩过的坑摊开来说代码可以直接拿去改。1. 突然要归档官网图片我为什么先掏出正则1.1 这个需求背后其实是什么问题表面上我们要做的是“提取图片”往深一层看这其实是“从半结构化文本里抽取信息”的典型场景。学校的官网由内容管理系统CMS生成图片在HTML里的位置是有规律的但又不是完全一致。今天加一个 alt 属性明天多一个 style后天整个图片改成懒加载规律会变。所以核心问题从来不是“找到那一张图”而是“找到那一类图片”。正则表达式的价值就在这里你可以用一套模式去描述“凡是img标签里以src/data-src开头的引用都给我抠出来”从而应对属性顺序变化、大小写不统一、引号单双混用这些真实存在的差异。理解到这一步你会发现处理官网图片和处理其他文本抽取任务没什么本质区别。今天抽图片链接明天抽文章里的电话号码、身份证号、邮箱、超链接底层都是同一个套路观察规律写成模式跑匹配处理边界情况。这也是我觉得这个入门项目特别值得写一篇完整记录的原因——它不是一个“脚本粘贴完事”的活儿而是正则思维的一次完整演练。1.2 正则 VS XPath/BeautifulSoup我为什么选了正则有朋友会问有了BeautifulSoup为什么还要用正则。我的回答是工具没有绝对优劣只有场景匹配。正则适合的是“规则明确、量级不大、一次性的文本抽取”解析库适合的是“频繁操作页面结构、需要按DOM层级关系取数”的场景。举个例子BeautifulSoup 拿到页面后可以soup.find_all(img)直接拿到所有图片节点这确实很爽。但如果你只跑一次就完事为了这一个提取动作去安装 bs4、学习它的 API、处理各种节点类型投入产出比并不高。而且官网HTML的不规范性有时候会让解析器产生意想不到的行为比如标签属性被 CMS 加了一堆自定义前缀解析器找 src 也得额外写逻辑。反观正则一个re.findall就能拿到所有匹配结果。虽然它不能理解DOM树结构但在“找 img 标签里的地址”这个平面任务上它的鲁棒性和可读性反而更好。整个项目可以不装任何第三方解析库只留 requests 一个网络依赖。对在学校服务器、内网机器这类网络受限的环境中操作越少的依赖越省心。这是我当时拍板用正则的核心理由。2. 动手之前先看看学校官网的图片是怎么写在HTML里的很多人写正则提取失败不是因为正则语法不懂而是因为没先研究目标文本长什么样。这一步千万别省。我拿到朋友给的网址后第一件事不是打开PyCharm而是打开浏览器开发者工具右键查看网页源代码先人工扫一遍图片标签的写法。看了一圈你会发现同样是官网不同栏目、不同时期发布的文章图片标签长得很不一样。有的老老实实写src有的把真实地址塞在>import requests HEADERS { User-Agent: (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36) } def fetch_html(url): resp requests.get(url, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding or utf-8 return resp.text这里我把 UA 字符串拆成多行是 Python 自动拼接字符串括号内字面量的特性代码看起来整洁一些不是必须的操作。实际请求时如果遇到 403优先检查 UA如果遇到编码乱码重点看 apparent_encoding 返回的是什么。按我的经验这两个问题解决了页面内容基本就能正常拿到手。3.2 拆解核心正则从img标签里精准抠出图片地址页面拿到手核心环节来了写正则。我采用的两级方案第一级先把整个img ...标签抠出来第二级再在标签内部找图片地址属性。一级正则写成img[^]*含义是匹配以img开头、之后跟着任意个不是的字符、最后以结束的内容。注意这里用了[^]而不是.这样即使 img 标签跨了多行也能匹配到因为[^]包含换行符而且不会把之后的HTML内容也包进来。有人会觉得一级正则太宽松万一标签写成img srcab.jpg怎么办这种写法在HTML里确实合法但实际官网极少有图片文件名带的可以接受这个边界。下面是一级匹配的代码import re IMG_TAG_PATTERN re.compile(rimg[^]*, re.I)拿到 tag 列表后第二级正则负责抠地址。官网图片属性五花八门我把常见的地址属性都列进一个非捕获分组里顺序按照“优先细节”排列data-src、data-original、data-lazy-src、src。属性名与等号之间、等号与值之间可能有多余空格所以用\s*兼容。属性值可能用单引号或双引号用[\\]覆盖。真正要捕获的是引号内部的URL对应模式里的(.*?)。这段模式的完整写法是SRC_PATTERN re.compile( r(?:data-src|data-original|data-lazy-src|src)\s*\s* r[\](.*?)[\], re.I )这里有几个值得展开的细节。第一(.*?)用的是懒惰匹配如果写成(.*)贪婪版本当一行里有多个属性值带引号时它会一路吃到最后一个引号才停导致抠出来的URL混进别的属性。第二属性之间可能没有空格不规范的CMS会生成altxsrcy.jpg用.在标签内部扫地址时可能粘连但因为我们是在单个 img 标签内部继续 search可接受。第三re.I必须带因为官网里SRC、Data-Src大小写都出现过。把两级正则串起来提取函数如下def extract_image_urls(html): results [] for tag in IMG_TAG_PATTERN.findall(html): m SRC_PATTERN.search(tag) if not m: continue src m.group(1).strip() results.append(src) return results实测下来这套提取逻辑能覆盖绝大多数官网新闻页。如果你遇到更特殊的属性名比如>from urllib.parse import urljoin def build_full_urls(raw_urls, base_url): full_urls [] for src in raw_urls: if src.lower().startswith(data:): continue full urljoin(base_url, src) if full not in full_urls: full_urls.append(full) return full_urls协议相对地址是另一个常见形态形式是//cdn.example.edu.cn/photo.jpg表示使用当前页面的协议。直接复制请求会失败但urljoin一看就知道这份地址缺协议会自动补上https:。base64 内联地址也要在这一步过滤判定条件很简单字符串以data:开头就不处理。对于新闻归档这种需求内联base64基本都是小图标不是目标高清照片过滤掉不影响结果。所以完整的提取函数会先过滤掉 data: 开头的地址再做 urljoin最后去重。去重我用list(dict.fromkeys(...))这个技巧既保序又去重比先转 set 再转 list 更稳定在Python 3.7里字典天然保序结果顺序和页面出现顺序一致归档时方便对照。3.4 批量下载去重、命名、重试一个都不能少地址处理干净最后就是下载。下载这块看着简单但命名直接决定归档效果。官网图片文件名经常冲突比如多个栏目都叫logo.png如果都存一个目录后面会把前面覆盖。我采用两种命名策略结合如果URL路径里有可读的文件名就保留原名如果重名就在前面加时间戳或序号。import os import time from urllib.parse import urlparse def download_image(url, save_dir./images, index0): os.makedirs(save_dir, exist_okTrue) path urlparse(url).path filename os.path.basename(path) if not filename or . not in filename: filename fimg_{int(time.time()*1000)}_{index}.jpg else: filename f{index}_{filename} resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: with open(os.path.join(save_dir, filename), wb) as f: f.write(resp.content) return filename return None请求频率一定要控制。官网是别人学校的正经服务器不是给你当压力测试靶子用的。不仅技术上要加time.sleep(0.5)之类延迟道义上也应该保持低频率、短时间段遵守对方的 robots.txt不抓敏感路径。如果遇到某个图片下载失败不要立刻重试到死最多重试两三次失败就记录日志跳到下一张最后统一看哪些没下下来手工处理。关于防盗链也要留个心眼。有些官网图片服务器会检查 Referer 头直接请求可能403。真遇到这种可以在下载请求里给referer头填页面URL。这属于“加一个 header 就能解决”的典型问题我后面会再提到。4. 真实环境里踩过的坑全在这里脚本写好了不代表万事大吉。实战环境的问题千奇百怪我在朋友那个站上调试的时候就遇到好几个。这里挑最常见、也最值得说的四个坑每个都附排查思路你遇到同样问题可以直接对照。现象可能原因快速定位匹配结果为空img与其他属性之间隔了换行检查标签是否跨多行改用[^]*下载下一堆空白小图懒加载属性未覆盖检查>