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