☰
Python图片爬取入门:requests+BeautifulSoup批量下载实战
2026/10/16 1:28:31 网站建设 项目流程

1. 从"手动右键另存为"到批量抓取:图片采集的真实痛点

如果你曾经为了收集一批参考图、素材图或者产品图,在浏览器里一张张右键"图片另存为",那你一定知道那种感觉有多崩溃。一个页面几十张图,十个页面就是几百张,鼠标点到手抽筋,文件夹里还全是乱七八糟的默认文件名。更别提有些页面用了懒加载,你不滚动到底部,图片根本不出来,右键保存下来的可能是个占位图。

这就是图片爬取要解决的核心问题:把重复、机械的采集动作交给代码,让程序自动完成"请求页面→解析结构→提取图片地址→下载保存"这一整条链路。而requests加BeautifulSoup这套组合,恰恰是入门图片爬取最经典、最容易上手的方案。requests负责把网页的 HTML 源码拿回来,BeautifulSoup负责从这堆 HTML 里把图片链接精准地挑出来,两者分工明确,学习曲线平缓。

这篇文章适合谁看?如果你已经会一点 Python 基础语法,知道什么是列表、字典、循环和函数,但还没真正写过一个能跑起来的爬虫,那这篇就是为你准备的。我会把每一步为什么这么做讲清楚,包括那些教程里通常不写、但你一上手就会踩的坑,比如相对路径怎么补全、防盗链怎么处理、文件名怎么去重、请求头为什么必须加。整套流程我会拆到你能直接照着复现的程度。

需要先说明一点:图片爬取涉及对目标网站服务器的访问,请务必遵守目标网站的robots.txt规则和相关服务条款,控制请求频率,不要对服务器造成压力,采集到的内容也仅用于个人学习或已获授权的用途。这是做任何采集工作的基本前提,后面我会在具体环节里反复强调。

2. 动手前的环境搭建与依赖选择逻辑

2.1 为什么是 requests + BeautifulSoup 而不是别的

Python 里能发请求的库不少,urllib是标准库自带,httpx支持异步,scrapy是一整套爬虫框架。那为什么入门图片采集我依然推荐requests+BeautifulSoup?

原因很实际。urllib用起来太啰嗦,光是发一个带请求头的 GET 请求就要写好几行,处理编码还容易出问题。scrapy功能强大,但它有自己的工程结构、调度器、中间件概念,对新手来说学习成本太高,你只是想下几张图,没必要上这么重的框架。httpx的异步能力确实好,但异步代码的调试门槛对初学者不友好,而且图片下载这种 IO 密集型任务,用同步方式配合适当的并发也能跑得不错。

requests的 API 设计极其人性化,requests.get(url, headers=...)一行就能拿到响应,.text拿文本、.content拿二进制、.status_code看状态码,直观得不行。BeautifulSoup则是 HTML 解析里容错性最好的库之一,哪怕目标页面的 HTML 写得乱七八糟、标签没闭合,它也能尽量帮你把结构理出来。这两个库配合,正好覆盖了图片爬取的两个核心动作:取回页面和解析页面。

2.2 安装与版本确认

安装本身没什么难度,但有个细节值得说。BeautifulSoup本身只是一个解析接口,它需要搭配一个具体的解析器才能工作。常用的解析器有html.parser(Python 内置)、lxml(速度快、容错好)、html5lib(最贴近浏览器解析行为但慢)。我一般推荐装lxml,因为它在处理不规范 HTML 时表现稳定,速度也快。

pip install requests pip install beautifulsoup4 pip install lxml

装完之后建议确认一下版本,避免因为版本过老导致某些方法不存在:

import requests import bs4 print(requests.__version__) print(bs4.__version__)

提示:如果你在多个项目间切换,强烈建议用虚拟环境(venv或conda)隔离依赖,避免不同项目对库版本的要求互相打架。这个习惯越早养成越好。

2.3 请求头里到底该放什么

很多新手第一次写爬虫,代码逻辑明明没问题,但拿回来的 HTML 是空的,或者直接返回 403。十有八九是没加请求头。服务器会检查请求里的User-Agent,如果发现是 Python 默认的python-requests/x.x.x,很多站点会直接拒绝。

一个够用的请求头至少包含User-Agent,稳妥一点再加上Referer(有些站点会校验来源页):

HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/" }

