DramaLINK - Episode Link Extractor

Stream or download your favorite drama effortlessly with DramaLINK! Unlock the power to play any drama directly in your preferred video player or download entire seasons in a single click using popular download managers like IDM. DramaLINK generates direct download links for all episodes, conveniently sorted by quality. Elevate your drama-watching experience now!

// ==UserScript==
// @name        DramaLINK - Episode Link Extractor
// @namespace   https://greasyfork.org/en/users/781076-jery-js
// @version     1.2.2
// @description Stream or download your favorite drama effortlessly with DramaLINK! Unlock the power to play any drama directly in your preferred video player or download entire seasons in a single click using popular download managers like IDM. DramaLINK generates direct download links for all episodes, conveniently sorted by quality. Elevate your drama-watching experience now!
// @icon        https://www.google.com/s2/favicons?domain=asianc.to
// @author      Jery
// @license     MIT
// @match       https://asianc.*/*-episode-*
// @match       https://asianc.sh/*-episode-*
// @match       https://runasian.*/*-episode-*
// @match       https://runasian.net/*-episode-*
// @match       https://dramanice.*/*-episode-*
// @match       https://dramanice.la/*-episode-*
// @match       https://watchasia.*/*-episode-*
// @match       https://watchasia.to/*-episode-*
// @grant       GM_registerMenuCommand
// ==/UserScript==

class Episode {
    constructor(number, title, links, type, thumbnail) {
        this.number = number;
        this.title = title;
        this.links = links;
        this.type = type;
        this.thumbnail = thumbnail;
        this.name = `${this.title} - ${this.number}`;
    }
}

