├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── icons ├── icon128.png ├── icon16.png ├── icon32.png └── icon48.png ├── manifest.json └── src ├── js ├── global │ ├── event.js │ ├── home-scroll-handler.js │ ├── network-tracker.js │ ├── post-modal-view-handler.js │ ├── reels-scroll-handler.js │ ├── stories-view-handler.js │ └── utils.js ├── main.js ├── post-view-handler.js ├── post.js ├── story.js ├── utils.js └── zip.js └── style └── style.css /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = crlf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | !manifest.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 HOAI AN LE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Instagram-Downloader 2 | 3 | ![icon](icons/icon128.png) 4 | 5 | ## How this work 6 | 7 | With regex and some `ReactFiber` magic, I'm able to know which post you wanna download and fetch the api to download the photos for you. 8 | 9 | ## Browser compatibility 10 | 11 | This extension should work fine on the following browsers with `fetch()` API and Chromium base browser, tested Browser: 12 | 13 | * Google Chrome 14 | * MS Edge 15 | * FireFox 16 | 17 | ## Download and install 18 | 19 | * Download [latest version](https://github.com/HOAIAN2/Instagram-Downloader/releases) and extract to a folder 20 | * Enable Chrome extensions developer mode 21 | * Drag and drop extracted folder to `chrome://extensions/` 22 | 23 | ## Usage 24 | 25 | * Go to any `post`, `reels`, `stories`, etc. Then click `Download` button to fetch data. 26 | 27 | * Click on any photos/videos to save. 28 | 29 | * Toggle multi select by click on `Photos` and select photos by click on them (or select all by click and hold on `Photos`). Then click on `Download` to save zip file. 30 | 31 | * If you scroll on the home page, this app will auto detect the post you wanna download so you don't have to click to comment section to open modal. Thanks to ReactFiber. 32 | 33 | ## Features 34 | 35 | * Download posts ✔ 36 | * Download reels ✔ 37 | * Download latest stories ✔ 38 | * Download highlight stories ✔ 39 | * Support high resolution ✔ 40 | * Support download zip file ✔ 41 | 42 | ## Customize 43 | 44 | You can modify anything you want except some constants start with "IG_" that definitely gonna break this extension. 45 | 46 | Edit Hide / Show Transition effects 47 | 48 | ```css 49 | .display-container.hide { 50 | transform-origin: 85% bottom; 51 | transform: scale(0); 52 | pointer-events: none; 53 | opacity: 0.6; 54 | } 55 | ``` 56 | 57 | ## Keyboard shortcut 58 | 59 | * Download: `D` 60 | * Close: `esc` `C` `c` 61 | * Select all `S` `s` 62 | * Keyboard shortcut should work if you don't focus on special HTML Elements like `input` `textarea` or any element with `textbox` role (ex: comment, search, ...) 63 | 64 | ## Deprecated features 65 | 66 | These features was deprecated for some reason. 67 | 68 | * V5.1.0 69 | * Set fallback download to latest post from some user. 70 | 71 | ## Here is Demo 72 | 73 | [Demo v5.1.0](https://github.com/HOAIAN2/Instagram-Downloader/assets/98139595/917369c9-cdbb-4315-8e6d-7a1632a8888b) 74 | -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HOAIAN2/Instagram-Downloader/569c0af1f71be335b064e52faa62cd0313ad4e13/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HOAIAN2/Instagram-Downloader/569c0af1f71be335b064e52faa62cd0313ad4e13/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HOAIAN2/Instagram-Downloader/569c0af1f71be335b064e52faa62cd0313ad4e13/icons/icon32.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HOAIAN2/Instagram-Downloader/569c0af1f71be335b064e52faa62cd0313ad4e13/icons/icon48.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Instagram Downloader", 3 | "description": "Download Photos and Videos from Instagram Post, Reels, TV, Stories", 4 | "version": "5.1.7", 5 | "manifest_version": 3, 6 | "icons": { 7 | "16": "icons/icon16.png", 8 | "32": "icons/icon32.png", 9 | "48": "icons/icon48.png", 10 | "128": "icons/icon128.png" 11 | }, 12 | "host_permissions": [ 13 | "https://www.instagram.com/*" 14 | ], 15 | "action": { 16 | "default_icon": "icons/icon128.png" 17 | }, 18 | "content_scripts": [ 19 | { 20 | "matches": [ 21 | "https://www.instagram.com/*" 22 | ], 23 | "js": [ 24 | "src/js/utils.js", 25 | "src/js/main.js", 26 | "src/js/post.js", 27 | "src/js/story.js", 28 | "src/js/zip.js" 29 | ], 30 | "css": [ 31 | "src/style/style.css" 32 | ] 33 | }, 34 | { 35 | "matches": [ 36 | "https://www.instagram.com/*" 37 | ], 38 | "js": [ 39 | "src/js/global/utils.js", 40 | "src/js/global/event.js", 41 | "src/js/global/home-scroll-handler.js", 42 | "src/js/global/stories-view-handler.js", 43 | "src/js/global/post-modal-view-handler.js", 44 | "src/js/global/reels-scroll-handler.js" 45 | ], 46 | "css": [], 47 | "world": "MAIN" 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /src/js/global/event.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Navigation API not work on FireFox, so I have to override History API 4 | * 5 | */ 6 | (function () { 7 | let path = window.location.pathname; 8 | // Save the original methods to call them later 9 | const originalPushState = history.pushState; 10 | const originalReplaceState = history.replaceState; 11 | 12 | const dispatchPathChangeEvent = () => { 13 | const newPath = window.location.pathname; 14 | if (newPath !== path) { 15 | path = newPath; 16 | window.dispatchEvent(new CustomEvent('pathChange', { 17 | detail: { 18 | oldPath: path, 19 | currentPath: newPath 20 | } 21 | })); 22 | } 23 | }; 24 | 25 | // Override pushState 26 | history.pushState = function (...args) { 27 | originalPushState.apply(this, args); 28 | dispatchPathChangeEvent(); 29 | }; 30 | 31 | // Override replaceState 32 | history.replaceState = function (...args) { 33 | originalReplaceState.apply(this, args); 34 | dispatchPathChangeEvent(); 35 | }; 36 | 37 | // Listen to the popstate event 38 | window.addEventListener('popstate', () => { 39 | dispatchPathChangeEvent(); 40 | }); 41 | })(); -------------------------------------------------------------------------------- /src/js/global/home-scroll-handler.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | function debounce(func, delay) { 3 | let timeout; 4 | return (...args) => { 5 | clearTimeout(timeout); 6 | timeout = setTimeout(() => { func(...args); }, delay); 7 | }; 8 | }; 9 | const homeScrollHandler = debounce(() => { 10 | function getVisibleArea(element) { 11 | const rect = element.getBoundingClientRect(); 12 | const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight); 13 | const viewWidth = Math.max(document.documentElement.clientWidth, window.innerWidth); 14 | const height = Math.max(0, Math.min(rect.bottom, viewHeight) - Math.max(rect.top, 0)); 15 | const width = Math.max(0, Math.min(rect.right, viewWidth) - Math.max(rect.left, 0)); 16 | return height * width; 17 | } 18 | const postContainers = Array.from(document.querySelectorAll('article')); 19 | const mostVisibleElement = postContainers.reduce((mostVisible, container) => { 20 | const visibleArea = getVisibleArea(container); 21 | return visibleArea > mostVisible.area ? { element: container, area: visibleArea } : mostVisible; 22 | }, { element: null, area: 0 }).element; 23 | 24 | if (mostVisibleElement) { 25 | const mediaFragmentKey = getValueByKey(mostVisibleElement.querySelector('[tabindex="0"][aria-hidden="true"]'), 'queryReference'); 26 | if (mediaFragmentKey) { 27 | window.dispatchEvent(new CustomEvent('shortcodeChange', { 28 | detail: { 29 | code: mediaFragmentKey.code 30 | } 31 | })); 32 | if (mediaFragmentKey.pk && mediaFragmentKey.code) { 33 | window.dispatchEvent(new CustomEvent('postView', { 34 | detail: { 35 | id: mediaFragmentKey.pk, 36 | code: mediaFragmentKey.code 37 | } 38 | })); 39 | } 40 | } 41 | } 42 | }, Math.floor(1000 / 60)); 43 | const observer = new MutationObserver(homeScrollHandler); 44 | function startObserve() { 45 | observer.disconnect(); 46 | const mainNode = document.querySelector('main'); 47 | if (mainNode) observer.observe(mainNode, { 48 | attributes: true, childList: true, subtree: true 49 | }); 50 | window.addEventListener('scroll', homeScrollHandler); 51 | } 52 | function stopObserve() { 53 | observer.disconnect(); 54 | window.removeEventListener('scroll', homeScrollHandler); 55 | } 56 | window.addEventListener('pathChange', (e) => { 57 | if (e.detail.currentPath === '/') startObserve(); 58 | else stopObserve(); 59 | }); 60 | if (window.location.pathname === '/') startObserve(); 61 | })(); -------------------------------------------------------------------------------- /src/js/global/network-tracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is not include in manifest so it not run. 3 | * If this file run, every network call with XHR will be tracked. 4 | * So you don't need to call api to get data, just cacth the response somewhere. 5 | */ 6 | 7 | ((xhr) => { 8 | const XHR = XMLHttpRequest.prototype; 9 | const open = XHR.open; 10 | const send = XHR.send; 11 | const setRequestHeader = XHR.setRequestHeader; 12 | const urlPatterns = [ 13 | /graphql\/query/, 14 | // /api\/v1\/media\/\d*\/info\// 15 | ]; 16 | XHR.open = function (method, url) { 17 | this._method = method; 18 | this._url = url; 19 | this._requestHeaders = {}; 20 | this._startTime = (new Date()).toISOString(); 21 | return open.apply(this, arguments); 22 | }; 23 | XHR.setRequestHeader = function (header, value) { 24 | this._requestHeaders[header] = value; 25 | return setRequestHeader.apply(this, arguments); 26 | }; 27 | XHR.send = function (postData) { 28 | this.addEventListener('load', () => { 29 | const url = this._url ? this._url.toLowerCase() : this._url; 30 | const match = urlPatterns.some(pattern => pattern.test(url)); 31 | if (!match) return; 32 | window.dispatchEvent(new CustomEvent('apiCall', { 33 | detail: { 34 | body: postData, 35 | request: this 36 | } 37 | })); 38 | }); 39 | return send.apply(this, arguments); 40 | }; 41 | 42 | })(XMLHttpRequest); 43 | 44 | window.addEventListener('apiCall', e => { 45 | if (e.detail.request._url.match(/api\/v1\/media\/\d*\/info\//)) { 46 | const response = JSON.parse(e.detail.request.response); 47 | const edges = response.items[0]; 48 | const data = { 49 | date: '', 50 | user: { 51 | username: edges.user['username'], 52 | fullName: edges.user['full_name'], 53 | }, 54 | medias: [] 55 | }; 56 | data.date = edges['taken_at']; 57 | if (edges['carousel_media']) { 58 | edges['carousel_media'].forEach((item) => { 59 | const media = { 60 | url: item['media_type'] === 1 ? item['image_versions2'].candidates[0]['url'] : item['video_versions'][0].url, 61 | isVideo: item['media_type'] === 1 ? false : true, 62 | id: item.id.split('_')[0] 63 | }; 64 | if (media.isVideo) media.thumbnail = item['image_versions2'].candidates[0]['url']; 65 | data.medias.push(media); 66 | }); 67 | } 68 | else { 69 | const media = { 70 | url: edges['media_type'] === 1 ? edges['image_versions2'].candidates[0]['url'] : edges['video_versions'][0].url, 71 | isVideo: edges['media_type'] === 1 ? false : true, 72 | id: edges.id.split('_')[0] 73 | }; 74 | if (media.isVideo) media.thumbnail = edges['image_versions2'].candidates[0]['url']; 75 | data.medias.push(media); 76 | } 77 | window.dispatchEvent(new CustomEvent('postLoad', { 78 | detail: { 79 | shortcode: edges.code, 80 | data: data 81 | } 82 | })); 83 | } 84 | 85 | const searchParams = new URLSearchParams(e.detail.body); 86 | const fbApiReqFriendlyName = searchParams.get('fb_api_req_friendly_name'); 87 | 88 | if (fbApiReqFriendlyName === 'PolarisStoriesV3ReelPageGalleryQuery' 89 | || fbApiReqFriendlyName === 'PolarisStoriesV3ReelPageGalleryPaginationQuery' 90 | ) { 91 | const response = JSON.parse(e.detail.request.response); 92 | const nodes = response.data['xdt_api__v1__feed__reels_media__connection'].edges; 93 | nodes.forEach(node => { 94 | const data = { 95 | date: node.node.items[0].taken_at, 96 | user: { 97 | username: node.node.user.username, 98 | fullName: '', 99 | }, 100 | medias: [] 101 | }; 102 | node.node.items.forEach(item => { 103 | const media = { 104 | url: item['media_type'] === 1 ? item['image_versions2'].candidates[0]['url'] : item['video_versions'][0].url, 105 | isVideo: item['media_type'] === 1 ? false : true, 106 | id: item.id.split('_')[0] 107 | }; 108 | if (media.isVideo) media.thumbnail = item['image_versions2'].candidates[0]['url']; 109 | data.medias.push(media); 110 | }); 111 | window.dispatchEvent(new CustomEvent('storiesLoad', { 112 | detail: data 113 | })); 114 | window.dispatchEvent(new CustomEvent('userLoad', { 115 | detail: { 116 | username: node.node.user.username, 117 | id: node.node.user.pk 118 | } 119 | })); 120 | }); 121 | } 122 | 123 | if (fbApiReqFriendlyName === 'PolarisStoriesV3ReelPageStandaloneDirectQuery') { 124 | const response = JSON.parse(e.detail.request.response); 125 | const nodes = response.data['xdt_api__v1__feed__reels_media']['reels_media']; 126 | nodes.forEach(node => { 127 | const data = { 128 | date: node.items[0].taken_at, 129 | user: { 130 | username: node.user.username, 131 | fullName: '', 132 | }, 133 | medias: [] 134 | }; 135 | node.items.forEach(item => { 136 | const media = { 137 | url: item['media_type'] === 1 ? item['image_versions2'].candidates[0]['url'] : item['video_versions'][0].url, 138 | isVideo: item['media_type'] === 1 ? false : true, 139 | id: item.id.split('_')[0] 140 | }; 141 | if (media.isVideo) media.thumbnail = item['image_versions2'].candidates[0]['url']; 142 | data.medias.push(media); 143 | }); 144 | window.dispatchEvent(new CustomEvent('storiesLoad', { 145 | detail: data 146 | })); 147 | window.dispatchEvent(new CustomEvent('userLoad', { 148 | detail: { 149 | username: node.user.username, 150 | id: node.user.pk 151 | } 152 | })); 153 | }); 154 | } 155 | if (fbApiReqFriendlyName === 'PolarisStoriesV3HighlightsPageQuery') { 156 | const response = JSON.parse(e.detail.request.response); 157 | const nodes = response.data['xdt_api__v1__feed__reels_media__connection'].edges; 158 | nodes.forEach(node => { 159 | const data = { 160 | date: node.node.items[0].taken_at, 161 | user: { 162 | username: node.node.user.username, 163 | fullName: '', 164 | }, 165 | medias: [] 166 | }; 167 | node.node.items.forEach(item => { 168 | const media = { 169 | url: item['media_type'] === 1 ? item['image_versions2'].candidates[0]['url'] : item['video_versions'][0].url, 170 | isVideo: item['media_type'] === 1 ? false : true, 171 | id: item.id.split('_')[0] 172 | }; 173 | if (media.isVideo) media.thumbnail = item['image_versions2'].candidates[0]['url']; 174 | data.medias.push(media); 175 | }); 176 | window.dispatchEvent(new CustomEvent('highlightsLoad', { 177 | detail: { 178 | id: node.node.id.split(':')[1], 179 | data: data 180 | } 181 | })); 182 | }); 183 | } 184 | }); -------------------------------------------------------------------------------- /src/js/global/post-modal-view-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * I have the algorithm that Instagram use to convert between post id and post shortcode. 3 | * But if the post is from a private profile, they add some extra stuff to the shortcode. 4 | * And I don't know how to convert between them. 5 | * So I wrote this to cache post id when user view post to reduce one api call. 6 | * 7 | */ 8 | window.addEventListener('pathChange', (e) => { 9 | /** 10 | * Article element only avaiable right away when view post from /explore or from profile page 11 | * Otherwise Instagram wait until api call success and render it. 12 | */ 13 | let article = document.querySelector('article[role="presentation"]'); 14 | 15 | const observer = new MutationObserver(() => { 16 | const postInfo = getValueByKey(article, 'post'); 17 | if (postInfo) { 18 | if (postInfo.id && postInfo.code) { 19 | window.dispatchEvent(new CustomEvent('postView', { 20 | detail: { 21 | id: postInfo.id, 22 | code: postInfo.code 23 | } 24 | })); 25 | } 26 | stopObserve(); 27 | } 28 | else { 29 | article = document.querySelector('article[role="presentation"]'); 30 | } 31 | }); 32 | 33 | function startObserve() { 34 | observer.observe(document.body, { 35 | attributes: true, childList: true, subtree: true 36 | }); 37 | } 38 | 39 | function stopObserve() { 40 | observer.disconnect(); 41 | } 42 | 43 | if (e.detail.currentPath.match(/\/(p|tv|reel|reels)\/([A-Za-z0-9_-]*)(\/?)/)) { 44 | startObserve(); 45 | } 46 | else { 47 | stopObserve(); 48 | } 49 | }); -------------------------------------------------------------------------------- /src/js/global/reels-scroll-handler.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('pathChange', e => { 2 | // Scroll on /reels/:code page like tiktok 3 | const match = e.detail.currentPath.match(/\/(reels)\/([A-Za-z0-9_-]*)(\/?)/); 4 | if (match) { 5 | const reels = document.querySelectorAll('main>div>div:nth-child(odd)'); 6 | reels.forEach(element => { 7 | const queryReference = getValueByKey(element, 'queryReference'); 8 | if (queryReference?.pk && queryReference?.code && queryReference?.code === match[2]) { 9 | window.dispatchEvent(new CustomEvent('postView', { 10 | detail: { 11 | id: queryReference.pk, 12 | code: queryReference.code, 13 | } 14 | })); 15 | return; 16 | } 17 | }); 18 | } 19 | }); -------------------------------------------------------------------------------- /src/js/global/stories-view-handler.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('pathChange', (e) => { 2 | if (e.detail.currentPath.match(/\/(stories)\/(.*?)\/(\d*)(\/?)/)) { 3 | const section = Array.from(document.querySelectorAll('section')).pop(); 4 | const username = getValueByKey(section, 'username'); 5 | const userId = getValueByKey(section, 'userId'); 6 | if (username && userId) 7 | window.dispatchEvent(new CustomEvent('userLoad', { 8 | detail: { 9 | username: username, 10 | id: userId 11 | } 12 | })); 13 | } 14 | }); -------------------------------------------------------------------------------- /src/js/global/utils.js: -------------------------------------------------------------------------------- 1 | // Different scope, have to redeclare: https://developer.chrome.com/docs/extensions/reference/api/scripting?hl=vi#type-ExecutionWorld 2 | function getValueByKey(obj, key) { 3 | if (typeof obj !== 'object' || obj === null) return null; 4 | const stack = [obj]; 5 | const visited = new Set(); 6 | while (stack.length) { 7 | const current = stack.pop(); 8 | if (visited.has(current)) continue; 9 | visited.add(current); 10 | try { 11 | if (current[key] !== undefined) return current[key]; 12 | } catch (error) { 13 | if (error.name === 'SecurityError') continue; 14 | console.log(error); 15 | } 16 | for (const value of Object.values(current)) { 17 | if (typeof value === 'object' && value !== null) { 18 | stack.push(value); 19 | } 20 | } 21 | } 22 | return null; 23 | }; 24 | 25 | function getAllValuesByKey(obj, targetKey) { 26 | if (typeof obj !== 'object' || obj === null) return []; 27 | const values = []; 28 | const stack = [obj]; 29 | const visited = new Set(); 30 | while (stack.length) { 31 | const current = stack.pop(); 32 | if (visited.has(current)) continue; 33 | visited.add(current); 34 | for (const [key, value] of Object.entries(current)) { 35 | if (key === targetKey) { 36 | values.push(value); 37 | } 38 | if (typeof value === 'object' && value !== null) { 39 | stack.push(value); 40 | } 41 | } 42 | } 43 | return values; 44 | }; 45 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | const IG_BASE_URL = window.location.origin + '/'; 2 | /** 3 | * @deprecated 4 | */ 5 | const IG_PROFILE_HASH = '69cba40317214236af40e7efa697781d'; 6 | /** 7 | * @deprecated 8 | */ 9 | const IG_POST_HASH = '9f8827793ef34641b2fb195d4d41151c'; 10 | 11 | const IG_SHORTCODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; 12 | const IG_POST_REGEX = /\/(p|tv|reel|reels)\/([A-Za-z0-9_-]*)(\/?)/; 13 | const IG_STORY_REGEX = /\/(stories)\/(.*?)\/(\d*)(\/?)/; 14 | const IG_HIGHLIGHT_REGEX = /\/(stories)\/(highlights)\/(\d*)(\/?)/; 15 | 16 | const APP_NAME = `${chrome.runtime.getManifest().name} v${chrome.runtime.getManifest().version}`; 17 | 18 | const appCache = Object.freeze({ 19 | /** 20 | * Cache user id, reduce one api call to get id from username 21 | * 22 | * username => id 23 | */ 24 | userIdsCache: new Map(), 25 | /** 26 | * Cache post id, reduce one api call to get post id from shortcode. 27 | * 28 | * Only for private profile, check out post-modal-view-handler.js 29 | * 30 | * shortcode => post_id 31 | */ 32 | postIdInfoCache: new Map(), 33 | }); 34 | 35 | const appState = Object.freeze((() => { 36 | let currentDisplay = ''; 37 | const current = { 38 | shortcode: '', 39 | username: '', 40 | highlights: '', 41 | }; 42 | const previous = { 43 | shortcode: '', 44 | username: '', 45 | highlights: '', 46 | }; 47 | window.addEventListener('shortcodeChange', e => { 48 | current.shortcode = e.detail.code; 49 | }); 50 | return { 51 | get currentDisplay() { return currentDisplay; }, 52 | set currentDisplay(value) { if (['post', 'stories', 'highlights'].includes(value)) currentDisplay = value; }, 53 | current: Object.freeze({ 54 | get shortcode() { return current.shortcode; }, 55 | set shortcode(value) { 56 | current.shortcode = value; 57 | downloadPostPhotos().then(data => { 58 | renderMedias(data); 59 | currentDisplay = 'post'; 60 | }); 61 | }, 62 | get username() { return current.username; }, 63 | set username(value) { 64 | current.username = value; 65 | downloadStoryPhotos('stories').then(data => { 66 | renderMedias(data); 67 | currentDisplay = 'stories'; 68 | }); 69 | }, 70 | get highlights() { return current.highlights; }, 71 | set highlights(value) { 72 | current.highlights = value; 73 | downloadStoryPhotos('highlights').then(data => { 74 | renderMedias(data); 75 | currentDisplay = 'hightlights'; 76 | }); 77 | }, 78 | }), 79 | setCurrentShortcode() { 80 | const page = window.location.pathname.match(IG_POST_REGEX); 81 | if (page) current.shortcode = page[2]; 82 | }, 83 | setCurrentUsername() { 84 | const page = window.location.pathname.match(IG_STORY_REGEX); 85 | if (page && page[2] !== 'highlights') current.username = page[2]; 86 | }, 87 | setCurrentHightlightsId() { 88 | const page = window.location.pathname.match(IG_HIGHLIGHT_REGEX); 89 | if (page) current.highlights = page[3]; 90 | }, 91 | setPreviousValues() { 92 | Object.keys(current).forEach(key => { previous[key] = current[key]; }); 93 | }, 94 | getFieldChange() { 95 | if (current.highlights !== previous.highlights) return 'highlights'; 96 | if (current.username !== previous.username) return 'stories'; 97 | if (current.shortcode !== previous.shortcode) return 'post'; 98 | return 'none'; 99 | }, 100 | }; 101 | })()); 102 | 103 | (() => { 104 | function createElement(htmlString) { 105 | const parser = new DOMParser(); 106 | const doc = parser.parseFromString(htmlString, 'text/html').body; 107 | const fragment = document.createDocumentFragment(); 108 | fragment.append(...doc.childNodes); 109 | return fragment; 110 | } 111 | function initUI() { 112 | document.body.appendChild(createElement( 113 | `
114 |
115 | Medias 116 | 117 |
118 |
119 |

