Last active
November 26, 2025 05:03
-
-
Save kiranwayne/445dda6abed129da85b5594a6e9e0f32 to your computer and use it in GitHub Desktop.
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 Udemy Transcript Downloader (Business & Consumer) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2.9 | |
| // @description Download Udemy transcripts. Draggable UI. Robust Timestamp cleaning. | |
| // @author You | |
| // @match https://*.udemy.com/course/* | |
| // @updateURL https://gist.githubusercontent.com/kiranwayne/445dda6abed129da85b5594a6e9e0f32/raw/udemy_transcript_downloader.js | |
| // @downloadURL https://gist.githubusercontent.com/kiranwayne/445dda6abed129da85b5594a6e9e0f32/raw/udemy_transcript_downloader.js | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=udemy.com | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // --- CONFIGURATION --- | |
| const REMOVE_TIMESTAMPS = true; // Set to false if you WANT timestamps | |
| // --------------------- | |
| let courseDataCache = null; | |
| let isFetching = false; | |
| // --- UI STYLES --- | |
| const style = document.createElement('style'); | |
| style.innerHTML = ` | |
| #udemy-transcript-panel { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 300px; | |
| background: #1c1d1f; | |
| color: #fff; | |
| border: 1px solid #3e4143; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.7); | |
| z-index: 9999; | |
| font-family: Arial, sans-serif; | |
| border-radius: 6px; | |
| display: flex; | |
| flex-direction: column; | |
| user-select: none; | |
| cursor: default; | |
| } | |
| #ut-header { | |
| padding: 12px 15px; | |
| background: #2d2f31; | |
| border-bottom: 1px solid #3e4143; | |
| border-radius: 6px 6px 0 0; | |
| cursor: move; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| #ut-header h3 { | |
| margin: 0; | |
| font-size: 15px; | |
| color: #a435f0; | |
| font-weight: bold; | |
| pointer-events: none; | |
| } | |
| #ut-body { | |
| padding: 15px; | |
| } | |
| .ut-btn { | |
| display: block; | |
| width: 100%; | |
| padding: 10px; | |
| margin-bottom: 10px; | |
| background: #2d2f31; | |
| border: 1px solid #fff; | |
| color: #fff; | |
| cursor: pointer; | |
| text-align: center; | |
| font-weight: bold; | |
| font-size: 13px; | |
| border-radius: 4px; | |
| transition: background 0.2s; | |
| user-select: none; | |
| } | |
| .ut-btn:hover { | |
| background: #3e4143; | |
| } | |
| .ut-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| #ut-status { | |
| margin-top: 5px; | |
| font-size: 12px; | |
| color: #d1d7dc; | |
| min-height: 1.2em; | |
| text-align: center; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| cursor: default; | |
| } | |
| #ut-close { | |
| cursor: pointer; | |
| font-weight: bold; | |
| color: #888; | |
| padding: 0 5px; | |
| } | |
| #ut-close:hover { | |
| color: #fff; | |
| } | |
| #ut-toggle-btn { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 50px; | |
| height: 50px; | |
| background: #a435f0; | |
| border-radius: 50%; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.5); | |
| z-index: 9999; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: white; | |
| font-size: 24px; | |
| font-weight: bold; | |
| border: 2px solid white; | |
| user-select: none; | |
| } | |
| #ut-toggle-btn:hover { | |
| background: #8710d8; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| // --- DRAG LOGIC --- | |
| function makeDraggable(el) { | |
| const header = el.querySelector('#ut-header'); | |
| let isDragging = false; | |
| let startX, startY, initialLeft, initialTop; | |
| header.addEventListener('mousedown', (e) => { | |
| if (e.target.id === 'ut-close') return; | |
| isDragging = true; | |
| startX = e.clientX; | |
| startY = e.clientY; | |
| const rect = el.getBoundingClientRect(); | |
| initialLeft = rect.left; | |
| initialTop = rect.top; | |
| el.style.bottom = 'auto'; | |
| el.style.right = 'auto'; | |
| el.style.left = initialLeft + 'px'; | |
| el.style.top = initialTop + 'px'; | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if (!isDragging) return; | |
| const dx = e.clientX - startX; | |
| const dy = e.clientY - startY; | |
| el.style.top = `${initialTop + dy}px`; | |
| el.style.left = `${initialLeft + dx}px`; | |
| }); | |
| window.addEventListener('mouseup', () => { isDragging = false; }); | |
| } | |
| // --- UI CREATION --- | |
| function createUI() { | |
| if (document.getElementById('udemy-transcript-panel')) return; | |
| const panel = document.createElement('div'); | |
| panel.id = 'udemy-transcript-panel'; | |
| panel.innerHTML = ` | |
| <div id="ut-header"> | |
| <h3>Transcript Downloader</h3> | |
| <div id="ut-close" title="Minimize">✕</div> | |
| </div> | |
| <div id="ut-body"> | |
| <button id="btn-single" class="ut-btn">Download Full (Single .txt)</button> | |
| <button id="btn-chapter" class="ut-btn">Download by Chapter (.zip)</button> | |
| <button id="btn-lecture" class="ut-btn">Download by Lecture (.zip)</button> | |
| <div id="ut-status">Ready</div> | |
| </div> | |
| `; | |
| document.body.appendChild(panel); | |
| const toggleBtn = document.createElement('div'); | |
| toggleBtn.id = 'ut-toggle-btn'; | |
| toggleBtn.innerText = 'T'; | |
| toggleBtn.title = 'Open Transcript Downloader'; | |
| toggleBtn.style.display = 'none'; | |
| toggleBtn.onclick = () => { | |
| toggleBtn.style.display = 'none'; | |
| panel.style.display = 'flex'; | |
| }; | |
| document.body.appendChild(toggleBtn); | |
| document.getElementById('ut-close').onclick = () => { | |
| panel.style.display = 'none'; | |
| toggleBtn.style.display = 'flex'; | |
| }; | |
| document.getElementById('btn-single').onclick = () => processDownload('single'); | |
| document.getElementById('btn-chapter').onclick = () => processDownload('chapter'); | |
| document.getElementById('btn-lecture').onclick = () => processDownload('lecture'); | |
| makeDraggable(panel); | |
| } | |
| // --- HELPER FUNCTIONS --- | |
| function updateStatus(msg) { | |
| const el = document.getElementById('ut-status'); | |
| if (el) el.textContent = msg; | |
| } | |
| function sanitizeFilename(name) { | |
| return name.replace(/[\/\\?%*:|"<>]/g, '-').replace(/\s+/g, ' ').trim(); | |
| } | |
| function getCourseId() { | |
| let id = null; | |
| const appLoader = document.querySelector('.ud-app-loader'); | |
| if (appLoader && appLoader.dataset.moduleArgs) { | |
| try { id = JSON.parse(appLoader.dataset.moduleArgs).courseId; } catch (e) {} | |
| } | |
| if (!id && window.UD && window.UD.course) id = window.UD.course.id; | |
| if (!id) id = document.body.getAttribute("data-clp-course-id"); | |
| return id; | |
| } | |
| // --- TEXT CLEANING --- | |
| function cleanTranscript(vttText) { | |
| if (!REMOVE_TIMESTAMPS) return vttText; | |
| return vttText | |
| // Remove WebVTT header | |
| .replace(/^WEBVTT/g, '') | |
| // Remove Note lines | |
| .replace(/^NOTE .*/gm, '') | |
| // Remove timestamps (Matches 00:00.000 --> 00:00.000 OR 00:00:00.000 --> ...) | |
| .replace(/^[\d:.]+\s-->\s[\d:.]+.*$/gm, '') | |
| // Remove single index numbers often found in VTT | |
| .replace(/^\d+$/gm, '') | |
| // Remove empty lines | |
| .replace(/^\s*[\r\n]/gm, '') | |
| // Join lines to make it a paragraph (Optional: remove this if you want line breaks preserved) | |
| .replace(/([^\n])\n([^\n])/g, '$1 $2') | |
| .trim(); | |
| } | |
| // --- CORE FETCH LOGIC --- | |
| async function fetchCourseContent(courseId) { | |
| if (courseDataCache) return courseDataCache; | |
| isFetching = true; | |
| updateStatus("Fetching curriculum list..."); | |
| const curriculumUrl = `/api-2.0/courses/${courseId}/subscriber-curriculum-items/?page_size=1000&fields[lecture]=title,object_index,asset&fields[chapter]=title,object_index&caching_intent=True`; | |
| const response = await fetch(curriculumUrl); | |
| const data = await response.json(); | |
| const results = data.results || []; | |
| results.sort((a, b) => a.sort_order - b.sort_order); | |
| const structuredData = []; | |
| let currentChapter = { title: "Introduction", index: 0, lectures: [] }; | |
| let totalLectures = 0; | |
| for (const item of results) { | |
| if (item._class === "chapter") { | |
| if (currentChapter.lectures.length > 0) structuredData.push(currentChapter); | |
| currentChapter = { | |
| title: item.title, | |
| index: item.object_index, | |
| lectures: [] | |
| }; | |
| } | |
| if (item._class === "lecture" && item.asset && item.asset.asset_type === "Video") { | |
| currentChapter.lectures.push({ | |
| id: item.id, | |
| title: item.title, | |
| index: item.object_index, | |
| asset: item.asset | |
| }); | |
| totalLectures++; | |
| } | |
| } | |
| if (currentChapter.lectures.length > 0) structuredData.push(currentChapter); | |
| let processed = 0; | |
| for (const chap of structuredData) { | |
| for (const lec of chap.lectures) { | |
| processed++; | |
| updateStatus(`Fetching captions: ${processed}/${totalLectures}`); | |
| await new Promise(r => setTimeout(r, 200)); | |
| try { | |
| const lectureUrl = `/api-2.0/users/me/subscribed-courses/${courseId}/lectures/${lec.id}/?fields[lecture]=asset,supplementary_assets&fields[asset]=captions`; | |
| const lecResp = await fetch(lectureUrl); | |
| const lecJson = await lecResp.json(); | |
| const tracks = (lecJson.asset && lecJson.asset.captions) ? lecJson.asset.captions : []; | |
| if (tracks.length > 0) { | |
| const track = tracks.find(t => t.locale_id === 'en_US' || t.locale_id === 'en_GB') | |
| || tracks.find(t => t.label && t.label.toLowerCase().includes('english')) | |
| || tracks[0]; | |
| const vttResp = await fetch(track.url); | |
| const vttText = await vttResp.text(); | |
| lec.transcript = cleanTranscript(vttText); | |
| lec.lang = track.locale_id || track.label; | |
| } else { | |
| lec.transcript = "[No transcript available]"; | |
| } | |
| } catch (e) { | |
| lec.transcript = "[Error fetching transcript]"; | |
| } | |
| } | |
| } | |
| isFetching = false; | |
| courseDataCache = structuredData; | |
| return structuredData; | |
| } | |
| // --- DOWNLOAD GENERATORS --- | |
| async function processDownload(mode) { | |
| if (isFetching) return; | |
| const courseId = getCourseId(); | |
| if (!courseId) { | |
| updateStatus("Error: Course ID not found."); | |
| return; | |
| } | |
| try { | |
| const data = await fetchCourseContent(courseId); | |
| updateStatus("Generating files..."); | |
| const zip = new JSZip(); | |
| const folderName = `Udemy_Course_${courseId}`; | |
| if (mode === 'single') { | |
| let content = `COURSE TRANSCRIPT\n\n`; | |
| data.forEach(chap => { | |
| content += `\n\n=== CHAPTER ${chap.index}: ${chap.title} ===\n`; | |
| chap.lectures.forEach(lec => { | |
| content += `\n--- Lecture ${lec.index}: ${lec.title} ---\n${lec.transcript}\n`; | |
| }); | |
| }); | |
| const blob = new Blob([content], {type: "text/plain;charset=utf-8"}); | |
| saveAs(blob, `${folderName}_Full.txt`); | |
| } else if (mode === 'chapter') { | |
| data.forEach(chap => { | |
| let content = `CHAPTER ${chap.index}: ${chap.title}\n\n`; | |
| chap.lectures.forEach(lec => { | |
| content += `\n--- Lecture ${lec.index}: ${lec.title} ---\n${lec.transcript}\n`; | |
| }); | |
| const safeTitle = sanitizeFilename(`${String(chap.index).padStart(2, '0')}_${chap.title}`); | |
| zip.file(`${safeTitle}.txt`, content); | |
| }); | |
| const content = await zip.generateAsync({type:"blob"}); | |
| saveAs(content, `${folderName}_Chapters.zip`); | |
| } else if (mode === 'lecture') { | |
| data.forEach(chap => { | |
| const chapFolder = zip.folder(sanitizeFilename(`${String(chap.index).padStart(2, '0')}_${chap.title}`)); | |
| chap.lectures.forEach(lec => { | |
| const safeTitle = sanitizeFilename(`${String(lec.index).padStart(3, '0')}_${lec.title}`); | |
| chapFolder.file(`${safeTitle}.txt`, lec.transcript); | |
| }); | |
| }); | |
| const content = await zip.generateAsync({type:"blob"}); | |
| saveAs(content, `${folderName}_Lectures.zip`); | |
| } | |
| updateStatus("Download Complete!"); | |
| setTimeout(() => updateStatus("Ready"), 3000); | |
| } catch (e) { | |
| updateStatus("Error during download."); | |
| console.error(e); | |
| isFetching = false; | |
| } | |
| } | |
| // --- INITIALIZATION --- | |
| function init() { | |
| const id = getCourseId(); | |
| if (id) { | |
| console.log(`[Transcript] Course ID found: ${id}`); | |
| createUI(); | |
| } else { | |
| console.log("[Transcript] No course ID found on this page."); | |
| } | |
| } | |
| window.addEventListener('load', () => setTimeout(init, 2000)); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment