- // ==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
- });
- }