☰
Python爬虫实战:requests抓取图片站第一页所有图片
2026/10/9 13:17:28 网站建设 项目流程

1. 从零拆解一个图片站抓取需求

1.1 这个需求到底在做什么

拿到“抓取某个图片站图片区第一页所有图片”这个题目,很多人第一反应是打开编辑器就写requests.get,但真正做过一批站点的人会先停下来想三件事:目标页面是静态渲染还是动态加载、图片真实地址藏在哪一层、第一页的边界怎么界定。这三个问题决定了后面代码是二十行还是两百行。

这个需求的核心目标很明确:给定一个图片列表页,把该页展示的所有图片原图下载到本地,并且按一定规则命名归档。它解决的是“手动一张张右键另存”的效率问题,适合刚接触爬虫、想找一个完整小项目练手的人,也适合需要批量收集素材的设计或运营同学。技术栈上,最轻量的组合是requests负责请求、lxml或BeautifulSoup负责解析、os与pathlib负责落盘,全程不需要浏览器内核,资源占用低,跑在小内存机器上也没压力。

需要提前说清楚的是:本文所有示例中的域名、路径、选择器均为演示用的虚构结构,实际抓取时必须替换为你自己有权访问的目标站点,并且严格遵守目标站点的 robots 协议与版权声明。抓取公开可访问的图片用于个人学习和小规模素材整理,和批量搬运他人版权内容,是两件性质完全不同的事,这条线心里要有数。

1.2 为什么选 requests 而不是 Selenium

热词里出现了“python爬虫可视化界面”“分布式爬虫”这些词,说明不少人一上来就想上重武器。我的经验是:能用 HTTP 请求直接拿到 HTML 的站点,就别碰浏览器自动化。原因很实在——Selenium 启动一个 Chromium 实例,内存起步就是几百 MB,速度比纯请求慢一个数量级,而且还要处理驱动版本匹配这种破事。只有当页面内容确实由 JavaScript 异步渲染、HTML 源码里根本找不到图片地址时,才值得上浏览器方案。

判断方法很简单:用curl或者浏览器开发者工具的 Network 面板看一眼,如果 Response 里能直接搜到图片的src,那就是静态的,requests足够。如果搜不到,图片地址是后面 XHR 请求回来再塞进 DOM 的,那要么去分析那个 XHR 接口直接请求数据,要么才考虑浏览器渲染。绝大多数图片列表站的第一页,其实都是服务端直出的,这也是为什么这个项目适合入门。

1.3 整体流程的四个阶段

把整个抓取拆成流水线,会清晰很多:

  1. 请求阶段:构造带合理请求头的 GET 请求,拿到列表页 HTML。
  2. 解析阶段:从 HTML 中定位“图片区”容器,提取其中每个图片项的详情页链接或直接的原图链接。
  3. 二次请求阶段:如果列表页只给了缩略图和详情页链接,就需要再进详情页拿原图地址。
  4. 下载与归档阶段:对图片二进制流写文件,处理重名、失败重试、格式识别。

这四个阶段里,坑最多的是解析和下载。解析的坑在于选择器写得太脆,站点改个 class 名就全废;下载的坑在于没做超时和重试,遇到一张图卡住整个脚本就挂在那里。后面会逐个展开。

2. 请求头、会话与反爬的基本应对

2.1 请求头不是随便抄一份就行

很多人从网上抄一段 User-Agent 就完事,结果请求回来是 403 或者一个验证页。请求头里真正影响服务端判断的主要是这几项:

请求头字段作用常见坑
User-Agent标识客户端类型用默认的 python-requests 极易被识别
Referer标识来源页面图片站常校验,缺失会返回防盗链图
Accept声明可接受的内容类型一般保持浏览器默认即可
Accept-Language语言偏好部分站点据此返回不同页面
Cookie会话状态需要登录或有防刷时必带

我的习惯是直接用浏览器开发者工具里“Copy as cURL”,然后把无关字段删掉,保留上面这几项。这样拿到的请求头和真实浏览器几乎一致,成功率最高。注意 User-Agent 要和 Accept-Language 匹配,别搞出一个英文 UA 配中文语言包的组合,反而显得可疑。

2.2 用 Session 保持连接与 Cookie

单次requests.get每次都要重新握手,而且 Cookie 不共享。对于需要先访问列表页、再访问详情页的场景,用requests.Session()是标准做法:

import requests session = requests.Session() session.headers.update({ "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-image-site.test/", "Accept-Language": "zh-CN,zh;q=0.9", }) resp = session.get("https://example-image-site.test/pic/list", timeout=10) resp.raise_for_status() html = resp.text

Session 的好处是底层复用了 TCP 连接,批量请求时速度提升明显,同时自动携带服务端下发的 Cookie,模拟出一个连续的浏览会话。timeout一定要设,不设的话遇到慢响应会一直挂着,这是新手最常见的“脚本卡死”原因。

2.3 关于“防止爬虫”的那些前端手段

热词里有“怎么在前端进行防止爬虫”“防止查看页面源码”,这里顺带说清楚:前端能做的防护本质上都是提高成本,而不是真正阻止。常见的几种手段和应对思路如下。

  • 禁用右键和 F12:纯 JS 层面拦截,直接关掉浏览器 JS 或者用开发者工具的独立窗口就能绕过,防护价值极低。
  • 字体混淆 / 图片替换文字:把关键数字或文字用自定义字体映射或雪碧图展示,抓到的文本是乱码。应对方式是下载字体文件做映射还原,成本较高。
  • 接口签名与时间戳校验:请求参数带签名,过期失效。需要逆向 JS 找到签名算法,属于进阶内容。
  • 频率限制与 IP 封禁:这是最有效的,服务端按 IP 或账号统计请求频率。应对方式只能是降低频率、加随机延时,而不是硬刚。

站在抓取方的角度,正确的态度是:控制请求频率,加time.sleep随机间隔,别把人家服务器打挂。站在站点方的角度,真正有效的防护是服务端限流加行为分析,前端那些花活更多是心理安慰。这个项目里,我们只需要保证自己的请求“看起来正常、频率克制”就够了。

3. 解析图片区:XPath 与 CSS 选择器的取舍

3.1 先定位“图片区”这个容器

一个列表页往往有导航、广告、推荐位、页脚等一堆区域,直接全局搜img标签会把 logo、图标、广告图全抓进来。所以第一步是精确定位到“图片区”这个容器。假设页面结构大致如下(虚构示例):

<div class="main-content"> <div class="sidebar">...</div> <div class="pic-list" id="picArea"> <div class="pic-item"> <a href="/detail/1001.html"> <img>from lxml import etree tree = etree.HTML(html) # 定位图片区容器,再取其中所有图片项的详情页链接 items = tree.xpath('//div[@id="picArea"]//div[@class="pic-item"]/a/@href') print(items)

用BeautifulSoup配合 CSS 选择器的示例:

from bs4 import BeautifulSoup soup = BeautifulSoup(html, "lxml") container = soup.select_one("#picArea") links = [a.get("href") for a in container.select("div.pic-item > a")]

两种写法结果一致。我个人的偏好是:解析列表页用 CSS 选择器,因为直观;处理详情页里那些“第几个符合条件的元素”时用 XPath,因为[1]、[last()]这类索引和函数太方便了。

3.3 注意>def pick_img_url(img_tag): for attr in ("data-src", "data-original", "data-lazy", "src"): val = img_tag.get(attr) if val and not val.startswith("data:image"): return val return None

data:image开头的是 base64 内联占位图,必须排除,否则你会下载一堆几十字节的灰色小方块。

4. 从列表页到原图:二次请求与地址还原

4.1 列表页给的是缩略图怎么办

很多图片站的列表页只展示缩略图,原图在详情页里。这时候流程就变成“列表页拿详情链接 → 进详情页拿原图地址 → 下载”。多一层请求,也就多一层失败可能,所以每一步都要做异常处理。

def get_detail_img_url(session, detail_url): resp = session.get(detail_url, timeout=10) resp.raise_for_status() tree = etree.HTML(resp.text) # 详情页里通常有一个主图容器,取其中最大的那张 urls = tree.xpath('//div[contains(@class,"detail-pic")]//img/@src') return urls[0] if urls else None

这里用contains(@class, ...)而不是精确匹配,是因为详情页的 class 经常带状态后缀,比如detail-pic active,精确匹配会漏掉。

4.2 缩略图地址到原图地址的规律还原

有些站点更省事,缩略图和原图只是路径或文件名不同,比如缩略图是/thumb/1001.jpg,原图是/origin/1001.jpg,或者缩略图带_small后缀。这种情况下不需要进详情页,直接字符串替换就能拿到原图,速度快很多。

def thumb_to_origin(thumb_url): # 演示用的替换规则,实际要按目标站点规律调整 return thumb_url.replace("/thumb/", "/origin/").replace("_small", "")

