【ITエンジニア向け】英語版Wiktionaryから特定の単語データ(単語,ローマ字転写,発音,品詞,意味,語源)を取得するコード

IT・アプリ 外国語学習 多言語比較学習

本コードの利用

本プログラムは、個人的な言語研究および単語帳作成の自動化を目的として開発したものです。
出力されるデータは英語となるため、必要に応じて翻訳を行って下さい。
大量のデータを扱う際はダンプファイルの使用が推奨されています。
また制作にあたっては、応用情報技術者(AP)相当の知見に基づき実装しておりますが、私は専業の開発職ではございません。
そのため、技術的な問い合わせについては対応しておりません。

免責事項

本プログラムの利用によって生じた損害等について、制作者は一切の責任を負いかねます。
コードの使用は自己責任でお願いいたします。
万が一不具合や損失が生じた場合でも、当方は一切の補償をいたしません。あらかじめご了承ください。

コード

Google Colobで使うときはコードを非表示にして折りたたんでおくと、ファイルのアップロードがしやすいです。


# ==============================================================================
# Wiktionary Multi-Language Extractor (Batch版)
# 配布元: https://language-geek.com
# ==============================================================================
#
# 【概要】
#   Wiktionary (en.wiktionary.org) から指定言語の単語データを一括取得し、
#   Excel ファイルとして出力する Google Colab 用スクリプト。
#
# 【データ出典とライセンス】
#   本スクリプトが取得するデータは Wiktionary (en.wiktionary.org) に由来する。
#   Wiktionary のコンテンツは CC BY-SA 3.0 および GFDL のデュアルライセンスで
#   提供されており、取得・再利用の際には以下の条件を守る必要がある。
#
#   ・出典の明記: データを公開・配布・論文等で使用する場合は
#     「Source: Wiktionary (en.wiktionary.org), CC BY-SA 3.0」のように
#     Wiktionary を出典として明示すること。
#   ・継承: CC BY-SA 3.0 は「同一条件」ライセンスのため、取得データを加工・
#     再配布する場合は同じ CC BY-SA 3.0 またはそれと互換性のある
#     ライセンスを適用すること。
#   ・取得したデータを外部に配布・公開しない場合(個人の学習・研究メモ等)は
#     ライセンス上の義務(出典明記・同一条件継承)が実際に問題となる場面は
#     少ないが、CC BY-SA 3.0 自体に「個人利用は免除」という条項はない。
#     利用状況に応じて各自で判断すること。
#
#   参考:
#     CC BY-SA 3.0: https://creativecommons.org/licenses/by-sa/3.0/
#     Wiktionary:Copyrights: https://en.wiktionary.org/wiki/Wiktionary:Copyrights
#
# 【Wikimedia API の利用マナー】
#   本スクリプトは Wikimedia API を通じてデータを取得する(HTML スクレイピングではない)。
#   API 利用にあたり以下のマナーを実装済み。
#
#   ・User-Agent の設定: Wikimedia は「ツール名/バージョン (連絡先)」形式の
#     User-Agent を推奨している。CONTACT_EMAIL フォームに自分のメールアドレスを
#     入力することで適切な User-Agent が自動設定される。
#     参考: https://www.mediawiki.org/wiki/API:Etiquette
#   ・レートリミットの遵守: バッチ間に 1〜1.5 秒、parse API 呼び出し間に 0.1 秒の
#     待機時間を設けており、サーバーへの過負荷を防いでいる。
#   ・バッチ API の活用: action=query で最大 50 単語の存在確認を 1 リクエストで
#     行うことで、不要なリクエスト数を最小化している。
#
# 【出力カラム】
#   word         : 入力単語(元の表記のまま)
#   transcription: ローマ字転写など(取得できない場合は "-")
#   IPA          : IPA発音記号(取得できない場合は "-")
#   POS          : 品詞 (Part of Speech)
#   meaning      : 意味(複数ある場合は改行区切り)
#   etymology    : 語源(取得できない場合は "-")
#
# 【バッチ処理の仕組み】
#   シングル版では 1単語 = 1リクエスト だが、バッチ版では以下の2ステップで効率化する。
#   Step1: action=query&titles=A|B|C|... で最大 BATCH_SIZE 単語の存在確認を1リクエストで実施。
#          存在しない単語への無駄な action=parse コールを事前に除外できる。
#   Step2: 存在が確認されたページのみ action=parse でHTML取得(1ページ1リクエスト)。
#          parse コール間は 0.1 秒のみ待機。
#   バッチ間の待機: 1〜1.5 秒(サーバー負荷軽減のため)
#
#   期待速度: 2000語 ÷ 50語/バッチ = 40バッチ × 約1秒 ≈ 約1〜3分
#
# 【Reconstruction語(Proto-言語)について】
#   Proto-Austronesian 等の再構形語は通常の /wiki/単語 ではなく
#   /wiki/Reconstruction:言語名/単語 というページに存在する。
#   これらも action=query のバッチに通常語と混在させて処理できる。
#   parse_section() に渡す is_reconstruction フラグで区別する。
#   詳細は RECONSTRUCTION_LANGS および is_reconstruction_lang() を参照。
#
# 【中断時の挙動】
#   Colab の停止ボタン(■)または Ctrl+C で中断した場合、
#   KeyboardInterrupt を捕捉してその時点までのデータを保存・ダウンロードする。
#
# 【保守者へ】
#   - 新しい言語を追加する場合: TARGET_LANGUAGE の @param リストに追記するだけでよい。
#   - 新しい言語でIPAが取得できない場合: collect_ipa() 内の ipa_strategies リストを参照。
#   - 新しい言語でセクションIDが一致しない場合: parse_section() 内の lang_id_variants を参照。
#   - Proto-系以外で Reconstruction ページを使う言語: RECONSTRUCTION_LANGS を参照。
#   - URL に含められない記号が単語に含まれる場合: SAFE_CHARS を参照。
# ==============================================================================

