Python爬虫实战:从列表页到详情页批量下载图片的完整方案

发布时间:2026/9/23 23:24:36
Python爬虫实战:从列表页到详情页批量下载图片的完整方案
前阵子接了个私活对方给了一个图片站点说“帮我把整站的套图抓下来”。我一看结构就是个典型的分页列表站图片直链藏在详情页里没有登录墙也没有太复杂的加密用 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.futuresPython 标准库用来做多线程下载加速。有人会问现在不是流行 Scrapy 吗还有 Playwright 这种能渲染 JS 的框架为什么不用原因很简单这个站的页面是服务端渲染的HTML 里直接就是完整的图片链接不需要执行 JavaScript。既然 requests 就能拿到完整数据就没必要上重型框架那只会增加学习成本和调试成本。同样解析 HTML 我也没有用正则表达式而是用了 BeautifulSoup。虽然正则写着快但可读性太差遇到标签结构稍微一变就不 work 了。BeautifulSoup 的select方法可以用类似 CSS 选择器的方式定位元素比如div.pic-list img看一眼就明白在找什么。对于这个项目来说够用而且好维护。工具链上代码编辑器我用的是 VS CodePython 环境是 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\activateLinux / 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文件里然后一键安装requests2.31.0 beautifulsoup44.12.2 lxml4.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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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, headersget_headers(), timeout10) 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_linksresp.encoding resp.apparent_encoding这一行是处理中文乱码的关键。很多站点没有在响应头里声明正确的字符集或者声明的是ISO-8859-1如果你直接用响应头里的编码去解码页面上所有中文都会变成乱码后续匹配标题、创建文件名都会出错。apparent_encoding是 requests 根据响应内容自动推断出的编码通常更接近真实情况。select(a.album-title)是 BeautifulSoup 的 CSS 选择器用法意思是找出所有classalbum-title的a标签。具体选择器要写什么取决于你实际抓取的站点结构这一步必须在动手前先用浏览器开发者工具F12确认好。用 requests 抓取页面时有个小建议把timeout参数始终显式地传上比如timeout10。如果不设超时程序可能因为网络问题一直卡住既不报错也不往下走非常难受。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, headersget_headers(), timeout10) 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, timeout15): 下载单张图片返回是否下载成功 # 已存在且大小不为 0跳过 if os.path.exists(save_path) and os.path.getsize(save_path) 0: return skipped try: resp requests.get(url, headersget_headers(), streamTrue, timeouttimeout) 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_size1024 * 512): if chunk: f.write(chunk) os.replace(temp_path, save_path) return success except Exception as e: print(f[下载失败] {url} - {e}) return failed这段代码里藏了三个小技巧。第一个技巧是streamTrue加iter_content分块写入。如果你不用流式下载直接resp.content图片会一次性加载进内存。图片小还好遇到几十 MB 的大图内存占用会瞬间飙高下载一批图下来机器就会变得很卡。分块写入的好处是无论文件多大内存占用都是固定的。第二个技巧是校验Content-Type。真实的图片 URL 返回的响应头里一定包含image/jpeg、image/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_workers8): os.makedirs(save_dir, exist_okTrue) with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {} for idx, url in enumerate(image_urls, start1): 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_workers8是我实测下来比较稳妥的并发数。并不是越大越好线程开太多会让本机的网络连接数暴涨反而触发目标服务器的限流。如果你发现下载频繁失败可以先降到 4 试试。executor.submit把任务提交给线程池as_completed会按任务完成的顺序返回结果这样你不用等全部任务结束就能实时看到进度。文件名统一用001.jpg、002.jpg这样的数字序号保证在资源管理器里排序不会乱。原来的 URL 里如果带了?查询参数直接os.path.splitext取扩展名会取到一堆乱七八糟的东西所以我在获取扩展名时先split(?)[0]把查询参数去掉这样拿到的是干净的.jpg、.png。4. 实战中踩过的坑与排查技巧4.1 403 Forbidden 与 UA 伪装我第一次跑这个脚本做到批量下载那一步前 50 张图都正常结果从第 51 张开始全部返回 403。一开始我以为是自己访问太频繁被拉黑了第一反应是加time.sleep降速。但加了睡眠之后还是偶发出现 403这就说明问题不是频率限制而是请求特征被识别了。排查过程很简单我用浏览器直接打开那张 403 的图片链接发现浏览器能正常打开。这就说明链接本身没问题是脚本的请求被服务端拒绝了。对比浏览器和脚本的请求头最明显的差异就是User-Agent和Referer。浏览器会自动带上完整的 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 连接挂着但一直没有数据返回。如果像最早那样不设置timeoutrequests 会一直等下去。解决方法是分两个维度设置超时一个是连接超时一个是读取超时。requests 的timeout参数可以传一个元组比如timeout(3.05, 15)第一个值是连接服务器的最长等待时间第二个值是从服务器读取数据的最长间隔时间。如果 3 秒连不上服务器或者连续 15 秒没有任何数据返回请求就会直接抛异常。我在代码里统一用了timeout15这是一个单一数值它同时作为连接超时和读取超时。单个值在一次实操中够用但你如果想让脚本更健壮建议改成元组形式resp requests.get( url, headersget_headers(), streamTrue, 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 这门语言的掌握绝对会上一个台阶。最后再分享一个我自己实际使用的小技巧爬虫脚本写完之后不要直接全量跑。先只抓取一个列表页、一个详情页下载两三张图确认整个链路没问题再放开全量执行。这样调试成本最小也能避免“跑了两千张图才发现逻辑错了”的灾难。