☰
开放目录站点文件批量下载:Python爬虫与附件发现器实战
2026/10/7 4:29:14 网站建设 项目流程

1. 开放目录站点长什么样:先搞清楚你要解析的HTML规律

很多老牌的资料站、学校内部的教学资源库、开源项目的镜像仓库,甚至某些网盘工具的分享页,都会用最简单的方式把文件挂在网上:没有搜索框,没有漂亮的UI,打开就是一个文件列表,文件夹带着斜杠,文件直接给你链接。这种站点在服务端基本就是开启了一个目录索引功能,Apache里叫Indexes,Nginx里叫autoindex,静态服务器上也是同理。它们本质上是给管理员用的一种“裸奔”形态,但对访客来说,反而成了一种低成本的资源入口。

我要做的附件发现器,就是针对这类开放目录站点写的一个Python爬虫。它会以某个目录页面为起点,逐层往下走,把里面所有文件的直链找出来,再批量下载到本地。好处是你不用一个个点开文件夹、再点开子文件夹,人肉记住哪些附件已经下过。整个过程交给脚本,你只需要等结果。

在写代码之前,我建议你先学会“看”这类页面的结构。用浏览器打开一个开放目录,按F12打开开发者工具,你看到的页面源码通常非常朴素:一堆<a>标签,每行一个链接,文件夹链接末尾带/,文件链接直接指向某个路径。有的站点会在<a>旁边附带文件大小、修改时间之类的信息,有的干脆连大小都懒得显示。

识别目录和文件,最朴素的规则就是看href末尾有没有斜杠。但这规则不绝对,得考虑几种例外:

  1. 有些站点把文件也伪装成斜杠结尾,但这种极少见,遇到再单独处理。
  2. href="../"表示返回上级目录,必须跳过,否则爬虫会无限往外走。
  3. href="?C=N;O=D"这类带查询参数的排序链接,也要跳过。
  4. 页面里可能还有返回首页之类的链接,因为它们是指向其他路径的绝对地址,可能会爬到站点外去。

我习惯在解析链接时写一个白名单式判断:只保留同域名、同协议下的链接,凡是host不一致的统统丢掉。这样即使页面里混了什么统计脚本、外链图标,也不会污染我们的结果。

另外,开放目录页面的链接大多数是相对路径,比如subdir/file.zip。解析时必须用urljoin(base_url, href)拼成完整URL,而不是直接拼接字符串。直接拼接很容易出问题,尤其是当页面URL带路径前缀时,写错一个斜杠就把整个目录搞乱了。urllib.parse.urljoin是标准库,这个坑它替你填了。

2. 核心骨架:用requests加lxml把目录递归拉下来

有了第一步的页面认知,爬虫逻辑就非常清晰了:请求一个目录页,解析出目录链接和文件链接;目录链接继续入队,文件链接记录下来;走到没有目录可走为止。这个遍历过程天然适合用队列来做,避免递归太深把Python的栈顶爆。

环境准备很简单,requests负责发请求,beautifulsoup4加lxml负责解析,其他全是标准库。如果你在Linux服务器上跑,记得先装一下lxml,它是C扩展库,pip install lxml的时候要编译,装不上就换pip install beautifulsoup4 html5lib,但解析速度会慢不少。