# @title 🌍 Wiktionary Multi-Language Extractor (Batch版) Settings
# @markdown ### 言語名について
# @markdown 入力ファイルの **A1セル** に言語名が記載されている場合は自動取得します。
# @markdown A1セルに言語名がない場合(単語データが入っている場合等)は、
# @markdown 下のフォームで手動指定してください。フォームが空欄のときは A1 の値が優先されます。
# @markdown 言語名は Wiktionary の表記に合わせてください(例: "Ancient Greek", "Proto-Austronesian")。
TARGET_LANGUAGE = "" # @param {type:"string"}
# @markdown ---
# @markdown 保存するファイル名(空欄の場合は "Wiktionary_言語名_YYYYMMDD.xlsx" になります)
OUTPUT_FILENAME = "" # @param {type:"string"}
# @markdown Wikimedia API ポリシー準拠の User-Agent 用メールアドレス(必須)
# @markdown 参考: https://www.mediawiki.org/wiki/API:Main_page#Identifying_your_client
CONTACT_EMAIL = "your-email@example.com" # @param {type:"string"}
# @markdown 処理する最大単語数(0 ですべて処理)
MAX_WORDS = 2000 # @param {type:"integer"}

import datetime
import requests
import pandas as pd
from bs4 import BeautifulSoup
from google.colab import files
import urllib.parse
import time
import re
import random

# ==============================================================================
# 定数
# ==============================================================================

# urllib.parse.quote() はデフォルトで多くの記号を %XX にエンコードするが、
# Wiktionary は以下の文字を URL にそのまま使うためエンコードしてはならない。
#   '  : Amis 語などアポストロフィを含む単語 (例: 'adi)
#   *  : Proto 語の再構形記号 (例: *aba) ※ URL には含めないが make_page_name で除去する
#   -  : ハイフンを含む単語・言語名
# 新たに記号を含む言語が見つかった場合はここに追記すること。
SAFE_CHARS = "'*-"

# Wikimedia API の action=query で一度に指定できる titles の上限。
# 参考: https://www.mediawiki.org/wiki/API:Query
API_MAX_TITLES = 50

# バッチサイズは API 上限と同じ 50 に固定する。
# 50未満に下げると速度が低下するだけでメリットがないため、変更は不要。
BATCH_SIZE = API_MAX_TITLES

# ==============================================================================
# Reconstruction ページの設定
# ==============================================================================
# Proto-Austronesian 等の「再構形語」は通常の /wiki/単語 ではなく、
# /wiki/Reconstruction:言語名/単語 というページに存在する。
#
# 「Proto-」または「Proto 」で始まる言語名は is_reconstruction_lang() で自動判定する。
# Wiktionary 上の Reconstruction 名称がツール上の言語名と異なる場合のみ、
# このマッピング辞書に明示的に登録する。
#
# 【保守者へ】
#   新たな Reconstruction 言語を追加する場合はここに追記すること。
#   既存のエントリは削除しないこと(削除すると該当言語の取得が壊れる)。
#
# 例:
#   "Proto-Japonic": "Proto-Japonic"
#   (ツール上の名前とWiktionary上の名前が一致している場合は登録不要)
RECONSTRUCTION_LANGS = {}


# 意味データを持たないメタセクションの見出し名リスト。
# parse_section() で <ol> が意味リストかどうかを判定する際に使用する。
# 【保守者へ】新しい言語で不要なセクションが取り込まれる場合はここに追記すること。
#             既存のエントリは削除しないこと(他言語の除外が壊れる)。
META_SECTIONS = [
    "Pronunciation",    # 発音セクション(IPA は PHASE 2 で別途収集する)
    "References",       # 参考文献
    "Etymology",        # 語源(テキストは PHASE 3 で別途収集する)
    "Anagrams",         # アナグラム
    "Synonyms",         # 類義語
    "Antonyms",         # 反義語
    "Derived terms",    # 派生語
    "Related terms",    # 関連語
    "See also",         # 関連項目
    "Further reading",  # 参考資料
    "Descendants",      # 派生言語への影響
]

# ==============================================================================
# ユーティリティ関数
# ==============================================================================

def is_reconstruction_lang(target_lang: str) -> bool:
    """
    指定言語が Reconstruction ページを使う言語かどうかを判定する。

    判定ルール(いずれか一つに該当すれば True):
      1. RECONSTRUCTION_LANGS に明示的に登録されている
      2. 言語名が "Proto-" で始まる(例: Proto-Austronesian)
      3. 言語名が "Proto " で始まる(スペース区切りの場合)
    """
    return (
        target_lang in RECONSTRUCTION_LANGS
        or target_lang.startswith("Proto-")
        or target_lang.startswith("Proto ")
    )


