正则表达式实战:批量提取网页图片链接并下载
去年帮学校信息中心整理官网改版素材要把旧版官网新闻栏目里的配图全部归档。页面数量不算多也就几十个列表页但每页十几张图逐个右键另存为显然不是正常人该干的事。我第一反应就是写个 Python 脚本用正则表达式把 HTML 里所有图片链接抠出来再批量下载。这篇文章记录的就是那次实操的全过程包括正则表达式的匹配思路、完整代码以及我在学校官网页面上踩过的几个坑。这套方法适合谁看如果你经常要从学校官网、学院站点、专题活动页里批量整理图片素材或者只是想搞懂用正则表达式提取网页内容到底是怎么一回事那这篇文章可以直接给你一套能跑的方案。我不讲那种永远用不上的理论只讲这次真的跑通的东西。1. 需求从哪来官网图片抓取的典型场景1.1 三种最常遇到的需求学校官网的图片归档需求我遇到过三种而且大概率你们也会遇到。第一种是官网改版。老站点了要迁移设计稿要沿用历史照片可旧站后台已经没法导出图片只有线上页面还能访问。第二种是新闻素材回溯。学校建校几十周年要做历史回顾展某几年的老照片在后台早被清理了但新闻页面里还嵌着当年的活动照片。第三种更简单就是给公众号、宣传册做配图需要把某个专题页里所有海报原图批量下载下来。不管哪一种本质都是同一件事目标页面的 HTML 里躺着图片地址你想快速把它们全部提取出来。正则表达式擅长干这个因为它不看页面渲染效果只按你给它描述的文本模式去抓内容速度快、依赖少一个re模块就能解决问题。1.2 先看看官网图片在 HTML 里的真实形态学校官网是老站居多HTML 写得很随意跟教科书里那种规规矩矩的代码差别非常大。我随便截几个当时见过的写法!-- 最标准的写法但只占一部分 -- img src/upload/images/2024/0912/school.jpg alt校庆活动照片 width300 !-- 单引号、属性顺序乱的 -- img width300 src/upload/2023/x.jpg alt运动会 !-- 属性后带多余空格 -- img src /upload/a.jpg !-- 大小写不统一 -- IMG SRChttp://statics.xx.edu.cn/images/banner.png !-- 图片地址带着版本号参数 -- img src/upload/news/2024/0912/main.jpg?w800h600注意看第三个src后面可以在等号两边出现空格第四个属性名和标签名可以是大写第五个图片地址不一定是干干净净的.jpg结尾它可能带着 query 参数。这些细节直接决定了你写正则时到底要怎么设计字符范围。很多人一上来就写src(.*)然后发现匹配出来的内容乱七八糟就是因为没先观察 HTML 的真实形态。我建议拿到页面后先右键查看源代码把图片相关片段复制出来放在编辑器里看几遍再动笔写正则。这个习惯帮我避开了后面绝大多数坑。2. 正则表达式的底层直觉字符、量词与贪婪模式2.1 正则里的标点符号长得像意思却完全不同正则表达式代表标点符号是什么这个问题我经常在讨论里看到。很多新手把正则里的.*?[]{}这些符号直接当成文本里的标点理解结果匹配老出错。实际上这些符号在正则里全是带刀侍卫不再是普通标点。符号在普通文本里的意思正则表达式里的意思.句号匹配任意单个字符换行除外*星号前一个字符出现 0 次或多次加号前一个字符出现 1 次或多次?问号前一个字符出现 0 次或 1 次跟在量词后面表示懒惰匹配[]方括号字符集合匹配其中任意一个字符{}花括号前面的字符重复指定次数()圆括号分组把一段表达式当成整体|竖线或的关系^脱字符匹配字符串开头在方括号内表示排除$美元符匹配字符串结尾\反斜杠转义符号或代表预定义字符类如\d数字、\w单词字符拿图片提取最常用的几个来说。\d是数字在匹配年份2024的时候可以直接写\d{4}\w是字母、数字、下划线匹配school_photo这种文件名很合适[^]表示除了双引号以外的任意字符这在提取 src 属性时是核心武器。真正坑的是点号。你在 HTML 里看到srca.jpg心里想我写个点号去匹配那个点号但正则里的.能匹配任意字符于是a.jpg匹配出来的可能是axjpg、a/jpg。所以当你要匹配真正的点号时必须写成\.才行。这就是正则里的标点符号和你想的不一样的最典型例子。2.2 贪婪与懒惰图片提取翻车的头号原因如果说标点符号的坑是理解错那贪婪匹配就是工具错。正则里的量词*和默认是贪婪的意思是它们会尽可能多地匹配内容直到再也匹配不下去为止。举个例子我当年第一次写图片提取就栽在这上面。有一行 HTML 是这样的img srca.jpgimg srcb.jpg我用img src(.*)去匹配结果findall只返回了一个结果内容是a.jpgimg srcb.jpg。为什么会这样因为.*贪婪地吃掉了中间的所有字符从第一个一直吃到最后一个前面才停下来满足那个的收尾要求。解决办法有两个。一个是让量词变懒惰写成.*?让它匹配尽量少的内容于是会在第一个后立刻收手。另一个更稳的做法是用排除字符类[^]*直接把双引号排除在外让表达式遇到引号就自然停下。在图片提取这个场景里我更推荐后者。懒惰匹配虽然简单但在引号内部还有引号、或者属性顺序变化时依然可能误判。[^]的意思是匹配一个或多个不是双引号的字符这在语义上就直接锁定了 src 属性值的边界不管前面后面发生什么它都不会跑出引号范围。3. 匹配规则的逐层拆解从 img 标签到 src 属性3.1 第一层匹配整个 img 标签写图片提取规则之前先想清楚层级。HTML 里图片的载体是img标签src 是这个标签的一个属性。我习惯分两步走先用一个正则把整个img ...标签圈出来再从这个标签里抠 src。虽然最终写成一行的规则更快但分层的思路更好理解也更容易调试。匹配整个 img 标签有个经典写法import re html img src/upload/a.jpg alt测试 img_tags re.findall(rimg[^], html, re.I) print(img_tags) # [img src/upload/a.jpg alt测试]img[^]的含义先匹配字面img然后[^]匹配一个或多个不是右尖括号的字符最后用收尾。为什么要用[^]而不是.*?因为在合法的 HTML 标签内部除了引号包裹的内容不会出现字符。用[^]可以直接断言一直吃到标签结束为止天然不会误伤下一个标签。后面的re.I是忽略大小写因为老官网里IMG、Img都很常见没有这个参数你的正则就得写三遍。3.2 第二层把 src 属性值精确抠出来有了整段img标签下一步就是提取 src。这一步我推荐一步到位pattern rimg[^]?src[\]([^\])[\] img_srcs re.findall(pattern, html, re.I)拆开解释。img[^]?从标签开头匹配[^]?懒惰地匹配到 src 出现之前的内容这样即使 img 标签里还有 id、class、width 之类的属性也不影响。然后src描述的是属性名本身。紧接着的[\]表示这里可能是双引号也可能是单引号两种情况都接收。([^\])是捕获组把引号之间的内容抓出来也就是图片地址。最后的[\]负责让匹配在闭引号处收尾。捕获组的圆括号是关键。re.findall只要遇到捕获组就只会返回捕获组里的内容而不是整个匹配结果。所以你拿到的直接就是干净的图片 URL 列表不需要再截取子串。3.3 第三层处理属性乱序、大小写和不加引号学校官网的 HTML 经常不按套路出牌。最常见的问题是 src 不在第一个属性位置比如img width300 height200 src/upload/a.jpg。前面那个img[^]?src里的?懒惰匹配这时就发挥作用了它不会因为中间隔了别的属性就失败会一直往后找直到碰到 src。更难搞的是 alt 属性出现在 src 后面时一行式的正则容易把 alt 的内容也吞进去。这种情况我的建议是回到分层写法把整个标签拿出来用两次re.search分别提取img_tags re.findall(rimg[^], html, re.I) for tag in img_tags: src re.search(rsrc\s*\s*[\]([^\])[\], tag, re.I) alt re.search(ralt\s*\s*[\]([^\]*)[\], tag, re.I) if src: print(图片:, src.group(1)) print(说明:, alt.group(1) if alt else )注意我加了\s*\s*这是用来应付src /upload/a.jpg这种等号周围带空格的写法。老官网特别喜欢在这种地方给你埋雷。还有一种极端情况是 src 值外面根本没加引号比如img src/upload/a.jpg。这种写法违反标准但确实存在。遇到时可以用src\s*\s*([^\s])来匹配把空格或右尖括号之外的内容整体作为 URL。不过我不建议一上来就用这种宽松写法因为它会把width300这种其他属性也误抓进来。先跑标准写法确认没有结果再考虑降级方案。4. 完整可运行的 Python 脚本从请求 HTML 到批量下载4.1 编码处理老官网最容易翻车的地方正则写好了还得先想办法拿到 HTML。很多人在这一步就翻车了症状是页面请求成功但正则匹配结果永远是空列表代码看起来没问题问题出在字符串本身已经乱码了。学校官网的老站大量使用 GBK 或 GB2312 编码而 requests 库在响应头没有明确 charset 时默认会按 ISO-8859-1 解码结果就是一堆乱码。乱码里的img标签可能还在但属性名、引号这些半角符号一般不受影响可一旦 src 里的中文文件名乱码匹配出来的内容就没法用了。我当时的处理方式是先拿二进制内容再让页面自己告诉我它是什么编码import requests resp requests.get(url, headersHEADERS, timeout15) # 优先看页面 meta 声明的编码 # requests 的 apparent_encoding 会用 chardet 猜一遍 resp.encoding resp.apparent_encoding or utf-8 html resp.text这段代码之所以要把resp.encoding手动赋值是为了让resp.text在解码时使用正确的编码。如果你还是发现匹配结果不对打印前 500 个字符出来看一眼看到这种替换字符就说明编码还是不对需要进一步处理。4.2 图片 URL 清洗与去重提取出来的 URL 往往不是直接能用的。学校官网的 HTML 里有几个非常常见的写法需要清洗src//statics.xx.edu.cn/images/a.jpg开头是双斜杠表示沿用当前页面的协议src\/upload\/a.jpg程序员手滑把反斜杠当转义符写进去了srcdata:image/png;base64,...直接把图片内嵌在 HTML 里这种没法下载应该过滤掉src/upload/thumb_a.jpg和src/upload/a.jpg可能是同一张图的两个尺寸需要去重策略。清洗和去重我放在同一个函数里处理from urllib.parse import urljoin, urlparse def clean_img_url(raw_url, page_url): raw_url raw_url.strip().strip(\) if raw_url.startswith(data:): return None if raw_url.startswith(\\/): raw_url raw_url.replace(\\/, /) # 把相对路径转成绝对路径 full_url urljoin(page_url, raw_url) return full_url这里最关键的是urljoin。它专门处理相对路径拼接传入页面 URL 和图片相对地址能正确拼出完整地址。比如页面 URL 是https://www.xx.edu.cn/news/2024/09/12.html图片 src 是../images/a.jpgurljoin知道先回到news/2024/09/上一级再拼上images/a.jpg得到https://www.xx.edu.cn/news/2024/images/a.jpg。你自己用字符串拼接百分之百会在这类问题上出错。4.3 批量下载、文件命名与访问频率核心提取逻辑跑通后剩下的就是下载保存。我把整个流程整合成了一个脚本直接复制就能用import os import re import time import argparse from urllib.parse import urljoin, urlparse import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } IMG_PATTERN re.compile(rimg[^]?src[\]([^\])[\], re.I) def fetch_html(page_url): resp requests.get(page_url, headersHEADERS, timeout15) resp.raise_for_status() resp.encoding resp.apparent_encoding or utf-8 return resp.text def extract_img_urls(html): urls IMG_PATTERN.findall(html) result [] for u in urls: u u.strip().strip(\) if u.startswith(data:): continue if u not in result: result.append(u) return result def normalize_url(page_url, raw_url): if raw_url.startswith(\\/): raw_url raw_url.replace(\\/, /) return urljoin(page_url, raw_url) def download_one(session, url, save_path): resp session.get(url, headersHEADERS, timeout15) resp.raise_for_status() with open(save_path, wb) as f: f.write(resp.content) def main(): parser argparse.ArgumentParser(description从学校官网页面提取并下载图片) parser.add_argument(-u, --url, requiredTrue, help页面 URL) parser.add_argument(-o, --output, default./imgs, help保存目录) parser.add_argument(-d, --delay, typefloat, default0.5, help下载间隔秒数) args parser.parse_args() html fetch_html(args.url) raw_urls extract_img_urls(html) full_urls [normalize_url(args.url, u) for u in raw_urls] os.makedirs(args.output, exist_okTrue) seen set() index 1 session requests.Session() for full_url in full_urls: if full_url in seen: continue seen.add(full_url) ext os.path.splitext(urlparse(full_url).path)[1] if not ext: ext .jpg save_path os.path.join(args.output, f{index:04d}{ext}) try: download_one(session, full_url, save_path) print(f[OK] {full_url} - {save_path}) except Exception as e: print(f[FAIL] {full_url} - {e}) index 1 time.sleep(args.delay) if __name__ __main__: main()命令行运行方式python grab_images.py -u https://www.xx.edu.cn/news/2024/0912.html -o ./imgs -d 0.3几个设计细节说下。文件命名用0001.jpg这种序号而不是原始文件名是因为官网很多图片名是中文或带特殊字符存到本地容易出现编码问题序号最省心。delay参数控制下载间隔我建议至少设 0.3 秒这既是对目标站点基本的礼貌也能避免短时间大量请求触发对方防护。seen集合负责去重同一个 URL 只下载一次。5. 实测踩坑记录转义、相对路径、动态渲染和重复图片5.1 转义陷阱圆括号、点号和反斜杠图片提取的正则里转义问题是最容易让人抓狂的。学校官网的图片目录经常带有年份和期号路径里出现圆括号是很常见的事比如/upload/news/2024(秋季)/a.jpg。在正则里圆括号是分组元字符你不转义它表达式就会把你想要的字符串拆成两组去匹配结果自然对不上。遇到这种情况我之前耗了半小时才反应过来是括号的问题。后来学乖了凡是包含特殊字符的路径统一用re.escape处理import re path /upload/news/2024(秋季)/a.jpg escaped re.escape(path) print(escaped) # \/upload\/news\/2024\(秋季\)\/a\.jpgre.escape会自动把路径里的点号、斜杠、括号全部转义生成的字符串可以直接当作正则模式的一部分使用。域名里的点号同样建议写成\.比如statics\.xx\.edu\.cn。虽然不写有时也能匹配上但点号能匹配任意字符万一你抽风写了个aXb匹配出来的域名就会不符合预期排查起来很痛苦。5.2 相对路径拼接不是所有 src 都能直接访问正则提取出来的 src 值五花八门绝对路径的相对路径都有。我最开始用了一个笨办法如果是/开头就在前面拼上站点首页地址。这个办法在大多数情况下能用但遇到../开头的相对路径就翻车了。举个例子页面在https://www.xx.edu.cn/news/2024/0912.html里面的图片写成img src../images/a.jpg。按我原来的逻辑会拼成https://www.xx.edu.cn/news/2024/images/a.jpg不对../应该先回退一级正确地址是https://www.xx.edu.cn/news/images/a.jpg。这种差异用肉眼很难发现直到你下载请求返回 404 才意识到。所以我在 4.2 里专门用urljoin处理就是因为它内置了完整的相对路径解析规则。不要自己写字符串拼接函数去处理相对路径你写不过标准库的。5.3 正则拿不到图先确认图片是不是 JS 动态加载有一次我在一个学院页面上跑脚本页面肉眼可见很多图片但正则提取结果空得可怜只有一两张。我第一反应是正则写错了反复调了几轮都没用。后来打开浏览器的开发者工具看了一眼网络请求才发现页面里的图片全是 JavaScript 动态加载的HTML 源码里压根没有img src这些内容。这种情况正则再强大也没用因为它只能处理已经存在的文本字符串没办法替你执行 JS。处理思路有两个层级。如果页面用的是懒加载图片地址藏在>url re.sub(r/thumb_, /, url)另外还容易漏掉 CSS 背景图。学校官网的 banner、栏目背景很多写在 CSS 里通过background-image引用img标签里完全看不到。提取背景图需要另一个正则bg_images re.findall(rurl\(\s*[\]?([^\)])[\]?\s*\), css_text, re.I)如果你要拿的是页面视觉素材img和 CSS 背景图得一起处理不然总感觉素材库少了半壁江山。5.5 页面编码不对导致匹配为空这是最隐蔽的一个坑。有一天我跑脚本代码没问题正则没问题页面里也确实有图片但findall返回空列表。我一度怀疑人生直到我把html变量打印到文件里发现中文全部变成乱码而某些img标签因为乱码被拆得不成形正则才匹配不上。requests 库在响应头没有明确编码时会默认按 ISO-8859-1 解码这对老官网来说是灾难。解决办法我在 4.1 已经写了就是手动设置resp.encoding resp.apparent_encoding。apparent_encoding会用 chardet 根据内容猜编码对中文页面判断相当准。这里有个小经验猜完之后最好打印一下确认别盲信因为 chardet 偶尔会把 GBK 页面误判成 GB2312虽然两者兼容性很高但极端情况会有问题。看到结果不对就手动指定resp.encoding gbk或gb2312再试。6. 进阶玩法把脚本变通用顺便覆盖几个相关需求6.1 给脚本加命令行参数当前脚本已经支持-u、-o、-d三个参数但如果你要经常跑不同站点建议再加两个一个自定义匹配正则的--pattern参数一个限定域名范围的--domain参数。parser.add_argument(--pattern, defaultrimg[^]?src[\]([^\])[\]) parser.add_argument(--domain, default, help只保留指定域名的图片)然后在使用IMG_PATTERN的地方改成IMG_PATTERN re.compile(args.pattern, re.I)在过滤 URL 时加上域名判断if args.domain and urlparse(full_url).netloc ! args.domain: continue这样脚本就从学校官网专用变成了通用的 HTML 图片提取工具。换到任何网站只要图片形态还在img标签里改个正则就能继续用。6.2 把 alt 图片说明一并提取出来归档图片时如果没有 alt 信息之后找图只能靠文件名和记忆非常难受。我后来给脚本加了个功能把每张图的 alt 说明也导出来生成一个 CSV 清单。由于 img 标签里属性的顺序不一定alt 在 src 前面或后面都有可能所以最稳妥的办法还是先提取整个 tag再分别匹配import csv img_tags re.findall(rimg[^], html, re.I) with open(images.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([src, alt, title]) for tag in img_tags: src re.search(rsrc\s*\s*[\]([^\])[\], tag, re.I) alt re.search(ralt\s*\s*[\]([^\]*)[\], tag, re.I) title re.search(rtitle\s*\s*[\]([^\]*)[\], tag, re.I) if src: writer.writerow([ src.group(1), alt.group(1) if alt else , title.group(1) if title else , ])这个清单对整理校园图库特别有用。比如你搜运动会直接在全文件里搜不用再一张一张打开看缩略图。6.3 从图片提取延伸到 13 位手机号提取正则表达式的思路一旦建立你会发现很多网页数据处理的需求都是同一件事给你一段文本你描述一个模式然后把它抓出来。很多人搜13位数字手机号码正则表达式怎么写其实和提取图片 src 用的是同一套方法论。从学校官网的联系我们页面提取手机号核心正则就一句话phone_pattern r\b1[3-9]\d{9}\b phones re.findall(phone_pattern, html)1[3-9]是因为国内手机号第二位从 3 到 9 都有分配\d{9}是后面 9 位数字两端的\b表示单词边界防止匹配113800000000这种 12 位数字串的尾部。如果页面里手机号带分隔符比如138-0000-0000那要写成1[3-9]\d[\s-]?\d{4}[\s-]?\d{4}这种容错版本。注意正则适合按格式抓取不适合校验号码是否真实存在。抓出来的号码可能格式对但实际是空号这很正常。你的目标如果是归档联系方式格式对就够了如果是业务系统校验那还得配合运营商号段库或短信验证。6.4 正则的示意图思维先画结构再写规则最后分享一个我觉得最有用的习惯写正则之前先在脑子里或纸上把目标字符串的结构画出来。这个方法我从那次官网图片提取以后一直在用尤其面对复杂模式时能极大地降低出错概率。比如我要匹配一个学校官网的图片地址先画结构协议://域名/目录路径/文件名.扩展名?参数 https://statics.xx.edu.cn/upload/2024/0912/school.jpg?w800然后逐段问自己协议部分可能出现什么https?://域名部分用什么符号分隔[a-z0-9.-]目录路径里可能有哪些字符[a-zA-Z0-9/_]文件名是什么[^/]?扩展名有哪几种\.(?:jpg|jpeg|png|gif|webp)参数部分要不要匹配(?:\?.*)?。这种先画结构再写规则的方式本质上就是给正则画一张匹配逻辑示意图。养成习惯之后你再看到别人写的复杂正则不是从头到尾硬读而是先在脑子里还原它背后的结构图解析速度会快很多。那次官网改版的素材整理我用这套脚本跑了两个晚上把几百张历史照片归档得整整齐齐。后来学院老师又让我帮忙整理官网通知公告里的配图我直接把脚本参数一换就跑完了。正则表达式的学习曲线确实陡但一旦你跨过标点符号陷阱和贪婪匹配这两道坎它就会变成那种你再也离不开的趁手工具。下次再遇到类似需求你可以直接从第 4 节的脚本改起剩下的坑我基本都替你踩过了。