LingQ Subtitle Downloader

Download subtitles (transcript with timestamp) from LingQ reader pages

  1. // ==UserScript==
  2. // @name LingQ Subtitle Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.0
  5. // @description Download subtitles (transcript with timestamp) from LingQ reader pages
  6. // @author Yuxin with ChatGPT
  7. // @match https://www.lingq.com/*
  8. // @match https://*lingq.com/*
  9. // @run-at document-start
  10. // @grant none
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. console.log("LingQ Subtitle Downloader Active Request script started.");
  18.  
  19. // Extract API URL from the current page URL.
  20. // For example, from "https://www.lingq.com/en/learn/es/web/listen/1003504" we derive:
  21. // - content language: es
  22. // - lesson ID: 1003504
  23. // and build: "https://www.lingq.com/api/v3/es/lessons/1003504/simple/?"
  24. function getApiUrl() {
  25. const m = window.location.href.match(/^https:\/\/www\.lingq\.com\/([^\/]+)\/learn\/([^\/]+)\/web\/(listen|reader)\/(\d+)/);
  26. if (m) {
  27. const contentLang = m[2]; // second segment after "learn"
  28. const lessonId = m[4];
  29. const apiUrl = `https://www.lingq.com/api/v3/${contentLang}/lessons/${lessonId}/simple/?`;
  30. console.log("Constructed API URL:", apiUrl);
  31. return apiUrl;
  32. }
  33. console.warn("Could not parse lesson API URL from:", window.location.href);
  34. return null;
  35. }
  36.  
  37. // Convert seconds to SRT timestamp format (HH:MM:SS,mmm)
  38. function formatTime(seconds) {
  39. let hours = Math.floor(seconds / 3600);
  40. let minutes = Math.floor((seconds % 3600) / 60);
  41. let secs = Math.floor(seconds % 60);
  42. let ms = Math.floor((seconds % 1) * 1000);
  43. return (hours < 10 ? "0" + hours : hours) + ":" +
  44. (minutes < 10 ? "0" + minutes : minutes) + ":" +
  45. (secs < 10 ? "0" + secs : secs) + "," +
  46. (ms < 100 ? (ms < 10 ? "00" + ms : "0" + ms) : ms);
  47. }
  48.  
  49. // Build SRT content from the fetched lesson data
  50. function buildSRT(data) {
  51. let srtContent = "";
  52. let index = 1;
  53. data.tokenizedText.forEach(segment => {
  54. if (segment.length > 0) {
  55. let item = segment[0];
  56. let start = item.timestamp[0];
  57. let end = item.timestamp[1];
  58. srtContent += index + "\n";
  59. srtContent += formatTime(start) + " --> " + formatTime(end) + "\n";
  60. srtContent += item.text + "\n\n";
  61. index++;
  62. }
  63. });
  64. return srtContent;
  65. }
  66.  
  67. // Trigger download of the SRT file
  68. function downloadSRT(srtContent, fileName) {
  69. const blob = new Blob([srtContent], { type: 'text/plain' });
  70. const url = URL.createObjectURL(blob);
  71. const a = document.createElement('a');
  72. a.href = url;
  73. a.download = fileName;
  74. document.body.appendChild(a);
  75. a.click();
  76. document.body.removeChild(a);
  77. URL.revokeObjectURL(url);
  78. }
  79.  
  80. // Create and insert the Download Button with improved styling.
  81. function addDownloadButton() {
  82. if (document.getElementById('lingqDownloadButton')) return; // Prevent duplicates
  83.  
  84. const button = document.createElement('button');
  85. button.id = 'lingqDownloadButton';
  86. button.innerText = "Download Subtitle";
  87. // Style: bottom right with background, outline, and custom text color.
  88. button.style.position = 'fixed';
  89. button.style.bottom = '10px';
  90. button.style.right = '10px';
  91. button.style.zIndex = 9999;
  92. button.style.padding = '10px 15px';
  93. button.style.fontSize = '14px';
  94. button.style.background = '#007bff';
  95. button.style.color = '#ffffff';
  96. button.style.border = '2px solid #0056b3';
  97. button.style.borderRadius = '4px';
  98. button.style.cursor = 'pointer';
  99. button.style.boxShadow = '2px 2px 6px rgba(0,0,0,0.2)';
  100.  
  101. button.addEventListener('click', function() {
  102. const apiUrl = getApiUrl();
  103. if (!apiUrl) {
  104. alert("Unable to determine lesson API URL.");
  105. return;
  106. }
  107. console.log("Sending request to API URL:", apiUrl);
  108. fetch(apiUrl, {
  109. headers: {
  110. 'accept': 'application/json',
  111. 'x-lingq-app': 'Web/6.0.10'
  112. }
  113. })
  114. .then(response => response.json())
  115. .then(data => {
  116. console.log("Received lesson data:", data);
  117. const srtContent = buildSRT(data);
  118. console.log("Generated SRT content:\n", srtContent);
  119. const fileName = data.title.replace(/[\\\/:*?"<>|]/g, '_') + ".srt";
  120. downloadSRT(srtContent, fileName);
  121. })
  122. .catch(error => {
  123. console.error("Error fetching lesson data:", error);
  124. alert("Error fetching lesson data.");
  125. });
  126. });
  127.  
  128. document.body.appendChild(button);
  129. }
  130.  
  131. // Remove the download button (used when leaving allowed pages)
  132. function removeDownloadButton() {
  133. const btn = document.getElementById('lingqDownloadButton');
  134. if (btn) {
  135. btn.remove();
  136. }
  137. }
  138.  
  139. // Update button visibility based on allowed pages (listen/reader pages)
  140. function updateButtonVisibility() {
  141. const allowedPattern = /^https:\/\/www\.lingq\.com\/[^\/]+\/learn\/[^\/]+\/web\/(listen|reader)\//;
  142. if (allowedPattern.test(window.location.href)) {
  143. addDownloadButton();
  144. } else {
  145. removeDownloadButton();
  146. }
  147. }
  148.  
  149. // Monitor URL changes using history API overrides and popstate events.
  150. (function() {
  151. const _wr = function(type) {
  152. let orig = history[type];
  153. return function() {
  154. let rv = orig.apply(this, arguments);
  155. window.dispatchEvent(new Event('locationchange'));
  156. return rv;
  157. };
  158. };
  159. history.pushState = _wr('pushState');
  160. history.replaceState = _wr('replaceState');
  161. window.addEventListener('popstate', () => {
  162. window.dispatchEvent(new Event('locationchange'));
  163. });
  164. })();
  165.  
  166. window.addEventListener('locationchange', () => {
  167. console.log("Location changed to:", window.location.href);
  168. updateButtonVisibility();
  169. });
  170.  
  171. window.addEventListener('DOMContentLoaded', () => {
  172. updateButtonVisibility();
  173. });
  174. })();