使用正则表达式抽取所需文本

语言是强大的力量。

引子

“给产品同学解决一个小问题” 一文中,通过 正则表达式和 sed 命令来抽取 total 值。

正则表达式是什么 ? 就像 1+1=2 可以表示 1 个苹果加 1 个苹果得到 2 个苹果一样,[0-9]{11} 可以表示 11 位数字。正则表达式是一种用于描述文本模式的形式语言,可以从文件里抽取所需的文本和信息。

本文探索用正则表达式来抽取文本的方法。主要使用 Shell 命令演示,但几乎所有的编程语言都支持正则表达式。为了不至于让读者陷入正则语言的琐碎讲解中,将直接进入示例演示,而基础知识放在文末。阅读的时候,可以先扫一遍基础知识,亦可在需要的时候去参考。

目标: 使用正则表达式 + Shell 命令快速抽取所需要的任意文本。


方法与工具

要使用正则表达式进行文本处理,需要了解几个必要方法和工具:

  • 按行处理模式。指每次从指定文件中读取一行文本进行处理,处理完成后读取下一行继续处理,直到将所有行处理完成。
  • 分解与组合。 匹配一个较长的文本时,会将该文本分割成若干小段,分别匹配每一小段,再组合起来。比如要匹配邮箱 shuqin@163.com ,可以分解为 shuqin 、 @ 、 163、 com 四段,分别匹配再组合。
  • sed & grep & awk 。正则表达式处理的三剑客。grep 通常用来搜索符合条件的行,sed 通常用于文本替换,awk 是一种模式语言,通常是匹配所需模式后做一些处理。
  • 管道 | 。 可以将上一个小程序的输出定向到下一个小程序的输入,从而将多个命令连接起来构成更强大的能力。
  • Python。 当 Shell 命令功能受限时,可以启用 Python 来搞定。

示例

找到符合条件的行

先给一个文本 dream.txt。 使用 cat dream.txt 可以查看文本内容:

I would like painting 100 stars in the sky.
I would like to resist 1001 rules that oppress people.
I would like to eat 30 boxes of tomatoes.
I want to have a long long sleep.

第一个小任务:找到含有数字的行。怎么办 ?制定策略:

  • 知道数字的识别。 \d , [0-9], [:digit:] 均可识别数字,在表达式中可以相互替换。通常用 \d 节省键盘敲击量,使用 [0-9] 或 [:digit:] 兼容性更强。因为部分终端可能不支持 \d 。这里统一使用 [0-9]。
  • 找到匹配行。使用 grep "RE_COND" file 可以在文件 file 找到符合 RE_COND 描述条件的行

结合起来,就得到了命令:

grep "[0-9]" dream.txt 
grep "\d" dream.txt 

抽取所需文本

找到了含有数字的行, 第二个小任务:抽取出这些数字。制定策略:

  • 使用每行处理模式。只要找到处理一行的方法,就可以了。
  • 识别多个数字。可以使用 [0-9]+ 表示一个或多个数字
  • 将数字捕获并输出到结果,将非数字替换为空。使用 sed 命令实现替换: sed 's/非数字(数字序列)非数字/(数字序列的引用符)/g' 。非数字用 [^0-9] 。[^range] 是一种范围排除性匹配,表示匹配出除 range 指定的所有字符之外的其它字符。

使用 sed -E 's/^[^0-9]*([0-9]+)[^0-9]*$/\1/g' 可达到目的。^ 标识文本的起始, $ 标识文本的结束, * 表示任意多个^[^0-9]*([0-9]+)[^0-9]*$ 的意思是,从文本开始,经过任意个非数字字符,然后遇到数字并捕获,再经过任意个非数字字符,走到文本的结束。\1 标识被捕获的第一个分组,也就是数字部分。

结合起来:

grep "[0-9]" dream.txt | sed -E 's/^[^0-9]*([0-9]+)[^0-9]*$/\1/g'

