meduza.io photo downloader

The script lets you download photos from meduza.io in max quality. It adds 2 buttons to each downloadable photo. One of the buttons opens the photo in a new tab in max quality, the second one downloads the photo.

/* 
	meduza.io photo downloader

	Copyright (C) 2022 T1mL3arn

	This program is free software: you can redistribute it and/or modify 
	it under the terms of the GNU General Public License as published by 
	the Free Software Foundation, version 3. 

	This program is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU General Public License for more details.
	You should have received a copy of the GNU General Public License
	along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

// ==UserScript==
// @name        meduza.io photo downloader
// @description The script lets you download photos from meduza.io in max quality. It adds 2 buttons to each downloadable photo. One of the buttons opens the photo in a new tab in max quality, the second one downloads the photo.
// @description:ru Скрипт позволяет скачивать фотографии с meduza.io в максимальном качестве. Он добавляет две кнопки для каждой фотографии, которую можно скачать. Одно кнпока открывает фотографию в новой вкладке в максимальном качестве, другая скачивает фотографию.
// @version     1.0.3
// @match       https://meduza.io/*
// @grant       none
// @author      T1mL3arn
// @icon				https://pbs.twimg.com/profile_images/1315630633952202757/JDNwKd7P_200x200.png
// @license			GPL-3.0-only
// @namespace https://greasyfork.org/users/51268
// ==/UserScript==

const DOWN_BUTTONS_CONTAINER_WRAPPER_PROP = 'mdz-photo-buttons-wrap'
const DOWN_BUTTONS_CONTAINER_CLASS = 'mdz-photo-buttons'
const DOWN_BUTTONS_CONTAINER_PROP = DOWN_BUTTONS_CONTAINER_CLASS
const BUTTON_CLASS = 'mdzd-button'
const BUTTON_CLASS__DOWN = 'mdzd-button--dl'
const BUTTON_CLASS__TAB = 'mdzd-button--tab'
const PROCESSED_FIGURE_ATTR_NAME = 'mdzd-processed'

const CSS = `

.${DOWN_BUTTONS_CONTAINER_CLASS} {
	box-sizing: border-box;
	position: absolute;
	right: 12px;
	/* original button 12px padding + its height 40px + margin 12px*/
	bottom: 64px;

	display: flex;
	flex-flow: column;

	opacity: 0;
	transition: opacity 0.25s ease;
	pointer-events: none;
}

/* disable buttons for small avatar pictures (buttons still available in fullscreen) 
*/
.EmbedBlock-module_xs__PNHGz .${DOWN_BUTTONS_CONTAINER_CLASS} {
	display: none;
}

.${DOWN_BUTTONS_CONTAINER_CLASS} > *:not(:last-child) {
	margin-bottom: 12px;
}

.Image-module_root__H5wAh:hover .${DOWN_BUTTONS_CONTAINER_CLASS} {
	opacity: 1;
}

.Lightbox-module-control > .${DOWN_BUTTONS_CONTAINER_CLASS} {
	margin-top: 12px;
	position: static;
	opacity: 1;
}

.${BUTTON_CLASS} {
	width: 42px;
	height: 42px;
	display: block;

	background-color: rgba(0,0,0,.65);
	background-repeat: no-repeat;
	background-position: 50%;
	background-size: 65%;

	border-radius: 50%;
	opacity: 0.75;
	cursor: pointer;
	pointer-events: auto;
}

.${BUTTON_CLASS}:hover {
	opacity: 1;
}

.${BUTTON_CLASS__DOWN} {
	background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pg0KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjEuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB2aWV3Qm94PSIwIDAgNDg1IDQ4NSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDg1IDQ4NTsiIHhtbDpzcGFjZT0icHJlc2VydmUiIGZpbGw9Im5vbmUiPg0KPGcgc3Ryb2tlPScjRUVFRUVFJyBzdHJva2Utd2lkdGg9JzI1JyBmaWxsPScjRUVFRUVFJz4NCgk8Zz4NCgkJPHBhdGggZD0iTTIzMywzNzguN2MyLjYsMi42LDYuMSw0LDkuNSw0czYuOS0xLjMsOS41LTRsMTA3LjUtMTA3LjVjNS4zLTUuMyw1LjMtMTMuOCwwLTE5LjFjLTUuMy01LjMtMTMuOC01LjMtMTkuMSwwTDI1NiwzMzYuNQ0KCQkJdi0zMjNDMjU2LDYsMjUwLDAsMjQyLjUsMFMyMjksNiwyMjksMTMuNXYzMjNsLTg0LjQtODQuNGMtNS4zLTUuMy0xMy44LTUuMy0xOS4xLDBzLTUuMywxMy44LDAsMTkuMUwyMzMsMzc4Ljd6Ii8+DQoJCTxwYXRoIGQ9Ik00MjYuNSw0NThoLTM2OEM1MSw0NTgsNDUsNDY0LDQ1LDQ3MS41UzUxLDQ4NSw1OC41LDQ4NWgzNjhjNy41LDAsMTMuNS02LDEzLjUtMTMuNVM0MzQsNDU4LDQyNi41LDQ1OHoiLz4NCgk8L2c+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8L3N2Zz4NCg==);
	background-position-y: 37%;
}

.${BUTTON_CLASS__TAB} {
	background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGlkPSJpY29uIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxnIGZpbGw9JyNFRUVFRUUnPgogICAgPHBhdGggZD0iTTI2LDI2SDZWNkgxNlY0SDZBMi4wMDIsMi4wMDIsMCwwLDAsNCw2VjI2YTIuMDAyLDIuMDAyLDAsMCwwLDIsMkgyNmEyLjAwMiwyLjAwMiwwLDAsMCwyLTJWMTZIMjZaIi8+CiAgICA8cGF0aCBkPSJNMjYsMjZINlY2SDE2VjRINkEyLjAwMiwyLjAwMiwwLDAsMCw0LDZWMjZhMi4wMDIsMi4wMDIsMCwwLDAsMiwySDI2YTIuMDAyLDIuMDAyLDAsMCwwLDItMlYxNkgyNloiLz4KICA8L2c+CiAgPHBvbHlnb24gc3Ryb2tlPScjRUVFRUVFJyBzdHJva2Utd2lkdGg9JzInIGZpbGw9JyNFRUVFRUUnIHBvaW50cz0iMjYgNiAyNiAyIDI0IDIgMjQgNiAyMCA2IDIwIDggMjQgOCAyNCAxMiAyNiAxMiAyNiA4IDMwIDggMzAgNiAyNiA2Ii8+CiAgPHJlY3QgaWQ9Il9UcmFuc3BhcmVudF9SZWN0YW5nbGVfIiBkYXRhLW5hbWU9IiZsdDtUcmFuc3BhcmVudCBSZWN0YW5nbGUmZ3Q7IiBmaWxsPSJub25lIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiLz4KPC9zdmc+Cg==);
}
`

const state = {
	savingFormat: 'png',		// webp is also supported
	fsButtons: null, 
}

function initStyle() {
	let elt = document.head.appendChild(document.createElement('style'))
	elt.id = 'meduza-photo-downloader-css'
	elt.textContent = CSS
}

function createDownloadButtons() {
	let downButton = document.createElement('a')
	downButton.classList.add(BUTTON_CLASS, BUTTON_CLASS__DOWN)
	downButton.title = 'Download image in max resolution'

	let tabButton = document.createElement('a')
	tabButton.classList.add(BUTTON_CLASS, BUTTON_CLASS__TAB)
	tabButton.target = "_blank"
	tabButton.title = 'Open image in new tab'

	let div = document.createElement('div')
	div.classList.add(DOWN_BUTTONS_CONTAINER_CLASS)
	div.appendChild(downButton)
	div.appendChild(tabButton)

	return div;
}

function setupButtons(e) {
	const figureElt = e.currentTarget

	// find the best URL
	let urls = figureElt.querySelectorAll(`source[type="image/${state.savingFormat}"]`).item(0).srcset
	// srcset here contains "2x" as the first source, so I just take it
	const bestImageUrl = urls.split(',')[0].split(' ')[0]

	// build the image name
	const { fileName } = getImageInfo(figureElt)

	// setup buttons
	let buttons = figureElt.querySelectorAll(`.${BUTTON_CLASS}`)
	if (buttons.length === 0) {
		// buttons are not here yet, need to add them
		const target = figureElt[DOWN_BUTTONS_CONTAINER_WRAPPER_PROP]
		const buttonsContainer = figureElt[DOWN_BUTTONS_CONTAINER_PROP]
		target.appendChild(buttonsContainer)
		buttons = buttonsContainer.children
	}
	//// download button
	buttons.item(0).href = bestImageUrl
	// Download attribute does not work due to image Content-Disposition header
	// specifies filename, see more https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#sect1 
	// Workaround (not the good one): https://stackoverflow.com/questions/3102226/how-to-set-name-of-file-downloaded-from-browser#answer-73939464
	buttons.item(0).download = fileName
	//// new tab button
	buttons.item(1).href = bestImageUrl

	// setup fullscreen buttons
	let fsButtons = state.fsButtons || (state.fsButtons = createDownloadButtons())
	fsButtons.children.item(0).href = bestImageUrl
	fsButtons.children.item(0).download = fileName
	fsButtons.children.item(1).href = bestImageUrl

	figureElt.querySelector('picture img').addEventListener('click', showFullscreenButtons)
}

function getImageInfo(figureElt) {

	let fullDescription = figureElt.querySelector('.MediaCaption-module_caption__ewfcc')?.textContent || ''
	let fileName = (/(.+?)(?:\.|$)/i).exec(fullDescription)?.[1] || getDate()

	return ({
		fileName: fileName + `.${state.savingFormat}`,
		fullDescription,
	})
}

function getDate() {
	return document.body.querySelector('.MetaItem-module_datetime__--O8c time.Timestamp-module_root__jPJ6w')?.textContent ||new Date().toLocaleDateString('ru-RU', {year: 'numeric', month: 'long', day: 'numeric'})
}

function showFullscreenButtons(e) {
	setTimeout(() => {
		document.querySelector('.Lightbox-module-container .Lightbox-module-control')
			.appendChild(state.fsButtons)
	}, 200);
}

function work() {
	Array(...document.querySelectorAll(`figure.EmbedBlock-module_root__wNZlD:not([${PROCESSED_FIGURE_ATTR_NAME}]), .HalfBlock-module_image__2lYel:not([${PROCESSED_FIGURE_ATTR_NAME}])`))
		.forEach(figureElt => {
			const img = figureElt.querySelector('.Image-module_root__H5wAh')

			if (img) {
				/* 

				I cannot rely on meduza to hold new buttons permanently as children.
				(example of such page where new buttons are removed from markup
				https://meduza.io/feature/2018/08/24/150-let-nazad-rossiya-deportirovala-cherkesov-v-siriyu-teper-oni-begut-obratno-no-i-zdes-im-ne-rady)
				So the buttons are stored as a property of its parent <figure> element

				*/

				figureElt[DOWN_BUTTONS_CONTAINER_PROP] = createDownloadButtons()
				figureElt[DOWN_BUTTONS_CONTAINER_WRAPPER_PROP] = img
				figureElt.setAttribute(PROCESSED_FIGURE_ATTR_NAME, true)
				figureElt.addEventListener('mouseover', setupButtons)
			}
		})
}

(function init() {
	initStyle();

	// TODO make it work for collage pictures 
	// on this https://meduza.io/feature/2022/12/20/chto-tut-bylo page?

	/* 
	
		NOTE: images for which buttons didn't work

		Buttons are added in fullscreen, but not on the preview
		https://meduza.io/feature/2023/03/14/ukraina-obvinila-dvuh-rossiyskih-snayperov-v-iznasilovanii-chetyrehletney-devochki-i-ee-materi-v-kievskoy-oblasti
	*/

	work();
	new MutationObserver(work).observe(document.getElementById('root'), { childList: true, subtree: true })
})()

/*
	pages to test:

	https://meduza.io/feature/2022/12/20/chto-tut-bylo 
	https://meduza.io/feature/2023/03/14/ukraina-obvinila-dvuh-rossiyskih-snayperov-v-iznasilovanii-chetyrehletney-devochki-i-ee-materi-v-kievskoy-oblasti
	https://meduza.io/feature/2018/08/24/150-let-nazad-rossiya-deportirovala-cherkesov-v-siriyu-teper-oni-begut-obratno-no-i-zdes-im-ne-rady

	avatar/photo images (download buttons are disabled by default, but
	still visible in fullscreen):
	https://meduza.io/feature/2023/09/14/moralnaya-otvetstvennost-budet-na-mne-do-kontsa-zhizni
*/