def make_page_name(word: str, target_lang: str) -> tuple:
    """
    単語と言語名から Wiktionary API に渡すページ名を生成する。

    通常言語:
      url_word をそのままページ名として返す。
      url_word は元の単語から「意味のない文字」(省略記号・波線・空白等)のみ除去したもの。
      ハイフン・アポストロフィ等は Wiktionary の URL に使われるため残す。

    Reconstruction 言語(Proto-系):
      "Reconstruction:言語名/単語" の形式で返す。
      単語先頭の * は再構形の慣例記号であり URL には含めない(lstrip で除去)。

    戻り値:
      (page_name: str, is_reconstruction: bool)

    注意:
      空単語や記号のみの単語は呼び出し元でフィルタ済みであることを前提とする。
      (main ループで clean_word チェックを行っているため)
    """
    # 省略記号・波線・空白のみ除去(ハイフン・アポストロフィ等は残す)
    url_word = re.sub(r'[.…〜~\s]', '', str(word)).strip()

    if is_reconstruction_lang(target_lang):
        recon_word = url_word.lstrip("*")  # * は URL に含めない
        recon_lang = RECONSTRUCTION_LANGS.get(target_lang, target_lang)
        return f"Reconstruction:{recon_lang}/{recon_word}", True

    return url_word, False


def is_valid_word(word: str) -> bool:
    """
    空単語・記号のみの単語を除外するバリデーション関数。
    make_page_name() で url_word が空文字列になる単語は API エラーになるため、
    メインループで処理前に除外する。

    除外対象: 省略記号・波線・空白・ハイフン・括弧・アスタリスク・引用符のみで構成される文字列。
    これらは Wiktionary のページ名として意味を持たない記号。

    戻り値: 有効な単語であれば True、そうでなければ False。
    """
    clean = re.sub(r'[.…〜~\s\-\(\)*\'\"]', '', str(word)).strip()
    return bool(clean)


def normalize_title(title: str) -> str:
    """
    Wikimedia API がページ名を正規化する際のルールを模倣する。

    action=query の応答では API がページ名を自動正規化するため、
    入力ページ名と応答タイトルを文字列比較する際にそのまま突き合わせると
    マッチしないことがある。この関数で両者を同じ形式に揃えてから比較する。

    正規化ルール(Wikimedia の実装に準拠):
      1. パーセントエンコードを解除(%27 → ' 等)
      2. アンダースコアをスペースに統一(Wikipedia/Wiktionary はどちらも同じページを指す)
      3. 先頭文字を大文字化(Wiktionary は先頭文字を大文字扱いする)

    例:
      "proto-austronesian" → "Proto-austronesian"
      "reconstruction:Proto-Austronesian/aba" → "Reconstruction:Proto-Austronesian/aba"
    """
    decoded = urllib.parse.unquote(title)
    spaced = decoded.replace("_", " ")
    return spaced[0].upper() + spaced[1:] if spaced else spaced


# ==============================================================================
# HTML パース処理(メイン解析ロジック)
# ==============================================================================

