news 2026/10/22 16:14:40

Python爬虫实战:requests与XPath抓取图片站第一页

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫实战:requests与XPath抓取图片站第一页

1. 图片站抓取任务的整体拆解与思路设计

1.1 为什么选这个场景练手

图片站抓取几乎是每个学 Python 爬虫的人都会碰到的第一个"有点意思"的实战项目。它比抓新闻列表直观——抓下来的东西肉眼可见,成不成立刻就知道;又比抓动态接口简单——大部分图片站的列表页是服务端渲染的静态 HTML,不需要逆向接口签名。标题里提到的这个站点,结构上属于典型的"列表页 + 详情页 + 图片资源"三层结构,第一页的图片区通常就是一组缩略图加跳转链接,非常适合拿来把 requests、XPath、文件落盘这条链路完整走一遍。

我先把结论摆在这:抓第一页所有图片,核心就三件事——拿到列表页 HTML、从 HTML 里定位到图片区、把图片区的图片地址提取出来下载。听起来简单,但每一步都有坑,尤其是"图片区"这个限定词,意味着你不能无脑抓页面上所有 img 标签,得先做区域定位,再在区域内做提取。这个"先框范围再取内容"的思路,是后面所有复杂爬虫的基础,值得在这里就养成习惯。

适合谁看:装好了 Python、写过几行 print、想找个真实站点练手的入门者;也适合已经会写爬虫但总在"图片下载不全""文件名乱码""被拦"这些地方翻车的人。下面我会把每一步的选择理由、参数计算、踩坑经验都摊开讲,代码可以直接抄,但更希望你理解为什么这么写。

1.2 整体流程的四个阶段

把任务拆开,实际执行时是四个阶段串起来的:

  1. 请求阶段:用 requests 向列表页发 GET 请求,拿到 HTML 文本。这一步要处理编码、请求头、超时和重试。
  2. 解析阶段:用 lxml 的 XPath 定位"图片区"容器,再在容器内提取每个图片项的链接和缩略图地址。
  3. 补全阶段:列表页给的往往是缩略图或相对路径,需要拼成完整 URL,必要时进详情页拿原图。
  4. 落盘阶段:按规则生成文件名,流式下载,处理重名、格式、目录结构。

这四个阶段里,解析阶段是分水岭。很多人卡在"我明明看到页面上有图,代码就是抓不到",八成是定位写错了,或者图片是 JS 动态加载的。标题里说的是"图片区第一页",我默认它是静态渲染的列表,如果实测发现是动态的,那就要换思路(后面第 4 节会讲怎么判断和应对)。

1.3 工具选型:为什么是 requests + lxml

热词里出现了 requests、xpath、cv2、numpy 这些词,说明大家关心的工具面很广。针对这个任务,我的选型是requests 负责请求,lxml 负责解析,理由如下:

工具用途选它的理由什么时候不用
requests发 HTTP 请求API 直观,会话保持、超时、重试都好控制需要执行 JS 时不够用
lxml + XPath解析 HTML定位精准,支持按文本、按属性、按层级筛选结构极乱时不如 BeautifulSoup 容错
BeautifulSoup解析 HTML容错强,写法简单大文档速度慢于 lxml
cv2 / numpy图片后处理去重、裁剪、格式转换单纯下载用不上

XPath 相比 CSS 选择器,优势在于能按"文本内容"定位,比如某个区块的标题文字是"图片区",我可以用contains(text(), '图片区')直接锚定,这在结构不规范的站点上特别好用。热词里那个"python xpath 爬虫 text 函数"说的就是这个点——text()取节点文本,配合contains()做模糊匹配,是定位中文区块标题的利器。

注意:选 lxml 要装lxml库,Windows 上如果 pip 装不上,去下对应的 whl 文件本地安装,别硬编译。

2. 请求阶段的核心细节与实操要点

2.1 请求头到底要带哪些

裸奔的 requests 请求,User-Agent 是python-requests/x.x.x,稍微有点防护的站点一眼就认出来。所以第一件事是伪装请求头。但也不是越多越好,带多了反而暴露。我的经验是这四个够用:

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", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://目标站点/", }

Referer这个字段很多人忽略,但图片站经常校验它——你直接请求图片 URL 而不带 Referer,服务器可能返回 403。所以下载图片时,Referer 要设成图片所在页面的地址,而不是首页。这一点后面下载环节还会强调。

Accept-Encoding我一般不手动加 gzip,让 requests 自己处理解压,省得手动 decode 出乱码。如果你非要加,记得 requests 会自动解压,不用自己写解压逻辑。

2.2 编码问题:中文乱码的根源

抓中文站点,十有八九会遇到乱码。根源在于:HTTP 响应头里的Content-Type声明的编码,和 HTML 里<meta charset>声明的编码,可能不一致,甚至都不声明。

requests 的判断逻辑是:先看响应头,没有就看apparent_encoding(用 chardet 猜)。猜的经常错。稳妥做法是手动指定:

resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 或者直接写 'utf-8' / 'gbk' html = resp.text

如果抓下来发现中文变成测试这种,说明编码判断错了,把resp.encoding改成'utf-8'试试;如果变成²âÊÔ这种,多半是'gbk'。判断技巧:看乱码的形态,拉丁字母带重音符号的是 UTF-8 被当 Latin-1 读了,方块问号的是 GBK 被当 UTF-8 读了。

实操心得:我习惯先resp.content拿字节,再用chardet.detect()看一眼置信度,置信度低于 0.7 就手动指定。多花两行代码,省半小时排查。

2.3 超时、重试与会话保持

timeout必须设,不设的话网络一卡程序就挂死。我一般设(5, 15)这个元组,5 秒连接超时,15 秒读取超时。连接超时短一点,快速失败;读取超时长一点,给大页面留时间。

重试用requests.adapters.HTTPAdapter配合Retry:

from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) session.mount("http://", HTTPAdapter(max_retries=retry)) session.mount("https://", HTTPAdapter(max_retries=retry))

backoff_factor=0.5意味着重试间隔是 0.5、1、2 秒递增,避免密集重试把对方惹毛。status_forcelist只对 5xx 重试,4xx 不重试——因为 404 你重试一百次还是 404,纯浪费。

用Session而不是每次requests.get,好处是 TCP 连接复用,抓多张图时速度快很多,而且 cookie 自动保持。图片站有时靠 cookie 做简单的访问计数,Session 能帮你维持住。

2.4 请求频率:别把人家站打挂

第一页图片数量有限,一般几十张,但下载图片时是逐个请求。我的做法是每张图之间 sleep 0.2 到 0.5 秒,加一点随机抖动:

import time, random time.sleep(random.uniform(0.2, 0.5))

别小看这个抖动,固定间隔的请求模式很容易被识别成脚本。随机化之后,流量特征更接近真人浏览。这不是为了对抗什么,纯粹是基本的礼貌和自我保护——你也不想跑一半 IP 被限速。

3. 解析阶段:精准定位"图片区"

3.1 先看结构再写代码

写 XPath 之前,一定要先在浏览器里把目标页面的结构看清楚。按 F12,用元素选择器点一下图片区,看它的容器是什么标签、什么 class 或 id。假设结构大致是这样(不同站点不同,这里用通用结构示意):

<div class="main-content"> <div class="picture-area"> <h2>图片区</h2> <ul class="pic-list"> <li><a href="/detail/123.html"><img src="/thumb/123.jpg" alt="图1"></a></li> <li><a href="/detail/124.html"><img src="/thumb/124.jpg" alt="图2"></a></li> </ul> </div> <div class="sidebar">...</div> </div>

关键点:图片区有独立的容器(这里是class="picture-area"),而且它和侧边栏、页脚是平级的。如果你直接//img,会把侧边栏的广告图、logo 全抓进来。所以第一步是框定容器。

3.2 XPath 定位容器的三种写法

定位"图片区"容器,我常用三种写法,按可靠性排序:

写法一:按 class 精确定位(最稳)

container = tree.xpath('//div[@class="picture-area"]')

前提是你确认了 class 名。缺点是 class 可能带多个值,比如class="picture-area clearfix",这时@class="picture-area"就匹配不上了。

写法二:按 class 包含匹配(推荐)

container = tree.xpath('//div[contains(@class, "picture-area")]')

contains()只要包含子串就匹配,容错性好很多。这是我用得最多的写法。

写法三:按区块标题文本锚定(最灵活)

container = tree.xpath('//h2[contains(text(), "图片区")]/parent::div')

