💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# **第 22 章 文本处理** 在本章中,我们会以在第 3 章中创建的 simple_grep.rb 为基础,来学习文本的一般处理方法。 这里,我们将会创建实现以下功能的脚本。 - **获取 HTML 文件并进行简单的加工** - **查找单词并显示其出现的次数** - **强调查找结果并对输出结果进行加工** ### **22.1 准备文本** 首先是准备作为处理对象的文本。 ### **22.1.1 下载文件** 这里,我们以山形浩生翻译的 *The Cathedral and the Bazaar*1 为例进行说明,该翻译版本已在网上公开并可自由使用。这是一篇非常著名的有关开源项目(Open Source Project)开发模式的论文,有兴趣的读者不妨一读。 1中文简体版名为《大教堂与集市》,由机械工业出版社于 2014 年出版,卫剑钒译。——译者注 *The Cathedral and the Bazaar* 翻译版的 URL 如下所示。 - [http://cruel.org/freeware/cathedral.html](http://cruel.org/freeware/cathedral.html) 虽然也可以使用浏览器访问、下载上面的 URL,不过既然已经学习了 Ruby,下面就让我们来试试用 Ruby 下载。 **代码清单 22.1 get_cathedral.rb** ~~~ require "open-uri" require "nkf" url = "http://cruel.org/freeware/cathedral.html" filename = "cathedral.html" File.open(filename, "w") do |f| text = open(url).read f.write text # UTF-8 环境下使用此段代码 #f.write NKF.nkf("-s", text) # Shift_JIS 环境下(日语Windows)使用此段代码 end ~~~ 程序在处理日语字符的时候需要注意编码问题 2。特别是从外部的输入,全部都用一样的编码也无可非议。在日语 Windows 环境中,由于是用 Shift_JIS 编码传递字符给命令行,因此文件也采用了相同的编码。本例中的 HTML 文件的编码为 UTF-8,因此如果是日语 Windows 环境的话,就需要用 NFK 转换为 Shift_JIS,然后再用 `write` 方法输出(请修改并执行代码清单 22.1 中注释了的代码部分)。 2中文也同样需要注意。——译者注 ### **22.1.2 获取正文** 从代码清单 22.1 得到的是用于在浏览器中显示的 HTML 文件。这个文件中有很多像头部、底部这样的我们不需要的部分,因此这里我们只把正文部分抽取出来。 首先必须定义好正文的起始位置与结束位置。而为此就必须先看看 HTML 里的内容。下面,我们来好好研究一下刚才下载的 cathedral.html。 ~~~ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/ strict.dtd"> <html lang="ja"><head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta name="Author" content="Eric Raymond, YAMAGATA Hiroo"> ┊ <hr /> <h2><a name="1">1 伽藍方式とバザール方式</a></h2> <p> Linux は破壊的存在なり。インターネットのかぼそい糸だけで結ばれた、地球全体に散らばった数千人の開発者たちが片手間にハッキングするだけで、超一流の OS が魔法みたいに編み出されてしまうなんて、ほんの 5 年前でさえだれも想像すらできなかったんだから。</p> ┊ </p><hr> <h2><a name="version">バージョンと変更履歴</a></h2> <p> $Id: cathedral-bazaar.sgml,v 1.40 1998/08/11 20:27:29 esr Exp $</p> ┊ ~~~ 通过上面的内容可以看出,以“`<h2><a name="1">1 伽藍方式とバザール方式</a></h2>`”开始的行就是正文的开始。 同样,从“`<h2><a name="version"> バージョンと変更履歴 </a></h2>`”这一行开始则为底部,之后的内容与正文无关。 然后,对这两行做上标记,把正文部分摘取出来。 **代码清单 22.2 cut_cathedral.rb** ~~~ 1: htmlfile = "cathedral.html" 2: textfile = "cathedral.txt" 3: 4: html = File.read(htmlfile) 5: 6: File.open(textfile, "w") do |f| 7: in_header = true 8: html.each_line do |line| 9: if in_header && /<a name="1">/ !~ line 10: next 11: else 12: in_header = false 13: end 14: break if /<a name="version">/ =~ line 15: f.write line 16: end 17: end ~~~ 在这个脚本中,以包含字符串 `<a name="1">` 的行作为开始,包含字符串 `<a name = "version">` 的行作为结束,把中间的内容保存到 catedral.txt 文件中。 首先,使用 `File.read` 方法读取 HTML 文件的全部内容。 接下来,对 HTML 文件的字符串使用 `each_line` 方法,逐行读取内容并赋值给变量 `line`,然后再将其保存到文件中。不过,在保存之前,需要先把 `in_header` 变量设为 `true`。这个变量可被用于检查正在处理的行是否为头部。第 9 行的 `if` 语句会利用这个变量值进行判断,如果是在头部内,并且正在读取的行不包含 `<a name="1">` 则跳出本次循环。而除此以外的情况下,则表示已经离开了头部部分,因此就要将 `in_header` 设置为 `false`。这样一来,从下个循环开始就不会再执行 next 了。 第 14 行中使用了 `if` 修饰符。break if…这样的形式常被用于跳出循环。这种写法的优点在于可以紧凑地书写不太长的 `if` 条件。在这里,程序会判断是否为表示正文结束的行,成功匹配则跳出循环。 接下来是第 15 行,程序能走到这里就证明 `line` 是正文部分,因此,使用 `write` 方法将 `line` 的内容输出到文件。 ### **22.1.3 删除标签** 但是输出到文件的正文部分中还残留着 HTML 标签(tag)。虽然即使有 HTML 标签也可以进行文本处理,但在本章中并不需要标签。因此接下来我们就来考虑一下如何把标签删除,以获取纯文本(plain text)格式的文件。 一般情况下,删除 HTML 标签,可以考虑使用解析 HTML 用的类库,不过在本例中,我们只是单纯地通过正则表达式来置换。 **代码清单 22.3 cut_cathedral2.rb** ~~~ 1: require 'cgi/util' 2: htmlfile = "cathedral.html" 3: textfile = "cathedral.txt" 4: 5: html = File.read(htmlfile) 6: 7: File.open(textfile, "w") do |f| 8: in_header = true 9: html.each_line do |line| 10: if in_header && /<a name="1">/ !~ line 11: next 12: else 13: in_header = false 14: end 15: break if /<a name="version">/ =~ line 16: line.gsub!(/<[^>]+>/, '') 17: esc_line = CGI.unescapeHTML(line) 18: f.write esc_line 19: end 20: end ~~~ 代码清单 22.3 在代码 22.2 的基础上添加了删除标签的功能,而实际两者只有第 16 行和第 17 行代码是不一样的。 在第 16 行中,用正则 表达式 `/<[^>]+>/` 表示标签。由于 HTML 标签是以 `<` 开始,以 `>` 结束的,因此这样就可以匹配标签部分。在第 17 行中,使用 `CGI.unescapeHTML` 方法,将 HTML 标签的 `&amp;`、`&lt;` 等转义字符,转换为普通字符 `&`、`<` 等。通过在第 1 行追加 `require 'cgi/util'`,我们就可以使用这个方法了。 做出这样的修改后,我们就可以得到以下文本: ~~~ 1 伽藍方式とバザール方式 Linux は破壊的存在なり。インターネットのかぼそい糸だけで結ばれた、地球全体に散らばった数千人の開発者たちが片手間にハッキングするだけで、超一流の OS が魔法みたいに編み出されてしまうなんて、ほんの 5 年前でさえだれも想像すらできなかったんだから。 ┊ ~~~ ### **22.2 扩展 simple_grep.rb :显示次数** 接下来,我们来看看 simple_grep.rb。这里对第 3 章中的例子稍微做了一点修改(代码清单 22.4)。 **代码清单 22.4 simple_grep.rb** ~~~ pattern = Regexp.new(ARGV[0]) filename = ARGV[1] File.open(filename) do |file| file.each_line do |line| if pattern =~ line print line end end end ~~~ 由于我们在 `File.open` 方法中使用了块,因此 `File#close` 就不再需要了,这样一来,程序也清爽了好多。 利用这个程序,我们来调查一下正文中的“伽藍”3 以及“バザール”4 这两个单词总共出现了多少次。 3中文是“大教堂”的意思。——译者注 4中文是“集市”的意思。——译者注 ### **计算匹配行** 由于通过 simple_grep.rb 匹配的行会原封不动地显示出来,因此 Mac OS X 或 Linux 的情况下,就可以配合 wc 命令 5 查看文本的行数。 5wc 即 word count 的缩写。——译者注 > **执行示例** ~~~ > ruby simple_grep.rb ' 伽藍' cathedral.txt | wc 20 79 4352 > ruby simple_grep.rb ' バザール' cathedral.txt | wc 38 122 8376 ~~~ 但是,我们不能确切地说正文中“伽藍”出现了 20 次,这是因为如果 1 行中该单词出现了多次的话,那么只通过计算行数是不能得出正确的结果的。 下面我们来改造这个程序,用 `String#scan` 方法,计算匹配的次数。 **代码清单 22.5 simple_scan.rb** ~~~ pattern = Regexp.new(ARGV[0]) filename = ARGV[1] count = 0 File.open(filename) do |file| file.each_line do |line| if pattern =~ line line.scan(pattern) do |s| count += 1 end print line end end end puts "count: #{count}" ~~~ > **执行示例** ~~~ > ruby simple_scan.rb ' 伽藍' cathedral.txt 1 伽藍方式とバザール方式 みたいな本当に大規模なツール)は伽藍のように組み立てられなきゃダメで、一人のウィザードか魔術師の小集団が、まったく孤立して慎重に組み立てあげるべ ┊ のほうは、閉じた開発者グループとめったにないリリースとでもっと伽藍的な開発方式を続けたということだった。 count: 21 > ruby simple_scan.rb ' バザール' cathedral.txt 1 伽藍方式とバザール方式 コミュニティはむしろ、いろんな作業やアプローチが渦を巻く、でかい騒がしいバザールに似ているみたいだった(これをまさに象徴しているのが ┊ が意識的に、ぼくがこれまでに記述してきたバザール戦術を用い、それに対して GCC count: 43 ~~~ 结果显示,“伽藍”出现了 21 次,“バザール”出现了 43 次。 如果不需要逐行处理,而只是单纯计算出现次数的话,则有更简单的实现方法(代码清单 22.6)。 **代码清单 22.6 simple_count.rb** ~~~ pattern = Regexp.new(ARGV[0]) filename = ARGV[1] count = 0 File.read(filename).scan(pattern) do |s| count += 1 end puts "count: #{count}" ~~~ 由于 `String#scan` 方法是对字符串使用的方法,因此,本例中没有使用 `File.open` 方法,而是通过 `File.read` 方法一次性读取了所有内容并将其保存为了字符串。 ### **22.3 扩展 simple_grep.rb :显示匹配的部分** 下面,让我们再次回到原来的 simple_scan.rb 来继续进行改造。 ### **22.3.1 突出匹配到的位置** 虽然显示出了匹配的行,但具体匹配到的位置却很难看清楚。因此下面我们就来试试在显示的时候强调匹配的部分(代码清单 22.7)。 **代码清单 22.7 simple_match.rb** ~~~ 1: pattern = Regexp.new(ARGV[0]) 2: filename = ARGV[1] 3: 4: count = 0 5: File.open(filename) do |file| 6: file.each_line do |line| 7: if pattern =~ line 8: line.scan(pattern) do |s| 9: count += 1 10: end 11: print line.gsub(pattern){|str| "<<#{str}>>"} 12: end 14: end 15: end 16: puts "count: #{count}" ~~~ 在第 11 行,使用 `gsub` 方法将原来直接输出变量 `line` 的地方进行转换后再输出。由于对 `gsub` 方法使用块后,匹配部分就可以通过块变量取得,因此这里在匹配部分的前后加上 `<<>>` 并返回。 执行结果如下所示: > **执行示例** ~~~ > ruby simple_match.rb ' 伽藍' cathedral.txt 1 << 伽藍>> 方式とバザール方式 みたいな本当に大規模なツール)は<< 伽藍>> のように組み立てられなきゃダメで、一人のウィザードか魔術師の小集団が、まったく孤立して慎重に組み立てあげるべ ┊ のほうは、閉じた開発者グループとめったにないリリースとでもっと<< 伽藍>> 的な開発方式を続けたということだった。 count: 21 ~~~ 可以看出,与之前相比,匹配部分被突出显示了。 ### **22.3.2 显示前后各 10 个字符** 然而,在上述 simple_match.rb 的执行结果中,匹配部分在行中的位置比较分散,这时我们就希望显示效果能更加紧凑些。例如显示匹配部分及其前后各 10 个字符(代码清单 22.8)。 **代码清单 22.8 simple_match2.rb** ~~~ pattern = Regexp.new("(.{10})("+ARGV[0]+")(.{10})") filename = ARGV[1] count = 0 File.open(filename) do |file| file.each_line do |line| line.scan(pattern) do |s| puts "#{s[0]}<<#{s[1]}>>#{s[2]}" count += 1 end end end puts "count: #{count}" ~~~ 正则表达式中的 {n} 表示重复之前的模式 n 次。因此,本例中的 `.{10}` 就表示匹配 10 个任意字符。除此以外,还可以用 {n,m} 匹配 n 次以上 m 次以下,用 {n,} 匹配 n 次以上,用 {,m} 匹配 m 次以下。 接下来,我们来看看效果如何。 > **执行示例** ~~~ > ruby simple_match2.rb ' 伽藍' cathedral.txt に大規模なツール)は<< 伽藍>> のように組み立てられ された。静かで荘厳な<< 伽藍>> づくりなんかない―― 、それどころかなぜ、<< 伽藍>> 建設者たちの想像を絶 F ツールみたいな、<< 伽藍>> 建築方式にくらべると ┊ count: 12 ~~~ 通过 `count: 12` 可以看出,出现的次数减少了。这是因为匹配部分前后不够 10 个字符时就不会匹配。另外,即使是 10 个字符,英文数字(ASCII)的字符长度与日语字符也不一样,这就导致了输出结果排列不整齐。因此我们再来修改一下(代码清单 22.9)。 **代码清单 22.9 simple_match3.rb** ~~~ 1: pattern = Regexp.new("(.{0,10})("+ARGV[0]+")(.{0,10})") 2: filename = ARGV[1] 3: 4: count = 0 5: File.open(filename) do |file| 6: file.each_line do |line| 7: line.scan(pattern) do |s| 8: prefix_len = 0 9: s[0].each_char do |ch| 10: if ch.ord < 128 11: prefix_len += 1 12: else 13: prefix_len += 2 14: end 15: end 16: space_len = 20 - prefix_len 17: puts "#{" "*space_len}#{s[0]}<<#{s[1]}>>#{s[2]}" 18: count += 1 19: end 20: end 21: end 22: puts "count: #{count}" ~~~ 修改程序第 1 行的正则表达式,将原来的匹配 10 个字符的 `{10}` 的地方,修改为匹配 0 个以上 10 个以下字符的 `{0,10}`。另外,在程序第 9 行以后,使用 `each_char` 方法逐个读取字符,并通过 `ord` 方法获取字符编码的码位。由于码位小于 128 时即为 ASCII 码,这时将长度加 1,除此以外的情况下则加 2,这些都是为了确定空白个数 `space_len` 以确保 20 个字符。然后再在 `s[0]` 之前留出与字符数相应的空白,这样输出结果就整齐多了。 > **执行示例** ~~~ > ruby simple_match3.rb ' 伽藍' cathedral.txt 1 << 伽藍>> 方式とバザール方式 に大規模なツール)は<< 伽藍>> のように組み立てられ された。静かで荘厳な<< 伽藍>> づくりなんかない―― 、それどころかなぜ、<< 伽藍>> 建設者たちの想像を絶 F ツールみたいな、<< 伽藍>> 建築方式にくらべると この信念のおかげで、<< 伽藍>> 建設式の開発への関与 自体が、FSF 式の<< 伽藍>> 建設型開発モデルの問 ープンな開発方針は、<< 伽藍>> 建設の正反対のものだ ここに、<< 伽藍>> 建築方式とバザール式 ┊ count: 21 ~~~ 可以看出,这次的结果整齐了许多。 ### **22.3.3 让前后的字符数可变更** 现在我们默认前后的字符只能是 10 个。不过这个数量如果能修改,那就灵活多了。于是,我们再次改造了程序(代码清单 22.10)。 **代码清单 22.10 simple_match4.rb** ~~~ 1: len = ARGV[2].to_i 2: pattern = Regexp.new("(.{0,#{len}})("+ARGV[0]+")(.{0,#{len}})") 3: filename = ARGV[1] 4: 5: count = 0 6: File.open(filename) do |file| 7: file.each_line do |line| 8: line.scan(pattern) do |s| 9: prefix_len = 0 10: s[0].each_char do |ch| 11: if ch.ord < 128 12: prefix_len += 1 13: else 14: prefix_len += 2 15: end 16: end 17: space_len = len * 2 - prefix_len 18: puts "#{" " * space_len}#{s[0]}<<#{s[1]}>>#{s[2]}" 19: count += 1 20: end 21: end 22: end 23: puts "count: #{count}" ~~~ 将指定长度的位置换为变量 `len`,可以通过 `ARGV[2]` 将其作为第 3 个参数来指定。 这里,我们指定为 5 个字符,来看看执行结果如何。 > **执行示例** ~~~ > ruby simple_match4.rb ' 伽藍' cathedral.txt 5 1 << 伽藍>> 方式とバザ ツール)は<< 伽藍>> のように組 かで荘厳な<< 伽藍>> づくりなん ろかなぜ、<< 伽藍>> 建設者たち みたいな、<< 伽藍>> 建築方式に おかげで、<< 伽藍>> 建設式の開 SF 式の<< 伽藍>> 建設型開発 ┊ とでもっと<< 伽藍>> 的な開発方 count: 21 ~~~ 从结果可以看出,前后的字符串都变为了 5 个字符。 正如本章所演示的那样,制作工具的时候,有效的做法是,首先由简单的开始,然后再慢慢地接近目标功能。即使是很难马上解决的问题,我们也可以将其细化,来逐个击破。