def parse_section(html_content: str, word: str, target_lang: str, is_reconstruction: bool) -> list:
    """
    Wiktionary の HTML から指定言語のセクションを抽出し、
    単語データ(品詞・意味・IPA・語源等)を解析して返す。

    引数:
      html_content   : action=parse API が返した HTML 文字列
      word           : 元の入力単語(出力レコードの word カラムに使う)
      target_lang    : 取得対象の言語名(セクション特定に使う)
      is_reconstruction: Reconstruction ページかどうか

    戻り値:
      list[dict]: 各要素が1レコード。取得できなかった場合は空リスト。

    -----------------------------------------------------------------------
    【保守者向け解説: ロジックの根幹】

    PHASE 1 - セクション抽出 (Section Targeting):
      Wiktionary は1ページに複数言語のセクションが並ぶ構造になっている。
      HTML 文字列から id="言語名" を検索し、該当位置から次の <h2> までを
      BeautifulSoup の解析対象(section_html)として切り出す。
      Reconstruction ページは1ページ = 1言語なので切り出し不要。

      【id バリアントについて】
      Wiktionary の h2 id は言語名のスペースをアンダースコアに変換することがある。
      "Ancient Greek" → id="Ancient_Greek" になるケースがあるため、複数パターンを試みる。
      新しい言語で取得できない場合はデバッグスクリプトで実際の id を確認し、
      lang_id_variants にバリアントを追加すること(既存のバリアントは削除しないこと)。

    PHASE 2 - IPA事前収集 (IPA Pre-Collection):
      言語によって IPA の HTML 構造が異なるため、戦略リスト (ipa_strategies) で
      複数パターンに対応する。戦略は上から順に試行し、値が取れた時点で終了する。
      新しい言語パターンが必要な場合は既存戦略を変更せずリストに追記する。

    PHASE 3 - 状態保持走査 (Stateful Scan):
      HTML の入れ子構造は言語・単語ごとに異なるため、親子関係に依存せず
      soup.find_all(True) で全タグを出現順に走査する(一本道スキャン)。
      Etymology 見出し・意味リスト(ol) を通過するたびに状態変数を更新し、
      ol に到達した時点で直前の状態(IPA・語源)を紐付けてレコード確定する。

      【Etymology のスコープ判定】
      Etymology 見出し直後の <p> タグが語源テキストだが、sourceline 属性は
      BeautifulSoup が文字列パース時に None を返す場合があるため、
      DOM 上のインデックス順序で前後関係を判定するフォールバックを持つ。
    -----------------------------------------------------------------------
    """

    # -----------------------------------------------------------------------
    # PHASE 1: 言語セクションの特定
    # -----------------------------------------------------------------------
    if is_reconstruction:
        # Reconstruction ページはページ全体が対象言語のコンテンツ
        section_html = html_content
    else:
        # h2 の id 属性で言語セクションを特定する。
        # バリアントを順に試し、最初にマッチしたものを採用する。
        # 【保守者へ】新しい言語で取得できない場合はここにバリアントを追加すること。
        #             既存のバリアントは削除しないこと(他言語への影響がある)。
        lang_id_variants = [
            target_lang,                                       # 例: "Ancient Greek"
            target_lang.replace(" ", "_"),                     # 例: "Ancient_Greek"
            target_lang.replace("-", " "),                     # 例: "Proto Austronesian"
            target_lang.replace("-", "_"),                     # 例: "Proto_Austronesian"
            target_lang.replace(" ", "_").replace("-", "_"),   # 例: "Old_High_German"
        ]

        start_idx = -1
        matched_section_id = None
        for lang_id in lang_id_variants:
            section_id = f'id="{lang_id}"'
            idx = html_content.find(section_id)
            if idx != -1:
                start_idx = idx
                matched_section_id = section_id
                break

        if start_idx == -1:
            # 対象言語のセクションが存在しない(単語はあるが当該言語の記載なし)
            return []

        # 次の <h2>(別言語セクションの開始)までを切り出す
        end_idx = html_content.find('<h2', start_idx + len(matched_section_id))
        section_html = (
            html_content[start_idx:end_idx] if end_idx != -1 else html_content[start_idx:]
        )

    soup = BeautifulSoup(section_html, "html.parser")

    # 編集リンク([edit] ボタン)はテキスト取得時にノイズになるため事前に除去する
    for edit in soup.find_all(class_='mw-editsection'):
        edit.decompose()

    # -----------------------------------------------------------------------
    # PHASE 2: IPA 事前収集
    # -----------------------------------------------------------------------
    # セクション全体から IPA を先にまとめて収集する。
    # スキャンループ中に逐次上書きすると「最後の1件」しか残らないため、
    # このフェーズで全件収集・正規化してから state_ipa に格納する。
    #
    # 正規化ルール:
    #   収集0件 → "-"(IPA 記載なし)
    #   収集1件 → 囲み文字 /…/ または […] を除去したクリーン値
    #   収集複数件 → 元の表記のまま ", " で連結(代表値を判断しない)

    def collect_ipa(section_soup: BeautifulSoup) -> str:
        """
        セクション内の IPA 表記を収集・正規化して返す。

        【保守者向け: IPA 抽出戦略リスト】
        ipa_strategies は (戦略名, 抽出関数) のタプルのリスト。
        上から順に試行し、値が取れた時点で終了する。

        新しい言語パターンが必要な場合:
          - 既存の戦略は変更・削除しないこと(他言語への影響がある)
          - リストの末尾(「戦略3以降」のコメント位置)に追記すること
          - 抽出関数のシグネチャ: fn(soup: BeautifulSoup) -> list[str]
        """

        def extract_from_spans(spans) -> list:
            """
            class="IPA" を持つ span 要素のリストから実際の IPA 値のみを返す。
            "IPA", "IPA(key):" 等のラベル文字列は除外する。
            ラベルの判定基準: テキストが "ipa" で始まり、かつ15文字未満。
            """
            vals = []
            for span in spans:
                text = span.get_text().strip()
                # ラベル文字列(例: "IPA", "IPA(key):")を除外
                # 15文字の根拠: "IPA(key):" が9文字、余裕を持たせて15文字未満をラベルとみなす
                if text.lower().startswith("ipa") and len(text) < 15:
                    continue
                if text:
                    vals.append(text)
            return vals

        ipa_strategies = [
            # ------------------------------------------------------------------
            # 戦略1: "IPA" テキストを含む <li> 内の class="IPA" スパンを収集
            #
            # 対象言語例: Korean
            # 採用理由:
            #   Korean の Pronunciation セクションは "Romanizations" という
            #   サブセクションを持ち、そこにも class="IPA" スパンが存在する。
            #   セクション全体から収集すると転写(romanization)が混入してしまう。
            #   "IPA" テキストを含む <li> 内に絞ることでこれを回避する。
            #
            # HTML 構造のイメージ:
            #   <li>(SK Standard/Seoul) IPA(key): <span class="IPA">[값]</span></li>
            #   <li>Phonetic hangul: ...</li>       ← "IPA" を含まないのでスキップ
            # ------------------------------------------------------------------
            (
                "IPA(key) li scope",
                lambda s: [
                    v
                    for li in s.find_all("li") if "IPA" in li.get_text()
                    for v in extract_from_spans(li.find_all(class_="IPA"))
                ]
            ),

            # ------------------------------------------------------------------
            # 戦略2: セクション全体から class="IPA" スパンを収集
            #
            # 対象言語例: Thai, Tocharian A/B, 多くの言語
            # 採用理由:
            #   戦略1(li スコープ)では取得できない言語に対するフォールバック。
            #   Thai 等は <li> を使わず直接 <span class="IPA">/kaː/</span> を置く。
            # ------------------------------------------------------------------
            (
                "section-wide IPA span",
                lambda s: extract_from_spans(s.find_all(class_="IPA"))
            ),

            # ------------------------------------------------------------------
            # 戦略3以降: 新しい言語パターンが見つかった場合はここに追記する
            #
            # 追記フォーマット:
            # (
            #     "戦略名(対象言語例と採用理由を記述)",
            #     lambda s: [...]  # 抽出ロジック
            # ),
            # ------------------------------------------------------------------
        ]

        values = []
        for _name, fn in ipa_strategies:
            values = fn(section_soup)
            if values:
                break

        if not values:
            return "-"
        if len(values) == 1:
            # /…/ または […] の囲み文字を除去して返す
            return re.sub(r'^[/\[]|[/\]]$', '', values[0]).strip()
        # 複数値はそのまま連結(どれが代表かを判断しない)
        return ", ".join(values)

    state_ipa = collect_ipa(soup)

    # -----------------------------------------------------------------------
    # PHASE 3: 順次スキャン実行
    # -----------------------------------------------------------------------
    # soup.find_all(True) で全タグを出現順に走査する。
    # 親子関係に依存しない一本道スキャンを採用する理由:
    #   Wiktionary の HTML 構造は言語・単語ごとに異なり、特定の親子関係に
    #   依存すると言語ごとに別ロジックが必要になる。
    #   全タグを出現順に見ていき「今どのセクションにいるか」を
    #   state_ety 変数で追跡することで汎用的に対応できる。

    results = []
    state_ety = "-"       # 直前に通過した Etymology テキストを保持する
    processed_ols = set() # 同一 <ol> を重複処理しないためのガード

    # 全要素リストを Etymology スコープ判定用に事前取得する。
    # sourceline が None の場合の DOM 順序フォールバックで使う。
    #
    # all_elems.index() は O(n) の線形探索なので、多義語等の要素数が多いページでは
    # Etymology ごとに全要素を走査することになり遅くなる。
    # elem_index に {id(elem): インデックス} の辞書を事前構築することで O(1) で引けるようにする。
    # id() を使う理由: BeautifulSoup の要素は == 比較が内容一致判定になるため、
    # 同一オブジェクトの識別にはオブジェクトIDを使う必要がある。
    all_elems  = list(soup.find_all(True))
    elem_index = {id(e): i for i, e in enumerate(all_elems)}

    for elem in all_elems:
        tag = elem.name

        # ---- Etymology 見出し処理 ----
        # h3/h4/h5 で "Etymology" を含む見出しに到達したら語源テキストを更新する。
        # 次の見出し or 意味リスト(ol) よりも前に <p> タグがあればそれが語源テキスト。
        if tag in ['h3', 'h4', 'h5'] and "Etymology" in elem.get_text():
            state_ety = "-"  # 新しい Etymology セクションに入るのでリセット
            p_candidate = elem.find_next("p")
            if p_candidate:
                next_limit = elem.find_next(['h3', 'h4', 'h5', 'h2', 'ol'])

                # sourceline による行番号比較が可能な場合はそちらを優先する。
                # BeautifulSoup が文字列から HTML をパースした場合、sourceline が
                # None になることがあるため、DOM インデックス順序でフォールバックする。
                p_src  = getattr(p_candidate, 'sourceline', None)
                nl_src = getattr(next_limit,  'sourceline', None) if next_limit else None

                if p_src is not None and nl_src is not None:
                    # sourceline が両方取れた場合: 行番号で比較
                    if p_src < nl_src:
                        state_ety = " ".join(p_candidate.get_text().split())
                else:
                    # フォールバック: DOM 上のインデックス順序で比較
                    p_pos  = elem_index.get(id(p_candidate), -1)
                    nl_pos = (
                        elem_index.get(id(next_limit), len(all_elems))
                        if next_limit else len(all_elems)
                    )
                    if p_pos != -1 and p_pos < nl_pos:
                        state_ety = " ".join(p_candidate.get_text().split())

        # ---- 意味リスト(ol) 処理 ----
        # <ol> タグが意味リストの本体。到達したらレコードを確定する。
        if tag == 'ol' and elem not in processed_ols:
            processed_ols.add(elem)

            # 品詞(POS) は直前の h3/h4/h5 見出しテキストから取得する。
            # 見出しに "•" や "(" が含まれる場合(例: "Verb • transitive")は
            # 最初のトークンのみを品詞名として採用する。
            prev_h = elem.find_previous(['h3', 'h4', 'h5'])
            pos_raw  = prev_h.get_text().strip() if prev_h else "Definition"
            pos_name = re.split(r' •| \(', pos_raw)[0].strip()

            # 意味データを持たないメタセクションは除外する。
            # 【保守者へ】除外すべき新しいセクション名が見つかった場合は
            #             モジュール冒頭の META_SECTIONS 定数に追記すること。
            if any(x in pos_name for x in META_SECTIONS):
                continue

            # 転写 (transcription): 品詞直前の <p> タグ内の class="tr" 要素から取得する。
            # 韓国語・アラビア語等でローマ字転写が記載されている場合に取得できる。
            transcription = "-"
            p_prev = elem.find_previous("p")
            if p_prev:
                tr_node = p_prev.find(class_="tr") or p_prev.find("i", class_="tr")
                if tr_node:
                    transcription = tr_node.get_text().strip()

            # 意味項目 (<li>) を収集・クリーンアップする。
            # 各 <li> から例文(dl)・サブリスト(ul)・用例スパン等を除去してテキスト化する。
            meanings = []
            for li in elem.find_all("li", recursive=False):
                li_copy = BeautifulSoup(str(li), "html.parser").li
                # 除去対象:
                #   dl  : 例文(定義直下の字下げブロック)
                #   ul  : サブ意味リスト(入れ子の箇条書き)
                #   span.h-usage-example, span.ux, span.f-key-example: 用例スパン
                for junk in li_copy.select(
                    "dl, ul, span.h-usage-example, span.ux, span.f-key-example"
                ):
                    junk.decompose()
                m_text = li_copy.get_text().strip()
                if m_text:
                    meanings.append(m_text)

            if meanings:
                results.append({
                    "word":          word,
                    "transcription": transcription,
                    "IPA":           state_ipa,
                    "POS":           pos_name,
                    "meaning":       "\n".join(meanings),
                    "etymology":     state_ety,
                })

    return results


