Ripper

Cleverly download all images on a webpage

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Ripper
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Cleverly download all images on a webpage
// @author       TetteDev
// @match        *://*/*
// @icon         https://icons.duckduckgo.com/ip2/tampermonkey.net.ico
// @license      MIT
// @grant        GM_cookie
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_setValue
// @run-at       document-idle
// @noframes
// ==/UserScript==

const RenderGui = (selector = '') => {
    const highlightSelector = '4px dashed purple';

    const highlightElement = (element) => {
        element.style.border = highlightSelector;
    };
    const unhighlightElement = (element) => {
        element.style.border = '';
    }

    let container = null;
    const guiClassName = 'gui-container';
    if ((container = document.querySelector(`.${guiClassName}`))) {
        container.remove();
    }
    else {
        const style = document.createElement('style');
        style.textContent = `
            .gui-container {
                font-family: 'Segoe UI', Arial, sans-serif;
                max-width: 750px;
                margin: 20px auto;
                padding: 10px;
                background: white;
                border-radius: 8px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.1);

                position: fixed;
                z-index: 9999;
                width: auto;
                height: auto;
                top: 15px;
                right: 15px;

                border: 1px solid black;
            }
            .input-group {
                display: flex;
                gap: 5px;
                margin-bottom: 10px;
            }
            .input-text {
                flex: 1;
                padding: 8px 12px;
                border: 1px solid #ddd;
                border-radius: 4px;
                font-size: 14px;
                color: black !important;
            }
            .btn {
                padding: 8px 16px;
                background:rgb(250, 0, 0);
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
                font-size: 14px;
            }
            .item-list {
                list-style: none;
                padding: 0;
                margin: 0 0 20px 0;
                max-height: 450px;
                overflow-y: auto;
                overflow-x: hidden;
            }
            .item-list li {
                display: flex;
                align-items: center;
                padding: 3px;
                border-bottom: 1px solid #eee;

                -webkit-user-select: none !important;
                -khtml-user-select: none !important;
                -moz-user-select: -moz-none !important;
                -o-user-select: none !important;
                user-select: none !important;
            }
            .item-list li:hover {
                background-color: yellow;
            }
            .checkbox-group {
                margin-bottom: 10px;
            }
            .checkbox-label {
                display: inline-flex;
                align-items: center;
                margin-right: 20px;
                cursor: pointer;
                color: black !important;
            }
            .download-btn {
                width: 100%;
                padding: 12px;
                background: rgb(250, 0, 0);
                color: white;
                font-weight: bold;
            }
        `;
        document.body.appendChild(style);
    }

    // Create GUI elements
    container = document.createElement('div');
    container.className = guiClassName;

    // Add dragging functionality
    let isDragging = false;
    let currentX;
    let currentY;
    let initialX;
    let initialY;
    let xOffset = 0;
    let yOffset = 0;

    const dragStart = (e) => {
        if (e.target !== container) return; // Only drag from container itself
        
        initialX = e.clientX - xOffset;
        initialY = e.clientY - yOffset;

        if (e.target === container) {
            isDragging = true;
            container.style.cursor = 'move';
        }
    };

    const dragEnd = () => {
        initialX = currentX;
        initialY = currentY;
        isDragging = false;
        container.style.cursor = '';
    };

    const drag = (e) => {
        if (!isDragging) return;
        
        e.preventDefault();
        currentX = e.clientX - initialX;
        currentY = e.clientY - initialY;
        xOffset = currentX;
        yOffset = currentY;

        container.style.transform = `translate(${currentX}px, ${currentY}px)`;
    };

    container.removeEventListener('mousedown', dragStart);
    document.removeEventListener('mousemove', drag);
    document.removeEventListener('mouseup', dragEnd);

    container.addEventListener('mousedown', dragStart);
    document.addEventListener('mousemove', drag);
    document.addEventListener('mouseup', dragEnd);

    // Input group
    const inputGroup = document.createElement('div');
    inputGroup.className = 'input-group';
    
    const textbox = document.createElement('input');
    textbox.type = 'text';
    textbox.className = 'input-text';
    textbox.placeholder = 'Enter a valid CSS selector';
    if (selector && typeof selector === 'string') textbox.value = selector;
    
    const getMatchesButton = document.createElement('button');
    getMatchesButton.className = 'btn';
    getMatchesButton.textContent = '⟳';
    getMatchesButton.style.fontWeight = 'bold';
    getMatchesButton.title = 'Execute the CSS Selector (or just press enter)';

    let matchedElements = [];

    textbox.addEventListener('keyup', (e) => {
        if (e.key !== 'Enter') return;
        getMatchesButton.dispatchEvent(new Event('click', { 'bubbles': true }));
    });
    getMatchesButton.onclick = () => {
        matchedElements.forEach(match => { unhighlightElement(match); });
        matchedElements = [];
        Array.from(document.querySelectorAll('.item-list > li')).forEach(li => { li.remove(); });
        const selector = textbox.value;
        if (!selector) return;

        try {
            const matches = Array.from(document.querySelectorAll(selector));
            matches.forEach((match, index) => {
                addListItem(`Match ${index + 1}`, match,
                    () => { 
                        matchedElements.forEach(match => { unhighlightElement(match); });
                        highlightElement(match);
                        match.scrollIntoView();

                        setTimeout(() => {
                            unhighlightElement(match);
                        }, 4000);
                    });
                matchedElements.push(match);
            });

            const lis = Array.from(document.querySelectorAll('.item-list > li'));
            const selected = matches.filter(match => {
                const cb = lis.find(li => li.ref.isEqualNode(match)).querySelector('input[type="checkbox"]');
                cb.onchange = () => {
                    const dlbtn = document.querySelector('.download-btn');
                    const lis = Array.from(document.querySelectorAll('.item-list > li'));
                    const selected = matches.filter(match => {
                        const cb = lis.find(li => li.ref.isEqualNode(match)).querySelector('input[type="checkbox"]');
                        return cb.checked;
                    });
                    dlbtn.textContent = `Download ${selected.length} Item(s)`;
                };
                return cb.checked;
            });
            document.querySelector('.download-btn').textContent = `Download ${selected.length} Item(s)`;

        } catch (err) { }
    };

    // List
    const itemList = document.createElement('ul');
    itemList.className = 'item-list';

    // Checkbox group
    const checkboxGroup = document.createElement('div');
    checkboxGroup.className = 'checkbox-group';
    
    const options = [['Humanize', 'checked'], ['Inherit HTTP Only Cookies', 'checked'], ['Preserve Original Filename'], ['(WIP) Support Video Elements'], 'Placeholder Normal'];
    options.forEach(opt => {
        const label = document.createElement('label');
        label.className = 'checkbox-label';
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';

        label.style.display = 'block';
        label.appendChild(checkbox);

        if (typeof opt === 'object') {
            const text = opt[0];
            label.appendChild(document.createTextNode(`  ${text}`));
            
            opt.slice(1).forEach(o => {
                switch (o) {
                    case 'checked':
                        checkbox.checked = true;
                        break;
                    case 'disabled':
                        checkbox.disabled = true;
                        break;
                    default:
                        console.warn(`Unrecognized checkbox opt: '${o}'`);
                        break;
                }
            })
        } else {
            label.appendChild(document.createTextNode(`  ${opt}`));
        }
        checkboxGroup.appendChild(label);
    });

    // Download button
    const downloadBtn = document.createElement('button');
    downloadBtn.className = 'btn download-btn';
    downloadBtn.textContent = 'Download 0 Item(s)';

    downloadBtn.onclick = async () => {
        if (matchedElements.length === 0) return;

        const ResolveMediaElementUrl = (img) => {
            const lazyAttributes = [
                'data-src', 'data-pagespeed-lazy-src', 'srcset', 'src', 'zoomfile', 'file', 'original', 'load-src', '_src', 'imgsrc', 'real_src', 'src2', 'origin-src',
                'data-lazyload', 'data-lazyload-src', 'data-lazy-load-src',
                'data-ks-lazyload', 'data-ks-lazyload-custom', 'loading',
                'data-defer-src', 'data-actualsrc',
                'data-cover', 'data-original', 'data-thumb', 'data-imageurl', 'data-placeholder',
            ];
            const IsUrl = (url) => {
                // TODO: needs support for relative file paths also?
                const pattern = new RegExp(
                    '^(https?:\\/\\/)?'+ // protocol
                    '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
                    '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
                    '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
                    '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
                    '(\\#[-a-z\\d_]*)?$','i');
                const isUrl = !!pattern.test(url);
                if (!isUrl) {
                    try {
                        new URL(url);
                        return true;
                    } catch(err) {
                        return false;
                    }
                }
                return true;
            };

            let possibleImageUrls = lazyAttributes.filter(attr => {
                let attributeValue = img.getAttribute(attr);
                if (!attributeValue) return false;
                attributeValue = attributeValue.replaceAll('\t', '').replaceAll('\n','');
                let ok = IsUrl(attributeValue.trim());
                if (!ok && attr === 'srcset') { 
                    // srcset usually contains a comma delimited string that is formatted like
                    // <URL1>, <WIDTH>w, <URL2>, <WIDTH>w, <URL3>, <WIDTH>w,
                    // TODO: handle this case
                    const srcsetItems = attributeValue.split(',').map(attr => attr.trim()).map(item => item.split(' '));
                    if (srcsetItems.length > 0) {
                        img.setAttribute('srcset', srcsetItems[srcsetItems.length - 1][0]);
                        ok = IsUrl(img.getAttribute('srcset'));
                    }
                }
                return ok;
            }).map(validAttr => img.getAttribute(validAttr).trim());

            if (!possibleImageUrls || possibleImageUrls.length < 1) {
                if (img.hasAttribute('src')) return img.src.trim();
                console.error('Could not resolve the image source URL from the image object', img);
                return '';
            }
            return possibleImageUrls.length > 1 ? [...new Set(possibleImageUrls)][0] : possibleImageUrls[0];
        };

        const lis = Array.from(document.querySelectorAll('.item-list > li'));
        let urls = matchedElements.map(match => {
            const matchCb = lis.find(li => li.ref.isEqualNode(match)).querySelector('input[type="checkbox"]');
            if (!(matchCb?.checked ?? true)) {
                console.warn('Skipping match ', match, ' cause it was unchecked in the match list');
                return '';
            }

            const opts = Array.from(document.querySelector('.checkbox-group').querySelectorAll('input[type="checkbox"]'));
            const optSupportVideoElements = opts.find(_ => _.parentElement.textContent.includes("Support Video Elements"))?.checked ?? false;

            const supportedTypes = optSupportVideoElements ? [[HTMLImageElement,"IMG"],[HTMLVideoElement,"VIDEO"]] : [[HTMLImageElement,"IMG"]];
            let actualMatch = 
                supportedTypes.some(supportedType => { const typeName = supportedType[0]; return match instanceof typeName; }) 
                    ? match 
                    : supportedTypes.map(supportedType => { const nodeName = supportedType[1]; return match.querySelector(nodeName); }).filter(res => res)[0];
                
            if (!actualMatch) {
                console.warn('Failed to find supported element type for parent match element: ', match);
                return '';
            }

            const src = ResolveMediaElementUrl(actualMatch);
            return src;
        }).filter(url => {
            return url.length > 0;
        });

        // TODO: filter out duplicates?
        await Download(urls);
    };

    // Add elements to container
    inputGroup.appendChild(textbox);
    inputGroup.appendChild(getMatchesButton);
    container.appendChild(inputGroup);
    //container.appendChild(itemListHeader);
    container.appendChild(itemList);
    container.appendChild(checkboxGroup);
    container.appendChild(downloadBtn);

    // Add to document
    document.body.appendChild(container);

    // Function to add new item to list
    function addListItem(text, elemRef, itemClickCallback = null) {
        const li = document.createElement('li');
        li.style.cssText = 'cursor: pointer; padding: 0px; color: black !important;'
        if (itemClickCallback && typeof itemClickCallback === 'function') {
            li.ondblclick = itemClickCallback;
        }
        if (elemRef) {
            li.ref = elemRef;
        }

        li.title = 'Double click an entry to scroll to it and highlight it';
        
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = true;
        if (!elemRef && !itemClickCallback) checkbox.disabled = true;
        checkbox.style.marginRight = '10px';
        const textNode = document.createTextNode(text);
        li.appendChild(checkbox);
        li.appendChild(textNode);
        itemList.appendChild(li);
    }

    //addListItem('No matches', null, null);
};

