Python爬虫实战:从列表页到详情页批量下载图片的完整方案
2026/9/23 23:24:29 网站建设 项目流程

前阵子接了个私活,对方给了一个图片站点,说“帮我把整站的套图抓下来”。我一看结构,就是个典型的分页列表站,图片直链藏在详情页里,没有登录墙,也没有太复杂的加密,用 Python 写个脚本就能搞定。这种活儿本质上就是练手级的爬虫项目,但真做起来,坑也不少:反爬、编码、超时、重复下载,哪一步没处理好都会让脚本跑一半就崩。

这篇文章就把我这个“Python 实战——下载推女郎图片”项目的完整过程拆开讲一遍,从需求分析、技术选型,到代码实现、问题排查,再到合规边界,全程记录我会怎么一步步落地。不管你是在学 Python 爬虫,还是想拿一个真实场景练手,这篇都能给你一套可以直接抄作业的方案。

1. 项目思路与整体拆解

1.1 这个实战项目解决什么问题

先说清楚这项目要干什么。目标是一个叫“推女郎”的图片素材站(示例项目名,下面统称“目标站”),页面结构非常典型:首页有列表页,列表页展示一组组图卡片,点进去是详情页,详情页里才是完整的图片列表。图片本身是静态资源,直接可以用 HTTP 请求拉下来。

很多人以为爬虫就是“用代码访问网页、拿 HTML、找图片链接、下载”,其实真实的难点从来不在这四步本身,而在于这四步能不能稳定、高效、不重复地跑完几百上千个页面。我这次接的私活,目标大概有几十个列表页,每个列表页 20 条套图左右,每条套图里又有几十张图。要是脚本写得不健壮,跑到第 200 张图就崩了,或者下载了一堆重复图片,那交付的时候你光整理数据就能整理到崩溃。

所以我一开始就对需求做了拆分:

  • 能遍历所有列表页,拿到每个详情页的 URL。
  • 能进入详情页,解析出所有图片的原图链接。
  • 能批量下载图片,保存到以套图命名的文件夹里。
  • 能跳过已下载的图片,支持断点续传。
  • 能应对超时、连接失败、被限流等常见异常。

这个项目特别适合 Python 初学者和刚接触爬虫的人,是因为它涉及的技能点非常集中:HTTP 请求、HTML 解析、文件读写、多线程,全是最基础的 Python 能力。你把这个项目做完,爬虫的整个骨架基本就通了,后面遇到再复杂的站点,无非是在这个骨架上加东西。

1.2 技术选型:为什么是 requests + BeautifulSoup

先说我选型的结果,然后解释为什么这么选。

核心库就三个:

  • requests:负责发 HTTP 请求,拿 HTML 和图片二进制数据。
  • BeautifulSoup4(简称 bs4):负责解析 HTML,提取链接和图片地址。
  • concurrent.futures:Python 标准库,用来做多线程下载加速。

有人会问,现在不是流行 Scrapy 吗?还有 Playwright 这种能渲染 JS 的框架,为什么不用?原因很简单:这个站的页面是服务端渲染的,HTML 里直接就是完整的图片链接,不需要执行 JavaScript。既然 requests 就能拿到完整数据,就没必要上重型框架,那只会增加学习成本和调试成本。

同样,解析 HTML 我也没有用正则表达式,而是用了 BeautifulSoup。虽然正则写着快,但可读性太差,遇到标签结构稍微一变就不 work 了。BeautifulSoup 的select方法可以用类似 CSS 选择器的方式定位元素,比如div.pic-list img,看一眼就明白在找什么。对于这个项目来说,够用,而且好维护。

工具链上,代码编辑器我用的是 VS Code,Python 环境是 3.10 的虚拟环境。这里多说一句:千万不要把依赖直接装到系统全局 Python 里,后面项目多了,版本冲突能让你怀疑人生。

1.3 项目目录与代码结构设计

写爬虫脚本之前,我习惯先把目录结构想清楚。很多人写着写着就把所有代码堆在一个文件里,最后改了这里坏了那里。我这次的项目目录是这样的:

spider_downloader/ ├── main.py # 主入口,负责调度整个下载流程 ├── config.py # 存放 URL、请求头、下载路径等配置 ├── parser.py # 解析列表页和详情页的 HTML ├── downloader.py # 图片下载逻辑,包含重试和去重 ├── images/ # 下载的图片存放目录 │ ├── 套图标题_01/ │ │ ├── 001.jpg │ │ └── 002.jpg │ └── 套图标题_02/ └── logs/ └── download.log # 日志文件,记录下载成功与失败的情况

把代码拆成模块,最大的好处是出了问题知道去哪修。比如图片下载失败,你只需要打开downloader.py,不需要在 500 行的main.py里翻找。这也是一种“面向维护编程”的思路,尤其是爬虫这种要反复调试的项目,分区清晰能省下大量时间。

2. 环境准备与依赖安装

2.1 创建独立项目环境

这一步是基本功,但很多新手会忽略。我见过太多人直接在全局环境里pip install requests,然后用着用着提示 “Cannot be resolved against python helper roots”,或者某个库版本冲突导致代码跑不起来。

正确的姿势是给每个项目建一个独立的虚拟环境。具体操作我以 Windows 和 Linux 两个平台分别说。

Windows 下,打开终端(PowerShell 或 CMD),进入项目目录:

cd spider_downloader python -m venv venv venv\Scripts\activate

Linux / macOS 下:

cd spider_downloader python3 -m venv venv source venv/bin/activate

激活之后,你的命令行前面会多出一个(venv)前缀,这就说明已经进入独立环境了。在这之后安装的所有 Python 包,都只会存在于这个项目的venv文件夹里,不会污染系统全局,也不怕影响其他项目。

如果你连 Python 都还没装好,先去官方站点下载对应系统的安装包。安装的时候有个容易被忽略的细节:Windows 下务必勾选 “Add Python to PATH”,否则后面在终端里执行python命令会直接报错。装好之后,在终端里跑一下python --version,能看到版本号就说明环境没问题。

2.2 安装核心依赖库

虚拟环境激活后,用 pip 安装依赖。我把所有依赖写进一个requirements.txt文件里,然后一键安装:

requests==2.31.0 beautifulsoup4==4.12.2 lxml==4.9.3

保存后,执行:

pip install -r requirements.txt

这里有几个细节要提一下。

为什么单独装lxml?因为 BeautifulSoup 默认的解析器是 Python 内置的html.parser,它也能用,但对复杂 HTML 的容错能力比较差,速度也慢。lxml是 C 语言写的解析器,速度快、容错强,是 BeautifulSoup 的最佳搭档。安装的时候指定pip install lxml即可。

另外,如果你的网络环境访问 PyPI 官方源很慢,可以临时切换国内镜像源安装,比如清华源:

pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

这个操作不会改到全局配置,只是这一次安装使用镜像源,挺适合在配置不固定的环境下用。

2.3 准备请求伪装工具

爬虫的本质是模拟浏览器访问,所以我们必须让服务器觉得“这是一个正常的浏览器在访问”,而不是脚本。最基础的手段就是设置User-Agent(简称 UA)和常用的请求头。

我在config.py里维护了一个请求头字典:

import random USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", ] def get_headers(): return { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", "Referer": "https://example.com/", }

为什么要随机切换 UA?因为如果你整个爬虫从头到尾只用同一个 UA,访问频率一上去,服务器很容易识别出这是非浏览器的访问行为,直接给你返回 403。随机切换多个主流浏览器的 UA,虽然不能完全绕过反爬,但至少能降低被识别的概率。

Referer也要带上。有些图片服务器会校验 Referer,如果不是从允许的页面跳转过来的,就拒绝返回图片内容。这个我在后面踩坑部分还会细讲。

3. 核心代码实现:请求、解析、下载全流程

3.1 第一步:获取列表页并提取详情页链接

整个爬虫的入口是列表页。目标站的列表页 URL 有一定的分页规律,一般是第一页不带页码,后面带上/page/N这样的路径。我以第二页以后的规则为例:

import requests from bs4 import BeautifulSoup from config import get_headers def get_detail_urls(page_url): """解析列表页,提取所有详情页的 URL""" resp = requests.get(page_url, headers=get_headers(), timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") detail_links = [] for a_tag in soup.select("a.album-title"): href = a_tag.get("href") if href: detail_links.append(href) return detail_links

resp.encoding = resp.apparent_encoding这一行是处理中文乱码的关键。很多站点没有在响应头里声明正确的字符集,或者声明的是ISO-8859-1,如果你直接用响应头里的编码去解码,页面上所有中文都会变成乱码,后续匹配标题、创建文件名都会出错。apparent_encoding是 requests 根据响应内容自动推断出的编码,通常更接近真实情况。

select("a.album-title")是 BeautifulSoup 的 CSS 选择器用法,意思是找出所有class="album-title"<a>标签。具体选择器要写什么,取决于你实际抓取的站点结构,这一步必须在动手前先用浏览器开发者工具(F12)确认好。

用 requests 抓取页面时有个小建议:把timeout参数始终显式地传上,比如timeout=10。如果不设超时,程序可能因为网络问题一直卡住,既不报错也不往下走,非常难受。

3.2 第二步:解析详情页,提取图片原图链接

拿到详情页 URL 之后,需要再次发请求,从详情页 HTML 里提取出所有图片的原图地址。这里我提炼出一个“解析函数”,方便复用:

from bs4 import BeautifulSoup from config import get_headers import requests def extract_image_urls(detail_url): """解析详情页,提取所有原图 URL""" resp = requests.get(detail_url, headers=get_headers(), timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") img_tags = soup.select("div.article-content img") urls = [] for img in img_tags: # 优先取>import os import requests from config import get_headers def download_image(url, save_path, timeout=15): """下载单张图片,返回是否下载成功""" # 已存在且大小不为 0,跳过 if os.path.exists(save_path) and os.path.getsize(save_path) > 0: return "skipped" try: resp = requests.get(url, headers=get_headers(), stream=True, timeout=timeout) resp.raise_for_status() # 判断内容类型,避免把反爬返回的 HTML 存成 .jpg content_type = resp.headers.get("Content-Type", "") if "image" not in content_type: return "failed" temp_path = save_path + ".tmp" with open(temp_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 512): if chunk: f.write(chunk) os.replace(temp_path, save_path) return "success" except Exception as e: print(f"[下载失败] {url} -> {e}") return "failed"

这段代码里藏了三个小技巧。

第一个技巧是stream=Trueiter_content分块写入。如果你不用流式下载,直接resp.content,图片会一次性加载进内存。图片小还好,遇到几十 MB 的大图,内存占用会瞬间飙高,下载一批图下来机器就会变得很卡。分块写入的好处是无论文件多大,内存占用都是固定的。

第二个技巧是校验Content-Type。真实的图片 URL 返回的响应头里一定包含image/jpegimage/png这类类型。但如果目标站加了反爬,它可能会返回一个 302 跳转,或者直接返回一段伪造的 HTML。如果不做校验,你会把一段 HTML 保存成.jpg文件,打不开,还占空间。

第三个技巧是先写临时文件再os.replace。如果在下载过程中程序崩溃,直接写入目标文件会留下一个不完整的半截图片。先写成.tmp,等全部下载完成再原子性地改名成最终文件,这样就能避免不完整文件污染最终结果。下次脚本重新运行,发现文件不存在或者大小为 0,就会重新下载。

3.4 多线程加速下载

单线程一张一张下载,遇到几百张图片的站点会非常慢。优化方式是多线程并发下载。Python 里最简单的多线程写法是concurrent.futures.ThreadPoolExecutor,不需要手动管理线程的创建和销毁:

from concurrent.futures import ThreadPoolExecutor, as_completed def batch_download(image_urls, save_dir, max_workers=8): os.makedirs(save_dir, exist_ok=True) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {} for idx, url in enumerate(image_urls, start=1): ext = os.path.splitext(url.split("?")[0])[1] if ext.lower() not in (".jpg", ".jpeg", ".png", ".webp", ".gif"): ext = ".jpg" save_path = os.path.join(save_dir, f"{idx:03d}{ext}") futures[executor.submit(download_image, url, save_path)] = url for future in as_completed(futures): result = future.result() if result == "success": print(f"[完成] {future.result()}") elif result == "skipped": print(f"[跳过] {future.result()}")

这里max_workers=8是我实测下来比较稳妥的并发数。并不是越大越好,线程开太多会让本机的网络连接数暴涨,反而触发目标服务器的限流。如果你发现下载频繁失败,可以先降到 4 试试。

executor.submit把任务提交给线程池,as_completed会按任务完成的顺序返回结果,这样你不用等全部任务结束就能实时看到进度。文件名统一用001.jpg002.jpg这样的数字序号,保证在资源管理器里排序不会乱。原来的 URL 里如果带了?查询参数,直接os.path.splitext取扩展名会取到一堆乱七八糟的东西,所以我在获取扩展名时先split("?")[0]把查询参数去掉,这样拿到的是干净的.jpg.png

4. 实战中踩过的坑与排查技巧

4.1 403 Forbidden 与 UA 伪装

我第一次跑这个脚本,做到批量下载那一步,前 50 张图都正常,结果从第 51 张开始,全部返回 403。一开始我以为是自己访问太频繁被拉黑了,第一反应是加time.sleep降速。但加了睡眠之后还是偶发出现 403,这就说明问题不是频率限制,而是请求特征被识别了。

排查过程很简单:我用浏览器直接打开那张 403 的图片链接,发现浏览器能正常打开。这就说明链接本身没问题,是脚本的请求被服务端拒绝了。对比浏览器和脚本的请求头,最明显的差异就是User-AgentReferer。浏览器会自动带上完整的 UA 和来源页地址,而脚本里如果只设置了 UA、没设置 Referer,图片服务器会判定为“跨站盗链”,拒绝返回图片。

解决方案就是我前面config.py里写的:请求头里加上Referer,把它设置成目标站的详情页 URL。加上之后,403 基本消失了。如果你加了还是偶尔出现,那就配合中等强度的随机延时,比如每下载一张图片随机停 0.5 到 1.5 秒。

4.2 动态渲染页面拿不到图片地址

我前文说过这个站是服务端渲染,所以 requests 就能搞定。但我需要明确地提醒你:如果哪天你遇到一个页面,用浏览器能打开、能正常看到图片,但用 requests 抓到的 HTML 里就是找不到图片链接,不要怀疑人生,这说明页面用了 JavaScript 动态渲染。

在这种情况下,你有两条路可以走。

第一条路是轻量方案:打开浏览器的开发者工具,切到 Network 面板,刷新页面,找出 XHR 或 Fetch 请求里返回图片数据 JSON 的接口,直接模拟请求这个接口,而不是请求 HTML 页面。很多前端动态站,数据其实是通过后台接口返回的,接口地址往往比页面地址好找,数据结构也更干净。我就是靠这个方法绕过了不少“看起来很难爬”的站。

第二条路是重量级方案:用 Playwright 或 Selenium 控制一个真实的浏览器内核去渲染页面,等页面加载完之后再提取 DOM。这个方案能处理所有动态渲染场景,代价是内存占用高、速度慢、部署复杂。对于本项目这种静态页面,完全没必要用。

我的原则是:能用接口拿到数据,就绝不硬啃渲染后的 DOM;能用 requests 解决,就绝不上浏览器自动化。

4.3 下载到一半卡死与超时设置

写这个脚本的时候,我踩过一个特别恶心的坑:下载大图时,脚本偶尔会卡住十几分钟毫无反应,既不报错也不结束。原因是图片服务器连接不稳定,TCP 连接挂着,但一直没有数据返回。如果像最早那样不设置timeout,requests 会一直等下去。

解决方法是分两个维度设置超时:一个是连接超时,一个是读取超时。requests 的timeout参数可以传一个元组,比如timeout=(3.05, 15),第一个值是连接服务器的最长等待时间,第二个值是从服务器读取数据的最长间隔时间。如果 3 秒连不上服务器,或者连续 15 秒没有任何数据返回,请求就会直接抛异常。

我在代码里统一用了timeout=15,这是一个单一数值,它同时作为连接超时和读取超时。单个值在一次实操中够用,但你如果想让脚本更健壮,建议改成元组形式:

resp = requests.get( url, headers=get_headers(), stream=True, timeout=(5, 20) )

这样即使遇到服务器一直保持连接却不返回数据的情况,也能在 20 秒内被强制拉回来,配合异常处理继续下载下一张图。

4.4 图片重复下载与断点续传

原始版本脚本没有去重逻辑,跑完第一遍之后,我检查输出目录,发现有不少图片是重复的。原因很简单:同一个详情页里,缩略图和原图可能是同一个链接;或者不同详情页之间存在交叉引用,同一张大图被多个页面引用了。

解决思路有两个层面。

第一个层面是“文件存在就不下载”。这就是我在download_image里加的最前面的判断:

if os.path.exists(save_path) and os.path.getsize(save_path) > 0: return "skipped"

这段代码结合原子性写入,天然支持断点续传:如果脚本跑到一半崩了,已经落盘的文件是完整的,下次重跑时直接跳过;只有那些还没下完的文件会被重新下载。

第二个层面是“内容级别的去重”。如果两张图片在不同 URL 下,却是同一份内容,靠文件名判断就无效了。更彻底的做法是计算文件的 MD5 或 SHA1 值,存到一份已下载哈希表里,每次下载完再校验哈希,重复内容的图片直接删除。不过对大部分场景而言,文件名去重已经能过滤掉绝大多数重复数据,哈希去重只是锦上添花。

5. 合规提醒与心得体会

5.1 爬虫的边界与规范

聊完技术,最后必须认真说几句合规的事。爬虫是把双刃剑,能力越大,责任越大。你写一个脚本去抓一个站点,至少要守住下面几条底线。

第一,尊重robots.txt。在写爬虫之前,先用https://目标域名/robots.txt看看目标站允许爬什么、禁止爬什么。虽然 robots 协议没有法律强制力,但它是一个站点对爬虫最基本的“行为规范”,是判断爬虫是否“善意”的重要依据。

第二,控制访问频率。我见过有人为了抓数据,把并发开到 50、100,直接把一个小站点打到宕机。这种操作不仅不道德,还可能构成破坏计算机信息系统,摊上官司。学会用随机的延时策略,做一个“慢而稳”的爬虫。

第三,只爬公开数据,不碰隐私。论坛用户的私信、未公开的联系方式,这些内容即使技术上能抓到,也不应该去碰。公序良俗和用户隐私的边界,不能因为“技术上行得通”就被突破。

第四,下载的数据不要用于商业用途。这次项目里,我只把图片用作本地备份和个人学习,没有对外传播,也没有用于任何盈利场景。如果你真的需要图片素材做商业项目,一定要去正规的无版权图库购买或者下载。

爬虫不是“技术好就能为所欲为”,而是要在法律的框架下,解决掉那些重复、机械的数据采集工作。抱着学习技术的心态写爬虫,和抱着侵犯他人权益的心态写爬虫,结局天差地别。

5.2 这次实战能延伸出哪些技能

做完这个项目,你会发现爬虫的基本功已经打通了。后面你可以按照同样的思路,去扩展更多能力。

  • 把“解析详情页”改成“解析 JSON 接口”,就能处理更多动态站点。
  • 把“多线程”换成“asyncio 协程”,能实现更大的并发吞吐量。
  • 把“保存到文件夹”改成“写入 SQLite 或 MySQL”,就能做数据检索和分析。
  • 把“图片下载”换成“文件下载”,就变成了一个通用的批量下载器。

当年我学爬虫,就是从一个类似的图片下载脚本开始的。那会儿什么都不懂,到处抄代码,抄完也跑不通。后来我静下心来,把 requests 怎么用、HTML 怎么解析、文件怎么读写一个个弄明白,再回头看那些“跑不通的代码”,原来都是因为缺少最基础的知识。所以如果你现在也是新手,不要急,先把这个项目的每一行代码看懂、自己打一遍,遇到问题用我前面的排查方法逐步定位。跑通之后,你对 Python 这门语言的掌握绝对会上一个台阶。

最后再分享一个我自己实际使用的小技巧:爬虫脚本写完之后,不要直接全量跑。先只抓取一个列表页、一个详情页,下载两三张图,确认整个链路没问题,再放开全量执行。这样调试成本最小,也能避免“跑了两千张图才发现逻辑错了”的灾难。

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

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

立即咨询