提示: -E 表示使用扩展的正则表达式,表达力更强一些。不同的终端,选项有所不同。有的是 -r ,有的是 -E 。可以 man sed 来查看选项说明。

找到一行的多个匹配

可以使用 cat dream.txt | tr '\n' ' ' > dream_single.txt 将 dream.txt 中的换行符替换成空格,得到单行文本 dream_single.txt :

I would like painting 100 stars in the sky. I would like to resist 1001 rules that oppress people. I would like to eat 30 boxes of tomatoes. I want to have a long long sleep.

第三个小任务:抽取单行文本中的所有数字。想想怎么做 ?

按照第二个小任务的做法,只要稍作扩展即可。有几个数字就添加个 ([0-9]+)[^0-9]* 。可以说笨拙又高效。

sed -E 's/^[^0-9]*([0-9]+)[^0-9]*([0-9]+)[^0-9]*([0-9]+)[^0-9]*$/\1 \2 \3/g' dream_single.txt

不过,现实常常是,我们并不知道有多少个数字。可能有 100 个, 可能没有。上述方式不够灵活。如何能够自动抽取所有数字呢?

使用 sed -E 's/[^0-9]*//g' dream_single.txt 会得到 100100130 ,空格没了,数字都挤到一块了,不符合期望;使用 sed -E 's/[^0-9]*/ /g' dream_single.txt 会得到 1 0 0 1 0 0 1 3 0,空格太多,原来的数字被分割了,也不符合期望。怎么办呢 ?

再想想抽取的含义:

  • 将所需要的信息捕获并使用引用在结果中保留;
  • 所需要的多条匹配信息必须分割来;可使用空格分开;
  • 将不需要的信息替换为空;但是空格不替换。

这样,可以使用 sed -E 's/[^0-9 ]*//g' dream_single.txt 实现第二三点;由于空格太多也不好,可以使用 sed -E 's/[[:space:]]+/ /g' 将多个空白符合并为一个空格符。两个替换动作可以合并写为:

sed -E 's/[^0-9 ]*//g;s/[[:space:]]+/ /g' dream_single.txt

找到某个匹配

上一个小任务,找到了指定条件的所有匹配;如果要找到某个匹配呢 ?

第四个小任务:匹配 boxes 之前的数字。

要完成这个任务,需要将所需要的信息进行更精确的识别。现在要拿到的是 boxes 之前的数字,可以将 boxes 作为一个识别参照物,在模式中加入所需要捕获的数字与该字符串之间的位置关系: ([0-9]+)[^0-9]*boxes 。[^0-9]* 表示不关心 boxes 与之前的数字之间有什么非数字的东西(可能有可能没有)。

sed -E 's/^.*[^0-9]+([0-9]+)[^0-9]*boxes.*$/\1/g' dream_single.txt

为什么要在 ([0-9]+) 之前加 [^0-9]+ 这个呢 ? 读者可以思考下。由于正则表达式的默认贪婪匹配模式,如果不加这个非数字的限制,.* 会把所需要的数字吃掉,只剩一个 0 。 . 是万能通配符,可以匹配任意字符

可以把 boxes, [^0-9]+ 这样的字符序列称为”屏障“,因为它们标识出我们要取的文本旁边有什么。就像打车时说附近有一座支付宝大楼一样。

应用

通用抽取器

假设有如下单行文本,要析取出其中的所有 total 。

{"result":true,"code":0,"message":null,"data":{"success":true,"code":200,"message":"successful","requestId":null,"errorData":null,"data":{"orderNos":["2020030316177500001"],"total":19}}}{"result":true,"code":0,"message":null,"data":{"success":true,"code":200,"message":"successful","requestId":null,"errorData":null,"data":{"orderNos":["2020030113422700003","2020030113283300009"],"total":8}}}{"result":true,"code":0,"message":null,"data":{"success":true,"code":200,"message":"successful","requestId":null,"errorData":null,"data":{"orderNos":["2020030522331200009"],"total":58}}}