# ==============================================================================
# API 取得処理
# ==============================================================================

def fetch_html_single(page_name: str, headers: dict) -> str | None:
    """
    Wikimedia API の action=parse で1ページ分の HTML を取得して返す。

    引数:
      page_name: Wiktionary のページ名(make_page_name() の戻り値)
      headers  : HTTP リクエストヘッダー(User-Agent を含む)

    戻り値:
      HTML 文字列。取得できなかった場合は None。

    失敗ケース:
      - HTTP ステータスが 200 以外(403 Forbidden, 404 Not Found 等)
      - API レスポンスに "error" キーが含まれる(ページ不存在等)
      - ネットワークエラー・タイムアウト(呼び出し元でキャッチすること)
    """
    api_url = (
        "https://en.wiktionary.org/w/api.php"
        f"?action=parse"
        f"&page={urllib.parse.quote(page_name, safe=SAFE_CHARS)}"
        f"&prop=text"
        f"&format=json"
        f"&formatversion=2"
    )
    try:
        resp = requests.get(api_url, headers=headers, timeout=25)
    except requests.exceptions.Timeout:
        print(f"\n[Warning] Timeout: {page_name}")
        return None
    except requests.exceptions.RequestException as e:
        print(f"\n[Warning] Network error ({page_name}): {e}")
        return None

    if resp.status_code != 200:
        print(f"\n[Warning] HTTP {resp.status_code}: {page_name}")
        return None

    data = resp.json()
    if "error" in data:
        # ページが存在しない場合など。通常運用では頻出するため警告は出さない。
        return None

    return data.get("parse", {}).get("text") or None


def fetch_html_batch(page_entries: list, headers: dict) -> dict:
    """
    最大 API_MAX_TITLES ページを2ステップで一括取得する。

    引数:
      page_entries: [(page_name, word), ...] のリスト。
                    辞書 {page_name: word} を使わない理由:
                    異なる単語が同じページ名に正規化された場合に辞書はキーが衝突し
                    後の単語が前の単語を上書きしてデータ欠落が起きる。
                    リストなら順序を保持しつつ重複ページ名を許容できる。
      headers     : HTTP リクエストヘッダー

    戻り値:
      {page_name: html_text} の辞書。取得できなかったページは含まれない。

    ステップ詳細:
      Step1 (存在確認・バッチ):
        action=query&titles=A|B|C|... で全ページの存在を1リクエストで確認する。
        API は "missing": true フラグで存在しないページを示す。
        このステップで存在しないページを除外することで、
        Step2 の action=parse コール数を削減できる。

        【正規化マッチングについて】
        API は応答でページ名を正規化する(先頭大文字化・アンダースコア→スペース等)。
        入力ページ名と応答タイトルを文字列比較する際は normalize_title() で
        両者を同じ形式に揃えてから比較する。

      Step2 (HTML 取得・個別):
        存在が確認されたページのみ fetch_html_single() で HTML を取得する。
        parse API は複数ページの一括取得に対応していないため1件ずつ処理する。
        コール間に 0.1 秒の最小待機を入れてサーバー負荷を軽減する。
    """
    page_names = [pn for pn, _ in page_entries]

    # Step1: ページ存在確認(バッチ・1リクエスト)
    titles_param = "|".join(
        urllib.parse.quote(p, safe=SAFE_CHARS) for p in page_names
    )
    query_url = (
        "https://en.wiktionary.org/w/api.php"
        f"?action=query"
        f"&titles={titles_param}"
        f"&format=json"
        f"&formatversion=2"
    )
    try:
        resp = requests.get(query_url, headers=headers, timeout=25)
    except requests.exceptions.RequestException as e:
        print(f"\n[Warning] Batch query failed: {e}")
        return {}

    if resp.status_code != 200:
        print(f"\n[Warning] Batch query HTTP {resp.status_code}")
        return {}

    data  = resp.json()
    pages = data.get("query", {}).get("pages", [])

    # missing フラグがないページの正規化タイトルを収集する
    existing_normalized = set()
    for page in pages:
        if not page.get("missing", False):
            existing_normalized.add(normalize_title(page.get("title", "")))

    # Step2: 存在するページのみ action=parse でHTML取得
    results = {}
    for page_name in page_names:
        if normalize_title(page_name) not in existing_normalized:
            continue  # 存在しないページはスキップ(失敗カウントは呼び出し元で管理)

        html = fetch_html_single(page_name, headers)
        if html:
            results[page_name] = html

        # parse API 連続コール間の最小待機(サーバー負荷軽減)
        time.sleep(0.1)

    return results


# ==============================================================================
# 出力処理(完了時・中断時共通)
# ==============================================================================

