Instagram Downloader - HISHTNIK

Download Instagram photos and videos from posts.

// ==UserScript==
// @name         Instagram Downloader - HISHTNIK
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.6
// @description  Download Instagram photos and videos from posts.
// @author       You
// @include      /^http.*:\/\/(?:www\.)?instagram\.com\/.*$/
// @icon         https://www.google.com/s2/favicons?domain=instagram.com
// @grant        GM_download
// ==/UserScript==

(function() {
    'use strict';

    // VIDEO DOWNLOADS TEMPORARILY NOT WORKING. ALTERNATIVE:
         // Firefox addon => Video Download Helper (with companion app installed)
         // Video Download Helper > Settings > Behaviour > Download Processor > Companion App (make sure you save)
         // To download videos, copy the link and open it in a new tab. Then click on the Video Download Helper Icon

    // Gradual download progress with videos: Tampermonkey > Settings > Advanced > Download Mode > Browser API

    /* Originally developed in Firefox but works in Chromium-based browsers too.
    Every 300 milliseconds, the script attempts to add buttons to the elements inside of a post
    SAVED_VIDEO_DOWNL_LINKS_OBJ stores the download links of fetched videos (so they don't have to be fetched again): {thumbFilename:videoDownloadLink, ....} (could reach limit faster)
         Saved video links are search based on the thumbnail filename (that on page vs saved)
    Photos are downloaded from their src attribute of the <img> element on page (since that seems to be the highest quality one) */

    let BTNS_WRAPPER_HTML_CLASS_STR = "hishtnikBtnsWrapper", BTN_HTML_CLASS_STR = `hishtnikBtn`, DOWNL_VIDEO_BTN_HTML_CLASS_STR = `hishtnikDownlVidBtn`, DOWNL_PHOTO_BTN_HTML_CLASS_STR = `hishtnikDownlPhotoBtn`, DOWNL_THUMB_BTN_HTML_CLASS_STR = `hishtnikDownlThumbBtn`, OPEN_THUMB_BTN_HTML_CLASS_STR = `hishtnikOpenThumbBtn`;
    let POST_MEDIA_ELEMS_CSS_SELECTOR_STR = `video, div[role="button"] img[style="object-fit: cover;"]`, POST_OP_USERNAME_CSS_SELECTOR_STR = `.UE9AK .Jv7Aj.mArmR.MqpiF`;

    let STYLE_HTML_STR =
        `<style>
            /* Download / Open thumb buttons */
            .${BTNS_WRAPPER_HTML_CLASS_STR} {display: flex !important; width: 100%; flex-direction: row !important; justify-content: space-between; z-index:9999999; position:absolute !important; top:0;}
            .${BTN_HTML_CLASS_STR} {width: auto; cursor:pointer; padding:5px; font-weight:bold; color:#ff2d2d; background:black; border:1px solid;}
            /* add borders and background to albums labers (easier to see) */
            .CzVzU > div, ._aatp > div {padding: 5px !important;}
            .CzVzU > div, ._aatp > div, button[aria-label="Go Back"], button[aria-label="Next"] {background: #951111 !important; border: 2px solid #979085 !important;}
        </style`;

    let SAVED_VIDEO_DOWNL_LINKS_OBJ = {}, MAX_SAVED_VIDEO_LINKS_INT = 100;

    run();
    async function run()
    {
        document.querySelector("html").appendChild(str_to_html_elem(STYLE_HTML_STR));
        document.addEventListener("dblclick", (event)=>{event.stopPropagation(); event.preventDefault();}, true); // prevent double click like on post items
        document.addEventListener("click", click_handler);
        while(1==1) {if (window.location.href.match(/^.+\/p\/.+$/)) add_btns(); await delay(300);}
    }

    function add_btns()
    {
        let htmlMediaElemsInPost = document.querySelectorAll(POST_MEDIA_ELEMS_CSS_SELECTOR_STR);

        for (let htmlMediaElem of htmlMediaElemsInPost) {
            let htmlMediaElemWrapper = htmlMediaElem.parentElement; // the buttons wrapper elem will become a sibiling to the media elem
            if (htmlMediaElemWrapper.querySelector(`.${BTNS_WRAPPER_HTML_CLASS_STR}`)) continue; // already added
            let btnsHtmlStr = ``;
            if (htmlMediaElem.nodeName == "VIDEO") {
                btnsHtmlStr += `<button class="${BTN_HTML_CLASS_STR} ${DOWNL_VIDEO_BTN_HTML_CLASS_STR}">DOWNL VIDEO</button>`;
                btnsHtmlStr += `<button class="${BTN_HTML_CLASS_STR} ${DOWNL_THUMB_BTN_HTML_CLASS_STR}">DOWNL THUMB</button>`;
                btnsHtmlStr += `<button class="${BTN_HTML_CLASS_STR} ${OPEN_THUMB_BTN_HTML_CLASS_STR}">OPEN THUMB</button>`;
            }
            else if (htmlMediaElem.nodeName == "IMG") {
                btnsHtmlStr += `<button class="${BTN_HTML_CLASS_STR} ${DOWNL_PHOTO_BTN_HTML_CLASS_STR}">DOWNL PHOTO</button>`;
            }
            btnsHtmlStr = `<div class="${BTNS_WRAPPER_HTML_CLASS_STR}">` + btnsHtmlStr + `</div>`;
            htmlMediaElemWrapper.appendChild(str_to_html_elem(btnsHtmlStr));
        }
    }

    function click_handler(event)
    {
        if (!event.target.classList.contains(BTN_HTML_CLASS_STR)) return;
        let htmlBtnElemClicked = event.target;
        if (htmlBtnElemClicked.classList.contains(OPEN_THUMB_BTN_HTML_CLASS_STR)) open_thumb(htmlBtnElemClicked);
        else if (htmlBtnElemClicked.classList.contains(DOWNL_THUMB_BTN_HTML_CLASS_STR)) downl_img("thumb", htmlBtnElemClicked);
        else if (htmlBtnElemClicked.classList.contains(DOWNL_PHOTO_BTN_HTML_CLASS_STR)) downl_img("photo", htmlBtnElemClicked);
        else if (htmlBtnElemClicked.classList.contains(DOWNL_VIDEO_BTN_HTML_CLASS_STR)) downl_video(htmlBtnElemClicked);
    }

    function open_thumb(htmlBtnElem)
    {
        let htmlMediaElemWrapper = htmlBtnElem.parentElement.parentElement;
        try {window.open(htmlMediaElemWrapper.querySelector("video").getAttribute("poster"))}
        catch(err) {alert("Failed to open thumbnail.")}
    }

    function downl_img(typeOfImgStr, htmlBtnElem)
    {
        let downlLinkStr;
        try {
            let htmlMediaElemWrapper = htmlBtnElem.parentElement.parentElement;
            downlLinkStr = (typeOfImgStr == "photo") ? htmlMediaElemWrapper.querySelector("img").src : htmlMediaElemWrapper.querySelector("video").poster;
        }
        catch(err) {alert("Couldn't get download link. Download failed."); return;}
        let authorStr = get_post_author();
        if (!authorStr) alert("Downloaded filename will not have the author's username due to an error. Please inform developer.");
        GM_download(downlLinkStr, generate_cust_filename(authorStr, downlLinkStr));
    }

    async function downl_video(htmlBtnElem)
    {
        let authorStr = get_post_author(); // author name is defined here first, in case the post is closed while fetching
        let targetVidThumbFilenameStr;
        try {targetVidThumbFilenameStr = get_filename_from_url(htmlBtnElem.parentElement.parentElement.querySelector("video").poster)}
        catch(err) {alert("Error initializing the video download process. Download failed."); return;}
        let downlLinkStr = SAVED_VIDEO_DOWNL_LINKS_OBJ[targetVidThumbFilenameStr] || await get_newly_fetched_download_link();
        if (!downlLinkStr) {alert("Error getting video download link. Download failed."); return;}
        if (!authorStr) alert("Downloaded filename will not have the author's username due to an error. Please inform developer.");
        GM_download(downlLinkStr, generate_cust_filename(authorStr, downlLinkStr));

        async function get_newly_fetched_download_link() {
            try {
                let fetchResponseObj = await fetch(window.location.href + "?__a=1");

                let postInfoObj = (await fetchResponseObj.json())["items"][0]; // get the data from the response and advance to the meaningful part
                let mediaInfosArr = postInfoObj["carousel_media"] || [postInfoObj]; // album test - if not, put it in array so you can loop

                for (let mediaInfoObj of mediaInfosArr) {
                    try {
                        let videoSrcStr = mediaInfoObj["video_versions"][0]["url"]; // the first version seems to be the highest quality one
                        let thumbFilenameStr = get_filename_from_url(mediaInfoObj["image_versions2"]["candidates"][0]["url"]);
                        if (Object.keys(SAVED_VIDEO_DOWNL_LINKS_OBJ).length == MAX_SAVED_VIDEO_LINKS_INT) delete SAVED_VIDEO_DOWNL_LINKS_OBJ[Object.keys(SAVED_VIDEO_DOWNL_LINKS_OBJ)[0]];
                        SAVED_VIDEO_DOWNL_LINKS_OBJ[thumbFilenameStr] = videoSrcStr;
                    }
                    catch(err){}
                }

                return SAVED_VIDEO_DOWNL_LINKS_OBJ[targetVidThumbFilenameStr];
            }
            catch(err) {return false}
        }
    }

    function delay(durationMs) {return new Promise(resolve => setTimeout(resolve, durationMs));}
    function get_post_author()
    {
        if (document.title.includes("@")) { // within profile
            try {return document.title.split("@").pop().split(")")[0].split(" ")[0]}
            catch(err) {return ""}
        }
        else { // individual post opened
            try {return document.querySelector(POST_OP_USERNAME_CSS_SELECTOR_STR).innerText.trim()}
            catch(err) {return ""}
        }
    }
    function generate_cust_filename(authorStr, downlLinkStr)
    {
        if (authorStr) authorStr += "_";
        return authorStr + get_filename_from_url(downlLinkStr);
    }
    function get_filename_from_url(url)
    {
        let filename = url.split("?")[0].split("/").pop();
        return filename.replace(/\.jpg\.webp$|\.webp$/, ".jpg"); // change .webp to .jpg. Also helps with inconsistencies between fetched thumb and thumbs on page
    }
    function str_to_html_elem(str)
    {
        let htmlWrapperElem = document.createElement("div");
        htmlWrapperElem.innerHTML = str;
        if (htmlWrapperElem.childElementCount == 1) htmlWrapperElem = htmlWrapperElem.firstChild; // only keep the wrapper if there are multiple direct children
        return htmlWrapperElem;
    }
})();