Greasy Fork is available in English.

Ripper

Cleverly download all images on a webpage

  1. // ==UserScript==
  2. // @name Ripper
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.3
  5. // @description Cleverly download all images on a webpage
  6. // @author TetteDev
  7. // @match *://*/*
  8. // @icon https://icons.duckduckgo.com/ip2/tampermonkey.net.ico
  9. // @license MIT
  10. // @grant GM_cookie
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM.xmlHttpRequest
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_getValue
  15. // @grant GM_deleteValue
  16. // @grant GM_setValue
  17. // @run-at document-idle
  18. // @noframes
  19. // ==/UserScript==
  20.  
  21. const RenderGui = (selector = '') => {
  22. const highlightSelector = '4px dashed purple';
  23.  
  24. const highlightElement = (element) => {
  25. element.style.border = highlightSelector;
  26. };
  27. const unhighlightElement = (element) => {
  28. element.style.border = '';
  29. }
  30.  
  31. let container = null;
  32. const guiClassName = 'gui-container';
  33. if ((container = document.querySelector(`.${guiClassName}`))) {
  34. container.remove();
  35. }
  36. else {
  37. const style = document.createElement('style');
  38. style.textContent = `
  39. .gui-container {
  40. font-family: 'Segoe UI', Arial, sans-serif;
  41. max-width: 750px;
  42. margin: 20px auto;
  43. padding: 10px;
  44. background: white;
  45. border-radius: 8px;
  46. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  47.  
  48. position: fixed;
  49. z-index: 9999;
  50. width: auto;
  51. height: auto;
  52. top: 15px;
  53. right: 15px;
  54.  
  55. border: 1px solid black;
  56. }
  57. .input-group {
  58. display: flex;
  59. gap: 5px;
  60. margin-bottom: 10px;
  61. }
  62. .input-text {
  63. flex: 1;
  64. padding: 8px 12px;
  65. border: 1px solid #ddd;
  66. border-radius: 4px;
  67. font-size: 14px;
  68. color: black !important;
  69. }
  70. .btn {
  71. padding: 8px 16px;
  72. background:rgb(250, 0, 0);
  73. color: white;
  74. border: none;
  75. border-radius: 4px;
  76. cursor: pointer;
  77. font-size: 14px;
  78. }
  79. .item-list {
  80. list-style: none;
  81. padding: 0;
  82. margin: 0 0 20px 0;
  83. max-height: 450px;
  84. overflow-y: auto;
  85. overflow-x: hidden;
  86. }
  87. .item-list li {
  88. display: flex;
  89. align-items: center;
  90. padding: 3px;
  91. border-bottom: 1px solid #eee;
  92.  
  93. -webkit-user-select: none !important;
  94. -khtml-user-select: none !important;
  95. -moz-user-select: -moz-none !important;
  96. -o-user-select: none !important;
  97. user-select: none !important;
  98. }
  99. .item-list li:hover {
  100. background-color: yellow;
  101. }
  102. .checkbox-group {
  103. margin-bottom: 10px;
  104. }
  105. .checkbox-label {
  106. display: inline-flex;
  107. align-items: center;
  108. margin-right: 20px;
  109. cursor: pointer;
  110. color: black !important;
  111. }
  112. .download-btn {
  113. width: 100%;
  114. padding: 12px;
  115. background: rgb(250, 0, 0);
  116. color: white;
  117. font-weight: bold;
  118. }
  119. `;
  120. document.body.appendChild(style);
  121. }
  122.  
  123. // Create GUI elements
  124. container = document.createElement('div');
  125. container.className = guiClassName;
  126.  
  127. // Add dragging functionality
  128. let isDragging = false;
  129. let currentX;
  130. let currentY;
  131. let initialX;
  132. let initialY;
  133. let xOffset = 0;
  134. let yOffset = 0;
  135.  
  136. const dragStart = (e) => {
  137. if (e.target !== container) return; // Only drag from container itself
  138. initialX = e.clientX - xOffset;
  139. initialY = e.clientY - yOffset;
  140.  
  141. if (e.target === container) {
  142. isDragging = true;
  143. container.style.cursor = 'move';
  144. }
  145. };
  146.  
  147. const dragEnd = () => {
  148. initialX = currentX;
  149. initialY = currentY;
  150. isDragging = false;
  151. container.style.cursor = '';
  152. };
  153.  
  154. const drag = (e) => {
  155. if (!isDragging) return;
  156. e.preventDefault();
  157. currentX = e.clientX - initialX;
  158. currentY = e.clientY - initialY;
  159. xOffset = currentX;
  160. yOffset = currentY;
  161.  
  162. container.style.transform = `translate(${currentX}px, ${currentY}px)`;
  163. };
  164.  
  165. container.removeEventListener('mousedown', dragStart);
  166. document.removeEventListener('mousemove', drag);
  167. document.removeEventListener('mouseup', dragEnd);
  168.  
  169. container.addEventListener('mousedown', dragStart);
  170. document.addEventListener('mousemove', drag);
  171. document.addEventListener('mouseup', dragEnd);
  172.  
  173. // Input group
  174. const inputGroup = document.createElement('div');
  175. inputGroup.className = 'input-group';
  176. const textbox = document.createElement('input');
  177. textbox.type = 'text';
  178. textbox.className = 'input-text';
  179. textbox.placeholder = 'Enter a valid CSS selector';
  180. if (selector && typeof selector === 'string') textbox.value = selector;
  181. const getMatchesButton = document.createElement('button');
  182. getMatchesButton.className = 'btn';
  183. getMatchesButton.textContent = '⟳';
  184. getMatchesButton.style.fontWeight = 'bold';
  185. getMatchesButton.title = 'Execute the CSS Selector (or just press enter)';
  186.  
  187. let matchedElements = [];
  188.  
  189. textbox.addEventListener('keyup', (e) => {
  190. if (e.key !== 'Enter') return;
  191. getMatchesButton.dispatchEvent(new Event('click', { 'bubbles': true }));
  192. });
  193. getMatchesButton.onclick = () => {
  194. matchedElements.forEach(match => { unhighlightElement(match); });
  195. matchedElements = [];
  196. Array.from(document.querySelectorAll('.item-list > li')).forEach(li => { li.remove(); });
  197. const selector = textbox.value;
  198. if (!selector) return;
  199.  
  200. try {
  201. const matches = Array.from(document.querySelectorAll(selector));
  202. matches.forEach((match, index) => {
  203. addListItem(`Match ${index + 1}`, match,
  204. () => {
  205. matchedElements.forEach(match => { unhighlightElement(match); });
  206. highlightElement(match);
  207. match.scrollIntoView();
  208.  
  209. setTimeout(() => {
  210. unhighlightElement(match);
  211. }, 4000);
  212. });
  213. matchedElements.push(match);
  214. });
  215.  
  216. const lis = Array.from(document.querySelectorAll('.item-list > li'));
  217. const selected = matches.filter(match => {
  218. const cb = lis.find(li => li.ref.isEqualNode(match)).querySelector('input[type="checkbox"]');
  219. cb.onchange = () => {
  220. const dlbtn = document.querySelector('.download-btn');
  221. const lis = Array.from(document.querySelectorAll('.item-list > li'));
  222. const selected = matches.filter(match => {
  223. const cb = lis.find(li => li.ref.isEqualNode(match)).querySelector('input[type="checkbox"]');
  224. return cb.checked;
  225. });
  226. dlbtn.textContent = `Download ${selected.length} Item(s)`;
  227. };
  228. return cb.checked;
  229. });
  230. document.querySelector('.download-btn').textContent = `Download ${selected.length} Item(s)`;
  231.  
  232. } catch (err) { }
  233. };
  234.  
  235. // List
  236. const itemList = document.createElement('ul');
  237. itemList.className = 'item-list';
  238.  
  239. // Checkbox group
  240. const checkboxGroup = document.createElement('div');
  241. checkboxGroup.className = 'checkbox-group';
  242. const options = [['Humanize', 'checked'], ['Inherit HTTP Only Cookies', 'checked'], ['Preserve Original Filename'], ['(WIP) Support Video Elements'], 'Placeholder Normal'];
  243. options.forEach(opt => {
  244. const label = document.createElement('label');
  245. label.className = 'checkbox-label';
  246. const checkbox = document.createElement('input');
  247. checkbox.type = 'checkbox';
  248.  
  249. label.style.display = 'block';
  250. label.appendChild(checkbox);
  251.  
  252. if (typeof opt === 'object') {
  253. const text = opt[0];
  254. label.appendChild(document.createTextNode(` ${text}`));
  255. opt.slice(1).forEach(o => {
  256. switch (o) {
  257. case 'checked':
  258. checkbox.checked = true;
  259. break;
  260. case 'disabled':
  261. checkbox.disabled = true;
  262. break;
  263. default:
  264. console.warn(`Unrecognized checkbox opt: '${o}'`);
  265. break;
  266. }
  267. })
  268. } else {
  269. label.appendChild(document.createTextNode(` ${opt}`));
  270. }
  271. checkboxGroup.appendChild(label);
  272. });
  273.  
  274. // Download button
  275. const downloadBtn = document.createElement('button');
  276. downloadBtn.className = 'btn download-btn';
  277. downloadBtn.textContent = 'Download 0 Item(s)';
  278.  
  279. downloadBtn.onclick = async () => {
  280. if (matchedElements.length === 0) return;
  281.  
  282. const ResolveMediaElementUrl = (img) => {
  283. const lazyAttributes = [
  284. 'data-src', 'data-pagespeed-lazy-src', 'srcset', 'src', 'zoomfile', 'file', 'original', 'load-src', '_src', 'imgsrc', 'real_src', 'src2', 'origin-src',
  285. 'data-lazyload', 'data-lazyload-src', 'data-lazy-load-src',
  286. 'data-ks-lazyload', 'data-ks-lazyload-custom', 'loading',
  287. 'data-defer-src', 'data-actualsrc',
  288. 'data-cover', 'data-original', 'data-thumb', 'data-imageurl', 'data-placeholder',
  289. ];
  290. const IsUrl = (url) => {
  291. // TODO: needs support for relative file paths also?
  292. const pattern = new RegExp(
  293. '^(https?:\\/\\/)?'+ // protocol
  294. '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
  295. '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
  296. '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
  297. '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
  298. '(\\#[-a-z\\d_]*)?$','i');
  299. const isUrl = !!pattern.test(url);
  300. if (!isUrl) {
  301. try {
  302. new URL(url);
  303. return true;
  304. } catch(err) {
  305. return false;
  306. }
  307. }
  308. return true;
  309. };
  310.  
  311. let possibleImageUrls = lazyAttributes.filter(attr => {
  312. let attributeValue = img.getAttribute(attr);
  313. if (!attributeValue) return false;
  314. attributeValue = attributeValue.replaceAll('\t', '').replaceAll('\n','');
  315. let ok = IsUrl(attributeValue.trim());
  316. if (!ok && attr === 'srcset') {
  317. // srcset usually contains a comma delimited string that is formatted like
  318. // <URL1>, <WIDTH>w, <URL2>, <WIDTH>w, <URL3>, <WIDTH>w,
  319. // TODO: handle this case
  320. const srcsetItems = attributeValue.split(',').map(attr => attr.trim()).map(item => item.split(' '));
  321. if (srcsetItems.length > 0) {
  322. img.setAttribute('srcset', srcsetItems[srcsetItems.length - 1][0]);
  323. ok = IsUrl(img.getAttribute('srcset'));
  324. }
  325. }
  326. return ok;
  327. }).map(validAttr => img.getAttribute(validAttr).trim());
  328.  
  329. if (!possibleImageUrls || possibleImageUrls.length < 1) {
  330. if (img.hasAttribute('src')) return img.src.trim();
  331. console.error('Could not resolve the image source URL from the image object', img);
  332. return '';
  333. }
  334. return possibleImageUrls.length > 1 ? [...new Set(possibleImageUrls)][0] : possibleImageUrls[0];
  335. };
  336.  
  337. const lis = Array.from(document.querySelectorAll('.item-list > li'));
  338. let urls = matchedElements.map(match => {
  339. const matchCb = lis.find(li => li.ref.isEqualNode(match)).querySelector('input[type="checkbox"]');
  340. if (!(matchCb?.checked ?? true)) {
  341. console.warn('Skipping match ', match, ' cause it was unchecked in the match list');
  342. return '';
  343. }
  344.  
  345. const opts = Array.from(document.querySelector('.checkbox-group').querySelectorAll('input[type="checkbox"]'));
  346. const optSupportVideoElements = opts.find(_ => _.parentElement.textContent.includes("Support Video Elements"))?.checked ?? false;
  347.  
  348. const supportedTypes = optSupportVideoElements ? [[HTMLImageElement,"IMG"],[HTMLVideoElement,"VIDEO"]] : [[HTMLImageElement,"IMG"]];
  349. let actualMatch =
  350. supportedTypes.some(supportedType => { const typeName = supportedType[0]; return match instanceof typeName; })
  351. ? match
  352. : supportedTypes.map(supportedType => { const nodeName = supportedType[1]; return match.querySelector(nodeName); }).filter(res => res)[0];
  353. if (!actualMatch) {
  354. console.warn('Failed to find supported element type for parent match element: ', match);
  355. return '';
  356. }
  357.  
  358. const src = ResolveMediaElementUrl(actualMatch);
  359. return src;
  360. }).filter(url => {
  361. return url.length > 0;
  362. });
  363.  
  364. // TODO: filter out duplicates?
  365. await Download(urls);
  366. };
  367.  
  368. // Add elements to container
  369. inputGroup.appendChild(textbox);
  370. inputGroup.appendChild(getMatchesButton);
  371. container.appendChild(inputGroup);
  372. //container.appendChild(itemListHeader);
  373. container.appendChild(itemList);
  374. container.appendChild(checkboxGroup);
  375. container.appendChild(downloadBtn);
  376.  
  377. // Add to document
  378. document.body.appendChild(container);
  379.  
  380. // Function to add new item to list
  381. function addListItem(text, elemRef, itemClickCallback = null) {
  382. const li = document.createElement('li');
  383. li.style.cssText = 'cursor: pointer; padding: 0px; color: black !important;'
  384. if (itemClickCallback && typeof itemClickCallback === 'function') {
  385. li.ondblclick = itemClickCallback;
  386. }
  387. if (elemRef) {
  388. li.ref = elemRef;
  389. }
  390.  
  391. li.title = 'Double click an entry to scroll to it and highlight it';
  392. const checkbox = document.createElement('input');
  393. checkbox.type = 'checkbox';
  394. checkbox.checked = true;
  395. if (!elemRef && !itemClickCallback) checkbox.disabled = true;
  396. checkbox.style.marginRight = '10px';
  397. const textNode = document.createTextNode(text);
  398. li.appendChild(checkbox);
  399. li.appendChild(textNode);
  400. itemList.appendChild(li);
  401. }
  402.  
  403. //addListItem('No matches', null, null);
  404. };
  405.  
  406. const SleepRange = (min, max) => {
  407. const _min = Math.min(min, max);
  408. const _max = Math.max(min, max);
  409. const ms = Math.floor(Math.random() * (_max - _min + 1) + _min);
  410. if (ms <= 0) return;
  411. return new Promise(r => setTimeout(r, ms));
  412. };
  413.  
  414. const GetBlob = (url, inheritHttpOnlyCookies = true) => {
  415. return new Promise(async (resolve, reject) => {
  416. // TODO: Handle blob urls?
  417. // const isBlobUrl = url.startsWith('blob:');
  418. // console.warn('Encountered a blob url but implementation is missing');
  419. // if (isBlobUrl) {
  420. // try {
  421. // const _res = await GM.xmlHttpRequest({method:'GET',url:url});
  422. // debugger;
  423. // } catch (err) { debugger; return reject(err); }
  424. // }
  425.  
  426. const res = await GM.xmlHttpRequest({
  427. method: 'GET',
  428. url: url,
  429. headers: {
  430. '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',
  431. 'Accept-Language': 'en-US,en;q=0.9',
  432. 'Accept-Encoding': 'gzip, deflate, br, zstd',
  433. 'DNT': `${window.navigator.doNotTrack || '1'}`,
  434.  
  435. 'Referer': document.location.href || url,
  436. 'Origin': document.location.origin || url,
  437.  
  438. 'Host': window.location.host || window.location.hostname,
  439. 'User-Agent': window.navigator.userAgent,
  440. 'Priority': 'u=0, i',
  441. 'Upgrade-Insecure-Requests': '1',
  442. 'Connection': 'keep-alive',
  443. //'Cache-Control': 'no-cache',
  444. 'Cache-Control': 'max-age=0',
  445.  
  446. 'Sec-Fetch-Dest': 'document',
  447. 'Sec-Fetch-Mode': 'navigate',
  448. 'Sec-Fetch-User': '?1',
  449. 'Sec-GPC': '1',
  450. },
  451. responseType: 'blob',
  452. cookiePartition: {
  453. topLevelSite: inheritHttpOnlyCookies ? location.origin : null
  454. }
  455. })
  456. .catch((error) => { debugger; return reject(error); });
  457.  
  458. const allowedImageTypes = ['webp','png','jpg','jpeg','gif','bmp','webm'];
  459. const HTTP_OK_CODE = 200;
  460. const ok =
  461. res.readyState == res['DONE'] &&
  462. res.status === HTTP_OK_CODE &&
  463. //res.response && ['webp','image'].some(t => res.response.type.includes(t))
  464. res.response && (res.response.type.startsWith('image/') && allowedImageTypes.includes(res.response.type.split('/')[1].toLowerCase()));
  465.  
  466. if (!ok) {
  467. debugger;
  468. return reject(error);
  469. }
  470.  
  471. return resolve({
  472. blob: res.response,
  473. filetype: res.response.type.split('/')[1],
  474. });
  475. });
  476. };
  477. const SaveBlob = async (blob, fileName) => {
  478. const MakeAndClickATagAsync = async (blobUrl, fileName) => {
  479. try {
  480. let link;
  481. // Reuse existing element for sequential downloads
  482. if (!window._downloadLink) {
  483. window._downloadLink = document.createElement('a');
  484. window._downloadLink.style.cssText = 'display: none !important;';
  485. try {
  486. document.body.appendChild(window._downloadLink);
  487. } catch (err) {
  488. // Handle Trusted Types policy
  489. if (window.trustedTypes && window.trustedTypes.createPolicy) {
  490. const policy = window.trustedTypes.createPolicy('default', {
  491. createHTML: (string) => string
  492. });
  493. }
  494. document.body.appendChild(window._downloadLink);
  495. }
  496. }
  497. link = window._downloadLink;
  498. // Set attributes and trigger download
  499. link.href = blobUrl;
  500. link.download = fileName;
  501. await Promise.resolve(link.click());
  502. return true;
  503. } catch (error) {
  504. console.error('Download failed:', error);
  505. await Promise.reject([false, error]);
  506. }
  507. };
  508.  
  509. const blobUrl = window.URL.createObjectURL(blob)
  510.  
  511. await MakeAndClickATagAsync(blobUrl, fileName)
  512. .catch(([state, errorMessage]) => { window.URL.revokeObjectURL(blobUrl); console.error(errorMessage); debugger; return reject([false, errorMessage, res]); });
  513. window.URL.revokeObjectURL(blobUrl);
  514. };
  515.  
  516. const cancelSignal = {cancelled:false};
  517. async function Download(urls) {
  518. if (urls.length === 0) return;
  519. if (typeof urls === 'string') urls = [urls];
  520. cancelSignal.cancelled = false;
  521.  
  522. const progressbar = document.createElement('div');
  523. progressbar.style.cssText = `position:fixed;z-index:9999;bottom:0px;right:0px;width:100%;max-height:30px;background-color:white;`;
  524. progressbar.innerHTML = `
  525. <span class="text" style="color:black;padding-right:5px;"></span>
  526. <button class="cancel">Stop</button
  527. `;
  528. document.body.appendChild(progressbar);
  529.  
  530. const text = progressbar.querySelector('.text');
  531. const btn = progressbar.querySelector('.cancel');
  532.  
  533. btn.onclick = () => { cancelSignal.cancelled = true; text.textContent = 'Aborting download, please wait ...'; };
  534.  
  535. const opts = Array.from(document.querySelector('.checkbox-group').querySelectorAll('input[type="checkbox"]'));
  536. const optHttpOnlyCookies = opts.find(_ => _.parentElement.textContent.includes("Inherit HTTP Only Cookies"))?.checked ?? true;
  537. const optHumanize = opts.find(_ => _.parentElement.textContent.includes('Humanize'))?.checked ?? true;
  538. const optPreserveOriginalFilename = opts.find(_ => _.parentElement.textContent.includes('Preserve Original Filename'))?.checked ?? false;
  539.  
  540. for (let i = 0; i < urls.length; i++) {
  541. if (cancelSignal.cancelled) break;
  542.  
  543. const url = urls[i];
  544. text.textContent = `Downloading ${url} ... (${i+1}/${urls.length})`;
  545.  
  546. try {
  547. const {blob, filetype} = await GetBlob(url, optHttpOnlyCookies);
  548. const filename = optPreserveOriginalFilename ? url.split('/').pop() : `${i}.${filetype}`;
  549. await SaveBlob(blob, filename);
  550. } catch (err) {
  551. console.error('Something went wrong downloading from url ', url);
  552. console.error(err);
  553. }
  554.  
  555. if (optHumanize) await SleepRange(650, 850);
  556. }
  557.  
  558. progressbar.remove();
  559. }
  560.  
  561. const defaultSelector = GM_getValue(document.location.host, undefined);
  562. if (typeof defaultSelector === 'undefined') {
  563. GM_registerMenuCommand('Show GUI', () => {
  564. RenderGui();
  565. });
  566. GM_registerMenuCommand(`Always show GUI for ${location.host}`, () => {
  567. GM_setValue(location.host, true);
  568. RenderGui();
  569. });
  570. // GM_registerMenuCommand(`Always show GUI for ${location.host} and save current selector`, () => {
  571. // const selector = document.querySelector('.input-text')?.value ?? true;
  572. // GM_setValue(selector);
  573. // RenderGui();
  574. // });
  575. }
  576. else {
  577. RenderGui(typeof defaultSelector === 'string' ? defaultSelector : '');
  578. GM_registerMenuCommand(`Dont show GUI for ${location.host}`, () => {
  579. GM_deleteValue(location.host);
  580. // TODO: Remove the GUI
  581. });
  582. }