Sleazy Fork is available in English.

Eza's Gallery Swallower

Turn a page of thumbnails into high-res images

Verzia zo dňa 16.03.2021. Pozri najnovšiu verziu.

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

// @exclude    *#dnr
// @noframes
// @version     2.13.61
// @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. 



// What I have so far could easily replace Pixiv Fixiv's current code. The spans with this.parentElement.remove() stuff, anyway; we know the image count beforehand. 
	// For both, I'd like to stretch a thumbnail behind an image as it's loading. I'd need non-square thumbs. Or at least I'd really prefer them. 
	// I guess style=backgroundWhatever:that.src? I keep adding and removing this -> that for some damn reason. 
// I could trivially turn this into a Pixiv Fixiv replacement - right? All I need is any image URL. No JSON shenanigans. 

// Change the BG color to something dark. (Ego says #324.) 
// This accidentally ignores muted images - I'm calling that a feature. 

// This script suggests a generic solution to Twitter nonsense: replace tweet divs with their media. Break classes and IDs so their infinite-scrolling JS can't remove them. No worries about their stupid random class names, since we'd just parent.parent.parent and then .replace(). 
	// Also I could probably just .remove() the sidebar bullshit. 

// And use a damn dark mode already! 
// Buttons for forward/back? Floating over top, maybe. scrollTo stuff. Ech, but it has to update as you manually scroll down, so prev/next are at least consistently relative. 
	// Ideally the focus is somewhere in the middle of the screen, not like, one scanline of an image counts as being 'on' that image. 

// 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? Pawoo. 
/*
https://mastodon.social/tags/art
Array.from( new Set( Array.from( document.querySelectorAll( '.display-name__account' ) ).map( v => v.innerText.split('@')[2] ) ) ).join( '\n' ) 
mastodon.art
mastodon.online
mastodon.social
pawoo.net
queer.af
gensokyo.social
infosec.exchange ?

slashine.onl
mastodont.cat
social.adamasnemesis.com
chitter.xyz
qoto.org
mastodon.sdf.org
photog.social
1929.com
equestria.social
fosstodon.org
mamot.fr
mastodon.technology
mastodon.cloud
wandering.shop
vis.social
todon.nl
pixelfed.social
libranet.de
social.tchncs.de
shrike.club
layer8.space
alive.bar
mastodon.ml
artalley.social
mstdn.social
mastodon.xyz
coletivos.org
framapiaf.org
chaos.social
mograph.social
octodon.social
embassy.social
mast.eu.org
plush.city
edolas.world
paypig.org
toot.elyat.im
friends.deko.cloud
www.librepunk.club
distrotoot.com
toot.wales
redroo.ml
artoot.xyz
Half of these seem to be crap. And it's still just a tiny sample. Yeesh. 
*/
// 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.) 

// Ayrion.com / Eka's Domain? 
	// https://static.aryion.com/g4/thumb/662671-65284-1murk5-thumb.auto.jpg
	// https://aryion.com/g4/data/662671-65284-1murh3.jpg/Slimshod-662671-loli_kitchen_girl_version_2020_01_18_1.jpg
	// https://aryion.com/g4/data/662671-65284-1murh3.jpg/Slimshod-662671.jpg works. 1murk5 doesn't. 
	// But then there's shit like:
	// https://static.aryion.com/g4/thumb/647381-65284-thumb.2640-330-330-330.crop.jpg
	// https://aryion.com/g4/data/647381-65284-1bx9vuq.png/Slimshod-647381-cocktoberfest.png
	// https://aryion.com/g4/view/647381 so the link is no help. 
	// <li class="gallery-item" id="647381"><div><a class="thumb" href="/g4/view/647381"><img src="//static.aryion.com/g4/thumb/647381-65284-thumb.2640-330-330-330.crop.jpg" alt=""></a><p class="item-title">Cocktober Fest [cooking] by MisticHobo</p><p class="g-small"><span>Views: 2,501<span class="biicon11 type-Images"></span></span></p></div></li>
	// Nope, needs fetching. 
	// Sometimes I worry about the mental health of people reading this for the code. Sorry, folks. I'm not here for boring shit. 
// HicceArs? 
// JabArchives.com? Comics deserve it. 
// Youhate.us? Down. 
// https://thehentaiworld.com/tag/romulo-mancin/page/2/ ? Images and manga. 

// CSS-only? Absurd tangent, but GreasyFork now lists CSS userscripts. It's probably possible to replace thumbnails on gallery websites with RegEx'd full-size versions, and fallbacks for each file extension. And obviously I think you can insert a line-break ::after each image and make them full-size or max-width / max-height. But yeesh. 

// Probing is possible without thumbnails - just onLoad( classList add "ready" ). Same results, but slower. Meh. 

// Spent a while agonizing over performance vis-a-vis live HTMLCollections vs. querySelector, and found out it super doesn't matter. 
	// Setting up getElements and then only checking the first one has approximately zero impact on a modern PC. 
	// And the impact of setInterval seems to be approximately dick-all, so long as the timeout isn't zero or effectively zero. 
	// The upshot is that I set the timer to 10 ms and pages just -fly.- The "swallow gallery" button works immediately. Images load instantly. 
		// The latter is a mild concern, because sites might notice. An ideal server interaction looks just like opening a bunch of pages.

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

// I could move html+="<text>" into actual DOM stuff by doing replacement_html = document.createElement( 'html' ). 
	// Main reason not to: it's so much more verbose. Even if I moved append_new_element to the top and used that: text is concise. 



	// To do:

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

// Ayrion. JabArchives. 



	// Genuine bugs:

// https://www.pixiv.net/user/55117629/series/87453?p=2 - gets everything but the manga. Different page format. 

// Ugoira submissions display a big floating X, because those belong to the image-extension-trying block, not to each self-removing image. 
	// Ahh, it's because next_image would try _p0 and fail. This rewrite assumes the first image works. Maybe do cleanup in the interval?
	// Navigation controls make this more of an issue. At least they tuck under the next image's controls, so they're merely unsightly. 

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

// Spinners don't spin on Baraag. (Might be intractable.)

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



// Changes since last version:
	// Changed regex @includes to have start-of-string indicator carets. That was a weird sentence. 
	// Made domain() actually split on period and parse subdomains. The caret thing was more of a security issue. @includes minimize risk. 
	// Submissions with no images remove their container, so: no more orphaned controls. 
	// Changed remove-image buttons so they just set image_containers to display:none. 
		// Goddammit, that doesn't stop DownThemAll from grabbing those links. 
	// Undo button. Z for keyboard shortcut. 
		// Changed undo and remove-image stuff to be object-oriented. Shove elements into a list of objects. Throw in a dummy placeholder. JS handles the details. 
		// Changed removal to ignore removing a submission with no image_containers, since it'd "restore" an empty submission. Correct - but pointless. 
		// Thoroughly fucked with submission_remover and reloader to get undo working sensibly. 
			// Won't set an undo state when initializing - naively, you could "undo" each submission that loaded. 
			// Still DRY as a bone. 
			// Gave .baskets an "unmodified" class, so repeatedly reloading a submission didn't generate undo events. 
		// Added undoable_remove because DRY. 
	// Off-by-one error on image count. (top_anchor has to be .image_container.) 
	// Undefined / null item.image values replaced with empty string, so image_url.replace() doesn't croak. Also a weird sentence. 





// ------------------------------------ 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' ] = 'display: none;'; 		// Also used for hidden probe thumbnails. 
style_rules[ '#image_counter' ] = 'position: absolute; left: 70px; top: 5px; font-size: 33px;';
style_rules[ '.button_spacer' ] = 'visibility: hidden; width: 30px !important; height: 30px;'; 
style_rules[ '.temporary' ] = 'opacity: 0; animation: fadeout 3s;'

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

// Remove / reload controls: 
style_rules[ '.remover' ] = 'color:#dd2e44 !important; background-color:#d7dbd8; border:1px solid #ab1919;';
style_rules[ '.remover:hover' ] = 'background-color:#992a2a;';
style_rules[ '.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:#dbd7d8; border:1px solid #19ab19;';
style_rules[ '.reloader:hover' ] = 'background-color:#2abd2a;';
style_rules[ '.undo' ] = 'position: fixed; right: 0px; bottom: 0px;'; 

// Previous / next buttons, per-submission:
style_rules[ '.nav_button' ] = 'color:#123 !important; background-color:#d7dbd8; border:1px solid #234;';
style_rules[ '.nav_button:hover' ] = 'background-color:#14f;';
style_rules[ '.nav_float' ] = 'z-index: 11; position: absolute; left: 0px; top: initial;';  
style_rules[ '.nav_next_image' ] = 'top: 72px;';  
style_rules[ '.remove_and_advance' ] = 'top: 144px; font-size: 20px !important; color: #a12 !important';  
style_rules[ '.remove_and_advance:hover' ] = 'background-color:#dd2e44;'; 

// Image-size controls:
style_rules[ '#controls' ] = 'float: right; font-size: 33px;';
style_rules[ '.control_button' ] = 'color:#FFF; background-color:#363;'; 
style_rules[ '.control_button:hover' ] = 'background-color:#282;'; 

// Previous / next links, at the bottom:
style_rules[ '.page_links' ] = 'font-size: 33px; !important';

// Thumbnail container and fixed-size thumbnails:
style_rules[ '.thumbnails' ] = 'display: grid; grid-gap: 5px;';  		// Spans can't have fixed size, divs can't flow sensibly. Argh. 
style_rules[ '#thumbnails span' ] = 'width:100px; height:100px; display: inline-block; overflow: hidden;';
style_rules[ '#thumbnails img' ] = 'width:100%;';

style_rules[ 'body' ] = 'line-height: 1.425 !important'; 		// Low values let thumbnails overlap image controls. Gelbooru picks 1.42857143... for some reason. 

// Spinners to indicate loading submissions / loading images:
style_rules[ '.submissions_loader' ] = '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_loader' ] = '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: images_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 ] + ' } \n'; } 
html += '</style> ';

