1. 前言:为什么我选了这个题目
如果你问一个没写过爬虫的人,爬网页最怕什么,十有八九会回答“反爬”“封IP”。可等我真正把某浏览器的官方更新日志页拉下来之后才发现,这个页面连一道正经的验证码都没上,真正折磨人的反而是后半程:HTML源码里塞满了样式标签、换行符、转义字符,版本号和日期散落在不同的段落里,有的长有的短,有的完整有的残缺。你看着屏幕上一堆半结构化文本,心里很清楚——单纯用XPath把整块div捞出来没意义,因为捞出来的是几十行糊在一起的脏字符串,根本没法直接转成表格。
这时候就轮到正则表达式和文本清洗上场了。正则负责从混沌文本里精确切出“版本号”“日期”“更新说明”这些关键字段,清洗负责把残留的标签、空白、特殊符号统统收拾干净。最后再用Python标准库里的csv模块把整理好的数据落盘,一个可以反复运行的更新日志采集脚本就齐活了。
这次的实战其实很适合两类人:一是刚学完Python基础、想做点真实项目练手的人,二是工作中需要定期跟踪软件更新动态、又不想每次手动逛页面的人。整个实现过程不依赖任何重量级框架,只用requests、re、csv这几个库就能跑通,对爬虫入门来说性价比极高。
2. 数据源分析:更新日志页面的真实面貌
2.1 这个页面到底长什么样
动手写代码之前,我习惯先在浏览器里把目标页面“拆开”看一眼。按F12打开开发者工具,切到Network面板,刷新页面,找到那个返回HTML文档的请求,右键复制为cURL格式,或者直接看它的Response内容。你很快会发现一个事实:页面源码里虽然有清晰的“版本号—发布日期—更新内容”结构,但这种结构是给人类眼睛看的,不是给代码用的。
具体来说,每个版本在HTML里大致对应这样一个模式:
- 一段标题文本,里面包含版本号;
- 一段日期文本,格式可能长成“2024年5月30日”也可能长成“Thu May 30 2024”;
- 若干条更新说明,每条用一个列表项或段落标签包着,内部还嵌了超链接、强调标签等。
我最初试着用BeautifulSoup的选择器去定位,确实也能取到数据,但问题在于:不同版本的条目结构不完全一致,有的更新说明里多了一个加粗标签,有的日期被嵌套在了特定class的span里,选择器的脆弱性马上就暴露了。尤其是当页面改过一版布局之后,之前写好的CSS路径基本全废。
正则相对而言更接近“文本本身的样子”,只要页面上“版本号写在日期前面”这个顺序不变,哪怕外层的标签换了一轮,匹配逻辑依然能存活很久。这是我把正则作为主解析方案的根本原因。
2.2 数据特征拆解:我需要提取哪些字段
在动手写正则之前,先把目标字段列成一张表,避免在代码里东一榔头西一棒子:
| 字段名 | 示例值 | 说明 |
|---|---|---|
| version | 114.0.5735.90 | 完整版本号,含主版本和子版本 |
| release_date | 2024-05-30 | 发布日期,统一转成ISO格式 |
| summary | 本次更新修复了若干安全问题 | 更新内容的文字摘要 |
| raw_text | 原始HTML段落 | 保留原始内容备查 |
为什么要分成这四个字段?因为后续的使用场景不一样。版本号和日期用来做时间线排序、判断是否有新版本;summary是给人看的描述;raw_text则是审计用的——万一清洗规则把有效信息误删了,还能回到原始数据找补。
2.3 正则的适用边界
诚实地说,正则不是万能的。如果页面结构是深嵌套、树形、层级明显的,用XPath和CSS选择器会舒服得多;如果页面内容是高度口语化、没有固定模式的自由文本,靠正则硬切也会很痛苦。但“更新日志”恰好是正则的舒适区:它有相对固定的开头模式(版本号/日期),条目之间边界清楚,文本虽然是半结构化的,但规律性强。搞清楚这一点,你后面遇到类似半结构化文本时就不会走弯路。
3. 请求与页面获取:夯实基础环节
3.1 工具选型与版本兼容说明
这次我用的是requests库。为什么不用aiohttp,也不用Scrapy?因为页面量级不大,属于轻量级同步抓取,requests加一个Session就能处理连接复用,没必要为了这点请求量引入异步复杂度。Python环境方面,我用的3.10,但以下代码在3.8及以上都应该能跑通。需要特别提醒的是新版requests对SSL的依赖变化,如果你的环境里有个别版本的openssl兼容性问题,最稳妥的解决办法是升级requests到最新稳定版,而不是在代码里关闭SSL验证。
3.2 抓取时最容易翻车的三个细节
第一,请求头一定得像一个“真人浏览器”。有些服务器虽然不强制检查UA,但如果UA是空的,返回结果可能是一个奇怪的安全验证页,而不是你要的数据。我习惯带上常见的UA、Accept语言和连接头,模拟日常浏览行为。
第二,响应编码要手动确认。很多网页的Headers里写着UTF-8,但实际内容的编码可能是平台内部默认编码,直接用response.text可能输出一串乱码。稳妥一点的做法是先用response.encoding看一下自动推测的编码,再结合页面meta标签确认,必要时手动指定编码再获取text。
第三,页面可能不是一个静态HTML就完事。部分版本日志的列表是后端渲染后直接输出的,但也有一些页面用异步接口加载初始数据,这时你直接抓HTML源码会发现页面空空如也。我的经验是:先用浏览器开发者工具查看Network里的XHR请求,找到真正返回数据的那条接口。这次的目标页面是同步渲染的,所以可以直接抓页面本身,但如果你照葫芦画瓢时遇到空页面,优先检查这个问题。
3.3 请求与预处理代码
拿到HTML之后,我习惯先做一次轻量预处理,把HTML实体和乱糟糟的空白做初步规整,再喂给正则。两个实用函数放在这里:
import requests import re import html def fetch_page(url: str, headers: dict) -> str: session = requests.Session() resp = session.get(url, headers=headers, timeout=15) resp.raise_for_status() # 手动确认编码;如果页面meta里声明的是utf-8就强制使用 resp.encoding = "utf-8" return resp.text def normalize_html(raw_html: str) -> str: # 将HTML实体如 &转换为真实字符 text = html.unescape(raw_html) # 先移除注释,避免里面的干扰文本被解析到 text = re.sub(r"<!--.*?-->", "", text, flags=re.S) # 把多余空白压成一个空格,方便正则匹配 text = re.sub(r"\s+", " ", text) return text这两步的意义在于:正则讲究“模式匹配”,而脏空白恰恰是破坏模式的最大敌人。你可以把这一步类比成做菜前的洗菜切菜——后面一切精细操作都建立在原料干净的基础上。
4. 正则解析核心:如何从HTML混沌中精准定位版本信息
4.1 匹配版本号的模式设计
版本号是这类日志里最高频出现的模式。Chrome系版本号的典型格式是“主版本.次版本.构建号.补丁号”,比如114.0.5735.90,但我不能假设它永远是四段,毕竟有些浏览器版本号只有三段。所以模式要兼顾“完整版”和“通用版”。
我最终用的模式是:
version_pattern = re.compile( r"Chrome\s*(\d+(?:\.\d+){2,3})", re.IGNORECASE )注意这里用的是(?:\.\d+){2,3},而不是[\d.]+。前者的意思是“匹配点号加数字这个组合出现2到3次”,能精确约束版本号的形态;后者会把“1.2.3.4.5.6.7”这种异常长串也吞进去,产生脏数据。这是正则提取中一个典型的“贪婪与精确”取舍。
还有一个坑:有些日志里版本号的写法是“版本 114.0.5735.90”,有些是“Chrome 114.0.5735.90”,还有些直接把版本号写在标题的最开头。所以我匹配时不会把“Chrome”这个前缀写死成必须出现,而是做成可选前缀,核心是“数字开头、点号分隔”的结构。
4.2 匹配日期的模式设计
日期字段的格式通常有中文和英文两种。如果页面的语言设置是中文,常看到“2024年5月30日”;如果是英文,则是“May 30, 2024”或者“2024-05-30”。统一处理的方式是写两个模式,分别提取,再转成统一格式。
date_pattern_cn = re.compile(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日") date_pattern_en = re.compile(r"([A-Za-z]{3})\s+(\d{1,2}),?\s+(\d{4})") MONTH_MAP = { "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12 } def extract_date(text: str) -> str | None: m = date_pattern_cn.search(text) if m: year, month, day = map(int, m.groups()) return f"{year:04d}-{month:02d}-{day:02d}" m = date_pattern_en.search(text) if m: mon_str, day, year = m.groups() month = MONTH_MAP.get(mon_str[:3]) if month: return f"{int(year):04d}-{month:02d}-{int(day):02d}" return None把日期统一成“年-月-日”的好处不用多说:排序、查重、做时间线都方便。这一步也是整个清洗理念的缩影——原始数据允许千奇百怪,但落库时必须归一化。
4.3 匹配整段更新条目的策略
最理想的情况是:一段日志正好对应一个版本,里面有版本号、日期和若干条更新说明。但是如果把所有文本混在一起整体匹配,正则回溯的次数会很难看,而且不同版本之间的边界容易搞混。
我的策略是分两步走:
- 先用一个宽泛的分隔正则把每个版本的块切出来;
- 再在每个块内部精确提取版本号、日期、说明文本。
块的分隔符在源码里通常是特定的锚点或加粗标签,不过不同页面写法不一样。最稳的办法是先找到页面里版本号出现的所有位置,以版本号本身作为块的起始标记,再截取到下一个版本号之前的内容。用re.finditer可以很好地实现这个逻辑:
def extract_version_blocks(normalized_text: str): version_iter = version_pattern.finditer(normalized_text) matches = list(version_iter) blocks = [] for i, m in enumerate(matches): start = m.start() end = matches[i + 1].start() if i + 1 < len(matches) else len(normalized_text) block = normalized_text[start:end] version = m.group(1) blocks.append((version, block)) return blocks这种“以版本号切块”的思路,比直接写一个超级正则去匹配“版本号+日期+说明”的完整结构要稳健得多。每个块内部即使格式稍有差异,也不影响整体提取结果。
5. 文本清洗详解:从又脏又乱的HTML到干净字段
5.1 清除标签与提取可读文本
块切完之后,每个block里还残留着大量HTML标签,比如<span class="xxx">、</strong>、<a href="...">等。这些标签对数据处理没有任何价值,反而会污染文本。我的清洗顺序是:
- 删除所有HTML标签,让文本裸露出来;
- 合并多余的空白和换行;
- 清理特殊标题符号和杂乱的标点。
这一步看起来简单,但有个细节值得单独说:删除标签的正则如果用re.sub(r"<.*?>", "", text),在碰到带有属性的标签时也能正常工作,因为.*?是非贪婪匹配。但如果你把.*?误写成.*,会直接吞掉一大片内容,损坏文本结构。这两个正则的差别我栽过跟头,写在这里提醒一下。
5.2 更新内容的去重与去噪
日志里版本说明有时候会有重复描述。比如某个版本修复了某个问题,下一个版本又提了一次,这个属于正常现象,但也有些重复是模板造成的,比如每条更新说明前都带“修复”“优化”之类的动词,清洗时不需要去掉它们,因为这对阅读者理解日志还是有帮助的。
另外,更新说明里常包含括号引用的链接地址或内部代号,这些信息在网页上有超链接,但转成纯文本后会变成大段URL,在CSV里看着很碍眼。我会用一条规则把类似(链接:...)的内容剔除,保留主要描述。
5.3 常见HTML实体的清理
页面源码里是“&”的,在浏览器里显示为“&”,但直接抓源码时它还是实体形态。前文的html.unescape已经处理了大部分实体,但偶尔会碰到遗漏的半角空格变体。保险起见,我还会再做一层替换:
def clean_text(text: str) -> str: text = re.sub(r"<[^>]+>", "", text) text = re.sub(r" ?", " ", text) text = re.sub(r"&?", "&", text) text = re.sub(r"<?", "<", text) text = re.sub(r">?", ">", text) text = re.sub(r"\s+", " ", text) text = re.sub(r"\u200b|\u200e|\u200f", "", text) return text.strip()\u200b是零宽空格,肉眼看不见,但在文本比较、正则提取时会产生让你抓狂的“明明看起来一样却匹配不上”的诡异问题。这一条是我后加的,因为实际抓取时真的遇到过。
5.4 空值和异常情况兜底
不是每一条日志都有完整的日期,也不是每个版本都有更新说明。清洗阶段要允许空值存在,但不能因为空值导致程序崩溃。我给每条记录构造一个字典,字段缺失时给一个默认值:
record = { "version": version, "release_date": date_str if date_str else "未知", "summary": summary if summary else "(无更新说明)", "raw_text": block[:200] }这看起来不像什么高深技术,但恰恰是很多入门者踩坑的地方——他们忽略了数据不完整的情况,结果跑到某一条缺失数据时程序直接抛异常,还误以为是正则写错了。
6. CSV导出与数据验证:给数据一个体面的归宿
6.1 csv模块的优雅用法
把数据写进CSV,听起来简单,但直接用csv.writer容易忽略编码问题。如果用普通的utf-8编码写文件,生成的CSV在Excel里打开会全是乱码。解决方案是用utf-8-sig编码,也就是UTF-8加BOM,Excel才能正确识别。
import csv def export_to_csv(records: list[dict], output_path: str): with open(output_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter( f, fieldnames=["version", "release_date", "summary", "raw_text"], quoting=csv.QUOTE_MINIMAL ) writer.writeheader() writer.writerows(records)newline=""这个参数千万别漏。如果不设置,在Windows系统下写入的CSV每行之间会多出一个空行,看起来非常业余。csv.QUOTE_MINIMAL是让csv模块在必要的时候自动给字段加引号,比如summary里出现逗号或换行的时候,否则导出的表会列错位。
6.2 数据验证:如何确认抓取结果可靠
跑完代码第一件事不是急着打开CSV看结果,而是做三个快速校验:
第一,条数校验。数一下records的长度和页面上肉眼可数的版本条目数是否一致,如果差太多,说明切块逻辑存在遗漏。
第二,版本号去重校验。正常情况下版本号不应该重复,如果重复出现,可能是正则在一条日志里匹配了两次,导致切块错位。用集合标记一下重复项很方便。
第三,日期缺失率。统计一下“未知”日期出现的比例,如果超过10%,说明日期正则对个别格式失灵了,需要回去看一下遗漏的样本长什么样。
这三个校验做下来,数据可靠性基本就有底了。
6.3 一个完整可运行的整合脚本骨架
把上面的步骤串起来,最终脚本的整体流程如下:
def main(): url = "替换为目标页面的实际地址" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } html_text = fetch_page(url, headers) normalized = normalize_html(html_text) blocks = extract_version_blocks(normalized) records = [] for version, block in blocks: date_str = extract_date(block) # 提取summary:通常是版本块内所有非版本号文本的集合 summary = extract_summary(block) records.append({ "version": version, "release_date": date_str or "未知", "summary": clean_text(summary), "raw_text": block[:200], }) export_to_csv(records, "update_logs.csv") print(f"导出完成,共 {len(records)} 条记录")日常使用时,把这个脚本保存成update_tracker.py,每次想更新数据跑一遍就行。后续如果需要做版本更新提醒,也可以在这个基础上加一个简单循环任务,定时执行脚本再对比CSV里的最新版本号。
7. 踩坑复盘:三个让新手猝不及防的隐蔽问题
7.1 版本号被截断成“114”
第一次跑脚本时,我的版本号正则用的是r"Chrome (\d+)",匹配到的结果全是114、115、116这种大版本号,小版本号和补丁号全丢了。这个bug在测试时很容易被忽略,因为大版本号看起来“好像也没错”,直到你要分析具体补丁更新时才发现数据根本无法区分。换用r"Chrome\s*(\d+(?:\.\d+){2,3})"之后,数据粒度才正常。
7.2 日期被截断成“5月30日”
我一开始的日期正则只匹配了“月日”部分,因为页面上日期常常被分割成两部分显示,跨标签节点。这导致提取出的日期缺少年份。解决办法就是第四章里的那样,让正则同时覆盖中英文年月日完整模式,并在后面补了年份缺失时的兜底逻辑。
7.3 HTML实体的零宽字符
贡献最阴间的一笔。我在清洗后做字符串比较时发现两条内容看似完全相同的记录一直去不了重,后来打印出两个字符串的Unicode码点才发现,其中一个字符串里藏着零宽空格\u200b。这种字符在页面源码里截图都看不出来,却是字符串处理的地雷。加了正则清除后,世界清净了。
8. 后续可以怎么扩展
这套脚本现在已经能稳定跑出CSV,但它的价值远不止“导出一次”。我后来在这个基础之上做了两个小扩展,思路分享给大家:
一个是在脚本里加了一个“增量检测”逻辑。每次运行之后把最新的版本号存进一个小配置文件,下次运行如果检测到版本号变了,就自动生成一条提醒消息,这样就不用每次跑完再手动翻CSV确认有没有新版本。
另一个是把CSV进一步拆成了带摘要的Markdown列表。更新日志纯文本有时候太长,尤其是某个版本修复了十几个问题的时候,导出到Excel里要滚动很久。我增加了一个函数,把每条summary里的第一条说明抽取出来作为“一句话摘要”,生成一个精简版报表,方便快速浏览。
如果还想做更重的分析,还可以把CSV导入数据可视化工具,按时间轴画版本发布频率图、统计两次更新之间的间隔天数。这些都是锦上添花的事,核心的数据采集和清洗链路,到这里已经完整了。