120 | Nothing to download 121 |

122 |
123 |
124 | `)); 125 | } 126 | function handleEvents() { 127 | const ESC_BUTTON = document.querySelector('.esc-button'); 128 | const TITLE_CONTAINER = document.querySelector('.title-container').firstElementChild; 129 | const DISPLAY_CONTAINER = document.querySelector('.display-container'); 130 | const DOWNLOAD_BUTTON = document.querySelector('.download-button'); 131 | const IGNORE_FOCUS_ELEMENTS = ['INPUT', 'TEXTAREA']; 132 | const ESC_EVENT_KEYS = ['Escape', 'C', 'c']; 133 | const DOWNLOAD_EVENT_KEYS = ['D']; 134 | const SELECT_EVENT_KEYS = ['S', 's']; 135 | function setTheme() { 136 | const isDarkMode = localStorage.getItem('igt') === null ? 137 | window.matchMedia('(prefers-color-scheme: dark)').matches : 138 | localStorage.getItem('igt') === 'dark'; 139 | if (isDarkMode) { 140 | DISPLAY_CONTAINER.classList.add('dark'); 141 | DISPLAY_CONTAINER.firstElementChild.classList.add('dark'); 142 | } 143 | else { 144 | DISPLAY_CONTAINER.classList.remove('dark'); 145 | DISPLAY_CONTAINER.firstElementChild.classList.remove('dark'); 146 | } 147 | } 148 | function pauseVideo() { 149 | if (DISPLAY_CONTAINER.classList.contains('hide')) { 150 | DISPLAY_CONTAINER.querySelectorAll('video').forEach(video => { 151 | video.pause(); 152 | }); 153 | } 154 | } 155 | function toggleSelectMode() { 156 | if (TITLE_CONTAINER.classList.contains('multi-select')) { 157 | TITLE_CONTAINER.title = 'Hold to select / deselect all'; 158 | DISPLAY_CONTAINER.querySelectorAll('.overlay').forEach(element => { 159 | element.classList.add('show'); 160 | }); 161 | } 162 | else { 163 | TITLE_CONTAINER.textContent = 'Medias'; 164 | TITLE_CONTAINER.title = APP_NAME; 165 | DISPLAY_CONTAINER.querySelectorAll('.overlay').forEach(element => { 166 | element.classList.remove('show'); 167 | }); 168 | } 169 | } 170 | function handleSelectAll() { 171 | if (!TITLE_CONTAINER.classList.contains('multi-select')) return; 172 | const totalItem = Array.from(DISPLAY_CONTAINER.querySelectorAll('.overlay')); 173 | const totalItemChecked = Array.from(DISPLAY_CONTAINER.querySelectorAll('.overlay.checked')); 174 | if (totalItemChecked.length !== totalItem.length) totalItem.forEach(item => { 175 | if (!item.classList.contains('saved')) item.classList.add('checked'); 176 | }); 177 | else { 178 | totalItem.forEach(item => { item.classList.remove('checked'); }); 179 | } 180 | } 181 | function setSelectedMedias() { 182 | if (TITLE_CONTAINER.classList.contains('multi-select')) { 183 | TITLE_CONTAINER.textContent = `Selected ${DISPLAY_CONTAINER.querySelectorAll('.overlay.checked').length}`; 184 | } 185 | } 186 | function handleChatTab() { 187 | const sectionNode = document.querySelector('section'); 188 | let chatTabsRootContent = document.querySelector('[data-pagelet="IGDChatTabsRootContent"]'); 189 | 190 | const chatTabsRootContentObserver = new MutationObserver(() => { 191 | if (window.location.pathname !== '/') { 192 | chatTabsRootContentObserver.disconnect(); 193 | return; 194 | } 195 | 196 | const chatTabThreadList = chatTabsRootContent.querySelector('[data-pagelet="IGDChatTabThreadList"]')?.parentElement; 197 | if (chatTabThreadList && chatTabThreadList.checkVisibility({ checkVisibilityCSS: true })) { 198 | DOWNLOAD_BUTTON.setAttribute('hidden', 'true'); 199 | DISPLAY_CONTAINER.classList.add('hide'); 200 | } 201 | else { 202 | DOWNLOAD_BUTTON.removeAttribute('hidden'); 203 | } 204 | }); 205 | 206 | const sectionObserver = new MutationObserver(() => { 207 | if (!chatTabsRootContent) { 208 | chatTabsRootContent = document.querySelector('[data-pagelet="IGDChatTabsRootContent"]'); 209 | if (!chatTabsRootContent) return; 210 | chatTabsRootContentObserver.observe(chatTabsRootContent, { 211 | attributes: true, childList: true, subtree: true 212 | }); 213 | sectionObserver.disconnect(); 214 | } 215 | else { 216 | chatTabsRootContentObserver.observe(chatTabsRootContent, { 217 | attributes: true, childList: true, subtree: true 218 | }); 219 | sectionObserver.disconnect(); 220 | } 221 | }); 222 | 223 | if (window.location.pathname === '/') { 224 | DISPLAY_CONTAINER.classList.add('home'); 225 | DOWNLOAD_BUTTON.classList.add('home'); 226 | sectionObserver.observe(sectionNode, { 227 | attributes: true, childList: true, subtree: true 228 | }); 229 | } 230 | else { 231 | sectionObserver.disconnect(); 232 | DISPLAY_CONTAINER.classList.remove('home'); 233 | DOWNLOAD_BUTTON.classList.remove('home'); 234 | } 235 | } 236 | const handleTheme = new MutationObserver(setTheme); 237 | const handleVideo = new MutationObserver(pauseVideo); 238 | const handleToggleSelectMode = new MutationObserver(toggleSelectMode); 239 | const handleSelectMedia = new MutationObserver(setSelectedMedias); 240 | handleTheme.observe(document.documentElement, { 241 | attributes: true, 242 | attributeFilter: ['class'] 243 | }); 244 | handleVideo.observe(DISPLAY_CONTAINER, { 245 | attributes: true, 246 | attributeFilter: ['class'] 247 | }); 248 | handleToggleSelectMode.observe(TITLE_CONTAINER, { 249 | attributes: true, 250 | attributeFilter: ['class'] 251 | }); 252 | handleSelectMedia.observe(DISPLAY_CONTAINER.querySelector('.medias-container'), { 253 | attributes: true, childList: true, subtree: true 254 | }); 255 | ESC_BUTTON.addEventListener('click', () => { 256 | DISPLAY_CONTAINER.classList.add('hide'); 257 | }); 258 | window.addEventListener('keydown', (e) => { 259 | if (window.location.pathname.startsWith('/direct')) return; 260 | if (IGNORE_FOCUS_ELEMENTS.includes(e.target.tagName)) return; 261 | if (e.target.role === 'textbox') return; 262 | if (e.ctrlKey) return; 263 | if (DOWNLOAD_EVENT_KEYS.includes(e.key)) { 264 | return DOWNLOAD_BUTTON.click(); 265 | } 266 | if (ESC_EVENT_KEYS.includes(e.key)) { 267 | return ESC_BUTTON.click(); 268 | } 269 | if (SELECT_EVENT_KEYS.includes(e.key) && !DISPLAY_CONTAINER.classList.contains('hide')) { 270 | return TITLE_CONTAINER.classList.toggle('multi-select'); 271 | } 272 | }); 273 | document.addEventListener('visibilitychange', () => { 274 | if (document.visibilityState === 'hidden') { 275 | DISPLAY_CONTAINER.querySelectorAll('video').forEach(video => { 276 | video.pause(); 277 | }); 278 | } 279 | }); 280 | handleLongClick(TITLE_CONTAINER, () => { 281 | TITLE_CONTAINER.classList.toggle('multi-select'); 282 | }, handleSelectAll); 283 | DOWNLOAD_BUTTON.addEventListener('click', handleDownload); 284 | window.addEventListener('online', () => { 285 | DISPLAY_CONTAINER.querySelectorAll('img , video').forEach(media => { 286 | media.src = media.src; 287 | }); 288 | }); 289 | window.addEventListener('pathChange', () => { 290 | if (window.location.pathname.startsWith('/direct')) { 291 | DOWNLOAD_BUTTON.setAttribute('hidden', 'true'); 292 | DISPLAY_CONTAINER.classList.add('hide'); 293 | } 294 | else DOWNLOAD_BUTTON.removeAttribute('hidden'); 295 | }); 296 | window.addEventListener('pathChange', handleChatTab); 297 | window.addEventListener('userLoad', e => { 298 | appCache.userIdsCache.set(e.detail.username, e.detail.id); 299 | }); 300 | window.addEventListener('postView', e => { 301 | if (appCache.postIdInfoCache.has(e.detail.id)) return; 302 | // Check valid shortcode 303 | if (e.detail.code.startsWith(convertToShortcode(e.detail.id))) { 304 | appCache.postIdInfoCache.set(e.detail.code, e.detail.id); 305 | } 306 | }); 307 | setTheme(); 308 | handleChatTab(); 309 | if (window.location.pathname.startsWith('/direct')) { 310 | DOWNLOAD_BUTTON.classList.add('hide'); 311 | DISPLAY_CONTAINER.classList.add('hide'); 312 | } 313 | } 314 | function run() { 315 | document.querySelectorAll('.display-container, .download-button').forEach(node => { 316 | node.remove(); 317 | }); 318 | initUI(); 319 | handleEvents(); 320 | } 321 | run(); 322 | })(); -------------------------------------------------------------------------------- /src/js/post-view-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is not include in manifest so it not run. 3 | * If this file run, every network call with XHR will be tracked. 4 | * So you don't need to call api to get data, just cacth the response somewhere. 5 | */ 6 | 7 | /** 8 | * I have the algorithm that Instagram use to convert between post id and post shortcode. 9 | * But if the post is from a private profile, they add some extra stuff to the shortcode. 10 | * And I don't know how to convert between them. 11 | * So I wrote this to cache post id when user view post to reduce one api call. 12 | * 13 | * Why the hell I need to match post info api or comments api instead of just one? 14 | * The reason is post info api only load when you view post from home or /explore. 15 | * If you view post from user profile page, it not gonna call post info, instead it call post comments api 16 | */ 17 | (() => { 18 | const API_POST_INFO_REGEX = /api\/v1\/media\/(\d*)\/info\.*?/; 19 | const API_POST_COMMENTS_REGEX = /api\/v1\/media\/(\d+)\/comments\/.*?/; 20 | 21 | const performanceObserver = new PerformanceObserver((list) => { 22 | const entries = list.getEntriesByType('resource'); 23 | entries.forEach((entry) => { 24 | 25 | const entryPath = new URL(entry.name).pathname; 26 | const currentPath = window.location.pathname; 27 | 28 | const matchPostInfoApi = entryPath.match(API_POST_INFO_REGEX); 29 | const matchPostCommentApi = entryPath.match(API_POST_COMMENTS_REGEX); 30 | const matchPostPath = currentPath.match(IG_POST_REGEX); 31 | 32 | if ((matchPostInfoApi || matchPostCommentApi) && matchPostPath) { 33 | const shortcode = matchPostPath[2]; 34 | let pk = null; 35 | if (matchPostInfoApi) pk = matchPostInfoApi[1]; 36 | if (matchPostCommentApi) pk = matchPostCommentApi[1]; 37 | 38 | // Check Valid pk and code 39 | if (shortcode.startsWith(convertToShortcode(pk)) && 40 | !appCache.postIdInfoCache.has(shortcode)) { 41 | appCache.postIdInfoCache.set(shortcode, pk); 42 | } 43 | 44 | } 45 | }); 46 | }); 47 | performanceObserver.observe({ entryTypes: ['resource'] }); 48 | })(); -------------------------------------------------------------------------------- /src/js/post.js: -------------------------------------------------------------------------------- 1 | function convertToPostId(shortcode) { 2 | let id = BigInt(0); 3 | for (let i = 0; i < shortcode.length; i++) { 4 | let char = shortcode[i]; 5 | id = (id * BigInt(64)) + BigInt(IG_SHORTCODE_ALPHABET.indexOf(char)); 6 | } 7 | return id.toString(10); 8 | } 9 | 10 | function convertToShortcode(postId) { 11 | let id = BigInt(postId); 12 | let shortcode = ''; 13 | while (id > BigInt(0)) { 14 | const remainder = id % BigInt(64); 15 | shortcode = IG_SHORTCODE_ALPHABET[Number(remainder)] + shortcode; 16 | id = id / BigInt(64); 17 | id = id - (id % BigInt(1)); 18 | } 19 | return shortcode; 20 | } 21 | 22 | async function getPostIdFromAPI() { 23 | const cachedPostId = appCache.postIdInfoCache.get(appState.current.shortcode); 24 | if (cachedPostId) return cachedPostId; 25 | const apiURL = new URL('/graphql/query/', IG_BASE_URL); 26 | const fetchOptions = getFetchOptions(); 27 | fetchOptions['method'] = 'POST'; 28 | fetchOptions.headers['content-type'] = 'application/x-www-form-urlencoded'; 29 | fetchOptions.headers['x-fb-friendly-name'] = 'PolarisPostActionLoadPostQueryQuery'; 30 | fetchOptions.body = new URLSearchParams({ 31 | fb_api_caller_class: 'RelayModern', 32 | fb_api_req_friendly_name: 'PolarisPostActionLoadPostQueryQuery', 33 | doc_id: '8845758582119845', 34 | variables: JSON.stringify({ 35 | shortcode: appState.current.shortcode, 36 | }), 37 | }).toString(); 38 | try { 39 | const respone = await fetch(apiURL.href, fetchOptions); 40 | const json = await respone.json(); 41 | return json.data['xdt_shortcode_media'].id; 42 | } catch (error) { 43 | console.log(error); 44 | return null; 45 | } 46 | } 47 | 48 | async function getPostPhotos(shortcode) { 49 | const postId = convertToPostId(shortcode); 50 | const apiURL = new URL(`/api/v1/media/${postId}/info/`, IG_BASE_URL); 51 | try { 52 | let respone = await fetch(apiURL.href, getFetchOptions()); 53 | if (respone.status === 400) { 54 | const postId = await getPostIdFromAPI(); 55 | if (!postId) throw new Error('Network bug'); 56 | const apiURL = new URL(`/api/v1/media/${postId}/info/`, IG_BASE_URL); 57 | respone = await fetch(apiURL.href, getFetchOptions()); 58 | } 59 | const json = await respone.json(); 60 | return json.items[0]; 61 | } catch (error) { 62 | console.log(error); 63 | return null; 64 | } 65 | } 66 | 67 | async function downloadPostPhotos() { 68 | if (!appState.current.shortcode) return null; 69 | const json = await getPostPhotos(appState.current.shortcode); 70 | if (!json) return null; 71 | const data = { 72 | date: json['taken_at'], 73 | user: { 74 | username: json.user['username'], 75 | }, 76 | medias: [] 77 | }; 78 | function extractMediaData(item) { 79 | const isVideo = item['media_type'] !== 1; 80 | const media = { 81 | url: isVideo ? item['video_versions'][0].url : item['image_versions2'].candidates[0].url, 82 | isVideo, 83 | id: item.pk 84 | }; 85 | return media; 86 | }; 87 | if (json.carousel_media) data.medias = json.carousel_media.map(extractMediaData); 88 | else data.medias.push(extractMediaData(json)); 89 | return data; 90 | } -------------------------------------------------------------------------------- /src/js/story.js: -------------------------------------------------------------------------------- 1 | async function getUserIdFromSearch(username) { 2 | if (appCache.userIdsCache.has(username)) return appCache.userIdsCache.get(username); 3 | const apiURL = new URL('/web/search/topsearch/', IG_BASE_URL); 4 | if (username) apiURL.searchParams.set('query', username); 5 | else apiURL.searchParams.set('query', appState.current.username); 6 | try { 7 | const respone = await fetch(apiURL.href); 8 | const json = await respone.json(); 9 | return json.users[0].user['pk_id']; 10 | } catch (error) { 11 | console.log(error); 12 | return ''; 13 | } 14 | } 15 | 16 | async function getUserId(username) { 17 | if (appCache.userIdsCache.has(username)) return appCache.userIdsCache.get(username); 18 | const apiURL = new URL('/api/v1/users/web_profile_info/', IG_BASE_URL); 19 | if (username) apiURL.searchParams.set('username', username); 20 | else apiURL.searchParams.set('username', appState.current.username); 21 | try { 22 | const respone = await fetch(apiURL.href, getFetchOptions()); 23 | const json = await respone.json(); 24 | return json.data.user['id']; 25 | } catch (error) { 26 | console.log(error); 27 | return ''; 28 | } 29 | } 30 | 31 | async function getStoryPhotos(userId) { 32 | const apiURL = new URL('/api/v1/feed/reels_media/', IG_BASE_URL); 33 | apiURL.searchParams.set('reel_ids', userId); 34 | try { 35 | const respone = await fetch(apiURL.href, getFetchOptions()); 36 | const json = await respone.json(); 37 | return json.reels[userId]; 38 | } catch (error) { 39 | console.log(error); 40 | return null; 41 | } 42 | } 43 | 44 | async function getHighlightStory(highlightsId) { 45 | const apiURL = new URL('/api/v1/feed/reels_media/', IG_BASE_URL); 46 | apiURL.searchParams.set('reel_ids', `highlight:${highlightsId}`); 47 | try { 48 | const respone = await fetch(apiURL.href, getFetchOptions()); 49 | const json = await respone.json(); 50 | return json.reels[`highlight:${highlightsId}`]; 51 | } catch (error) { 52 | console.log(error); 53 | return null; 54 | } 55 | } 56 | 57 | async function downloadStoryPhotos(type = 'stories') { 58 | let json = null; 59 | if (type === 'highlights') { 60 | if (!appState.current.highlights) return null; 61 | json = await getHighlightStory(appState.current.highlights); 62 | } 63 | else { 64 | const userId = await getUserId(appState.current.username); 65 | if (!userId) return null; 66 | json = await getStoryPhotos(userId); 67 | } 68 | if (!json) return null; 69 | const data = { 70 | date: json.items[0]['taken_at'], 71 | user: { 72 | username: json.user['username'], 73 | }, 74 | medias: [] 75 | }; 76 | json.items.forEach((item) => { 77 | const media = { 78 | url: item['media_type'] === 1 ? item['image_versions2'].candidates[0]['url'] : item['video_versions'][0].url, 79 | isVideo: item['media_type'] === 1 ? false : true, 80 | id: item.pk 81 | }; 82 | data.medias.push(media); 83 | }); 84 | return data; 85 | } -------------------------------------------------------------------------------- /src/js/utils.js: -------------------------------------------------------------------------------- 1 | function saveFile(blob, fileName) { 2 | const a = document.createElement('a'); 3 | a.download = fileName; 4 | a.href = URL.createObjectURL(blob); 5 | a.click(); 6 | URL.revokeObjectURL(a.href); 7 | } 8 | 9 | function getCookieValue(name) { 10 | return document.cookie.split('; ') 11 | .find(row => row.startsWith(`${name}=`)) 12 | ?.split('=')[1]; 13 | } 14 | 15 | function getFetchOptions() { 16 | return { 17 | headers: { 18 | // Hardcode variable: a="129477";f.ASBD_ID=a in JS, can be remove 19 | // 'x-asbd-id': '129477', 20 | 'x-csrftoken': getCookieValue('csrftoken'), 21 | 'x-ig-app-id': '936619743392459', 22 | 'x-ig-www-claim': sessionStorage.getItem('www-claim-v2'), 23 | // 'x-instagram-ajax': '1006598911', 24 | 'x-requested-with': 'XMLHttpRequest' 25 | }, 26 | referrer: window.location.href, 27 | referrerPolicy: 'strict-origin-when-cross-origin', 28 | method: 'GET', 29 | mode: 'cors', 30 | credentials: 'include' 31 | }; 32 | } 33 | 34 | function getValueByKey(obj, key) { 35 | if (typeof obj !== 'object' || obj === null) return null; 36 | const stack = [obj]; 37 | const visited = new Set(); 38 | while (stack.length) { 39 | const current = stack.pop(); 40 | if (visited.has(current)) continue; 41 | visited.add(current); 42 | try { 43 | if (current[key] !== undefined) return current[key]; 44 | } catch (error) { 45 | if (error.name === 'SecurityError') continue; 46 | console.log(error); 47 | } 48 | for (const value of Object.values(current)) { 49 | if (typeof value === 'object' && value !== null) { 50 | stack.push(value); 51 | } 52 | } 53 | } 54 | return null; 55 | }; 56 | 57 | function resetDownloadState() { 58 | const DOWNLOAD_BUTTON = document.querySelector('.download-button'); 59 | DOWNLOAD_BUTTON.classList.remove('loading'); 60 | DOWNLOAD_BUTTON.textContent = 'Download'; 61 | DOWNLOAD_BUTTON.disabled = false; 62 | } 63 | 64 | async function saveMedia(media, fileName) { 65 | try { 66 | const respone = await fetch(media.src); 67 | const blob = await respone.blob(); 68 | saveFile(blob, fileName); 69 | media.nextElementSibling.classList.remove('check'); 70 | } catch (error) { 71 | console.log(error); 72 | } 73 | } 74 | 75 | async function saveZip() { 76 | const DOWNLOAD_BUTTON = document.querySelector('.download-button'); 77 | DOWNLOAD_BUTTON.classList.add('loading'); 78 | DOWNLOAD_BUTTON.textContent = 'Loading...'; 79 | DOWNLOAD_BUTTON.disabled = true; 80 | const medias = Array.from(document.querySelectorAll('.overlay.checked')).map(item => item.previousElementSibling); 81 | const zipFileName = medias[0].title.replaceAll(' | ', '_') + '.zip'; 82 | async function fetchSelectedMedias() { 83 | let count = 0; 84 | const results = await Promise.allSettled(medias.map(async (media) => { 85 | const res = await fetch(media.src); 86 | const blob = await res.blob(); 87 | const data = { 88 | title: media.title.replaceAll(' | ', '_'), 89 | data: blob 90 | }; 91 | data.title = media.nodeName === 'VIDEO' ? `${data.title}.mp4` : `${data.title}.jpeg`; 92 | count++; 93 | DOWNLOAD_BUTTON.textContent = `${count}/${medias.length}`; 94 | return data; 95 | })); 96 | results.forEach(promise => { 97 | if (promise.status === 'rejected') throw new Error('Fail to fetch'); 98 | }); 99 | return results.map(promise => promise.value); 100 | } 101 | try { 102 | const medias = await fetchSelectedMedias(); 103 | const blob = await createZip(medias); 104 | saveFile(blob, zipFileName); 105 | document.querySelectorAll('.overlay').forEach(element => { 106 | element.classList.remove('checked'); 107 | }); 108 | resetDownloadState(); 109 | } catch (error) { 110 | console.log(error); 111 | resetDownloadState(); 112 | } 113 | } 114 | 115 | function shouldDownload() { 116 | if (window.location.pathname === '/' && appState.getFieldChange() !== 'none') { 117 | return appState.getFieldChange(); 118 | } 119 | appState.setCurrentShortcode(); 120 | appState.setCurrentUsername(); 121 | appState.setCurrentHightlightsId(); 122 | function getCurrentPage() { 123 | const currentPath = window.location.pathname; 124 | if (currentPath.match(IG_POST_REGEX)) return 'post'; 125 | if (currentPath.match(IG_STORY_REGEX)) { 126 | if (currentPath.match(IG_HIGHLIGHT_REGEX)) return 'highlights'; 127 | return 'stories'; 128 | } 129 | if (currentPath === '/') return 'post'; 130 | return 'none'; 131 | } 132 | const currentPage = getCurrentPage(); 133 | const valueChange = appState.getFieldChange(); 134 | if (['highlights', 'stories', 'post'].includes(currentPage)) { 135 | if (currentPage === valueChange) return valueChange; 136 | if (appState.currentDisplay !== currentPage) return currentPage; 137 | } 138 | return 'none'; 139 | } 140 | 141 | function setDownloadState(state = 'ready') { 142 | const DOWNLOAD_BUTTON = document.querySelector('.download-button'); 143 | const MEDIAS_CONTAINER = document.querySelector('.medias-container'); 144 | const options = { 145 | ready() { 146 | DOWNLOAD_BUTTON.classList.add('loading'); 147 | DOWNLOAD_BUTTON.textContent = 'Loading...'; 148 | DOWNLOAD_BUTTON.disabled = true; 149 | MEDIAS_CONTAINER.replaceChildren(); 150 | }, 151 | fail() { resetDownloadState(); }, 152 | success() { 153 | DOWNLOAD_BUTTON.disabled = false; 154 | appState.setPreviousValues(); 155 | const photosArray = MEDIAS_CONTAINER.querySelectorAll('img , video'); 156 | let loadedPhotos = 0; 157 | function countLoaded() { 158 | loadedPhotos++; 159 | if (loadedPhotos === photosArray.length) resetDownloadState(); 160 | } 161 | photosArray.forEach(media => { 162 | if (media.tagName === 'IMG') { 163 | media.addEventListener('load', countLoaded); 164 | media.addEventListener('error', countLoaded); 165 | } 166 | else { 167 | media.addEventListener('loadeddata', countLoaded); 168 | media.addEventListener('abort', countLoaded); 169 | } 170 | }); 171 | } 172 | }; 173 | options[state](); 174 | } 175 | 176 | async function handleDownload() { 177 | let data = null; 178 | const TITLE_CONTAINER = document.querySelector('.title-container').firstElementChild; 179 | const DISPLAY_CONTAINER = document.querySelector('.display-container'); 180 | const option = shouldDownload(); 181 | const totalItemChecked = Array.from(document.querySelectorAll('.overlay.checked')); 182 | if (TITLE_CONTAINER.classList.contains('multi-select') 183 | && !DISPLAY_CONTAINER.classList.contains('hide') 184 | && option === 'none' 185 | && totalItemChecked.length !== 0) { 186 | return saveZip(); 187 | } 188 | requestAnimationFrame(() => { DISPLAY_CONTAINER.classList.remove('hide'); }); 189 | if (option === 'none') return; 190 | setDownloadState('ready'); 191 | option === 'post' ? data = await downloadPostPhotos() : data = await downloadStoryPhotos(option); 192 | if (!data) return setDownloadState('fail'); 193 | appState.currentDisplay = option; 194 | renderMedias(data); 195 | } 196 | 197 | function renderMedias(data) { 198 | const TITLE_CONTAINER = document.querySelector('.title-container').firstElementChild; 199 | const MEDIAS_CONTAINER = document.querySelector('.medias-container'); 200 | MEDIAS_CONTAINER.replaceChildren(); 201 | if (!data) return; 202 | const fragment = document.createDocumentFragment(); 203 | const date = new Date(data.date * 1000).toISOString().split('T')[0]; 204 | data.medias.forEach(item => { 205 | const attributes = { 206 | class: 'medias-item', 207 | src: item.url, 208 | title: `${data.user.username} | ${item.id} | ${date}`, 209 | controls: '' 210 | }; 211 | const ITEM_TEMPLATE = 212 | `
213 | ${item.isVideo ? `` : ''} 214 |
215 |
`; 216 | const itemDOM = new DOMParser().parseFromString(ITEM_TEMPLATE, 'text/html').body.firstElementChild; 217 | const media = itemDOM.querySelector('img, video'); 218 | const selectBox = itemDOM.querySelector('.overlay'); 219 | Object.keys(attributes).forEach(key => { 220 | if (item.isVideo) media.setAttribute(key, attributes[key]); 221 | else if (key !== 'controls') media.setAttribute(key, attributes[key]); 222 | }); 223 | media.addEventListener('click', (e) => { 224 | if (TITLE_CONTAINER.classList.contains('multi-select')) { 225 | if (item.isVideo) e.preventDefault(); 226 | selectBox.classList.toggle('checked'); 227 | } 228 | else saveMedia(media, media.title.replaceAll(' | ', '_') + `${item.isVideo ? '.mp4' : '.jpeg'}`); 229 | }); 230 | fragment.appendChild(itemDOM); 231 | }); 232 | MEDIAS_CONTAINER.appendChild(fragment); 233 | TITLE_CONTAINER.classList.remove('multi-select'); 234 | setDownloadState('success'); 235 | } 236 | 237 | function handleLongClick(element, shortClickHandler, longClickHandler, delay = 400) { 238 | element.addEventListener('mousedown', () => { 239 | let count = 0; 240 | const intervalId = setInterval(() => { 241 | count = count + 10; 242 | if (count >= delay) { 243 | clearInterval(intervalId); 244 | longClickHandler(); 245 | } 246 | }, 10); 247 | element.addEventListener('mouseup', () => { 248 | clearInterval(intervalId); 249 | if (count < delay) shortClickHandler(); 250 | }, { once: true }); 251 | }); 252 | } 253 | 254 | function isValidJson(string) { 255 | try { 256 | JSON.parse(string); 257 | return true; 258 | } catch { 259 | return false; 260 | } 261 | } -------------------------------------------------------------------------------- /src/js/zip.js: -------------------------------------------------------------------------------- 1 | // How Zip work here https://en.wikipedia.org/wiki/ZIP_(file_format) 2 | function calculateCRC32(data) { 3 | const table = new Uint32Array(256); 4 | for (let i = 0; i < 256; i++) { 5 | let c = i; 6 | for (let k = 0; k < 8; k++) { 7 | c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; 8 | } 9 | table[i] = c; 10 | } 11 | let crc = 0xffffffff; 12 | for (let i = 0; i < data.length; i++) { 13 | crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); 14 | } 15 | return (crc ^ 0xffffffff) >>> 0; 16 | } 17 | 18 | function createLocalFileHeader(fileName, fileSize, crc32) { 19 | const fileNameBytes = new TextEncoder().encode(fileName); 20 | const header = new Uint8Array(30 + fileNameBytes.length); 21 | header.set([0x50, 0x4B, 0x03, 0x04], 0); 22 | header.set([0x14, 0x00], 4); 23 | header.set([0x00, 0x00], 6); 24 | header.set([0x00, 0x00], 8); 25 | header.set([0x00, 0x00, 0x00, 0x00], 10); 26 | header.set(new Uint8Array(new Uint32Array([crc32]).buffer), 14); 27 | header.set(new Uint8Array(new Uint32Array([fileSize]).buffer), 18); 28 | header.set(new Uint8Array(new Uint32Array([fileSize]).buffer), 22); 29 | header.set(new Uint8Array(new Uint16Array([fileNameBytes.length]).buffer), 26); 30 | header.set([0x00, 0x00], 28); 31 | header.set(fileNameBytes, 30); 32 | return header; 33 | } 34 | 35 | function createCentralDirectoryHeader(fileName, fileSize, crc32, offset) { 36 | const fileNameBytes = new TextEncoder().encode(fileName); 37 | const header = new Uint8Array(46 + fileNameBytes.length); 38 | header.set([0x50, 0x4B, 0x01, 0x02], 0); 39 | header.set([0x14, 0x00], 4); 40 | header.set([0x14, 0x00], 6); 41 | header.set([0x00, 0x00], 8); 42 | header.set([0x00, 0x00], 10); 43 | header.set([0x00, 0x00, 0x00, 0x00], 12); 44 | header.set(new Uint8Array(new Uint32Array([crc32]).buffer), 16); 45 | header.set(new Uint8Array(new Uint32Array([fileSize]).buffer), 20); 46 | header.set(new Uint8Array(new Uint32Array([fileSize]).buffer), 24); 47 | header.set(new Uint8Array(new Uint16Array([fileNameBytes.length]).buffer), 28); 48 | header.set([0x00, 0x00], 30); 49 | header.set([0x00, 0x00], 32); 50 | header.set([0x00, 0x00], 34); 51 | header.set([0x00, 0x00], 36); 52 | header.set([0x00, 0x00, 0x00, 0x00], 38); 53 | header.set(new Uint8Array(new Uint32Array([offset]).buffer), 42); 54 | header.set(fileNameBytes, 46); 55 | return header; 56 | } 57 | 58 | function createEndOfCentralDirectoryRecord(numFiles, centralDirSize, centralDirOffset) { 59 | const record = new Uint8Array(22); 60 | record.set([0x50, 0x4B, 0x05, 0x06], 0); 61 | record.set([0x00, 0x00], 4); 62 | record.set([0x00, 0x00], 6); 63 | record.set(new Uint8Array(new Uint16Array([numFiles]).buffer), 8); 64 | record.set(new Uint8Array(new Uint16Array([numFiles]).buffer), 10); 65 | record.set(new Uint8Array(new Uint32Array([centralDirSize]).buffer), 12); 66 | record.set(new Uint8Array(new Uint32Array([centralDirOffset]).buffer), 16); 67 | record.set([0x00, 0x00], 20); 68 | return record; 69 | } 70 | 71 | /** 72 | * 73 | * @param {Array<{ data: Blob, title: string }>} files 74 | * @returns {Promise} 75 | */ 76 | async function createZip(files) { 77 | const chunks = []; 78 | const centralDirectory = []; 79 | let offset = 0; 80 | for (const file of files) { 81 | const arrayBuffer = await file.data.arrayBuffer(); 82 | const content = new Uint8Array(arrayBuffer); 83 | const crc32 = calculateCRC32(content); 84 | const header = createLocalFileHeader(file.title, content.length, crc32); 85 | chunks.push(header); 86 | chunks.push(content); 87 | centralDirectory.push(createCentralDirectoryHeader(file.title, content.length, crc32, offset)); 88 | offset += header.length + content.length; 89 | } 90 | const centralDirOffset = offset; 91 | for (const dir of centralDirectory) { 92 | chunks.push(dir); 93 | offset += dir.length; 94 | } 95 | const endRecord = createEndOfCentralDirectoryRecord(files.length, offset - centralDirOffset, centralDirOffset); 96 | chunks.push(endRecord); 97 | return new Blob(chunks); 98 | } 99 | -------------------------------------------------------------------------------- /src/style/style.css: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | .display-container { 10 | background: white; 11 | width: calc(80vh /5*3); 12 | height: 80vh; 13 | max-width: 480px; 14 | max-height: 800px; 15 | border-radius: 4px; 16 | outline: 1px solid rgb(var(--ig-separator)); 17 | position: fixed; 18 | bottom: 60px; 19 | right: 20px; 20 | overflow: hidden; 21 | user-select: none; 22 | -webkit-user-select: none; 23 | transform-origin: center center; 24 | transform: scale(1); 25 | transition: transform 0.5s cubic-bezier(0.82, -0.07, 0.25, 1.08), opacity 0.4s ease-in-out, transform-origin 0.5s ease-in-out; 26 | will-change: transform, transform-origin, opacity; 27 | transition-property: transform, transform-origin, opacity, bottom; 28 | z-index: 1000000; 29 | } 30 | 31 | .display-container.dark { 32 | background: black; 33 | } 34 | 35 | .display-container.hide { 36 | transform-origin: 85% bottom; 37 | transform: scale(0); 38 | pointer-events: none; 39 | opacity: 0.6; 40 | } 41 | 42 | .display-container>.title-container { 43 | width: inherit; 44 | height: 8%; 45 | max-width: inherit; 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: center; 49 | font-size: min(36px, 3.5vh); 50 | font-family: Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', 51 | sans-serif; 52 | font-weight: 600; 53 | font-weight: bold; 54 | background: rgba(255, 255, 255, 0.9); 55 | backdrop-filter: blur(3px); 56 | position: absolute; 57 | padding: 0px 18px; 58 | z-index: 10; 59 | cursor: pointer; 60 | } 61 | 62 | .display-container>.title-container.dark { 63 | background: rgba(0, 0, 0, 0.8); 64 | } 65 | 66 | .esc-button { 67 | cursor: pointer; 68 | font-size: min(36px, 5vh); 69 | background: none; 70 | border: none; 71 | outline: none; 72 | cursor: pointer; 73 | } 74 | 75 | .esc-button:hover { 76 | opacity: 0.8; 77 | } 78 | 79 | .display-container>.medias-container { 80 | width: inherit; 81 | height: inherit; 82 | max-width: inherit; 83 | max-height: inherit; 84 | padding-top: calc(8% /3*5); 85 | display: flex; 86 | flex-direction: column; 87 | align-items: center; 88 | overflow: scroll; 89 | z-index: 0; 90 | } 91 | 92 | .medias-container:empty::after { 93 | content: ""; 94 | position: absolute; 95 | top: 50%; 96 | left: 50%; 97 | width: 40px; 98 | height: 40px; 99 | border: 4px solid #f3f3f3; 100 | border-top: 4px solid var(--primary-button-background); 101 | border-radius: 50%; 102 | animation: spin 2s linear infinite; 103 | transform: translate(-50%, -50%); 104 | } 105 | 106 | .display-container>.medias-container::-webkit-scrollbar { 107 | display: none; 108 | } 109 | 110 | .display-container>.medias-container>div { 111 | position: relative; 112 | width: 90%; 113 | height: fit-content; 114 | margin-bottom: 10px; 115 | transition: 0.5s; 116 | transition-property: width, height, scale; 117 | } 118 | 119 | .display-container>.medias-container>div:hover { 120 | scale: 1.01; 121 | } 122 | 123 | .display-container>.medias-container>div>.overlay { 124 | background: rgba(0, 0, 0, 0.5); 125 | pointer-events: none; 126 | position: absolute; 127 | width: 10%; 128 | aspect-ratio: 1; 129 | top: 10px; 130 | right: 10px; 131 | border-radius: 50%; 132 | border: 3px solid white; 133 | cursor: pointer; 134 | display: none; 135 | transition: 0.1s; 136 | } 137 | 138 | .display-container>.medias-container>div>.overlay>svg { 139 | display: none; 140 | } 141 | 142 | .display-container>.medias-container>div>.overlay.show { 143 | display: flex; 144 | justify-content: center; 145 | align-items: center; 146 | font-size: 0px; 147 | } 148 | 149 | .display-container>.medias-container>div>.overlay.checked { 150 | border-color: black; 151 | background: white; 152 | font-size: 20px; 153 | } 154 | 155 | .display-container>.medias-container>div>.medias-item { 156 | position: relative; 157 | width: 100%; 158 | cursor: pointer; 159 | } 160 | 161 | .download-button { 162 | width: 120px; 163 | height: 30px; 164 | color: white; 165 | outline: none; 166 | font-family: Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', 167 | sans-serif; 168 | font-weight: 600; 169 | font-size: large; 170 | font-weight: bold; 171 | border: none; 172 | border-radius: 15px; 173 | position: fixed; 174 | bottom: 20px; 175 | right: 20px; 176 | text-align: center; 177 | user-select: none; 178 | -webkit-user-select: none; 179 | cursor: pointer; 180 | background-color: var(--primary-button-background); 181 | transition: 0.3s ease; 182 | box-shadow: 0 4px 12px 0 var(--shadow-2); 183 | z-index: 1000000; 184 | } 185 | 186 | .download-button.loading { 187 | cursor: default; 188 | background-color: var(--primary-button-pressed); 189 | } 190 | 191 | /* Support bottom navigation */ 192 | @media (max-width:767px) { 193 | .download-button { 194 | display: flex; 195 | align-items: center; 196 | justify-content: center; 197 | 198 | text-align: center; 199 | border-radius: 50%; 200 | bottom: 70px; 201 | right: 20px; 202 | 203 | font-size: 0; 204 | width: 50px; 205 | height: 50px; 206 | } 207 | 208 | .download-button:before { 209 | content: '⇓'; 210 | display: inline-block; 211 | font-size: 32px; 212 | position: absolute; 213 | top: 60%; 214 | left: 50%; 215 | transform: translate(-50%, -50%); 216 | } 217 | 218 | .display-container { 219 | width: 100vw; 220 | top: 50%; 221 | left: 50%; 222 | transform: translate(-50%, -50%); 223 | } 224 | } 225 | 226 | /*Loading Spin in photos conatiner*/ 227 | 228 | @keyframes spin { 229 | 0% { 230 | transform: translate(-50%, -50%) rotate(0deg); 231 | } 232 | 233 | 100% { 234 | transform: translate(-50%, -50%) rotate(360deg); 235 | } 236 | } 237 | 238 | /* Handle chat tab show in home page on new version of Instagram */ 239 | 240 | .download-button.home { 241 | bottom: 100px; 242 | } 243 | 244 | .display-container.home { 245 | bottom: 140px; 246 | } --------------------------------------------------------------------------------