ようじょのおえかきちょう

ふぇぇ お医者さんにペン持ったらダメっていわれた〜〜

言語処理100本ノック2015 をRubyでやる【第3章】

コードは GitHub に随時上げていきます。この記事では省略した長い出力も output/ ディレクトリに置いてます。

github.com

今回は第 3 章「正規表現」です。

Wikipediaの記事を以下のフォーマットで書き出したファイルjawiki-country.json.gzがある.

  • 1行に1記事の情報がJSON形式で格納される
  • 各行には記事名が"title"キーに,記事本文が"text"キーの辞書オブジェクトに格納され,そのオブジェクトがJSON形式で書き出される
  • ファイル全体はgzipで圧縮される 以下の処理を行うプログラムを作成せよ.

20. JSONデータの読み込み

Wikipedia記事のJSONファイルを読み込み,「イギリス」に関する記事本文を表示せよ.問題21-29では,ここで抽出した記事本文に対して実行せよ.

解答

# util.rb

require 'zlib'
require 'json'

def gzip2hash(filename: 'jawiki-country.json.gz')
  articles = {}

  Zlib::GzipReader.open(filename) do |lines|
    lines.each do |line|
      article = JSON.parse(line)
      articles[article['title']] = article['text']
    end
  end

  articles
end

共通で使うメソッドとかを util.rb にまとめています。gzip は展開せずそのまま読みましたが、予め展開してても良さそうです。また以降の問題で イギリス の出力しか使わないので、そこだけ別ファイルに保存して読み込むのも良さそうです。

# 20.rb

require './util'

puts gzip2hash["イギリス"]

出力(一部省略)

