Open12

自然言語100本ノック 2025 をやってみる(20-29)

onaka_hettaonaka_hetta

https://zenn.dev/onakahetta/scraps/1367d5394db3ae
の続きです。
https://nlp100.github.io/2025/ja/ch03.html
こちらの「自然言語100本ノック 2025」の第二章です。

本章の主題

Wikipediaの記事を以下のフォーマットで書き出したファイルjawiki-country.json.gzがある。1行に1記事の情報がJSON形式で格納される各行には記事名が”title”キーに、記事本文が”text”キーの辞書オブジェクトに格納され、そのオブジェクトがJSON形式で書き出されるファイル全体はgzipで圧縮される。以下の処理を行うプログラムを作成せよ。

onaka_hettaonaka_hetta

20. JSONデータの読み込み

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

Pythonで正規表現を扱う方法はちょっとだけ調べました。
こんな感じで良いのではないでしょうか。

import json
import re
file_path = 'jawiki-country.json'

data = []

with open(file_path , 'r' , encoding='UTF-8') as f:
    for line in f:
        parsed_line = json.loads(line)
        if re.search(r"イギリス",parsed_line['text']):
            data.append(parsed_line)

これでdata変数の中身は以下のようなdict型になります。

{'title': 'エジプト',
 'text': '{{otheruses|主に現代のエジプト・アラブ共和国|古代|古代エジプト}}\n{{基礎情報 国\n|略名 =エジプト\n|漢字・・・・・(めちゃ長い記事の内容)
onaka_hettaonaka_hetta

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

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

記事の中はLF区切りで改行されていて、以下のようにCategoryを宣言している箇所があります。
\n{{デフォルトソート:えしふと}}\n[[Category:エジプト|*]]\n[[Category:共和国]]\n[[Category:軍事政権]]\n[[Category:フランコフォニー加盟国]]'}

なので、\nで区切ったうえで正規表現で引っ掛けてあげます。

for article in data:
    category_lines = []
    for article_line in article['text'].split('\n'):
        if re.search(r"\[\[Category:" , article_line):
            category_lines.append(article_line)
    article['category_lines'] = category_lines

問題文そのまま解釈すると、カテゴリを宣言している行だけを取り出すともとれますが、実際には記事ごとにカテゴリを整理したいと思うので、問20で作成したdict型のデータにCategoryだけを抽出した行をくっつけてあげることにしました。

{'title': 'エジプト',
 'text': '{{otheruses|主に現代のエジプト・アラブ共和国|古代|古代エジプト}}\n{{基礎情報 国・・・・・(めっちゃ長い記事)',
  '[[Category:共和国]]',
  '[[Category:軍事政権]]',
  '[[Category:フランコフォニー加盟国]]']}
  
onaka_hettaonaka_hetta

22. カテゴリ名の抽出

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

正規表現でカテゴリ名の部分だけを取得してそれを"categories"という配列に追加することにします。

for article in data:
    categories = []
    for article_line in article['text'].split('\n'):
        match = re.search(r"\[\[Category:(.*?)\]\]",article_line)
        if match:
            categories.append(match.group(1))
        article['categories'] = categories

結果。以下のようなdict型になりました

{
 'title': 'エジプト',
 'text': '{{otheruses|主に現代のエジプト・アラブ共和国|古代|古代エジプト}}\n{{基礎情報 国\n|略名 =エ
 'category_lines': [['エジプト|*'], ['共和国'], ['軍事政権'], ['フランコフォニー加盟国']],
 'categories': ['エジプト|*', '共和国', '軍事政権', 'フランコフォニー加盟国']
}
onaka_hettaonaka_hetta

23. セクション構造

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

以下のように記述しました。

for article in data:
    sections = []
    for article_line in article['text'].split('\n'):
        match = re.match(r"^(=+)\s*(.*?)\s*\1$",article_line)
        if match:
            sections.append(
                {
                    "level" : (len(match.group(1)) -1),
                    "section_name" : match.group(2)
                }
            )
        article['sections'] = sections
  • 最初のイコールと最後のイコールの数が一致するように
  • イコールと文字列の間に任意の数のスペースがある

あたりがポイントですね

階層構造は表現できていないですが、以下のようなデータ構造でセクションに関係するデータを追加しています。

