Results 1 to 1 of 1

Thread: Downloading photosets with userscript and aria2  

  1. #1
    Active Member skygate's Avatar
    Joined
    6 Oct 2017
    Posts
    155
    Likes
    141
    Images
    39

    Downloading photosets with userscript and aria2

    Earlier I wrote a userscript that simply copies the photo URLs, this is based on that one. Simple userscript for copying image URLs

    Basically I finally got tired of manually pasting the URLs each time to a downloader. So I decided to make it fully automatic that requires only a single click.

    To use, you need to have Tampermonkey installed in the browser and aria2 running with JSON-RPC enabled. Example command line:

    aria2c --enable-rpc=true --rpc-allow-origin-all=true --rpc-secret=itsasecret
    In the userscript, configure "rpc_url" and "download_root" to match your own environment.
    Note: For "rpc_url", if you are running aria2 locally, use "http" instead of "https" (my source code is written as "http" but the forum automatically converts any URL to "https" when posted).

    On Android, you can use Aria2Android or Termux which can install a lot of command line utilities including aria2. Mobile browsers I know of that can install Tampermonkey and therefore run userscripts include Firefox, Edge Canary, and Cromite.

    Currently supported image hosts:
    • imx.to
    • imagetwั–st.com
    • vipr.im
    • imgbox.com
    • pixhost.to
    • imagebam.com
    • imagevenue.com
    • pimpandhost.com


    Video demo: https://drive.google.com/file/d/1q35yOAUd...2ZN3/preview

    Userscript:
    // ==UserScript==
    // @name         Download Photoset - vipergirls.to
    // @namespace    https://tampermonkey.net/
    // @match        https://vipergirls.to/threads/*
    // @grant        GM.xmlHttpRequest
    // @grant        GM_addStyle
    // @grant        window.close
    // @version      2026-05-09
    // @author       skygate
    // @description  Transform image thumbnail URLs as full size URLs and send them to aria2 via jsonrpc for download
    // @icon         https://vipergirls.to/favicon.ico
    // @run-at       document-end
    // @connect      127.0.0.1
    // @require      https://cdn.jsdelivr.net/npm/js-md5@0.1.0/src/md5.min.js
    // ==/UserScript==
    /* globals md5 */
    
    const rpc_url = "https://127.0.0.1:6800/jsonrpc";
    const rpc_secret = "itsasecret";
    const download_root = "/storage/emulated/0/Download/";
    const close_page_when_done = false;
    const zero_padding = 3;
    
    
    const replace_patterns = [
        {
            match: /imx\.to/,
            find: /\/[a-z]+\/[a-z]+\//,
            replace: '/u/i/'
        },
        {
            match: /imagetwist\.com/,
            find: /\/th\//,
            replace: '/i/'
        },
        {
            match: /vipr\.im/,
            find: /\/th\//,
            replace: '/i/'
        },
        {
            match: /imgbox\.com/,
            find: /thumbs(.*)_t(\..*$)/,
            replace: 'images$1_o$2'
        },
        {
            match: /imgbox\.com/,
            find: /upload\/small/,
            replace: 'i'
        },
        {
            match: /pixhost\.to/,
            find: /t(\d+.*?)\/thumb/,
            replace: 'img$1/image'
        },
        {
            match: /imagebam\.com|imagevenue\.com/,
            find: /thumbs(.*)(\/[0-9a-f]{2}\/[0-9a-f]{2}\/[0-9a-f]{2}\/)(.*)_t(\..*$)/,
            replace: (match, p1, hexshard, filename, extension) => `images${p1}/${md5(`${filename}_o${extension}`).substring(0, 6).match(/.{1,2}/g).join('/')}/${filename}_o${extension}`
        },
        {
            match: /pimpandhost\.com/,
            find: /_\w(\.[^.]+)$/,
            replace: '$1',
        },
    ];
    
    GM_addStyle(
        `
        .photoset-download-btn {
            background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAABoVBMVEUAAAAeHh0kJCSYmJk7OzvX1drq6urAwL9sbGxpaWhSTlpQUE9eXV9ZWVo0NDQAAAAAAAAVFRUAAACU5wykpKSSkpJeXl7Hx8fg4OCO3Q3g/CrB0pRoY3a5ubne+CmLi4vH9BZHR0el2xZqampmZmbIyMi5uLxqkyKe3BhycnK2traJhoxJSUkEAAaJlzhRT1ZvgS9HRE2ChUmGhoYgICBFRkVjY2McHBxYWFg5OTk2NjZsbGwWFhZhtAA0NDQAAABIehALCguv5h8CEQAAAACMjIwAAAAAAACg/wBcXFxBQUD///+pqaxZWVlKSUr//P/4+Prq6e3o5+zl5ebg3+Te3t7Kyc7Ly8u7vLqtra1ub25kZGRTU1M8Oz45OTl9mzSl6hX6+vr09PTs7Ozh4eHb2tzZ2drQ0NDFw83Dw8O0s7errainp6Sjo6OQkJCKiY2mrYmFhYWVoIB5eXl0dHRubXRiYG2gvV5QUFDi+02UtEiYxERDQ0PJ+UE9PT3N9znb+DK+3zCm3jB/oC3V7SfG8SOk4yG58R+f7xiO4xe17RR6b4dzAAAASXRSTlMAB1L7Uf79/f38+/appm8vFwkF/Pn5+fj29vTz8/Ly8fHv7u3l4N7Yy8a8u7SurKiopaOcmpiRh2pdVlFOSkA+OjowJiAXFBAHXqIfsgAAAOJJREFUGNNjgAMBOx0VTVcQi9HKms9YS1U81IejxIyBQYjBxrPYNz3RO1M0QjGKByjPwOSpHCMrwRbJ7MNVygsW8I32i0wJZ/eJ4vaFqhCL4eD084rlkinjhQhwe3n5RednF0kXGIIFKjhzwwMD/Msb5VqMQAIsNSLe3v4BYfGVUg16YAEv5uS0kJCMqnqFVgOwQB1zUmhQcER8bVe7khODIINlbF5gWGpWc1NbQoIGSAVfnDwbaxBrTnWHsKQzA4Mgv0lcYTC7mra+rnqnLYMHA4Obo7mphb2AkLsLvwMDFgAATzIwfNStL3IAAAAASUVORK5CYII=') no-repeat left !important;
            padding-left: 20px;
        }
        html.ui-mobile .photoset-download-btn {
            padding-left: 0;
            font-size: 0;
            height: 16px;
            line-height: 24px;
        }
    
        .download-progress-container {
            position: fixed;
            top: 20px;
            right: 20px;
            width: 320px;
            background: #eee;
            border: 2px solid #111;
            border-right: 2px solid #555;
            border-bottom: 2px solid #555;
            padding: 8px;
            z-index: 10000;
            font-family: 'Courier New', monospace;
            font-size: 14px;
            letter-spacing: 0;
        }
    
        html.ui-mobile .download-progress-container {
            left: 50%;
            right: unset;
            width: 90%;
            transform: translateX(-50%);
        }
    
        .download-progress-header {
            position: relative;
        }
    
        .download-progress-title {
            font-size: 12px;
            font-weight: bold;
            text-align: center;
            margin-bottom: 6px;
            color: #111;
            letter-spacing: 1px;
            border-bottom: 1px solid #111;
            padding-bottom: 4px;
            text-transform: uppercase;
        }
        .download-progress-title::before {
            content: "[ ";
        }
        .download-progress-title::after {
            content: " ]";
        }
    
        .download-progress-close {
            display: none;
            position: absolute;
            top: -4px;
            right: 0;
            padding: 0;
            background: none;
            border: none;
            font-size: 16px;
            cursor: pointer;
        }
        .progress-complete .download-progress-close {
            display: inline-block;
        }
    
        .download-progress-wrapper {
            background: #000000;
            padding: 2px 2px 4px 2px;
            margin-bottom: 8px;
            color: #ccc;
            text-align: center;
        }
    
        .download-progress-bar {
            font-family: sans-serif;
            font-size: 16px;
            font-weight: bold;
        }
    
        .download-progress-stats {
            font-size: 11px;
            color: #111;
            text-align: center;
            font-weight: bold;
            letter-spacing: 1px;
            text-transform: uppercase;
        }
        `);
    
    const photosetposts = Array.from(document.getElementsByClassName('postcontainer'))
    .filter(postcontainer => postcontainer.querySelector('.postcontent a>img:not(.inlineimg)'));
    photosetposts.forEach(post => {
        const ctrl = post.querySelector('.postcontrols');
        const separator = document.createElement('span');
        separator.className = 'seperator';
        separator.innerHTML = ' ';
        ctrl.appendChild(separator);
        const btn = document.createElement('a');
        btn.className = 'photoset-download-btn';
        btn.textContent = 'Download Photoset';
        btn.href = "javascript:void(0);"
    
        btn.addEventListener("click", downloadPhotoset.bind(null, ctrl));
        btn.addEventListener("mousedown", (e) => {
            e.target.style.filter = 'blur(3px)';
        })
    
        btn.addEventListener("mouseup", (e) => {
            e.target.style.removeProperty('filter');
        })
    
        ctrl.appendChild(btn);
    });
    
    async function downloadPhotoset(ctrl) {
        const postcontainer = ctrl.closest('.postcontainer');
        const content = postcontainer.getElementsByClassName('content')[0];
        const username = postcontainer.querySelector('a[href^="members/"]').innerText;
        const thread_title = postcontainer.getElementsByTagName('H2')[0].innerText;
        const first_line = content.innerText.match(/^.*$/m)[0];
        const is_multi_post = photosetposts.length > 1 && photosetposts.every(post => post.querySelector('a[href^="members/"]').innerText === username);
        const dir_name = (is_multi_post ? (first_line || thread_title) : thread_title).replace(/[\0?<>\/\\:*|"]/g, "_");
        console.log(dir_name);
        const download_dir = download_root + dir_name;
        const thumb_anchors = content.querySelectorAll('a:has(img:not(.inlineimg))');
        console.log(Array.from(thumb_anchors).map(link => link.href));
        const img_list = [];
        const na_list = [];
        for (let i = 0; i < thumb_anchors.length; i++) {
            const thumb_img = thumb_anchors[i].getElementsByTagName('img')[0];
            if (!thumb_img) continue;
            const thumb_src = thumb_img.src;
            const thumb_url = thumb_anchors[i].href;
            if (!thumb_url) continue;
            const patterns = replace_patterns.filter(pattern => thumb_url.match(pattern.match));
            if (!patterns.length) {
                na_list.push(thumb_url);
                continue;
            }
            let source = thumb_src;
            for (const pattern of patterns) {
                if (pattern.hasOwnProperty('resolver')) {
                    source = await pattern.resolver(thumb_url);
                } else {
                    source = source.replace(pattern.find, pattern.replace);
                }
            }
            if (!source) continue;
            const name = String(i + 1).padStart(zero_padding, '0');
            const extension = source.slice((source.lastIndexOf(".") - 1 >>> 0) + 2);
            img_list.push({name: name + '.' + extension, src: source});
        }
    
        if (na_list.length) {
            alert("Don't have a find & replace pattern for the following urls: \n" + na_list.join('\n'));
        }
    
        if (!img_list.length) {
            return;
        }
    
        //console.log(imglist);
    
        // Use system.multicall to have the separate option to name each file
        const calls = img_list.map(item => ({
            methodName: 'aria2.addUri',
            params: [
                'token:' + rpc_secret,
                [item.src],
                {
                    'out': item.name,
                    'dir': download_dir,
                    'allow-overwrite': 'true'
                }
            ]
        }));
    
        try {
            let result;
            try {
                const response = await GM.xmlHttpRequest({
                    url: rpc_url,
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    responseType: 'json',
                    fetch: true,
                    data: JSON.stringify({
                        jsonrpc: "2.0",
                        id: Date.now().toString(),
                        method: "system.multicall",
                        params: [calls]
                    })
                });
                result = await response.response.result;
            } catch (err) {
                console.log(err);
                if (err.status === 408) alert("Can't connect to aria2");
                else alert(JSON.stringify(err));
                return;
            }
            console.log(result);
            let erroredResult = result.find(res => res.hasOwnProperty('code'));
            if (erroredResult) {
                alert(erroredResult.message);
                return;
            }
            const downloadGIDs = result.map(res => res[0]);
            const downloadCount = downloadGIDs.length;
            let completedCount = 0;
            let progressChecker;
    
            const createProgressBar = () => {
                const container = document.createElement('div');
                container.className = 'download-progress-container';
                const header = document.createElement('div');
                header.className = 'download-progress-header';
    
                const title = document.createElement('div');
                title.className = 'download-progress-title';
                title.textContent = 'Downloading';
    
                const closeBtn = document.createElement('button');
                closeBtn.className = 'download-progress-close';
                closeBtn.textContent = 'โœ•';
                closeBtn.addEventListener('click', () => { container.remove() });
    
                header.appendChild(title);
                header.appendChild(closeBtn);
    
                const progressWrapper = document.createElement('div');
                progressWrapper.className = 'download-progress-wrapper';
    
                const progressBar = document.createElement('div');
                progressBar.className = 'download-progress-bar';
    
                progressWrapper.appendChild(progressBar);
    
                const stats = document.createElement('div');
                stats.className = 'download-progress-stats';
                stats.textContent = `0 of ${downloadCount} (0%)`;
    
                container.appendChild(header);
                container.appendChild(progressWrapper);
                container.appendChild(stats);
                document.body.appendChild(container);
    
                return { container, title, progressBar, stats };
            };
    
            const { container: progressContainer, title: progressTitle, progressBar, stats: progressStats } = createProgressBar();
    
            const charFilled = 'โ–ˆ';
            const charUnfilled = 'โ–’';
            const measureChar = document.createElement('span');
            measureChar.textContent = charUnfilled;
            measureChar.style.visibility = 'hidden';
            measureChar.style.position = 'absolute';
            measureChar.style.whiteSpace = 'nowrap';
            progressBar.appendChild(measureChar);
            const charWidth = measureChar.getBoundingClientRect().width;
            progressBar.removeChild(measureChar);
            const progressBarWidth = progressBar.getBoundingClientRect().width;
            const barLength = Math.floor(progressBarWidth / charWidth);
    
            const generateASCIIBar = (percentage) => {
                const filledLength = Math.floor((percentage / 100) * barLength);
                const emptyLength = barLength - filledLength;
    
                return charFilled.repeat(filledLength) + charUnfilled.repeat(emptyLength);
            };
    
            const updateProgressBar = () => {
                const percentage = Math.round((completedCount / downloadCount) * 100);
                progressBar.textContent = generateASCIIBar(percentage);
                progressStats.textContent = `${completedCount} of ${downloadCount} (${percentage}%)`;
                if (percentage === 100) {
                    progressContainer.classList.add('progress-complete');
                    progressTitle.textContent = 'Downloaded';
                }
            };
            updateProgressBar();
    
            const checkProgress = async () => {
                try {
                    const progressCalls = downloadGIDs.map(gid => ({
                        methodName: 'aria2.tellStatus',
                        params: ['token:' + rpc_secret, gid]
                    }));
    
                    const progressResponse = await GM.xmlHttpRequest({
                        url: rpc_url,
                        method: 'POST',
                        responseType: 'json',
                        headers: { 'Content-Type': 'application/json' },
                        fetch: true,
                        data: JSON.stringify({
                            jsonrpc: "2.0",
                            id: Date.now().toString(),
                            method: "system.multicall",
                            params: [progressCalls]
                        })
                    });
    
                    const progressResult = await progressResponse.response;
                    const completedIndexes = [];
                    progressResult.result.forEach((status, index) => {
                        if (status && status[0].status === 'complete') {
                            completedIndexes.push(index);
                        }
                    });
    
                    for (let i = completedIndexes.length - 1; i >= 0; i--) {
                        downloadGIDs.splice(completedIndexes[i], 1);
                    }
    
                    completedCount = downloadCount - downloadGIDs.length;
    
                    updateProgressBar();
    
                    if (completedCount === downloadCount) {
                        clearInterval(progressChecker);
                        if (close_page_when_done) {
                            window.close();
                        }
                    }
                } catch (err) {
                    console.error(`Error checking progress: ${err.message}`);
                }
            };
            await checkProgress();
            progressChecker = setInterval(checkProgress, 100);
    
        } catch (err) {
            console.error(err);
            alert("Error: " + JSON.stringify(err));
        }
    }
    Last edited by skygate; Yesterday at 07:38. Reason: Update

  2. Liked by 4 users: Progishness, roger33, twat, version365

Posting Permissions