Sleazy Fork is available in English.

Eza's Gallery Swallower

Turn a page of thumbnails into high-res images

As of 02/04/2021. See the latest version.

// ==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/en/users/*
// @include     https://www.pixiv.net/bookmark_new_illust.php*
// @include     https://www.pixiv.net/en/tags/*/artworks*
// @include     https://www.pixiv.net/user/*/series/*

// @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/*

// @exclude    *#dnr
// @noframes
// @version     2.15.62
// @grant        GM_registerMenuCommand
// ==/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. 

// User options:
var default_size = "short"; 			// Choices: short, fit_width, fit_height, fit_window, full. 
var new_image_rate = 50; 			// 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. 
var top_to_bottom = false; 			// Default order. True: as seen on the page. False: reversed, which is usually chronological. 
var keyboard_controls = true; 		// Set to false if you don't want WASD / QERX / Ctrl+Z to do anything. 
var low_opacity = false; 				// See-through buttons. 



// Table of contents:
	// Preamble
		// CSS
		// HTML
		// Helper functions
		// Polyfills
	// Main execution 
		// Per-site image-gathering functions
		// The Button 
	// Show images 
		// Per-submission setup
		// Keyboard controls
		// Ongoing image-display function
		// Spinners

// Gelbooru is inconsistent about WebMs. Some resolve to images. Others are blank. Dunno which is better, but pick one. 
	// https://gelbooru.com/index.php?page=post&s=list&tags=rosiekawaii 
// Still, address videos on Gelbooru. E.g. https://gelbooru.com/index.php?page=post&s=view&id=5614716&tags=rosiekawaii#dnr#&dnr
	// https://img3.gelbooru.com/images/3c/23/3c2376210444f7a7da737b412c722faa.jpg
	// https://img3.gelbooru.com//images/3c/23/3c2376210444f7a7da737b412c722faa.webm
	// Totally doable. So how do I check for a file without just embedding videos? Ugh, might be beyond onload / onerror behavior. 
	// Orrrr I could check tags. Not reliable. Could be wrong. But would allow a <video> with onerror to fall back to images. 
	// Better idea: indicate that it's probably a video, and link to the page without #dnr&dnr. Use Eza's Image Glutton, folks. 
		// Obviously that's the Ugoira solution on Pixiv: "don't." So it should be how I handle Ugoiras now that they're broken-ish. 
	// Embedding videos is undesirable because it implies linking to them for download. 
		// This script is already rude on bandwidth - videos would make it a DDOS attack. 
	// Solution: show thumbnail, not linked. (To avoid DownThemAll grabbing the thumbnail.) I guess link it to the page. Big _new target. 

// 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. 
// https://thehentaiworld.com/tag/romulo-mancin/page/2/ ? Images and manga. 
// e-Hentai.
// DeviantArt? 

// Might move image count to the controls area. 
	// Its text already implies "image size" and "image order." But mostly I'm bugged that it doesn't line up with other text.

// Thematically, the image-size controls should probably be blue. Meh. They're distinct enough for not being grey. 
	// Consistent color is difficult. I want my clearly red and green reload / remove arrows. But there's little indication of which buttons affect a submission, a single image, the whole page, or simply your position on the page. I tried several janky attempts and figured it's best if engineers don't design interfaces. Plain grey with function-indicative color on-hover is just fine. The only buttons colored by default are the image controls, which are labeled... sort of. 

// Abandon thoughts of up-arrow on bottom submission remover. It'd conflict with... actually it might perfetly fit what remove-and-advance conveys. 
	// Maybe the issue here is overloading what 'X' means. No idea what else I'd use for 'nuke submission.' 

// 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. 

// Apparently you can just reference elements in "window" by their ID. It's not recommended practice... but oh well. 
	// Add syntactical sugar by replacing getElementById('name') with name_id. 
	// ... is it a security concern? We're just throwing shit at the page. We don't read anything from whatever_id.
	// We click() global_undo_id, so I guess give that a getElementsById out of sheer paranoia. 
	// Do remember these are third-party webpages. If they wanted to put malicious crap in your browser... it'd be there.
	// And: they already have defenses against users injecting arbitrary HTML. 

// Out-of-bounds signalling for page count -might- still be useful for Pixiv. That'd let us skip the thumbnail-try stuff. 

