Greasy Fork

Greasy Fork is available in English.

「絵でわかる日本語」 阅读体验增强

将「絵でわかる日本語」网站中的汉字注音由括号形式自动转换为振假名,隐藏广告和无关元素,并支持划词朗读功能,提升阅读体验。

当前为 2025-07-12 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name                 Edewakaru Enhanced
// @name:jp              「絵でわかる日本語」 閲覧体験強化
// @name:zh-CN           「絵でわかる日本語」 阅读体验增强
// @name:zh-TW           「絵でわかる日本語」 閱讀體驗增強
// @namespace            http://greasyfork.icu/users/49949-ipumpkin
// @version              2025.07.12
// @author               iPumpkin
// @description          Enhances reading experience on the "絵でわかる日本語" site by converting kanji readings from parentheses to ruby, hiding ads and clutter, and adding text-to-speech for selected text.
// @description:jp       「絵でわかる日本語」サイト内の漢字の読みを括弧表記から自動でふりがなに変換し、広告や不要な要素を非表示にします。選択テキストの読み上げ機能にも対応し、快適な読書体験を実現します。
// @description:zh-CN    将「絵でわかる日本語」网站中的汉字注音由括号形式自动转换为振假名,隐藏广告和无关元素,并支持划词朗读功能,提升阅读体验。
// @description:zh-TW    將「絵でわかる日本語」網站中的漢字注音由括號形式自動轉換為振假名,隱藏廣告與無關元素,並支援劃詞朗讀功能,提升閱讀體驗。
// @license              GPL-3.0
// @icon                 https://livedoor.blogimg.jp/edewakaru/imgs/8/c/8cdb7924.png
// @match                https://www.edewakaru.com/*
// @grant                GM_addStyle
// @grant                GM_getValue
// @grant                GM_setValue
// @run-at               document-start
// ==/UserScript==