目前能描述的是想要的信息 (total.*[0-9]+)

第一种策略是,想办法将单行转换成多行相似格式的文本,然后按行处理。比如:

sed 's/}{/};{/g' result.txt| tr ';' "\n" | sed -E 's/^.*total.*[^0-9]+([0-9]+).*$/\1/g'

其中 sed 's/}{/};{/g' result.txt| tr ';' "\n" 在多个相似的文本之间插入换行符,从而将单行文本转换成多行文本,每次只需要解析一个 total 即可。不过,这只是绕开了问题。

第二种策略,如上一节所述,将“不需要的信息替换成空”。不过,与上面的数字范围可以取反不同,对整个正则表达式取反并不简单。

第三种策略,采用正向思维,捕获所有需要的文本。 由于 sed 没法输出所有捕获的分组,因此,可采用 Python 编写一个较通用的正则析取器。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
import re

def extract(text, regex):
    pat = re.compile(regex)
    matches = pat.findall(text)
    # print 'matches: ',  matches
    if matches:
        for m in matches:
            print m

if __name__ == '__main__':
    args = sys.argv
    regex = args[1]
    file = args[2]
    print 'file: ', file, ' regex: ', regex

    text = ''
    with open(file, 'r') as f:
        text = f.read()

    extract(text, regex)

使用 python reg.py '"total":([0-9]+)' result.txt 即可。

这里将文件参数放在后面是有深意的。 这样,就可以与 xargs 连用,批量处理文件了。 比如 如下命令,可以批量处理当前目录下的所有后缀为 .txt 的文件。

ls *.txt | xargs -I {} python reg.py '"total":([0-9]+)' {}

话说回来,正则表达式,通常用来从不规则的文本里抽取信息,对于规则结构的数组、JSON ,使用合适的 JSON 模块来处理更适宜。


日志解析

下面再给一个从日志中抽取示例。从日志中抽取信息,是正则表达式的一大用武之地。给定一个应用日志文件: app.log , 从中抽取出 state-inconsistency 后面的所有订单。

———————————————————————————————————————1—————————————————————————————————————————
日志概况: 2020-03-06 00:02:50    bd-prod-trade-xxx-kw5r9/10.232.6.144    warn
日志内容: c76e173b3e2a4E59a0f0c31c222c9323  c.y.t.t.delay_compare-tc_order_compare - state-inconsistency: E20200305235232091500001

———————————————————————————————————————2—————————————————————————————————————————
日志概况: 2020-03-06 00:02:08    bd-prod-trade-xxx-kw5r9/10.232.6.144    warn
日志内容: 24ff9ff23be34d15af44bc7f9c8dfe43   c.y.t.t.delay_compare-tc_order_compare - state-inconsistency: E20200305235121088800001

可以用如下命令来解决。 grep 先拿到不一致的那些行, 然后 cut 用 -d : 指定冒号来分割一行文本,分割出 3 个子串,-f 取到第 3 个字段。 sed 将前面的空格去掉。

grep 'inconsis' app.log | cut -f 3 -d ':'  | sed 's/ //g'

不过,每次记这么长的命令,还得记住不一致的订单在第几个冒号后,也挺有负担的。 使用上述找到所有匹配的方法,可以得到:

grep 'inconsis' app.log | sed -E 's/^.*tency: *(E[0-9]+) *$/\1/g'

不过,即使比较有经验的人,要写出 s/xxx/yyy/g 里的 xxx ,也不是那么容易的。 因此,还是用通用解析器:

python reg.py "(E[0-9]+)" app.log

这样,会解析出 E59 这样不期望的字符。需要去掉。可以加一个”屏障标识“: \b ,这样就能得到所需结果。 \b 标识 E 左边的应是符号而不是单词。它是一种位置标识。位置标识就好比打车时告诉附近有个什么标志性建筑一样。

python reg.py "\b(E[0-9]+)" app.log

基础知识

