news 2026/10/30 16:43:45

Python批量下载URL文件:并发、断点续传与去重实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python批量下载URL文件:并发、断点续传与去重实战

简介:URL文件批量下载器是一款面向内容创作者、数据分析师与网络管理员的实用工具,用于解决从各类网站批量获取图片、文档、音频等网络资源时逐个点击、效率低下的问题。资源包共5个文件,包含可执行程序、动态链接库、说明文档与示例URL列表等,涵盖exe主程序、dll运行依赖、htm与docx使用说明以及txt链接清单,压缩包整体约1.22MB,体积轻巧、解压即用。工具围绕URL解析与批量任务调度展开,支持导入链接列表、设置下载目录与线程参数、查看进度与日志,并具备断点续传与失败重试等排错思路,便于读者理解HTTP/HTTPS协议与多线程下载的基本原理。目前已有2526人学习下载,适合需要频繁采集网络素材、希望提升资源获取效率的初中级用户参考使用。

1. URL文件批量下载器:从手工点击到脚本化落地的分水岭

手里攥着几百条 URL,每条指向一个 PDF、一张图片或者一个压缩包,浏览器里一个个点开、另存为、改文件名——这件事我干过整整一个下午,最后手指发酸,还漏了十几条。URL 文件批量下载器要解决的就是这个场景:把一份 URL 清单变成本地一堆命名规整的文件。它适合做数据采集、素材归档、报表拉取、模型训练集准备的人,也适合任何需要把「链接列表」变成「本地目录」的从业者。核心诉求只有三个:并发要可控、失败要能重试、文件名要能自己定。下面按「先跑通最小版本,再补并发和断点续传,最后处理反爬和异常」的路径拆开讲,代码可以直接抄。

2. 最小可用下载器:20 行代码把 URL 清单变成文件

2.1 为什么先用 requests 而不是 wget 或 curl

命令行工具 wget 和 curl 确实能批量下载,wget -i urls.txt一行就完事。但一旦遇到这几种情况就会卡住:需要自定义请求头、需要根据响应内容动态命名文件、需要跳过已下载的、需要把失败 URL 单独记录。这些在 shell 里写起来很别扭,而 Python 的 requests 库把这些控制权全部交回给你。常见做法是用 requests + os + 一个 for 循环起步,跑通之后再引入并发。

选 requests 而不是 aiohttp 的原因是:起步阶段并发不是瓶颈,代码可读性更重要。aiohttp 的 async/await 写法对新手不友好,调试也麻烦。等单线程跑通了、确认瓶颈确实在网络等待上,再换并发方案也不迟。

2.2 最小可运行脚本与逐行说明

import os import requests from urllib.parse import urlparse, unquote # URL 清单文件,每行一条 URL_FILE = "urls.txt" # 下载目录 OUT_DIR = "downloads" # 超时时间(连接+读取),单位秒 TIMEOUT = 30 # 自定义请求头,很多站点缺 UA 会直接 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" } os.makedirs(OUT_DIR, exist_ok=True) with open(URL_FILE, "r", encoding="utf-8") as f: urls = [line.strip() for line in f if line.strip()] for idx, url in enumerate(urls, 1): try: resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT, stream=True) resp.raise_for_status() # 非 2xx 直接抛异常 # 从 URL 路径提取文件名,unquote 处理中文和空格 path = urlparse(url).path filename = unquote(os.path.basename(path)) or f"file_{idx}" filepath = os.path.join(OUT_DIR, filename) with open(filepath, "wb") as out: for chunk in resp.iter_content(chunk_size=8192): out.write(chunk) print(f"[{idx}/{len(urls)}] OK {filename}") except Exception as e: print(f"[{idx}/{len(urls)}] FAIL {url} -> {e}")

逻辑说明:stream=True让 requests 不把整个响应体一次性读进内存,而是分块读取,这对下载大文件至关重要。iter_content(chunk_size=8192)每次读 8KB 写入磁盘,内存占用恒定。raise_for_status()把 4xx/5xx 变成异常,避免把错误页面当成文件存下来。

参数说明:TIMEOUT=30是连接和读取各自 30 秒,不是总时长。如果目标站点响应慢,可以调到 60。chunk_size一般 8192 或 16384 都行,再大对吞吐提升有限。HEADERS里的 UA 建议换成自己浏览器的真实 UA,不要用默认的 python-requests,很多站点会直接拒绝。

2.3 文件名冲突和非法字符怎么处理

上面的脚本有个明显问题:不同 URL 可能指向同名文件,后下载的会覆盖先下载的。另外 URL 里可能带?、&、%等字符,直接当文件名在 Windows 上会报错。常见做法是加一个序号前缀,同时过滤非法字符:

import re def safe_filename(name: str, idx: int) -> str: # 去掉 Windows/Linux 下的非法字符 name = re.sub(r'[\\/:*?"<>|]', "_", name) # 限制长度,避免超过文件系统上限 name = name[:150] return f"{idx:04d}_{name}"

把filename = unquote(...)那行替换成filename = safe_filename(unquote(os.path.basename(path)) or f"file_{idx}", idx),就能保证每个文件都有唯一前缀,且不会因为特殊字符翻车。序号用四位补零,方便后续按顺序排列。

3. 并发下载:把等待时间压到十分之一

3.1 并发模型选型:线程池还是异步 IO

单线程下载 500 个文件,如果每个平均耗时 2 秒,总共要 1000 秒,接近 17 分钟。但其中绝大部分时间花在等待网络响应上,CPU 几乎空闲。这时候并发就能把总时间压到接近最慢那个文件的时间。

Python 里做并发下载有两条路:concurrent.futures.ThreadPoolExecutor和asyncio + aiohttp。线程池的优势是代码改动小,把原来的 for 循环体丢进函数,用executor.map跑就行;劣势是线程数不能开太大,否则上下文切换开销反而拖慢速度。aiohttp 的优势是单线程事件循环,可以轻松开几百个并发连接;劣势是代码要重写成 async 风格,且遇到同步阻塞操作会卡住整个事件循环。

我一般会先用线程池,并发数设 8 到 16。如果目标站点对单 IP 有连接数限制,线程池反而更安全,因为你可以精确控制并发数。aiohttp 适合目标站点分散、单站点压力不大的场景。

3.2 线程池版本:改造最小脚本

from concurrent.futures import ThreadPoolExecutor, as_completed MAX_WORKERS = 8 # 并发数,建议 4~16 之间 def download_one(idx_url): idx, url = idx_url try: resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT, stream=True) resp.raise_for_status() path = urlparse(url).path filename = safe_filename(unquote(os.path.basename(path)) or f"file_{idx}", idx) filepath = os.path.join(OUT_DIR, filename) with open(filepath, "wb") as out: for chunk in resp.iter_content(chunk_size=8192): out.write(chunk) return (idx, url, "OK", filename) except Exception as e: return (idx, url, "FAIL", str(e)) with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = {pool.submit(download_one, (i, u)): u for i, u in enumerate(urls, 1)} for fut in as_completed(futures): idx, url, status, msg = fut.result() print(f"[{idx}] {status} {msg}")

逻辑说明:as_completed按完成顺序返回结果,而不是按提交顺序,这样先下完的先打印,不会因为某个慢文件卡住输出。每个任务独立捕获异常,一个失败不影响其他任务。

参数说明:MAX_WORKERS=8是经验值。如果目标站点是同一个域名,建议不要超过 16,否则容易被限流甚至封 IP。如果 URL 分散在多个域名,可以适当调高到 32。注意线程池的并发数不等于同时打开的连接数,requests 底层用 urllib3 的连接池,每个线程会复用连接。

3.3 断点续传:已下载的文件怎么跳过

批量下载最怕跑到一半断了,重新跑一遍又要从头开始。解决办法有两个层次:一是下载前检查文件是否已存在且大小合理,二是用 HTTP Range 头做真正的断点续传。

第一个层次最简单:在download_one开头加一段判断,如果目标文件已存在且大小大于 0,直接返回 SKIP。这能覆盖 90% 的场景,因为大部分文件要么完整下载了,要么根本没开始。

第二个层次需要服务端支持Accept-Ranges: bytes。先发一个 HEAD 请求拿到Content-Length,再检查本地文件大小,用Range: bytes={local_size}-续传。不是所有服务器都支持,所以要做好降级:如果返回 200 而不是 206,说明服务端不支持 Range,只能重新下载。

def download_with_resume(idx, url): path = urlparse(url).path filename = safe_filename(unquote(os.path.basename(path)) or f"file_{idx}", idx) filepath = os.path.join(OUT_DIR, filename) # 已存在且非空,跳过 if os.path.exists(filepath) and os.path.getsize(filepath) > 0: return (idx, url, "SKIP", filename) headers = dict(HEADERS) local_size = 0 if os.path.exists(filepath): local_size = os.path.getsize(filepath) headers["Range"] = f"bytes={local_size}-" resp = requests.get(url, headers=headers, timeout=TIMEOUT, stream=True) if resp.status_code == 416: # Range 超出范围,说明已下完 return (idx, url, "SKIP", filename) resp.raise_for_status() mode = "ab" if local_size and resp.status_code == 206 else "wb" with open(filepath, mode) as out: for chunk in resp.iter_content(chunk_size=8192): out.write(chunk) return (idx, url, "OK", filename)

注意mode = "ab"只在服务端返回 206 时才用追加模式,否则会得到一个损坏的文件。这个判断不能省,否则遇到不支持 Range 的服务器,文件会变成「半截旧数据 + 完整新数据」的拼接体,打开就报错。

4. 避坑与排查:批量下载最常见的 5 个翻车现场

4.1 现象:下载下来的全是 HTML 文件,不是目标文件

原因:目标站点有反爬或登录校验,未登录请求被重定向到登录页或验证页,返回 200 但内容是 HTML。requests 默认跟随重定向,所以不会报错。

解决:下载前先检查Content-Type响应头,如果不是预期的类型(比如application/pdf、image/jpeg),就跳过并记录。另外可以在请求头里带上Referer,很多站点会校验来源页。

4.2 现象:并发开到 32,结果大量超时和连接被拒

原因:目标站点对单 IP 的并发连接数有限制,超过阈值后直接拒绝新连接。或者本地网络出口带宽被占满,导致所有请求都变慢。

解决:把并发数降到 8 以下,并在每次请求之间加随机延迟。可以用time.sleep(random.uniform(0.1, 0.5))在任务开头加抖动。如果目标站点有Retry-After响应头,要尊重它,按指定时间等待后重试。

4.3 现象:文件名乱码,中文变成百分号编码

原因:URL 里的中文和特殊字符经过 percent-encoding,直接取os.path.basename拿到的是%E6%96%87%E4%BB%B6.pdf这种形式。

解决:用urllib.parse.unquote解码。但要注意,有些 URL 是双重编码的,解码一次后还是百分号形式,需要再解一次。判断方法是解码后如果还包含%且后面跟两位十六进制,就再解一次。

4.4 现象:下载到一半程序崩溃,重启后文件损坏

原因:写入过程中程序被中断,文件只写了一部分,但下次运行时因为文件已存在且大小大于 0,被 SKIP 逻辑跳过了。

解决:不要用「文件存在就跳过」这种粗粒度判断。正确做法是下载时先写临时文件(比如.part后缀),下载完成后用os.rename原子性地改成正式文件名。这样即使中途崩溃,临时文件不会被误认为完整文件。重启后清理所有.part文件重新下载即可。

4.5 现象:某些 URL 返回 403,但浏览器里能正常打开

原因:服务端校验了User-Agent、Referer、Cookie或Accept-Language等请求头,而脚本里没带全。

解决:用浏览器开发者工具的 Network 面板,找到该请求的完整请求头,复制到脚本的HEADERS里。重点是User-Agent、Referer、Accept、Accept-Language这四个。如果还不行,可能需要带 Cookie,那就得先从浏览器登录后导出 Cookie 再注入。

5. 进阶技巧:用 URL 指纹做增量下载和去重

批量下载做多了会发现一个需求:同一批 URL 隔几天再跑一次,只想下载新增的或内容变化的文件。这时候光靠文件名判断不够,因为文件名可能相同但内容已经更新。我的习惯是给每个 URL 算一个指纹,存到本地 SQLite 里,每次下载前先比对。

指纹可以用 URL 的 MD5 加上响应头的ETag或Last-Modified组合。如果服务端支持ETag,直接用它做版本判断最准。不支持的话,退而求其次用Content-Length加Last-Modified。下面是一个最小实现:

import sqlite3 import hashlib DB = "download_history.db" def init_db(): conn = sqlite3.connect(DB) conn.execute(""" CREATE TABLE IF NOT EXISTS downloads ( url_hash TEXT PRIMARY KEY, url TEXT, etag TEXT, last_modified TEXT, filepath TEXT, downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() return conn def url_fingerprint(url: str) -> str: return hashlib.md5(url.encode("utf-8")).hexdigest() def should_download(conn, url, etag, last_modified): h = url_fingerprint(url) row = conn.execute( "SELECT etag, last_modified FROM downloads WHERE url_hash=?", (h,) ).fetchone() if row is None: return True old_etag, old_lm = row if etag and old_etag and etag != old_etag: return True if last_modified and old_lm and last_modified != old_lm: return True return False

逻辑说明:先用 HEAD 请求拿到ETag和Last-Modified,再查本地数据库比对。如果都没有变化,直接跳过,省掉一次完整下载。下载完成后把新的ETag和Last-Modified写回数据库。

参数说明:url_hash用 MD5 而不是直接存 URL,是因为有些 URL 很长,建索引效率低。MD5 碰撞概率在这个场景下可以忽略。downloaded_at字段方便后续做「最近 7 天新增」之类的统计。

这个方案还有一个附带好处:如果某个 URL 下载失败了,数据库里没有记录,下次跑的时候会自动重试,不需要额外维护失败列表。我一般会在下载成功后才写数据库,失败的不写,这样失败任务天然就是「待重试」状态。

最后一个习惯:每次批量下载前,先用head请求快速探测所有 URL 的可用性,把 404 和 403 的挑出来单独处理,不要让它们混在下载队列里浪费并发槽位。这个预检步骤花不了几秒钟,但能省下大量无效等待。希望帮到你。

本文还有配套的精品资源,点击获取

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

从后见之明到决策升级:hindsight项目教你如何科学复盘

最近和几个做产品经理的朋友复盘一个失败项目&#xff0c;聊到一半&#xff0c;有人拍桌子说&#xff1a;“当时我们明明都看到那个风险了&#xff0c;为什么没停下来&#xff1f;” 这句话让我愣住。因为我也经常在回顾时产生这种“早知道”的感觉&#xff0c;可是冷静想想&am…

作者头像 李华
网站建设 2026/10/30 16:20:23

Label-studio ml 后端接入 YOLOV8 OBB:Model.py 核心解析与避坑指南

简介&#xff1a;这份资源是面向使用 Label Studio 进行半自动标注的算法工程师与深度学习开发者&#xff0c;提供 YOLOv8 目标检测 OBB&#xff08;旋转框&#xff09;模型接入 Label Studio ML 后端所需的 Model.py 文件。它解决的是在 Label Studio 中集成 YOLOv8 OBB 模型、…

作者头像 李华
网站建设 2026/10/30 16:16:45

SpringBoot+Vue在线考试系统搭建与核心实现

简介&#xff1a;这是一套面向计算机专业本科生的Java毕业设计级在线考试系统源码&#xff0c;适用于课程设计、毕设开发与教学实践场景&#xff0c;解决传统纸质考试组织难、阅卷慢、数据统计不便等痛点。资源包含SpringBootVue前后端分离架构的完整工程&#xff0c;含244个文…

作者头像 李华
网站建设 2026/10/30 16:14:56

深度学习实现锂电池SOH端到端时序回归

简介&#xff1a;本资源是一套基于深度学习的锂电池健康状态&#xff08;SOH&#xff09;评估完整实现方案&#xff0c;面向计算机、人工智能、电子信息、新能源等专业的本科生及初入职场的技术人员&#xff0c;适用于毕业设计、课程大作业与项目立项演示等实践场景。代码基于N…

作者头像 李华
网站建设 2026/10/30 16:14:51

Windows下onnxruntime 1.18.0 GPU C++部署指南

简介&#xff1a;面向C开发者的GPU优化版ONNX Runtime推理库已打包提供&#xff0c;版本号1.18.0&#xff0c;适用于Windows 64位平台&#xff0c;核心用途是利用CUDA和cuDNN加速深度学习模型的推理过程&#xff0c;解决CPU处理计算密集任务时性能不足的痛点。压缩包内共有35个…

作者头像 李华
网站建设 2026/10/30 16:11:48

SSM+JSP场馆预约系统源码实战:环境搭建、冲突检测与并发控制

简介&#xff1a;本资源为基于SSM与JSP的场馆预约管理系统完整项目包&#xff0c;面向计算机相关专业毕业设计学生及Java Web初学者&#xff0c;帮助解决场地预约场景下的系统设计与实现问题。项目采用SpringMVC、Spring、MyBatis作为后端框架&#xff0c;前端使用JSP与jQuery&…

作者头像 李华