去年帮学校信息中心整理官网改版素材,要把旧版官网新闻栏目里的配图全部归档。页面数量不算多,也就几十个列表页,但每页十几张图,逐个右键另存为显然不是正常人该干的事。我第一反应就是写个 Python 脚本,用正则表达式把 HTML 里所有图片链接抠出来,再批量下载。这篇文章记录的就是那次实操的全过程,包括正则表达式的匹配思路、完整代码,以及我在学校官网页面上踩过的几个坑。
这套方法适合谁看?如果你经常要从学校官网、学院站点、专题活动页里批量整理图片素材,或者只是想搞懂"用正则表达式提取网页内容"到底是怎么一回事,那这篇文章可以直接给你一套能跑的方案。我不讲那种永远用不上的理论,只讲这次真的跑通的东西。
1. 需求从哪来:官网图片抓取的典型场景
1.1 三种最常遇到的需求
学校官网的图片归档需求,我遇到过三种,而且大概率你们也会遇到。
第一种是官网改版。老站点了要迁移,设计稿要沿用历史照片,可旧站后台已经没法导出图片,只有线上页面还能访问。第二种是新闻素材回溯。学校建校几十周年要做历史回顾展,某几年的老照片在后台早被清理了,但新闻页面里还嵌着当年的活动照片。第三种更简单,就是给公众号、宣传册做配图,需要把某个专题页里所有海报原图批量下载下来。
不管哪一种,本质都是同一件事:目标页面的 HTML 里躺着图片地址,你想快速把它们全部提取出来。正则表达式擅长干这个,因为它不看页面渲染效果,只按你给它描述的"文本模式"去抓内容,速度快、依赖少,一个re模块就能解决问题。
1.2 先看看官网图片在 HTML 里的真实形态
学校官网是老站居多,HTML 写得很随意,跟教科书里那种规规矩矩的代码差别非常大。我随便截几个当时见过的写法:
<!-- 最标准的写法,但只占一部分 --> <img src="/upload/images/2024/0912/school.jpg" alt="校庆活动照片" width="300"> <!-- 单引号、属性顺序乱的 --> <img width="300' src='/upload/2023/x.jpg' alt='运动会'> <!-- 属性后带多余空格 --> <img src = "/upload/a.jpg" > <!-- 大小写不统一 --> <IMG SRC="http://statics.xx.edu.cn/images/banner.png"> <!-- 图片地址带着版本号参数 --> <img src="/upload/news/2024/0912/main.jpg?w=800&h=600">注意看第三个,src后面可以在等号两边出现空格,第四个属性名和标签名可以是大写,第五个图片地址不一定是干干净净的.jpg结尾,它可能带着 query 参数。这些细节直接决定了你写正则时到底要怎么设计字符范围。
很多人一上来就写src="(.*)",然后发现匹配出来的内容乱七八糟,就是因为没先观察 HTML 的真实形态。我建议拿到页面后,先右键查看源代码,把图片相关片段复制出来,放在编辑器里看几遍,再动笔写正则。这个习惯帮我避开了后面绝大多数坑。
2. 正则表达式的底层直觉:字符、量词与贪婪模式
2.1 正则里的"标点符号"长得像,意思却完全不同
"正则表达式代表标点符号是什么"这个问题,我经常在讨论里看到。很多新手把正则里的.*+?[]{}这些符号直接当成文本里的标点理解,结果匹配老出错。实际上这些符号在正则里全是"带刀侍卫",不再是普通标点。
| 符号 | 在普通文本里的意思 | 正则表达式里的意思 |
|---|---|---|
. | 句号 | 匹配任意单个字符(换行除外) |
* | 星号 | 前一个字符出现 0 次或多次 |
+ | 加号 | 前一个字符出现 1 次或多次 |
? | 问号 | 前一个字符出现 0 次或 1 次;跟在量词后面表示懒惰匹配 |
[] | 方括号 | 字符集合,匹配其中任意一个字符 |
{} | 花括号 | 前面的字符重复指定次数 |
() | 圆括号 | 分组,把一段表达式当成整体 |
| | 竖线 | 或的关系 |
^ | 脱字符 | 匹配字符串开头;在方括号内表示"排除" |
$ | 美元符 | 匹配字符串结尾 |
\ | 反斜杠 | 转义符号,或代表预定义字符类(如\d数字、\w单词字符) |
拿图片提取最常用的几个来说。\d是数字,在匹配年份2024的时候可以直接写\d{4};\w是字母、数字、下划线,匹配school_photo这种文件名很合适;[^"]表示"除了双引号以外的任意字符",这在提取 src 属性时是核心武器。
真正坑的是点号。你在 HTML 里看到src="a.jpg",心里想"我写个点号去匹配那个点号",但正则里的.能匹配任意字符,于是a.jpg匹配出来的可能是axjpg、a/jpg。所以当你要匹配真正的点号时,必须写成\.才行。这就是"正则里的标点符号和你想的不一样"的最典型例子。
2.2 贪婪与懒惰:图片提取翻车的头号原因
如果说标点符号的坑是"理解错",那贪婪匹配就是"工具错"。正则里的量词*和+默认是贪婪的,意思是它们会尽可能多地匹配内容,直到再也匹配不下去为止。
举个例子,我当年第一次写图片提取就栽在这上面。有一行 HTML 是这样的:
<img src="a.jpg"><img src="b.jpg">我用<img src="(.*)">去匹配,结果findall只返回了一个结果,内容是a.jpg"><img src="b.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(r'<img[^>]+>', 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 = r'<img[^>]+?src=["\']([^"\']+)["\']' img_srcs = re.findall(pattern, html, re.I)拆开解释。<img[^>]+?从标签开头匹配,[^>]+?懒惰地匹配到 src 出现之前的内容,这样即使 img 标签里还有 id、class、width 之类的属性也不影响。然后src=描述的是属性名本身。紧接着的["\']表示这里可能是双引号也可能是单引号,两种情况都接收。([^"\']+)是捕获组,把引号之间的内容抓出来,也就是图片地址。最后的["\']负责让匹配在闭引号处收尾。
捕获组的圆括号是关键。re.findall只要遇到捕获组,就只会返回捕获组里的内容,而不是整个匹配结果。所以你拿到的直接就是干净的图片 URL 列表,不需要再截取子串。
3.3 第三层:处理属性乱序、大小写和不加引号
学校官网的 HTML 经常不按套路出牌。最常见的问题是 src 不在第一个属性位置,比如<img width="300" height="200" src="/upload/a.jpg">。前面那个<img[^>]+?src=里的+?懒惰匹配这时就发挥作用了,它不会因为中间隔了别的属性就失败,会一直往后找,直到碰到 src。
更难搞的是 alt 属性出现在 src 后面时,一行式的正则容易把 alt 的内容也吞进去。这种情况我的建议是回到分层写法,把整个标签拿出来,用两次re.search分别提取:
img_tags = re.findall(r'<img[^>]+>', html, re.I) for tag in img_tags: src = re.search(r'src\s*=\s*["\']([^"\']+)["\']', tag, re.I) alt = re.search(r'alt\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。不过我不建议一上来就用这种宽松写法,因为它会把width=300这种其他属性也误抓进来。先跑标准写法,确认没有结果,再考虑降级方案。
4. 完整可运行的 Python 脚本:从请求 HTML 到批量下载
4.1 编码处理:老官网最容易翻车的地方
正则写好了,还得先想办法拿到 HTML。很多人在这一步就翻车了,症状是页面请求成功,但正则匹配结果永远是空列表,代码看起来没问题,问题出在字符串本身已经乱码了。
学校官网的老站大量使用 GBK 或 GB2312 编码,而 requests 库在响应头没有明确 charset 时,默认会按 ISO-8859-1 解码,结果就是一堆乱码。乱码里的<img标签可能还在,但属性名、引号这些半角符号一般不受影响,可一旦 src 里的中文文件名乱码,匹配出来的内容就没法用了。
我当时的处理方式是先拿二进制内容,再让页面自己告诉我它是什么编码:
import requests resp = requests.get(url, headers=HEADERS, timeout=15) # 优先看页面 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",程序员手滑把反斜杠当转义符写进去了;src="data: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.jpg,urljoin知道先回到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(r'<img[^>]+?src=["\']([^"\']+)["\']', re.I) def fetch_html(page_url): resp = requests.get(page_url, headers=HEADERS, timeout=15) 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, headers=HEADERS, timeout=15) 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", required=True, help="页面 URL") parser.add_argument("-o", "--output", default="./imgs", help="保存目录") parser.add_argument("-d", "--delay", type=float, default=0.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_ok=True) 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(r'url\(\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", default=r'<img[^>]+?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(r'<img[^>]+>', html, re.I) with open("images.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["src", "alt", "title"]) for tag in img_tags: src = re.search(r'src\s*=\s*["\']([^"\']+)["\']', tag, re.I) alt = re.search(r'alt\s*=\s*["\']([^"\']*)["\']', tag, re.I) title = re.search(r'title\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?w=800然后逐段问自己:协议部分可能出现什么(https?://);域名部分用什么符号分隔([a-z0-9.-]+);目录路径里可能有哪些字符([a-zA-Z0-9/_]+);文件名是什么([^/]+?);扩展名有哪几种(\.(?:jpg|jpeg|png|gif|webp));参数部分要不要匹配((?:\?.*)?)。
这种"先画结构再写规则"的方式,本质上就是给正则画一张匹配逻辑示意图。养成习惯之后,你再看到别人写的复杂正则,不是从头到尾硬读,而是先在脑子里还原它背后的结构图,解析速度会快很多。
那次官网改版的素材整理,我用这套脚本跑了两个晚上,把几百张历史照片归档得整整齐齐。后来学院老师又让我帮忙整理官网通知公告里的配图,我直接把脚本参数一换就跑完了。正则表达式的学习曲线确实陡,但一旦你跨过"标点符号陷阱"和"贪婪匹配"这两道坎,它就会变成那种你再也离不开的趁手工具。下次再遇到类似需求,你可以直接从第 4 节的脚本改起,剩下的坑,我基本都替你踩过了。