SeleniumでアコーディオンのinnerTextが空/途中で止まる問題を解決【段階スクロール×安定化判定】

Selenium(Python)でアコーディオンの innerText が空/途中までになる問題の解決法【段階スクロール×安定化判定・コピペ実装】のアイキャッチ
  • URLをコピーしました!

アコーディオンを開いたのに、innerText が空だったり途中までしか取れない……
Seleniumではよくある落とし穴です。
主な原因は、遅延描画・独立スクロール・仮想リストです。

本記事は、段階スクロールテキスト長の安定化判定を軸にした“観測駆動”テンプレートで、Seleniumにおける「アコーディオンのinnerTextが空/途中までしか取れない問題」を解決します。

見出しから対象を特定→必要なら展開→ページ末尾までの遅延描画促進→コンテナ自身のスクロール→テキスト長が連続して変化しなくなるまで待機、という手順で取りこぼしを実用上ゼロに近づけます

すぐ使えるコピペ実装とチェックリスト付き。
実サイトではセレクタの粒度だけ最小変更すれば導入できます。

テンプレの理解が倍速に。
いちど通読しておくと”取りこぼしゼロ化”がスムーズです

目次

先に結論(Selenium×アコーディオン×innerTextの要点)

アコーディオンは、開いて見えていても中身がまだ描画されていなかったり、独立スクロール領域や仮想リストでスクロールを重ねるほどテキストが増えることがあります。
ここでは次の流れで、取りこぼしを実用上ゼロに近づけるテンプレートにします。

  1. 見出しテキストからアコーディオンのコンテナを特定(<section class="accordion"><details> を両対応)
  2. 未展開なら展開aria-expanded または <details open> 判定)
  3. ページ全体を末尾まで数ラウンドスクロールして遅延描画を促進
  4. コンテナ自身を最下部まで段階スクロール(監視ベース描画に効く)
  5. テキスト長が連続して変化しなくなるまで観測(=安定化判定)。必要に応じて MutationObserverDOMアイドルを待機
  6. 仕上げに再観測し、長い方のテキストを採用

セレクタやXPathは汎用例です。
実DOMではタグ名・クラス名のみ最小変更で合わせてください。

フル実装(段階スクロール+安定化判定+details対応+MutationObserver)

主要ポイント
  • <details> と一般的なアコーディオンを自動分岐
  • 段階スクロール(IntersectionObserver対策)
  • テキスト長は textContent で軽量計測 → 最後だけ innerText
  • StaleElement の軽いリトライ
  • 任意で MutationObserver によるDOMアイドル待機

動作環境:Selenium 4系 / Python 3.x / Chromium系ブラウザ

夜間ジョブや長時間観測に。

# -*- coding: utf-8 -*-
"""
Seleniumでアコーディオン本文を“取りこぼしなく”抽出する観測テンプレート
- 段階スクロール + テキスト長の安定化判定
- <details> / 一般的なアコーディオン両対応
- DOMアイドル待機(MutationObserver, 任意)
- XPathリテラル安全化(引用符混在に強い)
"""

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
    TimeoutException,
    ElementClickInterceptedException,
    NoSuchElementException,
    StaleElementReferenceException,
)
import time

WAIT_SEC = 20  # 明示的待機の基準秒数(サイトの重さに応じて調整)


# ---------------------------
# 基本ユーティリティ
# ---------------------------

def safe_click(driver, el, scroll=True):
    """通常クリック → 遮られたら scrollIntoView + JSクリック。"""
    try:
        if scroll:
            driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
            # 二重 rAF で“描画2フレーム分の待ち”を確実化
            driver.execute_async_script(
                "requestAnimationFrame(()=>requestAnimationFrame(arguments[arguments.length-1]))"
            )
        el.click()
    except ElementClickInterceptedException:
        driver.execute_script("arguments[0].click();", el)


