Bionic Reading Neo

Bionic Reading User Script ⌘ + B

Version vom 12.01.2025. Aktuellste Version

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name              Bionic Reading Neo
// @namespace         http://tampermonkey.net/
// @version           0.1.1
// @description       Bionic Reading User Script ⌘ + B
// @author            RoCry
// @match             *://*/*
// @icon              data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAIMSURBVHgB7ZfvtcFAEMU377zvlEAHOkAFqAAdUAEqoAM6QAV0QAeogA68/W1M3ubPShbn5It7zpDE7OzNzJ3ZI7hrqBLxo0rGl8AvH+fzWbXb7VznWq2mqtWqajQaqt/vm/t3ESBCCNTrdeWLwWCg5vO5IQVWq5XTF7IQF9+PEAAEPRwOYbAgyNwEHI9HdbvdDOnJZPKfPQicTidaMWY6yF07RcZ90kdMv/n90c4mlguaaBRP/JwEptNpKgB+3W435dtqtQoREOAfrfEhAK7XayobvJEPgd1uZ3yJ5d2G1Dep/qyaP4N+e7Nmu92+NgcQrQ2E5wt5CS8CqHg4HJpvOxCq9gUxKpVKOIiyQE/v9/vYM2kle/P1eu09kMggZta5RJhniBQR2VAFRahnQX4XFDHUb3dLHgEIs7k9B5wl0P2uOp1OdE/qL5eL2mw2kQj51gTMtehgPB47JyFGB+g2jMrmJICyGZtJsBEHF8EEkGg2m+ZaDqwsAsRk8xhcJXANInuQ2DYajZ6WgDHM76y18dIcII1JJGdDEpLR2WwWe/4SATv9Ars9XaB8rLXb25sAAXq9Xup5kWmIPnSpzDCL4NIArSKnlhjPlKMlJUbeHJDDbLFYhK3rIuBjOrVhsIKDCIFDAjJvE5DNfQhIFiDiTYCFut5m4+RmRQmA5XJpYgWPhaXh+8fkS6B0An855cAlu9FutwAAAABJRU5ErkJggg==
// @exclude           /\.(js|java|c|cpp|h|py|css|less|scss|json|yaml|yml|xml)(?:\?.+)$/
// @license           MIT
// @run-at            document-end
// @grant            GM_getValue
// @grant            GM_setValue
// ==/UserScript==

/*
To change config, open browser console and run:
1. Get current config:
   > GM_getValue('config')

2. Set new config (example):
   > const newConfig = GM_getValue('config')
   > newConfig.scale = 0.7  // change any option you want
   > GM_setValue('config', newConfig)

3. Refresh the page to apply changes

Available config options are listed below in defaultConfig
*/

const defaultConfig = {
    // Whether to automatically apply bionic reading when page loads
    autoBionic: false,

    // Whether to skip processing text inside <a> tags that contain URLs
    skipLinks: true,

    // The ratio of characters to bold at the start of each word (0.0 to 1.0)
    scale: 0.5,

    // Maximum number of characters to bold in any word (null for no limit)
    maxBionicLength: null,

    // Opacity of the bold text (0.0 to 1.0)
    opacity: 1,

    // Number of words to skip between processed words (0 for no skipping)
    // Higher values create a "saccade" effect, mimicking natural eye movement
    saccade: 0,

    // Whether to use special Unicode characters instead of bold text
    symbolMode: false,

    // Whether to skip processing words in the excludeWords list
    skipWords: true,
    // List of common words to skip when skipWords is true
    excludeWords: ['is','and','as','if','the','of','to','be','for','this'],

    // Minimum word length to apply bionic reading (shorter words are skipped)
    minWordLength: 3,

    // Minimum ratio of ASCII characters required to consider content as English
    // (0.0 to 1.0) - Higher values mean stricter English detection
    minAsciiRatio: 0.9,
    // Number of characters to analyze for language detection
    charsToCheck: 300
};

let config = defaultConfig;
try {
    config = (_=>{
        const _config = GM_getValue('config');
        if(!_config) return defaultConfig;
    
        for(let key in defaultConfig){
            if(_config[key] === undefined) _config[key] = defaultConfig[key];
        }
        return _config;
    })();
    
    GM_setValue('config',config);
} catch(e) {
    console.log('Failed to read default config')
}

let isBionic = false;
let body = document.body;

const styleEl = document.createElement('style');
styleEl.textContent = `bbb{font-weight:bold;opacity:${config.opacity}}html[data-site="greasyfork"] a bionic{pointer-events:none}`;

document.documentElement.setAttribute('data-site',location.hostname.replace(/\.\w+$|www\./ig,''))

const excludeNodeNames = [
    'script','style','xmp',
    'input','textarea','select',
    'pre','code',
    'h1','h2',
    'b','strong',
    'svg','embed',
    'img','audio','video',
    'canvas',
];

const excludeClasses = [
    'highlight',
    'katex',
    'editor',
]

const excludeClassesRegexi = new RegExp(excludeClasses.join('|'),'i');
const linkRegex = /^https?:\/\//;

function isEnglishContent() {
    try {
        const title = document.title || '';
        const firstParagraphs = Array.from(document.getElementsByTagName('p'))
            .slice(0, 3)
            .map(p => p.textContent)
            .join(' ');
        
        const textToAnalyze = (title + ' ' + firstParagraphs)
            .slice(0, config.charsToCheck)
            .replace(/\s+/g, ' ')
            .trim();

        if (!textToAnalyze) return true;

        const asciiChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '')
            .split('')
            .filter(char => char.charCodeAt(0) <= 127).length;
        
        const totalChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '').length;
        
        if (totalChars === 0) return true;
        
        const asciiRatio = asciiChars / totalChars;
        console.log('🈂️ ASCII Ratio:', asciiRatio.toFixed(2));
        
        return asciiRatio >= config.minAsciiRatio;
    } catch (e) {
        console.error('Error checking content language:', e);
        return true;
    }
}

const gather = el=>{
    let textEls = [];
    el.childNodes.forEach(el=>{
        if(el.isEnB) return;
        if(el.originEl) return;

        if(el.nodeType === 3){
            textEls.push(el);
        }else if(el.childNodes){
            const nodeName = el.nodeName.toLowerCase();
            if(excludeNodeNames.includes(nodeName)) return;
            if(config.skipLinks){
                if(nodeName === 'a'){
                    if(linkRegex.test(el.textContent)) return;
                }
            }
            if(el.getAttribute){
                if(el.getAttribute('class') && excludeClassesRegexi.test(el.getAttribute('class'))) return;
                if(el.getAttribute('contentEditable') === 'true') return;
            }

            textEls = textEls.concat(gather(el))
        }
    })
    return textEls;
};

const engRegex  = /[a-zA-Z][a-z]+/;
const engRegexg = new RegExp(engRegex,'g');

const getHalfLength = word=>{
    if (word.length < config.minWordLength) return 0;

    let halfLength;
    if(/ing$/.test(word)){
        halfLength = word.length - 3;
    }else if(word.length<5){
        halfLength = Math.floor(word.length * config.scale);
    }else{
        halfLength = Math.ceil(word.length * config.scale);
    }

    if(config.maxBionicLength){
        halfLength = Math.min(halfLength, config.maxBionicLength)
    }
    return halfLength;
}

let count = 0;
const saccadeRound = config.saccade + 1;
const saccadeCounter = _=>{
    return ++count % saccadeRound === 0;
};

const replaceTextByEl = el=>{
    const text = el.data;
    if(!engRegex.test(text))return;

    if(!el.replaceEl){
        const spanEl = document.createElement('bionic');
        spanEl.isEnB = true;
        spanEl.innerHTML = text.replace(/[\u00A0-\u9999<>\&]/g, w=>'&#'+w.charCodeAt(0)+';')
            .replace(engRegexg,word=>{
                if(word.length < config.minWordLength) return word;
                if(config.skipWords && config.excludeWords.includes(word)) return word;
                if(config.saccade && !saccadeCounter()) return word;

                const halfLength = getHalfLength(word);
                if (halfLength === 0) return word;
                return '<bbb>'+word.substr(0,halfLength)+'</bbb>'+word.substr(halfLength);
            });
        spanEl.originEl = el;
        el.replaceEl = spanEl;
    }

    el.after(el.replaceEl);
    el.remove();
};

const replaceTextSymbolModeByEl = el=>{
    const text = el.data;
    if(!engRegex.test(text))return;

    // For symbol mode, we can still use textContent since we're not creating HTML
    el.data = text.replace(engRegexg,word=>{
        if(word.length < config.minWordLength) return word;
        if(config.skipWords && config.excludeWords.includes(word)) return word;
        if(config.saccade && !saccadeCounter()) return word;

        const halfLength = getHalfLength(word);
        if (halfLength === 0) return word;
        const a = word.substr(0,halfLength).
            replace(/[a-z]/g,w=>String.fromCharCode(55349,w.charCodeAt(0)+56717)).
            replace(/[A-Z]/g,w=>String.fromCharCode(55349,w.charCodeAt(0)+56723));
        const b = word.substr(halfLength).
            replace(/[a-z]/g,w=> String.fromCharCode(55349,w.charCodeAt(0)+56665)).
            replace(/[A-Z]/g,w=> String.fromCharCode(55349,w.charCodeAt(0)+56671));
        return a + b;
    })
}

const bionic = _=>{
    if (!isEnglishContent()) {
        console.log('🈂️ Non-English content detected, skipping bionic reading');
        return;
    }

    const textEls = gather(body);
    isBionic = true;
    count = 0;

    let replaceFunc = config.symbolMode ? replaceTextSymbolModeByEl : replaceTextByEl;
    textEls.forEach(replaceFunc);
    document.head.appendChild(styleEl);
}

const lazy = (func,ms = 15)=> {
    return _=>{
        clearTimeout(func.T)
        func.T = setTimeout(func,ms)
    }
};

const listenerFunc = lazy(_=>{
    if(!isBionic) return;
    bionic();
});

if(window.MutationObserver){
    (new MutationObserver(listenerFunc)).observe(body,{
        childList: true,
        subtree: true,
        attributes: true,
    });
}else{
    const {open,send} = XMLHttpRequest.prototype;
    XMLHttpRequest.prototype.open = function(){
        this.addEventListener('load',listenerFunc);
        return open.apply(this,arguments);
    };
    document.addEventListener('DOMContentLoaded',listenerFunc);
    document.addEventListener('DOMNodeInserted',listenerFunc);
}

if(config.autoBionic){
    window.addEventListener('load',bionic);
}

const revoke = _=>{
    const els = [...document.querySelectorAll('bionic')];
    els.forEach(el=>{
        const {originEl} = el;
        if(!originEl) return;
        el.after(originEl);
        el.remove();
    })
    isBionic = false;
};

document.addEventListener('keydown',e=>{
    const { ctrlKey , metaKey, key } = e;
    if( ctrlKey || metaKey ){
        if(key === 'b'){
            if(isBionic){
                revoke();
            }else{
                bionic();
            }
        }
    }
})