def save_and_download(
    all_data:        list,
    processed_words: list,
    found_words:     set,
    total:           int,
    found_count:     int,
    not_found_count: int,
    output_filename: str = "Wiktionary_Extracted_Data.xlsx",
    label:           str = "処理完了",
) -> None:
    """
    取得データを整形して Excel に保存・ダウンロードする。
    処理完了時・中断時の両方から呼ばれる共通処理。

    重複除去ルール:
      word + POS + meaning + etymology の4列が完全一致する行を重複とみなして除去する。
      etymology を含める理由:
        同じ単語・品詞・意味でも語源が異なる場合(同形異源語)は
        言語学的に別エントリであるため保持する必要がある。

    未取得単語の処理:
      processed_words のうち found_words に含まれない単語を「未取得」とみなし、
      word カラムのみ埋めた空欄行として出力ファイルの末尾に追加する。
      これにより入力リストとの対応を確認しやすくなる。
    """
    final_df = (
        pd.DataFrame(
            all_data,
            columns=["word", "transcription", "IPA", "POS", "meaning", "etymology"]
        )
        if all_data
        else pd.DataFrame(
            columns=["word", "transcription", "IPA", "POS", "meaning", "etymology"]
        )
    )

    # etymology を含む4列で重複除去(同形異源語を保持するため etymology を追加)
    final_df.drop_duplicates(
        subset=["word", "POS", "meaning", "etymology"],
        inplace=True
    )

    # 未取得の単語を空欄行として末尾に追加
    not_found_words = [w for w in processed_words if w not in found_words]
    if not_found_words:
        empty_rows = pd.DataFrame([
            {"word": w, "transcription": "", "IPA": "", "POS": "", "meaning": "", "etymology": ""}
            for w in not_found_words
        ])
        final_df = pd.concat([final_df, empty_rows], ignore_index=True)

    final_df = final_df[["word", "transcription", "IPA", "POS", "meaning", "etymology"]]
    final_df.to_excel(output_filename, index=False)
    files.download(output_filename)

    print(f"\n\n{'='*50}")
    print(f"  {label}")
    print(f"{'='*50}")
    print(f"  処理対象  : {len(processed_words)} 語(全 {total} 語中)")
    print(f"  取得成功  : {found_count} 件")
    print(f"  取得失敗  : {not_found_count} 件")
    print(f"  出力ファイル: {output_filename}")
    print(f"{'='*50}")


# ==============================================================================
# 進捗表示
# ==============================================================================

def print_progress(processed: int, total: int, found_count: int, not_found_count: int, label: str = "") -> None:
    """
    処理進捗を1行で上書き表示する。
    バッチ処理の各バッチ完了時に呼ばれる。

    引数:
      processed      : これまでに処理した単語数
      total          : 処理対象の総単語数
      found_count    : 取得成功数
      not_found_count: 取得失敗数
      label          : 行末に表示する補足情報(現在処理中の単語名など)
    """
    print(
        f"\r[{processed}/{total}] "
        f"取得: {found_count}  失敗: {not_found_count}  "
        f"{label[:30]}",  # 長すぎる単語名は30文字で打ち切る
        end=""
    )


# ==============================================================================
# 実行メイン
# ==============================================================================

print("--- Wiktionary Batch Extractor Ready ---")
uploaded = files.upload()

# User-Agent は Wikimedia API ポリシーに準拠した形式にする。
# 参考: https://www.mediawiki.org/wiki/API:Etiquette
# 形式: "ツール名/バージョン (連絡先メールアドレス)"
# メールアドレスを含めることでブロックされにくくなる。
# このスクリプトの配布元: https://language-geek.com
headers = {"User-Agent": f"WiktionaryExtractor/1.0 ({CONTACT_EMAIL})"}

