ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python爬虫实战:批量下载页面附件与MD5内容去重

Python爬虫实战:批量下载页面附件与MD5内容去重 做爬虫的人早晚会遇到一个场景目标网站不只是给你返回HTML页面还扔出一大堆PDF、Word、Excel附件你的任务是把它们全部抓到本地。这类需求在资料站、论文库、招标公告、文档中心里太常见了——你以为自己学的是爬虫结果干的是搬运工加仓库管理员的活。这节课就是典型的下载型资源采集实战从页面里找出所有附件链接、逐个下载、再对文件做内容级去重校验。适合刚学完requests和BeautifulSoup基础、想拿真实项目练手的零基础读者学完之后你不仅能抓PDF任何类型附件的批量采集思路都会打通。我在前面几节课里反复强调过爬虫工作流里下载文件这个环节最容易被低估。很多人以为拿到链接、requests.get一下就完事了结果文件损坏、重复下载、磁盘爆满、被封IP各种问题在项目上线后才集中爆发。下载型资源采集真正的难点不在“下载”本身而在两件事一是怎么把隐藏在页面各个角落的附件链接全部、准确地抽出来二是怎么判断“这个文件我是不是已经下过了”。这节课我会把这两个核心问题拆开揉碎讲清楚代码直接可以抄去改。1. 项目背景与整体设计思路1.1 下载型采集和普通页面采集的本质差异普通网页爬虫你抓下来的是一份HTML文本解析、清洗、入库就结束了整个流程都在内存和数据库里转。下载型采集完全不是一回事你得把二进制流写到磁盘要考虑磁盘空间、文件命名、覆盖冲突、下载中断甚至要考虑目标服务器对连续大文件请求的容忍度。这就好比一个是把菜谱抄到本子上另一个是把整个菜市场的食材搬回家——后者需要考虑的事情明显更多。从数据链路来看普通采集的终点是“入库”下载型采集的终点是“落盘”。一旦文件落到磁盘你后续的文件管理、去重、增量更新都建立在文件系统之上所以对文件的操作必须更谨慎。比如你只判断URL是否重复而不看文件内容是否一致那么网站更新了同一个URL下的PDF版本时你就漏掉了新文件——这种坑在实战里特别容易踩。1.2 技术选型为什么还是requests加BeautifulSoup有读者问既然要下载附件能不能直接用Scrapy的FilesPipeline能但我不建议零基础阶段一上来就上框架。这节课的目标是把下载型采集的底层逻辑讲透用requests加BeautifulSoup最直接一个负责网络请求一个负责HTML解析中间没有任何黑盒每一步发生了什么你都能看到。选型时还有几个补充考虑。首先要装的是requests和beautifulsoup4这两个是绝对主力其次是标准库里的hashlib用来做MD5内容校验再就是os和pathlib负责目录创建和文件路径操作。如果你的目标网站页面是动态渲染的比如附件链接藏在JavaScript里那这节课的静态解析方案就不够用需要上selenium——相关思路我在后面的常见问题章节会提到。1.3 项目目标拆解从页面到磁盘的完整链路我们用一个虚拟场景来定义需求某个资料站的文章详情页里正文中间和底部散落着PDF附件、Word文档、Excel表格页面结构不规律有些链接带相对路径有些带查询参数还有些文件名是URL编码后的乱码。需求是把页面内所有附件下载到本地按日期打平成目录并且在重复采集时不能重复下载。这个需求拆开来看有三个子任务。第一个是链接提取从HTML里找出所有指向附件文件的a标签还得过滤掉指向其他页面的普通链接。第二个是稳健下载处理超时、连接重置、文件名非法字符、目录不存在等异常确保下载过程十次里能成九次。第三个是去重校验先做URL级去重快速拦截再做文件内容级校验保证同一文件就算换了链接也不会重复落盘。三条链路各有各的坑我们逐个解决。2. 环境准备与依赖安装2.1 零基础视角的Python环境搭建要点如果你还没装Python我强烈建议直接装Python 3.8以上的版本不要去碰Python 2——现在主流爬虫库早就不维护Python 2了你装了只会给自己挖坑。Windows用户去官网下载安装包时记得勾选“Add Python to PATH”这个选项很多新手栽在“python不是内部或外部命令”上十有八九就是忘了这一步。我自己的习惯是安装完Python后立刻验证一下环境在命令行输入python --version能正常输出版本号就说明环境通了。然后升级一下包管理工具接着安装依赖。这里多说一句能不使用虚拟环境零基础阶段先用全局环境把代码跑通等以后项目多了再学venv隔离不然一上来就搞虚拟环境很容易被各种激活脚本绕晕。2.2 安装requests、beautifulsoup4与hashlib依赖安装一行命令搞定pip install requests beautifulsoup4hashlib不需要单独安装它是Python标准库跟着解释器一起就带了。你可以打开命令行依次import一下这几个库确认没有报错就代表环境没问题。python -c import requests; import bs4; import hashlib; print(ok)如果看到ok说明环境已经就绪。有个小坑要提醒如果你电脑上装了多个Python版本pip安装的包可能装到别的版本里去了。保险起见用python -m pip install requests beautifulsoup4来安装这样能确保包安装到当前python命令对应的解释器里。2.3 抓包与页面分析的准备工作动手写代码之前先花几分钟用浏览器开发者工具看看目标页面的结构。打开页面后按F12切到Elements面板直接搜“pdf”这个关键词就能看到附件链接长什么样。我一般是盯着三处看附件链接是相对路径还是绝对路径、链接是否带查询参数、文件真实扩展名是不是藏在链接中间或onclick事件里。扩展名过滤规则也要提前定好。常见附件扩展名包括.pdf、.doc、.docx、.xls、.xlsx、.ppt、.pptx、.zip、.rar把这些放进一个集合解析时直接判断链接后缀是否命中集合。注意有些链接长这样/download?id123filereport.pdf后缀分析会被查询参数干扰等会儿写正则时得把URL路径部分单独拎出来处理。3. 核心实现从页面解析到文件落盘3.1 链接提取把藏在页面里的附件全部找出来解析页面的代码并不复杂关键在细节。先用requests把页面拿下来这里设置一个合理的User-Agent别让服务器一眼看出你是爬虫import requests from bs4 import BeautifulSoup 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 } def fetch_html(url): resp requests.get(url, headersheaders, timeout15) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text html fetch_html(https://example.com/news/20250110/12345.html) soup BeautifulSoup(html, html.parser)拿到BeautifulSoup对象之后遍历所有a标签把href属性取出来。这里有个重要细节href可能是None有些a标签是锚点或者JavaScript伪协议直接用会报错或者拿到一堆垃圾值。所以每一条href都要先做类型判断和清洗。from urllib.parse import urljoin, urlparse ATTACH_EXTS {.pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .zip, .rar} def is_attachment_link(href): if not href: return False href href.strip() if href.startswith((javascript:, mailto:, tel:)): return False path urlparse(href).path.lower() return any(path.endswith(ext) for ext in ATTACH_EXTS)is_attachment_link的核心逻辑是解析URL路径部分拿路径后缀和扩展名集合比对。之所以必须用urlparse而不直接对整个href做endswith就是为了避开“/download?idreport.pdf”这种查询参数污染后缀判断的情况。等会儿你会发现很多真实链接比这更脏比如带着中文编码、多个参数、大小写混用所以lower()也别忘了。3.2 相对路径补全与链接去重的第一道防线HTML里的href经常是相对路径比如./files/report.pdf如果不处理直接拿来请求就会404。解决办法是用urljoin把当前页面URL作为base自动拼成完整绝对地址。这一步是链接提取里最容易出错的地方很多人在这一步少了urljoin结果全站附件下载失败。拿到全部附件绝对链接后先用集合做一次URL级去重。我们设一个set每拿到一个链接判断是否已经在集合里不在就加入待下载列表。这个去重是快速拦截能过滤掉99%的重复请求但它拦不住“同一个文件在不同URL下重复出现”的情况真正的内容级去重要等文件下载完之后用计算哈希的方式解决。def extract_attachment_links(page_url, html): soup BeautifulSoup(html, html.parser) links set() for a in soup.find_all(a, hrefTrue): href a[href].strip() if is_attachment_link(href): abs_url urljoin(page_url, href) links.add(abs_url) return list(links)注意find_all(a, hrefTrue)这个写法等于直接把没有href属性的a标签过滤掉了省去了后面判断attr是否存在的麻烦。拿到绝对URL后直接往集合里塞利用集合自动去重的特性把重复URL过滤掉。这个函数返回的是一个列表后面下载时按顺序遍历即可。3.3 下载函数流式写入与异常兜底链接拿到手进入核心下载环节。我推荐的写法是流式下载用resp.iter_content()按块读取边读边写文件而不是一次性resp.content。这样做的好处是两个一是内存占用恒定不管文件多大都不会把内存撑爆二是下载到一半网络中断时已写入的部分还能保留方便后续断点续传排查。def download_file(url, save_path, headers, timeout30): resp requests.get(url, headersheaders, timeouttimeout, streamTrue) resp.raise_for_status() with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) return save_path这段代码看起来简单但有个隐藏问题如果目标文件特别大且中途断了requests会抛出requests.exceptions.ConnectionError或ChunkedEncodingErroropen里面的文件句柄倒是不会泄漏但你会得到一个半截文件这个半截文件如果不处理后面做内容校验时会当成完整文件入库。所以我一般会在异常分支里删掉不完整的文件这个细节放到后面异常处理章节再讲。还有一个小坑必须提醒有些服务器返回的文件没有Content-Length头或者服务器不支持流式响应这时候用streamTrue会一直等到服务器关闭连接才结束。我的妥协方案是给请求加一个timeout同时配合后面讲的“文件大小校验”如果下载完发现文件为0字节或者远小于预期就把这次下载标记为失败。4. 去重校验机制URL去重与内容校验双保险4.1 为什么只做URL去重不够很多初写爬虫的人做下载去重就是维护一个已下载的链接列表下次再碰到这个链接就跳过。这在网站结构稳定时确实能用但现实中的资料站根本没这么老实。同一个PDF可能挂在两个入口一个在首页轮播图一个在列表页同一个文件也可能因为CDN节点不同在页面上展示为两个不同域名下的链接。只靠URL去重同一个文件会被下载两遍。更麻烦的情况是文件更新。网站管理员把2024年版本的报告替换成了2025年版本但URL没变。如果你的去重逻辑只比对URL那么看到已下载记录就直接跳过——你永远拿不到新版本。这时候必须引入内容级去重也就是对文件内容本身做指纹计算比对指纹是否已存在。4.2 MD5内容哈希如何为文件打唯一指纹MD5是一种哈希算法能把任意长度的文件内容计算成一个固定长度的十六进制字符串。这个字符串在文件内容一致时完全相同内容只要改动一个字节哈希值就会完全不同。所以它天然适合做文件指纹。import hashlib def calc_md5(file_path, chunk_size8192): md5 hashlib.md5() with open(file_path, rb) as f: while chunk : f.read(chunk_size): md5.update(chunk) return md5.hexdigest()这里必须用分块读取原因和文件下载一样大文件不能一次性读进内存。hashlib的update方法支持反复调用每次读8KB就往摘要算法里喂一次最终算出的结果和一次性读完整个文件的结果完全一致。我刻意用了海象运算符:它的作用是“先读8KB赋值给chunk再判断是否为空”代码更紧凑Python 3.8以上版本都支持正好匹配前文推荐的环境版本。4.3 文件名哈希索引把已下载文件登记进“黑名单”内容去重的落地方式是维护一个已见哈希集合。每下载完一个文件立刻计算它的MD5把哈希值加进集合。下次下载任何文件同样计算MD5先看哈希是否在集合里——在说明内容重复直接删掉新文件不在说明这是新内容登记入库。这样就算同一个文件换了URL、换了文件名只要内容一样照样能识别出来。downloaded_md5s set() def is_duplicate(file_path, downloaded_md5s): md5 calc_md5(file_path) if md5 in downloaded_md5s: return True, md5 downloaded_md5s.add(md5) return False, md5实际项目里这个集合不可能一直保存在内存里服务器重启就没了。至少要做到把集合持久化最简单的方案是写到一个文本文件里每行一个哈希值。我习惯用Python自带的json模块把哈希列表存成JSON数组下次程序启动时load进来增量更新后再写回去。这一步做扎实了你的采集器才算是真正支持断点续采和增量更新。4.4 下载前快速预检Content-Length与本地文件对比还有一种更省流量的去重思路在下载之前先看服务器返回的Content-Length。如果本地已有一个同名文件且大小一致那大概率是同一个文件。当然这个判断不够严谨文件大小相同但内容不同的情况非常常见所以它只能作为预筛选不能代替MD5校验。我给出的完整流程是先按URL去重再按文件名加Content-Length预检最后下载完成后用MD5收尾。三道防线各有各的作用组合起来才算完整。为什么不能只靠Content-Length举个例子服务器返回的Content-Length可能是Content-Encoding压缩后的大小也可能是HTTP/1.1里chunked传输时的缺失值还有可能在你请求时和实际下载时的文件大小不一致。这些情况我都碰到过所以我的结论永远是预检可以作为快速路径最终判断必须看内容指纹。5. 完整实战代码从列表页爬到全部附件合并5.1 入口函数与整体流程组织前面把各个功能模块讲清楚了现在把它们串成一个完整的、可运行的脚本。我习惯把整体流程写成一个main函数按顺序执行抓取页面、提取链接、逐一下载、MD5校验、清理重复。每一步用print打印一条日志方便实时观察进度。import os import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse import hashlib import json 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 } ATTACH_EXTS {.pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .zip, .rar} DOWNLOAD_DIR ./downloads HASH_INDEX_FILE ./downloaded_hashes.json def load_hash_index(): if os.path.exists(HASH_INDEX_FILE): with open(HASH_INDEX_FILE, r, encodingutf-8) as f: return set(json.load(f)) return set() def save_hash_index(downloaded_md5s): with open(HASH_INDEX_FILE, w, encodingutf-8) as f: json.dump(list(downloaded_md5s), f, ensure_asciiFalse, indent2)注意save_hash_index里我使用了ensure_asciiFalse这样哈希列表文件保存的是可读内容而不是\u转义序列。哈希值本身是纯十六进制中文字符串只会出现在文件名等其他字段里但保险起见还是把这个参数加上避免以后扩展字段时出现乱码。5.2 核心流程拼接抓取、过滤、下载、校验三件套继续往下写把整个流程完整串起来。这里我还加入了一个时间戳子目录避免同一天重复运行脚本时把文件直接改成同名覆盖既方便归档也方便排查问题。def create_subdir(): import time date_str time.strftime(%Y%m%d) subdir os.path.join(DOWNLOAD_DIR, date_str) os.makedirs(subdir, exist_okTrue) return subdir def sanitize_filename(url): path urlparse(url).path name os.path.basename(path) if not name: name download_ hashlib.md5(url.encode()).hexdigest()[:8] invalid_chars :/\\|?* for ch in invalid_chars: name name.replace(ch, _) return namesanitize_filename是我强烈建议的模块。真实网站的附件名五花八门有的带中文有的带空格有的包含了Windows文件名里不允许的字符。如果不做清洗open写文件时直接报Invalid argument异常。另外一个不得不防的情况是URL末尾没有文件名比如/download?id123这种basename拿到的是一个空字符串这时候我用URL自身的MD5前8位来生成一个兜底文件名保证每个文件都有归属。5.3 主流程边下载边校验的完整现场现在把main函数补齐。这里的逻辑顺序是抓HTML、提取链接、遍历链接、逐个下载、下载成功后立即算哈希、判断是否重复。如果重复删掉刚下载的文件并打印skip如果不是重复保留文件并把哈希登记到集合里。最后把集合写回JSON文件实现增量索引持久化。def main(): page_url https://example.com/docs/20250110/project.html downloaded_md5s load_hash_index() save_dir create_subdir() html fetch_html(page_url) links extract_attachment_links(page_url, html) print(f找到 {len(links)} 个附件链接) for idx, url in enumerate(links, 1): filename sanitize_filename(url) save_path os.path.join(save_dir, filename) try: download_file(url, save_path, HEADERS) except Exception as exc: print(f[{idx}/{len(links)}] 下载失败: {url} 原因: {exc}) if os.path.exists(save_path): os.remove(save_path) continue is_dup, md5 is_duplicate_file(save_path, downloaded_md5s) if is_dup: os.remove(save_path) print(f[{idx}/{len(links)}] 重复文件已删除: {filename} md5{md5}) else: print(f[{idx}/{len(links)}] 新文件已保存: {filename} md5{md5}) save_hash_index(downloaded_md5s) print(全部附件处理完成) if __name__ __main__: main()运行后你会看到类似下面的输出找到 12 个附件链接 [1/12] 新文件已保存: report_2025.pdf md53f4b2f8c1aa09e19b5b6d1f6d4e7a1c2 [2/12] 新文件已保存: table_2025.xlsx md59e9c8f7d6b5a4f3e2d1c0b9a8f7e6d5c [3/12] 重复文件已删除: report_final.pdf md53f4b2f8c1aa09e19b5b6d1f6d4e7a1c2注意第3条report_2025.pdf和report_final.pdf文件名完全不同但MD5一致说明底层是同一个文件只是挂了两套链接。这正是URL去重做不到、内容级去重才能拦截的场景也是本节课最核心的实战价值体现。5.4 断点续采支持程序中断后如何继续而不重复爬虫脚本跑一半断电、报错退出是常有的事。如果不做持久化索引重新运行时所有文件都会重复下载一遍。有了downloaded_hashes.json这个问题就迎刃而解程序启动时把上次所有文件的哈希加载进集合遇到已经在集合里的文件下载完算完哈希后会被立即识别并删除虽然还是会多消耗一次网络请求但至少不会重复占磁盘空间。如果想连“重复请求”也省掉可以再维护一个本地文件名集合下载前先看save_path是否存在且大小大于0存在就直接跳过下载并走一遍MD5校验。这算是一种优化路径但要注意同名覆盖问题——前文我加了时间戳子目录所以同名文件在当天会被覆盖跨天则互不影响这样方案才安全。6. 常见问题与排查技巧实录6.1 高频问题速查表现象可能原因排查与解决方法附件链接提取不到链接在JavaScript里动态生成改用selenium或抓接口数据不能只靠静态HTML下载下来是HTML而非PDF服务器未登录返回登录页检查响应头Content-Type带上登录Cookie下载速度极慢未设置stream或服务器限速加streamTrue下载适当增加timeout文件一直报重复并删除MD5索引文件没更新检查save_hash_index是否执行json是否写盘中文文件名乱码URL编码未解码用urllib.parse.unquote处理文件名SSL报错目标证书链不完整可设置verifyFalse并在脚本里关闭警告私域环境可用这张表里的每一行都是我在真实项目里碰到过的组合情况不是凭空写的。特别是“下载下来是HTML”这一条十次里至少有三次是因为没有处理登录态或加了防盗链的Referer校验。如果你发现保存的文件用文本编辑器打开是一堆HTML标签基本就是这个原因。6.2 请求头缺失与反爬虫应对的温和策略说到请求头除了User-AgentReferer也很关键。很多文件服务器会校验请求来源如果你的请求没有带Referer或者来源不对服务器直接拒绝响应或者返回一个错误页。最简单的做法是把当前页面URL作为Referer加到请求头里。headers { User-Agent: Mozilla/5.0 ..., Referer: page_url, }至于更复杂的反爬机制比如频率限制、验证码这里我只说一个原则控制请求频率下载完一个文件后sleep一下比如1到2秒。批量下载时这个习惯能大幅降低被限流的概率属于成本最低的温柔型应对。风险提示写在前面任何采集行为都应当遵守目标网站的服务条款和robots协议数据仅用于合法合规的个人学习或业务场景。6.3 下载中断与半截文件的检测修复前文提到过下载中断会留下半截文件。有些半截文件和完整文件大小差距明显一眼能看出来有些时候服务器提前断开但已写入的内容其实已经是完整的只是Content-Length和本地文件大小对不上。我的排查习惯是下载后检查文件大小为0字节的直接删除重新下载同时用文件扩展名对应的“魔法字节”做初检。所谓魔法字节就是文件开头的几个固定字节。PDF文件开头通常是%PDF十六进制是25 50 44 46ZIP文件开头是PK。我们可以读文件前几个字节判断它们的签名是否匹配预期的文件类型。这一招能在客户端就拦住大量伪装成PDF的HTML文件。def is_pdf_file(file_path): with open(file_path, rb) as f: header f.read(4) return header b%PDF把这个函数加在下载完成后的校验链里文件不是PDF就直接标记为异常比单靠扩展名判断可靠得多。同理docx和xlsx这类Office文件的魔法字节是PK和ZIP相同需要用更细的分段判断但一般初筛到这一步问题已经不大了。6.4 增量更新策略已有文件如何只补新不重复项目跑了一段时间后你大概率不会重新全量下载所有文件而是希望每次只下载新增的那几份。实现这个目标不需要改动核心逻辑只要把“哈希集合”换成“本地文件哈希数据库”扫描下载目录里所有文件的MD5构建索引下次运行时把新文件的MD5和索引比对。这与我们前面的做法是同一个思路区别只是索引的来源从本地扫描获得。我自己的做法是每天定时跑一次脚本先全量扫描本地文件生成哈希索引再抓取页面链接做增量比对。这样即使网站悄悄替换了某些文件内容只要页面链接在变化后的文件也会被识别为新文件并下载下来等于给采集项目加了一个自动追踪更新的能力。增量更新是下载型采集从能用走向好用的分水岭值得花时间打磨。7. 实操总结与扩展思路这节课走到这里你已经掌握了一个完整下载型采集器的所有核心组件页面抓取、链接提取、稳健下载、内容指纹、去重校验、持久化索引。坦率地说这套代码骨架放进真实的个人项目里完全够用即使以后遇到需要每天更新成百上千个附件的场景核心思路也不用变只需要在上面加并发和调度就够了。关于并发我额外说一句不建议零基础阶段直接用multiprocessing或ThreadPoolExecutor做并发下载原因不是并发难学而是你的经验还不足以应付并发带来的文件命名冲突和异常处理复杂度。先把单线程版本跑到稳定再把下载函数改成线程池版本是更稳妥的路径。线程池代码就三五行基础稳固之后加进去很快。最后分享一个我自己的体会下载型爬虫真正考验人的不是爬虫技术而是工程思维。你会开始关注磁盘有没有满、文件名合不合规、下载中断怎么处理、程序重跑会不会重复——这些才是线上项目能不能长期跑下去的关键。把这节课的代码动手跑一遍把每一个异常分支都触发一次你会发现收获远超预期。
返回列表