{{redirect|UK}}
{{基礎情報 国
|略名 = イギリス
|日本語国名 = グレートブリテン及び北アイルランド連合王国
|公式国名 = {{lang|en|United Kingdom of Great Britain and Northern Ireland}}<ref>英語以外での正式国名:<br/>
*{{lang|gd|An Rìoghachd Aonaichte na Breatainn Mhòr agus Eirinn mu Thuath}}([[スコットランド・ゲール語]])<br/>
*{{lang|cy|Teyrnas Gyfunol Prydain Fawr a Gogledd Iwerddon}}([[ウェールズ語]])<br/>
*{{lang|ga|Ríocht Aontaithe na Breataine Móire agus Tuaisceart na hÉireann}}([[アイルランド語]])<br/>
*{{lang|kw|An Rywvaneth Unys a Vreten Veur hag Iwerdhon Glédh}}([[コーンウォール語]])<br/>
*{{lang|sco|Unitit Kinrick o Great Breetain an Northren Ireland}}([[スコットランド語]])<br/>
**{{lang|sco|Claught Kängrick o Docht Brätain an Norlin Airlann}}、{{lang|sco|Unitet Kängdom o Great Brittain an Norlin Airlann}}(アルスター・スコットランド語)</ref>
|国旗画像 = Flag of the United Kingdom.svg
|国章画像 = [[ファイル:Royal Coat of Arms of the United Kingdom.svg|85px|イギリスの国章]]
|国章リンク = ([[イギリスの国章|国章]])
|標語 = {{lang|fr|Dieu et mon droit}}<br/>([[フランス語]]:神と私の権利)
|国歌 = [[女王陛下万歳|神よ女王陛下を守り給え]]
|位置画像 = Location_UK_EU_Europe_001.svg
|公用語 = [[英語]](事実上)
|首都 = [[ロンドン]]
|最大都市 = ロンドン

...

21. カテゴリ名を含む行を抽出

記事中でカテゴリ名を宣言している行を抽出せよ.

解答

require './util'

article = gzip2hash["イギリス"]

article.each_line do |line|
  puts line if line.include?("Category")
end

article は 1 つの長い文字列なので each_line で 1 行ずつに切り分けて処理します。

出力

[[Category:イギリス|*]]
[[Category:英連邦王国|*]]
[[Category:G8加盟国]]
[[Category:欧州連合加盟国]]
[[Category:海洋国家]]
[[Category:君主国]]
[[Category:島国|くれいとふりてん]]
[[Category:1801年に設立された州・地域]]

22. カテゴリ名の抽出

記事のカテゴリ名を(行単位ではなく名前で)抽出せよ.

解答

require './util'

article = gzip2hash["イギリス"]

puts article.scan(/\[{2}Category:(.+?)(?:\|.*)*\]{2}/)

String#scan(pattern) -> Array は、pattern とマッチする部分を文字列から全て取り出し、配列の要素として返します。正規表現/ の間に書きます。

正規表現 やくわり
\[{2} [ が 2 回連続
Category: Category:
(.+?) 1 文字以上の文字列
キャプチャする
(?:ほげほげ) 必要のために () を使う
キャプチャしない
|.* |ほげほげ〜 と続くもの(21 の出力を見て)
(?:|.*)* |ほげほげ〜 と続くものがあってもなくても良い
キャプチャしない
\]{2} ] が 2 回連続

キャプチャされた部分だけを集めた配列が scan の返り値となります。

Ruby正規表現はここで試し書きしています。 rubular.com

PHP・JS・Python・Go の場合はこのサイトが見やすくて好きです。 regex101.com

出力

イギリス
英連邦王国
G8加盟国
欧州連合加盟国
海洋国家
君主国
島国
1801年に設立された州・地域

23. セクション構造

記事中に含まれるセクション名とそのレベル(例えば"== セクション名 =="なら1)を表示せよ.

※ 自分がやった時はレベルが 1 ~ 4 だったんですが、今見たら 2 ~ 4 に修正されてました。ここには修正後の解答コードと出力を載せます。

解答

require './util'

article = gzip2hash["イギリス"]

article.scan(/(?<level>={2,})\s?(?<name>.+?)\s?(?:\k<level>)/).each do |level, name|
  puts [name, level.length].join(" ")
end
正規表現 やくわり
(?<level>={2,}) = が 2 回以上連続
level という名前でキャプチャ
\s? 空白がないか 1 つ
(?<name>.+?) 1 文字以上の文字列
name という名前でキャプチャ
(?:\k<level>) さっきマッチした level と同じもの
キャプチャしない

これだと空白文字の見出しもマッチするんですが、仕様がよくわからないのでとりあえずこれで……。

名前付きキャプチャはこの記事がわかりやすいです。

qiita.com

出力

国名 2
歴史 2
地理 2
気候 3
政治 2
外交と軍事 2
地方行政区分 2
主要都市 3
科学技術 2
経済 2
鉱業 3
農業 3
貿易 3
通貨 3
企業 3
交通 2
道路 3
鉄道 3
海運 3
航空 3
通信 2
国民 2
言語 3
宗教 3
婚姻 3
教育 3
文化 2
食文化 3
文学 3
哲学 3
音楽 3
イギリスのポピュラー音楽 4
映画 3
コメディ 3
国花 3
世界遺産 3
祝祭日 3
スポーツ 2
サッカー 3
競馬 3
モータースポーツ 3
脚注 2
関連項目 2
外部リンク 2

24. ファイル参照の抽出

記事から参照されているメディアファイルをすべて抜き出せ.

解答

require './util'

article = gzip2hash["イギリス"]

puts article.scan(/(?:ファイル|File):(.+?)\|/)

[[ファイル:Wiki.png|thumb|説明文]] って感じなのですが、欲しいのはファイル名だけなので | の手前で切ります。

出力

Royal Coat of Arms of the United Kingdom.svg
Battle of Waterloo 1815.PNG
The British Empire.png
Uk topo en.jpg
BenNevis2005.jpg
Elizabeth II greets NASA GSFC employees, May 8, 2007 edit.jpg
Palace of Westminster, London - Feb 2007.jpg
David Cameron and Barack Obama at the G20 Summit in Toronto.jpg
Soldiers Trooping the Colour, 16th June 2007.jpg
Scotland Parliament Holyrood.jpg
London.bankofengland.arp.jpg
City of London skyline from London City Hall - Oct 2008.jpg
Oil platform in the North SeaPros.jpg
Eurostar at St Pancras Jan 2008.jpg
Heathrow T5.jpg
Anglospeak.svg
CHANDOS3.jpg
The Fabs.JPG
PalaceOfWestminsterAtNight.jpg
Westminster Abbey - West Door.jpg
Edinburgh Cockburn St dsc06789.jpg
Canterbury Cathedral - Portal Nave Cross-spire.jpeg
Kew Gardens Palm House, London - July 2009.jpg
2005-06-27 - United Kingdom - England - London - Greenwich.jpg
Stonehenge2007 07 30.jpg
Yard2.jpg
Durham Kathedrale Nahaufnahme.jpg
Roman Baths in Bath Spa, England - July 2006.jpg
Fountains Abbey view02 2005-08-27.jpg
Blenheim Palace IMG 3673.JPG
Liverpool Pier Head by night.jpg
Hadrian's Wall view near Greenhead.jpg
London Tower (1).JPG
Wembley Stadium, illuminated.jpg

25. テンプレートの抽出

記事中に含まれる「基礎情報」テンプレートのフィールド名と値を抽出し,辞書オブジェクトとして格納せよ.

解答

# util.rb

def raw_template_to_hash(article)
  templates = {}

  article.match(/^\{{2}基礎情報.*?$\n(.+?)^\}{2}$/m) do |raw_template|
    raw_template[1].scan(/\|(?<key>.+?)\s*=\s*(?<val>.+?)(?:(?=\n$)|(?=\n\|))/m) do |key, val|
      templates[key] = block_given? ? yield(val) : val
    end
  end

  templates
end

まずは /^\{{2}基礎情報.*?$\n(.+?)^\}{2}$/m 。複数行にわたる項目も含まれていて、それ込みで 1 回の正規表現で取得するためにがんばりました。

正規表現 やくわり
^\{{2} 行頭に { が 2 回連続
基礎情報.*?$\n 基礎情報ほげほげ〜\n
(.+?) 1 文字以上の文字列
キャプチャ
^\}{2}$ 行頭に } が 2 回連続して、行末がくる
m . が改行にもマッチするようにする
これがないと .+?[\s\S]+ と書くことになる(デフォルトで . は改行を含まないため)

/\|(?<key>.+?)\s*=\s*(?<val>.+?)(?:(?=\n$)|(?=\n\|))/m について

正規表現 やくわり
| |
(?<key>.+?) 1 文字以上の文字列
key という名前でキャプチャ
\s*=\s* = の前後に空白がいくつあってもなくても良い
(?.+?) 1 文字以上の文字列
val という名前でキャプチャ
ほげ(?=\n$) 直後に \n$ がくる ほげ(肯定先読み)
`(?.+?)(?:(?=\n$) (?=\n|))| 直後に\n$\n|` がくる val
先読み部分はキャプチャしない

「{肯定,否定}{先,後}読み」の計 4 パターンあります。便利です。

abicky.net

# 25.rb

require './util'

article = gzip2hash["イギリス"]
hash = raw_template_to_hash(article)

hash.sort.to_h.each do |key, val|
  puts [key, val].join(" ")
end

他の人と出力を揃えるためソートしています。

出力(一部省略)

...

最大都市 ロンドン
標語 {{lang|fr|Dieu et mon droit}}<br/>([[フランス語]]:神と私の権利)
水面積率 1.3%
注記 <references />
略名 イギリス
確立年月日1 [[927年]]/[[843年]]
確立年月日2 [[1707年]]
確立年月日3 [[1801年]]
確立年月日4 [[1927年]]
確立形態1 [[イングランド王国]]/[[スコットランド王国]]<br />(両国とも[[連合法 (1707年)|1707年連合法]]まで)
確立形態2 [[グレートブリテン王国]]建国<br />([[連合法 (1707年)|1707年連合法]])
確立形態3 [[グレートブリテン及びアイルランド連合王国]]建国<br />([[連合法 (1800年)|1800年連合法]])
確立形態4 現在の国号「'''グレートブリテン及び北アイルランド連合王国'''」に変更

...

26. 強調マークアップの除去

25の処理時に,テンプレートの値からMediaWikiの強調マークアップ(弱い強調,強調,強い強調のすべて)を除去してテキストに変換せよ(参考: マークアップ早見表).

解答

# util.rb

def raw_template_to_hash(article)
  templates = {}

  article.match(/^\{{2}基礎情報.*?$\n(.+?)^\}{2}$/m) do |raw_template|
    raw_template[1].scan(/\|(?<key>.+?)\s*=\s*(?<val>.+?)(?:(?=\n$)|(?=\n\|))/m) do |key, val|
      templates[key] = block_given? ? yield(val) : val
    end
  end

  templates
end

class String
  def remove_emphasis
    self.gsub(
      /(?<emphasis>'{5}|'{3}|'{2})(?<content>.*?)\k<emphasis>/,
      '\k<content>'
    )
  end
end

25 ~ 28 の問題は大枠が一緒で、微妙に最後の処理が違うだけです。block_given? で val に施す処理を受け取り、なければ val をそのまま返します。これ書いてて自分天才! って思ったのでかなり良い筋いってるんじゃないかな、どうですか? Ruby だいすき〜〜

特定の文字列を除去するメソッドは String クラスから生やすことにしました。早見表より、強調は ' が 2個か3個か5個連続する部分のペアです。

# 26.rb

require './util'

article = gzip2hash["イギリス"]
hash = raw_template_to_hash(article) do |val|
  val.remove_emphasis
end

hash.sort.to_h.each do |key, val|
  puts [key, val].join(" ")
end

25 と比較すると、raw_template_to_hash に block が渡っています。

出力

...

最大都市 ロンドン
標語 {{lang|fr|Dieu et mon droit}}<br/>([[フランス語]]:神と私の権利)
水面積率 1.3%
注記 <references />
略名 イギリス
確立年月日1 [[927年]]/[[843年]]
確立年月日2 [[1707年]]
確立年月日3 [[1801年]]
確立年月日4 [[1927年]]
確立形態1 [[イングランド王国]]/[[スコットランド王国]]<br />(両国とも[[連合法 (1707年)|1707年連合法]]まで)
確立形態2 [[グレートブリテン王国]]建国<br />([[連合法 (1707年)|1707年連合法]])
確立形態3 [[グレートブリテン及びアイルランド連合王国]]建国<br />([[連合法 (1800年)|1800年連合法]])
確立形態4 現在の国号「グレートブリテン及び北アイルランド連合王国」に変更

...

27. 内部リンクの除去

26の処理に加えて,テンプレートの値からMediaWikiの内部リンクマークアップを除去し,テキストに変換せよ(参考: マークアップ早見表).

解答

# util.rb

class String
  def remove_fileinfo
    self.gsub(
      /(?:ファイル|File):(?<filename>.+?)\|/,
      '\k<filename>'
    )
  end

  def remove_innerlink
    self.gsub(
      /\[{2}(?:[^|]*?\|?)(?<link>[^|]*?)\]{2}/,
      '\k<link>'
    )
  end
end

remove_fileinfo は内部リンクを除去するのに邪魔だったので先に消しておく用です。

remove_innerlink がちょっと複雑です。内部リンクは ① [[記事名]] ② [[記事名|表示文字]] ③ [[記事名#節名|表示文字]] の 3 パターンあるのですが、後ろ側を取得(①なら 記事名、②③なら 表示文字)するようにしました。

正規表現 やくわり
\[{2} [ が 2 回連続
`[^ ]*||` じゃないやつがいくつあってもなくても良い
`[^ ]*?|?||じゃないやつがいくつあってもなくても良くて、|がないか 1 つだけある<br>1 つ目の?` は最短マッチ

greedy(最長、貪欲)とかlazy(最短)とかpossessive(強欲)があるみたいです。長さの観点からか欲の観点からか、名前統一できたらいいのに感があります……。

d.hatena.ne.jp

# 27.rb

require './util'

article = gzip2hash["イギリス"]
hash = raw_template_to_hash(article) do |val|
  val.remove_emphasis
    .remove_fileinfo
    .remove_innerlink
end

hash.sort.to_h.each do |key, val|
  puts [key, val].join(" ")
end

ブロックの中で除去の処理をメソッドチェーンして書いてます。

出力

...

最大都市 ロンドン
標語 {{lang|fr|Dieu et mon droit}}<br/>(フランス語:神と私の権利)
水面積率 1.3%
注記 <references />
略名 イギリス
確立年月日1 927年/843年
確立年月日2 1707年
確立年月日3 1801年
確立年月日4 1927年
確立形態1 イングランド王国/スコットランド王国<br />(両国とも1707年連合法まで)
確立形態2 グレートブリテン王国建国<br />(1707年連合法)
確立形態3 グレートブリテン及びアイルランド連合王国建国<br />(1800年連合法)
確立形態4 現在の国号「グレートブリテン及び北アイルランド連合王国」に変更

...

28. MediaWikiマークアップの除去

27の処理に加えて,テンプレートの値からMediaWikiマークアップを可能な限り除去し,国の基本情報を整形せよ.

解答

# util.rb

class String
  def remove_link
    self.gsub(
      /\[\S+\s?(?<title>[^\[\]]+)\]/,
      '\k<title>'
    )
  end

  def remove_tag
    self.gsub(
      /\<(?:[^\<\>])+\>/,
      ''
    )
  end

  def remove_template
    self.gsub(
      /\{{2}(?:.*)\|(?<lang_text>.*)\}{2}/,
      '\k<lang_text>'
    )
  end
end

外部リンクは ① [http://www.example.org] ② [http://www.example.org 表示文字] ③ http://www.example.org の 3 パターンあって、①完全削除、②表示文字だけ残す、③そのまま残す、とします。

# 28.rb

require './util'

article = gzip2hash["イギリス"]
hash = raw_template_to_hash(article) do |val|
  val.remove_emphasis
    .remove_fileinfo
    .remove_innerlink
    .remove_link
    .remove_tag
    .remove_template
end

hash.sort.to_h.each do |key, val|
  puts [key, val].join(" ")
end

出力

最大都市 ロンドン
標語 Dieu et mon droit(フランス語:神と私の権利)
水面積率 1.3%
注記 
略名 イギリス
確立年月日1 927年/843年
確立年月日2 1707年
確立年月日3 1801年
確立年月日4 1927年
確立形態1 イングランド王国/スコットランド王国(両国とも1707年連合法まで)
確立形態2 グレートブリテン王国建国(1707年連合法)
確立形態3 グレートブリテン及びアイルランド連合王国建国(1800年連合法)

29. 国旗画像のURLを取得する

テンプレートの内容を利用し,国旗画像のURLを取得せよ.(ヒント: MediaWiki APIimageinfoを呼び出して,ファイル参照をURLに変換すればよい)

解答

require 'open-uri'
require 'json'
require './util'

article = gzip2hash["イギリス"]
hash = raw_template_to_hash(article) do |val|
  val.remove_emphasis
    .remove_fileinfo
    .remove_innerlink
    .remove_link
    .remove_tag
    .remove_template
end

filename = hash["国旗画像"]
wikipedia_api = "http://ja.wikipedia.org/w/api.php?"
params = {
  action:        "query",
  titles:        "Image:" + filename,
  prop:          "imageinfo",
  iiprop:        "url",
  format:        "json",
  formatversion: "2"
}

begin
  uri = URI.parse(wikipedia_api)
  uri.query = URI.encode_www_form(params)
  json_res = JSON.parse(uri.open.read)
  puts json_res['query']['pages'][0]['imageinfo'][0]['url']
rescue => e
  puts e
end

URI.encode_www_form で params を application/x-www-form-urlencoded 形式に変換してくれます。action=query&titles=... みたいなやつです。filename に空白が含まれるので、そこも + に変換されます。

出力

https://upload.wikimedia.org/wikipedia/commons/a/ae/Flag_of_the_United_Kingdom.svg

正規表現たのしいです。たぶんもっと良く書けると思うので、数ヶ月後書き直したいです。1 行が長すぎるのも良くない。

先読み・後読みの話が出てきました。むかし CSS で肯定先読みぽいこと(子要素に .hoge を持つ親要素にだけ特定のスタイルを当てたい! とか。破綻気味なのは気にしない……)したくて試行錯誤してたなぁと思い出しました。

もっと良い書き方あるよ〜などあれば issue とかで教えてもらえるとうれしいです!

github.com