for filename in uploaded:
    df = pd.read_excel(filename, header=None)

    # -----------------------------------------------------------------------
    # 言語名の決定(優先順位):
    #   1. フォームの TARGET_LANGUAGE が入力されていればそれを使う
    #   2. 空欄の場合は入力ファイルの A1 セル(df.iloc[0, 0])から取得する
    #
    # A1 セルに言語名が入っている想定のフォーマット:
    #   A1: "Ancient Greek"  ← 言語名
    #   A2: "ἀγαθός"         ← 単語データ開始
    #   A3: "ἄνθρωπος"
    #   ...
    #
    # A1 が言語名でない場合(単語データが A1 から始まる場合)は
    #   フォームで TARGET_LANGUAGE を手動指定すること。
    # -----------------------------------------------------------------------
    a1_value = str(df.iloc[0, 0]).strip() if pd.notna(df.iloc[0, 0]) else ""
    lang = TARGET_LANGUAGE.strip() if TARGET_LANGUAGE.strip() else a1_value

    if not lang:
        print(f"[Error] 言語名が特定できません。"
              f"フォームの TARGET_LANGUAGE を入力するか、A1 セルに言語名を記載してください。")
        continue

    # 言語名の確認出力(不可視文字混入などのトラブル時のデバッグ用)
    print(f"[Info] 言語名を確認: '{lang}' (文字数: {len(lang)})")

    # -----------------------------------------------------------------------
    # 単語リストの取得
    # A1 が言語名として使われた場合(フォーム未入力)は A2 以降を単語として読む。
    # フォームに言語名が入力されている場合は A1 から全行を単語として読む。
    # -----------------------------------------------------------------------
    if TARGET_LANGUAGE.strip():
        # フォーム入力あり: A1 から全行が単語データ
        word_series = df.iloc[:, 0].dropna()
    else:
        # A1 を言語名として使用: A2 以降が単語データ
        word_series = df.iloc[1:, 0].dropna()

    all_words    = word_series.unique()
    unique_words = all_words[:MAX_WORDS] if MAX_WORDS > 0 else all_words
    total        = len(unique_words)

    # -----------------------------------------------------------------------
    # 出力ファイル名の決定(優先順位):
    #   1. フォームの OUTPUT_FILENAME が入力されていればそれを使う(.xlsx なければ付与)
    #   2. 空欄の場合は "Wiktionary_言語名_YYYYMMDD.xlsx" を自動生成する
    #
    # safe_lang の変換ルール:
    #   ファイル名に使えない文字(スペース / : * ? " < > | など)を
    #   アンダースコアに置換し、先頭・末尾の余分なアンダースコアを除去する。
    #   変換後が空になった場合は "Unknown" にフォールバックする。
    # -----------------------------------------------------------------------
    if OUTPUT_FILENAME.strip():
        output_filename = OUTPUT_FILENAME.strip()
        if not output_filename.lower().endswith(".xlsx"):
            output_filename += ".xlsx"
    else:
        date_str  = datetime.date.today().strftime("%Y%m%d")
        safe_lang = re.sub(r'[\\/:*?"<>|\s]', '_', lang)
        safe_lang = safe_lang.strip("_") or "Unknown"
        output_filename = f"Wiktionary_{safe_lang}_{date_str}.xlsx"

    print(f"[Info] 出力ファイル名: '{output_filename}'")

    print(f"\n--- セッション開始 ---")
    print(f"  言語      : {lang}")
    print(f"  処理対象  : {total} 語(全 {len(all_words)} 語中)")
    print(f"  バッチサイズ: {BATCH_SIZE}")
    print(f"  Reconstruction: {is_reconstruction_lang(lang)}")
    print(f"  出力ファイル: {output_filename}")

    # 全単語を (page_name, word, is_reconstruction) のタプルリストに変換する。
    # 通常語と Reconstruction 語を分けずに同一リストで管理する。
    #
    # 【設計メモ: Reconstruction 語もバッチ処理できる理由】
    # 以前は Reconstruction 語を個別処理にフォールバックしていたが、
    # action=query の titles パラメータには "Reconstruction:Lang/word" 形式の
    # ページ名もそのまま渡せる。通常語と混在させても問題ない。
    # parse_section() に渡す is_reconstruction フラグをエントリに持たせることで
    # バッチ内で両者を区別して正しくパースできる。
    all_entries = []
    for word in unique_words:
        if not is_valid_word(word):
            continue
        page_name, is_recon = make_page_name(word, lang)
        all_entries.append((page_name, word, is_recon))

    all_data        = []
    found_words     = set()
    found_count     = 0
    not_found_count = 0
    processed_words = []
    processed       = 0

    try:
        # -------------------------------------------------------------------
        # 全単語をバッチ処理(通常語・Reconstruction 語を区別しない)
        # -------------------------------------------------------------------
        for batch_start in range(0, len(all_entries), BATCH_SIZE):
            batch = all_entries[batch_start: batch_start + BATCH_SIZE]

            # fetch_html_batch には (page_name, word) のペアリストを渡す。
            # is_reconstruction フラグはパース時に使うため別途保持する。
            page_entries  = [(pn, w) for pn, w, _ in batch]
            recon_flags   = {pn: ir for pn, _, ir in batch}

            html_map = fetch_html_batch(page_entries, headers)

            # parse_section の結果をページ名でキャッシュする。
            # 同一バッチ内に同じページ名を持つ複数単語が存在する場合
            # (異なる入力表記が同一ページ名に正規化されるケース)、
            # HTML パースを重複して行わないようにする。
            # キャッシュキー: page_name / 値: word を "__cached__" にした結果リスト
            parse_cache: dict = {}

            for page_name, word in page_entries:
                processed += 1
                processed_words.append(word)

                html      = html_map.get(page_name)
                is_recon  = recon_flags.get(page_name, False)
                extracted = []

                if html:
                    if page_name in parse_cache:
                        # キャッシュヒット: パース結果を再利用しつつ word を差し替える
                        extracted = [
                            {**rec, "word": word} for rec in parse_cache[page_name]
                        ]
                    else:
                        try:
                            extracted = parse_section(html, word, lang, is_recon)
                            # word を "__cached__" に差し替えてキャッシュ保存する
                            # (次の単語で word だけ差し替えて再利用するため)
                            parse_cache[page_name] = [
                                {**rec, "word": "__cached__"} for rec in extracted
                            ]
                        except Exception as e:
                            print(f"\n[Error] parse_section failed ({word}): {e}")

                if extracted:
                    all_data.extend(extracted)
                    found_words.add(word)
                    found_count += 1
                else:
                    not_found_count += 1

            print_progress(
                processed, total, found_count, not_found_count,
                label=f"バッチ完了: {batch_start + len(batch)} 語"
            )
            # バッチ間の待機(サーバー負荷軽減。Wikimedia API ポリシーに準拠)
            time.sleep(random.uniform(1.0, 1.5))

        save_and_download(
            all_data, processed_words, found_words, total,
            found_count, not_found_count,
            output_filename=output_filename, label="処理完了サマリー"
        )

    except KeyboardInterrupt:
        # Colab の停止ボタン(■)または Ctrl+C で中断した場合。
        # その時点までのデータを保存してダウンロードする。
        print(f"\n\n[中断] {processed} 語処理時点で停止しました。途中結果を保存します...")
        save_and_download(
            all_data, processed_words, found_words, total,
            found_count, not_found_count,
            output_filename=output_filename, label="中断時サマリー"
        )