const SleepRange = (min, max) => {
    const _min = Math.min(min, max);
    const _max = Math.max(min, max);
    const ms = Math.floor(Math.random() * (_max - _min + 1) + _min);
    if (ms <= 0) return;
    return new Promise(r => setTimeout(r, ms));
};

const GetBlob = (url, inheritHttpOnlyCookies = true) => {
    return new Promise(async (resolve, reject) => {
        // TODO: Handle blob urls?
        // const isBlobUrl = url.startsWith('blob:');
        // console.warn('Encountered a blob url but implementation is missing');
        // if (isBlobUrl) {
        //     try {
        //         const _res = await GM.xmlHttpRequest({method:'GET',url:url});
        //         debugger;
        //     } catch (err) { debugger; return reject(err); }
        // }

        const res = await GM.xmlHttpRequest({
            method: 'GET',
            url: url,
            headers: {
                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
                'Accept-Language': 'en-US,en;q=0.9',
                'Accept-Encoding': 'gzip, deflate, br, zstd',
                'DNT': `${window.navigator.doNotTrack || '1'}`,

                'Referer':  document.location.href || url,
                'Origin': document.location.origin || url,

                'Host': window.location.host || window.location.hostname,
                'User-Agent': window.navigator.userAgent,
                'Priority': 'u=0, i',
                'Upgrade-Insecure-Requests': '1',
                'Connection': 'keep-alive',
                //'Cache-Control': 'no-cache',
                'Cache-Control': 'max-age=0',

                'Sec-Fetch-Dest': 'document',
                'Sec-Fetch-Mode': 'navigate',
                'Sec-Fetch-User': '?1',
                'Sec-GPC': '1',
            },
            responseType: 'blob',
            cookiePartition: {
                topLevelSite: inheritHttpOnlyCookies ? location.origin : null
            }
        })
        .catch((error) => { debugger; return reject(error); });

        const allowedImageTypes = ['webp','png','jpg','jpeg','gif','bmp','webm'];
        const HTTP_OK_CODE = 200;
        const ok =
                res.readyState == res['DONE'] &&
                res.status === HTTP_OK_CODE &&
                //res.response && ['webp','image'].some(t => res.response.type.includes(t))
                res.response && (res.response.type.startsWith('image/') && allowedImageTypes.includes(res.response.type.split('/')[1].toLowerCase()));

        if (!ok) {
            debugger;
            return reject(error);
        }

        return resolve({
            blob: res.response,
            filetype: res.response.type.split('/')[1],
        });
    });
};
const SaveBlob = async (blob, fileName) => {
    const MakeAndClickATagAsync = async (blobUrl, fileName) => {
        try {
            let link;
            
            // Reuse existing element for sequential downloads
            if (!window._downloadLink) {
                window._downloadLink = document.createElement('a');
                window._downloadLink.style.cssText = 'display: none !important;';
                try {
                    document.body.appendChild(window._downloadLink);
                } catch (err) {
                    // Handle Trusted Types policy
                    if (window.trustedTypes && window.trustedTypes.createPolicy) {
                        const policy = window.trustedTypes.createPolicy('default', {
                            createHTML: (string) => string
                        });
                    }
                    document.body.appendChild(window._downloadLink);
                }
            }
            link = window._downloadLink;
    
            // Set attributes and trigger download
            link.href = blobUrl;
            link.download = fileName;
            await Promise.resolve(link.click());
    
            return true;
        } catch (error) {
            console.error('Download failed:', error);
            await Promise.reject([false, error]);
        }
    };

    const blobUrl = window.URL.createObjectURL(blob)

    await MakeAndClickATagAsync(blobUrl, fileName)
    .catch(([state, errorMessage]) => { window.URL.revokeObjectURL(blobUrl); console.error(errorMessage); debugger; return reject([false, errorMessage, res]); });
    window.URL.revokeObjectURL(blobUrl);
};

