您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Turn a page of thumbnails into high-res images
当前为
// ==UserScript== // @name Eza's Gallery Swallower // @namespace https://inkbunny.net/ezalias // @description Turn a page of thumbnails into high-res images // @license MIT // @license Public domain / no rights reserved // @include https://www.pixiv.net/* // @include https://gelbooru.com/index.php?page* // @include https://safebooru.org/index.php?page* // @include https://e621.net/posts* // @include https://e926.net/posts* // @include https://e621.net/pools/* // @include https://e926.net/pools/* // @include https://*.booru.org/*s=list* // @include /^https?\:\/\/www\.hentai-foundry\.com\// // @include https://baraag.net/@* // @include https://botsin.space/@* // @include https://equestria.social/@* // @include https://mastodon.art/@* // @include https://mastodon.social/@* // @include https://pawoo.net/@* // @include /^https?\:\/\/rule34\.paheal\.net\// // @include https://derpibooru.org/* // @include https://inkbunny.net/* // @exclude https://inkbunny.net/s/* // @include https://www.furaffinity.net/* // @include https://danbooru.donmai.us/* // @include https://rule34.xxx/* // @include https://www.jabarchives.com/* // @include https://jabarchives.com/* // @include https://aryion.com/g4/view/* // @include https://aryion.com/g4/gallery/* // @include https://e-hentai.org/g/* // @include https://lolibooru.moe/* // @include https://yande.re/* // @include https://thehentaiworld.com/* // @include https://r34hub.com/* // @include https://rule34hentai.net/* // @include https://*.newgrounds.com/* // @include https://booru.allthefallen.moe/* // @exclude *#dnr // @noframes // @version 2.23.4 // @grant GM_registerMenuCommand // @grant GM.setValue // @grant GM.getValue // ==/UserScript== // Create a vertical view for high-res versions of all the image links you can see. // This includes all pages from multi-image submissions, where those exist. // Individual images (or whole submissions) can be removed with one click. // Each image is also a link to that image. // Table of contents: // Preamble // CSS // HTML // Helper functions // Polyfills // Main execution // Per-site image-gathering functions // The Button // Persistent options handling // Show images // Per-submission setup // Keyboard controls // Video play / pause on scroll // Ongoing image-display function // Spinners // Other Mastodon instances are many in number and few in users. An @include block would get ridiculous. // I am alarmingly close to forking this for Mastodon, doing @include *, and testing for e.g. h-cite / h-entry. // I don't want to @include * in general - this is not a general-purpose script. // Even a fetch-based version would only be as flexible as Image Glutton. // ... I've accidentally made this @include * friendly, by exclusively using button_delay_function. // If it defaulted to false I could use this anywhere, and only specific switch-case / match domains would get better values. // Or I could skip the interval entirely if button_delay_function remains at a default null. // (Probably better to do so if gather_items remains at a default null.) // HicceArs? // Youhate.us? Down. // DeviantArt? Tried it once, didn't really work. Probably needs a fetch. Even that's not consistent. It is a terrible website. // Tumblr? Not a -great- alternative to Eza's Tumblr Scrape, but pretty close to what that script originally wanted. // https://myhentaigallery.com/gallery/show/7994/1#dnr? Already covered by Eza's Comic Viewer... which I have not published. // Arguably the Gallery Swallower version sould be viewing all of an artist's comics at once. // It is annoyingly easy to get caught clicking remove-and-advance instead of just 'advance.' // The button snaps into place under your cursor if you're submission-aligned and hit 'next image.' // It's a problem mostly because the apparent result is the same - we scroll clean past the removed image. // Keyboard controls have made this a non-issue for me. Dunno how others use the script. // The non-technical solution would be to add space between -> and X->. // Can I trigger enforce_style on updates? this.parentElement, presumably, but still. // Testing shows whole-page enforce_style for 20-ish images takes 60-odd milliseconds. That is -rough.- // And yet: it doesn't affect smooth scrolling. So I dunno. // Having just posted the update with options, I realize it's a very mouse-driven menu. But tough shit. // If we can detect a selection, maybe only "swallow" the links you have selected. // Some querySelector hack for "containing" would be nice. Break the querySelector( img ).closest( a ) pattern. I do already extend queries to have previous / next selectors. However - nice as this would be - it would muck up readability. Both for me and for others. It takes new syntax. // Could have typical_item assume an <img> element really means .closest( a ). (Only r34hub does that? Huh.) // Some videos on TheHentaiWorld.com don't work. // https://thehentaiworld.com/videos/panam-palmer-timpossible-cyberpunk-2077-2/ // https://thehentaiworld.com/videos/alcina-dimitrescu-greatm8-resident-evil-village/ // Is it because the format is .mp4? // Tempted to golf away constant pattern of Array.from( document.querySelectorAll( ) ). DRY as "query()" or something. // To do: // Videos. // I need a link somewhere, and the sorta tablet-y design I've chosen says it shouldn't be a thin bit of text. // Added sensible links for DownThemAll, but they're not available for a human to click on. Later. // Possible alternative: on video.canplay, remove the video, and link to the source. // Maybe a "load videos" button? Like a partial reload. Mark each image_container as try_video. (Each empty submission as well?) // Ogg in video_formats? // Try that iframe-based cross-domain localStorage gimmick. // Newgrounds favorites? // Genuine bugs: // Dead images and possibly thumbnails take up horizontal space and cause slight movements while loading. // Pretty sure videos aren't being counted as "loading." (I accidentally got the count right because that checks for containers.) // Unicode arrows are fucky in Gelbooru on Chromium. // Gelbooru: non-video submissions can have nonexistent video URL resolve. Uh... huh. // https://gelbooru.com/index.php?page=post&s=view&id=498992&tags=shiwashiwa_no_kinchakubukuru#dnr#&dnr // https://img3.gelbooru.com//images/b0/c7/b0c7fc167cda70cc61d5710dc23045a2.gif // https://img3.gelbooru.com/images/b0/c7/b0c7fc167cda70cc61d5710dc23045a2.webm // The .webm file is there. It loads as a 0-second video. // Argh, and reloading the submission shows both the image and the video, until the video plays. // Gelbooru's ads show up sometimes. // Did I break remove-while-loading /again?/ I need to test that shit with infinite fake images instead of real submissions. // Seems fine? It is weirdly difficult to test for. Inconsistent behavior. // Changes since last version: // Added Booru.AllTheFallen.moe. The hardest part was deciding how to capitalize that name. // ------------------------------------ Custom replacement HTML ------------------------------------ // // Replacement page. Not used immediately; it just makes more sense up here. var html = ''; var style_rules = new Object; // CSS "selector": "style" map. Blame Baraag. // ----- // CSS // CSS rules as an associative array, so they can be applied per-element on uncooperative sites. // CENTER ALL THE THINGS. style_rules[ '*' ] = 'vertical-align: middle !important;'; // Image style(s): style_rules[ 'img, video' ] = 'max-width: initial; max-height: initial;' // For enforce_style. (Also counts as "full".) style_rules[ '.short img, .short video' ] = 'max-width: 90vw; max-height: 60vh;'; // Class is for <body>. style_rules[ '.fit_width img, .fit_width video' ] = 'max-width: 90vw;'; // Leaving space for big X. style_rules[ '.fit_height img, .fit_height video' ] = 'max-height: 95vh;'; style_rules[ '.fit_window img, .fit_window video' ] = 'max-width: 90vw; max-height: 95vh;'; // Dead spinners and other invisible elements: style_rules[ '.invisible' ] = 'display: none;'; // s0 for spinners: className is s+querySelectorAll.length. style_rules[ '.button_spacer' ] = 'visibility: hidden; width: 30px !important; height: 30px;'; style_rules[ '.spinner_spacer' ] = 'width: 70px; height: 60px; visibility: hidden'; style_rules[ '#image_counter_id' ] = 'left: 70px; top: 5px; font-size: 33px;'; // Remove / reload controls: style_rules[ '.remover' ] = 'color:#dd2e44 !important; background-color:#992a2a; border:1px solid #ab1919;'; style_rules[ '.remover.floating' ] = 'position: absolute; top: 50%; transform: translateY(-50%); width: 120px; height: 120px; font-size:72px !important;'; style_rules[ '.reloader' ] = 'color:#194d19 !important; background-color:#2abd2a; border:1px solid #19ab19;'; style_rules[ '.undo' ] = 'position: fixed; right: 0px; bottom: 0px;'; // Previous / next buttons, per-submission: style_rules[ '.nav_button' ] = 'color:#123 !important; background-color:#14f; border:1px solid #234;'; style_rules[ '.nav_float' ] = 'z-index: 11; position: absolute; left: 0px; top: initial;'; style_rules[ '.nav_next_image' ] = 'top: 72px;'; style_rules[ '.remover.nav_button' ] = 'top: 144px; font-size: 20px !important; color: #a12 !important; background-color:#dd2e44;'; // Remove-and-advance. // Keyboard control anchors / runout: style_rules[ '.image_container.submission' ] = 'position: absolute; height: 300px; visibility: hidden;'; // display:none breaks scrollIntoView. // Button DRY: style_rules[ '.eza_button' ] = 'border-radius: 50%; width: 60px; height: 60px; text-align: center; display: inline-block; cursor:pointer; line-height: 20px; font-size:33px !important; padding: 10px 10px; text-decoration: none; font-family: sans-serif !important; opacity: 1;'; style_rules[ '.eza_button:not(:hover)' ] = 'background-color:#d7dbd8;' // Inverts and elides repeated .nav:hover / .reloader:hover background-color rules. style_rules[ '.low_opacity .eza_button:not(:hover)' ] = 'opacity: 0.3;'; // enforce_style kludges: style_rules[ '.floating.remover' ] = 'width: 120px; height: 120px; font-size:72px !important;'; style_rules[ '.nav_button.remover' ] = 'font-size: 20px !important;'; // Image-size controls: style_rules[ '#controls_id' ] = 'float: right; font-size: 33px;'; style_rules[ '.eza_button.control_button' ] = 'color:#FFF; background-color:#363;'; // Overdefined to supercede :not(:hover). style_rules[ '.control_button:hover' ] = 'background-color:#282;'; style_rules[ '.short #short, .full #full, .fit_width #fit_width, .fit_height #fit_height, .fit_window #fit_window' ] = 'color:#FFF; background-color:#191;'; // Options stuff: style_rules[ '#options_button' ] = 'font-size: 25px !important; padding: 0px 0px; opacity: 1; width: 30px; height: 30px; left: 17px; top: 29px; z-index: 13; position: absolute;' style_rules[ '#options_dialog' ] = 'color: #ccc !important; position: absolute; left: 32px; top: 48px; z-index: 12; background-color: #161; padding: 10px 10px; line-height: 2;' style_rules[ '#options_dialog input[type="checkbox"]' ] = 'float: right; width: 100px; transform: translateY( 50% ); margin: unset !important;' style_rules[ '#options_dialog select' ] = 'float: right; height: auto; transform: translateY( 20% ); padding: 0;' // Translate is a complete kludge, because CSS is hot garbage. // Dark mode button: style_rules[ '#backdrop_id' ] = 'background: rgba(0,0,0,0); padding-bottom: 90vh'; // For enforce_style. style_rules[ '#backdrop_id.dark' ] = 'background: #181818'; style_rules[ '.dark #dark_mode_button' ] = 'color: #000 !important;' // Previous / next links, at the bottom: style_rules[ '#links_id' ] = 'font-size: 33px; !important'; // Thumbnail container and fixed-size thumbnails: style_rules[ '#thumbnails_id span' ] = 'width:100px; height:100px; display: inline-block; overflow: hidden;'; style_rules[ '#thumbnails_id img' ] = 'width:100%;'; style_rules[ 'body' ] = 'line-height: 1.425 !important'; // Lower values let thumbnails overlap image controls. // Spinners to indicate loading submissions / loading images: style_rules[ '#submissions_spinner_id' ] = 'position: absolute; left: 1px; top: 13px; z-index: 10; border: 8px solid #3498db; border-top: 8px solid #111111; border-bottom: 8px solid #111111; border-radius: 50%; width: 46px; height: 46px; box-sizing: unset !important; transition: transform 10000000s linear' // The blue one. style_rules [ '#images_spinner_id' ] = 'position: absolute; left: 8px; top: 20px; z-index: 9; border: 24px solid #db9834; border-top: 24px solid #aaaaaa; border-bottom: 24px solid #AAAAAA; border-radius: 50%; width: 0px; height: 0px; box-sizing: unset !important; transition: transform 30000000s linear' // The orange one. style_rules[ '.spinning a' ] = 'transform: rotate(3600000000deg);'; // Push all of that into a <style> block: html += '<style> '; for( let selector in style_rules ) { html += selector + ' { ' + style_rules[ selector ] + ' } '; } html += '#style_check_id{ display: none; } '; // If this works, don't enforce_style. html += '</style>'; // ----- // Page elements // Put everything in one DIV so I can change the background color. Yes, this is completely necessary. html += '<div id="backdrop_id">'; // Floaty stuff: html += '<br><span id="spinners_id">' // So enforce_style() can apply to spinners. + '<button class="spinner_spacer"></button>' // Spacing. <button> because <div> and <span> are dumb as hell about width/height. + '<a id="submissions_spinner_id"></a>' // Spinner for submissions, 'new images being found.' Blue. + '<a id="images_spinner_id"></a>' // Spinner for images, 'images loading in high-res.' Orange. + '<span id="image_counter_id"></span>' + '<span id="controls_id" class=""></span>' // Image-size controls. + '</span>' // Structure: html += '<br><br><span id="thumbnails_id"></span><br><br>' + '<center>' + '<span id="dark_mode_id"></span><br>' + '<br><span id="centered_id"></span>' // Where most stuff goes. + '<br><br><br>' // Spacing for prev / next links. + '<span id="links_id"></span>' // Previous Page / Next Page. + '<div style="position: absolute;" id="style_check_id"></div>' // Invisible probe for enforce_style. html += '</center></div>'; // Additional HTML may be added by per-site functions. Once injected, we use the DOM. // ------------------------------------ General setup & Per-site functions ------------------------------------ // // ----- // Global variables var trigger_size = [ 15, 70, 25, 10 ]; // Left, top, font-size, padding. Pixiv defaults. var items; // Scraped contents of page. var formats = [ '.png', '.jpg', '.gif', '.jpeg' ]; // File extensions for guessing URLs. var video_formats = [ '.webm', '.mp4' ]; var next_page, previous_page; // URLS to the actual previous / next page (where applicable). var page_number; // Surprise, this is global again. var undo_list = new Array; // For holding and restoring elements. (See undoable_replace.) First in, last out. var force_top_to_bottom; // Some domains / pages are already chronological. var image_size_symbols = { short: "▣", fit_width: "↔", fit_height: "↕", fit_window: "✢", full: "■" }; // Global for the sake of the options menu. var gather_items; // Per-site function to scrape current page contents. var button_delay_function = ( () => gather_items()[0] ); // Don't show Swallow Gallery button while this returns false. (Alternative to @includes.) var image_from_dom; // Per-site function to scrape fetched HTML (if relevant). // Old config options that are too technical to bother exposing: var new_image_rate = 100; // Milliseconds between loading images. E.g. 10 for instant, 200 for gradual. var fetch_rate = 250; // Slower rate for fetching than for loading images, to avoid rate-limiting. // ----- // Helper functions // Things specific to this script and its global variables: // Replace .jpg, .png, etc., because full-size image formats don't always match their thumbnail format. function scrub_extensions( url ) { formats.forEach( ext => { url = ( '' + url ).replace( ext, '%format' ); } ) return url; } // Fake CSS with inline style, if in-page CSS is prevented by CSP. I hate the modern web. function enforce_style( parent ) { if( options.translucent_buttons ) { document.body.classList.add( 'low_opacity' ) } else { document.body.classList.remove( 'low_opacity' ) } // Kludge. if( document.getElementById( 'style_check_id' ).clientWidth == 0 ) { return; } // Inline <style> block, not in style_rules. for( let selector in style_rules ) { // Global Array.from( ( parent || document ).querySelectorAll( selector ) ) // Whole document by default. .forEach( element => { element.style = element.style.cssText + style_rules[ selector ]; } ) } } // Fetch HTML, interpret as DOM, pass DOM to standard get-image(s) function, flag image(s) as ready to display. function standard_fetch( item_object, span ) { fetch( item_object.page ) .then( response => { // On fetch error, retry: set "ready" in 10-20 seconds. if( ! response.ok ) { setTimeout( () => span.classList.add( "ready" ), 1000 * ( 10 + Math.random() * 10 ) ); } return response.text(); } ) .then( text => { let doc = document.createElement( 'html' ); doc.innerHTML = text; item_object.image = image_from_dom( doc, item_object ); span.classList.add( "ready" ); } ) } // Grab element, swap it out, and keep both, so they can be swapped back. // Format is an array of objects, with properties .original and .replacement, both Elements. function undoable_replace( current, replacement ) { if( ! current.classList.contains( 'basket' ) ) // When removing images, mark submission as modified. { current.closest( '.submission' ).querySelector( '.basket' ).classList.remove( 'unmodified' ); } current.replaceWith( replacement ); undo_list.push( new Object( { original: current, replacement: replacement } ) ); return undo_list[ undo_list.length - 1 ]; } // DRY for submissions. // "page" is some <a>, "title" is in its URL, "thumb" is the <img> inside the <a>, and "image" is some scrub(thumb).replace() job. function typical_item( page, thumb_to_image ) { if( typeof( thumb_to_image ) != 'function' ) { thumb_to_image = null; } // Allow .map callback. let title = [ 'id=', 'post/', 'posts/', 'images/', 'view/', 'show/', '/s/' ] // Safely extract number after one of these indicators. .map( v => ( ( '' + page ).match( v + '([0-9]+)' ) || [''] ).pop() ).join( '' ) || "Link"; // ... or give up and be generic. let thumb = ( page.querySelector( 'img' ) || { src: '' } ).src; // img.src, but safely. return new Object( { page: page, title: title, thumb: thumb, image: thumb_to_image && thumb_to_image( scrub_extensions( thumb ) ) // No item.image for fetched sites. } ) } // DRY for page links via querySelector. function previous_and_next( previous, next ) { [ previous_page, next_page ] = [ previous, next ].map( q => document.querySelector( q ) ) } // Okay fine I'll DRY the ?p=n links. (Just e-Hentai and Pixiv at the moment.) function links_from_page_number( indicator, ordinal ) { page_number = ( parse_search( window.location.search )[ indicator ] || ordinal ) | 0; // Parse search again - parseInt or default. let basis = window.location.origin + window.location.pathname + "?" + indicator + "=" if( page_number > ordinal ) { previous_page = basis + (page_number - 1); } next_page = basis + (page_number + 1); } // Make snake_case variables into pretty labels. function variable_to_name( variable ) { return variable[0].toUpperCase() + variable.substr( 1 ).replace( /_/g, ' ' ) } // Things that really ought to be trivial and standard, but are a pain in the ass: // Turn window.location.search into a sensible associative array. function parse_search( search_string ) { let strings = search_string.split( /[\?\&]/ ) .filter( v => v ) // Remove empty strings let associative = new Object; strings.forEach( v => associative[ v.split('=')[0] ] = v.split('=')[1] ); return associative; } // Sensible "are we on this site or not?!" function. function domain( ending ) { let want = ending.split( '.' ).reverse(); // Reverse order, from TLD to domain to subdomain(s). let have = document.domain.split( '.' ).reverse(); for( let n = 0; n < want.length; n++ ) { if( want[n] != have[n] ) { return false; } } // Implicit else: return true; } // createElement / set attributes / appendChild pattern. HTMLElement.prototype.addElement = function( tag, attribute_object ) { let element = document.createElement( tag ); for( let attribute in attribute_object ) { element[ attribute ] = attribute_object[ attribute ]; } this.append( element ); return element; } // Return the next / previous instance of a selector, relative to this element. HTMLElement.prototype.nextQuery = function ( selector, direction ) { let reference = this.closest( selector ); // Ancestor or self, whatever. let list = Array.from( document.querySelectorAll( selector ) ); let index = list.findIndex( e => e == reference ); return list[ index + ( direction || 1 ) ]; // Default direction is forward. } HTMLElement.prototype.previousQuery = function ( selector ) { return this.nextQuery( selector, -1 ); } // "A link containing this text" should not be so goddamn complicated. Element.prototype.querySelectorIncludes = function ( query, text ) { return Array.from( this.querySelectorAll( query) ) .find( v => v.innerText.includes( text ) ); } // ----- // Per-site setup and gather functions var args = parse_search( window.location.search ); // If it happens anywhere it might as well happen here. // Typical site format is as follows. // If we're on this domain: // Position the "Swallow Gallery" button via trigger_size. // Grab previous_page / next_page links. // Define gather_items to return an array of objects. // "Item" objects include page URL, link title, and thumb URL. // Item.image is either a single URL or an array of URLs. // Leave item.image blank if using fetch(). See below. // Change button_delay_function if gather_items should only be called once. // (E.g. if gather_items changes the page or runs slowly.) // When using fetch() to get item.page, define image_from_dom to get item.image from that page. if( domain( 'pixiv.net' ) ) { formats = [ ".png", ".jpg", ".gif" ]; // I have never seen a JPEG on Pixiv. I have 100,000 _p0 JPGs, and they're all ".jpg". new_image_rate = fetch_rate; // Slow down, since we now load all images directly. // Ever-useful test profile: https://www.pixiv.net/en/users/53625793 // SFW profile for testing when logged-out: https://www.pixiv.net/en/users/44241794 // Series: https://www.pixiv.net/user/2258616/series/38203, https://www.pixiv.net/user/55117629/series/87453?p=2 // Did a lot of remove-empty-container and undo testing here: https://www.pixiv.net/en/users/45227836/artworks button_delay_function = () => document.querySelector( 'img[src*="360x"]' ) || // /series document.querySelector( 'img[src*="250x"]' ) || // /users document.querySelector( 'a *[style*="c/240"]' ) || // bookmark_new_illust document.querySelector( 'img[src*="240x480"]' ); gather_items = function() { links_from_page_number( 'p', 1 ); if( window.location.href.match( 'ranking.php' ) ) { force_top_to_bottom = true; } // https://i.pximg.net/c/360x360_70/img-master/img/2020/06/20/11/14/47/82439841_p0_square1200.jpg let thumbs = Array.from( document.querySelectorAll( 'img[src*="c/360"]' ) ) // /series // https://i.pximg.net/c/250x250_80_a2/img-master/img/2020/05/15/04/50/43/81571620_p0_square1200.jpg // https://i.pximg.net/c/250x250_80_a2/custom-thumb/img/2021/03/13/21/24/07/88416537_p0_custom1200.jpg .concat( ... document.querySelectorAll( 'section img[src*="c/250"]' ) ) // en/users/12345 - also "related works" on artworks pages? // url("https://i.pximg.net/c/240x240/img-master/img/2021/02/17/04/05/41/87838098_p0_master1200.jpg") .concat( ... document.querySelectorAll( 'div[style*="c/240"]' ) ) // bookmark_new_illust // https://i.pximg.net/c/240x480/img-master/img/2021/05/07/00/05/04/89657320_p0_master1200.jpg .concat( ... document.querySelectorAll( 'img[src*="240x480"]' ) ) // ranking.php return thumbs.map( t => { let thumb = t.src || t.style.backgroundImage.match( /\"(.*)\"/ )[1]; let image_part = thumb.match( /img\/(.*?)_/ )[1]; // 2021/03/07/07/11/52/88269944; let submission = image_part.split( '/' ).pop(); // 88269944 let count = t.closest( 'a' ).querySelector( 'span:not([class])' ); // Avoids inconsistent class names. return new Object( { page: 'https://www.pixiv.net/artworks/' + submission, title: submission, thumb: thumb, image: new Array( count && count.innerText | 0 ).fill( 0 ) // new Array( null ) has one element, which we replace. .map( (v,i,a) => 'https://i.pximg.net/img-original/img/' + image_part + '_p' + i + '%format' ) } ) } ) } } if( domain( 'gelbooru.com' ) || domain( 'safebooru.org' ) ) { // https://gelbooru.com/index.php?page=post&s=list&tags=shuujin_academy_uniform+chair+1girl+pink_background trigger_size = [ 15, 50, 16, 5 ]; // Left, top, font-size, padding. let current = document.querySelector( '#paginator b, .pagination b, .paginator b' ) previous_page = current.previousElementSibling; next_page = current.nextElementSibling; // https://gelbooru.com/index.php?page=post&s=view&id=4179699&tags=pink_background // https://img3.gelbooru.com/thumbnails/f5/c7/thumbnail_f5c7826072943fd72076ba9121b473f0.jpg // https://img3.gelbooru.com/images/f5/c7/f5c7826072943fd72076ba9121b473f0.jpg // https://safebooru.org/thumbnails/3259/thumbnail_91588bf615a2d239d5b09c7c959236bc17b58ca6.jpg?3389404 // https://safebooru.org//images/3259/91588bf615a2d239d5b09c7c959236bc17b58ca6.jpg?3389404 gather_items = () => Array.from( document.querySelectorAll( 'a[href*="s=view"]' ) ) .map( v => typical_item( v, t => t.replace( '/thumbnails', '/images' ).replace( /_*thumbnail_*/, '' ) ) ); } if( domain( 'e621.net' ) || domain( 'e926.net' ) ) { // https://e621.net/posts?tags=somik+mirror trigger_size = [ 15, 50, 16, 5 ]; // Left, top, font-size, padding. if( window.location.href.indexOf( '/pools/' ) > 0 ) { force_top_to_bottom = true; } previous_and_next( '#paginator-prev', '#paginator-next' ); // https://e621.net/posts/1333873?q=somik+mirror // https://static1.e621.net/data/preview/37/75/3775cd8664c688f98a41780f6796ce86.jpg // https://static1.e621.net/data/37/75/3775cd8664c688f98a41780f6796ce86.png gather_items = () => Array.from( document.querySelectorAll( 'article.post-preview:not(.blacklisted-active) a' ) ) .map( v => typical_item( v, t => t.replace( '/preview', '' ) ) ); } if( domain( 'hentai-foundry.com' ) ) { // http://www.hentai-foundry.com/users/FaveUsersRecentPictures?username=AmaZima // http://www.hentai-foundry.com/user/InCase/faves/pictures trigger_size = [ 15, 20, 25, 10 ]; previous_and_next( 'li.previous a', 'li.next a' ); gather_items = () => Array.from( document.querySelectorAll( 'a.thumbLink' ) ) .map( v => { // http://www.hentai-foundry.com/pictures/user/AmaZima/589016/Tired-but-happy-Lottie let p = v.href.split( '/' ); let username = p[5]; // AmaZima let title = p[6]; // 589016 return new Object( { page: v, title: title, // url("//thumbs.hentai-foundry.com/thumb.php?pid=589016&size=350") thumb: v.querySelector( 'span[style]' ).style.backgroundImage .match( /\"(.*)\"/ )[1], // http://pictures.hentai-foundry.com/a/AmaZima/589016/AmaZima-589016-Tired_but_happy_Lottie.png // http://pictures.hentai-foundry.com/t/Tixnen/869342/Tixnen-869342-Vasilina.jpg - extension matters, filename doesn't. image: [ ( window.location.protocol + "//pictures.hentai-foundry.com" ), username[0].toLowerCase(), username, title, ( username + '-' + title + '-' + p[7].replace( /-/g, '_' ) + '%format' ) ].join( '/' ) } ) } ) } // Should probably have some generic test for Mastodon, even if I use @includes. Because I use @includes. // Maybe put that last? Like this approach is only valid if gather_items is undefined. if( domain( 'baraag.net' ) || domain( 'equestria.social' ) || domain( 'botsin.space' ) || domain( 'pawoo.net' ) || domain( 'mastodon.art' ) || domain( 'mastodon.social' ) ) { // https://baraag.net/@Applalt/media // https://baraag.net/@Applalt/media?max_id=105165058865018699 // Yeesh. // One element on first page: next. One element past first page: previous. Otherwise: previous, next. [ next_page, previous_page ] = document.querySelectorAll( 'a[class*="load-more"]' ); // Usually reversed: [ 'max_id', 'page' ].forEach( v => { if( args[ v ] ) { [ previous_page, next_page ] = [ next_page, previous_page ]; } } ); gather_items = () => Array.from( document.querySelectorAll( '.h-entry, .h-cite' ) ) .filter( v => v.querySelector( 'div[data-props*="media"]' ) ) // Text-only posts fuck us up. .map( v => { let item = new Object; item.page = v.querySelector( 'a[class*="time"][href]' ).href; item.title = item.page.split( '/' ).pop(); // dataset.props example: Object { height: 343, sensitive: true, autoplay: true, media: Array[3] } // .media example: Array [ Object, Object, Object ] // .media[0]: Object { id: "105651575500861937", type: "image", // url: "https://baraag.net/system/media_att…", preview_url: "https://baraag.net/system/media_att…", // remote_url: null, preview_remote_url: null, text_url: "https://baraag.net/media/C62aWhAqWt…", // meta: Object, description: null, blurhash: "UFCGJfX99@RP^%t7OoV@4ms:kpn+x[V@Rja#" } // ... but none of those URLs are the post URL, so keep original item.page code. let data = JSON.parse( v .querySelector( 'div[data-props]' ) .dataset.props ) .media; // Array of objects if( data ) { // Clunky workaround for some video submissions. Only some. item.thumb = data.map( v => v.preview_url )[0]; // One thumbnail per submission. item.image = data.map( v => v.url ); } return item; } ) } if( domain( 'rule34.paheal.net' ) ) { // http://rule34.paheal.net/post/list/Marco_Diaz%20Polyle/1 [ previous_page, next_page ] = [ 'Prev', 'Next' ].map( q => document.body.querySelectorIncludes( 'a', q ) ); gather_items = () => Array.from( document.querySelectorAll( '.shm-thumb' ) ) .map( v => new Object( { page: v.querySelector( '.shm-thumb-link' ), title: v.querySelector( 'img' ).id.replace( 'thumb_', '' ), // https://lotus.paheal.net/_thumbs/12b056b3d74a9a7a8680ccb656cf8622/thumb.jpg // https://lotus.paheal.net/_images/12b056b3d74a9a7a8680ccb656cf8622/3736567%20-%20Amber_O%27Malley%20Dumbing_of_Age%20Joyce_Brown%20jcdr.png // https://lotus.paheal.net/_images/12b056b3d74a9a7a8680ccb656cf8622/fashion-bonanza.horse // Note: Paheal accepts any damn thing. Genuinely anything. Just replace( _thumbs, _images ) and it would roll with it. // ... but direct image links are right there on the page. thumb: v.querySelector( 'img' ).src, image: v.querySelector( 'a[href*="/_images"]' ).href } ) ) } if( domain( 'rule34.xxx' ) ) { // https://rule34.xxx/index.php?page=post&s=list&tags=davepetasprite%5e2+ trigger_size = [ 150, 5, 16, 5 ]; previous_and_next( 'a[alt="back"', 'a[alt="next"' ); // https://rule34.xxx/index.php?page=post&s=view&id=4584602 // https://miami.rule34.xxx/thumbnails/3500/thumbnail_62b3adfaa4d100c4a6fc7d419f61dd49.jpg?3944961 // https://us.rule34.xxx//images/3500/62b3adfaa4d100c4a6fc7d419f61dd49.png?3944961 gather_items = () => Array.from( document.querySelectorAll( '.thumb a' ) ) .map( v => typical_item( v, t => t.replace( /\/\/.*\.rule/, '//us.rule' ) // E.g. //miami.rule34.xxx -> //us.rule34.xxx .replace( '/thumbnails', '/images' ) .replace( 'thumbnail_', '' ) ) ) } if( domain( 'derpibooru.org' ) ) { // Note: this won't show blacklisted images, including "suggestive" images blacklisted for anonymous users. // This behavior does not match Image Glutton but does match other sites in Gallery Swallower. // Though I should probably .filter for known not-the-image thumbnails so nothing shows up. Hm. trigger_size = [ 15, 55, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a.js-prev', 'a.js-next' ); // https://derpibooru.org/images/2301306?q=artist%3Ahexado // https://derpicdn.net/img/2021/3/28/2581076/thumb.png // https://derpicdn.net/img/view/2021/3/28/2581078.png gather_items = () => Array.from( document.querySelectorAll( 'a[title*="Tagged"]' ) ) .map( v => typical_item( v, t => t.replace( '/img/', '/img/view/' ).replace( '/thumb', '' ) ) ); } if( domain( 'inkbunny.net' ) ) { // https://inkbunny.net/submissionsviewall.php?rid=e16e4b981e&mode=search&page=1&orderby=create_datetime&artist=Iztli // Custom thumbnail test - https://inkbunny.net/gallery/atryl/1/734675c046 - they just don't add _noncustom. It's fine. // This seems to mess with your preview size settings. trigger_size = [ 15, 265, 16, 5 ]; previous_and_next( 'a[title="previous page"', 'a[title="next page"' ); html += '<style> span { color: #ddd } </style>'; // Default colors are grey-on-grey. if( args.mode == "pool" ) { force_top_to_bottom = true; } gather_items = () => Array.from( document.querySelectorAll( '.widget_imageFromSubmission a' ) ).map( typical_item ); // An array of either one thumbnail or all thumbnails (if this submission has multiple files), mapped to convert to full-size. image_from_dom = ( doc, item ) => ( ! doc.querySelector( '#files_area' ) ? [ item.thumb ] : Array.from( doc.querySelectorAll( '.widget_imageFromSubmission img[title*="page"]' ) ).map( img => img.src ) ) .map( thumb => scrub_extensions( thumb ) .replace( /thumbnails\/[a-z]*/, 'files/full' ) // thumbnails/medium, thumbnails/large, whatever. .replace( '_noncustom%', '%' ) // % for scrub_extensions %format. Effectively $. ) } if( domain( 'furaffinity.net' ) ) { // https://www.furaffinity.net/gallery/mab/folder/808215/Divaea trigger_size = [ 15, 155, 16, 5 ]; // https://www.furaffinity.net/scraps/mab/2/ // https://www.furaffinity.net/gallery/mab/folder/43380/Wildcard/2/ // https://www.furaffinity.net/msg/submissions/new~40875552@48/ // Completely goofy previous / next links. Sometimes links with no distinction besides text. Sometimes forms. Forms! Like it's 1998! [ previous_page, next_page ] = [ 'Prev', 'Next' ].map( q => document.body.querySelectorIncludes( 'form', q ) ).map( f => f && f.action ); if( window.location.href.includes( 'msg/submissions' ) ) { previous_and_next( 'a.more-half.prev', 'a.more, a.more-half:not(.prev)' ); } button_delay_function = () => document.querySelector( 'figure.t-image' ); gather_items = function() { let items = Array.from( document.querySelectorAll( 'figure.t-image u a' ) ).map( typical_item ); // Minor witchcraft - some part of FA hits an infinite loop if you replace the page and then resize anything. // I don't understand what exactly it's doing. But I know that removing some things first can break it: Array.from( document.querySelectorAll( 'a' ) ).forEach( v => v.remove() ); return items; } image_from_dom = ( doc ) => doc.querySelector( '.download a' ).href; } if( domain( 'danbooru.donmai.us' ) ) { // https://danbooru.donmai.us/posts?tags=marble_macintosh // Forced to fetch, because "click for original size" images use a different CDN. // I could try making an educated guess based on data-width and data-large-width in the <article> properties. Meh. trigger_size = [ 15, 100, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a.paginator-prev', 'a.paginator-next' ); gather_items = () => Array.from( document.querySelectorAll( 'article.post-preview a' ) ).map( typical_item ); image_from_dom = ( doc ) => doc.querySelector( 'a[download]' ).href.split('?')[0]; // Trim ?download stuff. } if( domain( 'booru.org' ) ) { // https://svtfoe.booru.org/index.php?page=post&s=list&tags=socks&pid=20 trigger_size = [ 15, 55, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a[alt="back"]', 'a[alt="next"]' ); // https://svtfoe.booru.org/index.php?page=post&s=view&id=29292 // https://thumbs.booru.org/svtfoe/thumbnails//28/thumbnail_187209e3ca22a28fd1ce75a7fd4f54aee3cf1e62.jpg // https://img.booru.org/svtfoe//images/28/187209e3ca22a28fd1ce75a7fd4f54aee3cf1e62.jpg gather_items = () => Array.from( document.querySelectorAll( 'span.thumb a:not([style])' ) ) // Specifying not:(display:none) is weirdly difficult. .map( v => typical_item( v, t => t.replace( 'thumbs.', 'img.' ).replace( '/thumbnails', '/images' ).replace( 'thumbnail_', '' ) ) ); } if( domain( 'jabarchives.com' ) ) { // https://www.jabarchives.com/main/gallery/misterd/105 trigger_size = [ 90, 55, 16, 5 ]; // Left, top, font-size, padding. // Two identical .pagination bars. So: "previous" is before the current-page link in the first bar, and "next" is after the one in the second bar. let this_link = document.querySelectorAll( 'li.page-item.active a' ) if( this_link[0] ) { previous_page = this_link[0].previousQuery( 'li.page-item a' ) } if( this_link[1] ) { next_page = this_link[1].nextQuery( 'li.page-item a' ); } // https://jabarchives.com/main/post/10577 // https://jabarchives.com/main/media/posts/2017/09/10/1L511532132607200182023271_thumb.png // https://jabarchives.com/main/media/posts/2017/09/10/1L511532132607200182023271_large.png gather_items = () => Array.from( document.querySelectorAll( 'li.gallrpli a.image' ) ) .sort( (x,y) => x.href.split('/').pop() | 0 < y.href.split('/').pop() | 0 ) // Forced chronological order. .map( v => typical_item( v, t => t.replace( '_thumb', '_large' ) ) ); } if( domain( 'aryion.com' ) ) { trigger_size = [ 5, 110, 16, 5 ]; // Left, top, font-size, padding. [ previous_page, next_page ] = [ '<<', '>>' ].map( q => document.body.querySelectorIncludes( '.pagenav a', q ) ); // https://aryion.com/g4/view/486096 // https://aryion.com/g4/gallery/Mortaven - with folders. gather_items = () => Array.from( document.querySelectorAll( '.type-Images' ) ) // Avoid folders. .map( v => v.closest( 'li' ).querySelector( 'a' ) ).map( typical_item ); // Link / image are separate from folder indicator. image_from_dom = ( doc ) => doc.querySelector( 'meta[property*="secure_url"]' ).content; // Possibly not the largest size? } if( domain( 'e-hentai.org' ) ) { trigger_size = [ 5, 40, 16, 5 ]; // Left, top, font-size, padding. force_top_to_bottom = true; // They're all in page order. links_from_page_number( "p", 0 ); gather_items = () => Array.from( document.querySelectorAll( '.gdtm a' ) ) .map( (v,i,a) => new Object( { // This site has no discrete thumbnails. page: v, title: 'Page ' + ( page_number * 40 + i + 1 ), } ) ) image_from_dom = ( doc ) => doc.querySelector( '#img' ).src; } if( domain( 'lolibooru.moe' ) || domain( 'rule34hentai.net' ) ) { trigger_size = [ 30, 150, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a.previousPage', 'a.nextPage' ); if( domain( 'rule34hentai.net' ) ) { formats = ['.jpg']; // All thumbs are JPGs. video_formats = ['.mp4']; // Any video extension works. } // https://lolibooru.moe/data/preview/a5ea50c715ce6e4d100cd648894495b1.jpg // https://lolibooru.moe/image/a5ea50c715ce6e4d100cd648894495b1/lolibooru%20240335%20age_difference (etc) .jpg - Anything works. // https://lolibooru.moe/image/a5ea50c715ce6e4d100cd648894495b1.jpg works, so just do that. // https://rule34hentai.net/_thumbs/880ec94d9f95ff8ce8887e14a9f8b909/thumb.jpg // https://rule34hentai.net/_images/880ec94d9f95ff8ce8887e14a9f8b909/467235%20-%20Naras%20Overwatch%20Sombra%20Tracer.png // https://rule34hentai.net/_images/880ec94d9f95ff8ce8887e14a9f8b909.jpg - also works. gather_items = () => Array.from( document.querySelectorAll( 'a.thumb' ) ) .map( v => typical_item( v, t => t.replace( 'data/preview', 'image' ).replace( '/_thumbs', '/_images' ).replace( '/thumb.', '.' ) ) ) } if( domain( 'yande.re' ) ) { // Aggravatingly close to Lolibooru, but incompatible. trigger_size = [ 30, 105, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a.previous_page', 'a.next_page' ); // Some images are /jpeg/ and some images are /image/ and there's no way to tell. Dammit. So we fetch. gather_items = () => Array.from( document.querySelectorAll( 'li a.thumb' ) ) .map( typical_item ); image_from_dom = ( doc ) => doc.querySelector( '.highres-show' ).href; } if( domain( 'thehentaiworld.com' ) ) { trigger_size = [ 25, 125, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a.prev', 'a.next' ); gather_items = () => Array.from( document.querySelectorAll( 'div.thumb a' ) ) .map( typical_item ); image_from_dom = ( doc ) => doc.querySelector( '#miniThumbContainer' ) ? // If multi-image, Array.from( doc.querySelectorAll( '#miniThumbContainer img' ) ) // Modify thumbnails. .map( v => scrub_extensions( v.src ).replace( '-220x147', '' ) ) : doc.querySelector( '#info li a' ).href; // Else return main image. } if( domain( 'r34hub.com' ) ) { html += '<style> span { color: #ddd } </style>'; // Black text on a dark background image. Nope. [ previous_page, next_page ] = Array.from( document.querySelectorAll( '.pagination-button' ) ); // Always both or neither - sometimes links. gather_items = () => Array.from( document.querySelectorAll( '.media-block' ) ) .map( v => typical_item( v.closest( 'a' ) ) ) .slice(1) // #9996 keeps showing up first. No idea why. image_from_dom = ( doc ) => doc.querySelector( '.media-wrapper img, .media-wrapper source' ).src; } if( domain( 'newgrounds.com' ) ) { // https://kevinnator.newgrounds.com/art - no page links, ever. trigger_size = [ 15, 330, 16, 5 ]; // Left, top, font-size, padding. new_image_rate = fetch_rate = 400; // Very touchy about "too many requests." gather_items = () => Array.from( document.querySelectorAll( 'a[class*="item-art"]' ) ).map( typical_item ); image_from_dom = ( doc ) => Array.from( doc.querySelector( '.pod[itemscope] .pod-body' ).querySelectorAll( 'img' ) ) .map( i => i.alt ? 'https://art.ngfiles.com/comments/' + i.alt.split('_')[1].slice(0,-3) + '000/' + i.alt : i.src ); // Sub-images: iu_446193_6909266.webp -> https://art.ngfiles.com/comments/446000/iu_446193_6909266.webp } if( domain( 'booru.allthefallen.moe' ) ) { // https://booru.allthefallen.moe/posts?tags=lyed_loud trigger_size = [ 15, 55, 16, 5 ]; // Left, top, font-size, padding. previous_and_next( 'a.paginator-prev', 'a.paginator-next' ); gather_items = () => Array.from( document.querySelectorAll( 'article:not(.blacklisted-active) a' ) ) .map( v => typical_item( v, t => t.replace( '/preview', '/original' ) ) ); } // ----- // Controls to invoke script GM_registerMenuCommand( "Swallow entire gallery", show_images ); // TamperMonkey still uses this! Good. // Put button on page, since there's no menu in "modern" Userscript plugins. var add_button = setInterval( function() { if( button_delay_function() ) { // Periodically check if this page has what we're looking for. clearInterval( add_button ); document.body.addElement( 'button', { innerText: "Swallow gallery", onclick: async function() { this.onclick = null; // Idempotent. this.innerText='Swallowing...'; await initialize_options(); // Reload defaults as late as possible. show_images(); }, style: "position: absolute; color:#194d19 !important; background-color:#dbd7d8; border-radius: 20px; text-align: center; z-index: 10; " + "display: inline-block; border:1px solid #19ab19; cursor:pointer; line-height: 20px; font-family:Arial; text-decoration:none; " + "left: " + trigger_size[0] + "px; top: " + trigger_size[1] + "px; font-size:" + trigger_size[2] + "px; padding: " + trigger_size[3] + "px " + trigger_size[3] + "px;" } ) } }, 100 ); // Passive - no interaction concerns. // End of main execution. // ------------------------------------ Persistent options menu ------------------------------------ // // Plugin-agnostic save / load functions. var [ set_options, get_options ] = [ ( key, value ) => localStorage.setItem( key, value ), ( key ) => localStorage.getItem( key) ]; // Per-site. if( window.GM ) { [ set_options, get_options ] = [ GM.setValue, GM.getValue ]; } // Global. var options; var default_options = new Object( { defaults: [], // Dummy value, used as a label. image_size: "short", // Choices: short, fit_width, fit_height, fit_window, full. older_submissions_first: true, // Default order. False: as seen on the page. True: reversed, which is usually chronological. dark_mode: false, // Initialize into dark mode. user_interface: [], keyboard_controls: true, // Nearly the whole left half of the keyboard. WASD / QERX / F / Ctrl+Z. translucent_buttons: false, // See-through buttons. video_support: true, // Include video formats alongside images. videos_muted: true, videos_autoplay: true, // Automatically start videos as they come onscreen. } ) async function initialize_options() { // This is called by the Swallow Gallery button and the menu button. options = await get_options( 'eza_options' ); // GM.getValue is a Promise. options = options ? JSON.parse( options ) : JSON.parse( JSON.stringify( default_options ) ); // Defaults if nothing comes back. for( key in default_options ) { if( options[ key ] == null ) { options[ key ] = default_options[ key ]; } } // Fill in any gaps - prevent script errors. } async function save_options() { let menu = document.getElementById( 'options_dialog' ) if( menu ) { Array.from( menu.querySelectorAll( '.option_input' ) ).map( v => { options[ v.id ] = ( v.type == 'checkbox' ) ? v.checked : v.value; } ) } set_options( 'eza_options', JSON.stringify( options ) ); } // Generate a menu from the options object. async function show_menu() { await initialize_options(); // Prevent mismatches between windows / tabs. (Possibly a bad idea.) let dialog = document.getElementById( 'options_dialog' ) if( dialog ) { // Toggle off. close_menu(); } else { // Toggle on, build from scratch. let menu = document.querySelector( '#spinners_id' ).addElement( 'div', { id: 'options_dialog' } ) for( key in options ) { let menu_item = menu.addElement( 'div' ) let label = menu_item.addElement( 'span', { className: 'option_label', innerText: variable_to_name( key ) } ) if( typeof( options[ key ] ) == 'object' ) { label.style.fontWeight = 'bold' } // E.g. options.video_options=[] -> **Video options** // Inputs for each option: if( typeof( options[ key ] ) == 'boolean' ) { menu_item.addElement( 'input', { type: 'checkbox', className: 'option_input', checked: options[ key ], id: key } ) } if( key == "image_size" ) { let select = menu_item.addElement( 'select', { id: 'image_size', className: 'option_input' } ) for( let symbol in image_size_symbols ) { select.addElement( 'option', { value: symbol, innerText: variable_to_name( symbol ), selected: symbol == options.image_size } ) } } } menu.addElement( 'button', { innerText: 'Reset', onclick: async function() { await close_menu(); // In this order! options = await JSON.parse( JSON.stringify( default_options ) ); save_options(); } } ) // Also create an invisible barrier that closes the menu when you click anywhere else. document.body.addElement( 'div', { onclick: close_menu, id: 'click_away_div', style: 'position: fixed; width: 100vw; height: 100vh; top: 0px; left: 0px; z-index: 11;' } ) enforce_style( document.getElementById( 'spinners_id' ) ); } } // Closing the menu automatically saves current options. async function close_menu() { await save_options(); document.getElementById( 'options_dialog' ).remove(); document.getElementById( 'click_away_div' ).remove(); enforce_style(); // Necessary for translucent buttons on e.g. Baraag. } // ------------------------------------ Gallery Swallower ------------------------------------ // function show_images() { // ----- // Replace page, set up furniture // Grab links and/or thumbnails using per-site code: items = gather_items(); // Array of objects, listing page link, thumbnail, presumed fullsize image, etc. if( options.older_submissions_first && ! force_top_to_bottom ) { items.reverse(); } console.log( items ); // Debug. Be honest, this is staying here. // items = []; // Debug aid for checking previous page / next page. // Erase existing page, use ours instead. document.body.innerHTML = html; // Fetch pages less often than we'd add inline images, to avoid 503 errors. if( image_from_dom ) { new_image_rate = fetch_rate; } // Controls - e.g. image size and order. let controls_id = document.getElementById( 'controls_id' ); controls_id.addElement( 'span', { innerText: 'Size: ' } ); for( let label in image_size_symbols ) { controls_id.addElement( 'button', { innerText: image_size_symbols[ label ], className: "control_button eza_button", id: label, title: variable_to_name( label ), onclick: function () { document.getElementById( 'centered_id' ).className = controls_id.className = this.id; enforce_style(); } } ); controls_id.appendChild( document.createTextNode( " " ) ); // Asinine way to force spacing. ::after wouldn't obey enforce_style. } document.getElementById( options.image_size ).click(); // DRY for highlighting the active size. controls_id.addElement( 'span', { innerText: ' Order: ' } ); controls_id.addElement( 'button', { className: "control_button eza_button", title: "Reverse order", innerText: '⇅', onclick: function() { document.querySelectorAll( '.reversible' ) .forEach( node => node.parentElement.insertBefore( node, node.parentElement.firstChild ) ) } } ); // Options menu. document.querySelector( '#spinners_id' ).addElement( 'button', { id: 'options_button', innerText: '≡', className: 'control_button eza_button', title: 'Gallery Swallower options', // "Options for Eza's Gallery Swallower?" onclick: show_menu } ) // Dark mode button. let dark_button = document.getElementById( 'dark_mode_id' ).addElement( 'button', { id: 'dark_mode_button', innerText: '✺', className: 'control_button eza_button', title: 'Dark mode', onclick: function() { document.getElementById( 'backdrop_id' ).classList.toggle( 'dark' ); enforce_style(); } } ) if( options.dark_mode ) { dark_button.click(); } // On-by-default setting. // Navigation links, at the bottom. "Previous" or "Previous - Next" or "Next". let links_id = document.getElementById( 'links_id' ); links_id.innerHTML += previous_page ? "<a href='" + previous_page + "'>Previous page</a>" : ""; links_id.innerHTML += previous_page && next_page ? " - " : ""; links_id.innerHTML += next_page ? "<a href='" + next_page + "'>Next page</a>" : ""; // Global undo button, pinned to the corner. let global_undo = document.body.addElement( 'button', { title: 'Undo', innerText: '⟲', className: 'reloader undo eza_button', id: 'global_undo_id', onclick: function() { if( ! undo_list[0] ) { return; } let element = undo_list.pop(); element.replacement.replaceWith( element.original ); element.replacement.remove(); // Fingers crossed this frees the memory. Not super important. element.original.scrollIntoView(); } } ); // Start spinners. @keyframes won't work on e.g. Baraag. Transition requires an initial state, hence this delayed start. document.querySelector( '#spinners_id' ).className = 'spinning'; // ----- // Per-submission links and controls // Give each item its own set of spans, with basic onClick controls to remove images or reload a submission. // Very few of these need to be variables now, but 'let purpose =' adds clarity. It's no less efficient than before. for( let item_key = 0; item_key < items.length; item_key++ ) { let item = items[ item_key ]; let container = document.getElementById( 'centered_id' ).addElement( 'div', { id: item_key + 'container', className: "submission reversible" } ) // ← ⟳ 12345 ✕ → let nav_previous = container.addElement( 'button', { title: 'Previous submission', className: 'nav_button previous eza_button', innerText: '←', onclick: function() { this.previousQuery( '.submission' ).scrollIntoView(); } } ); let first_spacer = container.addElement( 'button', { className: 'button_spacer eza_button' } ); let reloader = container.addElement( 'button', { title: 'Reload submission', className: 'reloader eza_button', innerText: '⟳', onclick: function() { if( image_from_dom ) { item.image = null; } let pair = undoable_replace( this.closest( '.submission' ).querySelector( '.basket' ), document.body.addElement( 'span', { id: item_key, className: 'ready basket unmodified' } ) ); if( pair.original.classList.contains( 'unmodified' ) ) { undo_list.pop(); } pair.replacement.addElement( 'span', { className: 'sub_basket' } ); } } ); let link = container.addElement( 'a', { href: item.page + '#dnr#&dnr', innerText: ' ' + item.title + ' ', target: '_blank', style: 'font-size:33px' } ); // Only "code smell" says this should go in CSS. let submission_remover = container.addElement( 'button', { title: 'Remove submission', className: 'remover top eza_button', innerText: '✕', onclick: function(){ if( this.closest( '.submission' ).querySelector( '.image_container' ) == null ) { return; } // Don't remove (and store) empty submissions. undoable_replace( this.closest( '.submission' ).querySelector( '.sub_basket' ), document.createElement( 'span' ) ); } } ); let second_space = container.addElement( 'button', { className: 'button_spacer eza_button' } ); let nav_next = container.addElement( 'button', { title: 'Next submission', className: 'nav_button next eza_button', innerText: '→', onclick: function() { this.nextQuery( '.submission' ).scrollIntoView(); } } ); container.addElement( 'br' ); // Image(s) and the ✕ below each group. let basket = container.addElement( 'span', { id: item_key, className: 'basket unmodified' } ); // Hold off on ready-ing. let bottom_submission_remover = container.addElement( 'button', { innerText: '✕', title: 'Remove the above submission', className: 'remover bottom eza_button', onclick: function() { let sub = this.closest( '.submission' ); sub.querySelector( '.remover.top' ).click(); // Click top X for identical behavior. sub.scrollIntoView(); // Scroll back up, since vertical content disappeared. } } ); // Thumbails at the top. Fixed-size grid box, overflow hidden. Crops tall images. if( item.thumb ) { // Optional now, mostly because fuck e-Hentai. let thumb_box = document.getElementById( 'thumbnails_id' ).addElement( 'span', { className: 'reversible', onclick: function() { document.getElementById( item_key + 'container' ).scrollIntoView(); } } ) let thumbnail_image = thumb_box.addElement( 'img', { src: item.thumb } ); } reloader.click(); // DRY } // ----- // Keyboard controls // I should probably still addEventListener for scrolling and highlight the current image somehow. Control opacity? Image border? // The real issue is that current_image and current_submission don't have to line up. document.addEventListener( "keydown", key_handler, false ); function onscreen( element ) { return element.getBoundingClientRect().top + element.scrollHeight > 100 } // DRY function key_handler( event ) { if ( options.videos_autoplay ) { // Autoplay kludge: start/stop all videos 'on key press' to subvert browser settings. let videos = Array.from( document.querySelectorAll( 'video:not(.autoplayed)' ) ).forEach( v => { v.play(); v.pause(); v.classList.add( 'autoplayed' ); } ) } if( ! options.keyboard_controls ) { return; } // Find "current" elements: which image & submission are at or near the top of the screen? let current_image = Array.from( document.querySelectorAll( '.image_container' ) ).find( onscreen ); let current_submission = Array.from( document.querySelectorAll( '.submission' ) ).find( onscreen ); let current_video = current_image.querySelector( 'video' ); if( event.key == 'z' ) { document.getElementById( 'global_undo_id' ).click(); } // Z and Ctrl+Z both work. if( event.ctrlKey ) { return; } // Ctrl + anything else? Do nothing. switch( event.key ) { case 'a': current_image.querySelector( '.nav_previous_image' ).click(); break; case 'd': current_image.querySelector( '.nav_next_image' ).click(); break; case 'q': current_image.querySelector( '.remover.floating' ).click(); break; case 'e': current_image.querySelector( '.remover.nav_button' ).click(); break; case 'w': current_submission.querySelector( '.previous' ).click(); break; case 's': current_submission.querySelector( '.next' ).click(); break; case 'r': current_submission.querySelector( '.reloader' ).click(); break; case 'x': current_submission.querySelector( '.remover.bottom' ).click(); break; case 'c': // Remove submission and advance - repeatability beats mashing X/S. current_submission.querySelector( '.remover.bottom' ).click(); current_submission.querySelector( '.next' ).click(); break; case 'f': if( current_video ) { current_video.paused ? current_video.play() : current_video.pause(); } break; } } // Anchor at the top so hitting 'next' on a freshly-loaded page goes to the first image instead of the second. let top_anchor = document.getElementById( 'spinners_id' ).addElement( 'span', { className: 'image_container submission' } ); let first_image = top_anchor.addElement( 'button', { className: 'nav_next_image remover nav_button', onclick: function() { this.nextQuery( '.image_container' ).scrollIntoView(); } } ); let first_submission = top_anchor.addElement( 'button', { className: 'next', onclick: function() { this.nextQuery( '.submission' ).scrollIntoView(); } } ); // Anchor at the bottom so you can navigate past the last image. let bottom_anchor = document.getElementById( 'backdrop_id' ).addElement( 'span', { className: 'image_container submission bottom' } ); let final_image = bottom_anchor.addElement( 'button', { className: 'nav_previous_image', onclick: function() { this.previousQuery( '.image_container' ).scrollIntoView(); } } ); let final_submission = bottom_anchor.addElement( 'button', { className: 'previous', onclick: function() { this.previousQuery( '.submission' ).scrollIntoView(); } } ); enforce_style(); // Applies CSS per-element, if it needs to. // ----- // Autoplaying videos document.addEventListener( "scroll", function( event ) { // Videos autoplay as they come onscreen, and autopause as they go offscreen. // If manually paused - they will not autoplay, until manually played again. Array.from( document.getElementsByTagName( 'video' ) ).forEach( video => { if( ! options.videos_autoplay ) { return; } let bounds = video.getBoundingClientRect(); if( bounds.bottom < 0 || bounds.top > document.documentElement.clientHeight ) { if( ! video.paused ) { video.classList.add( 'automatically_paused' ); } video.pause(); } else if( video.classList.contains( 'automatically_paused' ) ) { video.classList.remove( 'automatically_paused' ) video.muted = options.videos_muted; video.play(); } } ) } , false ); // ----- // Ongoing interaction and loading // Load more images in "ready" submissions, when available. var interval_object = setInterval( function() { // Upper limit for simultaneous loading images? (Should treat 0 as 'no limit,' so var && length > var.) if( document.getElementsByClassName( 'loading' ).length > 10 ) { return; } // Add an image to the ready basket, increment page number, conditionally ready-up for another image. let ready_element = document.querySelector( '.ready' ) if( ! ready_element ) { return; } ready_element.classList.remove( 'ready' ); let item = items[ ready_element.id | 0 ]; // This should maybe be data-id or something. // If we need to fetch, we don't have an image to display yet. if( ! item.image ) { return standard_fetch( item, ready_element ); } // Exit interval. // All per-image divs go in one per-submission span, so whole-submission X prevents new images from loading. let outer_span = ready_element.querySelector( '.sub_basket' ); let container = outer_span.addElement( 'div', { style: 'position: relative;', className: 'image_container' } ); let manga_page = ( outer_span.dataset.image_number || 0 ) | 0; // Not web-page... comic-page. (No initial value required.) outer_span.dataset.image_number = 1 + manga_page; // Has to go after fetch stuff, or we skip the first page. // Place stuff in the container. let previous_image = container.addElement( 'button', { title: 'Previous image', innerText: '←', className: 'nav_button nav_float nav_previous_image eza_button', onclick: function() { this.previousQuery( '.image_container' ).scrollIntoView(); } } ); let next_image = container.addElement( 'button', { title: 'Next image', innerText: '→', className: 'nav_button nav_float nav_next_image eza_button', onclick: function() { this.nextQuery( '.image_container' ).scrollIntoView(); } } ); let remove_and_advance = container.addElement( 'button', { title: 'Remove image / Next image', innerText: '✕→', className: 'remover nav_button nav_float eza_button', onclick: function() { let next = this.nextQuery( '.image_container' ); this.closest( '.image_container' ).querySelector( '.remover.floating' ).click(); // DRY - click other remove button. next.scrollIntoView(); let toaster = document.body.addElement( 'span', { innerText: '✕ Image removed' } ) toaster.offsetWidth ? toaster.style = 'position: fixed; left: 60px; top: 0px; opacity: 0; transition: opacity 3s;' : null; // Reflow forces a distinct state to transition from. setTimeout( () => { toaster.remove(); }, 3000 ); } } ); // Prepare image filename once. if( typeof( item.image ) == "string" ) { item.image = [ item.image]; } // Believe it or not, this is cleaner. let image_url = item.image[ manga_page ] || ''; // '' to avoid null.replace() TypeErrors. // Display image. // Note: all URLs include %format, because String.replace( '', '%format' ) results in %formatString. // No, hang on - only when scrub_extensions is used. Fetched sites generally use exact URLs. // Really, it's down to sites that use typical_item but also have exact URLs, which... might just be Rule34Hentai.net? for( let format of ( image_url.includes( '%format' ) ? formats : [""] ) ) { // One image/link per plausible filetype. (Exact URLs - one filetype.) // console.log( image_url ); // Debug. let apng = container.addElement( 'a', { href: image_url.replace( '%format', format ), className: 'image_link', target: '_generic' } ); apng.addElement( 'img', { src: image_url.replace( '%format', format ), className: 'loading', onload: function() { this.classList.remove( "loading" ); }, onerror: function() { let container = this.closest( '.image_container' ); // If all images disappear, we'll remove the whole container. this.closest( 'a' ).remove(); // Wrong URL: remove parent link (apng) and "this" image. if( getComputedStyle( container ) && ! container.querySelector( 'img, video' ) ) { container.remove(); } // Force reflow, avoid race condition. } } ); } // Display video if possible - hidden by default, because empty videos take up space. if( options.video_support ) { // Could just set video_formats = [], but a proper if-block avoids all the added self-removing elements. // Videos: one <video>, multiple sources. error goes on last source only. invisible by default. (They take up a lot of space.) let video = container.addElement( 'video', { controls: true, loop: true, muted: options.videos_muted, className: options.videos_autoplay ? 'invisible automatically_paused' : 'invisible' } ) for( let extension of ( image_url.includes( '%format' ) ? video_formats : [""] ) ) { // for-of video_formats, but only if we're guessing. video.addElement( 'source', { src: image_url.replace( '%format', extension ) } ) } video.lastChild.addEventListener( 'error', function() { let container = this.closest( '.image_container' ); this.closest( 'video' ).remove(); if( getComputedStyle( container ) && ! container.querySelector( 'img, video' ) ) { container.remove(); } } ) // If a video loads, show that instead of any image. (E.g. Gelbooru uses similar URLs for "posters.") let video_trigger = video.addEventListener( 'canplay', function() { if( this.classList.contains( 'invisible' ) ) { // Once. (canplay can trigger itself and loop.) this.currentTime = 0; // Fighting race conditions. let video_link = this.parentElement.addElement( 'a', { innerText: 'Video link', className: 'invisible', // No good place for it yet, so hide it. href: this.currentSrc, download: this.currentSrc.split( '/' ).pop() } ); // DownThemAll uses 'page title.webm.' WHY. } this.classList.remove( 'invisible' ); this.style.display = 'initial'; // Kludge for Baraag videos. I really hate having to fake CSS. this.closest( '.image_container' ).querySelector( 'a.image_link' ).remove(); } ) } let remover = container.addElement( 'button', { title: 'Remove image', innerText: '✕', className: 'remover floating eza_button', onclick: function() { undoable_replace( this.closest( '.image_container' ), document.createElement( 'span' ) ); } } ); container.addElement( 'br' ); container.addElement( 'br' ); // Array-of-images stuff: if item.image is an array, use it, up to the last item. if( item.image.keys && item.image[ manga_page + 1 ] ) { ready_element.classList.add( "ready" ); } // Direct testing - no booleans. enforce_style( outer_span ); // Just for this image. }, new_image_rate ); // ----- // 🍭 Spinners 🍭 // Garish spinners that indicate "finding new images" and "files still loading." var spinner_interval = setInterval( function() { document.getElementById( 'submissions_spinner_id' ).style.opacity = document.getElementsByClassName( 'ready' ).length; document.getElementById( 'images_spinner_id' ).style.opacity = document.getElementsByClassName( 'loading' ).length; // Once again: the orange one. let count = document.querySelectorAll( 'div.image_container' ).length; document.getElementById( 'image_counter_id' ).innerText = count + ' image' + ( count != 1 ? 's' : '' ); enforce_style( document.getElementById( 'spinners_id' ) ); }, 1000 ); // Lazy interval. }