Zoom Smart Chapters Downloader

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

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