多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python批量修改HTML:正则与BeautifulSoup的选型与实践

Python批量修改HTML:正则与BeautifulSoup的选型与实践 2. 需求画像与两种主流实现路线先说结论这个需求本身不复杂但如果你直接拿正则去改HTML八成会在两周后收到“页面乱了”的反馈。原因很简单——HTML不是正则友好的文本格式。它有嵌套结构、自闭合标签、注释节点、动态渲染的模板语法甚至还有编码问题。我见过太多人用re.sub一把梭最后把div插进了script标签里直接在浏览器里炸出一片红色报错。在动手写代码之前最快半小时能写完的东西。真正要花时间的是想清楚你手里这批HTML文件是什么状态我通常会把场景分成三类第一类是纯静态文件没有后端模板引擎介入就是一个完整的html到/html。这种最简单用解析器就能搞定。第二类是模板文件比如Jinja2、Django Template或者Vue的SFC里面会有{% block %}、{{ variable }}或者template这类特殊语法。如果你用标准的BeautifulSoup去解析它会把这些当普通文本处理有可能误伤结构。第三类是HTML片段就是一个.html文件里只有几行div没有完整的文档头。这种用BeautifulSoup反而容易出问题因为它会自动补全htmlbody改完再输出会多出一堆你没写过的标签。对应这三类场景实现路线其实就两条正则表达式 字符串处理适合模板文件、片段文件或者你明确知道插入位置的文本特征极其稳定比如/body前、head里、某个固定id的div后面。BeautifulSouplxml适合完整静态页面结构可能千奇百怪需要按节点关系定位或者需要处理嵌套层级。下面我分别把这两条路线的坑和细节拆开讲。先说BeautifulSoup方案因为它更通用但坑也更多。3. 基于 BeautifulSoup 的批量插入方案3.1 为什么选 BeautifulSoup 而不是直接拼字符串很多人第一反应是“不就插入个div吗字符串拼接多简单”。但你要处理的是几十上百个文件每个文件的缩进风格、属性单双引号、标签换行方式都不同。字符串拼接要求你精确匹配到“插入点”而HTML里同一个位置可能有无数种写法/body可能是/body也可能是/body 还可能是/body\n/html甚至是大写/BODY。更麻烦的是你可能要插入的位置根本没有明确的锚点标签而是“在第三个section之后”。BeautifulSoup把这些差异全部屏蔽掉了。它把HTML解析成一棵树你通过节点关系找位置改完再序列化输出。解析器用的是lxml对标准HTML的容错性很高哪怕原文件里标签没闭合、属性没引号它都能尽量还原成一个合理的树结构。3.2 安装与基础用法首先安装库。我用pip装beautifulsoup4和lxml注意lxml在某些Python版本下需要预编译包Windows用户建议直接用pip install lxml一般能装上。pip install beautifulsoup4 lxml读文件的时候有个细节一定要加上编码参数。HTML文件最常见的编码是utf-8但国内很多老项目是gbk或gb2312。如果你不指定编码Python会用系统默认编码去读Windows下通常是gbk读utf-8的文件就会直接抛UnicodeDecodeError。我习惯统一用utf-8读遇到报错再回退。from bs4 import BeautifulSoup def read_html(path): # 优先尝试 utf-8 try: with open(path, r, encodingutf-8) as f: return f.read() except UnicodeDecodeError: # 回退到 gbk很多老文件是这种编码 with open(path, r, encodinggbk, errorsignore) as f: return f.read()HTML字符串拿到之后创建解析树soup BeautifulSoup(html, lxml)这里第二个参数我强制指定lxml不要用默认的html.parser。因为html.parser是Python标准库自带的解析速度慢而且对某些现代HTML5标签支持不好容易把template或自定义组件标签搞乱。lxml是C扩展解析快容错也更好。3.3 三种最常见的插入场景场景一在/body前插入这是最常见的需求比如给所有页面注入一段统计脚本或一个弹窗容器。用soup.body能直接拿到body节点然后在末尾追加def insert_before_body_end(soup, html_string): div soup.new_tag(div) # 注意new_tag 只能创建空标签要加内容需要进一步操作 div.string 这里是要插入的内容 soup.body.append(div)等一下这里有个坑。soup.new_tag创建的标签如果直接设置.string内容会被HTML转义。如果我要插入的不是纯文本而是一段包含子标签的HTML字符串就不能用new_tag而是应该用BeautifulSoup再解析一次然后把节点列表追加进去。def insert_before_body_end(soup, html_fragment): fragment_soup BeautifulSoup(html_fragment, lxml) # 把片段里的所有 body 子节点取出来 nodes list(fragment_soup.body.children) if fragment_soup.body else list(fragment_soup) for node in nodes: soup.body.append(node)这样就能把一段复杂的HTML片段追加到body末尾了。注意fragment_soup.body.children拿到的是生成器先转成list再遍历因为append操作会改变源文档树边遍历边修改会漏掉节点。场景二在某个特定节点后面插入比如我要在所有classarticle-content的div后面插入一个相关推荐模块。写法如下def insert_after_node(soup, target_class, html_fragment): for target in soup.select(f.{target_class}): fragment_soup BeautifulSoup(html_fragment, lxml) nodes list(fragment_soup.body.children) if fragment_soup.body else list(fragment_soup) for node in nodes: target.insert_after(node)这里用select方法传CSS选择器定位非常灵活。insert_after是BeautifulSoup内置的方法能把一个或多个节点插到当前节点后面。注意如果目标节点有多个每个目标后面都会插一份要确认这是你要的效果。场景三在head里插入meta或link用于注入viewport或description等。跟body的追加逻辑类似但我提醒一句head里有些标签是有严格顺序要求的比如meta charset必须在最前面。如果你插入的是meta标签最好用insert方法指定位置而不是简单append。比如插入到head的第一个子节点位置def insert_meta_first(soup, meta_attrs): meta soup.new_tag(meta, **meta_attrs) if soup.head: soup.head.insert(0, meta)3.4 批量处理文件列表拿到一批文件最稳妥的批量逻辑是先收集所有文件路径再逐个处理处理结果先写到临时文件全部成功后再替换原文件。为什么要这样因为如果处理到第10个文件时第9个文件已经被覆盖了而脚本因为编码问题报错退出你前面的文件就被改成半成品了还没法回滚。稳妥做法是先全部验证一遍可读性再一个个写。from pathlib import Path def batch_insert_html(file_pattern./html/*.html): files list(Path().glob(file_pattern)) for html_file in files: content read_html(html_file) soup BeautifulSoup(content, lxml) insert_before_body_end(soup, !-- 批量注入的div --div classinjected-block.../div) # 输出时保持统一缩进 output str(soup) # 写回先写临时文件 tmp_file html_file.with_suffix(.html.tmp) with open(tmp_file, w, encodingutf-8) as f: f.write(output) tmp_file.replace(html_file)写到这儿你可能已经发现str(soup)的问题了。下一节我就讲这个最隐蔽的坑格式化输出。3.5 输出时的格式问题BeautifulSoup默认的str(soup)输出会把所有节点的嵌套结构原样输出但它不会帮你美化缩进。如果你的原文件是手工缩进得很整齐的HTML经过soup解析再输出缩进可能全乱了。比如原本div classa div classb内容/div /div解析后输出可能是div classa div classb内容/div/div这不会影响浏览器渲染但会影响后续维护者的心情也会让你的代码diff变得巨大。如果你在意格式可以用soup.prettify()它会重新排版整个文档。但注意prettify()会改变原有格式把单行压缩的属性展开每个属性各占一行体积变大不说还容易引入无用diff。我通常的做法是只在插入节点自己带复杂结构时手动把该节点的代码格式化好其余原文件内容不动。不过说实话BeautifulSoup对原文件的“原始格式化”保留程度其实有限因为lxml解析之后文本节点里的换行和缩进本身就是按文本处理的。如果你对格式要求极其苛刻那就得走正则方案,那个能精确控制原文不动。3.6 BeautifulSoup 方案的适用边界我得泼一盆冷水BeautifulSoup并不适合所有HTML。遇到下面三种情况解析器方案会直接翻车包含模板语法。文件里有{{ variable }}或{% if %}BeautifulSoup会把这些当成普通文本但它解析时如果遇到{% raw %}这类模板标签lxml可能会误判标签结构导致后续插入的位置错乱。文件极大。几十万行的HTMLlxml解析会占不少内存而且prettify极其耗时。我曾经处理过一个20MB的网页快照prettify跑了快十秒体验很差。需要精确控制。比如我只想在HTML源码里“原文原样”地插入一段文本其他人的改动一个字符都不允许。这种情况任何解析器都做不到因为解析和重新序列化本身就是一种转换。这时候就轮到正则方案上场了。4. 基于正则表达式的轻量方案4.1 什么时候必须用正则我用正则方案的场景很明确模板文件和对原文格式零容忍的项目。比如一个Jinja2模板里面有{% extends base.html %}有{{ url_for(...) }}这些语法在BeautifulSoup眼里是文本但它内部的{% if %}块里可能含有HTML标签结构上跟外面是平级的。一旦解析后再输出模板标签的位置和顺序可能被调整而Jinja2对标签顺序极其敏感微小的变化就会导致模板渲染报错。正则方案的核心思路是把HTML当作文本处理用模式匹配找到“插入锚点”然后做字符串替换。完全不解析所以原文除了你主动插入的部分其他所有字节都保持原样。4.2 最常用的三个正则可复用片段1. 在/body前插入import re def insert_before_body_end_regex(html, injection): # 匹配 /body不区分大小写允许标签前后有空白 pattern re.compile(r(/body\s*), re.IGNORECASE) return pattern.sub(injection r\1, html)用一个捕获组先把/body保留下来替换时在它前面插入内容。注意\1引用的是原来匹配到的闭标签不管它是大写还是小写都能原样保留。2. 在head标签的之后插入def insert_into_head_regex(html, injection): # 匹配 head 或 head attr... 的结束 并捕获 pattern re.compile(r(head\b[^]*), re.IGNORECASE) return pattern.sub(r\1 injection, html)head\b[^]*表示匹配head标签开标签\b确保不匹配header[^]*匹配任意属性直到结束。替换时先把整个开标签原样保留再紧跟着插入内容。3. 在指定class的div结束标签后插入def insert_after_div_class_regex(html, class_name, injection): # 匹配 div class... ... /div 的闭合标签 pattern re.compile(r(/div\s*)(?\s*), re.IGNORECASE) # 这个不够精确更好的方式是在源码里找目标串说实话这条正则用起来往往不精确。因为你要定位的是“某个特定div的结束”而不是“随便一个div的结束”。更实用两步走第一步用字符串find定位目标div的起始位置第二步在它之后找到第一个未被嵌套闭合的/div位置这个用正则做会非常复杂。因此在实际项目里如果你的需求定位到嵌套结构就不要用正则回到BeautifulSoup方案只有当锚点是/body、/head、某个有唯一id的标签直接文本时正则才是最优解。4.3 批量替换的完整脚本我把正则批量替换封装成一个小脚本它做的事情是遍历文件夹下的.html文件对每个文件做三种注入输出到dist目录。由于正则方案完全保留原文编码处理反而更简单——读的时候用二进制读替换时也用二进制替换避开编码问题。from pathlib import Path def main(): src_dir Path(src_html) dst_dir Path(dist_html) dst_dir.mkdir(exist_okTrue) injections [ (r(/body\s*), div classfloat-btn底部悬浮/div, re.IGNORECASE), ] for src in src_dir.glob(*.html): raw src.read_bytes() # 读字节不做编码转换 text raw.decode(utf-8, errorsignore) # 按utf-8解码遇到错误忽略 for pattern, injection, flags in injections: text re.sub(pattern, injection r\1, text, flagsflags) (dst_dir / src.name).write_bytes(text.encode(utf-8)) if __name__ __main__: main()这里我用read_bytes读取原始字节然后手动指定解码方式比open(text mode)更可控。errorsignore可以容忍某些坏字节但会静默丢弃数据如果你的HTML里有特殊字符这个ignore会把它吃掉。更严谨的做法是先用chardet或charset-normalizer库检测编码但那是另一个话题这里不展开。5. 处理带模板语法文件的特殊技巧5.1 模板文件为什么麻烦这里我想重点展开一下。因为很多做自动化脚本的人其实要批量改的不是静态HTML而是一个项目里的Jinja2模板或Django模板。这些模板文件用BeautifulSoup解析有风险用正则替换锚点也有风险因为你不知道模板编译后生成的HTML跟你预想的是否一致。举个例子一个Jinja2模板可能长这样{% extends base.html %} {% block content %} div classarticle {{ article.body | safe }} /div {% endblock %}如果你用BeautifulSoup去解析lxml会把{% extends base.html %}和{% block content %}这些当成普通文本节点理论上没问题。但是{{ article.body | safe }}里的|在HTML中不是特殊字符{%也不是。真正的问题在extend逻辑模板继承时block会整体替换到父模板的对应位置那么你要插入的div到底插入到当前文件的block content内部还是插入到最终渲染结果的body中这两个位置完全不同。如果插入到block content里那所有继承这个模板的页面哪里是block content哪里就插入。如果插入到最终渲染结果的body里你得去改base.html。所以改模板文件之前先搞清楚你要插入的位置是在模板的哪个层面。我踩过的坑是直接在子模板block内插入了div结果页面因为div里包裹了{% raw %}块整个布局错位。5.2 模板文件的正则插入实践处理模板文件我推荐尽量找“稳定的文本锚点”。比如在/body前插入如果base.html里肯定有/body那就改base.html而不是子模板。如果只能在子模板里插就找{% endblock %}作为锚点。比如在block content结束时插入pattern re.compile(r({% endblock %}), re.IGNORECASE) html pattern.sub(injection r\1, html)这里{% endblock %}可能带名字如{% endblock content %}所以更稳妥的正则应该是pattern re.compile(r({% endblock\s*(?:content)?\s*%}), re.IGNORECASE)(?:content)?表示“content”出现或不出现都行。5.3 模板文件中的注释节点还有一个极易踩的坑模板文件里的!-- 注释 --。在Jinja2中!-- --会被原样输出到HTML但注释里可能包含模板变量比如!-- {{ page_id }} --。如果你用正则去匹配/body即使注释里有/body字样正则也会误匹配。因为/body在注释里只是一个字符串并不会让浏览器真的关闭body但你的正则会把它当成锚点插入位置就错了。解决这个问题需要先剔除注释里的内容再做替换或者在主体替换完成后把注释里的插入内容再移除。最简便的做法是替换前先把所有!-- ... --替换成占位符替换完成后再还原。实现如下def safe_substitute(html, pattern, injection): # 用占位符暂存注释 comments [] def stash(m): comments.append(m.group(0)) return f###COMMENT{len(comments)-1}### stashed_temp re.sub(r!--.*?--, stash, html, flagsre.DOTALL) # 正常替换 stashed_temp re.sub(pattern, injection r\1, stashed_temp, flagsre.IGNORECASE) # 还原注释 for i, c in enumerate(comments): stashed_temp stashed_temp.replace(f###COMMENT{i}###, c) return stashed_temp这个技巧在处理任何“文本锚点”时会非常有用我遇到模板文件必用。6. 一个完整的自动化脚本示例静态网页前面理论讲了不少这部分我给出一个可以直接拿到项目里改着用的完整脚本。这个脚本的处理目标某个目录下的一批静态HTML页面需要做三件事在每个页面的head中插入一段meta description。在每个页面的/body前插入一个“返回顶部”的div。在正文区域用classmain-content的div结尾做锚点后面插入推荐阅读模块。我采用BeautifulSoup方案因为页面是完整静态HTML结构相对可控并且需要在指定class节点之后插入用CSS选择器比正则方便太多。from pathlib import Path from bs4 import BeautifulSoup def make_div(klass, text_content): return fdiv class{klass}{text_content}/div def process_file(path, injection_meta, injection_footer, injection_recomment): content path.read_text(encodingutf-8) soup BeautifulSoup(content, lxml) # 注入 meta if soup.head: existing soup.head.find(meta, attrs{name: description}) if existing: existing[content] injection_meta[content] else: meta soup.new_tag(meta, attrs{name: description, content: injection_meta[content]}) soup.head.insert(0, meta) # 在 body 末尾注入 if soup.body: fragment_body BeautifulSoup(injection_footer, lxml) footer_nodes list(fragment_body.body.children) if fragment_body.body else list(fragment_body) for node in footer_nodes: soup.body.append(node) # 在 main-content 后面注入推荐模块 for target in soup.select(.main-content): fragment_recomment BeautifulSoup(injection_recomment, lxml) rec_nodes list(fragment_recomment.body.children) if fragment_recomment.body else list(fragment_recomment) for node in rec_nodes: target.insert_after(node) output str(soup) path.write_text(output, encodingutf-8) print(f已处理: {path}) def batch_process(): files list(Path(pages).glob(*.html)) meta {name: description, content: 这里是统一注入的描述文本} footer div classback-to-top onclickwindow.scrollTo(0,0)返回顶部/div recomment div classrecommend-boxh3推荐阅读/h3ulli文章一/lili文章二/li/ul/div for f in files: process_file(f, meta, footer, recomment) if __name__ __main__: batch_process()这个示例里有几个我特意打磨过的细节new_tag(meta, attrs...)里attrs传参时一定要传字典不要直接写成new_tag(meta, namedescription)因为name是标签属性会被当成单值属性处理多个属性时容易踩坑。insert_after是BeautifulSoup里的“移动节点”操作但如果同一份fragment_recomment解析出来的节点被循环插到多个目标后面第二次插入时节点会从原来的位置被移到新位置不会复制。所以如果你有多个.main-content每个后面都想要一份推荐模块必须重新解析injection_recomment上面代码里我已经在循环内重新解析了避开这个坑。str(soup)输出后meta标签可能被lxml重排成一个独立标签原本的缺省属性可能被补全这是可接受的。7. 常见问题排查与经验教训7.1 插入的位置总是多一个空行这几乎是正则方案中最常见的“幻觉级”问题。你明明用re.sub注入了一段内容打开文件一看注入内容前后多了空行。原因是原文件里/body前面本身就有一个换行你注入的内容前面也带了换行最终叠加成了空行。排查方法回看原始文件字节序列确认/body前是不是\n。处理技巧正则捕获组把锚点标签连同前面的空白一起捕获然后替换时把空白挪到注入内容后面。pattern re.compile(r(\s*/body\s*), re.IGNORECASE) html re.sub(pattern, injection r\1, html)这里\s*会贪婪匹配尽可能多的空白替换时把它放到注入内容之后这样原文件里的空行就会出现在插入内容后面整体看起来就正常了。7.2 编码声明被破坏许多中文HTML文件会在head里写meta charsetgb2312你用utf-8编码写回后文件里的中文全部乱码。排查思路先看文件原始meta charset声明再决定写回编码。正则方案中可以用字节操作读字节时保留原文件字节序标记写入时用同编码。最简单粗暴的处理先检测meta charset(...)里的编码名再统一用这个编码读写。def detect_charset(html_bytes): m re.search(rbmeta\scharset[\]?([a-zA-Z0-9-]), html_bytes, flagsre.IGNORECASE) if m: return m.group(1).decode(ascii) return utf-8我用bytes正则直接从原始字节里提取避免解码后再找charset时因为编码不对而报错。7.3 用 BeautifulSoup 插入后文件里的自定义 Web Component 丢失lxml对新出的自定义标签如my-widget、custom-element默认是当作普通未知元素处理的应该保留。但如果自定义组件内部包含template标签lxml会把template当普通节点处理其内部的div可能会被错误调整层级。遇到这种情况我直接放弃BeautifulSoup改用html5lib解析器它能更忠实地保留HTML5的语义。soup BeautifulSoup(content, html5lib)注意html5lib解析速度比lxml慢很多但正确率更高。你要在批量文件很多时评估一下性能。7.4 批量处理后某个文件被破坏排查的第一步永远是把原文件留一份备份。我习惯在处理前先把整个目录复制成backup_html或者用git提交一次。这样出了问题可以对比diff快速定位是脚本逻辑错误还是源文件本身有问题。如果脚本已经改乱了没有备份就只能靠记忆还原体验很糟糕。7.5 性能优化经验当你需要处理几百个文件时性能差异会很明显。我的经验BeautifulSoup的find_all和select都可以用但如果只需要找单节点find比find_all快一点因为后者会扫描整棵树收集列表。prettify()绝不要在批量脚本中用太慢。正则替换用re.compile一次循环里复用pattern对象不要每次循环都re.compile。如果文件数量巨大考虑用多进程。BeautifulSoup解析属于CPU密集型多线程换成多进程能明显加速但要注意进程间Python GIL的问题。写回文件时不要频繁open可以先在内存中完成替换最后统一写入。8. 结语我的选择建议最后说点我个人在项目里的取舍逻辑也是写这篇最想分享的结论。当锚点是/body、/head、{% endblock %}这类极其明确的文本特征且你对原文件格式有洁癖时正则无脑上又快又稳。当你需要按CSS类名、标签关系、兄弟节点这类结构特征定位插入点时必须用BeautifulSoup因为正则去数标签嵌套是灾难。当你处理的是模板文件尤其是有{% block %}、{{ }}、template混在一起的项目时正则 注释占位符是更安全的选择。而真正决定成败的往往不是用哪个库而是你处理前有没有先备份、有没有搞清楚编码、有没有想清楚插入在哪个层级。这三个问题想明白代码本身一小时就写完了。如果你也正好在做类似“批量改文件”的脚本不妨先从备份开始。改完记得对比一两个文件确认插入位置、标签闭合、编码都没问题再放量跑。这是我在无数次踩坑之后养成的习惯希望对你有用。
返回列表