Bionic Reading Neo

Bionic Reading User Script ⌘ + B

目前为 2025-01-13 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Bionic Reading Neo
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.1.3
  5. // @description Bionic Reading User Script ⌘ + B
  6. // @author RoCry
  7. // @match *://*/*
  8. // @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==
  9. // @exclude /\.(js|java|c|cpp|h|py|css|less|scss|json|yaml|yml|xml)(?:\?.+)$/
  10. // @license MIT
  11. // @run-at document-end
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @grant GM_registerMenuCommand
  15. // @grant GM_addStyle
  16. // ==/UserScript==
  17.  
  18. const defaultConfig = {
  19. // Whether to automatically apply bionic reading when page loads
  20. autoBionic: false,
  21.  
  22. // Whether to skip processing text inside <a> tags that contain URLs
  23. skipLinks: true,
  24.  
  25. // The ratio of characters to bold at the start of each word (0.0 to 1.0)
  26. scale: 0.5,
  27.  
  28. // Maximum number of characters to bold in any word (null for no limit)
  29. maxBionicLength: null,
  30.  
  31. // Opacity of the bold text (0.0 to 1.0)
  32. opacity: 1,
  33.  
  34. // Number of words to skip between processed words (0 for no skipping)
  35. // Higher values create a "saccade" effect, mimicking natural eye movement
  36. saccade: 0,
  37.  
  38. // Whether to use special Unicode characters instead of bold text
  39. symbolMode: false,
  40.  
  41. // Whether to skip processing words in the excludeWords list
  42. skipWords: true,
  43. // List of common words to skip when skipWords is true
  44. excludeWords: ['is','and','as','if','the','of','to','be','for','this'],
  45.  
  46. // Minimum word length to apply bionic reading (shorter words are skipped)
  47. minWordLength: 3,
  48.  
  49. // Minimum ratio of ASCII characters required to consider content as English
  50. // (0.0 to 1.0) - Higher values mean stricter English detection
  51. minAsciiRatio: 0.9,
  52. // Number of characters to analyze for language detection
  53. charsToCheck: 300,
  54.  
  55. // 400: Normal
  56. // 500: Medium
  57. // 600: Semi-bold
  58. // 700: Bold
  59. // 800: Extra bold
  60. // 900: Black (maximum boldness)
  61. fontWeight: 700,
  62. };
  63.  
  64. let config = defaultConfig;
  65. try {
  66. config = (_=>{
  67. const _config = GM_getValue('config');
  68. if(!_config) return defaultConfig;
  69. for(let key in defaultConfig){
  70. if(_config[key] === undefined) _config[key] = defaultConfig[key];
  71. }
  72. return _config;
  73. })();
  74. GM_setValue('config',config);
  75. } catch(e) {
  76. console.log('Failed to read default config')
  77. }
  78.  
  79. let isBionic = false;
  80. let body = document.body;
  81.  
  82. const styleEl = document.createElement('style');
  83. styleEl.textContent = `bbb{font-weight:${config.fontWeight};opacity:${config.opacity}}html[data-site="greasyfork"] a bionic{pointer-events:none}`;
  84.  
  85. document.documentElement.setAttribute('data-site',location.hostname.replace(/\.\w+$|www\./ig,''))
  86.  
  87. const excludeNodeNames = [
  88. 'script','style','xmp',
  89. 'input','textarea','select',
  90. 'pre','code',
  91. 'h1','h2',
  92. 'b','strong',
  93. 'svg','embed',
  94. 'img','audio','video',
  95. 'canvas',
  96. ];
  97.  
  98. const excludeClasses = [
  99. 'highlight',
  100. 'katex',
  101. 'editor',
  102. ]
  103.  
  104. const excludeClassesRegexi = new RegExp(excludeClasses.join('|'),'i');
  105. const linkRegex = /^https?:\/\//;
  106.  
  107. function isEnglishContent() {
  108. try {
  109. const title = document.title || '';
  110. const firstParagraphs = Array.from(document.getElementsByTagName('p'))
  111. .slice(0, 3)
  112. .map(p => p.textContent)
  113. .join(' ');
  114. const textToAnalyze = (title + ' ' + firstParagraphs)
  115. .slice(0, config.charsToCheck)
  116. .replace(/\s+/g, ' ')
  117. .trim();
  118.  
  119. if (!textToAnalyze) return true;
  120.  
  121. const asciiChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '')
  122. .split('')
  123. .filter(char => char.charCodeAt(0) <= 127).length;
  124. const totalChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '').length;
  125. if (totalChars === 0) return true;
  126. const asciiRatio = asciiChars / totalChars;
  127. console.log('🈂️ ASCII Ratio:', asciiRatio.toFixed(2));
  128. return asciiRatio >= config.minAsciiRatio;
  129. } catch (e) {
  130. console.error('Error checking content language:', e);
  131. return true;
  132. }
  133. }
  134.  
  135. const gather = el=>{
  136. let textEls = [];
  137. el.childNodes.forEach(el=>{
  138. if(el.isEnB) return;
  139. if(el.originEl) return;
  140.  
  141. if(el.nodeType === 3){
  142. textEls.push(el);
  143. }else if(el.childNodes){
  144. const nodeName = el.nodeName.toLowerCase();
  145. if(excludeNodeNames.includes(nodeName)) return;
  146. if(config.skipLinks){
  147. if(nodeName === 'a'){
  148. if(linkRegex.test(el.textContent)) return;
  149. }
  150. }
  151. if(el.getAttribute){
  152. if(el.getAttribute('class') && excludeClassesRegexi.test(el.getAttribute('class'))) return;
  153. if(el.getAttribute('contentEditable') === 'true') return;
  154. }
  155.  
  156. textEls = textEls.concat(gather(el))
  157. }
  158. })
  159. return textEls;
  160. };
  161.  
  162. const engRegex = /[a-zA-Z][a-z]+/;
  163. const engRegexg = new RegExp(engRegex,'g');
  164.  
  165. const getHalfLength = word=>{
  166. if (word.length < config.minWordLength) return 0;
  167.  
  168. let halfLength;
  169. if(/ing$/.test(word)){
  170. halfLength = word.length - 3;
  171. }else if(word.length<5){
  172. halfLength = Math.floor(word.length * config.scale);
  173. }else{
  174. halfLength = Math.ceil(word.length * config.scale);
  175. }
  176.  
  177. if(config.maxBionicLength){
  178. halfLength = Math.min(halfLength, config.maxBionicLength)
  179. }
  180. return halfLength;
  181. }
  182.  
  183. let count = 0;
  184. const saccadeRound = config.saccade + 1;
  185. const saccadeCounter = _=>{
  186. return ++count % saccadeRound === 0;
  187. };
  188.  
  189. const replaceTextByEl = el=>{
  190. const text = el.data;
  191. if(!engRegex.test(text))return;
  192.  
  193. if(!el.replaceEl){
  194. const spanEl = document.createElement('bionic');
  195. spanEl.isEnB = true;
  196. spanEl.innerHTML = text.replace(/[\u00A0-\u9999<>\&]/g, w=>'&#'+w.charCodeAt(0)+';')
  197. .replace(engRegexg,word=>{
  198. if(word.length < config.minWordLength) return word;
  199. if(config.skipWords && config.excludeWords.includes(word)) return word;
  200. if(config.saccade && !saccadeCounter()) return word;
  201.  
  202. const halfLength = getHalfLength(word);
  203. if (halfLength === 0) return word;
  204. return '<bbb>'+word.substr(0,halfLength)+'</bbb>'+word.substr(halfLength);
  205. });
  206. spanEl.originEl = el;
  207. el.replaceEl = spanEl;
  208. }
  209.  
  210. el.after(el.replaceEl);
  211. el.remove();
  212. };
  213.  
  214. const replaceTextSymbolModeByEl = el=>{
  215. const text = el.data;
  216. if(!engRegex.test(text))return;
  217.  
  218. // For symbol mode, we can still use textContent since we're not creating HTML
  219. el.data = text.replace(engRegexg,word=>{
  220. if(word.length < config.minWordLength) return word;
  221. if(config.skipWords && config.excludeWords.includes(word)) return word;
  222. if(config.saccade && !saccadeCounter()) return word;
  223.  
  224. const halfLength = getHalfLength(word);
  225. if (halfLength === 0) return word;
  226. const a = word.substr(0,halfLength).
  227. replace(/[a-z]/g,w=>String.fromCharCode(55349,w.charCodeAt(0)+56717)).
  228. replace(/[A-Z]/g,w=>String.fromCharCode(55349,w.charCodeAt(0)+56723));
  229. const b = word.substr(halfLength).
  230. replace(/[a-z]/g,w=> String.fromCharCode(55349,w.charCodeAt(0)+56665)).
  231. replace(/[A-Z]/g,w=> String.fromCharCode(55349,w.charCodeAt(0)+56671));
  232. return a + b;
  233. })
  234. }
  235.  
  236. const bionic = (checkIsEnglish = true) => {
  237. if (checkIsEnglish && !isEnglishContent()) {
  238. console.log('🈂️ Non-English content detected, skipping bionic reading');
  239. return;
  240. }
  241.  
  242. const textEls = gather(body);
  243. isBionic = true;
  244. count = 0;
  245.  
  246. let replaceFunc = config.symbolMode ? replaceTextSymbolModeByEl : replaceTextByEl;
  247. textEls.forEach(replaceFunc);
  248. document.head.appendChild(styleEl);
  249. }
  250.  
  251. const lazy = (func,ms = 15)=> {
  252. return _=>{
  253. clearTimeout(func.T)
  254. func.T = setTimeout(func,ms)
  255. }
  256. };
  257.  
  258. const listenerFunc = lazy(_=>{
  259. if(!isBionic) return;
  260. // Don't recheck language for updates when bionic is already enabled
  261. bionic(false);
  262. });
  263.  
  264. if(window.MutationObserver){
  265. (new MutationObserver(listenerFunc)).observe(body,{
  266. childList: true,
  267. subtree: true,
  268. attributes: true,
  269. });
  270. }else{
  271. const {open,send} = XMLHttpRequest.prototype;
  272. XMLHttpRequest.prototype.open = function(){
  273. this.addEventListener('load',listenerFunc);
  274. return open.apply(this,arguments);
  275. };
  276. document.addEventListener('DOMContentLoaded',listenerFunc);
  277. document.addEventListener('DOMNodeInserted',listenerFunc);
  278. }
  279.  
  280. if(config.autoBionic){
  281. window.addEventListener('load', () => bionic(true));
  282. }
  283.  
  284. const revoke = _=>{
  285. const els = [...document.querySelectorAll('bionic')];
  286. els.forEach(el=>{
  287. const {originEl} = el;
  288. if(!originEl) return;
  289. el.after(originEl);
  290. el.remove();
  291. })
  292. isBionic = false;
  293. };
  294.  
  295. document.addEventListener('keydown',e=>{
  296. const { ctrlKey , metaKey, key } = e;
  297. if( ctrlKey || metaKey ){
  298. if(key === 'b'){
  299. if(isBionic){
  300. revoke();
  301. // Save autoBionic as false when manually disabled
  302. config.autoBionic = false;
  303. GM_setValue('config', config);
  304. }else{
  305. bionic(false);
  306. // Save autoBionic as true when manually enabled
  307. config.autoBionic = true;
  308. GM_setValue('config', config);
  309. }
  310. }
  311. }
  312. })
  313.  
  314. GM_addStyle(`
  315. .bionic-config-dialog {
  316. position: fixed;
  317. top: 50%;
  318. left: 50%;
  319. transform: translate(-50%, -50%);
  320. background: white;
  321. padding: 20px;
  322. border-radius: 8px;
  323. box-shadow: 0 0 10px rgba(0,0,0,0.3);
  324. z-index: 999999;
  325. max-width: 500px;
  326. width: 90%;
  327. }
  328. .bionic-config-dialog label {
  329. display: block;
  330. margin: 10px 0;
  331. }
  332. .bionic-config-dialog input[type="number"] {
  333. width: 60px;
  334. }
  335. `);
  336.  
  337. function showConfigDialog() {
  338. const dialog = document.createElement('div');
  339. dialog.className = 'bionic-config-dialog';
  340. dialog.innerHTML = `
  341. <h3>Bionic Reading Settings</h3>
  342. <label>
  343. <input type="checkbox" id="autoBionic" ${config.autoBionic ? 'checked' : ''}>
  344. Auto-enable on page load
  345. </label>
  346. <label>
  347. <input type="checkbox" id="skipLinks" ${config.skipLinks ? 'checked' : ''}>
  348. Skip URL links
  349. </label>
  350. <label>
  351. Bold ratio (0.0-1.0):
  352. <input type="number" id="scale" min="0" max="1" step="0.1" value="${config.scale}">
  353. </label>
  354. <label>
  355. Bold opacity (0.0-1.0):
  356. <input type="number" id="opacity" min="0" max="1" step="0.1" value="${config.opacity}">
  357. </label>
  358. <label>
  359. Minimum word length:
  360. <input type="number" id="minWordLength" min="1" max="10" value="${config.minWordLength}">
  361. </label>
  362. <label>
  363. <input type="checkbox" id="symbolMode" ${config.symbolMode ? 'checked' : ''}>
  364. Use symbols instead of bold
  365. </label>
  366. <label>
  367. Font Weight (400-900):
  368. <input type="number" id="fontWeight" min="400" max="900" step="100" value="${config.fontWeight}">
  369. </label>
  370. <div>
  371. <button id="closeBtn">Close</button>
  372. <button id="saveBtn">Save</button>
  373. </div>
  374. `;
  375. // Add event listeners after creating the dialog
  376. dialog.querySelector('#closeBtn').addEventListener('click', () => dialog.remove());
  377. dialog.querySelector('#saveBtn').addEventListener('click', () => {
  378. const newConfig = {
  379. ...config,
  380. autoBionic: dialog.querySelector('#autoBionic').checked,
  381. skipLinks: dialog.querySelector('#skipLinks').checked,
  382. scale: parseFloat(dialog.querySelector('#scale').value),
  383. opacity: parseFloat(dialog.querySelector('#opacity').value),
  384. minWordLength: parseInt(dialog.querySelector('#minWordLength').value),
  385. symbolMode: dialog.querySelector('#symbolMode').checked,
  386. fontWeight: parseInt(dialog.querySelector('#fontWeight').value),
  387. };
  388. config = newConfig;
  389. GM_setValue('config', config);
  390. // Update style with new font weight
  391. styleEl.textContent = `bbb{font-weight:${config.fontWeight};opacity:${config.opacity}}html[data-site="greasyfork"] a bionic{pointer-events:none}`;
  392. if (isBionic) {
  393. revoke();
  394. bionic(false);
  395. }
  396. dialog.remove();
  397. });
  398. document.body.appendChild(dialog);
  399. }
  400.  
  401. // Register the menu command
  402. GM_registerMenuCommand('Bionic Reading Settings', showConfigDialog);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址