const websites = [
    {
        name: 'DramaCool',
        url: ['asianc', 'runasian', 'watchasia'],
        epLinks: 'ul.all-episode > li > a',
        epTitle: '.name > h1',
        linkElems: '.cf-download > a',
        thumbnail: '.logo > a > img',
        addStartButton: function() {
            const button = document.createElement('a');
            button.id = "DramaLINK_startBtn";
            button.style.cssText = `cursor: pointer; background-color: #145132;`;
            button.innerHTML = '<i class="icongec-dowload"></i> Generate Download Links';
            button.addEventListener('click', extractEpisodes);

            // Add the button to the page if user is logged in otherwise show placeholder
            if (document.querySelector('.cf-download')) {
                document.querySelector('.cf-download').appendChild(button);
            } else {
                const loginMessage = document.querySelector('.watch-drama > .plugins2').nextElementSibling;
                loginMessage.innerHTML = `<b style="color:#FFC119;">DramaLINK:</b> Please <a href="/login.html" title="login"><u>log in</u></a> to be able to batch download the series.`;
            }
        },
		_proxyKey: "temp_2ed7d641dd52613591687200e7f7958b",
        extractEpisodes: async function (status) {
            status.textContent = 'Starting...';
            let episodes = {};
            const episodePromises = Array.from(document.querySelectorAll(this.epLinks)).map(async epLink => { try {
                const response = await fetchHtml(epLink.href);
                const page = (new DOMParser()).parseFromString(response, 'text/html');

                // Workaround for runasian.net
                let epTitleElemText = page.querySelector(this.epTitle) 
                    ? page.querySelector(this.epTitle).textContent 
                    : page.querySelector('.block.watch-drama > h1').textContent;
                
                const [, epTitle, epNumber] = epTitleElemText.match(/(.+?) Episode (\d+)(?:.+)$/);
                const episodeTitle = `${epNumber.padStart(3, '0')} - ${epTitle}`;
                const thumbnail = page.querySelector(this.thumbnail).src;
                const linkElems = [...page.querySelectorAll(this.linkElems)];
                status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, '0')}...`;
                let links = {};
                for (const elem of linkElems) {
                    try {
                        const html = await (await fetch('https://proxy.cors.sh/'+elem.href, {headers: {"x-cors-api-key": this._proxyKey}} )).text();
                        const directLink = html.match(/window\.location="([^"]+)";/)[1];
                        links[elem.textContent.trim()] = directLink;
                    } catch (error) {
                        console.error(`Failed to fetch ${elem.href}: ${error}`);
                        status.textContent += `Failed to fetch ${elem.href}: ${error}`;
                    }
                }
                status.textContent = `Parsed ${epTitle} - ${epNumber.padStart(3, '0')}...`;

                episodes[episodeTitle] = new Episode(epNumber.padStart(3, '0'), epTitle, links, 'mp4', thumbnail);
            } catch (error) {alert(error)}} );
            await Promise.all(episodePromises);
            return episodes;
        }
    },
    {
        name: 'DramaNice',
        url: ['dramanice'],
        epLinks: 'ul.list_episode > li > a',
        epTitle: 'h1.label_coming',
        linkElems: '.cf-download > a',
        thumbnail: 'img',
        addStartButton: function() {
            const button = document.createElement('a');
            button.id = "DramaLINK_startBtn";
            button.style.cssText = `cursor: pointer; background-color: #145132;`;
            button.innerHTML = '<i class="icongec-dowload"></i> Generate Download Links';
            button.addEventListener('click', extractEpisodes);

            // Add the button to the page if user is logged in otherwise show placeholder
            if (document.querySelector('.cf-download')) {
                document.querySelector('.cf-download').appendChild(button);
            } else {
                const loginMessage = document.querySelector('.drama_video_body > .clr').nextElementSibling;
                loginMessage.innerHTML = `<b style="color:#FFC119;">DramaLINK:</b> Please <a href="/login.html" title="login"><u>log in</u></a> to be able to batch download the series.`;
            }
        },
		_proxyKey: "temp_2ed7d641dd52613591687200e7f7958b",
        extractEpisodes: async function (status) {
            status.textContent = 'Starting...';
            let episodes = {};
            const episodePromises = Array.from(document.querySelectorAll(this.epLinks)).map(async epLink => { try {
                const response = await fetchHtml(epLink.href);
                const page = (new DOMParser()).parseFromString(response, 'text/html');
                
                const [, epTitle, epNumber] = page.querySelector(this.epTitle).textContent.match(/(.+?) Episode (\d+)(?:.+)$/);
                const episodeTitle = `${epNumber.padStart(3, '0')} - ${epTitle}`;
                const thumbnail = page.querySelector(this.thumbnail).src;
                const linkElems = [...page.querySelectorAll(this.linkElems)]
                // const links = linkElems.reduce((obj, elem) => ({ ...obj, [elem.textContent.trim()]: elem.href }), {});
                status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, '0')}...`;
                let links = {};
                for (const elem of linkElems) {
                    try {
                        const html = await (await fetch('https://proxy.cors.sh/'+elem.href, {headers: {"x-cors-api-key": this._proxyKey}} )).text();
                        const directLink = html.match(/window\.location="([^"]+)";/)[1];
                        links[elem.textContent.trim()] = directLink;
                    } catch (error) {
                        console.error(`Failed to fetch ${elem.href}: ${error}`);
                        status.textContent += `Failed to fetch ${elem.href}: ${error}`;
                    }
                }
                status.textContent = `Parsed ${epTitle} - ${epNumber.padStart(3, '0')}...`;

                episodes[episodeTitle] = new Episode(epNumber.padStart(3, '0'), epTitle, links, 'mp4', thumbnail);
            } catch (error) {alert(error)}} );
            await Promise.all(episodePromises);
            return episodes;
        }
    }
];

async function fetchHtml(url) {
    const response = await fetch(url);
    if (response.ok) {
        return response.text();
    } else {
        alert(`Failed to fetch HTML for ${url}`);
        throw new Error(`Failed to fetch HTML for ${url}`);
    }
}

GM_registerMenuCommand('Extract Episodes', extractEpisodes);

// initialize
console.log('Initializing DramaLINK...');
const site = websites.find(site => site.url.some(url => window.location.href.includes(url)));

// attach button to page
site.addStartButton();

// append site specific css styles
document.body.style.cssText += (site.styles || '');

// This function creates an overlay on the page and displays a list of episodes extracted from a website.
// The function is triggered by a user command registered with `GM_registerMenuCommand`.
// The episode list is generated by calling the `extractEpisodes` method of a website object that matches the current URL.
async function extractEpisodes() {
    // Restore last overlay if it exists
    if (document.getElementById("DramaLINK_Overlay")) {
        document.getElementById("DramaLINK_Overlay").style.display = "flex";
        return;
    }

    // Create an overlay to cover the page
    const overlayDiv = document.createElement("div");
    overlayDiv.id = "DramaLINK_Overlay";
    overlayDiv.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 999; display: flex; align-items: center; justify-content: center;";
    document.body.appendChild(overlayDiv);
    overlayDiv.onclick = event => linksContainer.contains(event.target) ? null : overlayDiv.style.display = "none";

    // Create a form to display the Episodes list
    const linksContainer = document.createElement('div');
    linksContainer.id = "DramaLINK_LinksContainer";
    linksContainer.style.cssText = "position:relative; height:70%; width:60%; color:cyan; background-color:#0b0b0b; overflow:auto; border: groove rgb(75, 81, 84); border-radius: 10px; padding: 10px 5px; resize: both; scrollbar-width: thin; scrollbar-color: cyan transparent; display: flex; justify-content: center; align-items: center;";
    overlayDiv.appendChild(linksContainer);

    // Create a progress bar to display the progress of the episode extraction process
    const statusBar = document.createElement('span');
    statusBar.id = "DramaLINK_StatusBar";
    statusBar.textContent = "Extracting Links..."
    statusBar.style.cssText = "background-color: #0b0b0b; color: cyan;";
    linksContainer.appendChild(statusBar);

    // Extract episodes
    const episodes = await site.extractEpisodes(statusBar);

    console.log(episodes);

    // Get all links into format - {[qual1]:[ep1,2,3,4], [qual2]:[ep1,2,3,4], ...}
    const sortedEpisodes = Object.values(episodes).sort((a, b) => a.number - b.number);
    const sortedLinks = sortedEpisodes.reduce((acc, episode) => {
        for (let quality in episode.links) (acc[quality] ??= []).push(episode);
        return acc;
    }, {});
    console.log('sorted', sortedLinks);


    const qualityLinkLists = Object.entries(sortedLinks).map(([quality, episode]) => {
        const listOfLinks = episode.map(ep => {
            return `<li id="EpisodeLink" style="list-style-type: none;">
                      <span style="user-select:none; color:cyan;">
                      Ep ${ep.number.replace(/^0+/, '')}: </span>
                      <a title="${ep.title.replace(/[<>:"/\\|?*]/g, '')}" download="${encodeURI(ep.name)}.${ep.type}" href="${ep.links[quality]}" style="color:#FFC119;">
                      ${ep.links[quality]}</a>
                  </li>`;
        }).join("");

        return `<ol style="white-space: nowrap;">
                      <span id="Quality" style="display:flex; justify-content:center; align-items:center;">
                        <b style="color:#58FFA9; font-size:25px; cursor:pointer; user-select:none;">
                          -------------------${quality}-------------------\n
                        </b>
                      </span>
                      ${listOfLinks}
                    </ol><br><br>`;
    });

    // Update the linksContainer with the finally generated links under each quality option header
    linksContainer.style.cssText = "position:relative; height:70%; width:60%; color:cyan; background-color:#0b0b0b; overflow:auto; border: groove rgb(75, 81, 84); border-radius: 10px; padding: 10px 5px; resize: both; scrollbar-width: thin; scrollbar-color: cyan transparent;";
    linksContainer.innerHTML = qualityLinkLists.join("");

    // Add hover event listeners to update link text on hover
    linksContainer.querySelectorAll('#EpisodeLink').forEach(element => {
        const episode = element.querySelector('a');
        const link = episode.href;
        const name = decodeURIComponent(episode.download);
        element.addEventListener('mouseenter', () => window.getSelection().isCollapsed && (episode.textContent = name));
        element.addEventListener('mouseleave', () => episode.textContent = decodeURIComponent(link));
    });

    // Add hover event listeners to quality headers to transform them into speed dials
    document.querySelectorAll('#Quality b').forEach(header => {
        const style = `style="background-color: #00A651; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; user-select: none;"`
        const sdHTML = `
            <div style="display: flex; justify-content: center; padding: 10px;">
                <ul style="list-style: none; display: flex; gap: 10px;">
                    <button type="button" ${style} id="DramaLINK_selectLinks">Select</button>
                    <button type="button" ${style} id="DramaLINK_copyLinks">Copy</button>
                    <button type="button" ${style} id="DramaLINK_exportLinks">Export</button>
                    <button type="button" ${style} id="DramaLINK_playLinks">Play with VLC</button>
                </ul>
            </div>`

        let headerHTML = header.innerHTML;
        header.parentElement.addEventListener('mouseenter', () => (header.innerHTML = sdHTML, attachBtnClickListeners()));
        header.parentElement.addEventListener('mouseleave', () => (header.innerHTML = headerHTML));
    });

    // Attach click listeners to the speed dial buttons
    function attachBtnClickListeners() {
        const buttonIds = [
            { id: 'DramaLINK_selectLinks', handler: onSelectBtnPressed },
            { id: 'DramaLINK_copyLinks', handler: onCopyBtnClicked },
            { id: 'DramaLINK_exportLinks', handler: onExportBtnClicked },
            { id: 'DramaLINK_playLinks', handler: onPlayBtnClicked }
        ];

        buttonIds.forEach(({ id, handler }) => {
            const button = document.querySelector(`#${id}`);
            button.addEventListener('click', () => handler(button));
        });

        // Select Button click event handler
        function onSelectBtnPressed(it) {
            const links = it.closest('ol').querySelectorAll('li');
            const range = new Range();
            range.selectNodeContents(links[0]);
            range.setEndAfter(links[links.length - 1]);
            window.getSelection().removeAllRanges();
            window.getSelection().addRange(range);
            it.textContent = 'Selected!!';
            setTimeout(() => { it.textContent = 'Select'; }, 1000);
        }

        // copySelectedLinks click event handler
        function onCopyBtnClicked(it) {
            const links = it.closest('ol').querySelectorAll('li');
            const string = [...links].map(link => link.children[1].href).join('\n');
            navigator.clipboard.writeText(string);
            it.textContent = 'Copied!!';
            setTimeout(() => { it.textContent = 'Copy'; }, 1000);
        }

        // exportToPlaylist click event handler
        function onExportBtnClicked(it) {
            // Export all links under the quality header into a playlist file
            const links = it.closest('ol').querySelectorAll('li');
            let string = '#EXTM3U\n';
            links.forEach(link => {
                const episode = decodeURIComponent(link.children[1].download);
                string += `#EXTINF:-1,${episode}\n` + link.children[1].href + '\n';
            });
            const fileName = links[0].querySelector('a').title + '.m3u';
            const file = new Blob([string], { type: 'application/vnd.apple.mpegurl' });
            const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(file), download: fileName });
            a.click();
            it.textContent = 'Exported!!';
            setTimeout(() => { it.textContent = 'Export'; }, 1000);
        }

        // PlayWithVLC click event handler
        function onPlayBtnClicked(it) {
            // Export all links under the quality header into a playlist file
            const links = it.closest('ol').querySelectorAll('li');
            let string = '#EXTM3U\n';
            links.forEach(link => {
                const episode = decodeURIComponent(link.children[1].download);
                string += `#EXTINF:-1,${episode}\n` + link.children[1].href + '\n';
            });
            const file = new Blob([string], { type: 'application/vnd.apple.mpegurl' });
            const fileUrl = URL.createObjectURL(file);
            window.open(fileUrl);
            it.textContent = 'Launching VLC!!';
            setTimeout(() => { it.textContent = 'Play with VLC'; }, 2000);
            alert("Due to browser limitations, there is a high possibility that this feature may not work correctly.\nIf the video does not automatically play, please utilize the export button and manually open the playlist file manually.");
        }

        return {
            onSelectBtnPressed,
            onCopyBtnClicked,
            onExportBtnClicked,
            onPlayBtnClicked
        };
    }
}