// Tumblr? Not a -great- alternative to Eza's Tumblr Scrape, but pretty close to what that script originally wanted. 



	// To do:

// Put alt/title (onhover) text on size / order buttons? 

// -ms-transform is surely deprecated. 



	// Genuine bugs:

// Dead images and possibly thumbnails take up horizontal space and cause slight movements while loading. 

// Spinners don't spin on Baraag. (Might be intractable.)
	// It'd be a little funny to set .s1, .s2, .s3 etc. to different fixed rotations. But it'd screw up sites where the CSS works. 

// Keyboard controls often can't remove the last image. Eh. (Could give it 100vh of runout.)

// https://pawoo.net/@niwa2eito8/media?page=50 only shows "next page" when it should say "last page." 

// Bottom anchor once again does not extend "dark mode" div to the bottom of the... page? window? There's whitespace, is the point. 

// Link text (i.e. submission number, #dnr&dnr) isn't vertically centered with buttons. Not looking forward to fixing that. 

// Remove-and-advance font size is wrong in enforce_style. Dammit. 
	// Ditto floating X size. 



// Changes since last version: 
	// Fixed Danbooru previous / next page links being [object HTMLSpanElement] on first / last page. 
	// More if-else defaults changed to x||0. 
	// parseInt -> x|0 pattern. 
	// Overspecified .remover.floating rule so default :not(:hover) didn't make it tiny. 
		// Added two redundant rules to kludge big X and X-> on enforce_style sites. 
	// Golfed Pixiv previous / next links. Originally by a lot, but it didn't work on bookmark_new_illust. 
	// Elided default_style in setup HTML. We click the corresponding button. DRY. 
	// Simplified standard_fetch check to item.image==null. 





// ------------------------------------ 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. 

// Image style(s):
style_rules[ ".short img" ] = "max-width: 90vw; max-height: 60vh; vertical-align: middle;"; 		// Class is for <body>.  
style_rules[ ".full img" ] = "max-width: initial; max-height: initial; vertical-align: middle;"; 			// "initial" for enforce_style(). 
style_rules[ ".fit_width img" ] = "max-width: 90vw; max-height: initial; vertical-align: middle;";  	// Leaving space for big X.
style_rules[ ".fit_height img" ] = "max-width: initial; max-height: 95vh; vertical-align: middle;";  
style_rules[ ".fit_window img" ] = "max-width: 90vw; max-height: 95vh;vertical-align: middle;";  

// Dead spinners and other invisible elements:
style_rules[ '.invisible, .s0' ] = 'display: none;'; 		// s0 for spinners: className is s+querySelectorAll.length. 
style_rules[ '.button_spacer' ] = 'visibility: hidden; width: 30px !important; height: 30px;'; 

style_rules[ '#image_counter_id' ] = 'position: absolute; 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%; -ms-transform: translateY(-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. 

style_rules[ '.anchor' ] = '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;';
style_rules[ '.eza_button:not(:hover)' ] = 'background-color:#d7dbd8;' 		// Inverts and elides repeated .nav:hover / .reloader:hover background-color rules. 

if( low_opacity ) { style_rules[ '.eza_button' ] += ' opacity: 0.3;'; style_rules[ '.eza_button:hover' ] = 'opacity: 1.0;'; }		// Maybe. 

// 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;'; 

// Dark mode button:
style_rules[ '#backdrop_id.dark' ] = 'background: #181818'; 
style_rules[ '#backdrop_id:not(.dark)' ] = 'background: rgba(0,0,0,0)';  		// For enforce_style. 
style_rules[ '.dark #dark_mode_button' ] = 'color: #000;'

// 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'; 		// Low values don't separate thumbnails from image controls. Gelbooru picks 1.42857143... for some reason. 

// Spinners to indicate loading submissions / loading images:
style_rules[ '#submissions_spinner_id' ] = 'position: absolute; left: 0px; top: 0px;  z-index: 10; border: 8px solid #3498db; border-top: 8px solid #111111; border-bottom: 8px solid #111111; border-radius: 50%; width: 48px; height: 48px; box-sizing: unset !important; animation: spin 1s linear infinite;' 		// The blue one. 
style_rules [ '#images_spinner_id' ] = 'position: absolute; left: 8px; top: 8px; 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; animation: spin 3s linear infinite;' 		// The orange one. 

