Tencent Translator Enhancer

在腾讯翻译君中添加往返翻译等功能。

当前为 2019-12-24 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Tencent Translator Enhancer
  3. // @name:ja Tencent Translator Enhancer
  4. // @name:zh-CN Tencent Translator Enhancer
  5. // @namespace knoa.jp
  6. // @description It brings back-and-forth translation to Tencent Translator (腾讯翻译君).
  7. // @description:ja 騰訊翻訳君(腾讯翻译君)に往復翻訳などの機能を追加します。
  8. // @description:zh-CN 在腾讯翻译君中添加往返翻译等功能。
  9. // @include https://fanyi.qq.com/
  10. // @version 1
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. (function(){
  15. const SCRIPTID = 'TencentTranslatorEnhancer';
  16. const SCRIPTNAME = 'Tencent Translator Enhancer';
  17. const DEBUG = false;/*
  18. [update]
  19.  
  20. [bug]
  21. たまに失敗するね observe検知のタイミング?
  22.  
  23. [todo]
  24.  
  25. [possible]
  26.  
  27. [memo]
  28. */
  29. if(window === top && console.time) console.time(SCRIPTID);
  30. const CORRECTIONS = [
  31. (s) => s.replace(/htt?p(s?)[::]\/\/([^\s。]+)([。. ]*)/ig, 'http$1://$2'),
  32. (s) => s.replace(/([0-9]+):([0-9]+)/g, '$1:$2'),
  33. ];
  34. const SEPARATORS = ['\n:\n', '\n:\n', ':'];/*翻訳元, 翻訳先, 翻訳先span.textContent */
  35. const RETRY = 10;
  36. let site = {
  37. targets: {
  38. textpanelSource: () => $('.textpanel-source'),
  39. sourceTextarea: () => $('[node-type="source-textarea"]'),
  40. textpanelTargetTextblock: () => $('[node-type="textpanel-target-textblock"]'),
  41. exchangeLanguageButton: () => $('[node-type="exchange_language_button"]'),
  42. },
  43. get: {
  44. textSrcs: (textpanelTargetTextblock) => textpanelTargetTextblock.querySelectorAll('.text-src'),
  45. textDsts: (textpanelTargetTextblock) => textpanelTargetTextblock.querySelectorAll('.text-dst'),
  46. textMatrix: (textpanelTargetTextblock) => {
  47. return {
  48. srcs: Array.from(site.get.textSrcs(textpanelTargetTextblock)).map(e => e.textContent),
  49. dsts: Array.from(site.get.textDsts(textpanelTargetTextblock)).map(e => e.textContent),
  50. };
  51. },
  52. },
  53. };
  54. let html, elements = {}, timers = {}, sizes = {};
  55. let core = {
  56. initialize: function(){
  57. html = document.documentElement;
  58. html.classList.add(SCRIPTID);
  59. core.ready();
  60. core.addStyle();
  61. },
  62. ready: function(){
  63. core.getTargets(site.targets, RETRY).then(() => {
  64. log("I'm ready.");
  65. core.listenUserActions();
  66. core.expandClickableArea();
  67. });
  68. },
  69. listenUserActions: function(){
  70. window.addEventListener('keypress', function(e){
  71. switch(true){
  72. case(e.key === 'Enter' && e.shiftKey === true):
  73. core.translateSwitch();
  74. return e.preventDefault();
  75. case(e.key === 'Enter' && e.ctrlKey === true):
  76. core.translateBackSwitch();
  77. return e.preventDefault();
  78. }
  79. });
  80. },
  81. translateSwitch: function(){
  82. /* 翻訳言語の向きを入れ替える */
  83. let exchangeLanguageButton = elements.exchangeLanguageButton, sourceTextarea = elements.sourceTextarea;
  84. exchangeLanguageButton.click();
  85. sourceTextarea.focus();
  86. },
  87. translateBackSwitch: function(){
  88. /* 往復翻訳の有効無効を切り替える */
  89. let exchangeLanguageButton = elements.exchangeLanguageButton;
  90. if(exchangeLanguageButton.dataset.translateBack === 'true'){
  91. exchangeLanguageButton.dataset.translateBack = 'false';
  92. }else{
  93. exchangeLanguageButton.dataset.translateBack = 'true';
  94. core.translateBack();
  95. }
  96. },
  97. translateBack: function(){
  98. /* 往復翻訳する */
  99. let exchangeLanguageButton = elements.exchangeLanguageButton;
  100. let sourceTextarea = elements.sourceTextarea, textpanelTargetTextblock = elements.textpanelTargetTextblock;
  101. let source = sourceTextarea.value, target = textpanelTargetTextblock.innerText, result = '';
  102. /* まだ往復翻訳してなければ */
  103. let selectionStart = sourceTextarea.selectionStart, selectionEnd = sourceTextarea.selectionEnd;/*カーソル位置を記憶*/
  104. if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
  105. result = source + SEPARATORS[0] + target;
  106. /* すでに往復翻訳済みなら */
  107. }else{
  108. source = source.slice(0, source.indexOf(SEPARATORS[0]));
  109. target = target.slice(0, target.indexOf(SEPARATORS[1]));
  110. result = source + SEPARATORS[0] + target;
  111. }
  112. /* 左辺の表示を完成させる */
  113. CORRECTIONS.forEach(c => result = c(result));
  114. sourceTextarea.value = result;
  115. sourceTextarea.dispatchEvent(new Event('input'));
  116. sourceTextarea.setSelectionRange(selectionStart, selectionEnd);
  117. /* 右辺の表示を追従させる */
  118. core.translateSwitch();
  119. if(textpanelTargetTextblock.dataset.status !== undefined) return;
  120. let compositing = false, innerText = textpanelTargetTextblock.innerText;
  121. let observer = observe(textpanelTargetTextblock, function(records){
  122. if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
  123. exchangeLanguageButton.dataset.translateBack = 'false';
  124. delete(textpanelTargetTextblock.dataset.status);
  125. observer.disconnect();
  126. return;
  127. }
  128. if(textpanelTargetTextblock.innerText === innerText) return;
  129. innerText = textpanelTargetTextblock.innerText;
  130. switch(textpanelTargetTextblock.dataset.status){
  131. /* 往復を終えた最終翻訳が取得できたタイミング */
  132. case(undefined):
  133. case('back'):
  134. textpanelTargetTextblock.textMatrix = site.get.textMatrix(textpanelTargetTextblock);
  135. core.translateSwitch();
  136. textpanelTargetTextblock.dataset.status = 'go';
  137. break;
  138. /* 往路スタンバイに戻ったタイミング */
  139. case('go'):
  140. setTimeout(function(){
  141. let textDsts = site.get.textDsts(textpanelTargetTextblock);
  142. for(let i = Array.from(textDsts).findIndex(t => t.textContent === SEPARATORS[2]) + 1; textDsts[i]; i++){
  143. textDsts[i].textContent = textpanelTargetTextblock.textMatrix.dsts[i];
  144. let once = observe(textDsts[i], function(r){
  145. log(r);
  146. });
  147. }
  148. textpanelTargetTextblock.dataset.status = 'done';
  149. }, 1000);/*再度更新される場合があるので*/
  150. break;
  151. /* テキスト変更を検知して自動翻訳されたタイミング */
  152. case('done'):
  153. if(compositing === true) return;
  154. core.translateBack();
  155. textpanelTargetTextblock.dataset.status = 'back';
  156. break;
  157. }
  158. });
  159. sourceTextarea.addEventListener('compositionstart', function(e){
  160. compositing = true;
  161. });
  162. sourceTextarea.addEventListener('compositionend', function(e){
  163. compositing = false;
  164. });
  165. },
  166. expandClickableArea: function(){
  167. let textpanelSource = elements.textpanelSource, sourceTextarea = elements.sourceTextarea;
  168. textpanelSource.addEventListener('click', function(e){
  169. sourceTextarea.focus();
  170. }, true);
  171. },
  172. getTargets: function(targets, retry = 0){
  173. const get = function(resolve, reject, retry){
  174. for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
  175. let selected = targets[key]();
  176. if(selected){
  177. if(selected.length) selected.forEach((s) => s.dataset.selector = key);
  178. else selected.dataset.selector = key;
  179. elements[key] = selected;
  180. }else{
  181. if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
  182. log(`Not found: ${key}, retrying... (left ${retry})`);
  183. return setTimeout(get, 1000, resolve, reject, retry);
  184. }
  185. }
  186. resolve();
  187. };
  188. return new Promise(function(resolve, reject){
  189. get(resolve, reject, retry);
  190. });
  191. },
  192. addStyle: function(name = 'style'){
  193. if(core.html[name] === undefined) return;
  194. let style = createElement(core.html[name]());
  195. document.head.appendChild(style);
  196. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  197. elements[name] = style;
  198. },
  199. html: {
  200. style: () => `
  201. <style type="text/css">
  202. /* 翻訳方向スイッチボタン */
  203. [data-selector="exchangeLanguageButton"]{
  204. border: 1px solid transparent;
  205. border-radius: 100%;
  206. width: 36px;
  207. height: 36px;
  208. }
  209. [data-selector="exchangeLanguageButton"][data-translate-back="true"]{
  210. border: 1px solid rgb(160, 76, 247);
  211. }
  212. /* クリッカブル領域を広げる */
  213. [data-selector="textpanelSource"]{
  214. cursor: text;
  215. }
  216. dummy [data-selector="sourceTextarea"]{
  217. height: 100% !important;
  218. }
  219. /* 往復翻訳処理中 */
  220. [data-selector="textpanelTargetTextblock"]{
  221. transition: opacity 125ms;
  222. }
  223. [data-selector="textpanelTargetTextblock"][data-status="back"],
  224. [data-selector="textpanelTargetTextblock"][data-status="go"]{
  225. animation: ${SCRIPTID}-blink 500ms ease infinite;
  226. }
  227. @keyframes ${SCRIPTID}-blink{
  228. 0%{opacity: .250}
  229. 100%{opacity: .125}
  230. }
  231. </style>
  232. `,
  233. },
  234. };
  235. const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  236. const alert = window.alert, confirm = window.confirm, getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  237. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  238. class Storage{
  239. static key(key){
  240. return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
  241. }
  242. static save(key, value, expire = null){
  243. key = Storage.key(key);
  244. localStorage[key] = JSON.stringify({
  245. value: value,
  246. saved: Date.now(),
  247. expire: expire,
  248. });
  249. }
  250. static read(key){
  251. key = Storage.key(key);
  252. if(localStorage[key] === undefined) return undefined;
  253. let data = JSON.parse(localStorage[key]);
  254. if(data.value === undefined) return data;
  255. if(data.expire === undefined) return data;
  256. if(data.expire === null) return data.value;
  257. if(data.expire < Date.now()) return localStorage.removeItem(key);
  258. return data.value;
  259. }
  260. static delete(key){
  261. key = Storage.key(key);
  262. delete localStorage.removeItem(key);
  263. }
  264. static saved(key){
  265. key = Storage.key(key);
  266. if(localStorage[key] === undefined) return undefined;
  267. let data = JSON.parse(localStorage[key]);
  268. if(data.saved) return data.saved;
  269. else return undefined;
  270. }
  271. }
  272. const $ = function(s, f){
  273. let target = document.querySelector(s);
  274. if(target === null) return null;
  275. return f ? f(target) : target;
  276. };
  277. const $$ = function(s){return document.querySelectorAll(s)};
  278. const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  279. const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  280. const createElement = function(html = '<span></span>'){
  281. let outer = document.createElement('div');
  282. outer.innerHTML = html;
  283. return outer.firstElementChild;
  284. };
  285. const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
  286. let observer = new MutationObserver(callback.bind(element));
  287. observer.observe(element, options);
  288. return observer;
  289. };
  290. const normalize = function(string){
  291. return string.replace(/[!-~]/g, function(s){
  292. return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
  293. }).replace(normalize.RE, function(s){
  294. return normalize.KANA[s];
  295. }).replace(/ /g, ' ').replace(/~/g, '〜');
  296. };
  297. normalize.KANA = {
  298. ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ',
  299. ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ',
  300. ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド',
  301. バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ',
  302. パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ',
  303. ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ',
  304. ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ',
  305. カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ',
  306. サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ',
  307. タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト',
  308. ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ',
  309. ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ',
  310. マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ',
  311. ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ',
  312. ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ',
  313. ワ:'ワ', ヲ:'ヲ', ン:'ン',
  314. ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ',
  315. ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ',
  316. "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・',
  317. };
  318. normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g');
  319. const log = function(){
  320. if(!DEBUG) return;
  321. let l = log.last = log.now || new Date(), n = log.now = new Date();
  322. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  323. //console.log(error.stack);
  324. console.log(
  325. (SCRIPTID || '') + ':',
  326. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  327. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  328. /* :00 */ ':' + line,
  329. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  330. /* caller */ (callers[1] || '') + '()',
  331. ...arguments
  332. );
  333. };
  334. log.formats = [{
  335. name: 'Firefox Scratchpad',
  336. detector: /MARKER@Scratchpad/,
  337. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  338. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  339. }, {
  340. name: 'Firefox Console',
  341. detector: /MARKER@debugger/,
  342. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  343. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  344. }, {
  345. name: 'Firefox Greasemonkey 3',
  346. detector: /\/gm_scripts\//,
  347. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  348. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  349. }, {
  350. name: 'Firefox Greasemonkey 4+',
  351. detector: /MARKER@user-script:/,
  352. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  353. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  354. }, {
  355. name: 'Firefox Tampermonkey',
  356. detector: /MARKER@moz-extension:/,
  357. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  358. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  359. }, {
  360. name: 'Chrome Console',
  361. detector: /at MARKER \(<anonymous>/,
  362. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  363. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  364. }, {
  365. name: 'Chrome Tampermonkey',
  366. detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
  367. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
  368. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  369. }, {
  370. name: 'Chrome Extension',
  371. detector: /at MARKER \(chrome-extension:/,
  372. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  373. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  374. }, {
  375. name: 'Edge Console',
  376. detector: /at MARKER \(eval/,
  377. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  378. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  379. }, {
  380. name: 'Edge Tampermonkey',
  381. detector: /at MARKER \(Function/,
  382. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  383. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  384. }, {
  385. name: 'Safari',
  386. detector: /^MARKER$/m,
  387. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  388. getCallers: (e) => e.stack.split('\n'),
  389. }, {
  390. name: 'Default',
  391. detector: /./,
  392. getLine: (e) => 0,
  393. getCallers: (e) => [],
  394. }];
  395. log.format = log.formats.find(function MARKER(f){
  396. if(!f.detector.test(new Error().stack)) return false;
  397. //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
  398. return true;
  399. });
  400. const time = function(label){
  401. if(!DEBUG) return;
  402. const BAR = '|', TOTAL = 100;
  403. switch(true){
  404. case(label === undefined):/* time() to output total */
  405. let total = 0;
  406. Object.keys(time.records).forEach((label) => total += time.records[label].total);
  407. Object.keys(time.records).forEach((label) => {
  408. console.log(
  409. BAR.repeat((time.records[label].total / total) * TOTAL),
  410. label + ':',
  411. (time.records[label].total).toFixed(3) + 'ms',
  412. '(' + time.records[label].count + ')',
  413. );
  414. });
  415. time.records = {};
  416. break;
  417. case(!time.records[label]):/* time('label') to create and start the record */
  418. time.records[label] = {count: 0, from: performance.now(), total: 0};
  419. break;
  420. case(time.records[label].from === null):/* time('label') to re-start the lap */
  421. time.records[label].from = performance.now();
  422. break;
  423. case(0 < time.records[label].from):/* time('label') to add lap time to the record */
  424. time.records[label].total += performance.now() - time.records[label].from;
  425. time.records[label].from = null;
  426. time.records[label].count += 1;
  427. break;
  428. }
  429. };
  430. time.records = {};
  431. core.initialize();
  432. if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
  433. })();