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