多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

爬虫文件下载实战:流式下载、MD5去重与完整性校验全解析

爬虫文件下载实战:流式下载、MD5去重与完整性校验全解析 1. 为什么单独拿一节讲文件下载爬虫前面那些章节我们都在讲怎么把页面上的文字、表格、JSON数据抓下来。但真到了实战项目里你会经常遇到一类需求用户要的不是页面内容而是页面背后挂着的那些文件。PDF报告、Word文档、Excel报表、压缩包、图片素材——这些都属于下载型资源。这类资源的采集和普通页面抓取有本质区别。普通页面抓取是拿HTML文本解析结构化数据下载型资源则是把二进制文件完整地落盘保存。听起来只是多写几行代码的事但实际做起来坑不少文件动不动几十MB直接一次性读进内存容易炸一个页面几十个附件下载到一半断了只能重来文件反复采集后硬盘里一堆重复内容几十GB空间白白浪费。去重校验这一件事就足以让一个新手折腾一个晚上。这一节我们就基于实战项目中真实会遇到的一个场景来展开某行业资料门户网站页面结构不复杂但几乎每一篇文章下面都挂着若干个PDF附件。我们需要把这些PDF批量下载到本地同时做到三件事——完整下载、断点续传、去重校验。顺着这个场景我会把文件下载的原理、requests流式下载的用法、MD5去重校验的完整实现以及我在多次实战中踩过的坑一次讲清楚。这节内容适合已经能独立用requests抓取网页、能解析出链接的读者。如果你前面几章的内容已经消化了这节不会有什么理解障碍。如果你刚接触Python也不用担心我会把每个关键步骤都拆开揉碎地讲。2. 先说清楚三个关键设计决策2.1 为什么下载文件必须用流式下载很多人第一次写文件下载代码用的都是这种写法import requests resp requests.get(url) with open(file.pdf, wb) as f: f.write(resp.content)这段代码在小文件场景下跑起来没问题但一旦文件超过100MB就会非常难受。原因在于resp.content会一次性读取整个响应体存入内存。100MB的文件你就要付出至少100MB的峰值内存这还不算requests内部缓冲区和Python对象封装带来的额外开销。如果一台机器同时起10个线程下载冲击1GB内存很正常。正确的做法是开启流式模式用iter_content()方法按块读取每次只把一小块数据写入磁盘循环往复直到读取完毕。这样无论文件多大内存占用都保持在一个比较稳定的低水平。这背后的原理并不复杂。HTTP响应的body是通过网络连接逐步接收的streamTrue让requests不立即把整个body缓存到内存而是保留底层的连接和响应对象由你决定什么时候读、读多少。iter_content()每次返回指定大小的chunk配合文件对象write()就能实现边读边写。2.2 为什么URL去重不靠谱必须用文件指纹去重你可能会想去重还不简单吗同一批URL重复出现了我判断一下URL有没有下载过不就行了现实中完全不是这样。同样的文件经常挂在不同的URL下。举例某个PDF可能同时被三篇不同的文章引用分别以/download?id123、/files/2023/report.pdf、/attachment?fid123tokenabc三个完全不同的URL暴露出来。你只看URL就会把它下载三遍。更复杂的情况是同一个URL下的文件内容会变——服务器上的文件被替换了但URL还是那个URL。这种情况下即使URL已经记录在已下载清单里你也无法判断本地磁盘那份还对不对得上。所以真正可靠的文件级去重是对文件内容做摘要计算也就是计算文件的MD5值。MD5是根据文件字节流算出来的内容只要有一丁点不同MD5都会变。我们把下载完成的文件的MD5记录到一个清单里下次不管是通过什么URL将要下载下载完成拿到MD5后先去查一下清单如果MD5已经存在说明这个文件就是我们之前已经保存过的那个直接删除或跳过。这里顺便纠正一个常见误解MD5确实已经被证明存在理论上的碰撞漏洞不适合用于安全认证场景。但在文件去重这个场景下MD5的碰撞概率在实际文件夹样本空间里依然足够可靠而且计算性能高。配合文件大小比对几乎不可能发生误判。如果一定要更严谨就用SHA-256替换代码差别很小后面会给出来。2.3 校验文件完整度看哪些信号去重解决的是这个文件是不是已经下载过了完整度校验解决的是这次下载的文件的字节数对不对、是不是完整。实际采集工作流里这两个问题往往绑定在一起处理。一个文件算不算下载成功不能只看HTTP状态码是200。状态码只代表服务器对这个URL返回了正常响应不代表响应体就是你想要的那个PDF。有些服务器在你请求失败时会返回一个HTML错误页同样带着200状态码有些会返回一个空文件有些会在传输到一半时中断。所以完整的校验体系至少包括三层第一层是响应头校验重点看Content-Type和Content-Length。Content-Type告诉你返回的类型是否匹配预期类型Content-Length告诉你声称的文件总字节数。第二层是字节数比对。下载完成后统计本地文件的实际大小和响应头的Content-Length做对比不一致说明传输不完整。第三层是文件格式校验。不同文件格式有固定特征PDF文件以%PDF-1.x开头以%%EOF结尾ZIP文件以PK开头PNG图片有固定的8字节签名。校验头尾特征可以确认这不是一份伪装的HTML或半截数据。这三层校验全部过完才真正能判断这个文件下载完整了。我们会在这节的实操部分把这三层完整实现一遍。3. 实战一个完整的PDF附件批量采集流程这一节我们动手写代码。我会把整个实现拆成几个模块每一块都是可以独立复用的小工具。虽然讲的是PDF下载但方法完全适用于任何类型的文件附件。3.1 环境准备和必要依赖先说环境和依赖避免大家在版本上踩坑。实测环境是Python 3.10及以上理论上游3.8以上都能运行。需要的第三方库其实就一个pip install requestshashlib、os、re、urllib.parse这些模块都是Python标准库不用额外安装。这里再提醒一个requests版本的问题。有些老教程会让你安装requests 2.2x或更早的版本但requests原生就支持我们下面要用到的所有功能没必要降级。如果你之前装过特别老的版本建议先升级pip install --upgrade requests3.2 第一步从页面中提取所有PDF链接假设我们采集的目标页面HTML已经通过前面的章节所学的方法抓取回来了存在page_html字符串变量里。现在要从里面提取所有以.pdf结尾的链接。实际场景中PDF链接的出现方式非常多有的是a hrefxxx.pdf直接链接有的是script里动态拼接出来的有的是藏在>from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse def extract_pdf_urls(page_url, page_html): pdf_urls [] soup BeautifulSoup(page_html, html.parser) for link in soup.find_all(a, hrefTrue): href link[href].strip() # 跳过空链接、javascript协议、锚点链接 if not href or href.startswith((javascript:, #)): continue # 只关心以 .pdf 结尾的链接 if href.lower().endswith(.pdf): # 把相对路径拼接成绝对URL absolute_url urljoin(page_url, href) pdf_urls.append(absolute_url) # 有些链接带查询参数比如 file.pdf?id123 elif .pdf in href.lower().split(?)[0]: absolute_url urljoin(page_url, href) pdf_urls.append(absolute_url) # 去重URL return list(set(pdf_urls))这段代码里最关键的是urljoin()这个函数。它能把相对路径自动拼接成完整的绝对URL规避了相对路径拼接错误导致404这种非常隐蔽的坑。比如页面地址是https://example.com/news/2024/index.html链接是../files/report.pdfurljoin会自动得到https://example.com/files/report.pdf不用你自己一个个去拼。另外一个容易被忽略的点.pdf的判断要记得忽略大小写。Windows和Linux服务器上文件名大小写规则不同有些服务器上的文件就叫Report.PDF直接endswith(.pdf)会漏掉。3.3 第二步带校验的通用文件下载器现在写核心的下载函数。这个函数我会在实战里反复调整过很多版本目前这个版本兼顾了完整率、内存占用和可读性。import os import hashlib import requests CHUNK_SIZE 64 * 1024 # 每次读取64KB def download_file(url, dest_path, headersNone, timeout30): 流式下载返回字典作为结果状态 result { url: url, dest: dest_path, success: False, downloaded_size: 0, expected_size: None, md5: None, } # 默认携带一个常见的浏览器UA避免被部分服务器拒绝 default_headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36, Accept: */*, } if headers: default_headers.update(headers) try: with requests.get(url, headersdefault_headers, streamTrue, timeouttimeout) as resp: # 先判断状态码 if resp.status_code ! 200: result[error] fHTTP {resp.status_code} return result # 读取Content-Length注意可能没有这个头 content_length resp.headers.get(Content-Length) if content_length is not None: result[expected_size] int(content_length) # 确保保存目录存在 os.makedirs(os.path.dirname(dest_path) or ., exist_okTrue) hash_md5 hashlib.md5() downloaded 0 with open(dest_path, wb) as f: for chunk in resp.iter_content(chunk_sizeCHUNK_SIZE): # iter_content可能返回空块必须跳过 if not chunk: continue f.write(chunk) hash_md5.update(chunk) downloaded len(chunk) result[downloaded_size] downloaded result[md5] hash_md5.hexdigest() result[success] True except requests.RequestException as e: result[error] str(e) # 中断时清理半成品文件 if os.path.exists(dest_path): os.remove(dest_path) return result这个函数里有几个细节值得专门说明。为什么要让hash_md5.update(chunk)跟着文件写入一起做而不是下载完再单独打开文件算MD5因为这样只用一次磁盘读取就同时完成了落盘和计算尤其是文件特别大时能省下非常可观的时间和磁盘I/O开销。为什么CHUNK_SIZE选64KB而不是1MB或者4KB64KB是在大量实际场景里折中出来的值太小会导致网络读写频繁切换CPU开销上升太大会让内存占用和单次解压缓冲变大。如果要下载超大文件比如数GB可以适当调大到256KB如果网络不稳定建议保持小一些降低单块重传的代价。iter_content()可能返回空块这个点必须重视。网络波动、服务器返回异常时会出现空内容直接把空块写入文件不会报错但会把文件体积撑出多余的字节。加一个if not chunk: continue就很稳。还有一个注意点finally里最好把resp对象尽快释放使用with包裹请求上下文已经帮我们处理了这一点。如果你要在异常后想保留没有下载完的半成品以便断点续传那可以不用删除文件。但初学者阶段我强烈建议失败即删除否则半成品文件会混入已完成列表后面做去重校验时会把你坑得很惨。3.4 第三步完整度校验的三层检查下载器返回结果后不能直接就登记成功。要完成前面说的三层校验。我封装了一个函数import re def validate_pdf_file(file_path, expected_sizeNone): 返回 (is_valid, reason) if not os.path.exists(file_path): return False, 文件不存在 actual_size os.path.getsize(file_path) if actual_size 0: return False, 文件为空 if expected_size is not None and actual_size ! expected_size: return False, f大小不一致: 期望{expected_size}, 实际{actual_size} try: with open(file_path, rb) as f: head f.read(1024) # 读取文件尾部 f.seek(-1024, os.SEEK_END) tail f.read() except OSError: return False, 读取文件失败 # PDF文件头尾特征 if not head.startswith(b%PDF-): return False, 文件头不是PDF标识可能下载的是错误页面 if b%%EOF not in tail: return False, 文件缺少EOF结束标记传输不完整 return True, 校验通过这段代码里读文件尾部那段可能有人看不懂f.seek(-1024, os.SEEK_END)表示从文件结尾往前数1024字节然后读剩下的内容。%%EOF标记正常情况下出现在文件最后几百字节内所以读末尾1KB就足够了。如果PDF文件很大%%EOF离文件末尾正好超过1KB的情况几乎不存在。这一层校验的价值体现在实时上就是某个PDF下载完成后实际大小和Content-Length一致但打开发现是乱码或被截断的内容。多半是页面返回的本身就是一个残缺服务器文件尾部校验能直接拦截。校验文件头这一点对企业内部不上CDN的资料库尤其有用。我接手过的一个项目的资料库在错误时返回的不是4xx状态码而是200一段HTML提示页。在没做文件头校验之前差不多有5%的PDF文件实际上是一坨403请联系管理员的HTML源码看着文件名是PDF其实根本没下下来。3.5 第四步MD5指纹去重给文件发身份证号来到这一节的核心亮点为了支持跨URL去重需要维护一个全局指纹库。每次下载完成后算出的MD5先去指纹库里查一下。如果存在说明本地已有完全相同的文件返回一个新字段告知调用方重复了。class DuplicateCheck: def __init__(self, fingerprint_filedownloaded_hashes.txt): self.fingerprint_file fingerprint_file self.hash_set set() self._load() def _load(self): if os.path.exists(self.fingerprint_file): with open(self.fingerprint_file, r, encodingutf-8) as f: for line in f: line line.strip() if line: self.hash_set.add(line) def is_duplicate(self, file_hash): return file_hash in self.hash_set def add_hash(self, file_hash, extra_info): if file_hash not in self.hash_set: self.hash_set.add(file_hash) # 追加写崩溃也不会导致全库丢失 with open(self.fingerprint_file, a, encodingutf-8) as f: line file_hash if extra_info: line f|{extra_info} f.write(line \n) def count(self): return len(self.hash_set)这个类的设计有一个我在实践中验证过的点如果每次启动都全量把MD5清单读进内存几万条记录根本没什么压力。假设每行100字节10万条记录也就10MB内存。所以完全可以用一个set存内存启动时全量加载不需要引入数据库。但如果你的采集规模达到百万级指纹就建议换成SQLite存储查询效率更稳定。指纹库的每一行默认只存MD5值。你可以把源URL、文件大小作为附加信息用|分隔附在后面方便后续排查。注意这个文件的写入要用追加模式a不要整批重写。一旦程序在写入中途崩溃重写模式会丢失整个指纹库追加模式最多丢一行。3.6 主流程把这些模块串起来跑现在把所有环节串成完整的主流程模拟真实采集import time def collect_pdfs(start_urls, headersNone, output_dirdownloads): checker DuplicateCheck(downloaded_hashes.txt) results { success: 0, duplicate: 0, failed: 0, } session requests.Session() session.headers.update( headers or {User-Agent: Mozilla/5.0 ...} ) for page_url in start_urls: pdf_links [] try: resp session.get(page_url, timeout30) if resp.status_code 200: pdf_links extract_pdf_urls(page_url, resp.text) except requests.RequestException as e: print(f[!] 页面抓取失败: {page_url}, {e}) continue print(f[*] 从 {page_url} 发现 {len(pdf_links)} 个PDF链接) for idx, pdf_url in enumerate(pdf_links, 1): filename generate_unique_filename(pdf_url, idx) dest_path os.path.join(output_dir, filename) # 下载 dl_result download_file(pdf_url, dest_path, sessionsession) if not dl_result[success]: print(f [!] 下载失败: {pdf_url} - {dl_result.get(error)}) results[failed] 1 continue # 校验 is_valid, reason validate_pdf_file( dest_path, dl_result[expected_size] ) if not is_valid: print(f [!] 校验失败: {pdf_url} - {reason}) os.remove(dest_path) results[failed] 1 continue # 去重 if checker.is_duplicate(dl_result[md5]): os.remove(dest_path) print(f [~] 已存在相同文件跳过: {dl_result[md5]} - {pdf_url}) results[duplicate] 1 continue checker.add_hash(dl_result[md5], f{pdf_url}|{os.path.getsize(dest_path)}) results[success] 1 print(f [] 成功: {pdf_url} - {dest_path} (MD5: {dl_result[md5]})) print(f\n完成: 新增 {results[success]}, 去重 {results[duplicate]}, 失败 {results[failed]}) return results主流程看着长逻辑其实很简单页面级循环→提取PDF链接→逐一下载→校验→去重→登记指纹。我这里加入了requests.Session()这是一个容易被新手忽略但极大的性能优化点。Session会复用底层的TCP连接对同一个域名反复请求几十个PDF时能省去每次重新握手的时间。实测下来对于同一站点下载100个文件Session复用比裸requests.get快出将近一倍这还是在没开并发的情况下。generate_unique_filename你可能会好奇是什么函数。因为我们可能在一个页面里发现20个都叫report.pdf的链接直接按链接末尾文件名保存会互相覆盖。我通常会写一个小函数def generate_unique_filename(url, index): # 从URL中取出文件名部分 path urlparse(url).path filename os.path.basename(path) if not filename or not filename.lower().endswith(.pdf): filename ffile_{index}.pdf # 清洗可能存在的非法字符 filename re.sub(r[\\/*?:|], _, filename) # 避免同名覆盖加序号前缀 return f{index:03d}_{filename}用序号前缀可以保证有序性和唯一性。如果不喜欢序号也可以改成时间戳前缀。但我不建议把序号去掉理由很直接对于人来说《行业报告2024年度》_001.pdf和《行业报告2024年度》_002.pdf按文件名一眼能看出是同一篇的两个附件而如果文件名是随机字符串你后续人工核对时根本分不清谁是谁。4. 实操过程中的细节补充与参数选择分析代码层面已经能跑了但作为实战经验分享必须把几个教科书不会教的细节单独拿出来聊。4.1 关于断点续传的两个方案上面的代码在下载失败时会把半成品文件直接删掉这样可以保证指纹库的准确性。但代价是下载一个200MB的PDF如果下载到80%时网络闪断下次又得从头开始下载前面的80MB流量白白浪费了。某些业务场景里断点续传几乎是刚需。Requests原生支持加Range请求头实现断点续传headers {Range: fbytes{downloaded}-} resp requests.get(url, headersheaders, streamTrue)需要注意的是服务器不一定支持Range请求。当服务器返回206 Partial Content表示支持返回200表示服务器忽略你的Range头把整个文件又从头返回了。这种情况你需要在代码里判断一下重新从0开始写文件否则文件会变脏。我个人的经验是断点续传适合在文件体积大、网络环境又不稳定的场景做如果是大量的小文件比如每个PDF只有几百KB那追求断点续传反而是浪费时间直接失败删除、重新下载更省心。4.2 文件名编码问题的处理中文文件名在下载场景里简直是Bug高发区。很多服务端在响应头里返回Content-Disposition规范的做法是Content-Disposition: attachment; filename*UTF-8%E6%8A%A5%E5%91%8A.pdf这里Excel编码之后直接按字符串存文件名会得到一串百分号乱码。更常见的是urlparse(path).path拿到的中文路径是URL编码过的比如%E6%8A%A5%E5%91%8A.pdf。如果不做URL解码保存下来就是乱码文件名。解决方案有一个极度简单的from urllib.parse import unquote filename unquote(filename)一个小地方但对体验提升巨大。4.3 Content-Length为空的场景服务器没返回Content-Length头时怎么办常见于HTTP/1.1的chunked transfer encoding传输。这种情况下你没法在做下载前就知道文件大小下载完成后的校验就只能依赖文件头尾特征和格式校验。我在代码里已经做了兼容expected_size没有值时校验函数自动跳过大小比对。但注意下载器里downloaded计数器依然要保留因为你后面可能想拿它做日志分析。4.4 时间与性能单线程 vs 并发下载上面主流程是单线程逐个下载实际我在爬取某个公开资料库时一天要下3000多个文件。单线程按平均每文件1秒算光下载就要50分钟太慢了。并发改造其实非常简单用concurrent.futures.ThreadPoolExecutorfrom concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers5) as executor: future_to_url { executor.submit(download_file, url, file_path): url for url, file_path in download_tasks } for future in as_completed(future_to_url): result future.result() # 后续校验、去重逻辑相同max_workers的建议值是3到8之间别开太大。原因很简单服务器不是只伺候你的爬虫并发开太高会给目标服务器带宽和连接数造成压力容易被封IP甚至会违反基本的采集礼仪。另外指纹库DuplicateCheck不是线程安全的并发下载完成后汇总到主线程做去重登记不要在每个线程里直接改hash_set。5. 常见问题与排查技巧实录这一节是踩坑实录。每个问题都是我或身边的人实际遇到过的按出现频率排序。5.1 下载的文件打开是乱码或HTML表现文件后缀是.pdf双击打开却显示一堆HTML标签和文本。原因服务器对请求返回了一个错误提示页但状态码是200或者是登录失效服务器返回了登录页面。排查方法第一步打开文件文件头就能看明白。如果是!DOCTYPE html开头100%是下载到了网页。第二步用我们validate_pdf_file()里的文件头校验去批量拦截。解决给下载函数加上文件头校验非预期格式直接标记失败。同时建议检查请求头里是否携带了必要的Cookie。很多站点把登录态放在Cookie里你把Session带上去就能绕过。5.2 文件下载一半就断了但代码认为成功了表现下载过程没有报错可文件就是打不开或打开后内容不全。原因服务器在传输中断开连接时某些场景下requests不抛异常比如底层socket收到RST但上层包尚未读取完。排查方法比对本地文件大小和响应头Content-Length。不一致就是传输中断。解决严格开启校验把大小比对作为硬校验项。另外在下载循环里如果连续多次iter_content()都返回空块说明连接已经异常可以主动抛异常跳出去。5.3 文件名超长导致保存失败表现FileNotFoundError或OSError: [Errno 36] File name too long。原因Linux文件系统对单段文件名限制255字节Windows路径限制260个字符。长篇中文标题的PDF很容易踩线。解决保存文件名之前统一长度控制超过60个字符就截断def safe_filename(filename, max_len60): name, ext os.path.splitext(filename) if len(name) max_len: name name[:max_len] return name ext5.4 指纹库误删了本地文件表现同一个文件指纹库里已经记录了MD5但本地文件因为换磁盘或清理被人删了。去重逻辑看到指纹库存在就跳过结果这个文件再也下载不回来了。解决指纹库在设计时就应该绑定两个信息MD5值和本地保存的路径。在判断is_duplicate时同时检查本地路径是否存在。如果MD5在库里但路径没了仍然重新下载并更新记录。这也是我在add_hash里用|拼接了URL信息的原因实际生产环境建议存三列MD5、保存路径、来源URL用csv或sqlite管理更清晰。5.5 同一批文件反复重下但MD5对不上表现你以为上次下过这个文件了重跑一次却发现指纹库里的MD5和本地文件的MD5不一致。原因文件可能过期被服务器重新生成。比如某些动态PDF会根据请求时间加水印或过期时间同一内容的文件每次下载的内容都不完全相同。解决这种情况去重逻辑要从文件级退回到页面级。记录来源URL如果URL相同默认这个文件不需要重下只有在URL变化时才视为新文件。换句话说这种反爬策略下做MD5去重纯属给自己找麻烦。6. 进阶扩展思路把下载器变成可复用组件如果你把这节的代码完整跑通了会发现这套下载校验去重的骨架能直接迁移到很多场景。稍微做几个扩展就能变成一个更通用的工具箱。6.1 支持多类型文件现在代码里硬编码了PDF的头部特征校验。想支持ZIP、图片、Office文档只需要维护一个文件特征表MAGIC_PATTERNS { .pdf: [b%PDF-], .zip: [bPK\x03\x04, bPK\x05\x06], .png: [b\x89PNG\r\n\x1a\n], .jpg: [b\xff\xd8\xff], .docx: [bPK\x03\x04], # docx本质上是zip }校验函数改成根据文件后缀选择对应的MAGIC匹配失败就标记异常。6.2 接入消息队列做大数据量采集文件数量超过一定规模比如日采集10万个文件后本地Python脚本单进程就跑不动了。成熟做法是把下载任务写成独立的消息条目放进Redis或RabbitMQ队列多个Worker进程并发消费。每台机跑几个Worker配合指纹去重横向扩展很线性。这一节先不展开但思路可以提前留个印象。6.3 增加文件大小上限保护很多服务器上你请求一个PDF结果响应给你一个几个GB的巨型文件这个比例意外地高。所以下载函数里加一个保护逻辑很实用if expected_size and expected_size MAX_FILE_SIZE: result[error] f文件过大: {expected_size} return resultMAX_FILE_SIZE按业务需求设置一般个人采集设200MB或500MB足够。防止一个异常请求把磁盘塞满。6.4 采集合规性的最后提醒这部分多说一句重要但容易忽略。写爬虫做文件采集时无论技术多娴熟务必注意只能采集自己有权访问或已获得授权的数据关注目标网站的robots.txt和服务条款遵守合理的请求频率。下载的文件应仅用于学习、备份、引用等合法用途。做技术被技术反噬的案例比成功案例多得多别把自己拿去填坑。关于下载型资源采集我的体会可以归结为一句话下载器的难点从来不在于把文件写进磁盘而在于验证你拿到的文件真的就是你要的那个完整文件。流式下载、三层完整度校验、MD5指纹去重这三板斧差不多能解决90%以上的PDF和附件采集需求。剩下的10%——动态签名文件、反爬加限制、错漏百出的服务器实现——那就需要靠上面那些排查技巧和经验判断了。这一节的内容足够撑起你去做一个像样的批量下载工具了遇到具体站点时多调试几次就会越来越信手拈来。
返回列表