先找到文字是"图片区"的标题,再往上找它的父容器。这种写法在 class 名混淆、随机生成的站点上特别管用。热词里"python xpath 爬虫 text 函数"讲的就是这个技巧。

注意:text()只取直接子文本节点。如果标题是<h2><span>图片区</span></h2>,text()取不到,得用//h2[contains(., "图片区")],点号.表示当前节点所有后代文本。这个坑我踩过不止一次。

3.3 在容器内提取图片项

容器拿到后,在它内部提取每个图片项。注意 XPath 的上下文——用container[0].xpath('.//li')而不是tree.xpath('//li'),前面的点号表示"从当前节点往下找",不加点号会从整个文档根找,容器就白框了。

items = container[0].xpath('.//li') for item in items: # 详情页链接 detail_url = item.xpath('.//a/@href') # 缩略图地址 thumb_url = item.xpath('.//img/@src') # 图片描述(alt 属性) alt = item.xpath('.//img/@alt')

这里有个细节:xpath()返回的永远是列表,哪怕只有一个匹配。所以取值要[0],而且要判空,否则 IndexError 直接崩。我习惯写个辅助函数:

def first(lst, default=""): return lst[0].strip() if lst else default

这样first(item.xpath('.//a/@href'))就安全了,取不到返回空字符串,不会崩。

3.4 相对路径补全:别下到一半发现 404

列表页里的src="/thumb/123.jpg"是相对路径,直接拿去请求会失败。必须拼成绝对 URL。用urljoin最稳:

from urllib.parse import urljoin full_url = urljoin(base_url, thumb_url)

urljoin会自动处理各种情况:/thumb/123.jpg拼成https://站点/thumb/123.jpg,thumb/123.jpg拼成https://站点/当前目录/thumb/123.jpg,//cdn.xxx.com/123.jpg拼成https://cdn.xxx.com/123.jpg。别自己写字符串拼接,base + path这种写法遇到斜杠重复或缺失就出 bug。

还有一种情况:src里放的是占位图(比如data:image/gif;base64,...或者loading.gif),真实地址藏在>real_url = first(item.xpath('.//img/@data-src')) or first(item.xpath('.//img/@src'))

4. 实操过程:从零到图片落盘

4.1 完整代码骨架

把前面几节串起来,一个能跑的版本长这样:

import os import time import random import requests from lxml import etree from urllib.parse import urljoin from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry BASE_URL = "https://目标站点/list/1.html" SAVE_DIR = "downloads" def build_session(): session = requests.Session() retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) session.mount("http://", HTTPAdapter(max_retries=retry)) session.mount("https://", HTTPAdapter(max_retries=retry)) 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", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": BASE_URL, }) return session def get_html(session, url): resp = session.get(url, timeout=(5, 15)) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text def parse_images(html, base_url): tree = etree.HTML(html) containers = tree.xpath('//div[contains(@class, "picture-area")]') if not containers: return [] results = [] for item in containers[0].xpath('.//li'): href = item.xpath('.//a/@href') src = item.xpath('.//img/@data-src') or item.xpath('.//img/@src') alt = item.xpath('.//img/@alt') if not src: continue results.append({ "detail": urljoin(base_url, href[0]) if href else "", "img": urljoin(base_url, src[0]), "name": alt[0].strip() if alt else "", }) return results def download(session, url, path): resp = session.get(url, timeout=(5, 30), stream=True) resp.raise_for_status() with open(path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) def safe_name(name, idx, url): if not name: name = f"img_{idx:03d}" name = "".join(c for c in name if c not in r'\/:*?"<>|').strip() ext = os.path.splitext(url.split("?")[0])[1] or ".jpg" return f"{idx:03d}_{name[:50]}{ext}" def main(): os.makedirs(SAVE_DIR, exist_ok=True) session = build_session() html = get_html(session, BASE_URL) images = parse_images(html, BASE_URL) print(f"共解析到 {len(images)} 张图片") for i, item in enumerate(images, 1): fname = safe_name(item["name"], i, item["img"]) fpath = os.path.join(SAVE_DIR, fname) try: download(session, item["img"], fpath) print(f"[{i}/{len(images)}] OK -> {fname}") except Exception as e: print(f"[{i}/{len(images)}] FAIL {item['img']} : {e}") time.sleep(random.uniform(0.2, 0.5)) if __name__ == "__main__": main()

