Skill-Capped Video Downloader

Download videos from Skill-Capped

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        Skill-Capped Video Downloader
// @namespace   http://tampermonkey.net/
// @version     1.0
// @description Download videos from Skill-Capped
// @author      xizas
// @match       https://www.skill-capped.com/*
// @grant       none
// @run-at      document-idle
// ==/UserScript==

(function() {
    'use strict';

    const CLOUDFRONT_URL = 'https://d13z5uuzt1wkbz.cloudfront.net';
    const RESOLUTION = '2500';
    let ffmpegLoaded = false;
    let ffmpegLoading = false;

    // Load FFmpeg.wasm for MP4 conversion
    function loadFFmpeg() {
        if (ffmpegLoaded || ffmpegLoading) return;
        ffmpegLoading = true;

        const script1 = document.createElement('script');
        script1.src = 'https://unpkg.com/@ffmpeg/[email protected]/dist/umd/ffmpeg.js';
        document.head.appendChild(script1);

        const script2 = document.createElement('script');
        script2.src = 'https://unpkg.com/@ffmpeg/[email protected]/dist/umd/index.js';
        script2.onload = () => {
            ffmpegLoaded = true;
            console.log('[SC Downloader] FFmpeg loaded successfully');
        };
        document.head.appendChild(script2);
    }

    // Extract video ID from thumbnail
    function getVideoId() {
        const thumbnail = document.querySelector('img[data-name="Video Poster"]');
        if (thumbnail) {
            const match = thumbnail.src.match(/thumbnails\/([a-z0-9]+)\//i);
            if (match) return match[1];
        }

        // Fallback: try URL
        const urlMatch = window.location.pathname.match(/\/video\/([a-z0-9]+)/i);
        if (urlMatch) return urlMatch[1];

        return null;
    }

    // Extract video title for filename
    function getVideoTitle() {
        const selectors = [
            '[data-name="CourseTitleBar"]',
            '[data-name="Vid Info Row"] h1',
            '[data-name="Vid Info Row"] h2',
            'h1'
        ];

        for (const selector of selectors) {
            const el = document.querySelector(selector);
            if (el && el.textContent.trim()) {
                return el.textContent.trim()
                    .replace(/[<>:"/\\|?*]/g, '')
                    .substring(0, 80);
            }
        }
        return null;
    }

    // Download all .ts segments
    async function downloadSegments(videoId, updateStatus) {
        const chunks = [];
        let consecutiveFails = 0;

        for (let i = 1; i <= 2000; i++) {
            const url = `${CLOUDFRONT_URL}/${videoId}/HIDDEN${RESOLUTION}-${String(i).padStart(5, '0')}.ts`;

            try {
                const resp = await fetch(url);
                if (resp.ok) {
                    chunks.push(await resp.arrayBuffer());
                    consecutiveFails = 0;
                    const sizeMB = (chunks.reduce((a, b) => a + b.byteLength, 0) / 1024 / 1024).toFixed(1);
                    updateStatus(`Downloading: segment ${i} (${sizeMB} MB)`);
                } else {
                    consecutiveFails++;
                    if (consecutiveFails >= 3) break;
                }
            } catch (e) {
                consecutiveFails++;
                if (consecutiveFails >= 3) break;
            }
        }

        return chunks;
    }

    // Convert .ts to .mp4 using FFmpeg.wasm
    async function convertToMp4(chunks, updateStatus) {
        if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') {
            throw new Error('FFmpeg not loaded');
        }

        const { FFmpeg } = FFmpegWASM;
        const { fetchFile } = FFmpegUtil;

        const ffmpeg = new FFmpeg();

        updateStatus('Loading FFmpeg...');
        await ffmpeg.load({
            coreURL: 'https://unpkg.com/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js',
        });

        updateStatus('Preparing conversion...');
        const tsBlob = new Blob(chunks, { type: 'video/mp2t' });
        const tsData = await fetchFile(tsBlob);

        await ffmpeg.writeFile('input.ts', tsData);

        updateStatus('Converting to MP4... (please wait)');
        await ffmpeg.exec(['-i', 'input.ts', '-c', 'copy', 'output.mp4']);

        const mp4Data = await ffmpeg.readFile('output.mp4');
        return new Blob([mp4Data.buffer], { type: 'video/mp4' });
    }

    // Trigger file download
    function triggerDownload(blob, filename) {
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(a.href);
    }

    // Main download function
    async function startDownload(btn) {
        const videoId = getVideoId();
        if (!videoId) {
            btn.innerHTML = 'ERROR: No video found';
            return;
        }

        const videoTitle = getVideoTitle() || videoId;
        btn.disabled = true;

        const updateStatus = (msg) => { btn.innerHTML = msg; };

        try {
            // Download segments
            const chunks = await downloadSegments(videoId, updateStatus);

            if (!chunks.length) {
                updateStatus('ERROR: No data downloaded');
                btn.disabled = false;
                return;
            }

            // Try MP4 conversion
            try {
                const mp4Blob = await convertToMp4(chunks, updateStatus);
                triggerDownload(mp4Blob, `${videoTitle}.mp4`);
                updateStatus('✓ MP4 DOWNLOAD COMPLETE');
            } catch (e) {
                console.warn('[SC Downloader] FFmpeg failed, falling back to .ts:', e);
                updateStatus('Converting failed, downloading as .ts...');

                const tsBlob = new Blob(chunks, { type: 'video/mp2t' });
                triggerDownload(tsBlob, `${videoTitle}.ts`);
                updateStatus('✓ DOWNLOADED AS .TS');
            }
        } catch (e) {
            console.error('[SC Downloader] Error:', e);
            updateStatus('ERROR: ' + e.message);
        }

        setTimeout(() => {
            btn.innerHTML = '⬇ DOWNLOAD MP4';
            btn.disabled = false;
        }, 5000);
    }

    // Create download button
    function createButton() {
        // Remove existing button
        document.querySelectorAll('.sc-dl-btn').forEach(e => e.remove());

        // Check if we're on a video page
        if (!document.querySelector('[data-name="Video Player Container"]')) {
            return;
        }

        const videoId = getVideoId();
        if (!videoId) return;

        console.log('[SC Downloader] Video detected:', videoId);

        const btn = document.createElement('button');
        btn.className = 'sc-dl-btn';
        btn.innerHTML = '⬇ DOWNLOAD MP4';
        btn.title = 'Video ID: ' + videoId;
        btn.setAttribute('style', `
            position: fixed !important;
            top: 10px !important;
            left: 10px !important;
            z-index: 2147483647 !important;
            padding: 15px 30px !important;
            background: linear-gradient(135deg, #d55051 0%, #b33a3b 100%) !important;
            color: white !important;
            border: 2px solid rgba(255,255,255,0.3) !important;
            border-radius: 8px !important;
            font-family: "Roboto Condensed", Arial, sans-serif !important;
            font-size: 14px !important;
            font-weight: bold !important;
            text-transform: uppercase !important;
            cursor: pointer !important;
            box-shadow: 0 4px 15px rgba(0,0,0,0.4) !important;
            transition: all 0.2s ease !important;
        `);

        btn.onmouseenter = () => {
            btn.style.transform = 'translateY(-2px)';
            btn.style.boxShadow = '0 6px 20px rgba(0,0,0,0.5)';
        };
        btn.onmouseleave = () => {
            btn.style.transform = 'translateY(0)';
            btn.style.boxShadow = '0 4px 15px rgba(0,0,0,0.4)';
        };

        btn.onclick = () => startDownload(btn);

        document.body.appendChild(btn);

        // Load FFmpeg in background
        loadFFmpeg();
    }

    // Initialize
    function init() {
        createButton();

        // Watch for SPA navigation
        let lastUrl = location.href;
        const observer = new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                setTimeout(createButton, 1500);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    // Run when ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => setTimeout(init, 1000));
    } else {
        setTimeout(init, 1000);
    }

    // Also try on full load
    window.addEventListener('load', () => setTimeout(createButton, 2000));

})();