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.0
  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.5,
  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. const audioElement = GM_addElement('audio', { src: soundUrl, autoplay: true });
  262. audioElement.volume = CONFIG.SOUND_VOLUME;
  263. }
  264. }
  265.  
  266. function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
  267. const example = state.examples[state.currentExampleIndex] || {};
  268. const imageUrl = example.image_url || null;
  269. const soundUrl = example.sound_url || null;
  270. const sentence = example.sentence || null;
  271.  
  272. const resultVocabularySection = document.querySelector('.result.vocabulary');
  273. const hboxWrapSection = document.querySelector('.hbox.wrap');
  274. const subsectionMeanings = document.querySelector('.subsection-meanings');
  275. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  276.  
  277. // Remove any existing embed before creating a new one
  278. const existingEmbed = document.getElementById('immersion-kit-embed');
  279. if (existingEmbed) {
  280. existingEmbed.remove();
  281. }
  282.  
  283. if (resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3) {
  284. const wrapperDiv = document.createElement('div');
  285. wrapperDiv.id = 'image-wrapper';
  286. wrapperDiv.style.textAlign = 'center';
  287. wrapperDiv.style.padding = '5px 0';
  288.  
  289. const textDiv = document.createElement('div');
  290. textDiv.style.marginBottom = '5px';
  291. textDiv.style.lineHeight = '1.4rem';
  292.  
  293. const contentText = document.createElement('span');
  294. contentText.textContent = 'Immersion Kit';
  295. contentText.style.color = 'var(--subsection-label-color)';
  296. contentText.style.fontSize = '85%';
  297. contentText.style.marginRight = '0.5rem';
  298. contentText.style.verticalAlign = 'middle';
  299.  
  300. const speakerLink = createIconLink('ti ti-volume', (event) => {
  301. event.preventDefault();
  302. playAudio(soundUrl);
  303. }, '0.5rem');
  304.  
  305. const starLink = createStarLink();
  306.  
  307. textDiv.append(contentText, speakerLink, starLink);
  308. wrapperDiv.appendChild(textDiv);
  309.  
  310. if (imageUrl) {
  311. const imageElement = GM_addElement(wrapperDiv, 'img', {
  312. src: imageUrl,
  313. alt: 'Embedded Image',
  314. style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
  315. });
  316.  
  317. if (imageElement) {
  318. imageElement.addEventListener('click', () => {
  319. speakerLink.click();
  320. });
  321. }
  322.  
  323. if (sentence) {
  324. const sentenceText = document.createElement('div');
  325. sentenceText.innerHTML = highlightVocab(sentence, vocab);
  326. sentenceText.style.marginTop = '10px';
  327. sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
  328. sentenceText.style.color = 'lightgray';
  329. wrapperDiv.appendChild(sentenceText);
  330.  
  331. if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && example.translation) {
  332. const translationText = document.createElement('div');
  333. translationText.innerHTML = replaceSpecialCharacters(example.translation);
  334. translationText.style.marginTop = '5px';
  335. translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
  336. translationText.style.color = 'var(--subsection-label-color)';
  337. wrapperDiv.appendChild(translationText);
  338. }
  339. } else {
  340. const noneText = document.createElement('div');
  341. noneText.textContent = 'None';
  342. noneText.style.marginTop = '10px';
  343. noneText.style.fontSize = '85%';
  344. noneText.style.color = 'var(--subsection-label-color)';
  345. wrapperDiv.appendChild(noneText);
  346. }
  347. }
  348.  
  349. // Create a fixed-width container for arrows and image
  350. const navigationDiv = document.createElement('div');
  351. navigationDiv.id = 'immersion-kit-embed';
  352. navigationDiv.style.display = 'flex';
  353. navigationDiv.style.justifyContent = 'center';
  354. navigationDiv.style.alignItems = 'center';
  355. navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
  356. navigationDiv.style.margin = '0 auto';
  357.  
  358. const leftArrow = document.createElement('button');
  359. leftArrow.textContent = '<';
  360. leftArrow.style.marginRight = '10px';
  361. leftArrow.disabled = state.currentExampleIndex === 0;
  362. leftArrow.addEventListener('click', () => {
  363. if (state.currentExampleIndex > 0) {
  364. state.currentExampleIndex--;
  365. embedImageAndPlayAudio();
  366. preloadImages();
  367. }
  368. });
  369.  
  370. const rightArrow = document.createElement('button');
  371. rightArrow.textContent = '>';
  372. rightArrow.style.marginLeft = '10px';
  373. rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
  374. rightArrow.addEventListener('click', () => {
  375. if (state.currentExampleIndex < state.examples.length - 1) {
  376. state.currentExampleIndex++;
  377. embedImageAndPlayAudio();
  378. preloadImages();
  379. }
  380. });
  381.  
  382. navigationDiv.append(leftArrow, wrapperDiv, rightArrow);
  383.  
  384. if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
  385. subsectionMeanings.parentNode.insertBefore(navigationDiv, subsectionMeanings);
  386. } else if (resultVocabularySection) {
  387. resultVocabularySection.parentNode.insertBefore(navigationDiv, resultVocabularySection);
  388. } else if (hboxWrapSection) {
  389. hboxWrapSection.parentNode.insertBefore(navigationDiv, hboxWrapSection);
  390. } else if (subsectionLabels.length >= 4) {
  391. subsectionLabels[3].parentNode.insertBefore(navigationDiv, subsectionLabels[3]);
  392. }
  393. }
  394.  
  395. if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
  396. playAudio(soundUrl);
  397. }
  398. }
  399.  
  400. function embedImageAndPlayAudio() {
  401. const existingNavigationDiv = document.getElementById('immersion-kit-embed');
  402. if (existingNavigationDiv) existingNavigationDiv.remove();
  403.  
  404. const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
  405.  
  406. if (state.vocab && !state.apiDataFetched) {
  407. getImmersionKitData(state.vocab, () => {
  408. renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
  409. preloadImages();
  410. });
  411. } else {
  412. renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
  413. preloadImages();
  414. }
  415. }
  416.  
  417. function replaceSpecialCharacters(text) {
  418. return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
  419. }
  420.  
  421. function preloadImages() {
  422. const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
  423.  
  424. for (let i = Math.max(0, state.currentExampleIndex - CONFIG.NUM_PRELOADS); i <= Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUM_PRELOADS); i++) {
  425. if (!state.preloadedIndices.has(i)) {
  426. const example = state.examples[i];
  427. if (example.image_url) {
  428. GM_addElement(preloadDiv, 'img', { src: example.image_url });
  429. state.preloadedIndices.add(i);
  430. }
  431. }
  432. }
  433. }
  434.  
  435. function onUrlChange() {
  436. state.embedAboveSubsectionMeanings = false;
  437. if (window.location.href.includes('/vocabulary/')) {
  438. state.vocab = parseVocabFromVocabulary();
  439. } else if (window.location.href.includes('c=')) {
  440. state.vocab = parseVocabFromAnswer();
  441. } else if (window.location.href.includes('/kanji/')) {
  442. state.vocab = parseVocabFromKanji();
  443. } else {
  444. state.vocab = parseVocabFromReview();
  445. }
  446.  
  447. const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
  448. const shouldAutoPlaySound = !reviewUrlPattern.test(window.location.href);
  449.  
  450. if (state.vocab) {
  451. embedImageAndPlayAudio();
  452. }
  453. }
  454.  
  455. const observer = new MutationObserver(() => {
  456. if (window.location.href !== observer.lastUrl) {
  457. observer.lastUrl = window.location.href;
  458. onUrlChange();
  459. }
  460. });
  461.  
  462. observer.lastUrl = window.location.href;
  463. observer.observe(document, { subtree: true, childList: true });
  464.  
  465. onUrlChange();
  466. createExportImportButtons();
  467. })();