这段代码可以直接改 BASE_URL 和容器 XPath 就能用。下面拆解几个关键设计。

4.2 流式下载与 chunk 大小

stream=True配合iter_content(chunk_size=8192)是下载大文件的标配。为什么不用resp.content一次性读?因为图片可能几 MB,一次性读进内存,抓几百张时内存会飙。流式下载是边收边写,内存占用恒定。

chunk_size=8192(8KB)是个经验值。太小了系统调用频繁,太大了内存波动大。8KB 到 64KB 都行,我一般用 8192。注意iter_content可能返回空 chunk(keep-alive 的填充),所以要if chunk判一下再写。

4.3 文件名生成:三个必须处理的坑

文件名看着简单,实际是翻车重灾区。三个坑:

坑一:非法字符。图片的 alt 文本里可能有/、:、?、*这些,直接当文件名在 Windows 上会报错。所以safe_name里做了字符过滤。

坑二:重名覆盖。两张图 alt 一样,文件名就撞了,后下的覆盖先下的。解决办法是加序号前缀,f"{idx:03d}_"保证唯一。03d表示补零到 3 位,这样文件在资源管理器里是按顺序排的,看着舒服。

坑三:扩展名丢失。URL 可能带查询参数123.jpg?size=big,直接splitext会把.jpg?size=big当扩展名。所以先url.split("?")[0]去掉参数再取扩展名。如果 URL 里压根没扩展名(比如/image/123),就默认.jpg。

实操心得:alt 文本可能很长,我截断到 50 字符。太长的文件名在某些系统上有长度限制,而且看着乱。

4.4 下载时的 Referer 处理

前面提过,图片站常校验 Referer。上面代码里 session 的 Referer 设的是列表页地址,下载图片时用的还是这个 session,所以 Referer 自动带上了。但如果图片来自 CDN,Referer 可能要求是详情页地址。稳妥做法是下载每张图前,把 session 的 Referer 改成该图对应的详情页 URL:

session.headers["Referer"] = item["detail"] or BASE_URL

这样更接近真实浏览行为——用户是从详情页看到大图再保存的。如果还是 403,试试把 Referer 去掉,或者换成站点首页,不同站点策略不同,多试几次就知道了。

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

5.1 抓不到图片:先判断静态还是动态

最常见的求助是"代码跑通了但一张图没抓到"。排查顺序:

第一步,确认 HTML 里有没有图片地址。把get_html拿到的html存成文件,用编辑器搜.jpg。搜得到,说明是静态的,问题在 XPath;搜不到,说明图片是 JS 动态加载的,requests 拿到的 HTML 里根本没有。

第二步,如果是静态的,把 XPath 拿到浏览器控制台里测。F12 的 Console 里输入$x('//div[contains(@class,"picture-area")]'),看能不能选中。选不中就调整 XPath,选得中就说明代码里的解析有问题(多半是编码或容器索引)。

第三步,如果是动态的,requests 这条路走不通了。要么找它背后的数据接口(F12 的 Network 面板,筛选 XHR,看有没有返回图片列表的 JSON 接口),直接请求接口;要么上 Selenium/Playwright 这类能执行 JS 的工具。热词里"关于前端的一些安全技术操作""怎么在前端进行防止爬虫"说的就是这类对抗——但我们的原则是只抓公开的、允许抓的内容,遇到明确的反爬就停手,不硬刚。

5.2 常见问题速查表

现象可能原因排查/解决
中文乱码编码判断错手动设resp.encoding,试 utf-8 / gbk
抓到 0 张图XPath 错 / 动态加载存 HTML 搜.jpg,控制台测 XPath
图片 403缺 Referer下载时设 Referer 为详情页地址
图片是占位图懒加载取>seen = set() if item["img"] in seen: continue seen.add(item["img"])

技巧四:断点续传。抓大量图片时,中途挂了不想重头来。可以在下载成功后往一个done.txt里追加 URL,启动时先读这个文件,已下载的跳过。这个习惯在抓几百上千张图时能救命。