这里给出一些常用的知识点:

  • 普通字符: 除 *, ? , , (, ), [, ], -, . , +, ^, $, {, } , | , 以外的字符匹配它自身, 比如 a 匹配 a

  • 点号 . : 匹配任意不包括换行符的单个字符。比如, sa. 可匹配 sat, sa*, sa[ 等。

  • 字符组[characters] :匹配字符组中指定字符集合中的任意单个字符: 比如 [abc] 将匹配 a 或 b 或 c , ca[ptb] 将匹配 cap, cat 或 cab.

  • 排除性字符组[^characters] : 匹配字符组中指定字符集合之外的任意单个字符: 比如, [^abc] 将匹配除了 a,b,c 之外的任意单个字符。 ca[^ptb],将匹配 caX 的文本,除了 cap, cat, cab, 注意,这里是匹配一个非指定的字符,而不是不匹配。

  • 范围字符组: [char1-char2] 将匹配从char1 到 char2 之间的任意单个字符(按照ASCII编码). 比如, [a-z] 匹配任意小写字符; [A-Z] 匹配任意大写字符 ; [0-9] 匹配任意数字; [a-zA-Z0-9] 匹配任意大小写字符或数字

  • 特殊字符: 凡是在正则式中具有特殊含义的字符,要匹配字符本身(将其作为普通文本)都必须使用反斜线 \ 进行转义;通常需要转义的字符有: . + * ? { } [ ] ( ) - \ ^ $ | 比如, 匹配 . 的正则式是 . , 匹配 \ 的正则式是 \ , 匹配 ( 的正则式是 ( ;要匹配 (ab) 的正则表达式是 (ab) ; 要匹配 a? 的表达式是 a? ; a? 将匹配空或单个a

  • 匹配空白字符: \s 匹配任意空白符

  • 字符类: <> 等价于; \d <> [0-9] 任意单个数字 ; \w <==> [a-zA-Z0-9_]

  • 顺序结构 XY: 匹配 X 后紧跟 Y 的文本,比如 [0-9][a-z] 匹配 数字后跟小写字母的文本, 7z, 0x 等, 但不匹配 ap, 77

  • 多选分支结构X|Y: 匹配 X 或者 Y ,比如 [0-9]|[a-z] 匹配数字或小写字母,相当于 [0-9a-z]。

  • 匹配一个或多个 X : X+ ; 例如 s/d+ 匹配 s后跟至少一个数字, s9, s34, s235, ...

  • 匹配零个或多个 X : X* ; 例如 s/d* 匹配 s后跟空或者至少一个数字, s, s9, s34, s235, ...

  • 匹配零个或一个 X : X? ; 例如 https? 匹配 http 或 https

  • 子表达式 (X): 将 X 作为一个子表达式,紧邻的匹配量词将作用于 X 整体,而不是 X 中的单个字符。例如 (s\d){3} 匹配 s1s1s1 ,而不匹配 s111

捕获功能: 使用括号将一个子表达式匹配的文本进行捕获,后面可在模式或处理中对捕获的文本进行引用或处理。

((regex1)-(regex2))-(regex3) \n 引用被捕获的第 n 个文本; n 按左括号出现的顺序进行标识 \0 表示匹配的整个文本 ;\1 ref= ((regex1)-(regex2)) ; \2 ref= (regex1) ; \3 ref= (regex2) ; \4 ref= (regex3) 在替换文本时可以使用 $1, $2, $3, $4 分别引用 \1, \2, \3, \4 捕获的文本。 比如, (\d{3}) mygod(\1) 必须匹配 三位数字mygod三位数字 的模式, 并且,后面三位数字必须与前面三位数字完全相同。


小结

正则表达式是一种用于文本模式匹配和文本处理的灵活有效的语言,也是一种很有趣的小型领域语言。学习正则表达式会是一个有趣的经历。

关联文章:

posted @ 2020-03-08 16:22  琴水玉  阅读(8417)  评论(0编辑  收藏  举报