ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫实现网页链接自动提取与批量下载:从入门到避坑

Python爬虫实现网页链接自动提取与批量下载:从入门到避坑 “python爬虫自动下载网页链接”这个标题乍看像是给刚入门的朋友准备的练手项目但实际上这个需求背后藏着不少值得掰开揉碎讲清楚的东西不是简单对着一个URL调用requests.get就完事而是要“提取页面里所有符合规则的链接、过滤掉无关内容、按顺序或并发下载到本地、还得处理断点和反爬”。我在实际帮朋友做资料批量下载、备份公开文档、抓取设计素材包这类任务时踩过的坑基本都集中在这几个环节。这篇博文就围绕这个标题把从分析页面到断点续传下载的完整链路拆给大家适合已经会基础的Python语法、想用爬虫解决实际下载需求的读者也适合准备用Python做自动化办公和数据处理的朋友参考。1. 整体思路下载网页链接本质上是一套“提取-筛选-传输”流程先说结论网页上的“下载链接”从来不会自己跑到你硬盘里你要做的事情可以拆成三步从HTML里找到所有候选链接 → 按规则筛出真正要下载的文件 → 把文件流式写入本地。这三步听起来简单但每一步都有隐含的细节。我习惯用“逛超市按购物清单取货”来类比这个过程。整个网页就像一个巨大的超市货架上面既有你想要的商品PDF、压缩包、Excel表格也有大量无关的东西导航链接、广告跳转、上一篇下一篇的文章链接。你的购物清单就是“文件扩展名白名单”和“URL特征规则”。你不可能把整个货架搬回家也不可能挨个把每件商品拿起来看一眼再放回去——你要做的就是快速扫过货架只把购物清单里的商品扔进购物车。具体到技术选型上很多人一上来就问“要不要用Scrapy”。我的建议是除非你要爬整个网站成百上千个页面否则一个单文件的Python脚本就够了。Scrapy的学习曲线和项目结构对这种“下载一批链接”的任务来说属于杀鸡用牛刀而且调试起来反而更麻烦。用requests BeautifulSoup lxml 组合就完全能打代码量小、逻辑透明、出了问题也好排查。还有一个前置问题必须说清楚爬虫下载的合法性和道德边界。在动手之前建议看一眼目标网站的robots.txt了解哪些路径允许抓取同时控制请求频率别把人家服务器打崩。这篇文章讲的是技术实现但技术永远要在合规的前提下使用。你下载的资料如果是公开的、允许下载的那就没问题如果涉及版权或隐私那就别碰。下面进入正题。2. 环境准备Python环境、依赖安装、以及为什么用这几个库2.1 安装Python和创建虚拟环境如果你是从零开始先装Python 3.10或更高版本。安装的时候有一点要特别注意在Windows安装向导的第一个界面记得勾选“Add Python to PATH”不然之后在命令行里敲python会提示找不到命令。装完之后打开终端验证一下python --version能正常输出版本号就OK了。然后创建一个项目目录在里面建虚拟环境。虚拟环境的作用是隔离不同项目的依赖避免你给项目A装的库版本把项目B搞坏。mkdir link_downloader cd link_downloader python -m venv venv激活虚拟环境Windowsvenv\Scripts\activatemacOS / Linuxsource venv/bin/activate激活之后命令行前面会出现(venv)字样说明你已经在虚拟环境里了。接下来安装依赖pip install requests beautifulsoup4 lxml2.2 库选型为什么是它们requestsPython里最顺手的HTTP客户端。相比内置的urllibrequests的API设计人性化得多Session机制、超时设置、重试配置都很直观。BeautifulSoup4HTML解析库。配合lxml解析器能容错处理大量不规范HTML标签。很多网页标签没闭合、属性值带了奇怪字符用正则去匹配链接很容易翻车而BeautifulSoup能把这些“烂摊子”正常解析出来。lxmlC语言实现的解析引擎解析速度比纯Python的html.parser快很多处理大页面时体感差异很明显。为什么不直接推荐用正则表达式提取链接因为正则适合“文本格式非常规整”的场景而HTML是树形结构标签嵌套、属性顺序、引号变化都会让正则变得又臭又长且极易出错。BeautifulSoup按“标签属性”来定位元素思路清晰得多。当然如果你需要提取的链接是嵌在script里的JSON数据那正则或者json库反而更合适这个后面会提到。3. 链接提取与筛选从杂乱HTML里精准找出下载地址3.1 下载链接的常见存在形式先说几个最常见的“藏链接”的位置你分析页面时优先查这些地方a标签的href属性最常见比如a href/files/report.zip下载报告/a。img标签的src属性当你下载的是图片素材时。source、video、audio标签的src视频和音频文件。标签属性里的自定义字段比如>import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse, parse_qsl, urlencode headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 } def fetch_html(url): resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding # 处理乱码尤其是中文页面 return resp.text这里有两个细节值得注意headers里带上User-Agent很多服务器会拦截没有UA标识的请求resp.encoding resp.apparent_encodingrequests默认会根据HTTP头猜测编码但中文网页经常由于charset标错导致乱码用apparent_encoding基于内容自动检测通常更准。第二步解析HTML并提取所有a标签def extract_links(html): soup BeautifulSoup(html, lxml) links [] for a in soup.find_all(a, hrefTrue): href a[href].strip() if href.startswith((javascript:, mailto:, #)): continue absolute_url urljoin(base_url, href) # 补全相对路径 links.append(absolute_url) return links这里用urljoin(base_url, href)把相对路径比如/files/report.zip转成完整URL这一步非常关键。很多人提取出来的链接打不开就是因为忘了补全相对路径。3.3 按文件扩展名筛选提取完所有链接之后要按下载目标筛选。最直接的方式是按扩展名白名单过滤DOWNLOAD_EXTS {.zip, .pdf, .rar, .7z, .exe, .xlsx, .csv, .mp4, .jpg, .png} def filter_download_links(links): filtered [] for url in links: path urlparse(url).path.lower() if any(path.endswith(ext) for ext in DOWNLOAD_EXTS): filtered.append(url) return filtered但现实往往会给你出点幺蛾子很多下载链接长得像https://example.com/download?id12345并没有扩展名后缀这种情况下就要看URL里的特征参数了。你可以先跑一批链接出来肉眼观察规律再补充规则。比如某些站点会用/download?filereport.zip这种形式那就直接判断URL里是否包含/download和file关键词。这种“先看数据再定规则”的做法比一上来就想写一个万能正则会高效得多。3.4 URL去重防止重复下载同一个文件页面里同一个文件经常出现多次导航栏放一个“下载”文章底部又放一个“点击下载”。如果不做去重你会在下载阶段重复请求同一个URL浪费带宽是小事被服务器限流才是大事。去重不能直接比字符串因为同一个资源可能有不同的URL写法https://example.com/file.ziphttps://example.com/file.zip#download锚点不同https://example.com/file.zip?spm123加了追踪参数我一般会先把URL规范化再去重def normalize_url(url): parsed urlparse(url) # 去掉锚点 parsed parsed._replace(fragment) # query参数排序避免参数顺序不同导致误判 query parse_qsl(parsed.query, keep_blank_valuesTrue) query.sort() parsed parsed._replace(queryurlencode(query)) return parsed.geturl() def deduplicate_links(links): seen set() unique [] for url in links: normalized normalize_url(url) if normalized not in seen: seen.add(normalized) unique.append(url) return unique这段代码先把fragment锚点去掉再把query参数拆开排序重新拼接。注意parse_qsl会把a1b2变成[(a,1), (b,2)]排序后再拼回去这样?b2a1和?a1b2会被视作同一个URL。4. 下载器实现从“能下”到“稳下”链接提取完真正的考验来了。新手写的下载代码常常是content requests.get(url).content with open(file.zip, wb) as f: f.write(content)这段代码在遇到小文件时没问题但一旦文件上了几百MB或者网络不稳定就会暴露出一堆问题内存占用飙升、下载到一半连接断开、没有重试机制。你不可能每次下载都全程守在电脑前。4.1 请求头伪装与Session管理首先是请求头。默认的requests请求头是python-requests/x.x.x服务器一眼就能看出是脚本。带上浏览器的User-Agent是基本操作必要的时候还要带Referer——有些站点做了防盗链直接请求文件URL会返回403带上页面URL作为Referer就能过。建议用requests.Session()来发送所有请求。Session会自动保存Cookie并且复用底层的TCP连接请求多个文件时效率更高。代码结构如下import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry Retry( total3, # 最多重试3次 connect3, # 连接失败重试3次 read3, # 读取失败重试3次 backoff_factor0.5, # 重试间隔递增0.5s, 1s, 2s status_forcelist[500, 502, 503, 504] # 这些状态码触发重试 ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(http://, adapter) session.mount(https://, adapter)backoff_factor0.5的意思是第一次重试前等待0.5s第二次等待1s第三次等待2s呈指数退避避免对服务器造成集中压力。4.2 streamTrue 分块下载下载文件时一定要用streamTrue然后以二进制分块写入本地文件。这样文件是边下载边落盘的内存里始终只保留一小块数据下载2GB的文件也不会吃爆内存。def download_file(session, url, local_path): with session.get(url, headersheaders, streamTrue, timeout(10, 30)) as resp: resp.raise_for_status() with open(local_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk)chunk_size81928KB是一个比较平衡的选择太小会导致频繁IO太大又会让内存占用上升。timeout(10, 30)表示连接超时10秒读超时30秒防止某个文件卡住后整个脚本一直挂在那。4.3 断点续传下载到一半断了怎么接着下网络环境再稳定也有翻车的时候。断点续传的思路是下载前先看看本地文件已经存在多大如果服务器支持Range请求就从Content-Range指定的位置继续下载而不是从头开始。实现逻辑如下def download_resumable(session, url, local_path): # 获取本地已有文件大小 local_size 0 try: local_size os.path.getsize(local_path) except FileNotFoundError: pass headers {User-Agent: UA, Referer: url} if local_size 0: headers[Range] fbytes{local_size}- with session.get(url, headersheaders, streamTrue, timeout(10, 30)) as resp: if resp.status_code 206: # 服务器支持断点续传返回206 Partial Content mode ab # 追加写入 print(f断点续传 from {local_size}) elif resp.status_code 200: # 服务器不支持Range返回完整文件 mode wb local_size 0 else: resp.raise_for_status() content_length int(resp.headers.get(Content-Length, 0)) total local_size content_length with open(local_path, mode) as f: downloaded local_size for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) downloaded len(chunk)这里有一个关键判断服务器返回206说明它支持断点续传可以追加写入返回200说明它忽略了Range头把整个文件又发了一遍这时候必须用wb模式覆盖写否则文件内容会重复。我见过很多人没注意这一点断点续传反而把文件写坏了。4.4 并发下载多线程加速但别太贪顺序下载最稳但速度感人。如果目标服务器没有限制并发可以用ThreadPoolExecutor同时下载多个文件。实际测试下来下载10个100MB的文件4~8个线程能把总耗时降到顺序下载的三分之一左右。继续加线程收益不明显反而容易被服务器封IP。from concurrent.futures import ThreadPoolExecutor, as_completed MAX_WORKERS 5 def download_with_concurrency(links, output_dir): os.makedirs(output_dir, exist_okTrue) tasks {} with ThreadPoolExecutor(max_workersMAX_WORKERS) as executor: for url in links: filename extract_filename(url) local_path os.path.join(output_dir, filename) future executor.submit(download_resumable, session, url, local_path) tasks[future] (url, local_path) for future in as_completed(tasks): url, path tasks[future] try: future.result() print(f完成: {path}) except Exception as e: print(f失败: {url} - {e})文件名提取不能简单用url.split(/)[-1]就完事。有些URL末尾是download?id123这样取出来的文件名毫无意义。更靠谱的做法是优先看Content-Disposition响应头里的文件名其次再从URL里解析import re from urllib.parse import unquote, urlparse def extract_filename(session, url): # 先从响应头获取 try: resp session.head(url, allow_redirectsTrue, timeout10) cd resp.headers.get(Content-Disposition, ) match re.search(rfilename\*?(?:UTF-8)?\?([^\;]), cd, re.IGNORECASE) if match: return unquote(match.group(1)) except Exception: pass # 再从URL路径解析 path urlparse(url).path name path.rstrip(/).split(/)[-1] if name: return unquote(name) return fdownload_{hash(url) 0xffffffff}.bin注意filename*是RFC 5987定义的扩展写法用来支持中文文件名编码格式通常是filename*UTF-8%E6%96%87%E4%BB%B6.zip。正则里的(?:UTF-8)?就是用来兼容这种格式的。5. 常见问题与排查技巧我踩过的坑你大概率也会遇到5.1 403 Forbidden不是你的代码错了是人家不想理你遇到403第一反应别去改代码逻辑先检查请求头。最常见的三个原因缺User-Agent一些服务器对没有UA特征的请求直接拒绝。缺Referer这个在下载图片和附件时特别常见。文件所在的页面URL就是最好的Referer下载文件时带上它。需要Cookie有的站点要求你先访问页面拿到Cookie再带着Cookie下载文件。用Session访问一次页面再下载就能解决——这也是我前面推荐Session的原因。5.2 页面里搜不到下载链接前端动态渲染你用requests拿到HTML后发现里面根本没有a标签大概率是页面用JavaScript动态生成了下载链接。这时候有几个排查方向按F12打开开发者工具切到Network面板手动触发下载看浏览器发出了什么请求。很多情况下真正下载地址是前端调接口拿到的接口返回JSON再在JS里创建URL触发下载。如果接口需要动态签名requests模拟起来很麻烦这时候再考虑用Selenium或Playwright驱动真实的浏览器去自动化操作。但浏览器自动化开销大能走接口就走接口。5.3 文件名乱码八成是编码问题下载下来的文件名乱码分两种情况URL里的中文是百分号编码用urllib.parse.unquote解码比如%E6%8A%A5%E5%91%8A.zip解出来就是报告.zip。响应头文件名乱码Content-Disposition里的filename*是UTF-8编码处理时要指定UTF-8前缀正则或者unquote都能处理。5.4 SSL证书报错明明是合法网站却报错有些网站证书链不完整requests会抛出SSLError。如果只是自己用可以临时关闭证书验证session.get(url, verifyFalse, timeout10)但这个操作会弹出一个InsecureRequestWarning警告看着碍眼。可以用以下代码屏蔽import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)注意verifyFalse只应该在你完全信任目标网站且仅用于测试时使用。如果网站涉及敏感信息不要关闭证书验证。5.5 下载速度极慢可能是连接复用没开启前面提到的Session复用TCP连接非常关键。每新建一个requests.get请求都是一次完整的TCP握手TLS握手大量小文件下载时握手开销甚至超过数据传输本身。用Session统一请求性能提升立竿见影。6. 从“能跑”到“好用”一些扩展方向写到这里下载脚本已经能完成核心工作了。但如果想让它更贴近真实使用场景还可以做几个方向的升级。异步化如果下载的文件特别多线程池会占用较多系统资源可以改用aiohttpasyncio实现真正的异步IO。异步并发通常比线程并发更轻量但代码结构会复杂一些需要处理好信号量和异常捕获。可视化界面用Python自带的tkinter或者第三方库PySimpleGUI包一个简单的窗体输入URL、选择保存目录、点击开始下载这样不懂技术的人也能上手用。任务持久化把下载队列存到SQLite或者JSON文件里脚本中断后重新运行能自动跳过已经下载完的文件。这个对大批量下载特别省心。加一个链接提取预览在正式下载前先输出一份“将要下载的文件清单”让用户确认后再开始。这个设计看起来简单却能避免误下载一堆无关文件实际使用中会你会觉得非常有用。写在最后结合我一次帮朋友下载大量设计素材的经历——那是一个用Vue渲染的页面requests只能拿到空壳HTML所有下载链接都藏在初始化接口返回的JSON里。我换了下思路先用浏览器开发者工具找到那个接口构造请求拿到JSON然后再跑下载器整个过程不到半小时就搞定了。做爬虫最花时间的地方从来不是写代码而是分析页面结构、找出数据来源、应对各种反爬限制。但只要你掌握了上面这套“提取-筛选-下载”的流程面对大多数网页链接下载的需求都能游刃有余。最后再提醒一句下载前先看robots.txt请求频率别太激进给你的脚本加上延时和重试既是保护自己的IP也是尊重对方的服务器。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进