ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

用Playwright+DeepSeek模拟右键另存为,批量下载动态链接文件

用Playwright+DeepSeek模拟右键另存为,批量下载动态链接文件 最近在折腾网络爬虫碰到一个挺刁钻的需求页面上有一批下载链接是前端脚本动态拼接出来的直接用 requests 拿不到真实地址但人肉在浏览器里右键“链接另存为”却一切正常。于是我想能不能让爬虫也学会这一招借助 DeepSeek 来完善爬虫软件模拟鼠标右键点击把链接批量另存为本地文件。折腾了两天总算跑通这篇文章就把完整思路、关键代码和踩过的坑都记录下来给正在被动态链接、反爬下载折磨的朋友一个参考。先交代一下结果。最终我用的不是 requests也不是只会按坐标乱点的鼠标模拟工具而是 Playwright 这套浏览器自动化方案。配合 DeepSeek 生成和调试代码确实省了不少事。整个过程里最容易被误导的地方在于“模拟鼠标右键点击”——很多人以为自动化工具能把浏览器原生右键菜单里的“链接另存为”按钮点掉实际上不是这么回事。这篇文章会把这层窗户纸捅破然后给你一条能落地的实现路径。1. 项目整体设计与思路拆解1.1 需求还原为什么爬虫需要“右键另存为”先说说我遇到的真实场景。某个内部资料系统里每个附件链接都带一次性签名参数有效期只有几十秒而且服务器会校验Referer、Cookie和浏览器指纹。我用 requests 模拟登录、取 Cookie、拼签名折腾了半天下载回来的还是一堆几百字节的报错页。仔细抓包才发现浏览器里正常下载时请求头比 requests 多了一堆自动附加的浏览器特征每次都要去构造这些头维护成本极高。换句话说这已经不是一个“拼 URL 发 GET 请求”就能解决的问题而是一个“需要完整浏览器上下文”的下载需求。浏览器里的“右键 → 链接另存为”之所以好使是因为它把当前页面所有的会话状态、Cookie、渲染结果全部复用了等于让开发者不用关心底层细节只要模拟一个用户动作就行。所以这个项目的核心思路就变了不再去想怎么把签名算法逆向出来而是直接做浏览器自动化。在真实浏览器里打开页面定位目标链接触发下载行为把文件保存到本地。这个思路能绕开绝大多数基于 HTTP 请求指纹的反爬因为浏览器本身就是一个“可信客户端”。1.2 用 DeepSeek 辅助爬虫开发到底替我省了什么爬虫代码本身不难写难的是每次都会遇到新的意外。比如页面结构变了、链接加载变慢了、右键菜单是网页自己实现的、下载文件跑到临时目录里等等。写这种脚本一半时间在调选择器另一半时间在处理报错。这时候让 DeepSeek 参与进来是很自然的想法。我实际操作下来DeepSeek 的用途有三个。第一是写脚手架把需求描述给它让它先生成一版能跑的 Playwright 脚本省去我翻文档的时间。第二是报错翻译脚本跑出异常堆栈后把整段报错贴回去让它指出是哪一行、哪个 API 用法不对通常比搜索引擎直接很多。第三是方案推荐例如“右键菜单点不掉怎么办”它会给出几种不同的处理思路我再根据真实页面情况去选。这里要说明一点DeepSeek 生成的代码不能无脑用。它没有见过你目标网站的真实 DOM也不会知道你业务的文件命名规则所以我的习惯是让它出骨架、做排查最后的关键逻辑和收尾工作一定自己来。尤其是涉及下载路径、文件重名、任务断点续传这些细节必须人工把关。1.3 技术选型Playwright、Selenium 还是 pyautogui在定方案之前我把能想到的三种方式都对比了一遍。方案能否触发网页右键事件能否接管浏览器下载稳定性推荐度Playwright支持locator.click(buttonright)支持accept_downloadssave_as高首选Selenium支持ActionChains.context_click可通过浏览器 prefs 静默下载中备选pyautogui物理级模拟能点原生菜单能但依赖屏幕坐标低不推荐为什么不用 pyautogui网上很多教程喜欢用坐标模拟右键再去点“链接另存为”最后在系统保存对话框里输入文件名。这种方案在演示视频里看起来很神奇但放到真实批量任务里就是灾难。窗口位置、DPI 缩放、页面滚动、弹窗延迟任何一个变化都会让坐标失效脚本跑十分钟就崩。所以我强烈建议只要目标软件有自动化接口就不要走“按坐标点屏幕”这条路。Selenium 我也试过它通过ActionChains可以触发右键但下载控制比较有限要么依赖浏览器自带的下载目录要么手动处理下载弹窗。Playwright 在这方面更优雅它的事件模型允许我们直接等待下载产生然后用save_as()指定保存路径对批量任务友好得多。最终我选了 Playwright Chromium 的组合。2. 核心细节解析与实操要点2.1 “模拟鼠标右键点击”到底模拟的是什么这个坑我一开始就踩了。很多人以为用 Playwright 右键点击一个链接后浏览器会弹出原生右键菜单然后脚本就能继续点击菜单里的“链接另存为”。真相是网页里的右键行为分两层第一层是 DOM 的contextmenu事件网页自己可以监听第二层是浏览器原生菜单属于浏览器 UI自动化协议根本点不到。Playwright 的click(buttonright)只是在元素上派发了一个鼠标右键事件触发了contextmenu。如果网页自己实现了右键菜单比如网盘里的“下载”“重命名”那这个菜单是 HTML 元素你能继续定位并点击如果网页没实现浏览器会弹出原生菜单自动化脚本拿不到这个菜单的句柄这就是“右键点击没反应”的主要原因。所以真正要完成“另存为”我的处理方式是绕开原生菜单先尝试点击链接同时监听浏览器的下载事件文件被触发下载后通过下载对象保存到指定目录。这个效果和原生右键菜单里的“链接另存为”完全一致——都是用当前页面的会话信息发起下载只是触发方式从“右键菜单项”换成了程序化点击。如果页面有自定义右键菜单那就先右键弹菜单再点菜单里的下载项两种场景代码里都要兼顾。这里有个技巧值得记下来当你用右键点击时如果页面会弹出自定义菜单通常元素会出现某个特殊 class 或者display从none变成block。脚本里可以先等一下再判断菜单是否存在存在就去点下载项不存在就走普通的下载监听流程。判断逻辑要写在同一个循环里避免每次都固定走一条路。2.2 另存为机制拆解下载事件与浏览器设置在 Playwright 里把文件“另存为本地文件”的核心就两件事允许自动下载、保存下载文件。创建浏览器上下文时设置accept_downloadsTrue然后对点击操作包一层page.expect_download()等下载开始后就能拿到Download对象用download.save_as()写到指定路径。这里有一个很容易踩的坑如果页面点击链接后打开的是文件预览页面而不是直接下载expect_download()会一直等不到事件。遇到这种情况要么判断链接的Content-Type要么提前用 CDP 设置跳过预览。最简单的办法还是在浏览器加载前设置下载行为让浏览器把所有匹配类型都当成下载处理。Selenium 的做法是启动时加prefs把download.default_directory设成目标文件夹同时把download.prompt_for_download设为 False。点击链接后浏览器会静默下载不弹保存框。这两种方式对比下来Playwright 对下载的控制更细能拿到建议文件名也能随时改保存路径所以我最终选了它。2.3 链接定位、iframe 和动态加载的等待策略定位链接不能只写一个page.locator(a)就完事。很多页面的链接是接口返回后再渲染出来的页面刚打开时 DOM 里根本没有必须等接口响应。我常用的等待方式是locator.wait_for(statevisible)或者用page.wait_for_selector()指定超时时间。超时时间不要设太长否则脚本失败时你会在那里干等半天。如果链接藏在 iframe 里Playwright 默认定位不到 iframe 内部元素需要先定位 frame。比如page.frame_locator(iframe[namefilelist]).locator(a)。这个细节容易忽略直接找元素会发现数量一直是 0很让人抓狂。还有一类问题是懒加载。列表页滚动到可视区才加载后面的数据这时候需要边滚动边收集链接。我的做法是循环执行page.mouse.wheel(0, 1500)每次滚动后等 500 毫秒再统计当前页面上的链接数量直到数量不再变化。这个逻辑不复杂但写不好很容易漏数据。我强烈建议把“收集链接”和“下载链接”分成两个阶段先集中把链接的href和文本拿到存成 JSON再逐个下载。这样即使下载过程中断也不用重新抓一遍页面。3. 实操过程与核心环节实现3.1 环境准备安装 Python 和 Playwright环境部分很简单但版本问题容易卡人。我用的是 Python 3.11Playwright 建议用最新版因为老版本对 Chromium 的下载事件支持有一些小问题。安装命令如下python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install playwright openai playwright install chromiumplaywright install chromium这步会下载浏览器内核需要一点时间。如果你只想用系统里已有的 Chrome也可以设置channelchrome但我测试下来自带 Chromium 的兼容性最稳定建议先用它。DeepSeek 的接入是可选项。我推荐用 API 方式因为本地部署模型还需要显卡和内存对大多数人来说成本偏高。如果你确实有数据敏感需求可以自己部署一个量化版模型但生成速度会慢不少。个人学习的话直接申请一个 API key 就够用。3.2 让 DeepSeek 生成初版代码提示词与 API 调用我把需求描述成一段话让 DeepSeek 先生成骨架。提示词不需要太花哨但要把关键约束写清楚你是资深爬虫工程师。我要做一个 Python Playwright 的脚本 1. 打开 https://example.com/files 2. 等待列表中的附件链接可见 3. 模拟鼠标右键点击第一个链接 4. 如果出现自定义右键菜单点击菜单中的“下载” 5. 否则直接点击链接并监听下载事件将文件保存到 ./downloads 目录 6. 文件已存在时跳过 请输出完整可运行的代码并解释每步意图。调用 DeepSeek API 的代码很简洁它提供了 OpenAI 兼容接口from openai import OpenAI client OpenAI( api_key你的key, base_urlhttps://api.deepseek.com ) prompt 你是资深爬虫工程师。我要做一个 Python Playwright 的脚本... resp client.chat.completions.create( modeldeepseek-chat, messages[ {role: system, content: 你是一个熟悉 Playwright 的爬虫工程师。}, {role: user, content: prompt} ], temperature0.2, streamFalse ) print(resp.choices[0].message.content)注意一点DeepSeek 生成代码时偶尔会用旧版 Playwright API比如page.download事件监听方式新版已经不推荐了。所以生成后别急着跑先扫一眼有没有expect_download这种新式写法没有就让它改。把报错堆栈贴回去让它修比自己查文档快得多。3.3 完整爬虫脚本实现模拟右键点击并另存为本地文件下面这个脚本是我最终跑通的主程序省略了具体业务页面保留了核心逻辑import asyncio from pathlib import Path from playwright.async_api import async_playwright DOWNLOAD_DIR Path(./downloads) DOWNLOAD_DIR.mkdir(exist_okTrue) async def trigger_download(page, url): 在当前页面上下文里触发下载等效于右键另存为 async with page.expect_download(timeout15000) as dl_info: await page.evaluate(url { const a document.createElement(a); a.href url; a.download ; document.body.appendChild(a); a.click(); a.remove(); }, url) download await dl_info.value return download async def main(): async with async_playwright() as p: browser await p.chromium.launch(headlessFalse) context await browser.new_context( accept_downloadsTrue, viewport{width: 1280, height: 800} ) page await context.new_page() await page.goto(https://example.com/files, wait_untilnetworkidle) links page.locator(a.file-link) count await links.count() print(f共发现 {count} 个链接) for i in range(count): link links.nth(i) await link.scroll_into_view_if_needed() href await link.get_attribute(href) text (await link.inner_text()).strip() # 第一步模拟鼠标右键点击触发可能存在的自定义菜单 await link.click(buttonright) await page.wait_for_timeout(500) # 第二步判断页面是否出现了自定义右键菜单 custom_menu page.locator(.context-menu:visible) if await custom_menu.count() 0: await custom_menu.locator(text下载).click() else: # 没有自定义菜单直接走普通下载流程 download await trigger_download(page, href) ext Path(download.suggested_filename).suffix or .bin # 文件名清洗避免非法字符 safe_name .join( c for c in text if c not in \\/:*?| ).strip() target DOWNLOAD_DIR / f{safe_name}{ext} if target.exists(): print(f跳过{target.name}) continue await download.save_as(target) print(f已保存{target.name}) await browser.close() if __name__ __main__: asyncio.run(main())这里面的核心是trigger_download()。我通过page.evaluate()创建一个临时的a元素设置href和download属性然后触发点击。因为这个操作发生在当前页面上下文里所以浏览器会带上当前页面的全部 Cookie 和请求头效果和用户手动下载完全一致。download.suggested_filename是浏览器根据响应头里的Content-Disposition给出的文件名比自己猜后缀靠谱得多。另外注意headlessFalse。我建议调试阶段先开着浏览器窗口能直观看到每一步发生了什么。等确认稳定了再改成headlessTrue跑批量任务速度会快不少。3.4 批量下载、文件命名与断点续传批量场景下文件命名和去重必须提前设计好。我习惯先把收集到的链接存成一个 JSON 文件每个条目包含原始 URL、页面文本、页面标题然后下载脚本去读 JSON而不是每次重新抓页面。这样有两个好处一是下载脚本可以反复跑二是中途崩了不用重新解析页面。[ {index: 1, url: https://example.com/file/1001, text: 项目季度报告}, {index: 2, url: https://example.com/file/1002, text: 产品说明书} ]命名规则我用了“页面文本 原始后缀”。如果页面文本里有斜杠、冒号这些非法字符必须先清洗。下载前判断目标文件是否已经存在存在就跳过避免重复请求服务器。对于超时或者失败的项我会记录到fail.log下一次跑脚本时只重新处理日志里的 URL。4. 常见问题与排查技巧实录4.1 DeepSeek API 调用常见问题我在调试过程中遇到过几次 API 调用异常写在这里供参考。报错现象原因解决办法HTTP 401 UnauthorizedAPI key 错误或失效确认 key 没复制错检查账户余额请求超时一次问的问题太长等待超时设长 timeout或改用 streamTrue输出内容被截断上下文长度达到上限拆分问题分段提问不要一股脑塞全部代码request extension preparation failed网络环境波动或并发数超限退避重试降低并发检查网络环境这个request extension preparation failed是最让人迷惑的报错字面上看是请求扩展准备失败实际多半是网络问题。我遇到时的处理方式是先time.sleep(1)再重试连续失败超过 5 次就放弃这次调用手动看代码。网络波动时不要频繁重发反而更容易触发限流。4.2 右键点击没有反应或下载事件不触发右键点击后没有反应首先要区分是浏览器原生菜单还是网页自定义菜单。我建议在右键后加一个短暂的等待然后用locator.count()判断自定义菜单是否出现。如果没出现原生菜单我们点不到就直接走普通下载流程。下载事件不触发则要检查是不是预览模式。有些 PDF、图片链接点击后是在新标签页打开而不是下载。这时候要用context.expect_page()去捕获新页面等新页面加载后调用page.pdf()或者直接拿到内容再保存。如果目标是图片也可以把新页面的内容用page.locator(img)的src拿出来再下载。最容易被忽略的是下载前有跳转。点击链接后先跳到一个确认页再触发下载这时候expect_download()等待的时长不够就会超时。解决办法是把超时时间从默认的 10 秒拉到 20 秒并且在点击之前确保所有跳转链路你都清楚。4.3 文件保存位置不对、文件名乱码文件保存位置不对通常是accept_downloads没有设置或者浏览器默认下载目录没改。Playwright 里创建上下文时设置accept_downloadsTrue然后用download.save_as()指定绝对路径。Selenium 则需要改prefs这两者不要搞混。文件名乱码有几个原因一是响应头没有Content-Disposition浏览器只能根据 URL 推断文件名碰到中文 URL 就会出现百分号编码二是页面文本本身是乱码说明你取标题时编码处理有问题。我建议优先使用download.suggested_filename如果它也是空的再用urllib.parse.unquote处理 URL 文件名。4.4 大批量任务稳定性从脚本到工具最后说稳定性。爬这种带动态链接的页面最怕跑到一半浏览器崩了或者被服务器临时限流。我给自己的批量脚本定了三条规矩第一每条下载之间加随机延迟2 到 5 秒随机别太有规律否则很容易触发风控。第二整体运行写日志至少记录开始时间、URL、保存路径、成功失败。第三一定要支持断点续传。我的做法是每成功下载一个文件就把这个 URL 从待处理队列里移除并把进度写到一个progress.json里。重启脚本时先读进度文件而不是从头开始。还有一个经验是控制并发。Playwright 可以开多个页面但浏览器窗口一多内存占用会直线上升。我建议先单页面跑通再考虑并发。并发数超过 3 的时候反爬概率会明显增加个人学习场景完全没必要。最后说点体会做完这个项目我最大的感触是能解决问题的方案往往不是最“像人”的方案而是能复用浏览器能力的方案。模拟鼠标右键点击的真正价值不在于点开那个原生菜单而在于让我们能在一个受控的浏览器环境里复用用户身份去完成复杂的下载动作。用 DeepSeek 辅助写爬虫本质上也是在复用它的“工程经验”。它会帮你绕开一些低级的坑但不可能替代你对业务的理解。我在实际操作中都是让 AI 负责生成框架和排查报错自己负责选择器、命名、去重这些和真实页面强相关的东西。最后再分享一个小技巧如果你要下载的链接分布在多个页面先把所有页面的链接收集完再开始下载。不要每翻一页就下载一次这样既慢又容易触发限制。先把“找链接”和“下文件”切分开你会发现整个脚本的逻辑清晰得多后面维护也轻松很多。
RELATED READING

延伸阅读

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