// ==UserScript== // @name Edewakaru Enhanced // @name:jp 「絵でわかる日本語」 閲覧体験強化 // @name:zh-CN 「絵でわかる日本語」 阅读体验增强 // @name:zh-TW 「絵でわかる日本語」 閱讀體驗增強 // @namespace https://greasyfork.org/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 // @downloadURL none // ==/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: 'マイmy〇〇' }, { pattern: '目に余る②(めにあまる)', replacement: 'あまる②' }, { pattern: '言い方(いいかた)', replacement: 'かた' }, { pattern: '言い訳(いいわけ)', replacement: 'わけ' }, { pattern: '年越しそば(としこしそば)', replacement: '年越としこしそば' }, { pattern: '原因・理由(げんいん・りゆう)', replacement: '原因げんいん理由りゆう' }, { pattern: '目の色が変わる・目の色を変える(めのいろがかわる・かえる)', replacement: 'いろかわる・いろかええる' }, { pattern: '青菜・青野菜(あおな・あおやさい)', replacement: '青菜あおな青野菜あおやさい' }, { pattern: '水の泡になる・水の泡となる(みずのあわになる)', replacement: 'みずあわになる・みずあわとなる' }, { pattern: '意味で(いみ)', replacement: '意味いみで' }, { pattern: '和製英語で(わせいえいご)', replacement: '和製英語わせいえいごで' }, { pattern: '財布を(さいふ)', replacement: '財布さいふを' }, { pattern: '夏バテ防止(なつばてぼうし)', replacement: 'なつバテ防止ぼうし' }, { pattern: 'ソーシャル・ネットワーキング・サービス(Social Networking Service)', replacement: 'ソーシャルSocialネットワーキングNetworkingサービスService' }, ], }, HTML: [ { pattern: /一瞬(いっしゅん
)/g, replacement: '一瞬いっしゅん' }, { pattern: /居<\/span><\/b>(い)/g, replacement: '' }, { pattern: /留守<\/b>(るす)/g, replacement: '留守るす' }, { pattern: /次第<\/b><\/span>(しだい)/g, replacement: '次第しだい' }, { pattern: /から、当然<\/b><\/span>(とうぜん)/g, replacement: 'から、当然とうぜん' }, { pattern: /生きがい<\/b><\/span>(いきがい)/g, replacement: 'きがい' }, { pattern: /教えがい<\/b><\/span>(おしえがい)/g, replacement: 'おしえがい' }, { pattern: /育てがい<\/b><\/span>(そだてがい)/g, replacement: 'そだてがい' }, { pattern: /作りがい<\/b><\/span>(つくりがい)/g, replacement: 'つくがい' }, { pattern: /だけの状態<\/b><\/span>(じょうたい)だ<\/span><\/b>/g, replacement: 'だけの状態じょうたい' }, { pattern: /運動<\/span>(うんどう)/g, replacement: '運動うんどう' }, { pattern: /「エアコン」とはエアーコンディショナー(<\/span><\/b>/g, replacement: '「エアコン」とはエアーコンディショナーair conditioner' }, { pattern: /air conditioner)/g, replacement: '' }, { pattern: /書(か)き言葉(ことば)的<\/b>(てき)/g, replacement: '言葉的ことばてき' }, ], 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 += `${kanjiPart}${reading.substring(readingIndex, nextKanaIndex)}` 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) => `${katakana}${romaji}`) 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 ? `${kanji}${reading}` : 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: 'https://greasyfork.org/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 = `

設定パネル

` 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 = ` ` 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 = `` 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() })()