JPDB Immersion Kit Examples

Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.

当前为 2024-09-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name JPDB Immersion Kit Examples
  3. // @version 1.1
  4. // @description Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.
  5. // @author awoo
  6. // @namespace jpdb-immersion-kit-examples
  7. // @match https://jpdb.io/review*
  8. // @match https://jpdb.io/vocabulary/*
  9. // @match https://jpdb.io/kanji/*
  10. // @grant GM_addElement
  11. // @grant GM_xmlhttpRequest
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. const CONFIG = {
  19. IMAGE_WIDTH: '400px',
  20. ENABLE_EXAMPLE_TRANSLATION: true,
  21. SENTENCE_FONT_SIZE: '120%',
  22. TRANSLATION_FONT_SIZE: '85%',
  23. COLORED_SENTENCE_TEXT: true,
  24. AUTO_PLAY_SOUND: true,
  25. SOUND_VOLUME: 0.8,
  26. NUM_PRELOADS: 1
  27. };
  28.  
  29. const state = {
  30. currentExampleIndex: 0,
  31. examples: [],
  32. apiDataFetched: false,
  33. vocab: '',
  34. embedAboveSubsectionMeanings: false,
  35. preloadedIndices: new Set()
  36. };
  37.  
  38. function getImmersionKitData(vocab, callback) {
  39. const url = `https://api.immersionkit.com/look_up_dictionary?keyword=${encodeURIComponent(vocab)}&sort=shortness`;
  40. GM_xmlhttpRequest({
  41. method: "GET",
  42. url: url,
  43. onload: function(response) {
  44. if (response.status === 200) {
  45. try {
  46. const jsonData = JSON.parse(response.responseText);
  47. if (jsonData.data && jsonData.data[0] && jsonData.data[0].examples) {
  48. state.examples = jsonData.data[0].examples;
  49. state.apiDataFetched = true;
  50. state.currentExampleIndex = parseInt(getStoredData(vocab)) || 0;
  51. }
  52. } catch (e) {
  53. console.error('Error parsing JSON response:', e);
  54. }
  55. }
  56. callback();
  57. },
  58. onerror: function(error) {
  59. console.error('Error fetching data:', error);
  60. callback();
  61. }
  62. });
  63. }
  64.  
  65. function getStoredData(key) {
  66. return localStorage.getItem(key);
  67. }
  68.  
  69. function storeData(key, value) {
  70. localStorage.setItem(key, value);
  71. }
  72.  
  73. function exportFavorites() {
  74. const favorites = {};
  75. for (let i = 0; i < localStorage.length; i++) {
  76. const key = localStorage.key(i);
  77. favorites[key] = localStorage.getItem(key);
  78. }
  79. const blob = new Blob([JSON.stringify(favorites, null, 2)], { type: 'application/json' });
  80. const url = URL.createObjectURL(blob);
  81. const a = document.createElement('a');
  82. a.href = url;
  83. a.download = 'favorites.json';
  84. document.body.appendChild(a);
  85. a.click();
  86. document.body.removeChild(a);
  87. URL.revokeObjectURL(url);
  88. }
  89.  
  90. function importFavorites(event) {
  91. const file = event.target.files[0];
  92. if (!file) return;
  93.  
  94. const reader = new FileReader();
  95. reader.onload = function(e) {
  96. try {
  97. const favorites = JSON.parse(e.target.result);
  98. for (const key in favorites) {
  99. localStorage.setItem(key, favorites[key]);
  100. }
  101. alert('Favorites imported successfully!');
  102. } catch (error) {
  103. alert('Error importing favorites:', error);
  104. }
  105. };
  106. reader.readAsText(file);
  107. }
  108.  
  109. function parseVocabFromAnswer() {
  110. const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]');
  111. for (const element of elements) {
  112. const href = element.getAttribute('href');
  113. const text = element.textContent.trim();
  114. const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/);
  115. if (match) return match[2].trim();
  116. if (text) return text.trim();
  117. }
  118. return '';
  119. }
  120.  
  121. function parseVocabFromReview() {
  122. const kindElement = document.querySelector('.kind');
  123. if (!kindElement) return '';
  124.  
  125. const kindText = kindElement.textContent.trim();
  126. if (kindText !== 'Kanji' && kindText !== 'Vocabulary') return '';
  127.  
  128. if (kindText === 'Vocabulary') {
  129. const plainElement = document.querySelector('.plain');
  130. if (!plainElement) return '';
  131.  
  132. let vocabulary = plainElement.textContent.trim();
  133. const nestedVocabularyElement = plainElement.querySelector('div:not([style])');
  134. if (nestedVocabularyElement) {
  135. vocabulary = nestedVocabularyElement.textContent.trim();
  136. }
  137. const specificVocabularyElement = plainElement.querySelector('div:nth-child(3)');
  138. if (specificVocabularyElement) {
  139. vocabulary = specificVocabularyElement.textContent.trim();
  140. }
  141.  
  142. const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  143. if (kanjiRegex.test(vocabulary) || vocabulary) {
  144. return vocabulary;
  145. }
  146. } else if (kindText === 'Kanji') {
  147. const hiddenInput = document.querySelector('input[name="c"]');
  148. if (!hiddenInput) return '';
  149.  
  150. const vocab = hiddenInput.value.split(',')[1];
  151. const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  152. if (kanjiRegex.test(vocab)) {
  153. return vocab;
  154. }
  155. }
  156.  
  157. return '';
  158. }
  159.  
  160. function parseVocabFromVocabulary() {
  161. const url = window.location.href;
  162. const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#]*)#a/);
  163. if (match) {
  164. let vocab = match[2];
  165. state.embedAboveSubsectionMeanings = true;
  166. vocab = vocab.split('/')[0];
  167. return decodeURIComponent(vocab);
  168. }
  169. return '';
  170. }
  171.  
  172. function parseVocabFromKanji() {
  173. const url = window.location.href;
  174. const match = url.match(/https:\/\/jpdb\.io\/kanji\/([^#]*)#a/);
  175. if (match) {
  176. return decodeURIComponent(match[1]);
  177. }
  178. return '';
  179. }
  180.  
  181. function highlightVocab(sentence, vocab) {
  182. if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
  183. return vocab.split('').reduce((acc, char) => {
  184. const regex = new RegExp(char, 'g');
  185. return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
  186. }, sentence);
  187. }
  188.  
  189. function createIconLink(iconClass, onClick, marginLeft = '0') {
  190. const link = document.createElement('a');
  191. link.href = '#';
  192. link.style.border = '0';
  193. link.style.display = 'inline-flex';
  194. link.style.verticalAlign = 'middle';
  195. link.style.marginLeft = marginLeft;
  196.  
  197. const icon = document.createElement('i');
  198. icon.className = iconClass;
  199. icon.style.fontSize = '1.4rem';
  200. icon.style.opacity = '1.0';
  201. icon.style.verticalAlign = 'baseline';
  202. icon.style.color = '#3d81ff';
  203.  
  204. link.appendChild(icon);
  205. link.addEventListener('click', onClick);
  206. return link;
  207. }
  208.  
  209. function createStarLink() {
  210. const link = document.createElement('a');
  211. link.href = '#';
  212. link.style.border = '0';
  213. link.style.display = 'inline-flex';
  214. link.style.verticalAlign = 'middle';
  215.  
  216. const starIcon = document.createElement('span');
  217. starIcon.textContent = state.currentExampleIndex === parseInt(getStoredData(state.vocab)) ? '★' : '☆';
  218. starIcon.style.fontSize = '1.4rem';
  219. starIcon.style.marginLeft = '0.5rem';
  220. starIcon.style.color = '3D8DFF';
  221. starIcon.style.verticalAlign = 'middle';
  222. starIcon.style.position = 'relative';
  223. starIcon.style.top = '-2px';
  224.  
  225. link.appendChild(starIcon);
  226. link.addEventListener('click', (event) => {
  227. event.preventDefault();
  228. const favoriteIndex = parseInt(getStoredData(state.vocab));
  229. storeData(state.vocab, favoriteIndex === state.currentExampleIndex ? null : state.currentExampleIndex);
  230. embedImageAndPlayAudio();
  231. });
  232. return link;
  233. }
  234.  
  235. function createExportImportButtons() {
  236. const exportButton = document.createElement('button');
  237. exportButton.textContent = 'Export Favorites';
  238. exportButton.style.marginRight = '10px';
  239. exportButton.addEventListener('click', exportFavorites);
  240.  
  241. const importButton = document.createElement('button');
  242. importButton.textContent = 'Import Favorites';
  243. importButton.addEventListener('click', () => {
  244. const fileInput = document.createElement('input');
  245. fileInput.type = 'file';
  246. fileInput.accept = 'application/json';
  247. fileInput.addEventListener('change', importFavorites);
  248. fileInput.click();
  249. });
  250.  
  251. const buttonContainer = document.createElement('div');
  252. buttonContainer.style.textAlign = 'center';
  253. buttonContainer.style.marginTop = '10px';
  254. buttonContainer.append(exportButton, importButton);
  255.  
  256. document.body.appendChild(buttonContainer);
  257. }
  258.  
  259. function playAudio(soundUrl) {
  260. if (soundUrl) {
  261. // Fetch the audio file as a blob and create a blob URL
  262. GM_xmlhttpRequest({
  263. method: 'GET',
  264. url: soundUrl,
  265. responseType: 'blob',
  266. onload: function(response) {
  267. const blobUrl = URL.createObjectURL(response.response);
  268. const audioElement = GM_addElement('audio', { src: blobUrl, autoplay: true });
  269. audioElement.volume = CONFIG.SOUND_VOLUME; // Adjust volume as needed
  270. },
  271. onerror: function(error) {
  272. console.error('Error fetching audio:', error);
  273. }
  274. });
  275. }
  276. }
  277.  
  278. function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
  279. const example = state.examples[state.currentExampleIndex] || {};
  280. const imageUrl = example.image_url || null;
  281. const soundUrl = example.sound_url || null;
  282. const sentence = example.sentence || null;
  283.  
  284. const resultVocabularySection = document.querySelector('.result.vocabulary');
  285. const hboxWrapSection = document.querySelector('.hbox.wrap');
  286. const subsectionMeanings = document.querySelector('.subsection-meanings');
  287. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  288.  
  289. // Remove any existing embed before creating a new one
  290. const existingEmbed = document.getElementById('immersion-kit-embed');
  291. if (existingEmbed) {
  292. existingEmbed.remove();
  293. }
  294.  
  295. if (resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3) {
  296. const wrapperDiv = document.createElement('div');
  297. wrapperDiv.id = 'image-wrapper';
  298. wrapperDiv.style.textAlign = 'center';
  299. wrapperDiv.style.padding = '5px 0';
  300.  
  301. const textDiv = document.createElement('div');
  302. textDiv.style.marginBottom = '5px';
  303. textDiv.style.lineHeight = '1.4rem';
  304.  
  305. const contentText = document.createElement('span');
  306. contentText.textContent = 'Immersion Kit';
  307. contentText.style.color = 'var(--subsection-label-color)';
  308. contentText.style.fontSize = '85%';
  309. contentText.style.marginRight = '0.5rem';
  310. contentText.style.verticalAlign = 'middle';
  311.  
  312. const speakerLink = createIconLink('ti ti-volume', (event) => {
  313. event.preventDefault();
  314. playAudio(soundUrl);
  315. }, '0.5rem');
  316.  
  317. const starLink = createStarLink();
  318.  
  319. textDiv.append(contentText, speakerLink, starLink);
  320. wrapperDiv.appendChild(textDiv);
  321.  
  322. if (imageUrl) {
  323. const imageElement = GM_addElement(wrapperDiv, 'img', {
  324. src: imageUrl,
  325. alt: 'Embedded Image',
  326. style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
  327. });
  328.  
  329. if (imageElement) {
  330. imageElement.addEventListener('click', () => {
  331. speakerLink.click();
  332. });
  333. }
  334.  
  335. if (sentence) {
  336. const sentenceText = document.createElement('div');
  337. sentenceText.innerHTML = highlightVocab(sentence, vocab);
  338. sentenceText.style.marginTop = '10px';
  339. sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
  340. sentenceText.style.color = 'lightgray';
  341. wrapperDiv.appendChild(sentenceText);
  342.  
  343. if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && example.translation) {
  344. const translationText = document.createElement('div');
  345. translationText.innerHTML = replaceSpecialCharacters(example.translation);
  346. translationText.style.marginTop = '5px';
  347. translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
  348. translationText.style.color = 'var(--subsection-label-color)';
  349. wrapperDiv.appendChild(translationText);
  350. }
  351. } else {
  352. const noneText = document.createElement('div');
  353. noneText.textContent = 'None';
  354. noneText.style.marginTop = '10px';
  355. noneText.style.fontSize = '85%';
  356. noneText.style.color = 'var(--subsection-label-color)';
  357. wrapperDiv.appendChild(noneText);
  358. }
  359. }
  360.  
  361. // Create a fixed-width container for arrows and image
  362. const navigationDiv = document.createElement('div');
  363. navigationDiv.id = 'immersion-kit-embed';
  364. navigationDiv.style.display = 'flex';
  365. navigationDiv.style.justifyContent = 'center';
  366. navigationDiv.style.alignItems = 'center';
  367. navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
  368. navigationDiv.style.margin = '0 auto';
  369.  
  370. const leftArrow = document.createElement('button');
  371. leftArrow.textContent = '<';
  372. leftArrow.style.marginRight = '10px';
  373. leftArrow.disabled = state.currentExampleIndex === 0;
  374. leftArrow.addEventListener('click', () => {
  375. if (state.currentExampleIndex > 0) {
  376. state.currentExampleIndex--;
  377. embedImageAndPlayAudio();
  378. preloadImages();
  379. }
  380. });
  381.  
  382. const rightArrow = document.createElement('button');
  383. rightArrow.textContent = '>';
  384. rightArrow.style.marginLeft = '10px';
  385. rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
  386. rightArrow.addEventListener('click', () => {
  387. if (state.currentExampleIndex < state.examples.length - 1) {
  388. state.currentExampleIndex++;
  389. embedImageAndPlayAudio();
  390. preloadImages();
  391. }
  392. });
  393.  
  394. navigationDiv.append(leftArrow, wrapperDiv, rightArrow);
  395.  
  396. if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
  397. subsectionMeanings.parentNode.insertBefore(navigationDiv, subsectionMeanings);
  398. } else if (resultVocabularySection) {
  399. resultVocabularySection.parentNode.insertBefore(navigationDiv, resultVocabularySection);
  400. } else if (hboxWrapSection) {
  401. hboxWrapSection.parentNode.insertBefore(navigationDiv, hboxWrapSection);
  402. } else if (subsectionLabels.length >= 4) {
  403. subsectionLabels[3].parentNode.insertBefore(navigationDiv, subsectionLabels[3]);
  404. }
  405. }
  406.  
  407. if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
  408. playAudio(soundUrl);
  409. }
  410. }
  411.  
  412. function embedImageAndPlayAudio() {
  413. const existingNavigationDiv = document.getElementById('immersion-kit-embed');
  414. if (existingNavigationDiv) existingNavigationDiv.remove();
  415.  
  416. const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
  417.  
  418. if (state.vocab && !state.apiDataFetched) {
  419. getImmersionKitData(state.vocab, () => {
  420. renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
  421. preloadImages();
  422. });
  423. } else {
  424. renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
  425. preloadImages();
  426. }
  427. }
  428.  
  429. function replaceSpecialCharacters(text) {
  430. return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
  431. }
  432.  
  433. function preloadImages() {
  434. const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
  435.  
  436. for (let i = Math.max(0, state.currentExampleIndex - CONFIG.NUM_PRELOADS); i <= Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUM_PRELOADS); i++) {
  437. if (!state.preloadedIndices.has(i)) {
  438. const example = state.examples[i];
  439. if (example.image_url) {
  440. GM_addElement(preloadDiv, 'img', { src: example.image_url });
  441. state.preloadedIndices.add(i);
  442. }
  443. }
  444. }
  445. }
  446.  
  447. function onUrlChange() {
  448. state.embedAboveSubsectionMeanings = false;
  449. if (window.location.href.includes('/vocabulary/')) {
  450. state.vocab = parseVocabFromVocabulary();
  451. } else if (window.location.href.includes('c=')) {
  452. state.vocab = parseVocabFromAnswer();
  453. } else if (window.location.href.includes('/kanji/')) {
  454. state.vocab = parseVocabFromKanji();
  455. } else {
  456. state.vocab = parseVocabFromReview();
  457. }
  458.  
  459. const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
  460. const shouldAutoPlaySound = !reviewUrlPattern.test(window.location.href);
  461.  
  462. if (state.vocab) {
  463. embedImageAndPlayAudio();
  464. }
  465. }
  466.  
  467. const observer = new MutationObserver(() => {
  468. if (window.location.href !== observer.lastUrl) {
  469. observer.lastUrl = window.location.href;
  470. onUrlChange();
  471. }
  472. });
  473.  
  474. observer.lastUrl = window.location.href;
  475. observer.observe(document, { subtree: true, childList: true });
  476.  
  477. onUrlChange();
  478. createExportImportButtons();
  479. })();