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