]*>/,
},
_registeredWords: new Set(),
_wordBankForRegex: [],
_rubyCache: new Map(),
_htmlPatches: new Map(),
_simpleTextReplacements: new Map(),
globalRegex: null,
init(rules) {
if (!this._config.MODULE_ENABLED) return
this._rules = rules
this.compile()
},
applyRubyToContainer(container) {
if (!this._config.MODULE_ENABLED) return
this._applyHtmlPatches(container)
this._learnFromContainer(container)
this._buildFinalRegex()
this._processTextNodes(container)
},
compile() {
this._compileStaticRules()
this._buildFinalRegex()
},
_compileStaticRules() {
const { HTML, TEXT } = this._rules
HTML.OVERRIDE.forEach((rule) => {
const pattern = typeof rule.pattern === 'string' ? new RegExp(this._escapeRegExp(rule.pattern), 'g') : rule.pattern
this._htmlPatches.set(pattern, rule.replacement)
try {
if (this._regex.isRubyTag.test(rule.replacement)) {
} else {
const extractedPairs = this._extractRubyCandidates(rule.replacement)
extractedPairs.forEach(({ kanji, reading, fullPattern }) => {
this.registerWord({
pattern: fullPattern,
kanji,
reading,
source: 'HTML.OVERRIDE.Extract',
})
})
}
} catch (e) {}
})
HTML.AUTO.forEach((patternString) => {
if (patternString.includes('?')) {
const [prefix, suffix] = patternString.split('?')
if (suffix === void 0) return
const cleanPattern = prefix + suffix
const readingMatchForRegister = cleanPattern.match(this._regex.extractReading)
if (readingMatchForRegister) {
const kanjiPartForRegister = cleanPattern.replace(this._regex.extractReading, '')
const readingForRegister = readingMatchForRegister[1]
this.registerWord({
pattern: cleanPattern,
kanji: kanjiPartForRegister,
reading: readingForRegister,
source: 'HTML.AUTO',
})
}
const searchPattern = new RegExp(`${this._escapeRegExp(prefix)}((?:<[^>]+>)*?)${this._escapeRegExp(suffix)}`, 'g')
let replacementFunc
const readingForPatch = readingMatchForRegister ? readingMatchForRegister[1] : ''
if (this._regex.hasOnlyKana.test(readingForPatch)) {
const textPartForPatch = cleanPattern.replace(this._regex.extractReading, '')
const rubyHtml = this._parseFurigana(textPartForPatch, readingForPatch)
if (rubyHtml !== null) {
replacementFunc = (match, capturedTags) => rubyHtml + (capturedTags || '')
} else {
replacementFunc = (match) => match
}
} else {
const textOnlyForPatch = cleanPattern
replacementFunc = (match, capturedTags) => textOnlyForPatch + (capturedTags || '')
}
this._htmlPatches.set(searchPattern, replacementFunc)
}
})
TEXT.AUTO.forEach((patternString) => {
const match = patternString.match(this._regex.extractKanjiAndReading)
if (match) {
this.registerWord({
pattern: patternString,
kanji: match[1],
reading: match[2],
source: 'TEXT.AUTO',
})
}
})
TEXT.OVERRIDE.forEach((rule) => {
try {
if (this._regex.isRubyTag.test(rule.replacement)) {
this.registerWord({
pattern: rule.pattern,
replacement: rule.replacement,
source: 'TEXT.OVERRIDE',
})
} else {
this._simpleTextReplacements.set(rule.pattern, rule.replacement)
const extractedPairs = this._extractRubyCandidates(rule.replacement)
extractedPairs.forEach(({ kanji, reading, fullPattern }) => {
this.registerWord({
pattern: fullPattern,
kanji,
reading,
source: 'TEXT.OVERRIDE.Extract',
})
})
}
} catch (e) {}
})
},
registerWord({ pattern, kanji, reading, replacement, source }) {
if (this._registeredWords.has(pattern)) {
return
}
let finalReplacement = replacement
if (!finalReplacement) {
if (!kanji || typeof reading === 'undefined') return
const rubyHtml = this._parseFurigana(kanji, reading)
if (rubyHtml === null) {
return
}
finalReplacement = rubyHtml
}
this._registeredWords.add(pattern)
this._wordBankForRegex.push(this._escapeRegExp(pattern))
this._rubyCache.set(pattern, finalReplacement)
},
_learnFromContainer(container) {
const html = container.innerHTML
const textOnly = html.replace(this._regex.isHtmlTag, '')
for (const match of textOnly.matchAll(this._regex.matchBracketRuby)) {
const kanjiPart = match[1]
const readingPart = match[2]
const corePattern = `${kanjiPart}(${readingPart})`
if (this._registeredWords.has(corePattern)) {
continue
}
if (this._simpleTextReplacements.has(corePattern)) {
continue
}
if (!this._regex.hasOnlyKana.test(readingPart)) {
continue
}
if (!this._regex.hasOnlyKanjiKana.test(kanjiPart)) {
continue
}
this.registerWord({
pattern: corePattern,
kanji: kanjiPart,
reading: readingPart,
source: 'Learned',
})
}
},
_applyHtmlPatches(container) {
if (this._htmlPatches.size === 0) {
return
}
let html = container.innerHTML
const originalHtml = html
this._htmlPatches.forEach((replacement, pattern) => {
html = html.replace(pattern, replacement)
})
if (html !== originalHtml) {
container.innerHTML = html
}
},
_buildFinalRegex() {
this.globalRegex = this._wordBankForRegex.length > 0 ? new RegExp(`(${this._wordBankForRegex.join('|')})`, 'g') : null
},
_processTextNodes(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 = walker.nextNode()
while (node) {
const newContent = this._applyTextReplacements(node.nodeValue)
if (newContent !== node.nodeValue) {
nodesToProcess.push({ node, newContent })
}
node = walker.nextNode()
}
for (let i = nodesToProcess.length - 1; i >= 0; i--) {
const { node: node2, newContent } = nodesToProcess[i]
const fragment = document.createRange().createContextualFragment(newContent)
node2.parentNode.replaceChild(fragment, node2)
}
},
_applyTextReplacements(text) {
if (!text.includes('(') && !text.includes('(')) {
return text
}
let processedText = text
for (const [pattern, replacement] of this._simpleTextReplacements) {
processedText = processedText.replaceAll(pattern, replacement)
}
if (this.globalRegex) {
processedText = processedText.replace(this.globalRegex, (match) => {
return this._rubyCache.get(match) || match
})
}
processedText = processedText.replace(this._regex.matchLoanwordRuby, (match, loanword, katakana) => {
return `${loanword}`
})
processedText = processedText.replace(this._regex.matchKatakanaRuby, (match, katakana, loanword) => {
return `${katakana}`
})
processedText = processedText.replace(this._regex.matchKanjiRuby, (match, kanji, reading) => {
const fullMatch = `${kanji}(${reading})`
if (this._rules.EXCLUDE.STRINGS.has(fullMatch) || !this._regex.hasOnlyHiragana.test(reading) || this._rules.EXCLUDE.PARTICLES.has(reading)) {
return match
}
return `${kanji}`
})
return processedText
},
_parseFurigana(kanji, reading) {
if (this._regex.hasOnlyKatakana.test(kanji) || this._regex.hasOnlyKatakana.test(reading)) {
return null
}
const hiraganaReading = this._katakanaToHiragana(reading)
let result = ''
let kanjiIndex = 0
let readingIndex = 0
while (kanjiIndex < kanji.length) {
const currentKanjiChar = kanji[kanjiIndex]
if (this._regex.hasKanaChar.test(currentKanjiChar)) {
result += currentKanjiChar
const hiraganaCurrent = this._katakanaToHiragana(currentKanjiChar)
const tempNextReadingIndex = hiraganaReading.indexOf(hiraganaCurrent, readingIndex)
if (tempNextReadingIndex !== -1) {
readingIndex = tempNextReadingIndex + hiraganaCurrent.length
} else {
return null
}
kanjiIndex++
} else {
let kanjiPart = ''
let blockEndIndex = kanjiIndex
while (blockEndIndex < kanji.length && !this._regex.hasKanaChar.test(kanji[blockEndIndex])) {
kanjiPart += kanji[blockEndIndex]
blockEndIndex++
}
const nextKanaInKanji = kanji[blockEndIndex]
let readingEndIndex
if (nextKanaInKanji) {
const hiraganaNextKana = this._katakanaToHiragana(nextKanaInKanji)
readingEndIndex = hiraganaReading.indexOf(hiraganaNextKana, readingIndex)
if (readingEndIndex === -1) {
readingEndIndex = hiraganaReading.length
}
} else {
readingEndIndex = hiraganaReading.length
}
const readingPart = reading.substring(readingIndex, readingEndIndex)
if (kanjiPart) {
if (!readingPart) {
return null
} else {
result += `${kanjiPart}`
}
}
readingIndex = readingEndIndex
kanjiIndex = blockEndIndex
}
}
if (readingIndex < hiraganaReading.length) {
}
return result
},
_escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
},
_katakanaToHiragana(str) {
if (!str) return ''
return str.replace(/[\u30A1-\u30F6]/g, (match) => String.fromCharCode(match.charCodeAt(0) - 96))
},
_extractRubyCandidates(replacement) {
const results = []
for (const match of replacement.matchAll(this._regex.extractCandidates)) {
const kanji = match[1]
const reading = match[2]
if (!kanji || !reading) {
continue
}
const fullPattern = `${kanji}(${reading})`
results.push({ kanji, reading, fullPattern })
}
return results
},
}
const SettingsPanel = {
_config: {
MODULE_ENABLED: true,
FEEDBACK_URL: 'https://greasyfork.org/scripts/542386-edewakaru-enhanced',
OPTIONS: {
SCRIPT_ENABLED: { label: 'ページ最適化', defaultValue: true, type: 'toggle', handler: 'notification' },
FURIGANA_VISIBLE: { label: '振り仮名表示', defaultValue: true, type: 'toggle', handler: 'furigana', dependsOn: 'SCRIPT_ENABLED' },
IFRAME_LOAD_ENABLED: { label: '関連記事表示', defaultValue: true, type: 'toggle', handler: 'notification', dependsOn: 'SCRIPT_ENABLED' },
TTS_ENABLED: { label: '単語選択発音', defaultValue: false, type: 'toggle', handler: 'tts', dependsOn: 'SCRIPT_ENABLED', condition: () => 'speechSynthesis' in window },
TTS_RATE: { label: '発音速度', defaultValue: 1, type: 'slider', handler: 'ttsRate', min: 0.85, max: 1, step: 0.05, dependsOn: 'SCRIPT_ENABLED' },
},
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; }
.toggle-switch.disabled .toggle-slider { cursor: not-allowed; }
.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; right: 24px; z-index: 9999; padding: 8px 12px; background-color: #3B82F6; color: white; border-radius: 6px; font-size: 13px; opacity: 0; transform: translateX(20px); transition: opacity 0.5s ease, transform 0.5s ease; }
.settings-notification.show { opacity: 0.9; transform: translateX(0); }
.slider-container { display: flex; align-items: center; width: 100%; }
.slider-value { font-size: 12px; margin-left: 2px; }
.slider { -webkit-appearance: none; width: 40px; height: 6px; border-radius: 3px; background: #E5E7EB; outline: none; flex-shrink: 0; }
.slider:disabled { opacity: 0.5; cursor: not-allowed; }
.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #3B82F6; cursor: pointer; }
.slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #3B82F6; cursor: pointer; border: none; }
`,
},
_elements: {},
_notificationPosition: '24px',
_templates: {
panel: (feedbackUrl) => `
設定パネル
`,
toggle: (id, label, checked, disabled) => `
`,
slider: (id, label, value, min, max, step, disabled) => `
${label}${value.toFixed(2)}
`,
},
_handlers: {
notification() {
this._showNotification()
},
furigana(value) {
this._toggleFuriganaDisplay(value)
},
tts(value) {
emitter.emit('tts:toggle', value)
},
ttsRate(value) {
emitter.emit('tts:rate', value)
},
},
init() {
if (!this._config.MODULE_ENABLED) return
GM_addStyle(this._config.STYLES)
this._createPanel()
this._initFurigana()
},
getOptions() {
return Object.fromEntries(Object.entries(this._config.OPTIONS).map(([key, config]) => [key, GM_getValue(key, config.defaultValue)]))
},
_createPanel() {
const panel = document.createElement('div')
panel.id = 'settings-panel'
panel.innerHTML = this._templates.panel(this._config.FEEDBACK_URL)
this._elements.panel = panel
const options = this.getOptions()
const controls = Object.entries(this._config.OPTIONS)
.filter(([, config]) => !config.condition || config.condition())
.map(([key, config]) => {
const isDisabled = config.dependsOn && !options[config.dependsOn]
const control = this._createControl(key, config, options[key], isDisabled)
this._elements[key] = this._cacheControlElements(control)
return control
})
panel.append(...controls)
panel.addEventListener('change', this._handlePanelEvent.bind(this))
panel.addEventListener('input', this._handlePanelEvent.bind(this))
document.body.appendChild(panel)
this._updateNotificationPosition()
},
_createControl(key, config, value, disabled) {
const container = document.createElement('div')
container.className = 'setting-item'
container.dataset.key = key
const id = `setting-${key.toLowerCase()}`
const template = this._templates[config.type]
if (config.type === 'toggle') {
container.innerHTML = template(id, config.label, value, disabled)
} else if (config.type === 'slider') {
container.innerHTML = template(id, config.label, value, config.min, config.max, config.step, disabled)
}
return container
},
_cacheControlElements(container) {
return {
container,
control: container.querySelector('input'),
switch: container.querySelector('.toggle-switch'),
}
},
_handlePanelEvent(event) {
const item = event.target.closest('.setting-item')
if (!item) return
const key = item.dataset.key
const value = event.target.type === 'checkbox' ? event.target.checked : Number(event.target.value)
if (event.target.type === 'range') {
const valueDisplay = item.querySelector('.slider-value')
if (valueDisplay) valueDisplay.textContent = value.toFixed(2)
}
this._handleChange(key, value)
},
_handleChange(key, value) {
GM_setValue(key, value)
const config = this._config.OPTIONS[key]
const handler = this._handlers[config.handler]
if (handler) handler.call(this, value)
this._updateDependentOptions()
},
_updateDependentOptions() {
const options = this.getOptions()
Object.entries(this._config.OPTIONS)
.filter(([, config]) => config.dependsOn)
.forEach(([key, config]) => {
const isEnabled = options[config.dependsOn]
const element = this._elements[key]
if (element?.switch) element.switch.classList.toggle('disabled', !isEnabled)
if (element?.control) element.control.disabled = !isEnabled
})
},
_updateNotificationPosition() {
if (this._elements.panel) {
const rect = this._elements.panel.getBoundingClientRect()
this._notificationPosition = `${window.innerHeight - rect.top + 4}px`
}
},
_initFurigana() {
const visible = GM_getValue('FURIGANA_VISIBLE', this._config.OPTIONS.FURIGANA_VISIBLE.defaultValue)
if (!visible) {
this._toggleFuriganaDisplay(false)
}
},
_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; }`
},
_showNotification(message = '設定を保存しました。再読み込みしてください。') {
const existingNotification = document.querySelector('.settings-notification')
if (existingNotification) existingNotification.remove()
const notification = document.createElement('div')
notification.className = 'settings-notification'
notification.textContent = message
notification.style.bottom = this._notificationPosition
document.body.appendChild(notification)
requestAnimationFrame(() => {
notification.classList.add('show')
setTimeout(() => {
notification.classList.remove('show')
setTimeout(() => notification.remove(), 500)
}, 1500)
})
},
}
const TTSPlayer = {
_config: {
VOICES: new Map([
['female', 'Microsoft Nanami Online (Natural) - Japanese (Japan)'],
['male', 'Microsoft Keita Online (Natural) - Japanese (Japan)'],
]),
OPTIONS: {
lang: 'ja-JP',
volume: 1,
rate: 1,
pitch: 1,
},
},
_initPromise: null,
_voices: new Map(),
_availableVoices: [],
_isInitialized: false,
_eventHandlers: {
ttsSpeak: null,
ttsRate: null,
},
init(options = {}) {
if (!this._initPromise) {
this._initPromise = this._initialize()
}
this._setRate(options.rate)
if (!this._eventHandlers.ttsSpeak) {
this._initEventHandlers()
}
this._subscribeToEvents()
return this._initPromise
},
destroy() {
this._unsubscribeAll()
if ('speechSynthesis' in window) {
speechSynthesis.cancel()
}
},
speak(payload) {
if (!this._isInitialized) {
return
}
const { text, voice: voiceKey, rate } = payload || {}
if (!text?.trim() || this._availableVoices.length === 0) {
if (this._availableVoices.length === 0);
return
}
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.voice = this._getVoice(voiceKey)
utterance.lang = this._config.OPTIONS.lang
utterance.volume = this._config.OPTIONS.volume
utterance.rate = rate || this._config.OPTIONS.rate
utterance.pitch = this._config.OPTIONS.pitch
utterance.onerror = (e) => {
if (!['canceled', 'interrupted'].includes(e.error)) {
}
}
speechSynthesis.speak(utterance)
},
_initEventHandlers() {
this._eventHandlers.ttsSpeak = (payload) => {
this.speak(payload)
}
this._eventHandlers.ttsRate = (rate) => {
this._setRate(rate)
}
},
_subscribeToEvents() {
this._unsubscribeAll()
emitter.on('tts:speak', this._eventHandlers.ttsSpeak)
emitter.on('tts:rate', this._eventHandlers.ttsRate)
},
_unsubscribeAll() {
if (this._eventHandlers.ttsSpeak) {
emitter.off('tts:speak', this._eventHandlers.ttsSpeak)
}
if (this._eventHandlers.ttsRate) {
emitter.off('tts:rate', this._eventHandlers.ttsRate)
}
},
_setRate(rate) {
rate = typeof rate === 'string' ? Number.parseFloat(rate) : rate
if (rate >= 0.1 && rate <= 10 && !Number.isNaN(rate)) {
this._config.OPTIONS.rate = rate
return true
}
return false
},
_getVoice(key) {
if (key && this._voices.has(key)) {
return this._voices.get(key)
}
if (this._availableVoices.length > 0) {
return this._availableVoices[Math.floor(Math.random() * this._availableVoices.length)]
}
return void 0
},
_initialize() {
return new Promise((resolve) => {
if (!('speechSynthesis' in window)) {
return resolve()
}
let resolved = false
const loadVoices = () => {
if (resolved) return
resolved = true
const allVoices = speechSynthesis.getVoices()
const { VOICES, OPTIONS } = this._config
const voiceNameToKeyMap = new Map([...VOICES.entries()].map(([key, name]) => [name, key]))
this._voices.clear()
for (const v of allVoices) {
const key = voiceNameToKeyMap.get(v.name)
if (key && v.lang === OPTIONS.lang) {
this._voices.set(key, v)
}
}
this._availableVoices = [...this._voices.values()]
if (this._availableVoices.length > 0) {
this._isInitialized = true
}
speechSynthesis.onvoiceschanged = null
resolve()
}
const initialVoices = speechSynthesis.getVoices()
if (initialVoices.length > 0) {
loadVoices()
} else {
speechSynthesis.onvoiceschanged = loadVoices
setTimeout(loadVoices, 500)
}
})
},
}
const MainController = {
run() {
const options = SettingsPanel.getOptions()
if (!options.SCRIPT_ENABLED) {
document.addEventListener('DOMContentLoaded', () => SettingsPanel.init())
return
}
PageOptimizer.init()
RubyConverter.init(RULES)
TTSPlayer.init({ rate: options.TTS_RATE })
this._subscribeToEvents()
document.addEventListener('DOMContentLoaded', () => {
PageOptimizer.cleanupGlobalElements()
IframeLoader.init({ IFRAME_LOAD_ENABLED: options.IFRAME_LOAD_ENABLED })
SettingsPanel.init()
if (options.TTS_ENABLED) {
ContextMenu.init()
}
this._processPageContent()
})
},
_subscribeToEvents() {
emitter.on('tts:toggle', (enabled) => {
enabled ? ContextMenu.init() : ContextMenu.destroy()
})
},
_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.applyRubyToContainer(body)
IframeLoader.replaceIframesInContainer(body)
ImageProcessor.process(body)
PageOptimizer.cleanupArticleBody(body)
}
currentIndex = endIndex
if (currentIndex < articleBodies.length) {
requestAnimationFrame(processBatch)
} else {
PageOptimizer.finalizeLayout()
}
}
requestAnimationFrame(processBatch)
},
}
MainController.run()
})()