// Push all of that into a <style> block:
html += '<style> ';
for( selector in style_rules ) { html += selector + ' { ' + style_rules[ selector ] + ' } '; } 
// Spinner animations kinda shit the bed when you querySelector( '@keyframes spin' ). 
html += '@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } '; 
html += '@keyframes fadeout { from { opacity: 1; } to { opacity: 0; } } ';  
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 += '<span id="spinners_id">' 		// So enforce_style() can apply to spinners. Sort of. 
	+ '<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>'

// Structure:
html += '<br><span id="controls_id" class=""></span><br><br><br><br>' 		// Image-size controls. 
	+ '<span id="thumbnails_id"></span><br><br><br>' 
	+ '<center>'
	+ '<span id="dark_mode_id"></span><br>' 		// <span/> is deprecated. 
	+ '<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. 
	+ '</center>'
	+ '<br style="position: absolute;" id="style_check_id">' 		// Invisible probe for enforce_style. 
	+ '<br><br><br><br><br>' 		// Runout.

html += '</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 next_page, previous_page;  		// URLS to the actual previous / next page (where applicable). 
var undo_list = new Array; 				// For holding and restoring elements. (See undoable_replace.) First in, last out. 

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). 



	// ----- //			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( style_check_id.clientHeight == 0 ) { return; } 		// Inline <style> block, not in style_rules. 
	for( 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 => { 
			doc = document.createElement( 'html' );
			doc.innerHTML = text; 
			item_object.image = image_from_dom( doc ); 
			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 .dummy, both Elements. 
function undoable_replace( current, replacement ) { 
	if( ! current.className.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, dummy: replacement } ) ); 
	return undo_list[ undo_list.length - 1 ]; 
}

// DRY for submissions. "image" is always scrub(thumb).replace(). "title" is always part of "page." This condenses those. 
function typical_item( page, thumb, thumb_to_image ) { 
	let title = [ 'id=', 'post/', 'posts/', 'images/', 'view/'  ] 		// Safely extract number after one of these indicators. 
		.map( v => ( ( '' + page ).match( v + '([0-9]+)' ) || [''] ).pop() ).join( '' ) || "Link";  	// ... or give up and be generic.  
	return new Object( { page: page, title: title,  thumb: thumb, 
		image: thumb_to_image( scrub_extensions( thumb ) ) 
	} )
}

// DRY for page links via querySelector. 
function previous_and_next( previous, next ) { 
	[ previous_page, next_page ] = [ previous, next ].map( q => document.querySelector( q ) ) 
} 



// 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. s
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. 
Element.prototype.addElement = function( tag, attribute_object ) { 
	let element = document.createElement( tag ); 
	for( 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. 
Element.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. 
}
Element.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 ); 		// DRY 

