☰
Python爬虫实战:批量抓取洛克王国宠物图片
2026/10/12 17:26:18 网站建设 项目流程

1. 项目缘起与整体设计思路

1.1 为什么选这个题材练手

很多刚学Python的朋友问我,入门之后该拿什么项目练手。我的答案一直是:找一个你真正感兴趣、页面结构又不太复杂的网站,把它的图片批量抓下来。听起来简单,但里面涉及的知识点其实相当完整——HTTP请求、HTML解析、文件IO、异常处理、并发控制,一个都不少。

这次我选的目标是经典页游《洛克王国》的宠物图片。原因有三:第一,宠物图鉴页面结构相对规整,每只宠物有独立的详情页和缩略图,适合新手理解“列表页→详情页→资源文件”这种典型的三层抓取模型;第二,图片资源本身有收藏价值,抓下来能直接看到成果,正反馈强;第三,这个站点的反爬策略不算激进,不会一上来就把新手劝退,但也不是完全没有门槛,刚好能学到东西。

需要说明的是,本文所有操作仅用于个人学习和技术研究,抓取到的图片请勿用于任何商业用途或二次传播。尊重内容方的版权和服务器资源,是每个写爬虫的人应该守住的底线。

1.2 整体抓取流程拆解

在动手写代码之前,我习惯先在纸上把流程画清楚。这个项目的逻辑链路是这样的:

  • 第一步,从宠物图鉴的列表页入手,拿到所有宠物的详情页链接;
  • 第二步,逐个访问详情页,从页面中提取宠物的大图地址;
  • 第三步,根据图片地址下载文件,并按宠物名称命名保存到本地;
  • 第四步,处理翻页逻辑,把多页的宠物全部覆盖到。

这个流程对应到代码结构上,就是三个核心函数:一个负责拿链接列表,一个负责解析详情页拿图片地址,一个负责下载保存。主程序用一个循环把它们串起来。这种“分而治之”的写法,好处是每个环节可以单独调试,出了问题容易定位。

我见过不少新手一上来就写一个大函数从头包到尾,结果中间某一步报错,根本不知道是请求失败、解析失败还是保存失败。所以我的建议是,哪怕项目再小,也要按功能拆函数,这是写爬虫的基本功。

1.3 技术选型与理由

工具选择上,我最终用的是requests+BeautifulSoup这套组合。为什么不直接上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, headers=headers, timeout=10) 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 规律。常见的有两种:一种是?page=2这种查询参数,一种是/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, headers=headers, timeout=10) 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, headers=headers, timeout=10) response.encoding = "utf-8" soup = BeautifulSoup(response.text, "lxml") title = soup.find("h1") if title: name = title.get_text(strip=True) 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, headers=headers, timeout=15) 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"[{idx+1}/{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_workers=5) 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确认换成真实浏览器 UA
Referer检查是否缺失补上来源页面地址
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加timeout=10
部分图片缺失懒加载未处理取>if os.path.exists(save_path): print(f"已存在,跳过:{save_path}") return True

这个小判断能省下大量重复请求,尤其是在调试阶段反复运行时,效果非常明显。

第四个心得是关于日志记录。与其用print输出到控制台,不如同时写一份日志文件。这样程序跑完之后,哪些成功了、哪些失败了,一目了然,方便后续补抓。

import logging logging.basicConfig( filename="download.log", level=logging.INFO, format="%(asctime)s - %(message)s" )

这些细节看起来琐碎,但真正跑起批量任务的时候,每一条都能帮你省下不少排查时间。爬虫这东西,写起来不难,难的是写得稳、跑得久、出了问题能快速定位。把异常处理和日志做扎实,比追求花哨的技巧有用得多。

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

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

立即咨询