
简介面向零基础与有一定经验的Python爬虫学习者这份打包资源定位为可运行的完整爬虫项目集合覆盖请求发送、HTML解析、动态渲染处理、数据清洗入库及反爬绕行等核心环节能帮助读者从单个脚本进阶到工程化爬虫。资源压缩包共95个文件体积仅1.49MB其中包含18个Python源码文件、14个编译后的pyc文件、47张JPG示意图、3个Markdown文档、3个TXT说明、2个配置文件以及1个JSON数据文件源码、配置、文档与演示图配套齐全便于边看边练。目前已有79人学习下载包内包含多个完整的Scrapy项目实例如针对技术博客、知乎用户等场景的抓取工程还涉及法院公开数据的示例读者可对照学习工程搭建流程、中间件配置、数据抓取与导出逻辑。整体体积虽小但麻雀虽小五脏俱全是快速理解爬虫工程结构与动手实践的上佳素材。1. 爬虫.zip从URL到本地压缩包的一条完整链路很多人写爬虫到下载这一步就停了拿到一个 zip 链接requests.get(url).content一把梭文件确实下来了但内存爆没爆、断点续传要不要做、解压乱码怎么办、压缩包里有没有路径穿越统统没想过。爬虫.zip 这个标题把两件事压在一起——爬虫怎么把远端 zip 文件稳妥地取回来以及取回来之后 zip 不是终点解压、校验、防炸弹才算落地。这篇就顺着这条链路讲先写最稳的下载代码再补并发和断点续传然后处理解压安全最后给出一个能接进正式项目的下载-解压-校验模块。适合已经写过简单爬虫、想在文件型任务上把代码写扎实的开发者。2. 用 requests 把 zip 文件下载到本地请求头、流式响应与断点续传2.1 为什么爬虫下载 zip 不能直接用 res.content下载 zip 常见的最省事写法是resp requests.get(url); open(a.zip, wb).write(resp.content)。这个写法在小文件上没问题但 zip 一旦上几百 MBresp.content会把整个响应体先完整读进内存进程占用瞬间翻几倍再叠加并发机器直接卡死。另一个隐蔽问题是响应体未必是 zip。有些站点会先用 HTML 返回一个跳转页或返回一个 JSON 包裹的错误信息状态码仍然是 200。res.content会把这段 HTML 原样写进.zip等到解压时才发现文件头不对。所以真正稳的做法是streamTrue配合iter_content()一边读一边写同时在写入前检查响应头与文件签名。2.2 最小可跑通的下载代码下面这段是爬 zip 文件时的基础模板关键在于streamTrue和按块写入import requests url https://example.com/data/archive.zip headers { User-Agent: Mozilla/5.0 (compatible; zippy-crawler/1.0), Accept: application/zip, application/octet-stream, */*, } with requests.get(url, streamTrue, headersheaders, timeout(5, 30)) as resp: resp.raise_for_status() if len(resp.content) 0: raise ValueError(empty response body) with open(archive.zip, wb) as fp: for chunk in resp.iter_content(chunk_size65536): if chunk: fp.write(chunk)这里chunk_size一般取 64 KB 到 1 MB 之间。太小时磁盘写次数变多太大时内存占用没有本质改善因为iter_content内部就是按这个大小切片的。timeout(5, 30)前一个是连接超时后一个是读超时缺一不可只写一个数字的话连接和读会用同一个值服务端长时间不吐数据时容易误判。2.3 服务端不给 Content-Length 时怎么处理有些源站是动态生成 zip响应头里没有Content-Length或者只有Transfer-Encoding: chunked。此时无法预知总大小也就没法在下载前判断磁盘空间够不够。常见做法是边下边统计最后和文件头一起校验buf bytearray() total 0 for chunk in resp.iter_content(chunk_size1 20): total len(chunk) if total MAX_ZIP_SIZE: raise RuntimeError(zip too large: %d % total) fp.write(chunk) fp.seek(0) head fp.read(4) if head ! bPK\x03\x04: raise ValueError(not a zip file, head%r % head)zip 文件头固定是PK\x03\x04PK 是 PKWARE 的缩写判断这个比判断Content-Type可靠得多。有些服务器给application/octet-stream有些给application/x-zip-compressed还有干脆不给的所以内容签名才是硬标准。2.4 断点续传与 Range 请求下载到一半断网是常态。requests 本身没有续传能力要靠 HTTP 的Range头实现。写法是先从响应头里拿总大小再对比本地已下载的字节数决定从哪个偏移继续import os def download_resume(url, local_path, headersNone): headers dict(headers or {}) if os.path.exists(local_path): existing os.path.getsize(local_path) headers[Range] bytes%d- % existing else: existing 0 with requests.get(url, streamTrue, headersheaders, timeout(10, 60)) as resp: if resp.status_code 416: # 已下载长度超过文件总长说明本地文件损坏 os.remove(local_path) return download_resume(url, local_path, headers) mode ab if resp.status_code 206 else wb with open(local_path, mode) as fp: if mode ab: fp.seek(existing) for chunk in resp.iter_content(chunk_size65536): if chunk: fp.write(chunk)服务端返回206 Partial Content才说明接受了 Range返回200意味着不认 Range 或文件已变化这时必须用wb重新全量下载否则会把新旧内容拼在一起。416出现在本地文件比远端还大的情况通常是服务端文件被替换成更小的版本删除重下比继续拼更安全。3. zip 下载的并发设计线程池、连接复用与限速3.1 什么时候值得上线程池一次要拉几百个 zip 时串行下载的性能瓶颈在网络延迟而非带宽。每个请求要经历 DNS、TCP 握手、TLS 协商之后才开始传数据这部分开销在短连接上相当可观。用ThreadPoolExecutor开几个线程让多个 HTTP 请求同时进行能显著缩短总耗时。但 zip 下载通常集中在少数几个域目标站点未必禁并发所以并发主要用来“自己控制节奏”而不是无限加速。有一类场景不适合线程池服务端单文件生成很慢后端 API 本来就限流。这种任务应该改用「先拿文件清单再串行下载 缓存断点」的模式并发只会把自己账号或 IP 打爆。判断标准很简单——看目标站是不是 CDN 静态分发是就放心并发不是一律从低并发开始。3.2 并发参数怎么定连接数、重试与超时用线程池时max_workers不是越大越好。每个线程会占一个文件描述符和一个内存块更重要的是同域请求太密集会触发服务端限流。我一般按域估CDN 静态文件 4 到 8 并发普通业务站点 2 到 4 并发单 IP 的新站从 1 开始试探。下面是带连接复用的并发下载模板from concurrent.futures import ThreadPoolExecutor, as_completed import requests def download_one(task, session): url, local_path task with session.get(url, streamTrue, timeout(10, 60)) as resp: resp.raise_for_status() with open(local_path, wb) as fp: for chunk in resp.iter_content(chunk_size1 20): if chunk: fp.write(chunk) return local_path def download_many(tasks, workers4): with requests.Session() as session: with ThreadPoolExecutor(max_workersworkers) as pool: futures {pool.submit(download_one, t, session): t for t in tasks} for fut in as_completed(futures): url, path futures[fut] try: print(done, fut.result()) except Exception as exc: print(failed, url, exc)requests.Session底层使用 urllib3 的连接池默认每主机 10 个连接。并发拉取同一个域时如果max_workers超过连接池上限多余的请求要排队等空闲连接并发就白开了。可以把 Session 配成HTTPAdapter(pool_connections10, pool_maxsize20)让连接池容量盖过线程数否则建议每 worker 自己建一个 Session。参数参考表参数建议值说明max_workers4~8CDN 场景可到 8动态接口从 2 开始pool_maxsizemax_workers * 2覆盖请求排队损耗timeout(5, 60)连接 5 秒读 60 秒chunk_size1 MB平衡内存与写盘次数重试次数2~3只重试413/429/5xx不重试4xx3.3 限速与礼貌爬取并发一旦开起来很容易把对方机器打满。限速不是简单sleep而是要让单位时间内的请求次数可控。常见做法是按任务粒度做时间窗记录每批任务的开始时间如果整批完成太快就 sleep 到窗口结束再发下一批。例如窗口 1 秒、目标 5 个 zip就用 0.2 秒间隔import time def rate_limited_download(tasks, workers4, rpm300): delay 60.0 / max(rpm, 1) for task in tasks: start time.time() # 这里把 task 丢进线程池执行 elapsed time.time() - start wait delay - elapsed if wait 0: time.sleep(wait)gzip 压缩包本身已经省流量但限速的核心不是省流量而是降低对源站的瞬时压力。源站如果返回429 Too Many Requests重试时要看Retry-After头按它给的时间退避而不是固定 sleep 3 秒再撞上去。4. zip 内容不是直接能用解压、编码与压缩包炸弹4.1 解压时最容易踩的坑中文文件名乱码zipfile模块对文件名的默认编码是 cp437而国内大部分 zip 是用 GBK/GB18030 压缩的。直接用extractall()解压中文文件名会变成绋戝鎷这类乱码。处理方式不是改系统编码而是拿到压缩包内文件名后先按 cp437 解码回 bytes再用 GBK 还原import zipfile, os with zipfile.ZipFile(archive.zip) as zf: for info in zf.infolist(): raw info.filename try: fixed raw.encode(cp437).decode(gbk) except (UnicodeDecodeError, UnicodeEncodeError): fixed raw # 用 fixed 手动拼接目标路径再写文件注意这只适用于 ZipCrypto 和 Store/Deflate 压缩的常规 zip。如果压缩包是用较新工具做的文件名可能就是 UTF-8这时info.flag_bits 0x800会被置位zipfile已经正确解码硬转 GBK 反而破坏原字符串。正确逻辑是先检查 UTF-8 标志位存在就跳过修正。4.2 路径穿越攻击别让 zip 把文件写到目录外解压 zip 最危险的问题不是乱码而是恶意构造的文件名。一个 zip 里可以包含../evil.shextractall()会老老实实解析相对路径文件被写到目标目录之外。修复方式是自己遍历infolist()对每个文件名做规范化校验禁止任何成员跑到输出目录外import os def safe_extract(zip_path, out_dir): out_dir os.path.abspath(out_dir) os.makedirs(out_dir, exist_okTrue) with zipfile.ZipFile(zip_path) as zf: for info in zf.infolist(): target os.path.abspath(os.path.join(out_dir, info.filename)) if not target.startswith(out_dir os.sep) and target ! out_dir: raise ValueError(path traversal detected: %s % info.filename) zf.extractall(out_dir)normpath会把..折叠掉但防御要写在extractall之前而不是解压后检查。Windows 场景还要额外注意盘符C:\evil.exe经过os.path.join后可能与目标目录完全无关所以判断必须用startswith(out_dir os.sep)而不是in。4.3 压缩包炸弹与解压上限压缩包炸弹的原理是极小的压缩包解压出巨量数据常见极限可以达到几千倍膨胀比。爬虫任务里碰到这种包轻则磁盘写满重则拖垮整个任务。防弹要在解压前做好双重检查单个文件的压缩比以及所有文件累计解压后大小。下面给一个计入总大小的安全解压实现MAX_TOTAL 2 * 1024**3 # 总解压上限 2GB MAX_RATIO 1000 # 单文件压缩比阈值 def extract_checked(zip_path, out_dir): with zipfile.ZipFile(zip_path) as zf: total 0 for info in zf.infolist(): if info.file_size MAX_RATIO * max(info.compress_size, 1): raise RuntimeError(suspicious ratio: %s % info.filename) total info.file_size if total MAX_TOTAL: raise RuntimeError(total too large after extraction) zf.extractall(out_dir)阈值要按业务调。日志 zip 压缩比常年 200 以上是正常的但单个文件达到 1000 倍就值得警惕。更保守的做法是边解压边统计写到磁盘的字节数超过阈值就删掉重来这样能防住部分先膨胀后报错的包。解压目录要和下载目录分离炸弹包宁可留在下载区待人工审查也不要自动展开。5. 带密码的 zip 与 zip 密码恢复到底怎么处理才合规5.1 zip 常见加密方式ZipCrypto 与 AESzip 文件加密不是一个统一标准。传统工具的密码保护大多用 ZipCrypto算法弱、已知明文攻击可行破解速度很快。较新的工具默认用 AES-256 加密破解难度明显提升。先用zipfile判断压缩包是否加密再看加密标志位能避免在破解环节浪费时间import zipfile with zipfile.ZipFile(locked.zip) as zf: for info in zf.infolist(): if info.flag_bits 0x1: print(encrypted:, info.filename)flag_bits 0x1只说明有密码保护不区分 ZipCrypto 还是 AES。最高效的处理路径是能记起密码就根本不用破解记不起但合法拥有才进入密码恢复流程。这里有个容易被忽略的点——有些在线解压工具在密码错误时不会报错而是写出一堆垃圾文件所以校验密码要在内存里试不要在磁盘落文件。5.2 字典恢复的基本思路Python 的zipfile支持传入pwd参数但它是纯 Python 实现逐字节运算速度很慢只在密码列表非常短时可用。更快的路径是用专用工具提取 zip 哈希后交给哈希破解器。常用流程是先用zip2john提取哈希再用字典跑zip2john locked.zip locked.hash john --wordlistrockyou.txt locked.hashrockyou.txt 是比较常见的字典但只有十几万条密码现在很多工具生成的密码根本不在里面。更实用的是按目标站点规则生成掩码字典密码位数为 8 到 12 位、包含小写与数字用掩码?l?l?l?l?l?l?d?d之类去跑。掩码顺序从常见规律开始先试纯小写加数字再试大小写混合。下面这段 Python 适合密码候选数不多几万条的简单场景import zipfile def try_passwords(zip_path, candidates): with zipfile.ZipFile(zip_path) as zf: for pwd in candidates: try: zf.testzip(pwdpwd.encode()) return pwd except (RuntimeError, zipfile.BadZipFile): continue return Nonetestzip()会逐文件读 CRC密码错就直接抛RuntimeError。候选密码超过 10 万条时不建议走这条纯 Python 的加解密循环会跑得人想砸电脑。5.3 只跑自己资源的注意事项密码恢复的前提必须是自己有合法访问权限的压缩包。内部系统导出的加密备份、个人资料打包、公司交接的历史数据这些才是合理场景。不要拿这套流程去试探别人上传的文件更不要接入任何外部破解接口。爬虫任务如果遇到带密码的公开资源最稳妥的做法是跳过、记录 URL 和文件名留待人工确认授权后再处理。合规边界比技术能力重要得多。6. 爬虫.zip 的最后一公里解压后校验与文件指纹验证6.1 用 CRC32 校验解压是否正确下载完成和解压完成是两回事。zip 内部每个文件都带 CRC32 校验值zipfile解压时其实已经做了一次 CRC 校验失败会在extractall()时抛BadZipFile。但我一般会在解压后再用zinfo.CRC对每个落盘文件做一次独立 CRC 计算确认磁盘写入过程没有损坏import zipfile, zlib def verify_crc(zip_path): with zipfile.ZipFile(zip_path) as zf: for info in zf.infolist(): with zf.open(info) as fp: crc 0 for chunk in iter(lambda: fp.read(65536), b): crc zlib.crc32(chunk, crc) if crc 0xFFFFFFFF ! info.CRC: return False return True注意zlib.crc32的返回值是带符号整数要和info.CRC比较时先 0xFFFFFFFF否则负数永远不相等。6.2 对下载文件做 SHA-256 指纹比对CRC32 只防传输损坏不防内容被替换。源站如果给出官方 SHA-256 摘要就优先比对摘要CRC32 作为弱校验兜底。指纹可以复用下载时的流下载的同时算一遍不用再读一遍文件import hashlib def download_with_sha256(url, local_path, expectedNone): h hashlib.sha256() with requests.get(url, streamTrue, timeout(5, 30)) as resp: with open(local_path, wb) as fp: for chunk in resp.iter_content(chunk_size1 20): fp.write(chunk) h.update(chunk) digest h.hexdigest() if expected and digest ! expected: os.remove(local_path) raise ValueError(sha256 mismatch: %s % digest) return digest这样下载、计算指纹、落盘一次完成不会因为二次读文件浪费 IO。指纹比对失败的包直接删除比保留更有价值重下成本通常低于排查篡改源的成本。6.3 复用下载-解压-校验的最小模块把前面几章的关键点收拢成一个可复用函数下载时用流式、写前检查 zip 签名、解压前拦路径穿越、解压时卡文件大小、解压后校验 CRC。这个函数可以直接接进爬虫任务队列每个任务返回一个包含path、digest、file_count的字典方便后续做数据处理或入库。真正上线时再往里面加日志和持久化断点核心栈就这么几层够了。本文还有配套的精品资源点击获取