这里的User-Agent是模拟一个常见浏览器的标识。注意,这不是为了伪装做坏事,而是因为很多正常网站对非浏览器请求本身就有限制,加上它是为了让请求行为更接近普通访问。Referer的作用后面讲防盗链时会再展开。

3. 页面结构分析:图片到底藏在哪

3.1 先看 HTML,再写代码

写爬虫最忌讳的就是上来就敲代码。正确的顺序是:先用浏览器打开目标页面,按 F12 打开开发者工具,找到图片对应的 HTML 标签,看清楚它的结构,再决定用什么选择器去提取。

图片在 HTML 里通常长这样:

<img src="https://example.com/images/photo01.jpg" alt="示例图片" class="gallery-item">

关键信息在src属性里,这就是图片的真实地址。但现实往往没这么简单,你会遇到几种变体:

  • 懒加载图片:src里放的是占位图,真实地址藏在># find_all 写法 soup.find_all("img", class_="gallery-item") # select 写法 soup.select("img.gallery-item")

    如果结构更复杂,比如要选某个容器下的所有图片,CSS 选择器的优势就出来了:

    # 选 id 为 main 的容器内所有 img soup.select("#main img") # 选 class 为 content 的元素下、直接子级的 img soup.select(".content > img")

    select返回的是一个列表,里面是匹配到的Tag对象。拿到Tag之后,用tag.get("src")或者tag["src"]就能取出属性值。我习惯用.get(),因为当属性不存在时它返回None而不是抛异常,处理起来更安全。

    3.3 处理懒加载属性的通用思路

    针对懒加载,我的做法是写一个"候选属性列表",按优先级依次尝试,取到第一个非空值就用它:

    def extract_img_url(tag): for attr in ("src", "data-src", "data-original", "data-lazy-src"): url = tag.get(attr) if url: return url return None

    这样不管页面用的是哪种懒加载方案,都能兜住。实测下来,覆盖市面上绝大多数图库和电商页面都没问题。如果遇到srcset,可以再单独处理,把srcset按逗号切分,取第一个地址即可。

    4. 从相对路径到绝对路径:链接补全的坑

    4.1 为什么拿到的地址下载不了

    这是新手最容易卡住的地方。你从src里取出来的地址,很可能长这样:

    /images/photo01.jpg ../uploads/2024/pic.png //cdn.example.com/img/a.jpg

    这些都不是完整的 URL。第一种是根路径相对地址,第二种是相对当前页面的路径,第三种是协议相对地址(省略了http:或https:)。如果你直接拿这些字符串去requests.get(),程序会报错,因为它不知道域名是什么。

    4.2 用 urljoin 一次性解决

    Python 标准库urllib.parse里的urljoin就是专门干这个的。它的逻辑是:给一个基础 URL(通常是当前页面的地址),再给一个相对地址,它自动帮你拼成绝对地址。

    from urllib.parse import urljoin base = "https://example.com/gallery/list.html" relative = "../uploads/2024/pic.png" absolute = urljoin(base, relative) print(absolute) # https://example.com/uploads/2024/pic.png

    注意urljoin的第二个参数如果是完整的绝对地址,它会原样返回,所以你可以无脑对所有提取到的地址都过一遍urljoin,不用先判断是不是相对路径。这个技巧能省掉一大堆 if-else。

    4.3 协议相对地址的特殊处理

    //cdn.example.com/img/a.jpg这种地址,urljoin也能处理,它会继承基础 URL 的协议。但有个前提,你的基础 URL 本身得是完整的、带协议的。所以我在代码里会确保base_url一定是https://或http://开头的完整地址,而不是只写个域名。

    注意:如果页面本身是通过某种跳转才到达的,requests返回的response.url才是最终地址,用它作为urljoin的基础比用你手写的 URL 更准确。养成用response.url的习惯。

    5. 下载与保存:文件名、去重与并发

    5.1 文件名怎么取才不乱

    图片地址的最后一截通常就是文件名,比如https://example.com/img/photo01.jpg里的photo01.jpg。用os.path.basename(urlparse(url).path)就能取出来。但直接用它有几个问题:不同目录下可能有同名文件,会互相覆盖;有些地址带查询参数,文件名里会混进?这种非法字符。

    我的处理方式是:用 URL 的哈希值作为文件名主体,保留原始扩展名。这样既保证唯一性,又保留了文件类型信息。

    import hashlib from urllib.parse import urlparse import os def build_filename(url): path = urlparse(url).path ext = os.path.splitext(path)[1] or ".jpg" name = hashlib.md5(url.encode("utf-8")).hexdigest() return name + ext

    如果你希望文件名可读性更好,也可以保留原始文件名,但在前面加上序号或哈希前缀来避免冲突。两种方案各有取舍,哈希方案胜在绝对不重复,序号方案胜在直观。

    5.2 判断图片是否真的下载成功

    requests.get()返回 200 不代表拿到的就是图片。有些站点对图片请求会返回一个 HTML 错误页,状态码却是 200。所以下载后要校验两点:一是Content-Type是否以image/开头,二是内容长度是否合理(太小的可能是占位图)。

    resp = requests.get(img_url, headers=HEADERS, timeout=10) content_type = resp.headers.get("Content-Type", "") if resp.status_code == 200 and content_type.startswith("image/"): with open(filepath, "wb") as f: f.write(resp.content)

    用resp.content而不是resp.text,因为图片是二进制数据,用.text会经过编码转换,写出来的文件会损坏。这个细节很多人第一次都会搞错。

    5.3 控制请求频率与超时

    不加控制的爬虫会给服务器带来压力,也容易触发站点的访问限制。我的习惯是在每次请求之间加一个短暂的等待,用time.sleep()实现。等待时间不用太长,0.5 到 1 秒就够,既不影响效率,也显得"礼貌"。

    import time time.sleep(0.5)

    同时,requests.get()一定要加timeout参数。不加的话,遇到响应慢的服务器,程序可能一直卡在那里。设成 10 秒是个比较稳妥的值,超时后捕获异常跳过即可,不要让整个程序崩掉。

    5.4 要不要上并发

    同步下载几百张图确实慢,因为大部分时间都花在等待网络响应上。如果你要下载的量比较大,可以用concurrent.futures里的线程池来并发下载。图片下载是 IO 密集型任务,多线程能明显提速。

    from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=5) as executor: executor.map(download_one, img_urls)

    但并发数不要开太大,5 到 10 个线程比较合适。开太多一方面会给目标服务器造成压力,另一方面也容易触发限制。而且并发下载时,写文件的目录要提前创建好,避免多个线程同时创建目录出错。

    6. 那些教程不写但一定会踩的坑

    6.1 防盗链:为什么图片下载下来是坏的

    有些站点的图片服务器会校验Referer头,如果请求里没有正确的来源页,它会返回一张"禁止盗链"的提示图,或者直接返回 403。解决办法就是在下载图片的请求里也带上Referer,值设成图片所在页面的地址。

    img_headers = { "User-Agent": HEADERS["User-Agent"], "Referer": page_url } resp = requests.get(img_url, headers=img_headers, timeout=10)

    这个坑的典型表现是:你能拿到图片文件,但打开一看全是"该图片仅供XX网站使用"的字样。遇到这种情况,先检查Referer。

    6.2 编码问题导致的中文乱码

    requests会根据响应头猜测编码,但猜错的情况不少见,尤其是中文网站。如果页面标题或文件名出现乱码,可以手动指定编码:

    resp.encoding = resp.apparent_encoding

    apparent_encoding会用字符检测算法猜一个更靠谱的编码。不过对于图片下载来说,我们用的是.content,不涉及编码问题,这个坑主要影响的是解析 HTML 文本时。

    6.3 动态加载的图片抓不到

    如果你发现requests拿回来的 HTML 里根本没有图片标签,那说明图片是 JavaScript 动态渲染出来的。requests只能拿到服务器返回的原始 HTML,不会执行 JS。这种情况有两个方向:一是找到页面背后真正返回图片数据的接口(通常在开发者工具的 Network 面板里能看到),直接请求那个接口;二是用能执行 JS 的工具。前者更轻量,优先考虑。

    6.4 分页与翻页逻辑

    单页图片抓完了,往往还要抓下一页。分页的 URL 规律需要你自己观察,常见的有?page=2、/page/2/这类。写个循环,把页码拼进 URL,直到某一页没有图片为止。判断"没有图片"可以用"提取到的图片列表为空"作为终止条件,比硬编码页数更健壮。

    7. 一套可直接复现的完整实现

    把前面所有环节串起来,下面是一个结构清晰的完整脚本。我把它拆成了几个函数,每个函数只干一件事,方便你按需修改。

    import os import time import hashlib import requests from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" } SAVE_DIR = "downloaded_images" def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding return resp def parse_img_urls(html, base_url): soup = BeautifulSoup(html, "lxml") urls = [] for tag in soup.select("img"): raw = None for attr in ("src", "data-src", "data-original", "data-lazy-src"): raw = tag.get(attr) if raw: break if raw: urls.append(urljoin(base_url, raw)) return urls def build_filename(url): path = urlparse(url).path ext = os.path.splitext(path)[1] or ".jpg" name = hashlib.md5(url.encode("utf-8")).hexdigest() return name + ext def download_image(url, referer): headers = dict(HEADERS) headers["Referer"] = referer try: resp = requests.get(url, headers=headers, timeout=10) except requests.RequestException as e: print("请求失败:", url, e) return ctype = resp.headers.get("Content-Type", "") if resp.status_code == 200 and ctype.startswith("image/"): filepath = os.path.join(SAVE_DIR, build_filename(url)) with open(filepath, "wb") as f: f.write(resp.content) print("已保存:", filepath) else: print("跳过非图片:", url, resp.status_code, ctype) def main(start_url, max_pages=3): os.makedirs(SAVE_DIR, exist_ok=True) for page in range(1, max_pages + 1): url = start_url.format(page=page) resp = fetch_page(url) img_urls = parse_img_urls(resp.text, resp.url) if not img_urls: print("第", page, "页没有图片,停止") break for img_url in img_urls: download_image(img_url, resp.url) time.sleep(0.5) if __name__ == "__main__": main("https://example.com/gallery?page={page}", max_pages=3)

    这段代码里,start_url用了{page}占位符,方便你套用不同的分页格式。max_pages控制最多翻几页。每个函数职责单一,fetch_page只管取页面,parse_img_urls只管解析,download_image只管下载,main负责串联。这种拆分方式的好处是,当某个环节出问题时,你能快速定位是哪一步的锅。

    8. 让脚本更耐用的几个进阶思路

    8.1 断点续传与去重

    脚本跑到一半中断了,重新跑会重复下载已经下过的图。解决办法很简单:下载前先检查目标文件名是否已存在,存在就跳过。因为文件名是用 URL 哈希生成的,同一个 URL 必然对应同一个文件名,天然支持去重。

    filepath = os.path.join(SAVE_DIR, build_filename(url)) if os.path.exists(filepath): print("已存在,跳过:", filepath) return

    这一行判断能帮你省下大量重复请求,尤其是在调试阶段反复运行脚本的时候。

    8.2 把失败记录到日志

    网络请求失败是常态,不要让它静默消失。把失败的 URL 写到一个文本文件里,方便事后重试。

    def log_failure(url): with open("failed.txt", "a", encoding="utf-8") as f: f.write(url + "\n")

    配合前面的异常捕获,每次请求失败就记一笔。跑完之后打开failed.txt,把里面的地址单独再跑一遍,往往能补回大部分漏网的图片。

    8.3 用会话保持连接

    如果要在同一个站点下载大量图片,用requests.Session()会比每次新建连接更高效,因为它会复用底层的 TCP 连接,减少握手开销。

    session = requests.Session() session.headers.update(HEADERS)

    然后把所有requests.get换成session.get。对于几十张图的小任务,差别不明显;但如果是几百上千张,Session 带来的提速是实打实的。

    8.4 尊重 robots.txt

    在正式跑脚本之前,先访问目标站点的/robots.txt,看看它是否禁止了你要访问的路径。这是基本的网络礼仪,也是避免给自己惹麻烦的做法。如果站点明确禁止采集,那就换个目标,或者联系站点获取授权。技术能力是一回事,用得得体是另一回事。

    9. 我在实际使用中总结的几条经验

    写图片爬取脚本这些年,有几个体会想单独拎出来说。第一,永远先手动分析页面结构再写代码,花五分钟看 HTML,能省下半小时的调试。第二,请求头、超时、异常捕获这三样是标配,缺任何一个,脚本的健壮性都会大打折扣。第三,文件名用哈希是最省心的方案,虽然可读性差一点,但再也不用担心覆盖和非法字符的问题。

    还有一点关于并发:新手不要一上来就追求多线程。先用同步版本把整个流程跑通,确认解析逻辑、路径补全、下载保存都没问题,再考虑加并发提速。很多并发相关的 bug,本质上是同步逻辑本身就有问题,只是被并发放大了而已。

    最后,采集到的图片请务必注意版权和使用范围。用于个人学习、研究没问题,但如果是商业用途,一定要确认授权。技术是中性的,怎么用取决于人。把脚本写好的同时,也把使用的边界想清楚,这才是一个成熟从业者该有的样子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询