// 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 URLs. 
	// 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. 
			// Item.probe is only for Pixiv-style pagination. _p0, _p1, _p2, etc. 
	// 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". 

	// Ever-useful test profile: https://www.pixiv.net/en/users/53625793 
	// 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

	gather_items = function() { 
		let page_number = ( parse_search( window.location.search ).p || 1 ) | 0; 		// Parse search again - parseInt or 1. 
		if( page_number > 1 ) { previous_page = window.location.origin + window.location.pathname + "?p=" + (page_number - 1); }
		next_page = window.location.origin + window.location.pathname + "?p=" + (page_number + 1); 

		// 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( 'a[href*="/art"] img[src*="c/360"]' ) ) 		// /series
			.map( img => img.src ); 
		if( thumbs.length == 0 ) { 		// We'd concat() all three, but /series pages have spurious 250x250 links on the side.
			// 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
			thumbs = Array.from( document.querySelectorAll( 'a[href*="/art"] img[src*="c/250"]' )  ) 		// en/users/12345 
				.map( img => img.src )
			// url("https://i.pximg.net/c/240x240/img-master/img/2021/02/17/04/05/41/87838098_p0_master1200.jpg") 
			.concat( Array.from( document.querySelectorAll( 'a *[style*="c/240"]' ) ) 										// bookmark_new_illust  
				.map( div => div.style.backgroundImage
					.match( /".*"/ )[0].slice( 1, -1 ) ) ); 		// Remove url("/") from either end. 
		}

		return thumbs.map( t => { 
			// Tried doing this neatly with regexes, but some Ugoira thumbnails go 88267833_master1200, instead of 88267833_p0_square1200. 
			let image_part = t.split( '/img/' )[1].split( '_' )[0] + '_p'; 		// 2021/03/07/07/11/52/88269944_p
			let submission = t.split( '/' ).pop().split( '_' )[0]; 		// 88269944
			return new Object( { 
				page: 		'https://www.pixiv.net/artworks/' + submission,
				title: 		submission,
				thumb: 	t,
				probe: 	'https://i.pximg.net/c/48x48/img-master/img/' + image_part + '%number' + '_square1200' + '%format',
				image: 	'https://i.pximg.net/img-original/img/' + image_part + '%number' + '%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 
	// https://gelbooru.com/index.php?page=post&s=view&id=2135704 - bad submission crashes gather_items. 
	// https://gelbooru.com/index.php?page=post&s=list&tags=saberfish+aftersex+hug 
	// Huh. Only causes problems on the Comments page, not post list. 
	trigger_size = [ 15, 50, 16, 5 ]; 		// Left, top, font-size, padding. 

	// https://gelbooru.com/index.php?page=post&s=list&tags=4girls
	// https://gelbooru.com/index.php?page=post&s=list&tags=4girls&pid=42
	// https://gelbooru.com/index.php?page=comment&s=list&pid=10 		// Whoops. 
//	let page_number = args.pid ? parseInt( args.pid ) : 0; 
	let page_number = ( args.pid || 0 ) | 0; 		// parseInt or 0. 
	let pages_at_once = 42; 
	if( domain( 'safebooru.org' ) ) { pages_at_once = 40; } 
	if( args.page == "comment" ) { pages_at_once = 10; } 

	if( page_number > 1 ) { previous_page = window.location.href + "&pid=" + (page_number - pages_at_once); }
	next_page = window.location.href + "&pid=" + (page_number + pages_at_once); 		// Flawed, but it works. 

	// 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.href, v.querySelector( 'img' ).src, 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 ) { 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( 'a[href*="posts/"] img' ) )
		.map( v => v.closest('a') )
		.filter( v => ! v.closest('article').classList.contains( 'blacklisted-active' ) ) 		// Exclude hidden posts. 
		.map( v => typical_item( v, v.querySelector( 'img' ).src, 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
	// http://www.hentai-foundry.com/pictures/user/AmaZima 
	// http://www.hentai-foundry.com/pictures/user/AmaZima/scraps - single image, no pages 
	// Haha, http://www.hentai-foundry.com/pictures/user/Underrock/883176/MTWD-Ch.-II-Pg.-16#dnr#&dnr shows both PNG & JPG. 
	var 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 username = v.href.split( '/' )[5]; 	// AmaZima
			let title = v.href.split( '/' )[6]; 			// 589016
			return new Object( { 
				page: 		v,
				title: 		title, 
				// url("//thumbs.hentai-foundry.com/thumb.php?pid=589016&size=350")
				// 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
				thumb: 	v.querySelector( 'span[style]' ).style.backgroundImage 		// Why would it be easy.
					.replace( 'url("', '' )
					.replace( '")', '' ),
				image: 		window.location.protocol + "//pictures.hentai-foundry.com/" 
					+ username.slice( 0, 1 ).toLowerCase() + '/' + username + '/'
					+ title + '/' 
					+ username + '-' + title + '-'
					+ v.href.split( '/' ).pop().replace( /-/g, '_' )
					+ '%format' 
			} )
		} ) 
} 

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. 
	let navs = document.querySelectorAll( 'a[class*="load-more"]' ); 
	if( navs[0] ) { 		// Always 0, 1, or 2 elements long. 
		if( navs.length == 2 ) { previous_page = navs[0]; next_page = navs[1]; }  		// Both
		else if( window.location.href.indexOf( '?max_id=' ) > 0 ) { previous_page = navs[0]; } 		// Last page
		else { next_page = navs[0]; } 		// First 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; 		// Removing .href breaks this, and it really shouldn't. 
			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, v.querySelector( 'img[src*="/thumbnails"' ).src,  
			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, v.querySelector( 'img' ).src, t => t.replace( '/img/', '/img/view/' ).replace( '/thumb', '' ) ) ); 
} 

if( domain( 'inkbunny.net' ) ) {
	// https://inkbunny.net/submissionsviewall.php?rid=87f31d45ae&mode=search&page=1&orderby=create_datetime&text=sketches&stringtype=and&keywords=yes&title=yes&description=no&artist=Iztli&favsby=&type=&sale=&days=&keyword_id=&user_id=&random=&md5=
	// Custom thumbnail test - https://inkbunny.net/gallery/atryl/1/734675c046 - they just don't add _noncustom. It's fine. 
	// We could handle single-image submissions via the thumbnail alone. It just wouldn't be smaller or simpler. 
	// 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. 

	// https://inkbunny.net/s/1244537-latest#dnr#&dnr - ffs. 
	gather_items = function() { 
		return Array.from( document.querySelectorAll( '.widget_imageFromSubmission' ) ) 
			.map( v => new Object( { 
				page: 		v.querySelector( 'a' ),
				title: 		v.querySelector( 'a' ).href.split('/').pop(), 
				thumb: 	v.querySelector( 'img' ).src,
			} ) )
	}

	image_from_dom = function( doc ) {
		let images = Array.from( doc.querySelectorAll( '.widget_imageFromSubmission img[title*="page"]' ) )
			.map( img => scrub_extensions( img.src )
				.replace( 'thumbnails/medium', 'files/full' )
				.replace( '_noncustom', '')
			); 
		if( images.length == 0 ) { 		// Single image
			images = scrub_extensions( doc.querySelector( '.magicboxParent a' ) ); 
		} 
		return images; 
	}
}

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/
	// 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/ 
	// Yeesh. 
	previous_and_next( 'a.more-half.prev', 'a.more, a.more-half:not(.prev)' ); 		// Implicit if-else. 
	if( ! window.location.href.includes( 'msg/submissions' ) ) {  		// TypeError-safe return for null.action: 
		[ previous_page, next_page ] = [ 'Prev', 'Next' ].map( q => ( form = document.body.querySelectorIncludes( 'form', q ) ) && form.action ); 
	} 

	button_delay_function = () => document.querySelector( 'figure.t-image' ); 

	gather_items = function() { 
		let items = Array.from( document.querySelectorAll( 'figure.t-image u a' ) ) 
			.map( v => typical_item( v, v.querySelector( 'img' ).src, t => {} ) ); 		// No item.image. 
		// 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( v => new Object( { 
			page: 		v,
			title: 		v.href.split( 'posts/' )[1].split( '?' )[0],
			thumb: 	v.querySelector( 'img' ).src,
		} ) )

	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( 'a img[src*="thumbs"]' ) )
		.map( v => v.closest( 'a' ) )
		.filter( v => ! v.style.display ) 		// Exclude hidden posts. Should be !display=="none", but typeError says fuck you. 
		.map( v => typical_item( v, v.querySelector( 'img[src*="thumbs"]' ).src, 
			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, v.querySelector( 'img' ).src, t => t.replace( '_thumb', '_large' ) ) ); 
} 

