🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📌 上期回顾](#📌 上期回顾)
- [🎯 本期目标](#🎯 本期目标)
- [💡 为什么下载资源这么麻烦?](#💡 为什么下载资源这么麻烦?)
- [🔧 技术方案拆解](#🔧 技术方案拆解)
- [📝 完整实现](#📝 完整实现)
- [🔍 代码关键点解析](#🔍 代码关键点解析)
-
- [1. 流式下载的内存控制](#1. 流式下载的内存控制)
- [2. SHA256去重的高效性](#2. SHA256去重的高效性)
- [3. 断点续传的实现](#3. 断点续传的实现)
- [4. 文件名推断的优先级](#4. 文件名推断的优先级)
- [5. 文件验证的魔数检测](#5. 文件验证的魔数检测)
- [📊 实战验收](#📊 实战验收)
- [🎨 进阶优化方向](#🎨 进阶优化方向)
-
- [1. 并发下载](#1. 并发下载)
- [2. 增量去重](#2. 增量去重)
- [3. 压缩存储](#3. 压缩存储)
- [4. 断点续传的健壮性](#4. 断点续传的健壮性)
- [5. 下载速率限制](#5. 下载速率限制)
- [6. 自动重试失败任务](#6. 自动重试失败任务)
- [⚠️ 常见坑点](#⚠️ 常见坑点)
-
- [1. 文件名非法字符](#1. 文件名非法字符)
- [2. URL编码问题](#2. URL编码问题)
- [3. 超时设置](#3. 超时设置)
- [4. 磁盘空间检查](#4. 磁盘空间检查)
- [5. Content-Disposition编码](#5. Content-Disposition编码)
- [💼 实际应用场景](#💼 实际应用场景)
- [🎯 本期总结](#🎯 本期总结)
- [📖 下期预告](#📖 下期预告)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
📌 上期回顾
上一期《Python爬虫零基础入门【第九章:实战项目教学·第9节】可观测性:日志规范 + trace_id + 可复现错误包!》我们打造了一套可观测性体系------结构化日志、trace_id追踪、错误现场打包。从此爬虫出问题不再抓瞎,每次失败都能在5分钟内定位原因,甚至离线复现调试。
但到现在为止,我们采集的都是文本数据 :网页内容、JSON接口、表格数据...真实世界里还有大量文件型资源等着你去抓:
- 政府公告的PDF附件📄
- 上市公司的财报Excel📊
- 研究机构的Word文档📝
- 产品手册、技术白皮书...
这些资源往往才是最有价值的"干货"。今天咱们就来解决文件下载的工程化问题。
🎯 本期目标
这一期你会得到:
- 智能下载器:自动识别文件类型、推断文件名
- 流式下载:处理GB级大文件不爆内存
- 去重机制:基于SHA256指纹避免重复下载
- 断点续传:网络中断后从上次位置继续(简化版)
- 目录规范:按日期/类型分类存储,方便管理
- 下载验证:文件完整性校验、格式检测
验收标准很实在:能稳定下载100个PDF,自动去重,断网重连后能续传📥
💡 为什么下载资源这么麻烦?
问题1:文件名混乱
python
# 同一个文件,可能有不同URL
url1 = "https://example.com/report.pdf"
url2 = "https://example.com/download?id=12345" # 文件名藏在参数里
url3 = "https://cdn.example.com/abc123xyz" # 完全看不出是啥
你得会从URL、响应头、甚至内容里推断出合理的文件名。
问题2:重复下载浪费
同一份财报可能在多个页面都有链接:
- 公司官网
- 证监会网站
- 财经门户转载
朴素做法 :下载3次,浪费带宽和存储。
工程化做法:计算文件哈希,发现重复秒跳过。
问题3:大文件内存爆炸
python
# ❌ 错误示范
response = requests.get(url)
content = response.content # 100MB的PDF全部加载到内存
open('file.pdf', 'wb').write(content) # 峰值内存200MB+
正确做法:流式读取,边下边写,内存占用始终保持在几MB。
问题4:网络不稳定
下载一半突然断网,重新开始下太亏。需要支持:
- 记录已下载字节数
- 下次用Range头从断点继续
问题5:文件格式伪装
python
# URL说是PDF,实际下载下来是HTML错误页
url = "https://example.com/report.pdf"
# Content-Type: text/html
# 内容:<html><body>404 Not Found</body></html>
必须验证下载的文件确实是PDF,而不是错误页面。
🔧 技术方案拆解
方案一:智能文件名推断
优先级顺序:
-
Content-Disposition头(最可靠)
Content-Disposition: attachment; filename="2024年报.pdf" -
URL路径
https://example.com/reports/annual_2024.pdf → annual_2024.pdf -
Content-Type + 随机ID
Content-Type: application/pdf → file_7a8f9c2d.pdf
方案二:SHA256去重
python
# 下载前检查
sha256 = calculate_hash(file_content)
if sha256 in downloaded_set:
print("已存在,跳过")
return existing_path
# 下载后存储
downloaded_set.add(sha256)
关键点:哈希值作为唯一标识,比URL更可靠(同内容不同URL也能去重)。
方案三:流式下载
python
with requests.get(url, stream=True) as r:
with open(path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
每次只处理8KB,内存占用恒定。
方案四:断点续传(简化版)
python
# 检查本地已下载大小
local_size = os.path.getsize(temp_file)
# 使用Range头继续下载
headers = {'Range': f'bytes={local_size}-'}
response = requests.get(url, headers=headers, stream=True)
# 追加写入
with open(temp_file, 'ab') as f:
for chunk in response.iter_content(8192):
f.write(chunk)
方案五:目录规范
downloads/
├── 2026-01-24/ # 按日期
│ ├── pdf/
│ │ ├── report_abc123.pdf
│ │ └── annual_xyz789.pdf
│ ├── excel/
│ └── word/
├── metadata.jsonl # 下载元信息
└── checksums.txt # SHA256列表
📝 完整实现
整体架构说明
核心组件:
- FileDownloader:下载器核心(流式、去重、断点续传)
- FilenameResolver:文件名推断器
- FileValidator:文件验证器(类型检测、完整性校验)
- DownloadManager:下载管理器(任务队列、统计、元数据)
数据流向:
URL → 推断文件名 → 检查去重 → 流式下载 → 验证完整性 → 移动到正式目录 → 记录元数据
代码实现
python
"""
文件下载工具包
功能:PDF/附件下载、去重校验、断点续传
"""
import os
import re
import hashlib
import mimetypes
from pathlib import Path
from typing import Optional, Dict, Any, Set, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime
from urllib.parse import urlparse, unquote
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import json
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
# =============================================================================
# 1. 文件名推断器
# =============================================================================
class FilenameResolver:
"""
智能文件名推断
策略:
1. Content-Disposition头
2. URL路径
3. Content-Type + 随机ID
"""
# 常见MIME类型到扩展名映射
MIME_TO_EXT = {
'application/pdf': '.pdf',
'application/vnd.ms-excel': '.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/zip': '.zip',
'image/jpeg': '.jpg',
'image/png': '.png',
'text/plain': '.txt',
}
@staticmethod
def from_content_disposition(headers: Dict[str, str]) -> Optional[str]:
"""从Content-Disposition头提取文件名"""
cd = headers.get('Content-Disposition', '')
# 匹配 filename="xxx" 或 filename*=UTF-8''xxx
patterns = [
r'filename\*=UTF-8\'\'(.+)',
r'filename="?([^"]+)"?',
]
for pattern in patterns:
match = re.search(pattern, cd, re.IGNORECASE)
if match:
filename = unquote(match.group(1))
return filename.strip()
return None
@staticmethod
def from_url(url: str) -> Optional[str]:
"""从URL路径提取文件名"""
parsed = urlparse(url)
path = unquote(parsed.path)
# 取最后一段
filename = os.path.basename(path)
# 必须有扩展名才算有效
if filename and '.' in filename:
return filename
return None
@staticmethod
def from_content_type(content_type: str, prefix: str = "file") -> str:
"""根据Content-Type生成文件名"""
# 清理content_type
mime_type = content_type.split(';')[0].strip().lower()
# 查找对应扩展名
ext = FilenameResolver.MIME_TO_EXT.get(mime_type, '')
# 如果没找到,尝试用mimetypes库推断
if not ext:
ext = mimetypes.guess_extension(mime_type) or ''
# 生成随机文件名
random_id = hashlib.md5(
f"{prefix}_{datetime.now().isoformat()}".encode()
).hexdigest()[:12]
return f"{prefix}_{random_id}{ext}"
@classmethod
def resolve(
cls,
url: str,
headers: Dict[str, str],
prefix: str = "download"
) -> str:
"""
综合推断文件名
Args:
url: 下载URL
headers: 响应头
prefix: 文件名前缀(兜底用)
Returns:
推断的文件名
"""
# 策略1: Content-Disposition
filename = cls.from_content_disposition(headers)
if filename:
logger.debug(f"文件名来自Content-Disposition: {filename}")
return filename
# 策略2: URL路径
filename = cls.from_url(url)
if filename:
logger.debug(f"文件名来自URL: {filename}")
return filename
# 策略3: Content-Type + 随机ID
content_type = headers.get('Content-Type', 'application/octet-stream')
filename = cls.from_content_type(content_type, prefix)
logger.debug(f"文件名来自Content-Type: {filename}")
return filename
# =============================================================================
# 2. 文件验证器
# =============================================================================
class FileValidator:
"""
文件验证器
功能:
- 检测真实文件类型(防止伪装)
- 验证文件完整性
"""
# 文件魔数(前几个字节特征)
MAGIC_NUMBERS = {
'pdf': b'%PDF',
'zip': b'PK\x03\x04',
'xlsx': b'PK\x03\x04', # xlsx也是zip格式
'png': b'\x89PNG\r\n\x1a\n',
'jpg': b'\xff\xd8\xff',
'gif': b'GIF89a',
}
@staticmethod
def detect_type(file_path: Path) -> Optional[str]:
"""检测文件真实类型"""
if not file_path.exists():
return None
# 读取前8个字节
with open(file_path, 'rb') as f:
header = f.read(8)
# 匹配魔数
for file_type, magic in FileValidator.MAGIC_NUMBERS.items():
if header.startswith(magic):
return file_type
return 'unknown'
@staticmethod
def validate_pdf(file_path: Path) -> bool:
"""验证是否为有效PDF"""
detected = FileValidator.detect_type(file_path)
return detected == 'pdf'
@staticmethod
def validate_size(file_path: Path, min_size: int = 1024) -> bool:
"""验证文件大小(排除空文件或错误页)"""
return file_path.stat().st_size >= min_size
@classmethod
def validate(
cls,
file_path: Path,
expected_type: Optional[str] = None,
min_size: int = 1024
) -> Tuple[bool, str]:
"""
综合验证
Returns:
(是否有效, 失败原因)
"""
# 检查存在性
if not file_path.exists():
return False, "文件不存在"
# 检查大小
if not cls.validate_size(file_path, min_size):
return False, f"文件过小 ({file_path.stat().st_size} bytes)"
# 检查类型
detected_type = cls.detect_type(file_path)
if expected_type and detected_type != expected_type:
return False, f"类型不匹配 (期望{expected_type}, 实际{detected_type})"
return True, "验证通过"
# =============================================================================
# 3. 文件下载器
# =============================================================================
@dataclass
class DownloadResult:
"""下载结果"""
url: str
filename: str
file_path: str
file_size: int
sha256: str
content_type: str
download_time: float
success: bool
error: Optional[str] = None
class FileDownloader:
"""
文件下载器
功能:
- 流式下载
- SHA256去重
- 断点续传(简化版)
"""
def __init__(
self,
download_dir: Path = Path("downloads"),
chunk_size: int = 8192,
timeout: int = 30
):
self.download_dir = download_dir
self.chunk_size = chunk_size
self.timeout = timeout
# 创建目录
self.download_dir.mkdir(parents=True, exist_ok=True)
self.temp_dir = self.download_dir / ".temp"
self.temp_dir.mkdir(exist_ok=True)
# 配置Session
self.session = requests.Session()
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[500, 502, 503, 504]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
# 去重集合(SHA256)
self.downloaded_hashes: Set[str] = set()
self._load_checksums()
def _load_checksums(self):
"""加载已下载文件的校验和"""
checksum_file = self.download_dir / "checksums.txt"
if checksum_file.exists():
for line in checksum_file.read_text().splitlines():
if line.strip():
sha256 = line.split()[0]
self.downloaded_hashes.add(sha256)
logger.info(f"加载了{len(self.downloaded_hashes)}个已下载文件的校验和")
def _save_checksum(self, sha256: str, filename: str):
"""保存校验和"""
checksum_file = self.download_dir / "checksums.txt"
with open(checksum_file, 'a', encoding='utf-8') as f:
f.write(f"{sha256} {filename}\n")
self.downloaded_hashes.add(sha256)
def _calculate_hash(self, file_path: Path) -> str:
"""计算文件SHA256"""
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while True:
data = f.read(65536) # 64KB块
if not data:
break
sha256.update(data)
return sha256.hexdigest()
def download(
self,
url: str,
filename: Optional[str] = None,
subdir: Optional[str] = None,
resume: bool = True
) -> DownloadResult:
"""
下载文件
Args:
url: 下载URL
filename: 指定文件名(可选)
subdir: 子目录(如"pdf")
resume: 是否支持断点续传
Returns:
DownloadResult
"""
start_time = datetime.now()
try:
# 1. 发送HEAD请求获取元信息
head_response = self.session.head(url, timeout=self.timeout, allow_redirects=True)
headers = dict(head_response.headers)
# 2. 推断文件名
if not filename:
filename = FilenameResolver.resolve(url, headers)
# 3. 确定保存路径
if subdir:
target_dir = self.download_dir / subdir
target_dir.mkdir(exist_ok=True)
else:
target_dir = self.download_dir
final_path = target_dir / filename
temp_path = self.temp_dir / f"{filename}.tmp"
# 4. 检查是否已存在
if final_path.exists():
existing_hash = self._calculate_hash(final_path)
if existing_hash in self.downloaded_hashes:
logger.info(f"文件已存在(跳过): {filename}")
return DownloadResult(
url=url,
filename=filename,
file_path=str(final_path),
file_size=final_path.stat().st_size,
sha256=existing_hash,
content_type=headers.get('Content-Type', ''),
download_time=0,
success=True
)
# 5. 断点续传逻辑
resume_pos = 0
if resume and temp_path.exists():
resume_pos = temp_path.stat().st_size
logger.info(f"检测到未完成下载,从{resume_pos}字节继续")
# 6. 流式下载
request_headers = {}
if resume_pos > 0:
request_headers['Range'] = f'bytes={resume_pos}-'
response = self.session.get(
url,
headers=request_headers,
stream=True,
timeout=self.timeout
)
response.raise_for_status()
# 获取总大小
total_size = int(response.headers.get('Content-Length', 0))
if resume_pos > 0:
total_size += resume_pos
# 写入文件
mode = 'ab' if resume_pos > 0 else 'wb'
downloaded = resume_pos
with open(temp_path, mode) as f:
for chunk in response.iter_content(chunk_size=self.chunk_size):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# 打印进度(每10%)
if total_size > 0:
progress = (downloaded / total_size) * 100
if downloaded % (total_size // 10 or 1) < self.chunk_size:
logger.info(f"下载进度: {progress:.1f}% ({downloaded}/{total_size})")
# 7. 计算SHA256
file_hash = self._calculate_hash(temp_path)
# 8. 去重检查
if file_hash in self.downloaded_hashes:
logger.warning(f"文件内容重复(SHA256已存在): {filename}")
temp_path.unlink() # 删除临时文件
# 查找已存在的文件
for line in (self.download_dir / "checksums.txt").read_text().splitlines():
if line.startswith(file_hash):
existing_file = line.split()[1]
break
else:
existing_file = "未知"
return DownloadResult(
url=url,
filename=filename,
file_path=existing_file,
file_size=temp_path.stat().st_size if temp_path.exists() else 0,
sha256=file_hash,
content_type=headers.get('Content-Type', ''),
download_time=(datetime.now() - start_time).total_seconds(),
success=True,
error="内容重复(已跳过)"
)
# 9. 移动到正式目录
temp_path.rename(final_path)
# 10. 保存校验和
self._save_checksum(file_hash, str(final_path.relative_to(self.download_dir)))
elapsed = (datetime.now() - start_time).total_seconds()
file_size = final_path.stat().st_size
logger.info(
f"下载完成: {filename} | "
f"大小:{file_size/1024:.1f}KB | "
f"耗时:{elapsed:.1f}s | "
f"SHA256:{file_hash[:16]}..."
)
return DownloadResult(
url=url,
filename=filename,
file_path=str(final_path),
file_size=file_size,
sha256=file_hash,
content_type=headers.get('Content-Type', ''),
download_time=elapsed,
success=True
)
except Exception as e:
logger.error(f"下载失败: {url} | {type(e).__name__}: {e}")
return DownloadResult(
url=url,
filename=filename or "unknown",
file_path="",
file_size=0,
sha256="",
content_type="",
download_time=(datetime.now() - start_time).total_seconds(),
success=False,
error=str(e)
)
# =============================================================================
# 4. 下载管理器
# =============================================================================
class DownloadManager:
"""
下载管理器
功能:
- 批量下载
- 元数据记录
- 统计报告
"""
def __init__(self, download_dir: Path = Path("downloads")):
self.downloader = FileDownloader(download_dir)
self.download_dir = download_dir
self.metadata_file = self.download_dir / "metadata.jsonl"
self.results: list[DownloadResult] = []
def download_batch(
self,
urls: list[str],
subdirs: Optional[Dict[str, str]] = None
):
"""
批量下载
Args:
urls: URL列表
subdirs: URL到子目录的映射(可选)
"""
logger.info(f"开始批量下载 | 总数:{len(urls)}")
for i, url in enumerate(urls, 1):
subdir = subdirs.get(url) if subdirs else None
logger.info(f"[{i}/{len(urls)}] {url}")
result = self.downloader.download(url, subdir=subdir)
self.results.append(result)
# 记录元数据
self._save_metadata(result)
self.print_summary()
def _save_metadata(self, result: DownloadResult):
"""保存下载元数据"""
with open(self.metadata_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(asdict(result), ensure_ascii=False) + '\n')
def print_summary(self):
"""打印统计摘要"""
total = len(self.results)
success = sum(1 for r in self.results if r.success and not r.error)
failed = sum(1 for r in self.results if not r.success)
skipped = sum(1 for r in self.results if r.success and r.error)
total_size = sum(r.file_size for r in self.results if r.success) / (1024 * 1024)
total_time = sum(r.download_time for r in self.results)
print("\n" + "="*60)
print("📥 下载统计报告")
print("="*60)
print(f"总任务数: {total}")
print(f"成功下载: {success}")
print(f"跳过(重复): {skipped}")
print(f"失败: {failed}")
print(f"总大小: {total_size:.2f} MB")
print(f"总耗时: {total_time:.1f}秒")
if success > 0:
print(f"平均速度: {total_size/total_time:.2f} MB/s")
if failed > 0:
print("\n失败列表:")
for r in self.results:
if not r.success:
print(f" - {r.url}: {r.error}")
print("="*60 + "\n")
# =============================================================================
# 5. 使用示例
# =============================================================================
def demo_basic_download():
"""示例1: 基础下载"""
print("\n🔹 示例1: 基础PDF下载")
downloader = FileDownloader(Path("downloads/demo1"))
# 下载一个示例PDF(实际测试时替换为真实URL)
result = downloader.download(
"https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
subdir="pdf"
)
if result.success:
print(f"✅ 下载成功: {result.filename}")
print(f" 路径: {result.file_path}")
print(f" 大小: {result.file_size} bytes")
print(f" SHA256: {result.sha256[:16]}...")
# 验证文件
is_valid, message = FileValidator.validate(
Path(result.file_path),
expected_type='pdf'
)
print(f" 验证结果: {message}")
else:
print(f"❌ 下载失败: {result.error}")
def demo_deduplication():
"""示例2: 去重演示"""
print("\n🔹 示例2: 去重机制")
downloader = FileDownloader(Path("downloads/demo2"))
# 同一个文件下载两次
url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
print("第一次下载:")
result1 = downloader.download(url)
print("\n第二次下载(应该跳过):")
result2 = downloader.download(url)
if result1.sha256 == result2.sha256:
print("✅ 去重成功,第二次下载被跳过")
else:
print("❌ 去重失败")
def demo_batch_download():
"""示例3: 批量下载"""
print("\n🔹 示例3: 批量下载")
manager = DownloadManager(Path("downloads/demo3"))
# 模拟多个下载任务
urls = [
"https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
"https://httpbin.org/image/png", # PNG图片
"https://httpbin.org/status/404", # 会失败
]
# 按类型分目录
subdirs = {
urls[0]: "pdf",
urls[1]: "images",
}
manager.download_batch(urls, subdirs)
def demo_resume_download():
"""示例4: 断点续传演示"""
print("\n🔹 示例4: 断点续传(需要大文件测试)")
# 这里只是示意,实际需要一个大文件URL
print("提示: 断点续传需要服务器支持Range请求")
print("尝试下载大文件,中途Ctrl+C中断,再次运行会从断点继续")
# downloader = FileDownloader(Path("downloads/demo4"))
# result = downloader.download(
# "https://example.com/large_file.pdf",
# resume=True
# )
if __name__ == "__main__":
# 运行示例
demo_basic_download()
# demo_deduplication()
# demo_batch_download()
# demo_resume_download()
🔍 代码关键点解析
1. 流式下载的内存控制
python
with open(temp_path, mode) as f:
for chunk in response.iter_content(chunk_size=self.chunk_size):
if chunk:
f.write(chunk)
关键 :stream=True + iter_content(),每次只处理8KB,无论文件多大,内存占用都恒定在几MB。
2. SHA256去重的高效性
python
# 只计算一次哈希
file_hash = self._calculate_hash(temp_path)
# O(1)查找
if file_hash in self.downloaded_hashes:
return # 跳过
为什么比URL去重好?
- 同内容不同URL也能识别
- 即使文件名改了,内容一样就跳过
- 哈希碰撞概率极低(SHA256安全性高)
3. 断点续传的实现
python
# 检查已下载大小
resume_pos = temp_path.stat().st_size if temp_path.exists() else 0
# 使用Range头
headers = {'Range': f'bytes={resume_pos}-'}
# 追加模式写入
mode = 'ab' if resume_pos > 0 else 'wb'
注意:不是所有服务器都支持Range请求,需要检查响应码是否为206(Partial Content)。
4. 文件名推断的优先级
python
# 优先级从高到低
1. Content-Disposition(服务器明确指定)
2. URL路径(可靠性中等)
3. Content-Type + 随机ID(兜底方案)
这个设计保证了总能得到一个合理的文件名。
5. 文件验证的魔数检测
python
MAGIC_NUMBERS = {
'pdf': b'%PDF',
'jpg': b'\xff\xd8\xff',
...
}
with open(file_path, 'rb') as f:
header = f.read(8)
if header.startswith(b'%PDF'):
return 'pdf'
为什么不信任扩展名?
因为服务器可能返回错误页面但用.pdf扩展名,魔数检测能识破伪装。
📊 实战验收
运行测试
bash
python file_downloader.py
预期输出
🔹 示例1: 基础PDF下载
2026-01-24 15:30:45 [INFO] __main__: 下载进度: 10.0% (5120/51200)
2026-01-24 15:30:45 [INFO] __main__: 下载进度: 50.0% (25600/51200)
2026-01-24 15:30:46 [INFO] __main__: 下载进度: 100.0% (51200/51200)
2026-01-24 15:30:46 [INFO] __main__: 下载完成: dummy.pdf | 大小:50.0KB | 耗时:1.2s | SHA256:7a8f9c2d...
✅ 下载成功: dummy.pdf
路径: downloads/demo1/pdf/dummy.pdf
大小: 51200 bytes
SHA256: 7a8f9c2d...
验证结果: 验证通过
查看下载目录
bash
tree downloads/
downloads/
├── demo1/
│ ├── pdf/
│ │ └── dummy.pdf
│ ├── checksums.txt
│ └── metadata.jsonl
└── .temp/
验收标准
- 能正确下载PDF文件
- SHA256去重生效(同文件第二次跳过)
- 文件名推断合理
- checksums.txt正确记录
- metadata.jsonl包含完整元信息
- 文件验证能识别格式伪装
🎨 进阶优化方向
1. 并发下载
当前是串行下载,可以改成多线程:
python
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(downloader.download, url) for url in urls]
results = [f.result() for f in futures]
2. 增量去重
当前每次都重新计算SHA256,可以优化:
python
# 先检查文件大小
local_size = existing_file.stat().st_size
remote_size = int(headers.get('Content-Length', 0))
if local_size == remote_size:
# 大小相同,才计算哈希
file_hash = calculate_hash(existing_file)
3. 压缩存储
PDF/Excel往往能压缩:
python
import gzip
# 下载后自动压缩
with open(file_path, 'rb') asfile_path}.gz', 'wb') as f_out:
f_out.write(f_in.read())
os.remove(file_path) # 删除原文件
4. 断点续传的健壮性
python
# 检查服务器是否支持Range
head_response = session.head(url)
accept_ranges = head_response.headers.get('Accept-Ranges')
if accept_ranges == 'bytes':
resume = True
else:
logger.warning("服务器不支持断点续传")
resume = False
5. 下载速率限制
避免下载太快被限流:
python
from time import sleep
for chunk in response.iter_content(chunk_size):
f.write(chunk)
# 限速:每秒最多1MB
sleep(chunk_size / (1024 * 1024))
6. 自动重试失败任务
python
# 记录失败任务
failed_urls = [r.url for r in results if not r.success]
# 重试
if failed_urls:
logger.info(f"重试{len(failed_urls)}个失败任务")
retry_results = manager.download_batch(failed_urls)
⚠️ 常见坑点
1. 文件名非法字符
Windows不允许文件名包含< > : " / \ | ? *:
python
def sanitize_filename(filename: str) -> str:
# 替换非法字符
for char in r'<>:"/\|?*':
filename = filename.replace(char, '_')
return filename
2. URL编码问题
python
# ❌ 错误
filename = "报告 2024.pdf" # 空格会导致问题
# ✅ 正确
from urllib.parse import quote
safe_filename = quote(filename)
3. 超时设置
python
# 大文件需要更长超时
if total_size > 100 * 1024 * 1024: # 100MB
timeout = 300 # 5分钟
else:
timeout = 30
4. 磁盘空间检查
python
import shutil
free_space = shutil.disk_usage(download_dir).free
required_space = int(headers.get('Content-Length', 0)) * 1.2 # 预留20%
if free_space < required_space:
raise IOError("磁盘空间不足")
5. Content-Disposition编码
python
# 有些服务器用GB2312编码
cd = headers.get('Content-Disposition', '')
try:
filename = cd.encode('latin1').decode('gb2312')
except:
filename = cd # 解码失败就用原值
💼 实际应用场景
场景1:上市公司公告下载
python
# 采集公告列表
announcements = [
{'title': '2024年报', 'url': 'https://...', 'type': 'annual'},
{'title': '财务报表', 'url': 'https://...', 'type': 'financial'},
]
# 按类型分目录下载
subdirs = {item['url']: item['type'] for item in announcements}
manager.download_batch([item['url'] for item in announcements], subdirs)
场景2:研报库批量下载
python
# 从API获取研报列表
reports = api.get_reports(date='2024-01-24', limit=100)
# 过滤已下载
urls = [r['pdf_url'] for r in reports]
new_urls = []
for url in urls:
# 检查URL是否已在metadata中
if not is_downloaded(url):
new_urls.append(url)
logger.info(f"总数:{len(urls)}, 新增:{len(new_urls)}")
manager.download_batch(new_urls)
场景3:定期增量下载
python
# 每天凌晨跑一次
def daily_download():
today = datetime.now().date()
# 获取今日新增文件
new_files = get_new_files(today)
# 下载到按日期命名的目录
subdir = today.strftime('%Y-%m-%d')
for file_info in new_files:
downloader.download(
file_info['url'],
filename=file_info.get('filename'),
subdir=subdir
)
场景4:容错重跑
python
# 从metadata.jsonl读取历史记录
downloaded = set()
with open('downloads/metadata.jsonl') as f:
for line in f:
record = json.loads(line)
if record['success']:
downloaded.add(record['url'])
# 全量URL列表
all_urls = load_all_urls()
# 计算差集
missing_urls = set(all_urls) - downloaded
logger.info(f"检测到{len(missing_urls)}个未下载文件")
manager.download_batch(list(missing_urls))
🎯 本期总结
今天我们打造了一套生产级的文件下载工具:
✅ 智能文件名推断 :从响应头、URL、Content-Type多角度推断
✅ 流式下载 :处理GB级文件不爆内存
✅ SHA256去重 :内容级去重,比断点续传**:网络中断后从断点继续
✅ 文件验证 :魔数检测防止格式伪装
✅ 元数据管理:完整记录下载历史,支持容错重跑
核心思想就一句话:让文件下载像采集网页一样工程化、可追溯📥
有了这套工具,无论是批量下载财报、研报、公告附件,还是构建文档资源库,都能稳稳搞定。SHA256去重让你不用担心重复下载浪费,元数据记录让你随时知道下了什么。
📖 下期预告
9-11|Playwright 入门实战:渲染后 HTML + 截图定位问题
前面我们处理的都是静态页面和文件下载,但现在越来越多网站用JavaScript动态渲染。打开Network一看,数据都是前端异步加载的,传统requests根本抓不到。
下一期我们聊:
- 如何用Playwright控制真实浏览器
- 等待策略(元素出现、网络空闲、超时)
- 获取渲染后的完整HTML
- 截图调试(最有用的技巧📸)
- 何时该用Playwright,何时该找API
从此动态页面也难不倒你💪
作业(可选):
- 运行demo,下载几个PDF文件
- 查看checksums.txt和metadata.jsonl内容
- 尝试下载同一文件两次,验证去重机制
- 思考:如果要做一个"下载任务队列Web界面",需要哪些功能?
有问题随时在评论区讨论,咱们下期见!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。