Eza's Gallery Swallower

Turn a page of thumbnails into high-res images

Per 05-04-2021. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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

// @include     https://e-hentai.org/g/*

// @exclude    *#dnr
// @noframes
// @version     2.16.5
// @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.
	// Does this script replace Eza's Comic Viewer? (Not public yet, sorry.) 
	// It's certainly better-equipped to handle fetching. e-Hentai doesn't even have thumbnails; it has one big fat index image per gallery. 
// 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. 

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

// I could elide parse_search into links_from_page_number, and eliminate 'var page_number,' but... meh? 

// User reports nothing works. Page clears, spinners show up, and loading ceases with the word "Size:" in the corner. 
	// Immediate thought - is it just Paheal? Gallery SMOOTHERNER interfered with this, by changing the direct image links. 
		// (Alright, immediate thought was, 'did I leave items=[] uncommented?,' and thankfully the answer is no.) 
		// Then again that should still show controls. (Either one.) Is this user's plugin throwing a fit about add_size_button?
	// Installed Chromium + TamperMonkey to test this. 
	// http://rule34.paheal.net/post/list/Loup/1 as a good test page. (Example screenshot is obviously Paheal green.) 
		// Shows all controls, so that can't be that user's issue. 
		// Shows all three images and all elements... but not in the right places. 
			// I'm'a bet this is because I used item_id instead of getElementById( 'item_id' ). Alas. It was too clean to be reliable. 
			// Goddammit I'm going to have to modify this script in TamperMonkey instead of my editor. (Nevermind, ctrl+a / ctrl+c / ctrl+v.) 
			// Changed all those back to document.getElementById - did not fix the problem. Well, fuck. 
	// Inspector shows "basket unmodified" spans are inserted directly into the body, below the reloader button. Uh... huh. 
		// undoable_replace creates that span in body. Which seems bizarre, in retrospect, except it's supposed to get MOVED by undoable_replace. 
		// So I'm betting TamperMonkey and/or Chrome just does not handle Element.replace correctly. Fuck me sideways. 
		// Durr, check console. "current.className.contains is not a function." Wow. That's far dumber than I thought. 
		// Chromium straight-up has not implement String.contains. Oookay, back to .match. 
		// Although - it does support classList.contains. Less RegEx-y, more DOMTokenList... y. 
		// Alright, that fixed it. Ironically I can probably go back to thing_id if I want. 
	// So... is there any chance that solved the user's problem? I couldn't recreate the problem. 
		// GreasyFork has no information about their browser / extension. 



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



// Changes since last version: 
	// Added e-Hentai.org. No thumbnails. 
		// Made thumbnails conditional. 
	// DRY'd Pixiv and e-Hentai page function: links_from_page_number. Gelbooru didn't fit nicely. 
	// Golfed Gelbooru / Safebooru pages to nextElementSibling beside bolded current page number. 
	// Changed direct element_id references to getElementById( 'element_id' ) because that's probably a source of problems. 
	// Changed className.contains to classList.contains because Chromium hasn't implemented String.contains. Weird. 





// ------------------------------------ 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 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 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( document.getElementById( '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.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, 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 ) ) 
} 

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



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



// 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() { 
		links_from_page_number( 'p', 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. 

	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.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 ]; 		// Left, top, font-size, padding. 
	[ 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. (Image icon is outside the link and thumbnail.) 
		.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?
} 

if( domain( 'e-hentai.org' ) ) { 
	trigger_size = [ 5, 40, 16, 5 ]; 		// Left, top, font-size, padding. 
	top_to_bottom = true; 		// They're all in page order. 

	links_from_page_number( "p", 0 ); 

	// Huh. We can't do thumbnails the sensible way because there are no discrete images. It's all background:translate crap. 
	// margin:1px auto 0; width:100px; height:76px; background:transparent url(https://ehgt.org/m/001740/1740844-00.jpg) -0px 0 no-repeat
	// The lazy answer is, don't have thumbnails. 
	gather_items = () => Array.from( document.querySelectorAll( '.gdtm a' ) )
		.map( (v,i,a) => new Object( { 
			page: 		v, 
			title: 		'Page ' + ( page_number * 40 + i + 1 ), 
		} )  )

	image_from_dom = ( doc ) => doc.querySelector( '#img' ).src; 
} 



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





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

	let controls_id = document.getElementById( 'controls_id' ); 
	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 () { 
				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. 
	} 

	// 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 function. 
		// I can't use "■" as the name for an object property. I'm honestly kinda relieved. 
		// ... though I could use "short" and so on. 
	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".
	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', { innerText: '⟲', 	
		className: 'reloader undo eza_button', id: 'global_undo_id', 
		onclick: function() { 
			if( ! undo_list[0] ) { return; } 	
			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.
	document.getElementById( 'dark_mode_id' ).addElement( 'button', { className: 'control_button eza_button', id: 'dark_mode_button', innerText: '✺',
		onclick: function() { 
			document.getElementById( '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++ ) { 
		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', { 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.
		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 }  ); 
		} 

		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 = document.getElementById( '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 = document.getElementById( '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() { 
		document.getElementById( 'submissions_spinner_id' ).className = 's' + document.getElementsByClassName( 'ready' ).length; 		// '.0' is not a valid selector. 
		document.getElementById( 'images_spinner_id' ).className = 's' + document.getElementsByClassName( 'loading' ).length; 		// Once again: the orange one. 

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

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

}