if( domain( 'aryion.com' ) ) { 
	trigger_size = [ 5, 110, 16, 5 ];
	[ previous_page, next_page ] = [ '<<', '>>' ].map( q => document.body.querySelectorIncludes( '.pagenav a', q )	);

	// https://aryion.com/g4/view/486096
	gather_items = () => Array.from( document.querySelectorAll( 'li.gallery-item' ) )
		.filter( v => v.querySelector( '.type-Images' ) ) 		// Avoid folders / stories. 
		.map( v => typical_item( v.querySelector( 'a' ), v.querySelector( 'img' ).src, t => {} ) ); 		// No item.image - fetch required. 

	image_from_dom = ( doc ) => doc.querySelector( 'meta[property*="secure_url"]' ).content; 		// Possibly not the largest size?
} 



	// ----- //			Controls to invoke script



GM_registerMenuCommand( "Swallow entire gallery", show_images ); 		// GreaseMonkey dropped this feature years ago, but I am stubborn. 

// Put button on page, since there's no menu in "modern" Userscript plugins.
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: function() { this.innerText='Swallowing...'; show_images(); },   		// Oh. I didn't know I could just call that from here. 
				style: "position: absolute; color:#194d19 !important; background-color:#dbd7d8; border-radius: 20px; text-align: center; " + 
						"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. 





