用Python爬虫采集release note附件索引,自动维护版本台账

发布时间:2026/10/5 4:32:46
用Python爬虫采集release note附件索引,自动维护版本台账 上个月在做一个版本追溯时我在发布站点里从新到旧翻 release note大概翻了四十多页才找到一个被埋了很久的历史安装包。当时我就想这类完全公开的发布信息为什么不能写个 Python 爬虫把每个版本的附件索引自动采集下来整理成一张表格。后面我把这个脚本真正落地了用 requests 加 XPath 完成平时维护发布台账、给新同事做交接都省了非常多的手工活。这篇内容就围绕这件事展开如何用 Python 爬虫采集公开 release note 页面里的附件索引。它会讲清楚索引采集和文件下载在技术上的区别、为什么 requests XPath 在这种场景下比 Selenium 更顺手、怎么写一个能跑通又能抗住常见异常的脚本以及如何把结果输出成可追溯的版本台账。适合刚入门 Python、想拿真实场景练手爬虫的人也适合负责发布管理、需要定期整理版本附件的同学参考。1. 先想清楚这次要采集的是“附件索引”而不是附件本身动手写代码之前我一直建议先把任务边界理清楚。标题里写的是“采集公开 release note 附件索引”听上去很简单但很多人一上来就奔着“下载附件”去了最后脚本变得又重又复杂。实际上“采集索引”和“下载附件”是两个完全不同量级的事技术选型差别很大。1.1 一次版本追溯到底难在哪假设你们的产品每个迭代都会发布 release note页面里有版本号、发布日期、更新说明还有几个附件链接Windows 安装包、Linux 包、校验文件、使用文档。日常使用中大多数人只会看最新版本没人维护一份完整的历史清单。等哪天真出问题要查“2.3.0 版本到底发布过哪个安装包”你就只能一页一页去翻。手动翻页的问题不只是慢还有容易漏。有的发布站点一个版本占一整块区域有的版本拆成两个页面还有的附件链接是相对路径最后你复制到 Excel 里的 URL 可能还是断的。这些情况我全都遇到过。所以最开始的需求非常朴素把这些版本跟附件之间的关系变成结构化数据做成一个可筛选、可搜索的索引表。1.2 索引采集与文件下载的技术分岔点索引采集只需要拿链接和元信息完全不碰文件本体。你要处理的是一次 HTTP 请求、一次 HTML 解析、一次结果落库单页面数据量可能只有几 KB 到几十 KB。而文件下载要考虑大文件流式传输、断点续传、磁盘空间、下载失败重试复杂度高出一个量级。以实际场景举例一个 release note 页面可能有 20 个版本每个版本挂 5 个附件加起来 100 个链接。采集索引就是把 100 条 URL 和对应的版本号、日期记下来整个过程毫无压力。但如果要下载这 100 个附件安装包动辄几百 MB还要考虑服务器带宽、存储策略这就完全是另一个项目了。所以如果你是想要“自动下载全部安装包”那应该单独设计下载队列如果你的目标是“快速生成一张版本附件清单”那本文这套索引采集方案就是为这个目标服务的代码量少维护成本也低。1.3 任务清单与成功标准在写代码前我会把需求和验收标准写下来避免写着写着就跑偏。你可以直接照这个框架来定义自己的任务输入公开 release note 站点地址包含多个版本、分页或单页列表均可。输出CSV / Markdown / JSON 格式的表结构字段至少包含版本号、发布日期、附件名称、附件 URL、附件大小如果有、校验值如果有。边界只处理公开页面不涉及登录态不采集需要权限才能看到的内容不尝试绕过任何访问控制。验收标准脚本跑完之后生成的索引表跟页面上能看到的附件列表一一对应重复运行时不会产生重复记录。这个清单看着简单但它决定了后面的解析逻辑怎么写。比如我后面考虑的是“如何把页面上的块结构拆成一条记录”而不是“如何把文件从服务器上搬下来”整个脚本的重心就在解析和数据组织上。2. 技术选型requests XPath 为什么够用Selenium 反而添乱很多爬虫教程一上来就推荐 Selenium理由是“现在很多页面都是动态渲染的”。这个说法不能说错但在 release note 这种场景下大多数时候都是过度设计。我在决定技术方案之前只做一件事确认目标页面到底是不是静态渲染的。2.1 怎么判断页面是静态还是动态渲染方法很简单。用浏览器打开目标 release note 页面右键“查看网页源代码”。如果你能在源代码里直接看到附件链接和版本号那这个页面就是静态渲染的根本不需要跑浏览器。如果你在源代码里找不到数据只看到一堆script那才需要考虑动态渲染。还有个更精确的办法打开开发者工具F12切到 Network 面板刷新页面看有没有 XHR 或 Fetch 类型的接口返回 JSON 数据。如果附件列表是从某个/api/releases接口加载的那直接请求那个接口反而更省事。在真实项目中我遇到过的情况是几十个企业发布站里至少有七成是服务端渲染好的 HTML用 requests 直接拿就行。2.2 各方案的真实取舍对比我把几种常见方案放在一起对比过各有适用场景但爬取 release note 这种结构化静态页答案其实很明确。方案学习成本资源占用适用场景我的评价requests XPath低极低静态或服务端渲染的列表页优先选择Selenium BeautifulSoup中高需拉起真实浏览器重度 JS 渲染页面杀鸡用牛刀Scrapy中高中自带调度和管道大规模、多站点抓取小任务没必要上框架官方 API如 GitHub Releases API低极低目标平台提供 API有 API 就不要写爬虫我这里要特别说一下 GitHub Releases API因为很多人一听到“release note”就想到 GitHub。如果你的发布信息来自 GitHub 开源项目的 Releases 页面那用官方 API 是最优先选择结构清晰、字段完整完全不用解析 HTML。但很多企业内部或者产品自建发布站没有 API那就只能用爬虫采集 HTML。本文讲的通用方案就是为了覆盖这一类没有 API 的情况。2.3 环境准备与依赖安装环境上不需要很复杂的东西Python 3.8 以上即可。我建议新建一个虚拟环境把依赖跟系统环境隔离python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install requests lxmlrequests 负责发送 HTTP 请求lxml 负责 XPath 解析。安装 lxml 时如果遇到编译报错优先换成预编译的 wheel 包一般pip install lxml在 Windows 和主流 Linux 发行版上都会有对应的二进制包不需要手动编译。还有一个小提示如果你发现 lxml 的 API 用不习惯也可以直接用 parsel它是 Scrapy 作者提取出来的独立解析库用法几乎一样pip install parselfrom parsel import Selector sel Selector(texthtml) sel.xpath(//a/href).getall()但本文代码还是以 lxml 为例因为它在社区里更常见遇到问题时更容易搜到答案。3. 拆解页面结构XPath 定位附件链接的三种实用写法拿到 HTML 之后最核心的工作就是写 XPath。XPath 写得好不好直接决定了解析代码的稳定性。我在这一节会用一个典型的 release note 页面结构作为例子把定位的思路拆开讲。实际站点结构肯定不完全一样但思路是通用的。3.1 先看 HTML 再写代码不要凭感觉猜很多人喜欢在代码里用etree.HTML(html).xpath(//a/href)一把梭直接把页面上所有链接抓下来然后靠过滤词筛选。这种方法不是不能跑但很容易把导航栏、页脚、推荐位里的无关链接全带进来后面整理数据时会很难受。正确做法是先按 F12在 Elements 面板里找到放附件链接的那个区域观察它的嵌套结构。比如很多发布站的 HTML 长这样div classrelease-item>items tree.xpath(//div[contains(class, release-item)]) for item in items: version item.xpath(./h3/text())[0].strip() links item.xpath(.//a/href)注意第二个 XPath 开头是.//表示在当前块内部搜索而不是从整个文档根节点重新搜索。这一步小小的.很容易被漏掉漏掉之后你就会莫名拿到其他区域的链接这是我在实际写代码时踩过最多次的坑之一。第二种是“属性过滤”适用于附件链接有明显目录特征的情况links tree.xpath(//a[contains(href, /downloads/)]/href)如果页面的附件都挂在/downloads/目录下用这种写法最简单直接。我做版本追溯的时候有好几次遇到旧版本安装在/releases/archive/路径下new 版本在/downloads/下这时候用路径特征切分反而比遍历块更稳。第三种是“按可见文本过滤”对应的就是网上常说的 XPath text 函数用法。很多人在这一步容易踩坑# 这种写法经常匹配不到因为标签内文本可能带换行或空格 links tree.xpath(//a[text()setup]/href) # 更稳妥的写法先拿 a 标签节点再用 contains 对文本内容做模糊匹配 links tree.xpath(//a[text()[contains(., setup)]]/href) # 如果文本里既有空格又有大小写差异可以再用 normalize-space 处理 links tree.xpath(//a[normalize-space(text())app-2.3.1-setup.exe]/href)关于text()函数的具体用法这里多说一点//a[text()下载]只有在a的直接子文本节点完整等于“下载”时才会命中。如果 HTML 里写的是a href#下载/a那没问题但很多页面模板会在标签里加入span或者换行空格text()返回的是一个文本节点列表直接用等号比较常常失灵。所以我在写关键词过滤时更习惯用contains(., 下载)这个点号代表当前节点的全部文本内容包括所有子标签里的文字回退空间大得多。3.3 相对路径转绝对路径最容易被忽略的坑release note 页面里的附件链接绝大多数是相对路径比如/downloads/app-2.3.1-setup.exe。如果你把这些相对路径原样存进 CSV后面想直接用链接来下载或者分享就会得到一堆残缺地址。解决方案是使用urllib.parse.urljoin把页面基础和相对路径拼成完整 URLfrom urllib.parse import urljoin page_url https://releases.example.com/notes/ href /downloads/app-2.3.1-setup.exe full_url urljoin(page_url, href) print(full_url) # https://releases.example.com/downloads/app-2.3.1-setup.exe这一步非常简单但特别容易被忽略。我第一次写采集脚本时就没做结果导出的索引表里全是“/downloads/xxx”同事根本没法直接用。从那以后我就把“URL 标准化”当成固定步骤只要拿到链接就立刻转成绝对地址再落库。4. 核心脚本从单页跑通到全量循环技术选型确认后代码部分我采用“渐进实现”的方式先封装请求模块再做单页解析最后加翻页循环。这样每一步都可以验证结果不至于一口气写一大堆代码跑挂了都不知道问题在哪。4.1 请求封装状态检查、超时和 UA第一步是写一个稳定的请求函数。直接调用requests.get(url)对初学者来说很方便但不加超时、不检查状态码的话遇到页面异常时脚本会在网络层卡很长时间或者拿到 403 页面之后继续解析出一堆空数据。import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } def fetch_page(url, headersNone, timeout15): 获取页面 HTML返回响应对象或者抛出异常 try: resp requests.get(url, headersheaders or HEADERS, timeouttimeout) if resp.status_code ! 200: raise RuntimeError(f页面请求失败: {resp.status_code} - {url}) return resp except requests.RequestException as exc: raise RuntimeError(f请求异常: {url} - {exc}) from exc有几个细节值得说。UA 头最好用一个完整的浏览器 UArequests 库默认的python-requests/x.x非常容易被服务端识别并拒绝。Accept-Language也很重要很多站点会根据这个字段返回不同语言的页面如果我们要解析的 release note 页面是中文界面带上这个头可以保证解析逻辑一致。超时时间我一般设置 15 秒太短在弱网环境容易误伤太长遇到异常页面又会拖死整个脚本。4.2 单页解析函数封装“页面到记录”的转换拿到 HTML 之后解析函数负责把页面转换成结构化记录。我习惯把它写成独立的函数返回一个列表每条记录是一个字典。这样做的好处是后续无论是调试还是换输出格式都不需要改解析逻辑。from lxml import etree from urllib.parse import urljoin def parse_release_page(html, page_url): tree etree.HTML(html) results [] for item in tree.xpath(//div[contains(class, release-item)]): version_h item.xpath(./h3/text()) date_span item.xpath(./h3/span[classdate]/text()) version version_h[0].strip() if version_h else unknown date date_span[0].strip() if date_span else # 在版本块内找附件链接 for a in item.xpath(.//a[href]): name .join(a.xpath(.//text())).strip() href a.get(href) full_url urljoin(page_url, href) results.append({ version: version, date: date, file_name: name, file_url: full_url, }) return results这段代码有几个地方是专门针对 release note 场景做的。第一version_h用了列表取值再 strip避免因为页面里多了空格导致版本号变成 “v2.3.1 ”。第二附件名用.join(a.xpath(.//text()))而不是直接a.text因为附件名可能被span拆成几段直接取 text 只会拿到第一段。第三用a.get(href)拿原始属性值之后统一在 urljoin 时做标准转换避免在解析阶段处理拼接逻辑。4.3 多版本循环与翻页策略如果 release note 只有一个页面那上面这段代码就够了。但现实里发布站点版本很多往往有翻页或者“上一页/下一页”的导航。翻页策略一般有两种。第一种分页 URL 有规律。比如https://releases.example.com/notes/page/2/那直接循环页码就行base_url https://releases.example.com/notes/page/{page}/ all_records [] for page in range(1, 6): url base_url.format(pagepage) print(f正在采集: {url}) resp fetch_page(url) records parse_release_page(resp.text, url) if not records: break # 页面已到尾页 all_records.extend(records)第二种总览页列出所有版本的详情链接再逐个进入详情页采集。这种方式更稳健因为有些站点根本没有规律页码只有“查看更多”按钮或者版本列表是无限滚动加载的。# 先抓总览页拿所有详情页链接 overview_url https://releases.example.com/release-list resp fetch_page(overview_url) tree etree.HTML(resp.text) detail_links tree.xpath(//a[contains(class, release-link)]/href) for link in detail_links: detail_url urljoin(overview_url, link) print(f正在采集: {detail_url}) detail_resp fetch_page(detail_url) recs parse_release_page(detail_resp.text, detail_url) all_records.extend(recs)翻页循环里一定要加time.sleep()我通常设置在 1 到 2 秒之间。不要小看这个停顿它一方面是为了不给对方站点造成访问压力另一方面也是为了避免短时间内大量请求触发访问频率限制。如果对方的 release note 页面有几十上百个版本1 秒的间隔爬完整站也才一两分钟完全够用。5. 数据落地输出 CSV、Markdown 索引顺便做增量更新索引采集的最后一步是数据落地。这一步看似简单但编码和去重两个地方坑特别多处理不好整张表就白采集了。5.1 CSV 输出的编码坑如果你用 Python 内置的 csv 模块直接把数据写入文件然后用 Excel 打开大概率看到的是乱码。原因在于 csv 模块默认编码是 utf-8而 Windows 下 Excel 默认用 GBK 解码。解决办法是写入时用utf-8-sig编码import csv def write_csv(records, filepath): if not records: return keys [version, date, file_name, file_url] with open(filepath, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnameskeys) writer.writeheader() writer.writerows(records)utf-8-sig会在文件开头加上 BOM 标记Excel 打开时就能自动识别为 UTF-8避免中文乱码。这个细节不知道的话爬虫写完总觉得是数据问题调大半天才发现是编码问题。CSV 适合给 Excel 用户但我现在自己维护版本台账时更喜欢 Markdown 格式因为可以直接贴到内部 Wiki 或者 Git 仓库的 README 里。转换为 Markdown 其实非常简单def write_markdown(records, filepath): with open(filepath, w, encodingutf-8) as f: f.write(| 版本 | 日期 | 附件名 | 链接 |\n) f.write(| --- | --- | --- | --- |\n) for r in records: f.write(f| {r[version]} | {r[date]} | {r[file_name]} | {r[file_url]} |\n)5.2 增量更新怎么做到“不重复记录”如果你的脚本只是手动跑一次那去重需求不明显。但只要你把采集脚本配上定时任务让它定期自动更新就会遇到重复问题。同一个 release note 页面每周都在变新增了版本如果每次全量采集再写入表格里就会出现大量重复行。我的做法是以“版本号 附件文件名”作为唯一键在写入前先加载已有 CSV 中记录过的键import os def load_existing_keys(filepath): if not os.path.exists(filepath): return set() with open(filepath, r, encodingutf-8-sig) as f: reader csv.DictReader(f) return {(row[version], row[file_name]) for row in reader} def write_incremental(records, filepath): existing_keys load_existing_keys(filepath) new_records [] for r in records: key (r[version], r[file_name]) if key not in existing_keys: new_records.append(r) existing_keys.add(key) if new_records: with open(filepath, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[version, date, file_name, file_url]) writer.writerows(new_records) return len(new_records)增量写入这里有个细节要注意如果你只写入新增记录那 CSV 的字段顺序必须跟首次写入一致。我上面用DictWriter时显式指定了 fieldnames就是为了防止手滑调换字段顺序导致整个表错位。还有如果你需要更新某条记录的 URL 或日期纯追加方式做不到那就要读全量文件、按唯一键更新、再覆盖写回逻辑会多几行但也是同样的思路。5.3 校验值字段的补充思路很多 release note 页面会附带 SHA256 校验文件但链接指向的可能是单独的.sha256文件而不是页面里的一个字段。如果你希望在索引表里加上校验值可以考虑在采集附件链接后对每个校验文件再发一次请求读取里面的哈希值。不过这会把索引采集变成轻量下载速度会慢一些。我的建议是索引表先不解析校验文件内容只记录校验文件链接等真正要下载安装包时再按需下载校验文件这样索引更新效率更高。6. 实测中的异常处理403、限速和页面改版网上很多爬虫教程会把逻辑写得很顺滑但真实世界里页面不会按你的预期运行。我实际跑这个脚本的过程中遇到过几次典型的异常每次解决后都顺手把处理逻辑固化到了代码里。这一节我把完整的排查链路写出来方便你遇到问题时能自己定位而不是直接拿到答案。6.1 状态码伪装403 不一定是因为缺 UA第一次跑某个发布站时我直接用requests.get(url)没加任何请求头返回 403。当时我第一反应是“需要伪装浏览器 UA”于是加上了完整的 Chrome UA结果仍然 403。后来把响应体保存到本地一看发现服务器返回的是一个 JS 挑战页面。这种站点通常不只看 UA还看 TLS 指纹、Cookie 校验、JavaScript 执行能力。对于这种页面requests 很难直接通过需要确认这个站点到底是不是真的允许脚本访问。这里我的建议是分步骤排查。先观察正常浏览器访问时使用的是什么请求头特别是 Cookie。再看robots.txt确认站点对爬虫的友好程度。如果回复 403 且页面有明显的人机验证特征那就说明这个站点设置了更严格的风控这种情况下不应继续尝试绕过建议换数据源或者走官方 API。爬虫的边界在“公开信息”和“合理访问”之间执着于破解反爬没有意义。6.2 限速触发后的缓解手段另一种常见异常是 429 Too Many Requests页面明明能请求到数据但我连续翻了几十页之后突然开始被拒绝。服务器返回 429 时我采用的不是继续硬碰硬而是指数退避重试。import time def fetch_with_retry(url, max_retries3): for attempt in range(max_retries): try: return fetch_page(url) except RuntimeError as exc: if 429 in str(exc): wait 2 ** attempt print(f触发限速等待 {wait} 秒后重试) time.sleep(wait) else: raise raise RuntimeError(f重试 {max_retries} 次仍然失败: {url})指数退避的算法很简单第一次失败等 2 秒第二次等 4 秒第三次等 8 秒。如果三次都失败就不是简单限速可能是站点策略变更这时应停下检查。跑业务爬虫要始终记得对方站点的稳定性比你的采集速度重要。6.3 页面改版后如何定位问题页面改版是 release note 采集脚本最大的敌人。你上周写的 XPath 还能用这周站点模板一换脚本可能返回 200 但解析结果为空。这种问题比 403 更难发现因为脚本没报错只是默默返回了空数据。我的排查链路一般是四步。第一步先把 HTML 保存到本地文件看一下返回内容是不是真的包含附件链接这一步排除了网络层问题。第二步用开发者工具重新检查页面结构对比原来的 class 和标签是否变化。第三步在 Python 里手动执行 XPath一点点缩小定位范围而不是直接改一大段代码。第四步定位到问题后把所有 XPath 表达式抽出来放到配置区这样下次改版只需要改配置项不用动主逻辑。这四步走完绝大多数改版问题都能定位。这里最关键的意识是脚本没有异常不代表没问题空结果本身就是一种业务告警。所以我在脚本里加了校验逻辑当解析结果为 0 或者明显低于历史平均值时直接发日志通知而不是默默写完一个空文件。7. 从脚本到常驻工具定时任务、自动通知与“分布式”的克制用法当采集脚本从“临时用一次”变为“每周维护一次”就需要把它升级成带调度和通知的小工具。这一节我会说清楚定时任务的两套实现方案、为什么要加异常通知以及为什么很多人提的“分布式爬虫”在这个场景下完全是多余的。7.1 定时任务Linux cron 与 Windows 任务计划如果你的采集脚本跑在服务器上直接使用 cron 是最简单的方式。比如每周一早上九点自动更新一次版本索引0 9 * * 1 cd /home/yourname/release-index /home/yourname/venv/bin/python collect.py run.log 21注意这里必须使用虚拟环境里的 python 绝对路径否则 cron 环境下的依赖可能对不上。日志重定向到文件也很重要否则脚本异常时你根本不知道它有没有跑过、跑到哪一步出的错。Windows 上就用任务计划程序触发条件设为“按预定计划”操作选择“启动程序”填入 python.exe 和脚本路径即可。如果你不想依赖系统 cron也可以用 Python 的第三方库 schedule 或者 APScheduler 在脚本内部实现调度。这种方案适合“没有服务器、只有一台常开电脑”的场景import schedule import time def job(): # 执行采集和更新流程 run_collect() schedule.every().monday.at(09:00).do(job) while True: schedule.run_pending() time.sleep(60)我个人的建议是如果脚本已经放在服务器上了优先用系统自带 cron少一层常驻进程就少一分维护负担。如果是在个人电脑上跑再用 schedule。7.2 异常自动通知成功返回不代表一切正常前面提到过采集脚本最大的风险是静默失败。定时任务在跑没人盯着某天页面改版导致解析结果全空脚本依然会生成一个空索引文件并正常退出。这种情况下错误不是立刻暴露的而是等到你下一次手动查看台账时才发现。所以我强烈建议给脚本加一个最简单的通知渠道当结果为空、请求失败或者新增记录数为 0 时通过邮件或者其他即时消息工具发送提醒。如果是内部工具用钉钉或企微的 webhook 机器人最简单只需要往 webhook 地址 POST 一条 JSON 文本。示例如下import requests def send_alert(message): webhook https://your-webhook-url payload {msgtype: text, text: {content: message}} requests.post(webhook, jsonpayload, timeout10) if not records: send_alert(release note 采集结果为空请检查页面结构)这类主动告警机制能让你在用户发现数据缺失之前就介入处理。如果你维护的索引要被团队其他同事使用这个通知环节就更是必需项了。7.3 分布式爬虫为什么是过度设计最后说一个被问得比较多的问题“这个采集任务需要用 Scrapy 或者分布式爬虫吗”答案是绝大多数 release note 采集场景完全不需要。你面对的只是一个站点的几页到几十页单脚本串行跑完只要几分钟Scrapy 的调度器、下载中间件、Item Pipeline 在这里解决不了任何实际问题反而会让脚本体积变大、维护门槛变高。只有在一种情况下才需要考虑分布式你有成百上千个产品线每个产品线都有独立的 release note 页面而且每天都要全量刷新索引。这时单机串行的确可能需要升级为带队列的多节点采集同时用布隆过滤器加速 URL 去重。但对于单个产品站老老实实用本文这套几百行的脚本反而是最稳定的方案。我维护这套索引采集脚本到现在最大的体会是爬虫脚本本身并不难写难的是把页面变化和异常情况纳入设计。最实用的调整就是在代码里把解析逻辑隔离成单一函数页面结构改版时只改一个函数其他部分完全不动。发布台账这个事看起来很小但一旦自动化跑顺每个版本周期能省下的时间真不是一点半点。如果你也在维护 release note 或者类似的公开文档索引可以先从单页面解析开始逐步把定时和告警补上这比一开始就追求复杂框架要靠谱得多。