【第3章】言語処理100本ノックでPythonに入門

この記事は拙著[言語処理100本ノックでPythonに入門](https://qiita.com/hi-asano/items/abf450448568e287968d)の続編です。[100本ノック第3章](https://nlp100.github.io/ja/ch03.html)の解説をやってきます。

この章では正規表現を使います。Pythonでは`re`モジュールで扱います。このモジュールを使うには正規表現自体だけでなく、メソッド、マッチオブジェクトも把握する必要があるので、そこそこ大変です。もはや入門レベルとは思えません。[公式のチュートリアル](https://docs.python.org/ja/3/howto/regex.html)に勝る解説記事を私に書ける気がしなかったので、そちらを一通り読んでください。

ただしPythonの正規表現は遅いので、私はなるべく正規表現を回避して解きます。

とりあえずファイルを適当にダウンロードします。*このファイルは，クリエイティブ・コモンズ 表示-継承 3.0 非移植のライセンスで配布されています．*


```shell
$ wget https://nlp100.github.io/data/jawiki-country.json.gz
```
    


問題文によると、1行に1記事の情報がJSON形式で格納されていると書いてあります。JSON形式は配列や辞書を素朴に書き出したような形式で、多くのプログラミング言語がこの形式に対応しています。ただしこのファイル全体のフォーマットとしてはJSONL (JSON Lines)と呼ばれている形式になっています。`$ gunzip -c jawiki-country.json.gz | less` などでファイルの中身を見てみましょう（直接`less`で見れるかもしれません）。

# json
PythonにもこのJSONを簡単に扱うためのライブラリがあります。その名もjsonです。[ドキュメント](https://docs.python.org/ja/3/library/json.html)から持ってきた例ですが、このようにjson文字列をPythonオブジェクトにしてくれたり、その逆の操作がいとも簡単にできます。


```python
import json
dic = json.loads('{"bar":["baz", null, 1.0, 2]}')
print(type(dic))
print(dic)
```

    <class 'dict'>
    {'bar': ['baz', None, 1.0, 2]}



```python
dumped = json.dumps(dic)
print(type(dumped))
print(dumped)
```

    <class 'str'>
    {"bar": ["baz", null, 1.0, 2]}


わかりにくかったので`type()`で型名も表示させました。ちなみに`loads`, `dumps`の`s`は3単現ではなく`string`を意味します。

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

ダウンロードしたファイルはgz形式ですが、なるべく展開したくないですね。Pythonの`gzip`モジュールで読み込むか、Unixコマンドで展開結果を標準出力してパイプで繋ぐかする方が良いでしょう。

以下、解答例です。


```q20.py
import json
import sys


for line in sys.stdin:
    wiki_dict = json.loads(line)
    if wiki_dict['title'] == 'イギリス':
        print(wiki_dict.get('text'))

```


```shell
$ gunzip -c jawiki-country.json.gz | python q20.py > uk.txt
```


```shell
$ head -n5 uk.txt
```

    {{redirect|UK}}
    {{redirect|英国|春秋時代の諸侯国|英 (春秋)}}
    {{Otheruses|ヨーロッパの国|長崎県・熊本県の郷土料理|いぎりす}}
    {{基礎情報 国
    |略名  =イギリス


なんかリダイレクトや同名記事までとれてますが、特に問題無いでしょう。

# 21. カテゴリ名を含む行を抽出
> 記事中でカテゴリ名を宣言している行を抽出せよ．

Wikipediaの[マークアップ早見表](https://ja.wikipedia.org/wiki/Help:%E6%97%A9%E8%A6%8B%E8%A1%A8)と実際のファイルの中身を見て考えましょう。

`'[[Category'`で始まる行を抽出するだけで十分そうです。`str.startswith(prefix)`で、文字列が`prefix`で始まるかどうかの真理値を返してくれます。

以下、解答例です。



```q21.py
import sys


for line in sys.stdin:
    if line.startswith('[[Category'):
        print(line.rstrip())

```



（2015年版は小文字の`[[category`が混ざってた記憶があるのですが、2020年版では無くなってますね...）

# 22. カテゴリ名の抽出
> 記事のカテゴリ名を（行単位ではなく名前で）抽出せよ．

手を抜いてやるとこうなります。


```q22.py
import sys


for line in sys.stdin:
    print(line.lstrip("[Category:").rstrip("|*]\n"))

```



```shell
$ python q21.py < uk.txt | python q22.py
```

    イギリス
    イギリス連邦加盟国
    英連邦王国
    G8加盟国
    欧州連合加盟国|元
    海洋国家
    現存する君主国
    島国
    1801年に成立した国家・領域


# 23. セクション構造
> 記事中に含まれるセクション名とそのレベル（例えば"== セクション名 =="なら1）を表示せよ．

`==国名==`を`国名 1`にします。`str.count(sub)`で文字列中の`sub`を数えられます。

以下、正規表現を使わない解答例です。


```q23.py
import sys

for line in sys.stdin:
    if line.startswith('=='):
        sec_name = line.strip('= \n')
        level = int(line.count('=')/2 - 1)
        print(sec_name, level)

```



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

2020年版は全て`ファイル:Battle of Waterloo 1815.PNG|`のような形になっています。`|`以降は除去したいこと、また1行に複数ある可能性があることに注意して、正規表現を使います。正規表現のテストはオンラインのチェックツールなどを使うと楽です。

以下、解答例です。



```q24.py
import re
import sys

pat = re.compile(r'(ファイル:)(?P<filename>.+?)\|')
for line in sys.stdin:
    for match in pat.finditer(line):
        print(match.group('filename'))

```


`.+?\|`で「任意文字のできるだけ少ない繰り返しの後に`|`」という意味になります。複数マッチを考えるときは`finditer()`が便利ですね。マッチしなかった場合はそもそも`for`文が回りませんし。

`group`の引数は2でも同じ結果になります。

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

テンプレートの中で改行しているフィールドの処理が面倒です。

```
{{基礎情報 国
|略名 = イギリス
|日本語国名 = グレートブリテン及び北アイルランド連合王国
|公式国名 = {{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/>
```

以下、解答例です。

```q25.py
import sys
import json


def main():
    dic = extract_baseinf(sys.stdin)
    sys.stdout.write(json.dumps(dic, ensure_ascii=False))


def extract_baseinf(fi):
    baseinf = {}
    isbaseinf = False
    for line in fi:
        if isbaseinf:
            if line.startswith('}}'):
                return baseinf

            elif line[0] == '|':
                templis = line.strip('|\n').split('=')
                key = templis[0].rstrip()
                value = "=".join(templis[1:]).lstrip()
                baseinf[key] = value
                
            else:
                value = line.rstrip('\n')
                baseinf[key] += f"\n{value}"

        elif line.startswith('{{基礎情報'):
            isbaseinf = True


if __name__ == '__main__':
    main()

```

```python
!python q25.py < uk.txt > uk_baseinf.json
```

複数行にまたがっている場合は連結していくという方法で処理しています。

次の問題のコードが複雑化するので一回jsonに書き出します。このとき`ensure_ascii=False`しないと文字化けします。


# 26. 強調マークアップの除去
> 25の処理時に，テンプレートの値からMediaWikiの強調マークアップ（弱い強調，強調，強い強調のすべて）を除去してテキストに変換せよ（参考: [マークアップ早見表](http://ja.wikipedia.org/wiki/Help:%E6%97%A9%E8%A6%8B%E8%A1%A8)）．

`'`が2, 3, 5回連続していたら削除しましょう。正規表現的には`r'{2,5}.+?'{2,5}`のような感じでしょうが、真面目にやるのは大変です。例によって正規表現を使わずにやるとこうなります。


```q26.py
import json
import sys


def main():
    dic = json.loads(sys.stdin.read())
    dic = remove_emphasis(dic)
    print(json.dumps(dic, ensure_ascii=False, indent=4))

    
def remove_emphasis(dic):
    for key, value in dic.items():
        for n in (5, 3, 2):
            eliminated = value.split("'" * n)
            div, mod = divmod(len(eliminated), 2)
            if mod == 1 and div > 0:
                value = ''.join(eliminated)
                dic[key] = value
    return dic


if __name__ == '__main__':
    main()

```


前問で作っておいたjsonファイルを標準入力から読み込み、その辞書オブジェクトの値を変更するという流れになっています。`dict.items()`は辞書の`(key, value)`の組を次々と返すイテレータです。ぜひ覚えておきましょう。

文字列リテラルの中に`'`を使う場合は、エスケープするか外側を`"`で囲う必要があります。同じ文字列を連続させるには整数倍をすればよいです。そして`split()`で`'`を削除してみて、返ってきたリストの要素数が奇数かどうか判定することで`a''b`のようなイレギュラーな`'`を消さないようにしています。剰余は`%`で求められるが、商が0のときもそのままで良いので組み込み関数`divmod()`を使うことで、商と剰余を同時に求めています。

条件式`A and B`は何気に初出ですが、まぁ見ればわかると思います。`or`も同様です。重要なのは、その評価戦略です。`A and B`は`A==False`だと判明したら`B`を評価すること無く式の評価が終了します。そのため`A`は`B`よりも`False`になりやすいものにしておくことで効率的になります。同様に、`A or B`は`A==True`だと判明したら`B`を評価しないので、`A`にはより`True`になりやすい式を書きましょう。



# 27. 内部リンクの除去
> 26の処理に加えて，テンプレートの値からMediaWikiの内部リンクマークアップを除去し，テキストに変換せよ（参考: [マークアップ早見表](https://ja.wikipedia.org/wiki/Help:%E6%97%A9%E8%A6%8B%E8%A1%A8)）．

3パターンあるので正規表現を使います。


```q27.py
"""
[[記事名]]
[[記事名|表示文字]]
[[記事名#節名|表示文字]] 
"""
import json
import re
import sys


from q26 import remove_emphasis


def main():
    dic = json.loads(sys.stdin.read())
    dic = remove_emphasis(dic)
    dic = remove_link(dic)
    print(json.dumps(dic, ensure_ascii=False, indent=4))


def remove_link(dic):
    pat = re.compile(r"""
        \[\[        # [[
        ([^|]+\|)*  # 記事名|　無かったり繰り返されたり
        ([^]]+)     # 表示文字 patにマッチした部分をこいつに置換する
        \]\]        # ]]
    """, re.VERBOSE)
    for key, value in dic.items():
        value = pat.sub(r'\2', value)
        dic[key] = value
    return dic

if __name__ == '__main__':
    main()

```


前問の処理をした後、改めて辞書の値を変更していく流れです。

トリプルクォートで囲むと複数行に渡る文字列リテラルが書けます。さらに`re.VERBOSE`で正規表現内で空白・改行・コメントが無視されるようになるのですが、それでもなかなか見にくいですね…。

`pat.sub(r'\2', value)`の部分は、`value`のうち`pat`でマッチした部分を、マッチオブジェクトの`group(2)`に置換する、という意味になります。

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

Pandocとpypandocを使えばできます...。正規表現で頑張る場合は強調マークアップ、内部リンク、ファイル参照、外部リンク、`<ref>`、`<br />`、`{{0}}`あたりを消せば良いですかね、正規表現だけ載せておきます...


```python
basic_info = re.compile(r"\|(.+?)\s=\s(.+)")
emphasize = re.compile(r"('+){2,5}(.+?)('+){2,5}")
link_inner = re.compile(r"\[\[(.+?)\]\]")
file_ref = re.compile(r"\[\[ファイル:.+?\|.+?\|(.+?)\]\]")
ref = re.compile(r"<ref((\s.+?)>|(>.+?)</ref>)")
link_website = re.compile(r"\[.+?\]")
lang_template = re.compile(r"{{.+?\|.+?\|(.+?)}}")
br = re.compile(r"<.+?>")
space = re.compile(r"{{0}}")
```

# 29. 国旗画像のURLを取得する
> テンプレートの内容を利用し，国旗画像のURLを取得せよ．（ヒント: [MediaWiki](https://www.mediawiki.org/wiki/API:Main_page/ja) APIの[imageinfo](https://www.mediawiki.org/wiki/API:Properties/ja#imageinfo_.2F_ii)を呼び出して，ファイル参照をURLに変換すればよい）

`https://commons.wikimedia.org/w/api.php`にいろいろなパラメータ（ファイル名など）を付けてリクエストすれば良さそうです。「mediawiki api imageinfo」などとググればパラメータが出てきます。Python標準モジュールでAPIを叩くには`urllib`を使えばよいでしょう。ドキュメントの[使用例](https://docs.python.org/ja/3/library/urllib.request.html#examples)の「以下は GET メソッドを使ってパラメータを含む URL を取得するセッションの例です:」の部分を見ればできると思います。

以下、解答例です。

```q29.py
import json
import sys
from urllib import request, parse
import re


baseinf = json.loads(sys.stdin.read())

url = 'https://commons.wikimedia.org/w/api.php'
params = {'action': 'query', 'prop': 'imageinfo', 'iiprop': 'url',
            'format': 'json', 'titles': f'File:{baseinf["国旗画像"]}'}

req = request.Request(f'{url}?{parse.urlencode(params)}')
with request.urlopen(req) as res:
    body = res.read()

# print(body['query']['pages']['347935']['imageinfo'][0]['url'])
print(re.search(r'"url":"(.+?)"', body.decode()).group(1))
```

```python
!python q29.py < uk_baseinf.json
```

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


返ってくるJSONファイルが複雑なので、URLっぽい部分を探す方が実践的でしょう。`body`がなぜかバイト列になっていたのでデコードしないと動きませんでした。

# まとめ
- `re`
- `json`
- `str.startswith()`
- `dict.items()`
- `and`, `or`とその評価戦略
- `urllib`

# 次は4章
個人的にこの章は辛かったです。次からNLPという感じですかね。

[次章](https://qiita.com/hi-asano/items/fc4ad08a552d1f8bf4c0)