ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫入门实战:批量下载游戏宠物图片

Python爬虫入门实战:批量下载游戏宠物图片 1. 从想收集宠物图鉴这个念头说起很多刚学Python的朋友都会经历这样一个阶段语法学完了练习题也刷了不少但真让自己独立做点什么东西脑子里一片空白。我当初也是这样后来发现最好的突破口就是找一个自己真正感兴趣的小项目来练手。我选的方向是爬取一款经典网页游戏里的宠物图片——就是那种陪伴了很多90后、00后童年的养成类游戏里面有几百只形态各异的宠物每只都有专属的立绘。这个项目的目标很朴素把游戏图鉴里所有宠物的图片批量下载到本地按宠物名称分文件夹存好方便离线浏览。听起来简单但它几乎涵盖了爬虫入门的所有核心知识点发送请求、解析HTML、提取图片链接、处理相对路径、批量下载、异常重试、文件命名规范化。麻雀虽小五脏俱全。这篇文章适合两类人看。一类是刚学完Python基础语法、想找一个完整项目练手的新手另一类是做过后端或数据分析、但对爬虫这块不太熟悉、想快速补齐这块短板的开发者。我会把整个过程中踩过的坑、做过的取舍、以及那些教程里不会写的细节全部摊开来讲。你跟着走一遍基本就能独立完成类似结构的图片采集任务了。需要提前说明的是本文所有操作仅用于个人学习和技术研究采集的内容请勿用于任何商业用途或公开传播尊重原作品的版权是基本底线。另外实际操作中请控制请求频率不要给目标服务器造成不必要的压力这是每个爬虫开发者应有的基本素养。2. 动手之前先把能不能爬这件事想清楚2.1 目标页面的结构决定了你的技术路线在写任何一行代码之前我做的第一件事是打开浏览器的开发者工具仔细看目标页面的结构。这一步很多人会跳过直接上来就写requests.get()结果后面反复返工。我的习惯是先把页面类型判断清楚再决定用什么解析方案。打开图鉴页面后我发现它属于典型的列表页详情页结构。列表页展示所有宠物的缩略图和名称点击某个宠物进入详情页详情页里才有高清立绘。这就意味着我需要两步走先从列表页拿到所有宠物的详情页链接再逐个进入详情页提取图片地址。判断页面是服务端渲染还是客户端渲染也很关键。我在浏览器里右键查看网页源代码搜索宠物名称发现能直接搜到说明数据是写在HTML里的不是通过JavaScript动态加载的。这个判断非常重要——如果是动态加载的requests拿到的HTML里就没有数据得换用其他方案。确认是静态页面后我就可以放心用requests加解析库的组合了。2.2 解析库的选择BeautifulSoup还是lxml解析HTML的库有好几个常见的有BeautifulSoup、lxml、pyquery。我最后选了BeautifulSoup配合lxml解析器理由有几个。第一BeautifulSoup的API对新手极其友好。它的find()和find_all()方法直观到几乎不用查文档用标签名、class、id都能定位元素写起来像在描述找到所有class为pet-item的div这样自然。第二它容错性强。网页HTML经常有标签不闭合、嵌套混乱的问题BeautifulSoup能自动修复这些结构不会因为一个小瑕疵就整个解析失败。第三配合lxml作为底层解析器速度也够用比纯Python实现的html.parser快不少。lxml单独用性能更好但它的XPath语法对新手有一定门槛而且遇到不规范HTML时容错不如BeautifulSoup。pyquery则是jQuery风格的语法如果你之前写前端可能更顺手但生态和资料相对少一些。综合下来BeautifulSoup是入门阶段性价比最高的选择。安装很简单pip install requests beautifulsoup4 lxml这里有个小细节lxml是C语言实现的在某些系统上安装可能需要编译环境。如果pip install lxml报错可以先装好系统级的编译工具或者退而求其次用html.parser虽然慢一点但零依赖。2.3 请求头里必须带的东西直接裸奔发请求大概率会被服务器拒绝或者返回一个空页面。我一开始就吃了这个亏requests.get()返回状态码200但拿到的内容里啥都没有。后来加上User-Agent才正常。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告诉服务器你是什么浏览器Referer告诉服务器你是从哪个页面跳过来的。有些站点会检查Referer防止图片被外站盗链。这两个字段加上之后基本能应对大部分常规站点。至于更复杂的反爬手段比如Cookie校验、签名参数那是进阶话题这个项目暂时用不上。提示请求头里的域名和路径请替换成你实际要访问的地址本文用占位符代替避免指向具体站点。3. 分步骤拆解从列表页到本地文件夹的完整链路3.1 第一步把列表页所有详情链接抓下来列表页通常会有分页我遇到的是每页显示若干只宠物底部有页码导航。处理分页有两种思路一种是先解析出总页数然后循环构造每一页的URL另一种是不断点击下一页直到没有为止。我选了第一种因为URL规律明显构造起来更可控。先看单页的解析逻辑。假设列表页每个宠物条目长这样div classpet-list a classpet-item href/pet/123.html img src/thumb/123.jpg alt某宠物 span classname某宠物/span /a ... /div解析代码from bs4 import BeautifulSoup import requests def parse_list_page(url): resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) items soup.find_all(a, class_pet-item) result [] for item in items: detail_url item.get(href) name item.find(span, class_name).get_text(stripTrue) result.append({name: name, url: detail_url}) return result这里有几个容易忽略的点。第一resp.encoding的设置。requests有时候猜不准编码导致中文变成乱码。用apparent_encoding让它根据内容自动判断比默认的ISO-8859-1靠谱得多。第二href拿到的是相对路径比如/pet/123.html后面拼完整URL时需要加上域名前缀。第三get_text(stripTrue)里的strip参数能去掉首尾空白避免文件名里混入换行符和空格。分页处理base_url https://example-game-site.com/petlist?page{} all_pets [] for page in range(1, total_pages 1): all_pets.extend(parse_list_page(base_url.format(page))) time.sleep(1) # 每页之间停一秒total_pages可以手动看也可以从分页导航里解析出来。time.sleep(1)是礼貌性延迟别小看这一秒它能让你的请求看起来更像正常用户也能减轻服务器负担。3.2 第二步进入详情页提取高清图片地址详情页的结构一般是这样的div classpet-detail h1 classpet-name某宠物/h1 div classpet-image img src/images/pet/123_big.png alt某宠物立绘 /div /div提取逻辑def parse_detail_page(url): resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) name soup.find(h1, class_pet-name).get_text(stripTrue) img_tag soup.find(div, class_pet-image).find(img) img_url img_tag.get(src) return name, img_url这里有个坑我踩过有些详情页的图片不是直接写在src里而是放在data-src这样的自定义属性里用于懒加载。判断方法很简单如果src里是一个占位图比如loading.gif真正的地址多半在data-src或data-original里。遇到这种情况代码要相应调整img_url img_tag.get(data-src) or img_tag.get(src)用or短路运算优先取data-src取不到再退回src这样两种结构都能兼容。3.3 第三步下载图片并规范命名拿到图片URL后下载本身很简单但命名和存储的细节决定了你后期能不能愉快地使用这批素材。import os def download_image(img_url, save_dir, filename): if not os.path.exists(save_dir): os.makedirs(save_dir) # 补全相对路径 if img_url.startswith(/): img_url https://example-game-site.com img_url ext os.path.splitext(img_url)[1] or .png filepath os.path.join(save_dir, filename ext) try: resp requests.get(img_url, headersheaders, timeout15) if resp.status_code 200: with open(filepath, wb) as f: f.write(resp.content) return True except Exception as e: print(f下载失败 {filename}: {e}) return False注意写入模式是wb二进制写入因为图片是二进制数据用文本模式会损坏文件。文件名我用宠物名称但宠物名称里可能含有斜杠、冒号等文件系统不支持的字符需要做一次清洗import re safe_name re.sub(r[\\/:*?|], _, name)这个正则把Windows和Linux下都不允许的字符统一替换成下划线。别偷懒跳过这一步否则遇到名字里带特殊符号的宠物程序直接崩。4. 那些教程不会告诉你的坑与应对4.1 请求频率过高被封禁的完整排查过程项目跑到一半我突然发现后面几十个请求全部返回403。一开始我以为是代码逻辑问题反复检查URL拼接没发现异常。后来把单个失败的URL拿出来在浏览器里打开能正常访问说明不是链接失效。接着我怀疑是请求头问题把headers打印出来对比也没看出毛病。直到我把请求间隔去掉连续快速请求了十几次发现前几次正常、后面全部403才意识到是频率触发了限制。排查链路是这样的先确认单个请求在浏览器可用排除链接问题再确认请求头完整排除身份问题最后通过控制变量法调整请求间隔定位到频率问题。定位之后解决方案是加随机延迟import random time.sleep(random.uniform(1.5, 3.5))用随机而不是固定值是因为固定间隔的请求模式太规律容易被识别为机器行为。随机延迟模拟了人类浏览时快时慢的节奏隐蔽性更好。另外我还加了一个简单的重试机制def fetch_with_retry(url, retries3): for i in range(retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: return resp except requests.RequestException: pass time.sleep(2 ** i) # 指数退避 return None指数退避的意思是每次重试等待时间翻倍第一次等1秒第二次等2秒第三次等4秒。这样既能给服务器喘息时间也能在偶发网络抖动时自动恢复。4.2 图片下载了一半就中断怎么办批量下载最怕的就是跑到一半断了重新跑又得从头开始浪费时间。我的做法是加一个已完成清单机制。每成功下载一张就把文件名追加写入一个记录文件程序启动时先读取这个文件已经下载过的直接跳过。def load_done_set(record_file): if os.path.exists(record_file): with open(record_file, r, encodingutf-8) as f: return set(line.strip() for line in f) return set() def mark_done(record_file, filename): with open(record_file, a, encodingutf-8) as f: f.write(filename \n)这个机制看起来简单但实用性极强。尤其是采集量大、耗时长的时候断点续传能帮你省下大量重复劳动。我后来把这个模式用在了好几个项目上屡试不爽。4.3 文件名重复与覆盖的隐患有些宠物的名称可能重复或者不同形态共用同一个名字直接拿名称当文件名会导致后面的覆盖前面的。我在跑完第一遍后发现下载数量比预期少排查才发现是重名覆盖。解决办法是在文件名里加上唯一标识比如详情页URL里的数字IDpet_id re.search(r/(\d)\.html, detail_url).group(1) filename f{pet_id}_{safe_name}这样即使名称相同ID不同也不会冲突。另外写入前可以先判断文件是否已存在存在就跳过避免无谓的重复下载。5. 让代码更耐用的几个工程化习惯5.1 把配置抽出来别硬编码在逻辑里新手写爬虫最常见的毛病就是把URL、请求头、保存路径全写死在函数里。一旦要改个路径或者换个目标得满代码找。我习惯在文件开头集中定义配置CONFIG { base_url: https://example-game-site.com, list_url_template: https://example-game-site.com/petlist?page{}, save_dir: ./pet_images, record_file: ./downloaded.txt, delay_range: (1.5, 3.5), timeout: 10, }这样调整参数只需要改一处逻辑代码完全不用动。这个习惯在项目变大之后价值尤其明显。5.2 日志比print更值得投入一开始我用print输出进度但信息一多就刷屏而且没法区分正常信息和错误信息。后来换成了logging模块import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(crawler.log, encodingutf-8), logging.StreamHandler() ] )这样日志同时输出到控制台和文件出问题时可以回溯。日志级别也能帮你过滤信息INFO看进度ERROR看异常不用在一堆输出里大海捞针。5.3 用会话保持连接减少握手开销requests每次get都会新建一个连接对于需要请求几百次的任务来说TCP握手和TLS协商的开销累积起来不小。用Session对象可以复用连接session requests.Session() session.headers.update(headers) # 之后所有请求都用 session.get()实测下来用Session之后整体耗时能降低两到三成尤其是HTTPS站点效果更明显。而且Session还能自动保持Cookie某些需要登录态的场景直接受益。6. 采集完成后的整理与二次利用6.1 按属性分类归档图片下载完之后如果只是堆在一个文件夹里找起来很痛苦。我根据详情页里能拿到的属性比如宠物的系别、进化阶段在保存时就分好子目录save_dir os.path.join(CONFIG[save_dir], category)分类信息同样从详情页解析多提取一个字段的事但后期检索效率天差地别。这个思路可以推广到任何采集项目采集时多花一秒分类使用时省下十分钟翻找。6.2 生成一份索引清单除了图片本身我还顺手生成了一份CSV索引记录每只宠物的名称、ID、图片路径、采集时间。这份清单在后续做数据分析或者批量处理时特别有用比如你想统计某个系别的宠物数量直接读CSV就行不用去遍历文件系统。import csv with open(index.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([id, name, category, path]) writer.writerows(all_records)注意编码用utf-8-sig这样用Excel打开不会乱码。这个小细节坑过很多人普通utf-8在Excel里中文会显示成乱码加个BOM头就解决了。6.3 图片去重与质量筛选采集来的图片里可能混有缩略图和重复图。我写了个简单的去重脚本用文件内容的MD5值做指纹import hashlib def file_md5(path): with open(path, rb) as f: return hashlib.md5(f.read()).hexdigest()把MD5相同的文件只保留一份能清掉不少冗余。另外还可以按文件大小过滤太小的多半是占位图或加载失败的残图直接剔除。7. 关于合规与可持续采集的几点个人体会做爬虫这些年我越来越觉得技术本身不难难的是把握好分寸。这个项目虽然是练手性质但有几个原则我一直坚持。第一控制请求频率不是可选项而是必选项。我见过太多人为了快开几十个线程猛冲结果把人家小站打挂了。加个随机延迟、限制并发数对你只是多等几分钟对服务器却是生死攸关。第二采集的数据自己学习研究没问题但别拿去商用或者公开传播尤其是涉及美术资源的版权归属很明确。第三如果目标站点提供了官方API或者数据导出功能优先用官方渠道那才是最稳定、最合规的方式。从技术成长的角度看这个项目最大的价值不在于最后拿到了多少张图片而在于你完整走了一遍分析页面结构、选择技术方案、处理异常、优化性能、整理产出的流程。这套方法论可以平移到任何采集类任务上。我自己后来做过的商品信息采集、文章归档、公开数据整理底层套路都是这一套只是解析规则和存储方式不同而已。如果你跟着做完了建议再往前迈一步把脚本改造成支持命令行参数的形式用argparse接收目标URL和保存路径或者加个简单的进度条用tqdm库让等待过程更直观。这些改进都不难但能让你的工具从一次性脚本进化成可复用的小工具这个转变对提升工程能力很有帮助。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进