// ------------------------------------ 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( top_to_bottom == false ) { 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; } 

	function add_size_button( text, name ) { 		// DRY for five nearly-identical buttons. 
		controls_id.addElement( 'button', { innerText: text, className: "control_button eza_button", id: name, 
			onclick: function () { 
				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. 
	} 

	// Controls - e.g. image size and order. 
	// Maybe [ [ "▣", "short" ], [ "↔", "fit_width" ] ].forEach? Eh. It becomes a readability issue. 
		// Saves lines by not really having an in-scope add-a-button class. 
		// I can't use "■" as the name for an object property. I'm honestly kinda relieved. 
	controls_id.appendChild( document.createTextNode( "Size: " ) );  
	add_size_button( "▣", "short" ); 
	add_size_button( "↔", "fit_width" ); 
	add_size_button( "↕", "fit_height" ); 
	add_size_button( "✢", "fit_window" ); 
	add_size_button( "■", "full" ); 
	document.getElementById( default_size ).click(); 		// DRY for highlighting the active size. 

	controls_id.appendChild( document.createTextNode( " Order: " ) );  
	controls_id.addElement( 'button', { className: "control_button eza_button", innerText: '⇅',
		onclick: function() { 
			document.querySelectorAll( '.reversible' )
				.forEach( node => node.parentElement.insertBefore( node, node.parentElement.firstChild ) )
		} } ); 

	// Navigation links, at the bottom. "Previous" or "Previous - Next" or "Next".
	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', { innerText: '⟲', 	
		className: 'reloader undo eza_button', id: 'global_undo_id', 
		onclick: function() { 
			let element = undo_list.pop(); 
			element.dummy.replaceWith( element.original ); 
			element.dummy.remove(); 		// Fingers crossed this frees the memory. Not super important.  
			element.original.scrollIntoView(); 
		} } ); 

	// Dark mode button.
	dark_mode_id.addElement( 'button', { className: 'control_button eza_button', id: 'dark_mode_button', innerText: '✺',
		onclick: function() { 
			backdrop_id.classList.toggle( 'dark' );
			enforce_style(); 
		} 
	} )



	// ----- //			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++ ) { 
		item = items[ item_key ]; 

		let container = centered_id.addElement( 'div',
			{ id: item_key + 'container', className: "submission reversible" } )

		// ← ⟳ 12345 ✕ →
		let nav_previous = container.addElement( 'button', { 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', { className: 'reloader eza_button', innerText: '⟳', 
			onclick: function() {
				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(); } 
			} } );
		let link = container.addElement( 'a', { href: item.page + '#dnr#&dnr', 
			innerText: ' ' + item.title + ' ', target: '_blank', style: 'font-size:30px' } );  		// Only "code smell" says this should go in CSS.
		let submission_remover = container.addElement( 'button', { 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', { 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', { className: 'remover bottom eza_button', innerText: '✕',
			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.
		let thumb_box = thumbnails_id.addElement( 'span', { className: 'reversible',
			onclick: function() { document.getElementById( item_key + 'container' ).scrollIntoView(); } } )
		let thumbnail_image = thumb_box.addElement( 'img', { src: item.thumb }  ); 

		enforce_style(); 		// Applies CSS per-element, if it needs to. 

		reloader.click(); 		// DRY
	}  



	// ----- //			Keyboard controls



	if( 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 ) { 
			// Find "current" elements: which image & submission are at or near the top of the screen? 
			current_image = Array.from( document.querySelectorAll( '.image_container' ) ).find( onscreen );
			current_submission = Array.from( document.querySelectorAll( '.submission' ) ).find( onscreen );

			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 'z': document.getElementById( 'global_undo_id' ).click(); break; 		// Ctrl+Z also works.
			} 
		}

		// Alright, make it so hitting 'next' on a freshly-loaded page goes to the first image instead of the second. 
		// Can't just append two buttons of className x_container, because thing.querySelector can't return 'thing.' 
		let top_anchor = spinners_id.addElement( 'div', { className: 'image_container submission anchor' } );  
		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(); } } ); 

		// Okay fine there should be a bottom anchor too. 
		let bottom_anchor = backdrop_id.addElement( 'div', { className: 'image_container submission bottom anchor' } );
		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(); } } ); 
	}



	// ----- //			Ongoing interaction and loading 



	// Load more images in "ready" submissions, when available. 
	var interval_object = setInterval( function() { 

		// Add an image to the ready basket, increment page number, conditionally ready-up for another image. 
		// Image list: just grab list[n]. Automatic pagination: probe for matching numbered thumbnail. 
		if( ready_element = document.querySelector( '.ready' ) ) { 
			ready_element.classList.remove( 'ready' ); 		// We fancy. 

			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 == null ) { 		// Note: will not re-fetch. Bad item.image value? Tough.  
				standard_fetch( item, ready_element ); 
				return;  		// Exit interval. 
			} 

			let manga_page = ( ready_element.dataset.image_number || 0 ) | 0; 		// Not web-page... comic-page. (No initial value required.) 
			ready_element.dataset.image_number = 1 + manga_page; 		// Has to go after fetch stuff, or we skip the first page. 

			// All per-image divs go in one per-submission span, so whole-submission X prevents new images from loading.
			// Not 100% sure this stops anything from loading, now that "undo" means the basket still exists... somewhere. Testing shows a solid "mostly." 
			let outer_span = ready_element.querySelector( '.sub_basket' );  
			if( outer_span == null ) { outer_span = ready_element.addElement( 'span', { className: 'sub_basket' } ); } 		// Two hard problems. 
			let inner_div = outer_span.addElement( 'div', { style: 'position: relative;', className: 'image_container' } ); 

			// Place stuff in the container. 
			let previous_image = inner_div.addElement( 'button', { innerText: '←', 
				className: 'nav_button nav_float nav_previous_image eza_button', 
				onclick: function() { this.previousQuery( '.image_container' ).scrollIntoView(); } 
			} ); 
			let next_image = inner_div.addElement( 'button', { innerText: '→', 
				className: 'nav_button nav_float nav_next_image eza_button', 
				onclick: function() { this.nextQuery( '.image_container' ).scrollIntoView(); } 
			} ); 
			let remove_and_advance = inner_div.addElement( 'button', { 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', 
						style: 'position: fixed; left: 60px; top: 0px; animation: fadeout 3s;' } ) 
					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 ] || item.image[0]  || ''; 		// image[0] for Pixiv pagination. '' to avoid null.replace() TypeErrors. 
			image_url = image_url.replace( '%number', manga_page ); 		// Basically just Pixiv.  
			for( format of ( image_url.includes( '%format' ) ? formats : [""] ) ) { 		// One image/link per plausible filetype. (Exact URLs - one filetype.) 
// 				console.log( image_url ); 		// Debug. 
				let apng = inner_div.addElement( 'a', { href: image_url.replace( '%format', format ), 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. 
						setTimeout( () => { if( ! container.querySelector( 'img' ) ) { container.remove(); } }, 1000 ); 		// Goddamn race conditions. 
					} 
				} );  
			} 

			let remover = inner_div.addElement( 'button', { innerText: '✕', className: 'remover floating eza_button', 
				onclick: function() { undoable_replace( this.closest( '.image_container' ), document.createElement( 'span' ) ); } 
			} ); 
			inner_div.addElement( 'br' ); 
			inner_div.addElement( 'br' ); 



			// Automatic pagination, probing for presence of a next image:
			if( item.probe ) { 		// I'd check for %number in item_url, but we already remove it above. 
				for( format of formats ) { 
					inner_div.addElement( 'img', { className: 'invisible', 
						src: item.probe.replace( '%format', format ).replace( '%number', (manga_page+1) ),
						onload: function() { this.closest( '.basket' ).classList.add( "ready" ); this.remove();  }, 
						onerror: function() { this.remove(); } 
					} )  
				} 
			}

			// 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 and any probes. 
		} 

	}, new_image_rate ); 



	// ----- // 		🍭 Spinners 🍭



	// Garish spinners that indicate "finding new images" and "files still loading." 
	var spinner_interval = setInterval( function() { 
		submissions_spinner_id.className = 's' + document.getElementsByClassName( 'ready' ).length; 		// '.0' is not a valid selector. 
		images_spinner_id.className = 's' + document.getElementsByClassName( 'loading' ).length; 		// Once again: the orange one. 

		image_counter_id.innerText = document.querySelectorAll( '.image_container:not(.anchor)' ).length + ' images';  

		enforce_style( spinners_id ); 
	}, 1000 ); 		// Lazy interval. 

}