
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 数据特征拆解:我需要提取哪些字段在动手写正则之前先把目标字段列成一张表避免在代码里东一榔头西一棒子字段名示例值说明version114.0.5735.90完整版本号含主版本和子版本release_date2024-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, headersheaders, timeout15) resp.raise_for_status() # 手动确认编码如果页面meta里声明的是utf-8就强制使用 resp.encoding utf-8 return resp.text def normalize_html(raw_html: str) - str: # 将HTML实体如nbsp; amp;转换为真实字符 text html.unescape(raw_html) # 先移除注释避免里面的干扰文本被解析到 text re.sub(r!--.*?--, , text, flagsre.S) # 把多余空白压成一个空格方便正则匹配 text re.sub(r\s, , text) return text这两步的意义在于正则讲究“模式匹配”而脏空白恰恰是破坏模式的最大敌人。你可以把这一步类比成做菜前的洗菜切菜——后面一切精细操作都建立在原料干净的基础上。4. 正则解析核心如何从HTML混沌中精准定位版本信息4.1 匹配版本号的模式设计版本号是这类日志里最高频出现的模式。Chrome系版本号的典型格式是“主版本.次版本.构建号.补丁号”比如114.0.5735.90但我不能假设它永远是四段毕竟有些浏览器版本号只有三段。所以模式要兼顾“完整版”和“通用版”。我最终用的模式是version_pattern re.compile( rChrome\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 classxxx、/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(rnbsp;?, , text) text re.sub(ramp;?, , text) text re.sub(rlt;?, , text) text re.sub(rgt;?, , 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加BOMExcel才能正确识别。import csv def export_to_csv(records: list[dict], output_path: str): with open(output_path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter( f, fieldnames[version, release_date, summary, raw_text], quotingcsv.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;q0.9,en;q0.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”第一次跑脚本时我的版本号正则用的是rChrome (\d)匹配到的结果全是114、115、116这种大版本号小版本号和补丁号全丢了。这个bug在测试时很容易被忽略因为大版本号看起来“好像也没错”直到你要分析具体补丁更新时才发现数据根本无法区分。换用rChrome\s*(\d(?:\.\d){2,3})之后数据粒度才正常。7.2 日期被截断成“5月30日”我一开始的日期正则只匹配了“月日”部分因为页面上日期常常被分割成两部分显示跨标签节点。这导致提取出的日期缺少年份。解决办法就是第四章里的那样让正则同时覆盖中英文年月日完整模式并在后面补了年份缺失时的兜底逻辑。7.3 HTML实体的零宽字符贡献最阴间的一笔。我在清洗后做字符串比较时发现两条内容看似完全相同的记录一直去不了重后来打印出两个字符串的Unicode码点才发现其中一个字符串里藏着零宽空格\u200b。这种字符在页面源码里截图都看不出来却是字符串处理的地雷。加了正则清除后世界清净了。8. 后续可以怎么扩展这套脚本现在已经能稳定跑出CSV但它的价值远不止“导出一次”。我后来在这个基础之上做了两个小扩展思路分享给大家一个是在脚本里加了一个“增量检测”逻辑。每次运行之后把最新的版本号存进一个小配置文件下次运行如果检测到版本号变了就自动生成一条提醒消息这样就不用每次跑完再手动翻CSV确认有没有新版本。另一个是把CSV进一步拆成了带摘要的Markdown列表。更新日志纯文本有时候太长尤其是某个版本修复了十几个问题的时候导出到Excel里要滚动很久。我增加了一个函数把每条summary里的第一条说明抽取出来作为“一句话摘要”生成一个精简版报表方便快速浏览。如果还想做更重的分析还可以把CSV导入数据可视化工具按时间轴画版本发布频率图、统计两次更新之间的间隔天数。这些都是锦上添花的事核心的数据采集和清洗链路到这里已经完整了。