const cancelSignal = {cancelled:false};
async function Download(urls) {
    if (urls.length === 0) return;
    if (typeof urls === 'string') urls = [urls];
    cancelSignal.cancelled = false;

    const progressbar = document.createElement('div');
    progressbar.style.cssText = `position:fixed;z-index:9999;bottom:0px;right:0px;width:100%;max-height:30px;background-color:white;`;
    progressbar.innerHTML = `
        <span class="text" style="color:black;padding-right:5px;"></span>
        <button class="cancel">Stop</button
    `;
    document.body.appendChild(progressbar);

    const text = progressbar.querySelector('.text');
    const btn = progressbar.querySelector('.cancel');

    btn.onclick = () => { cancelSignal.cancelled = true; text.textContent = 'Aborting download, please wait ...'; };

    const opts = Array.from(document.querySelector('.checkbox-group').querySelectorAll('input[type="checkbox"]'));
    const optHttpOnlyCookies = opts.find(_ => _.parentElement.textContent.includes("Inherit HTTP Only Cookies"))?.checked ?? true;
    const optHumanize = opts.find(_ => _.parentElement.textContent.includes('Humanize'))?.checked ?? true;
    const optPreserveOriginalFilename = opts.find(_ => _.parentElement.textContent.includes('Preserve Original Filename'))?.checked ?? false;

    for (let i = 0; i < urls.length; i++) {
        if (cancelSignal.cancelled) break;

        const url = urls[i];
        text.textContent = `Downloading ${url} ... (${i+1}/${urls.length})`;

        try {
            const {blob, filetype} = await GetBlob(url, optHttpOnlyCookies);
            const filename = optPreserveOriginalFilename ? url.split('/').pop() : `${i}.${filetype}`;
            await SaveBlob(blob, filename);
        } catch (err) {
            console.error('Something went wrong downloading from url ', url);
            console.error(err);
        }

        if (optHumanize) await SleepRange(650, 850);
    }

    progressbar.remove();
}

const defaultSelector = GM_getValue(document.location.host, undefined);
if (typeof defaultSelector === 'undefined') {
    GM_registerMenuCommand('Show GUI', () => {
        RenderGui();
    });
    GM_registerMenuCommand(`Always show GUI for ${location.host}`, () => {
        GM_setValue(location.host, true);
        RenderGui();
    });
    // GM_registerMenuCommand(`Always show GUI for ${location.host} and save current selector`, () => {
    //     const selector = document.querySelector('.input-text')?.value ?? true;
    //     GM_setValue(selector);
    //     RenderGui();
    // });
}
else {
    RenderGui(typeof defaultSelector === 'string' ? defaultSelector : '');
    GM_registerMenuCommand(`Dont show GUI for ${location.host}`, () => {
        GM_deleteValue(location.host);
        // TODO: Remove the GUI
    });
}