;(function () {
  ;('use strict')
  const RULES = {
    TEXT: {
      AUTO: ['km(キロ)', 'm(メートル)', '℃(ど)', 'お団子(おだんご)', 'お年寄り(おとしより)', 'お店(おみせや)', 'お茶する(おちゃする)', 'ご先祖さま(ごせんぞさま)', '一つ(ひとつ)', '万引き(まんびき)', '三分の一(さんぶんのいち)', '不確か(ふたしか)', '不足(ふそく)', '世界1周旅行(せかいいっしゅうりょこう)', '中(じゅう)', '以上(いじょう)', '以外(いがい)', '住(す)', '使い分け(つかいわけ)', '使い方(つかいかた)', '使用(しよう)', '働(はたら)', '元を取る(もとをとる)', '元カノ(もとかの)', '元カレ(もとかれ)', '入学(にゅうがく)', '入(はい)', '全て(すべて)', '出張 (しゅっちょう)', '出張(しゅっちょう)', '分(ぶん)', '前(まえ)', '動作(どうさ)', '口の中(くちのなか)', '合(あ)', '吐き気(はきけ)', '味覚 (みかく)', '呼び方(よびかた)', '唐揚げ(からあげ)', '商品(しょうひん)', '土砂崩れ(どしゃくずれ)', '夏休み中(なつやすみちゅう)', '夏祭り(なつまつり)', '夕ご飯(ゆうごはん)', '大切(たいせつ)', '大好き(だいすき)', '学習者(がくしゅうしゃ)', '宝くじ(たからくじ)', '寝る前(ねるまえ)', '寝(ね)', '届け出(とどけで)', '座り心地(すわりごこち)', '引っ越す(ひっこす)', '当たり前(あたりまえ)', '役に立つ(やくにたつ)', '待(ま)', '後ろ(うしろ)', '怒り(いかり)', '思い出す(おもいだす)', '恵方巻き(えほうまき)', '悩み事(なやみごと)', '感じ方(かんじかた)', '戦(せん)', '手作り(てづくり)', '折があれば(おりがあれば)', '折に触れて(おりにふれて)', '折も折(おりもおり)', '折を見て(おりをみて)', '数え方(かぞえかた)', '文化(ぶんか)', '文法(ぶんぽう)', '旅行(りょこう)', '日記(にっき)', '早寝早起き(はやねはやおき)', '星の数ほどある(ほしのかずほどある)', '星の数ほどいる(ほしのかずほどいる)', '星の数(ほしのかず)', '昭和の日(しょうわのひ)', '暮(ぐ)', '有名(ゆうめい)', '梅雨入り(つゆいり)', '楽(たの)', '歩(ある)', '残業(ざんぎょう)', '気を付けて(きをつけて)', '気持ち(きもち)', '独り言(ひとりごと)', '瓜二つ(うりふたつ)', '甘い物(あまいもの)', '申し訳(もうしわけ)', '盗み食い(ぬすみぐい)', '真っ暗(まっくら)', '真ん中(まんなか)', '知り合い(しりあい)', '確か(たしか)', '社会(しゃかい)', '福笑い(ふくわらい)', '窓の外(まどのそと)', '立ち読み(たちよみ)', '第2月曜日(だいにげつようび)', '笹の葉(ささのは)', '細長い(ほそながい)', '紹介(しょうかい)', '組み合わせ(くみあわせ)', '経(た)', '結婚(けっこん)', '繰り返して(くりかえして)', '羽根つき(はねつき)', '考え方(かんがえかた)', '聞き手(ききて)', '腹が立つ(はらがたつ)', '自身(じしん)', '芸術の秋(げいじゅつのあき)', '落ち着(おちつ)', '行き方(いきかた)', '行き渡る(いきわたる)', '触り心地(さわりごこち)', '試験(しけん)', '話し手(はなして)', '話し言葉(はなしことば)', '読み方(よみかた)', '読書の秋(どくしょのあき)', '請け合い(うけあい)', '豪雨(ごうう)', '貯金(ちょきん)', '貯(た)', '買い物(かいもの)', '貸し借り(かしかり)', '足が早い(あしがはやい)', '通り(とおり)', '通り(どおり)', '通知(つうち)', '通(どお)', '連続(れんぞく)', '遅刻(ちこく)', '長い間(ながいあいだ)', '長生き(ながいき)', '雨の日(あめのひ)', '青い色(あおいいろ)', '青のり(あおのり)', '願い事(ねがいごと)', '食べず嫌い(たべずぎらい)', '食べ物(たべもの)', '食欲の秋(しょくよくのあき)', '食(しょく)', '飲み会(のみかい)', '飲み物(のみもの)', '駅(えき)', '驚き(おどろき)', '髪の毛(かみのけ)', '鳴き声(なきごえ)', '0点(れいてん)', '1か月間(いっかげつかん)', '1か月(いっかげつ)', '1つ(ひとつ)', '1人(ひとり)', '1列(いちれつ)', '1回(いっかい)', '1年(いちねん)', '1度(いちど)', '1日中(いちにちじゅう)', '1日(ついたち)', '1杯(いっぱい)', '1泊(いっぱく)', '10日間(とおかかん)', '10日(とおか)', '10杯(じゅっぱい)', '2人(ふたり)', '2日(ふつか)', '3日間(みっかかん)', '3日(みっか)', '3杯(さんばい)', '5分(ごふん)', '5日間(いつかかん)', '5月(ごがつ)', '7日(なのか)'],
      READING: [
        { pattern: '羽根を伸ばす(羽根を伸ばす)', reading: 'はねをのばす' },
        { pattern: '長蛇の列(長蛇の列)', reading: 'ちょうだのれつ' },
        { pattern: '付き合(つきあい)', reading: 'つきあ' },
        { pattern: 'コマ回し(こままわし)', reading: 'コマまわし' },
        { pattern: '今回(今回)', reading: 'こんかい' },
        { pattern: '一般的(いっぱん)', reading: 'いっぱんてき' },
        { pattern: '必ず(かなら)', reading: 'かならず' },
        { pattern: '青リンゴ(あおりんご)', reading: 'あおリンゴ' },
        { pattern: '食べ物(食べ物)', reading: 'たべもの' },
      ],
      FULL: [
        { pattern: 'マイ〇〇(my+〇〇)', replacement: '<ruby>マイ<rt>my</rt></ruby>〇〇' },
        { pattern: '目に余る②(めにあまる)', replacement: '<ruby>目<rt>め</rt></ruby>に<ruby>余<rt>あま</rt></ruby>る②' },
        { pattern: '言い方(いいかた)', replacement: '<ruby>言<rt>い</rt></ruby>い<ruby>方<rt>かた</rt></ruby>' },
        { pattern: '言い訳(いいわけ)', replacement: '<ruby>言<rt>い</rt></ruby>い<ruby>訳<rt>わけ</rt></ruby>' },
        { pattern: '年越しそば(としこしそば)', replacement: '<ruby>年越<rt>としこ</rt></ruby>しそば' },
        { pattern: '原因・理由(げんいん・りゆう)', replacement: '<ruby>原因<rt>げんいん</rt></ruby>・<ruby>理由<rt>りゆう</rt></ruby>' },
        { pattern: '目の色が変わる・目の色を変える(めのいろがかわる・かえる)', replacement: '<ruby>目<rt>め</rt></ruby>の<ruby>色<rt>いろ</rt></ruby>が<ruby>変<rt>かわ</rt></ruby>る・<ruby>目<rt>め</rt></ruby>の<ruby>色<rt>いろ</rt></ruby>を<ruby>変<rt>かえ</rt></ruby>える' },
        { pattern: '青菜・青野菜(あおな・あおやさい)', replacement: '<ruby>青菜<rt>あおな</rt></ruby>・<ruby>青野菜<rt>あおやさい</rt></ruby>' },
        { pattern: '水の泡になる・水の泡となる(みずのあわになる)', replacement: '<ruby>水<rt>みず</rt></ruby>の<ruby>泡<rt>あわ</rt></ruby>になる・<ruby>水<rt>みず</rt></ruby>の<ruby>泡<rt>あわ</rt></ruby>となる' },
        { pattern: '意味で(いみ)', replacement: '<ruby>意味<rt>いみ</rt></ruby>で' },
        { pattern: '和製英語で(わせいえいご)', replacement: '<ruby>和製英語<rt>わせいえいご</rt></ruby>で' },
        { pattern: '財布を(さいふ)', replacement: '<ruby>財布<rt>さいふ</rt></ruby>を' },
        { pattern: '夏バテ防止(なつばてぼうし)', replacement: '<ruby>夏<rt>なつ</rt></ruby>バテ<ruby>防止<rt>ぼうし</rt></ruby>' },
        { pattern: 'ソーシャル・ネットワーキング・サービス(Social Networking Service)', replacement: '<ruby>ソーシャル<rt>Social</rt></ruby>・<ruby>ネットワーキング<rt>Networking</rt></ruby>・<ruby>サービス<rt>Service</rt></ruby>' },
      ],
    },
    HTML: [
      { pattern: /一瞬(いっしゅん<br>)/g, replacement: '<ruby>一瞬<rt>いっしゅん</rt></ruby>' },
      { pattern: /<b><span style="font-size: 125%;">居<\/span><\/b>(い)/g, replacement: '<b><ruby>居<rt>い</rt></ruby></b>' },
      { pattern: /<b style="font-size: large;">留守<\/b>(るす)/g, replacement: '<b><ruby>留守<rt>るす</rt></ruby></b>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>次第<\/b><\/span>(しだい)/g, replacement: '<span style="color: rgb(255, 0, 0);"><b><ruby>次第<rt>しだい</rt></ruby></b></span>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>から、当然<\/b><\/span>(とうぜん)/g, replacement: '<span style="color: rgb(255, 0, 0);"><b>から、<ruby>当然<rt>とうぜん</rt></ruby></b></span>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>生きがい<\/b><\/span>(いきがい)/g, replacement: '<span style="color: rgb(255, 0, 0);"><b><ruby>生<rt>い</rt></ruby>きがい</b></span>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>教えがい<\/b><\/span>(おしえがい)/g, replacement: '<span style="color: rgb(255, 0, 0);"><b><ruby>教<rt>おし</rt></ruby>えがい</b></span>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>育てがい<\/b><\/span>(そだてがい)/g, replacement: '<span style="color: rgb(255, 0, 0);"><b><ruby>育<rt>そだ</rt></ruby>てがい</b></span>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>作りがい<\/b><\/span>(つくりがい)/g, replacement: '<span style="color: rgb(255, 0, 0);"><b><ruby>作<rt>つく</rt></ruby>がい</b></span>' },
      { pattern: /<span style="color: rgb\(255, 0, 0\);"><b>だけの状態<\/b><\/span><b><span style="color: rgb\(255, 0, 0\);">(じょうたい)だ<\/span><\/b>/g, replacement: '<span style="color: rgb(255, 0, 0);"><b>だけの<ruby>状態<rt>じょうたい</rt></ruby>だ</b></span>' },
      { pattern: /<span style="background-color: rgb\(204, 204, 204\);">運動<\/span>(うんどう)/g, replacement: '<span style="background-color: rgb(204, 204, 204);"><ruby>運動<rt>うんどう</rt></ruby></span>' },
      { pattern: /<b style="background-color: rgb\(255, 255, 0\);"><span style="color: rgb\(255, 0, 0\);">「エアコン」とはエアーコンディショナー(<\/span><\/b>/g, replacement: '<b style="background-color: rgb(255, 255, 0);"><span style="color: rgb(255, 0, 0);">「エアコン」とは<ruby>エアーコンディショナー<rt>air conditioner</rt></ruby></span></b>' },
      { pattern: /air conditioner)/g, replacement: '' },
      { pattern: /<b>書(か)き言葉(ことば)的<\/b>(てき)/g, replacement: '<b><ruby>書<rt>か</rt></ruby>き<ruby>言葉的<rt>ことばてき</rt></ruby></b>' },
    ],
    EXCLUDE: {
      STRINGS: new Set(['挙句(に)', '道草(を)', '以上(は)', '人称(私)', '人称(あなた)', '矢先(に)', '女性(おばあちゃん)']),
      PARTICLES: new Set(['は', 'が', 'を', 'に', 'で', 'と', 'から', 'まで', 'へ', 'より', 'の', 'て', 'し', 'も', 'や', 'ね', 'よ', 'さ', 'あ', 'な']),
    },
  }
  const PageOptimizer = {
    _config: {
      MODULE_ENABLED: true,
      GLOBAL_REMOVE_SELECTORS: ['header#blog-header', 'footer#blog-footer', '.ldb_menu', '#analyzer_tags', '#gdpr-banner', '.adsbygoogle', '#ad_rs', '#ad2', 'div[class^="fluct-unit"]', '.article-social-btn', 'iframe[src*="clap.blogcms.jp"]', '#article-options', 'a[href*="blogmura.com"]', 'a[href*="with2.net"]', 'div[id^="ldblog_related_articles_"]'],
      STYLES: `
        #container { width: 100%; }
        @media (min-width: 960px) { #container { max-width: 960px; } }
        @media (min-width: 1040px) { #container { max-width: 1040px; } }
        #content { display: flex; position: relative; padding: 50px 0 !important; }
        #main { flex: 1; float: none !important; width: 100% !important; }
        aside#sidebar { visibility: hidden; float: none !important; width: 350px !important; flex: 0 0 350px; }
        .plugin-categorize { position: fixed; height: 85vh; display: flex; flex-direction: column; padding: 0 !important; width: 350px !important; }
        .plugin-categorize .side { flex: 1; overflow-y: auto; max-height: unset; }
        .plugin-categorize .side > :not([hidden]) ~ :not([hidden]) { margin-top: 5px; margin-bottom: 0; }
        .article { padding: 0 0 20px 0 !important; margin-bottom: 30px !important; }
        .article-body { padding: 0 !important; }
        .article-pager { margin-bottom: 0 !important; }
        .article-body-inner { line-height: 2; opacity: 0; transition: opacity 0.3s; }
        .article-body-inner img.pict { margin: 0 !important; width: 80% !important; display: block; }
        .article-body-inner strike { color: orange !important; }
        .to-pagetop { position: fixed; bottom: 19.2px; right: 220px; z-index: 9999; }
        rt, iframe, time, .pager, #sidebar { -webkit-user-select: none; user-select: none; }
        .article-body-inner:after, .article-meta:after, #container:after, #content:after, article:after, section:after, .cf:after { content: none !important; display: none !important; height: auto !important; visibility: visible !important; }
      `,
    },
    init() {
      if (!this._config.MODULE_ENABLED) return
      const antiFlickerCss = `${this._config.GLOBAL_REMOVE_SELECTORS.join(', ')} { display: none !important; }`
      GM_addStyle(antiFlickerCss)
      GM_addStyle(this._config.STYLES)
    },
    cleanupGlobalElements() {
      if (!this._config.MODULE_ENABLED) return
      document.querySelectorAll(this._config.GLOBAL_REMOVE_SELECTORS.join(',')).forEach((el) => el.remove())
      document.querySelectorAll('body script, body link, body style, body noscript').forEach((el) => el.remove())
    },
    cleanupArticleBody(container) {
      if (!this._config.MODULE_ENABLED) return
      this._trimContainerBreaks(container)
      const lastElement = container.lastElementChild
      if (lastElement) {
        this._trimContainerBreaks(lastElement)
      }
      container.style.opacity = 1
    },
    _trimContainerBreaks(element) {
      if (!element) return
      const isJunkNode = (node) => {
        if (!node) return true
        if (node.nodeType === 3 && /^\s*$/.test(node.textContent)) {
          return true
        }
        if (node.nodeType === 1) {
          const tagName = node.tagName
          if (tagName === 'BR') return true
          if (tagName === 'SPAN' && /^\s*$/.test(node.textContent)) return true
          if (tagName === 'A' && /^\s*$/.test(node.textContent)) return true
        }
        return false
      }
      while (element.firstChild && isJunkNode(element.firstChild)) {
        element.removeChild(element.firstChild)
      }
      while (element.lastChild && isJunkNode(element.lastChild)) {
        element.removeChild(element.lastChild)
      }
    },
    finalizeLayout() {
      if (!this._config.MODULE_ENABLED) return
      const sidebar = document.querySelector('aside#sidebar')
      if (!sidebar) return
      const category = sidebar.querySelector('.plugin-categorize')
      sidebar.innerHTML = ''
      if (category) {
        sidebar.appendChild(category)
        sidebar.style.visibility = 'visible'
      }
    },
  }
  const ImageProcessor = {
    _config: {
      MODULE_ENABLED: true,
      IMG_SRC_REGEX: /(https:\/\/livedoor\.blogimg\.jp\/edewakaru\/imgs\/[a-z0-9]+\/[a-z0-9]+\/[a-z0-9]+)-s(\.jpg)/i,
    },
    process(container) {
      if (!this._config.MODULE_ENABLED) return
      container.querySelectorAll('a[href*="livedoor.blogimg.jp"]').forEach((link) => {
        const img = link.querySelector('img.pict')
        if (!img) return
        const newImg = document.createElement('img')
        newImg.loading = 'lazy'
        newImg.src = img.src.replace(this._config.IMG_SRC_REGEX, '$1$2')
        newImg.alt = (img.alt || '').replace(/blog/gi, '')
        Object.assign(newImg, { className: img.className, width: img.width, height: img.height })
        link.replaceWith(newImg)
      })
    },
  }
  const IframeLoader = {
    _config: {
      MODULE_ENABLED: true,
      IFRAME_LOAD_ENABLED: true,
      IFRAME_SELECTOR: 'iframe[src*="richlink.blogsys.jp"]',
      PLACEHOLDER_CLASS: 'iframe-placeholder',
      LOADING_CLASS: 'is-loading',
      CLICKABLE_CLASS: 'is-clickable',
      STYLES: `
        @keyframes iframe-spinner-rotation { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
        .iframe-placeholder { position: relative; display: inline-block; vertical-align: top; background-color: #f9f9f9; box-sizing: border-box; margin: 8px 0; }
        .is-loading::after { opacity: 0.9; content: ''; position: absolute; top: 50%; left: 50%; width: 32px; height: 32px; margin-top: -16px; margin-left: -16px; border: 4px solid #ccc; border-top-color: #3B82F6; border-radius: 50%; animation: iframe-spinner-rotation 1s linear infinite; }
        .is-clickable { opacity: 0.9; display: inline-grid; place-items: center; color: #ccc; font-weight: bold; font-size: 16px; cursor: pointer; transition: background-color 0.2s, color 0.2s; -webkit-user-select: none; user-select: none; }
        .is-clickable:hover { opacity: 0.9; color: #3B82F6; background-color: #f4f8ff; }
        @media screen and (max-width: 870px) { .iframe-placeholder { max-width: 350px !important; height: 105px !important; } }
        @media screen and (min-width: 871px) { .iframe-placeholder { max-width: 580px !important; height: 120px !important; } }
      `,
    },
    init(options) {
      if (!this._config.MODULE_ENABLED) return
      Object.assign(this._config, options)
      GM_addStyle(this._config.STYLES)
    },
    processContainer(container) {
      if (!this._config.MODULE_ENABLED) return
      const iframes = container.querySelectorAll(this._config.IFRAME_SELECTOR)
      if (iframes.length === 0) return
      this._config.IFRAME_LOAD_ENABLED ? this._processForLazyLoad(iframes) : this._processForClickToLoad(iframes)
    },
    _processForLazyLoad(iframes) {
      const observer = new IntersectionObserver(
        (entries, obs) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) {
              const placeholder = entry.target
              const iframe = document.createElement('iframe')
              iframe.src = placeholder.dataset.src
              iframe.setAttribute('style', placeholder.dataset.style)
              iframe.setAttribute('frameborder', '0')
              iframe.setAttribute('scrolling', 'no')
              iframe.style.opacity = '0'
              iframe.addEventListener(
                'load',
                () => {
                  placeholder.classList.remove(this._config.LOADING_CLASS)
                  iframe.style.opacity = '1'
                },
                { once: true },
              )
              placeholder.appendChild(iframe)
              obs.unobserve(placeholder)
            }
          })
        },
        {
          rootMargin: '200px 0px',
        },
      )
      iframes.forEach((iframe) => {
        const placeholder = document.createElement('div')
        placeholder.className = `${this._config.PLACEHOLDER_CLASS} ${this._config.LOADING_CLASS}`
        const originalStyle = iframe.getAttribute('style') || ''
        placeholder.setAttribute('style', originalStyle)
        placeholder.dataset.src = iframe.src
        placeholder.dataset.style = originalStyle
        iframe.replaceWith(placeholder)
        observer.observe(placeholder)
      })
    },
    _processForClickToLoad(iframes) {
      iframes.forEach((iframe) => {
        if (iframe.parentElement.classList.contains(this._config.PLACEHOLDER_CLASS)) return
        const originalSrc = iframe.src
        const originalStyle = iframe.getAttribute('style') || ''
        const placeholder = document.createElement('div')
        placeholder.className = `${this._config.PLACEHOLDER_CLASS} ${this._config.CLICKABLE_CLASS}`
        placeholder.textContent = '▶ 関連記事を読み込む'
        placeholder.setAttribute('style', originalStyle)
        placeholder.addEventListener(
          'click',
          () => {
            const newIframe = document.createElement('iframe')
            newIframe.src = originalSrc
            newIframe.setAttribute('style', originalStyle)
            newIframe.setAttribute('frameborder', '0')
            newIframe.setAttribute('scrolling', 'no')
            const loadingWrapper = document.createElement('div')
            loadingWrapper.className = `${this._config.PLACEHOLDER_CLASS} ${this._config.LOADING_CLASS}`
            loadingWrapper.setAttribute('style', originalStyle)
            newIframe.style.opacity = '0'
            loadingWrapper.appendChild(newIframe)
            newIframe.addEventListener(
              'load',
              () => {
                loadingWrapper.classList.remove(this._config.LOADING_CLASS)
                newIframe.style.opacity = '1'
              },
              { once: true },
            )
            placeholder.replaceWith(loadingWrapper)
          },
          { once: true },
        )
        iframe.replaceWith(placeholder)
      })
    },
  }
  const RubyConverter = {
    _config: {
      MODULE_ENABLED: true,
    },
    _rules: null,
    _regex: {
      bracket: /[【「](?:.*?)([^【】「」()・、\s~〜]+)(([^()]*))([^【】「」()]*)[】」]/g,
      katakana: /([ァ-ンー]+)[((]([\w\s+]+)[))]/g,
      ruby: /([一-龯々]+)\s*[((]([^()()]*)[))]/g,
      kanaOnly: /^[\u3040-\u309F]+$/,
      nonKana: /[^\u3040-\u309F]/,
      isKanaChar: /^[\u3040-\u309F]$/,
      hasInvalidChars: /[^一-龯々\u3040-\u309F\u30A0-\u30FF]/,
    },
    _processedWords: { patternResults: new Map(), globalRegex: null },
    _dynamicWords: new Set(),
    init(rules) {
      if (!this._config.MODULE_ENABLED) return
      this._rules = rules
      this._preprocessWords(rules)
    },
    processContainer(container) {
      if (!this._config.MODULE_ENABLED) return
      this._applyHtmlReplacements(container)
      this._findAndRegisterCompounds(container)
      this._processRubyInNodes(container)
    },
    _escapeRegExp: (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
    _parseCompoundEntry(entry) {
      if (typeof entry === 'string') {
        const match = entry.match(/(.*?)((.*?))/)
        if (match) return { pattern: entry, kanji: match[1].trim(), reading: match[2] }
      } else if (entry.reading) {
        return { pattern: entry.pattern, kanji: entry.pattern.replace(/(.*?)/, ''), reading: entry.reading }
      } else if (entry.replacement) {
        return entry
      }
      return null
    },
    _preprocessWords(rules) {
      const allPatterns = []
      rules.TEXT.AUTO.forEach((entry) => {
        const match = entry.match(/(.*?)((.*?))/)
        if (!match) return
        const pattern = entry
        const kanji = match[1].trim()
        const reading = match[2]
        allPatterns.push(this._escapeRegExp(pattern))
        this._processedWords.patternResults.set(pattern, this._segmentCompoundWord(kanji, reading))
      })
      rules.TEXT.READING.forEach((entry) => {
        const { pattern, reading } = entry
        const kanji = pattern.replace(/(.*?)/, '')
        allPatterns.push(this._escapeRegExp(pattern))
        this._processedWords.patternResults.set(pattern, this._segmentCompoundWord(kanji, reading))
      })
      rules.TEXT.FULL.forEach((entry) => {
        const { pattern, replacement } = entry
        allPatterns.push(this._escapeRegExp(pattern))
        this._processedWords.patternResults.set(pattern, replacement)
      })
      this._rebuildGlobalRegex(allPatterns)
    },
    _rebuildGlobalRegex(patterns) {
      this._processedWords.globalRegex = patterns.length > 0 ? new RegExp(`(${patterns.join('|')})`, 'g') : null
    },
    _segmentCompoundWord(kanji, reading) {
      let result = '',
        kanjiIndex = 0,
        readingIndex = 0
      while (kanjiIndex < kanji.length) {
        if (this._regex.isKanaChar.test(kanji[kanjiIndex])) {
          result += kanji[kanjiIndex]
          readingIndex = reading.indexOf(kanji[kanjiIndex], readingIndex) + 1
          kanjiIndex++
        } else {
          let kanjiPart = ''
          while (kanjiIndex < kanji.length && !this._regex.isKanaChar.test(kanji[kanjiIndex])) {
            kanjiPart += kanji[kanjiIndex++]
          }
          const nextKanaIndex = kanjiIndex < kanji.length ? reading.indexOf(kanji[kanjiIndex], readingIndex) : reading.length
          result += `<ruby>${kanjiPart}<rt>${reading.substring(readingIndex, nextKanaIndex)}</rt></ruby>`
          readingIndex = nextKanaIndex
        }
      }
      return result
    },
    _processTextContent(text) {
      if (!text.includes('(') && !text.includes('(')) return text
      if (this._processedWords.globalRegex) {
        text = text.replace(this._processedWords.globalRegex, (match) => this._processedWords.patternResults.get(match) || match)
      }
      text = text.replace(this._regex.katakana, (_, katakana, romaji) => `<ruby>${katakana}<rt>${romaji}</rt></ruby>`)
      return text.replace(this._regex.ruby, (match, kanji, reading) => {
        const fullMatch = `${kanji}(${reading})`
        if (this._rules.EXCLUDE.STRINGS.has(fullMatch) || (this._rules.EXCLUDE.PARTICLES.has(reading) && this._regex.kanaOnly.test(kanji)) || this._regex.nonKana.test(reading)) {
          return match
        }
        return reading ? `<ruby>${kanji}<rt>${reading}</rt></ruby>` : match
      })
    },
    _applyHtmlReplacements(element) {
      let html = element.innerHTML
      this._rules.HTML.forEach((rule) => {
        html = html.replace(rule.pattern, rule.replacement)
      })
      if (html !== element.innerHTML) element.innerHTML = html
    },
    _findAndRegisterCompounds(element) {
      const html = element.innerHTML
      const newPatterns = []
      for (const match of html.matchAll(this._regex.bracket)) {
        const kanjiPart = match[1]
        const readingPart = match[2]
        const suffixPart = match[3]
        if (match[0].includes('+') || match[0].includes('+') || this._regex.nonKana.test(readingPart) || this._regex.hasInvalidChars.test(kanjiPart)) {
          continue
        }
        const fullPattern = `${kanjiPart}(${readingPart})${suffixPart}`
        if (!this._dynamicWords.has(fullPattern)) {
          this._dynamicWords.add(fullPattern)
          const rubyHtml = this._segmentCompoundWord(kanjiPart, readingPart) + suffixPart
          this._processedWords.patternResults.set(fullPattern, rubyHtml)
          newPatterns.push(this._escapeRegExp(fullPattern))
        }
      }
      if (newPatterns.length > 0) {
        const existing = this._processedWords.globalRegex ? this._processedWords.globalRegex.source.slice(1, -2).split('|') : []
        this._rebuildGlobalRegex([...existing, ...newPatterns])
      }
    },
    _processRubyInNodes(root) {
      const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
        acceptNode: (n) => (n.parentNode.nodeName !== 'SCRIPT' && n.parentNode.nodeName !== 'STYLE' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
      })
      const nodesToProcess = []
      let node
      while ((node = walker.nextNode())) {
        const newContent = this._processTextContent(node.nodeValue)
        if (newContent !== node.nodeValue) nodesToProcess.push({ node, newContent })
      }
      for (let i = nodesToProcess.length - 1; i >= 0; i--) {
        const { node, newContent } = nodesToProcess[i]
        node.parentNode.replaceChild(document.createRange().createContextualFragment(newContent), node)
      }
    },
  }
  const SettingsPanel = {
    _config: {
      MODULE_ENABLED: true,
      FEEDBACK_URL: 'http://greasyfork.icu/scripts/542386-edewakaru-enhanced',
      OPTIONS: {
        SCRIPT_ENABLED: { label: 'ページ最適化', defaultValue: true, handler: '_handleScriptToggle', isChild: false },
        FURIGANA_VISIBLE: { label: '振り仮名表示', defaultValue: true, handler: '_handleFuriganaToggle', isChild: true },
        IFRAME_LOAD_ENABLED: { label: '関連記事表示', defaultValue: true, handler: '_handleIframeLoadToggle', isChild: true },
        TTS_ENABLED: { label: '単語選択発音', defaultValue: false, handler: '_handleTtsToggle', isChild: true },
      },
      STYLES: `
        #settings-panel { position: fixed; bottom: 24px; right: 24px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; padding: 16px; background: white; border-radius: 4px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1),0 4px 6px -2px rgba(0,0,0,0.05); width: 140px; opacity: 0.9; -webkit-user-select: none; user-select: none; }
        .settings-title { font-size: 14px; font-weight: 600; color: #1F2937; margin: 0 0 6px 0; text-align: center; border-bottom: 1px solid #E5E7EB; padding-bottom: 6px; position: relative; }
        .feedback-link, .feedback-link:visited { position: absolute; top: 0; right: 0; width: 16px; height: 16px; color: #E5E7EB !important; transition: color 0.2s ease-in-out; }
        .feedback-link:hover { color: #3B82F6 !important; }
        .feedback-link svg { width: 100%; height: 100%; }
        .setting-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
        .setting-label { font-size: 13px; font-weight: 500; color: #4B5563; cursor: pointer; flex: 1; line-height: 1.2; }
        .toggle-switch { position: relative; display: inline-block; width: 40px; height: 20px; flex-shrink: 0; }
        .toggle-switch.disabled { opacity: 0.5; pointer-events: none; }
        .toggle-switch input { opacity: 0; width: 0; height: 0; }
        .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #E5E7EB; transition: all 0.2s ease-in-out; border-radius: 9999px; }
        .toggle-slider:before { position: absolute; content: ""; height: 15px; width: 15px; left: 2.5px; bottom: 2.5px; background-color: white; transition: all 0.2s ease-in-out; border-radius: 50%; box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px 0 rgba(0,0,0,0.06); }
        input:checked+.toggle-slider { background-color: #3B82F6; }
        input:checked+.toggle-slider:before { transform: translateX(20px); }
        .settings-notification { position: fixed; bottom: 208px; right: 24px; z-index: 9999; padding: 8px 12px; background-color: #3B82F6; color: white; border-radius: 6px; font-size: 13px; animation: slideInOut 3s ease-in-out; -webkit-user-select: none; user-select: none; }
        @keyframes slideInOut { 0%, 100% { opacity: 0; transform: translateX(20px); } 15%, 85% { opacity: 0.9; transform: translateX(0); } }
      `,
    },
    _uiElements: {},
    init() {
      GM_addStyle(this._config.STYLES)
      this._createPanel()
      this._initializeFuriganaDisplay()
    },
    getOptions() {
      const options = {}
      for (const key in this._config.OPTIONS) {
        options[key] = GM_getValue(key, this._config.OPTIONS[key].defaultValue)
      }
      return options
    },
    _handleScriptToggle(enabled) {
      GM_setValue('SCRIPT_ENABLED', enabled)
      this._showNotification()
      this._updateChildOptionsUI(enabled)
    },
    _handleFuriganaToggle(visible) {
      GM_setValue('FURIGANA_VISIBLE', visible)
      this._toggleFuriganaDisplay(visible)
    },
    _handleIframeLoadToggle(enabled) {
      GM_setValue('IFRAME_LOAD_ENABLED', enabled)
      this._showNotification()
    },
    _handleTtsToggle(enabled) {
      GM_setValue('TTS_ENABLED', enabled)
      enabled ? ContextMenu.init() : ContextMenu.destroy()
    },
    _toggleFuriganaDisplay(visible) {
      const id = 'furigana-display-style'
      let style = document.getElementById(id)
      if (!style) {
        style = document.createElement('style')
        style.id = id
        document.head.appendChild(style)
      }
      style.textContent = `rt { display: ${visible ? 'ruby-text' : 'none'} !important; }`
    },
    _initializeFuriganaDisplay() {
      if (!GM_getValue('FURIGANA_VISIBLE', this._config.OPTIONS.FURIGANA_VISIBLE.defaultValue)) {
        this._toggleFuriganaDisplay(false)
      }
    },
    _createPanel() {
      if (!this._config.MODULE_ENABLED) return
      const panel = document.createElement('div')
      panel.id = 'settings-panel'
      panel.innerHTML = `
        <h3 class="settings-title">
          設定パネル
          <a href="${this._config.FEEDBACK_URL}" target="_blank" rel="noopener noreferrer" class="feedback-link" title="Feedback">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
          </a>
        </h3>
      `
      const isMasterEnabled = GM_getValue('SCRIPT_ENABLED', this._config.OPTIONS.SCRIPT_ENABLED.defaultValue)
      for (const key in this._config.OPTIONS) {
        const config = this._config.OPTIONS[key]
        let isDisabled = config.isChild && !isMasterEnabled
        if (key === 'TTS_ENABLED' && !('speechSynthesis' in window)) {
          isDisabled = true
        }
        panel.appendChild(this._createToggle(key, config, isDisabled))
      }
      document.body.appendChild(panel)
    },
    _createToggle(key, config, isDisabled) {
      const { label, handler, defaultValue } = config
      const id = `setting-${key.toLowerCase()}`
      const itemContainer = document.createElement('div')
      itemContainer.className = 'setting-item'
      itemContainer.dataset.key = key
      const isChecked = GM_getValue(key, defaultValue)
      itemContainer.innerHTML = `
        <label for="${id}" class="setting-label">${label}</label>
        <label class="toggle-switch ${isDisabled ? 'disabled' : ''}">
          <input type="checkbox" id="${id}" ${isChecked ? 'checked' : ''}>
          <span class="toggle-slider"></span>
        </label>
      `
      const toggleSwitch = itemContainer.querySelector('.toggle-switch')
      this._uiElements[key] = { switch: toggleSwitch }
      itemContainer.querySelector('input').addEventListener('change', (e) => this[handler](e.target.checked))
      return itemContainer
    },
    _updateChildOptionsUI(masterEnabled) {
      for (const key in this._config.OPTIONS) {
        if (this._config.OPTIONS[key].isChild) {
          const uiElement = this._uiElements[key]
          if (uiElement && uiElement.switch) {
            uiElement.switch.classList.toggle('disabled', !masterEnabled)
          }
        }
      }
    },
    _showNotification(message = '設定を保存しました。再読み込みしてください。') {
      const el = document.createElement('div')
      el.className = 'settings-notification'
      el.textContent = message
      document.body.appendChild(el)
      setTimeout(() => el.remove(), 3000)
    },
  }
  const TTSPlayer = {
    _config: {
      VOICE_NAMES: ['Microsoft Nanami Online (Natural) - Japanese (Japan)', 'Microsoft Keita Online (Natural) - Japanese (Japan)'],
      LANG: 'ja-JP',
    },
    _initPromise: null,
    _voices: [],
    async speak(text) {
      if (!this._initPromise) {
        this._initPromise = this._initialize()
      }
      await this._initPromise
      speechSynthesis.cancel()
      if (!text?.trim() || this._voices.length === 0) {
        this._voices.length === 0 && console.warn('[TTSPlayer] TTS is unavailable')
        return
      }
      const utterance = new SpeechSynthesisUtterance(text)
      utterance.voice = this._voices[Math.floor(Math.random() * this._voices.length)]
      utterance.lang = this._config.LANG
      utterance.onerror = (e) => {
        !['canceled', 'interrupted'].includes(e.error) && console.error(`[TTSPlayer] TTS playback error: ${e.error}`)
      }
      speechSynthesis.speak(utterance)
    },
    _initialize() {
      return new Promise((resolve) => {
        if (!('speechSynthesis' in window)) {
          console.warn('[TTSPlayer] SpeechSynthesis is not supported')
          return resolve()
        }
        let resolved = false
        const loadVoices = () => {
          if (resolved) return
          resolved = true
          const allVoices = speechSynthesis.getVoices()
          const { VOICE_NAMES, LANG } = this._config
          this._voices = allVoices.filter((v) => VOICE_NAMES.includes(v.name) && v.lang === LANG)
          console.warn(this._voices.length > 0 ? '[TTSPlayer] Native TTS is available' : '[TTSPlayer] No native voice found, TTS is unavailable')
          speechSynthesis.onvoiceschanged = null
          resolve()
        }
        const initialVoices = speechSynthesis.getVoices()
        if (initialVoices.length > 0) {
          loadVoices()
        } else {
          speechSynthesis.onvoiceschanged = loadVoices
          setTimeout(loadVoices, 500)
        }
      })
    },
  }
  const ContextMenu = {
    _config: {
      MODULE_ENABLED: true,
      MENU_ID: 'selection-context-menu',
      STYLES: `
        #selection-context-menu { position: absolute; top: 0; left: 0; display: none; z-index: 9999; opacity: 0; transition: opacity 0.1s ease-out, transform 0.1s ease-out; user-select: none; will-change: transform, opacity; }
        #selection-context-menu.visible { opacity: 0.9; }
        #selection-context-menu button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border-radius: 50%; cursor: grab; border: none; background-color: #3B82F6; color: #FFFFFF; box-shadow: 0 5px 15px rgba(0,0,0,0.15), 0 2px 5px rgba(0,0,0,0.1); transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out; }
        #selection-context-menu button:hover { background-color: #4B90F8; transform: scale(1.1); box-shadow: 0 8px 20px rgba(59, 130, 246, 0.3); }
        #selection-context-menu button:active { cursor: grabbing; }
        #selection-context-menu button svg { width: 20px; height: 20px; stroke: currentColor; stroke-width: 2; pointer-events: none; }
      `,
    },
    menuElement: null,
    isDragging: false,
    dragUpdatePending: false,
    lastPosX: 0,
    lastPosY: 0,
    dragOffsetX: 0,
    dragOffsetY: 0,
    boundHandleDragStart: null,
    boundHandleMouseUp: null,
    boundDragMove: null,
    boundDragEnd: null,
    boundTransitionEnd: null,
    init(options) {
      if (!this._config.MODULE_ENABLED) return
      if (this.menuElement) return
      Object.assign(this._config, options)
      GM_addStyle(this._config.STYLES)
      this._createMenu()
      this._bindEvents()
    },
    destroy() {
      if (!this.menuElement) return
      this.menuElement.remove()
      this.menuElement = null
      document.removeEventListener('mouseup', this.boundHandleMouseUp)
      document.removeEventListener('mousemove', this.boundDragMove)
      document.removeEventListener('mouseup', this.boundDragEnd)
    },
    _createMenu() {
      if (document.getElementById(this._config.MENU_ID)) return
      this.menuElement = document.createElement('div')
      this.menuElement.id = this._config.MENU_ID
      const readButton = document.createElement('button')
      readButton.title = 'Read'
      readButton.setAttribute('aria-label', 'Read selected text')
      readButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>`
      readButton.addEventListener('click', (event) => {
        if (this.isDragging) {
          event.stopPropagation()
          return
        }
        const cleanedText = this._getCleanedSelectedText()
        if (cleanedText) {
          TTSPlayer.speak(cleanedText)
        }
      })
      this.menuElement.appendChild(readButton)
      document.body.appendChild(this.menuElement)
    },
    _getCleanedSelectedText() {
      const selection = window.getSelection()
      if (!selection || selection.rangeCount === 0) return ''
      const tempContainer = document.createElement('div')
      tempContainer.appendChild(selection.getRangeAt(0).cloneContents())
      tempContainer.querySelectorAll('rt').forEach((el) => el.remove())
      tempContainer.querySelectorAll('br').forEach((el) => el.replaceWith('。'))
      let text = tempContainer.textContent
      const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu
      text = text.replace(emojiRegex, '。')
      text = text.replace(/\s+/g, '')
      return text
    },
    _bindEvents() {
      this.boundHandleDragStart = this._handleDragStart.bind(this)
      this.boundHandleMouseUp = this._handleMouseUp.bind(this)
      this.boundDragMove = this._handleDragMove.bind(this)
      this.boundDragEnd = this._handleDragEnd.bind(this)
      this.boundTransitionEnd = this._onTransitionEnd.bind(this)
      this.menuElement.addEventListener('mousedown', this.boundHandleDragStart)
      this.menuElement.addEventListener('transitionend', this.boundTransitionEnd)
      document.addEventListener('mouseup', this.boundHandleMouseUp)
    },
    _handleDragStart(event) {
      event.preventDefault()
      event.stopPropagation()
      this.isDragging = false
      this.dragOffsetX = event.pageX - this.lastPosX
      this.dragOffsetY = event.pageY - this.lastPosY
      this.menuElement.style.transition = 'none'
      document.addEventListener('mousemove', this.boundDragMove)
      document.addEventListener('mouseup', this.boundDragEnd, { once: true })
    },
    _handleDragMove(event) {
      event.preventDefault()
      if (this.dragUpdatePending) return
      this.dragUpdatePending = true
      requestAnimationFrame(() => {
        this.isDragging = true
        this.lastPosX = event.pageX - this.dragOffsetX
        this.lastPosY = event.pageY - this.dragOffsetY
        this.menuElement.style.transform = `translate(${this.lastPosX}px, ${this.lastPosY}px)`
        this.dragUpdatePending = false
      })
    },
    _handleDragEnd() {
      document.removeEventListener('mousemove', this.boundDragMove)
      this.menuElement.style.transition = ''
      setTimeout(() => {
        this.isDragging = false
      }, 0)
    },
    _handleMouseUp(event) {
      if (this.isDragging || this.menuElement.contains(event.target)) {
        return
      }
      setTimeout(() => {
        const selectedText = window.getSelection().toString().trim()
        if (selectedText.length > 0) {
          this._showMenu(event.pageX, event.pageY)
        } else {
          this._hideMenu()
        }
      }, 10)
    },
    _showMenu(x, y) {
      if (!this.menuElement) return
      this.lastPosX = x + 8
      this.lastPosY = y + 8
      this.menuElement.style.transition = 'none'
      this.menuElement.style.transform = `translate(${this.lastPosX}px, ${this.lastPosY}px)`
      this.menuElement.style.display = 'block'
      requestAnimationFrame(() => {
        this.menuElement.style.transition = ''
        this.menuElement.classList.add('visible')
      })
    },
    _hideMenu() {
      if (!this.menuElement || !this.menuElement.classList.contains('visible')) return
      this.menuElement.classList.remove('visible')
    },
    _onTransitionEnd() {
      if (this.menuElement && !this.menuElement.classList.contains('visible')) {
        this.menuElement.style.display = 'none'
      }
    },
  }
  const MainController = {
    run() {
      const options = SettingsPanel.getOptions()
      if (!options.SCRIPT_ENABLED) {
        document.addEventListener('DOMContentLoaded', () => SettingsPanel.init())
        return
      }
      PageOptimizer.init()
      RubyConverter.init(RULES)
      document.addEventListener('DOMContentLoaded', () => {
        PageOptimizer.cleanupGlobalElements()
        IframeLoader.init(options)
        SettingsPanel.init()
        if (options.TTS_ENABLED) ContextMenu.init()
        this._processPageContent()
      })
    },
    _processPageContent() {
      const articleBodies = document.querySelectorAll('.article-body-inner')
      if (articleBodies.length === 0) {
        PageOptimizer.finalizeLayout()
        return
      }
      let currentIndex = 0
      const processBatch = () => {
        const batchSize = Math.min(2, articleBodies.length - currentIndex)
        const endIndex = currentIndex + batchSize
        for (let i = currentIndex; i < endIndex; i++) {
          const body = articleBodies[i]
          RubyConverter.processContainer(body)
          IframeLoader.processContainer(body)
          ImageProcessor.process(body)
          PageOptimizer.cleanupArticleBody(body)
        }
        currentIndex = endIndex
        if (currentIndex < articleBodies.length) {
          requestAnimationFrame(processBatch)
        } else {
          PageOptimizer.finalizeLayout()
        }
      }
      requestAnimationFrame(processBatch)
    },
  }
  MainController.run()
})()