尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebDetector:用Python自动化探测与下载网站资源,从链接提取到并发下载

WebDetector:用Python自动化探测与下载网站资源,从链接提取到并发下载 简介WebDetector是一款用于自动扫描网站中下载链接、并将结果整理为XML文件保存的C#源代码项目适合网站管理员、SEO优化人员以及爬虫开发者快速掌握网页资源探测思路。压缩包内含108个文件共510KB主要包含24个CS源码文件、27个XML配置与数据定义、11个DLL运行库、3个可执行程序以及项目解决方案、类图、数据表描述和RDLC报表定义结构分明便于开发者定位核心逻辑。目前已有317人浏览学习说明该工具在小范围内具备一定的参考价值。通过阅读源码可以重点学习多线程并行处理网络请求时的调度方式以及使用正则表达式或HTML解析库提取链接、生成规范化XML的全过程同时也能了解依赖项、资源文件和序列化处理等实用技巧。虽然项目未附带说明文档但代码组织清晰对具备基础C#编程能力的学习者而言是一份能同时锻炼网络抓取、并发处理与数据格式化能力的实战代码库。 如果每天都要从几个网站页面里找可下载的附件手动点开、另存为、确认文件名坚持不了三天你就想写代码。WebDetector 就是那时候冒出来的一个念头给一个入口 URL自动化完成“找资源、探资源、下资源”这件事。这个名字拆开看就是 Web Detector也就是网站下载资源探测器我会把完整的 Python 源代码一起梳理出来。它适合网站维护人员、资源站运营、企业内部文档库管理员也适合想学习 requests、BeautifulSoup、多线程下载的开发者。使用它之前只需要记住一句话只扫描你有权访问或已经获得授权的站点。下面的设计思路和代码沿用了我自己项目里的核心逻辑你用的时候可以照抄再根据业务调整。1. 资源探测器最核心的价值把“找下载链接”变成自动化巡检1.1 手动巡检下载链接的真实痛点我最早面对的场景其实是公司内部一个软件分发页面。它上面挂了三十多个安装包和补丁每两周就要更新一次版本。我当时的职责是判断哪些链接是新增的、哪些文件被替换了、哪个安装包体积异常变大。手动一个个点过去当然也能完成但问题在于人的注意力撑不过二十个链接。页面一多、版本一多漏看一个文件是常有的事更不要说你还要盯着 Content-Length 去比对文件大小。这种重复劳动非常适合交给程序把入口页作为输入让代码把页面里的下载链接全部提取出来再自动发 HEAD 请求去看文件是否存在、大小多少最后生成一张表。WebDetector 最核心的价值并不在于“下载”这个动作而是把原本靠肉眼巡检的过程变成可重复、可量化的扫描任务。1.2 探测器和爬虫其实是两回事我知道很多人一听“探测网站资源”就把它和爬虫混在一起但两者目标完全不同。爬虫关心的是页面里的正文、标题、结构化数据要的是信息本身WebDetector 关心的则是“这个页面上有哪些可以下载的文件”以及“文件在不在、有多大、能不能下”。换句话说爬虫在做信息抽取探测器在做资源盘点。目标不同代码设计思路差异就很大比如不需要维护复杂的爬虫中间件不需要管分布式队列甚至不需要关心页面里面有哪些正文文本只需要把 href、src 属性的链接捞出来过滤出可下载类型然后进入探测流程。正是因为目标足够聚焦整个项目才能保持轻量核心代码量控制在一个很小的体积内任何一个人打开源代码都能快速读完、改完。2. 五段流水线URL采集、链接提取、规则过滤、探测、下载2.1 一次扫描任务的数据是怎么流动的整个 WebDetector 的架构按照数据流可以拆成五个模块我个人最满意的就是它足够直白。URL 采集模块负责抓取入口页面拿到 HTML链接提取模块从 HTML 里解析出所有超链接候选规则过滤模块把不是资源类型的链接先丢掉留下真正值得探测的 URL探测模块对候选 URL 发送 HEAD 请求得到响应状态、Content-Type、Content-Length最后下载模块按需把文件保存到本地同时把整个结果写进 SQLite。五个模块之间通过普通函数调用串联没有异步消息队列也没有复杂的服务治理因为对中小规模站点来说这些完全是多余的复杂度。下面这张表是我整理出的模块划分和它们各自的关键依赖方便你对照源码读模块要解决的核心问题关键依赖url_fetcher网页可能超时、乱码、被 UA 拦截requestslink_parserhref/src 混杂、相对路径拼接BeautifulSoup4 lxmlresource_filter分辨哪些链接是下载资源正则表达式 扩展名集合probe_worker避免误下载、获取文件大小requests HEAD/GET Rangedownloader断点续传、并发控制、进度反馈concurrent.futuresreport_builder让结果可视化、可追溯sqlite3 HTML 模板这张表也基本反映了依赖的方向越靠后的模块越靠近磁盘和网络越靠前的模块越靠近页面解析。真正做二次开发时你不需要把所有模块都读懂只需要找到自己要改的那一层。2.2 为什么不选 Scrapy可能会有人质疑Scrapy 不是更成熟吗确实Scrapy 在做大规模网页抓取时是很好的选择它有 Request 调度、去重队列、UA 轮换、Pipeline 等等。但 WebDetector 的目标是“扫描下载资源”不是“爬取大量网页”。如果引入 Scrapy就需要接受它的事件循环和中间件配置模型最小可运行项目也要好几个文件而我们的场景往往是入口页面只有几十个下载文件却可能有几百个。这种情况下requests 加 ThreadPoolExecutor 更直观代码更短出现并发问题也更容易排查。这不是说 Scrapy 不好而是工具选型要匹配问题规模。我还专门做过一个测试对 50 个入口页面、300 个资源链接的扫描任务这套轻量方案在默认参数下只要几十秒就能完成完全够用。3. 链接提取与资源规则匹配先解决“哪些 URL 值得下”3.1 页面抓取前的几个小准备在解析链接之前抓 HTML 这一步有几个细节很容易踩坑。第一个是超时时间不设置 timeout 的话碰到一个假死的服务器整个任务可能卡在那里好几分钟。我默认设置成 10 秒并且用 try/except 把坏页面单独记录下来不让它拖垮整个扫描。第二个是 User-Agent很多站点会给默认 requests 的 UA 返回 403所以最好在请求头里伪装成一个常见的浏览器 UA。第三是编码问题requests 的 resp.text 默认会用 header 里的编码去解码但不少中文站是 GBK 编码且 header 没写全导致解析出来一堆乱码链接里的中文路径也会被错误处理。我的做法是先设置 resp.encoding resp.apparent_encoding让 requests 从内容里探测真实编码再交给 BeautifulSoup 解析。这里放一段基础抓取代码# url_fetcher.py import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 WebDetector/1.0 } def fetch_html(url, timeout10): resp requests.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text3.2 从 HTML 里拿候选链接拿到 HTML 之后接下来要做的是解析链接。我主要是从 a 标签的 href 和 img、source 等标签的 src 属性里取值。只处理 a 标签是不够的因为有些网站会把下载按钮做成视频封面或者用 source 标签给播放器提供资源地址。这个阶段我尽量把所有候选链接都收集起来不要在这个阶段过度过滤因为过早过滤很容易漏掉真正想找的资源。需要过滤掉的只是一些明显无意义的链接类型比如 javascript: 开头的按钮、mailto: 邮箱、页面内的 # 锚点以及 data: 协议的 base64 内容。其他链接都先进候选池。拼接相对地址时我习惯用标准库的 urljoin。它会把相对路径和基础 URL 正确拼接比如当页面 URL 是 https://example.com/docs/index.htmlhref 是 ../files/a.zip 时urljoin 能自动得到 https://example.com/files/a.zip。这一句非常有价值相当于帮你处理了几乎所有相对路径问题。核心代码如下# link_parser.py from urllib.parse import urljoin from bs4 import BeautifulSoup def extract_links(html, base_url): soup BeautifulSoup(html, lxml) candidates set() for tag in soup.find_all([a, img, audio, video, source]): attr href if tag.name a else src raw tag.get(attr) if not raw: continue if raw.startswith((javascript:, mailto:, #, data:)): continue full_url urljoin(base_url, raw) if full_url.startswith((http://, https://)): candidates.add(full_url) return candidates我特意用了 set 而不是 list因为同一个文件可能在一个页面里出现多次去重放在这一步最划算后续所有逻辑都不需要再处理重复链接。3.3 用扩展名过滤再用响应头兜底收集到候选链接后就要进入 resource_filter 这一层。最简单的办法是判断 URL 路径的后缀名是不是常见资源类型。我从实际业务里总结了一个资源后缀集合基本覆盖了文档、压缩包、音视频、安装包和镜像文件pdf、zip、rar、7z、tar、gz、doc、docx、xls、xlsx、ppt、pptx、mp3、mp4、m4a、avi、mov、apk、ipa、exe、msi、dmg、iso。判断时要注意大小写统一用 lower() 处理。# resource_filter.py from pathlib import PurePosixPath from urllib.parse import urlparse RESOURCE_EXTS { .pdf, .zip, .rar, .7z, .tar, .gz, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .mp3, .mp4, .m4a, .avi, .mov, .apk, .ipa, .exe, .msi, .dmg, .iso, } def guess_resource_type(url): path PurePosixPath(urlparse(url).path) ext path.suffix.lower() if ext in RESOURCE_EXTS: return ext.lstrip(.) return None但只有后缀判断是不够的因为有些下载链接长这样/download?id123没有扩展名却会返回一个文件。所以后续的探测模块必须根据响应头的 Content-Type 和 Content-Disposition 来兜底。我的经验是扩展名过滤负责缩小范围响应头探测负责最终确认两者结合在一起才能做到既不乱下东西也不漏掉真正的资源。4. 探测和下载只下载该下载的还要控制好并发4.1 先用 HEAD 请求探一下文件情况在没有实际下载之前我们最好先“问一下”服务器这个 URL 是不是资源。HTTP 协议里有一个 HEAD 方法服务器会返回和 GET 一样的响应头但不返回响应体。用它来检查文件存在性、Content-Type 和 Content-Length 是最理想的方案一个几 GB 的大文件也只需要极小的流量就能判断出来。实现的时候要注意 allow_redirectsTrue因为很多下载地址会先从 CDN 跳到真实存储地址不跟随重定向的话Header 信息就是中间跳转页的而不是最终文件的。另一个坑是部分服务器不支持 HEAD会直接返回 405所以我把 HEAD 失败后的回退方案也写了进去用 GET 请求加上 Range: bytes0-0只请求第一个字节服务器如果支持断点续传会返回 206我们同样能拿到内容长度和类型。# probe_worker.py import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) WebDetector/1.0 } def probe_resource(url, timeout8): try: r requests.head(url, headersHEADERS, allow_redirectsTrue, timeouttimeout) if r.status_code in (200, 206): size int(r.headers.get(Content-Length) or 0) return True, r.headers.get(Content-Type, ), size except requests.RequestException: pass try: r requests.get( url, headers{**HEADERS, Range: bytes0-0}, streamTrue, timeouttimeout, ) if r.status_code in (200, 206): size int(r.headers.get(Content-Length) or 0) return True, r.headers.get(Content-Type, ), size except requests.RequestException: pass return False, , 0这一段我实际跑下来发现绝大多数资源站都能正确返回 HEAD 信息少数防护严格的站点会拒绝 HEAD但能通过 Range 拿到信息。所以我一直把这两个方法串在一起用而不是只留一个。4.2 并发下载与断点续传的细节探测完成之后真正下载时就要考虑两个问题怎么不把站点打挂以及下载中断了怎么办。WebDetector 用 ThreadPoolExecutor 控制并发数量默认 max_workers4。这个数字是我试过一轮之后选的单线程太慢8 个以上线程又容易触发部分服务器的限流甚至 4034 个是稳定性和速度都比较平衡的选择。如果你给自家服务器用可以稍微调大一点如果是扫描外部资源站建议保持低调。下载时的断点续传处理得比较简单先判断同目录下是否存在对应文件的 .tmp 临时文件如果存在就从它的当前大小开始发送 Range 请求以追加模式继续写入下载完成后把 .tmp 重命名为正式文件名。这样即使中途断网下一次运行也能继续而不是从头再来。注意只有在服务器返回 206 时才用追加模式如果返回 200说明服务器忽略了 Range 请求此时必须以 wb 模式重新写入否则文件会越写越大。# downloader.py from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed import requests def download_one(item): url item[url] save_path Path(item[save_path]) tmp_path save_path.with_suffix(save_path.suffix .tmp) headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) WebDetector/1.0} if tmp_path.exists() and tmp_path.stat().st_size 0: headers[Range] fbytes{tmp_path.stat().st_size}- with requests.Session() as session: with session.get(url, headersheaders, streamTrue, timeout30) as resp: resp.raise_for_status() mode ab if resp.status_code 206 else wb with open(tmp_path, mode) as f: for chunk in resp.iter_content(chunk_size64 * 1024): if chunk: f.write(chunk) if tmp_path.exists(): tmp_path.replace(save_path) return str(save_path) def run_download_tasks(tasks, max_workers4): results [] with ThreadPoolExecutor(max_workersmax_workers) as pool: futures [pool.submit(download_one, task) for task in tasks] for fut in as_completed(futures): results.append(fut.result()) return results这里有一个特别容易犯的错requests 官方文档其实不建议把同一个 Session 在多个线程里无保护地共享所以我上面的代码在每个线程内部各自创建一个 Session。这样会多一点点连接开销但换来的是稳定性和 debug 时的省心非常值得。至于 chunk_size我习惯用 64KB太大会占用内存太小又会频繁触发磁盘写入64KB 是一个比较舒服的中间值。5. 扫描结果落库与报表别让结果只出现在终端里5.1 两张表就要装下整个扫描结果探测和下载结束之后如果结果只是打印在终端里后面想查询历史版本、对比文件大小变化就会很痛苦。我给 WebDetector 配了一个非常小的 SQLite 存储层只建了两张表。scan_tasks 记录每一次扫描任务resource_links 记录每一条资源链接的探测和下载状态。两张表通过 task_id 关联这样你随时可以回答“上一次扫描发现了哪些新文件”或者“这个 PDF 是什么时候被加入页面的”。建表语句很简单但字段选择上我踩过不少坑比如 file_size 和 http_status 一定要区分开http_status 是探测时的状态码file_size 是响应头里拿到的长度downloaded 字段则用来标记是否真正保存到了本地。save_path 保存绝对路径方便后续脚本直接操作文件。完整 DDL 如下CREATE TABLE IF NOT EXISTS scan_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, entry_url TEXT NOT NULL, depth INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS resource_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, page_url TEXT, resource_url TEXT NOT NULL, file_type TEXT, content_type TEXT, file_size INTEGER DEFAULT 0, http_status INTEGER, downloaded INTEGER DEFAULT 0, save_path TEXT, discovered_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (task_id) REFERENCES scan_tasks(id) );字段设计好之后insert 语句也建议用参数化方式避免拼 SQL 时遇到 URL 里的单引号导致语法错误。SQLite 不需要单独部署Python 标准库自带对这类轻量级工具来说是最好的选择。5.2 一键生成 HTML 报表数据库里的数据如果只能通过 sqlite3 命令行查看还谈不上好用。我给 report_builder 加了一个很轻的功能扫描结束后把结果渲染成一个独立的 HTML 文件。报表包括入口 URL、扫描时间、资源总数、成功探测数、失败数以及一个资源表格。表格的每一行显示文件类型、文件大小、HTTP 状态、下载状态和完整 URL文件大小我会在展示时转成可读的 MB/GB方便人眼判断异常。这个功能的价值在于它可以作为定时任务的产物直接挂到内部页面上或者通过邮件发送给相关同事。HTML 模板我用了 Jinja2渲染代码很简洁把查询结果传给模板输出一个文件即可。核心逻辑可以放在报告构建器里前端只负责展示。之所以选择生成静态 HTML 而不是直接上 Flask是因为这个工具的定位是一次性扫描和定时巡检不需要常驻服务。静态报告零依赖、零端口、零安全问题往内部文件服务器上一扔就能看是我目前觉得性价比最高的方案。如果有更复杂的多用户交互需求再在相同的数据层上面套一个 Flask 或 FastAPI 也完全来得及。6. 真实跑出问题后我补上的六个补丁6.1 协议相对地址和相对路径第一个坑是页面里大量出现的 //cdn.example.com/file.zip 这种链接。用 urljoin 拼接后如果基础 URL 是 https 页面它会正常得到 https 开头的地址但如果入口页面是 http 页面而 CDN 只支持 https就会出现混合内容重定向后还可能拿到 403。后来我在 extract_links 里加了一步如果链接以 // 开头就强制使用入口页面当前的协议头去拼接而不是完全交给 urljoin。千万不能小看这个问题真实网站上这种写法非常普遍。6.2 没有扩展名的下载接口第二个坑就是前面提到的 /download?id123 地址。它的页面路径没有后缀但响应头里带上了 Content-Disposition: attachment; filenamexxx.zip。我一开始只靠扩展名过滤这类链接全被丢掉了后来反思过后加了双保险先把扩展名过滤后的结果入库再对一批“可疑但后缀不明显”的 URL 做 HEAD 探测只要 Content-Type 属于可下载类型或者 Content-Disposition 是 attachment就同样标记为资源。这个改动让识别率提升了不少代价只是多了一批 HEAD 请求流量非常小。6.3 相同文件因为跟踪参数被重复下载第三个坑是链接去重只看字符串不够。有些下载链接会带上 ?fromhomepage 或 ?spmxxx 这类跟踪参数导致同一个文件的 URL 每次都不同重复探测和重复下载。我的处理方式是在去重阶段把常见的跟踪参数从 query string 里剔除后再比较但对于真正可能影响下载内容的参数比如 ?id123绝对不能剔。所以我给代码加了一个可配置的忽略参数列表默认忽略 utm_*、from、spm、ref 这类跟踪参数。这不是一个能一刀切的问题最终还是要靠配置面对具体站点。6.4 CDN 不允许 HEAD 请求怎么办第四个坑是 HEAD 请求在某些 CDN 环境里并不好使返回 403 或者 405 的概率比源站高。我一开始也很困惑明明浏览器能下载HEAD 却探不到任何信息。后来排查发现是部分 CDN 的防护规则把 HEAD 请求当成扫描器特征之一直接拦截了。针对这种情况我的做法是给探测请求补上 Referer 头让它看起来像从当前页面发出的请求如果还是失败就自动降级到 Range: bytes0-0。这两种方式配合起来能覆盖绝大多数真实场景。6.5 文件名编码和非法字符第五个坑是下载文件的保存路径。保存时我最初直接用 URL 末尾的文件名结果遇到中文文件名的 URL保存到 Windows 上就乱码还遇到文件名叫 2024:report.pdf 的冒号在 Windows 文件名里是非法的。现在下载模块里会做两层处理第一层从 Content-Disposition 的 filename* 里解析经过编码的原始文件名第二层把最终文件名过滤掉 Windows 非法字符然后再写入磁盘。如果两边都拿不到靠谱的文件名最后才回退到 URL 末尾的路径段。6.6 静态页面抓不到 JS 动态加载链接第六个坑是我自己觉得最麻烦的现在有相当多页面不是直接在 HTML 里写死链接而是通过 JavaScript 异步加载资源列表。对这种页面纯 requests 拿到的是空壳 HTML什么链接都提取不到。我的补丁不是继续堆正则而是给 WebDetector 增加了一个可选的 Playwright 渲染模式开启后先用无头浏览器渲染页面再把最终 DOM 喂给同一套 extract_links 逻辑。要不要开启完全由配置决定因为无头浏览器很吃内存不是每个任务都需要。最后分享一个使用上的技巧如果只是想巡检资源是否存在没必要真的把文件下载下来。把运行参数里的 probe_only 打开WebDetector 就只做 HEAD 探测不写文件。定时任务可以用它把资源 URL、状态码、文件大小记录到数据库里下次扫描再和上一次对比新增、缺失、体积异常都能一眼发现。我在内部就是这么用的比全量下载省下的流量和磁盘空间非常可观而且误报率比想象中低很多。本文还有配套的精品资源点击获取
返回列表