import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse from collections import deque UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) MyAttachmentFinder/1.0" def fetch(url, timeout=10): """请求页面,失败返回None,不抛异常出去打断遍历""" try: resp = requests.get(url, headers={"User-Agent": UA}, timeout=timeout) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text except requests.RequestException as exc: print(f"[请求失败] {url} -> {exc}") return None def parse_links(page_url, html): """解析目录页,返回 (类型, 完整URL) 列表""" soup = BeautifulSoup(html, "lxml") links = [] for a in soup.find_all("a", href=True): href = a["href"].strip() # 父目录、排序参数、空链接直接跳过 if href in ("../", "./") or href.startswith("?") or href.startswith("#"): continue full_url = urljoin(page_url, href) # 只保留同host的链接 if urlparse(full_url).netloc != urlparse(page_url).netloc: continue if href.endswith("/"): links.append(("dir", full_url)) else: links.append(("file", full_url)) return links def collect_all_links(root_url): """BFS遍历所有目录,返回文件直链列表""" queue = deque([root_url]) visited = set() file_urls = [] while queue: page = queue.popleft() if page in visited: continue visited.add(page) html = fetch(page) if html is None: continue for kind, url in parse_links(page, html): if kind == "dir": if url not in visited: queue.append(url) else: file_urls.append(url) print(f"[遍历] {page} 已发现文件数 {len(file_urls)}") return file_urls

这个版本的逻辑我实测过,对绝大多数开放目录都能一次跑通。有几个设计细节值得说道说道。

visited集合用完整URL去重,而不是只记路径。因为同一个文件可能被不同目录页靠相对路径拼出不同URL,但完整URL只要有一点差异就会被当成两个文件。这种重复通常无害,下载阶段你自己决定要不要去重。

fetch函数把异常都吞掉了,只打印一行日志。这样某个子目录超时或者404,不会让整个爬虫崩掉,最多就是那个目录下的文件少几个。跑完之后你会拿到一个文件URL列表,里面可能有零星瑕疵,比整个任务报废强得多。

BFS比DFS更稳。因为开放目录的层级通常不深,但同一层目录很多,BFS先处理完一层再进下一层,日志看起来是有序的,排查问题也方便。真遇到深嵌套,BFS也不会把函数调用栈压爆。

拿到文件列表之后,最简单的下载就是拿着URL逐个GET。但别急着批量跑,先看第三章那几个坑,我当初直接开爬,第一天就翻车了三回。

3. 真实爬取中的五个坑:乱码、超时、断链和下载不完整

3.1 中文文件名变乱码,不是编码问题,是解析问题

我第一次跑这个爬虫,下载下来一批文件名全是%E6%95%99%E7%A8%8B.pdf这种百分号编码。很多人第一反应是去Unquote解码,但decode之后发现还是不对,因为文件名在目录页里显示的明明是中文。

真正的原因是:目录列表页的HTML里,href可能直接给你一个已经URL编码过的链接(比如%E6%95%99%E7%A8%8B.pdf),也可能给你一个原始中文链接。你的浏览器会自动帮你转换,但你在代码里拿到的字符串是没解码的。这时候要看你对URL的处理策略。

我的做法是:下载时直接用urljoin拼出来的完整URL去请求,让requests自己处理百分号编码。这个URL拿来做os.path.basename时确实是百分号形式,但要保存到本地时,再单独解一次码,而且要在不破坏URL语义的前提下解码。

from urllib.parse import unquote def safe_filename(url): """从URL中提取安全的本地文件名""" path = urlparse(url).path name = unquote(path.split("/")[-1]) # 去掉Windows非法字符 name = name.replace(":", "_").replace("*", "_").replace("?", "_") name = name.replace('"', "_").replace("<", "_").replace(">", "_") name = name.replace("|", "_").replace("\x00", "") return name.strip() or "unnamed"

这里解码用unquote而不是unquote_plus。因为目录列表URL里的空格通常被编码成%20而不是+,用unquote_plus会把原URL里作为查询参数分隔符的+也误转为空格,文件名就多出一堆空格来。

3.2 timeou t到底是秒还是毫秒,很多人理解错了

requests的timeout参数,单位是秒,不是毫秒。你写timeout=10意思是10秒。如果你写timeout=0.1,那就是100毫秒,对稍慢一点的服务器来说几乎必超时。

我一开始图快,把timeout设成0.5,结果跑内网服务器一点问题没有,换到外网资源站,大量目录直接超时,爬到的文件列表少了一多半。后来改成timeout=(3, 10),这是connect和read分开设,连接3秒、读取10秒,效果好了很多。

顺便说一句,大文件下载时的超时更应该关注读取超时而不是连接超时。因为大文件传输过程中,单次iter_content读不到数据的时间如果超过timeout,整个请求就断了。流式下载时用stream=True,配合iter_content,读取超时可以设大一点,比如30秒。

3.3 父目录链接是爬虫的“黑洞”,必须一进门就掐死

开放目录页面最上面通常有一个Parent Directory链接,指向上一级目录。如果你不把它排除,爬虫会一直往上爬到站点根目录,再从根目录把整个站点所有开放目录都爬一遍。这不是你想要的。

我见过一个爬虫跑了一整夜,把一个镜像站的几万个目录全部遍历完,下载了上千个不该下的大文件。排查半天,发现就是没处理../这一个链接。

所以在parse_links里,href等于../、./的一定要跳过。有些主题模板会把父目录链接渲染成别的样子,比如/files/,这种情况最好在遍历开始前就限定根目录的前缀,凡是不以根目录路径开头的目录URL都一律丢弃。

root_prefix = urlparse(root_url).path # 过滤时判断 if kind == "dir" and not urlparse(url).path.startswith(root_prefix): continue

这样即使遇到一个不守规矩的页面,也不会跑出你划定的边界。

3.4 下载不完整,先检查磁盘空间和Content-Length

批量下载大附件时,偶尔会遇到文件下载到一半就没有了,结束时间还特别早。这种问题多数不是网络断了,而是磁盘满了。你把文件写到一个100GB的目录,爬到80GB的时候磁盘报错,但脚本里没有检查写结果,直接忽略了异常。

下载函数里加一个文件大小校验是很有必要的。服务器返回的Content-Length是一个参考值,下载完成后把本地文件的实际大小和它比一比,不一致就打日志标记出来,方便之后重下。

def download(url, dest): try: with requests.get(url, headers={"User-Agent": UA}, stream=True, timeout=30) as resp: resp.raise_for_status() expect = int(resp.headers.get("Content-Length", 0)) actual = 0 with open(dest, "wb") as fp: for chunk in resp.iter_content(chunk_size=8192): fp.write(chunk) actual += len(chunk) if expect and actual != expect: print(f"[不完整] {url} 期望{expect} 实际{actual}") except requests.RequestException as exc: print(f"[下载失败] {url} -> {exc}")

3.5 请求频率太高,被服务器限流

这个必须要说。开放目录站点的服务器配置一般不高,你一口气开50个线程去打,很容易把别人服务器打挂,或者触发对方的防护机制,直接给你返回403或者429。平时自己写爬虫练手,默认把并发数控制在4到6,每次请求之间加个小延迟,这样既不会慢到无法忍受,也不至于给服务器制造压力。

延迟用time.sleep(random.uniform(0.3, 0.8))这种随机值比固定值更友好,避免你的请求呈现出规律的节拍,服务器对这种规律请求反而更容易识别然后封掉。

4. 下载阶段的优化:并发、断点续传和内网大文件的处理

4.1 从串行到线程池,下载速度能快出一条街

如果文件数量几百个,串行下载也能接受,但速度确实慢。用concurrent.futures.ThreadPoolExecutor加max_workers=4,代码改动量极小,速度提升很明显。文件下载是IO密集型任务,瓶颈在网络上,多线程很合适,不需要为了这个去上多进程。

from concurrent.futures import ThreadPoolExecutor, as_completed def batch_download(file_urls, output_dir, max_workers=4): tasks = [] with ThreadPoolExecutor(max_workers=max_workers) as pool: for url in file_urls: dest = os.path.join(output_dir, safe_filename(url)) tasks.append(pool.submit(download_with_resume, url, dest)) for fut in as_completed(tasks): result = fut.result() # 这里可以做统一的日志汇总

注意output_dir要先os.makedirs建好。多线程跑的时候,日志会乱序,这个正常,只要最终每个任务都有结果就行。如果你想看实时进度,可以在download函数里面维护一个全局计数器,但要加锁,否则多线程环境下计数会出错。

4.2 断点续传:服务器不支持Range也照样能兜底

断点续传看起来高级,实现原理其实一句话:请求头加上Range: bytes=已下载大小-,服务器支持就返回206,从指定位置继续发数据;不支持就返回200,那就干脆从头再下。

import os def download_with_resume(url, dest): headers = {"User-Agent": UA} mode = "wb" if os.path.exists(dest): headers["Range"] = f"bytes={os.path.getsize(dest)}-" mode = "ab" resp = requests.get(url, headers=headers, stream=True, timeout=30) if resp.status_code == 416: # 416说明请求的范围超出文件大小,本地文件其实已经完整了 print(f"[已完整] {dest}") return os.path.getsize(dest) resp.raise_for_status() with open(dest, mode) as fp: for chunk in resp.iter_content(chunk_size=8192): fp.write(chunk) return os.path.getsize(dest)

这个函数我把它作为默认下载函数一直在用。好处很明显:爬虫跑到一半断电,重跑一遍Script,已经下好的文件不会重新下载,直接从断点继续。文件大了以后,这点优化能省几个小时。

有个细节:mode="ab"时如果之前下载的文件不完整,我们是从本地已有大小开始写,但前提是服务器支持Range。如果不支持,服务器返回的是200而不是206,这时候resp.status_code是200,代码会走到raise_for_status,通过之后继续用ab模式往里追加,这就坏事了——文件会从中间开始拼接到旧文件后面,整个文件都是坏的。

所以在代码里看到status_code == 200时,要改成mode = "wb",从头覆盖。判断逻辑是:206用追加,200用覆盖,416说明已完成。

if resp.status_code == 206: mode = "ab" elif resp.status_code == 200: mode = "wb"

这个坑我踩过,一次断电续跑之后,好几个压缩包全部损坏,解压时报CRC错误,最后只能全删了重下。

4.3 大文件的流式写入与内存控制

下载大附件,比如几个GB的压缩包,千万不能用resp.content一次性读进内存,否则内存直接爆炸,进程被杀掉,前功尽弃。stream=True加iter_content是必须的。chunk_size我习惯用8192或者65536,太小IO次数多,太大占缓存,8192到65536这个区间是比较稳的。

另外,resp.close()这段钱不能省。流式请求如果没把连接释放,会占用文件描述符,跑几百个文件之后,系统就会报“Too many open files”。用with requests.get(...)上下文管理器是好习惯,出了作用域自动关闭连接。

with requests.get(url, headers=headers, stream=True, timeout=30) as resp: ...

这个写法在我这里是一律的,哪怕只下载一个文件也这么写。

5. 边界感:什么目录能发现、怎么控制请求频率更稳妥

开放目录站点的附件发现器写起来不难,真正难的是分寸感。这个工具本质上是把本来人类点点点就能做的事情自动化了,访问的仍然是你本来就能访问的公开内容。但如果把自动化用在攻击性场景——比如绕过认证、翻越权限目录、对服务器做压力测试——性质就变了。我自己的原则有几条:

  1. 只爬你有权访问的站点。自己搭的服务器、公司内网资料库、明确允许下载的公开镜像,这些没问题。
  2. 尊重站点根目录下的robots.txt。虽然很多开放目录根本不管这个文件,但写爬虫的人应该主动去看一眼。如果里面写了Disallow: /files/,就别再去爬,这不是技术问题,是起码的尊重。
  3. 控制请求频率。把并发调到4到6,下载间隙加随机延迟,不要用脚本去“碾压”一台小服务器。
  4. 下载下来的附件只用于个人学习和研究,不做二次分发,不拿去卖钱,不传播他人的隐私数据。

我不建议把这个爬虫做成公开的、可被任意人输入URL就开始扫的工具。因为开放目录站点往往没有任何访问控制,你拿它找资料没问题,但如果别人拿它去扫别人的服务器,遍历出来的可能就是不该公开的文件。你的工具替你干活,出了问题账还是会算在一开始写工具的人头上——至少在道义上是这样。

在技术上,防御性写法也能帮你避开大部分麻烦。比如只允许HTTP和HTTPS协议,只处理http开头且不是javascript:的链接。javascript:这种伪协议如果被拼进requests的URL,会让请求直接抛异常,虽然不至于系统崩溃,但日志会很难看。

def is_safe_url(url): return url.startswith("http://") or url.startswith("https://")

还有一点,下载到本地后,文件名和路径清理不能省。../这种路径穿越写法如果混在文件名里,写文件时可能把文件写到目录之外。这个在Windows和Linux下都有潜在风险。safe_filename里把/和\都替换掉,至少能保证文件名不会破坏目录结构。

我前后维护这个附件发现器一年多,最大的感受是:这类工具真正的价值不在“爬”这个动作,而在“校验”这套工程化细节。目录列表千奇百怪,有的服务器开启了Gzip,有的页面里混着JavaScript动态渲染的表格,有的服务器对HEAD请求不返回Content-Length。你把这些边界case一个个处理完了,这个玩具就变成了一个能用的工具。如果你也正在写类似的东西,别急着上来就写并发,先把第三章那五个坑在你的目标站点上跑一遍,踩一次就明白我说的是什么意思了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询