def scroll_window_to_bottom(driver, rounds=4, pause=0.35):
    """ページ全体を最下部へ数ラウンド。遅延描画を促進。"""
    last_height = 0
    for _ in range(rounds):
        driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
        time.sleep(pause)
        new_height = driver.execute_script("return document.body.scrollHeight") or 0
        if new_height == last_height:
            break
        last_height = new_height
    # 少し戻して末尾描画をもう一段促進
    driver.execute_script(
        "window.scrollTo(0, Math.max(0, document.body.scrollHeight - 400));"
    )
    time.sleep(0.2)


def is_scrollable(driver, el) -> bool:
    """要素自身が独立スクロール可能か。"""
    try:
        return bool(
            driver.execute_script(
                "return arguments[0].scrollHeight > arguments[0].clientHeight;", el
            )
        )
    except Exception:
        return False


def scroll_element_incremental(driver, el, steps=8, pause=0.12, ratio=0.85):
    """
    監視ベース描画に強い段階スクロール。
    clientHeight の ratio 分ずつ進めて描画を確実にトリガ。
    """
    if not is_scrollable(driver, el):
        return
    h = driver.execute_script("return arguments[0].clientHeight;", el) or 400
    delta = int(h * ratio)
    for _ in range(max(1, steps)):
        driver.execute_script("arguments[0].scrollTop += arguments[1];", el, delta)
        time.sleep(pause)


def scroll_element_to_bottom(driver, el, rounds=6, pause=0.25, stable_hits=2, mode="step"):
    """
    コンテナを最下部まで複数回送り続ける。
    mode='step' なら段階スクロール、'jump' なら一気に scrollHeight へ。
    scrollTop が連続 stable_hits 回変化しなければ終了。
    """
    if not is_scrollable(driver, el):
        return
    last_top, stable = -1, 0
    for _ in range(rounds):
        if mode == "step":
            scroll_element_incremental(driver, el, steps=4, pause=pause / 2, ratio=0.9)
        driver.execute_script("arguments[0].scrollTop = arguments[0].scrollHeight;", el)
        time.sleep(pause)
        cur_top = driver.execute_script("return arguments[0].scrollTop;", el) or 0
        if cur_top == last_top:
            stable += 1
        else:
            stable = 0
            last_top = cur_top
        if stable >= stable_hits:
            break


def _text_len_fast(driver, el) -> int:
    """textContent を用いた軽量な長さ計測。"""
    try:
        return int(
            driver.execute_script(
                "const n=arguments[0]; return (n.textContent||'').trim().length;", el
            )
            or 0
        )
    except Exception:
        return 0


def inner_text(driver, el) -> str:
    """最終取得用の innerText(改行やCSS影響を反映)。"""
    try:
        return (
            driver.execute_script(
                "const n=arguments[0]; return (n.innerText||'').trim();", el
            )
            or ""
        )
    except Exception:
        return ""


def wait_dom_idle(driver, el, idle_ms=900, timeout_ms=4000):
    """
    MutationObserver で DOM 変化が idle_ms 停止するのを待つ(任意)。
    Selenium の execute_async_script を利用。
    """
    script = """
    const [target, idle, limit, cb] = [arguments[0], arguments[1], arguments[2], arguments[arguments.length-1]];
    let done = false, timer = null; const started = Date.now();
    let watchdog = null;  // 先に宣言してTDZ回避
    const finish = () => { 
      if (!done) { 
        done = true; 
        if (watchdog) clearInterval(watchdog); 
        obs.disconnect(); 
        cb(true); 
      } 
    };
    const obs = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(finish, idle); });
    obs.observe(target, {subtree: true, childList: true, characterData: true});
    timer = setTimeout(finish, idle);
    watchdog = setInterval(() => { if (Date.now() - started > limit) { clearInterval(watchdog); finish(); } }, 120);
    """
    try:
        driver.execute_async_script(script, el, int(idle_ms), int(timeout_ms))
    except Exception:
        pass


# ---------------------------
# XPath リテラル安全化
# ---------------------------

def _xpath_literal(s: str) -> str:
    """
    XPath内で安全に文字列リテラルを生成する。
    - 単引号のみ → '...'
    - 二重引用符のみ → "..."
    - 両方含む → concat('a', "\"", 'b', ...)
    """
    if "'" not in s:
        return f"'{s}'"
    if '"' not in s:
        return f'"{s}"'
    parts = s.split("'")
    return "concat(" + ", \"'\", ".join(f"'{p}'" for p in parts) + ")"


# ---------------------------
# アコーディオン特定 & 展開
# ---------------------------

def find_accordion_container_by_heading(driver, title_text: str):
    """
    見出し(h1/h2/h3 の normalize-space 含む)で section 形式のアコーディオンを探す。
    見つからなければ <details><summary> の形式も探して返す。
    """
    t = _xpath_literal(title_text)
    # 1) section + 見出し
    xpath1 = (
        f"//section[contains(@class,'accordion')]"
        f"[.//*[self::h1 or self::h2 or self::h3][contains(normalize-space(), {t})]]"
    )
    # 2) <details> + <summary>
    xpath2 = f"//details[.//summary[contains(normalize-space(), {t})]]"
    wait = WebDriverWait(driver, WAIT_SEC)
    try:
        return wait.until(EC.presence_of_element_located((By.XPATH, xpath1)))
    except TimeoutException:
        return wait.until(EC.presence_of_element_located((By.XPATH, xpath2)))


def ensure_section_expanded(driver, container):
    """
    アコーディオンが閉じていれば開く。
    - <details> なら open 属性
    - それ以外は aria-expanded / .accordion-toggle / [aria-controls] など汎用トグル
    """
    tag = (container.tag_name or "").lower()
    if tag == "details":
        if container.get_attribute("open") is None:
            summary = container.find_element(By.CSS_SELECTOR, "summary")
            safe_click(driver, summary)
        return

    # 汎用トグル
    toggle = None
    for sel in (".accordion-toggle", "[aria-expanded]", "[aria-controls]"):
        try:
            toggle = container.find_element(By.CSS_SELECTOR, sel)
            break
        except NoSuchElementException:
            continue
    if toggle is None:
        # 最低限:container 内の button を当てる(要調整)
        try:
            toggle = container.find_element(By.CSS_SELECTOR, "button")
        except NoSuchElementException:
            return

    expanded = (toggle.get_attribute("aria-expanded") or "").lower() in ("true", "1")
    if not expanded:
        safe_click(driver, toggle)


# ---------------------------
# 観測ループ(安定化判定)
# ---------------------------

def text_after_stabilized_scrolls(
    driver,
    el,
    cycles=6,
    pause=0.35,
    container_scroll_rounds=4,
    length_stable_hits=2,
    use_dom_idle=False,
) -> str:
    """
    “スクロール → 長さ計測”を繰り返し、テキスト長が連続で変わらなくなるまで待つ。
    仮想リストや遅延描画で増える本文を観測的に取り切る。
    """
    prev_len, stable = -1, 0
    for _ in range(max(1, cycles)):
        scroll_element_to_bottom(
            driver,
            el,
            rounds=container_scroll_rounds,
            pause=pause / 2,
            stable_hits=2,
            mode="step",
        )
        cur_len = _text_len_fast(driver, el)
        if cur_len == prev_len:
            stable += 1
        else:
            stable = 0
            prev_len = cur_len
        if use_dom_idle:
            wait_dom_idle(driver, el, idle_ms=int(pause * 1000), timeout_ms=3000)
        if stable >= length_stable_hits:
            break
    return inner_text(driver, el)  # 最後だけ innerText で確定取得


