尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python批量下载网页图片:GrabImage.py抓图工具实战解析

Python批量下载网页图片:GrabImage.py抓图工具实战解析 简介GrabImage.py 是一份面向工业相机图像采集的 Python 实践脚本主要服务于自动化产线监控、质量检测与精密测量等场景帮助开发者快速解决相机与 Python 环境联调时的图像抓取问题。脚本基于 OpenCV 的 VideoCapture 模块完整演示了相机连接、单帧读取、曝光与增益参数调节、实时显示及图像保存并在结束时正确释放相机资源代码结构简洁便于直接复用或二次封装。资源包共 1 个 py 文件压缩包仅 4KB内容轻量但覆盖了工业相机编程的关键环节。已有 2763 人浏览/学习适合初入工业视觉的工程师和自动化设备调试人员。通过这份脚本读者可以掌握 ret、frame 返回值的判断方式熟悉常用相机参数的含义并理解采集流程的资源管理细节为后续图像处理与分析打下扎实基础。1. 为什么我非写GrabImage.py不可素材收集这件事太磨人做内容运营和素材整理的朋友应该都有过这种经历遇到一个设计很好的页面想把里面的配图批量存下来或者写报告时需要从某个图片来源站取一批参考图又或者给客户做提案需要快速收集竞品的界面截图。我最早也是手动一张张右键另存为几十张图点下来手酸不说还经常出现图片命名混乱、下载一半中断的情况。后来实在受不了这种重复劳动才决定把抓图这件事做成一个脚本工具于是就有了GrabImage.py。你可能要问市面上现成的下载工具那么多为什么还要自己写脚本我的答案很简单现成工具解决的是通用场景但实际工作里遇到的几乎都是带点特殊要求的场景。比如我需要下载的不是页面里的全部图片而是符合某个尺寸阈值的图片有些图片藏在CSS背景里浏览器右键根本点不出来有些站点加了防盗链普通下载器直接返回403。这些情况用现成的网页图片下载器往往很难配置但换成写一个几十行的Python脚本反而想怎么改就怎么改。GrabImage.py的设计目标非常明确输入一个网页地址或一组图片URL就能把图片批量抓取到本地并且在抓取过程中处理掉那些常见的反爬、延迟、文件损坏问题。它不是一个野心很大的爬虫框架而是一个干脏活的小工具适合以下人群经常需要从网页批量收集图片素材的设计师和内容编辑做数据采集和视觉数据集整理的技术人员入坑Python爬虫、想理解HTTP请求与图片下载完整链路的学习者纯粹想用脚本替代重复手工操作的效率党这篇文章我不打算只贴一段代码就完事。我会把GrabImage.py从第一版能跑到稳定可靠的完整过程拆开讲包括代码怎么设计、每个关键选择背后的理由、以及我在真实抓取中踩过的那些坑。你可以把它当成一份可直接照做的工程笔记。2. 第一版核心实现一个能跑的批量抓图脚本长什么样2.1 抓图的最小链路用requests拿字节流用文件IO落盘任何图片下载脚本的核心链路只有三步构造HTTP请求、接收响应字节流、把字节流写入文件。GrabImage.py第一版就是按照这个最小链路写的依赖库只用了requests和标准库里的pathlib不需要装什么重型框架。import requests from pathlib import Path def download_image(url: str, save_dir: str, filename: str None): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() if not filename: filename url.split(/)[-1].split(?)[0] if not filename: filename image_ hashlib.md5(url.encode()).hexdigest() .jpg save_path Path(save_dir) / filename save_path.parent.mkdir(parentsTrue, exist_okTrue) save_path.write_bytes(resp.content) return str(save_path)这段代码有三个细节需要解释一下因为它们解决了实际下载中最常见的三个问题第一个是User-Agent。很多服务器会拦截默认的Python-requests标识返回403或者干脆不返回数据。伪装成Chrome浏览器是最基本的礼貌大部分情况下都能绕过去。第二个是文件名提取逻辑。直接拿URL最后一段当文件名看起来简单实际却会遇到两种坑一是URL以斜杠结尾split(/)后拿到的是空字符串二是URL末尾带了查询参数比如image.jpg?width800height600如果不处理存下来的文件名就会是一长串乱码。所以代码里先尝试从路径段提取如果为空就用URL的MD5值兜底。第三个是timeout参数。它不是为了装样子而是防止某个URL迟迟不响应时整个程序卡死在那里。加了timeout之后每次请求最多等10秒超时直接抛异常。2.2 从下载单张到批量抓取页面解析需要的两个关键工具GrabImage.py不可能只会下载单张图片它真正要解决的是我在页面上看到很多图想一次搞定。所以第一版还需要另一个能力从网页HTML里找出所有图片URL。这一步涉及到两个核心工具requests拿HTML源码然后配合正则表达式或解析库提取img标签的src属性。import re from urllib.parse import urljoin def extract_image_urls(page_url: str, html: str) - list: # 匹配 src... 形式的图片标签 img_pattern re.compile(rimg[^]src[\]([^\])[\], re.IGNORECASE) raw_urls img_pattern.findall(html) absolute_urls [] for raw in raw_urls: if raw.startswith(data:): continue # base64内嵌图直接跳过 abs_url urljoin(page_url, raw) absolute_urls.append(abs_url) return list(set(absolute_urls))这里有一个非常容易忽略的坑HTML里的src属性很多是相对路径比如/uploads/2024/01/pic.jpg而不是完整的https://example.com/uploads/2024/01/pic.jpg。如果你直接拿相对路径去请求requests会报错。所以必须用urljoin把相对路径和页面URL拼接成绝对URL这是写爬虫类脚本的常识级操作但很多新手会漏掉。另外一个坑是data:开头的src表示图片被base64编码直接嵌在HTML里这种图片没有独立的URL可以跳过不处理也可以单独解码保存第一版为了省事直接跳过了。用正则提取img标签在HTML结构简单、规范的情况下完全够用。但如果目标页面比较复杂比如用了JS动态渲染图片不是直接写在HTML里就需要上更重量级的解析方案我后面会单独讲。2.3 跑通第一版后我发现能下载只是开胃菜第一版脚本写完后我拿自己博客的页面做了一个测试。当时感受是确实能批量下载了但随之而来的是好几个更麻烦的问题。首先是下载下来的文件怎么全是坏的。我的博客图床有防盗链直接带我的博客域名Referer访问没问题但脚本默认的requests请求不带Referer头服务器返回的是一张占位错误图或者干脆是HTML错误页。这就牵扯出我后面要讲的反盗链机制。其次是同一个页面的图片命名混乱。图床的URL通常长这样https://cdn.example.com/files/2024/05/abc123.jpg文件名提取出来是abc123.jpg但页面里图片原本有语义化命名比如流程图.png。如果不做映射下载下来的文件基本就是无意义的字符串序列后期整理素材反而更费劲。再者是响应断断续续。下载大图时网络一抖动requests.get超时直接抛异常整批下载就中断了。有没有办法做到单张失败不阻塞整体这些都是第一版脚本没有考虑到的也正是后面一步步迭代的方向。所以我建议你如果也准备写类似的脚本不要把能下载当作成功标准而要考虑抓下来的文件能不能用、命名是否可识别、中途失败会不会影响整体。这三个问题才是决定工具价值的分水岭。3. 抓回来一堆打不开的文件防盗链、超时与响应校验的真相3.1 防盗链的完整应对思路Referer和Origin不是玄学我自己在做GrabImage.py的时候遇到最多的问题就是下载下来的文件打不开。明明请求没报错文件大小也正常但图片预览器就是提示文件损坏。后来我把返回的字节流打印出来才发现里面根本不是图片数据而是一段HTML错误页面内容大概是403 Forbidden或者访问来源不被允许。这就是典型的防盗链机制。服务端检查HTTP请求头里的Referer字段如果发现来源域名不在白名单内就不返回真实图片。浏览器正常访问时Referer会自动带上当前页面的域名所以图能正常显示但requests发起请求时默认不设置Referer服务器自然就把它当成陌生人拦下了。解决思路也简单构造请求时把Referer设置成目标图片所在页面的域名def get_headers(url: str, referer: str None): parsed urlparse(url) default_referer f{parsed.scheme}://{parsed.netloc}/ return { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: referer or default_referer, Accept: image/avif,image/webp,image/apng,image/*,*/*;q0.8, }这里又有个小细节Referer不一定等于图片所在页面的URL有的站点要求Referer必须是某个固定入口域名。更稳妥的做法是把Referer设置为请求头Headers里的完整页面URL。如果这样还被拦就得观察真实的浏览器网络请求比对一下浏览器发了哪些头字段把它们全部复制过来。还有一个容易被忽略的点是Origin头。某些CDN和云存储服务不仅检查Referer还会检查Origin。这类服务通常有较严格的跨域策略如果Origin不一致同样会拒绝响应。不过大多数情况下带上一个跟Referer同域的Origin就能通过。3.2 响应内容校验光看status_code还不够很多人写下载脚本时只检查status_code 200但200响应体里装的不一定是图片。防盗链返回的错误页也可能带着200状态码更不用说有些服务器对不存在的图片会返回一个自定义的404 Not Found页面状态码同样是200。所以GrabImage.py在保存文件前必须做一道关卡校验Content-Type响应头以及文件字节流的前几个字节是不是真实的图片签名。IMAGE_SIGNATURES { jpg: (b\xff\xd8\xff, 3), png: (b\x89PNG\r\n\x1a\n, 8), gif: (bGIF87a, 6), webp: (bRIFF, 4), } def is_valid_image(content: bytes) - bool: for ext, (signature, length) in IMAGE_SIGNATURES.items(): if content[:length] signature: return True return False图片文件都有固定的魔数magic number比如JPEG文件头是FF D8 FFPNG文件头是八字节的固定序列。这些签名基本不会和文本内容冲突所以用它来判断文件是不是有效图片比单纯看扩展名可靠得多。完整实现了这个检查之后GrabImage.py才真正会放弃那些披着图片外衣的错误页。3.3 网络超时和SSLError批量抓图必须处理的生产环境问题批量抓图的时候网络问题会被放大。几十上百个请求里总有一两个因为DNS解析慢、连接被重置、TLS握手失败而抛异常。如果脚本不做处理一个异常就让整批任务中断体验非常差。我的处理策略分三层第一层为requests.get设置合理的timeout值。不要用requests默认的None永远等待也不要设置得太短导致正常图片还没下载完就被强制中断。我一般设成连接超时10秒、读取超时30秒的组合timeout(10, 30)。第二层捕获特定异常并做有限次重试。网络错误通常是瞬时性的重试一两次往往就好了。但要注意不能无限重试否则遇到一个一直失败的URL整个程序会卡死在那里。第三层对SSLError单独捕获。有些站点证书过期或不受信任requests默认会拒绝连接。如果确认目标站点安全可以在请求时加verifyFalse并关闭警告但这属于权衡之下的操作生产环境里不建议滥用。这层处理做完之后GrabImage.py才真正具备了无人值守的能力。我后来跑一个一千多张图片的采集任务中间遇到几十个失败脚本会自动重试最终只有几个顽固失败被记录到日志里不影响整体完成。4. 从能用到好用并发下载和文件命名的实战取舍4.1 串行下载太慢线程池是最小成本的提速方案第一版脚本是串行的下载完一张再下载下一张。下载50张平均大小200KB的图片串行耗时基本在30到50秒。这个速度自己手动操作勉强能接受但如果是几百张图等待过程就会让人怀疑人生。提速最直接的方式是引入并发。Python的concurrent.futures.ThreadPoolExecutor非常适合这种IO密集型的任务——图片下载的大部分时间都花在等待网络响应上CPU几乎没有负担用多线程就能轻松把速度提上去而且代码改动极小。from concurrent.futures import ThreadPoolExecutor, as_completed def batch_download(urls: list, save_dir: str, max_workers: int 8): results [] with ThreadPoolExecutor(max_workersmax_workers) as executor: future_to_url { executor.submit(download_image, url, save_dir): url for url in urls } for future in as_completed(future_to_url): url future_to_url[future] try: path future.result() results.append((url, path, ok)) except Exception as e: results.append((url, str(e), failed)) return results线程数不是越大越好。我试过把max_workers调到32结果有些站点因为同一IP的并发连接数限制开始返回429Too Many Requests反而拖慢了整体速度。现在默认设置在8到12之间对大多数图片站点都比较友好。如果你要抓的目标站比较严格可以先手动下调到4观察一下日志里的失败率再做决定。4.2 命名策略为什么我不用默认文件名前面提到过直接从URL提取文件名往往得到一串无意义字符。对于需要后续人工整理素材的场景这是一个很大的痛点。我在GrabImage.py里引入了两套命名方案第一套是保持原文件名适用于URL末尾本身就是语义化文件名的情况比如cover-main.jpg、report-2025.png。这种情况下只要把查询参数截掉就行。第二套是按月归档加序号适用于URL完全没有可读文件名的情况。脚本会按照2025-06-图片序号.jpg的格式重命名并在下载时维护一个计数器确保文件名唯一。这样即使URL里的文件名再乱落到本地也是一目了然的序列。这两套方案由命令行参数控制默认走第二套因为默认情况下可读性比保留原始名更重要。4.3 落盘逻辑临时文件 原子性重命名最后我想聊一个很多人不会注意、但在实际使用中非常关键的细节文件写入方式。第一版的write_bytes是直接把内容写到最终文件路径。如果下载到一半脚本被中断或者磁盘空间满了本地会留下一个残缺的半截文件。这个文件看起来存在但大小不对你后期清理素材时还得手动一个个排查。更稳妥的做法是先写入一个临时文件等文件全部写完并校验通过后再用os.replace做一次原子性重命名。这样磁盘上任何时候都不会出现下载了一半的文件要么不存在要么就是完整的。import os, tempfile def atomic_write(content: bytes, target_path: Path): tmp_path target_path.with_suffix(target_path.suffix .tmp) tmp_path.write_bytes(content) os.replace(tmp_path, target_path)这段代码的原理很简单先用.tmp后缀写入写完后调用os.replace把临时文件重命名为目标文件。这个操作在同一个文件系统内是原子的进程崩溃也不会产生半截文件。5. 命令行化与进阶场景GrabImage.py能处理的比你想的更多5.1 从脚本到命令行工具argparse让参数配置不再修改代码当GrabImage.py的功能越加越多每次都去改代码里的配置项变得很痛苦。我后来把它改造成了标准的命令行工具用Python自带的argparse接收参数这样就能在不改代码的前提下切换各种场景python grabimage.py fetch --url https://example.com/page --output ./images --concurrency 8 python grabimage.py download --urls urls.txt --output ./images --rename python grabimage.py extract --video video.mp4 --interval 5这里我已经悄悄给GrabImage.py增加了第二个子命令extract因为实际工作中从视频里抽帧也是图像抓取的一种常见需求。这个命令通过OpenCV逐帧读取视频按固定的时间间隔或者帧间隔保存图像。对于需要做数据集、需要从视频里截取关键画面的场景这个功能非常实用。至于第三个子命令download它支持传入一个文本文件里面每行一个图片URL适合那些已经有现成URL列表、不需要走页面解析的场景。5.2 特殊场景一懒加载页面和CSS背景图很多现代网站为了优化首屏速度图片并不会直接写在HTML源码里而是通过JavaScript实现懒加载。图片URL可能藏在style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表