'sections': [{'level': 1, 'section_name': '国号'},
  {'level': 1, 'section_name': '歴史'},
  {'level': 2, 'section_name': '古代エジプト'},
  {'level': 2, 'section_name': 'アケメネス朝ペルシア'},
  {'level': 2, 'section_name': 'ヘレニズム文化'},
  {'level': 2, 'section_name': 'ローマ帝国'},
  {'level': 2, 'section_name': 'イスラム王朝'},
  {'level': 2, 'section_name': 'オスマン帝国'},
onaka_hettaonaka_hetta

24. ファイル参照の抽出

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

記事内では以下のようにメディアファイルを参照しています。
[[File:Flag of Cairo.svg|24px]]
[[ファイル:Mohamed Morsi-05-2013.jpg|thumb|180px|民主化・・・
[[file:Bilady, Bilady, Bilady.ogg]]

上記を正規表現で抽出します。

for article in data:
    files = []
    for article_line in article['text'].split('\n'):
        match = re.match(r"\[\[(?:File|file|ファイル):([^|\]]+)",article_line)
        if match:
            files.append(match.group(1))
        article['files'] = files

data[0]["files"]

\[\[(?:File|file|ファイル):([^|\]]+)

  • [[から始まって
  • そのあとFilefileファイルの文字列と:が来て
  • |]が来るまでの文字列
    をファイル名としています。

結果

['All Gizah Pyramids.jpg',
 'Egyptiska hieroglyfer, Nordisk familjebok.png',
 'ModernEgypt, Muhammad Ali by Auguste Couder, BAP 17996.jpg',
 'Gamal Nasser.jpg',
 'Hosni Mubarak ritratto.jpg',
 'Mohamed Morsi-05-2013.jpg',
 'Abdel_Fattah_el-Sisi_September_2017.jpg',
 'Abrams in Tahrir.jpg',
 'Governorates of Egypt.svg',
 'Egypt Topography.png',
 'Egypt 2010 population density1.png',

問題なく抽出できていそうです

onaka_hettaonaka_hetta

25. テンプレートの抽出

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

def extract_basic_information(article_text):
    match = re.search(r'{{基礎情報.*?\n(.*?)\n}}' , article_text  ,re.DOTALL)
    if not match:
        return []
    basic_information = []
    content = match.group(1)
    for line in content.split("\n"):
        line = line.lstrip("|")
        if '=' in line:
            key , value = line.split("=" , 1)
            basic_information.append({key.strip() : value.strip()})
    return basic_information

for article in data:
    article['basic_information'] = extract_basic_information(article["text"])

data[0]['basic_information']

結果

[{'略名': 'エジプト'},
 {'漢字書き': '埃及'},
 {'日本語国名': 'エジプト・アラブ共和国'},
 {'公式国名': "{{lang|ar|'''جمهورية مصر العربية'''}}"},
 {'国旗画像': 'Flag of Egypt.svg'},
 {'国章画像': '[[ファイル:Coat_of_arms_of_Egypt.svg|100px|エジプトの国章]]'},
 {'国章リンク': '([[エジプトの国章|国章]])'},
 {'標語': 'なし'},
 {'位置画像': 'Egypt (orthographic projection).svg'},
 {'公用語': '[[アラビア語]]'},
 {'首都': '[[File:Flag of Cairo.svg|24px]] [[カイロ]]'},
 {'最大都市': 'カイロ'},
 {'元首等肩書': '[[近代エジプトの国家元首の一覧|大統領]]'},
 {'元首等氏名': '[[アブドルファッターフ・アッ=シーシー]]'},

もうちょっときれいにしたり、親子構造に対応させてもいいような気はしますが、一旦これで完成としようかと思います。

onaka_hettaonaka_hetta

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

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

リンク先を見るにMediaWikiでは'を使ったマークアップで強調している。これを正規表現で除去すればよさそうです。

「25の処理時に」と記載がありますので、25で作成した基本情報を辞書型に変換する関数の後に、強調マークアップを削除する構成にします。

def omit_emphasis_markup(text):
    # 2~5個のシングルクォーテーションを削除する
    text = re.sub(r"'{2,5}" , "" , text)
    return text

for article in data:
    # No.25 基本情報を辞書型に変換して情報を付加する
    article['basic_information'] = extract_basic_information(article["text"])
    # No.26 強調マークアップを除去
    article["text"] = omit_emphasis_markup(article["text"])
onaka_hettaonaka_hetta

27. 内部リンクの除去

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

内部リンクマークアップは以下のような形式で表現されます。
[[記事名]]
[[記事名|表示文字]]
[[記事名#節名|表示文字]]

なので角括弧2つで囲われている部分を内部リンクマークアップと見なせばよいか?と思ったのですが、それではどうやら不十分みたいです。

なぜなら、ファイルやカテゴリも、以下のように角括弧2つで囲われるからです。
[[ファイル:Wikipedia-logo-v2-ja.png|thumb|説明文]]
[[Category:ヘルプ|はやみひよう]]

調べてみたところ、内部リンクマークアップ以外で角括弧2つで囲われる場合には途中で:が挟まるようです。

ですので、角括弧2つで囲われていて中にコロンがない場合に、それを内部リンクマークアップと見做すことにします。

また、
[[記事名|表示文字]]
[[記事名#節名|表示文字]]
の形式である場合、「表示文字」の部分だけをテキストとして残すことにします。


# 表示文字だけを残すためのコールバック関数
def keep_only_desplayed_text(match):
    internal_link_text = match.group(1)
    if "|" in internal_link_text:
        return internal_link_text.split("|" , 1)[1]
    else:
        return internal_link_text

# 内部リンクマークアップの除去
def omit_internal_link_markup(text):
    text = re.sub(r"""
                \[\[                # 開始の[[
                  (
                    (?!             # 否定先読み
                        [^\]]*?:    # コロンを含むのは除外(閉じ括弧が来るまでに:が来るかを走査)
                    )
                    [^\]]+          # 閉じ]が来るまでの文字列
                  )
                \]\]                # 終了の]]
            """ , keep_only_desplayed_text, text , flags=re.VERBOSE)
    return text

for article in data:
    # No.25 基本情報辞書型に変換して情報を付加する
    article['basic_information'] = extract_basic_information(article["text"])
    # No.26 強調マークアップを除去
    text = omit_emphasis_markup(article["text"])
    # No.27 内部リンクマークアップの除去
    article["text"] = omit_internal_link_markup(text)


: が途中で出てくるようなものをマッチさせないために、否定先読みをしないといけないですね。
否定先読みの仕方覚えてなかったのでchatGPTに聞きながら書きました。

onaka_hettaonaka_hetta

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

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

途中で疲れちゃったので中途半端ですが、正規表現の学習の要点は抑えられたので以下でヨシとします。

def omit_mediawiki_markup(text):
    # 外部リンク [https://www.example.org 表示文字]の場合は「表示文字」だけ残す
    text = re.sub(r'\[https?://[^\s\]]+(?:\s+([^\]]+))?\]', lambda m: m.group(1) if m.group(1) else '', text)
    # コメントは消しちゃえ <!-- コメント -->
    text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
    # リダイレクト・ファイル・カテゴリも消さなければだけど疲れたので後で。。。
    return text

for article in data:
    # No.25 基本情報辞書型に変換して情報を付加する
    article['basic_information'] = extract_basic_information(article["text"])
    # No.26 強調マークアップを除去
    text = omit_emphasis_markup(article["text"])
    # No.27 内部リンクマークアップの除去
    article["text"] = omit_internal_link_markup(text)
    # No.28 MediaWikiマークアップの除去
    article["text"] = omit_mediawiki_markup(article["text"])
onaka_hettaonaka_hetta

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

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

国旗画像のURLを取得するAPIを以下のように記述しました。

import requests

# 国旗画像のURL取得API実行

def get_flag_image_url(image_file_name):
    base_url = "https://commons.wikimedia.org/w/api.php"
    params = {
        "action": "query",
        "format": "json",
        "titles": image_file_name,
        "prop": "imageinfo",
        "iiprop": "url",
        "iiurlwidth": 500
    }
    response = requests.get(base_url, params=params)
    data = response.json()
    
    pages = data.get("query", {}).get("pages", {})
    for page_id, page in pages.items():
        if 'imageinfo' in page:
            return page['imageinfo'][0]['url']
    return None

print(get_flag_image_url("File:Flag of the United Kingdom.svg"))

結果

https://upload.wikimedia.org/wikipedia/commons/8/83/Flag_of_the_United_Kingdom_%283-5%29.svg

APIのレスポンスデータ構造が変わりうるとき、レスポンスを辞書型に変換した際のプロパティアクセスを安全に行う方法に悩みましたが、調べてみるとgetメソッド使えば安全にわかりやすくアクセスできるのですね。

後はこの関数を処理内に組み込むだけなのですが、これは他のコードとやっていること同じなので割愛します