def get_section_text(
    driver,
    title_text: str,
    *,
    wait_visible_timeout=20,
    window_scroll_rounds=4,
    stabilize_cycles=6,
    length_stable_hits=2,
    use_dom_idle=False,
) -> str:
    """
    指定見出しのアコーディオン本文を“取りこぼし最小化”で抽出する高水準関数。
    Stale要素は軽くリトライする。
    """
    attempts = 2
    text_best = ""
    for _ in range(attempts):
        try:
            container = find_accordion_container_by_heading(driver, title_text)

            # 展開(閉じていれば開く)
            ensure_section_expanded(driver, container)

            # ページ全体の遅延描画を進める
            scroll_window_to_bottom(driver, rounds=window_scroll_rounds, pause=0.35)

            # container の可視化を軽く確認(不安定なら続行)
            try:
                WebDriverWait(driver, wait_visible_timeout).until(
                    lambda d: container.is_displayed()
                )
            except TimeoutException:
                pass

            # 観測 1 回目
            t1 = text_after_stabilized_scrolls(
                driver,
                container,
                cycles=stabilize_cycles,
                length_stable_hits=length_stable_hits,
                use_dom_idle=use_dom_idle,
            )
            # 観測 2 回目(末尾合わせ)
            driver.execute_script(
                "arguments[0].scrollIntoView({block:'end'});", container
            )
            time.sleep(0.2)
            t2 = text_after_stabilized_scrolls(
                driver,
                container,
                cycles=max(2, stabilize_cycles // 2),
                length_stable_hits=length_stable_hits,
                use_dom_idle=use_dom_idle,
            )

            text_best = t2 if len(t2) > len(t1) else t1
            break  # 成功
        except StaleElementReferenceException:
            # container が差し替わった場合は 1 回だけ取り直す
            time.sleep(0.2)
            continue

    return text_best

使い方(短例)

from selenium import webdriver

driver = webdriver.Chrome()  # 既存環境ならこの1行で十分

driver.set_script_timeout(10)

text = get_section_text(
    driver,
    title_text="仕様",
    window_scroll_rounds=4,
    stabilize_cycles=6,
    length_stable_hits=2,
    use_dom_idle=True,
)
print(text)

よくある失敗:「innerTextが空」「一部しか取れない」を潰す

  • window だけをスクロールしている
    独立スクロール領域では本文が増えません。
    テンプレはコンテナ自身の scrollTop 操作段階スクロールを組み合わせています。
  • 見た目は開いたのにテキストが空
    展開直後は未描画が多いです。
    テンプレは全体スクロール → コンテナ段階スクロール → 安定化判定の三段で取り切ります。
  • 仮想リストで一度に出ない
    連続してテキスト長が変化しないことを“安定”とみなし、必要ならDOMアイドル待機も併用します。
  • StaleElementReferenceException
    スクロールや再描画で要素が差し替わるUIがあります。テンプレは軽いリトライで再取得します。

MutationObserver/IntersectionObserverの仕組みを押さえると
”なぜ空になるか”が腑に落ちます

¥5,060 (2025/08/17 22:00時点 | Amazon調べ)

発展トピック:クリック遮蔽対策とパフォーマンス最適化

  • クリック遮蔽への備え
    固定ヘッダーや透明オーバーレイでクリックが遮られることがあります。
    safe_clickJSクリック フォールバックを維持し、必要に応じて document.elementFromPoint で直下要素を確認してください。
  • パフォーマンス
    安定化判定は textContent で軽量化し、最終取得だけ innerText にしています。
    描画直後の揺らぎには requestAnimationFrame を数回(上の二重rAF待ち)挟むと追従が良くなります。

ヘッドレス実行やスケール運用に。

チェックリスト(導入前の最終確認)

  • 展開判定は <details open> または aria-expanded を使っている
  • 全体スクロール → コンテナ段階スクロール → 安定化判定の流れを実装した
  • 安定化はテキスト長の連続不変で判定し、必要に応じて DOMアイドル待機を併用した
  • Stale の軽いリトライを入れた
  • セレクタとXPathは汎用例を出発点に、実DOMへ最小変更で合わせた

まとめ:アコーディオン本文を確実に取り切る

アコーディオンUIの本文抽出を「開いたら即 innerText」で済ませるのは、遅延描画・独立スクロール・仮想リストの前では脆弱です。

本稿のテンプレは、段階スクロール安定化判定、必要に応じたDOMアイドル待機で、観測ベースに取り切る設計です。
DOM構造やCSSが違っても、ロジックはそのまま、セレクタの粒度だけを調整すれば使い回せます。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

当ブログの運営者であり全ての記事を書いた陰の者。
なんでも好きなものだけ書きます。
X(旧Twitter)でも更新情報とたまにくだらないことを発信中。
コメント解放しておりませんので、Xの方で感想いただけると嬉しくて更新が早くなる傾向にあります。調子乗りです。

目次