但要注意:这种规律不是所有站点都成立,有的站点缩略图和原图是完全不同的文件名(带哈希),那就只能老老实实进详情页。判断方法是随便挑几张图,手动对比缩略图和原图的 URL,看有没有稳定的映射关系。有就省事,没有就别偷懒。

4.3 相对路径转绝对路径

解析出来的链接经常是/detail/1001.html这种相对路径,直接请求会报错。需要用urljoin拼成绝对地址:

from urllib.parse import urljoin base = "https://example-image-site.test/pic/list" detail_url = urljoin(base, "/detail/1001.html") # 结果:https://example-image-site.test/detail/1001.html

urljoin会自动处理各种斜杠和层级问题,比手动拼字符串靠谱得多。这一步千万别省,我见过太多人因为相对路径没转,请求全打到本地或者报MissingSchema错误。

5. 下载、命名与归档的实操细节

5.1 流式下载与超时控制

图片是二进制文件,用resp.content一次性读进内存对小图没问题,但遇到几 MB 的大图,批量下载时内存会飙。更稳的做法是流式下载:

def download_image(session, url, save_path): with session.get(url, timeout=15, stream=True) as resp: resp.raise_for_status() with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk)

stream=True让响应体不立即加载,iter_content按块写入,内存占用稳定在几 KB 级别。chunk_size用 8192 字节是经验值,太小会增加 IO 次数,太大对内存又没好处。

5.2 文件命名与去重

命名要解决两个问题:可读性和唯一性。直接用原图 URL 的最后一段做文件名最省事,但不同目录下可能有同名文件,会互相覆盖。我的做法是“序号 + 原始文件名”:

import os from pathlib import Path def build_save_path(save_dir, index, url): name = os.path.basename(url.split("?")[0]) or f"img_{index}.jpg" return Path(save_dir) / f"{index:03d}_{name}"

url.split("?")[0]是为了去掉查询参数,避免文件名里出现?这种非法字符。序号用三位补零,排序后顺序和页面一致,方便对照。如果担心重复下载,可以在下载前检查文件是否已存在,存在就跳过。

5.3 格式识别与扩展名修正

有时候 URL 里没有扩展名,或者扩展名和实际格式不符(比如.jpg实际是 webp)。稳妥的做法是下载后根据文件头魔数判断真实格式:

def guess_ext(data_head): if data_head.startswith(b"\xff\xd8\xff"): return ".jpg" if data_head.startswith(b"\x89PNG"): return ".png" if data_head.startswith(b"GIF8"): return ".gif" if data_head[:4] == b"RIFF" and data_head[8:12] == b"WEBP": return ".webp" return ".bin"

读文件头只需要前 12 个字节,成本极低。这个技巧在处理那些“URL 写 jpg 实际返回 webp”的站点时特别有用,否则你本地会有一堆打不开的假 jpg。

6. 常见问题与排查技巧实录

6.1 请求返回 403 或验证页

这是最高频的问题。排查顺序如下:

  1. 检查 User-Agent 是否还是默认的python-requests,换成浏览器 UA。
  2. 检查 Referer 是否缺失,图片站尤其敏感。
  3. 检查是否请求频率过高,加time.sleep(random.uniform(1, 3))。
  4. 检查是否需要 Cookie,先用 Session 访问一次首页再请求目标页。

如果以上都做了还是 403,可能是站点用了更严格的指纹校验(比如 TLS 指纹),这时候纯 requests 就比较难了,需要考虑其他方案,但这类站点占比不高。

6.2 解析结果为空

选择器写对了但结果为空,常见原因有三个:一是页面结构其实是 JS 渲染的,HTML 源码里根本没有目标元素;二是编码问题,resp.text用了错误的编码导致中文乱码,选择器里的中文匹配不上;三是 XPath 里的 class 匹配用了精确等号,而实际 class 有多个值。

编码问题可以显式指定:resp.encoding = resp.apparent_encoding,让 requests 根据内容自动判断。class 问题就用contains代替等号。

6.3 下载的图片打不开

多半是两种情况:一是下载到了防盗链返回的占位图(通常很小,几百字节),二是把 HTML 错误页当图片存了。排查方法是打印文件大小和文件头,如果大小异常小或者文件头是<!DOCTYPE或<html,那就是没拿到真图。这时候要回头检查 Referer 和 Cookie。

6.4 常见问题速查表

