Skip to content

Instantly share code, notes, and snippets.

@aculich
Created March 8, 2025 13:35
Show Gist options
  • Save aculich/491ace4a581c8707fa6cd8304d89ea79 to your computer and use it in GitHub Desktop.
Save aculich/491ace4a581c8707fa6cd8304d89ea79 to your computer and use it in GitHub Desktop.
Zoom Smart Chapters Downloader
// ==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