// ==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: 'マイ〇〇' },
{ 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: 'ソーシャル・ネットワーキング・サービス' },
],
},
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: '「エアコン」とはエアーコンディショナー' },
{ 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}`
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}`)
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}` : 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: '',
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()
})()