Created
March 8, 2025 13:35
-
-
Save aculich/491ace4a581c8707fa6cd8304d89ea79 to your computer and use it in GitHub Desktop.
Zoom Smart Chapters Downloader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Zoom Smart Chapters Downloader | |
// @namespace http://tampermonkey.net/ | |
// @version 1.2 | |
// @description Download Zoom Smart Chapters in JSON and Markdown formats | |
// @author Your name | |
// @match https://*.zoom.us/rec/play/* | |
// @match https://*.zoom.us/rec/share/* | |
// @grant none | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// Utility function to format time in HH:MM:SS | |
function formatTime(seconds) { | |
return new Date(seconds * 1000).toISOString().substr(11, 8); | |
} | |
// Parse time string (e.g. "From 00:00" or "From 01:23:45") to seconds | |
function parseTimeString(timeStr) { | |
const match = timeStr.match(/From (\d{2}:)?(\d{2}):(\d{2})/); | |
if (!match) return 0; | |
const hours = match[1] ? parseInt(match[1]) : 0; | |
const minutes = parseInt(match[2]); | |
const seconds = parseInt(match[3]); | |
return hours * 3600 + minutes * 60 + seconds; | |
} | |
// Get the Unix timestamp in milliseconds for a given offset in seconds | |
function getUnixTimestamp(offsetSeconds) { | |
// Get the recording start time from the URL if available | |
const urlParams = new URLSearchParams(window.location.search); | |
const startTimeParam = urlParams.get('startTime'); | |
if (startTimeParam) { | |
// If we have a startTime parameter, use it as reference | |
const baseTime = parseInt(startTimeParam); | |
// Remove the offset that was added to the URL | |
const currentOffset = urlParams.get('t') || 0; | |
return baseTime - (currentOffset * 1000) + (offsetSeconds * 1000); | |
} else { | |
// Fallback: Use current time minus total duration as base | |
const now = Date.now(); | |
const videoDuration = document.querySelector('video')?.duration || 0; | |
const videoCurrentTime = document.querySelector('video')?.currentTime || 0; | |
const startTime = now - ((videoDuration - videoCurrentTime) * 1000); | |
return startTime + (offsetSeconds * 1000); | |
} | |
} | |
// Monitor DOM changes for dynamic content | |
function setupDynamicContentMonitor() { | |
const observer = new MutationObserver((mutations) => { | |
mutations.forEach(mutation => { | |
if (mutation.type === 'childList' && mutation.addedNodes.length) { | |
mutation.addedNodes.forEach(node => { | |
if (node.nodeType === Node.ELEMENT_NODE) { | |
// Check if this is a summary or description element | |
if (node.classList?.contains('smart-chapter-summary') || | |
node.classList?.contains('content') || | |
node.querySelector?.('.smart-chapter-summary, .content')) { | |
console.group('Dynamic Content Added:'); | |
console.log('Element:', node); | |
console.log('Class:', node.className); | |
console.log('Content:', node.textContent?.trim().substring(0, 100) + '...'); | |
console.log('Full HTML:', node.outerHTML); | |
console.groupEnd(); | |
} | |
} | |
}); | |
} | |
}); | |
}); | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true | |
}); | |
return observer; | |
} | |
// Monitor network requests for API calls | |
function setupNetworkMonitor() { | |
const originalFetch = window.fetch; | |
window.fetch = async function(...args) { | |
const url = args[0]; | |
if (typeof url === 'string' && url.includes('zoom.us')) { | |
console.group('Zoom API Request:'); | |
console.log('URL:', url); | |
console.log('Args:', args[1]); | |
console.groupEnd(); | |
} | |
return originalFetch.apply(this, args); | |
}; | |
const originalXHR = window.XMLHttpRequest.prototype.open; | |
window.XMLHttpRequest.prototype.open = function(...args) { | |
const url = args[1]; | |
if (typeof url === 'string' && url.includes('zoom.us')) { | |
console.group('Zoom XHR Request:'); | |
console.log('URL:', url); | |
console.log('Method:', args[0]); | |
console.groupEnd(); | |
} | |
return originalXHR.apply(this, args); | |
}; | |
} | |
// Helper function to wait for an element | |
function waitForElement(selector, timeout = 2000) { | |
return new Promise((resolve) => { | |
if (document.querySelector(selector)) { | |
return resolve(document.querySelector(selector)); | |
} | |
const observer = new MutationObserver(() => { | |
if (document.querySelector(selector)) { | |
observer.disconnect(); | |
resolve(document.querySelector(selector)); | |
} | |
}); | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true | |
}); | |
setTimeout(() => { | |
observer.disconnect(); | |
resolve(null); | |
}, timeout); | |
}); | |
} | |
// Extract chapters from the DOM with enhanced dynamic content handling | |
async function extractChapters() { | |
const chapters = []; | |
const chapterElements = document.querySelectorAll('.smart-chapter-card'); | |
// Get the base URL from og:url meta tag | |
const ogUrlMeta = document.querySelector('meta[property="og:url"]'); | |
const baseUrl = ogUrlMeta ? ogUrlMeta.content : window.location.href.split('?')[0]; | |
// Get the original startTime from URL - this must remain constant across all chapter links | |
// due to Zoom's URL handling limitations | |
const urlParams = new URLSearchParams(window.location.search); | |
const originalStartTime = urlParams.get('startTime') || ''; | |
// Note: Due to Zoom's URL handling limitations, we must: | |
// 1. Keep the original startTime parameter the same across all chapter links | |
// 2. Add our calculated chapter start times in a separate parameter (chapterStartTime) | |
// This is because Zoom's player currently only respects the first chapter's startTime | |
// and ignores subsequent chapter timings. We keep our calculated times in the URL | |
// for potential future workarounds or third-party tools. | |
console.group('Interactive Chapter Extraction:'); | |
for (let index = 0; index < chapterElements.length; index++) { | |
const el = chapterElements[index]; | |
const timeEl = el.querySelector('.start-time'); | |
const titleEl = el.querySelector('.chapter-card-title'); | |
if (timeEl && titleEl) { | |
console.group(`Processing Chapter ${index + 1}`); | |
const timeStr = timeEl.textContent.trim(); | |
const title = titleEl.textContent.trim(); | |
console.log('Found title:', title); | |
// Try to trigger content loading through various interactions | |
console.group('Triggering Interactions:'); | |
// 1. Click the chapter card | |
console.log('Clicking chapter card...'); | |
el.click(); | |
// Wait longer after clicking the card | |
console.log('Waiting for UI update...'); | |
await new Promise(r => setTimeout(r, 1500)); | |
// 2. Try to find any clickable elements within the card | |
const clickables = el.querySelectorAll('button, [role="button"], [tabindex="0"]'); | |
for (const clickable of clickables) { | |
console.log('Clicking element:', clickable.className); | |
clickable.click(); | |
// Wait between clicking different elements | |
await new Promise(r => setTimeout(r, 800)); | |
} | |
// 3. Look for Vue.js related elements | |
const vueElements = el.querySelectorAll('[data-v-5eece099]'); | |
console.log(`Found ${vueElements.length} Vue elements`); | |
vueElements.forEach(vueEl => { | |
if (vueEl.__vue__) { | |
console.log('Vue instance found:', vueEl.__vue__.$data); | |
try { | |
vueEl.__vue__.$emit('click'); | |
vueEl.__vue__.$emit('select'); | |
} catch (e) { | |
console.log('Vue event emission failed:', e); | |
} | |
} | |
}); | |
// 4. Wait for potential dynamic content | |
console.log('Waiting for description content...'); | |
const summaryEl = await waitForElement('.smart-chapter-summary'); | |
if (summaryEl) { | |
console.log('Found summary element after waiting'); | |
// Add extra wait after finding summary element | |
await new Promise(r => setTimeout(r, 1000)); | |
} | |
console.groupEnd(); | |
const offsetSeconds = parseTimeString(timeStr); | |
const startTime = getUnixTimestamp(offsetSeconds); | |
// Get description using multiple approaches | |
let description = ''; | |
// Try different selectors and approaches | |
const attempts = [ | |
// Direct content div under summary | |
() => document.querySelector(`.smart-chapter-summary:nth-child(${index + 1}) .content > div`)?.textContent, | |
// Active/selected summary | |
() => document.querySelector('.smart-chapter-summary.active .content > div')?.textContent, | |
// Summary with matching title | |
() => Array.from(document.querySelectorAll('.smart-chapter-summary')) | |
.find(sum => sum.querySelector('.title')?.textContent.includes(title)) | |
?.querySelector('.content > div')?.textContent, | |
// Any visible summary content | |
() => document.querySelector('.smart-chapter-summary:not([style*="display: none"]) .content > div')?.textContent | |
]; | |
for (const attempt of attempts) { | |
const result = attempt(); | |
if (result) { | |
description = result.trim(); | |
console.log('Found description using attempt:', description.substring(0, 50) + '...'); | |
break; | |
} | |
// Add small delay between attempts | |
await new Promise(r => setTimeout(r, 300)); | |
} | |
console.log('Final description length:', description.length); | |
console.groupEnd(); | |
chapters.push({ | |
timestamp: timeStr, | |
startTime: startTime, | |
title: title, | |
description: description, | |
// Keep original startTime and add our calculated time as chapterStartTime | |
url: `${baseUrl}?${originalStartTime ? `startTime=${originalStartTime}&` : ''}chapterStartTime=${startTime}` | |
}); | |
// Much longer delay (10 seconds) between processing chapters | |
const nextChapter = index + 2; | |
const totalChapters = chapterElements.length; | |
console.log(`Waiting 1 seconds before processing chapter ${nextChapter}/${totalChapters}...`); | |
await new Promise(r => setTimeout(r, 1000)); | |
} | |
} | |
console.groupEnd(); | |
return chapters; | |
} | |
// Convert chapters to markdown format | |
function chaptersToMarkdown(chapters) { | |
return chapters.map(chapter => { | |
// Use the original timestamp from the HTML instead of converting Unix time | |
const time = chapter.timestamp.replace('From ', ''); | |
return `## [${chapter.title} (${time})](${chapter.url})\n\n${chapter.description}\n`; | |
}).join('\n'); | |
} | |
// Convert chapters to JSON format | |
function chaptersToJSON(chapters) { | |
return JSON.stringify(chapters, null, 2); | |
} | |
// Download content as file | |
function downloadFile(content, filename) { | |
const blob = new Blob([content], { type: 'text/plain' }); | |
const url = window.URL.createObjectURL(blob); | |
const a = document.createElement('a'); | |
a.href = url; | |
a.download = filename; | |
document.body.appendChild(a); | |
a.click(); | |
window.URL.revokeObjectURL(url); | |
document.body.removeChild(a); | |
} | |
// Create banner with enhanced debug capabilities | |
function createBanner() { | |
const banner = document.createElement('div'); | |
banner.style.cssText = ` | |
position: fixed; | |
top: 0; | |
left: 0; | |
right: 0; | |
background: #2D8CFF; | |
color: white; | |
padding: 10px; | |
z-index: 9999; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
`; | |
const container = document.createElement('div'); | |
container.style.cssText = ` | |
display: flex; | |
gap: 10px; | |
align-items: center; | |
`; | |
const label = document.createElement('span'); | |
label.textContent = 'Smart Chapters:'; | |
label.style.fontWeight = 'bold'; | |
// Common button style | |
const buttonStyle = ` | |
padding: 5px 15px; | |
border-radius: 4px; | |
border: none; | |
background: white; | |
color: #2D8CFF; | |
cursor: pointer; | |
font-weight: bold; | |
`; | |
// Common function to extract and process chapters | |
async function getProcessedChapters() { | |
console.group('Starting Chapter Extraction'); | |
const chapters = await extractChapters(); | |
console.log('Total chapters extracted:', chapters.length); | |
return chapters; | |
} | |
const jsonButton = document.createElement('button'); | |
jsonButton.textContent = 'Download JSON'; | |
jsonButton.style.cssText = buttonStyle; | |
jsonButton.onclick = async () => { | |
const chapters = await getProcessedChapters(); | |
downloadFile(chaptersToJSON(chapters), `zoom-chapters-${Date.now()}.json`); | |
console.groupEnd(); | |
}; | |
const mdButton = document.createElement('button'); | |
mdButton.textContent = 'Download Markdown'; | |
mdButton.style.cssText = buttonStyle; | |
mdButton.onclick = async () => { | |
const chapters = await getProcessedChapters(); | |
downloadFile(chaptersToMarkdown(chapters), `zoom-chapters-${Date.now()}.md`); | |
console.groupEnd(); | |
}; | |
const debugButton = document.createElement('button'); | |
debugButton.textContent = '🔍 Debug Log'; | |
debugButton.style.cssText = buttonStyle; | |
debugButton.onclick = async () => { | |
const chapters = await getProcessedChapters(); | |
// Additional debug logging | |
console.group('Smart Chapters Debug Info'); | |
// Check window for global variables | |
console.group('Global Variables:'); | |
const globals = ['smartChapters', 'chapters', 'zoomChapters', 'recording'].filter( | |
key => window[key] !== undefined | |
); | |
console.log('Found globals:', globals); | |
globals.forEach(key => console.log(key + ':', window[key])); | |
console.groupEnd(); | |
// Check for React/Vue devtools | |
console.group('Framework Detection:'); | |
console.log('Vue detected:', !!window.__VUE_DEVTOOLS_GLOBAL_HOOK__); | |
console.log('React detected:', !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__); | |
console.groupEnd(); | |
chapters.forEach((chapter, index) => { | |
console.group(`Chapter ${index + 1}: ${chapter.title}`); | |
console.log('Timestamp:', chapter.timestamp); | |
console.log('Unix Time:', chapter.startTime); | |
console.log('Title:', chapter.title); | |
console.log('Description:', chapter.description || '(no description)'); | |
console.log('URL:', chapter.url); | |
console.groupEnd(); | |
}); | |
console.groupEnd(); | |
console.groupEnd(); | |
}; | |
container.appendChild(label); | |
container.appendChild(jsonButton); | |
container.appendChild(mdButton); | |
container.appendChild(debugButton); | |
banner.appendChild(container); | |
// Adjust page content to account for banner height | |
const contentAdjuster = document.createElement('div'); | |
contentAdjuster.style.height = '50px'; | |
document.body.insertBefore(contentAdjuster, document.body.firstChild); | |
// Start monitors | |
setupDynamicContentMonitor(); | |
setupNetworkMonitor(); | |
return banner; | |
} | |
// Main function to initialize the script | |
function init() { | |
// Wait for the Smart Chapters container to be available | |
const checkForChapters = setInterval(() => { | |
const chaptersContainer = document.querySelector('.smart-chapter-container'); | |
if (!chaptersContainer) return; | |
// Only add the banner if it doesn't exist yet | |
if (!document.getElementById('smart-chapters-banner')) { | |
const banner = createBanner(); | |
banner.id = 'smart-chapters-banner'; | |
document.body.insertBefore(banner, document.body.firstChild); | |
clearInterval(checkForChapters); | |
} | |
}, 1000); | |
// Clear interval after 30 seconds to prevent infinite checking | |
setTimeout(() => clearInterval(checkForChapters), 30000); | |
} | |
// Start the script | |
init(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment