Python爬虫实战:批量抓取洛克王国宠物图片
1. 项目缘起与整体设计思路1.1 为什么选这个题材练手很多刚学Python的朋友问我入门之后该拿什么项目练手。我的答案一直是找一个你真正感兴趣、页面结构又不太复杂的网站把它的图片批量抓下来。听起来简单但里面涉及的知识点其实相当完整——HTTP请求、HTML解析、文件IO、异常处理、并发控制一个都不少。这次我选的目标是经典页游《洛克王国》的宠物图片。原因有三第一宠物图鉴页面结构相对规整每只宠物有独立的详情页和缩略图适合新手理解“列表页→详情页→资源文件”这种典型的三层抓取模型第二图片资源本身有收藏价值抓下来能直接看到成果正反馈强第三这个站点的反爬策略不算激进不会一上来就把新手劝退但也不是完全没有门槛刚好能学到东西。需要说明的是本文所有操作仅用于个人学习和技术研究抓取到的图片请勿用于任何商业用途或二次传播。尊重内容方的版权和服务器资源是每个写爬虫的人应该守住的底线。1.2 整体抓取流程拆解在动手写代码之前我习惯先在纸上把流程画清楚。这个项目的逻辑链路是这样的第一步从宠物图鉴的列表页入手拿到所有宠物的详情页链接第二步逐个访问详情页从页面中提取宠物的大图地址第三步根据图片地址下载文件并按宠物名称命名保存到本地第四步处理翻页逻辑把多页的宠物全部覆盖到。这个流程对应到代码结构上就是三个核心函数一个负责拿链接列表一个负责解析详情页拿图片地址一个负责下载保存。主程序用一个循环把它们串起来。这种“分而治之”的写法好处是每个环节可以单独调试出了问题容易定位。我见过不少新手一上来就写一个大函数从头包到尾结果中间某一步报错根本不知道是请求失败、解析失败还是保存失败。所以我的建议是哪怕项目再小也要按功能拆函数这是写爬虫的基本功。1.3 技术选型与理由工具选择上我最终用的是requestsBeautifulSoup这套组合。为什么不直接上Scrapy因为对于这种规模的项目Scrapy 的框架开销反而是一种负担配置文件和中间件的学习成本对新手不友好。requests处理请求足够稳定BeautifulSoup解析 HTML 的语法直观配合lxml解析器速度也够用。至于并发我没有一上来就用多线程。新手阶段先把单线程跑通、跑稳理解每一步在干什么比追求速度重要得多。等单线程版本稳定运行之后再考虑用ThreadPoolExecutor加速这是更合理的学习路径。本文也会先讲单线程版本再补充并发优化的思路。另外图片下载我用的是requests直接写文件的方式没有用urllib。原因很简单requests的content属性直接返回二进制数据配合open的wb模式写入代码清晰易懂异常处理也更方便。2. 核心细节解析与实操要点2.1 请求头的伪装与必要性很多新手第一个坑就栽在请求头上。直接裸奔一个requests.get(url)大概率拿到的是403或者一个空页面。原因在于服务器会检查请求头里的User-Agent默认的python-requests/2.x.x标识等于直接告诉对方“我是爬虫”。正确的做法是伪装成正常浏览器。我一般会准备一个headers字典至少包含User-Agent有条件的话再加上Referer。Referer的作用是告诉服务器“我是从哪个页面跳过来的”有些站点会校验这个字段缺失的话图片可能返回403。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-game-site.com/ }注意User-Agent不要随便编一个最好从自己浏览器的开发者工具里复制真实的。编造的 UA 有时候会被识别出来反而弄巧成拙。这里有个细节值得展开说。有些教程会教你准备一堆 UA 轮换使用但对于这种小项目一个固定的真实 UA 就够了。轮换 UA 反而可能触发风控因为同一个 IP 短时间内出现多种浏览器标识本身就是异常行为。我实测下来固定 UA 加适当的请求间隔稳定性远好于频繁换 UA。2.2 页面结构分析与选择器定位拿到页面之后下一步是找到图片地址藏在哪。打开浏览器的开发者工具用元素选择器点一下宠物图片就能看到对应的 HTML 结构。通常图片会放在img标签的src属性里但有些站点会用>soup BeautifulSoup(response.text, lxml) pet_links soup.find(div, class_pet-list).find_all(a)这里有个经验选择器要尽量“贴着”目标元素的结构写但不要贴得太死。比如用div.pet-list ul li a这种层级选择器一旦页面结构微调代码就废了。我更倾向于用class定位到最近的容器再在里面find_all容错性更好。2.3 图片命名与目录管理图片下载下来之后命名是个容易被忽视但很重要的环节。如果直接用 URL 里的文件名很可能是一串无意义的数字或哈希值几百张图混在一起根本分不清哪只是哪只。我的做法是用宠物的名称来命名名称从详情页的标题或某个特定标签里提取。目录结构上我会按类别或页码分文件夹存放。比如images/宠物名称.png如果宠物数量多可以再按首字母或属性分一级目录。这样做的好处是后续想找某只宠物的图直接按名字搜索就行不用一张张翻。import os save_dir images if not os.path.exists(save_dir): os.makedirs(save_dir) file_path os.path.join(save_dir, f{pet_name}.png)提示文件名里如果包含/、\、:这些特殊字符会导致保存失败。提取到名称之后记得用replace清洗一遍把非法字符替换成下划线。2.4 请求间隔与礼貌抓取这一点我必须单独拎出来讲。很多新手为了快循环里不加任何延时几百个请求瞬间打过去结果就是 IP 被限速甚至封禁。加延时不是为了“绕过”什么而是为了不给对方服务器造成不必要的压力这是基本的礼貌也是保护自己的手段。我的习惯是在每次请求之间time.sleep(0.5)到1秒。这个间隔对服务器来说很温和对抓取速度的影响也在可接受范围内。如果目标站点响应特别快可以适当缩短到 0.3 秒如果发现响应变慢或出现 429 状态码就要立刻加大间隔。import time import random time.sleep(random.uniform(0.5, 1.2))用random.uniform让间隔在一个区间内随机浮动比固定值更自然。固定间隔的请求模式太规律反而容易被识别为机器行为。这个技巧在抓取量大的时候尤其有用。3. 实操过程与核心环节实现3.1 环境准备与依赖安装动手之前先把环境搭好。Python 版本我建议用 3.8 以上太老的版本有些库的兼容性会有问题。依赖库一共三个requests、beautifulsoup4、lxml。前两个负责请求和解析lxml是解析器速度比 Python 自带的html.parser快不少。pip install requests beautifulsoup4 lxml安装完之后建议在代码里先做个简单的导入测试确认没有报错。我遇到过lxml在某些系统上安装失败的情况这时候可以退而求其次用html.parser虽然慢一点但功能不受影响。import requests from bs4 import BeautifulSoup import os import time import random注意如果你用的是虚拟环境记得先激活再安装。全局环境装一堆库时间长了容易乱虚拟环境是更好的习惯。3.2 列表页链接提取的完整实现列表页的任务是拿到所有宠物的详情页链接。这里要处理两个问题一是当前页的链接提取二是翻页逻辑。先看单页提取。请求列表页解析 HTML找到包含宠物链接的容器遍历里面的a标签把href属性取出来。这里要注意href可能是相对路径需要和域名拼接成完整 URL。def get_pet_links(page_url): response requests.get(page_url, headersheaders, timeout10) response.encoding utf-8 soup BeautifulSoup(response.text, lxml) links [] container soup.find(div, class_pet-list) if container: for a in container.find_all(a): href a.get(href) if href and href.startswith(/): links.append(base_url href) return links翻页逻辑要看站点的 URL 规律。常见的有两种一种是?page2这种查询参数一种是/page/2/这种路径形式。观察几页的 URL 就能总结出规律。我一般会先手动翻几页把 URL 记下来对比确认规律之后再写循环。all_links [] for page in range(1, 11): page_url f{base_url}/pet/list?page{page} links get_pet_links(page_url) if not links: break all_links.extend(links) time.sleep(random.uniform(0.5, 1.2))这里加了个if not links: break的判断作用是当某一页没有链接时提前结束循环避免无意义的请求。这个细节在页数不确定的时候特别有用。3.3 详情页图片地址解析拿到详情页链接之后逐个访问从页面里提取大图地址。这一步的关键是找准图片元素的选择器。通常宠物大图会有一个特定的class比如pet-image或detail-img用这个定位比用标签名更准。def get_image_url(detail_url): response requests.get(detail_url, headersheaders, timeout10) response.encoding utf-8 soup BeautifulSoup(response.text, lxml) img soup.find(img, class_pet-image) if img: src img.get(src) if src and not src.startswith(http): src base_url src return src return None同时宠物的名称也从详情页提取。名称一般在h1或某个标题标签里取到之后清洗一下特殊字符用作文件名。def get_pet_name(detail_url): response requests.get(detail_url, headersheaders, timeout10) response.encoding utf-8 soup BeautifulSoup(response.text, lxml) title soup.find(h1) if title: name title.get_text(stripTrue) for ch in [/, \\, :, *, ?, , , , |]: name name.replace(ch, _) return name return unknown提示这里我把名称提取和图片提取分成了两个函数各自独立请求了一次详情页。这样写逻辑清晰但请求次数翻倍了。优化的话可以在一个函数里同时提取名称和图片地址减少一次请求。新手阶段先求清晰优化留到后面。3.4 图片下载与保存的完整流程下载环节是整个项目的收尾也是最容易出问题的地方。核心逻辑是请求图片 URL拿到二进制内容写入文件。听起来简单但异常处理必须做足。def download_image(img_url, save_path): try: response requests.get(img_url, headersheaders, timeout15) if response.status_code 200: with open(save_path, wb) as f: f.write(response.content) return True else: print(f下载失败状态码{response.status_code}地址{img_url}) return False except requests.RequestException as e: print(f请求异常{e}地址{img_url}) return False这里有几个细节值得说。第一timeout一定要设不设的话遇到响应慢的服务器程序会一直卡在那里。第二用response.content而不是response.text因为图片是二进制数据用text会破坏文件。第三写入模式用wb不是w。把三个环节串起来的主程序base_url https://example-game-site.com headers {...} all_links [] for page in range(1, 11): page_url f{base_url}/pet/list?page{page} links get_pet_links(page_url) if not links: break all_links.extend(links) time.sleep(random.uniform(0.5, 1.2)) for idx, link in enumerate(all_links): name get_pet_name(link) img_url get_image_url(link) if img_url: save_path os.path.join(images, f{name}.png) success download_image(img_url, save_path) print(f[{idx1}/{len(all_links)}] {name} - {成功 if success else 失败}) time.sleep(random.uniform(0.5, 1.2))跑起来之后控制台会逐条打印进度images文件夹里图片一张张多起来那种成就感是很实在的。3.5 并发加速的进阶思路单线程版本跑通之后如果宠物数量多比如几百上千只等待时间会比较长。这时候可以考虑用线程池加速。核心思路是把下载任务提交给线程池让多个线程同时下载。from concurrent.futures import ThreadPoolExecutor def download_task(link): name get_pet_name(link) img_url get_image_url(link) if img_url: save_path os.path.join(images, f{name}.png) download_image(img_url, save_path) with ThreadPoolExecutor(max_workers5) as executor: executor.map(download_task, all_links)max_workers控制并发数我一般设 3 到 5。设太大反而容易触发风控而且对服务器压力也大。并发不是越多越好稳定才是第一位的。注意并发版本里time.sleep的位置要调整不能简单地在每个任务里加否则线程之间会互相等待。更合理的做法是在请求函数内部加短延时或者用信号量控制请求速率。4. 常见问题与排查技巧实录4.1 请求返回403或空内容的排查这是新手遇到最多的问题。现象是response.status_code返回 403或者返回 200 但内容是空的。排查思路按顺序来排查项检查方法解决方式User-Agent打印headers确认换成真实浏览器 UAReferer检查是否缺失补上来源页面地址Cookie看是否需要登录手动复制 Cookie 到 headers请求频率看是否请求过快加大sleep间隔IP 限制换网络环境测试降低频率等待一段时间我踩过的坑是有一次 UA 和 Referer 都设对了还是 403最后发现是请求间隔太短被临时限速了。等了十几分钟再跑就正常了。所以遇到 403 先别急着改代码先想想是不是请求太频繁。4.2 图片下载后打不开的原因下载下来的图片双击打不开或者显示损坏通常有几个原因。一是用response.text而不是content写入导致二进制数据被破坏。二是文件扩展名和实际格式不匹配比如实际是 webp 格式却存成了 png。三是下载过程中网络中断文件不完整。判断方法很简单看文件大小。如果只有几百字节基本可以确定是下载失败或者写入了错误内容。正常的宠物图片大小一般在几十 KB 到几百 KB 之间。if os.path.getsize(save_path) 1024: print(f文件异常小可能下载失败{save_path})4.3 选择器失效的应对策略页面结构不是一成不变的今天能用的选择器明天可能就失效了。表现是find返回None或者find_all返回空列表。这时候不要慌重新打开开发者工具检查目标元素的class或id有没有变化。我的经验是选择器尽量用稳定的属性。id通常比class稳定因为id是唯一的改动成本高。如果只能用class优先选那些语义明确的比如pet-detail-image就比img-box更可能长期存在。另外可以在代码里加个容错当主选择器失效时尝试备用选择器img soup.find(img, class_pet-image) or soup.find(img, class_detail-img)4.4 常见问题速查表问题现象可能原因快速解决403 ForbiddenUA 缺失或频率过高补 UA加延时返回内容为空需要 Cookie 或 Referer补全请求头图片损坏用了 text 而非 content改用 content wb文件名乱码编码未设置设response.encoding程序卡住不动未设 timeout加timeout10部分图片缺失懒加载未处理取>if os.path.exists(save_path): print(f已存在跳过{save_path}) return True这个小判断能省下大量重复请求尤其是在调试阶段反复运行时效果非常明显。第四个心得是关于日志记录。与其用print输出到控制台不如同时写一份日志文件。这样程序跑完之后哪些成功了、哪些失败了一目了然方便后续补抓。import logging logging.basicConfig( filenamedownload.log, levellogging.INFO, format%(asctime)s - %(message)s )这些细节看起来琐碎但真正跑起批量任务的时候每一条都能帮你省下不少排查时间。爬虫这东西写起来不难难的是写得稳、跑得久、出了问题能快速定位。把异常处理和日志做扎实比追求花哨的技巧有用得多。