// Spinner animations kinda shit the bed when you querySelector( '@keyframes spin' ). 
html += '<style> @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style>'; 		
html += '<style> @keyframes images_spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style>';  
html += '<style> @keyframes fadeout { from { opacity: 1; } to { opacity: 0; } } </style>';  

html += '<style> #style_check{ display: none; } </style> <div style="position: absolute;" id="style_check"> </div>';  		// Invisible probe for enforce_style. 



	// ----- //			Page elements



// Floaty stuff:
html += '<span id="spinners">'; 		// So enforce_style() can apply to spinners. Sort of. 
html += '<div class="submissions_loader" id="submissions_spinner"></div>' 		// Spinner for submissions, 'new images being found.' Blue. 
html += '<div class="images_loader" id="images_spinner"></div>' 		// Spinner for images, 'images loading in high-res.' Orange. 
html += '<span id="image_counter"></span>'; 
html += '</span>'; 

// Structure:
html += '<br><span id="controls" class=""></span><br><br><br><br>'; 		// Image-size controls. 
html += '<span id="thumbnails"></span><br><br>'; 
html += '<br><br><center><span id="centered" class="' + default_size + '"></span></center>';  		// Where most stuff goes.
html += '<br><br><br>'; 		// Spacing for prev / next links. 
html += '<center><span id="links"></span></center>'; 	// Previous Page / Next Page. 
html += '<br><br><br><br><br>'; 		// Runout.

// 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 button_delay_function = ( () => true ); 	// Don't show Swallow Gallery button while this returns false. (Alternative to @includes.) 
var automatic_pagination = false; 		// Pixiv-style p0, p1, p2, etc. 
var gather_items;  		// Per-site function to scrape current page contents. 
var image_from_dom; 	// Per-site function to scrape fetched HTML (if relevant). 
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_remove.) First in, last out. 




	// ----- //			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, format_list ) { 
	if( format_list == null ) { format_list = formats; } 		// Shut up, it's global. 
	format_list.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.querySelector( '#style_check' ).clientWidth == 0 ) { return; } 		// Inline <style> block, not in style_rules. 
	if( parent == null ) { parent = document; } 		// Apply everywhere by default 
	for( selector in style_rules ) { 		// Global
		Array.from( parent.querySelectorAll( selector ) )
			.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_remove( 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 ]; 
}

// 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. Should be standard!
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; 
}

// Concise, one-step createElement / set attributes / appendChild pattern. 
// Appending is odd, but createElement( 'span', {id:'whatever'} ) should be a stand option.
function append_new_element( target, nodename, attribute_object ) { 
	let element = document.createElement( nodename ); 
	for( attribute in attribute_object ) { element[ attribute ] = attribute_object[ attribute ]; } 
	target.appendChild( element ); 
	return element; 
} 

// Return the next / previous instance of a selector, relative to a given element. 
// I tried doing this as Element.prototype.nextQuery and none of the sites were having it. 
function next_element( origin, selector, direction ) {
	if( direction == null ) { direction = 1; } 
	let reference = origin.closest( selector ); 		// Ancestor or self, whatever. Pass in "this" for the origin and don't worry. 
	let list = Array.from( document.querySelectorAll( selector ) ); 
	let index = list.findIndex( e => e == reference ); 
	return list[ index + direction ]; 
}
function previous_element( origin, selector ) {
	return next_element( origin, selector, -1 ); 
} 

// TypeErrors for getElement / querySelector() .href are aggravating bullshit. 
function safely_return_property( object, property ) { 
	return object ? object[ property ] : null; 
} 

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



	// ----- //			Per-site setup and gather functions 