技巧五:验证图片完整性。下载完的图片可能因为网络中断只有一半。简单校验是看文件大小,小于 1KB 的基本是坏的。更严格的是用PIL或cv2尝试打开,打不开就删掉重下。热词里"python 下载 cv2"就是这个用途——cv2.imread返回 None 说明图片损坏。

6. 从第一页到可持续的抓取方案

6.1 第一页跑通后怎么扩展

第一页跑通只是起点。要抓多页,核心是找到翻页规律。常见的有三种:

  • URL 规律:/list/1.html、/list/2.html,直接改数字循环。
  • 参数规律:/list?page=1,改 query 参数。
  • 无规律:翻页链接在页面里,得先解析出"下一页"的 href,再请求。

前两种直接构造 URL 循环即可,第三种要写个循环:请求当前页 → 解析图片 → 解析下一页链接 → 请求下一页,直到没有下一页。注意设个最大页数上限,防止死循环。

6.2 并发下载:线程池的正确用法

单线程下载几十张图还行,几百张就慢了。用concurrent.futures.ThreadPoolExecutor并发下载:

from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=5) as pool: futures = {pool.submit(download, session, it["img"], path): it for it, path in tasks} for fut in as_completed(futures): try: fut.result() except Exception as e: print("fail:", e)

max_workers=5是保守值。别开太大,一是对站点压力大,二是 requests 的 Session 在多线程下共享要注意线程安全(一般读操作没问题,但保险起见可以每个线程一个 Session)。热词里"python 线程嵌套线程""python 协程"讲的就是并发这块,入门阶段用线程池足够,协程(aiohttp)性能更好但学习曲线陡,等有需求再上。

6.3 合规与边界:什么该抓什么不该抓

最后必须说清楚边界。抓图片这件事,技术上能做,不代表什么都能做:

  • 只抓公开可访问的内容,需要登录、付费、有明确访问限制的,不碰。
  • 遵守站点的 robots.txt,它声明了哪些路径不允许抓取,看一眼不费事。
  • 控制频率,别把人家服务器打挂,这是最基本的尊重。
  • 抓下来的内容仅用于个人学习研究,不二次分发、不商用。
  • 遇到明确的反爬机制就停手,不研究绕过手段。热词里那些"防止查看页面源码""防止打开"的话题,从站点角度是合理的自我保护,我们作为抓取方应该尊重。

我个人在实际操作中的体会是:爬虫技术本身是中性的,关键看用在哪、怎么用。把目标定在"学习 HTTP 请求、HTML 解析、文件 IO 这条完整链路",抓第一页几十张图练手,完全够用,也完全正当。等技术练熟了,去抓自己有权访问的数据、做自己的数据分析,那才是这门手艺真正的价值所在。

代码写到最后,你会发现真正花时间的不是那几行 requests 和 XPath,而是编码、超时、重试、文件名、去重这些"脏活"。把这些细节处理干净,你的爬虫才算从"能跑"变成"能用"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 0:26:27

Windows 64位Oracle 12c客户端安装配置与TNS解析避坑指南

简介&#xff1a;Oracle 12c 客户端 Windows 64 位官方安装包&#xff0c;面向需要在 Windows 平台连接和管理 Oracle 数据库的 DBA、后端开发与运维人员。它提供 SQL*Plus、SQL Developer、Oracle Net Services 等核心组件&#xff0c;解决客户端与不同操作系统数据库服务器之…

作者头像 李华
网站建设 2026/10/20 1:33:02

Win7 64位打印机装不上?PrintSpooler服务排查与修复指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 20:49:04

编译产物如何落地?从本地运行到手机端部署的完整指南

编译成功之后&#xff0c;很多人会愣在原地。无论是用 GCC、MSVC 还是其他编译器&#xff0c;辛辛苦苦把源码撸成了可执行文件&#xff0c;结果下一个问题立刻冒出来&#xff1a;这东西到底怎么在本地跑起来&#xff1f;怎么塞进手机里用&#xff1f;网上教程往往只讲到“编译通…

作者头像 李华
网站建设 2026/10/16 5:18:57

Claude 拔网线后,Trae 用户的 AI 编程工作流还能怎么接 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华

关于博客

这是一个专注于编程技术分享的极简博客,旨在为开发者提供高质量的技术文章和教程。

订阅更新

输入您的邮箱,获取最新文章更新。

© 2025 极简编程博客. 保留所有权利.