🏠 Home 

Zoom Smart Chapters Downloader

Download Zoom Smart Chapters in JSON and Markdown formats: https://gist.github.com/aculich/491ace4a581c8707fa6cd8304d89ea79


Install this script?
  1. // ==UserScript==
  2. // @name Zoom Smart Chapters Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.2
  5. // @description Download Zoom Smart Chapters in JSON and Markdown formats: https://gist.github.com/aculich/491ace4a581c8707fa6cd8304d89ea79
  6. // @author Your name
  7. // @match https://*.zoom.us/rec/play/*
  8. // @match https://*.zoom.us/rec/share/*
  9. // @grant none
  10. // ==/UserScript==
  11. (function() {
  12. 'use strict';
  13. // Utility function to format time in HH:MM:SS
  14. function formatTime(seconds) {
  15. return new Date(seconds * 1000).toISOString().substr(11, 8);
  16. }
  17. // Parse time string (e.g. "From 00:00" or "From 01:23:45") to seconds
  18. function parseTimeString(timeStr) {
  19. const match = timeStr.match(/From (\d{2}:)?(\d{2}):(\d{2})/);
  20. if (!match) return 0;
  21. const hours = match[1] ? parseInt(match[1]) : 0;
  22. const minutes = parseInt(match[2]);
  23. const seconds = parseInt(match[3]);
  24. return hours * 3600 + minutes * 60 + seconds;
  25. }
  26. // Get the Unix timestamp in milliseconds for a given offset in seconds
  27. function getUnixTimestamp(offsetSeconds) {
  28. // Get the recording start time from the URL if available
  29. const urlParams = new URLSearchParams(window.location.search);
  30. const startTimeParam = urlParams.get('startTime');
  31. if (startTimeParam) {
  32. // If we have a startTime parameter, use it as reference
  33. const baseTime = parseInt(startTimeParam);
  34. // Remove the offset that was added to the URL
  35. const currentOffset = urlParams.get('t') || 0;
  36. return baseTime - (currentOffset * 1000) + (offsetSeconds * 1000);
  37. } else {
  38. // Fallback: Use current time minus total duration as base
  39. const now = Date.now();
  40. const videoDuration = document.querySelector('video')?.duration || 0;
  41. const videoCurrentTime = document.querySelector('video')?.currentTime || 0;
  42. const startTime = now - ((videoDuration - videoCurrentTime) * 1000);
  43. return startTime + (offsetSeconds * 1000);
  44. }
  45. }
  46. // Monitor DOM changes for dynamic content
  47. function setupDynamicContentMonitor() {
  48. const observer = new MutationObserver((mutations) => {
  49. mutations.forEach(mutation => {
  50. if (mutation.type === 'childList' && mutation.addedNodes.length) {
  51. mutation.addedNodes.forEach(node => {
  52. if (node.nodeType === Node.ELEMENT_NODE) {
  53. // Check if this is a summary or description element
  54. if (node.classList?.contains('smart-chapter-summary') ||
  55. node.classList?.contains('content') ||
  56. node.querySelector?.('.smart-chapter-summary, .content')) {
  57. console.group('Dynamic Content Added:');
  58. console.log('Element:', node);
  59. console.log('Class:', node.className);
  60. console.log('Content:', node.textContent?.trim().substring(0, 100) + '...');
  61. console.log('Full HTML:', node.outerHTML);
  62. console.groupEnd();
  63. }
  64. }
  65. });
  66. }
  67. });
  68. });
  69. observer.observe(document.body, {
  70. childList: true,
  71. subtree: true
  72. });
  73. return observer;
  74. }
  75. // Monitor network requests for API calls
  76. function setupNetworkMonitor() {
  77. const originalFetch = window.fetch;
  78. window.fetch = async function(...args) {
  79. const url = args[0];
  80. if (typeof url === 'string' && url.includes('zoom.us')) {
  81. console.group('Zoom API Request:');
  82. console.log('URL:', url);
  83. console.log('Args:', args[1]);
  84. console.groupEnd();
  85. }
  86. return originalFetch.apply(this, args);
  87. };
  88. const originalXHR = window.XMLHttpRequest.prototype.open;
  89. window.XMLHttpRequest.prototype.open = function(...args) {
  90. const url = args[1];
  91. if (typeof url === 'string' && url.includes('zoom.us')) {
  92. console.group('Zoom XHR Request:');
  93. console.log('URL:', url);
  94. console.log('Method:', args[0]);
  95. console.groupEnd();
  96. }
  97. return originalXHR.apply(this, args);
  98. };
  99. }
  100. // Helper function to wait for an element
  101. function waitForElement(selector, timeout = 2000) {
  102. return new Promise((resolve) => {
  103. if (document.querySelector(selector)) {
  104. return resolve(document.querySelector(selector));
  105. }
  106. const observer = new MutationObserver(() => {
  107. if (document.querySelector(selector)) {
  108. observer.disconnect();
  109. resolve(document.querySelector(selector));
  110. }
  111. });
  112. observer.observe(document.body, {
  113. childList: true,
  114. subtree: true
  115. });
  116. setTimeout(() => {
  117. observer.disconnect();
  118. resolve(null);
  119. }, timeout);
  120. });
  121. }
  122. // Extract chapters from the DOM with enhanced dynamic content handling
  123. async function extractChapters() {
  124. const chapters = [];
  125. const chapterElements = document.querySelectorAll('.smart-chapter-card');
  126. // Get the base URL from og:url meta tag
  127. const ogUrlMeta = document.querySelector('meta[property="og:url"]');
  128. const baseUrl = ogUrlMeta ? ogUrlMeta.content : window.location.href.split('?')[0];
  129. // Get the original startTime from URL - this must remain constant across all chapter links
  130. // due to Zoom's URL handling limitations
  131. const urlParams = new URLSearchParams(window.location.search);
  132. const originalStartTime = urlParams.get('startTime') || '';
  133. // Note: Due to Zoom's URL handling limitations, we must:
  134. // 1. Keep the original startTime parameter the same across all chapter links
  135. // 2. Add our calculated chapter start times in a separate parameter (chapterStartTime)
  136. // This is because Zoom's player currently only respects the first chapter's startTime
  137. // and ignores subsequent chapter timings. We keep our calculated times in the URL
  138. // for potential future workarounds or third-party tools.
  139. console.group('Interactive Chapter Extraction:');
  140. for (let index = 0; index < chapterElements.length; index++) {
  141. const el = chapterElements[index];
  142. const timeEl = el.querySelector('.start-time');
  143. const titleEl = el.querySelector('.chapter-card-title');
  144. if (timeEl && titleEl) {
  145. console.group(`Processing Chapter ${index + 1}`);
  146. const timeStr = timeEl.textContent.trim();
  147. const title = titleEl.textContent.trim();
  148. console.log('Found title:', title);
  149. // Try to trigger content loading through various interactions
  150. console.group('Triggering Interactions:');
  151. // 1. Click the chapter card
  152. console.log('Clicking chapter card...');
  153. el.click();
  154. // Wait longer after clicking the card
  155. console.log('Waiting for UI update...');
  156. await new Promise(r => setTimeout(r, 1500));
  157. // 2. Try to find any clickable elements within the card
  158. const clickables = el.querySelectorAll('button, [role="button"], [tabindex="0"]');
  159. for (const clickable of clickables) {
  160. console.log('Clicking element:', clickable.className);
  161. clickable.click();
  162. // Wait between clicking different elements
  163. await new Promise(r => setTimeout(r, 800));
  164. }
  165. // 3. Look for Vue.js related elements
  166. const vueElements = el.querySelectorAll('[data-v-5eece099]');
  167. console.log(`Found ${vueElements.length} Vue elements`);
  168. vueElements.forEach(vueEl => {
  169. if (vueEl.__vue__) {
  170. console.log('Vue instance found:', vueEl.__vue__.$data);
  171. try {
  172. vueEl.__vue__.$emit('click');
  173. vueEl.__vue__.$emit('select');
  174. } catch (e) {
  175. console.log('Vue event emission failed:', e);
  176. }
  177. }
  178. });
  179. // 4. Wait for potential dynamic content
  180. console.log('Waiting for description content...');
  181. const summaryEl = await waitForElement('.smart-chapter-summary');
  182. if (summaryEl) {
  183. console.log('Found summary element after waiting');
  184. // Add extra wait after finding summary element
  185. await new Promise(r => setTimeout(r, 1000));
  186. }
  187. console.groupEnd();
  188. const offsetSeconds = parseTimeString(timeStr);
  189. const startTime = getUnixTimestamp(offsetSeconds);
  190. // Get description using multiple approaches
  191. let description = '';
  192. // Try different selectors and approaches
  193. const attempts = [
  194. // Direct content div under summary
  195. () => document.querySelector(`.smart-chapter-summary:nth-child(${index + 1}) .content > div`)?.textContent,
  196. // Active/selected summary
  197. () => document.querySelector('.smart-chapter-summary.active .content > div')?.textContent,
  198. // Summary with matching title
  199. () => Array.from(document.querySelectorAll('.smart-chapter-summary'))
  200. .find(sum => sum.querySelector('.title')?.textContent.includes(title))
  201. ?.querySelector('.content > div')?.textContent,
  202. // Any visible summary content
  203. () => document.querySelector('.smart-chapter-summary:not([style*="display: none"]) .content > div')?.textContent
  204. ];
  205. for (const attempt of attempts) {
  206. const r###lt = attempt();
  207. if (r###lt) {
  208. description = r###lt.trim();
  209. console.log('Found description using attempt:', description.substring(0, 50) + '...');
  210. break;
  211. }
  212. // Add small delay between attempts
  213. await new Promise(r => setTimeout(r, 300));
  214. }
  215. console.log('Final description length:', description.length);
  216. console.groupEnd();
  217. chapters.push({
  218. timestamp: timeStr,
  219. startTime: startTime,
  220. title: title,
  221. description: description,
  222. // Keep original startTime and add our calculated time as chapterStartTime
  223. url: `${baseUrl}?${originalStartTime ? `startTime=${originalStartTime}&` : ''}chapterStartTime=${startTime}`
  224. });
  225. // Much longer delay (10 seconds) between processing chapters
  226. const nextChapter = index + 2;
  227. const totalChapters = chapterElements.length;
  228. console.log(`Waiting 1 seconds before processing chapter ${nextChapter}/${totalChapters}...`);
  229. await new Promise(r => setTimeout(r, 1000));
  230. }
  231. }
  232. console.groupEnd();
  233. return chapters;
  234. }
  235. // Convert chapters to markdown format
  236. function chaptersToMarkdown(chapters) {
  237. return chapters.map(chapter => {
  238. // Use the original timestamp from the HTML instead of converting Unix time
  239. const time = chapter.timestamp.replace('From ', '');
  240. return `## [${chapter.title} (${time})](${chapter.url})\n\n${chapter.description}\n`;
  241. }).join('\n');
  242. }
  243. // Convert chapters to JSON format
  244. function chaptersToJSON(chapters) {
  245. return JSON.stringify(chapters, null, 2);
  246. }
  247. // Download content as file
  248. function downloadFile(content, filename) {
  249. const blob = new Blob([content], { type: 'text/plain' });
  250. const url = window.URL.createObjectURL(blob);
  251. const a = document.createElement('a');
  252. a.href = url;
  253. a.download = filename;
  254. document.body.appendChild(a);
  255. a.click();
  256. window.URL.revokeObjectURL(url);
  257. document.body.removeChild(a);
  258. }
  259. // Create banner with enhanced debug capabilities
  260. function createBanner() {
  261. const banner = document.createElement('div');
  262. banner.style.cssText = `
  263. position: fixed;
  264. top: 0;
  265. left: 0;
  266. right: 0;
  267. background: #2D8CFF;
  268. color: white;
  269. padding: 10px;
  270. z-index: 9999;
  271. display: flex;
  272. justify-content: center;
  273. align-items: center;
  274. box-shadow: 0 2px 4px rgba(0,0,0,0.2);
  275. `;
  276. const container = document.createElement('div');
  277. container.style.cssText = `
  278. display: flex;
  279. gap: 10px;
  280. align-items: center;
  281. `;
  282. const label = document.createElement('span');
  283. label.textContent = 'Smart Chapters:';
  284. label.style.fontWeight = 'bold';
  285. // Common button style
  286. const buttonStyle = `
  287. padding: 5px 15px;
  288. border-radius: 4px;
  289. border: none;
  290. background: white;
  291. color: #2D8CFF;
  292. cursor: pointer;
  293. font-weight: bold;
  294. `;
  295. // Common function to extract and process chapters
  296. async function getProcessedChapters() {
  297. console.group('Starting Chapter Extraction');
  298. const chapters = await extractChapters();
  299. console.log('Total chapters extracted:', chapters.length);
  300. return chapters;
  301. }
  302. const jsonButton = document.createElement('button');
  303. jsonButton.textContent = 'Download JSON';
  304. jsonButton.style.cssText = buttonStyle;
  305. jsonButton.onclick = async () => {
  306. const chapters = await getProcessedChapters();
  307. downloadFile(chaptersToJSON(chapters), `zoom-chapters-${Date.now()}.json`);
  308. console.groupEnd();
  309. };
  310. const mdButton = document.createElement('button');
  311. mdButton.textContent = 'Download Markdown';
  312. mdButton.style.cssText = buttonStyle;
  313. mdButton.onclick = async () => {
  314. const chapters = await getProcessedChapters();
  315. downloadFile(chaptersToMarkdown(chapters), `zoom-chapters-${Date.now()}.md`);
  316. console.groupEnd();
  317. };
  318. const debugButton = document.createElement('button');
  319. debugButton.textContent = '🔍 Debug Log';
  320. debugButton.style.cssText = buttonStyle;
  321. debugButton.onclick = async () => {
  322. const chapters = await getProcessedChapters();
  323. // Additional debug logging
  324. console.group('Smart Chapters Debug Info');
  325. // Check window for global variables
  326. console.group('Global Variables:');
  327. const globals = ['smartChapters', 'chapters', 'zoomChapters', 'recording'].filter(
  328. key => window[key] !== undefined
  329. );
  330. console.log('Found globals:', globals);
  331. globals.forEach(key => console.log(key + ':', window[key]));
  332. console.groupEnd();
  333. // Check for React/Vue devtools
  334. console.group('Framework Detection:');
  335. console.log('Vue detected:', !!window.__VUE_DEVTOOLS_GLOBAL_HOOK__);
  336. console.log('React detected:', !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__);
  337. console.groupEnd();
  338. chapters.forEach((chapter, index) => {
  339. console.group(`Chapter ${index + 1}: ${chapter.title}`);
  340. console.log('Timestamp:', chapter.timestamp);
  341. console.log('Unix Time:', chapter.startTime);
  342. console.log('Title:', chapter.title);
  343. console.log('Description:', chapter.description || '(no description)');
  344. console.log('URL:', chapter.url);
  345. console.groupEnd();
  346. });
  347. console.groupEnd();
  348. console.groupEnd();
  349. };
  350. container.appendChild(label);
  351. container.appendChild(jsonButton);
  352. container.appendChild(mdButton);
  353. container.appendChild(debugButton);
  354. banner.appendChild(container);
  355. // Adjust page content to account for banner height
  356. const contentAdjuster = document.createElement('div');
  357. contentAdjuster.style.height = '50px';
  358. document.body.insertBefore(contentAdjuster, document.body.firstChild);
  359. // Start monitors
  360. setupDynamicContentMonitor();
  361. setupNetworkMonitor();
  362. return banner;
  363. }
  364. // Main function to initialize the script
  365. function init() {
  366. // Wait for the Smart Chapters container to be available
  367. const checkForChapters = setInterval(() => {
  368. const chaptersContainer = document.querySelector('.smart-chapter-container');
  369. if (!chaptersContainer) return;
  370. // Only add the banner if it doesn't exist yet
  371. if (!document.getElementById('smart-chapters-banner')) {
  372. const banner = createBanner();
  373. banner.id = 'smart-chapters-banner';
  374. document.body.insertBefore(banner, document.body.firstChild);
  375. clearInterval(checkForChapters);
  376. }
  377. }, 1000);
  378. // Clear interval after 30 seconds to prevent infinite checking
  379. setTimeout(() => clearInterval(checkForChapters), 30000);
  380. }
  381. // Start the script
  382. init();
  383. })();