Bionic Reading Neo

Bionic Reading User Script ⌘ + B

目前為 2025-01-12 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name              Bionic Reading Neo
// @namespace         http://tampermonkey.net/
// @version           0.1.0
// @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;

        // Split text into parts and create DOM elements instead of using innerHTML
        const parts = text.split(engRegexg);
        const matches = text.match(engRegexg) || [];
        let currentIndex = 0;

        // Add first text part if exists
        if (parts[0]) {
            spanEl.appendChild(document.createTextNode(parts[0]));
            currentIndex++;
        }

        // Process each matched word
        matches.forEach((word, i) => {
            if(word.length < config.minWordLength || 
               (config.skipWords && config.excludeWords.includes(word)) ||
               (config.saccade && !saccadeCounter())) {
                spanEl.appendChild(document.createTextNode(word));
            } else {
                const halfLength = getHalfLength(word);
                if (halfLength === 0) {
                    spanEl.appendChild(document.createTextNode(word));
                } else {
                    const boldPart = document.createElement('bbb');
                    boldPart.textContent = word.substr(0, halfLength);
                    spanEl.appendChild(boldPart);
                    spanEl.appendChild(document.createTextNode(word.substr(halfLength)));
                }
            }

            // Add text part after the word if exists
            if (parts[currentIndex]) {
                spanEl.appendChild(document.createTextNode(parts[currentIndex]));
            }
            currentIndex++;
        });

        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();
            }
        }
    }
})