Instagram Source Opener

Open the original source of an IG post, story or profile picture. No jQuery

目前為 2020-02-12 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Instagram Source Opener
// @version      0.9.1
// @description  Open the original source of an IG post, story or profile picture. No jQuery
// @author       jomifepe
// @icon         https://www.instagram.com/favicon.ico
// @require      https://cdnjs.cloudflare.com/ajax/libs/arrive/2.4.1/arrive.min.js
// @include      https://www.instagram.com/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @namespace 	  https://gf.qytechs.cn/users/192987
// ==/UserScript==

(function() {
	"use strict"
	const LOGGING_ENABLED = false

	/* NOTE: this script relies a lot on class names, I'll keep an eye on changes */

	/* Instagram classes and selectors */
	const IG_C_STORY_CONTAINER = "yS4wN",
	      IG_C_STORY_MEDIA_CONTAINER = "qbCDp",
	      IG_C_POST_IMG = "FFVAD",
	      IG_C_POST_VIDEO = "tWeCl",
	      IG_C_SINGLE_POST_CONTAINER = "JyscU",
	      IG_C_MULTI_POST_SCROLLER = "MreMs",
	      IG_C_MULTI_POST_LIST_ITEM = "_-1_m6",
	      IG_C_POST_CONTAINER = "_8Rm4L",
	      IG_S_POST_BUTTONS = ".eo2As > section",
	      IG_C_PROFILE_PIC_CONTAINER = "RR-M-",
	      IG_C_PRIVATE_PROFILE_PIC_CONTAINER = "M-jxE",
	      IG_C_PRIVATE_PIC_IMG_CONTAINER = "_2dbep",
	      IG_C_PRIVATE_PROFILE_PIC_IMG_CONTAINER = "IalUJ",
	      IG_C_PROFILE_CONTAINER = "v9tJq",
	      IG_C_PROFILE_USERNAME_TITLE = "fKFbl",
	      IG_C_INSTAGRAM_POST_BLOCKER = "_9AhH0"

	/* Custom classes and selectors */
	const C_BTN_STORY = "iso-story-btn",
	      C_BTN_STORY_CONTAINER = "iso-story-container",
	      C_POST_WITH_BUTTON = "iso-post",
	      C_BTN_POST_OUTER_SPAN = "iso-post-container",
	      C_BTN_POST = "iso-post-btn",
	      C_BTN_POST_INNER_SPAN = "iso-post-span",
	      C_BTN_PROFILE_PIC_CONTAINER = "iso-profile-pic-container",
	      C_BTN_PROFILE_PIC = "iso-profile-picture-btn",
	      C_BTN_PROFILE_PIC_SPAN = "iso-profile-picture-span"

	const S_IG_POST_CONTAINER_WITHOUT_BUTTON = `.${IG_C_POST_CONTAINER}:not(.${C_POST_WITH_BUTTON})`

	/* Local storage keys */
	const POST_STORY_KB_STORAGE_KEY = "iso_post_story_kb",
	      PROFILE_PICTURE_KB_STORAGE_KEY = "iso_profile_picture_kb"

	/* Default letters for key bindings */
	const DEFAULT_POST_STORY_KB = "O",
	      DEFAULT_PROFILE_PICTURE_KB = "P"

	/* Global scope variables */
	let isStoryKeyBindingSetup = false,
	    isSinglePostKeyBindingSetup = false,
	    isProfileKeyBindingSetup = false
	let openPostStoryKeyBinding = DEFAULT_POST_STORY_KB,
	    openProfilePictureKeyBinding = DEFAULT_PROFILE_PICTURE_KB


	/* BEGIN SCRIPT ------------------------------ */

	registerMenuCommands()

	/* injects the needed CSS into DOM */
	injectStyles()

	/* triggered whenever a new instagram post is loaded on the feed */
	document.arrive(S_IG_POST_CONTAINER_WITHOUT_BUTTON, node => generatePostButton(node))
	/* triggered whenever a single post is opened (on a profile) */
	document.arrive(`.${IG_C_SINGLE_POST_CONTAINER}`, node => {
		generatePostButton(node)
		setupSinglePostEventListeners()
	})
	/* triggered whenever a story is opened */
	document.arrive(`.${IG_C_STORY_CONTAINER}`, node => {
		generateStoryButton(node)
		setupStoryEventListeners()
	})
	/* triggered whenever a profile page is loaded */
	document.arrive(`.${IG_C_PROFILE_CONTAINER}`, node => {
		generateProfilePictureButton(node)
		setupProfileEventListeners()
	})

	/* triggered whenever a single post is closed (on a profile) */
	document.leave(`.${IG_C_SINGLE_POST_CONTAINER}`, node => removeSinglePostEventListeners())
	/* triggered whenever a story is closed */
	document.leave(`.${IG_C_STORY_CONTAINER}`, node => removeStoryEventListeners())
	/* triggered whenever a profile page is left */
	document.leave(`.${IG_C_PROFILE_CONTAINER}`, node => removeProfileEventListeners())

	/**
	 * Window load callback
	 * Checks if there are relevant nodes already loaded in DOM and performs the corresponding actions
	 */
	window.onload = (e => {
		if (/* is on post feed */ window.location.pathname === '/') {
			let postArticles = document.querySelectorAll(S_IG_POST_CONTAINER_WITHOUT_BUTTON)
			postArticles.forEach(node => generatePostButton(node))
		} else if (isOnSinglePostPage()) {
			let node = document.querySelector(`.${IG_C_SINGLE_POST_CONTAINER}`)
			if (node != null) {
				generatePostButton(node)
			}
			setupSinglePostEventListeners()
		} else if (isOnStoryPage()) {
			let node = document.querySelector(`.${IG_C_STORY_CONTAINER}`)
			if (node == null) {
				generateStoryButton(node)
			}
			setupStoryEventListeners()
		} else if (isOnProfilePage()) {
			let node = document.querySelector(`.${IG_C_PROFILE_CONTAINER}`)
			if (node != null) {
				generateProfilePictureButton(node)
			}
			setupProfileEventListeners()
		}
	})

	/**
	 * Creates the commands to appear on the menu created by the <Any>monkey extension that's being used
	 * For example, on Tampermonkey, this menu is accessible by clicking on the extension icon
	 */
	function registerMenuCommands() {
		logMessage("Registered menu commands")
		GM_registerMenuCommand("Change post/story key binding", handlePostStoryKBMenuCommand, null)
		GM_registerMenuCommand("Change profile picture key binding", handleProfilePictureKBMenuCommand, null)
	}

	/**
	 * Handles the click action on the option to change the single post and story opening key binding, on the settings menu
	 */
	function handlePostStoryKBMenuCommand() {
		handleKBMenuCommand(POST_STORY_KB_STORAGE_KEY, DEFAULT_POST_STORY_KB, "single post and story")
			.then(newKeyBinding => {
				openPostStoryKeyBinding = newKeyBinding
				if (isOnSinglePostPage()) {
					removeSinglePostEventListeners()
					setupSinglePostEventListeners()
				} else if (isOnStoryPage()) {
					removeStoryEventListeners()
					setupSinglePostEventListeners()
				}
			})
	}

	/**
	 * Handles the click action on the option to change the profile picture opening key binding, on the settings menu
	 */
	function handleProfilePictureKBMenuCommand() {
		handleKBMenuCommand(PROFILE_PICTURE_KB_STORAGE_KEY, DEFAULT_PROFILE_PICTURE_KB, "profile picture")
			.then(newKeyBinding => {
				openProfilePictureKeyBinding = newKeyBinding
				removeProfileEventListeners()
				setupProfileEventListeners()
			})
	}

	/**
	 * Generic handler for the click action on the key binding changing options of the settings menu.
	 * Launches a prompt that asks the user for a new key binding for a specific action, saves it on local storage and returns it on promise resolve
	 * @param {string} keyBindingStorageKey Local storage key used to store the key binding
	 * @param {string} defaultKeyBinding Default key binding, used on the prompt message
	 * @param {string} keyBindingName Key binding name to show on log messages, just for context
	 * @returns {Promise} Promise object, when resolved contains the new key binding and when rejected contains
	 * an exception error object or nothing when the prompt was canceled or the input was left empty
	 */
	function handleKBMenuCommand(keyBindingStorageKey, defaultKeyBinding, keyBindingName) {
		return new Promise(async (resolve, reject) => {
			let currentKey = defaultKeyBinding
			try {
				currentKey = await GM_getValue(keyBindingStorageKey, defaultKeyBinding)
			} catch (error) {
				logError(`Failed to get current "${keyBindingName} key binding, considering default (Alt + ${defaultKeyBinding})`, error)
			}

			let newKeyBinding = prompt(`Enter a new letter for the key binding used to open a ${keyBindingName}
This letter then can be combined with the Alt key to perform said action\n
Current key binding: Alt + ${(await currentKey).toUpperCase()}`)
			if (newKeyBinding != null) {
				if (isKeyBindingValid(newKeyBinding)) {
					try {
						await GM_setValue(keyBindingStorageKey, newKeyBinding)
						showMessage(`Saved new key binding to open ${keyBindingName}: Alt + ${newKeyBinding.toUpperCase()}`)
						resolve(newKeyBinding)
					} catch (error) {
						showAndLogError(`Failed to save new key binding to open ${keyBindingName}`, error)
						reject(error)
					}
				}
			} else {
				reject()
			}
		})
	}

	/**
	 * Appends new elements to DOM containing the story source opening button
	 * @param {Object} node DOM element node
	 */
	function generateStoryButton(node) {
		/* exits if the story button already exists */
		if (elementExistsInNode(`.${C_BTN_STORY_CONTAINER}`, node)) return

		try {
			let buttonStoryContainer = document.createElement("span")
			let buttonStory = document.createElement("button")

			buttonStoryContainer.classList.add(C_BTN_STORY_CONTAINER)
			buttonStory.classList.add(C_BTN_STORY)
			buttonStoryContainer.setAttribute("title", "Open source")
			buttonStory.addEventListener("click", () => openStoryContent(node))

			buttonStoryContainer.appendChild(buttonStory)
			node.appendChild(buttonStoryContainer)
		} catch (exception) {
			logError("Failed to generate story button", exception)
		}
	}

	/**
	 * Appends new elements to DOM containing the post source opening button
	 * @param {Object} node DOM element node
	 */
	function generatePostButton(node) {
		/* exits if the post button already exists */
		if (elementExistsInNode(`.${C_BTN_POST_OUTER_SPAN}`, node)) return

		try {
			/* removes the div that's blocking the img element on a post */
			let blocker = node.querySelector(`.${IG_C_INSTAGRAM_POST_BLOCKER}`)
			if (blocker) blocker.parentNode.removeChild(blocker)

			let buttonsContainer = node.querySelector(IG_S_POST_BUTTONS)
			let newElementOuterSpan = document.createElement("span")
			let newElementButton = document.createElement("button")
			let newElementInnerSpan = document.createElement("span")

			newElementOuterSpan.classList.add(C_BTN_POST_OUTER_SPAN)
			newElementButton.classList.add(C_BTN_POST)
			newElementInnerSpan.classList.add(C_BTN_POST_INNER_SPAN)
			newElementOuterSpan.setAttribute("title", "Open source")
			newElementButton.addEventListener("click", () => openPostSourceFromSrcAttribute(node))

			newElementButton.appendChild(newElementInnerSpan)
			newElementOuterSpan.appendChild(newElementButton)
			buttonsContainer.appendChild(newElementOuterSpan)
			node.classList.add(C_POST_WITH_BUTTON)
		} catch (exception) {
			logError("Failed to generate post button", exception)
		}
	}

	/**
	 * Appends new elements to DOM containing the profile picture source opening button
	 * @param {Object} node DOM element node
	 */
	function generateProfilePictureButton(node) {
		/* exits if the profile picture button already exists */
		if (elementExistsInNode(`.${C_BTN_PROFILE_PIC_CONTAINER}`, node)) return

		try {
			let profilePictureContainer = node.querySelector(`.${IG_C_PROFILE_PIC_CONTAINER}`)
			/* if the profile is private and the user isn't following or isn't logged in */
			if (!profilePictureContainer) {
				profilePictureContainer = node.querySelector(`.${IG_C_PRIVATE_PROFILE_PIC_CONTAINER}`)
			}
			let newElementOuterSpan = document.createElement("span")
			let newElementButton = document.createElement("button")
			let newElementInnerSpan = document.createElement("span")
			newElementOuterSpan.setAttribute("title", "Open full size picture")
			newElementButton.addEventListener("click", e => {
				e.stopPropagation()
				openProfilePicture()
			})

			newElementOuterSpan.classList.add(C_BTN_PROFILE_PIC_CONTAINER)
			newElementButton.classList.add(C_BTN_PROFILE_PIC)
			newElementInnerSpan.classList.add(C_BTN_PROFILE_PIC_SPAN)

			newElementButton.appendChild(newElementInnerSpan)
			newElementOuterSpan.appendChild(newElementButton)
			profilePictureContainer.appendChild(newElementOuterSpan)
		} catch (error) {
			logError(error)
		}
	}

	/**
	 * Gets the story source url from the src attribute on the node and opens it in a new tab
	 * @param {Object} node DOM element node
	 */
	function openStoryContent(node = null) {
		try {
			let container = (node || document).querySelector(`.${IG_C_STORY_MEDIA_CONTAINER}`)
			let video = container.querySelector("video")
			let image = container.querySelector("img")
			if (video) {
				let videoElement = video.querySelector("source")
				let videoSource = videoElement ? videoElement.getAttribute("src") : null
				if (!videoSource) {
					throw "Video source isn't available"
				}
				window.open(videoSource, "_blank")
			} else if (image) {
				let imageSource = image.getAttribute("src")
				if (!imageSource) {
					throw "Image source isn't available"
				}
				window.open(imageSource, "_blank")
			} else {
				throw "Story media isn't available"
			}
		} catch (exception) {
			showAndLogError("Failed to open story source", exception)
		}
	}

	/**
	 * Gets the source url of a post from the src attribute on the node and opens it in a new tab
	 * @param {Object} node DOM element node
	 */
	function openPostSourceFromSrcAttribute(node = null) {
		/* if is on single post page and the node is null, the picture container can be found, since there's only one */
		if (node == null) {
			node = document.querySelector(`.${IG_C_SINGLE_POST_CONTAINER}`)
			if (node == null) return
		}

		let nodeListItems = node.querySelectorAll(`.${IG_C_MULTI_POST_LIST_ITEM}`)

		try {
			if (/* is multi post */ nodeListItems.length != 0) {
				let scroller = node.querySelector(`.${IG_C_MULTI_POST_SCROLLER}`)
				let scrollerOffset = Math.abs((() => {
					let scrollerStyles = window.getComputedStyle(scroller)
					return parseInt(scrollerStyles.getPropertyValue("transform").split(",")[4])
				})())

				let mediaIndex = 0
				if (scrollerOffset != 0) {
					let totalWidth = 0
					nodeListItems.forEach(item => {
						let itemStyles = window.getComputedStyle(item)
						totalWidth += parseInt(itemStyles.getPropertyValue("width"))
					})
					mediaIndex = ((scrollerOffset * nodeListItems.length) / totalWidth)
				}

				openPostMediaSource(nodeListItems[mediaIndex])
			} else /* is single post */ {
				openPostMediaSource(node)
			}
		} catch (exception) {
			showAndLogError("Failed o open post source", exception)
		}
	}

	/**
	 * Gets the source url of a post from the src attribute on the node and opens it in a new tab
	 * @param {Object} node DOM element node
	 */
	function openPostMediaSource(node) {
		let image = node.querySelector(`.${IG_C_POST_IMG}`)
		let video = node.querySelector(`.${IG_C_POST_VIDEO}`)
		if (!image && !video) {
			throw "Failed to open source, no media found"
		}
		window.open((video || image).getAttribute("src"), "_blank")
	}

	/**
	 * Tries to get the source URL of the user's profile picture using multiple methods, including 3rd party websites
	 * Opens the image in a new tab or shows an alert if it doesn't find any URL
	 */
	async function openProfilePicture() {
		let pageUsername = document.querySelector(`.${IG_C_PROFILE_USERNAME_TITLE}`).innerText
		let profilePageData = _sharedData.entry_data.ProfilePage

		var pictureUrl = null
		document.body.style.cursor = "wait"

		logMessage("Trying to get user's profile picture from 3rd party websites", true)

		/* trying to get the picture from instadp.org */
		try {
			logMessage("Trying to get user's profile picture from instadp.org")
			pictureUrl = await getProfilePictureFromInstadpDotOrg(pageUsername)
			if (!(await pictureUrl)) logError("No profile picture url found on instadp.org response")
		} catch (error) {
			logError("Couldn't get picture from instadp.org", error)
		}

		if (!pictureUrl) {
			/* trying to get the picture from instadp.com */
			try {
				logMessage("Trying to get user's profile picture from instadp.com")
				pictureUrl = await getProfilePictureFromInstadpDotCom(pageUsername)
				if (!(await pictureUrl)) logError("No profile picture url found on instadp.com response")
			} catch (error) {
				logError("Couldn't get picture from instadp.com", error)
			}
		}

		if (!pictureUrl) {
			/* trying to get the picture from izuum.com */
			try {
				logMessage("Trying to get user's profile picture from izuum.com")
				pictureUrl = await getProfilePictureFromIzuum(pageUsername)
				if (!(await pictureUrl)) logError("No profile picture url found on izuum.com response")
			} catch (error) {
				logError("Couldn't get picture from izuum.com", error)
			}
		}

		if (!pictureUrl) {
			/* trying to get the picture from existing data on the user's profile */
			try {
				logMessage("Trying to get user's profile picture from existing user data", true)
				pictureUrl = await getPictureFromExistingData(pageUsername, profilePageData)
				if (!(await pictureUrl)) logError("No profile picture url found on any existing data")
			} catch (error) {
				logError("Couldn't get picture from existing data on user's profile (3 methods failed)", error)
			}
		}

		document.body.style.cursor = "default"
		if (pictureUrl) {
			logMessage("Profile picture found, opening in a new tab")
			window.open(pictureUrl, "_blank")
		} else {
			showMessage("Couldn't get user's profile picture")
		}
	}

	/**
	 * Adds event listener(s) to the current document meant to handle key presses on a single post page
	 */
	async function setupSinglePostEventListeners() {
		if (!isSinglePostKeyBindingSetup) {
			loadPostStoryKeyBindings()
				.then(() => {
					logMessage("Added single post opening key binding")
					document.addEventListener('keydown', handleSinglePostKeyPress)
					isSinglePostKeyBindingSetup = true
				})
		}
	}

	/**
	 * Adds event listener(s) to the current document meant to handle key presses on a story page
	 */
	function setupStoryEventListeners() {
		if (!isStoryKeyBindingSetup) {
			loadPostStoryKeyBindings()
				.then(() => {
					logMessage("Added story key bindings")
					document.addEventListener('keydown', handleStoryKeyPress)
					isStoryKeyBindingSetup = true
				})
		}
	}

	/**
	 * Adds event listener(s) to the current document meant to handle key presses on a profile page
	 */
	function setupProfileEventListeners() {
		if (!isProfileKeyBindingSetup) {
			loadProfilePictureKeyBindings()
				.then(() => {
					logMessage("Added profile key bindings")
					document.addEventListener('keydown', handleProfileKeyPress)
					isProfileKeyBindingSetup = true
				})
		}
	}

	/**
	 * Removes the previously added event listener(s) meant to handle key presses on a single post page
	 */
	function removeSinglePostEventListeners() {
		if (isSinglePostKeyBindingSetup) {
			logMessage("Removed single post key bindings")
			document.removeEventListener('keydown', handleSinglePostKeyPress)
			isSinglePostKeyBindingSetup = false
		}
	}

	/**
	 * Removes the previously added event listener(s) meant to handle key presses on a story page
	 */
	function removeStoryEventListeners() {
		if (isStoryKeyBindingSetup) {
			logMessage("Removed story key bindings")
			document.removeEventListener('keydown', handleStoryKeyPress)
			isStoryKeyBindingSetup = false
		}
	}

	/**
	 * Removes the previously added event listener(s) meant to handle key presses on a profile page
	 */
	function removeProfileEventListeners() {
		if (isProfileKeyBindingSetup) {
			logMessage("Removed profile key bindings")
			document.removeEventListener('keydown', handleProfileKeyPress)
			isProfileKeyBindingSetup = false
		}
	}

	/**
	 * Handles key up events on a story page
	 * @param {Object} e Event object
	 */
	function handleStoryKeyPress(e) {
		if (e.key === openPostStoryKeyBinding.toLowerCase()) {
			if (isOnStoryPage()) {
				if (/* alt key was being pressed */ e.altKey) {
					logMessage("Detected source opening shortcut on a story page")
					openStoryContent()
				}
			}
		}
	}

	/**
	 * Handles key up events on a single post page
	 * @param {Object} e Event object
	 */
	function handleSinglePostKeyPress(e) {
		if (e.key === openPostStoryKeyBinding.toLowerCase()) {
			if (isOnSinglePostPage()) {
				if (/* alt key was being pressed */ e.altKey) {
					logMessage("Detected source opening shortcut on a single post page")
					openPostSourceFromSrcAttribute()
				}
			}
		}
	}

	/**
	 * Handles key up events on a profile page
	 * @param {Object} e Event object
	 */
	function handleProfileKeyPress(e) {
		if (e.key === openProfilePictureKeyBinding.toLowerCase()) {
			/* NOTE: Could be possible to open a profile picture while on a single post pop-up, but allowing that
			can lead to having overlapping key bindings if the user chooses to have the same key binding for opening
			posts, stories and profile pictures */
			if (!isOnStoryPage() && !isOnSinglePostPage()) {
				if (/* alt key was being pressed */ e.altKey) {
					logMessage("Detected profile picture opening shortcut on a profile page")
					openProfilePicture()
				}
			}
		}
	}

	/**
	 * Loads the key bind to open a single post or a story from local storage into a global scope variable, in order
	 * to be used on the key binding handler method
	 * @returns {Promise} Promise object, always resolved after loading key binding
	 */
	function loadPostStoryKeyBindings() {
		return new Promise(async resolve => {
			try {
				let kbName = "single post and story"
				openPostStoryKeyBinding = await loadKeyBindingFromStorage(
					POST_STORY_KB_STORAGE_KEY, DEFAULT_POST_STORY_KB, kbName)
			} catch (error) {
				logError(`Failed to load "${kbName}" key binding, considering default (Alt + ${DEFAULT_POST_STORY_KB})`, error)
			} finally {
				resolve()
			}
		})
	}

	/**
	 * Loads the key bind to open a profile picture from local storage into a global scope variable in order
	 * to be used on the key binding handler method
	 * @returns {Promise} Promise object, always resolved after loading key binding
	 */
	function loadProfilePictureKeyBindings() {
		return new Promise(async resolve => {
			try {
				let kbName = "profile picture"
				openProfilePictureKeyBinding = await loadKeyBindingFromStorage(
					PROFILE_PICTURE_KB_STORAGE_KEY, DEFAULT_PROFILE_PICTURE_KB, kbName)
			} catch (error) {
				logError(`Failed to load "${kbName}" key binding, considering default (Alt + ${DEFAULT_PROFILE_PICTURE_KB})`, error)
			} finally {
				resolve()
			}
		})
	}

	/**
	 * Loads a key binding from local storage, if it fails or doesn't have anything stores, returns the fallback key binding
	 * @param {string} storageKey Local storage key used to store the key binding
	 * @param {string} defaultKeyBinding Fallback key binding
	 * @param {string} keyBindingName Key binding name to show on log messages, just for context
	 * @returns {Promise} Promise object, when resolved contains the loaded key and when rejected contains an exception error object
	 */
	function loadKeyBindingFromStorage(storageKey, defaultKeyBinding, keyBindingName) {
		return new Promise(async resolve => {
			try {
				let kb = await GM_getValue(storageKey, defaultKeyBinding)
				if (isKeyBindingValid(await kb)) {
					let newKey = kb.toUpperCase()
					logMessage(`Loaded "${keyBindingName}" key binding: Alt + ${newKey}`)
					resolve(newKey)
				} else {
					logError(`Couldn't load "${keyBindingName}" key binding, "${kb}" key is invalid, considering default (Alt + ${defaultKeyBinding})`)
					resolve(defaultKeyBinding)
				}
			} catch (error) {
				logError(`Failed to load "${keyBindingName}" key binding, considering default (Alt + ${defaultKeyBinding})`, error)
				reject(error)
			}
		})
	}

	/**
	 * Tries to get the user's profile picture URL from sharedData and graphql API
	 * Picture URLs from shared data are usually low-res versions
	 * @param {string} username
	 * @param {Object} existingData
	 * @returns {Promise} Promise object, when resolved contains the picture URL
	 */
	async function getPictureFromExistingData(username, existingData) {
		return new Promise(async (resolve, reject) => {
			var pictureUrl = null

			/* trying to get the from current page's sharedData variable */
			try {
				logMessage("Trying to get user's profile picture from current page's sharedData")
				let userSharedData = existingData[0].graphql.user
				/* if sharedData is correct */
				if (userSharedData.username === username) {
					pictureUrl = userSharedData.profile_pic_url_hd
					if (!pictureUrl) logError("No profile picture url found on current page's sharedData")
				} else {
					logError("Current sharedData is incorrect, discarding url")
				}
			} catch (error) {
				logError("Couldn't get url from current page's sharedData", error)
			}

			if (!pictureUrl) {
				/* trying to get the picture from user data graphql API (?__a=1) */
				try {
					logMessage("Trying to get user's profile picture from user data graphql API (?__a=1)")
					pictureUrl = await getProfilePictureFromUpdatedSharedData()
					if (!(await pictureUrl)) logError("No profile picture url found on user data graphql API (?__a=1)")
				} catch (error) {
					logError("Couldn't get picture from user data graphql API (?__a=1)", error)
				}
			}

			if (!pictureUrl) {
				/* last resort: trying to get the picture from an updated HTML profile page */
				try {
					logMessage("Trying to get user's profile picture from updated HTML profile page")
					pictureUrl = await getProfilePictureFromUpdatedHTMLPage()
					if (!(await pictureUrl)) logError("No profile picture url found on updated HTML page")
				} catch (error) {
					logError("Couldn't get picture from updated HTML page", error)
				}
			}

			pictureUrl ? resolve(pictureUrl) : reject()
		})
	}

	/**
	 * Parses a whole HTML page in order to get the user's profile picture URL
	 * @returns {Promise} Promise object, when resolved returns the user's profile picture URL
	 * and when rejected contains an exception error object
	 */
	function getProfilePictureFromUpdatedHTMLPage() {
		getProfilePictureFromUp
		return new Promise((resolve, reject) => {
			httpGETRequest(window.location, false)
				.then(response => {
					let parser = new DOMParser()
					let doc = parser.parseFromString(response, "text/html")
					let allScripts = doc.querySelectorAll("script")

					for (let i = 0; i < allScripts.length; i++) {
						if (/window._sharedData/.test(allScripts[i].innerText)) {
							let extractedJSON = /window._sharedData = (.+)/.exec(allScripts[i].innerText)[1]
							extractedJSON = extractedJSON.slice(0, -1)
							let sharedData = JSON.parse(extractedJSON)
							let userInfo = sharedData.entry_data.ProfilePage[0].graphql.user.profile_pic_url_hd
							resolve(userInfo)
							break
						}
					}
				})
				.catch(error => reject(error))
		})
	}

	/**
	 * Requests user information from the Instagram API
	 * @deprecated This endpoint was almost completely shut down by instagram and only works with a different user agent and provides low-res pictures
	 * @param {number} userId
	 * @returns {Promise} Promise object, when resolved contains an object with the user's information
	 * and when rejected contains an exception error object
	 */
	function getUserFromUserInfoAPI(userId) {
		return new Promise((resolve, reject) => {
			httpGETRequest(`https://i.instagram.com/api/v1/users/${userId}/info/`)
				.then(response => resolve(response.user))
				.catch(error => reject(error))
		})
	}

	/**
	 * Requests the user profile page data from graphql in order to get its profile picture URL
	 * @returns {Promise} Promise object, when resolved contains the user's profile picture URL
	 * and when rejected contains an exception error object
	 */
	function getProfilePictureFromUpdatedSharedData() {
		return new Promise((resolve, reject) => {
			httpGETRequest(`${window.location}?__a=1`)
				.then(response => resolve(response.graphql.user.profile_pic_url_hd))
				.catch(error => reject(error))
		})
	}

	/**
	 * Performs a request to instadp.com and extracts the profile picture URL from the HTML response
	 * @todo Adapt to be able to get pictures from a user that was never searched on their website
	 * @param {string} username The user's Instagram username
	 * @returns {Promise} Promise object, when resolved contains the user's profile picture URL
	 * and when rejected contains an exception error object or a null if no URL was found
	 */
	function getProfilePictureFromInstadpDotCom(username) {
		/*
			Instadp.com has a different process and requires a POST, probably to populate their database
			If you ever searched for a user on their website, this request succeeds, otherwise it fails
		*/
		return new Promise((resolve, reject) => {
			httpGETRequest(`https://www.instadp.com/fullsize/${username}`, false)
				.then(response => {
					let urls = extractUrlsFromString(response)
					let instagramUrls = urls.filter(u =>
						u.includes("cdninstagram") && !u.includes("s150x150"))
					if (instagramUrls.length > 0) {
						resolve(instagramUrls[instagramUrls.length - 1])
					} else {
						reject()
					}
				})
				.catch(error => reject(error))
		})
	}

	/**
	 * Performs a request to instadp.org and extracts the profile picture URL from the HTML response
	 * @param {string} username The user's Instagram username
	 * @returns {Promise} Promise object, when resolved contains the user's profile picture URL and
	 * when rejected contains an exception error object or a null if no URL was found
	 */
	function getProfilePictureFromInstadpDotOrg(username) {
		return new Promise((resolve, reject) => {
			let headers = {"Content-Type": "application/x-www-form-urlencoded"}
			let data = `username=${username}`

			httpPOSTRequest('https://instadp.org/', headers, data, false)
				.then(response => {
					let urls = extractUrlsFromString(response)
					let instagramUrls = urls.filter(u => u.includes("cdninstagram"))
					if (instagramUrls.length > 0) {
						resolve(instagramUrls[0])
					} else {
						reject()
					}
				})
				.catch(error => reject(error))
		})
	}

	/**
	 * Performs a request to izuum.com and extracts the profile picture URL from the HTML response
	 * @param {string} username The user's Instagram username
	 * @returns {Promise} Promise object, when resolved contains the user's profile picture URL
	 * and when rejected contains an exception error object or a null if no URL was found
	 */
	function getProfilePictureFromIzuum(username) {
		return new Promise((resolve, reject) => {
			let headers = {"Content-Type": "application/x-www-form-urlencoded"}
			let data = `submit=${username}`

			httpPOSTRequest('http://izuum.com/index.php', headers, data, false)
				.then(response => {
					let urls = extractUrlsFromString(response)
					let instagramUrls = urls.filter(u => u.includes("cdninstagram"))
					let cleanUrls = instagramUrls.map(u => u.replace(/amp;/g, ''))
					if (cleanUrls.length > 0) {
						resolve(cleanUrls[0])
					} else {
						reject()
					}
				})
				.catch(error => reject(error))
		})
	}

	/**
	 * Performs an HTTP GET request using the GM_xmlhttpRequest function
	 * @param {string} url
	 * @param {boolean} [parseToJSON = true] default true
	 * @returns {Promise} Promise object, when resolved contains the response text and
	 * when rejected contains a message or an exception error object
	 */
	function httpGETRequest(url, parseToJSON = true) {
		return new Promise((resolve, reject) => {
			GM_xmlhttpRequest({
				method: "GET",
				url: url,
				timeout: 10000,
				onload: res => {
					if (res.status === 200) {
						let response = res.responseText
						if (parseToJSON) {
							response = JSON.parse(res.responseText)
						}
						resolve(response)
					} else {
						reject(`Status Code ${res.status} ${res.statusText.length > 0 ? `, ${res.statusText}` : ''}`)
					}
				},
				onerror: error => reject(error),
				ontimeout: () => reject("Request Timeout"),
				onabort: () => reject("Aborted")
			})
		})
	}

	/**
	 * Performs an HTTP POST request using the GM_xmlhttpRequest function
	 * @param {string} url
	 * @param {Object} [headers = null] default null
	 * @param {string} [data = null] default null
	 * @param {boolean} [parseToJSON = true] default true
	 * @returns {Promise} Promise object, when resolved contains the response text and
	 * when rejected contains a message or an exception error object
	 */
	function httpPOSTRequest(url, headers = null, data = null, parseToJSON = true) {
		return new Promise((resolve, reject) => {
			GM_xmlhttpRequest({
				method: "POST",
				url: url,
				...(headers && {headers: headers}),
				...(data && {data: data}),
				timeout: 10000,
				onload: res => {
					if (res.status === 200) {
						let response = res.responseText
						if (parseToJSON) {
							response = JSON.parse(res.responseText)
						}
						resolve(response)
					} else {
						reject(`Status Code ${res.status} ${res.statusText.length > 0 ?
							', ' + res.statusText : ''}`)
					}
				},
				onerror: error => reject(error),
				ontimeout: () => reject("Request Timeout"),
				onabort: () => reject("Aborted")
			})
		})
	}

	/**
	 * Checks if the current location corresponds to a story page
	 * @returns {boolean} true is it is a story page, false otherwise
	 */
	function isOnStoryPage() {
		return window.location.pathname.startsWith("/stories/")
	}

	/**
	 * Checks if the current location corresponds to a single post page
	 * @returns {boolean} true is it is a story page, false otherwise
	 */
	function isOnSinglePostPage() {
		return window.location.pathname.startsWith("/p/")
	}

	/**
	 * Checks if the current location corresponds to a profile page
	 * @returns {boolean} true is it is a story page, false otherwise
	 */
	function isOnProfilePage() {
		return window.location.pathname.length > 1 &&
			document.querySelector(`.${IG_C_PROFILE_CONTAINER}`)
	}

	/**
	 * Check if the key is valid to used as a key binding
	 * @param {string} key Key binding key
	 * @returns {boolean} true is the key is valid, false otherwise
	 */
	function isKeyBindingValid(key) {
		return /[a-zA-Z]/gm.test(key)
	}

	/**
	 * Extracts every URL found between quotes and double quotes in a given string
	 * Note: This regex is not bullet proof and has unnecessary rules, but it's works fine
	 * @param {string} string String to match
	 */
	function extractUrlsFromString(string) {
		return string.match(/(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#\/%=~_|$?!:;,.]*\)|[-A-Z0-9+&@#\/%=~_|$?!:;,.])*(?:\([-A-Z0-9+&@#\/%=~_|$?!:;,.]*\)|[A-Z0-9+&@#\/%=~_|$])/igm)
	}

	/**
	 * Extracts every substring found between quotes and double quotes in a given string
	 * @param {string} string String to match
	 */
	function extractStringsBetweenQuotes(string) {
		return string.match(/(?=["'])(?:"[^"\\]*(?:\\[\s\S][^"\\]*)*"|'[^'\\]*(?:\\[\s\S][^'\\]*)*')/gi)
	}

	/**
	 * Matches a CSS selector against a DOM element object to check if the element exist in the node
	 * @param {string} selector
	 * @param {Object} node DOM element node
	 * @returns {boolean} true if the element exists in the node, otherwise false
	 */
	function elementExistsInNode(selector, node) {
		return (node.querySelector(selector) != null)
	}

	/**
	 * Appends the necessary style elements to DOM using the GM_addStyle function
	 */
	function injectStyles() {
		let b64icon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAADdcAAA3XAUIom3gAAAAHdElNRQfiAxwDOBTFNFQBAAABKklEQVQ4y6WTvUoDQRSFvwkbxCqijY2okEIEixQpBMHKykIFC8H/yiew8Rl8i4ClCoJYWGkhaGXjrkmTbsUiTQoVf45Ndp1lZ8eIp7vnnnPvnBkG/gjjb2uAOcoW0fplnvaVRccAaIFZhnPqW3OkMa4Zz84o60RunAFoQm2bDDhgmSsOHad7BjBtrXFjb3jUi0Y8KUYV2hvQly77kH/qKTFIF33Id5MsHoMl30njdwoNlnw75SqaLDC45EnLYbDkW/lZOYMl3wRQTTW/4bQn3+jVoUK/YUqxPrSe1pGin26QD2wizVM15+7LDlykadIseswSbwzhgUpUeLWJO0nTHsOSpIa1XSsc06VR8PnqrGKom3t7xp66KkasxUw+AA0y4/iiADEP5p3/4BuEXi9gkPrfQgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxOC0wMy0yOFQwMzo1NjoyMCswMjowMO7sj9MAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTgtMDMtMjhUMDM6NTY6MjArMDI6MDCfsTdvAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAABJRU5ErkJggg=="
		let styles = [
			`.${C_BTN_POST_OUTER_SPAN}{margin-left:10px;margin-right:-10px;}`,
			`.${C_BTN_POST}{outline:none;-webkit-box-align:center;align-items:center;background:0;border:0;cursor:pointer;display:flex;-webkit-box-flex:0;flex-grow:0;-webkit-box-pack:center;justify-content:center;min-height:40px;min-width:40px;padding:0;}`,
			`.${C_BTN_PROFILE_PIC}{outline:none;background-color:white;border:0;cursor:pointer;display:flex;-webkit-box-flex:0;flex-grow:0;-webkit-box-pack:center;justify-content:center;min-height:40px;min-width:40px;padding:0;border-radius:50%;transition:background-color .5s ease;-webkit-transition:background-color .5s ease;}`,
			`.${C_BTN_PROFILE_PIC}:hover{background-color:#D0D0D0;transition:background-color .5s ease;-webkit-transition:background-color .5s ease;}`,
			`.${C_BTN_POST_INNER_SPAN},.${C_BTN_PROFILE_PIC_SPAN}{display:block;background-repeat:no-repeat;background-position:100%-26px;height:24px;width:24px;background-image:url(/static/bundles/base/sprite_glyphs.png/4b550af4600d.png);cursor:pointer;}`,
			`.${C_BTN_STORY}{border:none;position:fixed;top:0;right:0;margin:20px;cursor:pointer;width:24px;height:24px;background-color:transparent;background-image:url(${b64icon})}`,
			`.${C_BTN_PROFILE_PIC_CONTAINER}{transition:.5s ease;opacity:0;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);text-align:center}`,
			`.${IG_C_PRIVATE_PIC_IMG_CONTAINER}>img{transition:.5s ease;backface-visibility:hidden;}`,
			`.${IG_C_PRIVATE_PROFILE_PIC_IMG_CONTAINER}>img{transition:.5s ease;backface-visibility:hidden;}`,
			`.${IG_C_PROFILE_PIC_CONTAINER}:hover .${C_BTN_PROFILE_PIC_CONTAINER}{opacity:1}`,
			`.${IG_C_PRIVATE_PROFILE_PIC_CONTAINER}:hover .${C_BTN_PROFILE_PIC_CONTAINER}{opacity:1}`
		]

		styles.forEach((style) => GM_addStyle(style))
	}

	/**
	 * Shows an alert with an error message and logs an exception to the console
	 * @param {string} error
	 * @param {(Object|string)} exception
	 */
	function showAndLogError(error, exception) {
		showMessage(error)
		logError(error, exception)
	}

	/**
	 * Shows an alert with a message
	 * @param {string} message
	 */
	function showMessage(message) {
		alert(`Instagram Source Opener:\n${message}`)
	}

	/**
	 * Prints a message to the console, either as info or warning
	 * @param {string} message
	 * @param {boolean} warning
	 */
	function logMessage(message, warning = false) {
		if (LOGGING_ENABLED && message) {
			if (warning) {
				console.warn(`[ISO] ${message}`)
			} else {
				console.info(`[ISO] ${message}`)
			}
		}
	}

	/**
	 * Logs an error string and exception to the console
	 * @param {string} error
	 * @param {(Object|string)} exception
	 */
	function logError(error, exception = null) {
		if (LOGGING_ENABLED && error) {
			if (exception) {
				console.error(`[ISO] ${error}`, exception)
			} else {
				console.error(`[ISO] ${error}`)
			}
		}
	}
})()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址