2026/10/9 13:17:54

Python爬虫实战:requests抓取图片站第一页所有图片

Python爬虫实战:requests抓取图片站第一页所有图片 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 整体流程的四个阶段把整个抓取拆成流水线会清晰很多请求阶段构造带合理请求头的 GET 请求拿到列表页 HTML。解析阶段从 HTML 中定位“图片区”容器提取其中每个图片项的详情页链接或直接的原图链接。二次请求阶段如果列表页只给了缩略图和详情页链接就需要再进详情页拿原图地址。下载与归档阶段对图片二进制流写文件处理重名、失败重试、格式识别。这四个阶段里坑最多的是解析和下载。解析的坑在于选择器写得太脆站点改个 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;q0.9, }) resp session.get(https://example-image-site.test/pic/list, timeout10) resp.raise_for_status() html resp.textSession 的好处是底层复用了 TCP 连接批量请求时速度提升明显同时自动携带服务端下发的 Cookie模拟出一个连续的浏览会话。timeout一定要设不设的话遇到慢响应会一直挂着这是新手最常见的“脚本卡死”原因。2.3 关于“防止爬虫”的那些前端手段热词里有“怎么在前端进行防止爬虫”“防止查看页面源码”这里顺带说清楚前端能做的防护本质上都是提高成本而不是真正阻止。常见的几种手段和应对思路如下。禁用右键和 F12纯 JS 层面拦截直接关掉浏览器 JS 或者用开发者工具的独立窗口就能绕过防护价值极低。字体混淆 / 图片替换文字把关键数字或文字用自定义字体映射或雪碧图展示抓到的文本是乱码。应对方式是下载字体文件做映射还原成本较高。接口签名与时间戳校验请求参数带签名过期失效。需要逆向 JS 找到签名算法属于进阶内容。频率限制与 IP 封禁这是最有效的服务端按 IP 或账号统计请求频率。应对方式只能是降低频率、加随机延时而不是硬刚。站在抓取方的角度正确的态度是控制请求频率加time.sleep随机间隔别把人家服务器打挂。站在站点方的角度真正有效的防护是服务端限流加行为分析前端那些花活更多是心理安慰。这个项目里我们只需要保证自己的请求“看起来正常、频率克制”就够了。3. 解析图片区XPath 与 CSS 选择器的取舍3.1 先定位“图片区”这个容器一个列表页往往有导航、广告、推荐位、页脚等一堆区域直接全局搜img标签会把 logo、图标、广告图全抓进来。所以第一步是精确定位到“图片区”这个容器。假设页面结构大致如下虚构示例div classmain-content div classsidebar.../div div classpic-list idpicArea div classpic-item a href/detail/1001.html img>from lxml import etree tree etree.HTML(html) # 定位图片区容器再取其中所有图片项的详情页链接 items tree.xpath(//div[idpicArea]//div[classpic-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 Nonedata:image开头的是 base64 内联占位图必须排除否则你会下载一堆几十字节的灰色小方块。4. 从列表页到原图二次请求与地址还原4.1 列表页给的是缩略图怎么办很多图片站的列表页只展示缩略图原图在详情页里。这时候流程就变成“列表页拿详情链接 → 进详情页拿原图地址 → 下载”。多一层请求也就多一层失败可能所以每一步都要做异常处理。def get_detail_img_url(session, detail_url): resp session.get(detail_url, timeout10) 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.htmlurljoin会自动处理各种斜杠和层级问题比手动拼字符串靠谱得多。这一步千万别省我见过太多人因为相对路径没转请求全打到本地或者报MissingSchema错误。5. 下载、命名与归档的实操细节5.1 流式下载与超时控制图片是二进制文件用resp.content一次性读进内存对小图没问题但遇到几 MB 的大图批量下载时内存会飙。更稳的做法是流式下载def download_image(session, url, save_path): with session.get(url, timeout15, streamTrue) as resp: resp.raise_for_status() with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk)streamTrue让响应体不立即加载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 fimg_{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(bGIF8): return .gif if data_head[:4] bRIFF and data_head[8:12] bWEBP: return .webp return .bin读文件头只需要前 12 个字节成本极低。这个技巧在处理那些“URL 写 jpg 实际返回 webp”的站点时特别有用否则你本地会有一堆打不开的假 jpg。6. 常见问题与排查技巧实录6.1 请求返回 403 或验证页这是最高频的问题。排查顺序如下检查 User-Agent 是否还是默认的python-requests换成浏览器 UA。检查 Referer 是否缺失图片站尤其敏感。检查是否请求频率过高加time.sleep(random.uniform(1, 3))。检查是否需要 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(levellogging.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_okTrue) 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;q0.9, }) def fetch_list(): resp session.get(LIST_URL, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding tree etree.HTML(resp.text) hrefs tree.xpath(//div[idpicArea]//div[classpic-item]/a/href) return [urljoin(BASE, h) for h in hrefs] def fetch_origin(detail_url): resp session.get(detail_url, timeout10) 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 fimg_{index}.jpg path SAVE_DIR / f{index:03d}_{name} if path.exists(): logging.info(skip existing %s, path.name) return with session.get(url, timeout15, streamTrue) 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 列表、成功数、失败数写进一个日志文件跑完看一眼统计。如果失败率突然升高说明站点可能加了新限制这时候再去针对性排查比盲目重跑高效得多。抓取是个细活稳扎稳打比追求速度重要得多。