var args = parse_search( window.location.search ); 		// DRY 

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". 
	automatic_pagination = true; 

	// 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 
	// 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*="250x"]' ) || 		// /users
		document.querySelector( 'img[src*="360x"]' ) || 		// /series
		document.querySelector( 'a *[style*="c/240"]' ); 		// bookmark_new_illust

	gather_items = function() { 
 		// https://www.pixiv.net/user/55117629/series/87453?p=2
		args = parse_search( window.location.search ); 		// Fake page transitions mean this script doesn't restart, so... do this again. 
		let page_number = args["p"] ? parseInt( args["p"] ) : 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/250x250_80_a2/img-master/img/2020/05/15/04/50/43/81571620_p0_square1200.jpg 
		let thumbs = Array.from( document.querySelectorAll( 'a[href*="/art"] img[src*="c/250"]' )  ) 	// en/users/12345 
			.map( img => img.src )
		// https://i.pximg.net/c/360x360_70/img-master/img/2020/06/20/11/14/47/82439841_p0_square1200.jpg
		if( window.location.href.match( 'series' ) ) { 
			thumbs = Array.from( document.querySelectorAll( 'a[href*="/art"] img[src*="c/360"]' ) ) 		// /series
				.map( img => img.src ) } 
		// url("https://i.pximg.net/c/240x240/img-master/img/2021/02/17/04/05/41/87838098_p0_master1200.jpg") 
		if( window.location.href.match( '_new' ) ) { 
			thumbs = 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 pages_at_once = 42; 
	if( window.location.host == 'safebooru.org' ) { pages_at_once = 40; } 
	if( window.location.href.match( '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. 

	gather_items = () => Array.from( document.querySelectorAll( 'a[href*="s=view"]' ) )
		.map( v => new Object( { 
			// https://gelbooru.com/index.php?page=post&s=view&id=4179699&tags=pink_background
			page: 		v.href,
			title: 			v.href.split( '&id=' )[1].split( '&' )[0],
			// 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 
			thumb: 	v.querySelector( 'img' ).src,
			image: 		scrub_extensions( v.querySelector( 'img' ).src )
				.replace( '/thumbnails', '/images' )
				.replace( 'thumbnail_', '' ) 
				.replace( '_thumbnail', '' ) 		// Safebooru
		} ) ) 
}

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_page = safely_return_property( document.querySelector( '#paginator-prev' ), 'href' ); 
	next_page = safely_return_property( document.querySelector( '#paginator-next' ), 'href' ); 

	gather_items = () => Array.from( document.querySelectorAll( 'a[href*="posts/"] img' ) )
		.map( v => v.closest('a') )
		.filter( v => ! v.closest('article').className.match( 'blacklisted-active' ) ) 		// Exclude hidden posts. 
		.map( v => new Object( { 
			// https://e621.net/posts/1333873?q=somik+mirror
			page: 		v.href,
			title: 			v.href.split( 'posts/' )[1].split( '?' )[0],
			// https://static1.e621.net/data/preview/37/75/3775cd8664c688f98a41780f6796ce86.jpg
			// https://static1.e621.net/data/37/75/3775cd8664c688f98a41780f6796ce86.png
			thumb: 	v.querySelector( 'img' ).src,
			image: 		scrub_extensions( v.querySelector( 'img' ).src )
				.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_page = safely_return_property( document.querySelector( 'li.previous a' ), 'href' );
	next_page = safely_return_property( document.querySelector( 'li.next a' ), 'href' );

	button_delay_function = () => document.querySelector( 'a.thumbLink' )
	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.href,
				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' 
			} )
		} ) 
} 

// Baraag... and Mastodon in general, ideally. 
// Out-of-bounds signalling for page count -might- still be useful for Pixiv. That'd let us skip the thumbnail-try stuff. 
// Argh, retweets ("boosts?") aren't of class h-entry. They're h-cite. Can I just querySelector with a comma? Sure can. 
	// Guess that puts a nail in whether to grab links and filter down, or one-shot with complex CSS and work up. 
// https://baraag.net/@MahmaPuu/105784936076784125 - video that breaks gather_items. "TypeError: data is undefined."
	// Apparently a "Video" component instead of "MediaGallery". No .media property. Argh. 
if(	domain( 'baraag.net' ) || 
domain( 'equestria.social' ) || 
domain( 'botsin.space' ) || 
domain( 'pawoo.net' ) || 
domain( 'mastodon.art' ) || 
domain( 'mastodon.social' ) ) { 
	formats = [""];

	// 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].href; next_page = navs[1].href; }  		// Both
		else if( window.location.href.indexOf( '?max_id=' ) > 0 ) { previous_page = navs[0].href; } 		// Last page
		else { next_page = navs[0].href; } 		// 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;
			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 
	formats = [""];  		// Direct image links are right there on the page. 
	previous_page = safely_return_property( Array.from( document.querySelectorAll( 'a' ) ).find( a => a.innerText.match( 'Prev' ) ), 'href' ); 
	next_page = safely_return_property( Array.from( document.querySelectorAll( 'a' ) ).find( a => a.innerText.match( 'Next' ) ), 'href' ); 

	button_delay_function = () => document.querySelector( '.shm-thumb' ); 
	gather_items = () => Array.from( document.querySelectorAll( '.shm-thumb' ) ) 
		.map( v => new Object( { 
			page: 		v.querySelector( '.shm-thumb-link' ).href,
			title: 			v.querySelector( 'img' ).id.replace( 'thumb_', '' ), 
			thumb: 	v.querySelector( 'img' ).src,
			image: 	scrub_extensions( 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_page = safely_return_property( document.querySelector( 'a[alt="back"' ), 'href' ); 
	next_page = safely_return_property( document.querySelector( 'a[alt="next"' ), 'href' ); 

	button_delay_function = () => document.querySelector( '.thumb' ); 
	gather_items = () => Array.from( document.querySelectorAll( '.thumb' ) ) 
		.map( v => new Object( { 
			page: 		v.querySelector( 'a' ).href,
			title: 			v.id,
			// https://miami.rule34.xxx/thumbnails/3500/thumbnail_62b3adfaa4d100c4a6fc7d419f61dd49.jpg?3944961
			// https://us.rule34.xxx//images/3500/62b3adfaa4d100c4a6fc7d419f61dd49.png?3944961
			thumb: 	v.querySelector( 'img[src*="/thumbnails"' ).src,
			image: 	scrub_extensions( v.querySelector( 'img[src*="/thumbnails"' ).src )
				.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 images with "Suggestive" or otherwise not-the-image thumbnails. 
		// It obeys your blacklist, and if you're not signed in, it obeys the default blacklist.
		// This behavior does not match how Image Glutton redirects through those warnings.
		// I'm okay with it, here, because other sites in this script obey blacklists. 
		// 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_page = safely_return_property( document.querySelector( 'a.js-prev' ), 'href' ); 
	next_page = safely_return_property( document.querySelector( 'a.js-next' ), 'href' ); 

	button_delay_function = () => document.querySelector( 'a[title*="Tagged"]' ); 
	gather_items = () => Array.from( document.querySelectorAll( 'a[title*="Tagged"]' ) ) 
		.map( v => new Object( { 
			page: 		v.href, 		// https://derpibooru.org/images/2301306?q=artist%3Ahexado
			title: 			v.href.split( /[\?/]/ )[4], 		// 2301306
			thumb: 	v.querySelector( 'img' ).src, 		// https://derpicdn.net/img/2020/3/19/2301306/thumb.jpg
			image: 	scrub_extensions( v.querySelector( 'img' ).src )
				.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. 
	// This seems to mess with your preview size settings. 
	trigger_size = [ 15, 265, 16, 5 ]; 
	previous_page = safely_return_property( document.querySelector( 'a[title="previous page"' ), 'href' ); 
	next_page = safely_return_property( document.querySelector( 'a[title="next page"' ), 'href' ); 
	html += '<style> span { color: #ddd } </style>';  		// Default colors are grey-on-grey. 

	button_delay_function = () => document.querySelector( '.widget_imageFromSubmission' ); 
	gather_items = function() { 
		return Array.from( document.querySelectorAll( '.widget_imageFromSubmission' ) ) 
			.map( v => new Object( { 
				page: 		v.querySelector( 'a' ).href,
				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' ).href ) ; 		// String works, [ string ] doesn't. Hmm. 
		} 
		return images; 
	}
}

if( domain( 'furaffinity.net' ) ) {
	// https://www.furaffinity.net/gallery/mab/folder/808215/Divaea 
	trigger_size = [ 15, 155, 16, 5 ]; 
	formats = [""];

	// https://www.furaffinity.net/scraps/mab/
	// https://www.furaffinity.net/scraps/mab/2/
	// https://www.furaffinity.net/gallery/mab/folder/43380/Wildcard/2/
	// Not even links, on FA - they're form buttons. What year is it? 
	// But then your feed is like:
	// https://www.furaffinity.net/msg/submissions/new~40875552@48/ 
	// And the "next" button is actually 'a.more', except past the first page, where it's 'a.more-half prev'. Argh.
	if( window.location.href.match( 'msg/submissions' ) ) { 
		let buttons = Array.from( document.querySelectorAll( 'a.more-half, a.more' ) ); 
		previous_page = safely_return_property( buttons.find( v => v.className.match( 'prev' ) ), 'href' ); 
		next_page = safely_return_property( buttons.find( v => ! v.className.match( 'prev' ) ), 'href' ); 
	} else { 
		previous_page = safely_return_property( Array.from( document.querySelectorAll( 'form' ) )
			.find( v => v.innerText.match( 'Prev' ) ), 'action' ); 
		next_page = safely_return_property( Array.from( document.querySelectorAll( 'form' ) )
			.find( v => v.innerText.match( 'Next' ) ), 'action' );
	} 

	button_delay_function = () => document.querySelector( 'figure.t-image' ); 
	gather_items = function() { 
		let items = Array.from( document.querySelectorAll( 'figure.t-image' ) ) 
			.map( v => new Object( { 
				page: 		v.querySelector( 'a' ).href,
				title: 		v.querySelector( 'a' ).href.split('/')[4],
				thumb: 	v.querySelector( 'img' ).src,
			} ) )
		// 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 = function( doc ) { 
		return 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. 
	formats=[""]; 
	previous_page = safely_return_property( document.querySelector( '.paginator-prev' ), 'href' ); 
	next_page = safely_return_property( document.querySelector( '.paginator-next' ), 'href' ); 

	button_delay_function = () => document.querySelector( 'article.post-preview a' ); 
	gather_items = () => Array.from( document.querySelectorAll( 'article.post-preview a' ) )
		.map( v => new Object( { 
			page: 		v.href,
			title: 			v.href.split( 'posts/' )[1].split( '?' )[0],
			thumb: 	v.querySelector( 'img' ).src,
		} ) )

	image_from_dom = function( doc ) { 
		return 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_page = safely_return_property( document.querySelector( 'a[alt="back"]' ), 'href' ); 
	next_page = safely_return_property( document.querySelector( 'a[alt="next"]' ), 'href' ); 

	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 => new Object( { 
			// https://svtfoe.booru.org/index.php?page=post&s=view&id=29292
			page: 		v.href,
			title: 			v.href.split( '=' ).pop(), 
		 	// https://thumbs.booru.org/svtfoe/thumbnails//28/thumbnail_187209e3ca22a28fd1ce75a7fd4f54aee3cf1e62.jpg
			// https://img.booru.org/svtfoe//images/28/187209e3ca22a28fd1ce75a7fd4f54aee3cf1e62.jpg
			thumb: 	v.querySelector( 'img[src*="thumbs"]' ).src, 
			image: 		scrub_extensions( v.querySelector( 'img[src*="thumbs"]' ).src ) 
				.replace( 'thumbs.', 'img.' )
				.replace( '/thumbnails', '/images' )
				.replace( 'thumbnail_', '' ) 
		} ) ) 
}



	// ----- //			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.
var trigger = document.createElement( 'button' ); 
trigger.innerText = "Swallow gallery"; 
trigger.className = "unclicked_button"; 
trigger.onclick = function() { this.innerText='Swallowing...'; show_images(); }  		// Oh. I didn't know I could just call that from here. 
trigger.style = "position: absolute; left: " + trigger_size[0] + "px; top: " + trigger_size[1] + "px; 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; font-size:" + trigger_size[2] + "px; padding: " + trigger_size[3] + "px " + trigger_size[3] + "px; text-decoration:none;"

add_button = setInterval( function() { 
		if( button_delay_function() ) { 		// Periodically check if this page has what we're looking for. 
			clearInterval( add_button ); 
			document.body.appendChild( trigger ); 
		}  
	}, 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; 

	// Just in case. Fetch traffic might read differently than inline images. 
	if( image_from_dom ) { new_image_rate = fetch_rate; } 

	function add_button( text, onclick ) {  		// DRY. Specific to #controls, for brevity. 
		append_new_element( controls, 'button', { innerText: text, onclick: onclick, className: "control_button eza_button" } );
		controls.appendChild( document.createTextNode( " " ) ); 		// Asinine way to force spacing. 
	} 

	// Controls - e.g. image size and order.
	controls = document.getElementById( 'controls' ); 
	controls.appendChild( document.createTextNode( "Size: " ) );  
	add_button( "▣", function() { centered.className = "short"; enforce_style(); } ); 
	add_button( "↔", function() { centered.className = "fit_width"; enforce_style(); } ); 
	add_button( "↕", function() { centered.className = "fit_height"; enforce_style(); } ); 
	add_button( "✢", function() { centered.className = "fit_window"; enforce_style(); } ); 
	add_button( "■", function() { centered.className = "full"; enforce_style(); } ); 
	controls.appendChild( document.createTextNode( " Order: " ) ); 
	add_button( "⇅", function() { 
		for( id of [ 'centered', 'thumbnails' ] ) {  		// DRY
			let node = document.getElementById( id ); 
			node.childNodes.forEach( (v,i,a) => node.insertBefore( node.childNodes[i], node.firstChild ) ); 
		} 
	} )

	// Navigation links, at the bottom.
	let links_element = document.getElementById( 'links' ); 
	let link_html = ""; 		// "Previous" or "Previous - Next" or "Next"
	if( previous_page ) { link_html += "<a href='" + ( previous_page ) + "'>Previous page</a>"; } 
	if( previous_page && next_page ) { link_html += " - "; } 
	if( next_page ) { link_html += "<a href='" + ( next_page ) + "'>Next page</a>"; } 
	links_element.className = 'page_links'; 
	links_element.innerHTML = link_html; 

	// Global undo button, pinned to the corner.
	let global_undo = append_new_element( document.body, 'button', { innerText: '⟲', 	
		className: 'reloader undo eza_button', id: 'global_undo', 
		onclick: function() { 
			console.log( undo_list ); 
			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(); 
		} } ); 



	// ----- //			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 = append_new_element( document.getElementById( 'centered' ), 'div',
			{ id: item_key + 'container', className: "submission" } )

		// ← ⟳ 12345 ✕ →
		let nav_previous = append_new_element( container, 'button', { className: 'nav_button previous eza_button', innerText: '←', 
			onclick: function() { previous_element( this, '.submission' ).scrollIntoView(); } } );
		let first_spacer = append_new_element( container, 'button', { className: 'button_spacer eza_button' } ); 
		let reloader = append_new_element( container, 'button', { className: 'reloader eza_button', innerText: '⟳', 
			onclick: function() {
				let pair = undoable_remove( this.closest( '.submission' ).querySelector( '.basket' ), 
					append_new_element( document.body, 'span', { id: item_key, className: 'ready basket unmodified' } ) ); 
		 		// If the old basket was unchanged or nonexistent, don't keep its "undo" state. 
				if( pair.original.classList.contains( 'initialize' ) || pair.original.classList.contains( 'unmodified' ) ) { undo_list.pop(); }  
			} } );
		let link = append_new_element( container, '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 = append_new_element( container, '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_remove( this.closest( '.submission' ).querySelector( '.sub_basket' ), document.createElement( 'span' ) ); 
			} } );
		let second_space = append_new_element( container, 'button', { className: 'button_spacer eza_button' } ); 
		let nav_next = append_new_element( container, 'button', { className: 'nav_button next eza_button', innerText: '→', 
			onclick: function() { next_element( this, '.submission' ).scrollIntoView(); } } ); 
		append_new_element( container, 'br' ); 

		// Image(s) and the ✕ at the bottom. 
		let basket = append_new_element( container, 'span', { id: item_key, className: 'basket initialize' } ); 		// Hold off on ready-ing. 
		let bottom_submission_remover = append_new_element( container, '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 = append_new_element( document.getElementById( 'thumbnails' ), 'span', { 
			onclick: function() { document.getElementById( item_key + 'container' ).scrollIntoView(); } } )
		let thumbnail_image = append_new_element( thumb_box, 'img', { src: item.thumb }  ); 

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

		reloader.click(); 		// DRY
	}  



	// ----- //			Keyboard controls



	if( keyboard_controls ) { 
		document.addEventListener( "keydown", key_handler, false );
		function key_handler( event ) { 
			// Find "current" elements. I.e., which image / submission is onscreen. (A fuzzy question.) 
			current_image = Array.from( document.querySelectorAll( '.image_container' ) )
				.find( image => image.getBoundingClientRect().top + image.scrollHeight > 100 ); // > image.scrollHeight * 0.5? 
			if( ! current_image ) { current_image = Array.from( document.querySelectorAll( '.image_container' ) ).pop(); } 
			// Scrolling past the last image can cause no selection, so pick the last image. 

			current_submission = Array.from( document.querySelectorAll( '.submission' ) )
				.find( sub => sub.getBoundingClientRect().top + sub.scrollHeight > 75 ); // > window.screenY / 2? 
				// 100 seems a bit fucky with multiple failed-image posts? 
				// E.g. https://pawoo.net/@blooddj/media?page=24 circa 103169897373770181. 
			if( ! current_submission ) { current_submission = Array.from( document.querySelectorAll( '.submission' ) ).pop(); } 

			if( event.key == 'a' ) { current_image.querySelector( '.nav_previous_image' ).click(); } 
			if( event.key == 'd' ) { current_image.querySelector( '.nav_next_image' ).click(); } 
			if( event.key == 'q' ) { current_image.querySelector( '.remover.floating' ).click(); } 
			if( event.key == 'e' ) { current_image.querySelector( '.remove_and_advance' ).click(); } 

			if( event.key == 'w' ) { current_submission.querySelector( '.previous' ).click(); } 
			if( event.key == 's' ) { current_submission.querySelector( '.next' ).click(); } 
			if( event.key == 'r' ) { current_submission.querySelector( '.reloader' ).click(); } 
			if( event.key == 'x' ) { current_submission.querySelector( '.remover.bottom' ).click(); } 

			if( event.key == 'z' ) { document.querySelector( '#global_undo' ).click(); } 		// 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 = append_new_element( document.querySelector( '#spinners' ), 'div', { className: 'image_container submission', 
			style: 'position: absolute; top: 0px; left: 0px; height: 300px; visibility: hidden;' } ); 		// display:none breaks scrollIntoView.  
		let first_image = append_new_element( top_anchor, 'button', { className: 'nav_next_image remove_and_advance',
			onclick: function() { next_element( this, '.image_container' ).scrollIntoView(); } } ); 
		let first_submission = append_new_element( top_anchor, 'button', { className: 'next',
			onclick: function() { next_element( this, '.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_id = parseInt( ready_element.id ); 		// This should maybe be data-id or something. 

				// If we need to fetch, we don't have an image to display yet. 
				if( image_from_dom && items[ item_id ].image == null ) { 		// Note: will not re-fetch. Bad item.image value? Tough. 
					standard_fetch( items[ item_id ], ready_element ); 
					return;  		// Exit interval. 
				} 

				if( isNaN( ready_element.dataset.image_number ) ) { ready_element.dataset.image_number = 0; } 		// No initial value required. 
				let manga_page = parseInt( ready_element.dataset.image_number ); 		// Not web-page... comic-page. 
				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" mans the basket still exists... somewhere. Seems like a solid "mostly." 
				let outer_span = ready_element.querySelector( '.sub_basket' );  
				if( outer_span == null ) { outer_span = append_new_element( ready_element, 'span', { className: 'sub_basket' } ); } 		// Two hard problems. 
				let inner_div = append_new_element( outer_span, 'div', { style: 'position: relative;', className: 'image_container' } ); 

				// Place stuff on the page. 
				let previous_image = append_new_element( inner_div, 'button', { innerText: '←', 
					className: 'nav_button nav_float nav_previous_image eza_button', 
					onclick: function() { previous_element( this, '.image_container' ).scrollIntoView(); } } ); 
				let next_image = append_new_element( inner_div, 'button', { innerText: '→', 
					className: 'nav_button nav_float nav_next_image eza_button', 
					onclick: function() { next_element( this, '.image_container' ).scrollIntoView(); } } ); 
				let remove_and_advance = append_new_element( inner_div, 'button', { innerText: '✕→', 
					className: 'remover nav_button remove_and_advance nav_float eza_button', 
					onclick: function() {
						let next = next_element( this, '.image_container' ); 
						this.closest( '.image_container' ).querySelector( '.remover.floating' ).click(); 		// DRY - click other remove button. 
						next.scrollIntoView(); 

						let toaster = append_new_element( document.body, 'span', { innerText: '✕ Image removed', 
							style: 'position: fixed; left: 60px; top: 0px; animation: fadeout 3s;' } ) 
						setTimeout( () => { toaster.remove(); }, 3000 ); 
					} } ); 

				// The actual image. Or, several attempts to find the actual image, each with different file extensions. 
				let image_url = items[ item_id ].image; 				// Prepare filename once. 
				if( typeof( items[ item_id ].image ) == "object" ) { image_url = items[ item_id ].image[ manga_page ]; }
				if( ! image_url ) { image_url = ''; } 		// Getting an Undefined or Null makes the script stumble. Clunky debug-ish fix. 
				if( automatic_pagination ) { image_url = image_url.replace( '%number', manga_page ) } 		// Basically just Pixiv.  
				for( format of formats ) { 		// Try all plausible file extensions for an inline image. 
// 					console.log( image_url ); 		// Debug. 
					let apng = append_new_element( inner_div, 'a', { href: image_url.replace( '%format', format ), target: '_generic' } ); 
					append_new_element( apng, 'img', { src: image_url.replace( '%format', format ), className: 'loading', 
						onerror: function() { 
							let container = this.closest( '.image_container' ); 		// If all images disappear, remove the container. 
							this.closest( 'a' ).remove(); 		// Also destroys "this". 
							setTimeout( () => { if( ! container.querySelector( 'img' ) ) { container.remove(); } }, 1000 ); 		// Goddamn race conditions. 
						}, 		// Remove parent link as well. 
						onload: function() { this.classList.remove( "loading" ); } } );  
				} 

				let remover = append_new_element( inner_div, 'button', { innerText: '✕', className: 'remover floating eza_button', 
					onclick: function() { 
						undoable_remove( this.closest( '.image_container' ), document.createElement( 'span' ) ); 
					} } ); 		// Newline format purely to avoid "));}});". 
				append_new_element( inner_div, 'br' ); 
				append_new_element( inner_div, 'br' ); 



				// Probe-try stuff:
				if( automatic_pagination ) { 
					for( format of formats ) { 
						append_new_element( inner_div, 'img', { className: 'invisible', 
							src: items[ item_id ].probe.replace( '%format', format ).replace( '%number', (manga_page+1) ),
							onerror: function() { this.remove(); }, 
							onload: function() { this.closest( '.basket' ).classList.add( "ready" ); this.remove();  } } )  
					} 
				}

				// Array-of-images stuff:
				if( typeof( items[ item_id ].image ) == "object" ) { 		// Direct testing beats keeping track. 
					if( manga_page + 1 < items[ item_id ].image.length ) { 
						ready_element.classList.add( "ready" ); 
					} 
				}

				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." 
	// Barring a CSS :has property (CSS4 / ES22, maybe,) it is impossible to style an element based on its children. So the JS stays.
		// We could probably add a spinner to each image_container, and give it absolute position in the window... maybe.
		// That would be massively redundant, to the point I'd worry about performance, and it'd look exactly the same as this. 
		// Unless you made each 'spinner' a slowly rotating stick, and they each started when the image appeared, so you got a 'fascinator' look. Eh. 
	// Easy answer for responsive loading / lazy spinners: use another interval. 
	// Hoisted global variables caused weird behavior, and getElementsByClassName[0] has fuck-all performance impact. 
	var spinner_interval = 	setInterval( function() { 
			submissions_spinner.className = 'submissions_loader'; 	// While submissions are still adding images. (This is the blue one.)
			if( ! document.getElementsByClassName( 'ready' )[0] ) { submissions_spinner.className = 'invisible'; } 

			images_spinner.className = 'images_loader'; 	// While images are still downloading. (This is the orange one.)
			if( ! document.getElementsByClassName( 'loading' )[0] ) { images_spinner.className = 'invisible'; } 

			document.getElementById( 'image_counter' ).innerText = 
				'' + ( document.getElementsByClassName( 'image_container' ).length - 1 ) + ' images'; 		// -1 for top_anchor.  

			enforce_style( document.getElementById( 'spinners' ) ); 
	}, 1000 ); 

}