现象可能原因解决方向
403 ForbiddenUA/Referer 缺失或频率过高补请求头,加随机延时
解析结果为空动态渲染 / 编码错误 / 选择器过严查源码、修编码、用 contains
图片打不开防盗链占位图 / 存了错误页补 Referer,校验文件头
脚本卡死未设 timeout所有请求加 timeout
文件名乱码编码未处理用 URL 原始名或做转义
重复下载未做去重下载前检查文件存在

6.5 几个踩过坑才懂的经验

第一,先手动跑通一张图再写循环。很多人一上来就写完整脚本,结果报错时不知道是请求问题、解析问题还是下载问题。正确姿势是先用交互式环境把“请求→解析→拿到一个图片 URL→下载一张”这条链路走通,确认无误再套循环。

第二,把中间结果落盘。解析出来的 URL 列表先存成 txt,下载阶段再读这个 txt。这样解析逻辑改了不用重新请求,下载失败了也不用重新解析,调试效率翻倍。

第三,日志比 print 好用。用logging模块记录每个 URL 的请求状态和耗时,出问题时一眼能看出是哪一步慢、哪一步失败。print 在批量任务里刷屏太快,根本看不清。

第四,别用多线程硬冲。热词里有“python 线程嵌套线程”“分布式爬虫”,但小规模抓取用多线程很容易把目标站打挂,也容易触发封禁。单线程加 1 到 3 秒随机延时,抓个几十上百张图完全够用,稳定压倒一切。

7. 一个可直接复现的完整骨架

把前面的片段串起来,给一个结构清晰的完整示例。注意域名和选择器都是虚构的,实际使用时替换成你自己的目标。

import os import time import random import logging from pathlib import Path from urllib.parse import urljoin import requests from lxml import etree logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") BASE = "https://example-image-site.test" LIST_URL = urljoin(BASE, "/pic/list") SAVE_DIR = Path("./downloads") SAVE_DIR.mkdir(exist_ok=True) session = requests.Session() session.headers.update({ "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": BASE + "/", "Accept-Language": "zh-CN,zh;q=0.9", }) def fetch_list(): resp = session.get(LIST_URL, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding tree = etree.HTML(resp.text) hrefs = tree.xpath('//div[@id="picArea"]//div[@class="pic-item"]/a/@href') return [urljoin(BASE, h) for h in hrefs] def fetch_origin(detail_url): resp = session.get(detail_url, timeout=10) resp.raise_for_status() tree = etree.HTML(resp.text) urls = tree.xpath('//div[contains(@class,"detail-pic")]//img/@src') return urljoin(BASE, urls[0]) if urls else None def download(url, index): name = os.path.basename(url.split("?")[0]) or f"img_{index}.jpg" path = SAVE_DIR / f"{index:03d}_{name}" if path.exists(): logging.info("skip existing %s", path.name) return with session.get(url, timeout=15, stream=True) as resp: resp.raise_for_status() with open(path, "wb") as f: for chunk in resp.iter_content(8192): if chunk: f.write(chunk) logging.info("saved %s", path.name) def main(): details = fetch_list() logging.info("found %d items", len(details)) for i, d in enumerate(details, 1): try: origin = fetch_origin(d) if origin: download(origin, i) except Exception as e: logging.warning("item %d failed: %s", i, e) time.sleep(random.uniform(1, 3)) if __name__ == "__main__": main()

这个骨架覆盖了请求、解析、二次请求、下载、去重、异常处理和限速,直接改选择器和域名就能用。跑之前建议先把fetch_list的结果打印出来确认数量对不对,再放开下载。

8. 关于合规与长期维护的几句实在话

抓取这件事,技术只是门槛的一半,另一半是边界感。目标站点的 robots 协议、版权声明、服务条款,动手前花两分钟看一眼,能省掉很多麻烦。个人学习、小规模素材整理和批量搬运是两回事,前者控制频率、注明来源,后者涉及版权风险,别混为一谈。

从维护角度看,这类脚本最大的敌人是“站点改版”。今天能跑不代表下个月还能跑,所以选择器尽量选语义稳定的属性,把解析逻辑和下载逻辑解耦,中间结果落盘,这样改版时只需要修解析那一小段。我自己维护的几个小工具都是这个结构,改起来十分钟搞定,不用从头重写。

最后分享一个我常用的调试习惯:把每次抓取的 URL 列表、成功数、失败数写进一个日志文件,跑完看一眼统计。如果失败率突然升高,说明站点可能加了新限制,这时候再去针对性排查,比盲目重跑高效得多。抓取是个细活,稳扎稳打比追求速度重要得多。

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

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

立即咨询