搞一个批量抓素材的小工具是很多人的刚需。做课程、做设计、做内容采集经常要攒一堆图片和音频一个网页一个网页地“右键另存为”手会断。这篇文章讲的就是怎么用 Python 写一个爬虫批量抓取网站上的图片和音频文件而且自动按照网页的翻页页码把每一页的内容存进各自的文件夹里。整个过程从头拆到尾包括思路、代码、封装的细节、会遇到哪些坑全给你捋一遍你照着做就能跑起来。先说清楚这东西适合谁。如果你正在学 Python刚弄明白 requests 和 BeautifulSoup 的用法想拿一个小项目练手那这篇正合适。如果你不是程序员只是接到一个活要从某个素材网站把图片和音频按页抓下来你也能用这份代码直接干活。前提是目标网站没有复杂的加密接口就是常规的分页列表页资源地址走 HTML 里的 img 标签和 audio 标签暴露出来。这类站占大多数所以这个方案覆盖日常八成以上的需求。1. 需求拆解与方案设计1.1 这个爬虫要解决的三个核心问题不带脑子直接开爬代码写到一半就会卡住。先把题目拆开看本质是三个问题。第一个是分页遍历。目标网站大概率是列表页一页二十条内容翻页之后 URL 有规律可循比如?page2或者?p2这种。代码要做的就是把第 1 页到第 N 页的列表页全部请求一遍。第二个是资源提取。列表页里嵌着图片预览图可能还有音频播放器。需要从 HTML 里把图片的真实地址和音频的真实地址抠出来用到的工具就是 BeautifulSoup 配合 CSS 选择器。第三个是按页码保存。下载下来的文件不能全部堆在一个目录里否则几百个文件混在一起根本没法用。最好是每页一个文件夹分级分类存放。围绕这三个核心点整个项目就不会跑偏。1.2 技术选型为什么是 requests BeautifulSoup做爬虫可选的方案挺多。最重型的方案是 Scrapy分布式、高性能、自带中间件但学习曲线也陡一个简单批量下载项目上 Scrapy 有点杀鸡用牛刀配置写半天最后发现光是对接 scrapy 的文件管道就要折腾一阵子。另一个常见方案是 Selenium 配合无头浏览器。它能处理 JavaScript 渲染的动态页面可以模拟点击翻页适用范围广但开销大、速度慢每翻一页都要开一个浏览器资源占用高而且对新手来说调试起来很烦动不动就元素定位失败。最后我选的是 requests 配合 BeautifulSoup。这是轻量组合不用装依赖到怀疑人生requests 发 HTTP 请求BeautifulSoup 解析 HTML 提取节点逻辑顺手报错也直观。这套组合对静态页面和部分动态页面都够用。如果目标网站是纯 JS 渲染、列表数据全部藏在前端脚本里也不慌后面我会单独讲怎么用抓包的方式拆出数据接口照样能用 requests 解决。1.3 目录结构按页码归档的底层逻辑下载的文件如果直接f.write(response.content)写在当前目录那完蛋了。图片音频混在一起名字还是各种随机字符串找起来要命。所以目录设计从一开始就要定好。我惯用的结构是这样的download/ │ ├── page_001/ │ ├── img_001.jpg │ ├── img_002.jpg │ ├── audio_001.mp3 │ └── ... ├── page_002/ │ ├── img_001.jpg │ └── ... └── ...这样做有非常实际的好处。第 2 页的img_001.jpg和 第 5 页的img_001.jpg互不干扰文件名不用为了全局唯一而加长串的哈希网页顺序保留得清清楚楚。你要跟客户对接、跟同事协作、按页审核素材这个结构都不用解释人家打开就能看懂。还有一点要提前定下来文件名的前缀。图片归图片音频归音频文件名用不同的前缀区分避免同一页里图片和音频恰好撞了序号。我通常用img_和audio_做区分。2. 核心细节解析与实操要点2.1 分页 URL 的规律识别与页码定位做分页遍历首先要搞懂目标网站的翻页规则。最省事的情况是查询参数直接递增形如https://example.com/list?page1 https://example.com/list?page2代码层面就是拼字符串for page in range(1, total_pages 1): url fhttps://example.com/list?page{page}但有一种比较隐蔽的情况网站的页码不是查询参数而是路径的一部分比如https://example.com/list/2那拼接的时候要放在路径里。爬之前先把第 1 页、第 2 页、第 3 页的 URL 都拉出来人工比对一眼一般规律几秒钟就看得出来。如果连总数都不知道就遍历到没有下一页为止。判断的依据通常是页面里有没有“下一页”这个按钮或者列表区域是不是空列表。代码里可以加一个终止条件比如某页解析出来的资源数量是 0程序就自动停下。不过别太依赖“数量为 0”这个判断因为有的网站列表区域会有固定的占位符。更稳妥的办法是检查“下一页”按钮的链接是否存在。用 BeautifulSoup 找页码区域的 a 标签如果 href 属性指向当前页或不存在就说明到头了。2.2 资源地址的提取选择器与属性拿到列表页 HTML 之后要精准地把图片和音频位置定位出来。图片通常长这样img srchttps://cdn.example.com/images/cover_001.jpg>audio srchttps://cdn.example.com/audio/sound_001.mp3/audio但有的站为了兼容多格式会在 audio 内部放 source 标签audio controls source srchttps://cdn.example.com/audio/sound_001.mp3 typeaudio/mpeg /audio这种时候 BeautifulSoup 选择audio标签直接取src取不到值要换成audio source。同样的兜底逻辑先查 source 子标签再查父级 audio 标签的 src 属性。还有一个极易踩的坑相对路径。HTML 里经常出现这种写法img src/uploads/images/001.jpg如果直接拿这个路径去下载服务端返回 404因为少了域名。用urllib.parse.urljoin补全from urllib.parse import urljoin img_url urljoin(page_url, img_src)这个方法会判断img_src是绝对路径还是相对路径自动拼成完整的 URL相当省心。2.3 文件名清洗与去重网页里的文件名一般都能直接用但也存在夹带参数的情况比如从 CDN 出来的地址带着签名信息https://cdn.example.com/images/001.jpg?signabctokenxyz如果直接拿001.jpg?signabctokenxyz当文件名存盘Windows 系统直接报错问号是非法字符。所以文件名一定要从 URL 里拆出来再用正则做清洗。我常用的梳理步骤就三步。第一步用os.path.basename拆出文件名部分。第二步用urllib.parse.unquote解码 URL 编码中文文件名能正常显示。第三步专门处理重名如果目标文件夹里已经存在同名文件就在文件名后面加时间戳或随机数。这一步走到位后面能省去一大堆手工整理的时间。2.4 文件夹归档结构设计上一节说了目录结构的大框架这里再补充细节。建文件夹用os.makedirs加上exist_okTrue这样重复跑脚本不会报错import os page_dir os.path.join(download, fpage_{page:03d}) os.makedirs(page_dir, exist_okTrue)页码格式化用:03d而不是直接传page数字因为page_1和page_10在文件管理器里排序会乱补全成page_001、page_010之后文件夹从小到大整齐排好谁看了都舒服。下载时也不是拿到一个文件就立刻写先把二进制内容接过来正常状态再落盘。文件写完之后顺手记一下文件大小小于某个阈值比如 1KB就删掉重试能挡掉大量空响应或者错误页伪装成图片的情况。3. 完整实操从零构建批量下载器3.1 环境准备与依赖安装动手之前先把环境准备好虽然看着啰嗦但很多人栽在这一步。建议用 Python 3.9 以上版本理论上 3.6 也能跑但别给自己找麻烦。需要装的核心依赖就三个requests、beautifulsoup4、lxml。可能有人疑惑为什么不用html.parser这玩意不香。实测下来lxml的容错性更好面对写得不那么规范的前端页面也能正常解析。pip install requests beautifulsoup4 lxml如果需要处理接口抓包场景后续还要加json不过标准库自带。如果页面做了懒加载对图片地址提取有影响建议再装一个lxml就够用不需要额外插件。3.2 第一步封装请求函数爬虫的第一道门槛是请求能不能通这个函数是整个项目的地基。要处理的场景很多网站可能对默认的 Python UA 不友好直接返回 403网络抖动可能让请求超时服务器波动可能偶尔 500。所以我习惯把所有请求统一收口到一个函数里把异常处理、请求头、超时、重试逻辑全部封装好。伪代码如下import time import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8, } def fetch_url(url, retries3, timeout15): for attempt in range(1, retries 1): try: response requests.get(url, headersHEADERS, timeouttimeout) response.raise_for_status() return response except requests.RequestException as e: print(f[第 {attempt} 次请求失败] {url} - {e}) if attempt retries: time.sleep(2 * attempt) return Nonetimeout一定要设置不设置就会发生“卡死等半天”的局面代码没有响应也不知道该不该重试。重试的间隔按指数退避第一次失败等 2 秒第二次等 4 秒越是重试越放慢节奏给服务器喘口气也不容易把自己送上封禁名单。很多朋友会问 Django 或者 API 怎么处理 Cookie这个项目不涉及登录场景就先用单函数的通用方案。如果目标站需要登录才能看到资源后面另外再说。3.3 第二步解析列表页提取资源地址我举一个通用例子假设目标站的 HTML 结构是这个样子每页有一个列表容器里面每项都有图片和音频div classitem-list div classitem img src/uploads/img/001.jpg>from bs4 import BeautifulSoup from urllib.parse import urljoin def parse_list_page(html, base_url): soup BeautifulSoup(html, lxml) items [] for item in soup.select(div.item): img_tag item.find(img) img_url img_tag.get(data-original) or img_tag.get(src) img_url urljoin(base_url, img_url) audio_tag item.find(audio) if audio_tag: audio_src audio_tag.get(src) if not audio_src: source_tag audio_tag.find(source) if source_tag: audio_src source_tag.get(src) audio_url urljoin(base_url, audio_src) if audio_src else None else: audio_url None items.append((img_url, audio_url)) return items这个函数重点在两个地方。一是get的时候先取>import os def download_file(url, folder, filename): if not url: return False os.makedirs(folder, exist_okTrue) filepath os.path.join(folder, filename) if os.path.exists(filepath) and os.path.getsize(filepath) 2048: print(f[跳过] {filepath} 已存在) return True response fetch_url(url) if response is None: return False with open(filepath, wb) as f: f.write(response.content) size os.path.getsize(filepath) if size 2048: print(f[警告] {filepath} 文件大小异常 ({size} bytes)) os.remove(filepath) return False print(f[下载成功] {filepath} ({size} bytes)) return True这里有三个细节值得解释。一是断点续传的雏形如果文件已经存在且大小正常直接跳过不重新下载跑第二次脚本的时候就能快速把所有缺失文件补上。二是文件大小阈值response.content返回的二进制内容如果小于 2KB大概率是服务端返回了一个错误提示页存下来也不是正常图片直接删掉。三是下载和解耦下载函数不关心这个文件来自哪一页第几个只负责下载、落盘、校验。主循环也很直白import time base_url https://example.com/list?page total_pages 3 for page in range(1, total_pages 1): url f{base_url}{page} print(f 正在处理第 {page} 页 ) page_html fetch_url(url) if page_html is None: continue page_dir os.path.join(download, fpage_{page:03d}) items parse_list_page(page_html.text, url) for idx, (img_url, audio_url) in enumerate(items, start1): img_filename fimg_{idx:03d}.jpg audio_filename faudio_{idx:03d}.mp3 download_file(img_url, page_dir, img_filename) download_file(audio_url, page_dir, audio_filename) time.sleep(0.5)图片的文件名后缀我写死了.jpg如果你的目标站是 PNG 或者 WebP可以用os.path.splitext从 URL 里拆分出真实后缀。这里有个细节有的 CDN 地址跟参数混杂在一起os.path.splitext可能拆不干净需要先urlparse再os.path.splitext。我实际项目里直接写死后缀更省事反正最终文件是二进制内容对不对文件头也能判断。time.sleep(0.5)别嫌慢。每下载一个文件停半秒对单个站来说一天也能抓几千个文件服务器压力小不容易触发反爬机制。这种细节写评论的时候没人提但实际跑起来救过我好多次。3.5 第四步断点续传与完整性校验断点续传的逻辑在download_file里已经体现了一部分文件存在且大于 2KB 就跳过。但这个逻辑有一层隐含假设——网站的资源不会变。如果目标站的 CDN 域名会更换、文件会更新那你就要改成判断本地文件是否存在不管大小或者维护一个清单记录 URL 与文件名的映射。更强的校验可以引入哈希。下载完用hashlib.md5计算本地文件哈希跟服务器返回的Content-MD5对比但很多站点没有暴露这个头信息成本有点高。折中方案就是前文的大小阈值加扩展名匹配。我实际项目里还会在下载后用imghdr检测文件类型能识别出是不是真的图片如果识别不出来就删掉重下。3.6 完整代码汇总把上面所有函数拼起来整个项目的完整主体就在两百行以内。这种短小精悍的代码适合放在脚本里直接跑也适合以后改成命令行工具。我在文末会附一个完整代码的思维导图但整个项目就是这五大块请求、解析、下载、校验、主流程。4. 动态加载页面怎么办4.1 两种常见动态加载方式很多站点不用静态 HTML 直接输出数据而是通过 JavaScript 发起异步请求从后端接口拿 JSON 数据再动态渲染 DOM。在这种页面上你去解析 HTML会发现图片节点根本不存在请求结果里只有空白壳子。两种常见形式第一种是列表页是静态的但翻页时只替换局部区域改变 URL 但不刷新整个页面第二种是列表数据全部由接口动态加载页面刚打开时只有骨架屏。对于这两种情况直接模拟浏览器去解析 HTML 的路子走不奏效。4.2 方案一直接找数据接口推荐浏览器能拿到数据前提是向服务器的某个接口发了一个异步请求。我们手动复现这个请求把 JSON 拿到手解析反而比解析 HTML 更容易。方法不复杂。打开浏览器的开发者工具切到 Network 面板勾选 Fetch/XHR 过滤然后在页面上翻一页。观察新增的请求多半是个以api或者get_list开头的地址Response 部分是 JSON 结构。用实现的思路去替代 HTML 解析代码逻辑变成import requests import json api_url https://example.com/api/get_list params { page: 1, page_size: 20 } response requests.get(api_url, headersHEADERS, paramsparams) data response.json() for item in data[data][list]: img_url item[image] audio_url item[audio] print(img_url, audio_url)这种方式的效率远高于渲染浏览器而且可以直接分页、按条件筛选实用性很强。唯一的门槛是你要会从 JSON 结构里找到对应的字段这个没有统一套路每个站都不一样只能自己多看两层。还有一个小技巧如果接口返回的是加密 JSON先看一眼响应里有没有混淆的引号和大括号有的话大概率是前段加密这条路难走直接考虑方案二。4.3 方案二用浏览器渲染兜底接口加密严重、或者接口地址动态变化、或者接口根本没做 CORS 限制但参数生成逻辑藏在压缩 JS 里这种情况用 requests 死磕性价比极低。选择用 Playwright 或者 Selenium 让浏览器真实渲染然后接管它的响应。写起来长这样from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example.com/list?page1) page.wait_for_selector(div.item) html page.content() # 把 html 交给之前的 parse_list_page 解析即可 browser.close()Playwright 比 Selenium 的安装体验好自带浏览器驱动不用折腾 ChromeDriver 版本匹配那点事。但代价是慢、吃内存下载几百个文件的时候不推荐全程渲染最好只在解析阶段用浏览器拿到 HTML下载文件照旧用 requests。实际遇到过一个情况浏览器渲染出来的页面里图片地址是经过 CDN 跳转的直接下载下来的文件是带 JS 跳转的 HTML 页面。这个用方案的思路就卡住了最后是用 Playwright 的请求监听到浏览器真实图片请求拿到其请求地址再去下载才解决。这类偏门问题要案例化积累遇到的时候才知道怎么绕。4.4 踩坑记录接口和页面数据不一致玩动态加载时最容易出现的问题是接口返回的内容和前端渲染的内容不一致。比如页面上第一张图是展示图接口里第一张图却是另外一个字段。原因是前端逻辑里有一层数据处理可能是排序、过滤、改了字段名。这种问题排查的唯一手段是比对。我在项目里会把接口返回的 JSON 原样存一份然后浏览器页面里的数据也解析一份两者人工对齐。别偷懒否则下载一批素材回来发现序号全错位返工更折腾。5. 常见问题排查与避坑技巧5.1 常见问题速查表把这些年在爬虫项目里踩过的坑整理成一张表方便你直接对照排查。问题现象大概率原因解决方案请求返回 403被服务器识别为爬虫检查 User-Agent 是否设置为浏览器标识必要时加 Referer 头请求返回 404URL 拼接错误或相对路径未补全用 urljoin 处理资源地址手动检查拼接后的 URL图片下载后无法打开服务器返回的是错误页或跳转页用文件头检测格式或用文件大小阈值过滤后重试网页里能看到图片但代码取不到 src懒加载占位图真实地址在>import time last_request_time 0.0 def throttle_request(): global last_request_time elapsed time.time() - last_request_time if elapsed 0.5: time.sleep(0.5 - elapsed) last_request_time time.time()用这个代替死板的time.sleep(0.5)对大文件站点能省不少时间。还有一点遇到 429 或 503 时不要立刻重试这个状态码直接说明限流了。正确的做法是读取响应头里的Retry-After字段等够时间再请求。忽略这个字段就真变成“暴力爬虫”了。5.3 资源链接失效的处理资源链接失效是批量下载最烦的问题。刚爬完的页面里图片链接是好的隔了一天再跑第二遍可能就有几张图进入冷存储或 CDN 下架。处理这个问题的原则是下载时判断失败就重试重试不行就跳过不要死磕某一个文件。真要追求高完成率可以把失败的 URL 记录到一个文本文件里等第一轮跑完再单独跑一轮失败清单。这样主流程不会被单个坏链阻塞。一个细节是不要看到文件存在就默认下载成功一定要验证文件大小和类型否则残留的空文件会在后续整理时恶心死你。5.4 还应注意的合规与规范使用工具本身没有原罪但使用方式要讲究。爬虫能批量抓的数据一定也要让人家的服务器负担得起。我习惯在动手之前看一下目标站的robots.txt。如果明确写了这个路径禁止抓取就绕开或者联系对方拿到授权。专门抓别人会员资源、绕过登录限制、破解签名参数的活儿风险不可控出了问题扛不住不接这种单子。另外抓下来的素材版权不归你做项目用的时候要看好授权协议。有的站允许个人学习使用有的不允许商业用途这个要遵守。工具写好放在自己手里没问题乱传播、乱使用时被追究责任就不好玩了。合规的边界在技术上不复杂但这是每个写爬虫的人都该有的底线。我自己的习惯是做技术 Demo 时用目标站的公开页面不涉及任何需登录或付费的内容测试完毕立刻停止请求。在代码层面给整个脚本加一个最大页数限制、限制总请求数也是体现负责任的方式之一。一旦跑完就知道结果不会因为误操作让脚本无限循环。最后分享一个我在实际使用中养成的习惯不管抓什么站先拿第 1 页做单页试跑确认解析结果和文件大小都正常再放开到全量页码。这个习惯帮我挡掉过至少五次改版导致的全量白跑。还有下载后的文件夹不要急着删脚本优化参数时可以反复断点续传省流量省时间。希望这份拆解对你手上的需求有用你会写起来顺很多的。